cursedops 0.12.1 → 0.13.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/README.md CHANGED
@@ -17,6 +17,7 @@ bun add cursedops
17
17
  | `cursedops/worker-secrets` | a Worker holding EXACTLY its deployment's secrets, uploaded over a pipe, read back (0.5.0) — and read again, up to five times, before a set that is not exact is believed: the list lags the puts (0.11.1) |
18
18
  | `cursedops/worker-rollback` | a hostname back on its Mac origin: origin first, route second, the route found rather than typed (0.5.0) |
19
19
  | `cursedops/edge-fetch` | a request to a deployed Worker as a script must make it — curl pinned past the Mac's negative DNS cache (0.5.0); a request's own `maxTimeSec` over the instance's (0.11.1) |
20
+ | `cursedops/d1-columns` | the schema step's first half: any COLUMN `db/schema.sql` has and a live D1 table lacks, read in one `pragma_table_info` query and added by `ALTER TABLE … ADD COLUMN` — refusing a key, UNIQUE, or NOT NULL-without-DEFAULT column. `runWorkerDeploy` runs it for every app with a `databaseName` (0.13.0, task 2488, lifted from roms) |
20
21
  | `cursedops/d1-import` | a cutover's data proof — SQL literals and the row-for-row comparison, never a count (0.5.0); and `d1SchemaText`, the generated `db/schema.sql` that refuses a statement spanning lines, because D1's exec splits on newlines (0.9.0, lifted from five apps, task 2153) — and its `forge-d1-schema` bin (0.9.1), so no app carries its own schema script at all |
21
22
  | `cursedops/backups` | `BACKUP_KEEP`, the fleet's one retention for dated snapshots — six apps each exported `KEEP = 14` (0.9.0, task 2153); `BACKUP_STAMP_PATTERN`, `backupStamp` and its inverse `stampTime` — the local-time run name every backup script writes and station's audit reads (0.11.2) |
22
23
  | `cursedops/serve` | the static tier's four helpers — the path-traversal guard, the MIME table, the hashed-asset test, the crash handlers |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedops",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
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": {
@@ -97,6 +97,12 @@
97
97
  "source": "./src/edgeFetch.ts",
98
98
  "import": "./src/edgeFetch.ts"
99
99
  },
