Skip to content

Commit 5c13368

Browse files
os-zhuangclaude
andauthored
feat(objectql,runtime): default-runner setters are first-wins; the private-field probes are gone (#4251) (#4398)
setDefaultBodyRunner / setDefaultActionRunner now enforce their own documented contract -- "the runtime layer sets this once per engine" -- by keeping the first runner and returning false for any later call. Public accessors getDefaultBodyRunner() / getDefaultActionRunner() join them, and the fields become real private members instead of `(this as any)` attachments. Before this the invariant lived in the CALLERS: AppPlugin probed the engine's private _defaultBodyRunner / _defaultActionRunner fields through `any` to avoid clobbering another AppPlugin's runner on a shared kernel -- an invariant owned by every caller and enforced by none, and a private reach a field rename would have broken silently. The engine's own bindHooks fallback and ObjectQLPlugin's authored-action re-sync read the same fields the same way. All three read the public accessors now; the only remaining `_default*` mentions are comments and test doubles. Caller audit before changing semantics: every setter call site either owns a fresh engine (sandbox + hook-binder tests) or wants exactly keep-the-first (AppPlugin) -- nobody replaces a runner on a live engine. void -> boolean is additive; AppPlugin uses it to keep its "Installed default ... runner" log truthful. Pinned in hook-binder tests: a second install is refused end-to-end (the first runner is the one that executes) and the accessors expose what was kept. The app-plugin test that pinned the caller-side guard now pins the new division: setter called unconditionally, log skipped when the engine answers false. Verified: objectql 1464/89, runtime 1001/69 (serial runs; an earlier 6-file cloud-connection FAIL was local vitest concurrency noise beside a parallel spec dts build -- single-file and serial reruns green, and the "22 failures on clean HEAD" scare was a stale metadata-protocol dist, NOT a main regression); objectql + runtime dts builds; eslint clean; ratchet holds 168/36 none new. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent f985b3f commit 5c13368

8 files changed

Lines changed: 158 additions & 32 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/objectql": minor
3+
"@objectstack/runtime": patch
4+
---
5+
6+
feat(objectql,runtime): the default-runner setters are first-wins, and the private-field probes that used to enforce that are gone (#4251)
7+
8+
`setDefaultBodyRunner` / `setDefaultActionRunner` now enforce their own
9+
documented contract — "the runtime layer sets this once per engine" — by
10+
keeping the first runner and returning `false` for any later call. Public
11+
accessors `getDefaultBodyRunner()` / `getDefaultActionRunner()` join them, and
12+
the fields become real `private` members instead of `(this as any)` attachments.
13+
14+
Before this, the invariant lived in the CALLERS: AppPlugin probed the engine's
15+
private `_defaultBodyRunner` / `_defaultActionRunner` fields through `any` to
16+
avoid clobbering another AppPlugin's runner on a shared kernel — an invariant
17+
owned by every caller and enforced by none, and a private reach that a field
18+
rename would have broken silently (the guard reads `undefined`, every AppPlugin
19+
reinstalls). The engine's own `bindHooks` fallback and ObjectQLPlugin's
20+
authored-action re-sync read the same fields the same way. All three read the
21+
public accessors now; the only remaining `_default*` mentions in the repo are
22+
comments and test doubles.
23+
24+
Caller audit before the semantics change: every setter call site either owns a
25+
fresh engine (the sandbox and hook-binder tests) or wants exactly
26+
keep-the-first (AppPlugin) — nobody replaces a runner on a live engine. Return
27+
type `void``boolean` is additive; AppPlugin uses it to keep its "Installed
28+
default … runner" log truthful (skipped when the engine kept an earlier one).
29+
30+
Pinned in hook-binder tests: second install refused end-to-end (the first
31+
runner is the one that executes) and the accessors expose exactly what was
32+
kept.

packages/objectql/src/engine.ts

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -690,8 +690,8 @@ export class ObjectQL implements IDataEngine {
690690
metrics?: any;
691691
}): void {
692692
const merged = { ...(opts ?? {}), logger: this.logger } as any;
693-
if (!merged.bodyRunner && (this as any)._defaultBodyRunner) {
694-
merged.bodyRunner = (this as any)._defaultBodyRunner;
693+
if (!merged.bodyRunner && this._defaultBodyRunner) {
694+
merged.bodyRunner = this._defaultBodyRunner;
695695
}
696696
if (merged.strict === undefined && (this as any)._strictHookBinding) {
697697
merged.strict = true;
@@ -705,14 +705,41 @@ export class ObjectQL implements IDataEngine {
705705
bindHooksToEngine(this, hooks, merged);
706706
}
707707

708+
/** Default hook body-runner — see {@link setDefaultBodyRunner}. */
709+
private _defaultBodyRunner?: any;
710+
/** Default action body-runner factory — see {@link setDefaultActionRunner}. */
711+
private _defaultActionRunner?: (actionDef: any) => ((ctx: any) => Promise<unknown>) | undefined;
712+
708713
/**
709714
* Install a default body-runner used when `bindHooks` is called without
710715
* an explicit one. The runtime layer sets this once on each per-project
711716
* engine so every binding path (template seed, metadata sync, AppPlugin)
712717
* can execute hook `body.source` consistently.
718+
*
719+
* FIRST-WINS (#4251): "set once per engine" is this method's own contract,
720+
* so the method enforces it — a second call is ignored and returns `false`.
721+
* Callers used to implement the guard themselves by probing the private
722+
* `_defaultBodyRunner` field through `any` (multiple AppPlugin instances on
723+
* one kernel must not clobber each other's runner), which meant the
724+
* invariant lived in every caller and belonged to none. Nobody replaces a
725+
* runner on a live engine: every setter call site either owns a fresh
726+
* engine or wants exactly this keep-the-first behaviour.
727+
*
728+
* @returns `true` when this call installed the runner, `false` when one was
729+
* already present (kept unchanged).
713730
*/
714-
setDefaultBodyRunner(runner: any): void {
715-
(this as any)._defaultBodyRunner = runner;
731+
setDefaultBodyRunner(runner: any): boolean {
732+
if (this._defaultBodyRunner) {
733+
this.logger.debug('Default body runner already installed — keeping the first');
734+
return false;
735+
}
736+
this._defaultBodyRunner = runner;
737+
return true;
738+
}
739+
740+
/** The installed default body-runner, if any — the public read the first-wins guard implies. */
741+
getDefaultBodyRunner(): any {
742+
return this._defaultBodyRunner;
716743
}
717744

718745
/**
@@ -724,9 +751,25 @@ export class ObjectQL implements IDataEngine {
724751
* `body` into an executable `registerAction` handler. The factory returns
725752
* `undefined` for actions it cannot run (no `body`, invalid shape), which
726753
* callers must treat as "skip", not an error.
754+
*
755+
* FIRST-WINS (#4251) — same contract and rationale as
756+
* {@link setDefaultBodyRunner}.
757+
*
758+
* @returns `true` when this call installed the runner, `false` when one was
759+
* already present (kept unchanged).
727760
*/
728-
setDefaultActionRunner(runner: (actionDef: any) => ((ctx: any) => Promise<unknown>) | undefined): void {
729-
(this as any)._defaultActionRunner = runner;
761+
setDefaultActionRunner(runner: (actionDef: any) => ((ctx: any) => Promise<unknown>) | undefined): boolean {
762+
if (this._defaultActionRunner) {
763+
this.logger.debug('Default action runner already installed — keeping the first');
764+
return false;
765+
}
766+
this._defaultActionRunner = runner;
767+
return true;
768+
}
769+
770+
/** The installed default action-runner factory, if any. */
771+
getDefaultActionRunner(): ((actionDef: any) => ((ctx: any) => Promise<unknown>) | undefined) | undefined {
772+
return this._defaultActionRunner;
730773
}
731774

732775
/**

packages/objectql/src/hook-binder.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,35 @@ describe('bindHooksToEngine', () => {
227227
expect(calls).toEqual(['body-default-runner']);
228228
});
229229

230+
it('keeps the FIRST default body runner — the setter is first-wins (#4251)', async () => {
231+
const engine = makeEngine();
232+
const calls: string[] = [];
233+
expect(engine.setDefaultBodyRunner(() => async () => { calls.push('first'); })).toBe(true);
234+
// A second install (another AppPlugin sharing the kernel) is refused —
235+
// the invariant used to be enforced by callers probing the private
236+
// `_defaultBodyRunner` field; it is the engine's own now.
237+
expect(engine.setDefaultBodyRunner(() => async () => { calls.push('second'); })).toBe(false);
238+
const hook: Hook = {
239+
name: 'body-first-wins', object: 'account', events: ['beforeInsert'], priority: 100,
240+
body: { language: 'js', source: 'ctx.input.x = 1;' },
241+
} as unknown as Hook;
242+
engine.bindHooks([hook], { packageId: 'app:first-wins' });
243+
await engine.triggerHooks('beforeInsert', makeCtx());
244+
// Execution proves the first runner stayed installed.
245+
expect(calls).toEqual(['first']);
246+
});
247+
248+
it('keeps the FIRST default action runner and exposes both via public accessors (#4251)', () => {
249+
const engine = makeEngine();
250+
const first = () => undefined;
251+
const second = () => undefined;
252+
expect(engine.setDefaultActionRunner(first)).toBe(true);
253+
expect(engine.setDefaultActionRunner(second)).toBe(false);
254+
// The accessors are the public read the old private-field probes become.
255+
expect(engine.getDefaultActionRunner()).toBe(first);
256+
expect(engine.getDefaultBodyRunner()).toBeUndefined();
257+
});
258+
230259
it('explicit bodyRunner wins over the engine default', async () => {
231260
const engine = makeEngine();
232261
const calls: string[] = [];

packages/objectql/src/hook-binder.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,9 @@ function resolveHandler(
259259
if (body && typeof body === 'object') {
260260
let runner = opts.bodyRunner;
261261
if (typeof runner !== 'function') {
262-
const fallback = (engine as any)?._defaultBodyRunner;
262+
// [#4251] The public accessor — this read used to reach the private
263+
// `_defaultBodyRunner` field through `as any`.
264+
const fallback = engine?.getDefaultBodyRunner?.();
263265
if (typeof fallback === 'function') runner = fallback;
264266
}
265267
if (typeof runner !== 'function') {

packages/objectql/src/plugin-authored-actions.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,13 @@ function makeQlMock(overrides: AnyRecord = {}) {
3030
getArtifactItem: vi.fn(() => undefined),
3131
},
3232
// Default action runner as the runtime installs it: body → handler.
33+
// The plugin reads it through `getDefaultActionRunner()` (#4251); the
34+
// field stays so tests can swap or clear the runner directly.
3335
_defaultActionRunner: vi.fn((action: AnyRecord) =>
3436
action?.body ? async () => `ran:${action.name}` : undefined),
37+
getDefaultActionRunner(): unknown {
38+
return (this as AnyRecord)._defaultActionRunner;
39+
},
3540
...overrides,
3641
};
3742
}

packages/objectql/src/plugin.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1665,7 +1665,12 @@ export class ObjectQLPlugin implements Plugin {
16651665
// deleting the last authored action must unregister it.
16661666
ql.removeActionsByPackage('metadata-service');
16671667

1668-
const runner: any = ql._defaultActionRunner;
1668+
// [#4251] The public accessor — this read used to reach the private
1669+
// `_defaultActionRunner` field directly. Probed because `ql` can be a
1670+
// test double that predates the accessor.
1671+
const runner: any = typeof ql.getDefaultActionRunner === 'function'
1672+
? ql.getDefaultActionRunner()
1673+
: undefined;
16691674
let registered = 0;
16701675
let skippedNoHandler = 0;
16711676
for (const action of bindable) {

packages/runtime/src/app-plugin.test.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -436,11 +436,19 @@ describe('AppPlugin', () => {
436436
expect(mockManifest.register).not.toHaveBeenCalled();
437437
});
438438

439-
it('does not replace an already-installed default runner', async () => {
440-
mockQL._defaultBodyRunner = () => undefined;
439+
it('delegates keep-the-first to the engine — the setter is called unconditionally (#4251)', async () => {
440+
// The caller-side guard (probing the engine's private
441+
// `_defaultBodyRunner` field) is gone; the ENGINE's first-wins
442+
// setter owns the invariant, pinned in objectql's hook-binder
443+
// tests. This side only skips the "Installed" log when the engine
444+
// answers `false`.
445+
mockQL.setDefaultBodyRunner = vi.fn(() => false); // engine: already installed
441446
const plugin = new AppPlugin({ id: 'com.test.app' });
442447
await plugin.init(mockContext);
443-
expect(mockQL.setDefaultBodyRunner).not.toHaveBeenCalled();
448+
expect(mockQL.setDefaultBodyRunner).toHaveBeenCalledTimes(1);
449+
expect(vi.mocked(mockContext.logger.info).mock.calls.map((c) => c[0])).not.toContain(
450+
'[AppPlugin] Installed default hook body runner (runtime-authored hooks can execute)',
451+
);
444452
});
445453

446454
it('honours the OS_DISABLE_AUTHORED_HOOKS=1 opt-out', async () => {

packages/runtime/src/app-plugin.ts

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -55,26 +55,22 @@ interface AppEngineSurface {
5555
registerAction(objectKey: string, name: string, handler: unknown, packageId: string): void;
5656
registerDriver(driver: unknown): void;
5757
setDatasourceMapping(rules: unknown): void;
58-
setDefaultBodyRunner(runner: unknown): void;
58+
/**
59+
* FIRST-WINS setters (#4251): the engine keeps the first runner and
60+
* returns whether this call installed it. The idempotence guard used to
61+
* live HERE, as a probe of the engine's private `_defaultBodyRunner` /
62+
* `_defaultActionRunner` fields through this surface — an invariant owned
63+
* by every caller and enforced by none. It is the engine's now.
64+
* `boolean | void` because pre-first-wins engines (and bare `vi.fn()`
65+
* doubles) return undefined — treated as installed.
66+
*/
67+
setDefaultBodyRunner(runner: unknown): boolean | void;
5968
setDefaultActionRunner(
6069
runner: (actionDef: any) => ((ctx: any) => Promise<unknown>) | undefined,
61-
): void;
70+
): boolean | void;
6271
setHookMetricsRecorder(recorder: unknown): void;
6372
getHookMetricsRecorder(): any;
6473
readonly registry?: { setInitialDisabledPackageIds?: (ids: Iterable<string>) => void };
65-
/**
66-
* The idempotence guards for the two runners above, read directly.
67-
*
68-
* These are NOT part of any declared API: the engine attaches them with
69-
* `(this as any)._defaultBodyRunner = runner` and publishes no reader, so
70-
* "has another AppPlugin already installed one?" has no answer except
71-
* reaching for the field. Declared here to keep that reach visible instead
72-
* of laundering it through `any` — the fix is a public accessor on the
73-
* engine beside `getHookMetricsRecorder`, which already exists for exactly
74-
* this question about the metrics recorder. Filed on #4251.
75-
*/
76-
_defaultBodyRunner?: unknown;
77-
_defaultActionRunner?: unknown;
7874
}
7975

8076
/** The engine as AppPlugin uses it: the data contract plus those seams. */
@@ -326,13 +322,17 @@ export class AppPlugin implements Plugin {
326322
return; // no engine on this kernel — nothing to wire
327323
}
328324
if (!ql || typeof ql.setDefaultBodyRunner !== 'function') return;
329-
if (ql._defaultBodyRunner) return; // another AppPlugin already installed one
330-
ql.setDefaultBodyRunner(hookBodyRunnerFactory(new QuickJSScriptRunner(), {
325+
// [#4251] The setter is first-wins — the engine keeps the first runner
326+
// when several AppPlugins share one kernel; this caller no longer
327+
// probes the engine's private `_defaultBodyRunner` field to find out.
328+
const installed = ql.setDefaultBodyRunner(hookBodyRunnerFactory(new QuickJSScriptRunner(), {
331329
ql,
332330
logger: ctx.logger,
333331
appId: 'runtime-authored',
334332
}));
335-
ctx.logger.info('[AppPlugin] Installed default hook body runner (runtime-authored hooks can execute)');
333+
if (installed !== false) {
334+
ctx.logger.info('[AppPlugin] Installed default hook body runner (runtime-authored hooks can execute)');
335+
}
336336
}
337337

338338
/**
@@ -364,13 +364,15 @@ export class AppPlugin implements Plugin {
364364
return; // no engine on this kernel — nothing to wire
365365
}
366366
if (!ql || typeof ql.setDefaultActionRunner !== 'function') return;
367-
if (ql._defaultActionRunner) return; // another AppPlugin already installed one
368-
ql.setDefaultActionRunner(actionBodyRunnerFactory(new QuickJSScriptRunner(), {
367+
// [#4251] First-wins setter — same as the hook body runner above.
368+
const installed = ql.setDefaultActionRunner(actionBodyRunnerFactory(new QuickJSScriptRunner(), {
369369
ql,
370370
logger: ctx.logger,
371371
appId: 'runtime-authored',
372372
}));
373-
ctx.logger.info('[AppPlugin] Installed default action body runner (runtime-authored actions can execute)');
373+
if (installed !== false) {
374+
ctx.logger.info('[AppPlugin] Installed default action body runner (runtime-authored actions can execute)');
375+
}
374376
}
375377

376378
/**

0 commit comments

Comments
 (0)