|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// GOLDEN REGRESSION — removing a field that stores nothing is not a destructive |
| 4 | +// change, booted for real and driven through `PUT /api/v1/meta/object/:name`. |
| 5 | +// |
| 6 | +// The object save's safety diff refuses, with `409 DESTRUCTIVE_CHANGE`, a save |
| 7 | +// that drops a field: the column's data becomes unreachable, and only |
| 8 | +// `?force=true` lifts the refusal. That is true of a stored field and false of |
| 9 | +// a `formula`, which is computed on read and has no column (ADR-0079 addendum, |
| 10 | +// item 9). Refusing the formula removal taught authors and agents to send |
| 11 | +// `force=true` by reflex — which silences the true warning on a stored field. |
| 12 | +// |
| 13 | +// "Stored" is the spec's storage predicate (`isVirtualSearchField` / |
| 14 | +// `SEARCH_VIRTUAL_TYPES`), the one the sortability projection reads — so the |
| 15 | +// cases below are judged by what each field declares: |
| 16 | +// |
| 17 | +// 1. a `formula` removal saves 200 without `force`, and the stored values of |
| 18 | +// the other fields read back unchanged; |
| 19 | +// 2. a stored `text` removal still answers 409 DESTRUCTIVE_CHANGE, with the |
| 20 | +// same finding and message — the live control that this harness reaches |
| 21 | +// the gate at all; |
| 22 | +// 3. an `autonumber` removal still answers 409: it is computed by the ENGINE |
| 23 | +// but stored in a column, so "computed" is not the axis — storage is. |
| 24 | +// |
| 25 | +// Boots a fixture stack of its own, so it stays out of `SHARED_SHOWCASE`. |
| 26 | + |
| 27 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 28 | +import { bootStack, type VerifyStack } from '@objectstack/verify'; |
| 29 | +import { cel, defineStack } from '@objectstack/spec'; |
| 30 | + |
| 31 | +const NAME = 'dfu_invoice'; |
| 32 | + |
| 33 | +const stackDef = defineStack({ |
| 34 | + manifest: { |
| 35 | + id: 'com.dogfood.unstored-removal', |
| 36 | + namespace: 'dfu', |
| 37 | + version: '0.0.0', |
| 38 | + type: 'app', |
| 39 | + name: 'Unstored Field Removal', |
| 40 | + description: 'An empty app; the object under test is saved at runtime through the metadata door.', |
| 41 | + }, |
| 42 | +}); |
| 43 | + |
| 44 | +/** Every field the object starts with — each case removes exactly one. */ |
| 45 | +const FIELDS: Record<string, Record<string, unknown>> = { |
| 46 | + name: { type: 'text', label: 'Name' }, |
| 47 | + notes: { type: 'text', label: 'Notes' }, |
| 48 | + amount: { type: 'number', label: 'Amount' }, |
| 49 | + // JSON round-trip: the wire form of the `cel` envelope. |
| 50 | + doubled: { type: 'formula', label: 'Doubled', expression: JSON.parse(JSON.stringify(cel`record.amount * 2`)) }, |
| 51 | + reference: { type: 'autonumber', label: 'Reference' }, |
| 52 | +}; |
| 53 | + |
| 54 | +const definition = (fields: Record<string, Record<string, unknown>>) => ({ |
| 55 | + name: NAME, |
| 56 | + label: 'Invoice', |
| 57 | + sharingModel: 'public_read_write', |
| 58 | + fields, |
| 59 | +}); |
| 60 | + |
| 61 | +const without = (drop: string) => Object.fromEntries(Object.entries(FIELDS).filter(([k]) => k !== drop)); |
| 62 | + |
| 63 | +describe('dogfood: removing an unstored field through PUT /meta/object/:name', () => { |
| 64 | + let stack: VerifyStack; |
| 65 | + let token: string; |
| 66 | + let recordId: string; |
| 67 | + |
| 68 | + const call = async (method: string, path: string, body?: unknown) => { |
| 69 | + const res = await stack.apiAs(token, method, path, body); |
| 70 | + const json: any = await res.json().catch(() => ({})); |
| 71 | + return { status: res.status, json }; |
| 72 | + }; |
| 73 | + |
| 74 | + |
| 75 | + beforeAll(async () => { |
| 76 | + stack = await bootStack(stackDef); |
| 77 | + token = await stack.signIn(); |
| 78 | + |
| 79 | + const saved = await call('PUT', `/meta/object/${NAME}`, definition(FIELDS)); |
| 80 | + expect(saved.status, JSON.stringify(saved.json)).toBe(200); |
| 81 | + |
| 82 | + const created = await call('POST', `/data/${NAME}`, { name: 'INV-1', notes: 'keep me', amount: 21 }); |
| 83 | + expect(created.status, JSON.stringify(created.json)).toBeLessThan(300); |
| 84 | + recordId = String(created.json?.data?.id ?? created.json?.id); |
| 85 | + expect(recordId, JSON.stringify(created.json)).toMatch(/\S/); |
| 86 | + }, 300_000); |
| 87 | + |
| 88 | + afterAll(async () => { |
| 89 | + await stack?.stop?.(); |
| 90 | + }); |
| 91 | + |
| 92 | + it('a stored `text` removal still answers 409 DESTRUCTIVE_CHANGE with today\'s finding (live control)', async () => { |
| 93 | + const refused = await call('PUT', `/meta/object/${NAME}`, definition(without('notes'))); |
| 94 | + expect(refused.status, JSON.stringify(refused.json)).toBe(409); |
| 95 | + expect(refused.json?.code, JSON.stringify(refused.json)).toBe('DESTRUCTIVE_CHANGE'); |
| 96 | + expect(refused.json?.issues).toEqual([{ |
| 97 | + code: 'field_removed', |
| 98 | + field: 'notes', |
| 99 | + message: "Field 'notes' removed — existing data in this column will become inaccessible.", |
| 100 | + }]); |
| 101 | + }); |
| 102 | + |
| 103 | + it('an `autonumber` removal still answers 409: engine-computed, but stored in a column', async () => { |
| 104 | + const refused = await call('PUT', `/meta/object/${NAME}`, definition(without('reference'))); |
| 105 | + expect(refused.status, JSON.stringify(refused.json)).toBe(409); |
| 106 | + expect(refused.json?.code, JSON.stringify(refused.json)).toBe('DESTRUCTIVE_CHANGE'); |
| 107 | + expect(refused.json?.issues).toEqual([expect.objectContaining({ code: 'field_removed', field: 'reference' })]); |
| 108 | + }); |
| 109 | + |
| 110 | + it('a `formula` removal saves 200 without `force`, and the stored fields read back unchanged', async () => { |
| 111 | + const saved = await call('PUT', `/meta/object/${NAME}`, definition(without('doubled'))); |
| 112 | + expect(saved.status, JSON.stringify(saved.json)).toBe(200); |
| 113 | + |
| 114 | + const meta = await call('GET', `/meta/object/${NAME}`); |
| 115 | + expect(meta.status, JSON.stringify(meta.json)).toBe(200); |
| 116 | + expect(Object.keys(meta.json?.item?.fields ?? {})).not.toContain('doubled'); |
| 117 | + |
| 118 | + const read = await call('GET', `/data/${NAME}/${recordId}`); |
| 119 | + expect(read.status, JSON.stringify(read.json)).toBe(200); |
| 120 | + expect(read.json?.record, JSON.stringify(read.json)).toMatchObject({ |
| 121 | + name: 'INV-1', notes: 'keep me', amount: 21, reference: expect.stringMatching(/\S/), |
| 122 | + }); |
| 123 | + }); |
| 124 | +}); |
0 commit comments