cursedops 0.10.9 → 0.10.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedops",
3
- "version": "0.10.9",
3
+ "version": "0.10.11",
4
4
  "description": "The build-and-ops answers this generation's apps wrote independently and identically: finding a generation's roots — and printing a command that runs when pasted — without knowing a path, the generation's whole-tree laws run over one repo from a checkout or a worktree, macOS launchd agent install/replace/remove and the live port a job serves, the scaffolding and verdicts of a deployed smoke (origin probe, the smoke's own environment, a network that lies about DNS, a settled version), the static-serving helpers eight apps copied — the path-traversal guard among them — the API floor that keeps an unmatched /api/... from ever being answered with the app shell, the commit and dirty flag a checkout-served process reports, the Cloudflare Worker deploy toolkit four apps copied (the deploy sequence, exact-set secrets over a pipe, origin-first rollback, the curl edge fetch, the row-for-row D1 import proof, the billed-CPU tail check around a deploy's walk and smoke, and each app's worker:secrets and worker:smoke main as one function of its data), the relay a Worker fronts a Mac-bound app with (the Durable Object, the frames, the Mac's dialer and key rotation — lifted from station for roms — and the signed-in stage walk's skeleton and the relay app's whole worker:deploy), the Worker import-graph and await-port checks every Worker app's suite runs over its own source, and the public-surface ratchet three published libraries each carried a forked copy of. Mechanism only — no app knows its name from here. Bun, zero runtime dependencies (typescript is an optional peer, for public-surface only), ships source.",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/d1Schema.ts CHANGED
@@ -13,8 +13,9 @@
13
13
  * A Worker has no boot. An app's `migrate()` issues dozens of statements and inspects
14
14
  * `PRAGMA table_info` as it goes; running that on every invocation would spend a D1 invocation's
15
15
  * query budget before the request started, and D1 has no `PRAGMA table_info` to inspect with. So
