@bitcobblers/wod-wiki-storage 0.35.2116 → 0.35.2117

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-Y576CUFQ.mjs';
2
+ export { StorageError } from '../chunk-Y576CUFQ.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, 500)) {
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, 500)) {
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,6 +560,34 @@ 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) => {
@@ -475,7 +609,7 @@ async function initializeStore(db) {
475
609
  if (stored !== DB_VERSION) {
476
610
  throw new StorageError(
477
611
  500,
478
- `database schema v${stored} does not match this server (v${DB_VERSION}); use a compatible server or migrate a backed-up database`,
612
+ `database schema v${stored} is incompatible with v${DB_VERSION}; recreation required using an explicitly disposable database`,
479
613
  "schema_incompatible"
480
614
  );
481
615
  }
@@ -495,7 +629,7 @@ async function initializeStore(db) {
495
629
  }
496
630
  });
497
631
  }
498
- var TABLES = ["domain_note", "query_block", "segment_head"];
632
+ var TABLES = ["domain_note", "query_block"];
499
633
  var PROJECTION_GUARD_KEY = "domain_projection";
500
634
  function sourceItem(row) {
501
635
  const item = {};
@@ -514,7 +648,7 @@ function ensureDdl(db) {
514
648
  const n = db.dialect === "postgres" ? " DOUBLE PRECISION" : "";
515
649
  const t = db.dialect === "postgres" ? ' TEXT COLLATE "C"' : " TEXT";
516
650
  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)`,
651
+ `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
652
  `CREATE INDEX IF NOT EXISTS "idx_domain_note_date" ON "domain_note" ("date", "note_id")`,
519
653
  `CREATE INDEX IF NOT EXISTS "idx_domain_note_created" ON "domain_note" ("created_at", "note_id")`,
520
654
  `CREATE INDEX IF NOT EXISTS "idx_domain_note_source_path" ON "domain_note" ("source_path")`,
@@ -523,169 +657,140 @@ function ensureDdl(db) {
523
657
  `CREATE INDEX IF NOT EXISTS "idx_query_block_parent" ON "query_block" ("parent_note_id", "ordinal")`,
524
658
  `CREATE INDEX IF NOT EXISTS "idx_query_block_content" ON "query_block" ("content_id")`,
525
659
  `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")`
660
+ `CREATE INDEX IF NOT EXISTS "idx_query_block_source_note" ON "query_block" ("source_note_id")`
529
661
  ];
530
662
  return db.transaction(async (tx) => {
531
663
  for (const sql of ddl) await tx.exec(sql);
532
664
  });
533
665
  }
534
- async function upsertDomainNote(conn, note) {
666
+ var DOMAIN_NOTE_COLS = ["note_id", "note_type", "catalog", "source_id", "source_path", "title", "title_search", "created_at", "date", "text_search", "journal", "payload"];
667
+ 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"];
668
+ 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 ");
669
+ function domainNoteRow(note) {
535
670
  const id = text(note.id);
536
- if (!id) return;
671
+ if (!id) return void 0;
537
672
  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
- }));
673
+ return [
674
+ id,
675
+ T(note.type),
676
+ T(catalogOfItem(source)),
677
+ T(note.sourceId),
678
+ T(note.sourcePath),
679
+ T(note.title) ?? "",
680
+ (text(note.title) ?? "").toLowerCase(),
681
+ N(note.createdAt),
682
+ num(note.date),
683
+ (text(note.rawContent) ?? "").toLowerCase(),
684
+ sourceMatches(source, "journal") ? 1 : 0,
685
+ encodeRowJson(note)
686
+ ];
595
687
  }
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;
688
+ async function upsertDomainNotes(conn, notes) {
689
+ const rows = notes.map(domainNoteRow).filter((r) => r !== void 0);
690
+ await batchInsert(conn, "domain_note", [...DOMAIN_NOTE_COLS], rows, conflictUpdate(DOMAIN_NOTE_COLS, ["note_id"]));
609
691
  }
