|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#16185] The `sys_migration` half of the declared pair — `columns_moved_at`. |
| 5 | + * |
| 6 | + * `sys-migration.object.ts` states that the row contract "lives in |
| 7 | + * `@objectstack/spec/system` (`DataMigrationFlagSchema`)", which is why the |
| 8 | + * schema member and this column are one card and not two. That sentence is |
| 9 | + * prose; nothing in the repo held the two halves together, so a later card |
| 10 | + * could widen either one alone and every gate would stay green. This pin holds |
| 11 | + * them together for the member this card adds. |
| 12 | + * |
| 13 | + * It pins the column's SHAPE as well as its presence, because the shape is the |
| 14 | + * contract the ruling on #15989 chose: the column must be able to be absent. |
| 15 | + * A `required: true` column would make "the backfill ran here but the column |
| 16 | + * move did not" — a real, expected deployment state — unrepresentable in the |
| 17 | + * ledger, and mechanism A was chosen precisely because that state must encode |
| 18 | + * as nothing at all. The three sibling attestation columns are asserted beside |
| 19 | + * it as the control that "not required" is this table's shape and not an |
| 20 | + * oversight on the new one. |
| 21 | + * |
| 22 | + * ⛔ Not pinned here: any read of the column. This card adds the declaration |
| 23 | + * and the column and stops — the writer and the reader are the blocked driver |
| 24 | + * card's, and a pin over behaviour that does not exist yet would be a pin over |
| 25 | + * nothing. |
| 26 | + */ |
| 27 | + |
| 28 | +import { describe, it, expect } from 'vitest'; |
| 29 | +import { DataMigrationFlagSchema } from '@objectstack/spec/system'; |
| 30 | + |
| 31 | +import { SysMigration } from './sys-migration.object.js'; |
| 32 | + |
| 33 | +const FIELDS = SysMigration.fields as Record<string, Record<string, unknown>>; |
| 34 | + |
| 35 | +describe('sys_migration.columns_moved_at (#16185)', () => { |
| 36 | + it('is declared as a datetime column', () => { |
| 37 | + expect(FIELDS.columns_moved_at).toBeDefined(); |
| 38 | + expect(FIELDS.columns_moved_at.type).toBe('datetime'); |
| 39 | + }); |
| 40 | + |
| 41 | + it('is readonly — writes flow through the migration command, not the API', () => { |
| 42 | + expect(FIELDS.columns_moved_at.readonly).toBe(true); |
| 43 | + }); |
| 44 | + |
| 45 | + it('is NOT required: "backfilled here, columns not moved" must be representable', () => { |
| 46 | + // `ObjectSchema.create` resolves an undeclared `required` to `false`, so |
| 47 | + // the assertion is against that resolved value, not against absence. |
| 48 | + expect(FIELDS.columns_moved_at.required).toBe(false); |
| 49 | + // Control — the same shape on the three siblings it joins, against the two |
| 50 | + // columns that ARE required, so the assertion above is not vacuous. |
| 51 | + for (const sibling of ['verified_at', 'applied_at', 'deviation_observed_at']) { |
| 52 | + expect(FIELDS[sibling]?.required, sibling).toBe(false); |
| 53 | + } |
| 54 | + for (const mandatory of ['id', 'last_run_at']) { |
| 55 | + expect(FIELDS[mandatory]?.required, mandatory).toBe(true); |
| 56 | + } |
| 57 | + }); |
| 58 | + |
| 59 | + it('matches the row contract: the schema accepts the value this column stores', () => { |
| 60 | + const row = { |
| 61 | + id: 'adr-0104-file-references', |
| 62 | + last_run_at: '2026-09-01T00:00:00.000Z', |
| 63 | + blocking: 0, |
| 64 | + columns_moved_at: '2026-09-09T04:00:00.000Z', |
| 65 | + }; |
| 66 | + const parsed = DataMigrationFlagSchema.safeParse(row); |
| 67 | + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); |
| 68 | + expect((parsed.data as Record<string, unknown>).columns_moved_at).toBe(row.columns_moved_at); |
| 69 | + }); |
| 70 | + |
| 71 | + it('leaves the columns the ledger already had exactly where they were', () => { |
| 72 | + // Order matters to a reviewer reading the clause-② instrument, not to the |
| 73 | + // runtime: a re-order shows up there as a large false delta. |
| 74 | + expect(Object.keys(FIELDS)).toEqual([ |
| 75 | + 'id', |
| 76 | + 'last_run_at', |
| 77 | + 'verified_at', |
| 78 | + 'applied_at', |
| 79 | + 'blocking', |
| 80 | + 'advisory', |
| 81 | + 'details', |
| 82 | + 'deviation_observed_at', |
| 83 | + 'deviation_detail', |
| 84 | + 'columns_moved_at', |
| 85 | + 'created_at', |
| 86 | + 'updated_at', |
| 87 | + ]); |
| 88 | + }); |
| 89 | +}); |
0 commit comments