@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,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 = 29;
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, 500)) {
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, 500)) {
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,6 +871,34 @@ 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) => {
@@ -717,7 +920,7 @@ async function initializeStore(db) {
717
920
  if (stored !== DB_VERSION) {
718
921
  throw new StorageError(
719
922
  500,
720
- `database schema v${stored} does not match this server (v${DB_VERSION}); use a compatible server or migrate a backed-up database`,
923
+ `database schema v${stored} is incompatible with v${DB_VERSION}; recreation required using an explicitly disposable database`,
721
924
  "schema_incompatible"
722
925
  );
723
926
  }
@@ -737,10 +940,8 @@ async function initializeStore(db) {
737
940
  }
738
941
  });
739
942
  }
740
-
741
- // src/domain.ts
742
- var DOMAIN_PROJECTION_VERSION = 1;
743
- var TABLES = ["domain_note", "query_block", "segment_head"];
943
+ var DOMAIN_PROJECTION_VERSION = 2;
944
+ var TABLES = ["domain_note", "query_block"];
744
945
  var PROJECTION_GUARD_KEY = "domain_projection";
745
946
  function sourceItem(row) {
746
947
  const item = {};
@@ -759,7 +960,7 @@ function ensureDdl(db) {
759
960
  const n = db.dialect === "postgres" ? " DOUBLE PRECISION" : "";
760
961
  const t = db.dialect === "postgres" ? ' TEXT COLLATE "C"' : " TEXT";
761
962
  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)`,
963
+ `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
964
  `CREATE INDEX IF NOT EXISTS "idx_domain_note_date" ON "domain_note" ("date", "note_id")`,
764
965
  `CREATE INDEX IF NOT EXISTS "idx_domain_note_created" ON "domain_note" ("created_at", "note_id")`,
765
966
  `CREATE INDEX IF NOT EXISTS "idx_domain_note_source_path" ON "domain_note" ("source_path")`,
@@ -768,169 +969,140 @@ function ensureDdl(db) {
768
969
  `CREATE INDEX IF NOT EXISTS "idx_query_block_parent" ON "query_block" ("parent_note_id", "ordinal")`,
769
970
  `CREATE INDEX IF NOT EXISTS "idx_query_block_content" ON "query_block" ("content_id")`,
770
971
  `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")`
972
+ `CREATE INDEX IF NOT EXISTS "idx_query_block_source_note" ON "query_block" ("source_note_id")`
774
973
  ];
775
974
  return db.transaction(async (tx) => {
776
975
  for (const sql of ddl) await tx.exec(sql);
777
976
  });
778
977
  }
779
- async function upsertDomainNote(conn, note) {
978
+ var DOMAIN_NOTE_COLS = ["note_id", "note_type", "catalog", "source_id", "source_path", "title", "title_search", "created_at", "date", "text_search", "journal", "payload"];
979
+ 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"];
980
+ 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 ");
981
+ function domainNoteRow(note) {
780
982
  const id = text(note.id);
781
- if (!id) return;
983
+ if (!id) return void 0;
782
984
  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
- }));
985
+ return [
986
+ id,
987
+ T(note.type),
988
+ T(wodWikiWql.catalogOfItem(source)),
989
+ T(note.sourceId),
990
+ T(note.sourcePath),
991
+ T(note.title) ?? "",
992
+ (text(note.title) ?? "").toLowerCase(),
993
+ N(note.createdAt),
994
+ num(note.date),
995
+ (text(note.rawContent) ?? "").toLowerCase(),
996
+ wodWikiWql.sourceMatches(source, "journal") ? 1 : 0,
997
+ encodeRowJson(note)
998
+ ];
840
999
  }
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;
1000
+ async function upsertDomainNotes(conn, notes) {
1001
+ const rows = notes.map(domainNoteRow).filter((r) => r !== void 0);
1002
+ await batchInsert(conn, "domain_note", [...DOMAIN_NOTE_COLS], rows, conflictUpdate(DOMAIN_NOTE_COLS, ["note_id"]));
854
1003
  }