16
- * D1's schema is applied ONCE, by hand — `bunx wrangler d1 execute <name> --remote --file
17
- * db/schema.sql` — and there are two descriptions of the app's tables. Each app's own
16
+ * D1's schema is applied ahead of the Worker, by every `worker:deploy` — ordinary `--command`
17
+ * queries, never D1's blocking `--file` import (`schemaApplyArgv` in `cursedops/worker-deploy`) —
18
+ * and there are two descriptions of the app's tables. Each app's own
18
19
  * schema-matches test fails its gate when they disagree, so the file is a build product with a
19
20
  * checker rather than a document with a convention. {@link d1SchemaText} writes it, and refuses a
20
21
  * statement that spans lines, because D1's `exec` splits on newlines.
@@ -26,10 +26,11 @@
26
26
  * anonymous `/` there is a 302 to the Access issuer, which names no built asset and never matches.
27
27
  */
28
28
  import { spawnSync } from "node:child_process";
29
- import { readFileSync } from "node:fs";
30
- import { join } from "node:path";
29
+ import { existsSync, readFileSync, statSync } from "node:fs";
30
+ import { dirname, join, relative, resolve } from "node:path";
31
31
  import { builtAssetsIn, type Smoke } from "cursedops/smoke";
32
32
  import { stagedClientDir } from "cursedops/staged-client";
33
+ import { valueImports } from "cursedops/worker-safety";
33
34
 
34
35
  /** The smoke row's name — one spelling for every relay app. */
35
36
  export const PUBLIC_CLIENT_CHECK = "public-client";
@@ -123,6 +124,56 @@ export async function recordPublicClient(smoke: Smoke, staged: string | null, op
123
124
  return verdict.matched;
124
125
  }
125
126
 
127
+ /**
128
+ * What the relay Worker is BUILT from, relative to the app's checkout: every file reachable by
129
+ * relative import from wrangler's `main` (the Worker reaches into `src/` — roms'
130
+ * `../src/server/limits`, station's `../scripts/link`), plus the config and the lockfile that pin
131
+ * what it bundles from `node_modules`. Test files are never reachable, so a test-only change
132
+ * does not re-ship. Unresolvable edges are skipped; esbuild would refuse them anyway.
133
+ */
134
+ export function workerInputs(root: string, main = "worker/index.ts"): string[] {
135
+ const seen = new Set<string>();
136
+ const queue = [resolve(root, main)];
137
+ while (queue.length > 0) {
138
+ const file = queue.shift() as string;
139
+ if (seen.has(file)) continue;
140
+ seen.add(file);
141
+ let code: string;
142
+ try {
143
+ code = readFileSync(file, "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:"'`])\/\/.*$/gm, "$1");
144
+ } catch {
145
+ continue;
146
+ }
147
+ for (const spec of valueImports(code)) {
148
+ if (!spec.startsWith(".")) continue;
149
+ const base = resolve(dirname(file), spec);
150
+ const hit = [base, `${base}.ts`, `${base}.tsx`, `${base}.js`, join(base, "index.ts")].find(
151
+ (candidate) => existsSync(candidate) && statSync(candidate).isFile(),
152
+ );
153
+ if (hit) queue.push(hit);
154
+ }
155
+ }
156
+ const files = [...seen].map((file) => relative(root, file)).sort();
157
+ return [...files, "wrangler.jsonc", "package.json", "bun.lock"];
158
+ }
159
+
160
+ /**
161
+ * Pure: does the served Worker need re-uploading? `served` is `/healthz`'s `worker` object;
162
+ * `diffStatus` is `git diff --quiet <served.commit> HEAD -- <inputs>`'s exit status (0 same,
163
+ * 1 changed, anything else — an unknown or unreachable commit — unknowable). Null when it is
164
+ * current; otherwise the sentence saying why it is not. Unknown is behind: shipping is
165
+ * idempotent, and believing a fix is live when it is not is the failure this exists for.
166
+ */
167
+ export function workerCodeBehind(served: { commit?: unknown; dirty?: unknown } | null, head: string, diffStatus: number | null): string | null {
168
+ const commit = typeof served?.commit === "string" ? served.commit : "";
169
+ if (!commit) return "/healthz names no `worker.commit` — nothing says which Worker is serving";
170
+ if (served?.dirty === true) return `the served Worker (${commit}) was deployed from a dirty tree`;
171
+ if (head.startsWith(commit) || commit.startsWith(head)) return null;
172
+ if (diffStatus === 0) return null;
173
+ if (diffStatus === 1) return `the Worker's code changed since the served Worker (${commit} → ${head.slice(0, 8)})`;
174
+ return `the served Worker's commit ${commit} is not readable in this checkout — cannot prove it is current`;
175
+ }
176
+
126
177
  export interface ShipClientOptions extends PublicShellOptions {
127
178
  /** The public origin, e.g. `https://roms.cursedalchemy.com`. */
128
179
  base: string;
@@ -132,23 +183,52 @@ export interface ShipClientOptions extends PublicShellOptions {
132
183
  root: string;
133
184
  /** Runs `bun run worker:deploy` in `root`; returns its exit status. Injected for tests. */
134
185
  runWorkerDeploy?: (root: string) => number | null;
186
+ /** `git <args>` in `root` → exit status and trimmed stdout. Injected for tests. */
187
+ git?: (root: string, args: readonly string[]) => { status: number | null; stdout: string };
188
+ /** What the Worker is built from ({@link workerInputs}); default computed from `root`. */
189
+ workerInputs?: readonly string[];
135
190
  log?: (line: string) => void;
136
191
  }
137
192
 
138
193
  export interface ShipClientResult {
139
- /** `already` — the public shell named this build; `shipped` — worker:deploy ran and exited 0; `failed` — it did not. */
194
+ /** `already` — the public shell named this build AND the served Worker is current; `shipped` — worker:deploy ran and exited 0; `failed` — it did not. */
140
195
  outcome: "already" | "shipped" | "failed";
141
196
  /** worker:deploy's exit status when it ran. */
142
197
  status?: number | null;
143
198
  before: PublicClientVerdict | null;
199
+ /** Why the Worker itself was behind ({@link workerCodeBehind}), when the client was not the reason. */
200
+ workerBehind?: string;
201
+ }
202
+
203
+ const spawnGit = (root: string, args: readonly string[]): { status: number | null; stdout: string } => {
204
+ const run = spawnSync("git", [...args], { cwd: root, encoding: "utf8" });
205
+ return { status: run.status, stdout: (run.stdout ?? "").trim() };
206
+ };
207
+
208
+ /** `/healthz`'s `worker` object, or null when it cannot be read. */
209
+ async function servedWorker(options: ShipClientOptions): Promise<{ commit?: unknown; dirty?: unknown } | null> {
210
+ try {
211
+ const ask = options.fetch ?? ((u: string, init: RequestInit) => fetch(u, init));
212
+ const response = await ask(new URL("/healthz", options.base).toString(), {
213
+ redirect: "manual",
214
+ signal: AbortSignal.timeout(options.timeoutMs ?? 20_000),
215
+ headers: { accept: "application/json", ...(options.headers ?? {}) },
216
+ });
217
+ const body = (await response.json()) as { worker?: { commit?: unknown; dirty?: unknown } };
218
+ return body?.worker ?? null;
219
+ } catch {
220
+ return null;
221
+ }
144
222
  }
145
223
 
146
224
  const spawnWorkerDeploy = (root: string): number | null =>
147
225
  spawnSync("bun", ["run", "worker:deploy"], { cwd: root, stdio: ["ignore", "inherit", "inherit"] }).status;
148
226
 
149
227
  /**
150
- * The deploy step: when the public shell does not name this build, run `worker:deploy` (its own
151
- * stage walk first) so ONE command ships the release. Never throws for a fetch that fails — an
228
+ * The deploy step: when the public shell does not name this build — or it does but the served
229
+ * Worker (`/healthz` `worker.commit`) predates a change to what the Worker is built from
230
+ * ({@link workerInputs}) — run `worker:deploy` (its own stage walk first) so ONE command ships the
231
+ * release. Never throws for a fetch that fails — an
152
232
  * unreadable public shell is "behind", because shipping the Worker is idempotent and the smoke's
153
233
  * `public-client` row is what proves it took.
154
234
  */
@@ -162,8 +242,25 @@ export async function shipClientIfBehind(options: ShipClientOptions): Promise<Sh
162
242
  );
