@ultimat3/cli 1.1.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.
- package/CLAUDE.md +724 -0
- package/README.md +41 -9
- package/package.json +25 -23
- package/src/api-routes.ts +16 -0
- package/src/app-auth.ts +32 -0
- package/src/app-entities.ts +18 -0
- package/src/app-env.ts +103 -0
- package/src/app-load.ts +20 -3
- package/src/bin.ts +4 -3
- package/src/budgets.ts +114 -9
- package/src/cmd-build.ts +69 -21
- package/src/cmd-db-branch.ts +215 -0
- package/src/cmd-db.ts +332 -155
- package/src/cmd-deploy.ts +59 -6
- package/src/cmd-dev.ts +87 -17
- package/src/cmd-docs.ts +167 -0
- package/src/cmd-doctor.ts +64 -9
- package/src/cmd-env.ts +95 -0
- package/src/cmd-errors.ts +33 -13
- package/src/cmd-fix.ts +5 -1
- package/src/cmd-generate.ts +146 -111
- package/src/cmd-help.ts +16 -5
- package/src/cmd-i18n.ts +2 -0
- package/src/cmd-jobs.ts +47 -33
- package/src/cmd-mcp.ts +11 -2
- package/src/cmd-new.ts +13 -7
- package/src/cmd-planned.ts +55 -10
- package/src/cmd-policy.ts +1 -0
- package/src/cmd-registries.ts +3 -0
- package/src/cmd-secrets.ts +368 -0
- package/src/cmd-tasks.ts +1 -0
- package/src/cmd-test.ts +17 -23
- package/src/cmd-verify.ts +177 -23
- package/src/db-backfill.ts +401 -0
- package/src/db-branch.ts +251 -0
- package/src/db-destructive.ts +29 -0
- package/src/db-finding.ts +28 -0
- package/src/db-generate.ts +112 -0
- package/src/db-snapshot.ts +24 -0
- package/src/dev-assets.ts +86 -20
- package/src/dev-cache.ts +122 -0
- package/src/dev-dashboard.ts +19 -4
- package/src/dev-hooks.ts +27 -2
- package/src/dev-n-plus-one.ts +191 -0
- package/src/dev-queue.ts +105 -19
- package/src/dev-render.ts +158 -26
- package/src/dev-roles-fixture.ts +67 -0
- package/src/dev-roles.ts +186 -78
- package/src/dev-runtime.ts +117 -40
- package/src/dev-services.ts +15 -0
- package/src/dev-storage.ts +245 -0
- package/src/dev-sync.ts +107 -0
- package/src/dev-traces.ts +11 -3
- package/src/dispatch.ts +4 -2
- package/src/document-styles.ts +54 -0
- package/src/drift.ts +37 -9
- package/src/error-catalog.ts +7 -18
- package/src/error-codes.ts +186 -0
- package/src/error-contract.ts +29 -7
- package/src/error-fixes.ts +114 -0
- package/src/errors.ts +205 -140
- package/src/fix-command.ts +268 -0
- package/src/flag-number.ts +56 -0
- package/src/framework-scope.ts +49 -0
- package/src/generate-kinds.ts +97 -0
- package/src/guards.ts +186 -0
- package/src/index.ts +87 -14
- package/src/island-bundle.ts +166 -0
- package/src/island-routes.ts +50 -0
- package/src/jobs-driver.ts +33 -0
- package/src/jobs-json.ts +24 -0
- package/src/jobs-report.ts +17 -4
- package/src/mcp-db-target.ts +52 -27
- package/src/mcp-errors.ts +120 -19
- package/src/mcp-host.ts +44 -25
- package/src/messages.ts +81 -2
- package/src/metrics-endpoint.ts +73 -0
- package/src/migrations.ts +37 -4
- package/src/otlp-export.ts +64 -0
- package/src/output.ts +46 -16
- package/src/parse.ts +41 -3
- package/src/policy-facts.ts +38 -6
- package/src/policy-fixture.ts +14 -7
- package/src/prerender.ts +111 -2
- package/src/registry.ts +21 -3
- package/src/runtime-overrides.ts +66 -0
- package/src/safe-url-label.ts +24 -0
- package/src/scaffold-fixture.ts +10 -0
- package/src/scaffold-typecheck.ts +16 -38
- package/src/serve.ts +202 -18
- package/src/source-files.ts +4 -0
- package/src/statement-loop.ts +74 -0
- package/src/style-csp.ts +18 -0
- package/src/sync-authenticator.ts +59 -0
- package/src/templates/action.ts +15 -30
- package/src/templates/admin-page.ts +103 -0
- package/src/templates/admin.ts +11 -7
- package/src/templates/backfill.ts +212 -0
- package/src/templates/entity.ts +72 -31
- package/src/templates/guard.ts +143 -0
- package/src/templates/index.ts +12 -1
- package/src/templates/island.ts +67 -0
- package/src/templates/job.ts +53 -13
- package/src/templates/naming.ts +17 -1
- package/src/templates/policy.ts +35 -28
- package/src/templates/query.ts +24 -5
- package/src/templates/resource.ts +19 -11
- package/src/templates/route.ts +90 -15
- package/src/templates/scaffold-app.ts +142 -45
- package/src/templates/scaffold-claude-agents.ts +149 -0
- package/src/templates/scaffold-claude-commands.ts +221 -0
- package/src/templates/scaffold-claude.ts +134 -0
- package/src/templates/scaffold-container.ts +46 -2
- package/src/templates/scaffold-db-package.ts +91 -0
- package/src/templates/scaffold-docs.ts +24 -5
- package/src/templates/scaffold-domain-package.ts +90 -0
- package/src/templates/scaffold-env.ts +87 -0
- package/src/templates/scaffold-i18n.ts +4 -1
- package/src/templates/scaffold-mcp-package.ts +49 -0
- package/src/templates/scaffold-package-shape.ts +25 -4
- package/src/templates/scaffold-repo.ts +116 -257
- package/src/templates/scaffold-roles.ts +68 -0
- package/src/templates/scaffold-ui-package.ts +56 -0
- package/src/templates/slice-foundation.ts +88 -0
- package/src/templates/wrap.ts +95 -0
- package/src/test-counts.ts +35 -0
- package/src/test-select.ts +30 -15
- package/src/test-shards.ts +21 -3
- package/src/test-workers.ts +47 -0
- package/src/ts-scan.ts +271 -13
- package/src/tsconfig-references.ts +78 -0
- package/src/verify-floor.ts +133 -0
- package/src/verify-step.ts +19 -0
- package/src/verify-test-run.ts +72 -0
- package/src/verify-tests.ts +160 -71
- package/src/version-loader.ts +20 -3
- package/src/workspace-checks.ts +87 -16
- 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
|
|
4
|
-
// takes
|
|
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
|
|
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 {
|
|
15
|
-
import {
|
|
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
|
|
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
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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<
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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 +
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
74
|
+
const headFor = async (entry: RouteEntry, ctx: DevRouteData, data: RouteData): Promise<string> =>
|
|
42
75
|
renderHead(
|
|
43
|
-
headFromMeta(
|
|
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 +
|
|
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(
|
|
51
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
171
|
+
request: DevRouteData,
|
|
61
172
|
options: DevRenderOptions,
|
|
62
173
|
isr: IsrController,
|
|
63
174
|
ctx: Ctx,
|
|
64
175
|
): Promise<RenderResult> {
|
|
65
|
-
const url = new 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
|
|
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, () =>
|
|
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:
|
|
201
|
+
lang: lang(),
|
|
84
202
|
});
|
|
85
203
|
case 'stream': {
|
|
86
|
-
|
|
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="${
|
|
90
|
-
shell
|
|
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(
|
|
98
|
-
|
|
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
|
|