@ultimat3/cli 16.0.0 → 18.0.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.
- package/CLAUDE.md +90 -7
- package/package.json +30 -30
- package/src/app-root.ts +13 -1
- package/src/cmd-dev.ts +16 -2
- package/src/cmd-doctor.ts +1 -1
- package/src/cmd-test.ts +20 -9
- package/src/db-accept-created.ts +207 -0
- package/src/db-generate.ts +18 -1
- package/src/db-subscribes.ts +81 -0
- package/src/db-ungeneratable.ts +14 -2
- package/src/dev-assets.ts +19 -54
- package/src/dev-notify-retention.ts +69 -0
- package/src/dev-purge.ts +47 -2
- package/src/dev-render.ts +20 -4
- package/src/dev-replicator.ts +19 -1
- package/src/dev-roles.ts +19 -1
- package/src/dev-runtime.ts +12 -3
- package/src/dev-services.ts +8 -0
- package/src/dev-traces.ts +5 -1
- package/src/e2e-page.ts +16 -2
- package/src/error-codes.ts +9 -0
- package/src/icon-assets.ts +74 -0
- package/src/index.ts +19 -4
- package/src/island-harness-script.ts +8 -1
- package/src/island-shot.ts +45 -6
- package/src/island-verdict.ts +16 -4
- package/src/mcp-errors.ts +6 -0
- package/src/messages.ts +1 -1
- package/src/metrics-endpoint.ts +6 -1
- package/src/prerender.ts +16 -0
- package/src/pwa-artifacts.ts +188 -0
- package/src/serve.ts +13 -1
- package/src/sync-authenticator.ts +8 -2
- package/src/templates/naming.ts +11 -0
- package/src/templates/scaffold-repo.ts +14 -1
- package/src/test-shards.ts +150 -116
- package/src/ts-scan.ts +6 -1
- package/src/verify-test-run.ts +31 -46
package/CLAUDE.md
CHANGED
|
@@ -8,6 +8,7 @@ Tier 5. May import tiers 0–4. Nothing imports this except `create-ultimate`.
|
|
|
8
8
|
| stdout | `write-line.ts`'s `writeLine` — synchronous fd 1, never `process.stdout.write`, which truncates at the 64KB pipe buffer when `process.exit` follows. Exported, because `create-ultimate`'s entry point needs the same one |
|
|
9
9
|
| stderr | `write-line.ts`'s `writeErrorLine` — the same loop on fd 2, for a line that is not the command's answer. A `CommandResult` declaring `stream: 'stderr'` is routed there by `dispatch.ts`'s `sinkFor`, and `x mcp serve --transport stdio` is the one case: its fd 1 carries JSON-RPC frames, so the `✓ mcp stdio serving 13 tools` line rendered after the loop was a malformed frame. Neither renderer carries `stream`, exactly like `hold` |
|
|
10
10
|
| Boot logs under `--json` | `dispatch.ts` calls core's `setLogStream('stderr')` when `args.json` is set, once, for all thirty commands. `x db migrate --json` printed the boot logger's `ultimate migrate applied` and then the command's own object, so `json.load` raised on the second document. A server's stdout stays its log stream; this is the CLI process only |
|
|
11
|
+
| Test execution | `test-shards.ts`'s `testArgs` — ONE `bun test --parallel=N`, never N processes this repo packs itself. It did pack them, largest-first greedy over file SIZE, and the packer was deleted for buying **nothing**: four interleaved runs each on the 1296-file unit corpus gave 58.2/60.0/65.0/66.5s hand-packed against 54.5/57.8/61.7/64.5s under `--parallel=8`, within noise, because both are work-bound — 436.7s of file time is a 54.6s floor on 8 workers and the slowest single file is 20.5s. A greedy pack of 1296 small items lands near-optimal by accident. `--timings` is refused on the same evidence (#342). `--parallel` implies `--isolate`, so the per-file module registry is unchanged, and the per-worker database is too: `@ultimat3/testing`'s `workerId` already read `BUN_TEST_WORKER_ID`, which Bun sets 1..N. `ULTIMATE_TEST_WORKER` is set only for a single-shard `x test --worker I` rerun, which is one process |
|
|
11
12
|
| Numeric flags | `flag-number.ts` — one reader for `--port` / `--workers` / `--shard`. A bare `Number.parseInt` accepts `4abc` and answers `NaN`, which turned three checks into ones that cannot fail |
|
|
12
13
|
| Shell quoting | `shell-quote.ts`'s `quoteArg` — every value the CLI pastes into a `fix:` or a reproduce line, `exec.ts`'s missing-program refusal and `test-shards.ts`'s reproduce command both. A name holding a space or a `;` interpolated bare is an instruction that runs something else |
|
|
13
14
|
| Missing positionals | `MissingPositionalError`, never `BadFlagError` (names a flag that does not exist) and never `UnknownCommandError` (says a known command form is not one). Its `example` is a REAL invocation — `x g route <name>` in a shell is a redirect |
|
|
@@ -273,11 +274,28 @@ image of the wrong thing.
|
|
|
273
274
|
`page.pageErrors()` are bounded rings over the whole SESSION, so a shared one files state A's
|
|
274
275
|
console errors under state B — and per-state attribution is the half of the artifact that gates.
|
|
275
276
|
|
|
276
|
-
**The picture is the
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
277
|
+
**The picture is the CROP TARGET, `As of 2026-08-26`** — the readiness probe's own box, which is
|
|
278
|
+
the selector the manifest declared or the island's host element. Measured on `examples/dummy`
|
|
279
|
+
before it: 720x560 for a component whose box the verdict reported, in the same run, as 688x104.
|
|
280
|
+
`CaptureClip` had been on the port since #336 and `island-shot.ts` passed none, and this paragraph
|
|
281
|
+
said the port "takes no clip rectangle" — so did the verdict's own `blind` list, which is a blind
|
|
282
|
+
spot naming a capability the tool has, the same lie as one hiding a gap. The state's `viewport` is
|
|
283
|
+
still what the page is LAID OUT in, so there is still one browser per declared viewport, memoised;
|
|
284
|
+
it is no longer what the picture is.
|
|
285
|
+
|
|
286
|
+
**The clip is translated, not copied.** `getBoundingClientRect()` answers VIEWPORT coordinates and
|
|
287
|
+
a capture clip is in PAGE coordinates; they agree only at the origin, which is the one case a
|
|
288
|
+
harness happens to be in and is a rule nothing enforces. So the probe returns `scroll` beside `box`
|
|
289
|
+
and `clipFor` adds them — a component below the fold would otherwise crop a band it is not in, with
|
|
290
|
+
a picture that looks like a picture and nothing anywhere to report it. `box` keeps meaning the DOM's
|
|
291
|
+
own answer, because that is what the verdict publishes.
|
|
292
|
+
|
|
293
|
+
**Both themes are photographed by emulating the PREFERENCE.** `page.colorScheme(target.theme)`
|
|
294
|
+
before the navigation, so the first paint already has it. The harness's `data-theme` attribute stays
|
|
295
|
+
— it is right for a component that READS a theme it does not own — but it is the OUTCOME of a theme
|
|
296
|
+
decision, and a component that resolves `'system'` itself deletes it on mount: `x shot --island`
|
|
297
|
+
reported four pictures and wrote two, byte-identical, same md5 (#338). Re-setting the attribute
|
|
298
|
+
after readiness is not the repair; it photographs a state the component would never reach.
|
|
281
299
|
|
|
282
300
|
**`loadApp` does not import a states file**, for the reason it does not import an island: it
|
|
283
301
|
registers no primitive, and importing it would put `@ultimat3/testing` in the server module graph of
|
|
@@ -573,6 +591,8 @@ regex and `+` is a quantifier — `n1` is what actually selects these tests.
|
|
|
573
591
|
| `db-branch.ts` | what a branch IS: the closed verb set, the name it takes on disk and in `pg_database`, and list/create/drop per mode |
|
|
574
592
|
| `cmd-db-branch.ts` | `x db branch`'s wiring alone — which verb, which refusal, and the one connection an external clone runs on |
|
|
575
593
|
| `db-finding.ts` | one thrown value → one `Finding`, shared by `cmd-db.ts` and `cmd-db-branch.ts` |
|
|
594
|
+
| `db-accept-created.ts` | `acceptCreatedTables`: the post-migrate report minus the tables the applied migrations' own SQL creates — the half `@ultimat3/db`'s `unexpectedTable` names |
|
|
595
|
+
| `db-subscribes.ts` | `replicaIdentityTables`: the tables `x db gen` grants `REPLICA IDENTITY FULL`, read off each live query's declared `subscribes:` — and `X_QUERY_SUBSCRIBES_UNKNOWN` for a name no entity's table matches |
|
|
576
596
|
| `drift.ts` | `checkSourceDrift`: the `.hash` sidecar the `drift` step compares, no database needed |
|
|
577
597
|
| `schema-diff.ts` | what two GENERATED snapshots disagree about, as data — the pure half |
|
|
578
598
|
| `schema-drift.ts` | `checkMigrationDrift`: entity declarations against the newest `.snapshot.json`, and the composition the `drift` step and `x doctor` both read |
|
|
@@ -674,6 +694,47 @@ is only the channel each has. `ROLE=migrate` logged and exited 0 until it did no
|
|
|
674
694
|
whose only signal is the exit code reported success over a schema nobody can reconstruct, which is
|
|
675
695
|
the failure the post-migrate check exists to catch.
|
|
676
696
|
|
|
697
|
+
**`x db gen` emits `REPLICA IDENTITY FULL`, and the set is DECLARED rather than derived**,
|
|
698
|
+
`As of 2026-08-26` (#357). `@ultimat3/realtime` refuses a live subscription to a table without it —
|
|
699
|
+
logical replication carries no old row on an UPDATE, so no patch can be computed — and for two
|
|
700
|
+
years nothing in the framework emitted one. It could not be derived, and that is the load-bearing
|
|
701
|
+
fact: the relation name lives inside the query's `sql:` callback, which no generator can invoke
|
|
702
|
+
without valid input (`describeSql` says so itself — "`null` when no sample input was supplied").
|
|
703
|
+
So a live query DECLARES it (`subscribes:`, `@ultimat3/query`), the declaration is machine-checked
|
|
704
|
+
against the resolved `shape.entity` on the first subscribe (`X_QUERY_SUBSCRIBES_DRIFT`), and
|
|
705
|
+
`db-subscribes.ts` reads it off `describeQueries()` — the same source `frameworkSources` copies onto
|
|
706
|
+
`QueryFact.subscribes`, one hop earlier, because building the manifest here would re-load the app
|
|
707
|
+
and demand a `package.json` that `x db gen` has never needed.
|
|
708
|
+
|
|
709
|
+
**The third `subscribes:` refusal is this package's, because no other tier can ask it.**
|
|
710
|
+
`@ultimat3/db` keeps only the declared names an entity's table matches and DROPS the rest — it has
|
|
711
|
+
no way to tell a typo from a table another migration owns — and `@ultimat3/query` holds no table
|
|
712
|
+
catalog at all. So `subscribes: ['posts', 'user']` granted the identity to `posts`, dropped `user`
|
|
713
|
+
in silence, and read as granted. `X_QUERY_SUBSCRIBES_UNKNOWN` refuses it BEFORE anything is
|
|
714
|
+
written, naming the query and offering the tables the app does declare. It is checked after
|
|
715
|
+
`loadApp`'s findings, never before: a module that would not import leaves the registry short, and
|
|
716
|
+
every name whose entity lives in it would then look like a typo.
|
|
717
|
+
|
|
718
|
+
**And it accepts a table the migrations it just applied demonstrably created**, `As of 2026-08-26`
|
|
719
|
+
(issue #345). A snapshot records only what ENTITIES declare, so a table created by a HAND-WRITTEN
|
|
720
|
+
migration reached no sidecar and was `unexpected-table` on every deploy forever — with a `fix:`
|
|
721
|
+
that generated an empty migration, because `x db gen` diffs the entity registry against the newest
|
|
722
|
+
snapshot and the table is on neither side. `@ultimat3/db` fixed the wording; `acceptCreatedTables`
|
|
723
|
+
(`db-accept-created.ts`) is the half that file's `unexpectedTable` names, and it is composed around
|
|
724
|
+
`checkDrift` inside `runMigrations`, so `x db migrate` and `ROLE=migrate` accept the same set.
|
|
725
|
+
**Only `unexpected-table`, and only for a name a migration's SQL creates** — which is what keeps it
|
|
726
|
+
an acceptance rather than the check switched off: a table absent from the snapshot produces exactly
|
|
727
|
+
one difference (`diffSchema` reports it and never compares its columns), and a table nobody
|
|
728
|
+
declared and no migration created is still reported, cause and `fix:` intact. The evidence is the
|
|
729
|
+
applied list itself: `migrate()` runs first, so every file on disk has been applied by the time the
|
|
730
|
+
question is asked. The verb phrase is read ANCHORED off the raw statement, which is the whole
|
|
731
|
+
protection — a `create table` can only be at position 0 by being one, so `values ('create table
|
|
732
|
+
ghost')` opens with `insert` and a comment-only chunk is not a statement at all. A `stripSqlNoise`
|
|
733
|
+
pass was written first and deleted: it could not change one answer, and a defence that cannot fail
|
|
734
|
+
is one nobody can test. Everything the anchor admits and the name grammar does not — a comment
|
|
735
|
+
between the keywords, a `temp` table, a qualifier naming a schema `checkDrift` never introspected —
|
|
736
|
+
contributes nothing, which reports drift that could have been accepted and never the reverse.
|
|
737
|
+
|
|
677
738
|
**The `drift` step asks a third thing, off the same directory and with no database either: is every
|
|
678
739
|
destructive statement declared?** `db-destructive.ts` reads each committed migration through
|
|
679
740
|
`migrations.ts` — the reader `x db migrate` applies from, because a rail checking a list the
|
|
@@ -751,8 +812,15 @@ decisions behind that shape:
|
|
|
751
812
|
Regenerating is exactly what *discards* these statements, so the command every other db code
|
|
752
813
|
answers with is the one this one must not lead with — `X_MIGRATION_UNGENERATABLE`'s `CLI_FIXES` row
|
|
753
814
|
is `x verify --only drift`, and the re-declare branch (an enum is a text column plus a check
|
|
754
|
-
invariant) rides behind an em-dash because it is available for some of the statements and
|
|
755
|
-
|
|
815
|
+
invariant) rides behind an em-dash because it is available for some of the statements and not all.
|
|
816
|
+
**`REPLICA IDENTITY FULL` was the statement with no second branch, and stopped being one on
|
|
817
|
+
2026-08-26** (#357): a live query declares the relations it is patched from (`subscribes:`),
|
|
818
|
+
`db-subscribes.ts` reads them off the same registry the manifest is projected from, `x db gen`
|
|
819
|
+
emits the ALTER and `@ultimat3/db` records it on the snapshot so it is emitted once. The re-declare
|
|
820
|
+
branch covers it now: declare `subscribes:` and regenerate. A statement already committed is a
|
|
821
|
+
different question and still counts — `GENERATABLE_FORMS` (`@ultimat3/db`) matches a leading verb
|
|
822
|
+
phrase and does not carry this one, measured at 7 found / 7 declared on `examples/dummy`'s
|
|
823
|
+
`0001_init.sql`, `As of 2026-08-26` — so the marker branch remains the only remedy for SQL on disk.
|
|
756
824
|
|
|
757
825
|
**`x db gen` reports what it could not write, and exits 0.** `GeneratedMigration.unrendered` reached
|
|
758
826
|
the committed `.sql` as a `-- UNRENDERED` comment and nothing else read it; `db-generate.ts` now
|
|
@@ -813,6 +881,7 @@ hand-written layout and `readMigrations` skips it — read as a migration it sor
|
|
|
813
881
|
| `dev-runtime.ts` | start the rest on top of it and install the remaining accessors (storage, mail, transport) |
|
|
814
882
|
| `dev-cache.ts` | which cache tiers this process reads through, and the cross-instance invalidation hop |
|
|
815
883
|
| `dev-purge.ts` | the hourly retention sweep: which framework tables this boot owns, the `purge()` job over them and the `task` that fires it |
|
|
884
|
+
| `dev-notify-retention.ts` | `notify.inboxReadRetentionMs` / `inboxUnreadRetentionMs` off the app's own `app.config.ts` — the sibling of `loadSignInPath` and `loadCacheTiers`, because `startServices` holds no `AppConfig` |
|
|
816
885
|
| `dev-sync.ts` | the `sync` role: its live-query registry, who is dialling it, and the socket it owns |
|
|
817
886
|
| `runtime-overrides.ts` | the one field a host hands the framework a driver through |
|
|
818
887
|
| `sync-authenticator.ts` | the app's HTTP authenticator, seen as the sync node's |
|
|
@@ -824,6 +893,7 @@ hand-written layout and `readMigrations` skips it — read as a migration it sor
|
|
|
824
893
|
| `favicon.ts` | `/favicon.ico`: the app's own file, and the bytes the framework answers with when there is none |
|
|
825
894
|
| `dev-hooks.ts` | the pipeline's `authorize` seam, decided from the app's own `Policy` objects |
|
|
826
895
|
| `dev-replica.ts` | which boot gets a standby, and the one middleware frame that opens the read scope |
|
|
896
|
+
| `dev-replicator.ts` | the `replicator` role: the feed selected, locked and pumped — and `replicatedRelations()`, the entity TABLES it filters on |
|
|
827
897
|
| `dev-roles.ts` | `--role` selection plus start/stop for `web`, `sync`, `worker`, `scheduler` |
|
|
828
898
|
| `dev-dashboard.ts` | the `DevSources` hooks only this process can answer, and the two CLI panels |
|
|
829
899
|
| `dev-traces.ts` | core's spans → the `/_x` timeline's request traces |
|
|
@@ -912,6 +982,19 @@ The per-TENANT subscription cap is deliberately unset, and **both halves of it a
|
|
|
912
982
|
nothing — and no default is defensible when one tenant is a person and the next is five thousand
|
|
913
983
|
seats. The per-socket 128 stands because a socket is one browser tab.
|
|
914
984
|
|
|
985
|
+
**The change feed is filtered by TABLE, never by entity name**, `As of 2026-08-26`.
|
|
986
|
+
`replicatedRelations()` (`dev-replicator.ts`) is the one projection, and both of its readers are
|
|
987
|
+
catalog readers: `PgReplicationStream` keeps a change only when `#entities.has(relation.name)` and a
|
|
988
|
+
pgoutput Relation message names the table, while `warnPartialIdentity` matches the same list against
|
|
989
|
+
`pg_class.relname`. An entity NAME is the framework's own registry key — a cache tag, a policy and
|
|
990
|
+
`x entities describe` are all keyed by it — and `entity('user', { table: 'users' })` makes the two
|
|
991
|
+
different strings. It passed `.name`, so a renamed table matched on neither side: **every change
|
|
992
|
+
skipped** and a replica-identity warning that could never fire, with no error anywhere. Invisible to
|
|
993
|
+
every fixture in the tree, because `table` defaults to the name verbatim and all six entities in
|
|
994
|
+
`examples/dummy` have `name === table` — `dev-replicator.test.ts` uses `billingAccount` on
|
|
995
|
+
`billing_accounts` for exactly that reason, and proves the value through the real call chain:
|
|
996
|
+
`assertIdentifier` refuses `billingAccount` before any connection and accepts `billing_accounts`.
|
|
997
|
+
|
|
915
998
|
`trustProxy` is read from `TRUSTED_PROXY_HOPS` in `startWeb`, the way `PORT` and `ROLE` are read: it
|
|
916
999
|
is a fact about the deployment, not an app config choice, and one image runs behind an ingress in
|
|
917
1000
|
one cluster and behind nothing on a laptop. Without it `ctx.ip` is the ingress's socket address on
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "18.0.0",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"LICENSE"
|
|
29
29
|
],
|
|
30
30
|
"engines": {
|
|
31
|
-
"bun": ">=1.
|
|
31
|
+
"bun": ">=1.4.0"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
34
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
@@ -37,34 +37,34 @@
|
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@babel/core": "^7.28.4",
|
|
40
|
-
"@ultimat3/action": "
|
|
41
|
-
"@ultimat3/admin": "
|
|
42
|
-
"@ultimat3/ai": "
|
|
43
|
-
"@ultimat3/auth": "
|
|
44
|
-
"@ultimat3/cache": "
|
|
45
|
-
"@ultimat3/core": "
|
|
46
|
-
"@ultimat3/db": "
|
|
47
|
-
"@ultimat3/entity": "
|
|
48
|
-
"@ultimat3/flags": "
|
|
49
|
-
"@ultimat3/http": "
|
|
50
|
-
"@ultimat3/i18n": "
|
|
51
|
-
"@ultimat3/jobs": "
|
|
52
|
-
"@ultimat3/mail": "
|
|
53
|
-
"@ultimat3/manifest": "
|
|
54
|
-
"@ultimat3/mcp": "
|
|
55
|
-
"@ultimat3/money": "
|
|
56
|
-
"@ultimat3/notify": "
|
|
57
|
-
"@ultimat3/policy": "
|
|
58
|
-
"@ultimat3/pwa": "
|
|
59
|
-
"@ultimat3/query": "
|
|
60
|
-
"@ultimat3/realtime": "
|
|
61
|
-
"@ultimat3/render": "
|
|
62
|
-
"@ultimat3/schema": "
|
|
63
|
-
"@ultimat3/scraping": "
|
|
64
|
-
"@ultimat3/seo": "
|
|
65
|
-
"@ultimat3/storage": "
|
|
66
|
-
"@ultimat3/testing": "
|
|
67
|
-
"@ultimat3/time": "
|
|
40
|
+
"@ultimat3/action": "18.0.0",
|
|
41
|
+
"@ultimat3/admin": "18.0.0",
|
|
42
|
+
"@ultimat3/ai": "18.0.0",
|
|
43
|
+
"@ultimat3/auth": "18.0.0",
|
|
44
|
+
"@ultimat3/cache": "18.0.0",
|
|
45
|
+
"@ultimat3/core": "18.0.0",
|
|
46
|
+
"@ultimat3/db": "18.0.0",
|
|
47
|
+
"@ultimat3/entity": "18.0.0",
|
|
48
|
+
"@ultimat3/flags": "18.0.0",
|
|
49
|
+
"@ultimat3/http": "18.0.0",
|
|
50
|
+
"@ultimat3/i18n": "18.0.0",
|
|
51
|
+
"@ultimat3/jobs": "18.0.0",
|
|
52
|
+
"@ultimat3/mail": "18.0.0",
|
|
53
|
+
"@ultimat3/manifest": "18.0.0",
|
|
54
|
+
"@ultimat3/mcp": "18.0.0",
|
|
55
|
+
"@ultimat3/money": "18.0.0",
|
|
56
|
+
"@ultimat3/notify": "18.0.0",
|
|
57
|
+
"@ultimat3/policy": "18.0.0",
|
|
58
|
+
"@ultimat3/pwa": "18.0.0",
|
|
59
|
+
"@ultimat3/query": "18.0.0",
|
|
60
|
+
"@ultimat3/realtime": "18.0.0",
|
|
61
|
+
"@ultimat3/render": "18.0.0",
|
|
62
|
+
"@ultimat3/schema": "18.0.0",
|
|
63
|
+
"@ultimat3/scraping": "18.0.0",
|
|
64
|
+
"@ultimat3/seo": "18.0.0",
|
|
65
|
+
"@ultimat3/storage": "18.0.0",
|
|
66
|
+
"@ultimat3/testing": "18.0.0",
|
|
67
|
+
"@ultimat3/time": "18.0.0",
|
|
68
68
|
"babel-preset-solid": "^1.9.15"
|
|
69
69
|
}
|
|
70
70
|
}
|
package/src/app-root.ts
CHANGED
|
@@ -7,7 +7,19 @@ import { BunVersionError, NotInAppError } from './errors';
|
|
|
7
7
|
|
|
8
8
|
export const APP_CONFIG_FILE = 'app.config.ts';
|
|
9
9
|
export const MANIFEST_FILE = 'x.manifest.json';
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* The floor the shipped `x` enforces, and it must not sit below what `x` EMITS. It said `1.3.0`
|
|
12
|
+
* through 2026-08-27 while `x test` spent `bun test --isolate` — a flag Bun introduced in
|
|
13
|
+
* **1.3.13** — so a user on a Bun this file declared supported got an unknown-flag failure out of
|
|
14
|
+
* the gate's dominant step, with `x doctor` reporting the runtime as fine. `--parallel` arrived in
|
|
15
|
+
* the same release and is emitted now.
|
|
16
|
+
*
|
|
17
|
+
* `1.4.0` rather than `1.3.13` because a floor is a claim about a runtime somebody TESTED: CI pins
|
|
18
|
+
* `1.4.x`, both images build on `oven/bun:1.4-*`, and the per-worker database rests on
|
|
19
|
+
* `BUN_TEST_WORKER_ID`'s numbering, probed on 1.4.0 and on nothing older. `scripts/bun-pin.test.ts`
|
|
20
|
+
* holds this to the same series as every other pin.
|
|
21
|
+
*/
|
|
22
|
+
export const REQUIRED_BUN = '1.4.0';
|
|
11
23
|
|
|
12
24
|
export interface AppRoot {
|
|
13
25
|
readonly dir: string;
|
package/src/cmd-dev.ts
CHANGED
|
@@ -46,6 +46,7 @@ import { msg } from './messages';
|
|
|
46
46
|
import type { CommandResult, Finding } from './output';
|
|
47
47
|
import { findingFrom } from './output';
|
|
48
48
|
import { flagString } from './parse';
|
|
49
|
+
import { loadPwaArtifacts } from './pwa-artifacts';
|
|
49
50
|
import { metricsPortFor } from './serve';
|
|
50
51
|
import { loopFacts, loopFinding, loopNotice } from './statement-loop';
|
|
51
52
|
|
|
@@ -171,6 +172,11 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
171
172
|
};
|
|
172
173
|
const panels = devPanels(dashboard).map((panel) => panel.key);
|
|
173
174
|
|
|
175
|
+
// Resolved once, before the first route: the manifest's bytes and the three head elements that
|
|
176
|
+
// name it. `undefined` for an app that is not installable, and then nothing is mounted and no
|
|
177
|
+
// document changes — the 0kb baseline is not spent on a `<link>` to a file that does not exist.
|
|
178
|
+
const pwa = await loadPwaArtifacts(options.root);
|
|
179
|
+
|
|
174
180
|
const routes: readonly Route[] = [
|
|
175
181
|
...devDashboardRoutes(dashboard),
|
|
176
182
|
// The same API table the container serves: a read that answers here and 404s in production
|
|
@@ -179,7 +185,11 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
179
185
|
// The image pipeline's only HTTP surface: the icons the web manifest declares, and the
|
|
180
186
|
// variants every `srcset` promises. Mounted before the app's own routes so a page route can
|
|
181
187
|
// never shadow `/icons` or `/media`.
|
|
182
|
-
...assetRoutes({
|
|
188
|
+
...assetRoutes({
|
|
189
|
+
root: options.root,
|
|
190
|
+
storage: runtime.storage,
|
|
191
|
+
...(pwa === undefined ? {} : { pwa }),
|
|
192
|
+
}),
|
|
183
193
|
...storageRoutes({ storage: runtime.storage }),
|
|
184
194
|
// The chunks the documents below name. Mounted before the app's routes for the reason
|
|
185
195
|
// `/icons` and `/media` are: a page route must not be able to shadow an asset URL.
|
|
@@ -193,7 +203,11 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
193
203
|
islands: () => state.islands,
|
|
194
204
|
states: () => loadIslandStates(options.root),
|
|
195
205
|
}),
|
|
196
|
-
...appRoutes({
|
|
206
|
+
...appRoutes({
|
|
207
|
+
buildId,
|
|
208
|
+
resolveIsland: (file) => state.islands.resolverFor(file),
|
|
209
|
+
...(pwa === undefined ? {} : { pwaHead: pwa.head }),
|
|
210
|
+
}),
|
|
197
211
|
];
|
|
198
212
|
|
|
199
213
|
const replicaOverride = replicaOverrides(undefined, services.db, options.env);
|
package/src/cmd-doctor.ts
CHANGED
|
@@ -10,8 +10,8 @@ import { STORAGE_SIGNING_SECRET_KEY, usesDevStorageSecret } from '@ultimat3/stor
|
|
|
10
10
|
import { findAppRoot, REQUIRED_BUN, versionAtLeast } from './app-root';
|
|
11
11
|
import type { CliCommand, CommandContext } from './command';
|
|
12
12
|
import { checkMigrationSnapshots } from './db-snapshot';
|
|
13
|
-
import { ICON_SOURCE } from './dev-assets';
|
|
14
13
|
import { intFlagOr, neighbouringPort, PORT_RANGE } from './flag-number';
|
|
14
|
+
import { ICON_SOURCE } from './icon-assets';
|
|
15
15
|
import { msg } from './messages';
|
|
16
16
|
import type { CommandResult, Finding } from './output';
|
|
17
17
|
import type { ParsedArgs } from './parse';
|
package/src/cmd-test.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { discoverTests, missingSelection, readSample, readType, sampleFiles } fr
|
|
|
20
20
|
import { runShards } from './test-shards';
|
|
21
21
|
import { defaultWorkers, WORKER_CEILING, WORKER_FLOOR, WORKER_OVERSUBSCRIBE } from './test-workers';
|
|
22
22
|
import type { TestType } from './verify-tests';
|
|
23
|
-
import { TEST_TYPES } from './verify-tests';
|
|
23
|
+
import { SERIAL_TYPES, TEST_TYPES } from './verify-tests';
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* `--workers` and `--shard`. `Number.parseInt` alone accepted `4abc` and `4.9` as four, while
|
|
@@ -33,10 +33,10 @@ const readIndex = (args: ParsedArgs, name: string, min: number): number | undefi
|
|
|
33
33
|
command: 'test',
|
|
34
34
|
min,
|
|
35
35
|
// The ceiling the summary already claimed and the reader never enforced: `--workers 5000` was
|
|
36
|
-
// accepted
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
36
|
+
// accepted and the run clamps only to the file count, which is one Bun worker per test FILE,
|
|
37
|
+
// each with the framework module graph and a cloned database. `--worker` is an index into an
|
|
38
|
+
// N-way split, so the same bound holds it (the exact upper index is `workers - 1`, refused a
|
|
39
|
+
// line below by the check that knows the real width).
|
|
40
40
|
max: WORKER_CEILING,
|
|
41
41
|
example: `x test --${name} ${Math.max(min, 1)}`,
|
|
42
42
|
});
|
|
@@ -103,19 +103,20 @@ export const testCommand: CliCommand = {
|
|
|
103
103
|
spec: {
|
|
104
104
|
name: 'test',
|
|
105
105
|
summary:
|
|
106
|
-
'run one test type — or the whole suite — across N
|
|
106
|
+
'run one test type — or the whole suite — across N workers, one isolated database per worker',
|
|
107
107
|
usage: `x test [${TEST_TYPES.join('|')}] [--filter text] [--sample N] [--affected [--base ref] [--dirty]] [--workers N] [--worker I] [--json]`,
|
|
108
108
|
positionalChoices: TEST_TYPES,
|
|
109
109
|
flags: [
|
|
110
110
|
{
|
|
111
111
|
name: 'workers',
|
|
112
112
|
type: 'string',
|
|
113
|
-
summary: `
|
|
113
|
+
summary: `bun worker count (default: ${WORKER_OVERSUBSCRIBE}x CPUs, min ${WORKER_FLOOR}, max ${WORKER_CEILING}); clamped to the file count, and to 1 for ${SERIAL_TYPES.join(' and ')}`,
|
|
114
114
|
},
|
|
115
115
|
{
|
|
116
116
|
name: 'worker',
|
|
117
117
|
type: 'string',
|
|
118
|
-
summary:
|
|
118
|
+
summary:
|
|
119
|
+
'run only shard I of an N-way split of the selection, serially — one CI job\u2019s share',
|
|
119
120
|
},
|
|
120
121
|
{ name: 'filter', type: 'string', summary: 'only files whose path contains this substring' },
|
|
121
122
|
{
|
|
@@ -172,7 +173,17 @@ export const testCommand: CliCommand = {
|
|
|
172
173
|
}
|
|
173
174
|
const files = sample === undefined ? selected : sampleFiles(selected, sample);
|
|
174
175
|
const requested = readIndex(ctx.args, 'workers', 1) ?? defaultWorkers();
|
|
175
|
-
|
|
176
|
+
// A serial type is serial HERE TOO, `As of 2026-08-27`. `verify-tests.ts` routes `live` and
|
|
177
|
+
// `e2e` through `runSerial` and this command never read the same list, so `x verify` ran one
|
|
178
|
+
// process over the very files `x test live --workers 8` ran eight over — two answers to one
|
|
179
|
+
// question, which is axiom 1, and the dangerous one is the command a human types while
|
|
180
|
+
// debugging. What makes them serial is not a preference: a logical replication slot is named
|
|
181
|
+
// at the Postgres CLUSTER level, so a per-worker database does not isolate it and two workers
|
|
182
|
+
// race `pg_create_logical_replication_slot`; `e2e` shares one built `dist/` and one browser
|
|
183
|
+
// profile. Neither is visible without a real `TEST_DATABASE_URL`, which is why the split
|
|
184
|
+
// measured green for as long as it did.
|
|
185
|
+
const ceiling = type !== undefined && SERIAL_TYPES.includes(type) ? 1 : files.length;
|
|
186
|
+
const workers = Math.max(1, Math.min(requested, ceiling));
|
|
176
187
|
const only = readIndex(ctx.args, 'worker', 0);
|
|
177
188
|
if (only !== undefined && only >= workers) {
|
|
178
189
|
throw new BadFlagError({
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Single responsibility: accept a table an applied migration's own SQL created. A snapshot records
|
|
2
|
+
// only what ENTITIES declare, so a hand-written `create table` is `unexpected-table` on every
|
|
3
|
+
// deploy forever (issue #345) — and the migration list `x db migrate` has just applied is the one
|
|
4
|
+
// piece of evidence that the app owns the relation. Named in `@ultimat3/db`'s `unexpectedTable`.
|
|
5
|
+
|
|
6
|
+
import type { DriftReport, Migration } from '@ultimat3/db';
|
|
7
|
+
import { statementsOf } from '@ultimat3/db';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `create table`, `create unlogged table`, either with `if not exists`. Anchored, so only the verb
|
|
11
|
+
* phrase a statement OPENS with counts.
|
|
12
|
+
*
|
|
13
|
+
* `temp`/`temporary` is deliberately absent: a temporary table lives in `pg_temp` and can never be
|
|
14
|
+
* the relation `checkDrift` introspected, so reading one as evidence would accept a `drafts`
|
|
15
|
+
* somebody created by hand on the strength of SQL that never touched it.
|
|
16
|
+
*/
|
|
17
|
+
const CREATE_TABLE = /^create\s+(?:unlogged\s+)?table\s+(?:if\s+not\s+exists\s+)?/i;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `drop table`, with `if exists`. A migration that creates a relation and later drops it OWNS
|
|
21
|
+
* neither: re-creating `legacy_audit` by hand afterwards is drift, and a set that only ever grew
|
|
22
|
+
* accepted it forever. `cascade`/`restrict` and a comma list are handled by the caller.
|
|
23
|
+
*/
|
|
24
|
+
const DROP_TABLE = /^drop\s+table\s+(?:if\s+exists\s+)?/i;
|
|
25
|
+
|
|
26
|
+
/** `alter table … rename to …` — the old name stops existing and the new one starts. */
|
|
27
|
+
const ALTER_TABLE = /^alter\s+table\s+(?:if\s+exists\s+)?(?:only\s+)?/i;
|
|
28
|
+
const RENAME_TO = /^\s*rename\s+to\s+/i;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* One identifier: quoted (with `""` for a literal quote) or bare. The bare form is Postgres' own
|
|
32
|
+
* charset — a letter or `_` to open, then letters, digits, `_` and `$` — with everything above
|
|
33
|
+
* ASCII admitted, since the server accepts any multibyte letter and a name it accepts must be
|
|
34
|
+
* readable here or the relation behind it is drift nobody can clear.
|
|
35
|
+
*/
|
|
36
|
+
const NAME = /^(?:("(?:[^"]|"")+")|([A-Za-z_\u0080-\uFFFF][A-Za-z0-9_$\u0080-\uFFFF]*))/;
|
|
37
|
+
|
|
38
|
+
/** A qualifier's dot, with the whitespace Postgres allows on either side of it. */
|
|
39
|
+
const DOT = /^\s*\.\s*/;
|
|
40
|
+
|
|
41
|
+
/** The schema `checkDrift` compares — `introspect()`'s default, and what `runMigrations` asks for. */
|
|
42
|
+
const COMPARED_SCHEMA = 'public';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Postgres folds an unquoted identifier to lower case and stores a quoted one verbatim, so
|
|
46
|
+
* `CREATE TABLE LegacyAudit` and `create table legacyaudit` are the same relation and
|
|
47
|
+
* `"LegacyAudit"` is a different one. The catalog name is what a drift difference carries, so the
|
|
48
|
+
* fold has to happen here or the comparison misses on every unquoted name that was not typed flat.
|
|
49
|
+
*/
|
|
50
|
+
function unquote(match: RegExpExecArray): string {
|
|
51
|
+
const quoted = match[1];
|
|
52
|
+
if (quoted === undefined) return (match[2] ?? '').toLowerCase();
|
|
53
|
+
return quoted.slice(1, -1).replaceAll('""', '"');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The relation one statement creates, or `null`.
|
|
58
|
+
*
|
|
59
|
+
* **The anchor is the whole protection**, and it is read off the raw text. A statement can only
|
|
60
|
+
* open with `create table` by BEING one: `values ('create table ghost')` opens with `insert`, and
|
|
61
|
+
* a chunk that is nothing but a comment is not a statement at all (`statementsOf` drops it). A
|
|
62
|
+
* `stripSqlNoise` pass was written here first and then deleted — it could not change one answer,
|
|
63
|
+
* because position 0 is the one position no literal, comment or dollar body can cover, and a
|
|
64
|
+
* defence that cannot fail is a defence nobody can test.
|
|
65
|
+
*
|
|
66
|
+
* Anything the anchor admits but the name grammar does not — a comment between the keywords,
|
|
67
|
+
* `create table (` — contributes nothing. Fail-closed: drift that could have been accepted is
|
|
68
|
+
* reported, never the reverse.
|
|
69
|
+
*/
|
|
70
|
+
function createdBy(statement: string): string | null {
|
|
71
|
+
const text = statement.trimStart();
|
|
72
|
+
const head = CREATE_TABLE.exec(text);
|
|
73
|
+
if (head === null) return null;
|
|
74
|
+
const rest = text.slice(head[0].length);
|
|
75
|
+
const first = NAME.exec(rest);
|
|
76
|
+
if (first === null) return null;
|
|
77
|
+
|
|
78
|
+
const tail = rest.slice(first[0].length);
|
|
79
|
+
const dot = DOT.exec(tail);
|
|
80
|
+
if (dot === null) return unquote(first);
|
|
81
|
+
const second = NAME.exec(tail.slice(dot[0].length));
|
|
82
|
+
if (second === null) return null;
|
|
83
|
+
// A qualifier naming another schema is evidence about a relation this report never mentions:
|
|
84
|
+
// `checkDrift` introspects one schema, and `audit.drafts` is not the `drafts` it compared.
|
|
85
|
+
return unquote(first) === COMPARED_SCHEMA ? unquote(second) : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Every relation a migration script creates, in statement order.
|
|
90
|
+
*
|
|
91
|
+
* `statementsOf` is `@ultimat3/db`'s own splitter — the one `x db migrate` sends by — so a `;`
|
|
92
|
+
* inside a literal, an identifier or a dollar-quoted body is not a statement boundary here either.
|
|
93
|
+
*/
|
|
94
|
+
export function createdTables(up: string): readonly string[] {
|
|
95
|
+
const owned = new Set<string>();
|
|
96
|
+
applyOwnership(up, owned);
|
|
97
|
+
return [...owned];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Fold one migration's statements over the owned set, IN ORDER.
|
|
102
|
+
*
|
|
103
|
+
* A set that only ever grew was the defect: `create table legacy_audit` followed by
|
|
104
|
+
* `drop table legacy_audit` left the name accepted, so a `legacy_audit` somebody re-created by
|
|
105
|
+
* hand afterwards lost its `unexpected-table` finding — real drift, silenced, which is the one
|
|
106
|
+
* thing this module may not do.
|
|
107
|
+
*
|
|
108
|
+
* Fail-closed on anything the grammar cannot read: a `drop`/`rename` this cannot parse REMOVES
|
|
109
|
+
* nothing it is unsure about only when it could not name a relation at all; when it can name one,
|
|
110
|
+
* dropping it from the set is always the safe direction, because the cost of being wrong is a
|
|
111
|
+
* difference reported that could have been accepted.
|
|
112
|
+
*/
|
|
113
|
+
function applyOwnership(up: string, owned: Set<string>): void {
|
|
114
|
+
for (const statement of statementsOf(up)) {
|
|
115
|
+
const created = createdBy(statement);
|
|
116
|
+
if (created !== null) {
|
|
117
|
+
owned.add(created);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
for (const dropped of droppedBy(statement)) owned.delete(dropped);
|
|
121
|
+
const renamed = renamedBy(statement);
|
|
122
|
+
if (renamed === null) continue;
|
|
123
|
+
// Only inherit ownership when the OLD name was owned: renaming a hand-made table into a name
|
|
124
|
+
// a migration once created must not launder it into an accepted one.
|
|
125
|
+
owned.delete(renamed.from);
|
|
126
|
+
if (renamed.owned) owned.add(renamed.to);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Every relation one `drop table` names — the form takes a comma list. */
|
|
131
|
+
function droppedBy(statement: string): readonly string[] {
|
|
132
|
+
const text = statement.trimStart();
|
|
133
|
+
const head = DROP_TABLE.exec(text);
|
|
134
|
+
if (head === null) return [];
|
|
135
|
+
const dropped: string[] = [];
|
|
136
|
+
let rest = text.slice(head[0].length);
|
|
137
|
+
for (;;) {
|
|
138
|
+
const name = qualifiedName(rest);
|
|
139
|
+
if (name === null) break;
|
|
140
|
+
if (name.table !== null) dropped.push(name.table);
|
|
141
|
+
if (!/^\s*,/.test(name.rest)) break;
|
|
142
|
+
rest = name.rest.replace(/^\s*,\s*/, '');
|
|
143
|
+
}
|
|
144
|
+
return dropped;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** `alter table <old> rename to <new>`, or `null`. `owned` is filled in by the caller's set. */
|
|
148
|
+
function renamedBy(statement: string): { from: string; to: string; owned: boolean } | null {
|
|
149
|
+
const text = statement.trimStart();
|
|
150
|
+
const head = ALTER_TABLE.exec(text);
|
|
151
|
+
if (head === null) return null;
|
|
152
|
+
const source = qualifiedName(text.slice(head[0].length));
|
|
153
|
+
if (source === null || source.table === null) return null;
|
|
154
|
+
const verb = RENAME_TO.exec(source.rest);
|
|
155
|
+
if (verb === null) return null;
|
|
156
|
+
const target = qualifiedName(source.rest.slice(verb[0].length));
|
|
157
|
+
if (target === null || target.table === null) return null;
|
|
158
|
+
return { from: source.table, to: target.table, owned: true };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* One optionally schema-qualified relation name and what follows it. `table` is `null` when the
|
|
163
|
+
* qualifier names a schema `checkDrift` never introspected — the same rule `createdBy` applies.
|
|
164
|
+
*/
|
|
165
|
+
function qualifiedName(input: string): { table: string | null; rest: string } | null {
|
|
166
|
+
const first = NAME.exec(input);
|
|
167
|
+
if (first === null) return null;
|
|
168
|
+
const tail = input.slice(first[0].length);
|
|
169
|
+
const dot = DOT.exec(tail);
|
|
170
|
+
if (dot === null) return { table: unquote(first), rest: tail };
|
|
171
|
+
const second = NAME.exec(tail.slice(dot[0].length));
|
|
172
|
+
if (second === null) return null;
|
|
173
|
+
const rest = tail.slice(dot[0].length + second[0].length);
|
|
174
|
+
return { table: unquote(first) === COMPARED_SCHEMA ? unquote(second) : null, rest };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The drift report minus the tables these migrations demonstrably create.
|
|
179
|
+
*
|
|
180
|
+
* **Only `unexpected-table`, and only for a name a migration's SQL creates.** That is what makes
|
|
181
|
+
* this an acceptance and not the check switched off: a table absent from the snapshot produces
|
|
182
|
+
* exactly ONE difference (`diffSchema` reports it and never compares its columns), so nothing else
|
|
183
|
+
* about the relation was being said, and every difference about a table that IS declared — a
|
|
184
|
+
* missing column, a changed index, a dropped constraint — passes through untouched.
|
|
185
|
+
*
|
|
186
|
+
* The migrations are the list `x db migrate` applied immediately before the check, which is why
|
|
187
|
+
* "created" is provable rather than assumed: `runMigrations` applies every pending file and then
|
|
188
|
+
* asks, so a `create table` still on disk and unapplied is not one this path can be handed.
|
|
189
|
+
*
|
|
190
|
+
* Identity in, identity out when nothing matched — a report this has nothing to say about is not
|
|
191
|
+
* its to rebuild.
|
|
192
|
+
*/
|
|
193
|
+
export function acceptCreatedTables(
|
|
194
|
+
report: DriftReport,
|
|
195
|
+
migrations: readonly Migration[],
|
|
196
|
+
): DriftReport {
|
|
197
|
+
// In migration ORDER, across the whole list: ownership is a running state, not a union. A
|
|
198
|
+
// relation created by 0003 and dropped by 0007 is owned by neither.
|
|
199
|
+
const created = new Set<string>();
|
|
200
|
+
for (const migration of migrations) applyOwnership(migration.up, created);
|
|
201
|
+
if (created.size === 0) return report;
|
|
202
|
+
const differences = report.differences.filter(
|
|
203
|
+
(difference) => !(difference.kind === 'unexpected-table' && created.has(difference.table)),
|
|
204
|
+
);
|
|
205
|
+
if (differences.length === report.differences.length) return report;
|
|
206
|
+
return { ok: differences.length === 0, differences };
|
|
207
|
+
}
|
package/src/db-generate.ts
CHANGED
|
@@ -13,7 +13,9 @@ import {
|
|
|
13
13
|
snapshotJson,
|
|
14
14
|
} from '@ultimat3/db';
|
|
15
15
|
import { describeEntities } from '@ultimat3/entity';
|
|
16
|
+
import { describeQueries } from '@ultimat3/query';
|
|
16
17
|
import { loadApp } from './app-load';
|
|
18
|
+
import { replicaIdentityTables } from './db-subscribes';
|
|
17
19
|
import { reconcileSchemaHash, writeSchemaHash } from './drift';
|
|
18
20
|
import { hashFileName, MIGRATIONS_DIR, readMigrations, snapshotFileName } from './migrations';
|
|
19
21
|
import type { Finding } from './output';
|
|
@@ -103,10 +105,25 @@ export async function generateAppMigration(
|
|
|
103
105
|
throw migrationSnapshotMissing(id, join(MIGRATIONS_DIR, snapshotFileName(id)));
|
|
104
106
|
}
|
|
105
107
|
|
|
108
|
+
const entities = describeEntities();
|
|
109
|
+
// Read AFTER the findings check above, never before: a module that would not import leaves the
|
|
110
|
+
// registry short, and every `subscribes:` name whose entity lives in that module would then look
|
|
111
|
+
// like a typo. The refusal below is only sound over a whole registry.
|
|
112
|
+
//
|
|
113
|
+
// `describeQueries()` is the manifest's own source — `frameworkSources` copies this exact field
|
|
114
|
+
// onto `QueryFact.subscribes` and changes nothing — so this is the manifest fact one hop earlier.
|
|
115
|
+
// Building the manifest here would re-load the app and demand a `package.json`, which `x db gen`
|
|
116
|
+
// has never needed: generation reads declarations and writes files.
|
|
117
|
+
const replicaIdentityFull = replicaIdentityTables(
|
|
118
|
+
describeQueries(),
|
|
119
|
+
new Set(entities.map((entity) => entity.table)),
|
|
120
|
+
);
|
|
121
|
+
|
|
106
122
|
const migration = generateMigration({
|
|
107
|
-
entities
|
|
123
|
+
entities,
|
|
108
124
|
current,
|
|
109
125
|
name: options.name,
|
|
126
|
+
replicaIdentityFull,
|
|
110
127
|
...(options.allowDestructive === true ? { allowDestructive: true } : {}),
|
|
111
128
|
});
|
|
112
129
|
// An empty diff writes no MIGRATION — one with no statement still takes a ledger row, a checksum
|