855
- async function upsertQueryBlock(conn, block) {
1004
+ function queryBlockRow(block, parent) {
856
1005
  const id = text(block.id);
857
- if (!id) return;
1006
+ if (!id) return void 0;
858
1007
  const source = sourceItem(block);
859
1008
  const createdAt = N(block.createdAt);
860
- const parent = await resolveParent(conn, block);
861
1009
  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
- );
1010
+ return [
1011
+ id,
1012
+ T(block.noteId),
1013
+ parent?.note_id ?? null,
1014
+ T(block.blockContentId),
1015
+ T(block.blockId),
1016
+ num(block.position),
1017
+ T(block.dataType),
1018
+ T(block.sourceId),
1019
+ T(block.sourcePath),
1020
+ T(wodWikiWql.catalogOfItem(source)),
1021
+ effective,
1022
+ createdAt,
1023
+ parent?.note.title ?? T(block.noteTitle),
1024
+ (text(block.rawContent) ?? "").toLowerCase(),
1025
+ wodWikiWql.sourceMatches(source, "journal") ? 1 : 0,
1026
+ encodeRowJson(parent ? { ...block, noteTitle: parent.note.title } : block)
1027
+ ];
891
1028
  }
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
- }
1029
+ async function upsertQueryBlocks(conn, blocks) {
1030
+ const rows = blocks.map(([b, p]) => queryBlockRow(b, p)).filter((r) => r !== void 0);
1031
+ await batchInsert(conn, "query_block", [...QUERY_BLOCK_COLS], rows, conflictUpdate(QUERY_BLOCK_COLS, ["occurrence_id"]));
908
1032
  }
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
- }
1033
+ function indexNoteIdentities(notes) {
1034
+ const byNoteId = /* @__PURE__ */ new Map();
1035
+ for (const note of notes) {
1036
+ const id = text(note.id);
1037
+ if (id) byNoteId.set(id, { note_id: id, date: num(note.date), created_at: num(note.createdAt) ?? 0, note });
921
1038
  }
922
- if (head) await upsertSegmentHead(conn, k, head);
923
- else await conn.exec(`DELETE FROM "segment_head" WHERE "id" = ?`, [k]);
1039
+ return { byNoteId };
1040
+ }
1041
+ function resolveParentFrom(index, block) {
1042
+ const parent = typeof block.noteId === "string" ? index.byNoteId.get(block.noteId) : void 0;
1043
+ const mapped = typeof block.blockId === "string" ? parent?.note.blocks[block.blockId] : void 0;
1044
+ 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;
924
1045
  }
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]);
1046
+ async function fetchParentIndex(conn, noteIds) {
1047
+ const notes = await getRows(conn, storeDefinition("notes"), noteIds);
1048
+ return indexNoteIdentities(notes.filter((row) => row !== void 0));
1049
+ }
1050
+ var keyHead = (key) => Array.isArray(key) ? key[0] : key;
1051
+ async function selectIn(conn, selectCol, table, col, vals) {
1052
+ const IN_CHUNK = 500;
1053
+ const out = [];
1054
+ for (let i = 0; i < vals.length; i += IN_CHUNK) {
1055
+ const part = vals.slice(i, i + IN_CHUNK);
1056
+ const rows = await conn.query(`SELECT "${selectCol}" FROM "${table}" WHERE "${col}" IN (${part.map(() => "?").join(", ")})`, part);
1057
+ for (const r of rows) out.push(String(r[selectCol]));
1058
+ }
1059
+ return out;
1060
+ }
1061
+ async function healQueryBlocksForNotes(conn, noteIds) {
1062
+ const ids = await selectIn(conn, "occurrence_id", "query_block", "source_note_id", noteIds);
1063
+ await refreshBlocks(conn, [...new Set(ids)]);
1064
+ }
1065
+ async function refreshNotes(conn, ids) {
1066
+ const ks = [...new Set(ids.map((id) => String(keyHead(id))))];
1067
+ if (ks.length === 0) return;
1068
+ const found = /* @__PURE__ */ new Map();
1069
+ for (let i = 0; i < ks.length; i += 500) {
1070
+ const part = ks.slice(i, i + 500);
1071
+ const rows = await conn.query(`SELECT "k0", "data" FROM "notes" WHERE "k0" IN (${part.map(() => "?").join(", ")})`, part);
1072
+ for (const row of rows) found.set(String(row.k0), decodeRowJson(String(row.data)));
1073
+ }
1074
+ const present = [];
1075
+ const missing = [];
1076
+ for (const k of ks) {
1077
+ const note = found.get(k);
1078
+ if (note) present.push(note);
1079
+ else missing.push(k);
1080
+ }
1081
+ await upsertDomainNotes(conn, present);
1082
+ const presentIds = present.map((n) => text(n.id)).filter((x) => !!x);
1083
+ await healQueryBlocksForNotes(conn, presentIds);
1084
+ await deleteIn(conn, "domain_note", "note_id", missing);
1085
+ await deleteIn(conn, "query_block", "source_note_id", missing);
1086
+ await deleteIn(conn, "block_index", "i_by_note_0", missing);
1087
+ await deleteIn(conn, "block_efforts", "i_by_note_0", missing);
1088
+ }
1089
+ async function refreshBlocks(conn, ids) {
1090
+ const ks = [...new Set(ids.map((id) => String(keyHead(id))))];
1091
+ if (ks.length === 0) return;
1092
+ const found = /* @__PURE__ */ new Map();
1093
+ for (let i = 0; i < ks.length; i += 500) {
1094
+ const part = ks.slice(i, i + 500);
1095
+ const rows = await conn.query(`SELECT "k0", "data" FROM "block_index" WHERE "k0" IN (${part.map(() => "?").join(", ")})`, part);
1096
+ for (const row of rows) found.set(String(row.k0), decodeRowJson(String(row.data)));
1097
+ }
1098
+ const blocks = [...found.values()];
1099
+ const noteIds = [...new Set(blocks.map((b) => text(b.noteId)).filter((x) => !!x))];
1100
+ const index = await fetchParentIndex(conn, noteIds);
1101
+ await upsertQueryBlocks(conn, blocks.map((b) => [b, resolveParentFrom(index, b)]));
1102
+ await deleteIn(conn, "query_block", "occurrence_id", ks.filter((k) => !found.has(k)));
930
1103
  }
