cursedbelt-server 4.21.0 → 4.23.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 (44) hide show
  1. package/dist/server/bench/budget.d.ts +50 -0
  2. package/dist/server/bench/budget.js +49 -1
  3. package/dist/server/bench/index.d.ts +1 -1
  4. package/dist/server/bench/index.js +1 -1
  5. package/dist/server/d1/localBackup.d.ts +1 -1
  6. package/dist/server/d1/localBackup.js +1 -1
  7. package/dist/server/d1/pullD1.d.ts +24 -0
  8. package/dist/server/d1/pullD1.js +114 -40
  9. package/dist/server/master-lock/accountsPage.js +7 -3
  10. package/dist/server/master-lock/lockPage.d.ts +20 -1
  11. package/dist/server/master-lock/lockPage.js +53 -2
  12. package/dist/server/metrics/metricsBuffer.d.ts +10 -0
  13. package/dist/server/metrics/metricsBuffer.js +11 -3
  14. package/dist/server/metrics/requestPlace.d.ts +48 -0
  15. package/dist/server/metrics/requestPlace.js +77 -0
  16. package/dist/server/metrics/telemetrySink.d.ts +6 -1
  17. package/dist/server/metrics/telemetrySink.js +14 -2
  18. package/dist/server/middleware/requestLogger.d.ts +8 -0
  19. package/dist/server/middleware/requestLogger.js +4 -1
  20. package/dist/server/requestLog/requestMetrics.d.ts +11 -2
  21. package/dist/server/requestLog/requestMetrics.js +22 -4
  22. package/dist/server/telemetry.d.ts +1 -0
  23. package/dist/server/telemetry.js +1 -0
  24. package/package.json +3 -3
  25. package/src/server/bench/budget.spec.ts +38 -0
  26. package/src/server/bench/budget.ts +58 -1
  27. package/src/server/bench/cpuBudget.spec.ts +15 -3
  28. package/src/server/bench/index.ts +1 -0
  29. package/src/server/d1/localBackup.ts +4 -0
  30. package/src/server/d1/pullD1.spec.ts +62 -2
  31. package/src/server/d1/pullD1.ts +111 -42
  32. package/src/server/master-lock/accountsPage.ts +7 -3
  33. package/src/server/master-lock/lockPage.spec.ts +62 -2
  34. package/src/server/master-lock/lockPage.ts +58 -2
  35. package/src/server/metrics/metricsBuffer.ts +22 -2
  36. package/src/server/metrics/requestPlace.spec.ts +142 -0
  37. package/src/server/metrics/requestPlace.ts +92 -0
  38. package/src/server/metrics/telemetrySink.spec.ts +2 -1
  39. package/src/server/metrics/telemetrySink.ts +14 -2
  40. package/src/server/middleware/requestLogger.ts +12 -1
  41. package/src/server/requestLog/requestMetrics.spec.ts +73 -0
  42. package/src/server/requestLog/requestMetrics.ts +37 -4
  43. package/src/server/telemetry.ts +5 -0
  44. package/src/server/telemetryIsWorkerSafe.spec.ts +18 -0
@@ -74,6 +74,47 @@ export declare const DAYS_PER_MONTH = 30.4375;
74
74
  * the default may never drift ABOVE the measured allowance without a red build.
75
75
  */
76
76
  export declare const MEASURED_FLEET_REQUESTS_PER_DAY = 42971;
77
+ /**
78
+ * 🔴 The WORKER re-derivation — 2026-09-23 (task 083, bullet 3). Cloudflare's own numbers,
79
+ * not a proxy: the account's `workersInvocationsAdaptive` dataset (GraphQL Analytics API,
80
+ * `sum { requests cpuTimeUs }` by `scriptName`) over the seven whole days
81
+ * 2026-09-16T00:00Z → 2026-09-23T00:00Z, while `patterns` (since 09-18) and `collections`
82
+ * (since 09-22) served from Workers:
83
+ *
84
+ * ```
85
+ * script requests billed CPU-ms mean
86
+ * binary-server-edge-gate 754,974 1,805,750 2.39
87
+ * collections 350 171,228 489.2 (09-22: p99 15.2 s — one-off imports)
88
+ * collections-stage 61 96,081 1575 (stage: seeding and gate runs)
89
+ * patterns 424 12,718 30.0 (09-18: p99 2.9 s, first boot)
90
+ * 3 others 196 126
91
+ * ACCOUNT 756,005 2,085,903 2.76 → 108,001 req/day, 9.07 M CPU-ms/month (30 %)
92
+ * ```
93
+ *
94
+ * **What the call says.** `deriveCpuBudgetMs({ requestsPerDay: 108_001 })` = **9.1**, i.e.
95
+ * BELOW the shipped 9.9 — the account already carries more requests than the owner's
96
+ * 100,000/day projection. **The default did not move, and this is why:** 99.9 % of those
97
+ * requests are the binary server's edge gate, a fixed-cost auth check at 2.39 CPU-ms mean
98
+ * that no app route budget governs. Averaging them in hands the app routes an allowance
99
+ * those requests never use. The question the default answers is what an APP request may
100
+ * spend once the measured non-app spend is set aside:
101
+ * `deriveCpuBudgetMs({ requestsPerDay: MEASURED_FLEET_REQUESTS_PER_DAY, reservedCpuMsPerMonth: edge gate })`
102
+ * = **16.9** CPU-ms — the fleet's whole app traffic (42,971/day, above) on Workers, with the
103
+ * edge gate still running beside it. 9.9 stays under that with 1.7× headroom, and the
104
+ * account is at 30 % of its CPU allowance, so there is no evidence for tightening it either.
105
+ * App-Worker traffic itself (~111/day, mostly agent deploy checks) is too small to derive
106
+ * anything from yet. `budget.spec.ts` holds both orderings as assertions.
107
+ */
108
+ export declare const MEASURED_WORKER_TRAFFIC: {
109
+ readonly from: "2026-09-16T00:00:00Z";
110
+ readonly until: "2026-09-23T00:00:00Z";
111
+ readonly days: 7;
112
+ readonly requests: 756005;
113
+ readonly cpuMs: 2085903;
114
+ /** The binary server's edge gate — fixed-cost traffic no app route budget governs. */
115
+ readonly edgeGateRequests: 754974;
116
+ readonly edgeGateCpuMs: 1805750;
117
+ };
77
118
  /**
78
119
  * Derive the average CPU-ms a single request may spend before the fleet exceeds its
79
120
  * included CPU allowance.
@@ -90,6 +131,12 @@ export declare function deriveCpuBudgetMs(opts?: {
90
131
  requestsPerMonth?: number;
91
132
  /** Measured (or projected) requests per day — converted with {@link DAYS_PER_MONTH}. */