610
- async function upsertQueryBlock(conn, block) {
692
+ function queryBlockRow(block, parent) {
611
693
  const id = text(block.id);
612
- if (!id) return;
694
+ if (!id) return void 0;
613
695
  const source = sourceItem(block);
614
696
  const createdAt = N(block.createdAt);
615
- const parent = await resolveParent(conn, block);
616
697
  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
- );
698
+ return [
699
+ id,
700
+ T(block.noteId),
701
+ parent?.note_id ?? null,
702
+ T(block.blockContentId),
703
+ T(block.blockId),
704
+ num(block.position),
705
+ T(block.dataType),
706
+ T(block.sourceId),
707
+ T(block.sourcePath),
708
+ T(catalogOfItem(source)),
709
+ effective,
710
+ createdAt,
711
+ parent?.note.title ?? T(block.noteTitle),
712
+ (text(block.rawContent) ?? "").toLowerCase(),
713
+ sourceMatches(source, "journal") ? 1 : 0,
714
+ encodeRowJson(parent ? { ...block, noteTitle: parent.note.title } : block)
715
+ ];
646
716
  }
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
- }
717
+ async function upsertQueryBlocks(conn, blocks) {
718
+ const rows = blocks.map(([b, p]) => queryBlockRow(b, p)).filter((r) => r !== void 0);
719
+ await batchInsert(conn, "query_block", [...QUERY_BLOCK_COLS], rows, conflictUpdate(QUERY_BLOCK_COLS, ["occurrence_id"]));
663
720
  }
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
- }
721
+ function indexNoteIdentities(notes) {
722
+ const byNoteId = /* @__PURE__ */ new Map();
723
+ for (const note of notes) {
724
+ const id = text(note.id);
725
+ if (id) byNoteId.set(id, { note_id: id, date: num(note.date), created_at: num(note.createdAt) ?? 0, note });
676
726
  }
677
- if (head) await upsertSegmentHead(conn, k, head);
678
- else await conn.exec(`DELETE FROM "segment_head" WHERE "id" = ?`, [k]);
727
+ return { byNoteId };
728
+ }
729
+ function resolveParentFrom(index, block) {
730
+ const parent = typeof block.noteId === "string" ? index.byNoteId.get(block.noteId) : void 0;
731
+ const mapped = typeof block.blockId === "string" ? parent?.note.blocks[block.blockId] : void 0;
732
+ 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;
679
733
  }
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]);
734
+ async function fetchParentIndex(conn, noteIds) {
735
+ const notes = await getRows(conn, storeDefinition("notes"), noteIds);
736
+ return indexNoteIdentities(notes.filter((row) => row !== void 0));
737
+ }
738
+ var keyHead = (key) => Array.isArray(key) ? key[0] : key;
739
+ async function selectIn(conn, selectCol, table, col, vals) {
740
+ const IN_CHUNK = 500;
741
+ const out = [];
742
+ for (let i = 0; i < vals.length; i += IN_CHUNK) {
743
+ const part = vals.slice(i, i + IN_CHUNK);
744
+ const rows = await conn.query(`SELECT "${selectCol}" FROM "${table}" WHERE "${col}" IN (${part.map(() => "?").join(", ")})`, part);
745
+ for (const r of rows) out.push(String(r[selectCol]));
746
+ }
747
+ return out;
748
+ }
749
+ async function healQueryBlocksForNotes(conn, noteIds) {
750
+ const ids = await selectIn(conn, "occurrence_id", "query_block", "source_note_id", noteIds);
751
+ await refreshBlocks(conn, [...new Set(ids)]);
752
+ }
753
+ async function refreshNotes(conn, ids) {
754
+ const ks = [...new Set(ids.map((id) => String(keyHead(id))))];
755
+ if (ks.length === 0) return;
756
+ const found = /* @__PURE__ */ new Map();
757
+ for (let i = 0; i < ks.length; i += 500) {
758
+ const part = ks.slice(i, i + 500);
759
+ const rows = await conn.query(`SELECT "k0", "data" FROM "notes" WHERE "k0" IN (${part.map(() => "?").join(", ")})`, part);
760
+ for (const row of rows) found.set(String(row.k0), decodeRowJson(String(row.data)));
761
+ }
762
+ const present = [];
763
+ const missing = [];
764
+ for (const k of ks) {
765
+ const note = found.get(k);
766
+ if (note) present.push(note);
767
+ else missing.push(k);
768
+ }
769
+ await upsertDomainNotes(conn, present);
770
+ const presentIds = present.map((n) => text(n.id)).filter((x) => !!x);
771
+ await healQueryBlocksForNotes(conn, presentIds);
772
+ await deleteIn(conn, "domain_note", "note_id", missing);
773
+ await deleteIn(conn, "query_block", "source_note_id", missing);
774
+ await deleteIn(conn, "block_index", "i_by_note_0", missing);
775
+ await deleteIn(conn, "block_efforts", "i_by_note_0", missing);
776
+ }
777
+ async function refreshBlocks(conn, ids) {
778
+ const ks = [...new Set(ids.map((id) => String(keyHead(id))))];
779
+ if (ks.length === 0) return;
780
+ const found = /* @__PURE__ */ new Map();
781
+ for (let i = 0; i < ks.length; i += 500) {
782
+ const part = ks.slice(i, i + 500);
783
+ const rows = await conn.query(`SELECT "k0", "data" FROM "block_index" WHERE "k0" IN (${part.map(() => "?").join(", ")})`, part);
784
+ for (const row of rows) found.set(String(row.k0), decodeRowJson(String(row.data)));
785
+ }
786
+ const blocks = [...found.values()];
787
+ const noteIds = [...new Set(blocks.map((b) => text(b.noteId)).filter((x) => !!x))];
788
+ const index = await fetchParentIndex(conn, noteIds);
789
+ await upsertQueryBlocks(conn, blocks.map((b) => [b, resolveParentFrom(index, b)]));
790
+ await deleteIn(conn, "query_block", "occurrence_id", ks.filter((k) => !found.has(k)));
685
791
  }