100
+ "./d1-columns": {
101
+ "types": "./src/d1Columns.ts",
102
+ "bun": "./src/d1Columns.ts",
103
+ "source": "./src/d1Columns.ts",
104
+ "import": "./src/d1Columns.ts"
105
+ },
100
106
  "./d1-import": {
101
107
  "types": "./src/d1Import.ts",
102
108
  "bun": "./src/d1Import.ts",
@@ -0,0 +1,111 @@
1
+ /**
2
+ * A COLUMN added to a store reaches a live D1 — the step `db/schema.sql` cannot take.
3
+ *
4
+ * `runWorkerDeploy` applies `db/schema.sql` on every ship, and every line is `CREATE … IF NOT
5
+ * EXISTS`: right for a new table, and a silent no-op for a new column on a table D1 already has.
6
+ * roms found it first (task 2208: `rom_uploads.held_parts`, every upload a 500) and fixed it in the
7
+ * app; music found it again on 2026-10-01 (task 2488: `tracks.audio_hash` reached production by hand,
8
+ * never `music-stage`, and the schema's index on it stopped the ship). So the step lives here, in the
9
+ * one deploy every D1 app runs, for whatever `spec.databaseName` is.
10
+ *
11
+ * Before the schema applies, each table's live columns are read in ONE ordinary query
12
+ * ({@link liveColumnsQueries} — `pragma_table_info`, which D1's query API serves) and compared with
13
+ * the schema file's; a missing one is added. Only what `ALTER TABLE … ADD COLUMN` can do safely: a
14
+ * column that is a key, UNIQUE, or NOT NULL without a DEFAULT is REFUSED — that is a real migration,
15
+ * and the deploy stops rather than half-apply it. A table D1 does not have yet is left to the schema
16
+ * file, which creates it whole.
17
+ */
18
+
19
+ export interface SchemaTable {
20
+ table: string;
21
+ /** Each column's name and its whole definition, as the CREATE TABLE line gives it. */
22
+ columns: Array<{ name: string; definition: string }>;
23
+ }
24
+
25
+ /** Split a column list on its top-level commas (a `CHECK (a IN ('x','y'))` holds commas of its own). */
26
+ function topLevelParts(body: string): string[] {
27
+ const parts: string[] = [];
28
+ let depth = 0;
29
+ let quoted = false;
30
+ let current = "";
31
+ for (const ch of body) {
32
+ if (ch === "'") quoted = !quoted;
33
+ if (!quoted && ch === "(") depth++;
34
+ if (!quoted && ch === ")") depth--;
35
+ if (!quoted && depth === 0 && ch === ",") {
36
+ parts.push(current.trim());
37
+ current = "";
38
+ } else current += ch;
39
+ }
40
+ if (current.trim()) parts.push(current.trim());
41
+ return parts;
42
+ }
43
+
44
+ const CONSTRAINT = /^(PRIMARY\s+KEY|UNIQUE|CHECK|FOREIGN\s+KEY|CONSTRAINT)\b/i;
45
+
46
+ /** Every `CREATE TABLE` in a schema file (one statement per line, as `d1SchemaText` writes it), with its columns. */
47
+ export function schemaTables(schemaSql: string): SchemaTable[] {
48
+ const tables: SchemaTable[] = [];
49
+ for (const line of schemaSql.split("\n")) {
50
+ const match = /^CREATE TABLE IF NOT EXISTS\s+("?)(\w+)\1\s*\((.*)\)\s*;?\s*$/i.exec(line.trim());
51
+ if (!match) continue;
52
+ const columns = topLevelParts(match[3] as string)
53
+ .filter((part) => !CONSTRAINT.test(part))
54
+ .map((definition) => ({ name: (definition.split(/\s+/, 1)[0] as string).replace(/"/g, ""), definition }));
55
+ tables.push({ table: match[2] as string, columns });
56
+ }
57
+ return tables;
58
+ }
59
+
60
+ /**
61
+ * The read: `SELECT '<t>' AS t, name FROM pragma_table_info('<t>') UNION ALL …`, packed into as few
62
+ * queries as `maxBytes` allows — one wrangler call for a whole schema, not one per table.
63
+ */
64
+ export function liveColumnsQueries(tables: readonly SchemaTable[], maxBytes: number): string[] {
65
+ const queries: string[] = [];
66
+ let current = "";
67
+ for (const { table } of tables) {
68
+ const part = `SELECT '${table}' AS t, name FROM pragma_table_info('${table}')`;
69
+ const next = current ? `${current} UNION ALL ${part}` : part;
70
+ if (current && Buffer.byteLength(`${next};`) > maxBytes) {
71
+ queries.push(`${current};`);
72
+ current = part;
73
+ } else current = next;
74
+ }
75
+ if (current) queries.push(`${current};`);
76
+ return queries;
77
+ }
78
+
79
+ /** {@link liveColumnsQueries}' rows, as table → its column names. A table D1 lacks has no rows, so no entry. */
80
+ export function liveColumns(rows: ReadonlyArray<Record<string, unknown>>): Map<string, Set<string>> {
81
+ const live = new Map<string, Set<string>>();
82
+ for (const row of rows) {
83
+ const table = String(row.t);
84
+ const names = live.get(table) ?? new Set<string>();
85
+ names.add(String(row.name));
86
+ live.set(table, names);
87
+ }
88
+ return live;
89
+ }
90
+
91
+ /**
92
+ * The `ALTER TABLE … ADD COLUMN` statements that bring `live` up to the schema. `live` maps a table
93
+ * to its column names; a table absent from it is skipped (the schema file creates it). Throws on a
94
+ * column ADD COLUMN cannot add safely — see the header.
95
+ */
96
+ export function missingColumnStatements(tables: readonly SchemaTable[], live: Map<string, Set<string>>): string[] {
97
+ const statements: string[] = [];
98
+ for (const { table, columns } of tables) {
99
+ const have = live.get(table);
100
+ if (!have || have.size === 0) continue;
101
+ for (const { name, definition } of columns) {
102
+ if (have.has(name)) continue;
103
+ const upper = definition.toUpperCase();
104
+ if (/\bPRIMARY\s+KEY\b|\bUNIQUE\b/.test(upper) || (/\bNOT\s+NULL\b/.test(upper) && !/\bDEFAULT\b/.test(upper))) {
105
+ throw new Error(`${table}.${name} (${definition}) is missing from D1 and cannot be added by ADD COLUMN — write a real migration`);
106
+ }
107
+ statements.push(`ALTER TABLE ${table} ADD COLUMN ${definition}`);
108
+ }
109
+ }
110
+ return statements;
111
+ }
@@ -39,7 +39,8 @@
39
39
  * 4. **the STAGE first, for production** — `collections` went live 2026-09-22 with every anonymous
40
40
  * check green and its owner unable to sign in; the stage walk is what found why, and a step
41
41
  * in the sequence is one a person in a hurry cannot skip;
42
- * 5. build → 6. **schema** (`IF NOT EXISTS`, the database named explicitly, never the binding a
42
+ * 5. build → 6. **schema** (first any COLUMN a live table lacks — `cursedops/d1-columns`, since
43
+ * `IF NOT EXISTS` never adds one — then `IF NOT EXISTS`, the database named explicitly, never the binding a
43
44
  * forgotten `--env` would resolve; ordinary `--command` queries, never the blocking
44
45
  * `--file` import — {@link schemaApplyArgv}) → 7. **deploy with the `--var` stamp** (commit AND dirty
45
46
  * flag, so `/healthz` publishes what is running) → 8. **secrets** (after the deploy: a secret
@@ -55,6 +56,8 @@
55
56
  import { spawn, spawnSync } from "node:child_process";
56
57
  import { existsSync, readdirSync, readFileSync } from "node:fs";
57
58
  import { join } from "node:path";
59
+ import { liveColumns, liveColumnsQueries, missingColumnStatements, schemaTables } from "cursedops/d1-columns";
60
+ import { wranglerRows } from "cursedops/d1-import";
58
61
  import { forgeCodeRoot, forgeState } from "cursedops/roots";
59
62
  import { appendLedger, deferral, readShipSetting, type ShipEventLine, type ShipNowWhere, type ShipSetting } from "cursedops/ship";
60
63
 
@@ -818,23 +821,53 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
818
821
  step(`apply ${schema} to D1 \`${spec.databaseName}\``);
819
822
  enter("schema");
820
823
  let commands: string[];
824
+ let text: string;
821
825
  try {
822
- commands = schemaCommands((deps.read ?? ((path: string) => readFileSync(path, "utf8")))(schema));
826
+ text = (deps.read ?? ((path: string) => readFileSync(path, "utf8")))(schema);
827
+ commands = schemaCommands(text);
823
828
  } catch (cause) {
824
829
  return stop("schema", `${schema} could not be read as statements (${(cause as Error).message}) — nothing was deployed.`);
825
830
  }
826
- for (const argv of schemaApplyArgv(spec.databaseName, commands, envArgs)) {
827
- // a silent exit 0 and a Cloudflare API error are intermittent and the schema is idempotent,
828
- // so ONE retry; any other failure is not retried — it would fail the same way
829
- const attempt = (): ReturnType<typeof judgeSchemaApply> => {
831
+ // a silent exit 0 and a Cloudflare API error are intermittent and every query here is
832
+ // idempotent, so ONE retry; any other failure is not retried — it would fail the same way
833
+ const query = (argv: readonly string[]): ReturnType<typeof judgeSchemaApply> & { stdout: string } => {
834
+ const attempt = () => {
830
835
  const { code, stdout } = deps.capture(argv, spec.credential);
831
- return judgeSchemaApply(code, stdout);
836
+ return { ...judgeSchemaApply(code, stdout), stdout };
832
837
  };
833
838
  let verdict = attempt();
834
839
  if (!verdict.applied && verdict.retry) {
835
840
  error(` 🟡 ${verdict.line} — retrying once`);
836
841
  verdict = attempt();
837
842
  }
843
+ return verdict;
844
+ };
845
+ // Columns first (task 2488): `IF NOT EXISTS` never adds one to a table D1 already has, and the
846
+ // schema below may index it (`./d1Columns.ts`).
847
+ const tables = schemaTables(text);
848
+ if (tables.length > 0) {
849
+ const rows: Array<Record<string, unknown>> = [];
850
+ for (const argv of schemaApplyArgv(spec.databaseName, liveColumnsQueries(tables, SCHEMA_COMMAND_BYTES), envArgs)) {
851
+ const verdict = query(argv);
852
+ if (!verdict.applied) return stop("schema", `D1's live columns could not be read (${verdict.line}) — nothing was deployed.`);
853
+ rows.push(...wranglerRows(verdict.stdout));
854
+ }
855
+ let added: string[];
856
+ try {
857
+ added = missingColumnStatements(tables, liveColumns(rows));
858
+ } catch (cause) {
859
+ return stop("schema", `${(cause as Error).message} — nothing was deployed.`);
860
+ }
861
+ if (added.length > 0) {
862
+ for (const argv of schemaApplyArgv(spec.databaseName, schemaCommands(added.map((sql) => `${sql};`).join("\n")), envArgs)) {
863
+ const verdict = query(argv);
864
+ if (!verdict.applied) return stop("schema", `the missing columns were not added (${verdict.line}) — nothing was deployed.`);
865
+ }
866
+ log(` added ${added.length} column(s): ${added.join("; ")}`);
867
+ } else log(` every column ${schema} names is in D1 (or its table is new)`);
868
+ }
869
+ for (const argv of schemaApplyArgv(spec.databaseName, commands, envArgs)) {
870
+ const verdict = query(argv);
838
871
  if (!verdict.applied) return stop("schema", `the schema did not apply (${verdict.line}) — nothing was deployed.`);
839
872
  log(` ${verdict.line}`);
840
873
  }