cursedbelt-server 4.30.1 → 4.32.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/dist/server/d1/pullD1.d.ts +57 -8
- package/dist/server/d1/pullD1.js +157 -89
- package/dist/server/master-lock/accountsPage.d.ts +3 -3
- package/dist/server/master-lock/accountsPage.js +4 -4
- package/dist/server/master-lock/lockPage.d.ts +8 -13
- package/dist/server/master-lock/lockPage.js +25 -10
- package/package.json +1 -1
- package/src/server/d1/pullD1.spec.ts +111 -90
- package/src/server/d1/pullD1.ts +162 -84
- package/src/server/master-lock/accountsPage.ts +4 -4
- package/src/server/master-lock/lockPage.spec.ts +16 -1
- package/src/server/master-lock/lockPage.ts +29 -10
|
@@ -18,13 +18,30 @@
|
|
|
18
18
|
* the cutover (a GREEN backup of nothing current — the worst failure a backup has), guessing
|
|
19
19
|
* D1 snapshots a stale copy while the Mac is live.
|
|
20
20
|
*
|
|
21
|
-
* ## 🔴 Why
|
|
21
|
+
* ## 🔴 Why paged `SELECT`s, and never `wrangler d1 export`
|
|
22
22
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
23
|
+
* **A D1 export blocks the database from serving anything else for as long as it runs**
|
|
24
|
+
* (Cloudflare's own docs). Measured on family 2026-09-25 00:32–00:33 UTC: a manual `bun run
|
|
25
|
+
* backup` — then a per-table `wrangler d1 export` — held production for ~28 s at a time, and
|
|
26
|
+
* 14 requests to the family Worker sat waiting and threw Error 1101 (wall p50 28 s, CPU p50
|
|
27
|
+
* 1.6 ms). Two earlier throws at 13:56/13:58 had the same shape. So every nightly backup of
|
|
28
|
+
* every app on this seam was a scheduled outage.
|
|
29
|
+
*
|
|
30
|
+
* Since 4.32.0 the rows are read with ordinary `wrangler d1 execute --remote` SELECTs, a page
|
|
31
|
+
* of {@link PAGE_ROWS} rows at a time on a rowid keyset, which D1 serves between other
|
|
32
|
+
* requests like any read. Each value comes back as SQLite's own `quote()` of it — an SQL
|
|
33
|
+
* literal, as TEXT — so the REAL `5.0` stays `5.0` and the INTEGER `5` stays `5` (the reason
|
|
34
|
+
* this used `export` and not the REST API's JSON: `./http.ts` has the measurement), a BLOB is
|
|
35
|
+
* `X'…'`, and `quote()` round-trips a double exactly. The dump is then the same
|
|
36
|
+
* `INSERT INTO …` text an export wrote, so everything after it is unchanged.
|
|
37
|
+
*
|
|
38
|
+
* Still table by table: a whole-database export refused fts5 (*"cannot export databases with
|
|
39
|
+
* Virtual Tables"*), and a virtual table's shadow tables are an index, rebuilt, never copied.
|
|
40
|
+
*
|
|
41
|
+
* 🔴 The trade: an export was one point in time; pages are not. A row written to one table
|
|
42
|
+
* while another is being paged may be in the snapshot without its partner. For a nightly
|
|
43
|
+
* backup that is the right trade against taking production down, and the row-count check
|
|
44
|
+
* below still refuses a load short of what was read.
|
|
28
45
|
*/
|
|
29
46
|
export type BackupSourceChoice = {
|
|
30
47
|
kind: 'file' | 'd1' | 'refuse';
|
|
@@ -50,6 +67,13 @@ export declare function servedRuntime(url: string, { attempts, sleep }?: {
|
|
|
50
67
|
attempts?: number | undefined;
|
|
51
68
|
sleep?: ((ms: number) => Promise<void>) | undefined;
|
|
52
69
|
}): Promise<string | null>;
|
|
70
|
+
/**
|
|
71
|
+
* Rows per paged `SELECT`. The fleet's largest table held 290 rows when this was set (collections'
|
|
72
|
+
* `items`, 2026-09-25) and one `wrangler` call costs ~1 s, so a page this size is one call per
|
|
73
|
+
* table for every app today. A page D1 refuses — too large a response — is halved and asked
|
|
74
|
+
* again, down to one row, before it is a refusal.
|
|
75
|
+
*/
|
|
76
|
+
export declare const PAGE_ROWS = 500;
|
|
53
77
|
/** One `wrangler` invocation, as {@link pullD1ToSqlite} needs it. Injected so a spec runs none. */
|
|
54
78
|
export type Wrangler = (args: string[]) => {
|
|
55
79
|
code: number;
|
|
@@ -69,6 +93,8 @@ export declare function spawnWrangler(options: {
|
|
|
69
93
|
}): Wrangler;
|
|
70
94
|
/**
|
|
71
95
|
* 🔴 Trailing NUL bytes off a `wrangler d1 export` file, in place. Returns how many were removed.
|
|
96
|
+
* {@link pullD1ToSqlite} no longer exports (see the header), so nothing here calls this since
|
|
97
|
+
* 4.32.0; it stays exported for anyone still reading an export file by hand.
|
|
72
98
|
*
|
|
73
99
|
* Measured 2026-09-23 on vault's D1 (wrangler 4.136.3): `wrangler d1 export --table … --output`
|
|
74
100
|
* INTERMITTENTLY ends the file in hundreds of NUL bytes (`entries` +988, `oplog` +1,369, `sync_ops`
|
|
@@ -104,11 +130,34 @@ export interface PullD1Options {
|
|
|
104
130
|
afterLoadSql?: string;
|
|
105
131
|
wrangler: Wrangler;
|
|
106
132
|
}
|
|
133
|
+
/** How a table is walked in pages: by a rowid keyset, or — a `WITHOUT ROWID` table — by offset. */
|
|
134
|
+
export interface TablePlan {
|
|
135
|
+
table: string;
|
|
136
|
+
columns: string[];
|
|
137
|
+
/** The rowid alias to page on, or `null` for a table with none (then `order` + OFFSET). */
|
|
138
|
+
key: string | null;
|
|
139
|
+
order: string[];
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The paging plan for one table. The key is the first of `rowid`, `_rowid_`, `oid` that no
|
|
143
|
+
* declared column shadows — `items_fts_map` declares a column NAMED `rowid`, so it pages on
|
|
144
|
+
* `_rowid_`, which is the same value.
|
|
145
|
+
*/
|
|
146
|
+
export declare function tablePlan(table: string, ddl: string | null, cols: ReadonlyArray<{
|
|
147
|
+
name: string;
|
|
148
|
+
pk: number;
|
|
149
|
+
}>): TablePlan;
|
|
150
|
+
/** The SELECT for one page: the key (if any) and every column as its `quote()`d SQL literal. */
|
|
151
|
+
export declare function pageSql(plan: TablePlan, limit: number, after: {
|
|
152
|
+
key?: number;
|
|
153
|
+
offset?: number;
|
|
154
|
+
}): string;
|
|
107
155
|
/**
|
|
108
|
-
* Pull the live D1 database into a real SQLite file
|
|
156
|
+
* Pull the live D1 database into a real SQLite file, reading it with paged SELECTs that never
|
|
157
|
+
* block production (see the header — this used to be `wrangler d1 export`, which did).
|
|
109
158
|
*
|
|
110
159
|
* 🔴 It THROWS rather than returning an empty database. Every layer beneath treats a readable
|
|
111
|
-
* SQLite file as success, so
|
|
160
|
+
* SQLite file as success, so a pull that produced nothing would sail through the
|
|
112
161
|
* magic-byte check and `integrity_check` and land on the shelf with today's date on it.
|
|
113
162
|
*/
|
|
114
163
|
export declare function pullD1ToSqlite(options: PullD1Options): Promise<void>;
|
package/dist/server/d1/pullD1.js
CHANGED
|
@@ -18,13 +18,30 @@
|
|
|
18
18
|
* the cutover (a GREEN backup of nothing current — the worst failure a backup has), guessing
|
|
19
19
|
* D1 snapshots a stale copy while the Mac is live.
|
|
20
20
|
*
|
|
21
|
-
* ## 🔴 Why
|
|
21
|
+
* ## 🔴 Why paged `SELECT`s, and never `wrangler d1 export`
|
|
22
22
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
23
|
+
* **A D1 export blocks the database from serving anything else for as long as it runs**
|
|
24
|
+
* (Cloudflare's own docs). Measured on family 2026-09-25 00:32–00:33 UTC: a manual `bun run
|
|
25
|
+
* backup` — then a per-table `wrangler d1 export` — held production for ~28 s at a time, and
|
|
26
|
+
* 14 requests to the family Worker sat waiting and threw Error 1101 (wall p50 28 s, CPU p50
|
|
27
|
+
* 1.6 ms). Two earlier throws at 13:56/13:58 had the same shape. So every nightly backup of
|
|
28
|
+
* every app on this seam was a scheduled outage.
|
|
29
|
+
*
|
|
30
|
+
* Since 4.32.0 the rows are read with ordinary `wrangler d1 execute --remote` SELECTs, a page
|
|
31
|
+
* of {@link PAGE_ROWS} rows at a time on a rowid keyset, which D1 serves between other
|
|
32
|
+
* requests like any read. Each value comes back as SQLite's own `quote()` of it — an SQL
|
|
33
|
+
* literal, as TEXT — so the REAL `5.0` stays `5.0` and the INTEGER `5` stays `5` (the reason
|
|
34
|
+
* this used `export` and not the REST API's JSON: `./http.ts` has the measurement), a BLOB is
|
|
35
|
+
* `X'…'`, and `quote()` round-trips a double exactly. The dump is then the same
|
|
36
|
+
* `INSERT INTO …` text an export wrote, so everything after it is unchanged.
|
|
37
|
+
*
|
|
38
|
+
* Still table by table: a whole-database export refused fts5 (*"cannot export databases with
|
|
39
|
+
* Virtual Tables"*), and a virtual table's shadow tables are an index, rebuilt, never copied.
|
|
40
|
+
*
|
|
41
|
+
* 🔴 The trade: an export was one point in time; pages are not. A row written to one table
|
|
42
|
+
* while another is being paged may be in the snapshot without its partner. For a nightly
|
|
43
|
+
* backup that is the right trade against taking production down, and the row-count check
|
|
44
|
+
* below still refuses a load short of what was read.
|
|
28
45
|
*/
|
|
29
46
|
import { Database } from 'bun:sqlite';
|
|
30
47
|
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
@@ -82,8 +99,13 @@ export async function servedRuntime(url, { attempts = 3, sleep = (ms) => new Pro
|
|
|
82
99
|
}
|
|
83
100
|
return null;
|
|
84
101
|
}
|
|
85
|
-
/**
|
|
86
|
-
|
|
102
|
+
/**
|
|
103
|
+
* Rows per paged `SELECT`. The fleet's largest table held 290 rows when this was set (collections'
|
|
104
|
+
* `items`, 2026-09-25) and one `wrangler` call costs ~1 s, so a page this size is one call per
|
|
105
|
+
* table for every app today. A page D1 refuses — too large a response — is halved and asked
|
|
106
|
+
* again, down to one row, before it is a refusal.
|
|
107
|
+
*/
|
|
108
|
+
export const PAGE_ROWS = 500;
|
|
87
109
|
/**
|
|
88
110
|
* The argv that runs `wrangler`: THIS bun binary's `x`, never a bare `bunx` — a launchd job's
|
|
89
111
|
* PATH has no `bunx` (task 2112), and vault's and patterns' nightly backups each carried this
|
|
@@ -104,6 +126,8 @@ export function spawnWrangler(options) {
|
|
|
104
126
|
}
|
|
105
127
|
/**
|
|
106
128
|
* 🔴 Trailing NUL bytes off a `wrangler d1 export` file, in place. Returns how many were removed.
|
|
129
|
+
* {@link pullD1ToSqlite} no longer exports (see the header), so nothing here calls this since
|
|
130
|
+
* 4.32.0; it stays exported for anyone still reading an export file by hand.
|
|
107
131
|
*
|
|
108
132
|
* Measured 2026-09-23 on vault's D1 (wrangler 4.136.3): `wrangler d1 export --table … --output`
|
|
109
133
|
* INTERMITTENTLY ends the file in hundreds of NUL bytes (`entries` +988, `oplog` +1,369, `sync_ops`
|
|
@@ -143,104 +167,148 @@ export function backupTables(rows) {
|
|
|
143
167
|
.filter((name) => name === 'sqlite_sequence' || !name.startsWith('sqlite_'))
|
|
144
168
|
.sort();
|
|
145
169
|
}
|
|
170
|
+
const sqlIdent = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
171
|
+
const sqlText = (text) => `'${text.replaceAll("'", "''")}'`;
|
|
172
|
+
/** One `wrangler d1 execute --remote --json` — every statement's result rows, in order. */
|
|
173
|
+
function execute(wrangler, database, sql) {
|
|
174
|
+
const r = wrangler(['d1', 'execute', database, '--remote', '--env=', '--json', '--command', sql]);
|
|
175
|
+
if (r.code !== 0)
|
|
176
|
+
throw new Error(r.stderr.trim() || r.stdout.trim() || `wrangler exited ${r.code}`);
|
|
177
|
+
const parsed = JSON.parse(r.stdout.slice(r.stdout.indexOf('[')));
|
|
178
|
+
return parsed.map((statement) => statement.results ?? []);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The paging plan for one table. The key is the first of `rowid`, `_rowid_`, `oid` that no
|
|
182
|
+
* declared column shadows — `items_fts_map` declares a column NAMED `rowid`, so it pages on
|
|
183
|
+
* `_rowid_`, which is the same value.
|
|
184
|
+
*/
|
|
185
|
+
export function tablePlan(table, ddl, cols) {
|
|
186
|
+
const columns = cols.map((c) => c.name);
|
|
187
|
+
if (columns.length === 0)
|
|
188
|
+
throw new Error(`D1 reports no columns for \`${table}\``);
|
|
189
|
+
const lower = new Set(columns.map((c) => c.toLowerCase()));
|
|
190
|
+
const withoutRowid = /\bWITHOUT\s+ROWID\b/i.test(ddl ?? '');
|
|
191
|
+
const key = withoutRowid ? null : (['rowid', '_rowid_', 'oid'].find((alias) => !lower.has(alias)) ?? null);
|
|
192
|
+
const pk = cols.filter((c) => c.pk > 0).sort((a, b) => a.pk - b.pk).map((c) => c.name);
|
|
193
|
+
return { table, columns, key, order: pk.length > 0 ? pk : columns };
|
|
194
|
+
}
|
|
195
|
+
/** The SELECT for one page: the key (if any) and every column as its `quote()`d SQL literal. */
|
|
196
|
+
export function pageSql(plan, limit, after) {
|
|
197
|
+
const values = plan.columns.map((c, i) => `quote(${sqlIdent(c)}) AS c${i}`).join(', ');
|
|
198
|
+
if (plan.key) {
|
|
199
|
+
const where = after.key === undefined ? '' : ` WHERE ${plan.key} > ${after.key}`;
|
|
200
|
+
return `SELECT ${plan.key} AS k, ${values} FROM ${sqlIdent(plan.table)}${where} ORDER BY ${plan.key} LIMIT ${limit}`;
|
|
201
|
+
}
|
|
202
|
+
const order = plan.order.map(sqlIdent).join(', ');
|
|
203
|
+
return `SELECT ${values} FROM ${sqlIdent(plan.table)} ORDER BY ${order} LIMIT ${limit} OFFSET ${after.offset ?? 0}`;
|
|
204
|
+
}
|
|
205
|
+
/** Every row of one table as `INSERT INTO …;` lines, read in pages that never block D1. */
|
|
206
|
+
function dumpTable(wrangler, database, plan) {
|
|
207
|
+
const into = `INSERT INTO ${sqlIdent(plan.table)} (${plan.columns.map(sqlIdent).join(', ')}) VALUES (`;
|
|
208
|
+
const lines = [];
|
|
209
|
+
let limit = PAGE_ROWS;
|
|
210
|
+
let after = {};
|
|
211
|
+
for (;;) {
|
|
212
|
+
let page;
|
|
213
|
+
try {
|
|
214
|
+
page = execute(wrangler, database, pageSql(plan, limit, after))[0] ?? [];
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
if (limit > 1) {
|
|
218
|
+
limit = Math.max(1, Math.floor(limit / 2));
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
throw new Error(`could not read \`${plan.table}\` from D1: ${error.message}`);
|
|
222
|
+
}
|
|
223
|
+
for (const row of page) {
|
|
224
|
+
const literals = plan.columns.map((_, i) => row[`c${i}`]);
|
|
225
|
+
if (literals.some((v) => typeof v !== 'string')) {
|
|
226
|
+
throw new Error(`D1 returned a non-literal value paging \`${plan.table}\` — refusing a snapshot that may not restore`);
|
|
227
|
+
}
|
|
228
|
+
lines.push(`${into}${literals.join(', ')});`);
|
|
229
|
+
}
|
|
230
|
+
if (page.length < limit)
|
|
231
|
+
break;
|
|
232
|
+
after = plan.key ? { key: Number(page[page.length - 1]?.k) } : { offset: (after.offset ?? 0) + page.length };
|
|
233
|
+
}
|
|
234
|
+
return { text: lines.join('\n'), rows: lines.length };
|
|
235
|
+
}
|
|
146
236
|
/**
|
|
147
|
-
* Pull the live D1 database into a real SQLite file
|
|
237
|
+
* Pull the live D1 database into a real SQLite file, reading it with paged SELECTs that never
|
|
238
|
+
* block production (see the header — this used to be `wrangler d1 export`, which did).
|
|
148
239
|
*
|
|
149
240
|
* 🔴 It THROWS rather than returning an empty database. Every layer beneath treats a readable
|
|
150
|
-
* SQLite file as success, so
|
|
241
|
+
* SQLite file as success, so a pull that produced nothing would sail through the
|
|
151
242
|
* magic-byte check and `integrity_check` and land on the shelf with today's date on it.
|
|
152
243
|
*/
|
|
153
244
|
export async function pullD1ToSqlite(options) {
|
|
154
245
|
const { destination, database, wrangler } = options;
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
throw new Error(`could not list D1's tables: ${
|
|
161
|
-
|
|
162
|
-
const rows = JSON.parse(json)[0]?.results ?? [];
|
|
246
|
+
let rows;
|
|
247
|
+
try {
|
|
248
|
+
rows = (execute(wrangler, database, "select name, sql from sqlite_master where type = 'table'")[0] ?? []);
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
throw new Error(`could not list D1's tables: ${error.message}`);
|
|
252
|
+
}
|
|
163
253
|
const tables = backupTables(rows);
|
|
164
254
|
if (!tables.includes(options.requiredTable)) {
|
|
165
255
|
throw new Error(`D1 lists no \`${options.requiredTable}\` table (saw: ${rows.map((r) => r.name).join(', ') || 'nothing'})`);
|
|
166
256
|
}
|
|
167
|
-
|
|
168
|
-
|
|
257
|
+
// Every table's columns in ONE call: one statement each, answered in order.
|
|
258
|
+
let columnSets;
|
|
169
259
|
try {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
260
|
+
columnSets = execute(wrangler, database, tables.map((t) => `SELECT name, pk FROM pragma_table_info(${sqlText(t)});`).join(' '));
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
throw new Error(`could not read D1's columns: ${error.message}`);
|
|
264
|
+
}
|
|
265
|
+
const dumps = tables.map((table, i) => {
|
|
266
|
+
const cols = (columnSets[i] ?? []);
|
|
267
|
+
const plan = tablePlan(table, rows.find((r) => r.name === table)?.sql ?? null, cols);
|
|
268
|
+
return { table, ...dumpTable(wrangler, database, plan) };
|
|
269
|
+
});
|
|
270
|
+
if (dumps.every((d) => d.rows === 0)) {
|
|
271
|
+
throw new Error('the D1 pull contains no rows — refusing to snapshot an empty database as if it were the library');
|
|
272
|
+
}
|
|
273
|
+
const db = new Database(destination, { create: true });
|
|
274
|
+
try {
|
|
275
|
+
// `exec`, not `run`: each is many statements and `run` would execute only the first,
|
|
276
|
+
// leaving a file with a schema and no rows that looks perfectly valid.
|
|
277
|
+
db.exec(options.schemaSql);
|
|
278
|
+
// 🔴 A table D1 holds that the schema does not declare is created from D1's OWN DDL, never
|
|
279
|
+
// dropped: measured on music's first post-cutover pull (2026-09-23), `art_lookups` — a
|
|
280
|
+
// cache its art job creates where it first needs it — made the whole pull refuse with
|
|
281
|
+
// `no such table`, because its rows were exported and its table was not in the schema.
|
|
282
|
+
const have = new Set(db.query("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));
|
|
283
|
+
for (const row of rows) {
|
|
284
|
+
if (!tables.includes(row.name) || have.has(row.name) || !row.sql)
|
|
285
|
+
continue;
|
|
286
|
+
db.exec(row.sql.replace(/^\s*CREATE\s+TABLE\s+(?!IF\s+NOT\s+EXISTS)/i, 'CREATE TABLE IF NOT EXISTS '));
|
|
196
287
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
}
|
|
288
|
+
// One table at a time, so a fault is located rather than smeared over the whole load.
|
|
289
|
+
for (const { text } of dumps)
|
|
290
|
+
if (text)
|
|
291
|
+
db.exec(text);
|
|
292
|
+
if (options.afterLoadSql)
|
|
293
|
+
db.exec(options.afterLoadSql);
|
|
294
|
+
// 🔴 Every table holds as many rows as were read from D1, or this is not a backup: the check
|
|
295
|
+
// that makes a silently truncated load (task 2128's NUL padding, or whatever does it next)
|
|
296
|
+
// a REFUSAL. A row count only; nothing here reads what the rows hold.
|
|
297
|
+
const short = [];
|
|
298
|
+
for (const { table, rows: expected } of dumps) {
|
|
299
|
+
if (table === 'sqlite_sequence')
|
|
300
|
+
continue;
|
|
301
|
+
const loaded = db.query(`SELECT COUNT(*) AS n FROM ${sqlIdent(table)}`).get().n;
|
|
302
|
+
if (loaded < expected)
|
|
303
|
+
short.push(`${table}: ${loaded} of ${expected}`);
|
|
235
304
|
}
|
|
236
|
-
|
|
305
|
+
if (short.length > 0) {
|
|
237
306
|
db.close();
|
|
307
|
+
rmSync(destination, { force: true });
|
|
308
|
+
throw new Error(`the D1 pull did not load every row it read — ${short.join('; ')}. Refusing the snapshot.`);
|
|
238
309
|
}
|
|
239
310
|
}
|
|
240
311
|
finally {
|
|
241
|
-
|
|
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 });
|
|
312
|
+
db.close();
|
|
245
313
|
}
|
|
246
314
|
}
|
|
@@ -11,9 +11,9 @@ export declare const MASTER_LOCK_ACCOUNTS_PAGE_PATHS: {
|
|
|
11
11
|
readonly style: "/__lock/accounts.css";
|
|
12
12
|
readonly script: "/__lock/accounts.js";
|
|
13
13
|
};
|
|
14
|
-
/** The shortest password worth calling one
|
|
15
|
-
*
|
|
16
|
-
export
|
|
14
|
+
/** The shortest password worth calling one — defined beside the lock page's enrollment form
|
|
15
|
+
* (`lockPage.ts`) and re-exported, so a rotation cannot quietly differ from what enrollment demands. */
|
|
16
|
+
export { MASTER_LOCK_MIN_PASSWORD_LENGTH } from "./lockPage.js";
|
|
17
17
|
/**
|
|
18
18
|
* The document.
|
|
19
19
|
*
|
|
@@ -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, MASTER_LOCK_REVEAL_SOURCE, withRevealToggles } from "./lockPage.js";
|
|
36
|
+
import { MASTER_LOCK_DERIVE_SOURCE, MASTER_LOCK_MIN_PASSWORD_LENGTH, MASTER_LOCK_REVEAL_SOURCE, withRevealToggles } from "./lockPage.js";
|
|
37
37
|
/**
|
|
38
38
|
* The page's own two assets.
|
|
39
39
|
*
|
|
@@ -47,9 +47,9 @@ export const MASTER_LOCK_ACCOUNTS_PAGE_PATHS = {
|
|
|
47
47
|
script: `${MASTER_LOCK_PREFIX}/accounts.js`,
|
|
48
48
|
};
|
|
49
49
|
const escapeHtml = (value) => value.replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ch] ?? ch);
|
|
50
|
-
/** The shortest password worth calling one
|
|
51
|
-
*
|
|
52
|
-
export
|
|
50
|
+
/** The shortest password worth calling one — defined beside the lock page's enrollment form
|
|
51
|
+
* (`lockPage.ts`) and re-exported, so a rotation cannot quietly differ from what enrollment demands. */
|
|
52
|
+
export { MASTER_LOCK_MIN_PASSWORD_LENGTH } from "./lockPage.js";
|
|
53
53
|
/**
|
|
54
54
|
* The document.
|
|
55
55
|
*
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shortest master password the wall accepts — every `minlength` on both pages and both
|
|
3
|
+
* scripts' own checks read this one number, so a rotation cannot quietly differ from enrollment.
|
|
4
|
+
* 10 until 2026-09-24; the owner: *"make the minimum password length 6 characters"*.
|
|
5
|
+
*/
|
|
6
|
+
export declare const MASTER_LOCK_MIN_PASSWORD_LENGTH = 6;
|
|
1
7
|
/** Everything the page needs to know about the app it is standing in front of. */
|
|
2
8
|
export interface LockPageOptions {
|
|
3
9
|
/** What to call this app — "Collections", "binary-server inspector". */
|
|
@@ -21,18 +27,7 @@ export interface LockPageOptions {
|
|
|
21
27
|
*/
|
|
22
28
|
export declare function lockPageHtml(options: LockPageOptions): string;
|
|
23
29
|
/** 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.secret{position:relative;display:block}\n.secret input{padding-right:
|
|
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
|
-
*/
|
|
30
|
+
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:48px}\n.secret .reveal{position:absolute;right:5px;top:50%;transform:translateY(-50%);width:36px;height:32px;\n display:grid;place-items:center;padding:0;border-radius:7px;background:none;color:var(--ml-muted)}\n.secret .reveal svg{width:18px;height:18px}\n.secret .reveal .eye-off,.secret .reveal[aria-pressed=\"true\"] .eye{display:none}\n.secret .reveal[aria-pressed=\"true\"] .eye-off{display:block}\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";
|
|
36
31
|
export declare function withRevealToggles(html: string): string;
|
|
37
32
|
/**
|
|
38
33
|
* The toggle's behaviour, as browser source. It flips the input BEFORE it to `text` and back,
|
|
@@ -40,7 +35,7 @@ export declare function withRevealToggles(html: string): string;
|
|
|
40
35
|
* `password` when its form submits — so a manager saving the credential sees a password field.
|
|
41
36
|
* Publishes itself on `globalThis`, the seam `lockPage.spec.ts` drives it through.
|
|
42
37
|
*/
|
|
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
|
|
38
|
+
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 };\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;";
|
|
44
39
|
/**
|
|
45
40
|
* 🔴 The ONE browser copy of `deriveMasterLockVerifier`, shared by every page this feature
|
|
46
41
|
* serves. Pinned by `lockPage.spec.ts`, which evaluates it and compares against the
|
|
@@ -26,6 +26,12 @@
|
|
|
26
26
|
* scripts interpolate this string, and the spec asserts that they do.
|
|
27
27
|
*/
|
|
28
28
|
import { MASTER_LOCK_PATHS } from "cursedbelt-core/master-lock";
|
|
29
|
+
/**
|
|
30
|
+
* The shortest master password the wall accepts — every `minlength` on both pages and both
|
|
31
|
+
* scripts' own checks read this one number, so a rotation cannot quietly differ from enrollment.
|
|
32
|
+
* 10 until 2026-09-24; the owner: *"make the minimum password length 6 characters"*.
|
|
33
|
+
*/
|
|
34
|
+
export const MASTER_LOCK_MIN_PASSWORD_LENGTH = 6;
|
|
29
35
|
const escapeHtml = (value) => value.replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ch] ?? ch);
|
|
30
36
|
/**
|
|
31
37
|
* The document. Deliberately says the app's NAME and nothing else about it — whoever is
|
|
@@ -61,14 +67,14 @@ export function lockPageHtml(options) {
|
|
|
61
67
|
<span>${enroll ? "New master password" : "Master password"}</span>
|
|
62
68
|
<input id="ml-input" type="password" name="master-password"
|
|
63
69
|
autocomplete="${enroll ? "new-password" : "current-password"}"
|
|
64
|
-
autofocus required spellcheck="false" enterkeyhint="${enroll ? "next" : "go"}"${enroll ?
|
|
70
|
+
autofocus required spellcheck="false" enterkeyhint="${enroll ? "next" : "go"}"${enroll ? ` minlength="${MASTER_LOCK_MIN_PASSWORD_LENGTH}"` : ""}>
|
|
65
71
|
</label>${enroll
|
|
66
72
|
? `
|
|
67
73
|
<label class="field">
|
|
68
74
|
<span>Confirm master password</span>
|
|
69
75
|
<input id="ml-confirm" type="password" name="confirm-master-password"
|
|
70
76
|
autocomplete="new-password" required spellcheck="false" enterkeyhint="go"
|
|
71
|
-
minlength="
|
|
77
|
+
minlength="${MASTER_LOCK_MIN_PASSWORD_LENGTH}">
|
|
72
78
|
</label>
|
|
73
79
|
<label class="field">
|
|
74
80
|
<span>Hint <em>optional</em></span>
|
|
@@ -129,15 +135,17 @@ button.quiet[disabled]{opacity:.5}
|
|
|
129
135
|
.hints dd{margin:2px 0 10px;color:var(--ml-muted);overflow-wrap:anywhere}
|
|
130
136
|
.hints dd:last-child{margin-bottom:0}
|
|
131
137
|
.secret{position:relative;display:block}
|
|
132
|
-
.secret input{padding-right:
|
|
133
|
-
.secret .reveal{position:absolute;right:5px;top:50%;transform:translateY(-50%);width:
|
|
134
|
-
|
|
135
|
-
|
|
138
|
+
.secret input{padding-right:48px}
|
|
139
|
+
.secret .reveal{position:absolute;right:5px;top:50%;transform:translateY(-50%);width:36px;height:32px;
|
|
140
|
+
display:grid;place-items:center;padding:0;border-radius:7px;background:none;color:var(--ml-muted)}
|
|
141
|
+
.secret .reveal svg{width:18px;height:18px}
|
|
142
|
+
.secret .reveal .eye-off,.secret .reveal[aria-pressed="true"] .eye{display:none}
|
|
143
|
+
.secret .reveal[aria-pressed="true"] .eye-off{display:block}
|
|
136
144
|
.secret .reveal:hover{color:var(--ml-text);background:var(--ml-card)}
|
|
137
145
|
.secret .reveal:focus-visible{outline:2px solid var(--ml-accent);outline-offset:1px}
|
|
138
146
|
`;
|
|
139
147
|
/**
|
|
140
|
-
* 🔴 Every password field on the wall gets
|
|
148
|
+
* 🔴 Every password field on the wall gets an eye (show/hide) control (task 077-438's library share) — a
|
|
141
149
|
* master password is long, typed once and never recoverable, and a typo in "choose" is a lock-out.
|
|
142
150
|
*
|
|
143
151
|
* Markup: {@link withRevealToggles} wraps each `type="password"` input in `.secret` with a
|
|
@@ -147,8 +155,16 @@ button.quiet[disabled]{opacity:.5}
|
|
|
147
155
|
* CSP is `script-src 'self'` and allows no inline script (`MASTER_LOCK_CSP`, pinned by
|
|
148
156
|
* `beaconNeverReachesTheWall.spec.ts`). Nothing about the CSP changes for this.
|
|
149
157
|
*/
|
|
158
|
+
/*
|
|
159
|
+
* An eye, and an eye struck through — the owner, 2026-09-24: *"use an eye icon instead of words
|
|
160
|
+
* like 'Show'"*. Both are in the button; the stylesheet shows one by `aria-pressed`, so the
|
|
161
|
+
* script only flips state and the accessible name, never markup. Same glyphs as cursedbelt's
|
|
162
|
+
* React `SecretInput`, drawn inline because this page ships no icon font and no framework.
|
|
163
|
+
*/
|
|
164
|
+
const EYE = '<svg class="eye" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg>';
|
|
165
|
+
const EYE_OFF = '<svg class="eye-off" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.9 4.2A10.4 10.4 0 0 1 12 4c6.5 0 10 8 10 8a17.6 17.6 0 0 1-2.3 3.3"/><path d="M6.6 6.6C3.9 8.4 2 12 2 12s3.5 7 10 7c1.8 0 3.4-.5 4.8-1.3"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2"/><path d="M2 2l20 20"/></svg>';
|
|
150
166
|
export function withRevealToggles(html) {
|
|
151
|
-
return html.replace(/(<input\b[^>]*\btype="password"[^>]*>)/g,
|
|
167
|
+
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">${EYE}${EYE_OFF}</button></span>`);
|
|
152
168
|
}
|
|
153
169
|
/**
|
|
154
170
|
* The toggle's behaviour, as browser source. It flips the input BEFORE it to `text` and back,
|
|
@@ -165,7 +181,6 @@ export const MASTER_LOCK_REVEAL_SOURCE = `function wireMasterLockReveal(doc) {
|
|
|
165
181
|
input.type = on ? "text" : "password";
|
|
166
182
|
button.setAttribute("aria-pressed", on ? "true" : "false");
|
|
167
183
|
button.setAttribute("aria-label", on ? "Hide password" : "Show password");
|
|
168
|
-
button.textContent = on ? "Hide" : "Show";
|
|
169
184
|
};
|
|
170
185
|
button.addEventListener("click", () => {
|
|
171
186
|
show(input.type === "password");
|
|
@@ -252,7 +267,7 @@ const enrolling = document.body.dataset.mode === "enroll";
|
|
|
252
267
|
|
|
253
268
|
/** The shortest password worth calling one. Matched by the input's own minlength, and
|
|
254
269
|
* checked here too because a pasted value can beat the constraint on some engines. */
|
|
255
|
-
const MIN_LENGTH =
|
|
270
|
+
const MIN_LENGTH = ${MASTER_LOCK_MIN_PASSWORD_LENGTH};
|
|
256
271
|
|
|
257
272
|
let kdf = null;
|
|
258
273
|
let busy = false;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-server",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.32.0",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The app-facing Bun/Hono server tier of the cursedbelt split — storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
|