686
792
  var TRACKED = {
687
793
  notes: "notes",
688
- segments: "segments",
689
794
  block_index: "blocks"
690
795
  };
691
796
  async function deleteTargetKeys(conn, def, target) {
@@ -693,29 +798,54 @@ async function deleteTargetKeys(conn, def, target) {
693
798
  const rows = await listRows(conn, def, target);
694
799
  return rows.map((row) => keyFromValue(def, row));
695
800
  }
696
- async function refreshDomain(conn, ops, opts = {}) {
801
+ async function refreshDomain(conn, ops) {
697
802
  const impact = {
698
803
  notes: [],
699
- segments: [],
700
804
  blocks: [],
805
+ pinnedNotes: [],
806
+ pinnedSessions: [],
701
807
  notesClear: false,
702
- segmentsClear: false,
703
808
  blocksClear: false
704
809
  };
705
810
  const results = [];
811
+ for (const op of ops) if (op === null || typeof op !== "object" || Array.isArray(op)) throw new StorageError(400, "tx op must be an object");
812
+ const checks = ops.filter((op) => op.op === "check");
813
+ const checkStores = [...new Set(checks.map((op) => op.store))];
814
+ for (const store of checkStores) {
815
+ const scoped = checks.filter((op) => op.store === store);
816
+ const actual = await getRows(conn, storeDefinition(store), scoped.map((op) => op.key));
817
+ scoped.forEach((op, i) => {
818
+ if (!rowsEqual(actual[i] ?? null, op.value)) throw new StorageError(409, "transaction read changed before commit", "transaction_conflict");
819
+ });
820
+ }
821
+ let pending;
822
+ const flushPuts = async () => {
823
+ if (!pending) return;
824
+ const batch = pending;
825
+ pending = void 0;
826
+ if (batch.values.length === 0) return;
827
+ const changed = [];
828
+ const keys = await putRows(conn, batch.def, batch.values, changed);
829
+ const slot = TRACKED[batch.def.name];
830
+ if (slot) impact[slot].push(...changed);
831
+ const changedSet = new Set(changed.map((key) => JSON.stringify(key)));
832
+ if (batch.def.name === "notes") impact.pinnedNotes.push(...batch.values.filter((value) => changedSet.has(JSON.stringify(keyFromValue(batch.def, value)))));
833
+ if (batch.def.name === "sessions") impact.pinnedSessions.push(...batch.values.filter((value) => changedSet.has(JSON.stringify(keyFromValue(batch.def, value)))));
834
+ results.push(...keys);
835
+ };
706
836
  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");
837
+ if (op.op === "check") {
838
+ results.push(null);
839
+ continue;
709
840
  }
710
841
  const store = "store" in op ? op.store : void 0;
711
842
  const def = storeDefinition(typeof store === "string" ? store : String(store));
712
843
  const kind = typeof op.op === "string" ? op.op : void 0;
713
844
  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);
845
+ if (pending && pending.def.name !== def.name) await flushPuts();
846
+ (pending ??= { def, values: [] }).values.push(op.value);
718
847
  } else if (op.op === "delete") {
848
+ await flushPuts();
719
849
  const slot = TRACKED[def.name];
720
850
  if (slot) {
721
851
  for (const key of await deleteTargetKeys(conn, def, op.key)) impact[slot].push(key);
@@ -723,28 +853,32 @@ async function refreshDomain(conn, ops, opts = {}) {
723
853
  await deleteRows(conn, def, op.key);
724
854
  results.push(null);
725
855
  } else if (op.op === "clear") {
856
+ await flushPuts();
726
857
  await clearRows(conn, def);
727
858
  if (def.name === "notes") impact.notesClear = true;
728
- else if (def.name === "segments") impact.segmentsClear = true;
729
859
  else if (def.name === "block_index") impact.blocksClear = true;
730
860
  results.push(null);
731
861
  } else {
732
- throw new StorageError(400, `tx op must be put | delete | clear, got: ${String(kind)}`);
862
+ throw new StorageError(400, `tx op must be check | put | delete | clear, got: ${String(kind)}`);
733
863
  }
734
864
  }
735
- if (!opts.deferProjection) await refresh(conn, impact);
865
+ await flushPuts();
866
+ const finalNotes = (await getRows(conn, storeDefinition("notes"), [...new Set(impact.pinnedNotes.map((note) => note.id))])).filter(Boolean);
867
+ const finalSessions = (await getRows(conn, storeDefinition("sessions"), [...new Set(impact.pinnedSessions.map((session) => session.id))])).filter(Boolean);
868
+ await validateStoredPins(finalNotes, finalSessions, (keys) => getRows(conn, storeDefinition("block_revisions"), keys));
869
+ await refresh(conn, impact);
736
870
  return results;
737
871
  }
738
872
  async function refresh(conn, impact) {
739
873
  if (impact.notesClear) {
740
874
  await conn.exec(`DELETE FROM "domain_note"`);
741
- await conn.exec(`UPDATE "query_block" SET "parent_note_id" = NULL`);
875
+ await conn.exec(`DELETE FROM "query_block" WHERE "source_note_id" NOT IN (SELECT "k0" FROM "notes")`);
876
+ await conn.exec(`DELETE FROM "block_index" WHERE "i_by_note_0" NOT IN (SELECT "k0" FROM "notes")`);
877
+ await conn.exec(`DELETE FROM "block_efforts" WHERE "i_by_note_0" NOT IN (SELECT "k0" FROM "notes")`);
742
878
  }
743
- if (impact.segmentsClear) await conn.exec(`DELETE FROM "segment_head"`);
744
879
  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);
880
+ await refreshNotes(conn, impact.notes);
881
+ await refreshBlocks(conn, impact.blocks);
748
882
  }
