okmodel 0.3.0 → 0.5.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 (123) hide show
  1. package/README.md +263 -23
  2. package/dist/contracts/catalog/document.d.ts +26 -2
  3. package/dist/contracts/driver.d.ts +7 -0
  4. package/dist/dialects/pg/ext/citext.js +1 -1
  5. package/dist/dialects/pg/ext/index.js +1 -1
  6. package/dist/dialects/pg/ext/pg-trgm.js +1 -1
  7. package/dist/dialects/pg/index.js +3 -3
  8. package/dist/dialects/pg/introspect.d.ts +6 -0
  9. package/dist/dialects/pg/role/sql.d.ts +21 -4
  10. package/dist/dialects/pg/schema.d.ts +15 -6
  11. package/dist/dialects/pg/table.d.ts +17 -1
  12. package/dist/dialects/pg/view/index.d.ts +20 -6
  13. package/dist/dialects/pg/view/index.js +1 -1
  14. package/dist/dialects/pg/view/scratch.d.ts +2 -1
  15. package/dist/runtime/catalog-cache.d.ts +17 -0
  16. package/dist/runtime/compat-words.d.ts +23 -0
  17. package/dist/runtime/drift-detail.d.ts +20 -4
  18. package/dist/runtime/drift.d.ts +4 -2
  19. package/dist/runtime/pg/bun.d.ts +12 -7
  20. package/dist/runtime/pg/bun.js +5 -2
  21. package/dist/runtime/pg/pg.d.ts +12 -6
  22. package/dist/runtime/pg/pg.js +5 -2
  23. package/dist/runtime/pg/pglite.d.ts +11 -3
  24. package/dist/runtime/pg/pglite.js +5 -2
  25. package/dist/runtime/pg/postgresjs.d.ts +12 -6
  26. package/dist/runtime/pg/postgresjs.js +5 -2
  27. package/dist/runtime/plan.d.ts +3 -1
  28. package/dist/runtime/tenancy/index.js +1 -1
  29. package/dist/runtime/topology.d.ts +191 -0
  30. package/dist/runtime/traits/index.js +7 -6
  31. package/dist/runtime/types.d.ts +59 -2
  32. package/dist/runtime/validate/index.js +2 -2
  33. package/dist/shared/{x6wysh83.js → 3p4b1fg0.js} +3 -5
  34. package/dist/shared/{ty3v3hfk.js → 47xzb9fc.js} +2 -2
  35. package/dist/shared/{fs0ex00p.js → 4ef7mmqf.js} +2 -2
  36. package/dist/shared/{qybh48nd.js → 5fqx9yh4.js} +6 -2
  37. package/dist/shared/5mtf6cj3.js +94 -0
  38. package/dist/shared/{999ade2q.js → 5vdm4sgp.js} +1 -1
  39. package/dist/shared/{bqha5cg6.js → 6p5k20sm.js} +2 -2
  40. package/dist/shared/{t616ynfn.js → 8bwbwy8h.js} +71 -13
  41. package/dist/shared/{th14ygv2.js → 8ktwd3rh.js} +29 -1
  42. package/dist/shared/{wekze34d.js → 8xsbagad.js} +74 -2
  43. package/dist/shared/{bryqk9k9.js → 922qbgnw.js} +3 -3
  44. package/dist/shared/{meck53jv.js → 990a291j.js} +6 -5
  45. package/dist/shared/{jns2wx4t.js → 9tgmr1gh.js} +1 -1
  46. package/dist/shared/9yn6a2vd.js +11 -0
  47. package/dist/shared/{2ssqrv66.js → ayz64vzj.js} +1037 -181
  48. package/dist/shared/{k34e0xf2.js → bt9sfq0z.js} +16 -14
  49. package/dist/shared/{scwxrpx2.js → bzsjtd7c.js} +84 -15
  50. package/dist/shared/{m9tv7xn7.js → ckjbyrt9.js} +4 -4
  51. package/dist/shared/{ydaz1sv3.js → dxjp14rk.js} +10 -2
  52. package/dist/shared/{49ypnfwt.js → fancm5pt.js} +24 -229
  53. package/dist/shared/{6bazvqwm.js → fb00y8fp.js} +2 -2
  54. package/dist/shared/{1tfdfvhh.js → fyqkk1m0.js} +6 -4
  55. package/dist/shared/g516zdsb.js +905 -0
  56. package/dist/shared/{jkc6ws1p.js → g7ez901n.js} +83 -2
  57. package/dist/shared/{kgnc8grt.js → gdh9mrtp.js} +1 -1
  58. package/dist/shared/{p24x2334.js → h4nqvjkp.js} +5 -3
  59. package/dist/shared/{edwqqryd.js → h8ne16fq.js} +1 -1
  60. package/dist/shared/htnaj77v.js +17 -0
  61. package/dist/shared/jdgqqx31.js +72 -0
  62. package/dist/shared/{6w5j844a.js → jsd07prz.js} +2 -2
  63. package/dist/shared/{m78s7898.js → k7cc9nzk.js} +24 -7
  64. package/dist/shared/kdkxh3kb.js +758 -0
  65. package/dist/shared/nkc9sfqb.js +1161 -0
  66. package/dist/shared/{xva8v7qk.js → p7pfx624.js} +5 -5
  67. package/dist/shared/{xrfnq5k8.js → qgvt8nx1.js} +11 -2
  68. package/dist/shared/{ne02ng5c.js → qy9rdq0g.js} +9 -5
  69. package/dist/shared/{3ekj9x92.js → r6pj4fq2.js} +5 -3
  70. package/dist/shared/r73pt594.js +426 -0
  71. package/dist/shared/rasvvzgz.js +21 -0
  72. package/dist/shared/{01yr20s5.js → sf8m6tfy.js} +2 -2
  73. package/dist/shared/{hcmdtwgh.js → szm0cksa.js} +4 -1
  74. package/dist/shared/{axhk06q1.js → tq9afp8d.js} +1 -1
  75. package/dist/shared/{xw1d69vd.js → wqem4jed.js} +6 -6
  76. package/dist/shared/wxn4b7pp.js +252 -0
  77. package/dist/shared/xdpj5kkm.js +2203 -0
  78. package/dist/shared/xhh1srw3.js +589 -0
  79. package/dist/shared/{mt5wswjz.js → xsq7v6wp.js} +2 -2
  80. package/dist/shared/{7fvtrkwr.js → ycatkxmq.js} +1 -1
  81. package/dist/shared/ykv7wh5b.js +14 -0
  82. package/dist/shared/{fptpmvxp.js → z2rnngj4.js} +26 -21
  83. package/dist/tooling/cli.js +46 -17
  84. package/dist/tooling/migrate/apply.d.ts +61 -5
  85. package/dist/tooling/migrate/backfill.d.ts +136 -0
  86. package/dist/tooling/migrate/check.d.ts +65 -0
  87. package/dist/tooling/migrate/classify.d.ts +108 -0
  88. package/dist/tooling/migrate/commands.d.ts +2 -2
  89. package/dist/tooling/migrate/config.d.ts +22 -0
  90. package/dist/tooling/migrate/doctor.d.ts +3 -0
  91. package/dist/tooling/migrate/drift.d.ts +18 -2
  92. package/dist/tooling/migrate/estimate.d.ts +50 -0
  93. package/dist/tooling/migrate/extensions.d.ts +2 -0
  94. package/dist/tooling/migrate/files.d.ts +19 -0
  95. package/dist/tooling/migrate/index.d.ts +3 -2
  96. package/dist/tooling/migrate/index.js +69 -1
  97. package/dist/tooling/migrate/lint-rules.d.ts +51 -0
  98. package/dist/tooling/migrate/lint.d.ts +71 -0
  99. package/dist/tooling/migrate/managed.d.ts +1 -1
  100. package/dist/tooling/migrate/plan.d.ts +64 -9
  101. package/dist/tooling/migrate/project.d.ts +42 -7
  102. package/dist/tooling/migrate/provision.d.ts +18 -0
  103. package/dist/tooling/migrate/reference.d.ts +40 -0
  104. package/dist/tooling/migrate/runner.d.ts +108 -0
  105. package/dist/tooling/migrate/safe.d.ts +77 -0
  106. package/dist/tooling/migrate/seed.d.ts +20 -0
  107. package/dist/tooling/migrate/snapshot.d.ts +78 -0
  108. package/dist/tooling/migrate/status.d.ts +25 -6
  109. package/dist/tooling/testing/factories.d.ts +117 -0
  110. package/dist/tooling/testing/facts.d.ts +75 -0
  111. package/dist/tooling/testing/index.d.ts +89 -0
  112. package/dist/tooling/testing/index.js +8 -0
  113. package/dist/tooling/testing/isolation.d.ts +30 -0
  114. package/dist/tooling/testing/random.d.ts +40 -0
  115. package/dist/tooling/testing/record.d.ts +38 -0
  116. package/package.json +6 -2
  117. package/dist/shared/5nmdenb6.js +0 -1229
  118. package/dist/shared/dbjej1tw.js +0 -456
  119. package/dist/shared/g4f5kcx9.js +0 -37
  120. package/dist/shared/qbxzvwwc.js +0 -88
  121. package/dist/shared/zwmqxb0g.js +0 -79
  122. /package/dist/shared/{75xkjv0t.js → fa9vt245.js} +0 -0
  123. /package/dist/shared/{0jc3ffzf.js → zvbjc949.js} +0 -0
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  okmodel is a catalog-first TypeScript ORM, PostgreSQL first. Other SQL databases are the direction. CI runs PostgreSQL 15 to 18. The schema is the single source. Migrations and queries come from it.
4
4
 
