@bitcobblers/wod-wiki-storage 0.35.2116 → 0.35.2119

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.
@@ -1,19 +1,8 @@
1
- import { STORE_DEFS, extractKeyPart, isRange, DB_VERSION, DOMAIN_PROJECTION_VERSION, bufferToOps, stampUserOwned, bufferedGet, rangeFromIDB, isDirty, overlayRows, keyFromValue, bufferSlot, COLUMN_TYPES, keyInRange, compareKeys } from '../chunk-DAGT3P7M.mjs';
1
+ import { STORE_DEFS, StorageError, extractKeyPart, validateStorageRow, isRange, DB_VERSION, rowsEqual, validateStoredPins, DOMAIN_PROJECTION_VERSION, bufferToOps, stampUserOwned, bufferedGet, overlayRows, rangeFromIDB, isDirty, keyFromValue, bufferSlot, COLUMN_TYPES, keyInRange, compareKeys } from '../chunk-7ZCBM5NS.mjs';
2
+ export { StorageError } from '../chunk-7ZCBM5NS.mjs';
2
3
  import { catalogOfItem, sourceMatches } from '@bitcobblers/wod-wiki-wql';
3
4
  import { createClient } from '@libsql/client/web';
4
5
 
5
- // src/sql/errors.ts
6
- var StorageError = class extends Error {
7
- constructor(status, message, code) {
8
- super(message);
9
- this.status = status;
10
- this.code = code;
11
- this.name = "StorageError";
12
- }
13
- status;
14
- code;
15
- };
16
-
17
6
  // src/sql/connection.ts
18
7
  function libsqlRows(result) {
19
8
  return result.rows;
@@ -39,7 +28,8 @@ function libsqlDatabase(client) {
39
28
  query: (sql, params = []) => tx.execute({ sql, args: params }).then(libsqlRows),
40
29
  exec: async (sql, params = []) => {
41
30
  await tx.execute({ sql, args: params });
42
- }
31
+ },
32
+ batch: async (statements) => (await tx.batch(statements.map(({ sql, params = [] }) => ({ sql, args: params })))).map(libsqlRows)
43
33
  };
44
34
  const result = await fn(txConn);
45
35
  await tx.commit();
@@ -49,6 +39,10 @@ function libsqlDatabase(client) {
49
39
  await tx.rollback();
50
40
  } catch {
51
41
  }
42
+ const message = err instanceof Error ? err.message : String(err);
43
+ for (const code of ["revision_conflict", "transaction_conflict", "invalid_session_pin"]) {
44
+ if (message.includes(code)) throw new StorageError(409, message, code);
45
+ }
52
46
  throw err;
53
47
  } finally {
54
48
  tx.close();
@@ -91,16 +85,20 @@ function reviveBinary(value) {
91
85
  }
92
86
  return bytes.buffer;
93
87
  }
88
+ var canonicalJson = (_key, value) => {
89
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
90
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, Reflect.get(value, key)]));
91
+ };
94
92
  function encodeRowJson(value, binaryField) {
95
93
  if (binaryField && value !== null && typeof value === "object" && !Array.isArray(value)) {
96
94
  const row = value;
97
95
  const data = row[binaryField];
98
96
  if (data instanceof ArrayBuffer) {
99
97
  const bytes = new Uint8Array(data);
100
- return JSON.stringify({ ...row, [binaryField]: { [TAG]: bytesToBase64(bytes), [LEN]: bytes.length } });
98
+ return JSON.stringify({ ...row, [binaryField]: { [TAG]: bytesToBase64(bytes), [LEN]: bytes.length } }, canonicalJson);
101
99
  }
102
100
  }
103
- return JSON.stringify(value);
101
+ return JSON.stringify(value, canonicalJson);
104
102
  }
105
103
  function decodeRowJson(text2, binaryField) {
106
104
  const row = JSON.parse(text2);
@@ -277,6 +275,50 @@ async function getRow(conn, def, key) {
277
275
  );
278
276
  return rows[0] ? decodeRowJson(rows[0].data, binaryFieldOf(def)) : void 0;
279
277
  }
