Repository navigation
fix(service-datasource): a destructive re-import's refusal names the remedies that work from the import route - #21874
Conversation
The door pin follows each remedy a destructive re-import's refusal names. On this commit the refusal still prescribes `?force=true` on the import route, which reads none, so the pin is red by design. Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
…remedies that exist The external-table import saves through `saveMetaItem`, whose destructive-change refusal ended "re-submit with ?force=true". The import route reads no `force`, so following it repeated the refusal. The import now states `writeFace: 'external-import'` (a new member of the server-stated `writeFace` enum), and `destructiveChangeRemedy` renders that face in the existing faces' grammar: name the door, deny the mechanism, then prescribe. The remedies are importing the table under a new `name`, or saving the changed definition through `PUT /api/v1/meta/object/:name?force=true`. The refusal itself stays, and the 422 clause keeps its full-prose default on this face because the import route relays the message alone. Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
… is the shrunk definition Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 139 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 380422546466f83b7f3df84272f442bb6b38364d && git checkout 380422546466f83b7f3df84272f442bb6b38364d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin aead296874176b1bcf2f2b8a916223b11cf30c4b 5e5dedaa1356d89a40d25aa7618687b165e6f031 && git checkout -B drift-repro aead296874176b1bcf2f2b8a916223b11cf30c4b && git merge --no-ff 5e5dedaa1356d89a40d25aa7618687b165e6f031
node scripts/docs-audit/affected-docs.mjs --json aead296874176b1bcf2f2b8a916223b11cf30c4b
|
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…vice when it is used, so validate and the boot gate compare every federated object (objectstack-ai#21887) Fixes objectstack-ai#21876 Clause-②: no (narrowing) ## What this changes `ExternalDatasourceServicePlugin.init()` read the `metadata` service once and kept the answer. `objectstack start` composes no metadata plugin. Its `metadata` service is the kernel's in-memory fallback, which the kernel pre-injects after every plugin's `init()`, just before the start phase. The `start` log shows the order: `Service 'external-datasource' registered`, then `Service 'metadata' registered`, then `Phase 2: Start plugins`. So on `start`, every reader of the kept value saw no service for the life of the process. The plugin now looks up the `metadata` service when each reader runs. It uses a resolver function called at each use, the same pattern `metadataSaveDoor` already follows in this `init()` (AGENTS.md, "Startup registry reads", cure 1). That covers `getDatasource`, `getObject`, `listObjects`, `getNamespace` and the catalog write. The catalog write was a conditional spread, so `init()` decided whether the `persistCatalog` slot existed at all (H2). It is now a getter, as `persistObject` already is. With no metadata service at all, each reader returns the same fallback it always did. Not changed: what validation judges, what each `onMismatch` value does, and the boot gate's code (`packages/runtime`). The import's save call (`persistObject`, landed with objectstack-ai#21874) is not changed either. Objects are still read from the metadata service's copy. objectstack-ai#21842's PR objectstack-ai#21875 moves them to the engine registry. **The object reads, called out.** `getObject` and `listObjects` read the same kept value, so replacing it with a resolver also changes how those two readers are spelled: each calls the resolver instead of using the constant. They read the same members with the same fallback as before. Only the moment the service is looked up moves. Without this, the card's "Done when" cannot hold: on `start`, validate would still list no objects. These are the lines PR objectstack-ai#21875 replaces. When objectstack-ai#21875 merges `main`, its `getObject` and `listObjects` should replace these, and it should keep this PR's `getDatasource` line. Files: `packages/services/service-datasource/src/plugin.ts`, one new unit file beside it, one new dogfood file, and a `minor` changeset with the `!` banner. No `packages/spec` edit, no new export, and no edit to an existing `packages/qa` file. ## Measured on the real composition (showcase, `objectstack start` and `objectstack dev`) Remote fixture for the probe: `customers.lifetime_value` is TEXT on the remote, while `showcase_ext_customer` declares a currency field. That is a `type_mismatch` at severity `error`. A missing column would not survive the boot, because the showcase's own `onEnable` adds missing columns. A column type it leaves alone. The base build of `service-datasource` is `plugin.ts` at `2e780467`. The branch build is `plugin.ts` at `10dd86129b`, which is this head's `plugin.ts` minus objectstack-ai#21874's one-line `writeFace` change from the merge. | `objectstack start` | base | branch | | --- | --- | --- | | boot gate, drift, showcase's `onMismatch: 'warn'` | `all federated objects match their remote schema {"objects":0}` | `external schema drift` (warn) on `showcase_ext_customer`: `type_mismatch` `lifetime_value`, expected `currency`, actual `text` | | boot gate, no drift | (`objects: 0` whatever the remote holds) | `all federated objects match their remote schema {"objects":2}` | | boot gate, drift, datasource set to `onMismatch: 'fail'` for the probe (restored after; blob equals HEAD) | boots, `objects: 0` | **refuses to boot**: "Object 'showcase_ext_customer' does not match its remote table on datasource 'showcase_external': type_mismatch: customers.lifetime_value (expected currency, actual text)" | | `POST /datasources/showcase_external/external/validate` | `{ ok: true, results: [] }` | 2 rows: `showcase_ext_customer` `ok: false` with the `type_mismatch`, `showcase_ext_order` `ok: true` | | `POST …/external/refresh-catalog`, then `GET /meta/external_catalog/showcase_external_catalog` | snapshot answered; the read answers `RESOURCE_NOT_FOUND` (never stored) | snapshot answered; the read returns the stored record | | `GET …/external/tables` | 2 tables | same (the showcase sets no `allowedSchemas`) | | `POST …/tables/customers/draft` | `customers`, with the `TODO(namespace)` note | same (see the namespace note below) | | import `orders` as `probe_ext_orders_21876`, then as `showcase_probe_orders_21876` | 201, 201 | 201, 201 (same note) | | validate after both imports | `results: []` | the 2 code-defined rows only; the imported objects are not listed until restart (H5, objectstack-ai#21842's) | **`objectstack dev`, the control (H4):** base and branch gave byte-identical answers from validate, validate-after-import, tables, the catalog read and both imports, after stripping `snapshotAt`. Both boot gates log the same drift warning. `dev` answers exactly what the branch now answers on `start`. **The namespace note.** The showcase's code-defined datasource carries no `_packageId` on either composition, `dev` included, so its namespace never resolves there. The namespace half (the draft's prefix, the import's name check) is pinned in the unit file, with a datasource that carries package provenance. On `start` it was blind on every deployment, and it now answers as on `dev`. **Introspection (`data` service).** It is still read at `init()`. On `start` it is already registered by then: base `start` answered tables, draft and refresh. See Acceptance notes. ## Tests - New `packages/services/service-datasource/src/__tests__/external-metadata-read-at-use.test.ts`: 10 cases, relative import, so it measures `src/`. The `metadata` service is registered AFTER `init()`, as on `start`. Under that ordering: validate compares each federated object and reports the drifted column, and the boot gate's sweep (`validateAll`) lists every federated object. The draft takes the namespace of the datasource's package. An import's explicit name that breaks the prefix is refused, asserting `code: 'EXTERNAL_IMPORT_ERROR'` and `status: 400`, and the save door is never called. The refreshed catalog is persisted. `allowedSchemas` is honoured. The service is looked up again at each use, never remembered. Control: a service registered BEFORE `init()` (the `dev` ordering) gives the same answers. With no metadata service, every reader keeps its fallback. - New `packages/qa/dogfood/test/external-validate-start-ordering.dogfood.test.ts`: 3 cases, booted showcase. The remote's `customers.email` is renamed away after provisioning; the harness does not run `onEnable` at boot, so the drift survives. A precondition asserts that the harness's `metadata` service is the kernel's in-memory fallback, the `start` shape. Validate compares both federated objects and reports `missing_column email`. `validateAll()` lists both. - **Red at base** (`ce28b73809`: the two test files on the base `plugin.ts`): unit 7 failed, 3 passed, for example `expected [] to deeply equal [ 'wh_customer', 'wh_order' ]` and `expected "vi.fn()" to be called 1 times, but got 0 times`. Dogfood 2 failed, 1 passed (the precondition), `expected [] to deeply equal [ 'showcase_ext_customer', … ]`. - **Ablation** at `10dd86129b` (fix committed first), through `scripts/ablation-replace.mjs` in WRAP mode. The resolver line was replaced by a capture taken at `init()` and a resolver returning that capture (anchor hits 1, blob `3f9f1968` to `2dbc3ade`, marker `ABLATION-21876` count 1 on disk, anchor count 0). Unit: 7 of 10 failed, the same 7 as at base. Dogfood: 2 of 3 failed. Restore: blob after restore `3f9f1968` equals HEAD, `git diff HEAD` is empty and `git status` is clean. No `dist/` is on either path: the unit file imports `src/` relatively, and the dogfood config aliases `@objectstack/service-datasource` to `src/`. - **At this head `c6f237478c`** (`origin/main` `e864db56df` merged, which carries objectstack-ai#21874): closure build (`@objectstack/dogfood` dependencies plus `service-datasource`) 63 of 63 tasks. `@objectstack/service-datasource`: 38 files, 728 tests passed. `tsc --noEmit` passes, and `--listFiles` includes the new unit file (count 1). Dogfood: the new file plus `external-import-saves-like-meta` and `external-import-destructive-remedy` (the other two files that mount this plugin), 3 files, 10 tests passed. Dogfood `tsc --noEmit` passes, with the new file in `--listFiles` (count 1). **The gap, named.** The dogfood file boots the verify harness, not the `objectstack start` CLI. The harness composes no metadata plugin, so its `metadata` service is the same kernel fallback, injected at the same moment as on `start`. The precondition case holds that. The harness does not mount the boot gate. Mounting it from this file would need `@objectstack/runtime` as a value import, which the dogfood package does not alias. That would mean a new pair in `check:test-source-alias`'s shrink-only ledger or an edit to the dogfood vitest config, and both are outside this dispatch. So the file pins the gate's input (`validateAll()`), and the gate's own verdict on `start` is the probe table above. ## Gates At `c6f237478c`: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 67 commands: the dispatch's 59 plus 8 that the changeset brings. All 67 were run and all exit 0. `--ran` reconciles 67 run, 0 NOT-MEASURED, 0 UNRUN. `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (exit 3), because 8 unrelated packages had no `dist/`. `check:type-check-debt`'s re-measure later in the same battery built them, and the gate was re-run on the same head to exit 0. Also run: `pnpm check:startup-registry-verdict` exit 0 ("43 startup/open-registry seam(s) … none recording a verdict the boot can contradict"). Narrowed eslint (`--no-inline-config`, `--format json`) on the 3 touched `.ts` files: 3 files, 0 errors, 0 warnings. The changeset `.md` is outside eslint's population: eslint answers "File ignored because no matching configuration was supplied". `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict on any untouched file. ## Docs No `content/docs` sentence is made false. `data-modeling/external-datasources.mdx` promises a federated datasource is "validated at boot" and that a mismatch "fails boot". That was false on `start` and is now true. ## Acceptance notes - **`engine` (the `data` service) is still read at `init()`.** It is not part of this card. On both `start` and `dev`, `ObjectQLPlugin` registers it in its own `init()`, which runs before this plugin. Base `start` introspected (tables, draft, refresh answered), so nothing was measured wrong. Carrier: none. - **The showcase's code-defined datasources carry no package provenance**, so the federation draft and import never resolve a namespace for them, on `dev` as on `start`. It is reported to the seat with its evidence, not filed from here. - **A federated object saved at runtime** is still listed by the validate door only after the next restart. That is objectstack-ai#21842's, fixed by PR objectstack-ai#21875 after this lands. --- _Generated by [Claude Code](https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ved at runtime, with no restart (objectstack-ai#21875) Fixes objectstack-ai#21842 Clause-②: no ## What this changes `ExternalDatasourceServicePlugin` wired the federation service's `listObjects` and `getObject` to the `metadata` service. That service holds a copy of the engine's object registry, taken once at boot by `ObjectQLPlugin`'s startup bridge. `PUT /api/v1/meta/object/:name`, and the import that saves through it since objectstack-ai#21837, write `sys_metadata` and the engine registry (`applyRegistryWriteThrough`), never that copy. So `POST /api/v1/datasources/:name/external/validate` did not see a runtime-saved object until the next restart. Both object reads now come from the engine's object registry on the `objectql` service (`IObjectQLEngine.registry`, its `getAllObjects` and `getObject`). The registry is looked up when validation runs, never at `init()` (AGENTS.md, "Startup registry reads"), the same way the import's save door is. There is no second copy and no hand refresh. The service's comparison is untouched, and datasource definitions, the package namespace and the catalog write still go through the `metadata` service exactly as before (see **Decision needed** below). Files: `packages/services/service-datasource/src/plugin.ts`, one new unit file beside it, one new dogfood file, and a `patch` changeset. No `packages/spec` edit, no new export, no `content/docs` sentence changed (none describes where the validate door reads its objects). ## Measured on the real composition (`objectstack dev`, showcase) | step | base `2df3d13d` | this branch | |:--|:--|:--| | validate `showcase_external`, before any save | 2 rows, `showcase_ext_customer` and `showcase_ext_order`, both ok | same | | `PUT /meta/object/dg21842_saved` (bound to remote `customers`, fields `name`, `email`, `ghost_col`), then validate | 200, then still 2 rows | 200, then 3 rows; `dg21842_saved` is `ok: false` with one diff, `missing_column ghost_col` | | import remote `orders` as `dg21842_imported`, then validate | 201, then still 2 rows | 201, then 4 rows; `dg21842_imported` ok | | re-save `dg21842_saved` without `ghost_col`, then validate | not run | `dg21842_saved` ok, no diffs | | restart on the same database, then validate | 4 rows | the same 4 rows, the same verdicts | The injected anchors (`organization_id`, the audit pair, `owner_id`, `owning_business_unit_id`) are not reported on the runtime-saved object: the registry's copy carries them, and the objectstack-ai#21837 skip handles them. **H2 (does `getObject` already see the save?): falsified.** With only `listObjects` moved (an intermediate build), the saved and imported rows answered `unreachable` with `Object 'dg21842_saved' not found.` and `Object 'dg21842_imported' not found.`. The metadata service's `getObject` reads the same boot copy, so both reads moved. **H3 (the boot gate), on `objectstack dev`: holds.** Base and branch give the same boot gate output. On a fresh boot both log "all federated objects match their remote schema" with `objects: 2`. Booted on copies of one database that holds the stored objects, both log the same single drift warning (`dg21842_saved`, `missing_column ghost_col`). The bridge logs 109 of 109 registry objects copied, so at boot the copy and the registry hold the same objects. ## Decision needed: `objectstack start` Measured on `objectstack start` (production mode), showcase: - **Base:** the boot gate logs `objects: 0`, and validate answers `{ ok: true, results: [] }`. The code-defined federated objects are not listed at all. The plugin reads the `metadata` service at `init()`, and on `start` that service is the kernel's in-memory fallback, registered just before the start phase and after this plugin's `init()` (the log shows `Service 'external-datasource' registered`, then `Service 'metadata' registered`, then `Phase 2: Start plugins`). So the plugin holds no metadata service for its whole life. - **This branch:** objects are listed now, because the registry is read when validation runs. The datasource definition is still read from the service captured at `init()`, which is absent, so `validateObject` takes its "not federated" branch. Every row answers `ok: true` with no diffs, and nothing is compared. The boot gate logs `objects: 2`. A saved object with `ghost_col` answered `ok: true`. The boot gate's pass or abort does not move on either composition, but on `start` this branch turns "no rows" into rows that claim `ok` without a comparison. That is close to this dispatch's stop line, so the landing is the seat's call: - **A.** Land as is, and the init-time `metadata` capture becomes its own card. Cost: until that card lands, `start` answers per-object `ok: true` that it never compared. - **B.** Widen this PR: read the `metadata` service (datasource, namespace, catalog write) when it is used. Cost: on `start` the boot gate starts judging, so a deployment with drift under the default `onMismatch: 'fail'` refuses to boot where it started before. That is a boot-behaviour change outside this card. - **C.** The init-time capture becomes its own card and lands first; this PR then lands unchanged. Cost: this PR waits. Recommendation: **C**. Each landing stays honest on every composition. The boot-behaviour change gets its own changeset and its own decision, and this PR's diff and changeset stay as they are. ## Tests - New `packages/services/service-datasource/src/__tests__/external-validate-reads-live-registry.test.ts` (4 cases, relative import, measures `src/`). The fakes model the measured mechanism: `metadata` holds the boot copy, the `objectql` registry is live, and a save writes only the registry. A runtime-saved object is listed and judged by `validateDatasource` and by `validateAll`, and the code-defined one is still listed. A re-save is judged on what was saved. The registry is resolved when validation runs. The boot copy's object reads are never called. - New `packages/qa/dogfood/test/external-validate-sees-runtime-save.dogfood.test.ts` (3 cases, booted showcase): validate lists the code-defined objects, then also an object saved through `PUT /meta/object/:name`, then also an imported one. Under this harness the federation service finds no `metadata` service at `init()` (the same cause as `start`), so this file pins the listing only. The verdict half is the unit file's, and was measured on `objectstack dev` above. - At `b44c1c87bb` (after merging `origin/main`): `@objectstack/service-datasource` 38 files, 721 tests pass. `tsc --noEmit` passes, and `--listFiles` includes the new test file. Dogfood: the new file and `external-import-saves-like-meta.dogfood.test.ts`, 2 files, 6 tests pass. - Ablation, with the fix committed (`94e3056d62`), through `scripts/ablation-replace.mjs`: both readers were put back to the boot-copy reads (anchor hit 1, blob `5fd02852` to `cad2f3a6`). Unit: 4 of 4 failed, for example `expected [ 'code_cust' ] to deeply equal [ 'code_cust', 'saved_cust' ]`. Dogfood: 3 of 3 failed, `expected [] to deeply equal [ 'showcase_ext_customer', … ]`. Under the harness all three read `[]`, because there is no metadata service at `init()` there; the unit file is what separates "boot copy" from "live registry". Restore: blob equals HEAD (`5fd02852`), and `git diff HEAD` is empty. No `dist/` is on either path: the unit file imports `src/` relatively, and the dogfood config aliases `@objectstack/service-datasource` to `src/`. ## Gates At `c68487ae6c` (this head; it differs from `b44c1c87bb` by the changeset text only): `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 67 commands. All 67 were run and all exit 0, and `--ran` reconciles 67 run, 0 NOT-MEASURED, 0 UNRUN. That is the dispatch's 59 plus 8 the changeset brings (`check-adr-0087-registration` and `check-empty-changeset`, each with `--self-test`, `release-rehearsal-clone --self-test`, `release-pending-publish --self-test`, `check:objectui-changeset`, `check:pm-changeset-deadline-census`). `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (8 unrelated packages had no `dist/`). Those packages were built, and it was re-run to exit 0. Narrowed eslint (`--no-inline-config`, `--format json`) on the 3 touched `.ts` files: 3 files, 0 errors, 0 warnings. The config enables no type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict on any untouched file. ## Acceptance notes - The boot gate's completeness probe (`announceAllClear`, `packages/runtime`) still asks `metadata.listDiagnosed('object')`, a list the sweep no longer reads. It only shapes the all-clear sentence, never a verdict. Carrier: none. - `persistCatalog` and `getNamespace` read the same init-time `metadata` capture as the datasource read above. This was already noted on objectstack-ai#21837. - `origin/main` was merged at `b44c1c87bb`. objectstack-ai#21841 has not landed, and nothing on `main` since the base touches `service-datasource`. ## Seat's append: patch round 1 (written by `domain:services` seat 1 from the dev's report `5999337391`; the dev does not edit this body) ### Patch round 1 (head `9489265ae0`): `main` merged after objectstack-ai#21887, and `objectstack start` measured again **The merge.** `origin/main` `607463d736` was merged as `80dfcc30b7`. It carries objectstack-ai#21887 (merge `bc7747cb`) and objectstack-ai#21874. There was one conflict, in `plugin.ts`'s object readers. Resolution: `getObject` and `listObjects` read the engine registry (`objectRegistry()`). `getDatasource`, `getNamespace` and the `persistCatalog` getter keep objectstack-ai#21887's resolver, `metadata()`. `MetadataServiceLike` keeps only `get` and `register`. One sentence of the resolver's docblock said object reads went through it. It now says objects come from the registry. **objectstack-ai#21887's unit file moved with it (`9489265ae0`).** At the merge commit, its 4 object-listing cases went red (`expected [] to deeply equal [ 'wh_customer', 'wh_order' ]`), because its harness served objects only from the metadata fake. The harness now also serves them from an `objectql` registry fake. The re-ask case tells the two services apart by the datasource definition: a `managed` replacement compares nothing. The no-metadata case also runs with no registry. Every other case is unchanged. **The door pin now asserts the verdict.** Under the harness the metadata service is read when it is used (objectstack-ai#21887), so the runtime-saved object's row is a real comparison. It answers `ok: false` with one diff, `missing_column loyalty_tier`. The imported row answers `ok` with no diffs. **`objectstack start` (showcase): this branch against `origin/main` `607463d736` as the control, with the same steps.** | step | `origin/main` | this branch | |:--|:--|:--| | fresh boot, boot gate | `all federated objects match their remote schema {"objects":2}` | same | | `PUT /meta/object/dg21842_saved` (declares `loyalty_tier`, which the remote `customers` table lacks), then validate | 2 rows; the saved object is not listed | 3 rows; `dg21842_saved` is `ok: false` with `missing_column loyalty_tier` | | import `orders` as `dg21842_imported`, then validate | 2 rows | 4 rows; `dg21842_imported` is `ok: true` with no diffs | | restart on the same home, boot gate | one `external schema drift` warn: `dg21842_saved`, `missing_column loyalty_tier` | same | The question in this PR's **Decision needed** section is closed: on `start`, validate judges a runtime-saved object, and no row answers `ok` without a comparison. The seat decided option C (`5995103062`), and objectstack-ai#21876 landed first as PR objectstack-ai#21887. The boot gate output matches objectstack-ai#21887's on both the fresh boot and the restart. **Measured at `9489265ae0`:** - `@objectstack/service-datasource`: 39 files, 732 tests pass. Typecheck passes, and `--listFiles` includes both unit files. - Dogfood typecheck passes. The three door pins (this one, objectstack-ai#21887's `external-validate-start-ordering` and objectstack-ai#21788's `external-import-saves-like-meta`): 3 files, 9 tests pass. - Ablation, both object readers put back to `metadata()` through `scripts/ablation-replace.mjs`: the unit file went 4 of 4 red. The door pin went 2 of 3 red: the saved and imported cases failed, and the code-defined listing stayed green because the metadata service now answers it. The restore proved the blob equal to HEAD. - Gates: 67 derived, all exit 0. `--ran`: 67 run, 0 NOT-MEASURED, 0 UNRUN. --- _Generated by [Claude Code](https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21841
Clause-②: yes (widening)
What this changes
"Import as Object" (
POST /api/v1/datasources/:name/external/tables/:remote/import) saves through the metadata door'ssaveMetaItem. A re-import that would drop a field the stored object still carries is refused by that save's destructive-change gate, and the import route relays the refusal as400 EXTERNAL_IMPORT_ERROR. The refusal endedre-submit with ?force=true to proceed.The import route reads noforce, so following that sentence returned the identical refusal.persistObject(packages/services/service-datasource/src/plugin.ts) now sendswriteFace: 'external-import'on its save. The server states the face. The import's options cannot carry a face or aforceinto the save (pinned).destructiveChangeRemedy(packages/metadata-protocol/src/protocol.ts) gains a case for that face, in the existing faces' grammar: name the door, deny the mechanism, then prescribe. The served sentence is now:this import cannot be forced: the external-table import route accepts no force. Import the table under a new name, or save the changed definition of 'NAME' through PUT /api/v1/meta/object/NAME?force=true, which accepts the destructive change on purpose.Those are the words of the import's earlier changeset.SaveMetaItemRequestSchema.writeFace(packages/spec/src/api/protocol.zod.ts) and the localMetadataWriteFacegain the one member. The reference row is regenerated.400 EXTERNAL_IMPORT_ERROR, and the stored definition does not move (pinned before and after). The other faces' text is unchanged. On this face the 422 clause keeps its full-prose default, because the import route's envelope carries noissues. That is pinned, and ablated below.Route taken: an import face, not a working
force(four axes)All four axes point the same way, so there is no trade-off to hand up.
.objectui-sha0abd4f9f(and at its mainf1a177c), there are 0 calls of that route. The console's import dialog (importObjectDraft,app-shell/src/views/metadata-admin/external/api.ts) callsPOST …/draftand thenPUT /api/v1/meta/object/:name. Control: the draft route and thePUTboth hit in that file. In this repo at2df3d13d, there are 0 callers ofdatasources.external.import(outsidepackages/client. That SDK method sends its options as a JSON body and has no query-string channel at all. The door the face names already serves this case, from raw HTTP and from the SDK (meta.saveItem(…, { force: true })). The door pin measures it at200. Aforceon the import route would have zero pull.forcewas threaded only where a twin door already read it. The import route has no such twin. Acknowledging a destructive change stays on one door, where the caller authors the whole definition. A workingforcehere would also add a query parameter to a route with no closed query set, and aforcethroughIExternalDatasourceService.importObject's published contract.forceon the import would let a body that holds only table options overwrite an existing object's fields under a collidingname, including an object no import created. The face keeps the stricter contract: the import never overwrites destructively.caseand one call-site argument. Aforcewould be a new accepted parameter on a REST route plus a service-contract change, with no caller.Measured
28bf4c43, run on basesrcand basemetadata-protocoldist/):Tests 3 failed | 1 passed (4). Served message:… Field 'region' removed — existing data in this column will become inaccessible. — re-submit with ?force=true to proceed.The case that follows that prescription got pastexpect(followed).toBe(message): the import with?force=trueanswered the byte-identical refusal, and the stored definition was unchanged.5e5dedaa): the door pintest/external-import-destructive-remedy.dogfood.test.tspassesTests 4 passed (4). Run beside fix(service-datasource)!: Import as Object saves through the metadata door's save #21837's pin,Test Files 2 passed / Tests 7 passed. Each remedy is followed: the import under a newnameanswers201and serves 3 rows, andPUT /api/v1/meta/object/NAME?force=trueanswers200and dropsregion. Control: the samePUTwithoutforceanswers409 DESTRUCTIVE_CHANGEwith the stored definition unchanged.scripts/ablation-replace.mjs, the import stops stating its face;plugin.tsblobb0385ebdto2c89cd8f, anchor x1 to x0). Direction predicted before running. Door pinTests 3 failed | 1 passed (4), with the served text back tore-submit with ?force=true to proceed.. Seam pinTests 2 failed | 3 passed (5). The dogfood alias resolves@objectstack/service-datasourcetosrc, so no rebuild. Restore: blobb0385ebdequals HEAD,git diff HEADempty.case 'external-import':unmatched inprotocol.ts; blob4e881bafto66cce7ad). Face inventoryTests 2 failed | 24 passed (26), exactly the "never re-submit" and "names the door" cases. Restore proven, blob equals HEAD.4e881baftod1f48892). Face inventoryTests 1 failed | 25 passed (26), exactly the[COUPLING]case. Restore proven, blob equals HEAD.'external-importt'pasted intoplugin.ts):service-datasourcetypecheckTS2820 … not assignable to type '"package-duplicate" | "meta-envelope" | "meta-dispatch" | "external-import" | undefined'. That union is read from the rebuilt spec.d.ts. Restore proven, blob equals HEAD.Tests (all at HEAD
5e5dedaa)@objectstack/metadata-protocol:Test Files 214 passed | 3 skipped (217),Tests 27751 passed | 19 skipped; typecheck exit 0.@objectstack/service-datasource:Test Files 37 passed (37),Tests 718 passed (718); typecheck exit 0.@objectstack/spec:Test Files 668 passed (668),Tests 19289 passed | 1 todo; typecheck exit 0;check:generated:All 15 generated artifacts are up to dateaftergen:docs.@objectstack/dogfood: typecheck exit 0.tsc --listFilescount 1 for each of the three).dispatch-gates --commandsderived 114 commands at this HEAD. All 114 ran, and all exit 0. Two of them first refusedPREREQUISITE NOT MET:check:skill-examplesneededclient-react'sdist/, andcheck:dual-build-cjs-loadsneeded eight packages'dist/. Both were built and re-run to exit 0.dispatch-gates --ran:114 derived famil(ies) accounted for — 114 run, 0 NOT-MEASURED.--no-inline-config --format json) reported 7 files, 0 errors, 0 warnings. eslint's own config ignores the other two changed paths (.md,.mdx: "no matching configuration").eslint.config.mjsenables no type-aware linting, so this diff cannot move a verdict on an untouched file. The whole-repopnpm lintis CI's.Acceptance notes
packages/rest/src/external-datasource-routes.ts, not inexternal-datasource-service.tsas the claim's file surface says. It is unchanged: it already relays the message verbatim.@objectstack/restlists@objectstack/metadata-protocolas a devDependency, so itsdist/bundles a full copy of the protocol,destructiveChangeRemedyincluded. The runningprotocolservice is registered bymetadata-protocol's own plugin, andrestnever states this face, so the bundled copy's new case is unreachable fromrest. Observation only. No carrier.protocol.destructive-409-face-inventory.test.tsstill lists the compound-namePUT /meta/:type/:a/:bas row 2. That route was retired by commit7986d973f. This is pre-existing drift in a test comment and is untouched here. No carrier.PUT /meta/object/:nameand sends noforce, so a console re-import that shrinks an object would get themeta-envelope409, whose?force=truethe dialog does not offer. That is objectui-side, and it was not reproduced here.origin/main. It is 4 commits behind (2df3d13d..9f9510f2), and none of those commits touches any of this PR's 9 paths (git diff --statover them is empty).dispatch-gatesreported one stale gate input (scripts/engine-double-contract.pinned.json), which concerns fake engines, and this diff adds none.Changeset:
.changeset/21841-import-refusal-working-remedy.md.@objectstack/specand@objectstack/metadata-protocolareminor(the widened enum and parameter).@objectstack/service-datasourceispatch(its published exports and types do not move; its import now states a face).Generated by Claude Code