Skip to content

Commit 86a2111

Browse files
Azzerty23claude
andcommitted
refactor(policy): replace AsyncLocalStorage with explicit queryContext map
Eliminates the policyContextStorage AsyncLocalStorage by threading a per-operation Map<string,unknown> through the onQuery/onKyselyQuery hook chain instead. This removes the node:async_hooks dependency from the policy plugin (and the orm package browser stub that worked around it), letting the Next.js sample drop serverExternalPackages overrides. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 23a8253 commit 86a2111

13 files changed

Lines changed: 77 additions & 54 deletions

File tree

packages/orm/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@
2626
],
2727
"exports": {
2828
".": {
29-
"browser": "./dist/browser.mjs",
3029
"import": {
3130
"types": "./dist/index.d.mts",
3231
"default": "./dist/index.mjs"

packages/orm/src/browser.ts

Lines changed: 0 additions & 17 deletions
This file was deleted.

packages/orm/src/client/client-impl.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,10 @@ function createModelCrudHandler(
611611
throwIfNoResult = false,
612612
) => {
613613
return createZenStackPromise(async (txClient?: ClientContract<any>) => {
614+
// Per-operation context shared between onQuery and onKyselyQuery hooks.
615+
// onQuery plugins write here; the context executor passes it to onKyselyQuery.
616+
const queryContext = new Map<string, unknown>();
617+
614618
let proceed = async (_args: unknown) => {
615619
// prepare args for ext result: strip ext result field names from select/omit,
616620
// inject needs fields into select (recursively handles nested relations)
@@ -619,7 +623,16 @@ function createModelCrudHandler(
619623
? prepareArgsForExtResult(_args, model, schema, plugins)
620624
: _args;
621625

622-
const _handler = txClient ? handler.withClient(txClient) : handler;
626+
// Bind queryContext to the executor so onKyselyQuery hooks can read it.
627+
// Uses txClient's executor (which holds the tx connection) when in a transaction.
628+
const baseClient = txClient ?? client;
629+
const baseExecutor = (baseClient.$qb as any).getExecutor() as ZenStackQueryExecutor;
630+
const contextExecutor = baseExecutor.withQueryContext(queryContext);
631+
const contextClient = (baseClient as unknown as ClientImpl).withExecutor(
632+
contextExecutor,
633+
) as unknown as ClientContract<any>;
634+
635+
const _handler = handler.withClient(contextClient);
623636
const r = await _handler.handle(operation, processedArgs);
624637
if (!r && throwIfNoResult) {
625638
throw createNotFoundError(model);
@@ -652,6 +665,7 @@ function createModelCrudHandler(
652665
operation: nominalOperation,
653666
// reflect the latest override if provided
654667
args: _args,
668+
queryContext,
655669
// ensure inner overrides are propagated to the previous proceed
656670
proceed: (nextArgs: unknown) => _proceed(nextArgs),
657671
};

packages/orm/src/client/executor/zenstack-query-executor.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export class ZenStackQueryExecutor extends DefaultQueryExecutor {
8686
private readonly connectionProvider: ConnectionProvider,
8787
plugins: KyselyPlugin[] = [],
8888
private suppressMutationHooks: boolean = false,
89+
private readonly queryContext: Map<string, unknown> = new Map(),
8990
) {
9091
super(compiler, adapter, connectionProvider, plugins);
9192

@@ -214,6 +215,7 @@ export class ZenStackQueryExecutor extends DefaultQueryExecutor {
214215
schema: this.client.$schema,
215216
query,
216217
proceed: _p,
218+
queryContext: this.queryContext,
217219
});
218220
return hookResult;
219221
};
@@ -777,6 +779,7 @@ In such cases, ZenStack cannot reliably determine the IDs of the mutated entitie
777779
this.connectionProvider,
778780
[...this.plugins, plugin],
779781
this.suppressMutationHooks,
782+
this.queryContext,
780783
);
781784
}
782785

@@ -789,6 +792,7 @@ In such cases, ZenStack cannot reliably determine the IDs of the mutated entitie
789792
this.connectionProvider,
790793
[...this.plugins, ...plugins],
791794
this.suppressMutationHooks,
795+
this.queryContext,
792796
);
793797
}
794798

@@ -801,6 +805,7 @@ In such cases, ZenStack cannot reliably determine the IDs of the mutated entitie
801805
this.connectionProvider,
802806
[plugin, ...this.plugins],
803807
this.suppressMutationHooks,
808+
this.queryContext,
804809
);
805810
}
806811

@@ -813,6 +818,7 @@ In such cases, ZenStack cannot reliably determine the IDs of the mutated entitie
813818
this.connectionProvider,
814819
[],
815820
this.suppressMutationHooks,
821+
this.queryContext,
816822
);
817823
}
818824

