@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.
- package/dist/{chunk-DAGT3P7M.mjs → chunk-Y576CUFQ.mjs} +118 -36
- package/dist/chunk-Y576CUFQ.mjs.map +1 -0
- package/dist/index.cjs +182 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +12 -5
- package/dist/index.d.ts +12 -5
- package/dist/index.mjs +62 -16
- package/dist/index.mjs.map +1 -1
- package/dist/sql/index.cjs +507 -286
- package/dist/sql/index.cjs.map +1 -1
- package/dist/sql/index.d.cts +16 -61
- package/dist/sql/index.d.ts +16 -61
- package/dist/sql/index.mjs +422 -270
- package/dist/sql/index.mjs.map +1 -1
- package/dist/wire-Cfvz-I0h.d.cts +207 -0
- package/dist/wire-Cfvz-I0h.d.ts +207 -0
- package/package.json +6 -9
- package/dist/chunk-DAGT3P7M.mjs.map +0 -1
- package/dist/wire-C7ZtlBKe.d.cts +0 -489
- package/dist/wire-C7ZtlBKe.d.ts +0 -489
package/dist/sql/index.cjs
CHANGED
|
@@ -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
|
|
110
|
-
const revived = reviveBinary(
|
|
111
|
-
if (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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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((
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
);
|
|
728
|
+
const rows = [];
|
|
729
|
+
const out = [];
|
|
730
|
+
const junction = [];
|
|
603
731
|
const multi = multiEntryIndex(def);
|
|
604
|
-
|
|
605
|
-
const
|
|
606
|
-
const
|
|
607
|
-
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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
|
|
842
|
-
const
|
|
843
|
-
|
|
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
|
-
|
|
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
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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
|
-
|
|
893
|
-
|
|
894
|
-
|
|
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
|
-
|
|
910
|
-
const
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
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
|
-
|
|
923
|
-
|
|
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
|
|
926
|
-
const
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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
|
|
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 ===
|
|
953
|
-
|
|
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
|
-
|
|
960
|
-
|
|
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
|
-
|
|
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(`
|
|
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
|
-
|
|
991
|
-
|
|
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
|
-
|
|
1001
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1324
|
-
return
|
|
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
|
|
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((
|
|
1346
|
-
for (
|
|
1347
|
-
|
|
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][
|
|
1350
|
-
}
|
|
1536
|
+
if (i !== void 0) buckets[i][relation].push(decodeRowJson(String(row.payload)));
|
|
1537
|
+
}));
|
|
1351
1538
|
}
|
|
1352
|
-
const rows = noteRows.map((
|
|
1353
|
-
const [
|
|
1354
|
-
return { note: decodeRowJson(String(
|
|
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
|
-
|
|
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
|
|
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
|
|
1717
|
+
return this.runTx(stores, true, fn);
|
|
1537
1718
|
}
|
|
1538
|
-
runTx(stores, mutableMode, fn
|
|
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
|
|
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;
|