749
883
  async function rebuildProjections(conn) {
750
884
  await conn.exec(
@@ -752,18 +886,12 @@ async function rebuildProjections(conn) {
752
886
  );
753
887
  for (const t of TABLES) await conn.exec(`DELETE FROM "${t}"`);
754
888
  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);
889
+ const noteObjs = notes.map((row) => decodeRowJson(String(row.data)));
890
+ await upsertDomainNotes(conn, noteObjs);
765
891
  const blocks = await conn.query(`SELECT "data" FROM "block_index"`);
766
- for (const row of blocks) await upsertQueryBlock(conn, decodeRowJson(String(row.data)));
892
+ const blockObjs = blocks.map((row) => decodeRowJson(String(row.data)));
893
+ const index = indexNoteIdentities(noteObjs);
894
+ await upsertQueryBlocks(conn, blockObjs.map((b) => [b, resolveParentFrom(index, b)]));
767
895
  await conn.exec(
768
896
  `INSERT INTO "${SCHEMA_TABLE}" ("key", "value") VALUES (?, ?) ON CONFLICT ("key") DO UPDATE SET "value" = excluded."value"`,
769
897
  [PROJECTION_GUARD_KEY, JSON.stringify({ version: DOMAIN_PROJECTION_VERSION })]
@@ -1000,13 +1128,8 @@ function predicateSql(ctx, p, plan) {
1000
1128
  p.negate === true,
1001
1129
  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
1130
  );
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
- }
1131
+ case "text":
1132
+ 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
1133
  case "date": {
1011
1134
  const expr = blocks ? 'q."effective_date"' : 'COALESCE(n."date", n."created_at")';
1012
1135
  const lo = ctx.bind(p.start);
@@ -1050,7 +1173,7 @@ async function gate(conn) {
1050
1173
  if (stale > 0) {
1051
1174
  throw new StorageError(
1052
1175
  409,
1053
- `domain projections stale: ${stale} block rows have unresolved static parents`,
1176
+ `domain projections stale: ${stale} block rows have invalid or unresolved current parents`,
1054
1177
  "domain_projection_unavailable"
1055
1178
  );
1056
1179
  }
@@ -1069,47 +1192,43 @@ async function execute(conn, q, pg) {
1069
1192
  const fullWhere = selWhere && filterWhere ? `${selWhere} AND ${filterWhere.replace(/^ WHERE /, "")}` : selWhere || filterWhere;
1070
1193
  const count = async (where, params) => Number((await conn.query(`SELECT COUNT(*) AS "n" FROM ${table}${where}`, params))[0]?.n ?? 0);
1071
1194
  const selectedCount = await count(selWhere, selCtx.params);
1072
- const matchedCount = await count(fullWhere, allParams);
1195
+ const matchedCount = q.filters.length === 0 ? selectedCount : await count(fullWhere, allParams);
1073
1196
  const ordering = orderSql(q.order, plan);
1074
1197
  const paging = pagingSql(q, pg);
1075
1198
  if (plan !== "entries") {
1076
1199
  const pageSql2 = `SELECT "payload" FROM ${table}${fullWhere} ORDER BY ${ordering}${paging}`;
1077
1200
  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;
1201
+ const result = { projectionVersion: DOMAIN_PROJECTION_VERSION, selectedCount, matchedCount, plan, rows: rows2 };
1202
+ return result;
1080
1203
  }
1081
1204
  const pageSql = `SELECT "note_id", "payload" FROM ${table}${fullWhere} ORDER BY ${ordering}${paging}`;
1082
1205
  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(() => [[], [], [], []]);
1206
+ const buckets = noteRows.map(() => [[], [], []]);
1099
1207
  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)) {
1208
+ noteRows.forEach((row, i) => index.set(String(row.note_id), i));
1209
+ for (let start = 0; start < noteRows.length; start += 500) {
1210
+ const ids = noteRows.slice(start, start + 500).map((row) => String(row.note_id));
1211
+ const placeholders = ids.map(() => "?").join(", ");
1212
+ const statements = [
1213
+ { 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 },
1214
+ { 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 },
1215
+ { 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 }
1216
+ ];
1217
+ const relations = conn.batch ? await conn.batch(statements) : await (async () => {
1218
+ const rows2 = [];
1219
+ for (const statement of statements) rows2.push(await conn.query(statement.sql, statement.params));
1220
+ return rows2;
1221
+ })();
1222
+ relations.forEach((rows2, relation) => rows2.forEach((row) => {
1103
1223
  const i = index.get(String(row.note_id));
1104
- if (i !== void 0) buckets[i][rel].push(decodeRowJson(String(row.payload)));
1105
- }
1224
+ if (i !== void 0) buckets[i][relation].push(decodeRowJson(String(row.payload)));
1225
+ }));
1106
1226
  }
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 };
1227
+ const rows = noteRows.map((row, i) => {
1228
+ const [tags, links, pages] = buckets[i];
1229
+ return { note: decodeRowJson(String(row.payload)), tags, links, pages };
1110
1230
  });
1111
- const result = { projectionVersion: DOMAIN_PROJECTION_VERSION, selectedCount, matchedCount, plan, rows };
1112
- return result;
1231
+ return { projectionVersion: DOMAIN_PROJECTION_VERSION, selectedCount, matchedCount, plan, rows };
1113
1232
  }