@@ -825,11 +831,33 @@ In such cases, ZenStack cannot reliably determine the IDs of the mutated entitie
825831
connectionProvider,
826832
this.plugins as KyselyPlugin[],
827833
this.suppressMutationHooks,
834+
this.queryContext,
828835
);
829836
// replace client with a new one associated with the new executor
830837
newExecutor.client = this.client.withExecutor(newExecutor);
831838
return newExecutor;
832839
}
833840

841+
/**
842+
* Create a new executor carrying the given per-operation query context.
843+
* Called once per top-level ORM operation so that onQuery plugins can write
844+
* values (e.g. `operation`, `fetchPolicyCodes`) that onKyselyQuery plugins read —
845+
* without AsyncLocalStorage.
846+
*/
847+
withQueryContext(queryContext: Map<string, unknown>): ZenStackQueryExecutor {
848+
const newExecutor = new ZenStackQueryExecutor(
849+
this.client,
850+
this.driver,
851+
this.compiler,
852+
this.adapter,
853+
this.connectionProvider,
854+
this.plugins as KyselyPlugin[],
855+
this.suppressMutationHooks,
856+
queryContext,
857+
);
858+
newExecutor.client = this.client.withExecutor(newExecutor);
859+
return newExecutor;
860+
}
861+
834862
// #endregion
835863
}

packages/orm/src/client/plugin.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,13 @@ type OnQueryHookContext<Schema extends SchemaDef> = {
280280
* The ZenStack client that is performing the operation.
281281
*/
282282
client: ClientContract<Schema>;
283+
284+
/**
285+
* Per-operation mutable context shared between onQuery and onKyselyQuery hooks.
286+
* Plugins may write values here in onQuery and read them in onKyselyQuery, avoiding
287+
* the need for AsyncLocalStorage to bridge these two decoupled call sites.
288+
*/
289+
queryContext: Map<string, unknown>;
283290
};
284291

285292
// #endregion
@@ -390,6 +397,7 @@ export type OnKyselyQueryArgs<Schema extends SchemaDef> = {
390397
client: ClientContract<Schema>;
391398
query: RootOperationNode;
392399
proceed: ProceedKyselyQueryFunction;
400+
queryContext: Map<string, unknown>;
393401
};
394402

395403
export type ProceedKyselyQueryFunction = (query: RootOperationNode) => Promise<QueryResult<any>>;

