Skip to content

Commit 793ac4d

Browse files
committed
fix(runtime): AppPlugin contributes its datasource names as a function the set resolves at first read, so init() never walks packages[]
AppPlugin.init()'s manifest registration is the one thing in init() allowed to touch the artifact's packages[]; resolving the owner list there made a malformed packages[] refuse from the collections getter too. The host's code-datasource set now holds deferred contributions, resolved at its first read (still after every init(), before any reader in start()), and a contribution that throws stays pending and reaches every reader. Also restores the space in `const listOf = (`. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
1 parent 49421a8 commit 793ac4d

3 files changed

Lines changed: 129 additions & 33 deletions

File tree

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

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -159,11 +159,11 @@ export class AppPlugin implements Plugin {
159159
/** What `grantedPermissions` bound to on this artifact — see {@link ArtifactGrantBinding}. */
160160
private grantBindingResult?: ArtifactGrantBinding;
161161
/**
162-
* [#21922] {@link codeDefinedDatasourceOwners}, computed once: `init()`
163-
* contributes the names to the host's code-datasource set and `start()`
164-
* registers the same list, so the two can never disagree about which
165-
* datasources this artifact registers from code — and the residual-owner
166-
* warning prints once, not once per phase.
162+
* [#21922] {@link codeDefinedDatasourceOwners}, computed once: the host's
163+
* code-datasource set reads the names through the contribution `init()`
164+
* registers, and `start()` registers the same list, so the two can never
165+
* disagree about which datasources this artifact registers from code — and
166+
* the residual-owner warning prints once, at whichever read comes first.
167167
*/
168168
private codeDatasourceOwners?: Array<{ datasource: any; owner: { packageId?: string; packageVersion?: string } }>;
169169
/** When true, init/start become no-ops — env has no app payload. */
@@ -439,10 +439,15 @@ export class AppPlugin implements Plugin {
439439
// plugin's boot restore — a `start()` — never registers a stored row
440440
// over one of them, whatever order the plugins were composed in. The
441441
// in-memory registration itself stays in `start()` (see there).
442-
const codeNames = this.codeDefinedDatasourceOwners(ctx)
442+
//
443+
// Contributed as a FUNCTION the set resolves at its first read, never
444+
// resolved here: the names come from `collections`, which walks
445+
// `packages[]`, and the manifest registration above is the one thing
446+
// in `init()` allowed to touch `packages[]` (#15292's falsifier in
447+
// `plugin-dev` pins it). `CodeDatasourceNames` says the rest.
448+
contributeCodeDatasourceNames(ctx, () => this.codeDefinedDatasourceOwners(ctx)
443449
.map(({ datasource }) => datasource?.name)
444-
.filter((name): name is string => typeof name === 'string' && name.length > 0);
445-
if (codeNames.length > 0) contributeCodeDatasourceNames(ctx, codeNames);
450+
.filter((name): name is string => typeof name === 'string' && name.length > 0));
446451
}
447452

448453
/**
@@ -730,7 +735,7 @@ export class AppPlugin implements Plugin {
730735
datasource: any;
731736
owner: { packageId?: string; packageVersion?: string };
732737
}> {
733-
const listOf =(dsDefs: unknown): any[] =>
738+
const listOf = (dsDefs: unknown): any[] =>
734739
Array.isArray(dsDefs)
735740
? dsDefs
736741
: dsDefs && typeof dsDefs === 'object'

‎packages/runtime/src/code-datasource-names.test.ts‎

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,24 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22
//
3-
// [#21922 / #21944] The host's code-datasource set: one set, filled from code
4-
// in Phase 1, complete before ANY `start()` runs — which is when the
3+
// [#21922 / #21944] The host's code-datasource set: one set, contributed to
4+
// from code in Phase 1, complete before ANY `start()` runs — which is when the
55
// datasource-admin plugin's boot restore reads it.
66
//
77
// The boot case composes its reader FIRST, ahead of every producer. Start
88
// order follows insertion where no plugin declares an edge, and none of
99
// AppPlugin, DefaultDatasourcePlugin and DatasourceAdminServicePlugin declares
10-
// one to another — so a set filled in `start()` would be empty here. Filled in
11-
// `init()`, it is whole.
10+
// one to another — so a set contributed to in `start()` would be short here.
11+
// Contributed to in `init()`, it is whole. (AppPlugin's contribution is a
12+
// function the set resolves at that first read — `CodeDatasourceNames` says
13+
// why; `plugin-dev`'s #15292 falsifier pins that `init()` itself never reads
14+
// the artifact's `packages[]` for it.)
1215

1316
import { describe, it, expect } from 'vitest';
1417
import type { Plugin, PluginContext } from '@objectstack/core';
1518
import { Runtime } from './runtime.js';
1619
import { DefaultDatasourcePlugin } from './default-datasource-plugin.js';
1720
import { AppPlugin } from './app-plugin.js';
18-
import { CODE_DATASOURCE_NAMES_SERVICE, contributeCodeDatasourceNames } from './code-datasource-names.js';
21+
import { CODE_DATASOURCE_NAMES_SERVICE, CodeDatasourceNames, contributeCodeDatasourceNames } from './code-datasource-names.js';
1922

2023
// [#10126] Pay the first transform of these dist-resolved workspace deps at MODULE
2124
// LOAD, not inside a clocked `it()` body (`scripts/check-test-source-alias.mjs`).
@@ -53,13 +56,38 @@ describe('contributeCodeDatasourceNames — one set per kernel', () => {
5356
contributeCodeDatasourceNames(ctx, ['crm_wh', 'erp_wh']);
5457

5558
expect(registrations()).toBe(1);
56-
const set = services.get(CODE_DATASOURCE_NAMES_SERVICE) as Set<string>;
57-
expect([...set].sort()).toEqual(['crm_wh', 'default', 'erp_wh']);
59+
const set = services.get(CODE_DATASOURCE_NAMES_SERVICE) as CodeDatasourceNames;
60+
expect(set.list()).toEqual(['crm_wh', 'default', 'erp_wh']);
61+
expect(set.has('crm_wh')).toBe(true);
62+
expect(set.has('runtime_wh')).toBe(false);
5863
});
5964

60-
it('a name the kernel holds as something other than a set is a composition fault, refused loudly', () => {
65+
it('a contribution made as a function is resolved at the first read, not when it is made', () => {
6166
const { ctx, services } = registryCtx();
62-
services.set(CODE_DATASOURCE_NAMES_SERVICE, { has: () => false });
67+
let calls = 0;
68+
69+
contributeCodeDatasourceNames(ctx, () => { calls += 1; return ['app_wh']; });
70+
expect(calls).toBe(0);
71+
72+
const set = services.get(CODE_DATASOURCE_NAMES_SERVICE) as CodeDatasourceNames;
73+
expect(set.has('app_wh')).toBe(true);
74+
expect(set.has('app_wh')).toBe(true);
75+
expect(calls).toBe(1);
76+
});
77+
78+
it('a contribution that throws stays pending and reaches every reader — the set never answers without it', () => {
79+
const { ctx, services } = registryCtx();
80+
contributeCodeDatasourceNames(ctx, ['default']);
81+
contributeCodeDatasourceNames(ctx, () => { throw new Error('the artifact cannot be read'); });
82+
83+
const set = services.get(CODE_DATASOURCE_NAMES_SERVICE) as CodeDatasourceNames;
84+
expect(() => set.has('default')).toThrow(/the artifact cannot be read/);
85+
expect(() => set.has('default')).toThrow(/the artifact cannot be read/);
86+
});
87+
88+
it('a name the kernel holds as something other than this set is a composition fault, refused loudly', () => {
89+
const { ctx, services } = registryCtx();
90+
services.set(CODE_DATASOURCE_NAMES_SERVICE, new Set(['default']));
6391

6492
expect(() => contributeCodeDatasourceNames(ctx, ['default'])).toThrow(/already registered/);
6593
});
@@ -77,8 +105,7 @@ describe('the host\'s code-datasource set on a booted kernel', () => {
77105
version: '1.0.0',
78106
init: async () => {},
79107
start: async (ctx: PluginContext) => {
80-
const set = ctx.getService<Set<string>>(CODE_DATASOURCE_NAMES_SERVICE);
81-
seenAtFirstStart = [...set].sort();
108+
seenAtFirstStart = ctx.getService<CodeDatasourceNames>(CODE_DATASOURCE_NAMES_SERVICE).list();
82109
},
83110
};
84111

@@ -94,7 +121,7 @@ describe('the host\'s code-datasource set on a booted kernel', () => {
94121
await kernel.bootstrap();
95122
expect(seenAtFirstStart).toEqual(['app_wh', 'default']);
96123
// One set: the kernel service the two readers resolve by name.
97-
expect([...kernel.getService<Set<string>>(CODE_DATASOURCE_NAMES_SERVICE)].sort()).toEqual(['app_wh', 'default']);
124+
expect(kernel.getService<CodeDatasourceNames>(CODE_DATASOURCE_NAMES_SERVICE).list()).toEqual(['app_wh', 'default']);
98125
} finally {
99126
await kernel.shutdown();
100127
}
@@ -109,7 +136,7 @@ describe('the host\'s code-datasource set on a booted kernel', () => {
109136
await kernel.use(new AppPlugin({ manifest: { id: 'com.test.no-ds', name: 'No DS', version: '1.0.0' } }));
110137
try {
111138
await kernel.bootstrap();
112-
expect([...kernel.getService<Set<string>>(CODE_DATASOURCE_NAMES_SERVICE)]).toEqual(['default']);
139+
expect(kernel.getService<CodeDatasourceNames>(CODE_DATASOURCE_NAMES_SERVICE).list()).toEqual(['default']);
113140
} finally {
114141
await kernel.shutdown();
115142
}

‎packages/runtime/src/code-datasource-names.ts‎

Lines changed: 75 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
/**
44
* The host's code-datasource set (#21922, #21944): the ONE in-memory set of
55
* datasource names this host registers from code, kept on the kernel's
6-
* {@link CODE_DATASOURCE_NAMES_SERVICE} service.
6+
* {@link CODE_DATASOURCE_NAMES_SERVICE} service as a {@link CodeDatasourceNames}.
77
*
88
* ## Who fills it, and when
99
*
@@ -12,7 +12,8 @@
1212
*
1313
* - `AppPlugin` — every datasource the installed artifact declares
1414
* (`*.datasource.ts`), the same list its `start()` registers in the
15-
* MetadataService as `origin: 'code'`;
15+
* MetadataService as `origin: 'code'`, contributed as a function the set
16+
* resolves at its first read ({@link CodeDatasourceNames} says why);
1617
* - `DefaultDatasourcePlugin` — the host's own `default`.
1718
*
1819
* `init()` is Phase 1, and Phase 1 completes before ANY `start()` runs
@@ -58,35 +59,98 @@
5859
/** The kernel service the host's code-datasource set is registered under. */
5960
export const CODE_DATASOURCE_NAMES_SERVICE = 'code-datasource-names';
6061

62+
/**
63+
* What a producer contributes: names it knows now, or a function that names
64+
* them when the set is first read. `AppPlugin` contributes the function — see
65+
* {@link CodeDatasourceNames}.
66+
*/
67+
export type CodeDatasourceContribution = Iterable<string> | (() => Iterable<string>);
68+
69+
/**
70+
* The set itself. Readers use `has(name)` only, structurally.
71+
*
72+
* ## Why a contribution can be a function
73+
*
74+
* `AppPlugin` learns its datasource names from the artifact's `collections`,
75+
* and reading `collections` walks `packages[]`, which refuses a malformed entry.
76+
* `AppPlugin.init()`'s pinned contract is that its manifest registration is the
77+
* ONLY thing in `init()` that touches `packages[]` (#15292's falsifier in
78+
* `plugin-dev`): a malformed artifact is refused there, once, and DevPlugin
79+
* degrades that refusal to an error line. So `AppPlugin` registers, in
80+
* `init()`, a function this set calls at its first read. The CONTRIBUTION is
81+
* made in Phase 1 — before any `start()`, which is all the boot restore needs —
82+
* and the resolution happens where the set is used (AGENTS.md's first cure for
83+
* a startup registry read), with the same list `AppPlugin.start()` registers.
84+
*
85+
* A contribution that throws stays pending and the throw reaches the reader,
86+
* on every read: a set that answered without it would call a code datasource
87+
* "not code" without telling anyone.
88+
*/
89+
export class CodeDatasourceNames {
90+
private readonly names = new Set<string>();
91+
private readonly pending: Array<() => Iterable<string>> = [];
92+
93+
add(contribution: CodeDatasourceContribution): void {
94+
if (typeof contribution === 'function') this.pending.push(contribution);
95+
else this.addAll(contribution);
96+
}
97+
98+
has(name: string): boolean {
99+
this.settle();
100+
return this.names.has(name);
101+
}
102+
103+
/** Every name in the set, sorted — for diagnostics and pins. */
104+
list(): string[] {
105+
this.settle();
106+
return [...this.names].sort();
107+
}
108+
109+
private settle(): void {
110+
while (this.pending.length > 0) {
111+
const resolved = [...this.pending[0]()];
112+
this.pending.shift();
113+
this.addAll(resolved);
114+
}
115+
}
116+
117+
private addAll(names: Iterable<string>): void {
118+
for (const name of names) {
119+
if (typeof name === 'string' && name.length > 0) this.names.add(name);
120+
}
121+
}
122+
}
123+
61124
/** The slice of a plugin context the producers need: the service registry. */
62125
interface ServiceRegistryContext {
63126
getService<T>(name: string): T;
64127
registerService(name: string, service: unknown): void;
65128
}
66129

67130
/**
68-
* Add `names` to the host's code-datasource set, registering the set on the
69-
* kernel the first time any producer contributes. Called from a producer's
131+
* Add a contribution to the host's code-datasource set, registering the set on
132+
* the kernel the first time any producer contributes. Called from a producer's
70133
* `init()` only — see the module header for why the phase matters.
71134
*
72135
* `getService` throws while the service is unregistered; that miss is the
73-
* one case that creates the set. A name occupied by something that is not a
136+
* one case that creates the set. A name occupied by something that is not this
74137
* set is a composition fault, and `registerService` throws on it, loudly:
75138
* failing open there would leave every stored row free to shadow the code
76139
* definitions this set exists to protect.
77140
*/
78-
export function contributeCodeDatasourceNames(ctx: ServiceRegistryContext, names: Iterable<string>): void {
141+
export function contributeCodeDatasourceNames(
142+
ctx: ServiceRegistryContext,
143+
contribution: CodeDatasourceContribution,
144+
): void {
79145
let set: unknown;
80146
try {
81147
set = ctx.getService<unknown>(CODE_DATASOURCE_NAMES_SERVICE);
82148
} catch {
83149
set = undefined;
84150
}
85-
if (!(set instanceof Set)) {
86-
set = new Set<string>();
151+
if (!(set instanceof CodeDatasourceNames)) {
152+
set = new CodeDatasourceNames();
87153
ctx.registerService(CODE_DATASOURCE_NAMES_SERVICE, set);
88154
}
89-
for (const name of names) {
90-
if (typeof name === 'string' && name.length > 0) (set as Set<string>).add(name);
91-
}
155+
(set as CodeDatasourceNames).add(contribution);
92156
}

0 commit comments

Comments
 (0)