@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,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ var wodWikiCore = require('@bitcobblers/wod-wiki-core');
3
4
  var wodWikiWql = require('@bitcobblers/wod-wiki-wql');
4
5
  var web = require('@libsql/client/web');
5
6
 
@@ -40,7 +41,8 @@ function libsqlDatabase(client) {
40
41
  query: (sql, params = []) => tx.execute({ sql, args: params }).then(libsqlRows),
41
42
  exec: async (sql, params = []) => {
42
43
  await tx.execute({ sql, args: params });
43
- }
44
+ },
45
+ batch: async (statements) => (await tx.batch(statements.map(({ sql, params = [] }) => ({ sql, args: params })))).map(libsqlRows)
44
46
  };
45
47
  const result = await fn(txConn);
46
48
  await tx.commit();
@@ -50,6 +52,10 @@ function libsqlDatabase(client) {
50
52
  await tx.rollback();
51
53
  } catch {
52
54
  }
55
+ const message = err instanceof Error ? err.message : String(err);
56
+ for (const code of ["revision_conflict", "transaction_conflict", "invalid_session_pin"]) {
57
+ if (message.includes(code)) throw new StorageError(409, message, code);
58
+ }
53
59
  throw err;
54
60
  } finally {
55
61
  tx.close();
@@ -92,29 +98,33 @@ function reviveBinary(value) {
92
98
  }
93
99
  return bytes.buffer;
94
100
  }
101
+ var canonicalJson = (_key, value) => {
102
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
103
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, Reflect.get(value, key)]));
104
+ };
95
105
  function encodeRowJson(value, binaryField) {
96
106
  if (binaryField && value !== null && typeof value === "object" && !Array.isArray(value)) {
97
107
  const row = value;
98
108
  const data = row[binaryField];
99
109
  if (data instanceof ArrayBuffer) {
100
110
  const bytes = new Uint8Array(data);
101
- return JSON.stringify({ ...row, [binaryField]: { [TAG]: bytesToBase64(bytes), [LEN]: bytes.length } });
111
+ return JSON.stringify({ ...row, [binaryField]: { [TAG]: bytesToBase64(bytes), [LEN]: bytes.length } }, canonicalJson);
102
112
  }
103
113
  }
104
- return JSON.stringify(value);
114
+ return JSON.stringify(value, canonicalJson);
105
115
  }
106
116
  function decodeRowJson(text2, binaryField) {
107
117
  const row = JSON.parse(text2);
108
118
  if (binaryField && row !== null && typeof row === "object" && !Array.isArray(row)) {
109
- const record = row;
110
- const revived = reviveBinary(record[binaryField]);
111
- if (revived !== record[binaryField]) record[binaryField] = revived;
119
+ const record2 = row;
120
+ const revived = reviveBinary(record2[binaryField]);
121
+ if (revived !== record2[binaryField]) record2[binaryField] = revived;
112
122
  }
113
123
  return row;
114
124
  }
115
125
 
116
126
  // src/schema.ts