packages/orm/tsdown.config.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { createConfig } from '@zenstackhq/tsdown-config';
33
export default createConfig({
44
entry: {
55
index: 'src/index.ts',
6-
browser: 'src/browser.ts',
76
schema: 'src/schema.ts',
87
helpers: 'src/helpers.ts',
98
'common-types': 'src/common-types.ts',

packages/plugins/policy/src/context.ts

Lines changed: 0 additions & 14 deletions
This file was deleted.

packages/plugins/policy/src/plugin.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { type OnKyselyQueryArgs, type RuntimePlugin } from '@zenstackhq/orm';
22
import type { SchemaDef } from '@zenstackhq/orm/schema';
33
import { z } from 'zod';
4-
import { policyContextStorage } from './context';
54
import { check } from './functions';
65
import type { PolicyPluginOptions } from './options';
76
import { PolicyHandler } from './policy-handler';
@@ -45,27 +44,28 @@ export class PolicyPlugin implements RuntimePlugin<SchemaDef, PolicyExtQueryArgs
4544
$delete: fetchPolicyCodesSchema,
4645
};
4746

48-
// onQuery and onKyselyQuery are decoupled hook call sites with no shared argument path;
49-
// AsyncLocalStorage bridges per-query context into the Kysely executor.
5047
onQuery(ctx: {
5148
operation: string;
5249
args: Record<string, unknown> | undefined;
5350
proceed: (args: Record<string, unknown> | undefined) => Promise<unknown>;
51+
queryContext: Map<string, unknown>;
5452
[key: string]: unknown;
5553
}) {
56-
return policyContextStorage.run(
57-
{ operation: ctx.operation, fetchPolicyCodes: ctx.args?.['fetchPolicyCodes'] as boolean | undefined },
58-
() => ctx.proceed(ctx.args),
59-
);
54+
ctx.queryContext.set('policy:operation', ctx.operation);
55+
const fetchPolicyCodes = ctx.args?.['fetchPolicyCodes'] as boolean | undefined;
56+
if (fetchPolicyCodes !== undefined) {
57+
ctx.queryContext.set('policy:fetchPolicyCodes', fetchPolicyCodes);
58+
}
59+
return ctx.proceed(ctx.args);
6060
}
6161

62-
onKyselyQuery({ query, client, proceed }: OnKyselyQueryArgs<SchemaDef>) {
63-
const ctx = policyContextStorage.getStore();
62+
onKyselyQuery({ query, client, proceed, queryContext }: OnKyselyQueryArgs<SchemaDef>) {
63+
const fetchPolicyCodes = queryContext.get('policy:fetchPolicyCodes') as boolean | undefined;
6464
const effectiveOptions: PolicyPluginOptions =
65-
ctx?.fetchPolicyCodes !== undefined
66-
? { ...this.options, fetchPolicyCodes: ctx.fetchPolicyCodes }
65+
fetchPolicyCodes !== undefined
66+
? { ...this.options, fetchPolicyCodes }
6767
: this.options;
68-
const handler = new PolicyHandler<SchemaDef>(client, effectiveOptions);
68+
const handler = new PolicyHandler<SchemaDef>(client, effectiveOptions, queryContext);
6969
return handler.handle(query, proceed);
7070
}
7171
}

packages/plugins/policy/src/policy-handler.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ import {
4848
} from 'kysely';
4949
import { match } from 'ts-pattern';
5050
import { ColumnCollector } from './column-collector';
51-
import { policyContextStorage } from './context';
5251
import { ExpressionTransformer } from './expression-transformer';
5352
import type { PolicyPluginOptions } from './options';
5453
import type { Policy, PolicyOperation } from './types';
@@ -81,6 +80,7 @@ export class PolicyHandler<Schema extends SchemaDef> extends OperationNodeTransf
8180
constructor(
8281
private readonly client: ClientContract<Schema>,
8382
private readonly options: PolicyPluginOptions = {},
83+
private readonly queryContext: Map<string, unknown> = new Map(),
8484
) {
8585
super();
8686
this.dialect = getCrudDialect(this.client.$schema, this.client.$options);
@@ -107,7 +107,9 @@ export class PolicyHandler<Schema extends SchemaDef> extends OperationNodeTransf
107107
// When 0 rows returned on a throwing single-row read (findFirstOrThrow/findUniqueOrThrow), distinguish "not found" from policy denial
108108
if (
109109
result.rows.length === 0 &&
110-
SINGLE_ROW_OR_THROW_OPERATIONS.has(policyContextStorage.getStore()?.operation ?? '')
110+
SINGLE_ROW_OR_THROW_OPERATIONS.has(
111+
(this.queryContext.get('policy:operation') as string | undefined) ?? '',
112+
)
111113
) {
112114
await this.postReadZeroRowsCheck(selectNode, proceed);
113115
}

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)