163
243
  before = comparePublicClient(html, options.staged, options.base);
164
244
  if (before.matched) {
165
- log(` already serving it — ${before.built.join(", ")}`);
166
- return { outcome: "already", before };
245
+ // 🔴 The client matching is not the Worker matching: roms 86e5e522 changed only
246
+ // worker/index.ts, this said "already serving it", and /healthz kept answering the
247
+ // previous Worker until worker:deploy was run by hand (2026-09-25).
248
+ const git = options.git ?? spawnGit;
249
+ const head = git(options.root, ["rev-parse", "HEAD"]).stdout;
250
+ const served = await servedWorker(options);
251
+ const commit = typeof served?.commit === "string" ? served.commit : "";
252
+ const inputs = options.workerInputs ?? (commit ? workerInputs(options.root) : []);
253
+ const atHead = commit !== "" && head !== "" && (head.startsWith(commit) || commit.startsWith(head));
254
+ const diff = commit && head && !atHead ? git(options.root, ["diff", "--quiet", commit, "HEAD", "--", ...inputs]).status : null;
255
+ const behind = workerCodeBehind(served, head, diff);
256
+ if (behind === null) {
257
+ log(` already serving it — ${before.built.join(", ")}, Worker ${commit}`);
258
+ return { outcome: "already", before };
259
+ }
260
+ log(` the client is current but ${behind} — shipping the Worker`);
261
+ log(" running `bun run worker:deploy` — the stage walk, then production");
262
+ const status = (options.runWorkerDeploy ?? spawnWorkerDeploy)(options.root);
263
+ return { outcome: status === 0 ? "shipped" : "failed", status, before, workerBehind: behind };
167
264
  }
168
265
  log(` ${workerBehind(before, options.base, options.root)}`);
