Repository navigation
Expand file tree
/
Copy pathschema-migrate.ts
More file actions
1863 lines (1781 loc) · 91.8 KB
/
Copy pathschema-migrate.ts
File metadata and controls
1863 lines (1781 loc) · 91.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
/**
* Shared boot + rendering for `os migrate` (issue #2186).
*
* Boots the data stack (driver + ObjectQL + the compiled artifact's objects)
* via the supported `createStandaloneStack` programmatic entry, runs schema
* sync, and hands back the live SQL driver so the command can call
* `detectManagedDrift()` / `applyMigrationEntries()`.
*
* Migration only ever sees the objects this boot REGISTERED; tables/columns
* outside that set are never examined or altered. The two SCHEMA commands
* (`plan`/`apply`) therefore pass `composeHostStack` so the set is the one the
* deployment's own `os serve` boot registers — its `objectstack.config.ts` plus
* the platform floor `serve` composes unconditionally (#12938) — and
* `composeServedPlatform`, so it includes what `serve` mounts around the stack:
* the auth family behind its auth gate and the provider of every capability it
* resolves (#22506). The DATA subcommands keep their own narrower set
* (`./data-migration-plugins.ts`).
* A project with neither a config nor a compiled artifact still diffs the data
* stack alone — run `os build` first so its objects are visible.
*
* The boot's first input is the environment, and it is the one the serving
* commands read: the project's `.env*` files are loaded before anything
* resolves a database or a secret ({@link loadProjectEnvFiles}, #22581), and
* the stack reports which database it opened and who named it
* ({@link SchemaStack.dbSource}).
*
* Its datasources are the serving boot's too: when `os dev` / `os serve` would
* keep lifecycle-classed objects in a `telemetry` sibling database (ADR-0057
* §3.6), this boot provisions that sibling through the same helper
* (`provisionTelemetryDatasource`, #22579), and the plan diffs and applies each
* object against the database it lives in ({@link SchemaStack.detectManagedDrift}).
*
* [#24120] …and so are the datasources the deployment DECLARES. A serving boot
* connects every managed datasource its `datasourceMapping` routes objects to
* (or an object binds) through the `datasource-connection` service, under the
* datasource's declared name — so a `{ default: true, datasource: 'main' }`
* rule puts every object, platform objects included, in `main`. `os migrate
* plan` / `apply` compose that same service ({@link DeclaredDatasourceConnectionPlugin}),
* and plan each such datasource's driver beside the primary
* ({@link SchemaStack.drivers}). Without it the boot resolved the declared
* default's LOCATION as its primary but registered no driver under its name,
* so every object the rule routed there was bound to no driver: `Examined 0
* managed table(s)` over the database whose drift `os dev` reports, and names
* this command for.
*/
import path from 'node:path';
import chalk from 'chalk';
import dotenvFlow from 'dotenv-flow';
import type {
ManagedDriftEntry,
DriftCategory,
DriftOp,
MediaColumnMoveScan,
PendingSchemaWork,
} from '@objectstack/driver-sql';
import type { IObjectQLEngine } from '@objectstack/spec/contracts';
import { ORGANIZATION_OWNERSHIP_MIGRATION_ID, StorageNameMapping } from '@objectstack/spec/system';
import { isDevelopmentBoot } from '@objectstack/core';
import {
doctorNodeEnv,
provenanceOf,
readDotenvFiles,
type DotenvReading,
} from '../commands/doctor.js';
import { describeDriverConnection, describeDriverSqliteFile } from './connection-display.js';
import { reserveStdoutForJson } from './json-stdout.js';
import {
buildSchemaMigrationPlugins,
measureComposedCoverage,
type SchemaMigrationComposition,
} from './schema-migration-plugins.js';
import {
provisionTelemetryDatasource,
standaloneTelemetryPrimary,
TELEMETRY_DATASOURCE,
} from './telemetry-datasource.js';
export type { PendingSchemaWork };
export interface SqlDriverLike {
detectManagedDrift(): Promise<ManagedDriftEntry[]>;
applyMigrationEntries(
entries: ManagedDriftEntry[],
opts: { allowDestructive?: boolean },
): Promise<{ applied: ManagedDriftEntry[]; skipped: ManagedDriftEntry[] }>;
/**
* The ADR-0104 file-family column step's read-only planner (#15989) —
* optional, so a driver with no media arm (every driver that is not this
* repo's SQL one, and an older published build of it) still boots and simply
* offers no plan. ⛔ Its absence must read as "cannot plan", never as
* "nothing to move": the two are the same shape from here, and only the
* caller's own refusal branch can tell an operator which it was.
*/
planMediaColumnMove?: () => Promise<MediaColumnMoveScan>;
/**
* [#23816] Why this driver's drift report must not be acted on unattended,
* or `null` / absent when it may be — `TursoDriver`'s REMOTE face declares
* one. Read by the artifact-pinned boot gate (`./artifact-boot-migration.ts`),
* which then neither applies nor refuses on the report.
*/
readonly driftReportCaveat?: string | null;
/**
* [#23925] Why this driver's {@link applyMigrationEntries} cannot perform a
* drift op the SQL driver's apply door performs, or `null` when it can.
* Absent means the driver declares no such limit: its apply door applies
* what the differ means it to. `TursoDriver`'s REMOTE face declares one (it
* runs no SQLite table rebuild). Read by `os migrate plan` through
* {@link SchemaStack.driftApplyLimit}, never guessed from the dialect.
*/
driftApplyLimit?: (opType: DriftOp['type']) => string | null;
/** Deferred-DDL surface (#3917) — optional, so a driver without it still boots. */
setDeferredDdl?: (deferred: boolean) => void;
previewDeferredSchemaWork?: () => Promise<PendingSchemaWork[]>;
flushDeferredSchemaDdl?: () => Promise<PendingSchemaWork[]>;
config?: any;
disconnect?: () => Promise<void>;
}
export interface SchemaStack {
/**
* The PRIMARY datasource's SQL driver — the database {@link dbLabel} names.
* A plan's drift is read through {@link detectManagedDrift}, which covers the
* `telemetry` sibling as well; this is the primary alone.
*/
driver: SqlDriverLike | null;
dbLabel: string;
/**
* [#22579] The `telemetry` sibling database (ADR-0057 §3.6) this boot
* provisioned, as the serving boot would — the file lifecycle-classed
* objects (`audit` / `telemetry` / `event`) are planned against — or `null`
* when the serving boot keeps them in the primary: production without
* `OS_TELEMETRY_DB`, `OS_TELEMETRY_DB=0`, or a primary that is not a
* file-backed SQLite database. `os migrate plan` / `apply` print it beside
* the database in both faces.
*/
telemetryDatabase: string | null;
/**
* [#22579] Every SQL driver this boot plans against, in order: the primary
* ({@link driver}), then the `telemetry` sibling when there is one, then
* [#24120] each declared managed datasource the boot connected for the
* objects routed or bound to it, by name. An object whose engine-resolved
* driver is not here is outside the plan.
*/
drivers: readonly SqlDriverLike[];
/**
* [#24120] The database a planned driver ({@link drivers}) addresses, as an
* operator reads it: {@link dbLabel} for the primary, {@link telemetryDatabase}
* for the sibling, the connection a declared datasource's driver describes —
* or `null` for a driver this stack does not plan.
*/
databaseOf: (driver: SqlDriverLike) => string | null;
/**
* [#22579] Managed drift over every datasource this boot plans against: the
* primary, then the `telemetry` sibling when there is one. Each entry stays
* tied to the driver that reported it, which is where
* {@link applyMigrationEntries} applies it. `[]` with no SQL driver.
*/
detectManagedDrift: () => Promise<ManagedDriftEntry[]>;
/**
* [#22579] Apply drift {@link detectManagedDrift} returned, each entry on the
* driver that reported it. Refuses — before applying anything — an entry
* this stack did not report: it has no datasource to go to.
*/
applyMigrationEntries: (
entries: ManagedDriftEntry[],
opts: { allowDestructive?: boolean },
) => Promise<{ applied: ManagedDriftEntry[]; skipped: ManagedDriftEntry[] }>;
/**
* [#23925] Why {@link applyMigrationEntries} cannot perform `entry` on the
* database that reported it, as that database's driver declares it
* ({@link SqlDriverLike.driftApplyLimit}), or `null` when it can. Asked of
* the driver that reported the entry, the same one the apply hands it to.
* Refuses an entry this stack did not detect: no driver reported it.
*/
driftApplyLimit: (entry: ManagedDriftEntry) => string | null;
/**
* [#22581] Who named the database {@link dbLabel} describes: `--database-url`,
* a variable from this process's environment, one from the project's `.env*`
* files, the config's declared datasource, or nobody (the default). `os
* migrate plan` / `apply` print it beside the database in both faces.
*/
dbSource: DatabaseSource;
/**
* [#23816] What this boot did with schema sync: `'deferred'` (held back, see
* {@link pendingSchemaWork}), `'skipped'` (none attempted, the boot's
* `deferredDdlUnsupported: 'skip-schema-sync'` answer for a transport that
* cannot defer), or `'performed'` (the ordinary additive sync ran).
*/
schemaSync: 'deferred' | 'skipped' | 'performed';
managedTableCount: number;
/** The booted kernel — `getService('objectql')` etc. for one-shot commands
* beyond schema migration. */
kernel: any;
/**
* Create-table / add-column work the boot sync was held back from running
* (#3917). Always `[]` unless the stack was booted with `deferSchemaDdl`.
*/
pendingSchemaWork: PendingSchemaWork[];
/**
* [#24059] The physical tables this boot's composition declares OUTSIDE the
* object set: the package registry's `sys_packages`, named by the same
* `package.storage` declaration {@link pendingSchemaWork} plans it from
* (#23022) — or `[]` when the boot composed no package registry.
*
* Named whether or not the table exists, and on every boot: the plan lists the
* table as `create_table` only while it is absent, so once `os migrate apply`
* has created it the pending work names nothing. `os migrate plan`'s
* unmanaged-table sweep reads this, so the table the apply created is not
* reported as declared by nothing.
*/
nonObjectTables: readonly string[];
/**
* [#21529] Did this boot MEASURE `objectName`'s table as absent? `true` only
* when the held-back sync above would CREATE it (`create_table`), which the
* driver decides with `hasTable` — a fact about the database, not a guess
* from a failed read. Always `false` unless the stack was booted with
* `deferSchemaDdl`, and `false` again once {@link flushSchemaDdl} has run.
*
* What it is for: a read-only command does not read a table its own boot
* deferred. A table that does not exist holds no rows, so the command
* answers its empty work from this ("not asked") instead of issuing the read
* and turning the refusal into a query fault. ⛔ Only `create_table` counts:
* a table that exists but lacks a column (`add_columns`) is still read, and
* whatever that read refuses is still reported.
*/
tableAbsent: (objectName: string) => boolean;
/**
* Every object the booted stack knows about — the set the plan is computed
* against, including objects that arrived from installed packages rather than
* this project's `objectstack.config.ts`. Read by the ADR-0120 D5e
* unique-scope advisory; best-effort (`[]` when no ObjectQL service composed).
*/
allObjects: () => unknown[];
/**
* Perform the deferred sync — call only once the operator has confirmed the
* plan. Returns the work it actually ran (`[]` when nothing was deferred).
*
* Then the organization-ownership BIRTH half the deferred boot could not
* run ({@link recordBirthOfFlushedStore}): when this flush created the
* ledger's store from empty, it writes the birth marker, so the first `os
* serve` admits the database this run created. Throws when that write fails.
*/
flushSchemaDdl: () => Promise<PendingSchemaWork[]>;
/**
* What the host composition added, and anything it could not (#12938) — the
* host config it composed, whether that config actually loaded, the platform
* floor it added, and whether the set mirrors what `os serve` registers
* (`servedBoot`, #22580). `notes` is always `[]` unless the boot asked for
* `composeHostStack`, and `[]` even then when there was nothing to compose,
* so a project with neither a config nor a compiled artifact renders
* byte-identically to before this existed.
*/
composition: SchemaMigrationComposition;
shutdown: () => Promise<void>;
}
/**
* The kernel service names a SQL driver is published under, by driver: the
* `driver.<driver.name>` key `DefaultDatasourcePlugin` / `DriverPlugin`
* register. A name is listed per driver, never matched as a `driver.*`
* pattern: a non-SQL driver must stay unfound, and the two methods
* {@link findSqlDriverVia} checks are the second half of that guard.
*
* [#23648] `SqliteWasmDriver` (`wasm-sqlite://`, `*.wasm.db`, and the dev
* boot's step-down from better-sqlite3) is a `SqlDriver` published as
* `driver.com.objectstack.driver.sqlite-wasm`, measured on a `wasm-sqlite://`
* boot. Without it every `os migrate` command found no SQL driver on such a
* database.
*/
const SQL_DRIVER_SERVICES = [
'driver.com.objectstack.driver.sql',
'driver.com.objectstack.driver.turso',
'driver.com.objectstack.driver.sqlite-wasm',
'driver.sql',
];
/** Locate the SQL driver behind any `getService`-shaped lookup (kernel or plugin ctx). */
function findSqlDriverVia(
getService: (name: string) => any,
names: readonly string[] = SQL_DRIVER_SERVICES,
): SqlDriverLike | null {
for (const name of names) {
let d: any;
try { d = getService(name); } catch { /* not registered */ }
if (d && typeof d.detectManagedDrift === 'function' && typeof d.applyMigrationEntries === 'function') {
return d as SqlDriverLike;
}
}
return null;
}
function findSqlDriver(kernel: any): SqlDriverLike | null {
return findSqlDriverVia((name) => kernel?.getService?.(name));
}
/**
* [#22579] The `telemetry` sibling's SQL driver, under the service name its
* `DriverPlugin` published (`driver.telemetry`) — the same instance the engine
* routes lifecycle-classed objects to.
*/
function findTelemetryDriver(kernel: any): SqlDriverLike | null {
return findSqlDriverVia((name) => kernel?.getService?.(name), [`driver.${TELEMETRY_DATASOURCE}`]);
}
/**
* The same lookup, for callers that already hold a booted kernel (#8368's
* artifact-boot migration gate) instead of booting one through
* {@link bootSchemaStack}.
*
* Exported rather than re-derived at the call site so there stays ONE list of
* SQL-driver service names: a second copy would silently stop finding a driver
* the day a kind is added here, and "no SQL driver" is this gate's own
* everything-is-fine answer — the quietest possible way for a boot policy to
* stop running.
*/
export function findSqlDriverForKernel(kernel: unknown): SqlDriverLike | null {
return findSqlDriver(kernel);
}
/**
* The kernel services under which `ObjectQLPlugin.init()` publishes the engine.
* Both names point at one `ObjectQL` instance; it is shadowed once.
*/
const ENGINE_SERVICES = ['objectql', 'data'] as const;
/** One order for deferred work, whichever drivers it came from: the driver's own. */
function sortPendingSchemaWork(work: PendingSchemaWork[]): PendingSchemaWork[] {
return work.sort((a, b) => a.table.localeCompare(b.table) || a.kind.localeCompare(b.kind));
}
/**
* [#23022] The kernel service under which `PackageServicePlugin.init()`
* declares `sys_packages` — `@objectstack/service-package`'s
* `PACKAGE_STORAGE_SERVICE`, read by name because the package exports no
* constant for it.
*/
const PACKAGE_STORAGE_SERVICE = 'package.storage';
/** What {@link PACKAGE_STORAGE_SERVICE} answers with (`service-package`'s `PackageStorageDeclaration`). */
interface PackageStorageDeclaration {
readonly table: string;
readonly columns: readonly string[];
/** Read-only: `true` present, `false` absent, `undefined` could not tell. */
present(): Promise<boolean | undefined>;
/** The package registry's own `CREATE TABLE IF NOT EXISTS` and index. */
ensure(): Promise<void>;
}
/**
* [#23022] The package registry's declaration of `sys_packages`, as this boot
* composed it — or `null` when the boot composed no package registry.
*
* [#24059] The ONE reader of that declaration. {@link planPackageTable} plans
* the table from what this returns, and {@link SchemaStack.nonObjectTables}
* names the table from the same object, so `os migrate plan`'s unmanaged-table
* sweep knows the table by the source the plan creates it from — and knows it
* whether or not the table exists, which the plan's pending work cannot say:
* that lists the table only while it is absent.
*
* Throws when the service answers in a shape this CLI does not read: the two
* packages come from different releases, and neither the plan nor the sweep can
* name the table.
*/
function findPackageStorage(kernel: unknown): PackageStorageDeclaration | null {
let service: unknown;
try {
service = (kernel as { getService?: (name: string) => unknown }).getService?.(PACKAGE_STORAGE_SERVICE);
} catch {
return null; // not composed: this boot's deployment serves no package registry
}
if (service === undefined || service === null) return null;
const storage = service as Partial<PackageStorageDeclaration>;
if (
typeof storage.table !== 'string' || !Array.isArray(storage.columns)
|| typeof storage.present !== 'function' || typeof storage.ensure !== 'function'
) {
throw new Error(
`The "${PACKAGE_STORAGE_SERVICE}" service does not have the shape this CLI reads (table, columns, present, `
+ 'ensure), so sys_packages cannot be planned. @objectstack/cli and @objectstack/service-package must come '
+ 'from the same release.',
);
}
return storage as PackageStorageDeclaration;
}
/**
* [#23022] `sys_packages` as pending schema work, when it is absent.
*
* ## Why the plan needs this
*
* `PackageServicePlugin.start()` creates `sys_packages` with raw DDL, because
* the table cannot be a declared object (`service-package`'s
* `package-table.ts` records the measurement: the driver's managed tables key
* on `id` alone, and this one keys on `(id, version)`). So no driver deferral
* ever listed it, and a deployment that sets `OS_SKIP_SCHEMA_SYNC=1` — whose
* boot now creates nothing — was left with no way to get it from the documented
* path. The plugin declares the table in `init()`, which a migrate boot runs,
* and this lists it as `create_table` beside the objects' tables; `apply`'s
* flush then runs the plugin's own statements, so `apply` creates exactly what
* the boot creates.
*
* `null` when there is nothing to plan: the table exists. When its presence
* could not be READ, that is not absence: a note says the table was not
* examined, and nothing is planned.
*/
async function planPackageTable(
declared: PackageStorageDeclaration,
notes: string[],
): Promise<{ storage: PackageStorageDeclaration; work: PendingSchemaWork } | null> {
const present = await declared.present();
if (present === true) return null;
if (present === undefined) {
notes.push(
`${declared.table} (the package registry's table) was NOT examined: its presence could not be read on this `
+ 'database, so this run does not list it and does not create it.',
);
return null;
}
return {
storage: declared,
work: { table: declared.table, kind: 'create_table', columns: [...declared.columns] },
};
}
/**
* Arms deferred-DDL mode on EVERY SQL driver the boot connects, before any of
* them can schema-sync (#3917, #21391).
*
* Timing is the whole point, and it is why this is a plugin rather than a call
* in `bootSchemaStack`. The kernel runs **every** plugin's `init()` (Phase 1)
* before **any** `start()` (Phase 2). `DefaultDatasourcePlugin` connects the
* driver and registers it as `driver.*` in its `init()`; `ObjectQLPlugin` runs
* `syncRegisteredSchemas` — the create-table/add-column DDL this issue is about
* — in its `start()`. An `init()` that depends on the datasource plugin
* therefore lands in the one window where the driver exists and no DDL has run.
*
* ## Every SQL datasource, not the first one (#21391)
*
* This used to arm the first `driver.*` SQL service it found, which is the
* default datasource. Any other SQL datasource reaches the engine through
* `engine.registerDriver` alone: `DatasourceConnectionService.connect()` (how
* `AppPlugin.start()` connects the datasources an artifact declares, and it
* then calls `syncObjectSchema` for the objects bound to each), a host
* plugin's `drivers.register`, or `ObjectQLPlugin.start()` handing the engine
* a `driver.*` service some later `init()` published. Each of those schema-synced
* on a dry run. So the deferral is armed on three paths:
*
* - every `driver.*` service published so far (the default among them);
* - every driver the engine already holds, through its public accessors (the
* default by name, and the driver each registered object resolves to);
* - every driver registered from here on: `registerDriver` is shadowed on the
* engine instance the kernel publishes, and the shadow arms the driver
* BEFORE the engine holds it, then forwards the same instance. This is the
* seam `createDeclarationBootWriteGuard` uses for the same reason; that
* module's header states why an own property on the instance is what every
* caller reaches.
*
* {@link drivers} keeps what was armed, so the stack can report every
* datasource's held-back work and flush all of it on the operator's say-so.
* No driver changes: each one keeps its own deferred set and its own flush.
*
* Exported for its unit pin (`schema-migrate.remote-posture.test.ts`, #23816),
* which holds the primary exemption without booting a kernel.
*/
export class DeferSchemaDdlPlugin {
name = 'com.objectstack.cli.defer-schema-ddl';
version = '1.0.0';
/** Ordering, not optionality: our init must follow the one that registers `driver.*`. */
dependencies = ['com.objectstack.runtime.default-datasource'];
/** Every driver this boot deferred, in the order it was armed. */
readonly drivers: SqlDriverLike[] = [];
/** The kernel's logger, as `init()` was handed it: what the flush's birth half reports through. */
logger: BirthLogger | undefined;
/**
* [#23816] `exemptPrimary`: leave the engine's DEFAULT driver un-armed. Set
* only by a `skipSchemaSync` boot (`deferredDdlUnsupported:
* 'skip-schema-sync'`), where the engine sends that driver no DDL at all and
* its transport would refuse the arm. Every other driver is armed as before.
*/
constructor(private readonly options: { exemptPrimary?: boolean } = {}) {}
/** The driver {@link options}.exemptPrimary leaves un-armed, once `init()` has found it. */
private exempt: unknown = null;
/** Engines whose `registerDriver` is shadowed, and what to restore. */
private readonly shadows: Array<{
engine: Record<string, unknown>;
original: PropertyDescriptor | undefined;
shadow: (...args: unknown[]) => unknown;
}> = [];
init = async (ctx: any) => {
this.logger = ctx.logger;
if (this.options.exemptPrimary) this.exempt = defaultDriverOf(ctx);
for (const name of SQL_DRIVER_SERVICES) {
try { this.arm(ctx.getService(name)); } catch { /* not registered */ }
}
const services: Map<string, unknown> | undefined = ctx.getServices?.();
for (const [name, service] of services?.entries?.() ?? []) {
if (typeof name === 'string' && name.startsWith('driver.')) this.arm(service);
}
for (const name of ENGINE_SERVICES) {
let engine: unknown;
try { engine = ctx.getService(name); } catch { /* not registered */ }
if (engine && typeof engine === 'object') this.shadowEngine(engine as Record<string, unknown>);
}
if (this.drivers.length === 0) {
// No SQL driver yet (memory/mongo). Nothing has DDL to defer; a SQL
// driver registered later is armed on arrival by the shadow above.
ctx.logger?.debug?.('[defer-schema-ddl] no SQL driver yet — deferral armed on arrival');
}
};
/** Arm one driver. Idempotent; a non-SQL driver has nothing to defer. */
arm(driver: unknown): void {
if (!driver || typeof driver !== 'object') return;
if (driver === this.exempt) return;
const d = driver as SqlDriverLike;
if (this.drivers.includes(d)) return;
if (typeof d.setDeferredDdl === 'function') {
d.setDeferredDdl(true);
this.drivers.push(d);
return;
}
if (typeof d.detectManagedDrift === 'function' && typeof d.applyMigrationEntries === 'function') {
// Fail loudly rather than silently boot-syncing: the caller asked for a
// dry run and this driver cannot give one.
const name = (driver as { name?: unknown }).name;
throw new Error(
`The SQL driver${typeof name === 'string' ? ` '${name}'` : ''} does not support deferred schema DDL, ` +
'so this command cannot guarantee a dry run. Upgrade @objectstack/driver-sql.',
);
}
}
/** Arm what the engine holds, and every driver it is handed from now on. */
private shadowEngine(engine: Record<string, unknown>): void {
if (this.shadows.some((s) => s.engine === engine)) return; // `objectql` and `data` are one instance
const registerDriver = engine.registerDriver;
if (typeof registerDriver !== 'function') return;
this.armHeld(engine);
const original = Object.getOwnPropertyDescriptor(engine, 'registerDriver');
const shadow = (...args: unknown[]): unknown => {
// Arm BEFORE forwarding: the connect that registers a driver calls
// `syncObjectSchema` on it in the very next statement.
this.arm(args[0]);
return Reflect.apply(registerDriver as (...a: unknown[]) => unknown, engine, args);
};
Object.defineProperty(engine, 'registerDriver', {
value: shadow,
writable: true,
configurable: true,
enumerable: original?.enumerable ?? false,
});
this.shadows.push({ engine, original, shadow });
}
/** The drivers the engine already holds, through the accessors it makes public. */
private armHeld(engine: Record<string, unknown>): void {
const e = engine as {
getDefaultDriverName?: () => unknown;
getDriverByName?: (name: string) => unknown;
getDriverForObject?: (name: string) => unknown;
registry?: { getAllObjects?: () => unknown };
};
const name = e.getDefaultDriverName?.();
if (typeof name === 'string') this.arm(e.getDriverByName?.(name));
if (typeof e.getDriverForObject !== 'function') return;
const objects = e.registry?.getAllObjects?.();
for (const obj of Array.isArray(objects) ? objects : []) {
const objectName = (obj as { name?: unknown } | null)?.name;
if (typeof objectName === 'string') this.arm(e.getDriverForObject(objectName));
}
}
/** Put every shadowed `registerDriver` back, unless something else now sits on top of ours. */
release(): void {
for (const { engine, original, shadow } of this.shadows.splice(0)) {
if (Object.getOwnPropertyDescriptor(engine, 'registerDriver')?.value !== shadow) continue;
if (original) Object.defineProperty(engine, 'registerDriver', original);
else delete engine.registerDriver;
}
}
}
/** `@objectstack/runtime`'s birth half, loaded lazily as the rest of the runtime is here. */
type RecordOrganizationOwnershipBirth = typeof import('@objectstack/runtime').recordOrganizationOwnershipBirth;
/**
* What the birth half reports through: the runtime's own parameter, which reads
* `info` alone. A failure is never logged on this path — it is thrown to the
* caller ({@link recordBirthOfFlushedStore}) — so the sink has no error channel.
*/
type BirthLogger = Parameters<RecordOrganizationOwnershipBirth>[1];
/**
* [ADR-0131 D10; ruling record 6269955919, ① A] The organization-ownership
* BIRTH half of a deferred boot, run once {@link SchemaStack.flushSchemaDdl}
* has performed the DDL the boot held back.
*
* Every boot through here composes `OrganizationOwnershipBirthMarkerPlugin`
* (`createStandaloneStack`), which writes the birth marker when its `start()`
* sees the ledger's store created from empty. A deferred boot creates nothing
* by then: each driver stores its objects and skips them before counting, so
* the store reports nothing created and the plugin writes nothing. The tables
* are created HERE, at the flush. Without this step a fresh install whose
* first command is `os migrate apply` carries no marker, and its first `os
* serve` refuses it as a database an earlier release created.
*
* The decision is the runtime's own (`recordOrganizationOwnershipBirth`), read
* now: the ledger store's driver counts, from its `hasTable` probe, every
* table the flush created and every one it found, so `existing === 0 &&
* created > 0` holds only when no declared table was there before this run.
* ⛔ Never judged from the work the flush returns: a table already in sync
* produces no entry there, so "every entry is a create_table" also describes
* a populated database that merely gained a table — exactly the database
* that must never receive a birth marker. A store the flush did not touch
* (the Turso remote primary, whose schema sync this boot skipped) counts
* nothing created and is left to its first serving boot.
*
* A failed write throws: the tables are there and the marker is not, so the
* first `os serve` would refuse this database, and the command must not end
* reporting success. The ceremony certifies such a database.
*
* Exported for its unit pin (`schema-migrate.birth-after-flush.test.ts`), which
* holds the failed-write refusal without booting a kernel.
*/
export async function recordBirthOfFlushedStore(
kernel: { getService?: (name: string) => unknown },
recordBirth: RecordOrganizationOwnershipBirth,
logger: BirthLogger | undefined,
): Promise<void> {
let engine: unknown;
try {
engine = kernel.getService?.('objectql');
} catch {
return; // no engine — no ledger to write
}
if (!engine || typeof (engine as { getDriverForObject?: unknown }).getDriverForObject !== 'function') return;
try {
await recordBirth(engine as Parameters<RecordOrganizationOwnershipBirth>[0], logger);
} catch (error) {
throw new Error(
`This run created the database from empty, and its organization-ownership birth marker (sys_migration ` +
`'${ORGANIZATION_OWNERSHIP_MIGRATION_ID}') could not be written ` +
`(${error instanceof Error ? error.message : String(error)}). Without it the first 'os serve' takes this ` +
`database for one an earlier release created and refuses it. Fix the write, then run 'os migrate ` +
`organization-ownership --out PLAN' and 'os migrate organization-ownership --apply --plan-file PLAN ` +
`--backup-taken', which certifies it.`,
{ cause: error },
);
}
}
/**
* [#23816] The engine's DEFAULT driver — the primary datasource's — read
* through the accessors the engine makes public, or `null` when the engine
* names none yet.
*/
function defaultDriverOf(ctx: { getService?: (name: string) => unknown }): unknown {
for (const name of ENGINE_SERVICES) {
let engine: { getDefaultDriverName?: () => unknown; getDriverByName?: (name: string) => unknown } | undefined;
try { engine = ctx.getService?.(name) as typeof engine; } catch { /* not registered */ }
const driverName = engine?.getDefaultDriverName?.();
if (typeof driverName === 'string') return engine?.getDriverByName?.(driverName) ?? null;
}
return null;
}
/**
* [#24120] The kernel service `AppPlugin` connects a deployment's declared
* datasources through — ADR-0062 D1's one "definition → live driver" path,
* `DatasourceConnectionService`. `os serve` publishes it from
* `DatasourceAdminServicePlugin`; without a publisher, `AppPlugin` leaves every
* declared datasource metadata-only, which is how a schema-migration boot came
* to register no driver for the datasource a `datasourceMapping` rule routes
* its objects to.
*/
const DATASOURCE_CONNECTION_SERVICE = 'datasource-connection';
/**
* [#24120] The connect policy of a schema-migration boot: MANAGED datasources
* only. A datasource whose schema the remote owns (`schemaMode: 'external'` /
* `'validate-only'`) holds no table a migration diffs — its objects are
* federated, counted apart by the coverage pass — so a dry run opens no
* connection to it. Every managed datasource connects as on `os serve`.
*/
const MANAGED_DATASOURCES_ONLY = {
canConnect: (ds: { schemaMode?: string }): { allow: boolean; reason?: string } =>
ds.schemaMode === undefined || ds.schemaMode === 'managed'
? { allow: true }
: {
allow: false,
reason: `schemaMode '${ds.schemaMode}': the remote owns this datasource's schema, `
+ 'so a schema migration has no table to plan on it',
},
};
/**
* [#24120] Publishes {@link DATASOURCE_CONNECTION_SERVICE} on a schema-migration
* boot, so `AppPlugin` connects the deployment's declared datasources by the
* code `os serve` runs, under their declared names — ahead of the first schema
* sync, which is where `AppPlugin` connects a datasource its mapping routes
* registered objects to. Each connect reaches the engine through
* `registerDriver`, so {@link DeferSchemaDdlPlugin} arms it before it syncs and
* the declaration write guard refuses its row writes, exactly as for the
* primary.
*
* Two differences from `os serve`'s publisher, both because this boot is a
* migration and not a server: the policy is {@link MANAGED_DATASOURCES_ONLY},
* and a read-only probe's factory opens an absent SQLite file as an empty
* in-memory database (`sqliteAbsentFile`, #6743), as the primary's does. Not
* composed when the composition already brings a publisher (a host config
* listing `DatasourceAdminServicePlugin`): the kernel refuses a second.
*/
export class DeclaredDatasourceConnectionPlugin {
name = 'com.objectstack.cli.declared-datasource-connection';
version = '1.0.0';
/** ADR-0116: the service `init()` registers on every path. */
providesServices = [DATASOURCE_CONNECTION_SERVICE];
constructor(private readonly options: { readOnlyProbe?: boolean } = {}) {}
init = async (ctx: any) => {
// Lazy, as every driver-adjacent import in this module is (#5726).
const { DatasourceConnectionService, createDefaultDatasourceDriverFactory } =
await import('@objectstack/service-datasource');
const factory = createDefaultDatasourceDriverFactory(
this.options.readOnlyProbe ? { sqliteAbsentFile: 'empty-in-memory' } : {},
);
ctx.registerService(DATASOURCE_CONNECTION_SERVICE, new DatasourceConnectionService({
factory: () => factory,
engine: () => {
try { return ctx.getService('data'); } catch { return undefined; }
},
policy: MANAGED_DATASOURCES_ONLY,
logger: ctx.logger,
}));
};
}
/** Does any of `plugins` already publish {@link DATASOURCE_CONNECTION_SERVICE}? */
function publishesDatasourceConnection(plugins: readonly unknown[]): boolean {
return plugins.some((p) => {
const provides = (p as { providesServices?: unknown } | null)?.providesServices;
return Array.isArray(provides) && provides.includes(DATASOURCE_CONNECTION_SERVICE);
});
}
/**
* [#24120] The declared datasources this boot connected that the deployment's
* objects live in: each SQL driver registered under a datasource name the
* {@link DATASOURCE_CONNECTION_SERVICE} reports `available` that a registered,
* non-federated object resolves to — the engine's own routing answers which,
* never a re-read of the rules. `planned` (the primary and the sibling) is not
* repeated. Sorted by name, so the plan reads in one order.
*
* `available`, not the `connected` status: `AppPlugin` connects a mapped
* datasource before the first schema sync and asks again in `start()`, and the
* service retains the LAST verdict — `already-registered` for the same live
* driver.
*/
function findDeclaredDatasourceDrivers(
kernel: unknown,
planned: readonly SqlDriverLike[],
): Array<{ name: string; driver: SqlDriverLike }> {
const service = (name: string): any => {
try { return (kernel as { getService?: (n: string) => unknown })?.getService?.(name); } catch { return undefined; }
};
const states: unknown = service(DATASOURCE_CONNECTION_SERVICE)?.listConnectionStates?.();
const connected = new Set(
(Array.isArray(states) ? states : [])
.filter((s: { availability?: unknown }) => s?.availability === 'available')
.map((s: { name?: unknown }) => s.name),
);
if (connected.size === 0) return [];
const engine = service('objectql');
const objects: unknown = engine?.registry?.getAllObjects?.();
if (!Array.isArray(objects) || typeof engine?.getDriverForObject !== 'function') return [];
const found = new Map<string, SqlDriverLike>();
for (const obj of objects as Array<{ name?: unknown; external?: unknown }>) {
if (obj?.external != null || typeof obj?.name !== 'string') continue;
const driver = engine.getDriverForObject(obj.name);
const name = driver?.name;
if (typeof name !== 'string' || !connected.has(name) || found.has(name) || planned.includes(driver)) continue;
if (typeof driver.detectManagedDrift === 'function' && typeof driver.applyMigrationEntries === 'function') {
found.set(name, driver as SqlDriverLike);
}
}
return [...found].sort(([a], [b]) => a.localeCompare(b)).map(([name, driver]) => ({ name, driver }));
}
/**
* [#23816] Does the transport the boot will open for `resolved` refuse
* deferred schema DDL? Today exactly the Turso REMOTE transport. The
* standalone stack builds a `turso` datasource as `{ url, authToken }`, so
* `TursoDriver.detectMode` reads the resolved URL exactly as the driver the
* boot constructs will: the driver package answers, not a copy of its rule.
*
* Loaded lazily, as every driver is from this module (#5726). A package that
* cannot be loaded answers `false`, and the boot then fails where it always
* has, in `@objectstack/runtime`'s loader, naming the package to install.
* Exported for its unit pin (`schema-migrate.remote-posture.test.ts`).
*/
export async function transportRefusesDeferredDdl(resolved: { driver: string; url: string }): Promise<boolean> {
if (resolved.driver !== 'turso') return false;
let detectMode: ((config: { url: string }) => string) | undefined;
try {
detectMode = (await import('@objectstack/driver-turso')).TursoDriver.detectMode;
} catch {
return false;
}
return detectMode({ url: resolved.url }) === 'remote';
}
/**
* Name the database the migrate/resync commands are about to write to.
*
* Shares the startup banner's renderer (#3793): the same
* `{ connectionString }` shape that made the banner print `(unknown)` used to
* fall through here to a bare `pg` — and this string is what the
* `Apply N change(s) to …?` confirm shows, so it has to name the real target.
* Falls back to the client name only when the config carries no address at all.
*/
function describeDb(driver: SqlDriverLike | null): string {
const cfg: any = driver?.config;
if (!cfg) return 'unknown';
return describeDriverConnection(cfg) ?? String(cfg.client ?? 'unknown');
}
/**
* [#24120] One planned driver per DATABASE, for a reading of what each
* database holds (`os migrate plan`'s unmanaged-table sweep): the first driver
* of each, in {@link SchemaStack.drivers} order.
*
* Two planned drivers can address one database. A `datasourceMapping` default
* the primary resolves its location from is connected again under its declared
* name, exactly as `os serve` connects it — the primary's file and the declared
* datasource's are one file — and read twice, every table counted twice.
* Identity is the database file a SQLite driver names
* ({@link describeDriverSqliteFile}, resolved as SQLite opens it), otherwise
* the connection a driver describes; a driver with neither (an in-memory
* database, each connection its own) is never folded into another.
*/
export function distinctDatabaseDrivers(drivers: readonly SqlDriverLike[]): SqlDriverLike[] {
const seen = new Set<string>();
const out: SqlDriverLike[] = [];
for (const driver of drivers) {
const file = describeDriverSqliteFile(driver.config);
const identity = file !== undefined
? `file:${path.resolve(file)}`
: hasSqliteFilename(driver.config) ? undefined : describeDriverConnection(driver.config);
if (identity !== undefined && seen.has(identity)) continue;
if (identity !== undefined) seen.add(identity);
out.push(driver);
}
return out;
}
/** Does a driver config name a SQLite target at all — a file, `:memory:`, or SQLite's private temporary database? */
function hasSqliteFilename(config: unknown): boolean {
if (!config || typeof config !== 'object') return false;
const cfg = config as Record<string, unknown>;
const conn = cfg.connection;
const bag = conn && typeof conn === 'object' ? conn as Record<string, unknown> : cfg;
return typeof (bag.filename ?? cfg.filename) === 'string';
}
// ── The project's `.env*` files (#22581) ────────────────────────────
/**
* One load of a project's `.env*` files: what they hold, with the file each
* variable came from, and this process's environment as it stood BEFORE the
* load — the environment "exported" is judged against.
*/
export interface ProjectEnvLoad {
readonly reading: DotenvReading;
readonly shellEnv: Readonly<NodeJS.ProcessEnv>;
}
/** `[project root, mode]` → its load, for this process. See {@link loadProjectEnvFiles}. */
const projectEnvLoads = new Map<string, ProjectEnvLoad>();
/**
* Load the project's `.env*` files into this process as `os serve`, `os start`
* and `os dev` do, before anything resolves a database or a secret (#22581).
*
* The serving commands load them first thing; a one-shot boot used to read
* `process.env` alone. So a project keeping `OS_DATABASE_URL` in `.env` served
* one database and migrated another, and an `OS_AUTH_SECRET` kept there left
* the auth family out of `os migrate plan`.
*
* ## The serving commands' load, not a second one
*
* `dotenvFlow.config({ node_env, silent: true })` is the call `serve` /
* `start` / `dev` make, with `os serve`'s mode rule: `--dev` → `development`,
* else `NODE_ENV === 'test' ? 'test' : NODE_ENV || 'production'` — the half
* {@link doctorNodeEnv} spells, and all of `os start`'s. `dev` is set only when
* the boot composes as `os serve --dev` (`serveFlags.dev`: `os migrate
* security-catalog-overlays --dev`), so that command reads the development
* files the deployment it names serves with. dotenv-flow never overrides a
* variable this process already has, so an exported `OS_DATABASE_URL` keeps
* its precedence over `.env`, exactly as it does for `serve`. The file list and
* the per-variable origin are dotenv-flow's own (`readDotenvFiles`, the reader
* `os doctor` reports with), so the source this stack names is the file the
* load took the value from.
*
* ## Once per project root and mode per process
*
* Called by the occupancy probe (`./migrate-occupancy-gate.ts`), which resolves
* the target BEFORE the boot, and by {@link bootSchemaStack}; the second call
* returns the first one's record. Not only for cost: after the load every
* `.env` variable is in `process.env`, so a second snapshot would report all
* of them as exported.
*
* The record is keyed by root AND mode, so a `--dev` load and a plain one of the
* same root never share one. A real run never makes both: the process runs one
* command, and that command's probe and boot are handed the same `dev`. Only a
* test driving several commands in one process can, and it restores the
* environment between them, as the loads cannot take each other's values back.
*/
export function loadProjectEnvFiles(
projectRoot: string = process.cwd(),
opts: { readonly dev?: boolean } = {},
): ProjectEnvLoad {
const root = path.resolve(projectRoot);
const nodeEnv = opts.dev === true ? 'development' : doctorNodeEnv();
const key = JSON.stringify([root, nodeEnv]);
const prior = projectEnvLoads.get(key);
if (prior) return prior;
const shellEnv = { ...process.env };
const reading = readDotenvFiles(root, nodeEnv);
dotenvFlow.config({ node_env: nodeEnv, path: root, silent: true });
const load: ProjectEnvLoad = { reading, shellEnv };
projectEnvLoads.set(key, load);
return load;
}
/**
* [#22581] Who named the database a one-shot boot opened — the `--json` face of
* `os migrate plan` / `apply` (`databaseSource`).
*
* - `flag` — `--database-url`;
* - `process-env` — a variable this process was started with;
* - `env-file` — a variable from one of the project's `.env*` files, named by
* its file name (dotenv-flow reads them from the project root only);
* - `config-datasource` — the datasource the project config declares as its
* default home;
* - `default` — nobody: the project's default database file.
*/
export type DatabaseSource =
| { readonly kind: 'flag' }
| { readonly kind: 'process-env'; readonly variable: string }
| { readonly kind: 'env-file'; readonly variable: string; readonly file: string }
| { readonly kind: 'config-datasource'; readonly datasource: string }
| { readonly kind: 'default' };
/**
* The variable the env rung of `resolveProjectDatabaseUrl` (`@objectstack/runtime`)
* read: `(OS_DATABASE_URL ?? DATABASE_URL)` when that is non-blank, else
* `TURSO_DATABASE_URL` — its own `??` order. `schema-migrate.database-source.test.ts`
* holds the two equal over every combination of the three.
*/
export function envRungVariable(env: Readonly<NodeJS.ProcessEnv>): string {
if ((env.OS_DATABASE_URL ?? env.DATABASE_URL)?.trim()) {
return env.OS_DATABASE_URL !== undefined ? 'OS_DATABASE_URL' : 'DATABASE_URL';
}
return 'TURSO_DATABASE_URL';
}
/**
* Name the source of the database the resolution answered, from the rung it
* answered on (`resolveStandaloneDatabase`'s `source`) and the env load.
*/
export function resolveDatabaseSource(
load: ProjectEnvLoad,
resolved: { readonly source: string; readonly datasourceName?: string },
explicitUrl: string | undefined,
): DatabaseSource {
/** A variable's source: the `.env*` file it was loaded from, or the process environment. */
const fromVariable = (variable: string): DatabaseSource => {
const provenance = provenanceOf(load.reading, variable, load.shellEnv);
return provenance.source === 'file' && provenance.file
? { kind: 'env-file', variable, file: path.basename(provenance.file) }
: { kind: 'process-env', variable };
};
switch (resolved.source) {
case 'explicit':
// Every caller's `--database-url` declares `env: 'OS_DATABASE_URL'`, so
// oclif hands the variable's value over as the flag's. An explicit URL
// equal to it is the variable, wherever that came from.
return explicitUrl !== undefined && process.env.OS_DATABASE_URL === explicitUrl
? fromVariable('OS_DATABASE_URL')
: { kind: 'flag' };
case 'env':