931
1104
  var TRACKED = {
932
1105
  notes: "notes",
933
- segments: "segments",
934
1106
  block_index: "blocks"
935
1107
  };
936
1108
  async function deleteTargetKeys(conn, def, target) {
@@ -938,29 +1110,54 @@ async function deleteTargetKeys(conn, def, target) {
938
1110
  const rows = await listRows(conn, def, target);
939
1111
  return rows.map((row) => keyFromValue(def, row));
940
1112
  }
941
- async function refreshDomain(conn, ops, opts = {}) {
1113
+ async function refreshDomain(conn, ops) {
942
1114
  const impact = {
943
1115
  notes: [],
944
- segments: [],
945
1116
  blocks: [],
1117
+ pinnedNotes: [],
1118
+ pinnedSessions: [],
946
1119
  notesClear: false,
947
- segmentsClear: false,
948
1120
  blocksClear: false
949
1121
  };
950
1122
  const results = [];
1123
+ for (const op of ops) if (op === null || typeof op !== "object" || Array.isArray(op)) throw new StorageError(400, "tx op must be an object");
1124
+ const checks = ops.filter((op) => op.op === "check");
1125
+ const checkStores = [...new Set(checks.map((op) => op.store))];
1126
+ for (const store of checkStores) {
1127
+ const scoped = checks.filter((op) => op.store === store);
1128
+ const actual = await getRows(conn, storeDefinition(store), scoped.map((op) => op.key));
1129
+ scoped.forEach((op, i) => {
1130
+ if (!rowsEqual(actual[i] ?? null, op.value)) throw new StorageError(409, "transaction read changed before commit", "transaction_conflict");
1131
+ });
1132
+ }
1133
+ let pending;
1134
+ const flushPuts = async () => {
1135
+ if (!pending) return;
1136
+ const batch = pending;
1137
+ pending = void 0;
1138
+ if (batch.values.length === 0) return;
1139
+ const changed = [];
1140
+ const keys = await putRows(conn, batch.def, batch.values, changed);
1141
+ const slot = TRACKED[batch.def.name];
1142
+ if (slot) impact[slot].push(...changed);
1143
+ const changedSet = new Set(changed.map((key) => JSON.stringify(key)));
1144
+ if (batch.def.name === "notes") impact.pinnedNotes.push(...batch.values.filter((value) => changedSet.has(JSON.stringify(keyFromValue(batch.def, value)))));
1145
+ if (batch.def.name === "sessions") impact.pinnedSessions.push(...batch.values.filter((value) => changedSet.has(JSON.stringify(keyFromValue(batch.def, value)))));
1146
+ results.push(...keys);
1147
+ };
951
1148
  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");
1149
+ if (op.op === "check") {
1150
+ results.push(null);
1151
+ continue;
954
1152
  }
955
1153
  const store = "store" in op ? op.store : void 0;
956
1154
  const def = storeDefinition(typeof store === "string" ? store : String(store));
957
1155
  const kind = typeof op.op === "string" ? op.op : void 0;
958
1156
  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);
1157
+ if (pending && pending.def.name !== def.name) await flushPuts();
1158
+ (pending ??= { def, values: [] }).values.push(op.value);
963
1159
  } else if (op.op === "delete") {
1160
+ await flushPuts();
964
1161
  const slot = TRACKED[def.name];
965
1162
  if (slot) {
966
1163
  for (const key of await deleteTargetKeys(conn, def, op.key)) impact[slot].push(key);
@@ -968,28 +1165,32 @@ async function refreshDomain(conn, ops, opts = {}) {
968
1165
  await deleteRows(conn, def, op.key);
969
1166
  results.push(null);
970
1167
  } else if (op.op === "clear") {
1168
+ await flushPuts();
971
1169
  await clearRows(conn, def);
972
1170
  if (def.name === "notes") impact.notesClear = true;
973
- else if (def.name === "segments") impact.segmentsClear = true;
974
1171
  else if (def.name === "block_index") impact.blocksClear = true;
975
1172
  results.push(null);
976
1173
  } else {
977
- throw new StorageError(400, `tx op must be put | delete | clear, got: ${String(kind)}`);
1174
+ throw new StorageError(400, `tx op must be check | put | delete | clear, got: ${String(kind)}`);
978
1175
  }
979
1176
  }
980
- if (!opts.deferProjection) await refresh(conn, impact);
1177
+ await flushPuts();
1178
+ const finalNotes = (await getRows(conn, storeDefinition("notes"), [...new Set(impact.pinnedNotes.map((note) => note.id))])).filter(Boolean);
1179
+ const finalSessions = (await getRows(conn, storeDefinition("sessions"), [...new Set(impact.pinnedSessions.map((session) => session.id))])).filter(Boolean);
1180
+ await validateStoredPins(finalNotes, finalSessions, (keys) => getRows(conn, storeDefinition("block_revisions"), keys));
1181
+ await refresh(conn, impact);
981
1182
  return results;
982
1183
  }
983
1184
  async function refresh(conn, impact) {
984
1185
  if (impact.notesClear) {
985
1186
  await conn.exec(`DELETE FROM "domain_note"`);
986
- await conn.exec(`UPDATE "query_block" SET "parent_note_id" = NULL`);
1187
+ await conn.exec(`DELETE FROM "query_block" WHERE "source_note_id" NOT IN (SELECT "k0" FROM "notes")`);
1188
+ await conn.exec(`DELETE FROM "block_index" WHERE "i_by_note_0" NOT IN (SELECT "k0" FROM "notes")`);
1189
+ await conn.exec(`DELETE FROM "block_efforts" WHERE "i_by_note_0" NOT IN (SELECT "k0" FROM "notes")`);
987
1190
  }
988
- if (impact.segmentsClear) await conn.exec(`DELETE FROM "segment_head"`);
989
1191
  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);
1192
+ await refreshNotes(conn, impact.notes);
1193
+ await refreshBlocks(conn, impact.blocks);
993
1194
  }