1114
1233
  async function handleDomainQuery(db, body) {
1115
1234
  const query = parseQuery(body);
@@ -1176,18 +1295,14 @@ var SqlStorage = class {
1176
1295
  }
1177
1296
  async transaction(stores, mode, fn) {
1178
1297
  await this.ensureReady();
1179
- return this.runTx(stores, mode === "readwrite", fn, false);
1298
+ return this.runTx(stores, mode === "readwrite", fn);
1180
1299
  }
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
- */
1300
+ /** Bulk writes use the same affected-key refresh as ordinary transactions. */
1186
1301
  async bulkTransaction(stores, fn) {
1187
1302
  await this.ensureReady();
1188
- return this.runTx(stores, true, fn, true);
1303
+ return this.runTx(stores, true, fn);
1189
1304
  }
1190
- runTx(stores, mutableMode, fn, deferProjection) {
1305
+ runTx(stores, mutableMode, fn) {
1191
1306
  const scoped = new Set(stores);
1192
1307
  const buffer = /* @__PURE__ */ new Map();
1193
1308
  const cleared = /* @__PURE__ */ new Set();
@@ -1213,8 +1328,7 @@ var SqlStorage = class {
1213
1328
  const result = await fn(tx);
1214
1329
  const ops = bufferToOps(STORE_DEFS.map((d) => d.name), buffer, cleared);
1215
1330
  if (ops.length > 0) {
1216
- await refreshDomain(conn, ops, deferProjection ? { deferProjection: true } : {});
1217
- if (deferProjection) await rebuildProjections(conn);
1331
+ await refreshDomain(conn, ops);
1218
1332
  }
1219
1333
  return result;
1220
1334
  });
@@ -1289,6 +1403,20 @@ var SqlStore = class {
1289
1403
  if (this.cleared?.has(this.store)) return void 0;
1290
1404
  return getRow(this.conn, this.def, k);
1291
1405
  }
1406
+ async getMany(keys) {
1407
+ await this.owner.ensureReady();
1408
+ const normalized = keys.map((key) => this.pointKey(key));
1409
+ const committed = this.cleared?.has(this.store) ? keys.map(() => void 0) : await getRows(this.conn, this.def, normalized);
1410
+ return normalized.map((key, i) => {
1411
+ const state = bufferedGet(this.store, key, this.buffer);
1412
+ return state ? state.hit ? state.value : void 0 : committed[i];
1413
+ });
1414
+ }
1415
+ async getAllFromIndexMany(indexName, keys) {
1416
+ await this.owner.ensureReady();
1417
+ const rows = await listRowsFromIndexMany(this.conn, this.def, indexName, keys.map((key) => this.pointKey(key)));
1418
+ return rows.map((values, i) => overlayRows(this.def, this.store, this.buffer, this.cleared, values, void 0, { only: this.pointKey(keys[i]) }, indexName));
1419
+ }
1292
1420
  async getAll(query, count) {
1293
1421
  await this.owner.ensureReady();
1294
1422
  const range = rangeFromIDB(query);
@@ -1320,6 +1448,10 @@ var SqlStore = class {
1320
1448
  this.assertMutable();
1321
1449
  const stored = this.owner.stamp(this.store, value);
1322
1450
  const key = keyFromValue(this.def, stored);
1451
+ if (this.store === "block_revisions") {
1452
+ const previous = this.buffer?.get(bufferSlot(this.store, key));
1453
+ if (previous?.kind === "put" && !rowsEqual(previous.value, stored)) throw new StorageError(409, "immutable block revision collision", "revision_conflict");
1454
+ }
1323
1455
  if (this.buffer) {
1324
1456
  this.buffer.set(bufferSlot(this.store, key), { kind: "put", value: stored });
1325
1457
  return key;
@@ -1327,9 +1459,28 @@ var SqlStore = class {
1327
1459
  await this.owner.applyOps([{ op: "put", store: this.store, value: stored }]);
1328
1460
  return key;
1329
1461
  }
1462
+ async putMany(values, _keys) {
1463
+ await this.owner.ensureReady();
1464
+ this.assertMutable();
1465
+ const stored = values.map((value) => this.owner.stamp(this.store, value));
1466
+ const keys = stored.map((value) => keyFromValue(this.def, value));
1467
+ if (this.store === "block_revisions") {
1468
+ const seen = /* @__PURE__ */ new Map();
1469
+ keys.forEach((key, i) => {
1470
+ const slot = bufferSlot(this.store, key), previous = this.buffer?.get(slot);
1471
+ const value = seen.has(slot) ? seen.get(slot) : previous?.kind === "put" ? previous.value : void 0;
1472
+ if (value !== void 0 && !rowsEqual(value, stored[i])) throw new StorageError(409, "immutable block revision collision", "revision_conflict");
1473
+ seen.set(slot, stored[i]);
1474
+ });
1475
+ }
1476
+ if (this.buffer) keys.forEach((key, i) => this.buffer.set(bufferSlot(this.store, key), { kind: "put", value: stored[i] }));
1477
+ else await this.owner.applyOps(stored.map((value) => ({ op: "put", store: this.store, value })));
1478
+ return keys;
1479
+ }
1330
1480
  async delete(key) {
1331
1481
  await this.owner.ensureReady();
1332
1482
  this.assertMutable();
1483
+ if (this.store === "block_revisions") throw new StorageError(409, "block history requires explicit wipe", "revision_conflict");
1333
1484
  const range = isRange(key) ? key : void 0;
1334
1485
  const target = range ?? key;
1335
1486
  if (this.buffer) {
@@ -1349,6 +1500,7 @@ var SqlStore = class {
1349
1500
  async clear() {
1350
1501
  await this.owner.ensureReady();
1351
1502
  this.assertMutable();
1503
+ if (this.store === "block_revisions") throw new StorageError(409, "block history requires explicit wipe", "revision_conflict");
1352
1504
  if (this.buffer) {
1353
1505
  this.cleared?.add(this.store);
1354
1506
  const prefix = `${this.store}\0`;
@@ -1404,6 +1556,6 @@ var BrowserTursoStorage = class extends SqlStorage {
1404
1556
  }
1405
1557
  };
1406
1558
 
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 };
1559
+ export { BrowserTursoStorage, MAX_BODY, SCHEMA_TABLE, SqlStorage, clearRows, countRows, decodeRowJson, deleteRows, encodeRowJson, getRow, getRows, handleDomainQuery, initializeDomain, initializeStore, libsqlDatabase, listRows, listRowsFromIndexMany, putRow, putRows, rebuildProjections, refreshDomain, storeDefinition, validateTursoUrl, wipeDomain, wipeRows };
1408
1560
  //# sourceMappingURL=index.mjs.map
1409
1561
  //# sourceMappingURL=index.mjs.map