@bitcobblers/wod-wiki-storage 0.35.2116 → 0.35.2119
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-DAGT3P7M.mjs → chunk-7ZCBM5NS.mjs} +118 -36
- package/dist/chunk-7ZCBM5NS.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 +821 -289
- package/dist/sql/index.cjs.map +1 -1
- package/dist/sql/index.d.cts +172 -62
- package/dist/sql/index.d.ts +172 -62
- package/dist/sql/index.mjs +726 -273
- package/dist/sql/index.mjs.map +1 -1
- package/dist/wire-CiFcjo6_.d.cts +207 -0
- package/dist/wire-CiFcjo6_.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 = 30;
|
|
118
128
|
var COLUMN_TYPES = {
|
|
119
129
|
notes: { date: "number", createdAt: "number" },
|
|
120
130
|
page: { createdAt: "number" },
|
|
@@ -123,16 +133,9 @@ var COLUMN_TYPES = {
|
|
|
123
133
|
tags: { createdAt: "number" },
|
|
124
134
|
tag_types: { createdAt: "number" },
|
|
125
135
|
note_tags: {},
|
|
126
|
-
|
|
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, 50)) {
|
|
592
|
+
const params = [];
|
|
593
|
+
const conditions = part.map((key) => {
|
|
594
|
+
const parts = toParts(key);
|
|
595
|
+
if (parts.length !== def.keyPath.length) throw new StorageError(400, "invalid compound key arity");
|
|
596
|
+
params.push(...parts.map(toSqlScalar));
|
|
597
|
+
return `(${keyCols(def).map((col) => `"${col}" = ?`).join(" AND ")})`;
|
|
598
|
+
});
|
|
599
|
+
for (const row of await conn.query(`SELECT "data" FROM "${def.name}" WHERE ${conditions.join(" OR ")}`, params)) {
|
|
600
|
+
const value = decodeRowJson(String(row.data), binaryFieldOf(def));
|
|
601
|
+
found.set(JSON.stringify(requireKey(def, value)), value);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return keys.map((key) => found.get(JSON.stringify(key)));
|
|
605
|
+
}
|
|
606
|
+
async function listRowsFromIndexMany(conn, def, indexName, keys) {
|
|
607
|
+
const idx = indexDefinition(def, indexName);
|
|
608
|
+
if (idx.multiEntry) {
|
|
609
|
+
const out = [];
|
|
610
|
+
for (const key of keys) out.push(await listRows(conn, def, { only: key }, void 0, indexName));
|
|
611
|
+
return out;
|
|
612
|
+
}
|
|
613
|
+
const found = /* @__PURE__ */ new Map();
|
|
614
|
+
for (const part of chunks(keys, 50)) {
|
|
615
|
+
const params = [];
|
|
616
|
+
const conditions = part.map((key) => {
|
|
617
|
+
const parts = toParts(key);
|
|
618
|
+
if (parts.length !== idx.keyPath.length) throw new StorageError(400, "invalid index key arity");
|
|
619
|
+
params.push(...parts.map(toSqlScalar));
|
|
620
|
+
return `(${parts.map((_, i) => `"${indexCol(idx.name, i)}" = ?`).join(" AND ")})`;
|
|
621
|
+
});
|
|
622
|
+
for (const row of await conn.query(`SELECT "data" FROM "${def.name}" WHERE ${conditions.join(" OR ")} ORDER BY ${keyCols(def).map((col) => `"${col}"`).join(", ")}`, params)) {
|
|
623
|
+
const value = decodeRowJson(String(row.data), binaryFieldOf(def));
|
|
624
|
+
const parts = idx.keyPath.map((path) => extractKeyPart(value, path));
|
|
625
|
+
const key = JSON.stringify(parts.length === 1 ? parts[0] : parts);
|
|
626
|
+
const bucket = found.get(key);
|
|
627
|
+
if (bucket) bucket.push(value);
|
|
628
|
+
else found.set(key, [value]);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return keys.map((key) => found.get(JSON.stringify(key)) ?? []);
|
|
632
|
+
}
|
|
522
633
|
async function listRows(conn, def, range, count, indexName) {
|
|
523
634
|
validateRange(range);
|
|
524
635
|
const idx = indexName ? indexDefinition(def, indexName) : void 0;
|
|
@@ -576,51 +687,114 @@ async function countRows(conn, def, range, indexName) {
|
|
|
576
687
|
const rows = await conn.query(`SELECT COUNT(*) AS "n" FROM "${def.name}"${cond}`, params);
|
|
577
688
|
return Number(rows[0]?.n ?? 0);
|
|
578
689
|
}
|
|
579
|
-
|
|
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,31 +871,75 @@ async function ensureStoreTables(conn, dialect, def) {
|
|
|
696
871
|
);
|
|
697
872
|
await conn.exec(`CREATE INDEX IF NOT EXISTS "${jt}__v" ON "${jt}" ("v")`);
|
|
698
873
|
}
|
|
874
|
+
if (def.name === "notes" && dialect === "sqlite") {
|
|
875
|
+
await conn.exec(`CREATE TRIGGER IF NOT EXISTS "notes_revision_guard" BEFORE UPDATE ON "notes"
|
|
876
|
+
WHEN json_extract(NEW."data", '$.sourceRevision') < json_extract(OLD."data", '$.sourceRevision')
|
|
877
|
+
OR ((json_extract(NEW."data", '$.rawContent') IS NOT json_extract(OLD."data", '$.rawContent')
|
|
878
|
+
OR json_extract(NEW."data", '$.blocks') IS NOT json_extract(OLD."data", '$.blocks'))
|
|
879
|
+
AND json_extract(NEW."data", '$.sourceRevision') <= json_extract(OLD."data", '$.sourceRevision'))
|
|
880
|
+
BEGIN SELECT RAISE(ABORT, 'transaction_conflict: stale Note source revision'); END`);
|
|
881
|
+
}
|
|
882
|
+
if (def.name === "block_revisions" && dialect === "sqlite") {
|
|
883
|
+
await conn.exec(`CREATE TRIGGER IF NOT EXISTS "block_revision_guard" BEFORE INSERT ON "block_revisions"
|
|
884
|
+
WHEN EXISTS (SELECT 1 FROM "block_revisions" old WHERE old."k0" = NEW."k0" AND old."k1" = NEW."k1" AND old."k2" = NEW."k2" AND old."data" <> NEW."data")
|
|
885
|
+
BEGIN SELECT RAISE(ABORT, 'revision_conflict: immutable block revision collision'); END`);
|
|
886
|
+
}
|
|
887
|
+
if (def.name === "sessions" && dialect === "sqlite") {
|
|
888
|
+
await conn.exec(`CREATE TRIGGER IF NOT EXISTS "session_pin_guard" BEFORE UPDATE ON "sessions"
|
|
889
|
+
WHEN json_extract(NEW."data", '$.noteId') IS NOT json_extract(OLD."data", '$.noteId')
|
|
890
|
+
OR json_extract(NEW."data", '$.blockId') IS NOT json_extract(OLD."data", '$.blockId')
|
|
891
|
+
OR json_extract(NEW."data", '$.blockVersion') IS NOT json_extract(OLD."data", '$.blockVersion')
|
|
892
|
+
BEGIN SELECT RAISE(ABORT, 'invalid_session_pin: Session pin is immutable'); END`);
|
|
893
|
+
}
|
|
894
|
+
if (dialect === "postgres" && ["notes", "block_revisions", "sessions"].includes(def.name)) {
|
|
895
|
+
const condition = def.name === "block_revisions" ? `EXISTS (SELECT 1 FROM "block_revisions" old WHERE old."k0" = NEW."k0" AND old."k1" = NEW."k1" AND old."k2" = NEW."k2" AND old."data" <> NEW."data")` : def.name === "notes" ? `(NEW."data"::jsonb->>'sourceRevision')::bigint < (OLD."data"::jsonb->>'sourceRevision')::bigint OR ((NEW."data"::jsonb->'rawContent' IS DISTINCT FROM OLD."data"::jsonb->'rawContent' OR NEW."data"::jsonb->'blocks' IS DISTINCT FROM OLD."data"::jsonb->'blocks') AND (NEW."data"::jsonb->>'sourceRevision')::bigint <= (OLD."data"::jsonb->>'sourceRevision')::bigint)` : `NEW."data"::jsonb->'noteId' IS DISTINCT FROM OLD."data"::jsonb->'noteId' OR NEW."data"::jsonb->'blockId' IS DISTINCT FROM OLD."data"::jsonb->'blockId' OR NEW."data"::jsonb->'blockVersion' IS DISTINCT FROM OLD."data"::jsonb->'blockVersion'`;
|
|
896
|
+
const code = def.name === "block_revisions" ? "revision_conflict" : def.name === "notes" ? "transaction_conflict" : "invalid_session_pin";
|
|
897
|
+
const guard = `${def.name}_guard`;
|
|
898
|
+
await conn.exec(`CREATE OR REPLACE FUNCTION "${guard}"() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF ${condition} THEN RAISE EXCEPTION '${code}'; END IF; RETURN NEW; END; $$`);
|
|
899
|
+
await conn.exec(`DROP TRIGGER IF EXISTS "${guard}" ON "${def.name}"`);
|
|
900
|
+
await conn.exec(`CREATE TRIGGER "${guard}" BEFORE ${def.name === "block_revisions" ? "INSERT" : "UPDATE"} ON "${def.name}" FOR EACH ROW EXECUTE FUNCTION "${guard}"()`);
|
|
901
|
+
}
|
|
699
902
|
}
|
|
700
903
|
async function initializeStore(db) {
|
|
701
904
|
await db.transaction(async (tx) => {
|
|
702
905
|
await tx.exec(
|
|
703
906
|
`CREATE TABLE IF NOT EXISTS "${SCHEMA_TABLE}" ("key" TEXT PRIMARY KEY, "value" TEXT NOT NULL)`
|
|
704
907
|
);
|
|
908
|
+
const ignored = (name) => name.startsWith("_wodwiki_") || name.startsWith("sqlite_");
|
|
705
909
|
const storeTables = new Set(STORE_DEFS.map((d) => d.name));
|
|
910
|
+
for (const def of STORE_DEFS) {
|
|
911
|
+
const multi = def.indexes.find((i) => i.multiEntry);
|
|
912
|
+
if (multi) storeTables.add(junctionTable(def, multi));
|
|
913
|
+
}
|
|
706
914
|
const tableNames = new Set(
|
|
707
|
-
(await tx.query(tableListSql(db.dialect))).map((r) => String(Object.values(r)[0]))
|
|
915
|
+
(await tx.query(tableListSql(db.dialect))).map((r) => String(Object.values(r)[0])).filter((n) => !ignored(n))
|
|
708
916
|
);
|
|
709
917
|
const guardRows = await tx.query(`SELECT "value" FROM "${SCHEMA_TABLE}" WHERE "key" = ?`, [SCHEMA_KEY]);
|
|
710
918
|
let stored;
|
|
711
919
|
if (guardRows[0]) {
|
|
712
920
|
const guard = JSON.parse(String(guardRows[0].value));
|
|
713
|
-
if (typeof guard.version !== "number") {
|
|
921
|
+
if (typeof guard.version !== "number" || !Number.isSafeInteger(guard.version) || guard.version < 0) {
|
|
714
922
|
throw new StorageError(500, `corrupt ${SCHEMA_TABLE} guard row`, "bad_schema_guard");
|
|
715
923
|
}
|
|
716
924
|
stored = guard.version;
|
|
717
|
-
if (stored
|
|
925
|
+
if (stored > DB_VERSION) {
|
|
718
926
|
throw new StorageError(
|
|
719
927
|
500,
|
|
720
|
-
`database schema v${stored}
|
|
928
|
+
`database schema v${stored} is newer than this server supports (v${DB_VERSION}); refusing to downgrade`,
|
|
721
929
|
"schema_incompatible"
|
|
722
930
|
);
|
|
723
931
|
}
|
|
932
|
+
if (stored < DB_VERSION) {
|
|
933
|
+
const missing = [...storeTables].filter((name) => !tableNames.has(name));
|
|
934
|
+
const unexpected = [...tableNames].filter((name) => name !== SCHEMA_TABLE && !storeTables.has(name));
|
|
935
|
+
if (missing.length > 0 || unexpected.length > 0) {
|
|
936
|
+
throw new StorageError(
|
|
937
|
+
500,
|
|
938
|
+
`database schema v${stored} is incompatible with v${DB_VERSION} (missing tables: ${missing.join(", ") || "none"}; unrecognized tables: ${unexpected.join(", ") || "none"}); recreation required using an explicitly disposable database`,
|
|
939
|
+
"schema_incompatible"
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
724
943
|
} else if ([...tableNames].some((name) => storeTables.has(name))) {
|
|
725
944
|
throw new StorageError(
|
|
726
945
|
500,
|
|
@@ -737,10 +956,8 @@ async function initializeStore(db) {
|
|
|
737
956
|
}
|
|
738
957
|
});
|
|
739
958
|
}
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
var DOMAIN_PROJECTION_VERSION = 1;
|
|
743
|
-
var TABLES = ["domain_note", "query_block", "segment_head"];
|
|
959
|
+
var DOMAIN_PROJECTION_VERSION = 2;
|
|
960
|
+
var TABLES = ["domain_note", "query_block"];
|
|
744
961
|
var PROJECTION_GUARD_KEY = "domain_projection";
|
|
745
962
|
function sourceItem(row) {
|
|
746
963
|
const item = {};
|
|
@@ -759,7 +976,7 @@ function ensureDdl(db) {
|
|
|
759
976
|
const n = db.dialect === "postgres" ? " DOUBLE PRECISION" : "";
|
|
760
977
|
const t = db.dialect === "postgres" ? ' TEXT COLLATE "C"' : " TEXT";
|
|
761
978
|
const ddl = [
|
|
762
|
-
`CREATE TABLE IF NOT EXISTS "domain_note" ("note_id"${t} PRIMARY KEY NOT NULL, "note_type"${t}, "catalog"${t}, "source_id"${t}, "source_path"${t}, "title"${t} NOT NULL, "created_at"${n} NOT NULL, "date"${n}, "text_search"${t} NOT NULL, "journal"${n} NOT NULL, "payload"${t} NOT NULL)`,
|
|
979
|
+
`CREATE TABLE IF NOT EXISTS "domain_note" ("note_id"${t} PRIMARY KEY NOT NULL, "note_type"${t}, "catalog"${t}, "source_id"${t}, "source_path"${t}, "title"${t} NOT NULL, "title_search"${t} NOT NULL, "created_at"${n} NOT NULL, "date"${n}, "text_search"${t} NOT NULL, "journal"${n} NOT NULL, "payload"${t} NOT NULL)`,
|
|
763
980
|
`CREATE INDEX IF NOT EXISTS "idx_domain_note_date" ON "domain_note" ("date", "note_id")`,
|
|
764
981
|
`CREATE INDEX IF NOT EXISTS "idx_domain_note_created" ON "domain_note" ("created_at", "note_id")`,
|
|
765
982
|
`CREATE INDEX IF NOT EXISTS "idx_domain_note_source_path" ON "domain_note" ("source_path")`,
|
|
@@ -768,169 +985,140 @@ function ensureDdl(db) {
|
|
|
768
985
|
`CREATE INDEX IF NOT EXISTS "idx_query_block_parent" ON "query_block" ("parent_note_id", "ordinal")`,
|
|
769
986
|
`CREATE INDEX IF NOT EXISTS "idx_query_block_content" ON "query_block" ("content_id")`,
|
|
770
987
|
`CREATE INDEX IF NOT EXISTS "idx_query_block_source_path" ON "query_block" ("source_path")`,
|
|
771
|
-
`CREATE INDEX IF NOT EXISTS "idx_query_block_source_note" ON "query_block" ("source_note_id")
|
|
772
|
-
`CREATE TABLE IF NOT EXISTS "segment_head" ("id"${t} PRIMARY KEY NOT NULL, "note_id"${t} NOT NULL, "current_version"${n} NOT NULL, "position"${n}, "search_text"${t} NOT NULL)`,
|
|
773
|
-
`CREATE INDEX IF NOT EXISTS "idx_segment_head_note" ON "segment_head" ("note_id", "id")`
|
|
988
|
+
`CREATE INDEX IF NOT EXISTS "idx_query_block_source_note" ON "query_block" ("source_note_id")`
|
|
774
989
|
];
|
|
775
990
|
return db.transaction(async (tx) => {
|
|
776
991
|
for (const sql of ddl) await tx.exec(sql);
|
|
777
992
|
});
|
|
778
993
|
}
|
|
779
|
-
|
|
994
|
+
var DOMAIN_NOTE_COLS = ["note_id", "note_type", "catalog", "source_id", "source_path", "title", "title_search", "created_at", "date", "text_search", "journal", "payload"];
|
|
995
|
+
var QUERY_BLOCK_COLS = ["occurrence_id", "source_note_id", "parent_note_id", "content_id", "section_key", "ordinal", "data_type", "source_id", "source_path", "catalog", "effective_date", "created_at", "note_title", "normalized_text", "journal", "payload"];
|
|
996
|
+
var conflictUpdate = (cols, keys) => `ON CONFLICT (${keys.map((k) => `"${k}"`).join(", ")}) DO UPDATE SET ` + cols.filter((c) => !keys.includes(c)).map((c) => `"${c}" = excluded."${c}"`).join(",") + ` WHERE ` + cols.filter((col) => !keys.includes(col)).map((col) => `"${col}" IS DISTINCT FROM excluded."${col}"`).join(" OR ");
|
|
997
|
+
function domainNoteRow(note) {
|
|
780
998
|
const id = text(note.id);
|
|
781
|
-
if (!id) return;
|
|
999
|
+
if (!id) return void 0;
|
|
782
1000
|
const source = sourceItem(note);
|
|
783
|
-
|
|
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
|
-
}));
|
|
1001
|
+
return [
|
|
1002
|
+
id,
|
|
1003
|
+
T(note.type),
|
|
1004
|
+
T(wodWikiWql.catalogOfItem(source)),
|
|
1005
|
+
T(note.sourceId),
|
|
1006
|
+
T(note.sourcePath),
|
|
1007
|
+
T(note.title) ?? "",
|
|
1008
|
+
(text(note.title) ?? "").toLowerCase(),
|
|
1009
|
+
N(note.createdAt),
|
|
1010
|
+
num(note.date),
|
|
1011
|
+
(text(note.rawContent) ?? "").toLowerCase(),
|
|
1012
|
+
wodWikiWql.sourceMatches(source, "journal") ? 1 : 0,
|
|
1013
|
+
encodeRowJson(note)
|
|
1014
|
+
];
|
|
840
1015
|
}
|
|
841
|
-
async function
|
|
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;
|
|
1016
|
+
async function upsertDomainNotes(conn, notes) {
|
|
1017
|
+
const rows = notes.map(domainNoteRow).filter((r) => r !== void 0);
|
|
1018
|
+
await batchInsert(conn, "domain_note", [...DOMAIN_NOTE_COLS], rows, conflictUpdate(DOMAIN_NOTE_COLS, ["note_id"]));
|
|
854
1019
|
}
|
|
855
|
-
|
|
1020
|
+
function queryBlockRow(block, parent) {
|
|
856
1021
|
const id = text(block.id);
|
|
857
|
-
if (!id) return;
|
|
1022
|
+
if (!id) return void 0;
|
|
858
1023
|
const source = sourceItem(block);
|
|
859
1024
|
const createdAt = N(block.createdAt);
|
|
860
|
-
const parent = await resolveParent(conn, block);
|
|
861
1025
|
const effective = parent ? parent.date ?? parent.created_at : createdAt;
|
|
862
|
-
|
|
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
|
-
);
|
|
1026
|
+
return [
|
|
1027
|
+
id,
|
|
1028
|
+
T(block.noteId),
|
|
1029
|
+
parent?.note_id ?? null,
|
|
1030
|
+
T(block.blockContentId),
|
|
1031
|
+
T(block.blockId),
|
|
1032
|
+
num(block.position),
|
|
1033
|
+
T(block.dataType),
|
|
1034
|
+
T(block.sourceId),
|
|
1035
|
+
T(block.sourcePath),
|
|
1036
|
+
T(wodWikiWql.catalogOfItem(source)),
|
|
1037
|
+
effective,
|
|
1038
|
+
createdAt,
|
|
1039
|
+
parent?.note.title ?? T(block.noteTitle),
|
|
1040
|
+
(text(block.rawContent) ?? "").toLowerCase(),
|
|
1041
|
+
wodWikiWql.sourceMatches(source, "journal") ? 1 : 0,
|
|
1042
|
+
encodeRowJson(parent ? { ...block, noteTitle: parent.note.title } : block)
|
|
1043
|
+
];
|
|
891
1044
|
}
|
|
892
|
-
|
|
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
|
-
}
|
|
1045
|
+
async function upsertQueryBlocks(conn, blocks) {
|
|
1046
|
+
const rows = blocks.map(([b, p]) => queryBlockRow(b, p)).filter((r) => r !== void 0);
|
|
1047
|
+
await batchInsert(conn, "query_block", [...QUERY_BLOCK_COLS], rows, conflictUpdate(QUERY_BLOCK_COLS, ["occurrence_id"]));
|
|
908
1048
|
}
|
|
909
|
-
|
|
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
|
-
}
|
|
1049
|
+
function indexNoteIdentities(notes) {
|
|
1050
|
+
const byNoteId = /* @__PURE__ */ new Map();
|
|
1051
|
+
for (const note of notes) {
|
|
1052
|
+
const id = text(note.id);
|
|
1053
|
+
if (id) byNoteId.set(id, { note_id: id, date: num(note.date), created_at: num(note.createdAt) ?? 0, note });
|
|
921
1054
|
}
|
|
922
|
-
|
|
923
|
-
|
|
1055
|
+
return { byNoteId };
|
|
1056
|
+
}
|
|
1057
|
+
function resolveParentFrom(index, block) {
|
|
1058
|
+
const parent = typeof block.noteId === "string" ? index.byNoteId.get(block.noteId) : void 0;
|
|
1059
|
+
const mapped = typeof block.blockId === "string" ? parent?.note.blocks[block.blockId] : void 0;
|
|
1060
|
+
return parent && mapped && block.id === `${parent.note_id}:${mapped.id}` && block.blockVersion === mapped.version && block.dataType === mapped.kind && block.sourceRevision === parent.note.sourceRevision && block.from === mapped.from && block.to === mapped.to && block.rawContent === parent.note.rawContent.slice(mapped.from, mapped.to) ? parent : void 0;
|
|
1061
|
+
}
|
|
1062
|
+
async function fetchParentIndex(conn, noteIds) {
|
|
1063
|
+
const notes = await getRows(conn, storeDefinition("notes"), noteIds);
|
|
1064
|
+
return indexNoteIdentities(notes.filter((row) => row !== void 0));
|
|
924
1065
|
}
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
const
|
|
928
|
-
|
|
929
|
-
|
|
1066
|
+
var keyHead = (key) => Array.isArray(key) ? key[0] : key;
|
|
1067
|
+
async function selectIn(conn, selectCol, table, col, vals) {
|
|
1068
|
+
const IN_CHUNK = 500;
|
|
1069
|
+
const out = [];
|
|
1070
|
+
for (let i = 0; i < vals.length; i += IN_CHUNK) {
|
|
1071
|
+
const part = vals.slice(i, i + IN_CHUNK);
|
|
1072
|
+
const rows = await conn.query(`SELECT "${selectCol}" FROM "${table}" WHERE "${col}" IN (${part.map(() => "?").join(", ")})`, part);
|
|
1073
|
+
for (const r of rows) out.push(String(r[selectCol]));
|
|
1074
|
+
}
|
|
1075
|
+
return out;
|
|
1076
|
+
}
|
|
1077
|
+
async function healQueryBlocksForNotes(conn, noteIds) {
|
|
1078
|
+
const ids = await selectIn(conn, "occurrence_id", "query_block", "source_note_id", noteIds);
|
|
1079
|
+
await refreshBlocks(conn, [...new Set(ids)]);
|
|
1080
|
+
}
|
|
1081
|
+
async function refreshNotes(conn, ids) {
|
|
1082
|
+
const ks = [...new Set(ids.map((id) => String(keyHead(id))))];
|
|
1083
|
+
if (ks.length === 0) return;
|
|
1084
|
+
const found = /* @__PURE__ */ new Map();
|
|
1085
|
+
for (let i = 0; i < ks.length; i += 500) {
|
|
1086
|
+
const part = ks.slice(i, i + 500);
|
|
1087
|
+
const rows = await conn.query(`SELECT "k0", "data" FROM "notes" WHERE "k0" IN (${part.map(() => "?").join(", ")})`, part);
|
|
1088
|
+
for (const row of rows) found.set(String(row.k0), decodeRowJson(String(row.data)));
|
|
1089
|
+
}
|
|
1090
|
+
const present = [];
|
|
1091
|
+
const missing = [];
|
|
1092
|
+
for (const k of ks) {
|
|
1093
|
+
const note = found.get(k);
|
|
1094
|
+
if (note) present.push(note);
|
|
1095
|
+
else missing.push(k);
|
|
1096
|
+
}
|
|
1097
|
+
await upsertDomainNotes(conn, present);
|
|
1098
|
+
const presentIds = present.map((n) => text(n.id)).filter((x) => !!x);
|
|
1099
|
+
await healQueryBlocksForNotes(conn, presentIds);
|
|
1100
|
+
await deleteIn(conn, "domain_note", "note_id", missing);
|
|
1101
|
+
await deleteIn(conn, "query_block", "source_note_id", missing);
|
|
1102
|
+
await deleteIn(conn, "block_index", "i_by_note_0", missing);
|
|
1103
|
+
await deleteIn(conn, "block_efforts", "i_by_note_0", missing);
|
|
1104
|
+
}
|
|
1105
|
+
async function refreshBlocks(conn, ids) {
|
|
1106
|
+
const ks = [...new Set(ids.map((id) => String(keyHead(id))))];
|
|
1107
|
+
if (ks.length === 0) return;
|
|
1108
|
+
const found = /* @__PURE__ */ new Map();
|
|
1109
|
+
for (let i = 0; i < ks.length; i += 500) {
|
|
1110
|
+
const part = ks.slice(i, i + 500);
|
|
1111
|
+
const rows = await conn.query(`SELECT "k0", "data" FROM "block_index" WHERE "k0" IN (${part.map(() => "?").join(", ")})`, part);
|
|
1112
|
+
for (const row of rows) found.set(String(row.k0), decodeRowJson(String(row.data)));
|
|
1113
|
+
}
|
|
1114
|
+
const blocks = [...found.values()];
|
|
1115
|
+
const noteIds = [...new Set(blocks.map((b) => text(b.noteId)).filter((x) => !!x))];
|
|
1116
|
+
const index = await fetchParentIndex(conn, noteIds);
|
|
1117
|
+
await upsertQueryBlocks(conn, blocks.map((b) => [b, resolveParentFrom(index, b)]));
|
|
1118
|
+
await deleteIn(conn, "query_block", "occurrence_id", ks.filter((k) => !found.has(k)));
|
|
930
1119
|
}
|
|
931
1120
|
var TRACKED = {
|
|
932
1121
|
notes: "notes",
|
|
933
|
-
segments: "segments",
|
|
934
1122
|
block_index: "blocks"
|
|
935
1123
|
};
|
|
936
1124
|
async function deleteTargetKeys(conn, def, target) {
|
|
@@ -938,29 +1126,54 @@ async function deleteTargetKeys(conn, def, target) {
|
|
|
938
1126
|
const rows = await listRows(conn, def, target);
|
|
939
1127
|
return rows.map((row) => keyFromValue(def, row));
|
|
940
1128
|
}
|
|
941
|
-
async function refreshDomain(conn, ops
|
|
1129
|
+
async function refreshDomain(conn, ops) {
|
|
942
1130
|
const impact = {
|
|
943
1131
|
notes: [],
|
|
944
|
-
segments: [],
|
|
945
1132
|
blocks: [],
|
|
1133
|
+
pinnedNotes: [],
|
|
1134
|
+
pinnedSessions: [],
|
|
946
1135
|
notesClear: false,
|
|
947
|
-
segmentsClear: false,
|
|
948
1136
|
blocksClear: false
|
|
949
1137
|
};
|
|
950
1138
|
const results = [];
|
|
1139
|
+
for (const op of ops) if (op === null || typeof op !== "object" || Array.isArray(op)) throw new StorageError(400, "tx op must be an object");
|
|
1140
|
+
const checks = ops.filter((op) => op.op === "check");
|
|
1141
|
+
const checkStores = [...new Set(checks.map((op) => op.store))];
|
|
1142
|
+
for (const store of checkStores) {
|
|
1143
|
+
const scoped = checks.filter((op) => op.store === store);
|
|
1144
|
+
const actual = await getRows(conn, storeDefinition(store), scoped.map((op) => op.key));
|
|
1145
|
+
scoped.forEach((op, i) => {
|
|
1146
|
+
if (!rowsEqual(actual[i] ?? null, op.value)) throw new StorageError(409, "transaction read changed before commit", "transaction_conflict");
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
let pending;
|
|
1150
|
+
const flushPuts = async () => {
|
|
1151
|
+
if (!pending) return;
|
|
1152
|
+
const batch = pending;
|
|
1153
|
+
pending = void 0;
|
|
1154
|
+
if (batch.values.length === 0) return;
|
|
1155
|
+
const changed = [];
|
|
1156
|
+
const keys = await putRows(conn, batch.def, batch.values, changed);
|
|
1157
|
+
const slot = TRACKED[batch.def.name];
|
|
1158
|
+
if (slot) impact[slot].push(...changed);
|
|
1159
|
+
const changedSet = new Set(changed.map((key) => JSON.stringify(key)));
|
|
1160
|
+
if (batch.def.name === "notes") impact.pinnedNotes.push(...batch.values.filter((value) => changedSet.has(JSON.stringify(keyFromValue(batch.def, value)))));
|
|
1161
|
+
if (batch.def.name === "sessions") impact.pinnedSessions.push(...batch.values.filter((value) => changedSet.has(JSON.stringify(keyFromValue(batch.def, value)))));
|
|
1162
|
+
results.push(...keys);
|
|
1163
|
+
};
|
|
951
1164
|
for (const op of ops) {
|
|
952
|
-
if (op ===
|
|
953
|
-
|
|
1165
|
+
if (op.op === "check") {
|
|
1166
|
+
results.push(null);
|
|
1167
|
+
continue;
|
|
954
1168
|
}
|
|
955
1169
|
const store = "store" in op ? op.store : void 0;
|
|
956
1170
|
const def = storeDefinition(typeof store === "string" ? store : String(store));
|
|
957
1171
|
const kind = typeof op.op === "string" ? op.op : void 0;
|
|
958
1172
|
if (op.op === "put") {
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
if (slot) impact[slot].push(key);
|
|
962
|
-
results.push(key);
|
|
1173
|
+
if (pending && pending.def.name !== def.name) await flushPuts();
|
|
1174
|
+
(pending ??= { def, values: [] }).values.push(op.value);
|
|
963
1175
|
} else if (op.op === "delete") {
|
|
1176
|
+
await flushPuts();
|
|
964
1177
|
const slot = TRACKED[def.name];
|
|
965
1178
|
if (slot) {
|
|
966
1179
|
for (const key of await deleteTargetKeys(conn, def, op.key)) impact[slot].push(key);
|
|
@@ -968,28 +1181,32 @@ async function refreshDomain(conn, ops, opts = {}) {
|
|
|
968
1181
|
await deleteRows(conn, def, op.key);
|
|
969
1182
|
results.push(null);
|
|
970
1183
|
} else if (op.op === "clear") {
|
|
1184
|
+
await flushPuts();
|
|
971
1185
|
await clearRows(conn, def);
|
|
972
1186
|
if (def.name === "notes") impact.notesClear = true;
|
|
973
|
-
else if (def.name === "segments") impact.segmentsClear = true;
|
|
974
1187
|
else if (def.name === "block_index") impact.blocksClear = true;
|
|
975
1188
|
results.push(null);
|
|
976
1189
|
} else {
|
|
977
|
-
throw new StorageError(400, `tx op must be put | delete | clear, got: ${String(kind)}`);
|
|
1190
|
+
throw new StorageError(400, `tx op must be check | put | delete | clear, got: ${String(kind)}`);
|
|
978
1191
|
}
|
|
979
1192
|
}
|
|
980
|
-
|
|
1193
|
+
await flushPuts();
|
|
1194
|
+
const finalNotes = (await getRows(conn, storeDefinition("notes"), [...new Set(impact.pinnedNotes.map((note) => note.id))])).filter(Boolean);
|
|
1195
|
+
const finalSessions = (await getRows(conn, storeDefinition("sessions"), [...new Set(impact.pinnedSessions.map((session) => session.id))])).filter(Boolean);
|
|
1196
|
+
await validateStoredPins(finalNotes, finalSessions, (keys) => getRows(conn, storeDefinition("block_revisions"), keys));
|
|
1197
|
+
await refresh(conn, impact);
|
|
981
1198
|
return results;
|
|
982
1199
|
}
|
|
983
1200
|
async function refresh(conn, impact) {
|
|
984
1201
|
if (impact.notesClear) {
|
|
985
1202
|
await conn.exec(`DELETE FROM "domain_note"`);
|
|
986
|
-
await conn.exec(`
|
|
1203
|
+
await conn.exec(`DELETE FROM "query_block" WHERE "source_note_id" NOT IN (SELECT "k0" FROM "notes")`);
|
|
1204
|
+
await conn.exec(`DELETE FROM "block_index" WHERE "i_by_note_0" NOT IN (SELECT "k0" FROM "notes")`);
|
|
1205
|
+
await conn.exec(`DELETE FROM "block_efforts" WHERE "i_by_note_0" NOT IN (SELECT "k0" FROM "notes")`);
|
|
987
1206
|
}
|
|
988
|
-
if (impact.segmentsClear) await conn.exec(`DELETE FROM "segment_head"`);
|
|
989
1207
|
if (impact.blocksClear) await conn.exec(`DELETE FROM "query_block"`);
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
for (const id of impact.blocks) await refreshBlock(conn, id);
|
|
1208
|
+
await refreshNotes(conn, impact.notes);
|
|
1209
|
+
await refreshBlocks(conn, impact.blocks);
|
|
993
1210
|
}
|
|
994
1211
|
async function rebuildProjections(conn) {
|
|
995
1212
|
await conn.exec(
|
|
@@ -997,18 +1214,12 @@ async function rebuildProjections(conn) {
|
|
|
997
1214
|
);
|
|
998
1215
|
for (const t of TABLES) await conn.exec(`DELETE FROM "${t}"`);
|
|
999
1216
|
const notes = await conn.query(`SELECT "data" FROM "notes"`);
|
|
1000
|
-
|
|
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);
|
|
1217
|
+
const noteObjs = notes.map((row) => decodeRowJson(String(row.data)));
|
|
1218
|
+
await upsertDomainNotes(conn, noteObjs);
|
|
1010
1219
|
const blocks = await conn.query(`SELECT "data" FROM "block_index"`);
|
|
1011
|
-
|
|
1220
|
+
const blockObjs = blocks.map((row) => decodeRowJson(String(row.data)));
|
|
1221
|
+
const index = indexNoteIdentities(noteObjs);
|
|
1222
|
+
await upsertQueryBlocks(conn, blockObjs.map((b) => [b, resolveParentFrom(index, b)]));
|
|
1012
1223
|
await conn.exec(
|
|
1013
1224
|
`INSERT INTO "${SCHEMA_TABLE}" ("key", "value") VALUES (?, ?) ON CONFLICT ("key") DO UPDATE SET "value" = excluded."value"`,
|
|
1014
1225
|
[PROJECTION_GUARD_KEY, JSON.stringify({ version: DOMAIN_PROJECTION_VERSION })]
|
|
@@ -1245,13 +1456,8 @@ function predicateSql(ctx, p, plan) {
|
|
|
1245
1456
|
p.negate === true,
|
|
1246
1457
|
blocks ? `(q."content_id" IS NOT NULL AND EXISTS (SELECT 1 FROM "block_efforts" be WHERE be."i_by_block_0" = q."content_id" AND be."i_by_effort_0" IN (${p.values.map((v) => ctx.bind(v)).join(", ")})))` : `EXISTS (SELECT 1 FROM "block_efforts" be WHERE be."i_by_note_0" = ${noteId} AND be."i_by_effort_0" IN (${p.values.map((v) => ctx.bind(v)).join(", ")}))`
|
|
1247
1458
|
);
|
|
1248
|
-
case "text":
|
|
1249
|
-
|
|
1250
|
-
const title = ctx.contains(textCol, p.value);
|
|
1251
|
-
if (plan !== "entries") return title;
|
|
1252
|
-
const body = ctx.contains('h."search_text"', p.value);
|
|
1253
|
-
return `(${title} OR EXISTS (SELECT 1 FROM "segment_head" h JOIN "segments" s ON s."k0" = h."id" AND s."k1" = h."current_version" WHERE h."note_id" = ${noteId} AND COALESCE(s."i_by_history_0", 0) = 0 AND ${body}))`;
|
|
1254
|
-
}
|
|
1459
|
+
case "text":
|
|
1460
|
+
return blocks ? ctx.contains('q."normalized_text"', p.value) : `(${ctx.contains('n."title_search"', p.value)} OR ${ctx.contains('n."text_search"', p.value)})`;
|
|
1255
1461
|
case "date": {
|
|
1256
1462
|
const expr = blocks ? 'q."effective_date"' : 'COALESCE(n."date", n."created_at")';
|
|
1257
1463
|
const lo = ctx.bind(p.start);
|
|
@@ -1295,7 +1501,7 @@ async function gate(conn) {
|
|
|
1295
1501
|
if (stale > 0) {
|
|
1296
1502
|
throw new StorageError(
|
|
1297
1503
|
409,
|
|
1298
|
-
`domain projections stale: ${stale} block rows have unresolved
|
|
1504
|
+
`domain projections stale: ${stale} block rows have invalid or unresolved current parents`,
|
|
1299
1505
|
"domain_projection_unavailable"
|
|
1300
1506
|
);
|
|
1301
1507
|
}
|
|
@@ -1314,47 +1520,43 @@ async function execute(conn, q, pg) {
|
|
|
1314
1520
|
const fullWhere = selWhere && filterWhere ? `${selWhere} AND ${filterWhere.replace(/^ WHERE /, "")}` : selWhere || filterWhere;
|
|
1315
1521
|
const count = async (where, params) => Number((await conn.query(`SELECT COUNT(*) AS "n" FROM ${table}${where}`, params))[0]?.n ?? 0);
|
|
1316
1522
|
const selectedCount = await count(selWhere, selCtx.params);
|
|
1317
|
-
const matchedCount = await count(fullWhere, allParams);
|
|
1523
|
+
const matchedCount = q.filters.length === 0 ? selectedCount : await count(fullWhere, allParams);
|
|
1318
1524
|
const ordering = orderSql(q.order, plan);
|
|
1319
1525
|
const paging = pagingSql(q, pg);
|
|
1320
1526
|
if (plan !== "entries") {
|
|
1321
1527
|
const pageSql2 = `SELECT "payload" FROM ${table}${fullWhere} ORDER BY ${ordering}${paging}`;
|
|
1322
1528
|
const rows2 = (await conn.query(pageSql2, allParams)).map((r) => decodeRowJson(String(r.payload)));
|
|
1323
|
-
const
|
|
1324
|
-
return
|
|
1529
|
+
const result = { projectionVersion: DOMAIN_PROJECTION_VERSION, selectedCount, matchedCount, plan, rows: rows2 };
|
|
1530
|
+
return result;
|
|
1325
1531
|
}
|
|
1326
1532
|
const pageSql = `SELECT "note_id", "payload" FROM ${table}${fullWhere} ORDER BY ${ordering}${paging}`;
|
|
1327
1533
|
const noteRows = await conn.query(pageSql, allParams);
|
|
1328
|
-
const
|
|
1329
|
-
`SELECT h."note_id" AS "note_id", s."data" AS "payload" FROM selected n
|
|
1330
|
-
JOIN "segment_head" h ON h."note_id" = n."note_id"
|
|
1331
|
-
JOIN "segments" s ON s."k0" = h."id" AND s."k1" = h."current_version"
|
|
1332
|
-
WHERE COALESCE(s."i_by_history_0", 0) = 0
|
|
1333
|
-
ORDER BY h."position" ASC NULLS LAST, s."k0"`,
|
|
1334
|
-
`SELECT nt."i_by_note_0" AS "note_id", t."data" AS "payload" FROM selected n
|
|
1335
|
-
JOIN "note_tags" nt ON nt."i_by_note_0" = n."note_id"
|
|
1336
|
-
JOIN "tags" t ON t."k0" = nt."i_by_tag_0" ORDER BY t."k0"`,
|
|
1337
|
-
`SELECT p."i_by_note_0" AS "note_id", p."data" AS "payload" FROM selected n
|
|
1338
|
-
JOIN "page_notes" p ON p."i_by_note_0" = n."note_id" ORDER BY p."k0"`,
|
|
1339
|
-
`SELECT DISTINCT p."i_by_note_0" AS "note_id", pg."data" AS "payload" FROM selected n
|
|
1340
|
-
JOIN "page_notes" p ON p."i_by_note_0" = n."note_id"
|
|
1341
|
-
JOIN "page" pg ON pg."k0" = p."i_by_page_0" ORDER BY pg."data"`
|
|
1342
|
-
];
|
|
1343
|
-
const buckets = noteRows.map(() => [[], [], [], []]);
|
|
1534
|
+
const buckets = noteRows.map(() => [[], [], []]);
|
|
1344
1535
|
const index = /* @__PURE__ */ new Map();
|
|
1345
|
-
noteRows.forEach((
|
|
1346
|
-
for (
|
|
1347
|
-
|
|
1536
|
+
noteRows.forEach((row, i) => index.set(String(row.note_id), i));
|
|
1537
|
+
for (let start = 0; start < noteRows.length; start += 500) {
|
|
1538
|
+
const ids = noteRows.slice(start, start + 500).map((row) => String(row.note_id));
|
|
1539
|
+
const placeholders = ids.map(() => "?").join(", ");
|
|
1540
|
+
const statements = [
|
|
1541
|
+
{ sql: `SELECT nt."i_by_note_0" AS "note_id", t."data" AS "payload" FROM "note_tags" nt JOIN "tags" t ON t."k0" = nt."i_by_tag_0" WHERE nt."i_by_note_0" IN (${placeholders}) ORDER BY t."k0"`, params: ids },
|
|
1542
|
+
{ sql: `SELECT p."i_by_note_0" AS "note_id", p."data" AS "payload" FROM "page_notes" p WHERE p."i_by_note_0" IN (${placeholders}) ORDER BY p."k0"`, params: ids },
|
|
1543
|
+
{ sql: `SELECT DISTINCT p."i_by_note_0" AS "note_id", pg."data" AS "payload" FROM "page_notes" p JOIN "page" pg ON pg."k0" = p."i_by_page_0" WHERE p."i_by_note_0" IN (${placeholders}) ORDER BY pg."data"`, params: ids }
|
|
1544
|
+
];
|
|
1545
|
+
const relations = conn.batch ? await conn.batch(statements) : await (async () => {
|
|
1546
|
+
const rows2 = [];
|
|
1547
|
+
for (const statement of statements) rows2.push(await conn.query(statement.sql, statement.params));
|
|
1548
|
+
return rows2;
|
|
1549
|
+
})();
|
|
1550
|
+
relations.forEach((rows2, relation) => rows2.forEach((row) => {
|
|
1348
1551
|
const i = index.get(String(row.note_id));
|
|
1349
|
-
if (i !== void 0) buckets[i][
|
|
1350
|
-
}
|
|
1552
|
+
if (i !== void 0) buckets[i][relation].push(decodeRowJson(String(row.payload)));
|
|
1553
|
+
}));
|
|
1351
1554
|
}
|
|
1352
|
-
const rows = noteRows.map((
|
|
1353
|
-
const [
|
|
1354
|
-
return { note: decodeRowJson(String(
|
|
1555
|
+
const rows = noteRows.map((row, i) => {
|
|
1556
|
+
const [tags, links, pages] = buckets[i];
|
|
1557
|
+
return { note: decodeRowJson(String(row.payload)), tags, links, pages };
|
|
1355
1558
|
});
|
|
1356
|
-
|
|
1357
|
-
return result;
|
|
1559
|
+
return { projectionVersion: DOMAIN_PROJECTION_VERSION, selectedCount, matchedCount, plan, rows };
|
|
1358
1560
|
}
|
|
1359
1561
|
async function handleDomainQuery(db, body) {
|
|
1360
1562
|
const query = parseQuery(body);
|
|
@@ -1373,7 +1575,6 @@ var USER_OWNED_STORES = [
|
|
|
1373
1575
|
"tags",
|
|
1374
1576
|
"tag_types",
|
|
1375
1577
|
"note_tags",
|
|
1376
|
-
"segments",
|
|
1377
1578
|
"sessions",
|
|
1378
1579
|
"attachments",
|
|
1379
1580
|
"events",
|
|
@@ -1524,18 +1725,14 @@ var SqlStorage = class {
|
|
|
1524
1725
|
}
|
|
1525
1726
|
async transaction(stores, mode, fn) {
|
|
1526
1727
|
await this.ensureReady();
|
|
1527
|
-
return this.runTx(stores, mode === "readwrite", fn
|
|
1728
|
+
return this.runTx(stores, mode === "readwrite", fn);
|
|
1528
1729
|
}
|
|
1529
|
-
/**
|
|
1530
|
-
* Bulk load for migration destinations: like transaction(), but the
|
|
1531
|
-
* buffered writes skip per-key projection refresh and derived state is
|
|
1532
|
-
* rebuilt once — everything inside one SQL transaction (throw → rollback).
|
|
1533
|
-
*/
|
|
1730
|
+
/** Bulk writes use the same affected-key refresh as ordinary transactions. */
|
|
1534
1731
|
async bulkTransaction(stores, fn) {
|
|
1535
1732
|
await this.ensureReady();
|
|
1536
|
-
return this.runTx(stores, true, fn
|
|
1733
|
+
return this.runTx(stores, true, fn);
|
|
1537
1734
|
}
|
|
1538
|
-
runTx(stores, mutableMode, fn
|
|
1735
|
+
runTx(stores, mutableMode, fn) {
|
|
1539
1736
|
const scoped = new Set(stores);
|
|
1540
1737
|
const buffer = /* @__PURE__ */ new Map();
|
|
1541
1738
|
const cleared = /* @__PURE__ */ new Set();
|
|
@@ -1561,8 +1758,7 @@ var SqlStorage = class {
|
|
|
1561
1758
|
const result = await fn(tx);
|
|
1562
1759
|
const ops = bufferToOps(STORE_DEFS.map((d) => d.name), buffer, cleared);
|
|
1563
1760
|
if (ops.length > 0) {
|
|
1564
|
-
await refreshDomain(conn, ops
|
|
1565
|
-
if (deferProjection) await rebuildProjections(conn);
|
|
1761
|
+
await refreshDomain(conn, ops);
|
|
1566
1762
|
}
|
|
1567
1763
|
return result;
|
|
1568
1764
|
});
|
|
@@ -1637,6 +1833,20 @@ var SqlStore = class {
|
|
|
1637
1833
|
if (this.cleared?.has(this.store)) return void 0;
|
|
1638
1834
|
return getRow(this.conn, this.def, k);
|
|
1639
1835
|
}
|
|
1836
|
+
async getMany(keys) {
|
|
1837
|
+
await this.owner.ensureReady();
|
|
1838
|
+
const normalized = keys.map((key) => this.pointKey(key));
|
|
1839
|
+
const committed = this.cleared?.has(this.store) ? keys.map(() => void 0) : await getRows(this.conn, this.def, normalized);
|
|
1840
|
+
return normalized.map((key, i) => {
|
|
1841
|
+
const state = bufferedGet(this.store, key, this.buffer);
|
|
1842
|
+
return state ? state.hit ? state.value : void 0 : committed[i];
|
|
1843
|
+
});
|
|
1844
|
+
}
|
|
1845
|
+
async getAllFromIndexMany(indexName, keys) {
|
|
1846
|
+
await this.owner.ensureReady();
|
|
1847
|
+
const rows = await listRowsFromIndexMany(this.conn, this.def, indexName, keys.map((key) => this.pointKey(key)));
|
|
1848
|
+
return rows.map((values, i) => overlayRows(this.def, this.store, this.buffer, this.cleared, values, void 0, { only: this.pointKey(keys[i]) }, indexName));
|
|
1849
|
+
}
|
|
1640
1850
|
async getAll(query, count) {
|
|
1641
1851
|
await this.owner.ensureReady();
|
|
1642
1852
|
const range = rangeFromIDB(query);
|
|
@@ -1668,6 +1878,10 @@ var SqlStore = class {
|
|
|
1668
1878
|
this.assertMutable();
|
|
1669
1879
|
const stored = this.owner.stamp(this.store, value);
|
|
1670
1880
|
const key = keyFromValue(this.def, stored);
|
|
1881
|
+
if (this.store === "block_revisions") {
|
|
1882
|
+
const previous = this.buffer?.get(bufferSlot(this.store, key));
|
|
1883
|
+
if (previous?.kind === "put" && !rowsEqual(previous.value, stored)) throw new StorageError(409, "immutable block revision collision", "revision_conflict");
|
|
1884
|
+
}
|
|
1671
1885
|
if (this.buffer) {
|
|
1672
1886
|
this.buffer.set(bufferSlot(this.store, key), { kind: "put", value: stored });
|
|
1673
1887
|
return key;
|
|
@@ -1675,9 +1889,28 @@ var SqlStore = class {
|
|
|
1675
1889
|
await this.owner.applyOps([{ op: "put", store: this.store, value: stored }]);
|
|
1676
1890
|
return key;
|
|
1677
1891
|
}
|
|
1892
|
+
async putMany(values, _keys) {
|
|
1893
|
+
await this.owner.ensureReady();
|
|
1894
|
+
this.assertMutable();
|
|
1895
|
+
const stored = values.map((value) => this.owner.stamp(this.store, value));
|
|
1896
|
+
const keys = stored.map((value) => keyFromValue(this.def, value));
|
|
1897
|
+
if (this.store === "block_revisions") {
|
|
1898
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1899
|
+
keys.forEach((key, i) => {
|
|
1900
|
+
const slot = bufferSlot(this.store, key), previous = this.buffer?.get(slot);
|
|
1901
|
+
const value = seen.has(slot) ? seen.get(slot) : previous?.kind === "put" ? previous.value : void 0;
|
|
1902
|
+
if (value !== void 0 && !rowsEqual(value, stored[i])) throw new StorageError(409, "immutable block revision collision", "revision_conflict");
|
|
1903
|
+
seen.set(slot, stored[i]);
|
|
1904
|
+
});
|
|
1905
|
+
}
|
|
1906
|
+
if (this.buffer) keys.forEach((key, i) => this.buffer.set(bufferSlot(this.store, key), { kind: "put", value: stored[i] }));
|
|
1907
|
+
else await this.owner.applyOps(stored.map((value) => ({ op: "put", store: this.store, value })));
|
|
1908
|
+
return keys;
|
|
1909
|
+
}
|
|
1678
1910
|
async delete(key) {
|
|
1679
1911
|
await this.owner.ensureReady();
|
|
1680
1912
|
this.assertMutable();
|
|
1913
|
+
if (this.store === "block_revisions") throw new StorageError(409, "block history requires explicit wipe", "revision_conflict");
|
|
1681
1914
|
const range = isRange(key) ? key : void 0;
|
|
1682
1915
|
const target = range ?? key;
|
|
1683
1916
|
if (this.buffer) {
|
|
@@ -1697,6 +1930,7 @@ var SqlStore = class {
|
|
|
1697
1930
|
async clear() {
|
|
1698
1931
|
await this.owner.ensureReady();
|
|
1699
1932
|
this.assertMutable();
|
|
1933
|
+
if (this.store === "block_revisions") throw new StorageError(409, "block history requires explicit wipe", "revision_conflict");
|
|
1700
1934
|
if (this.buffer) {
|
|
1701
1935
|
this.cleared?.add(this.store);
|
|
1702
1936
|
const prefix = `${this.store}\0`;
|
|
@@ -1752,25 +1986,323 @@ var BrowserTursoStorage = class extends SqlStorage {
|
|
|
1752
1986
|
}
|
|
1753
1987
|
};
|
|
1754
1988
|
|
|
1989
|
+
// src/sql/sync.ts
|
|
1990
|
+
var SYNC_OPS_TABLE = "_wodwiki_sync_ops";
|
|
1991
|
+
var SYNC_STATE_TABLE = "_wodwiki_sync_state";
|
|
1992
|
+
var SYNC_ARCHIVE_TABLE = "_wodwiki_sync_ops_archive";
|
|
1993
|
+
var BINARY_FIELD2 = { attachments: "data" };
|
|
1994
|
+
var STORE_NAMES = Object.fromEntries(STORE_DEFS.map((d) => [d.name, true]));
|
|
1995
|
+
var STATE_KEY = "meta";
|
|
1996
|
+
function isSafeInt(value) {
|
|
1997
|
+
return typeof value === "number" && Number.isSafeInteger(value);
|
|
1998
|
+
}
|
|
1999
|
+
function isScalar(value) {
|
|
2000
|
+
return typeof value === "string" || typeof value === "number" && Number.isFinite(value);
|
|
2001
|
+
}
|
|
2002
|
+
function assertKey(key, what) {
|
|
2003
|
+
if (isScalar(key)) return;
|
|
2004
|
+
if (Array.isArray(key) && key.length > 0 && key.every(isScalar)) return;
|
|
2005
|
+
throw new StorageError(400, `${what} must be a string, finite number, or non-empty scalar tuple`, "bad_sync_key");
|
|
2006
|
+
}
|
|
2007
|
+
function validateMutation(raw) {
|
|
2008
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
2009
|
+
throw new StorageError(400, "sync mutation must be an object", "bad_sync_mutation");
|
|
2010
|
+
}
|
|
2011
|
+
const mutation = raw;
|
|
2012
|
+
if (mutation.op === "wipe") return { op: "wipe" };
|
|
2013
|
+
if (typeof mutation.store !== "string" || !STORE_NAMES[mutation.store]) {
|
|
2014
|
+
throw new StorageError(400, `sync mutation has unknown store ${String(mutation.store)}`, "bad_sync_mutation");
|
|
2015
|
+
}
|
|
2016
|
+
const store = mutation.store;
|
|
2017
|
+
if (mutation.op === "clear") return { op: "clear", store };
|
|
2018
|
+
if (mutation.op === "delete") {
|
|
2019
|
+
assertKey(mutation.key, "sync delete key");
|
|
2020
|
+
return { op: "delete", store, key: mutation.key };
|
|
2021
|
+
}
|
|
2022
|
+
if (mutation.op === "put") {
|
|
2023
|
+
assertKey(mutation.key, "sync put key");
|
|
2024
|
+
if (mutation.value === null || typeof mutation.value !== "object" || Array.isArray(mutation.value)) {
|
|
2025
|
+
throw new StorageError(400, "sync put value must be an object row", "bad_sync_mutation");
|
|
2026
|
+
}
|
|
2027
|
+
return { op: "put", store, key: mutation.key, value: mutation.value };
|
|
2028
|
+
}
|
|
2029
|
+
throw new StorageError(400, `sync mutation has unknown op ${String(mutation.op)}`, "bad_sync_mutation");
|
|
2030
|
+
}
|
|
2031
|
+
function validateEntry(entry) {
|
|
2032
|
+
if (typeof entry.id !== "string" || entry.id.length === 0 || entry.id.length > 200) {
|
|
2033
|
+
throw new StorageError(400, "sync entry id must be a non-empty string", "bad_sync_entry");
|
|
2034
|
+
}
|
|
2035
|
+
if (!isSafeInt(entry.at) || entry.at < 0) {
|
|
2036
|
+
throw new StorageError(400, "sync entry at must be a non-negative safe integer", "bad_sync_entry");
|
|
2037
|
+
}
|
|
2038
|
+
if (!Array.isArray(entry.ops) || entry.ops.length === 0) {
|
|
2039
|
+
throw new StorageError(400, "sync entry ops must be a non-empty array", "bad_sync_entry");
|
|
2040
|
+
}
|
|
2041
|
+
for (const op of entry.ops) validateMutation(op);
|
|
2042
|
+
}
|
|
2043
|
+
async function runStatements(tx, statements) {
|
|
2044
|
+
if (statements.length === 0) return [];
|
|
2045
|
+
if (tx.batch) return tx.batch(statements);
|
|
2046
|
+
const results = [];
|
|
2047
|
+
for (const statement of statements) {
|
|
2048
|
+
results.push(await tx.query(statement.sql, statement.params ?? []));
|
|
2049
|
+
}
|
|
2050
|
+
return results;
|
|
2051
|
+
}
|
|
2052
|
+
async function tableExists(conn, dialect, name) {
|
|
2053
|
+
const sql = dialect === "postgres" ? `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1` : `SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`;
|
|
2054
|
+
return (await conn.query(sql, [name])).length > 0;
|
|
2055
|
+
}
|
|
2056
|
+
async function readSyncState(tx) {
|
|
2057
|
+
const rows = await tx.query(`SELECT "value" FROM "${SYNC_STATE_TABLE}" WHERE "key" = ?`, [STATE_KEY]);
|
|
2058
|
+
if (!rows[0]) throw new StorageError(409, "sync target is not initialized", "sync_not_initialized");
|
|
2059
|
+
let parsed;
|
|
2060
|
+
try {
|
|
2061
|
+
parsed = JSON.parse(String(rows[0].value));
|
|
2062
|
+
} catch {
|
|
2063
|
+
throw new StorageError(400, "malformed sync state row", "bad_sync_state");
|
|
2064
|
+
}
|
|
2065
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
2066
|
+
throw new StorageError(400, "malformed sync state row", "bad_sync_state");
|
|
2067
|
+
}
|
|
2068
|
+
const state = parsed;
|
|
2069
|
+
if (!isSafeInt(state.schema) || state.schema < 0 || !isSafeInt(state.rev) || state.rev < 0) {
|
|
2070
|
+
throw new StorageError(400, "sync state schema/rev must be non-negative safe integers", "bad_sync_state");
|
|
2071
|
+
}
|
|
2072
|
+
return { schema: state.schema, rev: state.rev };
|
|
2073
|
+
}
|
|
2074
|
+
async function probeSyncTarget(db) {
|
|
2075
|
+
if (await tableExists(db, db.dialect, SYNC_STATE_TABLE)) {
|
|
2076
|
+
const state = await readSyncState(db);
|
|
2077
|
+
return { kind: "current", schemaVersion: state.schema, rev: state.rev };
|
|
2078
|
+
}
|
|
2079
|
+
if (await tableExists(db, db.dialect, SCHEMA_TABLE)) {
|
|
2080
|
+
const rows = await db.query(`SELECT "value" FROM "${SCHEMA_TABLE}" WHERE "key" = 'schema'`);
|
|
2081
|
+
if (rows[0]) {
|
|
2082
|
+
const guard = JSON.parse(String(rows[0].value));
|
|
2083
|
+
if (typeof guard.version === "number" && Number.isSafeInteger(guard.version) && guard.version >= 0) {
|
|
2084
|
+
return { kind: "legacy", schemaVersion: guard.version };
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
return { kind: "legacy", schemaVersion: 0 };
|
|
2088
|
+
}
|
|
2089
|
+
const others = await db.query(
|
|
2090
|
+
db.dialect === "postgres" ? `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'` : `SELECT name FROM sqlite_master WHERE type = 'table'`
|
|
2091
|
+
);
|
|
2092
|
+
const internal = (name) => name.startsWith("_wodwiki_") || name.startsWith("sqlite_");
|
|
2093
|
+
return others.some((row) => !internal(String(Object.values(row)[0]))) ? { kind: "unknown" } : "empty";
|
|
2094
|
+
}
|
|
2095
|
+
async function initSyncTarget(db, schemaVersion) {
|
|
2096
|
+
await db.transaction(async (tx) => {
|
|
2097
|
+
await tx.exec(`CREATE TABLE IF NOT EXISTS "${SYNC_STATE_TABLE}" ("key" TEXT PRIMARY KEY, "value" TEXT NOT NULL)`);
|
|
2098
|
+
await tx.exec(
|
|
2099
|
+
`CREATE TABLE IF NOT EXISTS "${SYNC_OPS_TABLE}" ("op_id" TEXT PRIMARY KEY, "rev" INTEGER NOT NULL, "idx" INTEGER NOT NULL, "at" INTEGER NOT NULL, "data" TEXT NOT NULL)`
|
|
2100
|
+
);
|
|
2101
|
+
await tx.exec(
|
|
2102
|
+
`CREATE INDEX IF NOT EXISTS "${SYNC_OPS_TABLE}_rev" ON "${SYNC_OPS_TABLE}" ("rev", "idx")`
|
|
2103
|
+
);
|
|
2104
|
+
await tx.exec(
|
|
2105
|
+
`INSERT INTO "${SYNC_STATE_TABLE}" ("key", "value") VALUES (?, ?) ON CONFLICT ("key") DO NOTHING`,
|
|
2106
|
+
[STATE_KEY, JSON.stringify({ schema: schemaVersion, rev: 0 })]
|
|
2107
|
+
);
|
|
2108
|
+
});
|
|
2109
|
+
}
|
|
2110
|
+
function encodeMutation(mutation) {
|
|
2111
|
+
if (mutation.op !== "put") return JSON.stringify(mutation);
|
|
2112
|
+
const encoded = encodeRowJson(mutation.value, BINARY_FIELD2[mutation.store]);
|
|
2113
|
+
return `{"key":${JSON.stringify(mutation.key)},"op":"put","store":${JSON.stringify(mutation.store)},"value":${encoded}}`;
|
|
2114
|
+
}
|
|
2115
|
+
function decodeMutation(data) {
|
|
2116
|
+
if (typeof data !== "string") {
|
|
2117
|
+
throw new StorageError(400, "sync op data must be a string", "bad_sync_mutation");
|
|
2118
|
+
}
|
|
2119
|
+
let parsed;
|
|
2120
|
+
try {
|
|
2121
|
+
parsed = JSON.parse(data);
|
|
2122
|
+
} catch {
|
|
2123
|
+
throw new StorageError(400, "sync op data is not valid JSON", "bad_sync_mutation");
|
|
2124
|
+
}
|
|
2125
|
+
const mutation = validateMutation(parsed);
|
|
2126
|
+
if (mutation.op !== "put") return mutation;
|
|
2127
|
+
return {
|
|
2128
|
+
op: "put",
|
|
2129
|
+
store: mutation.store,
|
|
2130
|
+
key: mutation.key,
|
|
2131
|
+
value: decodeRowJson(JSON.stringify(mutation.value), BINARY_FIELD2[mutation.store])
|
|
2132
|
+
};
|
|
2133
|
+
}
|
|
2134
|
+
async function applySyncBatch(db, entries, opts = {}) {
|
|
2135
|
+
if (entries.length === 0) return 0;
|
|
2136
|
+
for (const entry of entries) validateEntry(entry);
|
|
2137
|
+
return db.transaction(async (tx) => {
|
|
2138
|
+
const state = await readSyncState(tx);
|
|
2139
|
+
if (opts.expectSchema !== void 0 && state.schema !== opts.expectSchema) {
|
|
2140
|
+
throw new StorageError(
|
|
2141
|
+
409,
|
|
2142
|
+
`sync target schema changed from v${opts.expectSchema} to v${state.schema} between probe and write \u2014 retry the cycle`,
|
|
2143
|
+
"sync_schema_conflict"
|
|
2144
|
+
);
|
|
2145
|
+
}
|
|
2146
|
+
const firstIds = entries.map((entry) => `${entry.id}:0`);
|
|
2147
|
+
const present = /* @__PURE__ */ new Set();
|
|
2148
|
+
for (let i = 0; i < firstIds.length; i += 100) {
|
|
2149
|
+
const chunk = firstIds.slice(i, i + 100);
|
|
2150
|
+
const rows = await tx.query(
|
|
2151
|
+
`SELECT "op_id" FROM "${SYNC_OPS_TABLE}" WHERE "op_id" IN (${chunk.map(() => "?").join(", ")})`,
|
|
2152
|
+
chunk
|
|
2153
|
+
);
|
|
2154
|
+
for (const row of rows) present.add(String(row.op_id));
|
|
2155
|
+
}
|
|
2156
|
+
const statements = [];
|
|
2157
|
+
let rev = state.rev;
|
|
2158
|
+
let committed = 0;
|
|
2159
|
+
for (const entry of entries) {
|
|
2160
|
+
if (present.has(`${entry.id}:0`)) continue;
|
|
2161
|
+
rev += 1;
|
|
2162
|
+
committed += 1;
|
|
2163
|
+
entry.ops.forEach((mutation, idx) => {
|
|
2164
|
+
statements.push({
|
|
2165
|
+
sql: `INSERT INTO "${SYNC_OPS_TABLE}" ("op_id", "rev", "idx", "at", "data") VALUES (?, ?, ?, ?, ?) ON CONFLICT ("op_id") DO NOTHING`,
|
|
2166
|
+
params: [`${entry.id}:${idx}`, rev, idx, entry.at, encodeMutation(mutation)]
|
|
2167
|
+
});
|
|
2168
|
+
});
|
|
2169
|
+
}
|
|
2170
|
+
const nextSchema = opts.toSchemaVersion ?? state.schema;
|
|
2171
|
+
statements.push({
|
|
2172
|
+
sql: `UPDATE "${SYNC_STATE_TABLE}" SET "value" = ? WHERE "key" = ?`,
|
|
2173
|
+
params: [JSON.stringify({ schema: nextSchema, rev }), STATE_KEY]
|
|
2174
|
+
});
|
|
2175
|
+
await runStatements(tx, statements);
|
|
2176
|
+
return committed;
|
|
2177
|
+
});
|
|
2178
|
+
}
|
|
2179
|
+
async function fetchSyncOps(db, afterRev, limit) {
|
|
2180
|
+
if (!isSafeInt(afterRev) || afterRev < 0) {
|
|
2181
|
+
throw new StorageError(400, "fetchSyncOps cursor must be a non-negative safe integer", "bad_sync_cursor");
|
|
2182
|
+
}
|
|
2183
|
+
const bounded = Math.min(Math.max(1, Math.floor(limit)), 4294967295);
|
|
2184
|
+
const revRows = await db.query(
|
|
2185
|
+
`SELECT DISTINCT "rev" FROM (SELECT "rev" FROM "${SYNC_OPS_TABLE}" WHERE "rev" > ? ORDER BY "rev" ASC LIMIT ?)`,
|
|
2186
|
+
[afterRev, bounded]
|
|
2187
|
+
);
|
|
2188
|
+
const revs = revRows.map((row) => {
|
|
2189
|
+
const rev = Number(row.rev);
|
|
2190
|
+
if (!Number.isSafeInteger(rev) || rev < 0) {
|
|
2191
|
+
throw new StorageError(400, "sync op rev must be a non-negative safe integer", "bad_sync_row");
|
|
2192
|
+
}
|
|
2193
|
+
return rev;
|
|
2194
|
+
});
|
|
2195
|
+
if (revs.length === 0) return [];
|
|
2196
|
+
const rows = await db.query(
|
|
2197
|
+
`SELECT "op_id", "rev", "idx", "at", "data" FROM "${SYNC_OPS_TABLE}" WHERE "rev" IN (${revs.map(() => "?").join(", ")}) ORDER BY "rev", "idx"`,
|
|
2198
|
+
revs
|
|
2199
|
+
);
|
|
2200
|
+
return rows.map((row, at) => {
|
|
2201
|
+
const opId = row.op_id;
|
|
2202
|
+
const rev = Number(row.rev);
|
|
2203
|
+
const idx = Number(row.idx);
|
|
2204
|
+
const atMs = Number(row.at);
|
|
2205
|
+
if (typeof opId !== "string" || opId.length === 0 || !Number.isSafeInteger(rev) || rev < 0 || !Number.isSafeInteger(idx) || idx < 0 || !Number.isSafeInteger(atMs) || atMs < 0) {
|
|
2206
|
+
throw new StorageError(400, `sync op row ${at} has malformed shape`, "bad_sync_row");
|
|
2207
|
+
}
|
|
2208
|
+
return { opId, rev, idx, at: atMs, mutation: decodeMutation(row.data) };
|
|
2209
|
+
});
|
|
2210
|
+
}
|
|
2211
|
+
async function replaceSyncLog(db, entries, toSchemaVersion) {
|
|
2212
|
+
if (entries.length === 0) throw new StorageError(400, "replacement log must contain the converted snapshot", "bad_sync_entry");
|
|
2213
|
+
for (const entry of entries) validateEntry(entry);
|
|
2214
|
+
await db.transaction(async (tx) => {
|
|
2215
|
+
await readSyncState(tx);
|
|
2216
|
+
await tx.exec(
|
|
2217
|
+
`CREATE TABLE IF NOT EXISTS "${SYNC_ARCHIVE_TABLE}" ("op_id" TEXT PRIMARY KEY, "rev" INTEGER NOT NULL, "idx" INTEGER NOT NULL, "at" INTEGER NOT NULL, "data" TEXT NOT NULL)`
|
|
2218
|
+
);
|
|
2219
|
+
await tx.exec(`INSERT INTO "${SYNC_ARCHIVE_TABLE}" ("op_id", "rev", "idx", "at", "data") SELECT "op_id", "rev", "idx", "at", "data" FROM "${SYNC_OPS_TABLE}"`);
|
|
2220
|
+
await tx.exec(`DELETE FROM "${SYNC_OPS_TABLE}"`);
|
|
2221
|
+
const statements = [];
|
|
2222
|
+
let rev = 0;
|
|
2223
|
+
for (const entry of entries) {
|
|
2224
|
+
rev += 1;
|
|
2225
|
+
entry.ops.forEach((mutation, idx) => {
|
|
2226
|
+
statements.push({
|
|
2227
|
+
sql: `INSERT INTO "${SYNC_OPS_TABLE}" ("op_id", "rev", "idx", "at", "data") VALUES (?, ?, ?, ?, ?) ON CONFLICT ("op_id") DO NOTHING`,
|
|
2228
|
+
params: [`${entry.id}:${idx}`, rev, idx, entry.at, encodeMutation(mutation)]
|
|
2229
|
+
});
|
|
2230
|
+
});
|
|
2231
|
+
}
|
|
2232
|
+
statements.push({
|
|
2233
|
+
sql: `UPDATE "${SYNC_STATE_TABLE}" SET "value" = ? WHERE "key" = ?`,
|
|
2234
|
+
params: [JSON.stringify({ schema: toSchemaVersion, rev }), STATE_KEY]
|
|
2235
|
+
});
|
|
2236
|
+
await runStatements(tx, statements);
|
|
2237
|
+
});
|
|
2238
|
+
}
|
|
2239
|
+
async function readRawSyncOps(db) {
|
|
2240
|
+
const rows = await db.query(`SELECT "rev", "idx", "data" FROM "${SYNC_OPS_TABLE}" ORDER BY "rev", "idx"`);
|
|
2241
|
+
return rows.map((row) => {
|
|
2242
|
+
const rev = Number(row.rev);
|
|
2243
|
+
const idx = Number(row.idx);
|
|
2244
|
+
if (!Number.isSafeInteger(rev) || rev < 0 || !Number.isSafeInteger(idx) || idx < 0 || typeof row.data !== "string") {
|
|
2245
|
+
throw new StorageError(400, "sync op row has malformed shape", "bad_sync_row");
|
|
2246
|
+
}
|
|
2247
|
+
let parsed;
|
|
2248
|
+
try {
|
|
2249
|
+
parsed = JSON.parse(row.data);
|
|
2250
|
+
} catch {
|
|
2251
|
+
throw new StorageError(400, "sync op data is not valid JSON", "bad_sync_row");
|
|
2252
|
+
}
|
|
2253
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
2254
|
+
throw new StorageError(400, "sync op data must be an object", "bad_sync_row");
|
|
2255
|
+
}
|
|
2256
|
+
const mutation = parsed;
|
|
2257
|
+
if (typeof mutation.op !== "string" || typeof mutation.store !== "string") {
|
|
2258
|
+
throw new StorageError(400, "sync op must carry string op/store", "bad_sync_row");
|
|
2259
|
+
}
|
|
2260
|
+
return {
|
|
2261
|
+
op: mutation.op,
|
|
2262
|
+
store: mutation.store,
|
|
2263
|
+
key: mutation.key,
|
|
2264
|
+
value: mutation.value === void 0 || mutation.value === null ? void 0 : decodeRowJson(JSON.stringify(mutation.value), BINARY_FIELD2[mutation.store])
|
|
2265
|
+
};
|
|
2266
|
+
});
|
|
2267
|
+
}
|
|
2268
|
+
async function readLegacyRows(db, table) {
|
|
2269
|
+
if (!await tableExists(db, db.dialect, table)) return [];
|
|
2270
|
+
const rows = await db.query(`SELECT "data" FROM "${table}"`);
|
|
2271
|
+
return rows.map((row) => decodeRowJson(String(row.data), BINARY_FIELD2[table]));
|
|
2272
|
+
}
|
|
2273
|
+
|
|
1755
2274
|
exports.BrowserTursoStorage = BrowserTursoStorage;
|
|
1756
2275
|
exports.MAX_BODY = MAX_BODY;
|
|
1757
2276
|
exports.SCHEMA_TABLE = SCHEMA_TABLE;
|
|
2277
|
+
exports.SYNC_ARCHIVE_TABLE = SYNC_ARCHIVE_TABLE;
|
|
2278
|
+
exports.SYNC_OPS_TABLE = SYNC_OPS_TABLE;
|
|
2279
|
+
exports.SYNC_STATE_TABLE = SYNC_STATE_TABLE;
|
|
1758
2280
|
exports.SqlStorage = SqlStorage;
|
|
1759
2281
|
exports.StorageError = StorageError;
|
|
2282
|
+
exports.applySyncBatch = applySyncBatch;
|
|
1760
2283
|
exports.clearRows = clearRows;
|
|
1761
2284
|
exports.countRows = countRows;
|
|
1762
2285
|
exports.decodeRowJson = decodeRowJson;
|
|
1763
2286
|
exports.deleteRows = deleteRows;
|
|
1764
2287
|
exports.encodeRowJson = encodeRowJson;
|
|
2288
|
+
exports.fetchSyncOps = fetchSyncOps;
|
|
1765
2289
|
exports.getRow = getRow;
|
|
2290
|
+
exports.getRows = getRows;
|
|
1766
2291
|
exports.handleDomainQuery = handleDomainQuery;
|
|
2292
|
+
exports.initSyncTarget = initSyncTarget;
|
|
1767
2293
|
exports.initializeDomain = initializeDomain;
|
|
1768
2294
|
exports.initializeStore = initializeStore;
|
|
1769
2295
|
exports.libsqlDatabase = libsqlDatabase;
|
|
1770
2296
|
exports.listRows = listRows;
|
|
2297
|
+
exports.listRowsFromIndexMany = listRowsFromIndexMany;
|
|
2298
|
+
exports.probeSyncTarget = probeSyncTarget;
|
|
1771
2299
|
exports.putRow = putRow;
|
|
2300
|
+
exports.putRows = putRows;
|
|
2301
|
+
exports.readLegacyRows = readLegacyRows;
|
|
2302
|
+
exports.readRawSyncOps = readRawSyncOps;
|
|
1772
2303
|
exports.rebuildProjections = rebuildProjections;
|
|
1773
2304
|
exports.refreshDomain = refreshDomain;
|
|
2305
|
+
exports.replaceSyncLog = replaceSyncLog;
|
|
1774
2306
|
exports.storeDefinition = storeDefinition;
|
|
1775
2307
|
exports.validateTursoUrl = validateTursoUrl;
|
|
1776
2308
|
exports.wipeDomain = wipeDomain;
|