994
1195
  async function rebuildProjections(conn) {
995
1196
  await conn.exec(
@@ -997,18 +1198,12 @@ async function rebuildProjections(conn) {
997
1198
  );
998
1199
  for (const t of TABLES) await conn.exec(`DELETE FROM "${t}"`);
999
1200
  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);
1201
+ const noteObjs = notes.map((row) => decodeRowJson(String(row.data)));
1202
+ await upsertDomainNotes(conn, noteObjs);
1010
1203
  const blocks = await conn.query(`SELECT "data" FROM "block_index"`);
1011
- for (const row of blocks) await upsertQueryBlock(conn, decodeRowJson(String(row.data)));
1204
+ const blockObjs = blocks.map((row) => decodeRowJson(String(row.data)));
1205
+ const index = indexNoteIdentities(noteObjs);
1206
+ await upsertQueryBlocks(conn, blockObjs.map((b) => [b, resolveParentFrom(index, b)]));
1012
1207
  await conn.exec(
1013
1208
  `INSERT INTO "${SCHEMA_TABLE}" ("key", "value") VALUES (?, ?) ON CONFLICT ("key") DO UPDATE SET "value" = excluded."value"`,
1014
1209
  [PROJECTION_GUARD_KEY, JSON.stringify({ version: DOMAIN_PROJECTION_VERSION })]
@@ -1245,13 +1440,8 @@ function predicateSql(ctx, p, plan) {
1245
1440
  p.negate === true,
1246
1441
  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
1442
  );
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
- }
1443
+ case "text":
1444
+ 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
1445
  case "date": {
1256
1446
  const expr = blocks ? 'q."effective_date"' : 'COALESCE(n."date", n."created_at")';
1257
1447
  const lo = ctx.bind(p.start);
@@ -1295,7 +1485,7 @@ async function gate(conn) {
1295
1485
  if (stale > 0) {
1296
1486
  throw new StorageError(
1297
1487
  409,
1298
- `domain projections stale: ${stale} block rows have unresolved static parents`,
1488
+ `domain projections stale: ${stale} block rows have invalid or unresolved current parents`,
1299
1489
  "domain_projection_unavailable"
1300
1490
  );
1301
1491
  }
@@ -1314,47 +1504,43 @@ async function execute(conn, q, pg) {
1314
1504
  const fullWhere = selWhere && filterWhere ? `${selWhere} AND ${filterWhere.replace(/^ WHERE /, "")}` : selWhere || filterWhere;
1315
1505
  const count = async (where, params) => Number((await conn.query(`SELECT COUNT(*) AS "n" FROM ${table}${where}`, params))[0]?.n ?? 0);
1316
1506
  const selectedCount = await count(selWhere, selCtx.params);
1317
- const matchedCount = await count(fullWhere, allParams);
1507
+ const matchedCount = q.filters.length === 0 ? selectedCount : await count(fullWhere, allParams);
1318
1508
  const ordering = orderSql(q.order, plan);
1319
1509
  const paging = pagingSql(q, pg);
1320
1510
  if (plan !== "entries") {
1321
1511
  const pageSql2 = `SELECT "payload" FROM ${table}${fullWhere} ORDER BY ${ordering}${paging}`;
1322
1512
  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;
1513
+ const result = { projectionVersion: DOMAIN_PROJECTION_VERSION, selectedCount, matchedCount, plan, rows: rows2 };
1514
+ return result;
1325
1515
  }
1326
1516
  const pageSql = `SELECT "note_id", "payload" FROM ${table}${fullWhere} ORDER BY ${ordering}${paging}`;
1327
1517
  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(() => [[], [], [], []]);
1518
+ const buckets = noteRows.map(() => [[], [], []]);
1344
1519
  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)) {
1520
+ noteRows.forEach((row, i) => index.set(String(row.note_id), i));
1521
+ for (let start = 0; start < noteRows.length; start += 500) {
1522
+ const ids = noteRows.slice(start, start + 500).map((row) => String(row.note_id));
1523
+ const placeholders = ids.map(() => "?").join(", ");
1524
+ const statements = [
1525
+ { 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 },
1526
+ { 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 },
1527
+ { 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 }
1528
+ ];
1529
+ const relations = conn.batch ? await conn.batch(statements) : await (async () => {
1530
+ const rows2 = [];
1531
+ for (const statement of statements) rows2.push(await conn.query(statement.sql, statement.params));
1532
+ return rows2;
1533
+ })();
1534
+ relations.forEach((rows2, relation) => rows2.forEach((row) => {
1348
1535
  const i = index.get(String(row.note_id));
1349
- if (i !== void 0) buckets[i][rel].push(decodeRowJson(String(row.payload)));
1350
- }
1536
+ if (i !== void 0) buckets[i][relation].push(decodeRowJson(String(row.payload)));
1537
+ }));
1351
1538
  }
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 };
1539
+ const rows = noteRows.map((row, i) => {
1540
+ const [tags, links, pages] = buckets[i];
1541
+ return { note: decodeRowJson(String(row.payload)), tags, links, pages };
1355
1542
  });
1356
- const result = { projectionVersion: DOMAIN_PROJECTION_VERSION, selectedCount, matchedCount, plan, rows };
1357
- return result;
1543
+ return { projectionVersion: DOMAIN_PROJECTION_VERSION, selectedCount, matchedCount, plan, rows };
1358
1544
  }