92
133
  requestsPerDay?: number;
134
+ /**
135
+ * CPU-ms per month already spent by traffic OUTSIDE the budgeted routes (measured —
136
+ * the edge gate in {@link MEASURED_WORKER_TRAFFIC}), taken off the allowance first.
137
+ * Default: 0.
138
+ */
139
+ reservedCpuMsPerMonth?: number;
93
140
  }): number;
94
141
  /**
95
142
  * What a month costs in overage once the included allowances are spent. Used by the
@@ -112,6 +159,9 @@ export declare function monthlyOverageUsd(opts: {
112
159
  *
113
160
  * 🔴 Deliberately kept BELOW the ~22.9 ms that {@link MEASURED_FLEET_REQUESTS_PER_DAY}
114
161
  * derives; that constant says why, and `budget.spec.ts` reddens if the two ever swap.
162
+ *
163
+ * Re-derived from WORKER traffic on 2026-09-23 and kept: {@link MEASURED_WORKER_TRAFFIC}
164
+ * has the numbers (16.9 ms per app request after the edge gate's measured spend).
115
165
  */
116
166
  export declare const DEFAULT_ROUTE_CPU_BUDGET_MS: number;
117
167
  /** A route that is measured against a number. */
@@ -74,6 +74,47 @@ export const DAYS_PER_MONTH = 30.4375;
74
74
  * the default may never drift ABOVE the measured allowance without a red build.
75
75
  */
76
76
  export const MEASURED_FLEET_REQUESTS_PER_DAY = 42_971;
77
+ /**
78
+ * 🔴 The WORKER re-derivation — 2026-09-23 (task 083, bullet 3). Cloudflare's own numbers,
79
+ * not a proxy: the account's `workersInvocationsAdaptive` dataset (GraphQL Analytics API,
80
+ * `sum { requests cpuTimeUs }` by `scriptName`) over the seven whole days
81
+ * 2026-09-16T00:00Z → 2026-09-23T00:00Z, while `patterns` (since 09-18) and `collections`
82
+ * (since 09-22) served from Workers:
83
+ *
84
+ * ```
85
+ * script requests billed CPU-ms mean
86
+ * binary-server-edge-gate 754,974 1,805,750 2.39
87
+ * collections 350 171,228 489.2 (09-22: p99 15.2 s — one-off imports)
88
+ * collections-stage 61 96,081 1575 (stage: seeding and gate runs)
89
+ * patterns 424 12,718 30.0 (09-18: p99 2.9 s, first boot)
90
+ * 3 others 196 126
91
+ * ACCOUNT 756,005 2,085,903 2.76 → 108,001 req/day, 9.07 M CPU-ms/month (30 %)
92
+ * ```
93
+ *
94
+ * **What the call says.** `deriveCpuBudgetMs({ requestsPerDay: 108_001 })` = **9.1**, i.e.
95
+ * BELOW the shipped 9.9 — the account already carries more requests than the owner's
96
+ * 100,000/day projection. **The default did not move, and this is why:** 99.9 % of those
97
+ * requests are the binary server's edge gate, a fixed-cost auth check at 2.39 CPU-ms mean
98
+ * that no app route budget governs. Averaging them in hands the app routes an allowance
99
+ * those requests never use. The question the default answers is what an APP request may
100
+ * spend once the measured non-app spend is set aside:
101
+ * `deriveCpuBudgetMs({ requestsPerDay: MEASURED_FLEET_REQUESTS_PER_DAY, reservedCpuMsPerMonth: edge gate })`
102
+ * = **16.9** CPU-ms — the fleet's whole app traffic (42,971/day, above) on Workers, with the
103
+ * edge gate still running beside it. 9.9 stays under that with 1.7× headroom, and the
104
+ * account is at 30 % of its CPU allowance, so there is no evidence for tightening it either.
105
+ * App-Worker traffic itself (~111/day, mostly agent deploy checks) is too small to derive
106
+ * anything from yet. `budget.spec.ts` holds both orderings as assertions.
107
+ */
108
+ export const MEASURED_WORKER_TRAFFIC = {
109
+ from: '2026-09-16T00:00:00Z',
110
+ until: '2026-09-23T00:00:00Z',
111
+ days: 7,
112
+ requests: 756_005,
113
+ cpuMs: 2_085_903,
114
+ /** The binary server's edge gate — fixed-cost traffic no app route budget governs. */
115
+ edgeGateRequests: 754_974,
116
+ edgeGateCpuMs: 1_805_750,
117
+ };
77
118
  /**
78
119
  * Derive the average CPU-ms a single request may spend before the fleet exceeds its
79
120
  * included CPU allowance.
@@ -84,7 +125,11 @@ export const MEASURED_FLEET_REQUESTS_PER_DAY = 42_971;
84
125
  * allowed to become the common case.
85
126
  */
