Skip to content

Commit d77e150

Browse files
committed
fix(metadata-protocol): the default datasource's refusal names the host's database configuration, not a *.datasource.ts that does not exist
The origin-gated datasource row gains `hostOwned`: names the host defines from its own configuration. `default` is the one, reserved by contract (AppPlugin refuses an artifact declaring it, the admin service refuses to create it), so its PUT / no-row DELETE refusal now says what defines it and to restart the server; every package-declared datasource's sentence is byte-identical. Code, status and the refused set are unchanged. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
1 parent 793ac4d commit d77e150

4 files changed

Lines changed: 72 additions & 7 deletions

File tree

‎.changeset/21922-code-datasource-wins-at-restore.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ The runtime now keeps one in-memory set of the datasource names it registers fro
1616

1717
- After a restart over a stored row under a code-defined datasource's name, `GET /api/v1/datasources` serves the code definition (`origin: code`) instead of the row, and `PATCH /api/v1/datasources/:name` answers `400 DATASOURCE_ADMIN_ERROR` ("… is code-defined and cannot be edited at runtime.") where it answered 200 for a row that carried `origin: 'runtime'`.
1818
- No live pool is opened from such a row at boot.
19-
- `PUT /api/v1/meta/datasource/default` answered 200 and now answers `403 NOT_OVERRIDABLE`. `DELETE /api/v1/meta/datasource/default` with no stored row answered 200 and now answers the same `403`.
19+
- `PUT /api/v1/meta/datasource/default` answered 200 and now answers `403 NOT_OVERRIDABLE`. `DELETE /api/v1/meta/datasource/default` with no stored row answered 200 and now answers the same `403`. The refusal's remedy names the host's database configuration (the database URL the server starts with), which is what defines `default`; every other code-defined datasource's refusal still names its `*.datasource.ts` source.
2020
- The skipped row is kept, and the boot logs one warning naming it.
2121

2222
**Remedy.**

‎packages/metadata-protocol/src/packaged-base-regime.ts‎

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@
7777
* remedy. The two doors keep their own codes (`NOT_OVERRIDABLE` / 403 here,
7878
* `DATASOURCE_ADMIN_ERROR` / 400 there); the verdict and the remedy agree.
7979
*
80+
* [#21944] One code-defined datasource has no source file: the host's
81+
* `default`, defined by the database the server starts with. The row lists it
82+
* under `hostOwned`, and its sentence names that configuration instead of a
83+
* `*.datasource.ts` nobody can find. Every other name keeps the source remedy.
84+
*
8085
* The row is also what {@link isOriginGatedType} answers from, for the one
8186
* removal both the protocol's delete door and the repository's delete gate
8287
* allow on such a type: deleting a STORED row under a code-defined name. The
@@ -127,6 +132,13 @@ export type PackagedBaseRegimeRow =
127132
readonly source: string;
128133
/** The decision record the sentence cites. */
129134
readonly docs: string;
135+
/**
136+
* [#21944] Names the HOST defines from its own configuration rather than
137+
* from a {@link source} file, each with what defines it. For such a name
138+
* the source-file remedy is false — no such file exists, and an artifact
139+
* may not declare the name at all — so its sentence names this instead.
140+
*/
141+
readonly hostOwned?: Readonly<Record<string, string>>;
130142
};
131143

132144
/** The table. Keyed by the canonical (singular) metadata type. */
@@ -159,6 +171,14 @@ export const PACKAGED_BASE_REGIME: Readonly<Record<string, PackagedBaseRegimeRow
159171
noun: 'Datasource',
160172
source: '*.datasource.ts',
161173
docs: 'docs/adr/0062-external-datasource-runtime.md',
174+
// [#21944] `default` is the host's primary datasource: the runtime's
175+
// DefaultDatasourcePlugin builds it from the database the server is
176+
// started with (a URL flag or config, OS_DATABASE_URL, a default-routing
177+
// rule, or the unified default file — `resolve-project-database.ts`).
178+
// The name is reserved for it: AppPlugin refuses an artifact that
179+
// declares `default`, and the datasource-admin service refuses to create
180+
// one. So no `*.datasource.ts` declares it, and its remedy says so.
181+
hostOwned: { default: "the host's database configuration (the database URL the server starts with)" },
162182
},
163183
};
164184

