okmodel 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +182 -22
  2. package/dist/contracts/catalog/document.d.ts +6 -2
  3. package/dist/dialects/pg/ext/citext.js +1 -1
  4. package/dist/dialects/pg/ext/index.js +1 -1
  5. package/dist/dialects/pg/ext/pg-trgm.js +1 -1
  6. package/dist/dialects/pg/index.js +3 -3
  7. package/dist/dialects/pg/role/sql.d.ts +11 -4
  8. package/dist/dialects/pg/table.d.ts +17 -1
  9. package/dist/dialects/pg/view/index.js +1 -1
  10. package/dist/dialects/pg/view/scratch.d.ts +3 -2
  11. package/dist/runtime/catalog-cache.d.ts +17 -0
  12. package/dist/runtime/compat-words.d.ts +23 -0
  13. package/dist/runtime/drift-detail.d.ts +20 -4
  14. package/dist/runtime/drift.d.ts +4 -2
  15. package/dist/runtime/pg/bun.js +2 -2
  16. package/dist/runtime/pg/pg.js +2 -2
  17. package/dist/runtime/pg/pglite.js +2 -2
  18. package/dist/runtime/pg/postgresjs.js +2 -2
  19. package/dist/runtime/traits/index.js +5 -4
  20. package/dist/runtime/types.d.ts +2 -0
  21. package/dist/runtime/validate/index.js +2 -2
  22. package/dist/shared/2321avmn.js +418 -0
  23. package/dist/shared/{x6wysh83.js → 3p4b1fg0.js} +3 -5
  24. package/dist/shared/{wekze34d.js → 41kv2xdm.js} +74 -2
  25. package/dist/shared/{fs0ex00p.js → 4ef7mmqf.js} +2 -2
  26. package/dist/shared/5mtf6cj3.js +94 -0
  27. package/dist/shared/{999ade2q.js → 5vdm4sgp.js} +1 -1
  28. package/dist/shared/{qybh48nd.js → 6693j9rs.js} +1 -1
  29. package/dist/shared/{bqha5cg6.js → 6p5k20sm.js} +2 -2
  30. package/dist/shared/7nk68fj8.js +751 -0
  31. package/dist/shared/{ne02ng5c.js → 84ptn35q.js} +7 -4
  32. package/dist/shared/{t616ynfn.js → 8jb4ymh2.js} +59 -12
  33. package/dist/shared/{th14ygv2.js → 8ktwd3rh.js} +29 -1
  34. package/dist/shared/9yn6a2vd.js +11 -0
  35. package/dist/shared/ady1ms74.js +72 -0
  36. package/dist/shared/{scwxrpx2.js → c5g81ngb.js} +82 -15
  37. package/dist/shared/{6bazvqwm.js → fb00y8fp.js} +2 -2
  38. package/dist/shared/{jkc6ws1p.js → g7ez901n.js} +83 -2
  39. package/dist/shared/{kgnc8grt.js → gdh9mrtp.js} +1 -1
  40. package/dist/shared/hqjsxnz7.js +13 -0
  41. package/dist/shared/htnaj77v.js +17 -0
  42. package/dist/shared/{mt5wswjz.js → jgxzjzcz.js} +1 -1
  43. package/dist/shared/{6w5j844a.js → jsd07prz.js} +2 -2
  44. package/dist/shared/{m78s7898.js → kafaa5zv.js} +1 -1
  45. package/dist/shared/kj680nxv.js +19 -0
  46. package/dist/shared/{k34e0xf2.js → kkzwp3d2.js} +8 -8
  47. package/dist/shared/{2ssqrv66.js → n5y9zyj1.js} +1029 -174
  48. package/dist/shared/{ty3v3hfk.js → nxb42q7h.js} +2 -2
  49. package/dist/shared/{5nmdenb6.js → q6zsyh31.js} +1003 -40
  50. package/dist/shared/{fptpmvxp.js → q795afwa.js} +26 -21
  51. package/dist/shared/{3ekj9x92.js → qn0srwwq.js} +1 -1
  52. package/dist/shared/s4mb6a4b.js +545 -0
  53. package/dist/shared/{01yr20s5.js → sf8m6tfy.js} +2 -2
  54. package/dist/shared/{bryqk9k9.js → sr5ms7b3.js} +1 -1
  55. package/dist/shared/{meck53jv.js → tny2t3sm.js} +6 -5
  56. package/dist/shared/{p24x2334.js → v7tpnhnz.js} +1 -1
  57. package/dist/shared/{xw1d69vd.js → wqem4jed.js} +6 -6
  58. package/dist/shared/{49ypnfwt.js → xghkt7a9.js} +50 -28
  59. package/dist/shared/{7fvtrkwr.js → ycatkxmq.js} +1 -1
  60. package/dist/shared/ygem90rn.js +900 -0
  61. package/dist/shared/{1tfdfvhh.js → yjw02enp.js} +6 -4
  62. package/dist/shared/{ydaz1sv3.js → zg24x0hm.js} +1 -1
  63. package/dist/tooling/cli.js +46 -17
  64. package/dist/tooling/migrate/apply.d.ts +59 -5
  65. package/dist/tooling/migrate/backfill.d.ts +136 -0
  66. package/dist/tooling/migrate/check.d.ts +65 -0
  67. package/dist/tooling/migrate/classify.d.ts +108 -0
  68. package/dist/tooling/migrate/commands.d.ts +2 -2
  69. package/dist/tooling/migrate/config.d.ts +22 -0
  70. package/dist/tooling/migrate/doctor.d.ts +3 -0
  71. package/dist/tooling/migrate/drift.d.ts +18 -2
  72. package/dist/tooling/migrate/estimate.d.ts +50 -0
  73. package/dist/tooling/migrate/extensions.d.ts +2 -0
  74. package/dist/tooling/migrate/files.d.ts +12 -0
  75. package/dist/tooling/migrate/index.d.ts +3 -2
  76. package/dist/tooling/migrate/index.js +67 -1
  77. package/dist/tooling/migrate/lint-rules.d.ts +51 -0
  78. package/dist/tooling/migrate/lint.d.ts +71 -0
  79. package/dist/tooling/migrate/managed.d.ts +1 -1
  80. package/dist/tooling/migrate/plan.d.ts +64 -9
  81. package/dist/tooling/migrate/project.d.ts +42 -7
  82. package/dist/tooling/migrate/provision.d.ts +18 -0
  83. package/dist/tooling/migrate/reference.d.ts +40 -0
  84. package/dist/tooling/migrate/runner.d.ts +108 -0
  85. package/dist/tooling/migrate/safe.d.ts +77 -0
  86. package/dist/tooling/migrate/seed.d.ts +20 -0
  87. package/dist/tooling/migrate/snapshot.d.ts +78 -0
  88. package/dist/tooling/migrate/status.d.ts +25 -6
  89. package/dist/tooling/testing/factories.d.ts +117 -0
  90. package/dist/tooling/testing/facts.d.ts +72 -0
  91. package/dist/tooling/testing/index.d.ts +89 -0
  92. package/dist/tooling/testing/index.js +8 -0
  93. package/dist/tooling/testing/isolation.d.ts +30 -0
  94. package/dist/tooling/testing/random.d.ts +40 -0
  95. package/dist/tooling/testing/record.d.ts +38 -0
  96. package/package.json +6 -2
  97. package/dist/shared/dbjej1tw.js +0 -456
  98. package/dist/shared/g4f5kcx9.js +0 -37
  99. package/dist/shared/qbxzvwwc.js +0 -88
  100. package/dist/shared/zwmqxb0g.js +0 -79
  101. package/dist/shared/{xva8v7qk.js → awrsqrn7.js} +3 -3
  102. package/dist/shared/{75xkjv0t.js → fa9vt245.js} +0 -0
  103. package/dist/shared/{m9tv7xn7.js → radd3z4g.js} +3 -3
  104. /package/dist/shared/{0jc3ffzf.js → zvbjc949.js} +0 -0
@@ -9,13 +9,20 @@ import {
9
9
  serializeCatalog2,
10
10
  catalogHash2,
11
11
  parseCatalog2
12
- } from "./t616ynfn.js";
12
+ } from "./8jb4ymh2.js";
13
13
  import {
14
+ open2
15
+ } from "./wqem4jed.js";
16
+ import {
17
+ canonicalTypeName,
18
+ batchSizeField,
19
+ aboutBatchCount,
14
20
  parseReplace,
15
21
  planMigration,
16
22
  formatPlan,
23
+ parsePlan,
17
24
  staleRenames
18
- } from "./2ssqrv66.js";
25
+ } from "./n5y9zyj1.js";
19
26
  import {
20
27
  contributedFields
21
28
  } from "./n0dgs0pb.js";