278
+ async function getRows(conn, def, keys) {
279
+ const found = /* @__PURE__ */ new Map();
280
+ for (const part of chunks(keys, 50)) {
281
+ const params = [];
282
+ const conditions = part.map((key) => {
283
+ const parts = toParts(key);
284
+ if (parts.length !== def.keyPath.length) throw new StorageError(400, "invalid compound key arity");
285
+ params.push(...parts.map(toSqlScalar));
286
+ return `(${keyCols(def).map((col) => `"${col}" = ?`).join(" AND ")})`;
287
+ });
288
+ for (const row of await conn.query(`SELECT "data" FROM "${def.name}" WHERE ${conditions.join(" OR ")}`, params)) {
289
+ const value = decodeRowJson(String(row.data), binaryFieldOf(def));
290
+ found.set(JSON.stringify(requireKey(def, value)), value);
291
+ }
292
+ }
293
+ return keys.map((key) => found.get(JSON.stringify(key)));
294
+ }
295
+ async function listRowsFromIndexMany(conn, def, indexName, keys) {
296
+ const idx = indexDefinition(def, indexName);
297
+ if (idx.multiEntry) {
298
+ const out = [];
299
+ for (const key of keys) out.push(await listRows(conn, def, { only: key }, void 0, indexName));
300
+ return out;
301
+ }
302
+ const found = /* @__PURE__ */ new Map();
303
+ for (const part of chunks(keys, 50)) {
304
+ const params = [];
305
+ const conditions = part.map((key) => {
306
+ const parts = toParts(key);
307
+ if (parts.length !== idx.keyPath.length) throw new StorageError(400, "invalid index key arity");
308
+ params.push(...parts.map(toSqlScalar));
309
+ return `(${parts.map((_, i) => `"${indexCol(idx.name, i)}" = ?`).join(" AND ")})`;
310
+ });
311
+ for (const row of await conn.query(`SELECT "data" FROM "${def.name}" WHERE ${conditions.join(" OR ")} ORDER BY ${keyCols(def).map((col) => `"${col}"`).join(", ")}`, params)) {
312
+ const value = decodeRowJson(String(row.data), binaryFieldOf(def));
313
+ const parts = idx.keyPath.map((path) => extractKeyPart(value, path));
314
+ const key = JSON.stringify(parts.length === 1 ? parts[0] : parts);
315
+ const bucket = found.get(key);
316
+ if (bucket) bucket.push(value);
317
+ else found.set(key, [value]);
318
+ }
319
+ }
320
+ return keys.map((key) => found.get(JSON.stringify(key)) ?? []);
321
+ }
280
322
  async function listRows(conn, def, range, count, indexName) {
281
323
  validateRange(range);
282
324
  const idx = indexName ? indexDefinition(def, indexName) : void 0;
@@ -334,51 +376,114 @@ async function countRows(conn, def, range, indexName) {
334
376
  const rows = await conn.query(`SELECT COUNT(*) AS "n" FROM "${def.name}"${cond}`, params);
335
377
  return Number(rows[0]?.n ?? 0);
336
378
  }
337
- async function putRow(conn, def, value) {
338
- const key = requireKey(def, value);
339
- const keyParts = toParts(key).map(toSqlScalar);
379
+ var PARAM_BUDGET = 2e4;
380
+ var ROW_CAP = 100;
381
+ var chunks = (arr, size) => {
382
+ const out = [];
383
+ for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
384
+ return out;
385
+ };
386
+ async function batchInsert(conn, table, cols, rows, onConflict) {
387
+ if (rows.length === 0) return;
388
+ const collist = cols.map((c) => `"${c}"`).join(", ");
389
+ const tuple = `(${cols.map(() => "?").join(", ")})`;
390
+ const per = Math.max(1, Math.min(ROW_CAP, Math.floor(PARAM_BUDGET / cols.length)));
391
+ const statements = chunks(rows, per).map((part) => ({
392
+ sql: `INSERT INTO "${table}" (${collist}) VALUES ${part.map(() => tuple).join(", ")} ${onConflict}`,
393
+ params: part.flat()
394
+ }));
395
+ if (conn.batch) await conn.batch(statements);
396
+ else for (const statement of statements) await conn.exec(statement.sql, statement.params);
397
+ }
398
+ async function deleteIn(conn, table, col, vals) {
399
+ for (const part of chunks(vals, 500)) {
400
+ if (part.length === 0) continue;
401
+ await conn.exec(`DELETE FROM "${table}" WHERE "${col}" IN (${part.map(() => "?").join(", ")})`, part);
402
+ }
403
+ }
404
+ async function putRows(conn, def, values, changedKeys) {
405
+ if (values.length === 0) return [];
406
+ for (const value of values) validateStorageRow(def.name, value);
340
407
  const keys = keyCols(def);
341
408
  const cols = [...keys];
342
- const vals = [...keyParts];
343
409
  for (const idx of def.indexes) {
344
410
  if (idx.multiEntry) continue;
345
- idx.keyPath.forEach((path, part) => {
346
- cols.push(indexCol(idx.name, part));
347
- vals.push(toSqlScalar(extractKeyPart(value, path) ?? null));
348
- });
411
+ idx.keyPath.forEach((_, part) => cols.push(indexCol(idx.name, part)));
349
412
  }
350
413
  cols.push("data");
351
- vals.push(encodeRowJson(value, binaryFieldOf(def)));
352
414
  const keySet = new Set(keys);
353
415
  const updates = cols.filter((c) => !keySet.has(c)).map((c) => `"${c}" = excluded."${c}"`).join(", ");
354
- const collist = cols.map((c) => `"${c}"`).join(", ");
355
- const placeholders = cols.map(() => "?").join(", ");
356
416
  const pk = keys.map((k) => `"${k}"`).join(", ");
357
- await conn.exec(
358
- `INSERT INTO "${def.name}" (${collist}) VALUES (${placeholders}) ON CONFLICT (${pk}) DO UPDATE SET ${updates}`,
359
- vals
360
- );
417
+ const rows = [];
418
+ const out = [];
419
+ const junction = [];
361
420
  const multi = multiEntryIndex(def);
362
- if (multi) {
363
- const jt = junctionTable(def, multi);
364
- const cond = keys.map((c) => `"${c}" = ?`).join(" AND ");
365
- await conn.exec(`DELETE FROM "${jt}" WHERE ${cond}`, keyParts);
366
- const items = arrayPart(value, multi.keyPath[0]);
367
- if (Array.isArray(items)) {
368
- const jcols = [...keys, "v"];
369
- const jcollist = jcols.map((c) => `"${c}"`).join(", ");
370
- const jph = jcols.map(() => "?").join(", ");
371
- for (const item of items) {
372
- await conn.exec(
373
- `INSERT INTO "${jt}" (${jcollist}) VALUES (${jph}) ON CONFLICT DO NOTHING`,
374
- [...keyParts, JSON.stringify(item)]
375
- );
421
+ for (const value of values) {
422
+ const k = requireKey(def, value);
423
+ const keyParts = toParts(k).map(toSqlScalar);
424
+ const vals = [...keyParts];
425
+ for (const idx of def.indexes) {
426
+ if (idx.multiEntry) continue;
427
+ idx.keyPath.forEach((path) => vals.push(toSqlScalar(extractKeyPart(value, path) ?? null)));
428
+ }
429
+ vals.push(encodeRowJson(value, binaryFieldOf(def)));
430
+ rows.push(vals);
431
+ out.push(k);
432
+ if (multi) {
433
+ const items = arrayPart(value, multi.keyPath[0]);
434
+ if (Array.isArray(items)) {
435
+ for (const item of items) junction.push([...keyParts, JSON.stringify(item)]);
376
436
  }
377
437
  }
378
438
  }
379
- return key;
439
+ const tuple = `(${cols.map(() => "?").join(", ")})`;
440
+ const per = Math.max(1, Math.min(ROW_CAP, Math.floor(PARAM_BUDGET / cols.length)));
441
+ const statements = chunks(rows, per).map((part) => ({
442
+ sql: `INSERT INTO "${def.name}" (${cols.map((col) => `"${col}"`).join(", ")}) VALUES ${part.map(() => tuple).join(", ")} ` + (def.name === "block_revisions" ? `ON CONFLICT (${pk}) DO NOTHING` : `ON CONFLICT (${pk}) DO UPDATE SET ${updates} WHERE "${def.name}"."data" <> excluded."data"`) + ` RETURNING ${keys.map((col) => `"${col}"`).join(", ")}`,
443
+ params: part.flat()
444
+ }));
445
+ let returned;
446
+ try {
447
+ if (conn.batch) returned = await conn.batch(statements);
448
+ else {
449
+ returned = [];
450
+ for (const statement of statements) returned.push(await conn.query(statement.sql, statement.params));
451
+ }
452
+ } catch (error) {
453
+ const message = error instanceof Error ? error.message : String(error);
454
+ for (const code of ["revision_conflict", "transaction_conflict", "invalid_session_pin"]) {
455
+ if (message.includes(code)) throw new StorageError(409, message, code);
456
+ }
457
+ throw error;
458
+ }
459
+ const changed = returned.flat().map((row) => {
460
+ const parts = keys.map((col) => row[col]);
461
+ return parts.length === 1 ? parts[0] : parts;
462
+ });
463
+ changedKeys?.push(...changed);
464
+ if (multi) {
465
+ const jt = junctionTable(def, multi);
466
+ const changedSet = new Set(changed.map((key) => JSON.stringify(key)));
467
+ const deletes = values.flatMap((value) => {
468
+ const key = requireKey(def, value);
469
+ if (!changedSet.has(JSON.stringify(key))) return [];
470
+ const items = arrayPart(value, multi.keyPath[0]) ?? [];
471
+ return [{
472
+ sql: `DELETE FROM "${jt}" WHERE ${keys.map((col) => `"${col}" = ?`).join(" AND ")}` + (items.length ? ` AND "v" NOT IN (${items.map(() => "?").join(", ")})` : ""),
473
+ params: [...toParts(key).map(toSqlScalar), ...items.map((item) => JSON.stringify(item))]
474
+ }];
475
+ });
476
+ if (conn.batch && deletes.length) await conn.batch(deletes);
477
+ else for (const statement of deletes) await conn.exec(statement.sql, statement.params);
478
+ await batchInsert(conn, jt, [...keys, "v"], junction.filter((row) => changedSet.has(JSON.stringify(keys.length === 1 ? row[0] : row.slice(0, keys.length)))), "ON CONFLICT DO NOTHING");
479
+ }
480
+ return out;
481
+ }
482
+ async function putRow(conn, def, value) {
483
+ return (await putRows(conn, def, [value]))[0];
380
484
  }
381
485
  async function deleteRows(conn, def, keyOrRange) {
486
+ if (def.name === "block_revisions") throw new StorageError(409, "block history may only be removed by explicit wipe", "revision_conflict");
382
487
  const multi = multiEntryIndex(def);
383
488
  if (!isRange(keyOrRange)) {
384
489
  const parts = toParts(keyOrRange);
@@ -411,14 +516,15 @@ async function deleteRows(conn, def, keyOrRange) {
411
516
  }
412
517
  await conn.exec(`DELETE FROM "${def.name}"${where}`, params);
413
518
  }
414
- async function clearRows(conn, def) {
519
+ async function clearRows(conn, def, wipe = false) {
520
+ if (def.name === "block_revisions" && !wipe) throw new StorageError(409, "block history may only be removed by explicit wipe", "revision_conflict");
415
521
  const multi = multiEntryIndex(def);
416
522
  if (multi) await conn.exec(`DELETE FROM "${junctionTable(def, multi)}"`);
417
523
  await conn.exec(`DELETE FROM "${def.name}"`);
418
524
  }
419
525
  async function wipeRows(conn) {
420
526
  for (const def of STORE_DEFS) {
421
- if (!def.system) await clearRows(conn, def);
527
+ if (!def.system) await clearRows(conn, def, true);
422
528
  }
423
529
  }
424
530
  function tableListSql(dialect) {
@@ -454,31 +560,75 @@ async function ensureStoreTables(conn, dialect, def) {
454
560
  );
455
561
  await conn.exec(`CREATE INDEX IF NOT EXISTS "${jt}__v" ON "${jt}" ("v")`);
456
562
  }
563
+ if (def.name === "notes" && dialect === "sqlite") {
564
+ await conn.exec(`CREATE TRIGGER IF NOT EXISTS "notes_revision_guard" BEFORE UPDATE ON "notes"
565
+ WHEN json_extract(NEW."data", '$.sourceRevision') < json_extract(OLD."data", '$.sourceRevision')
566
+ OR ((json_extract(NEW."data", '$.rawContent') IS NOT json_extract(OLD."data", '$.rawContent')
567
+ OR json_extract(NEW."data", '$.blocks') IS NOT json_extract(OLD."data", '$.blocks'))
568
+ AND json_extract(NEW."data", '$.sourceRevision') <= json_extract(OLD."data", '$.sourceRevision'))
569
+ BEGIN SELECT RAISE(ABORT, 'transaction_conflict: stale Note source revision'); END`);
570
+ }
571
+ if (def.name === "block_revisions" && dialect === "sqlite") {
572
+ await conn.exec(`CREATE TRIGGER IF NOT EXISTS "block_revision_guard" BEFORE INSERT ON "block_revisions"
573
+ WHEN EXISTS (SELECT 1 FROM "block_revisions" old WHERE old."k0" = NEW."k0" AND old."k1" = NEW."k1" AND old."k2" = NEW."k2" AND old."data" <> NEW."data")
574
+ BEGIN SELECT RAISE(ABORT, 'revision_conflict: immutable block revision collision'); END`);
575
+ }
576
+ if (def.name === "sessions" && dialect === "sqlite") {
577
+ await conn.exec(`CREATE TRIGGER IF NOT EXISTS "session_pin_guard" BEFORE UPDATE ON "sessions"
578
+ WHEN json_extract(NEW."data", '$.noteId') IS NOT json_extract(OLD."data", '$.noteId')
579
+ OR json_extract(NEW."data", '$.blockId') IS NOT json_extract(OLD."data", '$.blockId')
580
+ OR json_extract(NEW."data", '$.blockVersion') IS NOT json_extract(OLD."data", '$.blockVersion')
581
+ BEGIN SELECT RAISE(ABORT, 'invalid_session_pin: Session pin is immutable'); END`);
582
+ }
583
+ if (dialect === "postgres" && ["notes", "block_revisions", "sessions"].includes(def.name)) {
584
+ const condition = def.name === "block_revisions" ? `EXISTS (SELECT 1 FROM "block_revisions" old WHERE old."k0" = NEW."k0" AND old."k1" = NEW."k1" AND old."k2" = NEW."k2" AND old."data" <> NEW."data")` : def.name === "notes" ? `(NEW."data"::jsonb->>'sourceRevision')::bigint < (OLD."data"::jsonb->>'sourceRevision')::bigint OR ((NEW."data"::jsonb->'rawContent' IS DISTINCT FROM OLD."data"::jsonb->'rawContent' OR NEW."data"::jsonb->'blocks' IS DISTINCT FROM OLD."data"::jsonb->'blocks') AND (NEW."data"::jsonb->>'sourceRevision')::bigint <= (OLD."data"::jsonb->>'sourceRevision')::bigint)` : `NEW."data"::jsonb->'noteId' IS DISTINCT FROM OLD."data"::jsonb->'noteId' OR NEW."data"::jsonb->'blockId' IS DISTINCT FROM OLD."data"::jsonb->'blockId' OR NEW."data"::jsonb->'blockVersion' IS DISTINCT FROM OLD."data"::jsonb->'blockVersion'`;
585
+ const code = def.name === "block_revisions" ? "revision_conflict" : def.name === "notes" ? "transaction_conflict" : "invalid_session_pin";
586
+ const guard = `${def.name}_guard`;
587
+ await conn.exec(`CREATE OR REPLACE FUNCTION "${guard}"() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF ${condition} THEN RAISE EXCEPTION '${code}'; END IF; RETURN NEW; END; $$`);
588
+ await conn.exec(`DROP TRIGGER IF EXISTS "${guard}" ON "${def.name}"`);
589
+ await conn.exec(`CREATE TRIGGER "${guard}" BEFORE ${def.name === "block_revisions" ? "INSERT" : "UPDATE"} ON "${def.name}" FOR EACH ROW EXECUTE FUNCTION "${guard}"()`);
590
+ }
457
591
  }
458
592
  async function initializeStore(db) {
459
593
  await db.transaction(async (tx) => {
460
594
  await tx.exec(
461
595
  `CREATE TABLE IF NOT EXISTS "${SCHEMA_TABLE}" ("key" TEXT PRIMARY KEY, "value" TEXT NOT NULL)`
462
596
  );
597
+ const ignored = (name) => name.startsWith("_wodwiki_") || name.startsWith("sqlite_");
463
598
  const storeTables = new Set(STORE_DEFS.map((d) => d.name));
599
+ for (const def of STORE_DEFS) {
600
+ const multi = def.indexes.find((i) => i.multiEntry);
601
+ if (multi) storeTables.add(junctionTable(def, multi));
602
+ }
464
603
  const tableNames = new Set(
465
- (await tx.query(tableListSql(db.dialect))).map((r) => String(Object.values(r)[0]))
604
+ (await tx.query(tableListSql(db.dialect))).map((r) => String(Object.values(r)[0])).filter((n) => !ignored(n))
466
605
  );
467
606
  const guardRows = await tx.query(`SELECT "value" FROM "${SCHEMA_TABLE}" WHERE "key" = ?`, [SCHEMA_KEY]);
468
607
  let stored;
469
608
  if (guardRows[0]) {
470
609
  const guard = JSON.parse(String(guardRows[0].value));
471
- if (typeof guard.version !== "number") {
610
+ if (typeof guard.version !== "number" || !Number.isSafeInteger(guard.version) || guard.version < 0) {
472
611
  throw new StorageError(500, `corrupt ${SCHEMA_TABLE} guard row`, "bad_schema_guard");
473
612
  }
474
613
  stored = guard.version;
475
- if (stored !== DB_VERSION) {
614
+ if (stored > DB_VERSION) {
476
615
  throw new StorageError(
477
616
  500,
478
- `database schema v${stored} does not match this server (v${DB_VERSION}); use a compatible server or migrate a backed-up database`,
617
+ `database schema v${stored} is newer than this server supports (v${DB_VERSION}); refusing to downgrade`,
479
618
  "schema_incompatible"
480
619
  );
481
620
  }
621
+ if (stored < DB_VERSION) {
622
+ const missing = [...storeTables].filter((name) => !tableNames.has(name));
623
+ const unexpected = [...tableNames].filter((name) => name !== SCHEMA_TABLE && !storeTables.has(name));
624
+ if (missing.length > 0 || unexpected.length > 0) {
625
+ throw new StorageError(
626
+ 500,
627
+ `database schema v${stored} is incompatible with v${DB_VERSION} (missing tables: ${missing.join(", ") || "none"}; unrecognized tables: ${unexpected.join(", ") || "none"}); recreation required using an explicitly disposable database`,
628
+ "schema_incompatible"
629
+ );
630
+ }
631
+ }
482
632
  } else if ([...tableNames].some((name) => storeTables.has(name))) {
483
633
  throw new StorageError(
484
634
  500,
@@ -495,7 +645,7 @@ async function initializeStore(db) {
495
645
  }
496
646
  });
497
647
  }
498
- var TABLES = ["domain_note", "query_block", "segment_head"];
648
+ var TABLES = ["domain_note", "query_block"];
499
649
  var PROJECTION_GUARD_KEY = "domain_projection";
500
650
  function sourceItem(row) {
501
651
  const item = {};
@@ -514,7 +664,7 @@ function ensureDdl(db) {
514
664
  const n = db.dialect === "postgres" ? " DOUBLE PRECISION" : "";
515
665
  const t = db.dialect === "postgres" ? ' TEXT COLLATE "C"' : " TEXT";
516
666
  const ddl = [
517
- `CREATE TABLE IF NOT EXISTS "domain_note" ("note_id"${t} PRIMARY KEY NOT NULL, "note_type"${t}, "catalog"${t}, "source_id"${t}, "source_path"${t}, "title"${t} NOT NULL, "created_at"${n} NOT NULL, "date"${n}, "text_search"${t} NOT NULL, "journal"${n} NOT NULL, "payload"${t} NOT NULL)`,
667
+ `CREATE TABLE IF NOT EXISTS "domain_note" ("note_id"${t} PRIMARY KEY NOT NULL, "note_type"${t}, "catalog"${t}, "source_id"${t}, "source_path"${t}, "title"${t} NOT NULL, "title_search"${t} NOT NULL, "created_at"${n} NOT NULL, "date"${n}, "text_search"${t} NOT NULL, "journal"${n} NOT NULL, "payload"${t} NOT NULL)`,
518
668
  `CREATE INDEX IF NOT EXISTS "idx_domain_note_date" ON "domain_note" ("date", "note_id")`,
519
669
  `CREATE INDEX IF NOT EXISTS "idx_domain_note_created" ON "domain_note" ("created_at", "note_id")`,
520
670
  `CREATE INDEX IF NOT EXISTS "idx_domain_note_source_path" ON "domain_note" ("source_path")`,
@@ -523,169 +673,140 @@ function ensureDdl(db) {
523
673
  `CREATE INDEX IF NOT EXISTS "idx_query_block_parent" ON "query_block" ("parent_note_id", "ordinal")`,
524
674
  `CREATE INDEX IF NOT EXISTS "idx_query_block_content" ON "query_block" ("content_id")`,
525
675
  `CREATE INDEX IF NOT EXISTS "idx_query_block_source_path" ON "query_block" ("source_path")`,
526
- `CREATE INDEX IF NOT EXISTS "idx_query_block_source_note" ON "query_block" ("source_note_id")`,
527
- `CREATE TABLE IF NOT EXISTS "segment_head" ("id"${t} PRIMARY KEY NOT NULL, "note_id"${t} NOT NULL, "current_version"${n} NOT NULL, "position"${n}, "search_text"${t} NOT NULL)`,
528
- `CREATE INDEX IF NOT EXISTS "idx_segment_head_note" ON "segment_head" ("note_id", "id")`
676
+ `CREATE INDEX IF NOT EXISTS "idx_query_block_source_note" ON "query_block" ("source_note_id")`
529
677
  ];
530
678
  return db.transaction(async (tx) => {
531
679
  for (const sql of ddl) await tx.exec(sql);
532
680
  });
533
681
  }
534
- async function upsertDomainNote(conn, note) {
682
+ var DOMAIN_NOTE_COLS = ["note_id", "note_type", "catalog", "source_id", "source_path", "title", "title_search", "created_at", "date", "text_search", "journal", "payload"];
683
+ var QUERY_BLOCK_COLS = ["occurrence_id", "source_note_id", "parent_note_id", "content_id", "section_key", "ordinal", "data_type", "source_id", "source_path", "catalog", "effective_date", "created_at", "note_title", "normalized_text", "journal", "payload"];
684
+ var conflictUpdate = (cols, keys) => `ON CONFLICT (${keys.map((k) => `"${k}"`).join(", ")}) DO UPDATE SET ` + cols.filter((c) => !keys.includes(c)).map((c) => `"${c}" = excluded."${c}"`).join(",") + ` WHERE ` + cols.filter((col) => !keys.includes(col)).map((col) => `"${col}" IS DISTINCT FROM excluded."${col}"`).join(" OR ");
685
+ function domainNoteRow(note) {
535
686
  const id = text(note.id);
536
- if (!id) return;
687
+ if (!id) return void 0;
537
688
  const source = sourceItem(note);
538
- await conn.exec(
539
- `INSERT INTO "domain_note" ("note_id","note_type","catalog","source_id","source_path","title","created_at","date","text_search","journal","payload")
540
- VALUES (?,?,?,?,?,?,?,?,?,?,?)
541
- ON CONFLICT ("note_id") DO UPDATE SET "note_type"=excluded."note_type","catalog"=excluded."catalog","source_id"=excluded."source_id",
542
- "source_path"=excluded."source_path","title"=excluded."title","created_at"=excluded."created_at","date"=excluded."date",
543
- "text_search"=excluded."text_search","journal"=excluded."journal","payload"=excluded."payload"`,
544
- [
545
- id,
546
- T(note.type),
547
- T(catalogOfItem(source)),
548
- T(note.sourceId),
549
- T(note.sourcePath),
550
- T(note.title) ?? "",
551
- N(note.createdAt),
552
- num(note.date),
553
- (text(note.title) ?? "").toLowerCase(),
554
- sourceMatches(source, "journal") ? 1 : 0,
555
- encodeRowJson(note)
556
- ]
557
- );
558
- }
559
- async function healQueryBlocksForNote(conn, note) {
560
- const id = text(note.id);
561
- if (!id) return;
562
- const rows = await conn.query(
563
- `SELECT "occurrence_id" FROM "query_block"
564
- WHERE "source_note_id" = ? OR ("source_path" IS NOT NULL AND "source_path" = ?)`,
565
- [id, T(note.sourcePath)]
566
- );
567
- for (const row of rows) await refreshBlock(conn, String(row.occurrence_id));
568
- }
569
- async function upsertSegmentHead(conn, id, head) {
570
- await conn.exec(
571
- `INSERT INTO "segment_head" ("id","note_id","current_version","position","search_text")
572
- VALUES (?,?,?,?,?)
573
- ON CONFLICT ("id") DO UPDATE SET "note_id"=excluded."note_id","current_version"=excluded."current_version",
574
- "position"=excluded."position","search_text"=excluded."search_text"`,
575
- [
576
- id,
577
- T(head.noteId) ?? "",
578
- N(head.version),
579
- // Local latest-segments order is position ?? createdAt — same fallback here.
580
- num(head.position) ?? num(head.createdAt),
581
- (text(head.rawContent) ?? "").toLowerCase()
582
- ]
583
- );
584
- }
585
- async function identityBy(conn, col, val) {
586
- const rows = await conn.query(
587
- `SELECT "note_id", "date", "created_at" FROM "domain_note" WHERE "${col}" = ?`,
588
- [val]
589
- );
590
- return rows.map((r) => ({
591
- note_id: String(r.note_id),
592
- date: num(r.date),
593
- created_at: num(r.created_at) ?? 0
594
- }));
689
+ return [
690
+ id,
691
+ T(note.type),
692
+ T(catalogOfItem(source)),
693
+ T(note.sourceId),
694
+ T(note.sourcePath),
695
+ T(note.title) ?? "",
696
+ (text(note.title) ?? "").toLowerCase(),
697
+ N(note.createdAt),
698
+ num(note.date),
699
+ (text(note.rawContent) ?? "").toLowerCase(),
700
+ sourceMatches(source, "journal") ? 1 : 0,
701
+ encodeRowJson(note)
702
+ ];
595
703
  }
596
- async function resolveParent(conn, block) {
597
- const byPath = async (sp2) => {
598
- const rows = await identityBy(conn, "source_path", sp2);
599
- return rows.length === 1 ? rows[0] : void 0;
600
- };
601
- const nid = text(block.noteId);
602
- if (nid) {
603
- const rows = await identityBy(conn, "note_id", nid);
604
- if (rows.length === 1) return rows[0];
605
- if (rows.length > 1) return void 0;
606
- }
607
- const sp = text(block.sourcePath);
608
- return sp ? byPath(sp) : void 0;
704
+ async function upsertDomainNotes(conn, notes) {
705
+ const rows = notes.map(domainNoteRow).filter((r) => r !== void 0);
706
+ await batchInsert(conn, "domain_note", [...DOMAIN_NOTE_COLS], rows, conflictUpdate(DOMAIN_NOTE_COLS, ["note_id"]));
609
707
  }
610
- async function upsertQueryBlock(conn, block) {
708
+ function queryBlockRow(block, parent) {
611
709
  const id = text(block.id);
612
- if (!id) return;
710
+ if (!id) return void 0;
613
711
  const source = sourceItem(block);
614
712
  const createdAt = N(block.createdAt);
615
- const parent = await resolveParent(conn, block);
616
713
  const effective = parent ? parent.date ?? parent.created_at : createdAt;
617
- await conn.exec(
618
- `INSERT INTO "query_block" ("occurrence_id","source_note_id","parent_note_id","content_id","section_key","ordinal",
619
- "data_type","source_id","source_path","catalog","effective_date","created_at","note_title","normalized_text","journal","payload")
620
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
621
- ON CONFLICT ("occurrence_id") DO UPDATE SET "source_note_id"=excluded."source_note_id","parent_note_id"=excluded."parent_note_id",
622
- "content_id"=excluded."content_id","section_key"=excluded."section_key","ordinal"=excluded."ordinal",
623
- "data_type"=excluded."data_type","source_id"=excluded."source_id","source_path"=excluded."source_path",
624
- "catalog"=excluded."catalog","effective_date"=excluded."effective_date","created_at"=excluded."created_at",
625
- "note_title"=excluded."note_title","normalized_text"=excluded."normalized_text","journal"=excluded."journal",
626
- "payload"=excluded."payload"`,
627
- [
628
- id,
629
- T(block.noteId),
630
- parent?.note_id ?? null,
631
- T(block.blockContentId),
632
- T(block.segmentId),
633
- num(block.position),
634
- T(block.dataType),
635
- T(block.sourceId),
636
- T(block.sourcePath),
637
- T(catalogOfItem(source)),
638
- effective,
639
- createdAt,
640
- T(block.noteTitle),
641
- (text(block.rawContent) ?? "").toLowerCase(),
642
- sourceMatches(source, "journal") ? 1 : 0,
643
- encodeRowJson(block)
644
- ]
645
- );
714
+ return [
715
+ id,
716
+ T(block.noteId),
717
+ parent?.note_id ?? null,
718
+ T(block.blockContentId),
719
+ T(block.blockId),
720
+ num(block.position),
721
+ T(block.dataType),
722
+ T(block.sourceId),
723
+ T(block.sourcePath),
724
+ T(catalogOfItem(source)),
725
+ effective,
726
+ createdAt,
727
+ parent?.note.title ?? T(block.noteTitle),
728
+ (text(block.rawContent) ?? "").toLowerCase(),
729
+ sourceMatches(source, "journal") ? 1 : 0,
730
+ encodeRowJson(parent ? { ...block, noteTitle: parent.note.title } : block)
731
+ ];
646
732
  }
647
- var keyHead = (key) => Array.isArray(key) ? key[0] : key;
648
- async function refreshNote(conn, id) {
649
- const k = keyHead(id);
650
- const rows = await conn.query(`SELECT "data" FROM "notes" WHERE "k0" = ?`, [k]);
651
- const note = rows[0] ? decodeRowJson(String(rows[0].data)) : void 0;
652
- if (note) {
653
- await upsertDomainNote(conn, note);
654
- await healQueryBlocksForNote(conn, note);
655
- } else {
656
- const orphans = await conn.query(
657
- `SELECT "occurrence_id" FROM "query_block" WHERE "parent_note_id" = ? OR "source_note_id" = ?`,
658
- [k, k]
659
- );
660
- await conn.exec(`DELETE FROM "domain_note" WHERE "note_id" = ?`, [k]);
661
- for (const row of orphans) await refreshBlock(conn, String(row.occurrence_id));
662
- }
733
+ async function upsertQueryBlocks(conn, blocks) {
734
+ const rows = blocks.map(([b, p]) => queryBlockRow(b, p)).filter((r) => r !== void 0);
735
+ await batchInsert(conn, "query_block", [...QUERY_BLOCK_COLS], rows, conflictUpdate(QUERY_BLOCK_COLS, ["occurrence_id"]));
663
736
  }
664
- async function refreshSegment(conn, id) {
665
- const k = keyHead(id);
666
- const rows = await conn.query(`SELECT "data" FROM "segments" WHERE "k0" = ?`, [k]);
667
- let head;
668
- let headVersion = -Infinity;
669
- for (const row of rows) {
670
- const seg = decodeRowJson(String(row.data));
671
- const v = num(seg.version) ?? 0;
672
- if (!head || v > headVersion) {
673
- head = seg;
674
- headVersion = v;
675
- }
737
+ function indexNoteIdentities(notes) {
738
+ const byNoteId = /* @__PURE__ */ new Map();
739
+ for (const note of notes) {
740
+ const id = text(note.id);
741
+ if (id) byNoteId.set(id, { note_id: id, date: num(note.date), created_at: num(note.createdAt) ?? 0, note });
676
742
  }
677
- if (head) await upsertSegmentHead(conn, k, head);
678
- else await conn.exec(`DELETE FROM "segment_head" WHERE "id" = ?`, [k]);
743
+ return { byNoteId };
679
744
  }
680
- async function refreshBlock(conn, id) {
681
- const k = keyHead(id);
682
- const rows = await conn.query(`SELECT "data" FROM "block_index" WHERE "k0" = ?`, [k]);
683
- if (rows[0]) await upsertQueryBlock(conn, decodeRowJson(String(rows[0].data)));
684
- else await conn.exec(`DELETE FROM "query_block" WHERE "occurrence_id" = ?`, [k]);
745
+ function resolveParentFrom(index, block) {
746
+ const parent = typeof block.noteId === "string" ? index.byNoteId.get(block.noteId) : void 0;
747
+ const mapped = typeof block.blockId === "string" ? parent?.note.blocks[block.blockId] : void 0;
748
+ return parent && mapped && block.id === `${parent.note_id}:${mapped.id}` && block.blockVersion === mapped.version && block.dataType === mapped.kind && block.sourceRevision === parent.note.sourceRevision && block.from === mapped.from && block.to === mapped.to && block.rawContent === parent.note.rawContent.slice(mapped.from, mapped.to) ? parent : void 0;
749
+ }
750
+ async function fetchParentIndex(conn, noteIds) {
751
+ const notes = await getRows(conn, storeDefinition("notes"), noteIds);
752
+ return indexNoteIdentities(notes.filter((row) => row !== void 0));
753
+ }
754
+ var keyHead = (key) => Array.isArray(key) ? key[0] : key;
755
+ async function selectIn(conn, selectCol, table, col, vals) {
756
+ const IN_CHUNK = 500;
757
+ const out = [];
758
+ for (let i = 0; i < vals.length; i += IN_CHUNK) {
759
+ const part = vals.slice(i, i + IN_CHUNK);
760
+ const rows = await conn.query(`SELECT "${selectCol}" FROM "${table}" WHERE "${col}" IN (${part.map(() => "?").join(", ")})`, part);
761
+ for (const r of rows) out.push(String(r[selectCol]));
762
+ }
763
+ return out;
764
+ }
765
+ async function healQueryBlocksForNotes(conn, noteIds) {
766
+ const ids = await selectIn(conn, "occurrence_id", "query_block", "source_note_id", noteIds);
767
+ await refreshBlocks(conn, [...new Set(ids)]);
768
+ }
769
+ async function refreshNotes(conn, ids) {
770
+ const ks = [...new Set(ids.map((id) => String(keyHead(id))))];
771
+ if (ks.length === 0) return;
772
+ const found = /* @__PURE__ */ new Map();
773
+ for (let i = 0; i < ks.length; i += 500) {
774
+ const part = ks.slice(i, i + 500);
775
+ const rows = await conn.query(`SELECT "k0", "data" FROM "notes" WHERE "k0" IN (${part.map(() => "?").join(", ")})`, part);
776
+ for (const row of rows) found.set(String(row.k0), decodeRowJson(String(row.data)));
777
+ }
778
+ const present = [];
779
+ const missing = [];
780
+ for (const k of ks) {
781
+ const note = found.get(k);
782
+ if (note) present.push(note);
783
+ else missing.push(k);
784
+ }
785
+ await upsertDomainNotes(conn, present);
786
+ const presentIds = present.map((n) => text(n.id)).filter((x) => !!x);
787
+ await healQueryBlocksForNotes(conn, presentIds);
788
+ await deleteIn(conn, "domain_note", "note_id", missing);
789
+ await deleteIn(conn, "query_block", "source_note_id", missing);
790
+ await deleteIn(conn, "block_index", "i_by_note_0", missing);
791
+ await deleteIn(conn, "block_efforts", "i_by_note_0", missing);
792
+ }
793
+ async function refreshBlocks(conn, ids) {
794
+ const ks = [...new Set(ids.map((id) => String(keyHead(id))))];
795
+ if (ks.length === 0) return;
796
+ const found = /* @__PURE__ */ new Map();
797
+ for (let i = 0; i < ks.length; i += 500) {
798
+ const part = ks.slice(i, i + 500);
799
+ const rows = await conn.query(`SELECT "k0", "data" FROM "block_index" WHERE "k0" IN (${part.map(() => "?").join(", ")})`, part);
800
+ for (const row of rows) found.set(String(row.k0), decodeRowJson(String(row.data)));
801
+ }
802
+ const blocks = [...found.values()];
803
+ const noteIds = [...new Set(blocks.map((b) => text(b.noteId)).filter((x) => !!x))];
804
+ const index = await fetchParentIndex(conn, noteIds);
805
+ await upsertQueryBlocks(conn, blocks.map((b) => [b, resolveParentFrom(index, b)]));
806
+ await deleteIn(conn, "query_block", "occurrence_id", ks.filter((k) => !found.has(k)));
685
807
  }
686
808
  var TRACKED = {
687
809
  notes: "notes",
688
- segments: "segments",
689
810
  block_index: "blocks"
690
811
  };
691
812
  async function deleteTargetKeys(conn, def, target) {
@@ -693,29 +814,54 @@ async function deleteTargetKeys(conn, def, target) {
693
814
  const rows = await listRows(conn, def, target);
694
815
  return rows.map((row) => keyFromValue(def, row));
695
816
  }
696
- async function refreshDomain(conn, ops, opts = {}) {
817
+ async function refreshDomain(conn, ops) {
697
818
  const impact = {
698
819
  notes: [],
699
- segments: [],
700
820
  blocks: [],
821
+ pinnedNotes: [],
822
+ pinnedSessions: [],
701
823
  notesClear: false,
702
- segmentsClear: false,
703
824
  blocksClear: false
704
825
  };
705
826
  const results = [];
827
+ for (const op of ops) if (op === null || typeof op !== "object" || Array.isArray(op)) throw new StorageError(400, "tx op must be an object");
828
+ const checks = ops.filter((op) => op.op === "check");
829
+ const checkStores = [...new Set(checks.map((op) => op.store))];
830
+ for (const store of checkStores) {
831
+ const scoped = checks.filter((op) => op.store === store);
832
+ const actual = await getRows(conn, storeDefinition(store), scoped.map((op) => op.key));
833
+ scoped.forEach((op, i) => {
834
+ if (!rowsEqual(actual[i] ?? null, op.value)) throw new StorageError(409, "transaction read changed before commit", "transaction_conflict");
835
+ });
836
+ }
837
+ let pending;
838
+ const flushPuts = async () => {
839
+ if (!pending) return;
840
+ const batch = pending;
841
+ pending = void 0;
842
+ if (batch.values.length === 0) return;
843
+ const changed = [];
844
+ const keys = await putRows(conn, batch.def, batch.values, changed);
845
+ const slot = TRACKED[batch.def.name];
846
+ if (slot) impact[slot].push(...changed);
847
+ const changedSet = new Set(changed.map((key) => JSON.stringify(key)));
848
+ if (batch.def.name === "notes") impact.pinnedNotes.push(...batch.values.filter((value) => changedSet.has(JSON.stringify(keyFromValue(batch.def, value)))));
849
+ if (batch.def.name === "sessions") impact.pinnedSessions.push(...batch.values.filter((value) => changedSet.has(JSON.stringify(keyFromValue(batch.def, value)))));
850
+ results.push(...keys);
851
+ };
706
852
  for (const op of ops) {
707
- if (op === null || typeof op !== "object" || Array.isArray(op)) {
708
- throw new StorageError(400, "tx op must be an object");
853
+ if (op.op === "check") {
854
+ results.push(null);
855
+ continue;
709
856
  }
710
857
  const store = "store" in op ? op.store : void 0;
711
858
  const def = storeDefinition(typeof store === "string" ? store : String(store));
712
859
  const kind = typeof op.op === "string" ? op.op : void 0;
713
860
  if (op.op === "put") {
714
- const key = await putRow(conn, def, op.value);
715
- const slot = TRACKED[def.name];
716
- if (slot) impact[slot].push(key);
717
- results.push(key);
861
+ if (pending && pending.def.name !== def.name) await flushPuts();
862
+ (pending ??= { def, values: [] }).values.push(op.value);
718
863
  } else if (op.op === "delete") {
864
+ await flushPuts();
719
865
  const slot = TRACKED[def.name];
720
866
  if (slot) {
721
867
  for (const key of await deleteTargetKeys(conn, def, op.key)) impact[slot].push(key);
@@ -723,28 +869,32 @@ async function refreshDomain(conn, ops, opts = {}) {
723
869
  await deleteRows(conn, def, op.key);
724
870
  results.push(null);
725
871
  } else if (op.op === "clear") {
872
+ await flushPuts();
726
873
  await clearRows(conn, def);
727
874
  if (def.name === "notes") impact.notesClear = true;
728
- else if (def.name === "segments") impact.segmentsClear = true;
729
875
  else if (def.name === "block_index") impact.blocksClear = true;
730
876
  results.push(null);
731
877
  } else {
732
- throw new StorageError(400, `tx op must be put | delete | clear, got: ${String(kind)}`);
878
+ throw new StorageError(400, `tx op must be check | put | delete | clear, got: ${String(kind)}`);
733
879
  }
734
880
  }
735
- if (!opts.deferProjection) await refresh(conn, impact);
881
+ await flushPuts();
882
+ const finalNotes = (await getRows(conn, storeDefinition("notes"), [...new Set(impact.pinnedNotes.map((note) => note.id))])).filter(Boolean);
883
+ const finalSessions = (await getRows(conn, storeDefinition("sessions"), [...new Set(impact.pinnedSessions.map((session) => session.id))])).filter(Boolean);
884
+ await validateStoredPins(finalNotes, finalSessions, (keys) => getRows(conn, storeDefinition("block_revisions"), keys));
885
+ await refresh(conn, impact);
736
886
  return results;
737
887
  }
738
888
  async function refresh(conn, impact) {
739
889
  if (impact.notesClear) {
740
890
  await conn.exec(`DELETE FROM "domain_note"`);
741
- await conn.exec(`UPDATE "query_block" SET "parent_note_id" = NULL`);
891
+ await conn.exec(`DELETE FROM "query_block" WHERE "source_note_id" NOT IN (SELECT "k0" FROM "notes")`);
892
+ await conn.exec(`DELETE FROM "block_index" WHERE "i_by_note_0" NOT IN (SELECT "k0" FROM "notes")`);
893
+ await conn.exec(`DELETE FROM "block_efforts" WHERE "i_by_note_0" NOT IN (SELECT "k0" FROM "notes")`);
742
894
  }
743
- if (impact.segmentsClear) await conn.exec(`DELETE FROM "segment_head"`);
744
895
  if (impact.blocksClear) await conn.exec(`DELETE FROM "query_block"`);
745
- for (const id of impact.notes) await refreshNote(conn, id);
746
- for (const key of impact.segments) await refreshSegment(conn, key);
747
- for (const id of impact.blocks) await refreshBlock(conn, id);
896
+ await refreshNotes(conn, impact.notes);
897
+ await refreshBlocks(conn, impact.blocks);
748
898
  }
749
899
  async function rebuildProjections(conn) {
750
900
  await conn.exec(
@@ -752,18 +902,12 @@ async function rebuildProjections(conn) {
752
902
  );
753
903
  for (const t of TABLES) await conn.exec(`DELETE FROM "${t}"`);
754
904
  const notes = await conn.query(`SELECT "data" FROM "notes"`);
755
- for (const row of notes) await upsertDomainNote(conn, decodeRowJson(String(row.data)));
756
- const segments = await conn.query(`SELECT "k0", "data" FROM "segments"`);
757
- const heads = /* @__PURE__ */ new Map();
758
- for (const row of segments) {
759
- const seg = decodeRowJson(String(row.data));
760
- const v = num(seg.version) ?? 0;
761
- const prev = heads.get(String(row.k0));
762
- if (!prev || v > prev.version) heads.set(String(row.k0), { id: row.k0, row: seg, version: v });
763
- }
764
- for (const head of heads.values()) await upsertSegmentHead(conn, head.id, head.row);
905
+ const noteObjs = notes.map((row) => decodeRowJson(String(row.data)));
906
+ await upsertDomainNotes(conn, noteObjs);
765
907
  const blocks = await conn.query(`SELECT "data" FROM "block_index"`);
766
- for (const row of blocks) await upsertQueryBlock(conn, decodeRowJson(String(row.data)));
908
+ const blockObjs = blocks.map((row) => decodeRowJson(String(row.data)));
909
+ const index = indexNoteIdentities(noteObjs);
910
+ await upsertQueryBlocks(conn, blockObjs.map((b) => [b, resolveParentFrom(index, b)]));
767
911
  await conn.exec(
768
912
  `INSERT INTO "${SCHEMA_TABLE}" ("key", "value") VALUES (?, ?) ON CONFLICT ("key") DO UPDATE SET "value" = excluded."value"`,
769
913
  [PROJECTION_GUARD_KEY, JSON.stringify({ version: DOMAIN_PROJECTION_VERSION })]
@@ -1000,13 +1144,8 @@ function predicateSql(ctx, p, plan) {
1000
1144
  p.negate === true,
1001
1145
  blocks ? `(q."content_id" IS NOT NULL AND EXISTS (SELECT 1 FROM "block_efforts" be WHERE be."i_by_block_0" = q."content_id" AND be."i_by_effort_0" IN (${p.values.map((v) => ctx.bind(v)).join(", ")})))` : `EXISTS (SELECT 1 FROM "block_efforts" be WHERE be."i_by_note_0" = ${noteId} AND be."i_by_effort_0" IN (${p.values.map((v) => ctx.bind(v)).join(", ")}))`
1002
1146
  );
1003
- case "text": {
1004
- const textCol = blocks ? 'q."normalized_text"' : 'n."text_search"';
1005
- const title = ctx.contains(textCol, p.value);
1006
- if (plan !== "entries") return title;
1007
- const body = ctx.contains('h."search_text"', p.value);
1008
- return `(${title} OR EXISTS (SELECT 1 FROM "segment_head" h JOIN "segments" s ON s."k0" = h."id" AND s."k1" = h."current_version" WHERE h."note_id" = ${noteId} AND COALESCE(s."i_by_history_0", 0) = 0 AND ${body}))`;
1009
- }
1147
+ case "text":
1148
+ return blocks ? ctx.contains('q."normalized_text"', p.value) : `(${ctx.contains('n."title_search"', p.value)} OR ${ctx.contains('n."text_search"', p.value)})`;
1010
1149
  case "date": {
1011
1150
  const expr = blocks ? 'q."effective_date"' : 'COALESCE(n."date", n."created_at")';
1012
1151
  const lo = ctx.bind(p.start);
@@ -1050,7 +1189,7 @@ async function gate(conn) {
1050
1189
  if (stale > 0) {
1051
1190
  throw new StorageError(
1052
1191
  409,
1053
- `domain projections stale: ${stale} block rows have unresolved static parents`,
1192
+ `domain projections stale: ${stale} block rows have invalid or unresolved current parents`,
1054
1193
  "domain_projection_unavailable"
1055
1194
  );
1056
1195
  }
@@ -1069,47 +1208,43 @@ async function execute(conn, q, pg) {
1069
1208
  const fullWhere = selWhere && filterWhere ? `${selWhere} AND ${filterWhere.replace(/^ WHERE /, "")}` : selWhere || filterWhere;
1070
1209
  const count = async (where, params) => Number((await conn.query(`SELECT COUNT(*) AS "n" FROM ${table}${where}`, params))[0]?.n ?? 0);
1071
1210
  const selectedCount = await count(selWhere, selCtx.params);
1072
- const matchedCount = await count(fullWhere, allParams);
1211
+ const matchedCount = q.filters.length === 0 ? selectedCount : await count(fullWhere, allParams);
1073
1212
  const ordering = orderSql(q.order, plan);
1074
1213
  const paging = pagingSql(q, pg);
1075
1214
  if (plan !== "entries") {
1076
1215
  const pageSql2 = `SELECT "payload" FROM ${table}${fullWhere} ORDER BY ${ordering}${paging}`;
1077
1216
  const rows2 = (await conn.query(pageSql2, allParams)).map((r) => decodeRowJson(String(r.payload)));
1078
- const result2 = { projectionVersion: DOMAIN_PROJECTION_VERSION, selectedCount, matchedCount, plan, rows: rows2 };
1079
- return result2;
1217
+ const result = { projectionVersion: DOMAIN_PROJECTION_VERSION, selectedCount, matchedCount, plan, rows: rows2 };
1218
+ return result;
1080
1219
  }
1081
1220
  const pageSql = `SELECT "note_id", "payload" FROM ${table}${fullWhere} ORDER BY ${ordering}${paging}`;
1082
1221
  const noteRows = await conn.query(pageSql, allParams);
1083
- const relations = [
1084
- `SELECT h."note_id" AS "note_id", s."data" AS "payload" FROM selected n
1085
- JOIN "segment_head" h ON h."note_id" = n."note_id"
1086
- JOIN "segments" s ON s."k0" = h."id" AND s."k1" = h."current_version"
1087
- WHERE COALESCE(s."i_by_history_0", 0) = 0
1088
- ORDER BY h."position" ASC NULLS LAST, s."k0"`,
1089
- `SELECT nt."i_by_note_0" AS "note_id", t."data" AS "payload" FROM selected n
1090
- JOIN "note_tags" nt ON nt."i_by_note_0" = n."note_id"
1091
- JOIN "tags" t ON t."k0" = nt."i_by_tag_0" ORDER BY t."k0"`,
1092
- `SELECT p."i_by_note_0" AS "note_id", p."data" AS "payload" FROM selected n
1093
- JOIN "page_notes" p ON p."i_by_note_0" = n."note_id" ORDER BY p."k0"`,
1094
- `SELECT DISTINCT p."i_by_note_0" AS "note_id", pg."data" AS "payload" FROM selected n
1095
- JOIN "page_notes" p ON p."i_by_note_0" = n."note_id"
1096
- JOIN "page" pg ON pg."k0" = p."i_by_page_0" ORDER BY pg."data"`
1097
- ];
1098
- const buckets = noteRows.map(() => [[], [], [], []]);
1222
+ const buckets = noteRows.map(() => [[], [], []]);
1099
1223
  const index = /* @__PURE__ */ new Map();
1100
- noteRows.forEach((r, i) => index.set(String(r.note_id), i));
1101
- for (const [rel, relation] of relations.entries()) {
1102
- for (const row of await conn.query(`WITH selected AS (${pageSql}) ${relation}`, allParams)) {
1224
+ noteRows.forEach((row, i) => index.set(String(row.note_id), i));
1225
+ for (let start = 0; start < noteRows.length; start += 500) {
1226
+ const ids = noteRows.slice(start, start + 500).map((row) => String(row.note_id));
1227
+ const placeholders = ids.map(() => "?").join(", ");
1228
+ const statements = [
1229
+ { sql: `SELECT nt."i_by_note_0" AS "note_id", t."data" AS "payload" FROM "note_tags" nt JOIN "tags" t ON t."k0" = nt."i_by_tag_0" WHERE nt."i_by_note_0" IN (${placeholders}) ORDER BY t."k0"`, params: ids },
1230
+ { sql: `SELECT p."i_by_note_0" AS "note_id", p."data" AS "payload" FROM "page_notes" p WHERE p."i_by_note_0" IN (${placeholders}) ORDER BY p."k0"`, params: ids },
1231
+ { sql: `SELECT DISTINCT p."i_by_note_0" AS "note_id", pg."data" AS "payload" FROM "page_notes" p JOIN "page" pg ON pg."k0" = p."i_by_page_0" WHERE p."i_by_note_0" IN (${placeholders}) ORDER BY pg."data"`, params: ids }
1232
+ ];
1233
+ const relations = conn.batch ? await conn.batch(statements) : await (async () => {
1234
+ const rows2 = [];
1235
+ for (const statement of statements) rows2.push(await conn.query(statement.sql, statement.params));
1236
+ return rows2;
1237
+ })();
1238
+ relations.forEach((rows2, relation) => rows2.forEach((row) => {
1103
1239
  const i = index.get(String(row.note_id));
1104
- if (i !== void 0) buckets[i][rel].push(decodeRowJson(String(row.payload)));
1105
- }
1240
+ if (i !== void 0) buckets[i][relation].push(decodeRowJson(String(row.payload)));
1241
+ }));
1106
1242
  }
1107
- const rows = noteRows.map((r, i) => {
1108
- const [segments, tags, links, pages] = buckets[i];
1109
- return { note: decodeRowJson(String(r.payload)), segments, tags, links, pages };
1243
+ const rows = noteRows.map((row, i) => {
1244
+ const [tags, links, pages] = buckets[i];
1245
+ return { note: decodeRowJson(String(row.payload)), tags, links, pages };
1110
1246
  });
1111
- const result = { projectionVersion: DOMAIN_PROJECTION_VERSION, selectedCount, matchedCount, plan, rows };
1112
- return result;
1247
+ return { projectionVersion: DOMAIN_PROJECTION_VERSION, selectedCount, matchedCount, plan, rows };
1113
1248
  }
1114
1249
  async function handleDomainQuery(db, body) {
1115
1250
  const query = parseQuery(body);
@@ -1176,18 +1311,14 @@ var SqlStorage = class {
1176
1311
  }
1177
1312
  async transaction(stores, mode, fn) {
1178
1313
  await this.ensureReady();
1179
- return this.runTx(stores, mode === "readwrite", fn, false);
1314
+ return this.runTx(stores, mode === "readwrite", fn);
1180
1315
  }
1181
- /**
1182
- * Bulk load for migration destinations: like transaction(), but the
1183
- * buffered writes skip per-key projection refresh and derived state is
1184
- * rebuilt once — everything inside one SQL transaction (throw → rollback).
1185
- */
1316
+ /** Bulk writes use the same affected-key refresh as ordinary transactions. */
1186
1317
  async bulkTransaction(stores, fn) {
1187
1318
  await this.ensureReady();
1188
- return this.runTx(stores, true, fn, true);
1319
+ return this.runTx(stores, true, fn);
1189
1320
  }
1190
- runTx(stores, mutableMode, fn, deferProjection) {
1321
+ runTx(stores, mutableMode, fn) {
1191
1322
  const scoped = new Set(stores);
1192
1323
  const buffer = /* @__PURE__ */ new Map();
1193
1324
  const cleared = /* @__PURE__ */ new Set();
@@ -1213,8 +1344,7 @@ var SqlStorage = class {
1213
1344
  const result = await fn(tx);
1214
1345
  const ops = bufferToOps(STORE_DEFS.map((d) => d.name), buffer, cleared);
1215
1346
  if (ops.length > 0) {
1216
- await refreshDomain(conn, ops, deferProjection ? { deferProjection: true } : {});
1217
- if (deferProjection) await rebuildProjections(conn);
1347
+ await refreshDomain(conn, ops);
1218
1348
  }
1219
1349
  return result;
1220
1350
  });
@@ -1289,6 +1419,20 @@ var SqlStore = class {
1289
1419
  if (this.cleared?.has(this.store)) return void 0;
1290
1420
  return getRow(this.conn, this.def, k);
1291
1421
  }
1422
+ async getMany(keys) {
1423
+ await this.owner.ensureReady();
1424
+ const normalized = keys.map((key) => this.pointKey(key));
1425
+ const committed = this.cleared?.has(this.store) ? keys.map(() => void 0) : await getRows(this.conn, this.def, normalized);
1426
+ return normalized.map((key, i) => {
1427
+ const state = bufferedGet(this.store, key, this.buffer);
1428
+ return state ? state.hit ? state.value : void 0 : committed[i];
1429
+ });
1430
+ }
1431
+ async getAllFromIndexMany(indexName, keys) {
1432
+ await this.owner.ensureReady();
1433
+ const rows = await listRowsFromIndexMany(this.conn, this.def, indexName, keys.map((key) => this.pointKey(key)));
1434
+ return rows.map((values, i) => overlayRows(this.def, this.store, this.buffer, this.cleared, values, void 0, { only: this.pointKey(keys[i]) }, indexName));
1435
+ }
1292
1436
  async getAll(query, count) {
1293
1437
  await this.owner.ensureReady();
1294
1438
  const range = rangeFromIDB(query);
@@ -1320,6 +1464,10 @@ var SqlStore = class {
1320
1464
  this.assertMutable();
1321
1465
  const stored = this.owner.stamp(this.store, value);
1322
1466
  const key = keyFromValue(this.def, stored);
1467
+ if (this.store === "block_revisions") {
1468
+ const previous = this.buffer?.get(bufferSlot(this.store, key));
1469
+ if (previous?.kind === "put" && !rowsEqual(previous.value, stored)) throw new StorageError(409, "immutable block revision collision", "revision_conflict");
1470
+ }
1323
1471
  if (this.buffer) {
1324
1472
  this.buffer.set(bufferSlot(this.store, key), { kind: "put", value: stored });
1325
1473
  return key;
@@ -1327,9 +1475,28 @@ var SqlStore = class {
1327
1475
  await this.owner.applyOps([{ op: "put", store: this.store, value: stored }]);
1328
1476
  return key;
1329
1477
  }
1478
+ async putMany(values, _keys) {
1479
+ await this.owner.ensureReady();
1480
+ this.assertMutable();
1481
+ const stored = values.map((value) => this.owner.stamp(this.store, value));
1482
+ const keys = stored.map((value) => keyFromValue(this.def, value));
1483
+ if (this.store === "block_revisions") {
1484
+ const seen = /* @__PURE__ */ new Map();
1485
+ keys.forEach((key, i) => {
1486
+ const slot = bufferSlot(this.store, key), previous = this.buffer?.get(slot);
1487
+ const value = seen.has(slot) ? seen.get(slot) : previous?.kind === "put" ? previous.value : void 0;
1488
+ if (value !== void 0 && !rowsEqual(value, stored[i])) throw new StorageError(409, "immutable block revision collision", "revision_conflict");
1489
+ seen.set(slot, stored[i]);
1490
+ });
1491
+ }
1492
+ if (this.buffer) keys.forEach((key, i) => this.buffer.set(bufferSlot(this.store, key), { kind: "put", value: stored[i] }));
1493
+ else await this.owner.applyOps(stored.map((value) => ({ op: "put", store: this.store, value })));
1494
+ return keys;
1495
+ }
1330
1496
  async delete(key) {
1331
1497
  await this.owner.ensureReady();
1332
1498
  this.assertMutable();
1499
+ if (this.store === "block_revisions") throw new StorageError(409, "block history requires explicit wipe", "revision_conflict");
1333
1500
  const range = isRange(key) ? key : void 0;
1334
1501
  const target = range ?? key;
1335
1502
  if (this.buffer) {
@@ -1349,6 +1516,7 @@ var SqlStore = class {
1349
1516
  async clear() {
1350
1517
  await this.owner.ensureReady();
1351
1518
  this.assertMutable();
1519
+ if (this.store === "block_revisions") throw new StorageError(409, "block history requires explicit wipe", "revision_conflict");
1352
1520
  if (this.buffer) {
1353
1521
  this.cleared?.add(this.store);
1354
1522
  const prefix = `${this.store}\0`;
@@ -1404,6 +1572,291 @@ var BrowserTursoStorage = class extends SqlStorage {
1404
1572
  }
1405
1573
  };
1406
1574
 
1407
- export { BrowserTursoStorage, MAX_BODY, SCHEMA_TABLE, SqlStorage, StorageError, clearRows, countRows, decodeRowJson, deleteRows, encodeRowJson, getRow, handleDomainQuery, initializeDomain, initializeStore, libsqlDatabase, listRows, putRow, rebuildProjections, refreshDomain, storeDefinition, validateTursoUrl, wipeDomain, wipeRows };
1575
+ // src/sql/sync.ts
1576
+ var SYNC_OPS_TABLE = "_wodwiki_sync_ops";
1577
+ var SYNC_STATE_TABLE = "_wodwiki_sync_state";
1578
+ var SYNC_ARCHIVE_TABLE = "_wodwiki_sync_ops_archive";
1579
+ var BINARY_FIELD2 = { attachments: "data" };
1580
+ var STORE_NAMES = Object.fromEntries(STORE_DEFS.map((d) => [d.name, true]));
1581
+ var STATE_KEY = "meta";
1582
+ function isSafeInt(value) {
1583
+ return typeof value === "number" && Number.isSafeInteger(value);
1584
+ }
1585
+ function isScalar(value) {
1586
+ return typeof value === "string" || typeof value === "number" && Number.isFinite(value);
1587
+ }
1588
+ function assertKey(key, what) {
1589
+ if (isScalar(key)) return;
1590
+ if (Array.isArray(key) && key.length > 0 && key.every(isScalar)) return;
1591
+ throw new StorageError(400, `${what} must be a string, finite number, or non-empty scalar tuple`, "bad_sync_key");
1592
+ }
1593
+ function validateMutation(raw) {
1594
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
1595
+ throw new StorageError(400, "sync mutation must be an object", "bad_sync_mutation");
1596
+ }
1597
+ const mutation = raw;
1598
+ if (mutation.op === "wipe") return { op: "wipe" };
1599
+ if (typeof mutation.store !== "string" || !STORE_NAMES[mutation.store]) {
1600
+ throw new StorageError(400, `sync mutation has unknown store ${String(mutation.store)}`, "bad_sync_mutation");
1601
+ }
1602
+ const store = mutation.store;
1603
+ if (mutation.op === "clear") return { op: "clear", store };
1604
+ if (mutation.op === "delete") {
1605
+ assertKey(mutation.key, "sync delete key");
1606
+ return { op: "delete", store, key: mutation.key };
1607
+ }
1608
+ if (mutation.op === "put") {
1609
+ assertKey(mutation.key, "sync put key");
1610
+ if (mutation.value === null || typeof mutation.value !== "object" || Array.isArray(mutation.value)) {
1611
+ throw new StorageError(400, "sync put value must be an object row", "bad_sync_mutation");
1612
+ }
1613
+ return { op: "put", store, key: mutation.key, value: mutation.value };
1614
+ }
1615
+ throw new StorageError(400, `sync mutation has unknown op ${String(mutation.op)}`, "bad_sync_mutation");
1616
+ }
1617
+ function validateEntry(entry) {
1618
+ if (typeof entry.id !== "string" || entry.id.length === 0 || entry.id.length > 200) {
1619
+ throw new StorageError(400, "sync entry id must be a non-empty string", "bad_sync_entry");
1620
+ }
1621
+ if (!isSafeInt(entry.at) || entry.at < 0) {
1622
+ throw new StorageError(400, "sync entry at must be a non-negative safe integer", "bad_sync_entry");
1623
+ }
1624
+ if (!Array.isArray(entry.ops) || entry.ops.length === 0) {
1625
+ throw new StorageError(400, "sync entry ops must be a non-empty array", "bad_sync_entry");
1626
+ }
1627
+ for (const op of entry.ops) validateMutation(op);
1628
+ }
1629
+ async function runStatements(tx, statements) {
1630
+ if (statements.length === 0) return [];
1631
+ if (tx.batch) return tx.batch(statements);
1632
+ const results = [];
1633
+ for (const statement of statements) {
1634
+ results.push(await tx.query(statement.sql, statement.params ?? []));
1635
+ }
1636
+ return results;
1637
+ }
1638
+ async function tableExists(conn, dialect, name) {
1639
+ const sql = dialect === "postgres" ? `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1` : `SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`;
1640
+ return (await conn.query(sql, [name])).length > 0;
1641
+ }
1642
+ async function readSyncState(tx) {
1643
+ const rows = await tx.query(`SELECT "value" FROM "${SYNC_STATE_TABLE}" WHERE "key" = ?`, [STATE_KEY]);
1644
+ if (!rows[0]) throw new StorageError(409, "sync target is not initialized", "sync_not_initialized");
1645
+ let parsed;
1646
+ try {
1647
+ parsed = JSON.parse(String(rows[0].value));
1648
+ } catch {
1649
+ throw new StorageError(400, "malformed sync state row", "bad_sync_state");
1650
+ }
1651
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1652
+ throw new StorageError(400, "malformed sync state row", "bad_sync_state");
1653
+ }
1654
+ const state = parsed;
1655
+ if (!isSafeInt(state.schema) || state.schema < 0 || !isSafeInt(state.rev) || state.rev < 0) {
1656
+ throw new StorageError(400, "sync state schema/rev must be non-negative safe integers", "bad_sync_state");
1657
+ }
1658
+ return { schema: state.schema, rev: state.rev };
1659
+ }
1660
+ async function probeSyncTarget(db) {
1661
+ if (await tableExists(db, db.dialect, SYNC_STATE_TABLE)) {
1662
+ const state = await readSyncState(db);
1663
+ return { kind: "current", schemaVersion: state.schema, rev: state.rev };
1664
+ }
1665
+ if (await tableExists(db, db.dialect, SCHEMA_TABLE)) {
1666
+ const rows = await db.query(`SELECT "value" FROM "${SCHEMA_TABLE}" WHERE "key" = 'schema'`);
1667
+ if (rows[0]) {
1668
+ const guard = JSON.parse(String(rows[0].value));
1669
+ if (typeof guard.version === "number" && Number.isSafeInteger(guard.version) && guard.version >= 0) {
1670
+ return { kind: "legacy", schemaVersion: guard.version };
1671
+ }
1672
+ }
1673
+ return { kind: "legacy", schemaVersion: 0 };
1674
+ }
1675
+ const others = await db.query(
1676
+ db.dialect === "postgres" ? `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'` : `SELECT name FROM sqlite_master WHERE type = 'table'`
1677
+ );
1678
+ const internal = (name) => name.startsWith("_wodwiki_") || name.startsWith("sqlite_");
1679
+ return others.some((row) => !internal(String(Object.values(row)[0]))) ? { kind: "unknown" } : "empty";
1680
+ }
1681
+ async function initSyncTarget(db, schemaVersion) {
1682
+ await db.transaction(async (tx) => {
1683
+ await tx.exec(`CREATE TABLE IF NOT EXISTS "${SYNC_STATE_TABLE}" ("key" TEXT PRIMARY KEY, "value" TEXT NOT NULL)`);
1684
+ await tx.exec(
1685
+ `CREATE TABLE IF NOT EXISTS "${SYNC_OPS_TABLE}" ("op_id" TEXT PRIMARY KEY, "rev" INTEGER NOT NULL, "idx" INTEGER NOT NULL, "at" INTEGER NOT NULL, "data" TEXT NOT NULL)`
1686
+ );
1687
+ await tx.exec(
1688
+ `CREATE INDEX IF NOT EXISTS "${SYNC_OPS_TABLE}_rev" ON "${SYNC_OPS_TABLE}" ("rev", "idx")`
1689
+ );
1690
+ await tx.exec(
1691
+ `INSERT INTO "${SYNC_STATE_TABLE}" ("key", "value") VALUES (?, ?) ON CONFLICT ("key") DO NOTHING`,
1692
+ [STATE_KEY, JSON.stringify({ schema: schemaVersion, rev: 0 })]
1693
+ );
1694
+ });
1695
+ }
1696
+ function encodeMutation(mutation) {
1697
+ if (mutation.op !== "put") return JSON.stringify(mutation);
1698
+ const encoded = encodeRowJson(mutation.value, BINARY_FIELD2[mutation.store]);
1699
+ return `{"key":${JSON.stringify(mutation.key)},"op":"put","store":${JSON.stringify(mutation.store)},"value":${encoded}}`;
1700
+ }
1701
+ function decodeMutation(data) {
1702
+ if (typeof data !== "string") {
1703
+ throw new StorageError(400, "sync op data must be a string", "bad_sync_mutation");
1704
+ }
1705
+ let parsed;
1706
+ try {
1707
+ parsed = JSON.parse(data);
1708
+ } catch {
1709
+ throw new StorageError(400, "sync op data is not valid JSON", "bad_sync_mutation");
1710
+ }
1711
+ const mutation = validateMutation(parsed);
1712
+ if (mutation.op !== "put") return mutation;
1713
+ return {
1714
+ op: "put",
1715
+ store: mutation.store,
1716
+ key: mutation.key,
1717
+ value: decodeRowJson(JSON.stringify(mutation.value), BINARY_FIELD2[mutation.store])
1718
+ };
1719
+ }
1720
+ async function applySyncBatch(db, entries, opts = {}) {
1721
+ if (entries.length === 0) return 0;
1722
+ for (const entry of entries) validateEntry(entry);
1723
+ return db.transaction(async (tx) => {
1724
+ const state = await readSyncState(tx);
1725
+ if (opts.expectSchema !== void 0 && state.schema !== opts.expectSchema) {
1726
+ throw new StorageError(
1727
+ 409,
1728
+ `sync target schema changed from v${opts.expectSchema} to v${state.schema} between probe and write \u2014 retry the cycle`,
1729
+ "sync_schema_conflict"
1730
+ );
1731
+ }
1732
+ const firstIds = entries.map((entry) => `${entry.id}:0`);
1733
+ const present = /* @__PURE__ */ new Set();
1734
+ for (let i = 0; i < firstIds.length; i += 100) {
1735
+ const chunk = firstIds.slice(i, i + 100);
1736
+ const rows = await tx.query(
1737
+ `SELECT "op_id" FROM "${SYNC_OPS_TABLE}" WHERE "op_id" IN (${chunk.map(() => "?").join(", ")})`,
1738
+ chunk
1739
+ );
1740
+ for (const row of rows) present.add(String(row.op_id));
1741
+ }
1742
+ const statements = [];
1743
+ let rev = state.rev;
1744
+ let committed = 0;
1745
+ for (const entry of entries) {
1746
+ if (present.has(`${entry.id}:0`)) continue;
1747
+ rev += 1;
1748
+ committed += 1;
1749
+ entry.ops.forEach((mutation, idx) => {
1750
+ statements.push({
1751
+ sql: `INSERT INTO "${SYNC_OPS_TABLE}" ("op_id", "rev", "idx", "at", "data") VALUES (?, ?, ?, ?, ?) ON CONFLICT ("op_id") DO NOTHING`,
1752
+ params: [`${entry.id}:${idx}`, rev, idx, entry.at, encodeMutation(mutation)]
1753
+ });
1754
+ });
1755
+ }
1756
+ const nextSchema = opts.toSchemaVersion ?? state.schema;
1757
+ statements.push({
1758
+ sql: `UPDATE "${SYNC_STATE_TABLE}" SET "value" = ? WHERE "key" = ?`,
1759
+ params: [JSON.stringify({ schema: nextSchema, rev }), STATE_KEY]
1760
+ });
1761
+ await runStatements(tx, statements);
1762
+ return committed;
1763
+ });
1764
+ }
1765
+ async function fetchSyncOps(db, afterRev, limit) {
1766
+ if (!isSafeInt(afterRev) || afterRev < 0) {
1767
+ throw new StorageError(400, "fetchSyncOps cursor must be a non-negative safe integer", "bad_sync_cursor");
1768
+ }
1769
+ const bounded = Math.min(Math.max(1, Math.floor(limit)), 4294967295);
1770
+ const revRows = await db.query(
1771
+ `SELECT DISTINCT "rev" FROM (SELECT "rev" FROM "${SYNC_OPS_TABLE}" WHERE "rev" > ? ORDER BY "rev" ASC LIMIT ?)`,
1772
+ [afterRev, bounded]
1773
+ );
1774
+ const revs = revRows.map((row) => {
1775
+ const rev = Number(row.rev);
1776
+ if (!Number.isSafeInteger(rev) || rev < 0) {
1777
+ throw new StorageError(400, "sync op rev must be a non-negative safe integer", "bad_sync_row");
1778
+ }
1779
+ return rev;
1780
+ });
1781
+ if (revs.length === 0) return [];
1782
+ const rows = await db.query(
1783
+ `SELECT "op_id", "rev", "idx", "at", "data" FROM "${SYNC_OPS_TABLE}" WHERE "rev" IN (${revs.map(() => "?").join(", ")}) ORDER BY "rev", "idx"`,
1784
+ revs
1785
+ );
1786
+ return rows.map((row, at) => {
1787
+ const opId = row.op_id;
1788
+ const rev = Number(row.rev);
1789
+ const idx = Number(row.idx);
1790
+ const atMs = Number(row.at);
1791
+ if (typeof opId !== "string" || opId.length === 0 || !Number.isSafeInteger(rev) || rev < 0 || !Number.isSafeInteger(idx) || idx < 0 || !Number.isSafeInteger(atMs) || atMs < 0) {
1792
+ throw new StorageError(400, `sync op row ${at} has malformed shape`, "bad_sync_row");
1793
+ }
1794
+ return { opId, rev, idx, at: atMs, mutation: decodeMutation(row.data) };
1795
+ });
1796
+ }
1797
+ async function replaceSyncLog(db, entries, toSchemaVersion) {
1798
+ if (entries.length === 0) throw new StorageError(400, "replacement log must contain the converted snapshot", "bad_sync_entry");
1799
+ for (const entry of entries) validateEntry(entry);
1800
+ await db.transaction(async (tx) => {
1801
+ await readSyncState(tx);
1802
+ await tx.exec(
1803
+ `CREATE TABLE IF NOT EXISTS "${SYNC_ARCHIVE_TABLE}" ("op_id" TEXT PRIMARY KEY, "rev" INTEGER NOT NULL, "idx" INTEGER NOT NULL, "at" INTEGER NOT NULL, "data" TEXT NOT NULL)`
1804
+ );
1805
+ await tx.exec(`INSERT INTO "${SYNC_ARCHIVE_TABLE}" ("op_id", "rev", "idx", "at", "data") SELECT "op_id", "rev", "idx", "at", "data" FROM "${SYNC_OPS_TABLE}"`);
1806
+ await tx.exec(`DELETE FROM "${SYNC_OPS_TABLE}"`);
1807
+ const statements = [];
1808
+ let rev = 0;
1809
+ for (const entry of entries) {
1810
+ rev += 1;
1811
+ entry.ops.forEach((mutation, idx) => {
1812
+ statements.push({
1813
+ sql: `INSERT INTO "${SYNC_OPS_TABLE}" ("op_id", "rev", "idx", "at", "data") VALUES (?, ?, ?, ?, ?) ON CONFLICT ("op_id") DO NOTHING`,
1814
+ params: [`${entry.id}:${idx}`, rev, idx, entry.at, encodeMutation(mutation)]
1815
+ });
1816
+ });
1817
+ }
1818
+ statements.push({
1819
+ sql: `UPDATE "${SYNC_STATE_TABLE}" SET "value" = ? WHERE "key" = ?`,
1820
+ params: [JSON.stringify({ schema: toSchemaVersion, rev }), STATE_KEY]
1821
+ });
1822
+ await runStatements(tx, statements);
1823
+ });
1824
+ }
1825
+ async function readRawSyncOps(db) {
1826
+ const rows = await db.query(`SELECT "rev", "idx", "data" FROM "${SYNC_OPS_TABLE}" ORDER BY "rev", "idx"`);
1827
+ return rows.map((row) => {
1828
+ const rev = Number(row.rev);
1829
+ const idx = Number(row.idx);
1830
+ if (!Number.isSafeInteger(rev) || rev < 0 || !Number.isSafeInteger(idx) || idx < 0 || typeof row.data !== "string") {
1831
+ throw new StorageError(400, "sync op row has malformed shape", "bad_sync_row");
1832
+ }
1833
+ let parsed;
1834
+ try {
1835
+ parsed = JSON.parse(row.data);
1836
+ } catch {
1837
+ throw new StorageError(400, "sync op data is not valid JSON", "bad_sync_row");
1838
+ }
1839
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1840
+ throw new StorageError(400, "sync op data must be an object", "bad_sync_row");
1841
+ }
1842
+ const mutation = parsed;
1843
+ if (typeof mutation.op !== "string" || typeof mutation.store !== "string") {
1844
+ throw new StorageError(400, "sync op must carry string op/store", "bad_sync_row");
1845
+ }
1846
+ return {
1847
+ op: mutation.op,
1848
+ store: mutation.store,
1849
+ key: mutation.key,
1850
+ value: mutation.value === void 0 || mutation.value === null ? void 0 : decodeRowJson(JSON.stringify(mutation.value), BINARY_FIELD2[mutation.store])
1851
+ };
1852
+ });
1853
+ }
1854
+ async function readLegacyRows(db, table) {
1855
+ if (!await tableExists(db, db.dialect, table)) return [];
1856
+ const rows = await db.query(`SELECT "data" FROM "${table}"`);
1857
+ return rows.map((row) => decodeRowJson(String(row.data), BINARY_FIELD2[table]));
1858
+ }
1859
+
1860
+ export { BrowserTursoStorage, MAX_BODY, SCHEMA_TABLE, SYNC_ARCHIVE_TABLE, SYNC_OPS_TABLE, SYNC_STATE_TABLE, SqlStorage, applySyncBatch, clearRows, countRows, decodeRowJson, deleteRows, encodeRowJson, fetchSyncOps, getRow, getRows, handleDomainQuery, initSyncTarget, initializeDomain, initializeStore, libsqlDatabase, listRows, listRowsFromIndexMany, probeSyncTarget, putRow, putRows, readLegacyRows, readRawSyncOps, rebuildProjections, refreshDomain, replaceSyncLog, storeDefinition, validateTursoUrl, wipeDomain, wipeRows };
1408
1861
  //# sourceMappingURL=index.mjs.map
1409
1862
  //# sourceMappingURL=index.mjs.map