@ultimat3/cli 1.2.0 → 2.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.
Files changed (138) hide show
  1. package/CLAUDE.md +724 -0
  2. package/README.md +41 -9
  3. package/package.json +25 -23
  4. package/src/api-routes.ts +16 -0
  5. package/src/app-auth.ts +32 -0
  6. package/src/app-entities.ts +18 -0
  7. package/src/app-env.ts +103 -0
  8. package/src/app-load.ts +20 -3
  9. package/src/bin.ts +4 -3
  10. package/src/budgets.ts +114 -9
  11. package/src/cmd-build.ts +69 -21
  12. package/src/cmd-db-branch.ts +215 -0
  13. package/src/cmd-db.ts +332 -155
  14. package/src/cmd-deploy.ts +59 -6
  15. package/src/cmd-dev.ts +83 -16
  16. package/src/cmd-docs.ts +167 -0
  17. package/src/cmd-doctor.ts +64 -9
  18. package/src/cmd-env.ts +95 -0
  19. package/src/cmd-errors.ts +33 -13
  20. package/src/cmd-fix.ts +5 -1
  21. package/src/cmd-generate.ts +146 -111
  22. package/src/cmd-help.ts +16 -5
  23. package/src/cmd-i18n.ts +2 -0
  24. package/src/cmd-jobs.ts +47 -33
  25. package/src/cmd-mcp.ts +11 -2
  26. package/src/cmd-new.ts +13 -7
  27. package/src/cmd-planned.ts +55 -10
  28. package/src/cmd-policy.ts +1 -0
  29. package/src/cmd-registries.ts +3 -0
  30. package/src/cmd-secrets.ts +368 -0
  31. package/src/cmd-tasks.ts +1 -0
  32. package/src/cmd-test.ts +17 -23
  33. package/src/cmd-verify.ts +177 -23
  34. package/src/db-backfill.ts +401 -0
  35. package/src/db-branch.ts +251 -0
  36. package/src/db-destructive.ts +29 -0
  37. package/src/db-finding.ts +28 -0
  38. package/src/db-generate.ts +112 -0
  39. package/src/db-snapshot.ts +24 -0
  40. package/src/dev-assets.ts +86 -20
  41. package/src/dev-cache.ts +122 -0
  42. package/src/dev-dashboard.ts +19 -4
  43. package/src/dev-hooks.ts +27 -2
  44. package/src/dev-n-plus-one.ts +191 -0
  45. package/src/dev-queue.ts +105 -19
  46. package/src/dev-render.ts +158 -26
  47. package/src/dev-roles-fixture.ts +67 -0
  48. package/src/dev-roles.ts +165 -78
  49. package/src/dev-runtime.ts +117 -40
  50. package/src/dev-services.ts +15 -0
  51. package/src/dev-storage.ts +245 -0
  52. package/src/dev-sync.ts +107 -0
  53. package/src/dev-traces.ts +11 -3
  54. package/src/dispatch.ts +4 -2
  55. package/src/document-styles.ts +54 -0
  56. package/src/drift.ts +37 -9
  57. package/src/error-catalog.ts +7 -18
  58. package/src/error-codes.ts +186 -0
  59. package/src/error-contract.ts +29 -7
  60. package/src/error-fixes.ts +114 -0
  61. package/src/errors.ts +201 -138
  62. package/src/fix-command.ts +268 -0
  63. package/src/flag-number.ts +56 -0
  64. package/src/framework-scope.ts +49 -0
  65. package/src/generate-kinds.ts +97 -0
  66. package/src/guards.ts +186 -0
  67. package/src/index.ts +84 -14
  68. package/src/island-bundle.ts +166 -0
  69. package/src/island-routes.ts +50 -0
  70. package/src/jobs-driver.ts +33 -0
  71. package/src/jobs-json.ts +24 -0
  72. package/src/jobs-report.ts +17 -4
  73. package/src/mcp-db-target.ts +52 -27
  74. package/src/mcp-errors.ts +120 -19
  75. package/src/mcp-host.ts +44 -25
  76. package/src/messages.ts +81 -2
  77. package/src/metrics-endpoint.ts +4 -3
  78. package/src/migrations.ts +37 -4
  79. package/src/otlp-export.ts +64 -0
  80. package/src/output.ts +46 -16
  81. package/src/parse.ts +41 -3
  82. package/src/policy-facts.ts +38 -6
  83. package/src/policy-fixture.ts +14 -7
  84. package/src/prerender.ts +111 -2
  85. package/src/registry.ts +21 -3
  86. package/src/runtime-overrides.ts +66 -0
  87. package/src/safe-url-label.ts +24 -0
  88. package/src/scaffold-fixture.ts +10 -0
  89. package/src/scaffold-typecheck.ts +16 -38
  90. package/src/serve.ts +170 -10
  91. package/src/source-files.ts +4 -0
  92. package/src/statement-loop.ts +74 -0
  93. package/src/style-csp.ts +18 -0
  94. package/src/sync-authenticator.ts +59 -0
  95. package/src/templates/action.ts +15 -30
  96. package/src/templates/admin-page.ts +103 -0
  97. package/src/templates/admin.ts +11 -7
  98. package/src/templates/backfill.ts +212 -0
  99. package/src/templates/entity.ts +72 -31
  100. package/src/templates/guard.ts +143 -0
  101. package/src/templates/index.ts +12 -1
  102. package/src/templates/island.ts +67 -0
  103. package/src/templates/job.ts +53 -13
  104. package/src/templates/naming.ts +17 -1
  105. package/src/templates/policy.ts +35 -28
  106. package/src/templates/query.ts +24 -5
  107. package/src/templates/resource.ts +19 -11
  108. package/src/templates/route.ts +90 -15
  109. package/src/templates/scaffold-app.ts +142 -45
  110. package/src/templates/scaffold-claude-agents.ts +149 -0
  111. package/src/templates/scaffold-claude-commands.ts +221 -0
  112. package/src/templates/scaffold-claude.ts +134 -0
  113. package/src/templates/scaffold-container.ts +46 -2
  114. package/src/templates/scaffold-db-package.ts +91 -0
  115. package/src/templates/scaffold-docs.ts +24 -5
  116. package/src/templates/scaffold-domain-package.ts +90 -0
  117. package/src/templates/scaffold-env.ts +87 -0
  118. package/src/templates/scaffold-i18n.ts +4 -1
  119. package/src/templates/scaffold-mcp-package.ts +49 -0
  120. package/src/templates/scaffold-package-shape.ts +25 -4
  121. package/src/templates/scaffold-repo.ts +116 -257
  122. package/src/templates/scaffold-roles.ts +68 -0
  123. package/src/templates/scaffold-ui-package.ts +56 -0
  124. package/src/templates/slice-foundation.ts +88 -0
  125. package/src/templates/wrap.ts +95 -0
  126. package/src/test-counts.ts +35 -0
  127. package/src/test-select.ts +30 -15
  128. package/src/test-shards.ts +21 -3
  129. package/src/test-workers.ts +47 -0
  130. package/src/ts-scan.ts +271 -13
  131. package/src/tsconfig-references.ts +78 -0
  132. package/src/verify-floor.ts +133 -0
  133. package/src/verify-step.ts +19 -0
  134. package/src/verify-test-run.ts +72 -0
  135. package/src/verify-tests.ts +160 -71
  136. package/src/version-loader.ts +20 -3
  137. package/src/workspace-checks.ts +87 -16
  138. package/src/write-line.ts +34 -0