@@ -24,12 +31,13 @@ import {
24
31
  writeWouldValidate
25
32
  } from "./9m80mkac.js";
26
33
  import {
27
- catalog
28
- } from "./jkc6ws1p.js";
34
+ catalog,
35
+ isDomain
36
+ } from "./g7ez901n.js";
29
37
  import {
30
38
  snakeCase,
31
39
  emittedTypeName
32
- } from "./6w5j844a.js";
40
+ } from "./jsd07prz.js";
33
41
  import {
34
42
  ColumnBuilder2
35
43
  } from "./c9z5g0fm.js";
@@ -243,8 +251,62 @@ var ERROR_DOCS = [
243
251
  {
244
252
  code: "OKM1510",
245
253
  title: "Unsafe migration",
246
- summary: "A migration is not expand-safe and has no reason.",
247
- fix: "Split the change into expand and contract, or pass a reason that CI can record."
254
+ summary: "A migration has an unresolved lint error, or an override with an empty reason or a code the statement did not trigger.",
255
+ fix: "Put `-- okm-allow OKM15xx: reason` on the line above the statement. The code must be one that statement triggered, and the reason must not be empty."
256
+ },
257
+ {
258
+ code: "OKM1511",
259
+ title: "Drop table",
260
+ summary: "A statement drops a table. The rows and anything that reads them are gone.",
261
+ fix: "Stop reading the table in an expand migration, then drop it in a later contract. Or allow OKM1511 with a reason."
262
+ },
263
+ {
264
+ code: "OKM1512",
265
+ title: "Drop column",
266
+ summary: "A statement drops a column. Stored values and the code that reads them are gone.",
267
+ fix: "Stop reading the column in an expand migration, then drop it in a later contract. Or allow OKM1512 with a reason."
268
+ },
269
+ {
270
+ code: "OKM1513",
271
+ title: "Drop enum",
272
+ summary: "A statement drops an enum that columns or code may still depend on.",
273
+ fix: "Move every column off the enum first. Or allow OKM1513 with a reason."
274
+ },
275
+ {
276
+ code: "OKM1514",
277
+ title: "Drop domain",
278
+ summary: "A statement drops a domain that columns or code may still depend on.",
279
+ fix: "Move every column off the domain first. Or allow OKM1514 with a reason."
280
+ },
281
+ {
282
+ code: "OKM1515",
283
+ title: "Drop function",
284
+ summary: "A statement drops a function that queries or triggers may still call.",
285
+ fix: "Remove the callers in an expand migration, then drop the function. Or allow OKM1515 with a reason."
286
+ },
287
+ {
288
+ code: "OKM1516",
289
+ title: "Drop view",
290
+ summary: "A statement drops a view that queries may still read.",
291
+ fix: "Stop reading the view in an expand migration, then drop it. Or allow OKM1516 with a reason."
292
+ },
293
+ {
294
+ code: "OKM1517",
295
+ title: "Drop extension",
296
+ summary: "A statement drops an extension. A dependent still in the catalog is refused before this finding.",
297
+ fix: "Confirm nothing outside the catalog uses the extension. Or allow OKM1517 with a reason."
298
+ },
299
+ {
300
+ code: "OKM1518",
301
+ title: "Drop materialized view",
302
+ summary: "A statement drops a materialized view and the rows it stores.",
303
+ fix: "Stop reading the view in an expand migration, then drop it. Or allow OKM1518 with a reason."
304
+ },
305
+ {
306
+ code: "OKM1519",
307
+ title: "Rename column",
308
+ summary: "A statement renames a column. Existing queries still use the old name.",
309
+ fix: "Add the new column, backfill, and switch readers before dropping the old name. Or allow OKM1519 with a reason."
248
310
  },
249
311
  {
250
312
  code: "OKM1520",
@@ -256,7 +318,7 @@ var ERROR_DOCS = [
256
318
  code: "OKM1521",
257
319
  title: "Snapshot mismatch",
258
320
  summary: "Provisioning from the snapshot does not match the replayed migration history.",
259
- fix: "Regenerate the snapshot from the history. okm migrate check is the command that reports this."
321
+ fix: "Regenerate the snapshot from the history. okm migrate check --provision is the command that reports this."
260
322
  },
261
323
  {
262
324
  code: "OKM1522",
@@ -264,12 +326,114 @@ var ERROR_DOCS = [
264
326
  summary: "Another apply already holds the target's migration lock.",
265
327
  fix: "Wait for that apply to finish. Do not start a second apply against the same target."
266
328
  },
329
+ {
330
+ code: "OKM1523",
331
+ title: "Rename table",
332
+ summary: "A statement renames a table. Existing queries still use the old name.",
333
+ fix: "Create the new table, backfill, and switch readers before dropping the old name. Or allow OKM1523 with a reason."
334
+ },
335
+ {
336
+ code: "OKM1524",
337
+ title: "Column type change",
338
+ summary: "A statement changes a column type. Existing readers and writers may not accept the new type.",
339
+ fix: "Add a new column, backfill, and switch readers before dropping the old one. Or allow OKM1524 with a reason."
340
+ },
341
+ {
342
+ code: "OKM1525",
343
+ title: "Required column",
344
+ summary: "A statement adds a NOT NULL column with no default to a table that already has rows.",
345
+ fix: "Add the column nullable, backfill, then set it NOT NULL. Or allow OKM1525 with a reason."
346
+ },
347
+ {
348
+ code: "OKM1526",
349
+ title: "Drop default",
350
+ summary: "A statement removes a column default. Writers that omitted the column will fail.",
351
+ fix: "Update writers to send the column, then drop the default. Or allow OKM1526 with a reason."
352
+ },
353
+ {
354
+ code: "OKM1527",
355
+ title: "Shrink length",
356
+ summary: "A statement shortens a character length. Values longer than the new limit no longer fit.",
357
+ fix: "Shorten the stored values first, or keep the old length. Or allow OKM1527 with a reason."
358
+ },
359
+ {
360
+ code: "OKM1528",
361
+ title: "Unique constraint",
362
+ summary: "A statement adds a unique or primary-key constraint on a table that already has rows.",
363
+ fix: "Deduplicate the rows first. Or allow OKM1528 with a reason."
364
+ },
365
+ {
366
+ code: "OKM1529",
367
+ title: "Unique index",
368
+ summary: "A statement creates a unique index on a table that already has rows.",
369
+ fix: "Deduplicate the rows first. Or allow OKM1529 with a reason."
370
+ },
267
371
  {
268
372
  code: "OKM1530",
269
373
  title: "Ambiguous rename",
270
374
  summary: "A rename could match more than one object.",
271
375
  fix: "Declare the rename explicitly so the plan has one pairing."
272
376
  },
377
+ {
378
+ code: "OKM1531",
379
+ title: "Validating check",
380
+ summary: "A statement adds or validates a check against rows already stored.",
381
+ fix: "Add the check NOT VALID, repair the rows, then validate it. Or allow OKM1531 with a reason."
382
+ },
383
+ {
384
+ code: "OKM1532",
385
+ title: "Validating foreign key",
386
+ summary: "A statement adds or validates a foreign key against rows already stored.",
387
+ fix: "Add the foreign key NOT VALID, repair the rows, then validate it. Or allow OKM1532 with a reason."
388
+ },
389
+ {
390
+ code: "OKM1533",
391
+ title: "Narrowing type",
392
+ summary: "A statement narrows a numeric or integer type. Values outside the new range no longer fit.",
393
+ fix: "Rewrite the values into the new range first. Or allow OKM1533 with a reason."
394
+ },
395
+ {
396
+ code: "OKM1534",
397
+ title: "Index locks",
398
+ summary: "A statement creates an index on an existing table without CONCURRENTLY.",
399
+ fix: "Create the index with CREATE INDEX CONCURRENTLY, outside a transaction. The planner does this on an existing table."
400
+ },
401
+ {
402
+ code: "OKM1535",
403
+ title: "Check without NOT VALID",
404
+ summary: "A statement adds a check on an existing table without NOT VALID.",
405
+ fix: "Add the check NOT VALID, then validate it in its own step. The planner does this on an existing table."
406
+ },
407
+ {
408
+ code: "OKM1536",
409
+ title: "Foreign key without NOT VALID",
410
+ summary: "A statement adds a foreign key on an existing table without NOT VALID.",
411
+ fix: "Add the foreign key NOT VALID, then validate it in its own step. The planner does this on an existing table."
412
+ },
413
+ {
414
+ code: "OKM1537",
415
+ title: "Set not null",
416
+ summary: "A statement sets NOT NULL on a column that already exists.",
417
+ fix: "Add CHECK (column IS NOT NULL) NOT VALID, validate it, then SET NOT NULL and drop the check. The planner does this on an existing column."
418
+ },
419
+ {
420
+ code: "OKM1538",
421
+ title: "Type rewrite",
422
+ summary: "A statement changes a column type in a way that rewrites the table.",
423
+ fix: "There is no safe form for a rewriting type change in this version. A column swap is expand and contract work for a later step. OKM1524 already requires a reason for the type change."
424
+ },
425
+ {
426
+ code: "OKM1539",
427
+ title: "Timestamp without time zone",
428
+ summary: "A column is timestamp without time zone. The instant depends on the session time zone.",
429
+ fix: "Use timestamptz."
430
+ },
431
+ {
432
+ code: "OKM1540",
433
+ title: "varchar length",
434
+ summary: "A column is varchar(n). text stores the same values without a length limit in the type.",
435
+ fix: "Use text, and check the length in the application when one is required."
436
+ },
273
437
  {
274
438
  code: "OKM1541",
275
439
  title: "Picklist removal",
@@ -282,6 +446,48 @@ var ERROR_DOCS = [
282
446
  summary: "A migration contains a data statement outside backfill().",
283
447
  fix: "Move the statement into backfill(). Data changes are batched and resumable there."
284
448
  },
449
+ {
450
+ code: "OKM1543",
451
+ title: "serial column",
452
+ summary: "A column uses serial or a nextval default. Identity is the form this repo generates.",
453
+ fix: "Use an identity column generated always."
454
+ },
455
+ {
456
+ code: "OKM1544",
457
+ title: "json column",
458
+ summary: "A column is json. jsonb is available and stores the same documents in binary.",
459
+ fix: "Use jsonb."
460
+ },
461
+ {
462
+ code: "OKM1545",
463
+ title: "Identity not always",
464
+ summary: "An identity column is generated by default. The repo default is generated always.",
465
+ fix: "Declare the identity as generated always."
466
+ },
467
+ {
468
+ code: "OKM1546",
469
+ title: "Backfill key",
470
+ summary: "A backfill step targets a table that has no primary key.",
471
+ fix: "Add a primary key. A backfill walks that key and does not scan the table."
472
+ },
473
+ {
474
+ code: "OKM1547",
475
+ title: "History differs",
476
+ summary: "Replaying a migration does not match its stored catalog, or the file was generated from a different parent than the previous migration.",
477
+ fix: "Regenerate the migration from the previous catalog so the SQL and the stored catalog describe the same schema."
478
+ },
479
+ {
480
+ code: "OKM1548",
481
+ title: "Previous catalog",
482
+ summary: "An expand migration drops or changes a table, column, constraint, or type the previous catalog relies on.",
483
+ fix: "Keep every table, column, constraint, and type the previous catalog relies on, or classify the migration as contract."
484
+ },
485
+ {
486
+ code: "OKM1549",
487
+ title: "Stale head",
488
+ summary: "The last migration's catalog does not match the schema.",
489
+ fix: "Run okm generate."
490
+ },
285
491
  {
286
492
  code: "OKM1601",
287
493
  title: "Stale typed SQL",
@@ -494,6 +700,532 @@ function indexDocs() {
494
700
  return index;
495
701
  }
496
702
 
703
+ // src/tooling/migrate/lint.ts
704
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
705
+ import { join } from "node:path";
706
+
707
+ // src/tooling/migrate/lint-rules.ts
708
+ var INTEGER_WIDTH = {
709
+ smallint: 2,
710
+ integer: 4,
711
+ bigint: 8
712
+ };
713
+ var FLOAT_WIDTH = {
714
+ real: 4,
715
+ "double precision": 8
716
+ };
717
+ var STEP_RULES = [
718
+ rule("OKM1511", "destructive", "error", "Dropping a table destroys its rows.", (input) => hit(matches(input.step, "drop-table", /^drop table\b/), "drops a table")),
719
+ rule("OKM1512", "destructive", "error", "Dropping a column destroys its values.", (input) => hit(matches(input.step, "drop-column", /^alter table\s+\S+\s+drop column\b/), "drops a column")),
720
+ rule("OKM1513", "destructive", "error", "Dropping an enum can break columns that use it.", (input) => hit(droppedType(input) === "enum", "drops an enum")),
721
+ rule("OKM1514", "destructive", "error", "Dropping a domain can break columns that use it.", (input) => hit(droppedType(input) === "domain", "drops a domain")),
722
+ rule("OKM1515", "destructive", "error", "Dropping a function can break its callers.", (input) => hit(matches(input.step, "drop-function", /^drop function\b/), "drops a function")),
723
+ rule("OKM1516", "destructive", "error", "Dropping a view can break queries that read it.", (input) => hit(matches(input.step, "drop-view", /^drop view\b/), "drops a view")),
724
+ rule("OKM1517", "destructive", "error", "Dropping an extension removes it even when the catalog has no dependent.", (input) => hit(matches(input.step, "drop-extension", /^drop extension\b/), "drops an extension")),
725
+ rule("OKM1518", "destructive", "error", "Dropping a materialized view destroys the rows it stores.", (input) => hit(matches(input.step, "drop-matview", /^drop materialized view\b/), "drops a materialized view")),
726
+ rule("OKM1519", "backward-incompatible", "error", "Renaming a column breaks queries that still use the old name.", (input) => hit(matches(input.step, "rename-column", /^alter table\s+\S+\s+rename column\b/), "renames a column")),
727
+ rule("OKM1523", "backward-incompatible", "error", "Renaming a table breaks queries that still use the old name.", (input) => hit(renamesTable(input.step), "renames a table")),
728
+ rule("OKM1524", "backward-incompatible", "error", "Changing a column type can break readers and writers.", (input) => typeChange(input, "incompatible")),
729
+ rule("OKM1525", "backward-incompatible", "error", "Adding NOT NULL without a default fails when the table already has rows.", (input) => hit(requiredColumn(input), "adds NOT NULL without a default")),
730
+ rule("OKM1526", "backward-incompatible", "error", "Removing a default breaks writers that omit the column.", (input) => hit(matches(input.step, "drop-default", /^alter table\s+\S+\s+alter column\s+\S+\s+drop default\b/), "removes a default")),
731
+ rule("OKM1527", "backward-incompatible", "error", "Shortening a character length rejects values that used to fit.", (input) => typeChange(input, "shrink")),
732
+ rule("OKM1528", "data-dependent", "error", "A new unique or primary key on an existing table fails when rows collide.", (input) => hit(uniqueConstraint(input), "builds a unique or primary key")),
733
+ rule("OKM1529", "data-dependent", "error", "A new unique index on an existing table fails when rows collide.", (input) => hit(uniqueIndex(input), "creates a unique index")),
734
+ rule("OKM1531", "data-dependent", "error", "A check that validates existing rows fails when a row does not pass.", (input) => hit(validatingCheck(input), "validates a check")),
735
+ rule("OKM1532", "data-dependent", "error", "A foreign key that validates existing rows fails when a row has no target.", (input) => hit(validatingForeignKey(input), "validates a foreign key")),
736
+ rule("OKM1533", "data-dependent", "error", "Narrowing an integer, float, or numeric type rejects values outside the new range.", (input) => typeChange(input, "narrow")),
737
+ rule("OKM1534", "locking", "error", "Creating an index on an existing table without CONCURRENTLY locks writes.", (input) => hit(blockingIndex(input), "creates an index without CONCURRENTLY")),
738
+ rule("OKM1535", "locking", "error", "Adding a check without NOT VALID locks the table while it scans.", (input) => hit(checkWithoutNotValid(input), "adds a check without NOT VALID")),
739
+ rule("OKM1536", "locking", "error", "Adding a foreign key without NOT VALID locks the table while it scans.", (input) => hit(foreignKeyWithoutNotValid(input), "adds a foreign key without NOT VALID")),
740
+ rule("OKM1537", "locking", "error", "SET NOT NULL locks the table while it scans for nulls.", (input) => hit(unsafeSetNotNull(input), "sets NOT NULL")),
741
+ rule("OKM1538", "locking", "warning", "A type change that rewrites the table locks it for the rewrite.", (input) => typeChange(input, "rewrite")),
742
+ rule("OKM1542", "data-dependent", "error", "A data statement outside backfill() is not a migration step.", (input) => hit(dataStatement(input.step), "changes rows outside backfill()"))
743
+ ];
744
+ var COLUMN_RULES = [
745
+ {
746
+ code: "OKM1539",
747
+ category: "type-preference",
748
+ severity: "warning",
749
+ doc: "timestamp without time zone depends on the session time zone; use timestamptz.",
750
+ check: (column) => /^timestamp(?:\(\d+\))? without time zone$/.test(canonical(column.definition.dataType)) ? "is timestamp without time zone" : undefined
751
+ },
752
+ {
753
+ code: "OKM1540",
754
+ category: "type-preference",
755
+ severity: "warning",
756
+ doc: "varchar(n) can be text; a length check belongs in the application.",
757
+ check: (column) => /^character varying\(\d+\)$/.test(canonical(column.definition.dataType)) ? "is varchar(n)" : undefined
758
+ },
759
+ {
760
+ code: "OKM1543",
761
+ category: "type-preference",
762
+ severity: "warning",
763
+ doc: "serial and nextval defaults should be identity generated always.",
764
+ check: (column) => isSerial(column) ? "uses serial or nextval" : undefined
765
+ },
766
+ {
767
+ code: "OKM1544",
768
+ category: "type-preference",
769
+ severity: "warning",
770
+ doc: "json should be jsonb, which is available on every supported Postgres.",
771
+ check: (column) => canonical(column.definition.dataType) === "json" ? "is json" : undefined
772
+ },
773
+ {
774
+ code: "OKM1545",
775
+ category: "type-preference",
776
+ severity: "warning",
777
+ doc: "An identity that is not generated always differs from the repo default.",
778
+ check: (column) => column.definition.identity?.always === false ? "is generated by default" : undefined
779
+ }
780
+ ];
781
+ function rule(code, category, severity, doc, check) {
782
+ return { code, category, severity, doc, check };
783
+ }
784
+ function hit(matched, reason) {
785
+ return matched ? [reason] : [];
786
+ }
787
+ function normalized(sql) {
788
+ const collapsed = sql.trim().replace(/\s+/g, " ");
789
+ let out = "";
790
+ let quoted = false;
791
+ for (const char of collapsed) {
792
+ if (char === '"') {
793
+ quoted = !quoted;
794
+ out += char;
795
+ continue;
796
+ }
797
+ out += quoted ? char : char.toLowerCase();
798
+ }
799
+ return out;
800
+ }
801
+ function dataStatement(step) {
802
+ if (step.backfill !== undefined)
803
+ return false;
804
+ const text = normalized(step.sql);
805
+ if (/^(?:insert|update|delete|merge|truncate)\b/.test(text))
806
+ return true;
807
+ return /^with\b/.test(text) && /\b(?:insert|update|delete|merge)\b/.test(text);
808
+ }
809
+ function matches(step, kind, pattern) {
810
+ return pattern.test(normalized(step.sql)) || step.kind === kind;
811
+ }
812
+ function renamesTable(step) {
813
+ const text = normalized(step.sql);
814
+ if (/^alter table\s+\S+\s+rename to\b/.test(text))
815
+ return true;
816
+ return step.kind === "rename-table" && !/^alter table\s+\S+\s+rename (column|constraint)\b/.test(text);
817
+ }
818
+ function droppedType(input) {
819
+ const text = normalized(input.step.sql);
820
+ const dropping = /^drop type\b/.test(text);
821
+ if (!dropping && input.step.kind !== "drop-enum" && input.step.kind !== "drop-domain") {
822
+ return;
823
+ }
824
+ if (input.step.kind === "drop-domain")
825
+ return "domain";
826
+ if (input.step.kind === "drop-enum")
827
+ return "enum";
828
+ const name = lastName(/^drop type\s+(\S+)/.exec(text)?.[1]);
829
+ const found = input.before.objects.find((object) => object.kind === "type" && object.identity.name === name);
830
+ if (found !== undefined && isDomain(found.definition))
831
+ return "domain";
832
+ return "enum";
833
+ }
834
+ function requiredColumn(input) {
835
+ const text = normalized(input.step.sql);
836
+ const adding = /^alter table\s+\S+\s+add column\b/.test(text) || input.step.kind === "add-column-required";
837
+ if (!adding)
838
+ return false;
839
+ if (input.step.kind === "add-column")
840
+ return false;
841
+ if (!tableExists(input))
842
+ return false;
843
+ if (input.step.kind === "add-column-required")
844
+ return true;
845
+ const body = text.replace(/^alter table\s+\S+\s+add column\s+/, "");
846
+ return /\bnot null\b/.test(body) && !/\bdefault\b/.test(body) && !/\bgenerated\b/.test(body);
847
+ }
848
+ function uniqueConstraint(input) {
849
+ if (!tableExists(input))
850
+ return false;
851
+ const text = normalized(input.step.sql);
852
+ if (/\busing index\b/.test(text))
853
+ return false;
854
+ if (/^alter table\s+\S+\s+add constraint\b/.test(text) && /\b(unique|primary key)\b/.test(text)) {
855
+ return true;
856
+ }
857
+ if (!/^create unique index\b/.test(text))
858
+ return false;
859
+ const name = createdIndexName(text);
860
+ if (name === undefined)
861
+ return false;
862
+ return input.later.some((step) => usesIndex(normalized(step.sql), name));
863
+ }
864
+ function uniqueIndex(input) {
865
+ const text = normalized(input.step.sql);
866
+ if (!/^create unique index\b/.test(text))
867
+ return false;
868
+ if (!tableExists(input))
869
+ return false;
870
+ const name = createdIndexName(text);
871
+ if (name !== undefined && input.later.some((step) => usesIndex(normalized(step.sql), name))) {
872
+ return false;
873
+ }
874
+ return true;
875
+ }
876
+ function createdIndexName(text) {
877
+ const match = /^create unique index (?:concurrently )?(?:"([^"]+)"|([a-z_][\w$]*))/.exec(text);
878
+ return match?.[1] ?? match?.[2];
879
+ }
880
+ function usesIndex(text, name) {
881
+ return text.includes(`using index "${name}"`) || text.includes(`using index ${name}`);
882
+ }
883
+ function unsafeSetNotNull(input) {
884
+ const text = normalized(input.step.sql);
885
+ const match = /^alter table\s+(\S+)\s+alter column\s+(\S+)\s+set not null\b/.exec(text);
886
+ if (match === null && input.step.kind !== "set-not-null")
887
+ return false;
888
+ if (!tableExists(input))
889
+ return false;
890
+ const table = match?.[1];
891
+ const column = match?.[2];
892
+ if (table !== undefined && column !== undefined && validatedNotNullCheck(input.earlier, table, column)) {
893
+ return false;
894
+ }
895
+ return true;
896
+ }
897
+ function validatedNotNullCheck(earlier, table, column) {
898
+ const needle = `check (${column} is not null) not valid`;
899
+ for (const step of earlier) {
900
+ const text = normalized(step.sql);
901
+ if (!text.startsWith(`alter table ${table} `) || !text.includes(needle))
902
+ continue;
903
+ const added = /^alter table\s+\S+\s+add constraint\s+(\S+)\s+check\b/.exec(text);
904
+ const name = added?.[1];
905
+ if (name === undefined)
906
+ continue;
907
+ const validated = earlier.some((item) => normalized(item.sql).startsWith(`alter table ${table} validate constraint ${name}`));
908
+ if (validated)
909
+ return true;
910
+ }
911
+ return false;
912
+ }
913
+ function blockingIndex(input) {
914
+ const text = normalized(input.step.sql);
915
+ if (!/^create(?: unique)? index\b/.test(text) && input.step.kind !== "create-index")
916
+ return false;
917
+ if (/\bconcurrently\b/.test(text))
918
+ return false;
919
+ return tableExists(input);
920
+ }
921
+ function checkWithoutNotValid(input) {
922
+ return addedConstraint(input, "check") && !/\bnot valid\b/.test(normalized(input.step.sql));
923
+ }
924
+ function foreignKeyWithoutNotValid(input) {
925
+ return addedConstraint(input, "foreign key") && !/\bnot valid\b/.test(normalized(input.step.sql));
926
+ }
927
+ function validatingCheck(input) {
928
+ if (widenValidate(input))
929
+ return false;
930
+ if (validates(input, "check"))
931
+ return true;
932
+ return checkWithoutNotValid(input);
933
+ }
934
+ function widenValidate(input) {
935
+ const text = normalized(input.step.sql);
936
+ if (!/^alter table\s+\S+\s+validate constraint\b/.test(text))
937
+ return false;
938
+ if (input.step.kind === "widen-check")
939
+ return true;
940
+ const name = /^alter table\s+\S+\s+validate constraint\s+(\S+)/.exec(text)?.[1];
941
+ if (name === undefined)
942
+ return false;
943
+ return input.earlier.some((step) => {
944
+ if (step.kind !== "widen-check")
945
+ return false;
946
+ const prior = normalized(step.sql);
947
+ return prior.includes(`add constraint ${name} `);
948
+ });
949
+ }
950
+ function validatingForeignKey(input) {
951
+ if (validates(input, "foreignKey"))
952
+ return true;
953
+ return foreignKeyWithoutNotValid(input);
954
+ }
955
+ function addedConstraint(input, kind) {
956
+ if (!tableExists(input))
957
+ return false;
958
+ const text = normalized(input.step.sql);
959
+ if (!/^alter table\s+\S+\s+add constraint\b/.test(text))
960
+ return false;
961
+ return new RegExp(`\\b${kind}\\b`).test(text);
962
+ }
963
+ function validates(input, kind) {
964
+ const text = normalized(input.step.sql);
965
+ const table = /^alter table\s+\S+\s+validate constraint\s+(\S+)/.exec(text);
966
+ if (table !== null) {
967
+ const token = table[1];
968
+ if (token === undefined)
969
+ return kind === "check";
970
+ const name = lastName(token);
971
+ if (name === undefined)
972
+ return kind === "check";
973
+ const constraint = findConstraint(input.after, name);
974
+ if (constraint === undefined)
975
+ return kind === "check";
976
+ return constraint.definition.constraintKind === kind;
977
+ }
978
+ if (kind === "check" && /^alter domain\s+\S+\s+validate constraint\b/.test(text))
979
+ return true;
980
+ return false;
981
+ }
982
+ function typeChange(input, mode) {
983
+ const text = normalized(input.step.sql);
984
+ const changing = /^alter table\s+\S+\s+alter column\s+\S+\s+set data type\b/.test(text) || input.step.kind === "set-column-type";
985
+ if (!changing)
986
+ return [];
987
+ const compared = columnTypes(input);
988
+ if (compared === undefined) {
989
+ if (mode === "incompatible" || mode === "rewrite")
990
+ return ["changes a column type"];
991
+ return [];
992
+ }
993
+ const { before, after } = compared;
994
+ if (before === after)
995
+ return [];
996
+ const shrink = lengthShrink(before, after);
997
+ if (mode === "shrink")
998
+ return shrink ? ["shortens a character length"] : [];
999
+ if (shrink)
1000
+ return [];
1001
+ if (mode === "narrow")
1002
+ return narrows(before, after) ? ["narrows a type"] : [];
1003
+ if (lengthGrow(before, after))
1004
+ return [];
1005
+ if (mode === "incompatible")
1006
+ return ["changes a column type"];
1007
+ return rewrites(before, after) ? ["rewrites the table"] : [];
1008
+ }
1009
+ function columnTypes(input) {
1010
+ const text = normalized(input.step.sql);
1011
+ const match = /^alter table\s+(\S+)\s+alter column\s+(\S+)\s+set data type\b/.exec(text);
1012
+ const table = lastName(match?.[1]);
1013
+ const column = lastName(match?.[2]);
1014
+ if (table === undefined || column === undefined)
1015
+ return;
1016
+ const previous = findColumn(input.before, table, column);
1017
+ const next = findColumn(input.after, table, column);
1018
+ if (previous === undefined || next === undefined)
1019
+ return;
1020
+ return {
1021
+ before: canonical(previous.definition.dataType),
1022
+ after: canonical(next.definition.dataType)
1023
+ };
1024
+ }
1025
+ function lengthShrink(before, after) {
1026
+ const left = charLength(before);
1027
+ const right = charLength(after);
1028
+ return left !== undefined && right !== undefined && left.family === right.family && right.length < left.length;
1029
+ }
1030
+ function lengthGrow(before, after) {
1031
+ const left = charLength(before);
1032
+ const right = charLength(after);
1033
+ return left !== undefined && right !== undefined && left.family === right.family && right.length > left.length;
1034
+ }
1035
+ function rewrites(before, after) {
1036
+ if (lengthShrink(before, after) || lengthGrow(before, after))
1037
+ return false;
1038
+ if (isCharacter(before) && after === "text")
1039
+ return false;
1040
+ return true;
1041
+ }
1042
+ function narrows(before, after) {
1043
+ const leftInt = INTEGER_WIDTH[before];
1044
+ const rightInt = INTEGER_WIDTH[after];
1045
+ if (leftInt !== undefined && rightInt !== undefined)
1046
+ return rightInt < leftInt;
1047
+ const leftFloat = FLOAT_WIDTH[before];
1048
+ const rightFloat = FLOAT_WIDTH[after];
1049
+ if (leftFloat !== undefined && rightFloat !== undefined)
1050
+ return rightFloat < leftFloat;
1051
+ const left = numericSpec(before);
1052
+ const right = numericSpec(after);
1053
+ if (left === undefined || right === undefined)
1054
+ return false;
1055
+ return right.precision < left.precision || right.scale < left.scale;
1056
+ }
1057
+ function charLength(type) {
1058
+ const varying = /^character varying\((\d+)\)$/.exec(type);
1059
+ if (varying?.[1] !== undefined)
1060
+ return { family: "varying", length: Number(varying[1]) };
1061
+ const fixed = /^character\((\d+)\)$/.exec(type);
1062
+ if (fixed?.[1] !== undefined)
1063
+ return { family: "fixed", length: Number(fixed[1]) };
1064
+ return;
1065
+ }
1066
+ function isCharacter(type) {
1067
+ return type === "text" || charLength(type) !== undefined;
1068
+ }
1069
+ function numericSpec(type) {
1070
+ const match = /^numeric(?:\((\d+)(?:,(\d+))?\))?$/.exec(type);
1071
+ if (match === null)
1072
+ return;
1073
+ if (match[1] === undefined)
1074
+ return { precision: 1000, scale: 0 };
1075
+ return {
1076
+ precision: Number(match[1]),
1077
+ scale: match[2] === undefined ? 0 : Number(match[2])
1078
+ };
1079
+ }
1080
+ function tableExists(input) {
1081
+ const name = statementTable(input.step);
1082
+ return name !== undefined && input.existingTables.has(name);
1083
+ }
1084
+ function statementTable(step) {
1085
+ const text = normalized(step.sql);
1086
+ const altered = /^(?:alter|drop) table\s+(\S+)/.exec(text);
1087
+ if (altered?.[1] !== undefined)
1088
+ return lastName(altered[1]);
1089
+ const index = /^create(?: unique)? index(?: concurrently)?\s+\S+\s+on\s+(\S+)/.exec(text);
1090
+ return lastName(index?.[1]);
1091
+ }
1092
+ function lastName(token) {
1093
+ if (token === undefined)
1094
+ return;
1095
+ const parts = [...token.matchAll(/"(?:[^"]|"")*"|[A-Za-z_][\w$]*/g)];
1096
+ const last = parts.at(-1)?.[0];
1097
+ if (last === undefined)
1098
+ return;
1099
+ if (last.startsWith('"'))
1100
+ return last.slice(1, -1).replaceAll('""', '"');
1101
+ return last;
1102
+ }
1103
+ function findColumn(source, table, name) {
1104
+ return source.objects.find((object) => object.kind === "column" && object.identity.parent.name === table && object.identity.name === name);
1105
+ }
1106
+ function findConstraint(source, name) {
1107
+ return source.objects.find((object) => object.kind === "constraint" && object.identity.name === name);
1108
+ }
1109
+ function canonical(typeName) {
1110
+ return canonicalTypeName(typeName);
1111
+ }
1112
+ function isSerial(column) {
1113
+ const typeName = column.definition.dataType.toLowerCase();
1114
+ if (typeName === "serial" || typeName === "smallserial" || typeName === "bigserial")
1115
+ return true;
1116
+ if (column.definition.identity !== undefined)
1117
+ return false;
1118
+ return /nextval\s*\(/i.test(column.definition.defaultExpression ?? "");
1119
+ }
1120
+
1121
+ // src/tooling/migrate/lint.ts
1122
+ function lintPlan(plan, before, after) {
1123
+ const existingTables = tableNames(before);
1124
+ const findings = [];
1125
+ plan.steps.forEach((step, index) => {
1126
+ findings.push(...lintStep(step, index + 1, before, after, existingTables, plan.steps.slice(0, index), plan.steps.slice(index + 1)));
1127
+ });
1128
+ return findings;
1129
+ }
1130
+ function lintCatalog(source) {
1131
+ const findings = [];
1132
+ for (const object of source.objects) {
1133
+ if (object.kind !== "column")
1134
+ continue;
1135
+ for (const rule of COLUMN_RULES) {
1136
+ const reason = rule.check(object);
1137
+ if (reason === undefined)
1138
+ continue;
1139
+ findings.push(finding(rule.severity, rule.code, columnPlace(object), reason));
1140
+ }
1141
+ }
1142
+ return findings;
1143
+ }
1144
+ function lintMigrationDirectory(directory, pending) {
1145
+ if (!existsSync(directory))
1146
+ return [];
1147
+ const names = readdirSync(directory).filter((file) => file.endsWith(".sql")).sort();
1148
+ let before = catalog([]);
1149
+ const findings = [];
1150
+ for (const file of names) {
1151
+ const id = file.slice(0, -".sql".length);
1152
+ const plan = parsePlan(readFileSync(join(directory, file), "utf8"));
1153
+ const after = parseCatalog2(readFileSync(join(directory, `${id}.catalog.json`), "utf8"));
1154
+ if (pending === undefined || pending.has(id)) {
1155
+ for (const item of lintPlan(plan, before, after)) {
1156
+ findings.push({ ...item, place: `${id} ${item.place}` });
1157
+ }
1158
+ }
1159
+ before = after;
1160
+ }
1161
+ return findings;
1162
+ }
1163
+ function formatFindings(findings) {
1164
+ if (findings.length === 0)
1165
+ return "";
1166
+ return `${findings.map(formatFinding).join(`
1167
+ `)}
1168
+ `;
1169
+ }
1170
+ function hasError(findings) {
1171
+ return findings.some((item) => item.severity === "error");
1172
+ }
1173
+ function lintRefusal(findings) {
1174
+ const doc = errorDoc("OKM1510");
1175
+ return new OkmError2("OKM1510", formatFindings(findings).trim(), {
1176
+ fix: { summary: doc?.fix ?? "Allow the finding with a reason, or change the statement." }
1177
+ });
1178
+ }
1179
+ function lintStep(step, number, before, after, existingTables, earlier, later) {
1180
+ const hits = [];
1181
+ for (const rule of STEP_RULES) {
1182
+ for (const reason of rule.check({ step, before, after, existingTables, earlier, later })) {
1183
+ hits.push({ code: rule.code, severity: rule.severity, reason });
1184
+ }
1185
+ }
1186
+ const triggered = new Set(hits.map((item) => item.code));
1187
+ const silenced = new Set;
1188
+ const findings = [];
1189
+ const place = `step ${String(number)}`;
1190
+ for (const allow of step.allows ?? []) {
1191
+ if (allow.reason.trim().length === 0 || allow.code.length === 0) {
1192
+ findings.push(finding("error", "OKM1510", place, "override needs a reason"));
1193
+ continue;
1194
+ }
1195
+ if (!triggered.has(allow.code)) {
1196
+ findings.push(finding("error", "OKM1510", place, `override ${allow.code} does not match this statement`));
1197
+ continue;
1198
+ }
1199
+ silenced.add(allow.code);
1200
+ }
1201
+ for (const hit of hits) {
1202
+ if (silenced.has(hit.code))
1203
+ continue;
1204
+ findings.push(finding(hit.severity, hit.code, place, hit.reason));
1205
+ }
1206
+ return findings;
1207
+ }
1208
+ function finding(severity, code, place, reason) {
1209
+ const doc = errorDoc(code);
1210
+ if (doc === undefined)
1211
+ throw new Error(`${code} missing from the error registry.`);
1212
+ return { severity, code, place, reason, fix: doc.fix };
1213
+ }
1214
+ function formatFinding(item) {
1215
+ return `${item.severity} ${item.code} ${item.place}: ${item.reason} -- fix: ${item.fix}`;
1216
+ }
1217
+ function tableNames(source) {
1218
+ const names = new Set;
1219
+ for (const object of source.objects) {
1220
+ if (object.kind === "table")
1221
+ names.add(object.identity.name);
1222
+ }
1223
+ return names;
1224
+ }
1225
+ function columnPlace(column) {
1226
+ return `${column.identity.parent.name}.${column.identity.name}`;
1227
+ }
1228
+
497
1229
  // src/tooling/migrate/policy.ts
498
1230
  var READ = new Set([
499
1231
  "plan",
@@ -637,8 +1369,8 @@ function endpointOf(url) {
637
1369
  }
638
1370
 
639
1371
  // src/tooling/migrate/project.ts
640
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
641
- import { basename, join } from "node:path";
1372
+ import { existsSync as existsSync2, mkdirSync, readdirSync as readdirSync2, readFileSync as readFileSync2, writeFileSync } from "node:fs";
1373
+ import { basename, join as join2 } from "node:path";
642
1374
  import { pathToFileURL } from "node:url";
643
1375
 
644
1376
  // src/dialects/pg/declarations.ts
@@ -978,6 +1710,181 @@ function objectIdentity(object) {
978
1710
  return object.identity;
979
1711
  }
980
1712
 
1713
+ // src/tooling/migrate/estimate.ts
1714
+ var LARGE_TABLE_ROWS = 1e6;
1715
+ var ESTIMATE_SQL = `
1716
+ select c.relname, c.reltuples::float8::text
1717
+ from pg_class c
1718
+ join pg_namespace n on n.oid = c.relnamespace
1719
+ where n.nspname = $1
1720
+ and c.relname = any(string_to_array($2, E'\\x1f'))
1721
+ and c.relkind in ('r', 'p', 'f', 'm')
1722
+ `;
1723
+ async function readRowEstimates(url, schema, tables) {
1724
+ const pool = open2({ url, max: 1 });
1725
+ try {
1726
+ const connection = await pool.reserve?.();
1727
+ if (connection === undefined) {
1728
+ throw new Error("The target connection cannot start a transaction.");
1729
+ }
1730
+ let began = false;
1731
+ try {
1732
+ await connection.execute("begin read only");
1733
+ began = true;
1734
+ await connection.execute("set local statement_timeout = '5s'");
1735
+ const map = new Map;
1736
+ const unique = [...new Set(tables)];
1737
+ if (unique.length > 0) {
1738
+ const result = await connection.execute(ESTIMATE_SQL, [schema, unique.join("\x1F")]);
1739
+ for (const row of result.rows) {
1740
+ const name = row[0];
1741
+ if (name === null || name === undefined)
1742
+ continue;
1743
+ map.set(name, classify(row[1] ?? null));
1744
+ }
1745
+ }
1746
+ await connection.execute("commit");
1747
+ began = false;
1748
+ return map;
1749
+ } catch (error) {
1750
+ if (began)
1751
+ await connection.execute("rollback").catch(() => {
1752
+ return;
1753
+ });
1754
+ throw error;
1755
+ } finally {
1756
+ await connection.release();
1757
+ }
1758
+ } finally {
1759
+ await pool.close();
1760
+ }
1761
+ }
1762
+ function annotateLock(step, estimates) {
1763
+ const pieces = lockPieces(step);
1764
+ const text = withBatches(step, pieces.map((piece) => {
1765
+ if (piece.table === undefined)
1766
+ return piece.mode;
1767
+ return `${piece.mode} on ${piece.table}, ${estimatePhrase(estimates.get(piece.table))}`;
1768
+ }).join("; "), estimates);
1769
+ if (step.safeRewrite === true)
1770
+ return `${text}; safe rewrite applied`;
1771
+ if (exclusiveOverLarge(pieces, estimates)) {
1772
+ return `${text}; note: more than ${String(LARGE_TABLE_ROWS)} estimated rows`;
1773
+ }
1774
+ return text;
1775
+ }
1776
+ function aboutRows(count) {
1777
+ const rounded = Math.max(0, Math.round(count));
1778
+ if (rounded < 1000)
1779
+ return `about ${String(rounded)} rows`;
1780
+ const units = [
1781
+ [1e9, "B"],
1782
+ [1e6, "M"],
1783
+ [1000, "K"]
1784
+ ];
1785
+ for (const [unit, suffix] of units) {
1786
+ if (rounded < unit)
1787
+ continue;
1788
+ const scaled = rounded / unit;
1789
+ const digits = scaled >= 10 || Number.isInteger(scaled) ? 0 : 1;
1790
+ const text = trimZero(scaled.toFixed(digits));
1791
+ return `about ${text}${suffix} rows`;
1792
+ }
1793
+ return `about ${String(rounded)} rows`;
1794
+ }
1795
+ function withBatches(step, text, estimates) {
1796
+ const batch = step.backfill?.batch;
1797
+ if (batch === undefined)
1798
+ return text;
1799
+ const name = step.tables?.[0];
1800
+ const estimate = name === undefined ? undefined : estimates.get(name);
1801
+ if (estimate === undefined || estimate.kind !== "rows")
1802
+ return text;
1803
+ return `${text}, about ${String(aboutBatchCount(estimate.reltuples, batch))} batches`;
1804
+ }
1805
+ function classify(raw) {
1806
+ if (raw === null)
1807
+ return { kind: "unknown" };
1808
+ const value = Number(raw);
1809
+ if (!Number.isFinite(value) || value < 0)
1810
+ return { kind: "unknown" };
1811
+ return { kind: "rows", reltuples: value };
1812
+ }
1813
+ function estimatePhrase(estimate) {
1814
+ if (estimate === undefined)
1815
+ return "new table";
1816
+ if (estimate.kind === "unknown")
1817
+ return "rows unknown (table not analyzed)";
1818
+ return aboutRows(estimate.reltuples);
1819
+ }
1820
+ function lockPieces(step) {
1821
+ const segments = step.lock.split("; ").map((part) => part.trim()).filter((part) => part.length > 0);
1822
+ const named = new Map;
1823
+ for (const segment of segments) {
1824
+ const table = lockTable(segment);
1825
+ if (table !== undefined)
1826
+ named.set(table, stripLockTable(segment));
1827
+ }
1828
+ const primary = segments[0] ?? step.lock;
1829
+ const primaryTable = lockTable(primary);
1830
+ const primaryMode = primaryTable === undefined ? primary : stripLockTable(primary);
1831
+ const pieces = [];
1832
+ const seen = new Set;
1833
+ if (primaryTable === undefined) {
1834
+ const lead = (step.tables ?? []).filter((name) => !named.has(name));
1835
+ if (lead.length === 0 && named.size === 0)
1836
+ pieces.push({ mode: step.lock, table: undefined });
1837
+ for (const table of lead) {
1838
+ seen.add(table);
1839
+ pieces.push({ mode: primaryMode, table });
1840
+ }
1841
+ }
1842
+ for (const segment of segments) {
1843
+ const table = lockTable(segment);
1844
+ if (table === undefined) {
1845
+ if (primaryTable !== undefined || pieces.length === 0) {
1846
+ pieces.push({ mode: segment, table: undefined });
1847
+ }
1848
+ continue;
1849
+ }
1850
+ if (seen.has(table))
1851
+ continue;
1852
+ seen.add(table);
1853
+ pieces.push({ mode: stripLockTable(segment), table });
1854
+ }
1855
+ for (const table of step.tables ?? []) {
1856
+ if (seen.has(table))
1857
+ continue;
1858
+ pieces.push({ mode: primaryMode, table });
1859
+ }
1860
+ return pieces;
1861
+ }
1862
+ function exclusiveOverLarge(pieces, estimates) {
1863
+ for (const piece of pieces) {
1864
+ if (piece.mode !== "ACCESS EXCLUSIVE" || piece.table === undefined)
1865
+ continue;
1866
+ const estimate = estimates.get(piece.table);
1867
+ if (estimate === undefined || estimate.kind !== "rows")
1868
+ continue;
1869
+ if (estimate.reltuples > LARGE_TABLE_ROWS)
1870
+ return true;
1871
+ }
1872
+ return false;
1873
+ }
1874
+ function lockTable(segment) {
1875
+ const match = /\bon\s+"((?:[^"]|"")*)"\."((?:[^"]|"")*)"/.exec(segment);
1876
+ const name = match?.[2];
1877
+ if (name === undefined)
1878
+ return;
1879
+ return name.replaceAll('""', '"');
1880
+ }
1881
+ function stripLockTable(segment) {
1882
+ return segment.replace(/\s+on\s+"((?:[^"]|"")*)"\."((?:[^"]|"")*)"/, "").trim();
1883
+ }
1884
+ function trimZero(text) {
1885
+ return text.endsWith(".0") ? text.slice(0, -2) : text;
1886
+ }
1887
+
981
1888
  // src/tooling/migrate/project.ts
982
1889
  async function buildProject(cwd) {
983
1890
  const config = await loadConfig(cwd);
@@ -989,17 +1896,21 @@ async function generateProject(cwd, name, flags) {
989
1896
  writeArtifact(cwd, prepared.config, prepared.built);
990
1897
  if (prepared.plan.steps.length === 0)
991
1898
  return;
992
- const directory = join(cwd, prepared.config.migrations ?? "migrations");
1899
+ const directory = join2(cwd, prepared.config.migrations ?? "migrations");
993
1900
  mkdirSync(directory, { recursive: true });
994
1901
  const base = `${nextNumber(directory)}_${safeName(name)}`;
995
- const sqlPath = join(directory, `${base}.sql`);
1902
+ const sqlPath = join2(directory, `${base}.sql`);
996
1903
  writeFileSync(sqlPath, formatPlan(prepared.plan));
997
- writeFileSync(join(directory, `${base}.catalog.json`), serializeCatalog2(prepared.built.catalog));
998
- return sqlPath;
1904
+ writeFileSync(join2(directory, `${base}.catalog.json`), serializeCatalog2(prepared.built.catalog));
1905
+ return { path: sqlPath, findings: lintPrepared(prepared) };
999
1906
  }
1000
- async function planProject(cwd, name, flags) {
1907
+ async function planProject(cwd, name, flags, invoke) {
1001
1908
  const prepared = await prepare(cwd, name, flags);
1002
- return prepared.plan;
1909
+ return {
1910
+ plan: prepared.plan,
1911
+ findings: lintPrepared(prepared),
1912
+ text: await displayPlan(prepared.config, prepared.plan, invoke)
1913
+ };
1003
1914
  }
1004
1915
  async function checkProject(cwd, flags) {
1005
1916
  const opened = await openProject(cwd);
@@ -1013,9 +1924,9 @@ async function checkProject(cwd, flags) {
1013
1924
  const targets = listTargets(opened.config);
1014
1925
  assertTargetAlias(targets);
1015
1926
  if (opened.config.tables !== undefined) {
1016
- const directory = join(cwd, opened.config.tables);
1017
- const files = existsSync(directory) ? readdirSync(directory) : [];
1018
- const source = readFileSync(join(cwd, opened.config.schema), "utf8");
1927
+ const directory = join2(cwd, opened.config.tables);
1928
+ const files = existsSync2(directory) ? readdirSync2(directory) : [];
1929
+ const source = readFileSync2(join2(cwd, opened.config.schema), "utf8");
1019
1930
  const missing = unlistedTableFiles(source, files);
1020
1931
  if (missing.length > 0) {
1021
1932
  throw new OkmError2("OKM1024", `Table file not in schema: ${missing.join(", ")}.`, {
@@ -1023,14 +1934,20 @@ async function checkProject(cwd, flags) {
1023
1934
  });
1024
1935
  }
1025
1936
  }
1937
+ const findings = lintOpened(opened);
1938
+ if (hasError(findings))
1939
+ throw lintRefusal(findings);
1026
1940
  const named = flags?.target;
1027
1941
  if (targets.length === 0)
1028
- return;
1942
+ return findings;
1029
1943
  if (targets.length > 1 && named === undefined)
1030
- return;
1944
+ return findings;
1031
1945
  const target = selectTarget(opened.config, named);
1032
- const { assertAuthorDrift } = await import("./49ypnfwt.js");
1946
+ assertTargetPolicy(target, "check", flags?.allowProtected ?? false);
1947
+ assertTargetPolicy(target, "drift", flags?.allowProtected ?? false);
1948
+ const { assertAuthorDrift } = await import("./hqjsxnz7.js");
1033
1949
  await assertAuthorDrift(target.url, opened.built.catalog, opened.config.roles);
1950
+ return findings;
1034
1951
  }
1035
1952
  function unlistedTableFiles(schemaSource, fileNames) {
1036
1953
  const missing = [];
@@ -1056,15 +1973,58 @@ async function prepare(cwd, name, flags) {
1056
1973
  after: opened.built.catalog,
1057
1974
  renames: opened.renames,
1058
1975
  replacements: flags.map((flag) => parseReplace(flag)),
1059
- name
1976
+ name,
1977
+ ...batchSizeField(opened.config.backfill?.batchSize)
1978
+ });
1979
+ return {
1980
+ config: opened.config,
1981
+ built: opened.built,
1982
+ plan,
1983
+ before: opened.previous,
1984
+ after: opened.built.catalog
1985
+ };
1986
+ }
1987
+ function lintPrepared(prepared) {
1988
+ return lintPlan(prepared.plan, prepared.before, prepared.after);
1989
+ }
1990
+ async function displayPlan(config, plan, invoke) {
1991
+ const offline = formatPlan(plan);
1992
+ const targets = listTargets(config);
1993
+ if (targets.length === 0)
1994
+ return offline;
1995
+ assertTargetAlias(targets);
1996
+ const named = invoke?.target;
1997
+ if (named === undefined && targets.length !== 1)
1998
+ return offline;
1999
+ const target = selectTarget(config, named);
2000
+ assertTargetPolicy(target, "plan", invoke?.allowProtected ?? false);
2001
+ let estimates;
2002
+ try {
2003
+ const names = plan.steps.flatMap((step) => step.tables ?? []);
2004
+ estimates = await readRowEstimates(target.url, "public", names);
2005
+ } catch {
2006
+ return offline;
2007
+ }
2008
+ return formatPlan(plan, (step) => annotateLock(step, estimates));
2009
+ }
2010
+ function lintOpened(opened) {
2011
+ const plan = planMigration({
2012
+ before: opened.previous,
2013
+ after: opened.built.catalog,
2014
+ renames: opened.renames,
2015
+ name: "check",
2016
+ ...batchSizeField(opened.config.backfill?.batchSize)
1060
2017
  });
1061
- return { config: opened.config, built: opened.built, plan };
2018
+ return [
2019
+ ...lintPlan(plan, opened.previous, opened.built.catalog),
2020
+ ...lintCatalog(opened.built.catalog)
2021
+ ];
1062
2022
  }
1063
2023
  async function openProject(cwd) {
1064
2024
  const config = await loadConfig(cwd);
1065
2025
  const built = withRoles(await loadSchema(cwd, config.schema), config);
1066
2026
  const declarations = schemaDeclarations(built);
1067
- const previous = readPrevious(join(cwd, config.migrations ?? "migrations"));
2027
+ const previous = readPrevious(join2(cwd, config.migrations ?? "migrations"));
1068
2028
  const stale = staleRenames(previous?.catalog, declarations.renames);
1069
2029
  if (stale.length > 0) {
1070
2030
  throw new OkmError2("OKM1020", stale.join(" "), {
@@ -1084,17 +2044,20 @@ function withRoles(built, config) {
1084
2044
  return { ...built, catalog: attachRoles(built.catalog, config.roles) };
1085
2045
  }
1086
2046
  function writeArtifact(cwd, config, built) {
1087
- const directory = join(cwd, config.out ?? ".okm");
2047
+ const directory = join2(cwd, config.out ?? ".okm");
1088
2048
  mkdirSync(directory, { recursive: true });
1089
2049
  const text = serializeCatalog2(built.catalog);
1090
- writeFileSync(join(directory, "catalog.json"), text);
1091
- writeFileSync(join(directory, "catalog.hash"), `${catalogHash2(built.catalog)}
2050
+ writeFileSync(join2(directory, "catalog.json"), text);
2051
+ writeFileSync(join2(directory, "catalog.hash"), `${catalogHash2(built.catalog)}
1092
2052
  `);
1093
- writeFileSync(join(directory, "types.d.ts"), `${emitRowTypes(built)}${referenceAugmentation(built)}`);
2053
+ writeFileSync(join2(directory, "types.d.ts"), `${emitRowTypes(built)}${referenceAugmentation(built)}`);
1094
2054
  return directory;
1095
2055
  }
2056
+ async function loadBuiltSchema(cwd, config) {
2057
+ return withRoles(await loadSchema(cwd, config.schema), config);
2058
+ }
1096
2059
  async function loadConfig(cwd) {
1097
- const path = join(cwd, "okmodel.config.ts");
2060
+ const path = join2(cwd, "okmodel.config.ts");
1098
2061
  const imported = await import(`${pathToFileURL(path).href}?okm=${Date.now()}`);
1099
2062
  const record = isRecord(imported) ? imported : {};
1100
2063
  const config = record.default ?? record.config;
@@ -1104,7 +2067,7 @@ async function loadConfig(cwd) {
1104
2067
  return config;
1105
2068
  }
1106
2069
  async function loadSchema(cwd, spec) {
1107
- const path = join(cwd, spec);
2070
+ const path = join2(cwd, spec);
1108
2071
  const imported = await import(`${pathToFileURL(path).href}?okm=${Date.now()}`);
1109
2072
  const record = isRecord(imported) ? imported : {};
1110
2073
  for (const value of Object.values(record)) {
@@ -1116,13 +2079,13 @@ async function loadSchema(cwd, spec) {
1116
2079
  throw new OkmError2("invalid", `${spec} must export a schema().`);
1117
2080
  }
1118
2081
  function readPrevious(directory) {
1119
- if (!existsSync(directory))
2082
+ if (!existsSync2(directory))
1120
2083
  return;
1121
- const catalogs = readdirSync(directory).filter((file) => file.endsWith(".catalog.json")).sort();
2084
+ const catalogs = readdirSync2(directory).filter((file) => file.endsWith(".catalog.json")).sort();
1122
2085
  const latest = catalogs.at(-1);
1123
2086
  if (latest === undefined)
1124
2087
  return;
1125
- const text = readFileSync(join(directory, latest), "utf8");
2088
+ const text = readFileSync2(join2(directory, latest), "utf8");
1126
2089
  return { catalog: parseCatalog2(text) };
1127
2090
  }
1128
2091
  function referenceAugmentation(source) {
@@ -1147,8 +2110,8 @@ function referenceAugmentation(source) {
1147
2110
  }
1148
2111
  function nextNumber(directory) {
1149
2112
  let max = 0;
1150
- if (existsSync(directory)) {
1151
- for (const file of readdirSync(directory)) {
2113
+ if (existsSync2(directory)) {
2114
+ for (const file of readdirSync2(directory)) {
1152
2115
  const match = /^(\d+)_/.exec(file);
1153
2116
  const value = match?.[1];
1154
2117
  if (value !== undefined)
@@ -1189,14 +2152,14 @@ function projectImportsValidate(cwd) {
1189
2152
  function directoryImportsValidate(dir) {
1190
2153
  let entries;
1191
2154
  try {
1192
- entries = readdirSync(dir, { withFileTypes: true });
2155
+ entries = readdirSync2(dir, { withFileTypes: true });
1193
2156
  } catch {
1194
2157
  return false;
1195
2158
  }
1196
2159
  for (const entry of entries) {
1197
2160
  if (entry.name.startsWith(".") || SKIP_SCAN.has(entry.name))
1198
2161
  continue;
1199
- const path = join(dir, entry.name);
2162
+ const path = join2(dir, entry.name);
1200
2163
  if (entry.isDirectory()) {
1201
2164
  if (directoryImportsValidate(path))
1202
2165
  return true;
@@ -1204,7 +2167,7 @@ function directoryImportsValidate(dir) {
1204
2167
  }
1205
2168
  if (!/\.(?:ts|tsx|mts|cts)$/.test(entry.name))
1206
2169
  continue;
1207
- if (runtimeImportsValidate(readFileSync(path, "utf8")))
2170
+ if (runtimeImportsValidate(readFileSync2(path, "utf8")))
1208
2171
  return true;
1209
2172
  }
1210
2173
  return false;
@@ -1226,4 +2189,4 @@ function runtimeImportsValidate(source) {
1226
2189
  return false;
1227
2190
  }
1228
2191
 
1229
- export { errorDoc, assertTargetPolicy, assertDirectConnection, listTargets, selectTarget, buildProject, generateProject, planProject, checkProject, projectHead, openProject, loadConfig };
2192
+ export { errorDoc, lintMigrationDirectory, formatFindings, hasError, lintRefusal, assertTargetPolicy, assertDirectConnection, listTargets, selectTarget, buildProject, generateProject, planProject, checkProject, projectHead, openProject, loadBuiltSchema, loadConfig, loadSchema };