169
266
  } else {
package/src/relay.ts CHANGED
@@ -59,6 +59,16 @@ export interface ResponseFrame {
59
59
 
60
60
  export type LinkFrame = RequestFrame | ResponseFrame;
61
61
 
62
+ /**
63
+ * The link's keepalive, as bare text (never JSON, so neither {@link parseFrame} nor
64
+ * {@link parseWarmFrame} can mistake one for a frame). The Mac sends {@link LINK_PING}; the
65
+ * object answers {@link LINK_PONG} through `setWebSocketAutoResponse`, which the runtime does
66
+ * WITHOUT waking a hibernated object — so the heartbeat bills nothing. `cursedops/relay-link`
67
+ * has why it exists: a half-open socket after the Mac slept.
68
+ */
69
+ export const LINK_PING = "ping";
70
+ export const LINK_PONG = "pong";
71
+
62
72
  /**
63
73
  * Sent UP by the Mac once, right after it connects (station, task 089): the GETs every page
64
74
  * performs, so a {@link RelayLink} with an offline cache can refresh it while the Mac is awake.
@@ -223,6 +233,8 @@ export interface LinkState {
223
233
  acceptWebSocket(socket: LinkSocket, tags?: string[]): void;
224
234
  getWebSockets(tag?: string): LinkSocket[];
225
235
  storage: LinkStorage;
236
+ /** workerd's — answers {@link LINK_PING} without waking the object. Optional: a test has none. */
237
+ setWebSocketAutoResponse?(pair: unknown): void;
226
238
  }
227
239
 
228
240
  /** What `/healthz` and a smoke may know about the link. No content — the body is public. */
@@ -349,6 +361,9 @@ export class RelayLink {
349
361
  this.timeoutMs = options.timeoutMs ?? DEFAULT_RELAY_TIMEOUT_MS;
350
362
  this.maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
351
363
  this.now = options.now ?? Date.now;
364
+ // The heartbeat's answer, given by the runtime while the object sleeps (see LINK_PING).
365
+ const Pair = (globalThis as { WebSocketRequestResponsePair?: new (request: string, response: string) => unknown }).WebSocketRequestResponsePair;
366
+ if (Pair && state.setWebSocketAutoResponse) state.setWebSocketAutoResponse(new Pair(LINK_PING, LINK_PONG));
352
367
  }
353
368
 
354
369
  async fetch(request: Request): Promise<Response> {
@@ -499,8 +514,16 @@ export class RelayLink {
499
514
  });
500
515
  }
501
516
 