5
- Version 0.3.0. Apache-2.0.
5
+ Version 0.5.0. Apache-2.0.
6
6
 
7
7
  ## Contents
8
8
 
@@ -54,7 +54,16 @@ Version 0.3.0. Apache-2.0.
54
54
  - [Views](#views)
55
55
  - [Roles and grants](#roles-and-grants)
56
56
  - [okm ext and okm doctor](#okm-ext-and-okm-doctor)
57
+ - [Linter](#linter)
58
+ - [Reference data](#reference-data)
59
+ - [Backfill](#backfill)
60
+ - [Testing](#testing)
61
+ - [Topology](#topology)
62
+ - [Reference app](#reference-app)
63
+ - [Startup](#startup)
64
+ - [Protected targets](#protected-targets)
57
65
  - [Commands](#commands)
66
+ - [Check a history in CI](#check-a-history-in-ci)
58
67
  - [Roadmap](#roadmap)
59
68
  - [Size](#size)
60
69
  - [Docs](#docs)
@@ -143,13 +152,15 @@ A protected target refuses push. Production settings are in the [production chec
143
152
 
144
153
  ### Reviewed migrations
145
154
 
146
- Generate writes a SQL file and `.okm`, and apply runs that file later so you can read the migration first.
155
+ Generate writes a SQL file and `.okm`. On an empty database, apply installs that schema and any reference rows. A database that already has history runs the new file, so you can read it first.
147
156
 
148
157
  ```sh
149
158
  bunx okm generate init
150
159
  bunx okm migrate apply
151
160
  ```
152
161
 
162
+ Each step is `expand`, `contract`, or unclassified SQL. Expand is additive. Contract removes or tightens something. On an existing table the planner writes the safe form: a concurrent index, a check or foreign key as `NOT VALID` then `VALIDATE`, and `SET NOT NULL` through a validated check. A backfill step updates rows in batches and resumes after a failure. See [Backfill](#backfill).
163
+
153
164
  ### One client
154
165
 
155
166
  Import `db` from this file wherever the server handles a request. The process keeps one client.
@@ -880,25 +891,250 @@ okm doctor
880
891
  okm doctor OKM1811
881
892
  ```
882
893
 
894
+ ### Linter
895
+
896
+ A migration plan is checked before it runs. Dropping a table, renaming a column, or adding a unique constraint on a table that already exists is an error. On an existing table the planner writes the safe form: a concurrent index, a check or foreign key as `NOT VALID` then `VALIDATE`, and `SET NOT NULL` through a validated check. The same statements written by hand, without that form, are errors. A type change that rewrites the table stays a warning. `okm generate` prints the finding and still writes the file. `okm migrate plan` and `okm check` exit non-zero on an error. `okm migrate apply` refuses with OKM1510 before any statement. The linter reads the plan and the catalogs. It does not connect. See [the linter](https://github.com/omqkhafi/okmodel/blob/main/docs/linter.md).
897
+
898
+ ```text
899
+ error OKM1511 step 1: drops a table -- fix: Stop reading the table in an expand migration, then drop it in a later contract. Or allow OKM1511 with a reason.
900
+ ```
901
+
902
+ ```sql
903
+ -- okm-allow OKM1511: the table is empty and nothing reads it
904
+ drop table "public"."notes";
905
+ ```
906
+
907
+ ### Reference data
908
+
909
+ Rows the application needs are declared on the table. Apply on an empty database inserts a missing key. It does not update or delete. The rows are not part of the catalog hash, so a new key does not need a DDL migration.
910
+
911
+ `reference.ts`:
912
+
913
+ ```ts
914
+ import { schema, table, t } from "okmodel/pg";
915
+
916
+ const roles = table(
917
+ "roles",
918
+ { code: t.text().primaryKey(), label: t.text() },
919
+ {
920
+ reference: {
921
+ key: "code",
922
+ rows: [{ code: "admin", label: "Admin" }],
923
+ },
924
+ },
925
+ );
926
+
927
+ export default schema({ tables: [roles] });
928
+ ```
929
+
930
+ ### Backfill
931
+
932
+ A backfill is one idempotent `UPDATE` in the migration file. `$1` is the exclusive lower bound and `$2` is the inclusive upper bound. Null opens that side. Apply commits one batch at a time and resumes from `okm_backfill`. There is no `okm backfill` command. A protected target refuses the step unless that invocation passes `--allow-protected`.
933
+
934
+ ```sql
935
+ -- name: fill
936
+ -- class: expand
937
+
938
+ -- class: expand
939
+ -- action: backfill
940
+ -- lock: ROW EXCLUSIVE
941
+ -- transactional: false
942
+ -- backfill table="public"."notes" key="id" batch=10
943
+ update "public"."notes" set "title" = 'noted' where "title" = 'hello' and ("id" > $1 or $1 is null) and ("id" <= $2 or $2 is null);
944
+ ```
945
+
946
+ ### Testing
947
+
948
+ `okmodel/testing` opens a real pool. `factories` insert rows, `expectQueries` counts statements, and `isolation()` checks that tenant A cannot see tenant B. `okm seed <file>` calls the file's default export with that harness. The target must already be migrated. A protected target is refused unless `--allow-protected`.
949
+
950
+ `factory.ts`:
951
+
952
+ ```ts
953
+ import { schema, table, t } from "okmodel/pg";
954
+ import { open } from "okmodel/pg/pglite";
955
+ import { testing } from "okmodel/testing";
956
+
957
+ const notes = table("notes", { id: t.identity(), title: t.text() });
958
+ const app = schema({ tables: [notes] });
959
+ const harness = await testing(app, { driver: open() });
960
+ const factories = harness.factories({
961
+ notes: (x) => ({ title: x.words(2) }),
962
+ });
963
+ await harness.expectQueries(1, () => factories.notes.create());
964
+ const rows = await harness.db.notes.find({ limit: 5 });
965
+ if (rows.length !== 1) throw new Error("expected one note");
966
+ await harness.close();
967
+ ```
968
+
969
+ `seed.ts`:
970
+
971
+ ```ts
972
+ export default async function seed(t: {
973
+ factories(definitions: {
974
+ authors(x: { words(count: number): string }): { name: string };
975
+ notes(x: { words(count: number): string; ref(table: string): unknown }): {
976
+ title: string;
977
+ authorId: unknown;
978
+ };
979
+ }): {
980
+ notes: { create(): Promise<unknown> };
981
+ };
982
+ }): Promise<void> {
983
+ const factories = t.factories({
984
+ authors: (x) => ({ name: x.words(2) }),
985
+ notes: (x) => ({ title: x.words(2), authorId: x.ref("authors") }),
986
+ });
987
+ await factories.notes.create();
988
+ }
989
+ ```
990
+
991
+ ```sh
992
+ bunx okm seed seed.ts
993
+ ```
994
+
995
+ ### Topology
996
+
997
+ `connect({ primary, replicas }, options)` opens one pool per endpoint. An automatic read chooses a replica that has replayed at least as far as this client's last committed write. `routing.consistency` is `"session"` by default. `"eventual"` does not keep that watermark. `routing.maxLag` is a duration (`"5s"`, `"500ms"`, `"2m"`) or a size (`"16MB"`, `"512KB"`, `"1GB"`, `"4096B"`). A bare number is rejected. `find({ route: "primary" })` reads the primary. `find({ route: "replica" })` requires a replica that satisfies the same watermark and lag, and it does not fall back to the primary. `db.using("primary")` or `db.using("replica")` returns a client that forces that route. That client has no `close` and no `using`. `onRoute` receives `{ op, endpoint, reason }`. A throw from it is ignored. A string or an existing pool is one endpoint and serves either `route` from it, and it has no `using`.
998
+
999
+ The watermark is one value for the whole `connect()`. The root, `for()`, `unscoped()`, `using()`, and `reserve()` share it. A write through any of them moves it for the others. A watermark per `for()` client is not in this version.
1000
+
1001
+ When replicas are configured and consistency is `"session"`, a committed write reads `pg_current_wal_insert_lsn()` before its promise resolves. That is one extra round trip: on the primary pool after an autocommit `execute` or `batch`, and on the reserved connection after a successful `COMMIT`. A rollback does not read it. `"eventual"`, and a connect with no replicas, do not read it either.
1002
+
1003
+ `routing.select` picks among the replicas that passed health, the watermark, and `maxLag`. The default is `"weighted"`. `weighted` is smooth weighted round-robin. Equal weights take turns. A weight of `0` is rejected. `roundRobin` ignores weights and rotates through the eligible replicas. `leastConnections` uses the replica with the fewest statements already in flight on this client. A tie follows configuration order. `latencyAware` uses the replica with the lowest moving average of round-trip time. A function receives each candidate as `{ name, weight, inflight, latencyMs, lag }` and `{ op: "read" }`. `lag` is the number of bytes the replica is behind the primary, or `null` when that distance is unknown. The function returns one of those objects or its `name`. It runs only when an automatic read has two or more candidates.
1004
+
1005
+ A write, a batch, a transaction, or a locking read on a replica route is OKM1840. `route: "replica"` or `using("replica")` with no eligible replica is OKM1843, including a topology with no replicas. An automatic read with `routing.fallback: "error"` and no eligible replica is OKM1844. A migrate target that carries `primary`, `replicas`, `weight`, or `pool` is OKM1845. Waiting for a connection past `timeouts.acquire` is OKM1846, and that wait stays on the endpoint's own pool. The rules are in [topology](https://github.com/omqkhafi/okmodel/blob/main/docs/topology.md).
1006
+
1007
+ A replica whose pool is at its maximum, with no idle connection and callers waiting, is skipped. If every replica that can serve the read is in that state, the read uses the primary and `onRoute` reports `fallback:saturated`. When healthy replicas exist and none has caught up, or none is inside `maxLag`, the reason is `fallback:behind`. `route: "replica"` still uses a saturated replica, and it still requires the watermark and `maxLag`.
1008
+
1009
+ `replicas.ts`:
1010
+
1011
+ ```ts
1012
+ import { connect } from "okmodel/pg/postgresjs";
1013
+
1014
+ import schema from "./schema.ts";
1015
+
1016
+ const url = process.env.DATABASE_URL;
1017
+ if (url === undefined || url.length === 0) throw new Error("DATABASE_URL is not set");
1018
+ const east = process.env.REPLICA_URL;
1019
+ if (east === undefined || east.length === 0) throw new Error("REPLICA_URL is not set");
1020
+ const west = process.env.REPLICA_URL_WEST ?? east;
1021
+
1022
+ const endpoints = {
1023
+ primary: url,
1024
+ replicas: [{ url: east, weight: 2, name: "east" }, west],
1025
+ };
1026
+
1027
+ const db = await connect(endpoints, {
1028
+ schema,
1029
+ routing: { probe: "1s", fallback: "primary", consistency: "session", select: "weighted" },
1030
+ onRoute(event) {
1031
+ // { op: "read", endpoint: "east", reason: "auto:east" }
1032
+ void event;
1033
+ },
1034
+ });
1035
+
1036
+ await db.notes.find({ limit: 50 });
1037
+ await db.notes.find({ limit: 50, route: "primary" });
1038
+ await db.notes.find({ limit: 50, route: "replica" });
1039
+ const replica = db.using("replica");
1040
+ await replica.notes.find({ limit: 50 });
1041
+ await db.close();
1042
+
1043
+ const eventual = await connect(endpoints, {
1044
+ schema,
1045
+ routing: { consistency: "eventual" },
1046
+ });
1047
+ await eventual.notes.find({ limit: 50 });
1048
+ await eventual.close();
1049
+
1050
+ for (const select of ["roundRobin", "leastConnections", "latencyAware"] as const) {
1051
+ const client = await connect(endpoints, { schema, routing: { select } });
1052
+ await client.notes.find({ limit: 1 });
1053
+ await client.close();
1054
+ }
1055
+
1056
+ const custom = await connect(endpoints, {
1057
+ schema,
1058
+ routing: {
1059
+ select(candidates) {
1060
+ return candidates[0] ?? "east";
1061
+ },
1062
+ },
1063
+ });
1064
+ await custom.notes.find({ limit: 1 });
1065
+ await custom.close();
1066
+ ```
1067
+
1068
+ ### Reference app
1069
+
1070
+ The worked example is a project tracker: workspaces as tenants, archive, a function, two views, three generated migrations, and this topology. It is a private package in the repository, not on npm. CI runs it on the primary and two standbys. Board reads go to a replica. A read just after a write, with replay paused, comes back from the primary. The report uses `route: "replica"`. See the [example app](https://github.com/omqkhafi/okmodel/blob/main/docs/example-app.md) and the [reference app notes](https://github.com/omqkhafi/okmodel/blob/main/packages/reference-app/README.md).
1071
+
1072
+ ### Startup
1073
+
1074
+ `connect()` compares the app with `okm_history`. A database that is ahead by expand migrations still opens. Ahead by a contract migration, or behind the app, is OKM1520 and names the migration. `okm migrate status` prints the same states, plus `failed at step N (resume with okm migrate apply)`.
1075
+
1076
+ ### Protected targets
1077
+
1078
+ `protected: true` is a flag on the target, not on the name and not on `NODE_ENV`. Plan, status, check, ext check, and drift stay allowed. Expand and reference rows stay allowed. An empty protected database can be provisioned. Contract, unclassified SQL, push, backfill, and seed are OKM1850 unless that invocation passes `--allow-protected`. `okm migrate check` writes a scratch schema, so it stays refused, and the flag does not apply. Point it at a throwaway Postgres. Protection does not affect `connect()`.
1079
+
883
1080
  ## Commands
884
1081
 
885
- | Command | What it does |
886
- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
887
- | `okm build` | Validates the schema and writes `.okm/` (catalog, hash, emitted row types). `okm generate` writes those files too. |
888
- | `okm check` | Reports a stale `renamedFrom` and a table file the schema does not import. |
889
- | `okm generate [name]` | Writes a SQL migration and `.okm/`. The name defaults to `migration`. Prints `no changes` when the schema matches. |
890
- | `okm dev` | Uses a target named `dev`, or creates a PGlite database in `.okm/dev-db`. It does not apply migrations or write the catalog. |
891
- | `okm push` | Applies the schema directly. Refused when the target is protected. |
892
- | `okm migrate plan <name>` | Prints the plan and its class. The name is required. |
893
- | `okm migrate apply` | Replays migration files on the database. |
894
- | `okm migrate status` | Prints version, catalog hash, and state for each target. |
895
- | `okm ext list` | Prints the extensions the connected server can install and the version that is installed. |
896
- | `okm ext check` | Compares those versions with the schema. A missing extension is OKM1811. A pin the server does not meet is OKM1812. |
897
- | `okm doctor [code]` | Lists the triggers on each table. A code argument prints that code. |
898
- | `okm --version` | Prints the package version. |
1082
+ | Command | What it does |
1083
+ | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1084
+ | `okm build` | Validates the schema and writes `.okm/`. Does not connect. |
1085
+ | `okm check` | Lints the schema and, after `okm_meta` exists, compares the database. Drift is OKM1520. Allowed on a protected target. |
1086
+ | `okm generate [name]` | Writes a SQL migration and `.okm/`. Prints lint findings and still writes the file. Offline. |
1087
+ | `okm dev` | Opens a PGlite database in `.okm/dev-db`, or a target named `dev`. It does not apply migrations. |
1088
+ | `okm push` | Applies the schema directly. OKM1850 on a protected target unless `--allow-protected`. |
1089
+ | `okm migrate plan <name>` | Prints each step's class and lock, plus a row estimate when the target answers. Read-only on a protected target. An error finding exits non-zero. |
1090
+ | `okm migrate apply` | Installs the head snapshot on an empty database, or runs pending files. Expand is allowed on a protected target. Contract, unclassified SQL, and backfill need `--allow-protected`. |
1091
+ | `okm migrate check` | Replays the history in a scratch schema and checks the previous catalog, the head, and the linter. Prints `ok N migrations`. Refused on a protected target, and `--allow-protected` does not apply. |
1092
+ | `okm migrate status` | Prints version, catalog hash, and state. Read-only on a protected target. An unfinished backfill is listed under the table. |
1093
+ | `okm seed <file>` | Runs the file's default export against the migrated target. OKM1850 on a protected target unless `--allow-protected`. |
1094
+ | `okm ext list` | Prints extensions the server can install. Read-only on a protected target. |
1095
+ | `okm ext check` | Compares installed extensions with the schema. OKM1811 if one is missing, OKM1812 if the pin is not met. Read-only on a protected target. `okm ext test` and `okm ext scaffold` are not in this version. |
1096
+ | `okm doctor [code]` | Lists triggers, or explains one error code. A role check is read-only on a protected target. |
1097
+ | `okm --version` | Prints the package version. |
899
1098
 
900
1099
  `okmodel` and `okm` are the same command.
901
1100
 
1101
+ ## Check a history in CI
1102
+
1103
+ `okm migrate check` is the command for a pipeline. It applies every migration into a scratch schema, plans each result back to that file's catalog, checks the previous catalog, requires the last catalog to match the schema, and lints the history. Success prints `ok N migrations`. A failure exits non-zero.
1104
+
1105
+ Point it at a throwaway Postgres. A protected target is refused, and `--allow-protected` does not apply. `defineConfig({ lintFrom: "<migration id>" })` skips lint for files before that id, which is how a project adopts the linter without failing on old migrations. Apply still lints pending migrations only.
1106
+
1107
+ The previous-catalog check is schema-level. It does not prove application behaviour. Tenant targets are not checked.
1108
+
1109
+ ```yaml
1110
+ name: migrate
1111
+ on: [push, pull_request]
1112
+ jobs:
1113
+ check:
1114
+ runs-on: ubuntu-latest
1115
+ services:
1116
+ postgres:
1117
+ image: postgres:17
1118
+ env:
1119
+ POSTGRES_PASSWORD: postgres
1120
+ ports:
1121
+ - 5432:5432
1122
+ options: >-
1123
+ --health-cmd "pg_isready -U postgres"
1124
+ --health-interval 10s
1125
+ --health-timeout 5s
1126
+ --health-retries 5
1127
+ steps:
1128
+ - uses: actions/checkout@v5
1129
+ - uses: oven-sh/setup-bun@v2
1130
+ - run: bun install --frozen-lockfile
1131
+ - run: bunx okm migrate check
1132
+ env:
1133
+ DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres
1134
+ ```
1135
+
1136
+ The config reads `DATABASE_URL`, and that target is not protected.
1137
+
902
1138
  ## Roadmap
903
1139
 
904
1140
  Status is on the [board](https://github.com/users/omqkhafi/projects/1). Each release is a milestone.
@@ -906,8 +1142,8 @@ Status is on the [board](https://github.com/users/omqkhafi/projects/1). Each rel
906
1142
  - [x] [0.1](https://github.com/omqkhafi/okmodel/milestone/1) — Schema, queries, and migrations on PostgreSQL, with postgres.js and PGlite.
907
1143
  - [x] [0.2](https://github.com/omqkhafi/okmodel/milestone/2) — Hidden and sensitive fields, validation, traits, tenancy, archive and restore, richer relations, presets, transactions, and operators for JSON, arrays, ranges, and search.
908
1144
  - [x] [0.3](https://github.com/omqkhafi/okmodel/milestone/3) — Extensions, domains, functions, triggers, views, roles, and grants.
909
- - [ ] [0.4](https://github.com/omqkhafi/okmodel/milestone/4) — Safer migration plans, backfill, drift checks, provisioning, reference data, and a testing package.
910
- - [ ] [0.5](https://github.com/omqkhafi/okmodel/milestone/5) — A primary with replicas, read routing, and a reference app.
1145
+ - [x] [0.4](https://github.com/omqkhafi/okmodel/milestone/4) — Safer migration plans, backfill, drift checks, provisioning, reference data, and a testing package.
1146
+ - [x] [0.5](https://github.com/omqkhafi/okmodel/milestone/5) — A primary with replicas, read routing, and a reference app.
911
1147
 
912
1148
  `okmodel/internal` has no stability promise. Names on that subpath can change or disappear in any release.
913
1149
 
@@ -919,11 +1155,11 @@ Measured on this release.
919
1155
 
920
1156
  | | Minified | Gzip | Cold import |
921
1157
  | -------------------------------------------- | -------: | -----: | ----------: |
922
- | Runtime entry | 5,288 | 2,026 | 1.794 ms |
923
- | App startup (10 tables, one find) | 89,465 | 29,723 | 10.554 ms |
924
- | App startup, every 0.2 feature in use (full) | 119,940 | 39,123 | 12.663 ms |
1158
+ | Runtime entry | 5,288 | 2,026 | 2.347 ms |
1159
+ | App startup (10 tables, one find) | 89,751 | 29,801 | 15.061 ms |
1160
+ | App startup, every 0.2 feature in use (full) | 120,132 | 39,162 | 15.956 ms |
925
1161
 
926
- The full app has column tenancy, `archivable()`, `timestamps()`, validation rules, `one`, `many` and `manyThrough` relations, presets, and calls `include`, `page`, `aggregate`, `tx` and `batch`. A feature costs bytes only in an app that uses it: the full app is 30,475 minified and 9,400 gzip bytes above the plain one. The runtime entry and the plain app are gated. The full app is printed, not gated.
1162
+ The full app has column tenancy, `archivable()`, `timestamps()`, validation rules, `one`, `many` and `manyThrough` relations, presets, and calls `include`, `page`, `aggregate`, `tx` and `batch`. A feature costs bytes only in an app that uses it: the full app is 30,381 minified and 9,361 gzip bytes above the plain one. The runtime entry and the plain app are gated. The full app is printed, not gated. The plain app's cold import on this sample is above the 15 ms local reference. That figure is printed and is not a gate.
927
1163
 
928
1164
  The rest of the measurements are in [size](https://github.com/omqkhafi/okmodel/blob/main/docs/size.md).
929
1165
 
@@ -931,6 +1167,10 @@ The rest of the measurements are in [size](https://github.com/omqkhafi/okmodel/b
931
1167
 
932
1168
  - [Quickstart](https://github.com/omqkhafi/okmodel/blob/main/docs/quickstart.md)
933
1169
  - [Production checklist](https://github.com/omqkhafi/okmodel/blob/main/docs/production.md)
1170
+ - [Linter](https://github.com/omqkhafi/okmodel/blob/main/docs/linter.md)
1171
+ - [Backfill](https://github.com/omqkhafi/okmodel/blob/main/docs/backfill.md)
1172
+ - [Provisioning](https://github.com/omqkhafi/okmodel/blob/main/docs/provisioning.md)
1173
+ - [Testing](https://github.com/omqkhafi/okmodel/blob/main/docs/testing.md)
1174
+ - [Topology](https://github.com/omqkhafi/okmodel/blob/main/docs/topology.md)
934
1175
  - [Known limits](https://github.com/omqkhafi/okmodel/blob/main/docs/known-limits.md)
935
1176
  - [Changelog](https://github.com/omqkhafi/okmodel/blob/main/changelog.md)
936
- - [Design spec](https://github.com/omqkhafi/okmodel/blob/main/docs/okmodel-api-design.md)
@@ -35,8 +35,12 @@ export declare function renameColumn(source: Catalog, change: {
35
35
  /**
36
36
  * Renames a table without renaming its constraints or indexes.
37
37
  *
38
- * Column, index, and constraint parents move to the new name. Expression text
39
- * that names the table is rewritten. Constraint and index names stay.
38
+ * Column, index, constraint, and trigger parents move to the new name, and
39
+ * edges that pointed at those objects move with them. Expression text that
40
+ * names the table is rewritten. Constraint and index names stay. A grant on
41
+ * the table, or on an identity sequence named `{table}_{column}_seq`, follows
42
+ * the name the schema will use. The planner emits the `RENAME` for a default
43
+ * name; a custom name is left as it is.
40
44
  *
41
45
  * @param source - Catalog document
42
46
  * @param change - Namespace, current table name, and the new name
@@ -58,6 +62,26 @@ export declare function serializeCatalog(source: Catalog): string;
58
62
  * @returns Lowercase hex digest
59
63
  */
60
64
  export declare function catalogHash(source: Catalog): string;
65
+ /**
66
+ * The catalog `connect()` compares with `okm_meta` (D208).
67
+ *
68
+ * Roles, grants, and default privileges come from the migration config, not
69
+ * the schema, so they are left out. A `schema()` catalog never has them, so
70
+ * `connect()` hashes it unchanged. `okm check` still verifies them. A
71
+ * catalog without them is returned as is.
72
+ *
73
+ * @param source - Catalog document
74
+ * @returns The catalog without role, grant, and default-privilege objects
75
+ */
76
+ export declare function startupCatalog(source: Catalog): Catalog;
77
+ /**
78
+ * {@link catalogHash} of {@link startupCatalog}. The hash `okm_meta` and
79
+ * `okm_history` store, and the one `connect()` computes.
80
+ *
81
+ * @param source - Catalog document
82
+ * @returns Lowercase hex digest
83
+ */
84
+ export declare function startupHash(source: Catalog): string;
61
85
  /**
62
86
  * Loads a build artifact without validating objects.
63
87
  *
@@ -61,6 +61,13 @@ export type ExecuteOptions = {
61
61
  * A deadline is kind `timeout`.
62
62
  */
63
63
  readonly timeout?: number;
64
+ /**
65
+ * Read routing hint.
66
+ *
67
+ * Drivers ignore it. A topology router reads it. Absent, the router
68
+ * classifies the statement.
69
+ */
70
+ readonly route?: "primary" | "replica";
64
71
  };
65
72
  /** Column names and parameter count from a describe. */
66
73
  export type DescribeResult = {
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  extension2
3
- } from "../../../shared/7fvtrkwr.js";
3
+ } from "../../../shared/ycatkxmq.js";
4
4
 
5
5
  // src/dialects/pg/ext/citext.ts
6
6
  function citext(options = {}) {
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  extension2
3
- } from "../../../shared/7fvtrkwr.js";
3
+ } from "../../../shared/ycatkxmq.js";
4
4
  export {
5
5
  extension2 as extension
6
6
  };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  extension2
3
- } from "../../../shared/7fvtrkwr.js";
3
+ } from "../../../shared/ycatkxmq.js";
4
4
  import {
5
5
  tag
6
6
  } from "../../../shared/cn0n68ft.js";
@@ -20,10 +20,10 @@ import {
20
20
  many2,
21
21
  manyThrough2,
22
22
  schema2
23
- } from "../../shared/x6wysh83.js";
23
+ } from "../../shared/3p4b1fg0.js";
24
24
  import {
25
25
  domainType
26
- } from "../../shared/qbxzvwwc.js";
26
+ } from "../../shared/g7ez901n.js";
27
27
  import {
28
28
  id2,
29
29
  uuid2
@@ -32,7 +32,7 @@ import {
32
32
  table2,
33
33
  index2,
34
34
  sql2
35
- } from "../../shared/6w5j844a.js";
35
+ } from "../../shared/jsd07prz.js";
36
36
  import {
37
37
  text2,
38
38
  varchar2,
@@ -39,6 +39,12 @@ export type CatalogQuery = {
39
39
  * @returns A checked catalog
40
40
  */
41
41
  export declare function introspectSchema(runner: CatalogQuery, concrete: string, logical?: string, options?: IntrospectOptions): Promise<Catalog>;
42
+ /**
43
+ * A function's argument types as a grant names them, for `pg_proc p`.
44
+ *
45
+ * Types only, comma-separated, no spaces. Argument names are left out.
46
+ */
47
+ export declare const FUNCTION_ARG_TYPES = "replace(oidvectortypes(p.proargtypes), ' ', '')";
42
48
  /**
43
49
  * Applies one expression as a check and reads Postgres's reprint.
44
50
  *
@@ -8,11 +8,16 @@ import type { CatalogObject } from "../../../contracts/catalog/types.js";
8
8
  /** Statements the planner inserts around the rest of the catalog. */
9
9
  export type PrivilegeSql = {
10
10
  /** Revokes, run while the objects still exist. */
11
- readonly revoke: readonly string[];
11
+ readonly revoke: readonly PrivilegeStatement[];
12
12
  /** Role creates, role alters, and default privileges. Before new tables. */
13
- readonly prepare: readonly string[];
13
+ readonly prepare: readonly PrivilegeStatement[];
14
14
  /** Grants on objects that exist, or that this plan creates first. */
15
- readonly grant: readonly string[];
15
+ readonly grant: readonly PrivilegeStatement[];
16
+ };
17
+ /** One privilege statement and the planner kind it is. */
18
+ export type PrivilegeStatement = {
19
+ readonly sql: string;
20
+ readonly kind: "revoke" | "revoke-default" | "create-role" | "alter-role" | "grant-default" | "grant";
16
21
  };
17
22
  /**
18
23
  * Diffs role, grant, and default-privilege objects into SQL.
@@ -25,9 +30,11 @@ export type PrivilegeSql = {
25
30
  * @param before - Previous catalog objects
26
31
  * @param after - Next catalog objects
27
32
  * @param schema - Concrete schema name
33
+ * @param recreated - Identity keys this plan drops and creates. Postgres drops
34
+ * grants with the object, so those grants are issued again after it exists
28
35
  * @returns Statements in three groups
29
36
  */
30
- export declare function privilegeSql(before: readonly CatalogObject[], after: readonly CatalogObject[], schema: string): PrivilegeSql;
37
+ export declare function privilegeSql(before: readonly CatalogObject[], after: readonly CatalogObject[], schema: string, recreated?: ReadonlySet<string>): PrivilegeSql;
31
38
  /**
32
39
  * Role name in `create role`, when the statement is one.
33
40
  *
@@ -35,6 +42,16 @@ export declare function privilegeSql(before: readonly CatalogObject[], after: re
35
42
  * @returns The role, or `undefined` when the statement is not `CREATE ROLE`
36
43
  */
37
44
  export declare function createdRoleName(sql: string): string | undefined;
45
+ /**
46
+ * Runs a plan's `CREATE ROLE` so a role another session created first is not an error.
47
+ *
48
+ * Roles belong to the cluster. Two databases provisioned at once both find the
49
+ * role absent, and the second `CREATE ROLE` fails on `pg_authid`.
50
+ *
51
+ * @param sql - A `CREATE ROLE` statement from the plan
52
+ * @returns The statement in a `DO` block that ignores a duplicate role
53
+ */
54
+ export declare function createRoleOnce(sql: string): string;
38
55
  /**
39
56
  * Reports whether a statement creates or alters a role.
40
57
  *
@@ -144,6 +144,13 @@ type ApplySchemaTraits<TTable, TFields, TTraits> = TTable extends {
144
144
  readonly "~insert": InsertFrom<TFields>;
145
145
  readonly "~update": UpdateFrom<TFields>;
146
146
  } & HasArchive<TTraits> & PresetsOfList<TTraits>;
147
+ /**
148
+ * `~views` when `schema({ views })` declared any, so `db.views` is typed.
149
+ * Nothing at runtime carries the key.
150
+ */
151
+ type SchemaViews<TViews extends readonly unknown[]> = TViews extends readonly [] ? unknown : {
152
+ readonly "~views": TViews;
153
+ };
147
154
  /**
148
155
  * Compiles tables into a catalog.
149
156
  *
@@ -156,19 +163,21 @@ type ApplySchemaTraits<TTable, TFields, TTraits> = TTable extends {
156
163
  */
157
164
  export declare function schema<const TTables extends readonly AnyTable[], const TTraits extends readonly {
158
165
  readonly fields: Readonly<Record<string, object>>;
159
- }[], const TTenancy extends ColumnTenancy | undefined = undefined, const TValidation = undefined>(config: Omit<SchemaInput<TTables>, "traits" | "tenancy" | "validation"> & {
166
+ }[], const TTenancy extends ColumnTenancy | undefined = undefined, const TValidation = undefined, const TViews extends readonly unknown[] = readonly []>(config: Omit<SchemaInput<TTables>, "traits" | "tenancy" | "validation" | "views"> & {
160
167
  readonly traits: TTraits;
161
168
  readonly tenancy?: TTenancy;
162
169
  readonly validation?: TValidation;
163
- }): [TValidation] extends [undefined] ? TenancySchema<SchemaWithTraits<TTables, TTraits>, TTables, TTenancy> : TenancySchema<SchemaWithTraits<TTables, TTraits>, TTables, TTenancy> & {
170
+ readonly views?: TViews;
171
+ }): ([TValidation] extends [undefined] ? TenancySchema<SchemaWithTraits<TTables, TTraits>, TTables, TTenancy> : TenancySchema<SchemaWithTraits<TTables, TTraits>, TTables, TTenancy> & {
164
172
  readonly "~validation": TValidation;
165
- };
166
- export declare function schema<const TTables extends readonly AnyTable[], const TTenancy extends ColumnTenancy | undefined = undefined, const TValidation = undefined>(config: Omit<SchemaInput<TTables>, "tenancy" | "validation"> & {
173
+ }) & SchemaViews<TViews>;
174
+ export declare function schema<const TTables extends readonly AnyTable[], const TTenancy extends ColumnTenancy | undefined = undefined, const TValidation = undefined, const TViews extends readonly unknown[] = readonly []>(config: Omit<SchemaInput<TTables>, "tenancy" | "validation" | "views"> & {
167
175
  readonly tenancy?: TTenancy;
168
176
  readonly validation?: TValidation;
169
- }): [TValidation] extends [undefined] ? TenancySchema<BuiltSchema<TTables>, TTables, TTenancy> : TenancySchema<BuiltSchema<TTables>, TTables, TTenancy> & {
177
+ readonly views?: TViews;
178
+ }): ([TValidation] extends [undefined] ? TenancySchema<BuiltSchema<TTables>, TTables, TTenancy> : TenancySchema<BuiltSchema<TTables>, TTables, TTenancy> & {
170
179
  readonly "~validation": TValidation;
171
- };
180
+ }) & SchemaViews<TViews>;
172
181
  /**
173
182
  * Registers a hook from an opt-in module.
174
183
  *
@@ -112,7 +112,13 @@ export type TableOptions<TColumns> = {
112
112
  */
113
113
  readonly presets?: PresetMap<RowFrom<TColumns>>;
114
114
  readonly policies?: unknown;
115
- readonly reference?: unknown;
115
+ /**
116
+ * Rows the application requires to exist.
117
+ *
118
+ * Apply inserts a row when its key is missing. It does not update or delete.
119
+ * The rows stay on the schema. They are not a diff in the catalog.
120
+ */
121
+ readonly reference?: ReferenceOption;
116
122
  /** `one` and `many`. Other relation kinds stay reserved. */
117
123
  readonly relations?: unknown;
118
124
  };
@@ -207,6 +213,16 @@ export type InsertFrom<TColumns> = {
207
213
  export type UpdateFrom<TColumns, TKey extends string = never> = {
208
214
  readonly [K in keyof TColumns as K extends TKey ? never : ColumnUpdateOf<TColumns[K]> extends never ? never : K]: ColumnUpdateOf<TColumns[K]>;
209
215
  };
216
+ /**
217
+ * Rows declared with the table option `reference`.
218
+ *
219
+ * `key` is one column name or several, in constraint order. `rows` are the
220
+ * values apply inserts when that key is absent.
221
+ */
222
+ export type ReferenceOption = {
223
+ readonly key: string | readonly string[];
224
+ readonly rows: readonly Readonly<Record<string, string | number | boolean | null>>[];
225
+ };
210
226
  /**
211
227
  * Declares a table.
212
228
  *