Skip to content

Commit e864db5

Browse files
fix(service-datasource): a destructive re-import's refusal names the remedies that work from the import route (#21874)
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's `saveMetaItem`. 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 as `400 EXTERNAL_IMPORT_ERROR`. The refusal ended `re-submit with ?force=true to proceed.` The import route reads no `force`, so following that sentence returned the identical refusal. - `persistObject` (`packages/services/service-datasource/src/plugin.ts`) now sends `writeFace: 'external-import'` on its save. The server states the face. The import's options cannot carry a face or a `force` into 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 local `MetadataWriteFace` gain the one member. The reference row is regenerated. - The refusal itself stays: still `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 no `issues`. 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. - **Business need (measured).** The external-table import route has no first-party caller that re-imports. In objectui at the pinned `.objectui-sha` `0abd4f9f` (and at its main `f1a177c`), there are 0 calls of that route. The console's import dialog (`importObjectDraft`, `app-shell/src/views/metadata-admin/external/api.ts`) calls `POST …/draft` and then `PUT /api/v1/meta/object/:name`. Control: the draft route and the `PUT` both hit in that file. In this repo at `2df3d13d`, there are 0 callers of `datasources.external.import(` outside `packages/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 at `200`. A `force` on the import route would have zero pull. - **Long-term soundness.** This follows the precedent the class's earlier ruling set. A `force` was 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 working `force` here would also add a query parameter to a route with no closed query set, and a `force` through `IExternalDatasourceService.importObject`'s published contract. - **Preventing AI mistakes.** Both routes make the sentence true. But a `force` on the import would let a body that holds only table options overwrite an existing object's fields under a colliding `name`, including an object no import created. The face keeps the stricter contract: the import never overwrites destructively. - **Startup focus.** The face costs one enum member, one `case` and one call-site argument. A `force` would be a new accepted parameter on a REST route plus a service-contract change, with no caller. ## Measured - **H1 reproduced first** (red pin committed in `28bf4c43`, run on base `src` and base `metadata-protocol` `dist/`): `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 past `expect(followed).toBe(message)`: the import with `?force=true` answered the byte-identical refusal, and the stored definition was unchanged. - **After** (HEAD `5e5dedaa`): the door pin `test/external-import-destructive-remedy.dogfood.test.ts` passes `Tests 4 passed (4)`. Run beside #21837's pin, `Test Files 2 passed / Tests 7 passed`. Each remedy is followed: the import under a new `name` answers `201` and serves 3 rows, and `PUT /api/v1/meta/object/NAME?force=true` answers `200` and drops `region`. Control: the same `PUT` without `force` answers `409 DESTRUCTIVE_CHANGE` with the stored definition unchanged. - **Ablation 1** (`scripts/ablation-replace.mjs`, the import stops stating its face; `plugin.ts` blob `b0385ebd` to `2c89cd8f`, anchor x1 to x0). Direction predicted before running. Door pin `Tests 3 failed | 1 passed (4)`, with the served text back to `re-submit with ?force=true to proceed.`. Seam pin `Tests 2 failed | 3 passed (5)`. The dogfood alias resolves `@objectstack/service-datasource` to `src`, so no rebuild. Restore: blob `b0385ebd` equals HEAD, `git diff HEAD` empty. - **Ablation 2** (`case 'external-import':` unmatched in `protocol.ts`; blob `4e881baf` to `66cce7ad`). Face inventory `Tests 2 failed | 24 passed (26)`, exactly the "never re-submit" and "names the door" cases. Restore proven, blob equals HEAD. - **Ablation 3** (the import face added to the 422 trimming case; blob `4e881baf` to `d1f48892`). Face inventory `Tests 1 failed | 25 passed (26)`, exactly the `[COUPLING]` case. Restore proven, blob equals HEAD. - **Reverse type check** (`'external-importt'` pasted into `plugin.ts`): `service-datasource` typecheck `TS2820 … 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 date` after `gen:docs`. - `@objectstack/dogfood`: typecheck exit 0. - Each edited test file is in its package's typecheck program (`tsc --listFiles` count 1 for each of the three). - Gates: `dispatch-gates --commands` derived 114 commands at this HEAD. All 114 ran, and all exit 0. Two of them first refused `PREREQUISITE NOT MET`: `check:skill-examples` needed `client-react`'s `dist/`, and `check:dual-build-cjs-loads` needed 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`. - Lint, narrowed: eslint over the 7 changed TypeScript files (`--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.mjs` enables no type-aware linting, so this diff cannot move a verdict on an untouched file. The whole-repo `pnpm lint` is CI's. ## Acceptance notes - The import route lives in `packages/rest/src/external-datasource-routes.ts`, not in `external-datasource-service.ts` as the claim's file surface says. It is unchanged: it already relays the message verbatim. - `@objectstack/rest` lists `@objectstack/metadata-protocol` as a devDependency, so its `dist/` bundles a full copy of the protocol, `destructiveChangeRemedy` included. The running `protocol` service is registered by `metadata-protocol`'s own plugin, and `rest` never states this face, so the bundled copy's new case is unreachable from `rest`. Observation only. No carrier. - The face-inventory docblock in `protocol.destructive-409-face-inventory.test.ts` still lists the compound-name `PUT /meta/:type/:a/:b` as row 2. That route was retired by commit `7986d973f`. This is pre-existing drift in a test comment and is untouched here. No carrier. - Not measured: the console's import dialog saves its draft through `PUT /meta/object/:name` and sends no `force`, so a console re-import that shrinks an object would get the `meta-envelope` 409, whose `?force=true` the dialog does not offer. That is objectui-side, and it was not reproduced here. - The branch is not merged with `origin/main`. It is 4 commits behind (`2df3d13d..9f9510f`), and none of those commits touches any of this PR's 9 paths (`git diff --stat` over them is empty). `dispatch-gates` reported 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/spec` and `@objectstack/metadata-protocol` are `minor` (the widened enum and parameter). `@objectstack/service-datasource` is `patch` (its published exports and types do not move; its import now states a face). --- _Generated by [Claude Code](https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5e0b489 commit e864db5

9 files changed

Lines changed: 424 additions & 11 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/metadata-protocol': minor
4+
'@objectstack/service-datasource': patch
5+
---
6+
7+
fix(service-datasource): a destructive re-import's refusal names the remedies that work from the import route, instead of a `?force=true` that route never reads (#21841)
8+
9+
Clause-②: yes (widening)
10+
11+
- **What was wrong.** "Import as Object" (`POST /api/v1/datasources/:name/external/tables/:remote/import`) saves through the metadata door's own `saveMetaItem`. A re-import that would drop or retype a field the stored object still carries is refused by that save's destructive-change gate, and the import route relays the refusal as `400 EXTERNAL_IMPORT_ERROR`. The refusal ended `re-submit with ?force=true to proceed.` The import route reads no `force`, so a caller who did exactly that got the identical refusal back.
12+
- **What the refusal says now.** The import states its own write face, and the refusal ends: this import cannot be forced, because the external-table import route accepts no `force`. Import the table under a new `name`, or save the changed definition through `PUT /api/v1/meta/object/:name?force=true`, which accepts the destructive change on purpose. Both remedies are measured on the showcase: each one answers `201` or `200` where the re-import answered `400`. The same words appear in this package's earlier changeset for the import.
13+
- **What widens.** `SaveMetaItemRequestSchema.writeFace` (`@objectstack/spec`) and `saveMetaItem`'s `writeFace` parameter (`@objectstack/metadata-protocol`) gain one member, `'external-import'`. The member is stated by the server. No door reads it from a request body, and the import's own options cannot carry it, or a `force`, into the save. Nothing accepted today is refused.
14+
- **What does not change.** The refusal itself stays: a destructive re-import is still `400 EXTERNAL_IMPORT_ERROR`, and the stored definition does not move. The import route gains no `force`. Acknowledging a destructive change stays on the metadata door. The other faces' wording is unchanged. A `422 INVALID_METADATA` relayed by the import keeps its full findings in the message, because the import route's envelope carries no `issues`.

‎content/docs/references/api/protocol.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2588,7 +2588,7 @@ Installed package with runtime lifecycle state
25882588
| **force** | `boolean` | optional | Destructive-change acknowledgement (`?force=true` on the REST door): skips the safety diff that refuses an `object` save whose body drops fields or narrows types the stored item still carries (409 with the findings otherwise). Only `object` saves reach that diff, so the flag is inert for every other type. Absent = the guard runs. |
25892589
| **mode** | `Enum<'draft' \| 'publish'>` | optional | Per-item lifecycle (ADR-0005 drafts): `draft` stages the body as a pending draft overlay (`?mode=draft` on the REST door; the publish door promotes it later); `publish` or ABSENT writes straight to the live `active` row — the legacy default, kept so callers that predate the draft/publish split keep working. Any value other than `draft` is read as `publish`. |
25902590
| **packageId** | `string \| null` | optional | ADR-0048 — the software package to bind the saved row to (`sys_metadata.package_id`; `?package=<id>` on the REST door, sent only when it names a real package). Set when authoring inside a Studio package workspace; a named read-only base package is refused. On create the row is stamped with this id; on update an existing binding is preserved, never silently re-bound. Absent = env-local overlay (no package stamp); it also scopes which row the unpinned parent-version resolution reads. |
2591-
| **writeFace** | `Enum<'package-duplicate' \| 'meta-envelope' \| 'meta-dispatch'>` | optional | Which write door a refusal is being rendered FOR — stated by the SERVER, never by a remote caller: every door builds this request field by field and never spreads a request body into it, so there is no path for a client to smuggle a face in, and a face arriving in a wire body is simply never read. Two refusals branch on it, for different questions: the 409 destructive-change remedy names the acknowledgement mechanism that actually exists on the refusing door (`?force=true` on the REST doors; the dispatcher and the duplicate-package door have none), and the 422 invalid-metadata message adapts to whether a structured `issues[]` channel reaches the consumer beside it. Absent = the conservative default wording. |
2591+
| **writeFace** | `Enum<'package-duplicate' \| 'meta-envelope' \| 'meta-dispatch' \| 'external-import'>` | optional | Which write door a refusal is being rendered FOR — stated by the SERVER, never by a remote caller: every door builds this request field by field and never spreads a request body into it, so there is no path for a client to smuggle a face in, and a face arriving in a wire body is simply never read. Two refusals branch on it, for different questions: the 409 destructive-change remedy names the acknowledgement mechanism that actually exists on the refusing door (`?force=true` on the REST doors; the dispatcher, the duplicate-package door and the external-table import have none), and the 422 invalid-metadata message adapts to whether a structured `issues[]` channel reaches the consumer beside it. Absent = the conservative default wording. |
25922592

25932593

25942594
---

‎packages/metadata-protocol/src/protocol.destructive-409-face-inventory.test.ts‎

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,9 @@
4949
* | 5 | `migrateStoredMetadata` (this file's protocol) | any | **true** | no — `force` | (`rows[].reason`) | n/a |
5050
* | 6 | {@link ObjectStackProtocolImplementation.duplicatePackage} | `row.type` incl. `object` | no | **yes** | `failed[].error` on a **200** | ⛔ **NO — sole carrier** |
5151
* | 7 | `plugin-security` permission-set projection ×4 | `'permission'` | no | no — type | n/a | n/a |
52+
* | 8 | `@objectstack/service-datasource` external-table import (`persistObject`) | `'object'` | never — the import route reads no `force` | **yes** | the import route's `400 EXTERNAL_IMPORT_ERROR`, message only | ⛔ **NO — sole carrier** |
5253
*
53-
* Rows 1-3 and 6 are pinned below. Rows 4, 5 and 7 are eliminated by a
54+
* Rows 1-3, 6 and 8 are pinned below. Rows 4, 5 and 7 are eliminated by a
5455
* constant in the call itself (a literal `type`, or `force: true`), which is
5556
* why they are argued rather than pinned: there is no runtime state that could
5657
* make them reach the gate.
@@ -159,6 +160,25 @@
159160
* by giving the dispatcher a `force` or by taking row 2's back out — is undoing
160161
* a decision, not tidying an inconsistency.
161162
*
163+
* ## [#21841] Row 8 — the external-table import, a face of its own
164+
*
165+
* "Import as Object" saves through this very `saveMetaItem` since #21788, so a
166+
* re-import that would shrink an object it already created reaches the gate.
167+
* The import route relays every throw as `400 EXTERNAL_IMPORT_ERROR` with the
168+
* message alone, and it reads no `force`, so the default clause sent a caller
169+
* round in a circle exactly as rows 2, 3 and 6 once did. It went row 3's way,
170+
* not row 2's: the route has no twin that reads `?force`, and no first-party
171+
* caller re-imports through it (the console's import dialog saves its draft
172+
* through `PUT /meta/object/:name`), so threading a `force` would be a new
173+
* capability with no pull. `'external-import'` names the two remedies that
174+
* exist instead — a new `name`, or the metadata door with `?force=true` — and
175+
* section 6 below pins it.
176+
*
177+
* Row 8 is also a second SOLE CARRIER beside row 6: the route's envelope has
178+
* no `issues`, so the findings prose reaches the caller through the message
179+
* alone. That is one more reason the commit 809e61221 verdict stands, and it is
180+
* why `'external-import'` keeps the 422's default, full-prose clause.
181+
*
162182
* ⛔ Never a bare `toThrow()` here. `duplicatePackage` does not throw, it
163183
* REPORTS, and what the report says IS the defect; and for the throw itself
164184
* the minimum assertion is `code` + `status` (ADR-0112 envelope), with the
@@ -308,6 +328,14 @@ const DUPLICATE_REMEDY_HEAD = 'this copy cannot be forced';
308328
* See section 5.
309329
*/
310330
const DISPATCH_REMEDY_HEAD = 'this save cannot be forced';
331+
/**
332+
* [#21841] …and as the external-table IMPORT renders it — the fourth answer.
333+
* Unlike the dispatcher, a door that DOES read `?force` exists for this very
334+
* object, so the clause sends the caller there by name. See section 6.
335+
*/
336+
const IMPORT_REMEDY_HEAD = 'this import cannot be forced';
337+
/** The metadata door the import face prescribes, spelled for the fixture's item. */
338+
const IMPORT_REMEDY_DOOR = 'PUT /api/v1/meta/object/crm_task?force=true';
311339
/** One finding's prose, as `detectDestructiveObjectChanges` words it. */
312340
const FINDING_PROSE = "Field 'b' removed — existing data in this column will become inaccessible.";
313341

@@ -689,3 +717,110 @@ describe('[#11095] [GUARD] the `meta-dispatch` face prescribes a remedy that doo
689717
for (const i of dispatch.issues) expect(dispatch.message).not.toContain(i.message);
690718
});
691719
});
720+
721+
// ═══════════════════════════════════════════════════════════════════════════
722+
// 6. [#21841] [GUARD] Inventory row 8 — the external-table import face, which
723+
// names the door that DOES read `force` for this object
724+
// ═══════════════════════════════════════════════════════════════════════════
725+
726+
describe('[#21841] [GUARD] the `external-import` face prescribes remedies that exist', () => {
727+
it('declares the ADR-0112 envelope — the refusal itself is unchanged', async () => {
728+
const err = await destructiveRefusal('external-import');
729+
730+
// This card moves a SENTENCE. The import route restamps the envelope as
731+
// `400 EXTERNAL_IMPORT_ERROR`, but what it relays is this refusal, so the
732+
// producer's own code, status and findings must not move under it.
733+
expect(err.code).toBe('DESTRUCTIVE_CHANGE');
734+
expect(err.status).toBe(409);
735+
expect(err.issues).toEqual(expect.arrayContaining([
736+
expect.objectContaining({ code: 'field_removed', field: 'b' }),
737+
]));
738+
});
739+
740+
it('⛔ never tells the caller to re-submit the import with `?force=true`', async () => {
741+
const err = await destructiveRefusal('external-import');
742+
743+
// The defect, stated as the assertion that fails without the fix: the
744+
// import route reads no `force`, so this sentence sent a caller round.
745+
expect(err.message).not.toContain(PUT_REMEDY);
746+
expect(err.message).not.toContain('re-submit with ?force=true');
747+
});
748+
749+
it('names the door, denies the mechanism, then prescribes the two remedies that exist', async () => {
750+
const err = await destructiveRefusal('external-import');
751+
752+
expect(err.message).toContain(IMPORT_REMEDY_HEAD);
753+
expect(err.message).toContain('accepts no `force`');
754+
// Remedy one: the import itself, under a name nothing stores yet.
755+
expect(err.message).toContain('under a new `name`');
756+
// Remedy two: the metadata door, which reads `?force` for this item.
757+
expect(err.message).toContain(IMPORT_REMEDY_DOOR);
758+
});
759+
760+
it('[#10886 non-effect] the per-field findings prose is still there, untrimmed', async () => {
761+
const err = await destructiveRefusal('external-import');
762+
763+
// Row 8 is a sole carrier (the route relays the message alone), so the
764+
// findings must stay in the sentence on this face above all.
765+
expect(err.message).toContain(FINDING_PROSE);
766+
expect(err.message).toContain('would drop or transform existing data');
767+
});
768+
769+
it('⛔ the four faces are a SWITCH — the new one moved none of the others', async () => {
770+
const [plain, envelope, dispatch, imported] = await Promise.all([
771+
destructiveRefusal(),
772+
destructiveRefusal('meta-envelope'),
773+
destructiveRefusal('meta-dispatch'),
774+
destructiveRefusal('external-import'),
775+
]);
776+
777+
expect(plain.message).toContain(PUT_REMEDY);
778+
expect(envelope.message).toContain(PUT_REMEDY);
779+
expect(dispatch.message).toContain(DISPATCH_REMEDY_HEAD);
780+
for (const other of [plain, envelope, dispatch]) {
781+
expect(other.message).not.toContain(IMPORT_REMEDY_HEAD);
782+
}
783+
expect(imported.message).not.toContain(DISPATCH_REMEDY_HEAD);
784+
expect(imported.message).not.toContain(DUPLICATE_REMEDY_HEAD);
785+
});
786+
787+
/**
788+
* ⭐ The coupling, from the other side of section 5's.
789+
*
790+
* `'meta-dispatch'` had to JOIN the 422's trimming case because its door
791+
* carries `issues[]`. The import route does not: it answers
792+
* `sendError(res, 400, 'EXTERNAL_IMPORT_ERROR', message)` and nothing else,
793+
* so the 422's findings reach that caller through the message alone. The
794+
* import face must therefore stay OUT of the trimming case and keep the
795+
* full-prose default. Adding it there would delete the author's findings
796+
* from that wire with every 409 assertion above still green.
797+
*/
798+
it('⛔ [COUPLING] the new face changes the 409 clause and NOTHING about the 422', async () => {
799+
const { protocol } = makeKernel({ seed: [objectRow('crm_task', ['a', 'b', 'c', 'd'])] });
800+
const invalid = async (writeFace?: string) => {
801+
try {
802+
await protocol.saveMetaItem({
803+
type: 'view',
804+
name: 'task_list',
805+
item: {
806+
name: 'task_list', object: 'task', type: 'list', label: 'Tasks',
807+
columns: [{ field: 'title', summary: { type: 'sum', fieldd: 'amount' } }],
808+
},
809+
...(writeFace ? { writeFace } : {}),
810+
});
811+
} catch (e: any) { return e; }
812+
throw new Error('expected saveMetaItem to refuse the invalid body');
813+
};
814+
815+
const plain = await invalid();
816+
const imported = await invalid('external-import');
817+
818+
expect(imported.code).toBe('INVALID_METADATA');
819+
expect(imported.status).toBe(422);
820+
// Byte-for-byte the default clause, which restates the findings (its
821+
// first three; the fixture has fewer) in the sentence itself.
822+
expect(imported.message).toBe(plain.message);
823+
expect(imported.issues.length).toBeGreaterThan(0);
824+
for (const i of imported.issues.slice(0, 3)) expect(imported.message).toContain(i.message);
825+
});
826+
});

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4833,6 +4833,24 @@ function detectDestructiveObjectChanges(prev: any, next: any): Array<{
48334833
* field. The compound door's parity argument is a statement about ONE pair of
48344834
* routes that spell one name two ways; it is not a general licence, and the
48354835
* dispatcher is not the third member of that pair.
4836+
*
4837+
* ## [#21841] The external-table import, and why it went the dispatcher's way
4838+
*
4839+
* "Import as Object" (`@objectstack/service-datasource`) saves through this
4840+
* gate since #21788, so a re-import that would shrink an object it created is
4841+
* refused here and relayed as `400 EXTERNAL_IMPORT_ERROR`. The import route
4842+
* reads no `force`, so the default clause sent that caller round in a circle.
4843+
* It states `'external-import'` and gets a clause of its own rather than a
4844+
* `force`, for the reasons the dispatcher did and one more: the route has no
4845+
* twin that reads `?force`, and no first-party caller re-imports through it
4846+
* (the console's import dialog saves its draft through
4847+
* `PUT /api/v1/meta/object/:name`, the very door this clause names), so a
4848+
* `force` there would be a new capability with nothing pulling on it. Unlike
4849+
* the dispatcher, a door that DOES read `?force` exists for the same item, so
4850+
* the clause names it instead of only telling the caller to reconcile.
4851+
*
4852+
* ⛔ Same warning as above: the import is not a twin of the `PUT` door, and
4853+
* giving it a `force` to "match" would be a new surface, not a repair.
48364854
*/
48374855
/**
48384856
* [commit 82cb6e849 / commit d806081dd] Which write door a `saveMetaItem` refusal is being
@@ -4865,8 +4883,16 @@ function detectDestructiveObjectChanges(prev: any, next: any): Array<{
48654883
* case rather than a hypothetical — which is why {@link specValidationFindings}
48664884
* lists the two faces on one `case` instead of letting `'meta-dispatch'` fall
48674885
* to a default that was never written for it.
4886+
*
4887+
* ⚠️ [#21841] `'external-import'` differs from `'meta-dispatch'` on that SAME
4888+
* second question, in the other direction: the import route relays a refusal
4889+
* as `sendError(res, 400, 'EXTERNAL_IMPORT_ERROR', message)` and nothing else,
4890+
* so no `issues[]` reaches its caller and the message is the sole carrier of
4891+
* the findings. It therefore stays OUT of {@link specValidationFindings}'
4892+
* trimming case and keeps the full-prose default — the polarity that comment
4893+
* defends, working as designed for a door added after it.
48684894
*/
4869-
type MetadataWriteFace = 'package-duplicate' | 'meta-envelope' | 'meta-dispatch';
4895+
type MetadataWriteFace = 'package-duplicate' | 'meta-envelope' | 'meta-dispatch' | 'external-import';
48704896

48714897
function destructiveChangeRemedy(
48724898
face: MetadataWriteFace | undefined,
@@ -4897,6 +4923,20 @@ function destructiveChangeRemedy(
48974923
return `this save cannot be forced: the dispatcher's \`PUT /meta\` accepts no \`force\`. `
48984924
+ `Re-submit '${name}' with a body that keeps the fields and types named above, `
48994925
+ `or reconcile that stored item first.`;
4926+
case 'external-import':
4927+
// [#21841] "Import as Object" (`@objectstack/service-datasource`),
4928+
// relayed by the import route as `400 EXTERNAL_IMPORT_ERROR`. That
4929+
// route reads no `force` and was deliberately not given one (see the
4930+
// section above `MetadataWriteFace`). Both remedies are things the
4931+
// caller can do from where they stand: import the table under a
4932+
// name nothing stores yet, or save the changed definition through
4933+
// the metadata door, which reads `?force=true` for this very item
4934+
// and is where a destructive change is acknowledged on purpose.
4935+
// Same grammar as the two faces above — name the door, deny the
4936+
// mechanism, then prescribe.
4937+
return `this import cannot be forced: the external-table import route accepts no \`force\`. `
4938+
+ `Import the table under a new \`name\`, or save the changed definition of '${name}' `
4939+
+ `through \`PUT /api/v1/meta/object/${name}?force=true\`, which accepts the destructive change on purpose.`;
49004940
default:
49014941
return 're-submit with ?force=true to proceed.';
49024942
}

0 commit comments

Comments
 (0)