502
- webSocketMessage(_socket: LinkSocket, message: string | ArrayBuffer): void | Promise<void> {
517
+ webSocketMessage(socket: LinkSocket, message: string | ArrayBuffer): void | Promise<void> {
503
518
  if (typeof message !== "string") return;
519
+ // Only reached where the runtime's auto-response is not armed (a test, an old runtime): a
520
+ // Mac whose pings went unanswered would redial every minute.
521
+ if (message === LINK_PING) {
522
+ try {
523
+ socket.send(LINK_PONG);
524
+ } catch {}
525
+ return;
526
+ }
504
527
  const warm = parseWarmFrame(message);
505
528
  if (warm) return this.warm(warm.paths);
506
529
  const frame = parseFrame(message);
package/src/relayLink.ts CHANGED
@@ -10,13 +10,26 @@
10
10
  * 🔴 **The Mac listens on nothing for the relay.** What reaches the app arrives down a socket this
11
11
  * process opened to a hostname it verified over TLS, holding a key only the Worker's digest matches.
12
12
  *
13
- * 🔴 **No timer asks anything** (owner, 2026-09-15: *"There should be no polling"*). The one
14
- * `setTimeout` is the reconnect after the socket CLOSES — a sleeping Mac's socket dies, and the link
15
- * is back within seconds of waking, backing off to a minute while the Worker is unreachable.
13
+ * 🔴 **No timer asks for DATA** (owner, 2026-09-15: *"There should be no polling"*). One timer
14
+ * exists, and it is a keepalive on the idle socket, not a request for anything: {@link dialLink}'s
15
+ * heartbeat. It used to say "a sleeping Mac's socket dies, and the link is back within seconds of
16
+ * waking" — measured FALSE on 2026-09-25. The owner powered the Mac off and on; both `com.roms.link`
17
+ * and `com.station.link` came back up holding a socket the Worker had long since dropped. No `close`
18
+ * event ever fired on the Mac for a half-open TCP connection, so nothing redialed, and roms and
19
+ * station answered "the Mac is not connected" for every API call with the Mac awake, logging
20
+ * `✓ linked` the whole time. A `ping` every {@link LINK_HEARTBEAT_MS} that the Worker's object
21
+ * answers `pong` WITHOUT waking (`setWebSocketAutoResponse`, `cursedops/relay`) is what notices, and
22
+ * a tick that arrives far later than it was scheduled is the Mac having slept — redialed at once.
16
23
  */
17
24
  import { spawnSync } from "node:child_process";
18
25
  import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
19
- import { frameToRequest, parseFrame, type ResponseFrame, responseFrame, sha256Hex, toBase64 } from "cursedops/relay";
26
+ import { frameToRequest, LINK_PING, LINK_PONG, parseFrame, type ResponseFrame, responseFrame, sha256Hex, toBase64 } from "cursedops/relay";
27
+
28
+ /**
29
+ * How often an idle link proves the Worker still holds its other end. A dead socket is noticed
30
+ * within two of these (one ping unanswered), and a slept Mac on its first tick after waking.
31
+ */
32
+ export const LINK_HEARTBEAT_MS = 30_000;
20
33
  import { readEnvFile } from "cursedops/worker-deploy";
21
34
 
22
35
  export interface LinkSettings {
@@ -129,11 +142,24 @@ export interface DialOptions {
129
142
  /** Sent right after each connect — station's `warm` frame. */
130
143
  onOpen?: (socket: DialSocket) => void;
131
144
  log?: (line: string) => void;
145
+ /** Default {@link LINK_HEARTBEAT_MS}; 0 turns the heartbeat off (a test of the redial alone). */
146
+ heartbeatMs?: number;
132
147
  /** Tests only. */
133
148
  openSocket?: (url: string, headers: Record<string, string>) => DialSocket;
134
149
  setTimer?: (fn: () => void, ms: number) => unknown;
150
+ /** Tests only — a repeating timer; returns its cancel. */
151
+ every?: (fn: () => void, ms: number) => () => void;
152
+ /** Tests only — the wall clock the sleep detector reads. */
153
+ now?: () => number;
135
154
  }
136
155
 
156
+ const repeat = (fn: () => void, ms: number): (() => void) => {
157
+ const timer = setInterval(fn, ms);
158
+ // Never the reason the process stays up — the socket is.
159
+ (timer as { unref?: () => void }).unref?.();
160
+ return () => clearInterval(timer);
161
+ };
162
+
137
163
  export interface DialedLink {
138
164
  /** Stop reconnecting and close the socket. */
139
165
  close(): void;
@@ -158,25 +184,75 @@ export function dialLink(options: DialOptions): DialedLink {
158
184
  const log = options.log ?? ((line: string) => console.log(line));
159
185
  const open = options.openSocket ?? bunSocket;
160
186
  const later = options.setTimer ?? ((fn: () => void, ms: number) => setTimeout(fn, ms));
187
+ const every = options.every ?? repeat;
188
+ const now = options.now ?? Date.now;
189
+ const heartbeatMs = options.heartbeatMs ?? LINK_HEARTBEAT_MS;
161
190
  let backoff = 1_000;
162
191
  let stopped = false;
163
192
  let current: DialSocket | null = null;
164
193
  let isOpen = false;
194
+ let stopHeartbeat: (() => void) | null = null;
165
195
  const connect = () => {
166
196
  if (stopped) return;
167
197
  const socket = open(options.settings.url, { ...(options.headers ?? {}), authorization: `Bearer ${options.settings.key}` });
168
198
  current = socket;
199
+ /**
200
+ * Give up on THIS socket and dial a new one now. 🔴 Never by waiting for its `close`: a
201
+ * half-open socket's close handshake waits on a peer that is gone, which is the whole
202
+ * failure. Its late events are ignored because it is no longer `current`.
203
+ */
204
+ const abandon = (why: string) => {
205
+ if (current !== socket) return;
206
+ stopHeartbeat?.();
207
+ stopHeartbeat = null;
208
+ isOpen = false;
209
+ current = null;
210
+ log(`[${options.app}-link] ✗ ${why}; redialing now`);
211
+ try {
212
+ socket.close(4001, why.slice(0, 120));
213
+ } catch {}
214
+ backoff = 1_000;
215
+ connect();
216
+ };
217
+ let awaitingPong = false;
169
218
  socket.addEventListener("open", () => {
219
+ if (current !== socket) return;
170
220
  backoff = 1_000;
171
221
  isOpen = true;
172
222
  log(`[${options.app}-link] ✓ linked to ${origin} — answering its requests`);
173
223
  options.onOpen?.(socket);
224
+ if (heartbeatMs > 0) {
225
+ let lastTick = now();
226
+ stopHeartbeat = every(() => {
227
+ const at = now();
228
+ const late = at - lastTick;
229
+ lastTick = at;
230
+ // The timer could not fire because the machine was asleep — whatever the socket
231
+ // says, the Worker dropped its end long ago (the owner's power cycle, 2026-09-25).
232
+ if (late > heartbeatMs * 3) return abandon(`no tick for ${Math.round(late / 1000)}s — this Mac slept`);
233
+ if (awaitingPong) return abandon(`the Worker did not answer a ping within ${heartbeatMs / 1000}s — the socket is half-open`);
234
+ awaitingPong = true;
235
+ try {
236
+ socket.send(LINK_PING);
237
+ } catch (error) {
238
+ abandon(`a ping could not be sent (${(error as Error).message})`);
239
+ }
240
+ }, heartbeatMs);
241
+ }
174
242
  });
175
243
  socket.addEventListener("message", async (event) => {
176
- const reply = await answerFrame(options.fetch, String(event.data), origin, options.app);
244
+ if (current !== socket) return;
245
+ // ANY frame proves the far end is there; a pong carries nothing else.
246
+ awaitingPong = false;
247
+ const text = String(event.data);
248
+ if (text === LINK_PONG) return;
249
+ const reply = await answerFrame(options.fetch, text, origin, options.app);
177
250
  if (reply) socket.send(JSON.stringify(reply));
178
251
  });
179
252
  socket.addEventListener("close", (event) => {
253
+ if (current !== socket) return;
254
+ stopHeartbeat?.();
255
+ stopHeartbeat = null;
180
256
  isOpen = false;
181
257
  if (stopped) return;
182
258
  log(`[${options.app}-link] link closed (${event.code}${event.reason ? ` ${event.reason}` : ""}); reconnecting in ${backoff / 1000}s`);
@@ -191,6 +267,8 @@ export function dialLink(options: DialOptions): DialedLink {
191
267
  return {
192
268
  close() {
193
269
  stopped = true;
270
+ stopHeartbeat?.();
271
+ stopHeartbeat = null;
194
272
  current?.close(1000, "closed by the Mac");
195
273
  },
196
274
  connected: () => isOpen,
@@ -38,7 +38,8 @@
38
38
  * check green and its owner unable to sign in; the stage walk is what found why, and a step
39
39
  * in the sequence is one a person in a hurry cannot skip;
40
40
  * 5. build → 6. **schema** (`IF NOT EXISTS`, the database named explicitly, never the binding a
41
- * forgotten `--env` would resolve) → 7. **deploy with the `--var` stamp** (commit AND dirty
41
+ * forgotten `--env` would resolve; ordinary `--command` queries, never the blocking
42
+ * `--file` import — {@link schemaApplyArgv}) → 7. **deploy with the `--var` stamp** (commit AND dirty
42
43
  * flag, so `/healthz` publishes what is running) → 8. **secrets** (after the deploy: a secret
43
44
  * needs a script to attach to) → 9. **smoke**.
44
45
  *
@@ -238,6 +239,8 @@ export interface DeployDeps {
238
239
  run: (argv: readonly string[], env: Record<string, string>) => number;
239
240
  /** `git <args>` in the checkout; trimmed stdout, `""` on failure. */
240
241
  git: (args: readonly string[]) => string;
242
+ /** A file in the checkout, as text — the schema. Default: `readFileSync` against the process's cwd. */
243
+ read?: (path: string) => string;
241
244
  log?: (line: string) => void;
242
245
  error?: (line: string) => void;
243
246
  }
@@ -260,13 +263,124 @@ export interface DeployResult {
260
263
  export function workerDeployDeps(cwd: string): DeployDeps {
261
264
  return {
262
265
  run: (argv, env) => {
263
- console.log(` $ ${argv.join(" ")}`);
266
+ // a schema `--command` is kilobytes of DDL — the echo names it, it does not reprint it
267
+ console.log(` $ ${argv.map((arg) => (arg.length > 160 ? `${arg.slice(0, 120)}… (${arg.length} chars)` : arg)).join(" ")}`);
264
268
  return spawnSync(argv[0] as string, argv.slice(1), { cwd, stdio: "inherit", env: { ...process.env, ...env } }).status ?? 1;
265
269
  },
266
270
  git: (args) => (spawnSync("git", [...args], { cwd, encoding: "utf8" }).stdout ?? "").trim(),
271
+ read: (path) => readFileSync(join(cwd, path), "utf8"),
267
272
  };
268
273
  }
269
274
 
275
+ /**
276
+ * The statements of a schema file, each on one line with its comments gone — split on the `;`s
277
+ * that end a statement, never on one inside a string, a quoted name, a comment, or a trigger's
278
+ * `BEGIN … END` / an expression's `CASE … END`. Hand-written schemas (patterns') format a
279
+ * CREATE TABLE across lines; generated ones (`d1SchemaText`) are already one per line.
280
+ */
281
+ export function sqlStatements(text: string): string[] {
282
+ const statements: string[] = [];
283
+ let current = "";
284
+ let depth = 0;
285
+ let word = "";
286
+ const endWord = (): void => {
287
+ const w = word.toUpperCase();
288
+ word = "";
289
+ if (w === "CASE") depth++;
290
+ else if (w === "BEGIN" && /^\s*CREATE\s+(TEMP\s+|TEMPORARY\s+)?TRIGGER\b/i.test(current)) depth++;
291
+ else if (w === "END" && depth > 0) depth--;
292
+ };
293
+ for (let i = 0; i < text.length; i++) {
294
+ const c = text[i] as string;
295
+ if (/[A-Za-z_]/.test(c)) {
296
+ word += c;
297
+ current += c;
298
+ continue;
299
+ }
300
+ if (word) endWord();
301
+ if (c === "-" && text[i + 1] === "-") {
302
+ while (i < text.length && text[i] !== "\n") i++;
303
+ current += " ";
304
+ continue;
305
+ }
306
+ if (c === "/" && text[i + 1] === "*") {
307
+ const close = text.indexOf("*/", i + 2);
308
+ if (close < 0) throw new Error("an unterminated /* comment");
309
+ i = close + 1;
310
+ current += " ";
311
+ continue;
312
+ }
313
+ if (c === "'" || c === '"' || c === "`" || c === "[") {
314
+ const closer = c === "[" ? "]" : c;
315
+ let j = i + 1;
316
+ for (;;) {
317
+ const at = text.indexOf(closer, j);
318
+ if (at < 0) throw new Error(`an unterminated ${c} quote`);
319
+ // SQL escapes a quote by doubling it: 'it''s'
320
+ if (closer !== "]" && text[at + 1] === closer) {
321
+ j = at + 2;
322
+ continue;
323
+ }
324
+ j = at;
325
+ break;
326
+ }
327
+ current += text.slice(i, j + 1);
328
+ i = j;
329
+ continue;
330
+ }
331
+ if (c === ";" && depth === 0) {
332
+ const statement = current.replace(/\s+/g, " ").trim();
333
+ if (statement) statements.push(statement);
334
+ current = "";
335
+ continue;
336
+ }
337
+ current += /\s/.test(c) ? " " : c;
338
+ }
339
+ if (word) endWord();
340
+ const tail = current.replace(/\s+/g, " ").trim();
341
+ if (tail) statements.push(tail);
342
+ return statements;
343
+ }
344
+
345
+ /**
346
+ * D1's limit on one SQL statement is 100 KB; a command is kept well under it (and under any argv
347
+ * ceiling) by starting a new one before it would cross this many bytes.
348
+ */
349
+ export const SCHEMA_COMMAND_BYTES = 90_000;
350
+
351
+ /** {@link sqlStatements}, packed into as few `--command` strings as {@link SCHEMA_COMMAND_BYTES} allows. */
352
+ export function schemaCommands(text: string, maxBytes: number = SCHEMA_COMMAND_BYTES): string[] {
353
+ const commands: string[] = [];
354
+ let current = "";
355
+ for (const statement of sqlStatements(text)) {
356
+ const next = current ? `${current}; ${statement}` : statement;
357
+ if (current && Buffer.byteLength(`${next};`) > maxBytes) {
358
+ commands.push(`${current};`);
359
+ current = statement;
360
+ } else current = next;
361
+ }
362
+ if (current) commands.push(`${current};`);
363
+ const over = commands.find((command) => Buffer.byteLength(command) > maxBytes);
364
+ if (over) throw new Error(`one statement is over ${maxBytes} bytes, past what D1 takes in a query: ${over.slice(0, 80)}…`);
365
+ return commands;
366
+ }
367
+
368
+ /**
369
+ * The schema step's argv — ordinary queries, `wrangler d1 execute --remote --command`, one per
370
+ * {@link schemaCommands} chunk.
371
+ *
372
+ * 🔴 NEVER `--file`. With `--remote`, `--file` goes through D1's IMPORT API, and wrangler says what
373
+ * that costs: "your D1 database will be unavailable to serve queries" for the length of it — the
374
+ * same blocking class as `wrangler d1 export`, which failed 72 of 198 concurrent family reads on
375
+ * 2026-09-24 (`pullD1ToSqlite`'s header). This step runs on every production deploy of every
376
+ * Worker app with a schema, and the schema is idempotent, so the import blocked production to
377
+ * change nothing. `--command` runs through the query API like any request the Worker makes.
378
+ * `workerDeploy.test.ts` pins that no argv here ever carries `--file`.
379
+ */
380
+ export function schemaApplyArgv(databaseName: string, commands: readonly string[], envArgs: readonly string[]): string[][] {
381
+ return commands.map((command) => ["bunx", "wrangler", "d1", "execute", databaseName, "--remote", "--command", command, "-y", ...envArgs]);
382
+ }
383
+
270
384
  /**
271
385
  * Run the sequence in the header's order, stopping at the first red with a sentence. Never
272
386
  * exits — the caller does, with {@link DeployResult.code}.
@@ -319,8 +433,15 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
319
433
  if (spec.databaseName) {
320
434
  const schema = spec.schemaFile ?? "db/schema.sql";
321
435
  step(`apply ${schema} to D1 \`${spec.databaseName}\``);
322
- const applied = deps.run(["bunx", "wrangler", "d1", "execute", spec.databaseName, "--remote", "--file", schema, "-y", ...envArgs], spec.credential);
323
- if (applied !== 0) return stop("schema", "the schema did not apply — nothing was deployed.");
436
+ let commands: string[];
437
+ try {
438
+ commands = schemaCommands((deps.read ?? ((path: string) => readFileSync(path, "utf8")))(schema));
439
+ } catch (cause) {
440
+ return stop("schema", `${schema} could not be read as statements (${(cause as Error).message}) — nothing was deployed.`);
441
+ }
442
+ for (const argv of schemaApplyArgv(spec.databaseName, commands, envArgs)) {
443
+ if (deps.run(argv, spec.credential) !== 0) return stop("schema", "the schema did not apply — nothing was deployed.");
444
+ }
324
445
  }
325
446
  step(`deploy ${spec.workerName} @ ${commit.slice(0, 8)}${dirty ? " (dirty — stage only)" : ""}`);
326
447
  const stamp = spec.stampVars