cursedbelt-server 4.40.1 → 4.40.3
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.js +11 -0
- package/dist/server/incidents/store.js +1 -1
- package/dist/server/sharing/router.js +1 -1
- package/package.json +8 -4
- package/src/server/d1/pullD1.spec.ts +23 -0
- package/src/server/d1/pullD1.ts +12 -0
- package/src/server/incidents/incidents.spec.ts +0 -1
- package/src/server/incidents/store.ts +1 -1
- package/src/server/master-lock/guard.spec.ts +0 -1
- package/src/server/master-lock/sessions.spec.ts +3 -1
- package/src/server/sharing/router.ts +1 -1
- package/src/server/storage/files/catalogue.media-host.spec.ts +0 -2
package/dist/server/d1/pullD1.js
CHANGED
|
@@ -287,6 +287,15 @@ export async function pullD1ToSqlite(options) {
|
|
|
287
287
|
// `exec`, not `run`: each is many statements and `run` would execute only the first,
|
|
288
288
|
// leaving a file with a schema and no rows that looks perfectly valid.
|
|
289
289
|
db.exec(options.schemaSql);
|
|
290
|
+
// 🔴 The schema's TRIGGERS come off for the load and go back on after it. A trigger fires on
|
|
291
|
+
// every restored row as if it were a new write: measured 2026-09-29, collections' api-cache
|
|
292
|
+
// stamp triggers (AFTER INSERT ON items → a `meta` row) wrote `meta` during the `items` load,
|
|
293
|
+
// and `meta`'s own rows then refused with `UNIQUE constraint failed: meta.key` — every
|
|
294
|
+
// nightly backup of an app with such a trigger would have failed. D1's rows are already the
|
|
295
|
+
// triggers' results; the snapshot needs the triggers only for what happens AFTER it.
|
|
296
|
+
const triggers = db.query("SELECT name, sql FROM sqlite_master WHERE type = 'trigger' AND sql IS NOT NULL").all();
|
|
297
|
+
for (const t of triggers)
|
|
298
|
+
db.exec(`DROP TRIGGER ${sqlIdent(t.name)}`);
|
|
290
299
|
// 🔴 A table D1 holds that the schema does not declare is created from D1's OWN DDL, never
|
|
291
300
|
// dropped: measured on music's first post-cutover pull (2026-09-23), `art_lookups` — a
|
|
292
301
|
// cache its art job creates where it first needs it — made the whole pull refuse with
|
|
@@ -301,6 +310,8 @@ export async function pullD1ToSqlite(options) {
|
|
|
301
310
|
for (const { text } of dumps)
|
|
302
311
|
if (text)
|
|
303
312
|
db.exec(text);
|
|
313
|
+
for (const t of triggers)
|
|
314
|
+
db.exec(t.sql);
|
|
304
315
|
if (options.afterLoadSql)
|
|
305
316
|
db.exec(options.afterLoadSql);
|
|
306
317
|
// 🔴 Every table holds as many rows as were read from D1, or this is not a backup: the check
|
|
@@ -165,7 +165,7 @@ export function createIncidentStore(db) {
|
|
|
165
165
|
},
|
|
166
166
|
acknowledge(fp, now = Date.now()) {
|
|
167
167
|
const row = selectOne.get(fp);
|
|
168
|
-
if (
|
|
168
|
+
if (row?.state !== "open")
|
|
169
169
|
return false;
|
|
170
170
|
db.prepare(`UPDATE ${INCIDENTS_TABLE} SET state = 'acknowledged', acknowledged_at = ?
|
|
171
171
|
WHERE fingerprint = ?`).run(now, fp);
|
|
@@ -26,7 +26,7 @@ const isLevel = (v) => v === 'read' || v === 'write';
|
|
|
26
26
|
* ```
|
|
27
27
|
*/
|
|
28
28
|
export function mountShareRoutes(
|
|
29
|
-
//
|
|
29
|
+
// `any` on purpose: the caller's Hono env is theirs, and this
|
|
30
30
|
// module reads nothing off it — narrowing it would force every app to name its own Env
|
|
31
31
|
// here for no gain.
|
|
32
32
|
app, options) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-server",
|
|
3
|
-
"version": "4.40.
|
|
3
|
+
"version": "4.40.3",
|
|
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.",
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
"verify": "bun run gate",
|
|
26
26
|
"prepublishOnly": "bun run gate --force",
|
|
27
27
|
"test:pg": "bun run scripts/testPg.ts",
|
|
28
|
-
"gate": "bun run scripts/paths.ts --only gate.ts"
|
|
28
|
+
"gate": "bun run scripts/paths.ts --only gate.ts",
|
|
29
|
+
"lint": "biome check --error-on-warnings ."
|
|
29
30
|
},
|
|
30
31
|
"files": [
|
|
31
32
|
"dist",
|
|
@@ -352,7 +353,7 @@
|
|
|
352
353
|
"dependencies": {
|
|
353
354
|
"argon2id": "1.0.1",
|
|
354
355
|
"cursedbelt-core": "^2.1.1",
|
|
355
|
-
"cursedops": ">=0.8.1 <0.
|
|
356
|
+
"cursedops": ">=0.8.1 <0.13.0",
|
|
356
357
|
"cwip": "^4.6.0",
|
|
357
358
|
"jose": "^6.2.3"
|
|
358
359
|
},
|
|
@@ -387,7 +388,7 @@
|
|
|
387
388
|
}
|
|
388
389
|
},
|
|
389
390
|
"devDependencies": {
|
|
390
|
-
"@biomejs/biome": "^2.5.
|
|
391
|
+
"@biomejs/biome": "^2.5.14",
|
|
391
392
|
"@happy-dom/global-registrator": "^20.10.6",
|
|
392
393
|
"@node-rs/argon2": "^2.0.2",
|
|
393
394
|
"@types/bun": "^1.3.14",
|
|
@@ -408,6 +409,9 @@
|
|
|
408
409
|
{
|
|
409
410
|
"script": "paths"
|
|
410
411
|
},
|
|
412
|
+
{
|
|
413
|
+
"script": "lint"
|
|
414
|
+
},
|
|
411
415
|
{
|
|
412
416
|
"script": "typecheck"
|
|
413
417
|
},
|
|
@@ -290,6 +290,29 @@ describe('pulling D1 down — paged SELECTs, table by table, never an export', (
|
|
|
290
290
|
expect(row?.art_key).toBe('art/1');
|
|
291
291
|
});
|
|
292
292
|
|
|
293
|
+
test('🔴 a schema TRIGGER does not fire on the restored rows, and is in the snapshot after (collections, 2026-09-29)', async () => {
|
|
294
|
+
const dir = scratch();
|
|
295
|
+
const schema = `${SCHEMA}
|
|
296
|
+
CREATE TRIGGER items_stamp AFTER INSERT ON items BEGIN
|
|
297
|
+
INSERT INTO meta (key, value) VALUES ('v', 1) ON CONFLICT(key) DO UPDATE SET value = value + 1;
|
|
298
|
+
END;`;
|
|
299
|
+
const source = new Database(':memory:');
|
|
300
|
+
source.exec(schema);
|
|
301
|
+
source.exec("INSERT INTO items VALUES ('a', 'x'); INSERT INTO items VALUES ('b', 'y');");
|
|
302
|
+
await pullD1ToSqlite({
|
|
303
|
+
destination: join(dir, 'pulled.sqlite'),
|
|
304
|
+
database: 'collections',
|
|
305
|
+
schemaSql: schema,
|
|
306
|
+
requiredTable: 'items',
|
|
307
|
+
afterLoadSql: REBUILD,
|
|
308
|
+
wrangler: d1(source).run,
|
|
309
|
+
});
|
|
310
|
+
const db = opened(dir);
|
|
311
|
+
expect(Number((db.query("SELECT value FROM meta WHERE key = 'v'").get() as { value: unknown }).value)).toBe(2);
|
|
312
|
+
expect(db.query("SELECT name FROM sqlite_master WHERE type = 'trigger'").all()).toEqual([{ name: 'items_stamp' }]);
|
|
313
|
+
db.close();
|
|
314
|
+
});
|
|
315
|
+
|
|
293
316
|
test('🔴 a database without the required table is refused as the wrong one', async () => {
|
|
294
317
|
const dir = scratch();
|
|
295
318
|
await expect(
|
package/src/server/d1/pullD1.ts
CHANGED
|
@@ -346,6 +346,17 @@ export async function pullD1ToSqlite(options: PullD1Options): Promise<void> {
|
|
|
346
346
|
// `exec`, not `run`: each is many statements and `run` would execute only the first,
|
|
347
347
|
// leaving a file with a schema and no rows that looks perfectly valid.
|
|
348
348
|
db.exec(options.schemaSql);
|
|
349
|
+
// 🔴 The schema's TRIGGERS come off for the load and go back on after it. A trigger fires on
|
|
350
|
+
// every restored row as if it were a new write: measured 2026-09-29, collections' api-cache
|
|
351
|
+
// stamp triggers (AFTER INSERT ON items → a `meta` row) wrote `meta` during the `items` load,
|
|
352
|
+
// and `meta`'s own rows then refused with `UNIQUE constraint failed: meta.key` — every
|
|
353
|
+
// nightly backup of an app with such a trigger would have failed. D1's rows are already the
|
|
354
|
+
// triggers' results; the snapshot needs the triggers only for what happens AFTER it.
|
|
355
|
+
const triggers = db.query("SELECT name, sql FROM sqlite_master WHERE type = 'trigger' AND sql IS NOT NULL").all() as {
|
|
356
|
+
name: string;
|
|
357
|
+
sql: string;
|
|
358
|
+
}[];
|
|
359
|
+
for (const t of triggers) db.exec(`DROP TRIGGER ${sqlIdent(t.name)}`);
|
|
349
360
|
// 🔴 A table D1 holds that the schema does not declare is created from D1's OWN DDL, never
|
|
350
361
|
// dropped: measured on music's first post-cutover pull (2026-09-23), `art_lookups` — a
|
|
351
362
|
// cache its art job creates where it first needs it — made the whole pull refuse with
|
|
@@ -359,6 +370,7 @@ export async function pullD1ToSqlite(options: PullD1Options): Promise<void> {
|
|
|
359
370
|
}
|
|
360
371
|
// One table at a time, so a fault is located rather than smeared over the whole load.
|
|
361
372
|
for (const { text } of dumps) if (text) db.exec(text);
|
|
373
|
+
for (const t of triggers) db.exec(t.sql);
|
|
362
374
|
if (options.afterLoadSql) db.exec(options.afterLoadSql);
|
|
363
375
|
// 🔴 Every table holds as many rows as were read from D1, or this is not a backup: the check
|
|
364
376
|
// that makes a silently truncated load (task 2128's NUL padding, or whatever does it next)
|
|
@@ -208,7 +208,6 @@ describe("what a message may not contain or travel down", () => {
|
|
|
208
208
|
store.reconcile("ingest", [
|
|
209
209
|
{
|
|
210
210
|
...stuckIngest(),
|
|
211
|
-
// biome-ignore lint/suspicious/noExplicitAny: the point of the test is the wrong type
|
|
212
211
|
facts: { failed: 42, firstFile: "Mom's 60th birthday.mov" as any },
|
|
213
212
|
},
|
|
214
213
|
]),
|
|
@@ -316,7 +316,7 @@ export function createIncidentStore(db: Database): IncidentStore {
|
|
|
316
316
|
|
|
317
317
|
acknowledge(fp, now = Date.now()) {
|
|
318
318
|
const row = selectOne.get(fp) as Row | null;
|
|
319
|
-
if (
|
|
319
|
+
if (row?.state !== "open") return false;
|
|
320
320
|
db.prepare(
|
|
321
321
|
`UPDATE ${INCIDENTS_TABLE} SET state = 'acknowledged', acknowledged_at = ?
|
|
322
322
|
WHERE fingerprint = ?`,
|
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
MASTER_LOCK_STATE_HEADER,
|
|
12
12
|
type MasterLockKdfParams,
|
|
13
13
|
deriveMasterLockVerifier,
|
|
14
|
-
newMasterLockKdfParams,
|
|
15
14
|
} from "cursedbelt-core/master-lock";
|
|
16
15
|
import { createMasterLockGuard } from "./guard.js";
|
|
17
16
|
import { MasterLock } from "./masterLock.js";
|
|
@@ -107,7 +107,9 @@ describe("🔴 two devices in flight together (task 2138)", () => {
|
|
|
107
107
|
let pending: string | null = null;
|
|
108
108
|
const lock = new MasterLock({
|
|
109
109
|
store: record,
|
|
110
|
-
state: { read: () => seen, write: (json) =>
|
|
110
|
+
state: { read: () => seen, write: (json) => {
|
|
111
|
+
pending = json;
|
|
112
|
+
} },
|
|
111
113
|
seedJson: SEED,
|
|
112
114
|
});
|
|
113
115
|
return { lock, settle: () => pending !== null && createKvMasterLockStore(kv, MASTER_LOCK_STATE_KEY).write(pending) };
|
|
@@ -126,7 +126,7 @@ const isLevel = (v: unknown): v is ShareLevel => v === 'read' || v === 'write';
|
|
|
126
126
|
* ```
|
|
127
127
|
*/
|
|
128
128
|
export function mountShareRoutes<V extends ShareRouterViewer>(
|
|
129
|
-
//
|
|
129
|
+
// `any` on purpose: the caller's Hono env is theirs, and this
|
|
130
130
|
// module reads nothing off it — narrowing it would force every app to name its own Env
|
|
131
131
|
// here for no gain.
|
|
132
132
|
app: Hono<any>,
|
|
@@ -17,8 +17,6 @@ import { bytesStream } from 'cursedbelt-core/testing';
|
|
|
17
17
|
const CTX: MediaCtx = { userId: 'u1', tenantId: 't1' };
|
|
18
18
|
const OWNER = CTX.userId;
|
|
19
19
|
const stream = bytesStream;
|
|
20
|
-
const drain = async (s: ReadableStream<Uint8Array>): Promise<Uint8Array> =>
|
|
21
|
-
new Uint8Array(await new Response(s).arrayBuffer());
|
|
22
20
|
|
|
23
21
|
let db: Database;
|
|
24
22
|
let root: string;
|