86
127
  export function deriveCpuBudgetMs(opts = {}) {
87
- const includedCpuMs = opts.includedCpuMs ?? WORKERS_PAID_INCLUDED_CPU_MS;
128
+ const reserved = opts.reservedCpuMsPerMonth ?? 0;
129
+ const includedCpuMs = (opts.includedCpuMs ?? WORKERS_PAID_INCLUDED_CPU_MS) - reserved;
130
+ if (!(reserved >= 0) || !(includedCpuMs > 0)) {
131
+ throw new Error(`deriveCpuBudgetMs: reservedCpuMsPerMonth (${reserved}) must be ≥ 0 and leave some allowance`);
132
+ }
88
133
  const requestsPerMonth = opts.requestsPerMonth ??
89
134
  (opts.requestsPerDay ?? OWNER_PROJECTED_REQUESTS_PER_DAY) * DAYS_PER_MONTH;
90
135
  if (!(requestsPerMonth > 0)) {
@@ -113,6 +158,9 @@ export function monthlyOverageUsd(opts) {
113
158
  *
114
159
  * 🔴 Deliberately kept BELOW the ~22.9 ms that {@link MEASURED_FLEET_REQUESTS_PER_DAY}
115
160
  * derives; that constant says why, and `budget.spec.ts` reddens if the two ever swap.
161
+ *
162
+ * Re-derived from WORKER traffic on 2026-09-23 and kept: {@link MEASURED_WORKER_TRAFFIC}
163
+ * has the numbers (16.9 ms per app request after the edge gate's measured spend).
116
164
  */
117
165
  export const DEFAULT_ROUTE_CPU_BUDGET_MS = Math.round(deriveCpuBudgetMs() * 10) / 10;
118
166
  export function isExemption(b) {
@@ -33,7 +33,7 @@
33
33
  * alternative is a number that gets quoted as a billing fact.
34
34
  */
35
35
  export { type AssertCpuBudgetsOpts, assertCpuBudgets, checkCpuBudgets, type CpuViolation, type CpuViolationKind, formatCpuBudgetReport, } from './assert.js';
36
- export { type CpuBudgetConfig, type CpuBudgetExemption, type CpuBudgetLimit, DAYS_PER_MONTH, DEFAULT_ROUTE_CPU_BUDGET_MS, deriveCpuBudgetMs, isExemption, MEASURED_FLEET_REQUESTS_PER_DAY, monthlyOverageUsd, OWNER_PROJECTED_REQUESTS_PER_DAY, type ResolvedBudget, resolveBudget, type RouteBudget, validateCpuBudgetConfig, WORKERS_PAID_INCLUDED_CPU_MS, WORKERS_PAID_INCLUDED_REQUESTS, WORKERS_PAID_USD_PER_MILLION_CPU_MS, WORKERS_PAID_USD_PER_MILLION_REQUESTS, } from './budget.js';
36
+ export { type CpuBudgetConfig, type CpuBudgetExemption, type CpuBudgetLimit, DAYS_PER_MONTH, DEFAULT_ROUTE_CPU_BUDGET_MS, deriveCpuBudgetMs, isExemption, MEASURED_FLEET_REQUESTS_PER_DAY, MEASURED_WORKER_TRAFFIC, monthlyOverageUsd, OWNER_PROJECTED_REQUESTS_PER_DAY, type ResolvedBudget, resolveBudget, type RouteBudget, validateCpuBudgetConfig, WORKERS_PAID_INCLUDED_CPU_MS, WORKERS_PAID_INCLUDED_REQUESTS, WORKERS_PAID_USD_PER_MILLION_CPU_MS, WORKERS_PAID_USD_PER_MILLION_REQUESTS, } from './budget.js';
37
37
  export { type CpuBudgetOpts, cpuBudget } from './cpuBudget.js';
38
38
  export { type CpuClock, type CpuSpan, fixedCpuClock, processCpuClock, resolveCpuClock, unavailableCpuClock, workerCpuClock, } from './cpuClock.js';
39
39
  export { type CpuBudgetReport, type CpuRecorder, type CpuRecorderOpts, createCpuRecorder, percentile, type RouteCpuStats, } from './recorder.js';
@@ -33,7 +33,7 @@
33
33
  * alternative is a number that gets quoted as a billing fact.
34
34
  */
35
35
  export { assertCpuBudgets, checkCpuBudgets, formatCpuBudgetReport, } from './assert.js';
36
- export { DAYS_PER_MONTH, DEFAULT_ROUTE_CPU_BUDGET_MS, deriveCpuBudgetMs, isExemption, MEASURED_FLEET_REQUESTS_PER_DAY, monthlyOverageUsd, OWNER_PROJECTED_REQUESTS_PER_DAY, resolveBudget, validateCpuBudgetConfig, WORKERS_PAID_INCLUDED_CPU_MS, WORKERS_PAID_INCLUDED_REQUESTS, WORKERS_PAID_USD_PER_MILLION_CPU_MS, WORKERS_PAID_USD_PER_MILLION_REQUESTS, } from './budget.js';
36
+ export { DAYS_PER_MONTH, DEFAULT_ROUTE_CPU_BUDGET_MS, deriveCpuBudgetMs, isExemption, MEASURED_FLEET_REQUESTS_PER_DAY, MEASURED_WORKER_TRAFFIC, monthlyOverageUsd, OWNER_PROJECTED_REQUESTS_PER_DAY, resolveBudget, validateCpuBudgetConfig, WORKERS_PAID_INCLUDED_CPU_MS, WORKERS_PAID_INCLUDED_REQUESTS, WORKERS_PAID_USD_PER_MILLION_CPU_MS, WORKERS_PAID_USD_PER_MILLION_REQUESTS, } from './budget.js';
37
37
  export { cpuBudget } from './cpuBudget.js';
38
38
  export { fixedCpuClock, processCpuClock, resolveCpuClock, unavailableCpuClock, workerCpuClock, } from './cpuClock.js';
39
39
  export { createCpuRecorder, percentile, } from './recorder.js';
@@ -82,4 +82,4 @@ export declare function createLocalBackup(opts: LocalBackupOpts): DatabaseBackup
82
82
  * a host that genuinely has both.
83
83
  */
84
84
  export declare function backupFor(flavor: 'local' | 'd1', local: () => LocalBackupOpts, remote: () => TimeTravelOpts): DatabaseBackup;
85
- export { type BackupSourceChoice, type BackupSourceInput, backupTables, chooseBackupSource, type PullD1Options, pullD1ToSqlite, servedRuntime, type Wrangler, } from './pullD1.js';
85
+ export { type BackupSourceChoice, type BackupSourceInput, backupTables, chooseBackupSource, type PullD1Options, insertLines, pullD1ToSqlite, servedRuntime, spawnWrangler, stripTrailingNuls, type Wrangler, wranglerArgv, } from './pullD1.js';
@@ -111,4 +111,4 @@ export function backupFor(flavor, local, remote) {
111
111
  // After a Worker cutover the live rows are D1's, and a Mac-hosted backup must pull them down
112
112
  // rather than snapshot the frozen file — see `./pullD1.ts`. Here, not in the `./d1` barrel,
113
113
  // for the reason this file's header gives: it reaches `bun:sqlite`.
114
- export { backupTables, chooseBackupSource, pullD1ToSqlite, servedRuntime, } from './pullD1.js';
114
+ export { backupTables, chooseBackupSource, insertLines, pullD1ToSqlite, servedRuntime, spawnWrangler, stripTrailingNuls, wranglerArgv, } from './pullD1.js';
@@ -56,6 +56,30 @@ export type Wrangler = (args: string[]) => {
56
56
  stdout: string;
57
57
  stderr: string;
58
58
  };
59
+ /**
60
+ * The argv that runs `wrangler`: THIS bun binary's `x`, never a bare `bunx` — a launchd job's
61
+ * PATH has no `bunx` (task 2112), and vault's and patterns' nightly backups each carried this
62
+ * line until 4.22.0.
63
+ */
64
+ export declare const wranglerArgv: (bun?: string) => string[];
65
+ /** The real {@link Wrangler}: `bun x wrangler …` in `cwd`, with `env` added to this process's. */
66
+ export declare function spawnWrangler(options: {
67
+ cwd: string;
68
+ env?: Record<string, string | undefined>;
69
+ }): Wrangler;
70
+ /**
71
+ * 🔴 Trailing NUL bytes off a `wrangler d1 export` file, in place. Returns how many were removed.
72
+ *
73
+ * Measured 2026-09-23 on vault's D1 (wrangler 4.136.3): `wrangler d1 export --table … --output`
74
+ * INTERMITTENTLY ends the file in hundreds of NUL bytes (`entries` +988, `oplog` +1,369, `sync_ops`
75
+ * +5,476 across three runs — a different table each time, always trailing). `bun:sqlite`'s `exec`
76
+ * reads SQL as a C string, so the first NUL silently ended the load: every table after it came back
77
+ * EMPTY with no error, and vault's real backup held `vault_keys` = 0 rows (task 2128). Only
78
+ * TRAILING bytes: a NUL inside the SQL is a different fault, and the row-count check refuses it.
79
+ */
80
+ export declare function stripTrailingNuls(file: string): number;
81
+ /** `^INSERT INTO ` lines — one per row in a `wrangler d1 export --no-schema` dump. */
82
+ export declare const insertLines: (dump: string) => number;
59
83
  /**
60
84
  * The tables a snapshot carries, from D1's own `sqlite_master` rows — every real table, and
61
85
  * none of what cannot or must not be re-inserted: a virtual table and its shadows (`_config`,
@@ -27,7 +27,7 @@
27
27
  * (`./http.ts` has the measurement), and a restore must put back what was there.
28
28
  */
29
29
  import { Database } from 'bun:sqlite';
30
- import { existsSync, readFileSync } from 'node:fs';
30
+ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
31
31
  /** Which side a backup must read. Pure, so every branch is testable without a network. */
32
32
  export function chooseBackupSource(input) {
33
33
  if (input.fromFileFlag) {
@@ -84,6 +84,48 @@ export async function servedRuntime(url, { attempts = 3, sleep = (ms) => new Pro
84
84
  }
85
85
  /** How many times one table's export is asked for when wrangler says success and writes nothing. */
86
86
  const EXPORT_ATTEMPTS = 3;
87
+ /**
88
+ * The argv that runs `wrangler`: THIS bun binary's `x`, never a bare `bunx` — a launchd job's
89
+ * PATH has no `bunx` (task 2112), and vault's and patterns' nightly backups each carried this
90
+ * line until 4.22.0.
91
+ */
92
+ export const wranglerArgv = (bun = process.execPath) => [bun, 'x', 'wrangler'];
93
+ /** The real {@link Wrangler}: `bun x wrangler …` in `cwd`, with `env` added to this process's. */
94
+ export function spawnWrangler(options) {
95
+ return (args) => {
96
+ const r = Bun.spawnSync([...wranglerArgv(), ...args], {
97
+ cwd: options.cwd,
98
+ env: { ...process.env, ...options.env },
99
+ stdout: 'pipe',
100
+ stderr: 'pipe',
101
+ });
102
+ return { code: r.exitCode ?? 1, stdout: r.stdout.toString(), stderr: r.stderr.toString() };
103
+ };
104
+ }
105
+ /**
106
+ * 🔴 Trailing NUL bytes off a `wrangler d1 export` file, in place. Returns how many were removed.
107
+ *
108
+ * Measured 2026-09-23 on vault's D1 (wrangler 4.136.3): `wrangler d1 export --table … --output`
109
+ * INTERMITTENTLY ends the file in hundreds of NUL bytes (`entries` +988, `oplog` +1,369, `sync_ops`
110
+ * +5,476 across three runs — a different table each time, always trailing). `bun:sqlite`'s `exec`
111
+ * reads SQL as a C string, so the first NUL silently ended the load: every table after it came back
112
+ * EMPTY with no error, and vault's real backup held `vault_keys` = 0 rows (task 2128). Only
113
+ * TRAILING bytes: a NUL inside the SQL is a different fault, and the row-count check refuses it.
114
+ */
115
+ export function stripTrailingNuls(file) {
116
+ if (!existsSync(file))
117
+ return 0;
118
+ const bytes = readFileSync(file);
119
+ let end = bytes.length;
120
+ while (end > 0 && bytes[end - 1] === 0)
121
+ end--;
122
+ if (end === bytes.length)
123
+ return 0;
124
+ writeFileSync(file, bytes.subarray(0, end));
125
+ return bytes.length - end;
126
+ }
127
+ /** `^INSERT INTO ` lines — one per row in a `wrangler d1 export --no-schema` dump. */
128
+ export const insertLines = (dump) => (dump.match(/^INSERT INTO /gm) ?? []).length;
87
129
  /**
88
130
  * The tables a snapshot carries, from D1's own `sqlite_master` rows — every real table, and
89
131
  * none of what cannot or must not be re-inserted: a virtual table and its shadows (`_config`,
@@ -123,50 +165,82 @@ export async function pullD1ToSqlite(options) {
123
165
  throw new Error(`D1 lists no \`${options.requiredTable}\` table (saw: ${rows.map((r) => r.name).join(', ') || 'nothing'})`);
124
166
  }
125
167
  let sql = '';
126
- for (const table of tables) {
127
- const dump = `${destination}.${table}.sql`;
128
- // 🔴 `wrangler d1 export` has been measured answering SUCCESS and writing no file —
129
- // collections' nightly of 2026-09-23T15:35Z refused on `derivative_retries`, and the same
130
- // command wrote the file (32 bytes, an empty table) an hour later. So a missing file is
131
- // asked for again, twice, before it is a refusal; a failure that persists still refuses,
132
- // because a snapshot missing a table is not a backup.
133
- let exported = { code: 1, stdout: '', stderr: 'not attempted' };
134
- for (let attempt = 1; attempt <= EXPORT_ATTEMPTS; attempt++) {
135
- exported = wrangler(['d1', 'export', database, '--remote', '--env=', '--table', table, '--no-schema', '--output', dump, '-y']);
136
- if (exported.code !== 0 || existsSync(dump))
137
- break;
168
+ const dumps = [];
169
+ try {
170
+ for (const table of tables) {
171
+ const dump = `${destination}.${table}.sql`;
172
+ // 🔴 `wrangler d1 export` has been measured answering SUCCESS and writing no file —
173
+ // collections' nightly of 2026-09-23T15:35Z refused on `derivative_retries`, and the same
174
+ // command wrote the file (32 bytes, an empty table) an hour later. So a missing file is
175
+ // asked for again, twice, before it is a refusal; a failure that persists still refuses,
176
+ // because a snapshot missing a table is not a backup.
177
+ let exported = { code: 1, stdout: '', stderr: 'not attempted' };
178
+ for (let attempt = 1; attempt <= EXPORT_ATTEMPTS; attempt++) {
179
+ exported = wrangler(['d1', 'export', database, '--remote', '--env=', '--table', table, '--no-schema', '--output', dump, '-y']);
180
+ if (exported.code !== 0 || existsSync(dump))
181
+ break;
182
+ }
183
+ if (exported.code !== 0) {
184
+ throw new Error(`wrangler d1 export --table ${table} failed: ${exported.stderr.trim() || exported.stdout.trim()}`);
185
+ }
186
+ if (!existsSync(dump)) {
187
+ throw new Error(`wrangler d1 export --table ${table} reported success ${EXPORT_ATTEMPTS} times and wrote no file at ${dump}`);
188
+ }
189
+ stripTrailingNuls(dump);
190
+ const text = readFileSync(dump, 'utf8');
191
+ dumps.push({ table, text });
192
+ sql += `${text}\n`;
138
193
  }
139
- if (exported.code !== 0) {
140
- throw new Error(`wrangler d1 export --table ${table} failed: ${exported.stderr.trim() || exported.stdout.trim()}`);
194
+ if (!/INSERT INTO/i.test(sql)) {
195
+ throw new Error('the D1 export contains no rows — refusing to snapshot an empty database as if it were the library');
141
196
  }
142
- if (!existsSync(dump)) {
143
- throw new Error(`wrangler d1 export --table ${table} reported success ${EXPORT_ATTEMPTS} times and wrote no file at ${dump}`);
197
+ const db = new Database(destination, { create: true });
198
+ try {
199
+ // `exec`, not `run`: each is many statements and `run` would execute only the first,
200
+ // leaving a file with a schema and no rows that looks perfectly valid.
201
+ db.exec(options.schemaSql);
202
+ // 🔴 A table D1 holds that the schema does not declare is created from D1's OWN DDL, never
203
+ // dropped: measured on music's first post-cutover pull (2026-09-23), `art_lookups` — a
204
+ // cache its art job creates where it first needs it — made the whole pull refuse with
205
+ // `no such table`, because its rows were exported and its table was not in the schema.
206
+ const have = new Set(db.query("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));
207
+ for (const row of rows) {
208
+ if (!tables.includes(row.name) || have.has(row.name) || !row.sql)
209
+ continue;
210
+ db.exec(row.sql.replace(/^\s*CREATE\s+TABLE\s+(?!IF\s+NOT\s+EXISTS)/i, 'CREATE TABLE IF NOT EXISTS '));
211
+ }
212
+ // One table at a time, so a fault is located rather than smeared over the whole load.
213
+ for (const { text } of dumps)
214
+ if (text.trim())
215
+ db.exec(text);
216
+ if (options.afterLoadSql)
217
+ db.exec(options.afterLoadSql);
218
+ // 🔴 Every table holds as many rows as its dump has INSERTs, or this is not a backup: the
219
+ // check that makes a silently truncated load — the NUL padding, or whatever does it next —
220
+ // a REFUSAL. A row count only; nothing here reads what the rows hold.
221
+ const short = [];
222
+ for (const { table, text } of dumps) {
223
+ if (table === 'sqlite_sequence')
224
+ continue;
225
+ const expected = insertLines(text);
226
+ const loaded = db.query(`SELECT COUNT(*) AS n FROM "${table.replaceAll('"', '""')}"`).get().n;
227
+ if (loaded < expected)
228
+ short.push(`${table}: ${loaded} of ${expected}`);
229
+ }
230
+ if (short.length > 0) {
231
+ db.close();
232
+ rmSync(destination, { force: true });
233
+ throw new Error(`the D1 pull did not load every exported row — ${short.join('; ')}. Refusing the snapshot.`);
234
+ }
144
235
  }
145
- sql += `${readFileSync(dump, 'utf8')}\n`;
146
- }
147
- if (!/INSERT INTO/i.test(sql)) {
148
- throw new Error('the D1 export contains no rows — refusing to snapshot an empty database as if it were the library');
149
- }
150
- const db = new Database(destination, { create: true });
151
- try {
152
- // `exec`, not `run`: each is many statements and `run` would execute only the first,
153
- // leaving a file with a schema and no rows that looks perfectly valid.
154
- db.exec(options.schemaSql);
155
- // 🔴 A table D1 holds that the schema does not declare is created from D1's OWN DDL, never
156
- // dropped: measured on music's first post-cutover pull (2026-09-23), `art_lookups` — a
157
- // cache its art job creates where it first needs it — made the whole pull refuse with
158
- // `no such table`, because its rows were exported and its table was not in the schema.
159
- const have = new Set(db.query("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));
160
- for (const row of rows) {
161
- if (!tables.includes(row.name) || have.has(row.name) || !row.sql)
162
- continue;
163
- db.exec(row.sql.replace(/^\s*CREATE\s+TABLE\s+(?!IF\s+NOT\s+EXISTS)/i, 'CREATE TABLE IF NOT EXISTS '));
236
+ finally {
237
+ db.close();
164
238
  }
165
- db.exec(sql);
166
- if (options.afterLoadSql)
167
- db.exec(options.afterLoadSql);
168
239
  }
169
240
  finally {
170
- db.close();
241
+ // The per-table dumps are a second, unmanaged copy of the data that no retention rule
242
+ // would ever delete — eighteen were found beside vault's snapshot (task 2128).
243
+ for (const table of tables)
244
+ rmSync(`${destination}.${table}.sql`, { force: true });
171
245
  }
172
246
  }
@@ -33,7 +33,7 @@
33
33
  * only be watching one of them.
34
34
  */
35
35
  import { MASTER_LOCK_PATHS, MASTER_LOCK_PREFIX } from "cursedbelt-core/master-lock";
36
- import { MASTER_LOCK_DERIVE_SOURCE } from "./lockPage.js";
36
+ import { MASTER_LOCK_DERIVE_SOURCE, MASTER_LOCK_REVEAL_SOURCE, withRevealToggles } from "./lockPage.js";
37
37
  /**
38
38
  * The page's own two assets.
39
39
  *
@@ -59,7 +59,7 @@ export const MASTER_LOCK_MIN_PASSWORD_LENGTH = 10;
59
59
  export function accountsPageHtml(options) {
60
60
  const label = escapeHtml(options.appLabel);
61
61
  const min = MASTER_LOCK_MIN_PASSWORD_LENGTH;
62
- return `<!doctype html>
62
+ return withRevealToggles(`<!doctype html>
63
63
  <html lang="en">
64
64
  <head>
65
65
  <meta charset="utf-8">
@@ -156,7 +156,7 @@ export function accountsPageHtml(options) {
156
156
  <script type="module" src="${MASTER_LOCK_ACCOUNTS_PAGE_PATHS.script}"></script>
157
157
  </body>
158
158
  </html>
159
- `;
159
+ `);
160
160
  }
161
161
  /**
162
162
  * The page-specific stylesheet. Loaded AFTER `lock.css` and inheriting every `--ml-*` token
@@ -212,6 +212,10 @@ const MIN_LENGTH = ${MASTER_LOCK_MIN_PASSWORD_LENGTH};
212
212
  // ── derivation ───────────────────────────────────────────────────────────────
213
213
  ${MASTER_LOCK_DERIVE_SOURCE}
214
214
 
215
+ // ── show / hide ──────────────────────────────────────────────────────────────
216
+ ${MASTER_LOCK_REVEAL_SOURCE}
217
+ wireMasterLockReveal(document);
218
+
215
219
  const rotateForm = document.getElementById("ml-rotate");
216
220
  const addForm = document.getElementById("ml-add");
217
221
  const list = document.getElementById("ml-accounts");
@@ -21,7 +21,26 @@ export interface LockPageOptions {
21
21
  */
22
22
  export declare function lockPageHtml(options: LockPageOptions): string;
23
23
  /** The stylesheet. Follows the viewer's theme and commits to nothing else. */
24
- export declare const LOCK_STYLE = ":root{color-scheme:light dark;\n --ml-bg:#f4f5f7;--ml-card:#ffffff;--ml-text:#14171c;--ml-muted:#69707c;--ml-border:#d9dde3;\n --ml-accent:#1f6feb;--ml-accent-text:#ffffff;--ml-danger:#b42318;\n --ml-shadow:0 18px 48px rgba(16,18,22,.14)}\n@media (prefers-color-scheme:dark){:root{\n --ml-bg:#0d0f13;--ml-card:#171a20;--ml-text:#e7eaef;--ml-muted:#8b93a1;--ml-border:#2b3039;\n --ml-accent:#4f8cf7;--ml-accent-text:#0b0d11;--ml-danger:#f97066;\n --ml-shadow:0 18px 48px rgba(0,0,0,.55)}}\n*{box-sizing:border-box}\nbody{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;\n background:var(--ml-bg);color:var(--ml-text);\n font:15px/1.55 ui-sans-serif,system-ui,-apple-system,\"Segoe UI\",sans-serif}\n.card{width:100%;max-width:23rem;background:var(--ml-card);border:1px solid var(--ml-border);\n border-radius:16px;padding:28px 26px 24px;box-shadow:var(--ml-shadow);text-align:center}\n.glyph{font-size:30px;line-height:1;margin-bottom:10px}\nh1{margin:0 0 6px;font-size:18px;font-weight:650;letter-spacing:-.01em}\n.note{margin:0 0 20px;font-size:13px;color:var(--ml-muted)}\n.field{display:block;text-align:left;margin-bottom:14px}\n.field span{display:block;font-size:12px;font-weight:600;color:var(--ml-muted);margin-bottom:6px}\ninput{width:100%;height:40px;padding:0 12px;border:1px solid var(--ml-border);border-radius:9px;\n background:var(--ml-bg);color:inherit;font:inherit}\ninput:focus{outline:2px solid var(--ml-accent);outline-offset:1px;border-color:transparent}\nbutton{width:100%;height:40px;border:0;border-radius:9px;background:var(--ml-accent);\n color:var(--ml-accent-text);font:inherit;font-weight:600;cursor:pointer}\nbutton[disabled]{opacity:.6;cursor:progress}\n.status{margin:14px 0 0;min-height:1.2em;font-size:12.5px;color:var(--ml-muted)}\n.status[data-tone=\"error\"]{color:var(--ml-danger)}\n.field span em{font-style:normal;font-weight:500;text-transform:none;opacity:.7}\nbutton.quiet{margin-top:10px;height:32px;background:none;color:var(--ml-muted);font-weight:500;\n font-size:12.5px;text-decoration:underline;text-underline-offset:3px}\nbutton.quiet:hover{color:var(--ml-text)}\nbutton.quiet[disabled]{opacity:.5}\n.hints{margin:10px 0 0;padding:12px 14px;text-align:left;border:1px solid var(--ml-border);\n border-radius:10px;background:var(--ml-bg);font-size:12.5px}\n.hints dt{font-weight:650;color:var(--ml-text)}\n.hints dd{margin:2px 0 10px;color:var(--ml-muted);overflow-wrap:anywhere}\n.hints dd:last-child{margin-bottom:0}\n";
24
+ export declare const LOCK_STYLE = ":root{color-scheme:light dark;\n --ml-bg:#f4f5f7;--ml-card:#ffffff;--ml-text:#14171c;--ml-muted:#69707c;--ml-border:#d9dde3;\n --ml-accent:#1f6feb;--ml-accent-text:#ffffff;--ml-danger:#b42318;\n --ml-shadow:0 18px 48px rgba(16,18,22,.14)}\n@media (prefers-color-scheme:dark){:root{\n --ml-bg:#0d0f13;--ml-card:#171a20;--ml-text:#e7eaef;--ml-muted:#8b93a1;--ml-border:#2b3039;\n --ml-accent:#4f8cf7;--ml-accent-text:#0b0d11;--ml-danger:#f97066;\n --ml-shadow:0 18px 48px rgba(0,0,0,.55)}}\n*{box-sizing:border-box}\nbody{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;\n background:var(--ml-bg);color:var(--ml-text);\n font:15px/1.55 ui-sans-serif,system-ui,-apple-system,\"Segoe UI\",sans-serif}\n.card{width:100%;max-width:23rem;background:var(--ml-card);border:1px solid var(--ml-border);\n border-radius:16px;padding:28px 26px 24px;box-shadow:var(--ml-shadow);text-align:center}\n.glyph{font-size:30px;line-height:1;margin-bottom:10px}\nh1{margin:0 0 6px;font-size:18px;font-weight:650;letter-spacing:-.01em}\n.note{margin:0 0 20px;font-size:13px;color:var(--ml-muted)}\n.field{display:block;text-align:left;margin-bottom:14px}\n.field span{display:block;font-size:12px;font-weight:600;color:var(--ml-muted);margin-bottom:6px}\ninput{width:100%;height:40px;padding:0 12px;border:1px solid var(--ml-border);border-radius:9px;\n background:var(--ml-bg);color:inherit;font:inherit}\ninput:focus{outline:2px solid var(--ml-accent);outline-offset:1px;border-color:transparent}\nbutton{width:100%;height:40px;border:0;border-radius:9px;background:var(--ml-accent);\n color:var(--ml-accent-text);font:inherit;font-weight:600;cursor:pointer}\nbutton[disabled]{opacity:.6;cursor:progress}\n.status{margin:14px 0 0;min-height:1.2em;font-size:12.5px;color:var(--ml-muted)}\n.status[data-tone=\"error\"]{color:var(--ml-danger)}\n.field span em{font-style:normal;font-weight:500;text-transform:none;opacity:.7}\nbutton.quiet{margin-top:10px;height:32px;background:none;color:var(--ml-muted);font-weight:500;\n font-size:12.5px;text-decoration:underline;text-underline-offset:3px}\nbutton.quiet:hover{color:var(--ml-text)}\nbutton.quiet[disabled]{opacity:.5}\n.hints{margin:10px 0 0;padding:12px 14px;text-align:left;border:1px solid var(--ml-border);\n border-radius:10px;background:var(--ml-bg);font-size:12.5px}\n.hints dt{font-weight:650;color:var(--ml-text)}\n.hints dd{margin:2px 0 10px;color:var(--ml-muted);overflow-wrap:anywhere}\n.hints dd:last-child{margin-bottom:0}\n.secret{position:relative;display:block}\n.secret input{padding-right:68px}\n.secret .reveal{position:absolute;right:5px;top:50%;transform:translateY(-50%);width:auto;height:30px;\n min-width:56px;padding:0 10px;border-radius:7px;background:none;color:var(--ml-muted);\n font-size:12.5px;font-weight:600}\n.secret .reveal:hover{color:var(--ml-text);background:var(--ml-card)}\n.secret .reveal:focus-visible{outline:2px solid var(--ml-accent);outline-offset:1px}\n";
25
+ /**
26
+ * 🔴 Every password field on the wall gets a Show/Hide control (task 077-438's library share) — a
27
+ * master password is long, typed once and never recoverable, and a typo in "choose" is a lock-out.
28
+ *
29
+ * Markup: {@link withRevealToggles} wraps each `type="password"` input in `.secret` with a
30
+ * `<button type="button" data-reveal>` after it — the inputs keep every attribute, `autocomplete`
31
+ * included, so a password manager still sees a password field. Behaviour:
32
+ * {@link MASTER_LOCK_REVEAL_SOURCE}, interpolated into BOTH same-origin scripts, because the wall's
33
+ * CSP is `script-src 'self'` and allows no inline script (`MASTER_LOCK_CSP`, pinned by
34
+ * `beaconNeverReachesTheWall.spec.ts`). Nothing about the CSP changes for this.
35
+ */
36
+ export declare function withRevealToggles(html: string): string;
37
+ /**
38
+ * The toggle's behaviour, as browser source. It flips the input BEFORE it to `text` and back,
39
+ * keeps `aria-pressed`/`aria-label` true to what is on screen, and returns every field to
40
+ * `password` when its form submits — so a manager saving the credential sees a password field.
41
+ * Publishes itself on `globalThis`, the seam `lockPage.spec.ts` drives it through.
42
+ */
43
+ export declare const MASTER_LOCK_REVEAL_SOURCE = "function wireMasterLockReveal(doc) {\n const buttons = doc.querySelectorAll(\"[data-reveal]\");\n for (const button of buttons) {\n const input = button.previousElementSibling;\n if (!input || input.tagName !== \"INPUT\") continue;\n const show = (on) => {\n input.type = on ? \"text\" : \"password\";\n button.setAttribute(\"aria-pressed\", on ? \"true\" : \"false\");\n button.setAttribute(\"aria-label\", on ? \"Hide password\" : \"Show password\");\n button.textContent = on ? \"Hide\" : \"Show\";\n };\n button.addEventListener(\"click\", () => {\n show(input.type === \"password\");\n input.focus();\n });\n input.form?.addEventListener(\"submit\", () => show(false), true);\n }\n return buttons.length;\n}\nglobalThis.wireMasterLockReveal = wireMasterLockReveal;";
25
44
  /**
26
45
  * 🔴 The ONE browser copy of `deriveMasterLockVerifier`, shared by every page this feature
27
46
  * serves. Pinned by `lockPage.spec.ts`, which evaluates it and compares against the
@@ -41,7 +41,7 @@ export function lockPageHtml(options) {
41
41
  "Choose one now. Nobody else can see it and nobody else can reset it — " +
42
42
  "if you forget it, it cannot be recovered, so leave yourself a hint."
43
43
  : "This site is locked. Signing in is not enough — enter the master password to continue."));
44
- return `<!doctype html>
44
+ return withRevealToggles(`<!doctype html>
45
45
  <html lang="en">
46
46
  <head>
47
47
  <meta charset="utf-8">
@@ -88,7 +88,7 @@ export function lockPageHtml(options) {
88
88
  <script type="module" src="${MASTER_LOCK_PATHS.script}"></script>
89
89
  </body>
90
90
  </html>
91
- `;
91
+ `);
92
92
  }
93
93
  /** The stylesheet. Follows the viewer's theme and commits to nothing else. */
94
94
  export const LOCK_STYLE = `:root{color-scheme:light dark;
@@ -128,7 +128,54 @@ button.quiet[disabled]{opacity:.5}
128
128
  .hints dt{font-weight:650;color:var(--ml-text)}
129
129
  .hints dd{margin:2px 0 10px;color:var(--ml-muted);overflow-wrap:anywhere}
130
130
  .hints dd:last-child{margin-bottom:0}
131
+ .secret{position:relative;display:block}
132
+ .secret input{padding-right:68px}
133
+ .secret .reveal{position:absolute;right:5px;top:50%;transform:translateY(-50%);width:auto;height:30px;
134
+ min-width:56px;padding:0 10px;border-radius:7px;background:none;color:var(--ml-muted);
135
+ font-size:12.5px;font-weight:600}
136
+ .secret .reveal:hover{color:var(--ml-text);background:var(--ml-card)}
137
+ .secret .reveal:focus-visible{outline:2px solid var(--ml-accent);outline-offset:1px}
131
138
  `;
139
+ /**
140
+ * 🔴 Every password field on the wall gets a Show/Hide control (task 077-438's library share) — a
141
+ * master password is long, typed once and never recoverable, and a typo in "choose" is a lock-out.
142
+ *
143
+ * Markup: {@link withRevealToggles} wraps each `type="password"` input in `.secret` with a
144
+ * `<button type="button" data-reveal>` after it — the inputs keep every attribute, `autocomplete`
145
+ * included, so a password manager still sees a password field. Behaviour:
146
+ * {@link MASTER_LOCK_REVEAL_SOURCE}, interpolated into BOTH same-origin scripts, because the wall's
147
+ * CSP is `script-src 'self'` and allows no inline script (`MASTER_LOCK_CSP`, pinned by
148
+ * `beaconNeverReachesTheWall.spec.ts`). Nothing about the CSP changes for this.
149
+ */
150
+ export function withRevealToggles(html) {
151
+ return html.replace(/(<input\b[^>]*\btype="password"[^>]*>)/g, '<span class="secret">$1<button type="button" class="reveal" data-reveal aria-label="Show password" aria-pressed="false">Show</button></span>');
152
+ }
153
+ /**
154
+ * The toggle's behaviour, as browser source. It flips the input BEFORE it to `text` and back,
155
+ * keeps `aria-pressed`/`aria-label` true to what is on screen, and returns every field to
156
+ * `password` when its form submits — so a manager saving the credential sees a password field.
157
+ * Publishes itself on `globalThis`, the seam `lockPage.spec.ts` drives it through.
158
+ */
159
+ export const MASTER_LOCK_REVEAL_SOURCE = `function wireMasterLockReveal(doc) {
160
+ const buttons = doc.querySelectorAll("[data-reveal]");
161
+ for (const button of buttons) {
162
+ const input = button.previousElementSibling;
163
+ if (!input || input.tagName !== "INPUT") continue;
164
+ const show = (on) => {
165
+ input.type = on ? "text" : "password";
166
+ button.setAttribute("aria-pressed", on ? "true" : "false");
167
+ button.setAttribute("aria-label", on ? "Hide password" : "Show password");
168
+ button.textContent = on ? "Hide" : "Show";
169
+ };
170
+ button.addEventListener("click", () => {
171
+ show(input.type === "password");
172
+ input.focus();
173
+ });
174
+ input.form?.addEventListener("submit", () => show(false), true);
175
+ }
176
+ return buttons.length;
177
+ }
178
+ globalThis.wireMasterLockReveal = wireMasterLockReveal;`;
132
179
  /**
133
180
  * 🔴 The ONE browser copy of `deriveMasterLockVerifier`, shared by every page this feature
134
181
  * serves. Pinned by `lockPage.spec.ts`, which evaluates it and compares against the
@@ -179,6 +226,10 @@ export const LOCK_SCRIPT = `const paths = ${JSON.stringify(MASTER_LOCK_PATHS)};
179
226
  // ── derivation ───────────────────────────────────────────────────────────────
180
227
  ${MASTER_LOCK_DERIVE_SOURCE}
181
228
 
229
+ // ── show / hide ──────────────────────────────────────────────────────────────
230
+ ${MASTER_LOCK_REVEAL_SOURCE}
231
+ wireMasterLockReveal(document);
232
+
182
233
  const form = document.getElementById("ml-form");
183
234
  const input = document.getElementById("ml-input");
184
235
  const confirmInput = document.getElementById("ml-confirm");
@@ -18,7 +18,17 @@ export interface MetricRow {
18
18
  durationMs: number | null;
19
19
  bytesOut: number | null;
20
20
  userId: string | null;
21
+ /**
22
+ * Where the request came from — a PLACE, never an address (4.23.0). Filled by
23
+ * `requestLogger`'s `locate` hook; absent/null when nothing could place it. There is
24
+ * deliberately no `ip` field: the raw address is never stored, anywhere.
25
+ */
26
+ country?: string | null;
27
+ region?: string | null;
28
+ city?: string | null;
21
29
  }
30
+ /** The optional place columns, in insert order. Each is written only when the table HAS it. */
31
+ export declare const PLACE_COLUMNS: readonly ["country", "region", "city"];
22
32
  export interface MetricsBufferOptions {
23
33
  db: Database;
24
34
  /** Flush cadence in ms. Default: 1500. */
@@ -1,11 +1,19 @@
1
+ /** The optional place columns, in insert order. Each is written only when the table HAS it. */
2
+ export const PLACE_COLUMNS = ['country', 'region', 'city'];
1
3
  export function createMetricsBuffer(opts) {
2
4
  const { db, flushIntervalMs = 1500, maxSize = 5000 } = opts;
3
5
  let buffer = [];
4
- const insert = db.prepare(`INSERT INTO request_metrics (id, ts, method, route, status, duration_ms, bytes_out, user_id)
5
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
6
+ // 🔴 The place columns are written only where the table has them (4.23.0). An app that
7
+ // created `request_metrics` with its own DDL before they existed keeps working unchanged —
8
+ // a fixed column list would make `prepare` throw on its first boot after the bump.
9
+ const present = new Set(db.query('PRAGMA table_info(request_metrics)').all().map((c) => c.name));
10
+ const place = PLACE_COLUMNS.filter((c) => present.has(c));
11
+ const columns = ['id', 'ts', 'method', 'route', 'status', 'duration_ms', 'bytes_out', 'user_id', ...place];
12
+ const insert = db.prepare(`INSERT INTO request_metrics (${columns.join(', ')})
13
+ VALUES (${columns.map(() => '?').join(', ')})`);
6
14
  const insertMany = db.transaction((rows) => {
7
15
  for (const r of rows) {
8
- insert.run(crypto.randomUUID(), r.ts, r.method, r.route, r.status, r.durationMs, r.bytesOut, r.userId);
16
+ insert.run(crypto.randomUUID(), r.ts, r.method, r.route, r.status, r.durationMs, r.bytesOut, r.userId, ...place.map((c) => r[c] ?? null));
9
17
  }
10
18
  });
11
19
  const flush = () => {