1359
1545
  async function handleDomainQuery(db, body) {
1360
1546
  const query = parseQuery(body);
@@ -1373,7 +1559,6 @@ var USER_OWNED_STORES = [
1373
1559
  "tags",
1374
1560
  "tag_types",
1375
1561
  "note_tags",
1376
- "segments",
1377
1562
  "sessions",
1378
1563
  "attachments",
1379
1564
  "events",
@@ -1524,18 +1709,14 @@ var SqlStorage = class {
1524
1709
  }
1525
1710
  async transaction(stores, mode, fn) {
1526
1711
  await this.ensureReady();
1527
- return this.runTx(stores, mode === "readwrite", fn, false);
1712
+ return this.runTx(stores, mode === "readwrite", fn);
1528
1713
  }
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
- */
1714
+ /** Bulk writes use the same affected-key refresh as ordinary transactions. */
1534
1715
  async bulkTransaction(stores, fn) {
1535
1716
  await this.ensureReady();
1536
- return this.runTx(stores, true, fn, true);
1717
+ return this.runTx(stores, true, fn);
1537
1718
  }
1538
- runTx(stores, mutableMode, fn, deferProjection) {
1719
+ runTx(stores, mutableMode, fn) {
1539
1720
  const scoped = new Set(stores);
1540
1721
  const buffer = /* @__PURE__ */ new Map();
1541
1722
  const cleared = /* @__PURE__ */ new Set();
@@ -1561,8 +1742,7 @@ var SqlStorage = class {
1561
1742
  const result = await fn(tx);
1562
1743
  const ops = bufferToOps(STORE_DEFS.map((d) => d.name), buffer, cleared);
1563
1744
  if (ops.length > 0) {
1564
- await refreshDomain(conn, ops, deferProjection ? { deferProjection: true } : {});
1565
- if (deferProjection) await rebuildProjections(conn);
1745
+ await refreshDomain(conn, ops);
1566
1746
  }
1567
1747
  return result;
1568
1748
  });
@@ -1637,6 +1817,20 @@ var SqlStore = class {
1637
1817
  if (this.cleared?.has(this.store)) return void 0;
1638
1818
  return getRow(this.conn, this.def, k);
1639
1819
  }
1820
+ async getMany(keys) {
1821
+ await this.owner.ensureReady();
1822
+ const normalized = keys.map((key) => this.pointKey(key));
1823
+ const committed = this.cleared?.has(this.store) ? keys.map(() => void 0) : await getRows(this.conn, this.def, normalized);
1824
+ return normalized.map((key, i) => {
1825
+ const state = bufferedGet(this.store, key, this.buffer);
1826
+ return state ? state.hit ? state.value : void 0 : committed[i];
1827
+ });
1828
+ }
1829
+ async getAllFromIndexMany(indexName, keys) {
1830
+ await this.owner.ensureReady();
1831
+ const rows = await listRowsFromIndexMany(this.conn, this.def, indexName, keys.map((key) => this.pointKey(key)));
1832
+ return rows.map((values, i) => overlayRows(this.def, this.store, this.buffer, this.cleared, values, void 0, { only: this.pointKey(keys[i]) }, indexName));
1833
+ }
1640
1834
  async getAll(query, count) {
1641
1835
  await this.owner.ensureReady();
1642
1836
  const range = rangeFromIDB(query);
@@ -1668,6 +1862,10 @@ var SqlStore = class {
1668
1862
  this.assertMutable();
1669
1863
  const stored = this.owner.stamp(this.store, value);
1670
1864
  const key = keyFromValue(this.def, stored);
1865
+ if (this.store === "block_revisions") {
1866
+ const previous = this.buffer?.get(bufferSlot(this.store, key));
1867
+ if (previous?.kind === "put" && !rowsEqual(previous.value, stored)) throw new StorageError(409, "immutable block revision collision", "revision_conflict");
1868
+ }
1671
1869
  if (this.buffer) {
1672
1870
  this.buffer.set(bufferSlot(this.store, key), { kind: "put", value: stored });
1673
1871
  return key;
@@ -1675,9 +1873,28 @@ var SqlStore = class {
1675
1873
  await this.owner.applyOps([{ op: "put", store: this.store, value: stored }]);
1676
1874
  return key;
1677
1875
  }
1876
+ async putMany(values, _keys) {
1877
+ await this.owner.ensureReady();
1878
+ this.assertMutable();
1879
+ const stored = values.map((value) => this.owner.stamp(this.store, value));
1880
+ const keys = stored.map((value) => keyFromValue(this.def, value));
1881
+ if (this.store === "block_revisions") {
1882
+ const seen = /* @__PURE__ */ new Map();
1883
+ keys.forEach((key, i) => {
1884
+ const slot = bufferSlot(this.store, key), previous = this.buffer?.get(slot);
1885
+ const value = seen.has(slot) ? seen.get(slot) : previous?.kind === "put" ? previous.value : void 0;
1886
+ if (value !== void 0 && !rowsEqual(value, stored[i])) throw new StorageError(409, "immutable block revision collision", "revision_conflict");
1887
+ seen.set(slot, stored[i]);
1888
+ });
1889
+ }
1890
+ if (this.buffer) keys.forEach((key, i) => this.buffer.set(bufferSlot(this.store, key), { kind: "put", value: stored[i] }));
1891
+ else await this.owner.applyOps(stored.map((value) => ({ op: "put", store: this.store, value })));
1892
+ return keys;
1893
+ }
1678
1894
  async delete(key) {
1679
1895
  await this.owner.ensureReady();
1680
1896
  this.assertMutable();
1897
+ if (this.store === "block_revisions") throw new StorageError(409, "block history requires explicit wipe", "revision_conflict");
1681
1898
  const range = isRange(key) ? key : void 0;
1682
1899
  const target = range ?? key;
1683
1900
  if (this.buffer) {
@@ -1697,6 +1914,7 @@ var SqlStore = class {
1697
1914
  async clear() {
1698
1915
  await this.owner.ensureReady();
1699
1916
  this.assertMutable();
1917
+ if (this.store === "block_revisions") throw new StorageError(409, "block history requires explicit wipe", "revision_conflict");
1700
1918
  if (this.buffer) {
1701
1919
  this.cleared?.add(this.store);
1702
1920
  const prefix = `${this.store}\0`;
@@ -1763,12 +1981,15 @@ exports.decodeRowJson = decodeRowJson;
1763
1981
  exports.deleteRows = deleteRows;
1764
1982
  exports.encodeRowJson = encodeRowJson;
1765
1983
  exports.getRow = getRow;
1984
+ exports.getRows = getRows;
1766
1985
  exports.handleDomainQuery = handleDomainQuery;
1767
1986
  exports.initializeDomain = initializeDomain;
1768
1987
  exports.initializeStore = initializeStore;
1769
1988
  exports.libsqlDatabase = libsqlDatabase;
1770
1989
  exports.listRows = listRows;
1990
+ exports.listRowsFromIndexMany = listRowsFromIndexMany;
1771
1991
  exports.putRow = putRow;
1992
+ exports.putRows = putRows;
1772
1993
  exports.rebuildProjections = rebuildProjections;
1773
1994
  exports.refreshDomain = refreshDomain;
1774
1995
  exports.storeDefinition = storeDefinition;