@@ -0,0 +1,191 @@
1
+ // The N+1 ledger: one count per statement shape per request, and a verdict once a shape crosses
2
+ // the threshold. Counting state hangs off the request's own `Ctx` in a `WeakMap`, so it is
3
+ // collected with the request and never swept. Installed by `x dev` and by nothing else — a
4
+ // production process pays the one `undefined` branch `@ultimat3/db`'s seam already costs (axiom 6).
5
+ // It counts and it warns once; what a verdict *means* — which code, which `fix:` — is
6
+ // `statement-loop.ts`'s, so all four surfaces read one answer.
7
+
8
+ import type { Ctx } from '@ultimat3/core';
9
+ import { assert, tryUseContext } from '@ultimat3/core';
10
+ import type { StatementAttribution, StatementEvent, StatementObserver } from '@ultimat3/db';
11
+ import { statementFingerprint, statementKind } from '@ultimat3/db';
12
+ import { N_PLUS_ONE_THRESHOLD } from '@ultimat3/entity';
13
+ import { loopFacts, warnLoop } from './statement-loop';
14
+
15
+ /** Verdicts retained. A dev diagnostic shows the recent loops; it does not page through history. */
16
+ const DEFAULT_LIMIT = 50;
17
+
18
+ /** One statement shape, repeated inside one request past the threshold. */
19
+ export interface RepeatedStatement {
20
+ /** What was repeated: `members.findById` when attributed, else the statement's own text. */
21
+ readonly fingerprint: string;
22
+ /** Which loop this is, and therefore which fix a report can name. */
23
+ readonly kind: 'read' | 'write';
24
+ /** The entity and operation that compiled it, absent for hand-written SQL and queue traffic. */
25
+ readonly attribution?: StatementAttribution | undefined;
26
+ /** One of the statements, verbatim — the SQL a report shows under the fingerprint. */
27
+ readonly sample: string;
28
+ /** Statements of this shape the request has issued so far. Never below the threshold. */
29
+ readonly count: number;
30
+ /** The request the loop happened in; a report and its log line name the same one. */
31
+ readonly requestId: string;
32
+ readonly traceId: string;
33
+ }
34
+
35
+ export interface StatementLedger {
36
+ /** Hand this to `setStatementObserver()`. */
37
+ readonly observer: StatementObserver;
38
+ /** Shapes that crossed the threshold, newest first. */
39
+ repeats(): readonly RepeatedStatement[];
40
+ /**
41
+ * The same verdicts, for one request. What the browser overlay shows next to an error: a page
42
+ * that looped names its loop on the page, not only in a terminal the author is not looking at.
43
+ */
44
+ repeatsFor(ctx: Ctx): readonly RepeatedStatement[];
45
+ reset(): void;
46
+ }
47
+
48
+ export interface StatementLedgerOptions {
49
+ /**
50
+ * Statements of one shape in one request that trip a verdict. Defaults to
51
+ * `N_PLUS_ONE_THRESHOLD` — `@ultimat3/entity`'s, so this ledger and the strict test fixture
52
+ * cannot disagree about how many of one shape is a loop.
53
+ */
54
+ readonly threshold?: number;
55
+ /** Verdicts retained before the oldest is dropped. Default `50`. */
56
+ readonly limit?: number;
57
+ }
58
+
59
+ /** The live count behind a `RepeatedStatement`: reported once, then still counting. */
60
+ interface RepeatGroup {
61
+ readonly fingerprint: string;
62
+ readonly kind: 'read' | 'write';
63
+ readonly attribution: StatementAttribution | undefined;
64
+ readonly sample: string;
65
+ readonly requestId: string;
66
+ readonly traceId: string;
67
+ count: number;
68
+ /** Whether this shape is already a verdict. The flag, not the count, so a `threshold` of 1 works. */
69
+ promoted: boolean;
70
+ }
71
+
72
+ /** The verdict a surface reads, without the bookkeeping the group keeps for the ledger itself. */
73
+ const snapshot = (group: RepeatGroup): RepeatedStatement => ({
74
+ fingerprint: group.fingerprint,
75
+ kind: group.kind,
76
+ attribution: group.attribution,
77
+ sample: group.sample,
78
+ count: group.count,
79
+ requestId: group.requestId,
80
+ traceId: group.traceId,
81
+ });
82
+
83
+ /**
84
+ * Count statement shapes per request and report the ones that repeat past `threshold`.
85
+ *
86
+ * Three rules, each load-bearing. **Per request, keyed by the context object** — the map dies with
87
+ * the `Ctx` that owns it, so a dev server up for a week accumulates nothing and no sweep has to
88
+ * decide when a request ended. A statement issued outside a request (a migration, a boot probe, a
89
+ * script) is not counted at all: "five of one shape" only means something inside one unit of work.
90
+ * A `withChildContext` scope is its own key and therefore its own tally, which is the price of
91
+ * keying on identity rather than on `requestId` and holding the counts forever.
92
+ *
93
+ * **An expected statement is not counted** — `expectedQueryLoop` suppresses a verdict, and this
94
+ * ledger *is* the verdict. The statement is still sent, still observed and still a span, so the
95
+ * timeline keeps showing the loop while the thing that warns is told the author already answered.
96
+ *
97
+ * **A shape is promoted exactly once**, on the statement that crosses the threshold, and the group
98
+ * behind it keeps counting — so a loop of fifty is one verdict reading `count: 50`, not
99
+ * forty-six verdicts. The report list is bounded and drops its oldest entry.
100
+ *
101
+ * Promotion is also the moment the log line goes out, and it is **one line per request per code**:
102
+ * a request that loops three different shapes of read has three verdicts and one `X_N_PLUS_ONE_QUERY`
103
+ * warning, because a log is read to learn that this request looped and the three shapes are what
104
+ * `x dev`'s findings, `/_x` and the overlay are for. The line names the count as it stood when the
105
+ * threshold was crossed; every other surface reads the count as it stands when asked.
106
+ */
107
+ export function createStatementLedger(options: StatementLedgerOptions = {}): StatementLedger {
108
+ const threshold = options.threshold ?? N_PLUS_ONE_THRESHOLD;
109
+ const limit = options.limit ?? DEFAULT_LIMIT;
110
+ assert(
111
+ Number.isInteger(threshold) && threshold >= 1,
112
+ `createStatementLedger() was given a threshold of ${threshold}, which no statement count can reach`,
113
+ 'pass a whole number of statements: createStatementLedger({ threshold: 5 })',
114
+ );
115
+ assert(
116
+ Number.isInteger(limit) && limit >= 1,
117
+ `createStatementLedger() was given a limit of ${limit}, so no verdict could be kept`,
118
+ 'pass how many verdicts to retain: createStatementLedger({ limit: 50 })',
119
+ );
120
+ const byRequest = new WeakMap<Ctx, Map<string, RepeatGroup>>();
121
+ // Which codes this request has already warned about. Its own map rather than a field on the
122
+ // groups: the rule is one line per *code*, and the groups are per shape — three shapes of read
123
+ // in one request share one warning and each keeps its own verdict.
124
+ const warned = new WeakMap<Ctx, Set<string>>();
125
+ const reported: RepeatGroup[] = [];
126
+
127
+ const warnOnce = (ctx: Ctx, group: RepeatGroup): void => {
128
+ const facts = loopFacts(snapshot(group));
129
+ let codes = warned.get(ctx);
130
+ if (codes === undefined) {
131
+ codes = new Set();
132
+ warned.set(ctx, codes);
133
+ }
134
+ if (codes.has(facts.code)) return;
135
+ codes.add(facts.code);
136
+ warnLoop(facts);
137
+ };
138
+
139
+ const onStatement = (event: StatementEvent): void => {
140
+ if (event.expected !== undefined) return;
141
+ const ctx = tryUseContext();
142
+ if (ctx === undefined) return;
143
+ let groups = byRequest.get(ctx);
144
+ if (groups === undefined) {
145
+ groups = new Map();
146
+ byRequest.set(ctx, groups);
147
+ }
148
+ const fingerprint = statementFingerprint(event);
149
+ let group = groups.get(fingerprint);
150
+ if (group === undefined) {
151
+ group = {
152
+ fingerprint,
153
+ kind: statementKind(event.text),
154
+ attribution: event.attribution,
155
+ sample: event.text,
156
+ requestId: ctx.requestId,
157
+ traceId: ctx.traceId,
158
+ count: 0,
159
+ promoted: false,
160
+ };
161
+ groups.set(fingerprint, group);
162
+ }
163
+ // A statement that threw is still a statement: fifty identical timeouts are still a loop, and
164
+ // one that reports them as four is a loop nobody is told about.
165
+ group.count += 1;
166
+ if (group.promoted || group.count < threshold) return;
167
+ group.promoted = true;
168
+ reported.push(group);
169
+ if (reported.length > limit) reported.shift();
170
+ warnOnce(ctx, group);
171
+ };
172
+
173
+ return {
174
+ observer: { onStatement },
175
+ repeats(): readonly RepeatedStatement[] {
176
+ // Snapshotted, because the groups behind these are still counting — and newest first,
177
+ // matching the trace recorder: the loop that just happened is the one being looked at.
178
+ return reported.map(snapshot).reverse();
179
+ },
180
+ repeatsFor(ctx: Ctx): readonly RepeatedStatement[] {
181
+ // Read off this request's own tally rather than filtered out of `reported`, so a verdict the
182
+ // bound already dropped is still shown on the page it happened on.
183
+ const groups = byRequest.get(ctx);
184
+ if (groups === undefined) return [];
185
+ return [...groups.values()].filter((group) => group.promoted).map(snapshot);
186
+ },
187
+ reset(): void {
188
+ reported.length = 0;
189
+ },
190
+ };
191
+ }
package/src/dev-queue.ts CHANGED
@@ -1,19 +1,40 @@
1
1
  // The database and the job queue, started together and released together. Split from
