Skip to content

Commit e5f2a80

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-21898-builtin-node-config-values-judged
2 parents 34e16e3 + 753e7a1 commit e5f2a80

19 files changed

Lines changed: 1388 additions & 50 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@objectstack/service-datasource": minor
3+
"@objectstack/metadata-protocol": minor
4+
"@objectstack/runtime": minor
5+
---
6+
7+
fix(service-datasource,runtime,metadata-protocol)!: a stored datasource row no longer displaces a code-defined datasource at boot, and the metadata door refuses edits to the host's `default` (#21922, #21944)
8+
9+
Clause-②: no (narrowing)
10+
11+
A code-defined datasource (one the installed artifact declares in `*.datasource.ts`, or the host's own `default`) is read-only: `DatasourceSchema.origin` publishes it as "GitOps-owned, read-only in the UI", and the datasource-admin service states "code wins on collision". The boot restore broke both. It registered every stored `datasource` row in `sys_metadata` over whatever the runtime had registered from code, so after a restart a row left under a code-defined name was served by the admin door, editable there when it carried `origin: 'runtime'`, and handed to pool rehydration. A stored `default` row opened a second live pool named `default` on the row's own connection. The metadata door also still saved edits to `default`, the one code-defined datasource no package declares.
12+
13+
The runtime now keeps one in-memory set of the datasource names it registers from code, on the kernel service `code-datasource-names`: `AppPlugin` adds the datasources the artifact declares and `DefaultDatasourcePlugin` adds `default`, both in `init()`, so the set is complete before any `start()` runs. The boot restore skips a stored row under a name in that set, and the metadata door's code-datasource check reads the same set.
14+
15+
**BREAKING — what moves for consumers.**
16+
17+
- 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'`.
18+
- 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`. 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.
20+
- The skipped row is kept, and the boot logs one warning naming it.
21+
22+
**Remedy.**
23+
24+
- To change a code-defined datasource, change its code definition and redeploy: its `*.datasource.ts` source, or the host's database configuration for `default`.
25+
- A row the boot warning names is removable, and removing it is the repair: `DELETE /api/v1/meta/datasource/:name` answers 200 and deletes it.
26+
27+
**Unchanged.** A runtime datasource with no code twin restores, saves and deletes through both doors as before. A host that composes neither `AppPlugin` nor `DefaultDatasourcePlugin` registers no set, and its stored rows restore as before. While a stored row exists under a code-defined name, `GET /api/v1/meta/datasource/:name` still serves that row (the metadata door reads its stored overlay first); after the `DELETE` above it serves the code definition, in the same boot.
28+
29+
<!-- adr-0087: not-required (no-migration-prescription) a boot-restore verdict and a metadata-door verdict on datasources the host registers from code, which the published contract already calls read-only: no authorable key, spelling, export or stored shape moves, and no stored row is rewritten, converted or dropped. A row an earlier runtime write left under a code-defined name stays in sys_metadata and is removed by the operator with the metadata door's own DELETE; which stored edit an operator meant to keep is not something a ledger entry can rewrite. The other categories are closed on facts: every bumped package publishes (not unpublished); no ADR-0087 id covers this restore or this door (not already-registered); and the change is a verdict, not a declaration (not runtime-interface-only or type-surface-only). -->
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
The datasource read redaction resolves a driver's identity the way its sibling helper does. The per-driver half of `redactableConfigKeys` now looks a driver up through `resolveDriverId`, the resolver `passthroughSecretPaths` and the write door's contract lookup already use. So every spelling the write door accepts as a builtin driver is redacted as that driver.
6+
7+
Clause-②: no
8+
9+
- The still-writable credential key is withheld on the datasource admin read (`GET /api/v1/datasources/:name`) and on the metadata read under every accepted spelling of its driver, and `redactedConfigKeys` names it.
10+
- A crafted driver id that made the read throw now answers as a driver the platform ships no contract for: the canonical credential spellings and the former aliases are withheld, and the read succeeds.
11+
- `restoreRedactedConfig` and the credential migration read the same list, so an untouched Save still restores the stored value under every accepted spelling, and the migration names the still-writable key as residue there too.
12+
- Unchanged: what the write door accepts, every export and its type, and the answer for a canonical spelling.

‎docs/qa/platform-checklist/areas/studio-authoring.json‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1451,6 +1451,103 @@
14511451
"history": [
14521452
{ "revision": 1, "date": "2026-09-30", "change": "new — the 清单项待写 row of docs/NORTH-STAR.md beside platform-core.docs-audience-gate and platform-core.docs-portal-render; drafted with objectui#10188 and corrected by the at-tier review of objectui PR 11258 (the UI-mirror clause scoped to entries naming an installed doc or book; no signed-out persona — /docs sits behind ProtectedRoute)", "ref": "#20939" }
14531453
]
1454+
},
1455+
{
1456+
"id": "studio-authoring.picklist-bound-select",
1457+
"title": "A picklist authored in a package is used by two objects' select fields from the designer: both forms offer its options, the bound field refuses inline options, and a GET → edit → PUT of an object with a bound field saves",
1458+
"since": "v17.7",
1459+
"status": "active",
1460+
"revision": 1,
1461+
"priority": "P2",
1462+
"surface": "mixed",
1463+
"personas": [
1464+
"seeded admin (authors the objects in Studio; the picklist is authored in a package)"
1465+
],
1466+
"fixtures": {
1467+
"app": "showcase",
1468+
"requires": [
1469+
"a package that declares a picklist and one picklistExtensions entry adding one option to it; the stock showcase declares none, and the kind is package-owned (allowRuntimeCreate false), so add both in the run's own checkout, uncommitted: inside the defineStack call of examples/app-showcase/objectstack.config.ts add `picklists: [definePicklist({ name: NAME, label: LABEL, options: [two or more { label, value }] })]` (definePicklist is exported from '@objectstack/spec') and `picklistExtensions: [{ extend: NAME, options: [{ label, value }] }]`, whose one value the list does not already carry (a repeated value is refused at load with INVALID_METADATA). The extension may sit in the same defineStack as the list; it merges the same way as one from another package. Boot with `--compile` so the artifact carries the edit (os dev otherwise serves an existing dist/objectstack.json), and revert the file after the run",
1470+
"a writable project package with two runtime objects, each with a sharingModel and one select field carrying inline options; one of them published with a field already bound (picklist: NAME, no options)",
1471+
"the console under test carries objectui#10202's build (objectui PR 11669, merged as objectui c3623eb1). While .objectui-sha predates that merge (it does at objectstack 80f9f7e6, pin 0abd4f9f), flow it back with `pnpm objectui:refresh` from a ../objectui at or after the merge, per the multi-repo rule, and name the console revision in the run record"
1472+
]
1473+
},
1474+
"steps": [
1475+
"boot the showcase isolated with the console under test; sign in as the seeded admin",
1476+
"GET /api/v1/meta/picklist and GET /api/v1/meta/picklist/NAME — the list is served with name, label, its own options and _packageId",
1477+
"GET /api/v1/meta/object/OBJ_A/layers for the object whose field is already bound — record the bound field as served (picklist + resolved options, the extension's option included)",
1478+
"metadata admin (Setup → Metadata → Object → OBJ_A): select an UNRELATED field, rename its label, let the autosave run; record the PUT body and status",
1479+
"select the bound field: record what the select-field editor shows (Options from = the picklist; the values read-only; no inline options editor)",
1480+
"open OBJ_B, select its inline select field, open 'Options from', choose the picklist; record the PUT body and status",
1481+
"Studio → the package → Data → OBJ_A: add a field and let the autosave run; record the PUT body and status",
1482+
"publish both objects; open the new-record form of each and open the bound select",
1483+
"Setup → Metadata → Picklist → NAME: record what the page shows and offers"
1484+
],
1485+
"acceptance": [
1486+
{
1487+
"clause": "the metadata-admin edit of an unrelated field saves: PUT 200, and no picklist-bound field in the body carries options; every other field (an inline select included) is sent byte-identical",
1488+
"oracle": "api",
1489+
"verify": "the captured PUT body and status",
1490+
"evidence": "the PUT body"
1491+
},
1492+
{
1493+
"clause": "the designer shows no live 'picklist and options cannot both be declared' issue on an object loaded with a served bound field",
1494+
"oracle": "dom",
1495+
"verify": "page text after load",
1496+
"evidence": "screenshot"
1497+
},
1498+
{
1499+
"clause": "the select-field editor offers the served picklists by name",
1500+
"oracle": "dom",
1501+
"verify": "picker options vs GET /meta/picklist",
1502+
"evidence": "picker screenshot"
1503+
},
1504+
{
1505+
"clause": "choosing a picklist PUTs the field with picklist and WITHOUT options (200)",
1506+
"oracle": "api",
1507+
"verify": "the captured PUT body and status",
1508+
"evidence": "the PUT body"
1509+
},
1510+
{
1511+
"clause": "a bound field refuses inline options: no inline options editor (no 'Add value'), the list's values shown read-only with the reason",
1512+
"oracle": "dom",
1513+
"verify": "the field inspector",
1514+
"evidence": "screenshot"
1515+
},
1516+
{
1517+
"clause": "the Studio data page's autosave of an object with a bound field is accepted (200), the bound field sent without options",
1518+
"oracle": "api",
1519+
"verify": "the captured PUT",
1520+
"evidence": "the PUT body"
1521+
},
1522+
{
1523+
"clause": "after publish, both objects' new-record forms offer the picklist's options, the extension's option included",
1524+
"oracle": "dom",
1525+
"verify": "open each bound select",
1526+
"evidence": "the two option lists"
1527+
},
1528+
{
1529+
"clause": "the picklist page shows label, name, owning package, each option with its label and value, and each extension under its declaring package — and offers no create, edit or delete (no New on the list; every control on the detail page disabled or read-only; no write request)",
1530+
"oracle": "dom",
1531+
"verify": "list + detail page, network log",
1532+
"evidence": "screenshots + the empty write log"
1533+
}
1534+
],
1535+
"negative": [
1536+
"any PUT of an object with a bound field answered 422 INVALID_METADATA at fields.FIELD.options — FAIL (the served-options producer obligation)",
1537+
"an inline options editor, or a free-text name box, offered on a bound field — FAIL",
1538+
"a New / Save / Delete on the picklist list or detail page — FAIL (the kind is package-owned, allowRuntimeCreate false)",
1539+
"the picklist page saying 'no package extends this picklist' when the package read failed — FAIL"
1540+
],
1541+
"traps": ["stale-console-bundle", "cache-staleness"],
1542+
"source": [
1543+
"objectui#10202 ruling 6006062645 (A′): (a) use picklist, (b) served options dropped before the PUT, (c) read-only page, (d) this item",
1544+
"objectstack#18164; producer obligation 5925784898 on objectui#10202; server pin packages/objectql/src/protocol-picklist-served-roundtrip.test.ts",
1545+
"packages/spec/src/data/picklist.zod.ts#PicklistSchema, packages/spec/src/data/picklist.zod.ts#PicklistExtensionSchema, packages/spec/src/data/picklist.zod.ts#PicklistServedFieldSchema; packages/spec/src/data/field.zod.ts#FieldSchema.picklist refinement",
1546+
"the fixture's spelling: packages/spec/src/data/picklist.zod.ts#definePicklist, the defineStack keys packages/spec/src/stack.zod.ts#picklists and packages/spec/src/stack.zod.ts#picklistExtensions; an extension in the list's own package merges (packages/objectql/src/engine-picklist.test.ts, the nested-plugin case)"
1547+
],
1548+
"history": [
1549+
{ "revision": 1, "date": "2026-10-06", "change": "new — drafted with objectui#10202 against the measured run of its PR; filed by the seat. Authored from that draft with these changes: since v17.7, because 17.6.0 shipped before objectui PR 11669 merged; the picker clause split in two, one oracle each; the picklist fixture spelled with its export, its stack keys and the reload it needs; the console fixture naming the merge it must carry; the spec citations written as anchors the gate resolves", "ref": "#21957" }
1550+
]
14541551
}
14551552
]
14561553
}

‎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
}

0 commit comments

Comments
 (0)