@@ -197,12 +217,19 @@ function regimeCPrescription(routes: PackagedBaseRegimeCRoutes): string {
197217
* origin-gated row's owning source — the only remedy such a type has — and the
198218
* row's citation.
199219
*/
200-
function rowPrescription(row: PackagedBaseRegimeRow): string {
220+
function rowPrescription(row: PackagedBaseRegimeRow, name?: string): string {
201221
switch (row.regime) {
202222
case 'C':
203223
return regimeCPrescription(row.routes);
204-
case 'origin-gated':
205-
return `Edit the ${row.source} source that declares it and redeploy. See ${row.docs}.`;
224+
case 'origin-gated': {
225+
const host = name !== undefined && row.hostOwned !== undefined
226+
&& Object.prototype.hasOwnProperty.call(row.hostOwned, name)
227+
? row.hostOwned[name]
228+
: undefined;
229+
return host !== undefined
230+
? `It is defined by ${host}: change that configuration and restart the server. See ${row.docs}.`
231+
: `Edit the ${row.source} source that declares it and redeploy. See ${row.docs}.`;
232+
}
206233
}
207234
}
208235

@@ -247,7 +274,8 @@ export function isOriginGatedType(type: string): boolean {
247274
* `flow` 411 / 404, `action` 365 / 358, `permission` 317 / 310, `datasource`
248275
* 192 / 193 — so a name of up to 88 characters arrives whole for every row
249276
* (pinned). A `flow`'s sentence is byte-identical to the one the row table
250-
* replaced (pinned literally).
277+
* replaced (pinned literally). [#21944] A row's `hostOwned` name is a fixed,
278+
* short name with its own remedy (`default`: under 300 characters whole).
251279
*/
252280
export function packagedBaseRegimeSentence(
253281
type: string, name: string, operation: 'save' | 'delete',
@@ -260,5 +288,5 @@ export function packagedBaseRegimeSentence(
260288
+ (operation === 'delete' ? 'removed' : 'edited') + ' at runtime: it is read-only. '
261289
: `Metadata item '${singular}/${name}' is provided by a code package, and its packaged base is locked `
262290
+ (operation === 'delete' ? `against removal. ` : `against in-place edits. `);
263-
return lock + rowPrescription(row);
291+
return lock + rowPrescription(row, name);
264292
}

‎packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,19 @@ const CODE_NAMES_SERVICE = 'code-datasource-names';
434434
const DEFAULT_SAVE_VERDICT = "Datasource 'default' is code-defined and cannot be edited at runtime: it is read-only.";
435435
const DEFAULT_DELETE_VERDICT = "Datasource 'default' is code-defined and cannot be removed at runtime: it is read-only.";
436436
const hostServices = (names: string[]) => new Map<string, unknown>([[CODE_NAMES_SERVICE, new Set(names)]]);
437+
/**
438+
* The remedy `default`'s refusal must carry: no `*.datasource.ts` declares
439+
* `default` — the host defines it from the database the server starts with —
440+
* so the sentence names that, and never the source-file remedy.
441+
*/
442+
const HOST_REMEDY = "It is defined by the host's database configuration";
443+
const expectHostRemedy = (message: unknown) => {
444+
const text = String(message);
445+
expect(text).toContain(HOST_REMEDY);
446+
expect(text).toContain('restart the server');
447+
expect(text).not.toContain('.datasource.ts');
448+
expect(text).not.toContain('OS_METADATA_WRITABLE');
449+
};
437450

438451
describe('[#21944] the resolver reads the host\'s code-datasource set', () => {
439452
it('sees a name the host registers from code, and only the `datasource` type', () => {
@@ -471,6 +484,7 @@ for (const { label, environmentId } of KERNELS) {
471484

472485
expect({ code: err?.code, status: err?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
473486
expect(String(err?.message).startsWith(`${DEFAULT_SAVE_VERDICT} `), String(err?.message)).toBe(true);
487+
expectHostRemedy(err?.message);
474488
expect(rows.size).toBe(0);
475489
expect(historyRows).toEqual([]);
476490
});
@@ -482,6 +496,7 @@ for (const { label, environmentId } of KERNELS) {
482496

483497
expect({ code: err?.code, status: err?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
484498
expect(String(err?.message).startsWith(`${DEFAULT_DELETE_VERDICT} `), String(err?.message)).toBe(true);
499+
expectHostRemedy(err?.message);
485500
expect(rows.size).toBe(0);
486501
});
487502

@@ -494,6 +509,19 @@ for (const { label, environmentId } of KERNELS) {
494509
expect(Array.from(rows.values()).filter((r) => r.name === 'default')).toEqual([]);
495510
});
496511

512+
it('side by side: `default` names the host\'s configuration, a package-declared datasource still names its source', async () => {
513+
const { protocol } = session();
514+
515+
const host = await refusalOf(protocol.saveMetaItem({ type: 'datasource', name: 'default', item: body('default', 'x') }));
516+
const packaged = await refusalOf(protocol.saveMetaItem({ type: 'datasource', name: CODE_DS, item: body(CODE_DS, 'x') }));
517+
518+
expect({ code: host?.code, status: host?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
519+
expect({ code: packaged?.code, status: packaged?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
520+
expectHostRemedy(host?.message);
521+
expectVerdict(packaged?.message, SAVE_VERDICT);
522+
expect(String(packaged?.message)).not.toContain(HOST_REMEDY);
523+
});
524+
497525
it('control: a runtime datasource still saves with the set registered', async () => {
498526
const { protocol, rows } = session();
499527
const saved = await protocol.saveMetaItem({ type: 'datasource', name: RUNTIME_DS, item: body(RUNTIME_DS, 'Runtime') });

‎packages/qa/dogfood/test/datasource-restore-code-wins.dogfood.test.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@
2929
// - the `/meta` door's `DELETE` of such a row (the repair) removes it, and
3030
// what the admin door serves does not change;
3131
// - `PUT` and `DELETE` on `/api/v1/meta/datasource/default` are refused with
32-
// the answer the door gives every code-defined datasource;
32+
// the answer the door gives every code-defined datasource, its remedy
33+
// naming the host's database configuration — no `*.datasource.ts`
34+
// declares `default`;
3335
// - a runtime datasource with no code twin still restores, and one still
3436
// saves through the `/meta` door.
3537
//
@@ -178,6 +180,13 @@ describe('[#21922 / #21944] a stored datasource row never displaces a code datas
178180
expect({ status: del.status, code: del.code }).toEqual({ status: 403, code: 'NOT_OVERRIDABLE' });
179181
expect(del.message.startsWith("Datasource 'default' is code-defined and cannot be removed at runtime: it is read-only."), del.message).toBe(true);
180182

183+
// The remedy tells the truth about `default`: no `*.datasource.ts` declares
184+
// it — the host defines it from the database the server starts with.
185+
for (const message of [put.message, del.message]) {
186+
expect(message).toContain("It is defined by the host's database configuration");
187+
expect(message).not.toContain('.datasource.ts');
188+
}
189+
181190
expect(await storedRows('default')).toEqual([]);
182191
});
183192

0 commit comments

Comments
 (0)