2
2
  // `dev-runtime.ts` because `x jobs` needs exactly this pair and nothing else — and because a
3
- // process that installs two ambient accessors (`db()`, `jobDriver()`) must have one place that
4
- // takes both back, or the next command in the same process inherits a driver over a closed socket.
3
+ // process that installs ambient accessors (`db()`, `jobDriver()`, the jobs facade, the event bus)
4
+ // must have one place that takes them all back, or the next command in the same process inherits
5
+ // a driver over a closed socket.
5
6
 
6
- import type { PgliteClient, PostgresClient, SqlFragment } from '@ultimat3/db';
7
+ import {
8
+ postgresIdempotencyStore,
9
+ resetIdempotency,
10
+ SQL_IDEMPOTENCY_TABLE,
11
+ setIdempotencyStore,
12
+ } from '@ultimat3/action';
13
+ import type { DbClient, PgliteClient, PostgresClient, SqlFragment } from '@ultimat3/db';
7
14
  import {
8
15
  createPgliteClient,
9
16
  createPostgresClient,
17
+ currentTx,
10
18
  pgliteDataDir,
11
19
  raw,
12
20
  setDbClient,
13
21
  } from '@ultimat3/db';
14
- import type { JobDriver, PgExecutor } from '@ultimat3/jobs';
15
- import { createPgDriver, resetJobDriver, SQL_JOBS_TABLE, setJobDriver } from '@ultimat3/jobs';
22
+ import type { Tx } from '@ultimat3/entity';
23
+ import type { EventBus, JobDriver, OutboxStore, PgExecutor } from '@ultimat3/jobs';
24
+ import {
25
+ createJobsFacade,
26
+ createPgDriver,
27
+ createPgEventBus,
28
+ createPgOutboxStore,
29
+ resetJobDriver,
30
+ resetJobsFacade,
31
+ SQL_JOBS_TABLE,
32
+ setEventBus,
33
+ setJobDriver,
34
+ setJobsFacade,
35
+ } from '@ultimat3/jobs';
16
36
  import type { DevServices } from './dev-services';
