4040 *
4141 * Every case runs the real command, plan then apply, over a fresh copy of the
4242 * database; each fate the case does not exercise is `report`. SQLite only.
43+ *
44+ * ## `os migrate apply --allow-destructive` on the same composition
45+ *
46+ * The schema-drift report of this composition offers the two declared
47+ * objects' `organization_id` for the destructive apply. Its drop gate
48+ * (`organization-ownership-drop-gate.ts`, ADR-0131 D10 item 5) holds that drop
49+ * back until the ceremony's marker is verified — on these host-declared
50+ * objects as on the platform's inventory, since the ceremony plans every
51+ * table that carries the column. The real `os migrate apply` command, over a
52+ * fresh copy of the same v17 database:
53+ *
54+ * - no marker: refused, naming the ceremony; nothing dropped;
55+ * - CONTROL: a verified marker: the drop applies;
56+ * - CONTROL: a destructive drop of another column is unchanged.
4357 */
4458
4559import { afterAll , beforeAll , describe , expect , it , vi } from 'vitest' ;
@@ -50,7 +64,9 @@ import { randomBytes } from 'node:crypto';
5064import { tmpdir } from 'node:os' ;
5165import { dirname , join , resolve } from 'node:path' ;
5266import { fileURLToPath , pathToFileURL } from 'node:url' ;
67+ import { ORGANIZATION_OWNERSHIP_MIGRATION_ID } from '@objectstack/spec/system' ;
5368import { isExitSignal } from './format.js' ;
69+ import MigrateApply from '../commands/migrate/apply.js' ;
5470import MigrateOrganizationOwnership from '../commands/migrate/organization-ownership.js' ;
5571
5672// Paid at module load, not inside a clocked case (`check:test-source-alias`).
@@ -248,6 +264,74 @@ async function ceremony(name: string, drop: string): Promise<Run> {
248264 }
249265}
250266
267+ /** A fresh copy of the v17 database for one `os migrate apply` case, with `prepare` run on it raw. */
268+ function applyCase ( name : string , prepare ?: ( db : Database . Database ) => void ) : string {
269+ const caseDir = join ( dir , 'apply-cases' , name ) ;
270+ mkdirSync ( caseDir , { recursive : true } ) ;
271+ const dbFile = join ( caseDir , 'v17.db' ) ;
272+ copyFileSync ( templateDb , dbFile ) ;
273+ if ( prepare ) {
274+ const db = new Database ( dbFile ) ;
275+ try {
276+ prepare ( db ) ;
277+ } finally {
278+ db . close ( ) ;
279+ }
280+ }
281+ return dbFile ;
282+ }
283+
284+ /** The real `os migrate apply --allow-destructive --yes --json` over `dbFile`, from the host's project. */
285+ async function migrateApply ( dbFile : string ) : Promise < { payload : any ; exitCode : number } > {
286+ const savedExit = process . exitCode ;
287+ const swallow = ( ( _chunk : unknown , ...rest : unknown [ ] ) => {
288+ const cb = rest . find ( ( a ) => typeof a === 'function' ) as ( ( ) => void ) | undefined ;
289+ if ( cb ) cb ( ) ;
290+ return true ;
291+ } ) as typeof process . stdout . write ;
292+ const stdout = vi . spyOn ( process . stdout , 'write' ) . mockImplementation ( swallow ) ;
293+ vi . spyOn ( process . stderr , 'write' ) . mockImplementation ( swallow ) ;
294+ vi . spyOn ( console , 'log' ) . mockImplementation ( ( ) => { } ) ;
295+ vi . spyOn ( console , 'warn' ) . mockImplementation ( ( ) => { } ) ;
296+ vi . spyOn ( console , 'error' ) . mockImplementation ( ( ) => { } ) ;
297+ // `run()` ends the one-shot process itself once the document is written.
298+ let processExit : number | undefined ;
299+ vi . spyOn ( process , 'exit' ) . mockImplementation ( ( ( code ?: number ) => {
300+ processExit = code ?? 0 ;
301+ throw new Error ( `__PROCESS_EXIT__:${ processExit } ` ) ;
302+ } ) as never ) ;
303+ process . chdir ( projectDir ) ;
304+ let thrownExit : number | undefined ;
305+ try {
306+ try {
307+ await MigrateApply . run ( [ '--database-url' , `file:${ dbFile } ` , '--allow-destructive' , '--yes' , '--json' ] , { root : CLI_ROOT } ) ;
308+ } catch ( error ) {
309+ const trapped = error instanceof Error && error . message . startsWith ( '__PROCESS_EXIT__' ) ;
310+ if ( ! trapped && ! isExitSignal ( error ) ) throw error ;
311+ if ( ! trapped ) thrownExit = ( error as { oclif ?: { exit ?: number } } ) . oclif ?. exit ;
312+ }
313+ const out = stdout . mock . calls . map ( ( c ) => String ( c [ 0 ] ) ) . join ( '' ) . trim ( ) ;
314+ let payload : any ;
315+ try {
316+ payload = JSON . parse ( out ) ;
317+ } catch {
318+ payload = { unparsed : out . slice ( - 600 ) } ;
319+ }
320+ return { payload, exitCode : processExit ?? thrownExit ?? ( process . exitCode as number | undefined ) ?? 0 } ;
321+ } finally {
322+ process . chdir ( savedCwd ) ;
323+ process . exitCode = savedExit ;
324+ vi . restoreAllMocks ( ) ;
325+ }
326+ }
327+
328+ /** The `organization_id` drops among an apply payload's entries, by table, sorted. */
329+ const organizationDropsIn = ( entries : any [ ] | undefined ) : string [ ] =>
330+ ( entries ?? [ ] )
331+ . filter ( ( d ) => d ?. op ?. type === 'drop_column' && d . op . column === 'organization_id' )
332+ . map ( ( d ) => String ( d . table ) )
333+ . sort ( ) ;
334+
251335const columnDrops = ( report : any ) : { status : string ; summary : Record < string , number > ; blockers : string [ ] } =>
252336 report . steps . find ( ( s : { step : string } ) => s . step === 'column-drops' ) ;
253337const blockersFor = ( report : any , table : string ) : string [ ] =>
@@ -337,3 +421,65 @@ describe('organization-ownership --apply: column-drop on an object the host decl
337421 expect ( exitCode ) . toBe ( 1 ) ;
338422 } , CASE_TIMEOUT_MS ) ;
339423} ) ;
424+
425+ describe ( 'os migrate apply --allow-destructive: the organization_id drop on a host-declared platform-global object waits for the ceremony' , ( ) => {
426+ it ( 'holds the drop back until the marker is verified, naming the ceremony, and drops nothing' , async ( ) => {
427+ const dbFile = applyCase ( 'no-marker' ) ;
428+ const { payload, exitCode } = await migrateApply ( dbFile ) ;
429+
430+ expect ( exitCode , JSON . stringify ( payload ) . slice ( 0 , 600 ) ) . toBe ( 1 ) ;
431+ expect ( payload ) . toMatchObject ( {
432+ message : 'refused_organization_ownership_unmigrated' ,
433+ tables : [ ...DECLARED ] . sort ( ) ,
434+ applied : [ ] ,
435+ created : [ ] ,
436+ marker : null ,
437+ } ) ;
438+ expect ( payload . detail ) . toMatch ( / o s m i g r a t e o r g a n i z a t i o n - o w n e r s h i p - - a p p l y / ) ;
439+ // The drops the refusal held back are the ones the drift offered.
440+ expect ( organizationDropsIn ( payload . skipped ) ) . toEqual ( [ ...DECLARED ] . sort ( ) ) ;
441+ for ( const table of HOST_OBJECTS ) expect ( hasOrganizationColumn ( dbFile , table ) , table ) . toBe ( true ) ;
442+ } , CASE_TIMEOUT_MS ) ;
443+
444+ it ( 'CONTROL: once the ceremony\'s marker is verified, the drop applies' , async ( ) => {
445+ const dbFile = applyCase ( 'verified-marker' , ( db ) => {
446+ const now = new Date ( ) . toISOString ( ) ;
447+ db . prepare ( 'INSERT INTO sys_migration (id, last_run_at, verified_at, blocking) VALUES (?, ?, ?, 0)' ) . run (
448+ ORGANIZATION_OWNERSHIP_MIGRATION_ID ,
449+ now ,
450+ now ,
451+ ) ;
452+ } ) ;
453+ const { payload, exitCode } = await migrateApply ( dbFile ) ;
454+
455+ expect ( exitCode , JSON . stringify ( payload ) . slice ( 0 , 600 ) ) . toBe ( 0 ) ;
456+ expect ( payload . message ) . toBeUndefined ( ) ;
457+ expect ( organizationDropsIn ( payload . applied ) ) . toEqual ( [ ...DECLARED ] . sort ( ) ) ;
458+ for ( const table of DECLARED ) expect ( hasOrganizationColumn ( dbFile , table ) , table ) . toBe ( false ) ;
459+ // The undeclared object's column is the release's, and stays.
460+ expect ( hasOrganizationColumn ( dbFile , AUDIT ) ) . toBe ( true ) ;
461+ } , CASE_TIMEOUT_MS ) ;
462+
463+ it ( 'CONTROL: a destructive drop of another column is unchanged' , async ( ) => {
464+ // The declared objects' organization_id already gone, and a column no
465+ // object declares on one of them: the run's one destructive change.
466+ const dbFile = applyCase ( 'other-column' , ( db ) => {
467+ for ( const table of DECLARED ) db . exec ( `ALTER TABLE "${ table } " DROP COLUMN organization_id` ) ;
468+ db . exec ( `ALTER TABLE "${ LEDGER } " ADD COLUMN legacy_23684_note TEXT` ) ;
469+ } ) ;
470+ const { payload, exitCode } = await migrateApply ( dbFile ) ;
471+
472+ expect ( exitCode , JSON . stringify ( payload ) . slice ( 0 , 600 ) ) . toBe ( 0 ) ;
473+ expect ( payload . message ) . toBeUndefined ( ) ;
474+ expect (
475+ ( payload . applied as any [ ] ) . filter ( ( d ) => d ?. op ?. type === 'drop_column' ) . map ( ( d ) => `${ d . table } .${ d . op . column } ` ) ,
476+ ) . toEqual ( [ `${ LEDGER } .legacy_23684_note` ] ) ;
477+ const db = new Database ( dbFile , { readonly : true } ) ;
478+ try {
479+ const columns = ( db . prepare ( `PRAGMA table_info("${ LEDGER } ")` ) . all ( ) as Array < { name : string } > ) . map ( ( c ) => c . name ) ;
480+ expect ( columns ) . not . toContain ( 'legacy_23684_note' ) ;
481+ } finally {
482+ db . close ( ) ;
483+ }
484+ } , CASE_TIMEOUT_MS ) ;
485+ } ) ;
0 commit comments