Repository navigation
Expand file tree
/
Copy pathschema-drift.ts
More file actions
2682 lines (2582 loc) · 134 KB
/
Copy pathschema-drift.ts
File metadata and controls
2682 lines (2582 loc) · 134 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.
/**
* Managed-datasource schema drift (issue #2186).
*
* The driver's `initObjects` sync is *additive-only*: it creates missing
* tables and adds missing columns, but never alters or drops existing ones.
* So a non-additive metadata change (relax `storage.notNull`, change a type/length,
* drop or rename a field) silently diverges from an existing database — the
* served metadata says one thing and the physical column enforces another.
*
* This module is the single source of truth for *detecting* that divergence
* (metadata is authoritative on a `managed` datasource) and for *categorising*
* each divergence by how dangerous it is to reconcile:
*
* - `safe` — loosening that cannot lose data and cannot fail:
* relax NOT NULL → NULL, widen a varchar. Applied
* automatically by dev auto-reconcile (P2).
* - `needs_confirm`— a change a human should eyeball but that does not
* destroy data (e.g. a non-narrowing type change).
* - `destructive` — drops or tightenings that can lose data or fail:
* drop an orphaned column, narrow a varchar, add a
* NOT NULL constraint over possibly-null data. Only
* applied by `os migrate apply --allow-destructive`.
*
* The detector reuses {@link SchemaDiffEntry} (the same shape the external /
* federated validator emits, ADR-0015 §5.2) so CLI / Studio / audit can render
* managed and external drift uniformly.
*/
import { createHash } from 'node:crypto';
import {
isAppResolvedDefaultToken,
isUniqueDeclared,
STRUCTURED_JSON_TYPES,
FILE_REFERENCE_TYPES,
MULTI_OPTION_TYPES,
isMultiValueField,
} from '@objectstack/spec/data';
import type { SchemaDiffEntry } from '@objectstack/spec/shared';
// ───────────────────────────────────────────────────────────────────────
// Unique-scope vocabulary (ADR-0120)
// ───────────────────────────────────────────────────────────────────────
/**
* Sentinel naming the NULL-organization ("platform") bucket — ADR-0120 D3.
*
* Every organization-scoped unique index materializes its organization key
* part as `COALESCE(organization_id, '__global__')` instead of the raw column:
* SQL UNIQUE is NULL-distinct, so the raw column enforced NOTHING on rows
* whose organization is NULL — which is every row on a single-tenant stack
* (#5030). The COALESCE folds all NULL-organization rows into one bucket,
* unique among themselves, without touching the other rows.
*
* Three invariants, all deliberate (ADR-0120 D3, maintainer-resolved):
* - **Storage stays NULL.** Only the index folds NULL into the bucket; a
* `WHERE organization_id = '__global__'` matches nothing, by design.
* - **The word is the platform's existing name for this bucket** — the
* autonumber sequence table keys global rows by the same sentinel
* (`SqlDriver`'s `GLOBAL_TENANT`), so a constraint-violation error reading
* `(__global__, a@b.com)` says "platform bucket", not "corrupt data".
* - **The token is reserved**: an organization id may never equal it
* (guarded at the organization-creation seam in plugin-auth).
*
* Since ADR-0131 D1 the tenant column is NOT NULL, so new DDL keys the plain
* column and no longer emits this form. The COALESCE form stays READ:
* every database an earlier release built keeps those indexes, and the
* differ takes them as equivalent to the plain key on a certified database
* ({@link diffManagedIndexes}'s `tenantKeyCertified`).
*/
export const GLOBAL_TENANT = '__global__';
/**
* Driver-side unique-scope vocabulary — ADR-0120 D1.
*
* `'organization'` is accepted here AHEAD of the spec schema: #4986 lands the
* spec/lint token separately, and the merge order is deliberately driver first
* so spec-side acceptance never outruns driver-side enforcement. Until then,
* spec's `isUniqueDeclared` / `isGlobalUnique` know nothing of
* `'organization'`, so these wrappers are the single judgment point inside
* driver-sql — every scope decision in this package reads them, never the
* spec helpers directly.
*/
export function isUniqueScopeDeclared(unique: unknown): boolean {
return unique === 'organization' || isUniqueDeclared(unique);
}
/**
* The organization-scoped spellings of a FIELD-level `unique`: bare `true`
* (the positional synonym, unchanged since #3696) and the explicit
* `'organization'` word (ADR-0120 D1). Pass a field's `unique`; do NOT pass a
* declared index's.
*
* This is NOT the scope judgment for a declared index, and it must not be
* reached for there. {@link normalizeDeclaredIndex} decides with a strict
* `idx?.unique === 'organization'` instead, so a declared index's bare `true`
* is taken VERBATIM as global — the `'global'` arm. That the two paths judge
* the same token differently is the answer to #4986, not an oversight: the
* spellings were authored under different contracts, and both halves are
* pinned (`sql-driver-declared-index-organization-respelling.test.ts`).
*
* Routing the declared-index branch through this predicate so code and comment
* agree is REJECTED — maintainer ruling 2026-08-13, option 1 of #8323. It
* would silently reinterpret every existing declared `unique: true` on
* deployed databases as organization-scoped: an unannounced index migration,
* landing a release BEFORE #5082 refuses the bare spelling — the
* two-migrations-with-contradictory-meanings sequence that ruling exists to
* avoid. Whether a declared index's bare `true` should be refused at all is
* PARKED on #5082 (v18 D2: bare `true` → `'global'` plus a loud refusal).
* Until that lands the divergence stays, surfaced to authors rather than
* silently repaired: lint `unique/unscoped-declared-index` warns on it
* (`packages/lint/src/data-model-rules.ts`) and `IndexSchema.unique`'s
* `describe()` states it (`packages/spec/src/data/object.zod.ts`).
*/
export function isOrganizationScopedUnique(unique: unknown): boolean {
return unique === true || unique === 'organization';
}
/**
* The NULL-safe organization key part, spelled once (ADR-0120 D3). Display and
* signature form — DDL emission quotes the identifier per dialect.
*
* Read vocabulary only since ADR-0131 D1: no generator marks the tenant key
* part NULL-safe any more, so this renders an index an earlier release built,
* or an op a caller hands in already normalized.
*/
export function organizationKeyPartSql(column: string): string {
return `COALESCE(${column}, '${GLOBAL_TENANT}')`;
}
export type SqlDialectName = 'sqlite' | 'postgres' | 'mysql' | 'unknown';
export type DriftCategory = 'safe' | 'needs_confirm' | 'destructive';
/**
* A reconcilable schema operation, machine-readable for the reconciler.
*
* Column ops name a single `column`; index ops (#3728) name the index and may
* span several columns, so their `column` carries only the leading one — set so
* the sorting / rendering already keyed on it keeps working.
*/
export type DriftOp =
| { type: 'relax_not_null'; table: string; column: string }
| { type: 'tighten_not_null'; table: string; column: string }
| { type: 'widen_varchar'; table: string; column: string; to: number; from?: number }
| { type: 'narrow_varchar'; table: string; column: string; to: number; from?: number }
| { type: 'drop_column'; table: string; column: string }
/**
* Strip a column DEFAULT metadata never asked for (#4560).
*
* Today's only source is a `defaultValue` runtime token that a pre-fix build
* emitted as a literal (`DEFAULT 'current_user'`), so every insert that
* omitted the field got the token's own spelling instead of the engine's
* deliberate "leave it unset". Dropping it cannot fail and cannot lose data —
* stored rows keep whatever they hold; only FUTURE omitted inserts change,
* from a bogus literal to NULL. Rows already carrying the bogus value are NOT
* rewritten: they stay visible to the dangling-reference audit (#4551), whose
* standing rule is report, never rewrite.
*/
| { type: 'drop_column_default'; table: string; column: string }
/**
* REPORT ONLY (#11535). The column's base type diverges from the one the
* declaration materialises, and **the platform deliberately does not change
* it** — an operator must, by hand.
*
* ⛔ There is NO reconciler arm for this op, and adding one is not a
* refactor. Whether ObjectStack should perform the `ALTER TABLE … TYPE …
* USING …` itself is a **live maintainer decision** (the other half of
* #11535): it is a migration over existing rows plus an index drop/rebuild,
* i.e. destructive and hard to roll back. This op exists so the divergence
* can be SEEN travelling the same plan/report road as every other finding —
* it is the absence of an automatic migration made explicit, not a
* placeholder for one.
*
* Measured consequence of having no arm, on live Postgres 16.13 (the same on
* MySQL 8.0.46): `applyDriftOpInPlace` matches no case and returns `false`,
* so `applyMigrationEntries` reports the entry as **skipped, never applied**,
* and logs it. That is the intended behaviour, not a gap to close. SQLite
* reaches the same verdict by a different road (#11722): its arm rebuilds the
* table and reports as applied only the entries the rebuild actually acted
* on, so an op with no arm is skipped there too rather than announced as
* migrated.
*
* `from`/`to` are the physical and declared type words, carried so a renderer
* can show the divergence without re-deriving it from the message.
*/
| { type: 'manual_column_type_change'; table: string; column: string; to: string; from: string }
/**
* REPORT ONLY (#12121). The column is a `varchar(n)` under a TEXT-family
* field that declared NO usable bound — the shape `createColumn` emits as
* TEXT — so the column caps writes the declaration allows, and **the platform
* deliberately does not change it**.
*
* ⛔ A DISTINCT op rather than a second use of `manual_column_type_change`,
* for a measured reason and not a stylistic one: `os migrate
* multi-value-columns` selects its ENTIRE population by
* `op.type === 'manual_column_type_change'` (`isStaleMultiValueColumn`, "the
* only op this command touches") and then recovers the dialect by matching the
* finding's message against {@link manualJsonConversionSql}. Reusing that op
* would hand this finding to a command whose remedy converts the column to
* **json** — the wrong column type for a signature — and, since this message
* embeds no json statement, the command would file it under
* `remedy_not_recognized`: one refusal line per finding, on every run, for a
* divergence that command has no business with.
*
* ⛔ There is NO reconciler arm, deliberately. `applyDriftOpInPlace` matches
* no case, so `applyMigrationEntries` reports the entry as skipped and logs
* it. Whether ObjectStack should run the `ALTER` itself is a separate decision
* with hazards this differ must not pre-empt: on MySQL a `MODIFY` restates the
* WHOLE column definition (silently dropping a NOT NULL or DEFAULT that is not
* repeated) and a TEXT column cannot carry a plain index without a prefix
* length, so the conversion can turn a working table into one whose declared
* index no longer exists.
*
* `from` is the physical type word and `to` is always `'text'`, spelled the way
* `manual_column_type_change` spells them so a renderer showing `from → to`
* needs no new arm.
*/
| { type: 'manual_widen_varchar_to_text'; table: string; column: string; to: 'text'; from: string }
/**
* Retire the legacy platform-wide UNIQUE index on a now-tenant-scoped field
* and put the composite `(tenantField, field)` in its place (#3696). The two
* names differ, so the reconciler creates before it drops — uniqueness is
* never unenforced in between — and because the old index is strictly
* stronger than the new one, the replacement can neither fail nor lose data.
*/
| {
type: 'replace_unique_index';
table: string;
column?: string;
dropIndexNames: string[];
createIndexName: string;
createColumns: string[];
/** Columns whose key part is the NULL-safe organization form (ADR-0120 D3). */
nullSafeColumns?: string[];
}
/** Materialize a declared index that has no physical counterpart. */
| {
type: 'create_index';
table: string;
column?: string;
indexName: string;
columns: string[];
unique: boolean;
/** Columns whose key part is the NULL-safe organization form (ADR-0120 D3). */
nullSafeColumns?: string[];
}
/** Drop an index ObjectStack generated that metadata no longer declares. */
| { type: 'drop_index'; table: string; column?: string; indexName: string }
/**
* An index exists under the declared name but with a different definition.
* `syncDeclaredIndexes` is name-idempotent and would skip it forever, so the
* only fix is drop-then-create under the same name.
*/
| {
type: 'recreate_index';
table: string;
column?: string;
indexName: string;
columns: string[];
unique: boolean;
/** Columns whose key part is the NULL-safe organization form (ADR-0120 D3). */
nullSafeColumns?: string[];
/**
* ADR-0120 D4: the divergence is EXACTLY the bare organization column
* tightening into its NULL-safe COALESCE form — same column identities,
* same uniqueness, physical index fully plain. This is the one recreate
* whose danger is data-dependent rather than structural, so it goes
* through the duplicate pre-flight probe: clean → `safe` (dev
* `autoMigrate: 'safe'` may apply it), duplicates found → blocked with a
* row report, old index left in place.
*/
tightenNullSafeOnly?: boolean;
}
/**
* REPORT ONLY. Metadata declares an index that can never be built, because
* a key column is one no declaration will ever materialize: the name is not
* a field of the object (a misspelling the Studio save door admits), or it
* is a virtual `formula` field, which is computed on read and has no column.
* `SqlDriver.syncDeclaredIndexes` skips the whole index and says so at
* `error`. For a UNIQUE index, the constraint it declares is NOT enforced
* while the object keeps working normally, which is the AGENTS.md
* durability-degradation shape.
*
* ⛔ There is NO reconciler arm, and none can exist: there is no column to
* build the index over. The remedy is a metadata edit (name stored fields in
* `indexes[].fields`, or drop the index), so `applyMigrationEntries` reports
* the entry as skipped, on every dialect. The op sits in
* {@link INDEX_DRIFT_OPS} so that skip takes the index path and never
* triggers a SQLite table rebuild.
*
* This op exists so that `os migrate plan` can show the skip. Before it,
* {@link expectedIndexes} dropped such an index from the expected set, and
* the plan reported nothing at all.
*
* `missingColumns` lists only the key columns that will never materialize.
* A declared column that is merely not added YET is pending additive work,
* not this finding.
*/
| {
type: 'unbuildable_index';
table: string;
column?: string;
indexName: string;
unique: boolean;
missingColumns: string[];
};
/**
* Physical work the boot sync is holding back (#3917).
*
* Distinct from {@link DriftOp}: drift is divergence between metadata and an
* EXISTING column/index that only a deliberate reconcile may resolve, whereas
* this is the work `initObjects` performs on its own — captured rather than
* executed while the driver runs with DDL deferred, so `os migrate plan` can
* show it and `os migrate apply` can gate it behind the confirmation prompt.
*
* The plan's promise is that it shows what `apply` will do, so **anything added
* to `initObjects`' physical path has to be representable here** — otherwise an
* operator confirms a two-column plan and `apply` additionally rewrites a table.
* That is the gap #3954 closed for the datetime convergence; keep it closed.
*/
export interface PendingSchemaWork {
table: string;
kind: PendingSchemaWorkKind;
/**
* Declared columns for a create; the missing ones for an add; the columns
* being converged for the two datetime steps.
*
* The additive kinds name only fields that MATERIALIZE a column
* ({@link fieldHasColumn}) — a virtual `formula` field never appears. The
* promise above cuts both ways: a plan may not promise work `apply` cannot
* deliver either, or the finding can never be cleared (#3978).
*/
columns: string[];
/**
* How much data the step touches, when that is knowable up front and worth
* knowing — absent for the additive kinds, which touch none.
*
* For `normalize_datetime_storage` it is the number of row-writes (summed
* across `columns`, since each is its own `UPDATE`). For
* `widen_datetime_columns` it is the table's row count, because MySQL's
* `ALTER … MODIFY` is a full rebuild holding a metadata lock — which is the
* number that decides "now" versus "in a maintenance window".
*/
rows?: number;
}
/**
* What kind of physical work a {@link PendingSchemaWork} entry represents.
*
* The first two are purely additive and never touch existing rows. The rest are
* NOT: `normalize_datetime_storage` / `normalize_time_storage` rewrite rows in
* place (the SQLite canonical-text backfills, #3912/#3994) and
* `widen_datetime_columns` / `widen_time_columns` rebuild a column (the MySQL
* `TIMESTAMP` → `DATETIME(3)` and `TIME` → `TIME(3)` widenings, #3942/#3994).
* They are rendered under their own heading for that reason: the additive
* section tells the operator the work is never data-losing, and that claim must
* not silently come to cover a row rewrite.
*/
export type PendingSchemaWorkKind =
| 'create_table'
| 'add_columns'
| 'normalize_datetime_storage'
| 'normalize_time_storage'
| 'widen_datetime_columns'
| 'widen_time_columns';
/** True for the kinds that rewrite or rebuild existing data rather than adding to it. */
export function isInPlaceSchemaWork(kind: PendingSchemaWorkKind): boolean {
return kind !== 'create_table' && kind !== 'add_columns';
}
/** Ops that act on an index rather than a column — reconciled without a table rebuild. */
export const INDEX_DRIFT_OPS: ReadonlySet<DriftOp['type']> = new Set([
'replace_unique_index',
'create_index',
'drop_index',
'recreate_index',
'unbuildable_index',
]);
export type IndexDriftOp = Extract<
DriftOp,
{ type: 'replace_unique_index' | 'create_index' | 'drop_index' | 'recreate_index' | 'unbuildable_index' }
>;
/** Ops that act on a single column — the only ones with a guaranteed `column`. */
export type ColumnDriftOp = Exclude<DriftOp, IndexDriftOp>;
/** True when this op mutates an index rather than a column. */
export function isIndexDriftOp(op: DriftOp): op is IndexDriftOp {
return INDEX_DRIFT_OPS.has(op.type);
}
/**
* A managed-schema drift finding: a {@link SchemaDiffEntry} enriched with the
* owning table, a reconcile {@link DriftOp}, and a {@link DriftCategory}.
*/
export interface ManagedDriftEntry extends SchemaDiffEntry {
table: string;
category: DriftCategory;
op: DriftOp;
/** Human one-liner with an actionable hint. */
message: string;
}
/**
* [#23925] The remedy sentence {@link diffManagedTable} ends each destructive
* COLUMN entry's message with: `os migrate apply --allow-destructive`, the
* command that performs the entry wherever the reporting driver's apply door
* can. Spelled once, here, so the module that writes the sentence is the one
* that takes it off again ({@link driftFindingWithoutRemedy}).
*
* Why it must come off somewhere: the finding is the same on every face (the
* report is this differ's), but whether that command can perform the entry is
* the reporting driver's to declare. The Turso REMOTE face runs no SQLite
* table rebuild, so a `tighten_not_null` there is not applied by the command
* its message names, and `os migrate plan` shows the finding with the
* driver's declared limit instead of a remedy that database does not have.
* Every other reader keeps the message as written, byte for byte.
*/
const DESTRUCTIVE_COLUMN_REMEDY = {
tighten_not_null: ' Run "os migrate apply --allow-destructive".',
narrow_varchar: ' "os migrate apply --allow-destructive".',
drop_column: ' — "os migrate apply --allow-destructive" to drop it.',
} as const satisfies Partial<Record<DriftOp['type'], string>>;
/**
* [#23925] A drift entry's finding without the `os migrate apply
* --allow-destructive` remedy sentence {@link diffManagedTable} ends a
* destructive column entry with ({@link DESTRUCTIVE_COLUMN_REMEDY}); any other
* entry's message, unchanged. For a renderer whose database's driver declares
* it cannot apply the entry, so that remedy is never offered there.
*/
export function driftFindingWithoutRemedy(entry: Pick<ManagedDriftEntry, 'message' | 'op'>): string {
const remedy: string | undefined = (DESTRUCTIVE_COLUMN_REMEDY as Partial<Record<DriftOp['type'], string>>)[entry.op.type];
return remedy !== undefined && entry.message.endsWith(remedy)
? entry.message.slice(0, -remedy.length)
: entry.message;
}
/**
* [ADR-0131 D10 item 5] The column whose drop the organization-ownership
* ceremony owns: on a database it has not certified, the ceremony drops it
* itself and `os migrate apply --allow-destructive` refuses the run.
*/
const CEREMONY_OWNED_DROP_COLUMN = 'organization_id';
/** Is `entry` the drop of a column the organization-ownership ceremony owns? */
export function isCeremonyOwnedColumnDrop(entry: Pick<ManagedDriftEntry, 'op'>): boolean {
return entry.op.type === 'drop_column' && entry.op.column === CEREMONY_OWNED_DROP_COLUMN;
}
/**
* [ADR-0131 D10 item 5] The boot's `[schema-drift]` line for a ceremony-owned
* column drop ({@link isCeremonyOwnedColumnDrop}) on a database the driver
* reads as not certified: the finding without its `--allow-destructive`
* sentence, which that database refuses, and the ceremony in its place, in
* the words `os migrate plan` shows under the same entry.
*/
export function ceremonyOwnedDropFinding(entry: Pick<ManagedDriftEntry, 'message' | 'op'>): string {
return (
`${driftFindingWithoutRemedy(entry)} — a drop the organization-ownership ceremony owns, and it has not ` +
`verified on this database: run "os migrate organization-ownership", which drops it itself.`
);
}
/** Columns the driver creates unconditionally — never metadata fields. */
export const BUILTIN_COLUMNS = new Set(['id', 'created_at', 'updated_at']);
/**
* Suffix of a HASH-SHADOW column — the driver-owned column that carries a
* declared UNIQUE index MySQL cannot express over the values themselves
* (#11627). See `SqlDriver.createHashShadowUniqueIndex` for why it exists and
* what it stores.
*/
export const HASH_SHADOW_SUFFIX = '__hash';
/**
* Is this physical column a driver-owned hash shadow (#11627)?
*
* ⚠️ Load-bearing for the ORPHAN differ below, and the reason this predicate
* is exported rather than inlined. A hash-shadow column exists in the database
* and — by construction — in no metadata field, which is the exact shape the
* orphan pass reports as `unmapped_column` with a `drop_column` op. Dropping it
* would take the UNIQUE index it carries with it, silently returning the object
* to "registered but its declared uniqueness unenforced" — the very state
* #11627 and commit d0e3a885b exist to end, reached this time through the migration tool
* rather than through a refused DDL.
*
* Matched by SUFFIX rather than by a registry of known names, deliberately: the
* differ runs against a database whose metadata it is comparing to, and a
* shadow whose declared index has since been removed must still be recognised
* as driver-owned (it is cleaned up by the index's own removal path, not by a
* blind column drop).
*
* ⚠️ That last clause was an ASSERTION about a path that did not exist, and it
* is the load-bearing half of this docblock: it is the whole reason this pass
* may decline to report the column. #13056 built the path it names —
* `SqlDriver.dropOrphanedHashShadowColumn`, run by the `drop_index` op after
* the index goes — because until then `dropIndexIfExists` issued one statement
* family and never touched a column. The shadow of a retired declaration
* therefore outlived it forever: a `VARBINARY(32)` STORED generated column,
* recomputed on every INSERT and on every UPDATE touching its sources, keyed by
* nothing and reported by no pass. Skipping the column here is only correct
* while some other path really does collect it — so if that method is ever
* removed or its call site moved, this `continue` becomes a leak again and the
* skip must go with it.
*/
export function isHashShadowColumn(name: string): boolean {
return name.endsWith(HASH_SHADOW_SUFFIX);
}
/**
* The hash-shadow column that carries `indexName` (#11627), capped to MySQL's
* 64-character identifier limit.
*
* ⚠️ Lives HERE, beside {@link isHashShadowColumn}, rather than in the driver:
* The defect commit cd1348802 fixed was the price of the split. The ORPHAN-column pass knew the shadow
* vocabulary and the INDEX differ did not, so a healthy shadow-carried UNIQUE
* had its column protected from a drop while the index that column carries was
* proposed for a destructive rebuild. Both passes now ask the same module the
* same question, and `SqlDriver.hashShadowColumnFor` delegates here so the name
* the sync CREATES and the name the differ LOOKS FOR cannot drift apart.
*
* Derived from the INDEX name rather than from the column list, deliberately:
* one shadow serves one declared index, a composite index has no single column
* to name it after, and the index name is already the differ's identity for the
* constraint. The overflow branch keeps a truncated prefix for readability and
* appends a digest of the FULL name, so two long index names that share a
* prefix still get different shadows.
*/
export function hashShadowColumnFor(indexName: string): string {
const direct = `${indexName}${HASH_SHADOW_SUFFIX}`;
if (direct.length <= 64) return direct;
const digest = createHash('sha256').update(indexName).digest('hex').slice(0, 8);
const keep = 64 - HASH_SHADOW_SUFFIX.length - digest.length - 1;
return `${indexName.slice(0, keep)}_${digest}${HASH_SHADOW_SUFFIX}`;
}
/**
* One key part a hash shadow hashes: the column identity, and whether the
* generation expression folds it through the NULL-safe `COALESCE(col, ...)`
* form (ADR-0120 D3, carried into the shadow by commit df1c75c4b).
*/
export interface HashShadowKeyPart {
column: string;
nullSafe: boolean;
}
/**
* Read the DECLARED key parts back out of a hash shadow's stored
* `GENERATION_EXPRESSION` (commit cd1348802).
*
* This is what makes a shadow-carried key COMPARABLE rather than merely
* skippable. Since commit df1c75c4b the expression carries the NULL-safe parts in their
* COALESCE spelling, so the FORM of the key — which columns, and which of them
* are folded — survives the round trip, and the differ can ask the real
* question ("does this shadow enforce what metadata declares?") instead of the
* blind one ("is this a shadow at all?").
*
* ⛔ Why the blind question is not good enough: a shadow created BEFORE commit df1c75c4b
* hashes the RAW columns, so `CONCAT` returns NULL for every NULL-organization
* row and the rows the COALESCE bucket exists to constrain are constrained by
* nothing (#5030's shape). It is indistinguishable BY NAME from a healthy one.
* Skipping every shadow would make that class of drift permanently invisible —
* trading a false destructive finding for a true silent one.
*
* MySQL stores the expression normalized and back-quoted — e.g.
* `unhex(sha2(concat(coalesce(`org`,_utf8mb4'__global__'),0x1f,`v`),256))` — so
* the key parts are exactly the back-quoted runs in key order, and the optional
* `coalesce(` prefix marks the folded ones. The `unhex` / `sha2` / `concat`
* wrapper and the `_utf8mb4'...'` literal carry no back quotes and contribute
* nothing.
*/
export function parseHashShadowKeyParts(generationExpression: string): HashShadowKeyPart[] {
const matches = String(generationExpression ?? '').matchAll(
/(coalesce\s*\(\s*)?`((?:[^`]|``)+)`/gi,
);
return [...matches].map((m) => ({
column: m[2]!.replace(/``/g, '`'),
nullSafe: m[1] != null,
}));
}
/** Minimal shape of an introspected physical column (see SqlDriver.introspectColumns). */
export interface PhysicalColumn {
name: string;
type: string;
nullable: boolean;
/**
* Raw as knex `columnInfo()` reports it — a number on some dialects, a
* STRING on SQLite (measured: `"255"`). The varchar differ below narrows
* via `typeof` before comparing, which is the pattern for any new reader.
*/
maxLength?: number | string;
/**
* The column's raw DEFAULT as the dialect reports it (knex `columnInfo`), or
* `null`/`undefined` when it has none. Dialect-decorated — SQLite and Postgres
* quote a string literal and Postgres appends a `::type` cast — so compare it
* through {@link physicalDefaultIsToken}, never with `===`.
*/
defaultValue?: unknown;
}
/** Minimal shape of a metadata field definition. */
export interface FieldDef {
type?: string;
required?: boolean;
multiple?: boolean;
maxLength?: number;
/** ADR-0113: the explicit physical constraint — nullability drift reads THIS, not `required`. */
storage?: { notNull?: boolean };
/**
* The declared default. Only consulted for the runtime-token dimension
* (#4560): a token is an instruction, so it must never appear as a physical
* column DEFAULT. Literal defaults are deliberately NOT diffed — a hand-edited
* DEFAULT on a column is a DBA's business, and reporting every one of them
* would drown the plan the same way undeclared indexes would.
*/
defaultValue?: unknown;
}
/**
* Does the physical column DEFAULT literally spell out `token`?
*
* Each dialect decorates the literal it reports differently — SQLite
* `'current_user'`, Postgres `'current_user'::character varying`, MySQL a bare
* `current_user` — so the raw string is stripped of one layer of quoting and of
* a trailing cast before comparing. Deliberately EXACT after that: this is the
* fingerprint of a DEFAULT the platform itself emitted from a token spelling,
* and matching loosely would let it drop a default that merely resembles one.
*/
export function physicalDefaultIsToken(raw: unknown, token: string): boolean {
if (typeof raw !== 'string') return false;
let s = raw.trim();
const cast = s.indexOf('::');
if (cast > 0) s = s.slice(0, cast).trim();
if (s.length >= 2 && ((s.startsWith("'") && s.endsWith("'")) || (s.startsWith('"') && s.endsWith('"')))) {
s = s.slice(1, -1);
}
return s === token;
}
/**
* Does this metadata field materialise a physical column? Mirrors
* `SqlDriver.createColumn` exactly: `formula` is virtual (computed, no column);
* everything else — including a MULTI-VALUED field (a JSON column) — gets one.
*
* [#17469] "Multi-valued" is `@objectstack/spec`'s `isMultiValueField`, the
* same question `createColumn`'s short-circuit now asks. Reading `multiple`
* raw here would keep a column alive for a declaration the writer no longer
* gives one.
*/
export function fieldHasColumn(field: FieldDef): boolean {
if (isMultiValueField({ type: String(field?.type ?? ''), multiple: field?.multiple === true })) return true;
return (field?.type ?? 'string') !== 'formula';
}
/** Whether the dialect physically enforces varchar length (SQLite does not). */
function enforcesVarcharLength(dialect: SqlDialectName): boolean {
return dialect === 'postgres' || dialect === 'mysql';
}
/**
* Is this physical column a `varchar`/`char` — the only kind that HAS a
* declared length to compare against (#11431)?
*
* Without this the length branch below read a MySQL **TEXT** column as a
* varchar 65535 wide, because that is literally what the server reports for it.
* Measured on MySQL 8.0.46 and Postgres 16, same two columns:
*
* MySQL `text` character_maximum_length = 65535
* Postgres `text` character_maximum_length = NULL
* both `varchar(30)` character_maximum_length = 30
*
* So the defect was MySQL-only and invisible on Postgres. Every bounded,
* unkeyed text field — the shape `createColumn` deliberately leaves as TEXT —
* diffed as "declared 4000, column allows 65535" and produced a
* `narrow_varchar` op at severity `error`, category **destructive**, against a
* table the driver had just created and which held no rows. Measured on live
* MySQL: `sys_email`'s envelope alone accounts for seven such findings, each
* inviting `os migrate apply --allow-destructive` to rewrite a TEXT column into
* a varchar for no reason.
*
* A TEXT column refuses nothing a `maxLength` allows, so there is no
* divergence to plan an ALTER for; the bound is enforced at the write seam.
* Spelled as a substring test rather than an equality because the three
* dialects disagree on the word — Postgres says `character varying`, MySQL and
* SQLite say `varchar` — matching the predicate `introspectSchema` already
* uses for the same question.
*/
function isCharacterColumn(type: string | undefined): boolean {
return /char/i.test(String(type ?? ''));
}
/**
* Is this physical column one that ACCEPTS a stringified JSON array without
* complaint — i.e. `varchar`/`char`/`text` in any dialect's spelling (#11535)?
*
* Wider than {@link isCharacterColumn} by exactly the TEXT family, and that
* width is the point rather than an accident: the write path stringifies a
* multi-value field's array, and a text column takes the literal as happily as
* a varchar does. Both are the silent-corruption shape.
*
* Deliberately NOT "anything that is not json". A stale `integer` or
* `timestamp` column under a now-multi-value field is already LOUD — Postgres
* refuses `'["a","b"]'` with `22P02 invalid input syntax`, MySQL with
* `ER_TRUNCATED_WRONG_VALUE` — so it needs no finding to become visible, and
* matching it here would report a divergence the database itself already
* refuses. The textual family is the one that says yes and corrupts.
*/
function acceptsStringifiedJson(type: string | undefined): boolean {
return /char|text/i.test(String(type ?? ''));
}
/**
* The field types whose column is TEXT whenever the field declares no usable
* `maxLength` — `createColumn`'s text-family case (#11794 / #11875).
*
* ⚠️ Read the scope precisely: these are the types for which the emitter's
* answer is TEXT **regardless of whether an index keys the column**. That
* independence is what licenses the #12121 branch below to report on this shape
* without being told which columns are keyed, and it is not an assumption — it
* falls out of the emitter's own expression, `keyable = keyed ?
* keyableTextLength(field) : null`, whose keyed arm returns `null` for a field
* with no positive-integer bound. A text-family field that DID declare a keyable
* bound takes `varchar(maxLength)` when keyed, which is exactly why that branch
* is gated on the declaration being ABSENT and never fires for it.
*
* ⛔ NOT derived from the spec's `BOUNDED_STRING_FIELD_TYPES`, for the reason
* `sql-driver-12017-bounded-string-spec-parity.test.ts` argues in full: that set
* answers "may this type declare a bound?" and carries no varchar/TEXT
* partition, so deriving would have to INVENT an answer for every future member
* at the one seam where the maintainer has actually ruled per type. It is a
* hand-written list and is therefore PINNED rather than trusted —
* `schema-drift.unbounded-text-column.test.ts` probes the driver's OWN
* dispatch (`varcharColumnChars`) over every `FieldType` the spec declares and
* asserts set equality in both directions, plus the keyed-and-unkeyed `null`
* above for every member. A type entering or leaving `createColumn`'s text
* family reds there by name.
*
* ⚠️ It lives here rather than being imported from `sql-driver.ts` because the
* dependency runs the other way — `sql-driver.ts` imports this module — so an
* import would be a cycle. The pin is what stands in for a shared constant.
*
* ⛔ Module-exported so this package's own suites can pin it, and deliberately
* NOT added to `index.ts` — the same call {@link MULTI_VALUE_COLUMN_REMEDY_COMMAND}
* makes: nothing outside this package has a question this set answers.
*/
export const UNBOUNDED_TEXT_FIELD_TYPES: ReadonlySet<string> = new Set([
'text', 'textarea', 'html', 'markdown', 'richtext', 'code',
// #11875 — joined the family once the write seam enforced their declared bound.
'signature', 'qrcode',
]);
/**
* The field types the EMITTER materialises as a JSON column — the writer's own
* predicate, restated on this side of the cycle (#15771).
*
* `createColumn`'s catch-all is `JSON_COLUMN_TYPES.has(type) ? jsonColumn(...)
* : table.string(...)` and `isJsonField` — the read-side deserializer — is
* `JSON_COLUMN_TYPES.has(type) || isMultiValueField(field)` (#17469 — was
* `|| !!field.multiple`). So the writer has always asked about the TYPE as well
* as multi-value, while the base-type branch below asked only
* `field.multiple === true`. A single-value JSON-class field on a
* `varchar`/`text` column was therefore written as JSON and did not exist to
* the differ, permanently and silently — the additive sync never revisits a
* column. Two halves disagreeing about which declarations get a json column is
* the defect class this module exists to close; this constant is the deletion
* of that fork, not a new criterion.
*
* ⚠️ Seeded from `@objectstack/spec` — the SAME sets `JSON_COLUMN_TYPES` is
* seeded from — rather than hand-listed, so a value-shape class added to the
* spec becomes a JSON column and a DETECTABLE one in one edit. Only the two
* driver-internal aliases are hand-written, because they are not authorable
* `FieldType`s at all: `object` / `array` name introspected external columns.
*
* ⚠️ The FILE family is NOT here, and its absence is not a narrowing of the
* #15771 population. Since the ADR-0104 addendum a single-value media field is
* a json column on a deployment that has not moved its columns and a `varchar`
* on one that has, so it is not a constant on either side of the cycle. It is
* asked as `fileColumnsMoved` — a per-CALL input to {@link diffManagedTable},
* supplied by `SqlDriver.detectTableDrift` from the driver's own arm — and
* OMITTING it keeps this module's pre-addendum verdicts exactly (see the
* parameter's own note). ⛔ Re-adding the family to this set would report the
* RULED end-state column as `type_mismatch` / `severity: 'error'` with a remedy
* that converts it back to json, i.e. tell every deployment that ran the
* migration to undo it.
*
* ⚠️ A second constant rather than an import, for the reason
* {@link UNBOUNDED_TEXT_FIELD_TYPES} gives in full — `sql-driver.ts` imports
* THIS module, so the import would be a cycle. It is therefore PINNED rather
* than trusted: `schema-drift.json-column-parity.test.ts` asserts set equality
* against the driver's own `isJsonField`, in both directions, over every
* `FieldType` the spec declares.
*
* ⛔ Module-exported so this package's own suites can pin it, and deliberately
* NOT added to `index.ts` — the same call {@link UNBOUNDED_TEXT_FIELD_TYPES}
* and {@link MULTI_VALUE_COLUMN_REMEDY_COMMAND} make.
*/
export const JSON_COLUMN_FIELD_TYPES: ReadonlySet<string> = new Set<string>([
...STRUCTURED_JSON_TYPES, ...MULTI_OPTION_TYPES,
'object', 'array',
]);
/**
* Does a JSON-class field's column carry its type on THIS dialect — i.e. does a
* stale textual column silently corrupt the value (#11535 / #15771)?
*
* The question is about the DIALECT, so widening the population it is asked
* about (from `multiple` alone to the emitter's own JSON-class predicate,
* #15771) changes nothing here: what makes SQLite exempt is that its read path
* `JSON.parse`s a textual column regardless, which is as true of a single-value
* `file` id as of an array — re-measured on an in-memory cell, the stale
* `varchar(2048)` column and the driver's own `text` column store BYTE-IDENTICAL
* bytes and read back identically.
*
* Postgres and MySQL: yes. Measured end to end on live Postgres 16.13 and MySQL
* 8.0.46 — a field that gained `multiple: true` over a pre-existing
* `varchar(255)` column round-trips as the LITERAL STRING `["a","b"]`
* (`typeof === 'string'`, `Array.isArray === false`), because the write path
* stringifies for a json field on every non-SQLite dialect while the read path
* relies on the driver's column-type-based decoding, which a stale textual
* column defeats.
*
* SQLite: **no**, and the exclusion is measured rather than assumed. The same
* stale column reads back as a real `['a','b']` array there (the read path
* `JSON.parse`s on SQLite regardless of what the column calls itself), so there
* is no corruption to report — reporting it anyway would put a permanent
* `error` finding on every long-lived SQLite development database for a
* divergence that changes no value. SQLite's column type is an affinity label,
* not an enforced type, which is the same reason
* {@link enforcesVarcharLength} excludes it.
*/
function jsonColumnTypeIsLoadBearing(dialect: SqlDialectName): boolean {
return dialect === 'postgres' || dialect === 'mysql';
}
/**
* The operator-run command that repairs a stale multi-value column (#11535).
*
* Named in the finding's `message` because a message that only DESCRIBES a
* problem leaves the operator to invent the repair. Until `os migrate
* multi-value-columns` shipped (#11733) there was nothing else to say, so the
* message told them to run raw SQL by hand — and the sentence it opened with,
* "ObjectStack will NOT change this column for you", became false the moment
* that command landed. The hand-run route is still printed below it, because
* the statement is what the command runs and an operator without the CLI still
* needs it; it is no longer the FIRST thing offered.
*
* ⛔ Module-exported so this package's own suites can pin the spelling, and
* deliberately NOT added to `index.ts`: naming a CLI command in a warning is a
* string, and a published export is the step that would let the CLI import its
* own id back out of the driver it boots. `schema-drift.base-type-mismatch.test.ts`
* asserts the emitted message contains it; the command id itself lives in
* `packages/cli/src/commands/migrate/multi-value-columns.ts`.
*
* ⚠️ It changes NOTHING about how loud this finding is or what it gates:
* `severity: 'error'` and `category: 'needs_confirm'` are untouched, so the
* finding still refuses no boot (see the emission site's comment — the boot
* gate reads CATEGORY, and `destructive` is the value that would stop an
* already-serving database from starting).
*/
export const MULTI_VALUE_COLUMN_REMEDY_COMMAND = 'os migrate multi-value-columns';
/**
* The hand-run statement that converts a stale textual column to `json`,
* spelled for the dialect the operator is actually on.
*
* The Postgres form keeps the SHAPE of the reporter's own production workaround
* (#11535) — the three-way CASE over the three states a stale column's rows are
* actually in — with one arm changed, for a reason that was measured rather than
* reasoned: the reporter's `to_json(col)` turns a legacy single value into the
* JSON **scalar** `"a"`, under a field the metadata now declares MULTI-VALUE. On
* live Postgres 16.13 that row read back as a string with `Array.isArray ===
* false`, i.e. still not the shape the declaration promises, while live MySQL
* 8.0.46's `JSON_ARRAY(col)` produced `["a"]`. `json_build_array` makes the two
* dialects hand back the same value for the same row, which is the standing rule
* here — one declaration with two enforcement answers is the defect class this
* package's conformance matrices exist to close.
*
* Both forms are EXECUTED against live servers by
* `schema-drift.base-type-mismatch.test.ts`, over rows in every state the column
* can be in (legacy single value, already-stringified array, empty string,
* NULL), and the finding is asserted to clear afterwards — an operator-facing
* remedy nobody runs is a remedy that drifts into being wrong.
*/
export function manualJsonConversionSql(dialect: SqlDialectName, table: string, column: string): string {
if (dialect === 'mysql') {
// MySQL will not cast text to json implicitly: rows holding a legacy single
// value have to become one-element arrays FIRST, or the ALTER fails with
// `ER_INVALID_JSON_TEXT` on the first non-JSON row.
return (
`UPDATE \`${table}\` SET \`${column}\` = JSON_ARRAY(\`${column}\`) ` +
`WHERE \`${column}\` IS NOT NULL AND \`${column}\` <> '' AND LEFT(\`${column}\`, 1) <> '['; ` +
`UPDATE \`${table}\` SET \`${column}\` = NULL WHERE \`${column}\` = ''; ` +
`ALTER TABLE \`${table}\` MODIFY \`${column}\` json;`
);
}
return (
`ALTER TABLE "${table}" ALTER COLUMN "${column}" TYPE json USING ` +
// The `IS NULL` arm is not redundant with the `= ''` one and is not
// decoration: `json_build_array(NULL)` is `[null]`, a one-element array, so
// without it every NULL row silently gains a value. Measured on live
// Postgres 16.13 while writing this — the arm exists because the version
// without it was run and produced `[null]`.
`(CASE WHEN "${column}" IS NULL THEN NULL WHEN "${column}" = '' THEN NULL ` +
`WHEN "${column}" LIKE '[%' THEN "${column}"::json ` +
`ELSE json_build_array("${column}") END);`
);
}
/**
* Diff one table's metadata fields against its physical columns and return the
* set of *drift* findings. Metadata is authoritative.
*
* Note: a metadata field with no physical column is NOT reported — the
* additive sync (`ALTER TABLE ADD COLUMN`) already covers added fields, so by
* the time this runs every expected column exists. We only surface the
* non-additive divergences the additive sync can never fix.
*/
export function diffManagedTable(args: {
table: string;
fields: Record<string, FieldDef>;
columns: PhysicalColumn[];
dialect: SqlDialectName;
/**
* Which columns an index KEYS ON (commit d0e3a885b), keyed by field name — the exact
* map {@link indexedKeyColumns} builds. Consulted ONLY by the varchar-length
* branch below, through {@link varcharColumnChars}, to answer the same
* question `createColumn` asks before it sizes a text-family column.
* Omitted (the default), a field reads as unkeyed — the same default the
* emitter itself takes when nobody supplies one.
*/
keyedColumns?: ReadonlyMap<string, { unique: boolean }>;
/**
* The emitter's OWN read-only mirror — `SqlDriver.varcharColumnChars`,
* bound by the caller — asked "what `varchar(n)` would `createColumn`
* actually build for this field, or `null` if it would not build a varchar
* at all?" (#12732). `SqlDriver.detectTableDrift` supplies it on every real
* call; a caller that doesn't (this module cannot reach `sql-driver.ts` —
* see {@link UNBOUNDED_TEXT_FIELD_TYPES}'s note on the cycle) keeps this
* branch's pre-#12732 behaviour, unconditional on `declaredMaxLength`
* alone — an intentionally additive default so no existing direct caller of
* this exported function changes shape under it.
*/
varcharColumnChars?: (field: FieldDef, keyed?: { unique: boolean }) => number | null;
/**
* Has this deployment completed the ADR-0104 column move for the file family
* (`SqlDriverConfig.fileColumnsMoved`, resolved from
* `sys_migration.columns_moved_at`)? `SqlDriver.detectTableDrift` supplies
* its own arm on every real call.
*
* OMITTED — the default — means NOT moved, which reproduces this module's
* pre-addendum verdicts for the family byte for byte: the same intentionally
* additive posture {@link varcharColumnChars} above takes, and the same
* direction the driver's own arm fails toward. A caller that has not been
* threaded the new input therefore keeps reporting a `varchar` media column
* as the #15771 corruption it still is on an unmoved deployment.
*/
fileColumnsMoved?: boolean;
/**
* The table's tenant column (`SqlDriver.resolveTenantField`): the one column
* whose nullability the v18 organization-ownership ceremony owns (ruling
* record 6269955919 on the v18 epic, ② A). The ceremony applies NOT NULL to
* it on a database it upgraded, and no declaration says so — the injected
* tenant field is `required: false` with no `storage` — so a NOT NULL on it
* is the ceremony's verdict, never drift: no `relax_not_null` is proposed
* for it, and nothing here undoes the ceremony's work. Omitted (the
* default), every column is judged against its declaration as before.
*/
ceremonyOwnedNullability?: string | null;
}): ManagedDriftEntry[] {
const { table, fields, columns, dialect, keyedColumns, varcharColumnChars } = args;
const ceremonyOwnedNullability = args.ceremonyOwnedNullability ?? null;
const fileColumnsMoved = args.fileColumnsMoved === true;
const out: ManagedDriftEntry[] = [];
const columnsByName = new Map(columns.map((c) => [c.name, c]));
// Field name → physical column it should produce. Built only for fields that
// materialise a column, so orphan detection below treats virtual fields as
// "no column expected".
const expectedColumns = new Set<string>();
for (const [fieldName, field] of Object.entries(fields ?? {})) {
if (BUILTIN_COLUMNS.has(fieldName)) continue;
if (!fieldHasColumn(field)) continue;
expectedColumns.add(fieldName);
const col = columnsByName.get(fieldName);
if (!col) continue; // additive sync adds it; not drift
// ── nullability (ADR-0113: compared against `storage.notNull`, the
// explicit physical constraint — NOT against `required`, which is the
// write-time contract and implies nothing about the column) ──────────
const expectNullable = field.storage?.notNull !== true;
if (expectNullable && !col.nullable && field.required !== true && fieldName !== ceremonyOwnedNullability) {