|
49 | 49 | * | 5 | `migrateStoredMetadata` (this file's protocol) | any | **true** | no — `force` | (`rows[].reason`) | n/a | |
50 | 50 | * | 6 | {@link ObjectStackProtocolImplementation.duplicatePackage} | `row.type` incl. `object` | no | **yes** | `failed[].error` on a **200** | ⛔ **NO — sole carrier** | |
51 | 51 | * | 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** | |
52 | 53 | * |
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 |
54 | 55 | * constant in the call itself (a literal `type`, or `force: true`), which is |
55 | 56 | * why they are argued rather than pinned: there is no runtime state that could |
56 | 57 | * make them reach the gate. |
|
159 | 160 | * by giving the dispatcher a `force` or by taking row 2's back out — is undoing |
160 | 161 | * a decision, not tidying an inconsistency. |
161 | 162 | * |
| 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 | + * |
162 | 182 | * ⛔ Never a bare `toThrow()` here. `duplicatePackage` does not throw, it |
163 | 183 | * REPORTS, and what the report says IS the defect; and for the throw itself |
164 | 184 | * the minimum assertion is `code` + `status` (ADR-0112 envelope), with the |
@@ -308,6 +328,14 @@ const DUPLICATE_REMEDY_HEAD = 'this copy cannot be forced'; |
308 | 328 | * See section 5. |
309 | 329 | */ |
310 | 330 | 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'; |
311 | 339 | /** One finding's prose, as `detectDestructiveObjectChanges` words it. */ |
312 | 340 | const FINDING_PROSE = "Field 'b' removed — existing data in this column will become inaccessible."; |
313 | 341 |
|
@@ -689,3 +717,110 @@ describe('[#11095] [GUARD] the `meta-dispatch` face prescribes a remedy that doo |
689 | 717 | for (const i of dispatch.issues) expect(dispatch.message).not.toContain(i.message); |
690 | 718 | }); |
691 | 719 | }); |
| 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 | +}); |
0 commit comments