37
+ import type { RuntimeOverrides } from './runtime-overrides';
17
38
 
18
39
  /** Both embedded and external clients boot lazily and close explicitly. */
19
40
  export type DevDbClient = PgliteClient | PostgresClient;
@@ -21,6 +42,13 @@ export type DevDbClient = PgliteClient | PostgresClient;
21
42
  export interface RunningQueue {
22
43
  readonly db: DevDbClient;
23
44
  readonly jobs: JobDriver;
45
+ /**
46
+ * The `x_outbox` store this boot installed behind `handle.enqueue()`. Returned because the
47
+ * relay that drains it is a ROLE's decision, not the queue's — `dev-roles.ts` starts one.
48
+ */
49
+ readonly outbox: OutboxStore;
50
+ /** The `x_job_events` bus a `step.waitForEvent` resumes from. Durable, not per-process. */
51
+ readonly events: EventBus;
24
52
  stop(): Promise<void>;
25
53
  }
26
54
 
@@ -43,38 +71,94 @@ function startDb(services: DevServices): DevDbClient {
43
71
  * The fragment is assembled by hand rather than through `sql`` ` because the driver hands over
44
72
  * `$1..$n` text it wrote itself plus already-bound values — there is no interpolation to guard.
45
73
  */
46
- function executorFor(client: DevDbClient): PgExecutor {
74
+ export function pgExecutorFor(client: DbClient): PgExecutor {
47
75
  return {
48
76
  query: <R>(text: string, values: readonly unknown[]): Promise<readonly R[]> =>
49
77
  client.query<R>({ text, values } satisfies SqlFragment),
50
78
  };
51
79
  }
52
80
 
81
+ /**
82
+ * Every table this process's framework packages own, applied before anything reads one.
83
+ *
84
+ * PGlite speaks the extended protocol, which carries one statement per round trip, so the DDL is
85
+ * applied statement by statement. Safe to split on `;`: both constants are fixed, with no
86
+ * semicolon inside a literal, and each package's own SQL test is where that stays true.
87
+ *
88
+ * `SQL_IDEMPOTENCY_TABLE` is here and not in `@ultimat3/action` because a package that holds no
89
+ * database dependency cannot apply its own schema — the same reason `SQL_JOBS_TABLE` is applied
90
+ * here. Without it `postgresIdempotencyStore` is a store whose first reservation fails on a
91
+ * missing relation, which is how a retried `POST /api/payments/charge` charges a card twice.
92
+ */
93
+ async function applySchema(client: DevDbClient): Promise<void> {
94
+ for (const ddl of [SQL_JOBS_TABLE, SQL_IDEMPOTENCY_TABLE]) {
95
+ for (const statement of ddl.split(';')) {
96
+ if (statement.trim().length > 0) await client.execute(raw(statement));
97
+ }
98
+ }
99
+ }
100
+
53
101
  /**
54
102
  * The dev queue is the real Postgres queue on the embedded Postgres — claiming, leases and the
55
103
  * one-live-job-per-key index all behave here exactly as in production. A memory queue in dev
56
104
  * would hide every bug this driver exists to make impossible.
57
105
  *
58
- * PGlite speaks the extended protocol, which carries one statement per round trip, so the DDL
59
- * is applied statement by statement. Safe to split on `;`: `SQL_JOBS_TABLE` is a fixed constant
60
- * with no semicolon inside a literal, and `driver-pg-sql.test.ts` is where that stays true.
106
+ * Three ambient installs, not one, and they go in together because they are one decision:
107
+ *
108
+ * - `setJobDriver` is what `jobDriver()` answers and what a worker claims from.
109
+ * - `setJobsFacade` is what `handle.enqueue()` routes through, so an enqueue inside a request's
110
+ * transaction STAGES a row that commits or vanishes with the business rows. Without it the
111
+ * fallback facade publishes straight to the driver, and a job for a transaction that rolled
112
+ * back still runs. `currentTx()` resolves the executor because a `DbTx` IS a client on the
113
+ * transaction's own connection, while the `Tx` token `@ultimat3/entity` hands over is not that
114
+ * object — so the token is a key and the ALS is the lookup.
115
+ * - `setEventBus` makes `step.waitForEvent` durable. The memory bus this replaced forgot every
116
+ * pending correlation on restart, which is a job that waits forever.
117
+ * - `setIdempotencyStore` makes `idempotent: true` mean it across replicas. The memory default is
118
+ * one process' worth of keys, so a client retrying `POST /api/payments/charge` after a timeout
119
+ * lands on a replica that has never seen the key and charges the card a second time.
120
+ *
121
+ * The idempotency store is installed HERE and not from the app, even though
122
+ * `@ultimat3/action` documents `postgresIdempotencyStore({ executor: Bun.sql })`: `Bun.sql` has no
123
+ * `.query(text, values)` — it is a tagged template whose positional form is `unsafe` — so that
124
+ * line does not satisfy `PgExecutor` at all, and a second executor would open a second pool
125
+ * against a URL this boot already resolved. Boot owns the connection, so boot supplies it.
126
+ * `startServices` runs before `loadApp`, so the store is in place before `registerAction`
127
+ * evaluates a `scope: 'shared'` declaration against it.
61
128
  */
62
- async function startJobs(client: DevDbClient): Promise<JobDriver> {
63
- for (const statement of SQL_JOBS_TABLE.split(';')) {
64
- if (statement.trim().length > 0) await client.execute(raw(statement));
65
- }
66
- const driver = createPgDriver({ executor: executorFor(client) });
129
+ async function startJobs(client: DevDbClient, overrides?: RuntimeOverrides): Promise<RunningQueue> {
130
+ await applySchema(client);
131
+ const executor = pgExecutorFor(client);
132
+ const driver = overrides?.jobs ?? createPgDriver({ executor });
67
133
  setJobDriver(driver);
68
- return driver;
134
+ const outbox = createPgOutboxStore({
135
+ executor,
136
+ // The open transaction is a client on its own connection; the `Tx` token is not that object.
137
+ txExecutor: () => pgExecutorFor(currentTx() ?? client),
138
+ });
139
+ setJobsFacade(
140
+ createJobsFacade({ store: outbox, driver }, () => currentTx() as unknown as Tx | undefined),
141
+ );
142
+ const events = createPgEventBus({ executor });
143
+ setEventBus(events);
144
+ setIdempotencyStore(postgresIdempotencyStore({ executor }));
145
+ return { db: client, jobs: driver, outbox, events, stop: () => releaseQueue(client, driver) };
69
146
  }
70
147
 
71
148
  /**
72
- * Release both ambient accessors, then the resources behind them, in that order: a driver reset
149
+ * Release every ambient accessor, then the resources behind them, in that order: a driver reset
73
150
  * after its database is closed leaves a window where `jobDriver()` answers over a dead socket.
74
151
  * A stale driver is worse than none — the next command sees one installed and skips queue
75
152
  * startup entirely, so every query it makes fails on a connection this process already dropped.
153
+ *
154
+ * The facade goes with the driver for the same reason: an enqueue routed through a store bound to
155
+ * a closed client is a staged row nothing will ever publish.
76
156
  */
77
157
  async function releaseQueue(db: DevDbClient, jobs: JobDriver | undefined): Promise<void> {
158
+ // The idempotency store goes back to the memory default for the same reason the facade does: it
159
+ // holds this client, and the next command in this process would reserve keys over a closed one.
160
+ resetIdempotency();
161
+ resetJobsFacade();
78
162
  resetJobDriver();
79
163
  setDbClient(undefined);
80
164
  await jobs?.close?.();
@@ -87,14 +171,16 @@ async function releaseQueue(db: DevDbClient, jobs: JobDriver | undefined): Promi
87
171
  * would pay for services it cannot even use. `startServices` builds on this so there is one boot
88
172
  * path for "which database" and "which queue", not two.
89
173
  */
90
- export async function startQueue(services: DevServices): Promise<RunningQueue> {
174
+ export async function startQueue(
175
+ services: DevServices,
176
+ overrides?: RuntimeOverrides,
177
+ ): Promise<RunningQueue> {
91
178
  const db = startDb(services);
92
179
  try {
93
180
  // Pay the Postgres boot here, so the first request is not the slow one and a broken database
94
181
  // fails at boot rather than on some later query.
95
182
  await db.ping();
96
- const jobs = await startJobs(db);
97
- return { db, jobs, stop: () => releaseQueue(db, jobs) };
183
+ return await startJobs(db, overrides);
98
184
  } catch (error) {
99
185
  // `db.ping()` or `startJobs` is where a broken database is supposed to fail. Without this,
100
186
  // the caller exits holding the PGlite lock and the ambient accessors, and nothing is left to
package/src/dev-render.ts CHANGED
@@ -2,29 +2,55 @@
2
2
  // `@ultimat3/render`'s own function for that mode — the CLI picks the mode and supplies the
3
3
  // document, it never decides what a mode means or what headers it earns.
4
4
  //
5
- // The document is head + shell. Islands are the compiled client graph's, and there is no
6
- // compiled graph before `x build`, so a dev page serves its real `<head>`, its real status and
7
- // its real cache headers around an empty root — never a 404.
5
+ // The document is head + the route's own component, rendered by `@ultimat3/render`'s server JSX
6
+ // writer, with the surface's compiled CSS inlined. Inlined rather than linked because a `site/`
7
+ // page is a 0kb-JS artifact a CDN serves as one file: a stylesheet link would add a round trip to
8
+ // the render path the mode exists to make cheap, and a static export would need a second file.
8
9
 
9
10
  import type { Ctx } from '@ultimat3/core';
10
11
  import type { RouteMeta as HttpRouteMeta, Route, RouteParams } from '@ultimat3/http';
11
12
  import { asCtx, html, stream } from '@ultimat3/http';
12
- import type { IsrController, RenderResult, RouteEntry } from '@ultimat3/render';
13
+ import { currentLocale } from '@ultimat3/i18n';
14
+ import type {
15
+ IslandCollector,
16
+ IsrController,
17
+ RenderResult,
18
+ RouteData,
19
+ RouteEntry,
20
+ } from '@ultimat3/render';
13
21
  import {
14
22
  contentHash,
23
+ createIslandCollector,
15
24
  createIsrController,
16
25
  headFromMeta,
26
+ hydrateRuntime,
27
+ metaContextFor,
28
+ renderComponent,
17
29
  renderHead,
18
30
  renderSpa,
19
31
  renderSsr,
32
+ routeDataFor,
20
33
  routeEntries,
21
34
  SPA_ROOT_ID,
22
35
  seoRenderers,
23
36
  staticHeaders,
24
37
  streamResult,
38
+ stylesFor,
25
39
  } from '@ultimat3/render';
26
40
 
27
- export interface DevRenderOptions {
41
+ /**
42
+ * Specifier → built chunk URL, bound to the route file the specifier is written relative to.
43
+ * Supplied by whoever built the islands (`x dev`, the container, the static build); absent means
44
+ * no island was built, and a page that renders one then fails by name rather than emitting a
45
+ * `data-x-entry` nothing can import.
46
+ */
47
+ export type IslandResolver = (routeFile: string) => (src: string) => string;
48
+
49
+ export interface DocumentOptions {
50
+ readonly resolveIsland?: IslandResolver;
51
+ }
52
+
53
+ export interface DevRenderOptions extends DocumentOptions {
28
54
  readonly buildId: string;
29
55
  /** Injected so a test can drive the ISR store without a timer. */
30
56
  readonly isr?: IsrController;
@@ -36,67 +62,173 @@ export interface DevRouteData extends Record<string, unknown> {
36
62
  readonly params: RouteParams;
37
63
  }
38
64
 
39
- const LANG = 'en';
65
+ /**
66
+ * `<html lang>` is the request's own locale, never a constant: the `locale` stage negotiated it
67
+ * one stage before this handler and published it on the context, so a hardcoded `'en'` shipped
68
+ * every document mislabelled — wrong for a screen reader, wrong for `hreflang`, wrong for a CDN
69
+ * keying on `content-language`. Outside a request (`x build`'s prerender) it is the app's own
70
+ * configured fallback, which is the only defensible answer there.
71
+ */
72
+ const lang = (): string => currentLocale();
40
73
 
41
- const headFor = async (entry: RouteEntry, data: DevRouteData): Promise<string> =>
74
+ const headFor = async (entry: RouteEntry, ctx: DevRouteData, data: RouteData): Promise<string> =>
42
75
  renderHead(
43
- headFromMeta(await entry.config.meta(data), seoRenderers({ path: new URL(data.url).pathname })),
76
+ headFromMeta(
77
+ await entry.config.meta(metaContextFor(ctx, data)),
78
+ seoRenderers({ path: new URL(ctx.url).pathname }),
79
+ ),
80
+ );
81
+
82
+ /** `<style>` for the surface's own stylesheets, or nothing at all when the surface imports none. */
83
+ const styleTag = (entry: RouteEntry): string => {
84
+ const css = stylesFor(entry.surface);
85
+ return css.length === 0 ? '' : `<style>${css}</style>`;
86
+ };
87
+
88
+ /**
89
+ * The route's rendered body, inside the hydration root. A module that exports no component (an
90
+ * `api/` route, or a `spa` whose data is all client-side) renders an empty root, which is the
91
+ * shell those modes are defined to serve — not a fallback for a component that failed.
92
+ */
93
+ export async function routeBody(
94
+ entry: RouteEntry,
95
+ ctx: DevRouteData,
96
+ data: RouteData,
97
+ islands: IslandCollector,
98
+ ): Promise<string> {
99
+ if (entry.component === undefined) return `<div id="${SPA_ROOT_ID}"></div>`;
100
+ const url = new URL(ctx.url);
101
+ const html = await renderComponent(
102
+ entry.component,
103
+ // `data` is the route's own `load` result and is what `meta` was just given — the same object,
104
+ // never a second resolution. `query` is supplied because a page that reads `props.query.x`
105
+ // otherwise dereferences undefined and takes the whole render down.
106
+ {
107
+ data,
108
+ params: ctx.params,
109
+ url: ctx.url,
110
+ query: Object.fromEntries(url.searchParams) as Readonly<Record<string, string>>,
111
+ },
112
+ entry.file,
113
+ { islands },
44
114
  );
115
+ return `<div id="${SPA_ROOT_ID}">${html}</div>`;
116
+ }
117
+
118
+ /**
119
+ * One collector per RENDER, never module-global: two requests render different params, and a
120
+ * shared collector would bill one page for the other's islands. `hydrate` comes off the route, so
121
+ * an island never declares its own timing, and `resolve` is the build's — identity when nothing
122
+ * built any, which fails at the first island by name rather than emitting an unusable entry.
123
+ */
124
+ const collectorFor = (entry: RouteEntry, options: DocumentOptions): IslandCollector =>
125
+ createIslandCollector({
126
+ file: entry.file,
127
+ hydrate: entry.config.hydrate,
128
+ ...(options.resolveIsland === undefined ? {} : { resolve: options.resolveIsland(entry.file) }),
129
+ });
45
130
 
46
131
  /**
47
- * Head + shell for one route render. Exported because the build's prerenderer must emit the same
132
+ * Head + body for one route render. Exported because the build's prerenderer must emit the same
48
133
  * document `x dev` serves — two document builders is how a page that works in dev ships broken.
49
134
  */
50
- export async function routeDocument(entry: RouteEntry, data: DevRouteData): Promise<string> {
51
- return shellFor(await headFor(entry, data));
135
+ export async function routeDocument(
136
+ entry: RouteEntry,
137
+ ctx: DevRouteData,
138
+ options: DocumentOptions = {},
139
+ ): Promise<string> {
140
+ return documentFrom(entry, ctx, await routeDataFor(entry.config, ctx), options);
52
141
  }
53
142
 
54
- const shellFor = (head: string): string =>
55
- `<!doctype html><html lang="${LANG}"><head>${head}</head>` +
56
- `<body><div id="${SPA_ROOT_ID}"></div></body></html>`;
143
+ /**
144
+ * The document from data ALREADY resolved. Split from `routeDocument` so one request resolves
145
+ * `load` exactly once: `stream` renders head and body separately, and resolving in each would let
146
+ * a `<title>` describe content the body does not contain.
147
+ *
148
+ * The hydration runtime is appended after the body and only after it: what strategies a page needs
149
+ * is a fact about the islands the walk just recorded, so emitting it earlier would either guess or
150
+ * ship the whole runtime to a page with no island — the 0kb baseline, spent on nothing.
151
+ */
152
+ async function documentFrom(
153
+ entry: RouteEntry,
154
+ ctx: DevRouteData,
155
+ data: RouteData,
156
+ options: DocumentOptions,
157
+ ): Promise<string> {
158
+ const islands = collectorFor(entry, options);
159
+ const [head, body] = await Promise.all([
160
+ headFor(entry, ctx, data),
161
+ routeBody(entry, ctx, data, islands),
162
+ ]);
163
+ return (
164
+ `<!doctype html><html lang="${lang()}"><head>${head}${styleTag(entry)}</head>` +
165
+ `<body>${body}${hydrateRuntime(islands.directives)}</body></html>`
166
+ );
167
+ }
57
168
 
58
169
  async function resultFor(
59
170
  entry: RouteEntry,
60
- data: DevRouteData,
171
+ request: DevRouteData,
61
172
  options: DevRenderOptions,
62
173
  isr: IsrController,
63
174
  ctx: Ctx,
64
175
  ): Promise<RenderResult> {
65
- const url = new URL(data.url);
176
+ const url = new URL(request.url);
177
+ // ONCE per request, before the mode is chosen. Every branch below reads this same object, so a
178
+ // route's `load` runs exactly once however its mode splits head from body.
179
+ const data = await routeDataFor(entry.config, request);
66
180
  switch (entry.config.render) {
67
181
  case 'static': {
68
182
  // Not `renderStatic`: that enumerates every prerendered path for the build. A request
69
183
  // names exactly one, and it earns the same content-hashed headers.
70
- const body = await routeDocument(entry, data);
184
+ const body = await documentFrom(entry, request, data, options);
71
185
  return { status: 200, headers: staticHeaders(contentHash(body), options.buildId), body };
72
186
  }
73
187
  case 'isr': {
74
- const served = await isr.serve(url.pathname, () => routeDocument(entry, data));
188
+ const served = await isr.serve(url.pathname, () =>
189
+ documentFrom(entry, request, data, options),
190
+ );
75
191
  return served.result;
76
192
  }
77
193
  case 'spa':
194
+ // The shell renders no body by definition, but it still carries the surface's CSS: the
195
+ // client paints into `#x-root` and a flash of unstyled shell is the mode's own regression.
78
196
  return renderSpa({
79
197
  entry,
80
198
  buildId: options.buildId,
81
- head: await headFor(entry, data),
199
+ head: (await headFor(entry, request, data)) + styleTag(entry),
82
200
  chunks: [],
83
- lang: LANG,
201
+ lang: lang(),
84
202
  });
85
203
  case 'stream': {
86
- const head = await headFor(entry, data);
204
+ // The shell IS the component: nothing can yet mark a subtree as a hole. Solid's `Suspense`
205
+ // is not the missing piece and never will be here — it calls `getContextId()`, which throws
206
+ // outside a Solid renderer, and this package's JSX factory is inert by design. A hole marker
207
+ // has to be the framework's own. Until it exists the first flush carries the whole body —
208
+ // correct output, no streaming benefit.
209
+ const islands = collectorFor(entry, options);
210
+ const [head, shell] = await Promise.all([
211
+ headFor(entry, request, data),
212
+ routeBody(entry, request, data, islands),
213
+ ]);
87
214
  return streamResult(
88
215
  {
89
- head: `<!doctype html><html lang="${LANG}"><head>${head}</head><body>`,
90
- shell: `<div id="${SPA_ROOT_ID}"></div>`,
216
+ head: `<!doctype html><html lang="${lang()}"><head>${head}${styleTag(entry)}</head><body>`,
217
+ // The runtime rides the first flush, with the shell it boots. A later chunk would leave
218
+ // the window between flush one and the close with inert islands and no listeners on
219
+ // them — which is exactly the first-click-lost failure `interaction` replay exists for.
220
+ shell: `${shell}${hydrateRuntime(islands.directives)}`,
91
221
  holes: [],
92
222
  },
93
223
  { buildId: options.buildId },
94
224
  );
95
225
  }
96
226
  default:
97
- return renderSsr({ entry, params: data.params, url, ctx }, () => routeDocument(entry, data), {
98
- buildId: options.buildId,
99
- });
227
+ return renderSsr(
228
+ { entry, params: request.params, url, ctx },
229
+ () => documentFrom(entry, request, data, options),
230
+ { buildId: options.buildId },
231
+ );
100
232
  }
101
233
  }
102
234