cursedbelt-server 4.40.0 → 4.40.2

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.
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-server",
3
- "version": "4.40.0",
3
+ "version": "4.40.2",
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.",
@@ -352,7 +352,7 @@
352
352
  "dependencies": {
353
353
  "argon2id": "1.0.1",
354
354
  "cursedbelt-core": "^2.1.1",
355
- "cursedops": ">=0.8.1 <0.11.0",
355
+ "cursedops": ">=0.8.1 <0.12.0",
356
356
  "cwip": "^4.6.0",
357
357
  "jose": "^6.2.3"
358
358
  },
@@ -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(
@@ -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)