117
- var DB_VERSION = 28;
127
+ var DB_VERSION = 30;
118
128
  var COLUMN_TYPES = {
119
129
  notes: { date: "number", createdAt: "number" },
120
130
  page: { createdAt: "number" },
@@ -123,16 +133,9 @@ var COLUMN_TYPES = {
123
133
  tags: { createdAt: "number" },
124
134
  tag_types: { createdAt: "number" },
125
135
  note_tags: {},
126
- segments: {
127
- version: "number",
128
- position: "number",
129
- createdAt: "number",
130
- updatedAt: "number",
131
- isHistory: "boolean"
132
- },
136
+ block_revisions: { version: "number", savedAt: "number" },
133
137
  sessions: {
134
- segmentVersion: "number",
135
- version: "number",
138
+ blockVersion: "number",
136
139
  startTime: "number",
137
140
  endTime: "number",
138
141
  duration: "number",
@@ -145,7 +148,7 @@ var COLUMN_TYPES = {
145
148
  attachments: { createdAt: "number" },
146
149
  events: {
147
150
  timestamp: "number",
148
- segmentVersion: "number",
151
+ blockVersion: "number",
149
152
  line: "number",
150
153
  sourceStatementId: "number",
151
154
  stackLevel: "number",
@@ -158,7 +161,10 @@ var COLUMN_TYPES = {
158
161
  field_catalog_meta: {},
159
162
  efforts: { "baseAttributes.met": "number" },
160
163
  block_index: {
161
- segmentVersion: "number",
164
+ blockVersion: "number",
165
+ sourceRevision: "number",
166
+ from: "number",
167
+ to: "number",
162
168
  position: "number",
163
169
  createdAt: "number",
164
170
  isStatic: "boolean"
@@ -205,17 +211,12 @@ var STORE_DEFS = [
205
211
  { name: "by-note-tag", keyPath: ["noteId", "tagId"], unique: true },
206
212
  { name: "by-user", keyPath: ["userId"] }
207
213
  ] },
208
- // 5. segments
209
- { name: "segments", keyPath: ["id", "version"], indexes: [
214
+ { name: "block_revisions", keyPath: ["noteId", "blockId", "version"], indexes: [
210
215
  { name: "by-note", keyPath: ["noteId"] },
211
- { name: "by-type", keyPath: ["dataType"] },
212
- { name: "by-page", keyPath: ["pageId"] },
213
- { name: "by-history", keyPath: ["isHistory"] },
214
- { name: "by-user", keyPath: ["userId"] }
216
+ { name: "by-block", keyPath: ["noteId", "blockId"] }
215
217
  ] },
216
218
  // 6. sessions
217
219
  { name: "sessions", keyPath: ["id"], indexes: [
218
- { name: "by-segment", keyPath: ["segmentId"] },
219
220
  { name: "by-note", keyPath: ["noteId"] },
220
221
  { name: "by-completed", keyPath: ["createdAt"] },
221
222
  { name: "by-content", keyPath: ["blockContentId"] },
@@ -224,9 +225,6 @@ var STORE_DEFS = [
224
225
  { name: "by-origin", keyPath: ["origin"] },
225
226
  { name: "by-user", keyPath: ["userId"] }
226
227
  ] },
227
- // 7. results — removed in V26: legacy alias of sessions. The V26 upgrade
228
- // migrates any residual rows into `sessions` (projecting pre-V21 data.logs
229
- // into event rows) before dropping the store; no compatibility alias.
230
228
  // 8. attachments
231
229
  { name: "attachments", keyPath: ["id"], indexes: [
232
230
  { name: "by-note", keyPath: ["noteId"] },
@@ -321,7 +319,7 @@ function rangeFromIDB(query) {
321
319
  wire.lowerOpen = query.lowerOpen === true;
322
320
  }
323
321
  if (query.upper !== void 0 && query.upper !== null) {
324
- wire.upper = query.upper;
322
+ wire.upper = Array.isArray(query.upper) ? query.upper.map((p) => Array.isArray(p) && p.length === 0 ? "\uFFFF" : p) : query.upper;
325
323
  wire.upperOpen = query.upperOpen === true;
326
324
  }
327
325
  return wire.lower !== void 0 || wire.upper !== void 0 ? wire : void 0;
@@ -353,6 +351,75 @@ function keyInRange(key, range) {
353
351
  }
354
352
  return true;
355
353
  }
354
+ function rowsEqual(left, right) {
355
+ if (Object.is(left, right)) return true;
356
+ if (left instanceof ArrayBuffer && right instanceof ArrayBuffer) {
357
+ const a2 = new Uint8Array(left), b2 = new Uint8Array(right);
358
+ return a2.length === b2.length && a2.every((value, i) => value === b2[i]);
359
+ }
360
+ if (typeof left !== "object" || left === null || typeof right !== "object" || right === null) return false;
361
+ if (Array.isArray(left) || Array.isArray(right)) return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((value, i) => rowsEqual(value, right[i]));
362
+ const a = Object.keys(left).filter((key) => Reflect.get(left, key) !== void 0);
363
+ const b = Object.keys(right).filter((key) => Reflect.get(right, key) !== void 0);
364
+ return a.length === b.length && a.every((key) => Object.hasOwn(right, key) && rowsEqual(Reflect.get(left, key), Reflect.get(right, key)));
365
+ }
366
+ function record(value) {
367
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new StorageError(400, "storage row must be an object");
368
+ return value;
369
+ }
370
+ function validateStorageRow(store, value) {
371
+ if (store !== "notes" && store !== "block_revisions" && store !== "sessions" && store !== "block_index") return;
372
+ const row = record(value);
373
+ const positive = (field) => typeof row[field] === "number" && Number.isSafeInteger(row[field]) && row[field] > 0;
374
+ const required = (field) => typeof row[field] === "string" && row[field].length > 0;
375
+ if (store === "notes") {
376
+ if (!required("id") || typeof row.title !== "string" || typeof row.rawContent !== "string" || !positive("sourceRevision") || typeof row.createdAt !== "number" || !Number.isFinite(row.createdAt) || "segments" in row || "segmentIds" in row || row.date !== void 0 && (typeof row.date !== "number" || !Number.isFinite(row.date)) || row.seedOrigin !== void 0 && row.seedOrigin !== "seed" && row.seedOrigin !== "user" || row.seedVersion !== void 0 && !positive("seedVersion") || row.tags !== void 0 && (!Array.isArray(row.tags) || !row.tags.every((tag) => typeof tag === "string")) || ["type", "sourceId", "sourcePath", "catalog", "seedChunkId"].some((field) => row[field] !== void 0 && typeof row[field] !== "string")) {
377
+ throw new StorageError(400, "invalid whole Note", "invalid_note_blocks");
378
+ }
379
+ try {
380
+ wodWikiCore.validateNoteBlocks(row.rawContent, record(row.blocks), row.sourceRevision);
381
+ } catch (error) {
382
+ throw new StorageError(400, error instanceof Error ? error.message : "invalid block map", "invalid_note_blocks");
383
+ }
384
+ } else if (store === "block_revisions") {
385
+ if (!required("noteId") || !required("blockId") || !positive("version") || typeof row.rawContent !== "string" || typeof row.savedAt !== "number" || !Number.isFinite(row.savedAt) || !["wod", "query", "widget", "embed"].includes(String(row.kind)) || (row.kind === "wod" ? row.dialect !== "time" && row.dialect !== "log" : row.dialect !== void 0 || row.sport !== void 0) || row.sport !== void 0 && typeof row.sport !== "string") throw new StorageError(400, "invalid block revision", "invalid_note_blocks");
386
+ try {
387
+ wodWikiCore.validateNoteBlocks(row.rawContent, { [String(row.blockId)]: {
388
+ id: String(row.blockId),
389
+ kind: row.kind,
390
+ version: row.version,
391
+ sourceRevision: 1,
392
+ from: 0,
393
+ to: row.rawContent.length,
394
+ ...row.dialect === void 0 ? {} : { dialect: row.dialect },
395
+ ...row.sport === void 0 ? {} : { sport: row.sport }
396
+ } }, 1);
397
+ } catch (error) {
398
+ throw new StorageError(400, error instanceof Error ? error.message : "invalid revision source", "invalid_note_blocks");
399
+ }
400
+ } else if (store === "sessions") {
401
+ if (!required("noteId") || !required("blockId") || !positive("blockVersion") || ["segmentId", "segmentVersion", "version", "rawContent", "script", "serializedScript", "source", "data", "logs"].some((field) => field in row)) {
402
+ throw new StorageError(400, "Session requires immutable block pin", "invalid_session_pin");
403
+ }
404
+ } else if (!required("noteId") || !required("blockId") || !positive("blockVersion") || !positive("sourceRevision") || row.id !== `${row.noteId}:${row.blockId}` || !Number.isSafeInteger(row.from) || !Number.isSafeInteger(row.to) || "segmentId" in row || "segmentVersion" in row || !["wod", "query", "widget", "embed"].includes(String(row.dataType)) || typeof row.rawContent !== "string" || typeof row.noteTitle !== "string" || typeof row.from !== "number" || typeof row.to !== "number" || row.from < 0 || row.to <= row.from) {
405
+ throw new StorageError(400, "invalid actionable block projection", "invalid_note_blocks");
406
+ }
407
+ }
408
+ async function validateStoredPins(notes, sessions, getRevisions) {
409
+ const keys = [];
410
+ for (const note of notes) for (const block of Object.values(note.blocks)) keys.push([note.id, block.id, block.version]);
411
+ for (const session of sessions) keys.push([session.noteId, session.blockId, session.blockVersion]);
412
+ const revisions = await getRevisions(keys);
413
+ let i = 0;
414
+ for (const note of notes) for (const block of Object.values(note.blocks)) {
415
+ const revision = revisions[i++];
416
+ if (!revision || revision.kind !== block.kind || revision.rawContent !== note.rawContent.slice(block.from, block.to) || revision.dialect !== block.dialect || revision.sport !== block.sport) throw new StorageError(409, "Note block has no matching immutable revision", "invalid_note_blocks");
417
+ }
418
+ for (let sessionIndex = 0; sessionIndex < sessions.length; sessionIndex++) {
419
+ const revision = revisions[i++];
420
+ if (!revision || revision.kind !== "wod") throw new StorageError(409, "Session pin must reference a saved workout revision", "invalid_session_pin");
421
+ }
422
+ }
356
423
 
357
424
  // src/sql/store.ts
358
425
  var SCHEMA_TABLE = "_wodwiki_schema";
@@ -519,6 +586,50 @@ async function getRow(conn, def, key) {
519
586
  );
520
587
  return rows[0] ? decodeRowJson(rows[0].data, binaryFieldOf(def)) : void 0;
521
588
  }
589
+ async function getRows(conn, def, keys) {
590
+ const found = /* @__PURE__ */ new Map();
591
+ for (const part of chunks(keys, 50)) {
592
+ const params = [];
593
+ const conditions = part.map((key) => {
594
+ const parts = toParts(key);
595
+ if (parts.length !== def.keyPath.length) throw new StorageError(400, "invalid compound key arity");
596
+ params.push(...parts.map(toSqlScalar));
597
+ return `(${keyCols(def).map((col) => `"${col}" = ?`).join(" AND ")})`;
598
+ });
599
+ for (const row of await conn.query(`SELECT "data" FROM "${def.name}" WHERE ${conditions.join(" OR ")}`, params)) {
600
+ const value = decodeRowJson(String(row.data), binaryFieldOf(def));
601
+ found.set(JSON.stringify(requireKey(def, value)), value);
602
+ }
603
+ }
604
+ return keys.map((key) => found.get(JSON.stringify(key)));
605
+ }
606
+ async function listRowsFromIndexMany(conn, def, indexName, keys) {
607
+ const idx = indexDefinition(def, indexName);
608
+ if (idx.multiEntry) {
609
+ const out = [];
610
+ for (const key of keys) out.push(await listRows(conn, def, { only: key }, void 0, indexName));
611
+ return out;
612
+ }
613
+ const found = /* @__PURE__ */ new Map();
614
+ for (const part of chunks(keys, 50)) {
615
+ const params = [];
616
+ const conditions = part.map((key) => {
617
+ const parts = toParts(key);
618
+ if (parts.length !== idx.keyPath.length) throw new StorageError(400, "invalid index key arity");
619
+ params.push(...parts.map(toSqlScalar));
620
+ return `(${parts.map((_, i) => `"${indexCol(idx.name, i)}" = ?`).join(" AND ")})`;
621
+ });
622
+ 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)) {
623
+ const value = decodeRowJson(String(row.data), binaryFieldOf(def));
624
+ const parts = idx.keyPath.map((path) => extractKeyPart(value, path));
625
+ const key = JSON.stringify(parts.length === 1 ? parts[0] : parts);
626
+ const bucket = found.get(key);
627
+ if (bucket) bucket.push(value);
628
+ else found.set(key, [value]);
629
+ }
630
+ }
631
+ return keys.map((key) => found.get(JSON.stringify(key)) ?? []);
632
+ }
522
633
  async function listRows(conn, def, range, count, indexName) {
523
634
  validateRange(range);
524
635
  const idx = indexName ? indexDefinition(def, indexName) : void 0;
@@ -576,51 +687,114 @@ async function countRows(conn, def, range, indexName) {
576
687
  const rows = await conn.query(`SELECT COUNT(*) AS "n" FROM "${def.name}"${cond}`, params);
577
688
  return Number(rows[0]?.n ?? 0);
578
689
  }
579
- async function putRow(conn, def, value) {
580
- const key = requireKey(def, value);
581
- const keyParts = toParts(key).map(toSqlScalar);
690
+ var PARAM_BUDGET = 2e4;
691
+ var ROW_CAP = 100;
692
+ var chunks = (arr, size) => {
693
+ const out = [];
694
+ for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
695
+ return out;
696
+ };
697
+ async function batchInsert(conn, table, cols, rows, onConflict) {
698
+ if (rows.length === 0) return;
699
+ const collist = cols.map((c) => `"${c}"`).join(", ");
700
+ const tuple = `(${cols.map(() => "?").join(", ")})`;
701
+ const per = Math.max(1, Math.min(ROW_CAP, Math.floor(PARAM_BUDGET / cols.length)));
702
+ const statements = chunks(rows, per).map((part) => ({
703
+ sql: `INSERT INTO "${table}" (${collist}) VALUES ${part.map(() => tuple).join(", ")} ${onConflict}`,
704
+ params: part.flat()
705
+ }));
706
+ if (conn.batch) await conn.batch(statements);
707
+ else for (const statement of statements) await conn.exec(statement.sql, statement.params);
708
+ }
709
+ async function deleteIn(conn, table, col, vals) {
710
+ for (const part of chunks(vals, 500)) {
711
+ if (part.length === 0) continue;
712
+ await conn.exec(`DELETE FROM "${table}" WHERE "${col}" IN (${part.map(() => "?").join(", ")})`, part);
713
+ }
714
+ }
715
+ async function putRows(conn, def, values, changedKeys) {
716
+ if (values.length === 0) return [];
717
+ for (const value of values) validateStorageRow(def.name, value);
582
718
  const keys = keyCols(def);
583
719
  const cols = [...keys];
584
- const vals = [...keyParts];
585
720
  for (const idx of def.indexes) {
586
721
  if (idx.multiEntry) continue;
587
- idx.keyPath.forEach((path, part) => {
588
- cols.push(indexCol(idx.name, part));
589
- vals.push(toSqlScalar(extractKeyPart(value, path) ?? null));
590
- });
722
+ idx.keyPath.forEach((_, part) => cols.push(indexCol(idx.name, part)));
591
723
  }
592
724
  cols.push("data");
593
- vals.push(encodeRowJson(value, binaryFieldOf(def)));
594
725
  const keySet = new Set(keys);
595
726
  const updates = cols.filter((c) => !keySet.has(c)).map((c) => `"${c}" = excluded."${c}"`).join(", ");
596
- const collist = cols.map((c) => `"${c}"`).join(", ");
597
- const placeholders = cols.map(() => "?").join(", ");
598
727
  const pk = keys.map((k) => `"${k}"`).join(", ");
599
- await conn.exec(
600
- `INSERT INTO "${def.name}" (${collist}) VALUES (${placeholders}) ON CONFLICT (${pk}) DO UPDATE SET ${updates}`,
601
- vals
602
- );
728
+ const rows = [];
729
+ const out = [];
730
+ const junction = [];
603
731
  const multi = multiEntryIndex(def);
604
- if (multi) {
605
- const jt = junctionTable(def, multi);
606
- const cond = keys.map((c) => `"${c}" = ?`).join(" AND ");
607
- await conn.exec(`DELETE FROM "${jt}" WHERE ${cond}`, keyParts);
608
- const items = arrayPart(value, multi.keyPath[0]);
609
- if (Array.isArray(items)) {
610
- const jcols = [...keys, "v"];
611
- const jcollist = jcols.map((c) => `"${c}"`).join(", ");
612
- const jph = jcols.map(() => "?").join(", ");
613
- for (const item of items) {
614
- await conn.exec(
615
- `INSERT INTO "${jt}" (${jcollist}) VALUES (${jph}) ON CONFLICT DO NOTHING`,
616
- [...keyParts, JSON.stringify(item)]
617
- );
732
+ for (const value of values) {
733
+ const k = requireKey(def, value);
734
+ const keyParts = toParts(k).map(toSqlScalar);
735
+ const vals = [...keyParts];
736
+ for (const idx of def.indexes) {
737
+ if (idx.multiEntry) continue;
738
+ idx.keyPath.forEach((path) => vals.push(toSqlScalar(extractKeyPart(value, path) ?? null)));
739
+ }
740
+ vals.push(encodeRowJson(value, binaryFieldOf(def)));
741
+ rows.push(vals);
742
+ out.push(k);
743
+ if (multi) {
744
+ const items = arrayPart(value, multi.keyPath[0]);
745
+ if (Array.isArray(items)) {
746
+ for (const item of items) junction.push([...keyParts, JSON.stringify(item)]);
618
747
  }
619
748
  }
620
749
  }
621
- return key;
750
+ const tuple = `(${cols.map(() => "?").join(", ")})`;
751
+ const per = Math.max(1, Math.min(ROW_CAP, Math.floor(PARAM_BUDGET / cols.length)));
752
+ const statements = chunks(rows, per).map((part) => ({
753
+ 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(", ")}`,
754
+ params: part.flat()
755
+ }));
756
+ let returned;
757
+ try {
758
+ if (conn.batch) returned = await conn.batch(statements);
759
+ else {
760
+ returned = [];
761
+ for (const statement of statements) returned.push(await conn.query(statement.sql, statement.params));
762
+ }
763
+ } catch (error) {
764
+ const message = error instanceof Error ? error.message : String(error);
765
+ for (const code of ["revision_conflict", "transaction_conflict", "invalid_session_pin"]) {
766
+ if (message.includes(code)) throw new StorageError(409, message, code);
767
+ }
768
+ throw error;
769
+ }
770
+ const changed = returned.flat().map((row) => {
771
+ const parts = keys.map((col) => row[col]);
772
+ return parts.length === 1 ? parts[0] : parts;
773
+ });
774
+ changedKeys?.push(...changed);
775
+ if (multi) {
776
+ const jt = junctionTable(def, multi);
777
+ const changedSet = new Set(changed.map((key) => JSON.stringify(key)));
778
+ const deletes = values.flatMap((value) => {
779
+ const key = requireKey(def, value);
780
+ if (!changedSet.has(JSON.stringify(key))) return [];
781
+ const items = arrayPart(value, multi.keyPath[0]) ?? [];
782
+ return [{
783
+ sql: `DELETE FROM "${jt}" WHERE ${keys.map((col) => `"${col}" = ?`).join(" AND ")}` + (items.length ? ` AND "v" NOT IN (${items.map(() => "?").join(", ")})` : ""),
784
+ params: [...toParts(key).map(toSqlScalar), ...items.map((item) => JSON.stringify(item))]
785
+ }];
786
+ });
787
+ if (conn.batch && deletes.length) await conn.batch(deletes);
788
+ else for (const statement of deletes) await conn.exec(statement.sql, statement.params);
789
+ 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");
790
+ }
791
+ return out;
792
+ }
793
+ async function putRow(conn, def, value) {
794
+ return (await putRows(conn, def, [value]))[0];
622
795
  }
623
796
  async function deleteRows(conn, def, keyOrRange) {
797
+ if (def.name === "block_revisions") throw new StorageError(409, "block history may only be removed by explicit wipe", "revision_conflict");
624
798
  const multi = multiEntryIndex(def);
625
799
  if (!isRange(keyOrRange)) {
626
800
  const parts = toParts(keyOrRange);
@@ -653,14 +827,15 @@ async function deleteRows(conn, def, keyOrRange) {
653
827
  }
654
828
  await conn.exec(`DELETE FROM "${def.name}"${where}`, params);
655
829
  }
656
- async function clearRows(conn, def) {
830
+ async function clearRows(conn, def, wipe = false) {
831
+ if (def.name === "block_revisions" && !wipe) throw new StorageError(409, "block history may only be removed by explicit wipe", "revision_conflict");
657
832
  const multi = multiEntryIndex(def);
658
833
  if (multi) await conn.exec(`DELETE FROM "${junctionTable(def, multi)}"`);
659
834
  await conn.exec(`DELETE FROM "${def.name}"`);
660
835
  }
661
836
  async function wipeRows(conn) {
662
837
  for (const def of STORE_DEFS) {
663
- if (!def.system) await clearRows(conn, def);
838
+ if (!def.system) await clearRows(conn, def, true);
664
839
  }
665
840
  }
666
841
  function tableListSql(dialect) {
@@ -696,31 +871,75 @@ async function ensureStoreTables(conn, dialect, def) {
696
871
  );
697
872
  await conn.exec(`CREATE INDEX IF NOT EXISTS "${jt}__v" ON "${jt}" ("v")`);
698
873
  }
874
+ if (def.name === "notes" && dialect === "sqlite") {
875
+ await conn.exec(`CREATE TRIGGER IF NOT EXISTS "notes_revision_guard" BEFORE UPDATE ON "notes"
876
+ WHEN json_extract(NEW."data", '$.sourceRevision') < json_extract(OLD."data", '$.sourceRevision')
877
+ OR ((json_extract(NEW."data", '$.rawContent') IS NOT json_extract(OLD."data", '$.rawContent')
878
+ OR json_extract(NEW."data", '$.blocks') IS NOT json_extract(OLD."data", '$.blocks'))
879
+ AND json_extract(NEW."data", '$.sourceRevision') <= json_extract(OLD."data", '$.sourceRevision'))
880
+ BEGIN SELECT RAISE(ABORT, 'transaction_conflict: stale Note source revision'); END`);
881
+ }
882
+ if (def.name === "block_revisions" && dialect === "sqlite") {
883
+ await conn.exec(`CREATE TRIGGER IF NOT EXISTS "block_revision_guard" BEFORE INSERT ON "block_revisions"
884
+ 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")
885
+ BEGIN SELECT RAISE(ABORT, 'revision_conflict: immutable block revision collision'); END`);
886
+ }
887
+ if (def.name === "sessions" && dialect === "sqlite") {
888
+ await conn.exec(`CREATE TRIGGER IF NOT EXISTS "session_pin_guard" BEFORE UPDATE ON "sessions"
889
+ WHEN json_extract(NEW."data", '$.noteId') IS NOT json_extract(OLD."data", '$.noteId')
890
+ OR json_extract(NEW."data", '$.blockId') IS NOT json_extract(OLD."data", '$.blockId')
891
+ OR json_extract(NEW."data", '$.blockVersion') IS NOT json_extract(OLD."data", '$.blockVersion')
892
+ BEGIN SELECT RAISE(ABORT, 'invalid_session_pin: Session pin is immutable'); END`);
893
+ }
894
+ if (dialect === "postgres" && ["notes", "block_revisions", "sessions"].includes(def.name)) {
895
+ 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'`;
896
+ const code = def.name === "block_revisions" ? "revision_conflict" : def.name === "notes" ? "transaction_conflict" : "invalid_session_pin";
897
+ const guard = `${def.name}_guard`;
898
+ 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; $$`);
899
+ await conn.exec(`DROP TRIGGER IF EXISTS "${guard}" ON "${def.name}"`);
900
+ await conn.exec(`CREATE TRIGGER "${guard}" BEFORE ${def.name === "block_revisions" ? "INSERT" : "UPDATE"} ON "${def.name}" FOR EACH ROW EXECUTE FUNCTION "${guard}"()`);
901
+ }
699
902
  }
700
903
  async function initializeStore(db) {
701
904
  await db.transaction(async (tx) => {
702
905
  await tx.exec(
703
906
  `CREATE TABLE IF NOT EXISTS "${SCHEMA_TABLE}" ("key" TEXT PRIMARY KEY, "value" TEXT NOT NULL)`
704
907
  );
908
+ const ignored = (name) => name.startsWith("_wodwiki_") || name.startsWith("sqlite_");
705
909
  const storeTables = new Set(STORE_DEFS.map((d) => d.name));
910
+ for (const def of STORE_DEFS) {
911
+ const multi = def.indexes.find((i) => i.multiEntry);
912
+ if (multi) storeTables.add(junctionTable(def, multi));
913
+ }
706
914
  const tableNames = new Set(
707
- (await tx.query(tableListSql(db.dialect))).map((r) => String(Object.values(r)[0]))
915
+ (await tx.query(tableListSql(db.dialect))).map((r) => String(Object.values(r)[0])).filter((n) => !ignored(n))
708
916
  );
709
917
  const guardRows = await tx.query(`SELECT "value" FROM "${SCHEMA_TABLE}" WHERE "key" = ?`, [SCHEMA_KEY]);
710
918
  let stored;
711
919
  if (guardRows[0]) {
712
920
  const guard = JSON.parse(String(guardRows[0].value));
713
- if (typeof guard.version !== "number") {
921
+ if (typeof guard.version !== "number" || !Number.isSafeInteger(guard.version) || guard.version < 0) {
714
922
  throw new StorageError(500, `corrupt ${SCHEMA_TABLE} guard row`, "bad_schema_guard");
715
923
  }
716
924
  stored = guard.version;
717
- if (stored !== DB_VERSION) {
925
+ if (stored > DB_VERSION) {
718
926
  throw new StorageError(
719
927
  500,
720
- `database schema v${stored} does not match this server (v${DB_VERSION}); use a compatible server or migrate a backed-up database`,
928
+ `database schema v${stored} is newer than this server supports (v${DB_VERSION}); refusing to downgrade`,
721
929
  "schema_incompatible"
722
930
  );
723
931
  }
932
+ if (stored < DB_VERSION) {
933
+ const missing = [...storeTables].filter((name) => !tableNames.has(name));
934
+ const unexpected = [...tableNames].filter((name) => name !== SCHEMA_TABLE && !storeTables.has(name));
935
+ if (missing.length > 0 || unexpected.length > 0) {
936
+ throw new StorageError(
937
+ 500,
938
+ `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`,
939
+ "schema_incompatible"
940
+ );
941
+ }
942
+ }
724
943
  } else if ([...tableNames].some((name) => storeTables.has(name))) {
725
944
  throw new StorageError(
726
945
  500,
@@ -737,10 +956,8 @@ async function initializeStore(db) {
737
956
  }
738
957
  });
739
958
  }
740
-
741
- // src/domain.ts
742
- var DOMAIN_PROJECTION_VERSION = 1;
743
- var TABLES = ["domain_note", "query_block", "segment_head"];
959
+ var DOMAIN_PROJECTION_VERSION = 2;
960
+ var TABLES = ["domain_note", "query_block"];
744
961
  var PROJECTION_GUARD_KEY = "domain_projection";
745
962
  function sourceItem(row) {
746
963
  const item = {};
@@ -759,7 +976,7 @@ function ensureDdl(db) {
759
976
  const n = db.dialect === "postgres" ? " DOUBLE PRECISION" : "";
760
977
  const t = db.dialect === "postgres" ? ' TEXT COLLATE "C"' : " TEXT";
761
978
  const ddl = [
762
- `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)`,
979
+ `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)`,
763
980
  `CREATE INDEX IF NOT EXISTS "idx_domain_note_date" ON "domain_note" ("date", "note_id")`,
764
981
  `CREATE INDEX IF NOT EXISTS "idx_domain_note_created" ON "domain_note" ("created_at", "note_id")`,
765
982
  `CREATE INDEX IF NOT EXISTS "idx_domain_note_source_path" ON "domain_note" ("source_path")`,
@@ -768,169 +985,140 @@ function ensureDdl(db) {
768
985
  `CREATE INDEX IF NOT EXISTS "idx_query_block_parent" ON "query_block" ("parent_note_id", "ordinal")`,
769
986
  `CREATE INDEX IF NOT EXISTS "idx_query_block_content" ON "query_block" ("content_id")`,
770
987
  `CREATE INDEX IF NOT EXISTS "idx_query_block_source_path" ON "query_block" ("source_path")`,
771
- `CREATE INDEX IF NOT EXISTS "idx_query_block_source_note" ON "query_block" ("source_note_id")`,
772
- `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)`,
773
- `CREATE INDEX IF NOT EXISTS "idx_segment_head_note" ON "segment_head" ("note_id", "id")`
988
+ `CREATE INDEX IF NOT EXISTS "idx_query_block_source_note" ON "query_block" ("source_note_id")`
774
989
  ];
775
990
  return db.transaction(async (tx) => {
776
991
  for (const sql of ddl) await tx.exec(sql);
777
992
  });
778
993
  }
779
- async function upsertDomainNote(conn, note) {
994
+ var DOMAIN_NOTE_COLS = ["note_id", "note_type", "catalog", "source_id", "source_path", "title", "title_search", "created_at", "date", "text_search", "journal", "payload"];
995
+ 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"];
996
+ 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 ");
997
+ function domainNoteRow(note) {
780
998
  const id = text(note.id);
781
- if (!id) return;
999
+ if (!id) return void 0;
782
1000
  const source = sourceItem(note);
783
- await conn.exec(
784
- `INSERT INTO "domain_note" ("note_id","note_type","catalog","source_id","source_path","title","created_at","date","text_search","journal","payload")
785
- VALUES (?,?,?,?,?,?,?,?,?,?,?)
786
- ON CONFLICT ("note_id") DO UPDATE SET "note_type"=excluded."note_type","catalog"=excluded."catalog","source_id"=excluded."source_id",
787
- "source_path"=excluded."source_path","title"=excluded."title","created_at"=excluded."created_at","date"=excluded."date",
788
- "text_search"=excluded."text_search","journal"=excluded."journal","payload"=excluded."payload"`,
789
- [
790
- id,
791
- T(note.type),
792
- T(wodWikiWql.catalogOfItem(source)),
793
- T(note.sourceId),
794
- T(note.sourcePath),
795
- T(note.title) ?? "",
796
- N(note.createdAt),
797
- num(note.date),
798
- (text(note.title) ?? "").toLowerCase(),
799
- wodWikiWql.sourceMatches(source, "journal") ? 1 : 0,
800
- encodeRowJson(note)
801
- ]
802
- );
803
- }
804
- async function healQueryBlocksForNote(conn, note) {
805
- const id = text(note.id);
806
- if (!id) return;
807
- const rows = await conn.query(
808
- `SELECT "occurrence_id" FROM "query_block"
809
- WHERE "source_note_id" = ? OR ("source_path" IS NOT NULL AND "source_path" = ?)`,
810
- [id, T(note.sourcePath)]
811
- );
812
- for (const row of rows) await refreshBlock(conn, String(row.occurrence_id));
813
- }
814
- async function upsertSegmentHead(conn, id, head) {
815
- await conn.exec(
816
- `INSERT INTO "segment_head" ("id","note_id","current_version","position","search_text")
817
- VALUES (?,?,?,?,?)
818
- ON CONFLICT ("id") DO UPDATE SET "note_id"=excluded."note_id","current_version"=excluded."current_version",
819
- "position"=excluded."position","search_text"=excluded."search_text"`,
820
- [
821
- id,
822
- T(head.noteId) ?? "",
823
- N(head.version),
824
- // Local latest-segments order is position ?? createdAt — same fallback here.
825
- num(head.position) ?? num(head.createdAt),
826
- (text(head.rawContent) ?? "").toLowerCase()
827
- ]
828
- );
829
- }
830
- async function identityBy(conn, col, val) {
831
- const rows = await conn.query(
832
- `SELECT "note_id", "date", "created_at" FROM "domain_note" WHERE "${col}" = ?`,
833
- [val]
834
- );
835
- return rows.map((r) => ({
836
- note_id: String(r.note_id),
837
- date: num(r.date),
838
- created_at: num(r.created_at) ?? 0
839
- }));
1001
+ return [
1002
+ id,
1003
+ T(note.type),
1004
+ T(wodWikiWql.catalogOfItem(source)),
1005
+ T(note.sourceId),
1006
+ T(note.sourcePath),
1007
+ T(note.title) ?? "",
1008
+ (text(note.title) ?? "").toLowerCase(),
1009
+ N(note.createdAt),
1010
+ num(note.date),
1011
+ (text(note.rawContent) ?? "").toLowerCase(),
1012
+ wodWikiWql.sourceMatches(source, "journal") ? 1 : 0,
1013
+ encodeRowJson(note)
1014
+ ];
840
1015
  }
841
- async function resolveParent(conn, block) {
842
- const byPath = async (sp2) => {
843
- const rows = await identityBy(conn, "source_path", sp2);
844
- return rows.length === 1 ? rows[0] : void 0;
845
- };
846
- const nid = text(block.noteId);
847
- if (nid) {
848
- const rows = await identityBy(conn, "note_id", nid);
849
- if (rows.length === 1) return rows[0];
850
- if (rows.length > 1) return void 0;
851
- }
852
- const sp = text(block.sourcePath);
853
- return sp ? byPath(sp) : void 0;
1016
+ async function upsertDomainNotes(conn, notes) {
1017
+ const rows = notes.map(domainNoteRow).filter((r) => r !== void 0);
1018
+ await batchInsert(conn, "domain_note", [...DOMAIN_NOTE_COLS], rows, conflictUpdate(DOMAIN_NOTE_COLS, ["note_id"]));
854
1019
  }
855
- async function upsertQueryBlock(conn, block) {
1020
+ function queryBlockRow(block, parent) {
856
1021
  const id = text(block.id);
857
- if (!id) return;
1022
+ if (!id) return void 0;
858
1023
  const source = sourceItem(block);
859
1024
  const createdAt = N(block.createdAt);
860
- const parent = await resolveParent(conn, block);
861
1025
  const effective = parent ? parent.date ?? parent.created_at : createdAt;
862
- await conn.exec(
863
- `INSERT INTO "query_block" ("occurrence_id","source_note_id","parent_note_id","content_id","section_key","ordinal",
864
- "data_type","source_id","source_path","catalog","effective_date","created_at","note_title","normalized_text","journal","payload")
865
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
866
- ON CONFLICT ("occurrence_id") DO UPDATE SET "source_note_id"=excluded."source_note_id","parent_note_id"=excluded."parent_note_id",
867
- "content_id"=excluded."content_id","section_key"=excluded."section_key","ordinal"=excluded."ordinal",
868
- "data_type"=excluded."data_type","source_id"=excluded."source_id","source_path"=excluded."source_path",
869
- "catalog"=excluded."catalog","effective_date"=excluded."effective_date","created_at"=excluded."created_at",
870
- "note_title"=excluded."note_title","normalized_text"=excluded."normalized_text","journal"=excluded."journal",
871
- "payload"=excluded."payload"`,
872
- [
873
- id,
874
- T(block.noteId),
875
- parent?.note_id ?? null,
876
- T(block.blockContentId),
877
- T(block.segmentId),
878
- num(block.position),
879
- T(block.dataType),
880
- T(block.sourceId),
881
- T(block.sourcePath),
882
- T(wodWikiWql.catalogOfItem(source)),
883
- effective,
884
- createdAt,
885
- T(block.noteTitle),
886
- (text(block.rawContent) ?? "").toLowerCase(),
887
- wodWikiWql.sourceMatches(source, "journal") ? 1 : 0,
888
- encodeRowJson(block)
889
- ]
890
- );
1026
+ return [
1027
+ id,
1028
+ T(block.noteId),
1029
+ parent?.note_id ?? null,
1030
+ T(block.blockContentId),
1031
+ T(block.blockId),
1032
+ num(block.position),
1033
+ T(block.dataType),
1034
+ T(block.sourceId),
1035
+ T(block.sourcePath),
1036
+ T(wodWikiWql.catalogOfItem(source)),
1037
+ effective,
1038
+ createdAt,
1039
+ parent?.note.title ?? T(block.noteTitle),
1040
+ (text(block.rawContent) ?? "").toLowerCase(),
1041
+ wodWikiWql.sourceMatches(source, "journal") ? 1 : 0,
1042
+ encodeRowJson(parent ? { ...block, noteTitle: parent.note.title } : block)
1043
+ ];
891
1044
  }
892
- var keyHead = (key) => Array.isArray(key) ? key[0] : key;
893
- async function refreshNote(conn, id) {
894
- const k = keyHead(id);
895
- const rows = await conn.query(`SELECT "data" FROM "notes" WHERE "k0" = ?`, [k]);
896
- const note = rows[0] ? decodeRowJson(String(rows[0].data)) : void 0;
897
- if (note) {
898
- await upsertDomainNote(conn, note);
899
- await healQueryBlocksForNote(conn, note);
900
- } else {
901
- const orphans = await conn.query(
902
- `SELECT "occurrence_id" FROM "query_block" WHERE "parent_note_id" = ? OR "source_note_id" = ?`,
903
- [k, k]
904
- );
905
- await conn.exec(`DELETE FROM "domain_note" WHERE "note_id" = ?`, [k]);
906
- for (const row of orphans) await refreshBlock(conn, String(row.occurrence_id));
907
- }
1045
+ async function upsertQueryBlocks(conn, blocks) {
1046
+ const rows = blocks.map(([b, p]) => queryBlockRow(b, p)).filter((r) => r !== void 0);
1047
+ await batchInsert(conn, "query_block", [...QUERY_BLOCK_COLS], rows, conflictUpdate(QUERY_BLOCK_COLS, ["occurrence_id"]));
908
1048
  }
909
- async function refreshSegment(conn, id) {
910
- const k = keyHead(id);
911
- const rows = await conn.query(`SELECT "data" FROM "segments" WHERE "k0" = ?`, [k]);
912
- let head;
913
- let headVersion = -Infinity;
914
- for (const row of rows) {
915
- const seg = decodeRowJson(String(row.data));
916
- const v = num(seg.version) ?? 0;
917
- if (!head || v > headVersion) {
918
- head = seg;
919
- headVersion = v;
920
- }
1049
+ function indexNoteIdentities(notes) {
1050
+ const byNoteId = /* @__PURE__ */ new Map();
1051
+ for (const note of notes) {
1052
+ const id = text(note.id);
1053
+ if (id) byNoteId.set(id, { note_id: id, date: num(note.date), created_at: num(note.createdAt) ?? 0, note });
921
1054
  }
922
- if (head) await upsertSegmentHead(conn, k, head);
923
- else await conn.exec(`DELETE FROM "segment_head" WHERE "id" = ?`, [k]);
1055
+ return { byNoteId };
1056
+ }
1057
+ function resolveParentFrom(index, block) {
1058
+ const parent = typeof block.noteId === "string" ? index.byNoteId.get(block.noteId) : void 0;
1059
+ const mapped = typeof block.blockId === "string" ? parent?.note.blocks[block.blockId] : void 0;
1060
+ 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;
1061
+ }
1062
+ async function fetchParentIndex(conn, noteIds) {
1063
+ const notes = await getRows(conn, storeDefinition("notes"), noteIds);
1064
+ return indexNoteIdentities(notes.filter((row) => row !== void 0));
924
1065
  }
925
- async function refreshBlock(conn, id) {
926
- const k = keyHead(id);
927
- const rows = await conn.query(`SELECT "data" FROM "block_index" WHERE "k0" = ?`, [k]);
928
- if (rows[0]) await upsertQueryBlock(conn, decodeRowJson(String(rows[0].data)));
929
- else await conn.exec(`DELETE FROM "query_block" WHERE "occurrence_id" = ?`, [k]);
1066
+ var keyHead = (key) => Array.isArray(key) ? key[0] : key;
1067
+ async function selectIn(conn, selectCol, table, col, vals) {
1068
+ const IN_CHUNK = 500;
1069
+ const out = [];
1070
+ for (let i = 0; i < vals.length; i += IN_CHUNK) {
1071
+ const part = vals.slice(i, i + IN_CHUNK);
1072
+ const rows = await conn.query(`SELECT "${selectCol}" FROM "${table}" WHERE "${col}" IN (${part.map(() => "?").join(", ")})`, part);
1073
+ for (const r of rows) out.push(String(r[selectCol]));
1074
+ }
1075
+ return out;
1076
+ }
1077
+ async function healQueryBlocksForNotes(conn, noteIds) {
1078
+ const ids = await selectIn(conn, "occurrence_id", "query_block", "source_note_id", noteIds);
1079
+ await refreshBlocks(conn, [...new Set(ids)]);
1080
+ }
1081
+ async function refreshNotes(conn, ids) {
1082
+ const ks = [...new Set(ids.map((id) => String(keyHead(id))))];
1083
+ if (ks.length === 0) return;
1084
+ const found = /* @__PURE__ */ new Map();
1085
+ for (let i = 0; i < ks.length; i += 500) {
1086
+ const part = ks.slice(i, i + 500);
1087
+ const rows = await conn.query(`SELECT "k0", "data" FROM "notes" WHERE "k0" IN (${part.map(() => "?").join(", ")})`, part);
1088
+ for (const row of rows) found.set(String(row.k0), decodeRowJson(String(row.data)));
1089
+ }
1090
+ const present = [];
1091
+ const missing = [];
1092
+ for (const k of ks) {
1093
+ const note = found.get(k);
1094
+ if (note) present.push(note);
1095
+ else missing.push(k);
1096
+ }
1097
+ await upsertDomainNotes(conn, present);
1098
+ const presentIds = present.map((n) => text(n.id)).filter((x) => !!x);
1099
+ await healQueryBlocksForNotes(conn, presentIds);
1100
+ await deleteIn(conn, "domain_note", "note_id", missing);
1101
+ await deleteIn(conn, "query_block", "source_note_id", missing);
1102
+ await deleteIn(conn, "block_index", "i_by_note_0", missing);
1103
+ await deleteIn(conn, "block_efforts", "i_by_note_0", missing);
1104
+ }
1105
+ async function refreshBlocks(conn, ids) {
1106
+ const ks = [...new Set(ids.map((id) => String(keyHead(id))))];
1107
+ if (ks.length === 0) return;
1108
+ const found = /* @__PURE__ */ new Map();
1109
+ for (let i = 0; i < ks.length; i += 500) {
1110
+ const part = ks.slice(i, i + 500);
1111
+ const rows = await conn.query(`SELECT "k0", "data" FROM "block_index" WHERE "k0" IN (${part.map(() => "?").join(", ")})`, part);
1112
+ for (const row of rows) found.set(String(row.k0), decodeRowJson(String(row.data)));
1113
+ }
1114
+ const blocks = [...found.values()];
1115
+ const noteIds = [...new Set(blocks.map((b) => text(b.noteId)).filter((x) => !!x))];
1116
+ const index = await fetchParentIndex(conn, noteIds);
1117
+ await upsertQueryBlocks(conn, blocks.map((b) => [b, resolveParentFrom(index, b)]));
1118
+ await deleteIn(conn, "query_block", "occurrence_id", ks.filter((k) => !found.has(k)));
930
1119
  }
931
1120
  var TRACKED = {
932
1121
  notes: "notes",
933
- segments: "segments",
934
1122
  block_index: "blocks"
935
1123
  };
936
1124
  async function deleteTargetKeys(conn, def, target) {
@@ -938,29 +1126,54 @@ async function deleteTargetKeys(conn, def, target) {
938
1126
  const rows = await listRows(conn, def, target);
939
1127
  return rows.map((row) => keyFromValue(def, row));
940
1128
  }
941
- async function refreshDomain(conn, ops, opts = {}) {
1129
+ async function refreshDomain(conn, ops) {
942
1130
  const impact = {
943
1131
  notes: [],
944
- segments: [],
945
1132
  blocks: [],
1133
+ pinnedNotes: [],
1134
+ pinnedSessions: [],
946
1135
  notesClear: false,
947
- segmentsClear: false,
948
1136
  blocksClear: false
949
1137
  };
950
1138
  const results = [];
1139
+ for (const op of ops) if (op === null || typeof op !== "object" || Array.isArray(op)) throw new StorageError(400, "tx op must be an object");
1140
+ const checks = ops.filter((op) => op.op === "check");
1141
+ const checkStores = [...new Set(checks.map((op) => op.store))];
1142
+ for (const store of checkStores) {
1143
+ const scoped = checks.filter((op) => op.store === store);
1144
+ const actual = await getRows(conn, storeDefinition(store), scoped.map((op) => op.key));
1145
+ scoped.forEach((op, i) => {
1146
+ if (!rowsEqual(actual[i] ?? null, op.value)) throw new StorageError(409, "transaction read changed before commit", "transaction_conflict");
1147
+ });
1148
+ }
1149
+ let pending;
1150
+ const flushPuts = async () => {
1151
+ if (!pending) return;
1152
+ const batch = pending;
1153
+ pending = void 0;
1154
+ if (batch.values.length === 0) return;
1155
+ const changed = [];
1156
+ const keys = await putRows(conn, batch.def, batch.values, changed);
1157
+ const slot = TRACKED[batch.def.name];
1158
+ if (slot) impact[slot].push(...changed);
1159
+ const changedSet = new Set(changed.map((key) => JSON.stringify(key)));
1160
+ if (batch.def.name === "notes") impact.pinnedNotes.push(...batch.values.filter((value) => changedSet.has(JSON.stringify(keyFromValue(batch.def, value)))));
1161
+ if (batch.def.name === "sessions") impact.pinnedSessions.push(...batch.values.filter((value) => changedSet.has(JSON.stringify(keyFromValue(batch.def, value)))));
1162
+ results.push(...keys);
1163
+ };
951
1164
  for (const op of ops) {
952
- if (op === null || typeof op !== "object" || Array.isArray(op)) {
953
- throw new StorageError(400, "tx op must be an object");
1165
+ if (op.op === "check") {
1166
+ results.push(null);
1167
+ continue;
954
1168
  }
955
1169
  const store = "store" in op ? op.store : void 0;
956
1170
  const def = storeDefinition(typeof store === "string" ? store : String(store));
957
1171
  const kind = typeof op.op === "string" ? op.op : void 0;
958
1172
  if (op.op === "put") {
959
- const key = await putRow(conn, def, op.value);
960
- const slot = TRACKED[def.name];
961
- if (slot) impact[slot].push(key);
962
- results.push(key);
1173
+ if (pending && pending.def.name !== def.name) await flushPuts();
1174
+ (pending ??= { def, values: [] }).values.push(op.value);
963
1175
  } else if (op.op === "delete") {
1176
+ await flushPuts();
964
1177
  const slot = TRACKED[def.name];
965
1178
  if (slot) {
966
1179
  for (const key of await deleteTargetKeys(conn, def, op.key)) impact[slot].push(key);
@@ -968,28 +1181,32 @@ async function refreshDomain(conn, ops, opts = {}) {
968
1181
  await deleteRows(conn, def, op.key);
969
1182
  results.push(null);
970
1183
  } else if (op.op === "clear") {
1184
+ await flushPuts();
971
1185
  await clearRows(conn, def);
972
1186
  if (def.name === "notes") impact.notesClear = true;
973
- else if (def.name === "segments") impact.segmentsClear = true;
974
1187
  else if (def.name === "block_index") impact.blocksClear = true;
975
1188
  results.push(null);
976
1189
  } else {
977
- throw new StorageError(400, `tx op must be put | delete | clear, got: ${String(kind)}`);
1190
+ throw new StorageError(400, `tx op must be check | put | delete | clear, got: ${String(kind)}`);
978
1191
  }
979
1192
  }
980
- if (!opts.deferProjection) await refresh(conn, impact);
1193
+ await flushPuts();
1194
+ const finalNotes = (await getRows(conn, storeDefinition("notes"), [...new Set(impact.pinnedNotes.map((note) => note.id))])).filter(Boolean);
1195
+ const finalSessions = (await getRows(conn, storeDefinition("sessions"), [...new Set(impact.pinnedSessions.map((session) => session.id))])).filter(Boolean);
1196
+ await validateStoredPins(finalNotes, finalSessions, (keys) => getRows(conn, storeDefinition("block_revisions"), keys));
1197
+ await refresh(conn, impact);
981
1198
  return results;
982
1199
  }
983
1200
  async function refresh(conn, impact) {
984
1201
  if (impact.notesClear) {
985
1202
  await conn.exec(`DELETE FROM "domain_note"`);
986
- await conn.exec(`UPDATE "query_block" SET "parent_note_id" = NULL`);
1203
+ await conn.exec(`DELETE FROM "query_block" WHERE "source_note_id" NOT IN (SELECT "k0" FROM "notes")`);
1204
+ await conn.exec(`DELETE FROM "block_index" WHERE "i_by_note_0" NOT IN (SELECT "k0" FROM "notes")`);
1205
+ await conn.exec(`DELETE FROM "block_efforts" WHERE "i_by_note_0" NOT IN (SELECT "k0" FROM "notes")`);
987
1206
  }
988
- if (impact.segmentsClear) await conn.exec(`DELETE FROM "segment_head"`);
989
1207
  if (impact.blocksClear) await conn.exec(`DELETE FROM "query_block"`);
990
- for (const id of impact.notes) await refreshNote(conn, id);
991
- for (const key of impact.segments) await refreshSegment(conn, key);
992
- for (const id of impact.blocks) await refreshBlock(conn, id);
1208
+ await refreshNotes(conn, impact.notes);
1209
+ await refreshBlocks(conn, impact.blocks);
993
1210
  }
994
1211
  async function rebuildProjections(conn) {
995
1212
  await conn.exec(
@@ -997,18 +1214,12 @@ async function rebuildProjections(conn) {
997
1214
  );
998
1215
  for (const t of TABLES) await conn.exec(`DELETE FROM "${t}"`);
999
1216
  const notes = await conn.query(`SELECT "data" FROM "notes"`);
1000
- for (const row of notes) await upsertDomainNote(conn, decodeRowJson(String(row.data)));
1001
- const segments = await conn.query(`SELECT "k0", "data" FROM "segments"`);
1002
- const heads = /* @__PURE__ */ new Map();
1003
- for (const row of segments) {
1004
- const seg = decodeRowJson(String(row.data));
1005
- const v = num(seg.version) ?? 0;
1006
- const prev = heads.get(String(row.k0));
1007
- if (!prev || v > prev.version) heads.set(String(row.k0), { id: row.k0, row: seg, version: v });
1008
- }
1009
- for (const head of heads.values()) await upsertSegmentHead(conn, head.id, head.row);
1217
+ const noteObjs = notes.map((row) => decodeRowJson(String(row.data)));
1218
+ await upsertDomainNotes(conn, noteObjs);
1010
1219
  const blocks = await conn.query(`SELECT "data" FROM "block_index"`);
1011
- for (const row of blocks) await upsertQueryBlock(conn, decodeRowJson(String(row.data)));
1220
+ const blockObjs = blocks.map((row) => decodeRowJson(String(row.data)));
1221
+ const index = indexNoteIdentities(noteObjs);
1222
+ await upsertQueryBlocks(conn, blockObjs.map((b) => [b, resolveParentFrom(index, b)]));
1012
1223
  await conn.exec(
1013
1224
  `INSERT INTO "${SCHEMA_TABLE}" ("key", "value") VALUES (?, ?) ON CONFLICT ("key") DO UPDATE SET "value" = excluded."value"`,
1014
1225
  [PROJECTION_GUARD_KEY, JSON.stringify({ version: DOMAIN_PROJECTION_VERSION })]
@@ -1245,13 +1456,8 @@ function predicateSql(ctx, p, plan) {
1245
1456
  p.negate === true,
1246
1457
  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(", ")}))`
1247
1458
  );
1248
- case "text": {
1249
- const textCol = blocks ? 'q."normalized_text"' : 'n."text_search"';
1250
- const title = ctx.contains(textCol, p.value);
1251
- if (plan !== "entries") return title;
1252
- const body = ctx.contains('h."search_text"', p.value);
1253
- 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}))`;
1254
- }
1459
+ case "text":
1460
+ return blocks ? ctx.contains('q."normalized_text"', p.value) : `(${ctx.contains('n."title_search"', p.value)} OR ${ctx.contains('n."text_search"', p.value)})`;
1255
1461
  case "date": {
1256
1462
  const expr = blocks ? 'q."effective_date"' : 'COALESCE(n."date", n."created_at")';
1257
1463
  const lo = ctx.bind(p.start);
@@ -1295,7 +1501,7 @@ async function gate(conn) {
1295
1501
  if (stale > 0) {
1296
1502
  throw new StorageError(
1297
1503
  409,
1298
- `domain projections stale: ${stale} block rows have unresolved static parents`,
1504
+ `domain projections stale: ${stale} block rows have invalid or unresolved current parents`,
1299
1505
  "domain_projection_unavailable"
1300
1506
  );
1301
1507
  }
@@ -1314,47 +1520,43 @@ async function execute(conn, q, pg) {
1314
1520
  const fullWhere = selWhere && filterWhere ? `${selWhere} AND ${filterWhere.replace(/^ WHERE /, "")}` : selWhere || filterWhere;
1315
1521
  const count = async (where, params) => Number((await conn.query(`SELECT COUNT(*) AS "n" FROM ${table}${where}`, params))[0]?.n ?? 0);
1316
1522
  const selectedCount = await count(selWhere, selCtx.params);
1317
- const matchedCount = await count(fullWhere, allParams);
1523
+ const matchedCount = q.filters.length === 0 ? selectedCount : await count(fullWhere, allParams);
1318
1524
  const ordering = orderSql(q.order, plan);
1319
1525
  const paging = pagingSql(q, pg);
1320
1526
  if (plan !== "entries") {
1321
1527
  const pageSql2 = `SELECT "payload" FROM ${table}${fullWhere} ORDER BY ${ordering}${paging}`;
1322
1528
  const rows2 = (await conn.query(pageSql2, allParams)).map((r) => decodeRowJson(String(r.payload)));
1323
- const result2 = { projectionVersion: DOMAIN_PROJECTION_VERSION, selectedCount, matchedCount, plan, rows: rows2 };
1324
- return result2;
1529
+ const result = { projectionVersion: DOMAIN_PROJECTION_VERSION, selectedCount, matchedCount, plan, rows: rows2 };
1530
+ return result;
1325
1531
  }
1326
1532
  const pageSql = `SELECT "note_id", "payload" FROM ${table}${fullWhere} ORDER BY ${ordering}${paging}`;
1327
1533
  const noteRows = await conn.query(pageSql, allParams);
1328
- const relations = [
1329
- `SELECT h."note_id" AS "note_id", s."data" AS "payload" FROM selected n
1330
- JOIN "segment_head" h ON h."note_id" = n."note_id"
1331
- JOIN "segments" s ON s."k0" = h."id" AND s."k1" = h."current_version"
1332
- WHERE COALESCE(s."i_by_history_0", 0) = 0
1333
- ORDER BY h."position" ASC NULLS LAST, s."k0"`,
1334
- `SELECT nt."i_by_note_0" AS "note_id", t."data" AS "payload" FROM selected n
1335
- JOIN "note_tags" nt ON nt."i_by_note_0" = n."note_id"
1336
- JOIN "tags" t ON t."k0" = nt."i_by_tag_0" ORDER BY t."k0"`,
1337
- `SELECT p."i_by_note_0" AS "note_id", p."data" AS "payload" FROM selected n
1338
- JOIN "page_notes" p ON p."i_by_note_0" = n."note_id" ORDER BY p."k0"`,
1339
- `SELECT DISTINCT p."i_by_note_0" AS "note_id", pg."data" AS "payload" FROM selected n
1340
- JOIN "page_notes" p ON p."i_by_note_0" = n."note_id"
1341
- JOIN "page" pg ON pg."k0" = p."i_by_page_0" ORDER BY pg."data"`
1342
- ];
1343
- const buckets = noteRows.map(() => [[], [], [], []]);
1534
+ const buckets = noteRows.map(() => [[], [], []]);
1344
1535
  const index = /* @__PURE__ */ new Map();
1345
- noteRows.forEach((r, i) => index.set(String(r.note_id), i));
1346
- for (const [rel, relation] of relations.entries()) {
1347
- for (const row of await conn.query(`WITH selected AS (${pageSql}) ${relation}`, allParams)) {
1536
+ noteRows.forEach((row, i) => index.set(String(row.note_id), i));
1537
+ for (let start = 0; start < noteRows.length; start += 500) {
1538
+ const ids = noteRows.slice(start, start + 500).map((row) => String(row.note_id));
1539
+ const placeholders = ids.map(() => "?").join(", ");
1540
+ const statements = [
1541
+ { 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 },
1542
+ { 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 },
1543
+ { 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 }
1544
+ ];
1545
+ const relations = conn.batch ? await conn.batch(statements) : await (async () => {
1546
+ const rows2 = [];
1547
+ for (const statement of statements) rows2.push(await conn.query(statement.sql, statement.params));
1548
+ return rows2;
1549
+ })();
1550
+ relations.forEach((rows2, relation) => rows2.forEach((row) => {
1348
1551
  const i = index.get(String(row.note_id));
1349
- if (i !== void 0) buckets[i][rel].push(decodeRowJson(String(row.payload)));
1350
- }
1552
+ if (i !== void 0) buckets[i][relation].push(decodeRowJson(String(row.payload)));
1553
+ }));
1351
1554
  }
1352
- const rows = noteRows.map((r, i) => {
1353
- const [segments, tags, links, pages] = buckets[i];
1354
- return { note: decodeRowJson(String(r.payload)), segments, tags, links, pages };
1555
+ const rows = noteRows.map((row, i) => {
1556
+ const [tags, links, pages] = buckets[i];
1557
+ return { note: decodeRowJson(String(row.payload)), tags, links, pages };
1355
1558
  });
1356
- const result = { projectionVersion: DOMAIN_PROJECTION_VERSION, selectedCount, matchedCount, plan, rows };
1357
- return result;
1559
+ return { projectionVersion: DOMAIN_PROJECTION_VERSION, selectedCount, matchedCount, plan, rows };
1358
1560
  }
1359
1561
  async function handleDomainQuery(db, body) {
1360
1562
  const query = parseQuery(body);
@@ -1373,7 +1575,6 @@ var USER_OWNED_STORES = [
1373
1575
  "tags",
1374
1576
  "tag_types",
1375
1577
  "note_tags",
1376
- "segments",
1377
1578
  "sessions",
1378
1579
  "attachments",
1379
1580
  "events",
@@ -1524,18 +1725,14 @@ var SqlStorage = class {
1524
1725
  }
1525
1726
  async transaction(stores, mode, fn) {
1526
1727
  await this.ensureReady();
1527
- return this.runTx(stores, mode === "readwrite", fn, false);
1728
+ return this.runTx(stores, mode === "readwrite", fn);
1528
1729
  }
1529
- /**
1530
- * Bulk load for migration destinations: like transaction(), but the
1531
- * buffered writes skip per-key projection refresh and derived state is
1532
- * rebuilt once — everything inside one SQL transaction (throw → rollback).
1533
- */
1730
+ /** Bulk writes use the same affected-key refresh as ordinary transactions. */
1534
1731
  async bulkTransaction(stores, fn) {
1535
1732
  await this.ensureReady();
1536
- return this.runTx(stores, true, fn, true);
1733
+ return this.runTx(stores, true, fn);
1537
1734
  }
1538
- runTx(stores, mutableMode, fn, deferProjection) {
1735
+ runTx(stores, mutableMode, fn) {
1539
1736
  const scoped = new Set(stores);
1540
1737
  const buffer = /* @__PURE__ */ new Map();
1541
1738
  const cleared = /* @__PURE__ */ new Set();
@@ -1561,8 +1758,7 @@ var SqlStorage = class {
1561
1758
  const result = await fn(tx);
1562
1759
  const ops = bufferToOps(STORE_DEFS.map((d) => d.name), buffer, cleared);
1563
1760
  if (ops.length > 0) {
1564
- await refreshDomain(conn, ops, deferProjection ? { deferProjection: true } : {});
1565
- if (deferProjection) await rebuildProjections(conn);
1761
+ await refreshDomain(conn, ops);
1566
1762
  }
1567
1763
  return result;
1568
1764
  });
@@ -1637,6 +1833,20 @@ var SqlStore = class {
1637
1833
  if (this.cleared?.has(this.store)) return void 0;
1638
1834
  return getRow(this.conn, this.def, k);
1639
1835
  }
1836
+ async getMany(keys) {
1837
+ await this.owner.ensureReady();
1838
+ const normalized = keys.map((key) => this.pointKey(key));
1839
+ const committed = this.cleared?.has(this.store) ? keys.map(() => void 0) : await getRows(this.conn, this.def, normalized);
1840
+ return normalized.map((key, i) => {
1841
+ const state = bufferedGet(this.store, key, this.buffer);
1842
+ return state ? state.hit ? state.value : void 0 : committed[i];
1843
+ });
1844
+ }
1845
+ async getAllFromIndexMany(indexName, keys) {
1846
+ await this.owner.ensureReady();
1847
+ const rows = await listRowsFromIndexMany(this.conn, this.def, indexName, keys.map((key) => this.pointKey(key)));
1848
+ return rows.map((values, i) => overlayRows(this.def, this.store, this.buffer, this.cleared, values, void 0, { only: this.pointKey(keys[i]) }, indexName));
1849
+ }
1640
1850
  async getAll(query, count) {
1641
1851
  await this.owner.ensureReady();
1642
1852
  const range = rangeFromIDB(query);
@@ -1668,6 +1878,10 @@ var SqlStore = class {
1668
1878
  this.assertMutable();
1669
1879
  const stored = this.owner.stamp(this.store, value);
1670
1880
  const key = keyFromValue(this.def, stored);
1881
+ if (this.store === "block_revisions") {
1882
+ const previous = this.buffer?.get(bufferSlot(this.store, key));
1883
+ if (previous?.kind === "put" && !rowsEqual(previous.value, stored)) throw new StorageError(409, "immutable block revision collision", "revision_conflict");
1884
+ }
1671
1885
  if (this.buffer) {
1672
1886
  this.buffer.set(bufferSlot(this.store, key), { kind: "put", value: stored });
1673
1887
  return key;
@@ -1675,9 +1889,28 @@ var SqlStore = class {
1675
1889
  await this.owner.applyOps([{ op: "put", store: this.store, value: stored }]);
1676
1890
  return key;
1677
1891
  }
1892
+ async putMany(values, _keys) {
1893
+ await this.owner.ensureReady();
1894
+ this.assertMutable();
1895
+ const stored = values.map((value) => this.owner.stamp(this.store, value));
1896
+ const keys = stored.map((value) => keyFromValue(this.def, value));
1897
+ if (this.store === "block_revisions") {
1898
+ const seen = /* @__PURE__ */ new Map();
1899
+ keys.forEach((key, i) => {
1900
+ const slot = bufferSlot(this.store, key), previous = this.buffer?.get(slot);
1901
+ const value = seen.has(slot) ? seen.get(slot) : previous?.kind === "put" ? previous.value : void 0;
1902
+ if (value !== void 0 && !rowsEqual(value, stored[i])) throw new StorageError(409, "immutable block revision collision", "revision_conflict");
1903
+ seen.set(slot, stored[i]);
1904
+ });
1905
+ }
1906
+ if (this.buffer) keys.forEach((key, i) => this.buffer.set(bufferSlot(this.store, key), { kind: "put", value: stored[i] }));
1907
+ else await this.owner.applyOps(stored.map((value) => ({ op: "put", store: this.store, value })));
1908
+ return keys;
1909
+ }
1678
1910
  async delete(key) {
1679
1911
  await this.owner.ensureReady();
1680
1912
  this.assertMutable();
1913
+ if (this.store === "block_revisions") throw new StorageError(409, "block history requires explicit wipe", "revision_conflict");
1681
1914
  const range = isRange(key) ? key : void 0;
1682
1915
  const target = range ?? key;
1683
1916
  if (this.buffer) {
@@ -1697,6 +1930,7 @@ var SqlStore = class {
1697
1930
  async clear() {
1698
1931
  await this.owner.ensureReady();
1699
1932
  this.assertMutable();
1933
+ if (this.store === "block_revisions") throw new StorageError(409, "block history requires explicit wipe", "revision_conflict");
1700
1934
  if (this.buffer) {
1701
1935
  this.cleared?.add(this.store);
1702
1936
  const prefix = `${this.store}\0`;
@@ -1752,25 +1986,323 @@ var BrowserTursoStorage = class extends SqlStorage {
1752
1986
  }
1753
1987
  };
1754
1988
 
1989
+ // src/sql/sync.ts
1990
+ var SYNC_OPS_TABLE = "_wodwiki_sync_ops";
1991
+ var SYNC_STATE_TABLE = "_wodwiki_sync_state";
1992
+ var SYNC_ARCHIVE_TABLE = "_wodwiki_sync_ops_archive";
1993
+ var BINARY_FIELD2 = { attachments: "data" };
1994
+ var STORE_NAMES = Object.fromEntries(STORE_DEFS.map((d) => [d.name, true]));
1995
+ var STATE_KEY = "meta";
1996
+ function isSafeInt(value) {
1997
+ return typeof value === "number" && Number.isSafeInteger(value);
1998
+ }
1999
+ function isScalar(value) {
2000
+ return typeof value === "string" || typeof value === "number" && Number.isFinite(value);
2001
+ }
2002
+ function assertKey(key, what) {
2003
+ if (isScalar(key)) return;
2004
+ if (Array.isArray(key) && key.length > 0 && key.every(isScalar)) return;
2005
+ throw new StorageError(400, `${what} must be a string, finite number, or non-empty scalar tuple`, "bad_sync_key");
2006
+ }
2007
+ function validateMutation(raw) {
2008
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
2009
+ throw new StorageError(400, "sync mutation must be an object", "bad_sync_mutation");
2010
+ }
2011
+ const mutation = raw;
2012
+ if (mutation.op === "wipe") return { op: "wipe" };
2013
+ if (typeof mutation.store !== "string" || !STORE_NAMES[mutation.store]) {
2014
+ throw new StorageError(400, `sync mutation has unknown store ${String(mutation.store)}`, "bad_sync_mutation");
2015
+ }
2016
+ const store = mutation.store;
2017
+ if (mutation.op === "clear") return { op: "clear", store };
2018
+ if (mutation.op === "delete") {
2019
+ assertKey(mutation.key, "sync delete key");
2020
+ return { op: "delete", store, key: mutation.key };
2021
+ }
2022
+ if (mutation.op === "put") {
2023
+ assertKey(mutation.key, "sync put key");
2024
+ if (mutation.value === null || typeof mutation.value !== "object" || Array.isArray(mutation.value)) {
2025
+ throw new StorageError(400, "sync put value must be an object row", "bad_sync_mutation");
2026
+ }
2027
+ return { op: "put", store, key: mutation.key, value: mutation.value };
2028
+ }
2029
+ throw new StorageError(400, `sync mutation has unknown op ${String(mutation.op)}`, "bad_sync_mutation");
2030
+ }
2031
+ function validateEntry(entry) {
2032
+ if (typeof entry.id !== "string" || entry.id.length === 0 || entry.id.length > 200) {
2033
+ throw new StorageError(400, "sync entry id must be a non-empty string", "bad_sync_entry");
2034
+ }
2035
+ if (!isSafeInt(entry.at) || entry.at < 0) {
2036
+ throw new StorageError(400, "sync entry at must be a non-negative safe integer", "bad_sync_entry");
2037
+ }
2038
+ if (!Array.isArray(entry.ops) || entry.ops.length === 0) {
2039
+ throw new StorageError(400, "sync entry ops must be a non-empty array", "bad_sync_entry");
2040
+ }
2041
+ for (const op of entry.ops) validateMutation(op);
2042
+ }
2043
+ async function runStatements(tx, statements) {
2044
+ if (statements.length === 0) return [];
2045
+ if (tx.batch) return tx.batch(statements);
2046
+ const results = [];
2047
+ for (const statement of statements) {
2048
+ results.push(await tx.query(statement.sql, statement.params ?? []));
2049
+ }
2050
+ return results;
2051
+ }
2052
+ async function tableExists(conn, dialect, name) {
2053
+ 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 = ?`;
2054
+ return (await conn.query(sql, [name])).length > 0;
2055
+ }
2056
+ async function readSyncState(tx) {
2057
+ const rows = await tx.query(`SELECT "value" FROM "${SYNC_STATE_TABLE}" WHERE "key" = ?`, [STATE_KEY]);
2058
+ if (!rows[0]) throw new StorageError(409, "sync target is not initialized", "sync_not_initialized");
2059
+ let parsed;
2060
+ try {
2061
+ parsed = JSON.parse(String(rows[0].value));
2062
+ } catch {
2063
+ throw new StorageError(400, "malformed sync state row", "bad_sync_state");
2064
+ }
2065
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
2066
+ throw new StorageError(400, "malformed sync state row", "bad_sync_state");
2067
+ }
2068
+ const state = parsed;
2069
+ if (!isSafeInt(state.schema) || state.schema < 0 || !isSafeInt(state.rev) || state.rev < 0) {
2070
+ throw new StorageError(400, "sync state schema/rev must be non-negative safe integers", "bad_sync_state");
2071
+ }
2072
+ return { schema: state.schema, rev: state.rev };
2073
+ }
2074
+ async function probeSyncTarget(db) {
2075
+ if (await tableExists(db, db.dialect, SYNC_STATE_TABLE)) {
2076
+ const state = await readSyncState(db);
2077
+ return { kind: "current", schemaVersion: state.schema, rev: state.rev };
2078
+ }
2079
+ if (await tableExists(db, db.dialect, SCHEMA_TABLE)) {
2080
+ const rows = await db.query(`SELECT "value" FROM "${SCHEMA_TABLE}" WHERE "key" = 'schema'`);
2081
+ if (rows[0]) {
2082
+ const guard = JSON.parse(String(rows[0].value));
2083
+ if (typeof guard.version === "number" && Number.isSafeInteger(guard.version) && guard.version >= 0) {
2084
+ return { kind: "legacy", schemaVersion: guard.version };
2085
+ }
2086
+ }
2087
+ return { kind: "legacy", schemaVersion: 0 };
2088
+ }
2089
+ const others = await db.query(
2090
+ db.dialect === "postgres" ? `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'` : `SELECT name FROM sqlite_master WHERE type = 'table'`
2091
+ );
2092
+ const internal = (name) => name.startsWith("_wodwiki_") || name.startsWith("sqlite_");
2093
+ return others.some((row) => !internal(String(Object.values(row)[0]))) ? { kind: "unknown" } : "empty";
2094
+ }
2095
+ async function initSyncTarget(db, schemaVersion) {
2096
+ await db.transaction(async (tx) => {
2097
+ await tx.exec(`CREATE TABLE IF NOT EXISTS "${SYNC_STATE_TABLE}" ("key" TEXT PRIMARY KEY, "value" TEXT NOT NULL)`);
2098
+ await tx.exec(
2099
+ `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)`
2100
+ );
2101
+ await tx.exec(
2102
+ `CREATE INDEX IF NOT EXISTS "${SYNC_OPS_TABLE}_rev" ON "${SYNC_OPS_TABLE}" ("rev", "idx")`
2103
+ );
2104
+ await tx.exec(
2105
+ `INSERT INTO "${SYNC_STATE_TABLE}" ("key", "value") VALUES (?, ?) ON CONFLICT ("key") DO NOTHING`,
2106
+ [STATE_KEY, JSON.stringify({ schema: schemaVersion, rev: 0 })]
2107
+ );
2108
+ });
2109
+ }
2110
+ function encodeMutation(mutation) {
2111
+ if (mutation.op !== "put") return JSON.stringify(mutation);
2112
+ const encoded = encodeRowJson(mutation.value, BINARY_FIELD2[mutation.store]);
2113
+ return `{"key":${JSON.stringify(mutation.key)},"op":"put","store":${JSON.stringify(mutation.store)},"value":${encoded}}`;
2114
+ }
2115
+ function decodeMutation(data) {
2116
+ if (typeof data !== "string") {
2117
+ throw new StorageError(400, "sync op data must be a string", "bad_sync_mutation");
2118
+ }
2119
+ let parsed;
2120
+ try {
2121
+ parsed = JSON.parse(data);
2122
+ } catch {
2123
+ throw new StorageError(400, "sync op data is not valid JSON", "bad_sync_mutation");
2124
+ }
2125
+ const mutation = validateMutation(parsed);
2126
+ if (mutation.op !== "put") return mutation;
2127
+ return {
2128
+ op: "put",
2129
+ store: mutation.store,
2130
+ key: mutation.key,
2131
+ value: decodeRowJson(JSON.stringify(mutation.value), BINARY_FIELD2[mutation.store])
2132
+ };
2133
+ }
2134
+ async function applySyncBatch(db, entries, opts = {}) {
2135
+ if (entries.length === 0) return 0;
2136
+ for (const entry of entries) validateEntry(entry);
2137
+ return db.transaction(async (tx) => {
2138
+ const state = await readSyncState(tx);
2139
+ if (opts.expectSchema !== void 0 && state.schema !== opts.expectSchema) {
2140
+ throw new StorageError(
2141
+ 409,
2142
+ `sync target schema changed from v${opts.expectSchema} to v${state.schema} between probe and write \u2014 retry the cycle`,
2143
+ "sync_schema_conflict"
2144
+ );
2145
+ }
2146
+ const firstIds = entries.map((entry) => `${entry.id}:0`);
2147
+ const present = /* @__PURE__ */ new Set();
2148
+ for (let i = 0; i < firstIds.length; i += 100) {
2149
+ const chunk = firstIds.slice(i, i + 100);
2150
+ const rows = await tx.query(
2151
+ `SELECT "op_id" FROM "${SYNC_OPS_TABLE}" WHERE "op_id" IN (${chunk.map(() => "?").join(", ")})`,
2152
+ chunk
2153
+ );
2154
+ for (const row of rows) present.add(String(row.op_id));
2155
+ }
2156
+ const statements = [];
2157
+ let rev = state.rev;
2158
+ let committed = 0;
2159
+ for (const entry of entries) {
2160
+ if (present.has(`${entry.id}:0`)) continue;
2161
+ rev += 1;
2162
+ committed += 1;
2163
+ entry.ops.forEach((mutation, idx) => {
2164
+ statements.push({
2165
+ sql: `INSERT INTO "${SYNC_OPS_TABLE}" ("op_id", "rev", "idx", "at", "data") VALUES (?, ?, ?, ?, ?) ON CONFLICT ("op_id") DO NOTHING`,
2166
+ params: [`${entry.id}:${idx}`, rev, idx, entry.at, encodeMutation(mutation)]
2167
+ });
2168
+ });
2169
+ }
2170
+ const nextSchema = opts.toSchemaVersion ?? state.schema;
2171
+ statements.push({
2172
+ sql: `UPDATE "${SYNC_STATE_TABLE}" SET "value" = ? WHERE "key" = ?`,
2173
+ params: [JSON.stringify({ schema: nextSchema, rev }), STATE_KEY]
2174
+ });
2175
+ await runStatements(tx, statements);
2176
+ return committed;
2177
+ });
2178
+ }
2179
+ async function fetchSyncOps(db, afterRev, limit) {
2180
+ if (!isSafeInt(afterRev) || afterRev < 0) {
2181
+ throw new StorageError(400, "fetchSyncOps cursor must be a non-negative safe integer", "bad_sync_cursor");
2182
+ }
2183
+ const bounded = Math.min(Math.max(1, Math.floor(limit)), 4294967295);
2184
+ const revRows = await db.query(
2185
+ `SELECT DISTINCT "rev" FROM (SELECT "rev" FROM "${SYNC_OPS_TABLE}" WHERE "rev" > ? ORDER BY "rev" ASC LIMIT ?)`,
2186
+ [afterRev, bounded]
2187
+ );
2188
+ const revs = revRows.map((row) => {
2189
+ const rev = Number(row.rev);
2190
+ if (!Number.isSafeInteger(rev) || rev < 0) {
2191
+ throw new StorageError(400, "sync op rev must be a non-negative safe integer", "bad_sync_row");
2192
+ }
2193
+ return rev;
2194
+ });
2195
+ if (revs.length === 0) return [];
2196
+ const rows = await db.query(
2197
+ `SELECT "op_id", "rev", "idx", "at", "data" FROM "${SYNC_OPS_TABLE}" WHERE "rev" IN (${revs.map(() => "?").join(", ")}) ORDER BY "rev", "idx"`,
2198
+ revs
2199
+ );
2200
+ return rows.map((row, at) => {
2201
+ const opId = row.op_id;
2202
+ const rev = Number(row.rev);
2203
+ const idx = Number(row.idx);
2204
+ const atMs = Number(row.at);
2205
+ if (typeof opId !== "string" || opId.length === 0 || !Number.isSafeInteger(rev) || rev < 0 || !Number.isSafeInteger(idx) || idx < 0 || !Number.isSafeInteger(atMs) || atMs < 0) {
2206
+ throw new StorageError(400, `sync op row ${at} has malformed shape`, "bad_sync_row");
2207
+ }
2208
+ return { opId, rev, idx, at: atMs, mutation: decodeMutation(row.data) };
2209
+ });
2210
+ }
2211
+ async function replaceSyncLog(db, entries, toSchemaVersion) {
2212
+ if (entries.length === 0) throw new StorageError(400, "replacement log must contain the converted snapshot", "bad_sync_entry");
2213
+ for (const entry of entries) validateEntry(entry);
2214
+ await db.transaction(async (tx) => {
2215
+ await readSyncState(tx);
2216
+ await tx.exec(
2217
+ `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)`
2218
+ );
2219
+ 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}"`);
2220
+ await tx.exec(`DELETE FROM "${SYNC_OPS_TABLE}"`);
2221
+ const statements = [];
2222
+ let rev = 0;
2223
+ for (const entry of entries) {
2224
+ rev += 1;
2225
+ entry.ops.forEach((mutation, idx) => {
2226
+ statements.push({
2227
+ sql: `INSERT INTO "${SYNC_OPS_TABLE}" ("op_id", "rev", "idx", "at", "data") VALUES (?, ?, ?, ?, ?) ON CONFLICT ("op_id") DO NOTHING`,
2228
+ params: [`${entry.id}:${idx}`, rev, idx, entry.at, encodeMutation(mutation)]
2229
+ });
2230
+ });
2231
+ }
2232
+ statements.push({
2233
+ sql: `UPDATE "${SYNC_STATE_TABLE}" SET "value" = ? WHERE "key" = ?`,
2234
+ params: [JSON.stringify({ schema: toSchemaVersion, rev }), STATE_KEY]
2235
+ });
2236
+ await runStatements(tx, statements);
2237
+ });
2238
+ }
2239
+ async function readRawSyncOps(db) {
2240
+ const rows = await db.query(`SELECT "rev", "idx", "data" FROM "${SYNC_OPS_TABLE}" ORDER BY "rev", "idx"`);
2241
+ return rows.map((row) => {
2242
+ const rev = Number(row.rev);
2243
+ const idx = Number(row.idx);
2244
+ if (!Number.isSafeInteger(rev) || rev < 0 || !Number.isSafeInteger(idx) || idx < 0 || typeof row.data !== "string") {
2245
+ throw new StorageError(400, "sync op row has malformed shape", "bad_sync_row");
2246
+ }
2247
+ let parsed;
2248
+ try {
2249
+ parsed = JSON.parse(row.data);
2250
+ } catch {
2251
+ throw new StorageError(400, "sync op data is not valid JSON", "bad_sync_row");
2252
+ }
2253
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
2254
+ throw new StorageError(400, "sync op data must be an object", "bad_sync_row");
2255
+ }
2256
+ const mutation = parsed;
2257
+ if (typeof mutation.op !== "string" || typeof mutation.store !== "string") {
2258
+ throw new StorageError(400, "sync op must carry string op/store", "bad_sync_row");
2259
+ }
2260
+ return {
2261
+ op: mutation.op,
2262
+ store: mutation.store,
2263
+ key: mutation.key,
2264
+ value: mutation.value === void 0 || mutation.value === null ? void 0 : decodeRowJson(JSON.stringify(mutation.value), BINARY_FIELD2[mutation.store])
2265
+ };
2266
+ });
2267
+ }
2268
+ async function readLegacyRows(db, table) {
2269
+ if (!await tableExists(db, db.dialect, table)) return [];
2270
+ const rows = await db.query(`SELECT "data" FROM "${table}"`);
2271
+ return rows.map((row) => decodeRowJson(String(row.data), BINARY_FIELD2[table]));
2272
+ }
2273
+
1755
2274
  exports.BrowserTursoStorage = BrowserTursoStorage;
1756
2275
  exports.MAX_BODY = MAX_BODY;
1757
2276
  exports.SCHEMA_TABLE = SCHEMA_TABLE;
2277
+ exports.SYNC_ARCHIVE_TABLE = SYNC_ARCHIVE_TABLE;
2278
+ exports.SYNC_OPS_TABLE = SYNC_OPS_TABLE;
2279
+ exports.SYNC_STATE_TABLE = SYNC_STATE_TABLE;
1758
2280
  exports.SqlStorage = SqlStorage;
1759
2281
  exports.StorageError = StorageError;
2282
+ exports.applySyncBatch = applySyncBatch;
1760
2283
  exports.clearRows = clearRows;
1761
2284
  exports.countRows = countRows;
1762
2285
  exports.decodeRowJson = decodeRowJson;
1763
2286
  exports.deleteRows = deleteRows;
1764
2287
  exports.encodeRowJson = encodeRowJson;
2288
+ exports.fetchSyncOps = fetchSyncOps;
1765
2289
  exports.getRow = getRow;
2290
+ exports.getRows = getRows;
1766
2291
  exports.handleDomainQuery = handleDomainQuery;
2292
+ exports.initSyncTarget = initSyncTarget;
1767
2293
  exports.initializeDomain = initializeDomain;
1768
2294
  exports.initializeStore = initializeStore;
1769
2295
  exports.libsqlDatabase = libsqlDatabase;
1770
2296
  exports.listRows = listRows;
2297
+ exports.listRowsFromIndexMany = listRowsFromIndexMany;
2298
+ exports.probeSyncTarget = probeSyncTarget;
1771
2299
  exports.putRow = putRow;
2300
+ exports.putRows = putRows;
2301
+ exports.readLegacyRows = readLegacyRows;
2302
+ exports.readRawSyncOps = readRawSyncOps;
1772
2303
  exports.rebuildProjections = rebuildProjections;
1773
2304
  exports.refreshDomain = refreshDomain;
2305
+ exports.replaceSyncLog = replaceSyncLog;
1774
2306
  exports.storeDefinition = storeDefinition;
1775
2307
  exports.validateTursoUrl = validateTursoUrl;
1776
2308
  exports.wipeDomain = wipeDomain;