@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.mjs
CHANGED
|
@@ -1,19 +1,8 @@
|
|
|
1
|
-
import { STORE_DEFS, extractKeyPart, isRange, DB_VERSION, DOMAIN_PROJECTION_VERSION, bufferToOps, stampUserOwned, bufferedGet, rangeFromIDB, isDirty,
|
|
1
|
+
import { STORE_DEFS, StorageError, extractKeyPart, validateStorageRow, isRange, DB_VERSION, rowsEqual, validateStoredPins, DOMAIN_PROJECTION_VERSION, bufferToOps, stampUserOwned, bufferedGet, overlayRows, rangeFromIDB, isDirty, keyFromValue, bufferSlot, COLUMN_TYPES, keyInRange, compareKeys } from '../chunk-7ZCBM5NS.mjs';
|
|
2
|
+
export { StorageError } from '../chunk-7ZCBM5NS.mjs';
|
|
2
3
|
import { catalogOfItem, sourceMatches } from '@bitcobblers/wod-wiki-wql';
|
|
3
4
|
import { createClient } from '@libsql/client/web';
|
|
4
5
|
|
|
5
|
-
// src/sql/errors.ts
|
|
6
|
-
var StorageError = class extends Error {
|
|
7
|
-
constructor(status, message, code) {
|
|
8
|
-
super(message);
|
|
9
|
-
this.status = status;
|
|
10
|
-
this.code = code;
|
|
11
|
-
this.name = "StorageError";
|
|
12
|
-
}
|
|
13
|
-
status;
|
|
14
|
-
code;
|
|
15
|
-
};
|
|
16
|
-
|
|
17
6
|
// src/sql/connection.ts
|
|
18
7
|
function libsqlRows(result) {
|
|
19
8
|
return result.rows;
|
|
@@ -39,7 +28,8 @@ function libsqlDatabase(client) {
|
|
|
39
28
|
query: (sql, params = []) => tx.execute({ sql, args: params }).then(libsqlRows),
|
|
40
29
|
exec: async (sql, params = []) => {
|
|
41
30
|
await tx.execute({ sql, args: params });
|
|
42
|
-
}
|
|
31
|
+
},
|
|
32
|
+
batch: async (statements) => (await tx.batch(statements.map(({ sql, params = [] }) => ({ sql, args: params })))).map(libsqlRows)
|
|
43
33
|
};
|
|
44
34
|
const result = await fn(txConn);
|
|
45
35
|
await tx.commit();
|
|
@@ -49,6 +39,10 @@ function libsqlDatabase(client) {
|
|
|
49
39
|
await tx.rollback();
|
|
50
40
|
} catch {
|
|
51
41
|
}
|
|
42
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
43
|
+
for (const code of ["revision_conflict", "transaction_conflict", "invalid_session_pin"]) {
|
|
44
|
+
if (message.includes(code)) throw new StorageError(409, message, code);
|
|
45
|
+
}
|
|
52
46
|
throw err;
|
|
53
47
|
} finally {
|
|
54
48
|
tx.close();
|
|
@@ -91,16 +85,20 @@ function reviveBinary(value) {
|
|
|
91
85
|
}
|
|
92
86
|
return bytes.buffer;
|
|
93
87
|
}
|
|
88
|
+
var canonicalJson = (_key, value) => {
|
|
89
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
|
|
90
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, Reflect.get(value, key)]));
|
|
91
|
+
};
|
|
94
92
|
function encodeRowJson(value, binaryField) {
|
|
95
93
|
if (binaryField && value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
96
94
|
const row = value;
|
|
97
95
|
const data = row[binaryField];
|
|
98
96
|
if (data instanceof ArrayBuffer) {
|
|
99
97
|
const bytes = new Uint8Array(data);
|
|
100
|
-
return JSON.stringify({ ...row, [binaryField]: { [TAG]: bytesToBase64(bytes), [LEN]: bytes.length } });
|
|
98
|
+
return JSON.stringify({ ...row, [binaryField]: { [TAG]: bytesToBase64(bytes), [LEN]: bytes.length } }, canonicalJson);
|
|
101
99
|
}
|
|
102
100
|
}
|
|
103
|
-
return JSON.stringify(value);
|
|
101
|
+
return JSON.stringify(value, canonicalJson);
|
|
104
102
|
}
|
|
105
103
|
function decodeRowJson(text2, binaryField) {
|
|
106
104
|
const row = JSON.parse(text2);
|
|
@@ -277,6 +275,50 @@ async function getRow(conn, def, key) {
|
|
|
277
275
|
);
|
|
278
276
|
return rows[0] ? decodeRowJson(rows[0].data, binaryFieldOf(def)) : void 0;
|
|
279
277
|
}
|
|
278
|
+
async function getRows(conn, def, keys) {
|
|
279
|
+
const found = /* @__PURE__ */ new Map();
|
|
280
|
+
for (const part of chunks(keys, 50)) {
|
|
281
|
+
const params = [];
|
|
282
|
+
const conditions = part.map((key) => {
|
|
283
|
+
const parts = toParts(key);
|
|
284
|
+
if (parts.length !== def.keyPath.length) throw new StorageError(400, "invalid compound key arity");
|
|
285
|
+
params.push(...parts.map(toSqlScalar));
|
|
286
|
+
return `(${keyCols(def).map((col) => `"${col}" = ?`).join(" AND ")})`;
|
|
287
|
+
});
|
|
288
|
+
for (const row of await conn.query(`SELECT "data" FROM "${def.name}" WHERE ${conditions.join(" OR ")}`, params)) {
|
|
289
|
+
const value = decodeRowJson(String(row.data), binaryFieldOf(def));
|
|
290
|
+
found.set(JSON.stringify(requireKey(def, value)), value);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return keys.map((key) => found.get(JSON.stringify(key)));
|
|
294
|
+
}
|
|
295
|
+
async function listRowsFromIndexMany(conn, def, indexName, keys) {
|
|
296
|
+
const idx = indexDefinition(def, indexName);
|
|
297
|
+
if (idx.multiEntry) {
|
|
298
|
+
const out = [];
|
|
299
|
+
for (const key of keys) out.push(await listRows(conn, def, { only: key }, void 0, indexName));
|
|
300
|
+
return out;
|
|
301
|
+
}
|
|
302
|
+
const found = /* @__PURE__ */ new Map();
|
|
303
|
+
for (const part of chunks(keys, 50)) {
|
|
304
|
+
const params = [];
|
|
305
|
+
const conditions = part.map((key) => {
|
|
306
|
+
const parts = toParts(key);
|
|
307
|
+
if (parts.length !== idx.keyPath.length) throw new StorageError(400, "invalid index key arity");
|
|
308
|
+
params.push(...parts.map(toSqlScalar));
|
|
309
|
+
return `(${parts.map((_, i) => `"${indexCol(idx.name, i)}" = ?`).join(" AND ")})`;
|
|
310
|
+
});
|
|
311
|
+
for (const row of await conn.query(`SELECT "data" FROM "${def.name}" WHERE ${conditions.join(" OR ")} ORDER BY ${keyCols(def).map((col) => `"${col}"`).join(", ")}`, params)) {
|
|
312
|
+
const value = decodeRowJson(String(row.data), binaryFieldOf(def));
|
|
313
|
+
const parts = idx.keyPath.map((path) => extractKeyPart(value, path));
|
|
314
|
+
const key = JSON.stringify(parts.length === 1 ? parts[0] : parts);
|
|
315
|
+
const bucket = found.get(key);
|
|
316
|
+
if (bucket) bucket.push(value);
|
|
317
|
+
else found.set(key, [value]);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return keys.map((key) => found.get(JSON.stringify(key)) ?? []);
|
|
321
|
+
}
|
|
280
322
|
async function listRows(conn, def, range, count, indexName) {
|
|
281
323
|
validateRange(range);
|
|
282
324
|
const idx = indexName ? indexDefinition(def, indexName) : void 0;
|
|
@@ -334,51 +376,114 @@ async function countRows(conn, def, range, indexName) {
|
|
|
334
376
|
const rows = await conn.query(`SELECT COUNT(*) AS "n" FROM "${def.name}"${cond}`, params);
|
|
335
377
|
return Number(rows[0]?.n ?? 0);
|
|
336
378
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
379
|
+
var PARAM_BUDGET = 2e4;
|
|
380
|
+
var ROW_CAP = 100;
|
|
381
|
+
var chunks = (arr, size) => {
|
|
382
|
+
const out = [];
|
|
383
|
+
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
|
|
384
|
+
return out;
|
|
385
|
+
};
|
|
386
|
+
async function batchInsert(conn, table, cols, rows, onConflict) {
|
|
387
|
+
if (rows.length === 0) return;
|
|
388
|
+
const collist = cols.map((c) => `"${c}"`).join(", ");
|
|
389
|
+
const tuple = `(${cols.map(() => "?").join(", ")})`;
|
|
390
|
+
const per = Math.max(1, Math.min(ROW_CAP, Math.floor(PARAM_BUDGET / cols.length)));
|
|
391
|
+
const statements = chunks(rows, per).map((part) => ({
|
|
392
|
+
sql: `INSERT INTO "${table}" (${collist}) VALUES ${part.map(() => tuple).join(", ")} ${onConflict}`,
|
|
393
|
+
params: part.flat()
|
|
394
|
+
}));
|
|
395
|
+
if (conn.batch) await conn.batch(statements);
|
|
396
|
+
else for (const statement of statements) await conn.exec(statement.sql, statement.params);
|
|
397
|
+
}
|
|
398
|
+
async function deleteIn(conn, table, col, vals) {
|
|
399
|
+
for (const part of chunks(vals, 500)) {
|
|
400
|
+
if (part.length === 0) continue;
|
|
401
|
+
await conn.exec(`DELETE FROM "${table}" WHERE "${col}" IN (${part.map(() => "?").join(", ")})`, part);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
async function putRows(conn, def, values, changedKeys) {
|
|
405
|
+
if (values.length === 0) return [];
|
|
406
|
+
for (const value of values) validateStorageRow(def.name, value);
|
|
340
407
|
const keys = keyCols(def);
|
|
341
408
|
const cols = [...keys];
|
|
342
|
-
const vals = [...keyParts];
|
|
343
409
|
for (const idx of def.indexes) {
|
|
344
410
|
if (idx.multiEntry) continue;
|
|
345
|
-
idx.keyPath.forEach((
|
|
346
|
-
cols.push(indexCol(idx.name, part));
|
|
347
|
-
vals.push(toSqlScalar(extractKeyPart(value, path) ?? null));
|
|
348
|
-
});
|
|
411
|
+
idx.keyPath.forEach((_, part) => cols.push(indexCol(idx.name, part)));
|
|
349
412
|
}
|
|
350
413
|
cols.push("data");
|
|
351
|
-
vals.push(encodeRowJson(value, binaryFieldOf(def)));
|
|
352
414
|
const keySet = new Set(keys);
|
|
353
415
|
const updates = cols.filter((c) => !keySet.has(c)).map((c) => `"${c}" = excluded."${c}"`).join(", ");
|
|
354
|
-
const collist = cols.map((c) => `"${c}"`).join(", ");
|
|
355
|
-
const placeholders = cols.map(() => "?").join(", ");
|
|
356
416
|
const pk = keys.map((k) => `"${k}"`).join(", ");
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
);
|
|
417
|
+
const rows = [];
|
|
418
|
+
const out = [];
|
|
419
|
+
const junction = [];
|
|
361
420
|
const multi = multiEntryIndex(def);
|
|
362
|
-
|
|
363
|
-
const
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
421
|
+
for (const value of values) {
|
|
422
|
+
const k = requireKey(def, value);
|
|
423
|
+
const keyParts = toParts(k).map(toSqlScalar);
|
|
424
|
+
const vals = [...keyParts];
|
|
425
|
+
for (const idx of def.indexes) {
|
|
426
|
+
if (idx.multiEntry) continue;
|
|
427
|
+
idx.keyPath.forEach((path) => vals.push(toSqlScalar(extractKeyPart(value, path) ?? null)));
|
|
428
|
+
}
|
|
429
|
+
vals.push(encodeRowJson(value, binaryFieldOf(def)));
|
|
430
|
+
rows.push(vals);
|
|
431
|
+
out.push(k);
|
|
432
|
+
if (multi) {
|
|
433
|
+
const items = arrayPart(value, multi.keyPath[0]);
|
|
434
|
+
if (Array.isArray(items)) {
|
|
435
|
+
for (const item of items) junction.push([...keyParts, JSON.stringify(item)]);
|
|
376
436
|
}
|
|
377
437
|
}
|
|
378
438
|
}
|
|
379
|
-
|
|
439
|
+
const tuple = `(${cols.map(() => "?").join(", ")})`;
|
|
440
|
+
const per = Math.max(1, Math.min(ROW_CAP, Math.floor(PARAM_BUDGET / cols.length)));
|
|
441
|
+
const statements = chunks(rows, per).map((part) => ({
|
|
442
|
+
sql: `INSERT INTO "${def.name}" (${cols.map((col) => `"${col}"`).join(", ")}) VALUES ${part.map(() => tuple).join(", ")} ` + (def.name === "block_revisions" ? `ON CONFLICT (${pk}) DO NOTHING` : `ON CONFLICT (${pk}) DO UPDATE SET ${updates} WHERE "${def.name}"."data" <> excluded."data"`) + ` RETURNING ${keys.map((col) => `"${col}"`).join(", ")}`,
|
|
443
|
+
params: part.flat()
|
|
444
|
+
}));
|
|
445
|
+
let returned;
|
|
446
|
+
try {
|
|
447
|
+
if (conn.batch) returned = await conn.batch(statements);
|
|
448
|
+
else {
|
|
449
|
+
returned = [];
|
|
450
|
+
for (const statement of statements) returned.push(await conn.query(statement.sql, statement.params));
|
|
451
|
+
}
|
|
452
|
+
} catch (error) {
|
|
453
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
454
|
+
for (const code of ["revision_conflict", "transaction_conflict", "invalid_session_pin"]) {
|
|
455
|
+
if (message.includes(code)) throw new StorageError(409, message, code);
|
|
456
|
+
}
|
|
457
|
+
throw error;
|
|
458
|
+
}
|
|
459
|
+
const changed = returned.flat().map((row) => {
|
|
460
|
+
const parts = keys.map((col) => row[col]);
|
|
461
|
+
return parts.length === 1 ? parts[0] : parts;
|
|
462
|
+
});
|
|
463
|
+
changedKeys?.push(...changed);
|
|
464
|
+
if (multi) {
|
|
465
|
+
const jt = junctionTable(def, multi);
|
|
466
|
+
const changedSet = new Set(changed.map((key) => JSON.stringify(key)));
|
|
467
|
+
const deletes = values.flatMap((value) => {
|
|
468
|
+
const key = requireKey(def, value);
|
|
469
|
+
if (!changedSet.has(JSON.stringify(key))) return [];
|
|
470
|
+
const items = arrayPart(value, multi.keyPath[0]) ?? [];
|
|
471
|
+
return [{
|
|
472
|
+
sql: `DELETE FROM "${jt}" WHERE ${keys.map((col) => `"${col}" = ?`).join(" AND ")}` + (items.length ? ` AND "v" NOT IN (${items.map(() => "?").join(", ")})` : ""),
|
|
473
|
+
params: [...toParts(key).map(toSqlScalar), ...items.map((item) => JSON.stringify(item))]
|
|
474
|
+
}];
|
|
475
|
+
});
|
|
476
|
+
if (conn.batch && deletes.length) await conn.batch(deletes);
|
|
477
|
+
else for (const statement of deletes) await conn.exec(statement.sql, statement.params);
|
|
478
|
+
await batchInsert(conn, jt, [...keys, "v"], junction.filter((row) => changedSet.has(JSON.stringify(keys.length === 1 ? row[0] : row.slice(0, keys.length)))), "ON CONFLICT DO NOTHING");
|
|
479
|
+
}
|
|
480
|
+
return out;
|
|
481
|
+
}
|
|
482
|
+
async function putRow(conn, def, value) {
|
|
483
|
+
return (await putRows(conn, def, [value]))[0];
|
|
380
484
|
}
|
|
381
485
|
async function deleteRows(conn, def, keyOrRange) {
|
|
486
|
+
if (def.name === "block_revisions") throw new StorageError(409, "block history may only be removed by explicit wipe", "revision_conflict");
|
|
382
487
|
const multi = multiEntryIndex(def);
|
|
383
488
|
if (!isRange(keyOrRange)) {
|
|
384
489
|
const parts = toParts(keyOrRange);
|
|
@@ -411,14 +516,15 @@ async function deleteRows(conn, def, keyOrRange) {
|
|
|
411
516
|
}
|
|
412
517
|
await conn.exec(`DELETE FROM "${def.name}"${where}`, params);
|
|
413
518
|
}
|
|
414
|
-
async function clearRows(conn, def) {
|
|
519
|
+
async function clearRows(conn, def, wipe = false) {
|
|
520
|
+
if (def.name === "block_revisions" && !wipe) throw new StorageError(409, "block history may only be removed by explicit wipe", "revision_conflict");
|
|
415
521
|
const multi = multiEntryIndex(def);
|
|
416
522
|
if (multi) await conn.exec(`DELETE FROM "${junctionTable(def, multi)}"`);
|
|
417
523
|
await conn.exec(`DELETE FROM "${def.name}"`);
|
|
418
524
|
}
|
|
419
525
|
async function wipeRows(conn) {
|
|
420
526
|
for (const def of STORE_DEFS) {
|
|
421
|
-
if (!def.system) await clearRows(conn, def);
|
|
527
|
+
if (!def.system) await clearRows(conn, def, true);
|
|
422
528
|
}
|
|
423
529
|
}
|
|
424
530
|
function tableListSql(dialect) {
|
|
@@ -454,31 +560,75 @@ async function ensureStoreTables(conn, dialect, def) {
|
|
|
454
560
|
);
|
|
455
561
|
await conn.exec(`CREATE INDEX IF NOT EXISTS "${jt}__v" ON "${jt}" ("v")`);
|
|
456
562
|
}
|
|
563
|
+
if (def.name === "notes" && dialect === "sqlite") {
|
|
564
|
+
await conn.exec(`CREATE TRIGGER IF NOT EXISTS "notes_revision_guard" BEFORE UPDATE ON "notes"
|
|
565
|
+
WHEN json_extract(NEW."data", '$.sourceRevision') < json_extract(OLD."data", '$.sourceRevision')
|
|
566
|
+
OR ((json_extract(NEW."data", '$.rawContent') IS NOT json_extract(OLD."data", '$.rawContent')
|
|
567
|
+
OR json_extract(NEW."data", '$.blocks') IS NOT json_extract(OLD."data", '$.blocks'))
|
|
568
|
+
AND json_extract(NEW."data", '$.sourceRevision') <= json_extract(OLD."data", '$.sourceRevision'))
|
|
569
|
+
BEGIN SELECT RAISE(ABORT, 'transaction_conflict: stale Note source revision'); END`);
|
|
570
|
+
}
|
|
571
|
+
if (def.name === "block_revisions" && dialect === "sqlite") {
|
|
572
|
+
await conn.exec(`CREATE TRIGGER IF NOT EXISTS "block_revision_guard" BEFORE INSERT ON "block_revisions"
|
|
573
|
+
WHEN EXISTS (SELECT 1 FROM "block_revisions" old WHERE old."k0" = NEW."k0" AND old."k1" = NEW."k1" AND old."k2" = NEW."k2" AND old."data" <> NEW."data")
|
|
574
|
+
BEGIN SELECT RAISE(ABORT, 'revision_conflict: immutable block revision collision'); END`);
|
|
575
|
+
}
|
|
576
|
+
if (def.name === "sessions" && dialect === "sqlite") {
|
|
577
|
+
await conn.exec(`CREATE TRIGGER IF NOT EXISTS "session_pin_guard" BEFORE UPDATE ON "sessions"
|
|
578
|
+
WHEN json_extract(NEW."data", '$.noteId') IS NOT json_extract(OLD."data", '$.noteId')
|
|
579
|
+
OR json_extract(NEW."data", '$.blockId') IS NOT json_extract(OLD."data", '$.blockId')
|
|
580
|
+
OR json_extract(NEW."data", '$.blockVersion') IS NOT json_extract(OLD."data", '$.blockVersion')
|
|
581
|
+
BEGIN SELECT RAISE(ABORT, 'invalid_session_pin: Session pin is immutable'); END`);
|
|
582
|
+
}
|
|
583
|
+
if (dialect === "postgres" && ["notes", "block_revisions", "sessions"].includes(def.name)) {
|
|
584
|
+
const condition = def.name === "block_revisions" ? `EXISTS (SELECT 1 FROM "block_revisions" old WHERE old."k0" = NEW."k0" AND old."k1" = NEW."k1" AND old."k2" = NEW."k2" AND old."data" <> NEW."data")` : def.name === "notes" ? `(NEW."data"::jsonb->>'sourceRevision')::bigint < (OLD."data"::jsonb->>'sourceRevision')::bigint OR ((NEW."data"::jsonb->'rawContent' IS DISTINCT FROM OLD."data"::jsonb->'rawContent' OR NEW."data"::jsonb->'blocks' IS DISTINCT FROM OLD."data"::jsonb->'blocks') AND (NEW."data"::jsonb->>'sourceRevision')::bigint <= (OLD."data"::jsonb->>'sourceRevision')::bigint)` : `NEW."data"::jsonb->'noteId' IS DISTINCT FROM OLD."data"::jsonb->'noteId' OR NEW."data"::jsonb->'blockId' IS DISTINCT FROM OLD."data"::jsonb->'blockId' OR NEW."data"::jsonb->'blockVersion' IS DISTINCT FROM OLD."data"::jsonb->'blockVersion'`;
|
|
585
|
+
const code = def.name === "block_revisions" ? "revision_conflict" : def.name === "notes" ? "transaction_conflict" : "invalid_session_pin";
|
|
586
|
+
const guard = `${def.name}_guard`;
|
|
587
|
+
await conn.exec(`CREATE OR REPLACE FUNCTION "${guard}"() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF ${condition} THEN RAISE EXCEPTION '${code}'; END IF; RETURN NEW; END; $$`);
|
|
588
|
+
await conn.exec(`DROP TRIGGER IF EXISTS "${guard}" ON "${def.name}"`);
|
|
589
|
+
await conn.exec(`CREATE TRIGGER "${guard}" BEFORE ${def.name === "block_revisions" ? "INSERT" : "UPDATE"} ON "${def.name}" FOR EACH ROW EXECUTE FUNCTION "${guard}"()`);
|
|
590
|
+
}
|
|
457
591
|
}
|
|
458
592
|
async function initializeStore(db) {
|
|
459
593
|
await db.transaction(async (tx) => {
|
|
460
594
|
await tx.exec(
|
|
461
595
|
`CREATE TABLE IF NOT EXISTS "${SCHEMA_TABLE}" ("key" TEXT PRIMARY KEY, "value" TEXT NOT NULL)`
|
|
462
596
|
);
|
|
597
|
+
const ignored = (name) => name.startsWith("_wodwiki_") || name.startsWith("sqlite_");
|
|
463
598
|
const storeTables = new Set(STORE_DEFS.map((d) => d.name));
|
|
599
|
+
for (const def of STORE_DEFS) {
|
|
600
|
+
const multi = def.indexes.find((i) => i.multiEntry);
|
|
601
|
+
if (multi) storeTables.add(junctionTable(def, multi));
|
|
602
|
+
}
|
|
464
603
|
const tableNames = new Set(
|
|
465
|
-
(await tx.query(tableListSql(db.dialect))).map((r) => String(Object.values(r)[0]))
|
|
604
|
+
(await tx.query(tableListSql(db.dialect))).map((r) => String(Object.values(r)[0])).filter((n) => !ignored(n))
|
|
466
605
|
);
|
|
467
606
|
const guardRows = await tx.query(`SELECT "value" FROM "${SCHEMA_TABLE}" WHERE "key" = ?`, [SCHEMA_KEY]);
|
|
468
607
|
let stored;
|
|
469
608
|
if (guardRows[0]) {
|
|
470
609
|
const guard = JSON.parse(String(guardRows[0].value));
|
|
471
|
-
if (typeof guard.version !== "number") {
|
|
610
|
+
if (typeof guard.version !== "number" || !Number.isSafeInteger(guard.version) || guard.version < 0) {
|
|
472
611
|
throw new StorageError(500, `corrupt ${SCHEMA_TABLE} guard row`, "bad_schema_guard");
|
|
473
612
|
}
|
|
474
613
|
stored = guard.version;
|
|
475
|
-
if (stored
|
|
614
|
+
if (stored > DB_VERSION) {
|
|
476
615
|
throw new StorageError(
|
|
477
616
|
500,
|
|
478
|
-
`database schema v${stored}
|
|
617
|
+
`database schema v${stored} is newer than this server supports (v${DB_VERSION}); refusing to downgrade`,
|
|
479
618
|
"schema_incompatible"
|
|
480
619
|
);
|
|
481
620
|
}
|
|
621
|
+
if (stored < DB_VERSION) {
|
|
622
|
+
const missing = [...storeTables].filter((name) => !tableNames.has(name));
|
|
623
|
+
const unexpected = [...tableNames].filter((name) => name !== SCHEMA_TABLE && !storeTables.has(name));
|
|
624
|
+
if (missing.length > 0 || unexpected.length > 0) {
|
|
625
|
+
throw new StorageError(
|
|
626
|
+
500,
|
|
627
|
+
`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`,
|
|
628
|
+
"schema_incompatible"
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
482
632
|
} else if ([...tableNames].some((name) => storeTables.has(name))) {
|
|
483
633
|
throw new StorageError(
|
|
484
634
|
500,
|
|
@@ -495,7 +645,7 @@ async function initializeStore(db) {
|
|
|
495
645
|
}
|
|
496
646
|
});
|
|
497
647
|
}
|
|
498
|
-
var TABLES = ["domain_note", "query_block"
|
|
648
|
+
var TABLES = ["domain_note", "query_block"];
|
|
499
649
|
var PROJECTION_GUARD_KEY = "domain_projection";
|
|
500
650
|
function sourceItem(row) {
|
|
501
651
|
const item = {};
|
|
@@ -514,7 +664,7 @@ function ensureDdl(db) {
|
|
|
514
664
|
const n = db.dialect === "postgres" ? " DOUBLE PRECISION" : "";
|
|
515
665
|
const t = db.dialect === "postgres" ? ' TEXT COLLATE "C"' : " TEXT";
|
|
516
666
|
const ddl = [
|
|
517
|
-
`CREATE TABLE IF NOT EXISTS "domain_note" ("note_id"${t} PRIMARY KEY NOT NULL, "note_type"${t}, "catalog"${t}, "source_id"${t}, "source_path"${t}, "title"${t} NOT NULL, "created_at"${n} NOT NULL, "date"${n}, "text_search"${t} NOT NULL, "journal"${n} NOT NULL, "payload"${t} NOT NULL)`,
|
|
667
|
+
`CREATE TABLE IF NOT EXISTS "domain_note" ("note_id"${t} PRIMARY KEY NOT NULL, "note_type"${t}, "catalog"${t}, "source_id"${t}, "source_path"${t}, "title"${t} NOT NULL, "title_search"${t} NOT NULL, "created_at"${n} NOT NULL, "date"${n}, "text_search"${t} NOT NULL, "journal"${n} NOT NULL, "payload"${t} NOT NULL)`,
|
|
518
668
|
`CREATE INDEX IF NOT EXISTS "idx_domain_note_date" ON "domain_note" ("date", "note_id")`,
|
|
519
669
|
`CREATE INDEX IF NOT EXISTS "idx_domain_note_created" ON "domain_note" ("created_at", "note_id")`,
|
|
520
670
|
`CREATE INDEX IF NOT EXISTS "idx_domain_note_source_path" ON "domain_note" ("source_path")`,
|
|
@@ -523,169 +673,140 @@ function ensureDdl(db) {
|
|
|
523
673
|
`CREATE INDEX IF NOT EXISTS "idx_query_block_parent" ON "query_block" ("parent_note_id", "ordinal")`,
|
|
524
674
|
`CREATE INDEX IF NOT EXISTS "idx_query_block_content" ON "query_block" ("content_id")`,
|
|
525
675
|
`CREATE INDEX IF NOT EXISTS "idx_query_block_source_path" ON "query_block" ("source_path")`,
|
|
526
|
-
`CREATE INDEX IF NOT EXISTS "idx_query_block_source_note" ON "query_block" ("source_note_id")
|
|
527
|
-
`CREATE TABLE IF NOT EXISTS "segment_head" ("id"${t} PRIMARY KEY NOT NULL, "note_id"${t} NOT NULL, "current_version"${n} NOT NULL, "position"${n}, "search_text"${t} NOT NULL)`,
|
|
528
|
-
`CREATE INDEX IF NOT EXISTS "idx_segment_head_note" ON "segment_head" ("note_id", "id")`
|
|
676
|
+
`CREATE INDEX IF NOT EXISTS "idx_query_block_source_note" ON "query_block" ("source_note_id")`
|
|
529
677
|
];
|
|
530
678
|
return db.transaction(async (tx) => {
|
|
531
679
|
for (const sql of ddl) await tx.exec(sql);
|
|
532
680
|
});
|
|
533
681
|
}
|
|
534
|
-
|
|
682
|
+
var DOMAIN_NOTE_COLS = ["note_id", "note_type", "catalog", "source_id", "source_path", "title", "title_search", "created_at", "date", "text_search", "journal", "payload"];
|
|
683
|
+
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"];
|
|
684
|
+
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 ");
|
|
685
|
+
function domainNoteRow(note) {
|
|
535
686
|
const id = text(note.id);
|
|
536
|
-
if (!id) return;
|
|
687
|
+
if (!id) return void 0;
|
|
537
688
|
const source = sourceItem(note);
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
num(note.date),
|
|
553
|
-
(text(note.title) ?? "").toLowerCase(),
|
|
554
|
-
sourceMatches(source, "journal") ? 1 : 0,
|
|
555
|
-
encodeRowJson(note)
|
|
556
|
-
]
|
|
557
|
-
);
|
|
558
|
-
}
|
|
559
|
-
async function healQueryBlocksForNote(conn, note) {
|
|
560
|
-
const id = text(note.id);
|
|
561
|
-
if (!id) return;
|
|
562
|
-
const rows = await conn.query(
|
|
563
|
-
`SELECT "occurrence_id" FROM "query_block"
|
|
564
|
-
WHERE "source_note_id" = ? OR ("source_path" IS NOT NULL AND "source_path" = ?)`,
|
|
565
|
-
[id, T(note.sourcePath)]
|
|
566
|
-
);
|
|
567
|
-
for (const row of rows) await refreshBlock(conn, String(row.occurrence_id));
|
|
568
|
-
}
|
|
569
|
-
async function upsertSegmentHead(conn, id, head) {
|
|
570
|
-
await conn.exec(
|
|
571
|
-
`INSERT INTO "segment_head" ("id","note_id","current_version","position","search_text")
|
|
572
|
-
VALUES (?,?,?,?,?)
|
|
573
|
-
ON CONFLICT ("id") DO UPDATE SET "note_id"=excluded."note_id","current_version"=excluded."current_version",
|
|
574
|
-
"position"=excluded."position","search_text"=excluded."search_text"`,
|
|
575
|
-
[
|
|
576
|
-
id,
|
|
577
|
-
T(head.noteId) ?? "",
|
|
578
|
-
N(head.version),
|
|
579
|
-
// Local latest-segments order is position ?? createdAt — same fallback here.
|
|
580
|
-
num(head.position) ?? num(head.createdAt),
|
|
581
|
-
(text(head.rawContent) ?? "").toLowerCase()
|
|
582
|
-
]
|
|
583
|
-
);
|
|
584
|
-
}
|
|
585
|
-
async function identityBy(conn, col, val) {
|
|
586
|
-
const rows = await conn.query(
|
|
587
|
-
`SELECT "note_id", "date", "created_at" FROM "domain_note" WHERE "${col}" = ?`,
|
|
588
|
-
[val]
|
|
589
|
-
);
|
|
590
|
-
return rows.map((r) => ({
|
|
591
|
-
note_id: String(r.note_id),
|
|
592
|
-
date: num(r.date),
|
|
593
|
-
created_at: num(r.created_at) ?? 0
|
|
594
|
-
}));
|
|
689
|
+
return [
|
|
690
|
+
id,
|
|
691
|
+
T(note.type),
|
|
692
|
+
T(catalogOfItem(source)),
|
|
693
|
+
T(note.sourceId),
|
|
694
|
+
T(note.sourcePath),
|
|
695
|
+
T(note.title) ?? "",
|
|
696
|
+
(text(note.title) ?? "").toLowerCase(),
|
|
697
|
+
N(note.createdAt),
|
|
698
|
+
num(note.date),
|
|
699
|
+
(text(note.rawContent) ?? "").toLowerCase(),
|
|
700
|
+
sourceMatches(source, "journal") ? 1 : 0,
|
|
701
|
+
encodeRowJson(note)
|
|
702
|
+
];
|
|
595
703
|
}
|
|
596
|
-
async function
|
|
597
|
-
const
|
|
598
|
-
|
|
599
|
-
return rows.length === 1 ? rows[0] : void 0;
|
|
600
|
-
};
|
|
601
|
-
const nid = text(block.noteId);
|
|
602
|
-
if (nid) {
|
|
603
|
-
const rows = await identityBy(conn, "note_id", nid);
|
|
604
|
-
if (rows.length === 1) return rows[0];
|
|
605
|
-
if (rows.length > 1) return void 0;
|
|
606
|
-
}
|
|
607
|
-
const sp = text(block.sourcePath);
|
|
608
|
-
return sp ? byPath(sp) : void 0;
|
|
704
|
+
async function upsertDomainNotes(conn, notes) {
|
|
705
|
+
const rows = notes.map(domainNoteRow).filter((r) => r !== void 0);
|
|
706
|
+
await batchInsert(conn, "domain_note", [...DOMAIN_NOTE_COLS], rows, conflictUpdate(DOMAIN_NOTE_COLS, ["note_id"]));
|
|
609
707
|
}
|
|
610
|
-
|
|
708
|
+
function queryBlockRow(block, parent) {
|
|
611
709
|
const id = text(block.id);
|
|
612
|
-
if (!id) return;
|
|
710
|
+
if (!id) return void 0;
|
|
613
711
|
const source = sourceItem(block);
|
|
614
712
|
const createdAt = N(block.createdAt);
|
|
615
|
-
const parent = await resolveParent(conn, block);
|
|
616
713
|
const effective = parent ? parent.date ?? parent.created_at : createdAt;
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
T(block.sourceId),
|
|
636
|
-
T(block.sourcePath),
|
|
637
|
-
T(catalogOfItem(source)),
|
|
638
|
-
effective,
|
|
639
|
-
createdAt,
|
|
640
|
-
T(block.noteTitle),
|
|
641
|
-
(text(block.rawContent) ?? "").toLowerCase(),
|
|
642
|
-
sourceMatches(source, "journal") ? 1 : 0,
|
|
643
|
-
encodeRowJson(block)
|
|
644
|
-
]
|
|
645
|
-
);
|
|
714
|
+
return [
|
|
715
|
+
id,
|
|
716
|
+
T(block.noteId),
|
|
717
|
+
parent?.note_id ?? null,
|
|
718
|
+
T(block.blockContentId),
|
|
719
|
+
T(block.blockId),
|
|
720
|
+
num(block.position),
|
|
721
|
+
T(block.dataType),
|
|
722
|
+
T(block.sourceId),
|
|
723
|
+
T(block.sourcePath),
|
|
724
|
+
T(catalogOfItem(source)),
|
|
725
|
+
effective,
|
|
726
|
+
createdAt,
|
|
727
|
+
parent?.note.title ?? T(block.noteTitle),
|
|
728
|
+
(text(block.rawContent) ?? "").toLowerCase(),
|
|
729
|
+
sourceMatches(source, "journal") ? 1 : 0,
|
|
730
|
+
encodeRowJson(parent ? { ...block, noteTitle: parent.note.title } : block)
|
|
731
|
+
];
|
|
646
732
|
}
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
const rows = await conn.query(`SELECT "data" FROM "notes" WHERE "k0" = ?`, [k]);
|
|
651
|
-
const note = rows[0] ? decodeRowJson(String(rows[0].data)) : void 0;
|
|
652
|
-
if (note) {
|
|
653
|
-
await upsertDomainNote(conn, note);
|
|
654
|
-
await healQueryBlocksForNote(conn, note);
|
|
655
|
-
} else {
|
|
656
|
-
const orphans = await conn.query(
|
|
657
|
-
`SELECT "occurrence_id" FROM "query_block" WHERE "parent_note_id" = ? OR "source_note_id" = ?`,
|
|
658
|
-
[k, k]
|
|
659
|
-
);
|
|
660
|
-
await conn.exec(`DELETE FROM "domain_note" WHERE "note_id" = ?`, [k]);
|
|
661
|
-
for (const row of orphans) await refreshBlock(conn, String(row.occurrence_id));
|
|
662
|
-
}
|
|
733
|
+
async function upsertQueryBlocks(conn, blocks) {
|
|
734
|
+
const rows = blocks.map(([b, p]) => queryBlockRow(b, p)).filter((r) => r !== void 0);
|
|
735
|
+
await batchInsert(conn, "query_block", [...QUERY_BLOCK_COLS], rows, conflictUpdate(QUERY_BLOCK_COLS, ["occurrence_id"]));
|
|
663
736
|
}
|
|
664
|
-
|
|
665
|
-
const
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
for (const row of rows) {
|
|
670
|
-
const seg = decodeRowJson(String(row.data));
|
|
671
|
-
const v = num(seg.version) ?? 0;
|
|
672
|
-
if (!head || v > headVersion) {
|
|
673
|
-
head = seg;
|
|
674
|
-
headVersion = v;
|
|
675
|
-
}
|
|
737
|
+
function indexNoteIdentities(notes) {
|
|
738
|
+
const byNoteId = /* @__PURE__ */ new Map();
|
|
739
|
+
for (const note of notes) {
|
|
740
|
+
const id = text(note.id);
|
|
741
|
+
if (id) byNoteId.set(id, { note_id: id, date: num(note.date), created_at: num(note.createdAt) ?? 0, note });
|
|
676
742
|
}
|
|
677
|
-
|
|
678
|
-
else await conn.exec(`DELETE FROM "segment_head" WHERE "id" = ?`, [k]);
|
|
743
|
+
return { byNoteId };
|
|
679
744
|
}
|
|
680
|
-
|
|
681
|
-
const
|
|
682
|
-
const
|
|
683
|
-
|
|
684
|
-
|
|
745
|
+
function resolveParentFrom(index, block) {
|
|
746
|
+
const parent = typeof block.noteId === "string" ? index.byNoteId.get(block.noteId) : void 0;
|
|
747
|
+
const mapped = typeof block.blockId === "string" ? parent?.note.blocks[block.blockId] : void 0;
|
|
748
|
+
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;
|
|
749
|
+
}
|
|
750
|
+
async function fetchParentIndex(conn, noteIds) {
|
|
751
|
+
const notes = await getRows(conn, storeDefinition("notes"), noteIds);
|
|
752
|
+
return indexNoteIdentities(notes.filter((row) => row !== void 0));
|
|
753
|
+
}
|
|
754
|
+
var keyHead = (key) => Array.isArray(key) ? key[0] : key;
|
|
755
|
+
async function selectIn(conn, selectCol, table, col, vals) {
|
|
756
|
+
const IN_CHUNK = 500;
|
|
757
|
+
const out = [];
|
|
758
|
+
for (let i = 0; i < vals.length; i += IN_CHUNK) {
|
|
759
|
+
const part = vals.slice(i, i + IN_CHUNK);
|
|
760
|
+
const rows = await conn.query(`SELECT "${selectCol}" FROM "${table}" WHERE "${col}" IN (${part.map(() => "?").join(", ")})`, part);
|
|
761
|
+
for (const r of rows) out.push(String(r[selectCol]));
|
|
762
|
+
}
|
|
763
|
+
return out;
|
|
764
|
+
}
|
|
765
|
+
async function healQueryBlocksForNotes(conn, noteIds) {
|
|
766
|
+
const ids = await selectIn(conn, "occurrence_id", "query_block", "source_note_id", noteIds);
|
|
767
|
+
await refreshBlocks(conn, [...new Set(ids)]);
|
|
768
|
+
}
|
|
769
|
+
async function refreshNotes(conn, ids) {
|
|
770
|
+
const ks = [...new Set(ids.map((id) => String(keyHead(id))))];
|
|
771
|
+
if (ks.length === 0) return;
|
|
772
|
+
const found = /* @__PURE__ */ new Map();
|
|
773
|
+
for (let i = 0; i < ks.length; i += 500) {
|
|
774
|
+
const part = ks.slice(i, i + 500);
|
|
775
|
+
const rows = await conn.query(`SELECT "k0", "data" FROM "notes" WHERE "k0" IN (${part.map(() => "?").join(", ")})`, part);
|
|
776
|
+
for (const row of rows) found.set(String(row.k0), decodeRowJson(String(row.data)));
|
|
777
|
+
}
|
|
778
|
+
const present = [];
|
|
779
|
+
const missing = [];
|
|
780
|
+
for (const k of ks) {
|
|
781
|
+
const note = found.get(k);
|
|
782
|
+
if (note) present.push(note);
|
|
783
|
+
else missing.push(k);
|
|
784
|
+
}
|
|
785
|
+
await upsertDomainNotes(conn, present);
|
|
786
|
+
const presentIds = present.map((n) => text(n.id)).filter((x) => !!x);
|
|
787
|
+
await healQueryBlocksForNotes(conn, presentIds);
|
|
788
|
+
await deleteIn(conn, "domain_note", "note_id", missing);
|
|
789
|
+
await deleteIn(conn, "query_block", "source_note_id", missing);
|
|
790
|
+
await deleteIn(conn, "block_index", "i_by_note_0", missing);
|
|
791
|
+
await deleteIn(conn, "block_efforts", "i_by_note_0", missing);
|
|
792
|
+
}
|
|
793
|
+
async function refreshBlocks(conn, ids) {
|
|
794
|
+
const ks = [...new Set(ids.map((id) => String(keyHead(id))))];
|
|
795
|
+
if (ks.length === 0) return;
|
|
796
|
+
const found = /* @__PURE__ */ new Map();
|
|
797
|
+
for (let i = 0; i < ks.length; i += 500) {
|
|
798
|
+
const part = ks.slice(i, i + 500);
|
|
799
|
+
const rows = await conn.query(`SELECT "k0", "data" FROM "block_index" WHERE "k0" IN (${part.map(() => "?").join(", ")})`, part);
|
|
800
|
+
for (const row of rows) found.set(String(row.k0), decodeRowJson(String(row.data)));
|
|
801
|
+
}
|
|
802
|
+
const blocks = [...found.values()];
|
|
803
|
+
const noteIds = [...new Set(blocks.map((b) => text(b.noteId)).filter((x) => !!x))];
|
|
804
|
+
const index = await fetchParentIndex(conn, noteIds);
|
|
805
|
+
await upsertQueryBlocks(conn, blocks.map((b) => [b, resolveParentFrom(index, b)]));
|
|
806
|
+
await deleteIn(conn, "query_block", "occurrence_id", ks.filter((k) => !found.has(k)));
|
|
685
807
|
}
|
|
686
808
|
var TRACKED = {
|
|
687
809
|
notes: "notes",
|
|
688
|
-
segments: "segments",
|
|
689
810
|
block_index: "blocks"
|
|
690
811
|
};
|
|
691
812
|
async function deleteTargetKeys(conn, def, target) {
|
|
@@ -693,29 +814,54 @@ async function deleteTargetKeys(conn, def, target) {
|
|
|
693
814
|
const rows = await listRows(conn, def, target);
|
|
694
815
|
return rows.map((row) => keyFromValue(def, row));
|
|
695
816
|
}
|
|
696
|
-
async function refreshDomain(conn, ops
|
|
817
|
+
async function refreshDomain(conn, ops) {
|
|
697
818
|
const impact = {
|
|
698
819
|
notes: [],
|
|
699
|
-
segments: [],
|
|
700
820
|
blocks: [],
|
|
821
|
+
pinnedNotes: [],
|
|
822
|
+
pinnedSessions: [],
|
|
701
823
|
notesClear: false,
|
|
702
|
-
segmentsClear: false,
|
|
703
824
|
blocksClear: false
|
|
704
825
|
};
|
|
705
826
|
const results = [];
|
|
827
|
+
for (const op of ops) if (op === null || typeof op !== "object" || Array.isArray(op)) throw new StorageError(400, "tx op must be an object");
|
|
828
|
+
const checks = ops.filter((op) => op.op === "check");
|
|
829
|
+
const checkStores = [...new Set(checks.map((op) => op.store))];
|
|
830
|
+
for (const store of checkStores) {
|
|
831
|
+
const scoped = checks.filter((op) => op.store === store);
|
|
832
|
+
const actual = await getRows(conn, storeDefinition(store), scoped.map((op) => op.key));
|
|
833
|
+
scoped.forEach((op, i) => {
|
|
834
|
+
if (!rowsEqual(actual[i] ?? null, op.value)) throw new StorageError(409, "transaction read changed before commit", "transaction_conflict");
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
let pending;
|
|
838
|
+
const flushPuts = async () => {
|
|
839
|
+
if (!pending) return;
|
|
840
|
+
const batch = pending;
|
|
841
|
+
pending = void 0;
|
|
842
|
+
if (batch.values.length === 0) return;
|
|
843
|
+
const changed = [];
|
|
844
|
+
const keys = await putRows(conn, batch.def, batch.values, changed);
|
|
845
|
+
const slot = TRACKED[batch.def.name];
|
|
846
|
+
if (slot) impact[slot].push(...changed);
|
|
847
|
+
const changedSet = new Set(changed.map((key) => JSON.stringify(key)));
|
|
848
|
+
if (batch.def.name === "notes") impact.pinnedNotes.push(...batch.values.filter((value) => changedSet.has(JSON.stringify(keyFromValue(batch.def, value)))));
|
|
849
|
+
if (batch.def.name === "sessions") impact.pinnedSessions.push(...batch.values.filter((value) => changedSet.has(JSON.stringify(keyFromValue(batch.def, value)))));
|
|
850
|
+
results.push(...keys);
|
|
851
|
+
};
|
|
706
852
|
for (const op of ops) {
|
|
707
|
-
if (op ===
|
|
708
|
-
|
|
853
|
+
if (op.op === "check") {
|
|
854
|
+
results.push(null);
|
|
855
|
+
continue;
|
|
709
856
|
}
|
|
710
857
|
const store = "store" in op ? op.store : void 0;
|
|
711
858
|
const def = storeDefinition(typeof store === "string" ? store : String(store));
|
|
712
859
|
const kind = typeof op.op === "string" ? op.op : void 0;
|
|
713
860
|
if (op.op === "put") {
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
if (slot) impact[slot].push(key);
|
|
717
|
-
results.push(key);
|
|
861
|
+
if (pending && pending.def.name !== def.name) await flushPuts();
|
|
862
|
+
(pending ??= { def, values: [] }).values.push(op.value);
|
|
718
863
|
} else if (op.op === "delete") {
|
|
864
|
+
await flushPuts();
|
|
719
865
|
const slot = TRACKED[def.name];
|
|
720
866
|
if (slot) {
|
|
721
867
|
for (const key of await deleteTargetKeys(conn, def, op.key)) impact[slot].push(key);
|
|
@@ -723,28 +869,32 @@ async function refreshDomain(conn, ops, opts = {}) {
|
|
|
723
869
|
await deleteRows(conn, def, op.key);
|
|
724
870
|
results.push(null);
|
|
725
871
|
} else if (op.op === "clear") {
|
|
872
|
+
await flushPuts();
|
|
726
873
|
await clearRows(conn, def);
|
|
727
874
|
if (def.name === "notes") impact.notesClear = true;
|
|
728
|
-
else if (def.name === "segments") impact.segmentsClear = true;
|
|
729
875
|
else if (def.name === "block_index") impact.blocksClear = true;
|
|
730
876
|
results.push(null);
|
|
731
877
|
} else {
|
|
732
|
-
throw new StorageError(400, `tx op must be put | delete | clear, got: ${String(kind)}`);
|
|
878
|
+
throw new StorageError(400, `tx op must be check | put | delete | clear, got: ${String(kind)}`);
|
|
733
879
|
}
|
|
734
880
|
}
|
|
735
|
-
|
|
881
|
+
await flushPuts();
|
|
882
|
+
const finalNotes = (await getRows(conn, storeDefinition("notes"), [...new Set(impact.pinnedNotes.map((note) => note.id))])).filter(Boolean);
|
|
883
|
+
const finalSessions = (await getRows(conn, storeDefinition("sessions"), [...new Set(impact.pinnedSessions.map((session) => session.id))])).filter(Boolean);
|
|
884
|
+
await validateStoredPins(finalNotes, finalSessions, (keys) => getRows(conn, storeDefinition("block_revisions"), keys));
|
|
885
|
+
await refresh(conn, impact);
|
|
736
886
|
return results;
|
|
737
887
|
}
|
|
738
888
|
async function refresh(conn, impact) {
|
|
739
889
|
if (impact.notesClear) {
|
|
740
890
|
await conn.exec(`DELETE FROM "domain_note"`);
|
|
741
|
-
await conn.exec(`
|
|
891
|
+
await conn.exec(`DELETE FROM "query_block" WHERE "source_note_id" NOT IN (SELECT "k0" FROM "notes")`);
|
|
892
|
+
await conn.exec(`DELETE FROM "block_index" WHERE "i_by_note_0" NOT IN (SELECT "k0" FROM "notes")`);
|
|
893
|
+
await conn.exec(`DELETE FROM "block_efforts" WHERE "i_by_note_0" NOT IN (SELECT "k0" FROM "notes")`);
|
|
742
894
|
}
|
|
743
|
-
if (impact.segmentsClear) await conn.exec(`DELETE FROM "segment_head"`);
|
|
744
895
|
if (impact.blocksClear) await conn.exec(`DELETE FROM "query_block"`);
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
for (const id of impact.blocks) await refreshBlock(conn, id);
|
|
896
|
+
await refreshNotes(conn, impact.notes);
|
|
897
|
+
await refreshBlocks(conn, impact.blocks);
|
|
748
898
|
}
|
|
749
899
|
async function rebuildProjections(conn) {
|
|
750
900
|
await conn.exec(
|
|
@@ -752,18 +902,12 @@ async function rebuildProjections(conn) {
|
|
|
752
902
|
);
|
|
753
903
|
for (const t of TABLES) await conn.exec(`DELETE FROM "${t}"`);
|
|
754
904
|
const notes = await conn.query(`SELECT "data" FROM "notes"`);
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
const heads = /* @__PURE__ */ new Map();
|
|
758
|
-
for (const row of segments) {
|
|
759
|
-
const seg = decodeRowJson(String(row.data));
|
|
760
|
-
const v = num(seg.version) ?? 0;
|
|
761
|
-
const prev = heads.get(String(row.k0));
|
|
762
|
-
if (!prev || v > prev.version) heads.set(String(row.k0), { id: row.k0, row: seg, version: v });
|
|
763
|
-
}
|
|
764
|
-
for (const head of heads.values()) await upsertSegmentHead(conn, head.id, head.row);
|
|
905
|
+
const noteObjs = notes.map((row) => decodeRowJson(String(row.data)));
|
|
906
|
+
await upsertDomainNotes(conn, noteObjs);
|
|
765
907
|
const blocks = await conn.query(`SELECT "data" FROM "block_index"`);
|
|
766
|
-
|
|
908
|
+
const blockObjs = blocks.map((row) => decodeRowJson(String(row.data)));
|
|
909
|
+
const index = indexNoteIdentities(noteObjs);
|
|
910
|
+
await upsertQueryBlocks(conn, blockObjs.map((b) => [b, resolveParentFrom(index, b)]));
|
|
767
911
|
await conn.exec(
|
|
768
912
|
`INSERT INTO "${SCHEMA_TABLE}" ("key", "value") VALUES (?, ?) ON CONFLICT ("key") DO UPDATE SET "value" = excluded."value"`,
|
|
769
913
|
[PROJECTION_GUARD_KEY, JSON.stringify({ version: DOMAIN_PROJECTION_VERSION })]
|
|
@@ -1000,13 +1144,8 @@ function predicateSql(ctx, p, plan) {
|
|
|
1000
1144
|
p.negate === true,
|
|
1001
1145
|
blocks ? `(q."content_id" IS NOT NULL AND EXISTS (SELECT 1 FROM "block_efforts" be WHERE be."i_by_block_0" = q."content_id" AND be."i_by_effort_0" IN (${p.values.map((v) => ctx.bind(v)).join(", ")})))` : `EXISTS (SELECT 1 FROM "block_efforts" be WHERE be."i_by_note_0" = ${noteId} AND be."i_by_effort_0" IN (${p.values.map((v) => ctx.bind(v)).join(", ")}))`
|
|
1002
1146
|
);
|
|
1003
|
-
case "text":
|
|
1004
|
-
|
|
1005
|
-
const title = ctx.contains(textCol, p.value);
|
|
1006
|
-
if (plan !== "entries") return title;
|
|
1007
|
-
const body = ctx.contains('h."search_text"', p.value);
|
|
1008
|
-
return `(${title} OR EXISTS (SELECT 1 FROM "segment_head" h JOIN "segments" s ON s."k0" = h."id" AND s."k1" = h."current_version" WHERE h."note_id" = ${noteId} AND COALESCE(s."i_by_history_0", 0) = 0 AND ${body}))`;
|
|
1009
|
-
}
|
|
1147
|
+
case "text":
|
|
1148
|
+
return blocks ? ctx.contains('q."normalized_text"', p.value) : `(${ctx.contains('n."title_search"', p.value)} OR ${ctx.contains('n."text_search"', p.value)})`;
|
|
1010
1149
|
case "date": {
|
|
1011
1150
|
const expr = blocks ? 'q."effective_date"' : 'COALESCE(n."date", n."created_at")';
|
|
1012
1151
|
const lo = ctx.bind(p.start);
|
|
@@ -1050,7 +1189,7 @@ async function gate(conn) {
|
|
|
1050
1189
|
if (stale > 0) {
|
|
1051
1190
|
throw new StorageError(
|
|
1052
1191
|
409,
|
|
1053
|
-
`domain projections stale: ${stale} block rows have unresolved
|
|
1192
|
+
`domain projections stale: ${stale} block rows have invalid or unresolved current parents`,
|
|
1054
1193
|
"domain_projection_unavailable"
|
|
1055
1194
|
);
|
|
1056
1195
|
}
|
|
@@ -1069,47 +1208,43 @@ async function execute(conn, q, pg) {
|
|
|
1069
1208
|
const fullWhere = selWhere && filterWhere ? `${selWhere} AND ${filterWhere.replace(/^ WHERE /, "")}` : selWhere || filterWhere;
|
|
1070
1209
|
const count = async (where, params) => Number((await conn.query(`SELECT COUNT(*) AS "n" FROM ${table}${where}`, params))[0]?.n ?? 0);
|
|
1071
1210
|
const selectedCount = await count(selWhere, selCtx.params);
|
|
1072
|
-
const matchedCount = await count(fullWhere, allParams);
|
|
1211
|
+
const matchedCount = q.filters.length === 0 ? selectedCount : await count(fullWhere, allParams);
|
|
1073
1212
|
const ordering = orderSql(q.order, plan);
|
|
1074
1213
|
const paging = pagingSql(q, pg);
|
|
1075
1214
|
if (plan !== "entries") {
|
|
1076
1215
|
const pageSql2 = `SELECT "payload" FROM ${table}${fullWhere} ORDER BY ${ordering}${paging}`;
|
|
1077
1216
|
const rows2 = (await conn.query(pageSql2, allParams)).map((r) => decodeRowJson(String(r.payload)));
|
|
1078
|
-
const
|
|
1079
|
-
return
|
|
1217
|
+
const result = { projectionVersion: DOMAIN_PROJECTION_VERSION, selectedCount, matchedCount, plan, rows: rows2 };
|
|
1218
|
+
return result;
|
|
1080
1219
|
}
|
|
1081
1220
|
const pageSql = `SELECT "note_id", "payload" FROM ${table}${fullWhere} ORDER BY ${ordering}${paging}`;
|
|
1082
1221
|
const noteRows = await conn.query(pageSql, allParams);
|
|
1083
|
-
const
|
|
1084
|
-
`SELECT h."note_id" AS "note_id", s."data" AS "payload" FROM selected n
|
|
1085
|
-
JOIN "segment_head" h ON h."note_id" = n."note_id"
|
|
1086
|
-
JOIN "segments" s ON s."k0" = h."id" AND s."k1" = h."current_version"
|
|
1087
|
-
WHERE COALESCE(s."i_by_history_0", 0) = 0
|
|
1088
|
-
ORDER BY h."position" ASC NULLS LAST, s."k0"`,
|
|
1089
|
-
`SELECT nt."i_by_note_0" AS "note_id", t."data" AS "payload" FROM selected n
|
|
1090
|
-
JOIN "note_tags" nt ON nt."i_by_note_0" = n."note_id"
|
|
1091
|
-
JOIN "tags" t ON t."k0" = nt."i_by_tag_0" ORDER BY t."k0"`,
|
|
1092
|
-
`SELECT p."i_by_note_0" AS "note_id", p."data" AS "payload" FROM selected n
|
|
1093
|
-
JOIN "page_notes" p ON p."i_by_note_0" = n."note_id" ORDER BY p."k0"`,
|
|
1094
|
-
`SELECT DISTINCT p."i_by_note_0" AS "note_id", pg."data" AS "payload" FROM selected n
|
|
1095
|
-
JOIN "page_notes" p ON p."i_by_note_0" = n."note_id"
|
|
1096
|
-
JOIN "page" pg ON pg."k0" = p."i_by_page_0" ORDER BY pg."data"`
|
|
1097
|
-
];
|
|
1098
|
-
const buckets = noteRows.map(() => [[], [], [], []]);
|
|
1222
|
+
const buckets = noteRows.map(() => [[], [], []]);
|
|
1099
1223
|
const index = /* @__PURE__ */ new Map();
|
|
1100
|
-
noteRows.forEach((
|
|
1101
|
-
for (
|
|
1102
|
-
|
|
1224
|
+
noteRows.forEach((row, i) => index.set(String(row.note_id), i));
|
|
1225
|
+
for (let start = 0; start < noteRows.length; start += 500) {
|
|
1226
|
+
const ids = noteRows.slice(start, start + 500).map((row) => String(row.note_id));
|
|
1227
|
+
const placeholders = ids.map(() => "?").join(", ");
|
|
1228
|
+
const statements = [
|
|
1229
|
+
{ 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 },
|
|
1230
|
+
{ 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 },
|
|
1231
|
+
{ 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 }
|
|
1232
|
+
];
|
|
1233
|
+
const relations = conn.batch ? await conn.batch(statements) : await (async () => {
|
|
1234
|
+
const rows2 = [];
|
|
1235
|
+
for (const statement of statements) rows2.push(await conn.query(statement.sql, statement.params));
|
|
1236
|
+
return rows2;
|
|
1237
|
+
})();
|
|
1238
|
+
relations.forEach((rows2, relation) => rows2.forEach((row) => {
|
|
1103
1239
|
const i = index.get(String(row.note_id));
|
|
1104
|
-
if (i !== void 0) buckets[i][
|
|
1105
|
-
}
|
|
1240
|
+
if (i !== void 0) buckets[i][relation].push(decodeRowJson(String(row.payload)));
|
|
1241
|
+
}));
|
|
1106
1242
|
}
|
|
1107
|
-
const rows = noteRows.map((
|
|
1108
|
-
const [
|
|
1109
|
-
return { note: decodeRowJson(String(
|
|
1243
|
+
const rows = noteRows.map((row, i) => {
|
|
1244
|
+
const [tags, links, pages] = buckets[i];
|
|
1245
|
+
return { note: decodeRowJson(String(row.payload)), tags, links, pages };
|
|
1110
1246
|
});
|
|
1111
|
-
|
|
1112
|
-
return result;
|
|
1247
|
+
return { projectionVersion: DOMAIN_PROJECTION_VERSION, selectedCount, matchedCount, plan, rows };
|
|
1113
1248
|
}
|
|
1114
1249
|
async function handleDomainQuery(db, body) {
|
|
1115
1250
|
const query = parseQuery(body);
|
|
@@ -1176,18 +1311,14 @@ var SqlStorage = class {
|
|
|
1176
1311
|
}
|
|
1177
1312
|
async transaction(stores, mode, fn) {
|
|
1178
1313
|
await this.ensureReady();
|
|
1179
|
-
return this.runTx(stores, mode === "readwrite", fn
|
|
1314
|
+
return this.runTx(stores, mode === "readwrite", fn);
|
|
1180
1315
|
}
|
|
1181
|
-
/**
|
|
1182
|
-
* Bulk load for migration destinations: like transaction(), but the
|
|
1183
|
-
* buffered writes skip per-key projection refresh and derived state is
|
|
1184
|
-
* rebuilt once — everything inside one SQL transaction (throw → rollback).
|
|
1185
|
-
*/
|
|
1316
|
+
/** Bulk writes use the same affected-key refresh as ordinary transactions. */
|
|
1186
1317
|
async bulkTransaction(stores, fn) {
|
|
1187
1318
|
await this.ensureReady();
|
|
1188
|
-
return this.runTx(stores, true, fn
|
|
1319
|
+
return this.runTx(stores, true, fn);
|
|
1189
1320
|
}
|
|
1190
|
-
runTx(stores, mutableMode, fn
|
|
1321
|
+
runTx(stores, mutableMode, fn) {
|
|
1191
1322
|
const scoped = new Set(stores);
|
|
1192
1323
|
const buffer = /* @__PURE__ */ new Map();
|
|
1193
1324
|
const cleared = /* @__PURE__ */ new Set();
|
|
@@ -1213,8 +1344,7 @@ var SqlStorage = class {
|
|
|
1213
1344
|
const result = await fn(tx);
|
|
1214
1345
|
const ops = bufferToOps(STORE_DEFS.map((d) => d.name), buffer, cleared);
|
|
1215
1346
|
if (ops.length > 0) {
|
|
1216
|
-
await refreshDomain(conn, ops
|
|
1217
|
-
if (deferProjection) await rebuildProjections(conn);
|
|
1347
|
+
await refreshDomain(conn, ops);
|
|
1218
1348
|
}
|
|
1219
1349
|
return result;
|
|
1220
1350
|
});
|
|
@@ -1289,6 +1419,20 @@ var SqlStore = class {
|
|
|
1289
1419
|
if (this.cleared?.has(this.store)) return void 0;
|
|
1290
1420
|
return getRow(this.conn, this.def, k);
|
|
1291
1421
|
}
|
|
1422
|
+
async getMany(keys) {
|
|
1423
|
+
await this.owner.ensureReady();
|
|
1424
|
+
const normalized = keys.map((key) => this.pointKey(key));
|
|
1425
|
+
const committed = this.cleared?.has(this.store) ? keys.map(() => void 0) : await getRows(this.conn, this.def, normalized);
|
|
1426
|
+
return normalized.map((key, i) => {
|
|
1427
|
+
const state = bufferedGet(this.store, key, this.buffer);
|
|
1428
|
+
return state ? state.hit ? state.value : void 0 : committed[i];
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1431
|
+
async getAllFromIndexMany(indexName, keys) {
|
|
1432
|
+
await this.owner.ensureReady();
|
|
1433
|
+
const rows = await listRowsFromIndexMany(this.conn, this.def, indexName, keys.map((key) => this.pointKey(key)));
|
|
1434
|
+
return rows.map((values, i) => overlayRows(this.def, this.store, this.buffer, this.cleared, values, void 0, { only: this.pointKey(keys[i]) }, indexName));
|
|
1435
|
+
}
|
|
1292
1436
|
async getAll(query, count) {
|
|
1293
1437
|
await this.owner.ensureReady();
|
|
1294
1438
|
const range = rangeFromIDB(query);
|
|
@@ -1320,6 +1464,10 @@ var SqlStore = class {
|
|
|
1320
1464
|
this.assertMutable();
|
|
1321
1465
|
const stored = this.owner.stamp(this.store, value);
|
|
1322
1466
|
const key = keyFromValue(this.def, stored);
|
|
1467
|
+
if (this.store === "block_revisions") {
|
|
1468
|
+
const previous = this.buffer?.get(bufferSlot(this.store, key));
|
|
1469
|
+
if (previous?.kind === "put" && !rowsEqual(previous.value, stored)) throw new StorageError(409, "immutable block revision collision", "revision_conflict");
|
|
1470
|
+
}
|
|
1323
1471
|
if (this.buffer) {
|
|
1324
1472
|
this.buffer.set(bufferSlot(this.store, key), { kind: "put", value: stored });
|
|
1325
1473
|
return key;
|
|
@@ -1327,9 +1475,28 @@ var SqlStore = class {
|
|
|
1327
1475
|
await this.owner.applyOps([{ op: "put", store: this.store, value: stored }]);
|
|
1328
1476
|
return key;
|
|
1329
1477
|
}
|
|
1478
|
+
async putMany(values, _keys) {
|
|
1479
|
+
await this.owner.ensureReady();
|
|
1480
|
+
this.assertMutable();
|
|
1481
|
+
const stored = values.map((value) => this.owner.stamp(this.store, value));
|
|
1482
|
+
const keys = stored.map((value) => keyFromValue(this.def, value));
|
|
1483
|
+
if (this.store === "block_revisions") {
|
|
1484
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1485
|
+
keys.forEach((key, i) => {
|
|
1486
|
+
const slot = bufferSlot(this.store, key), previous = this.buffer?.get(slot);
|
|
1487
|
+
const value = seen.has(slot) ? seen.get(slot) : previous?.kind === "put" ? previous.value : void 0;
|
|
1488
|
+
if (value !== void 0 && !rowsEqual(value, stored[i])) throw new StorageError(409, "immutable block revision collision", "revision_conflict");
|
|
1489
|
+
seen.set(slot, stored[i]);
|
|
1490
|
+
});
|
|
1491
|
+
}
|
|
1492
|
+
if (this.buffer) keys.forEach((key, i) => this.buffer.set(bufferSlot(this.store, key), { kind: "put", value: stored[i] }));
|
|
1493
|
+
else await this.owner.applyOps(stored.map((value) => ({ op: "put", store: this.store, value })));
|
|
1494
|
+
return keys;
|
|
1495
|
+
}
|
|
1330
1496
|
async delete(key) {
|
|
1331
1497
|
await this.owner.ensureReady();
|
|
1332
1498
|
this.assertMutable();
|
|
1499
|
+
if (this.store === "block_revisions") throw new StorageError(409, "block history requires explicit wipe", "revision_conflict");
|
|
1333
1500
|
const range = isRange(key) ? key : void 0;
|
|
1334
1501
|
const target = range ?? key;
|
|
1335
1502
|
if (this.buffer) {
|
|
@@ -1349,6 +1516,7 @@ var SqlStore = class {
|
|
|
1349
1516
|
async clear() {
|
|
1350
1517
|
await this.owner.ensureReady();
|
|
1351
1518
|
this.assertMutable();
|
|
1519
|
+
if (this.store === "block_revisions") throw new StorageError(409, "block history requires explicit wipe", "revision_conflict");
|
|
1352
1520
|
if (this.buffer) {
|
|
1353
1521
|
this.cleared?.add(this.store);
|
|
1354
1522
|
const prefix = `${this.store}\0`;
|
|
@@ -1404,6 +1572,291 @@ var BrowserTursoStorage = class extends SqlStorage {
|
|
|
1404
1572
|
}
|
|
1405
1573
|
};
|
|
1406
1574
|
|
|
1407
|
-
|
|
1575
|
+
// src/sql/sync.ts
|
|
1576
|
+
var SYNC_OPS_TABLE = "_wodwiki_sync_ops";
|
|
1577
|
+
var SYNC_STATE_TABLE = "_wodwiki_sync_state";
|
|
1578
|
+
var SYNC_ARCHIVE_TABLE = "_wodwiki_sync_ops_archive";
|
|
1579
|
+
var BINARY_FIELD2 = { attachments: "data" };
|
|
1580
|
+
var STORE_NAMES = Object.fromEntries(STORE_DEFS.map((d) => [d.name, true]));
|
|
1581
|
+
var STATE_KEY = "meta";
|
|
1582
|
+
function isSafeInt(value) {
|
|
1583
|
+
return typeof value === "number" && Number.isSafeInteger(value);
|
|
1584
|
+
}
|
|
1585
|
+
function isScalar(value) {
|
|
1586
|
+
return typeof value === "string" || typeof value === "number" && Number.isFinite(value);
|
|
1587
|
+
}
|
|
1588
|
+
function assertKey(key, what) {
|
|
1589
|
+
if (isScalar(key)) return;
|
|
1590
|
+
if (Array.isArray(key) && key.length > 0 && key.every(isScalar)) return;
|
|
1591
|
+
throw new StorageError(400, `${what} must be a string, finite number, or non-empty scalar tuple`, "bad_sync_key");
|
|
1592
|
+
}
|
|
1593
|
+
function validateMutation(raw) {
|
|
1594
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
1595
|
+
throw new StorageError(400, "sync mutation must be an object", "bad_sync_mutation");
|
|
1596
|
+
}
|
|
1597
|
+
const mutation = raw;
|
|
1598
|
+
if (mutation.op === "wipe") return { op: "wipe" };
|
|
1599
|
+
if (typeof mutation.store !== "string" || !STORE_NAMES[mutation.store]) {
|
|
1600
|
+
throw new StorageError(400, `sync mutation has unknown store ${String(mutation.store)}`, "bad_sync_mutation");
|
|
1601
|
+
}
|
|
1602
|
+
const store = mutation.store;
|
|
1603
|
+
if (mutation.op === "clear") return { op: "clear", store };
|
|
1604
|
+
if (mutation.op === "delete") {
|
|
1605
|
+
assertKey(mutation.key, "sync delete key");
|
|
1606
|
+
return { op: "delete", store, key: mutation.key };
|
|
1607
|
+
}
|
|
1608
|
+
if (mutation.op === "put") {
|
|
1609
|
+
assertKey(mutation.key, "sync put key");
|
|
1610
|
+
if (mutation.value === null || typeof mutation.value !== "object" || Array.isArray(mutation.value)) {
|
|
1611
|
+
throw new StorageError(400, "sync put value must be an object row", "bad_sync_mutation");
|
|
1612
|
+
}
|
|
1613
|
+
return { op: "put", store, key: mutation.key, value: mutation.value };
|
|
1614
|
+
}
|
|
1615
|
+
throw new StorageError(400, `sync mutation has unknown op ${String(mutation.op)}`, "bad_sync_mutation");
|
|
1616
|
+
}
|
|
1617
|
+
function validateEntry(entry) {
|
|
1618
|
+
if (typeof entry.id !== "string" || entry.id.length === 0 || entry.id.length > 200) {
|
|
1619
|
+
throw new StorageError(400, "sync entry id must be a non-empty string", "bad_sync_entry");
|
|
1620
|
+
}
|
|
1621
|
+
if (!isSafeInt(entry.at) || entry.at < 0) {
|
|
1622
|
+
throw new StorageError(400, "sync entry at must be a non-negative safe integer", "bad_sync_entry");
|
|
1623
|
+
}
|
|
1624
|
+
if (!Array.isArray(entry.ops) || entry.ops.length === 0) {
|
|
1625
|
+
throw new StorageError(400, "sync entry ops must be a non-empty array", "bad_sync_entry");
|
|
1626
|
+
}
|
|
1627
|
+
for (const op of entry.ops) validateMutation(op);
|
|
1628
|
+
}
|
|
1629
|
+
async function runStatements(tx, statements) {
|
|
1630
|
+
if (statements.length === 0) return [];
|
|
1631
|
+
if (tx.batch) return tx.batch(statements);
|
|
1632
|
+
const results = [];
|
|
1633
|
+
for (const statement of statements) {
|
|
1634
|
+
results.push(await tx.query(statement.sql, statement.params ?? []));
|
|
1635
|
+
}
|
|
1636
|
+
return results;
|
|
1637
|
+
}
|
|
1638
|
+
async function tableExists(conn, dialect, name) {
|
|
1639
|
+
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 = ?`;
|
|
1640
|
+
return (await conn.query(sql, [name])).length > 0;
|
|
1641
|
+
}
|
|
1642
|
+
async function readSyncState(tx) {
|
|
1643
|
+
const rows = await tx.query(`SELECT "value" FROM "${SYNC_STATE_TABLE}" WHERE "key" = ?`, [STATE_KEY]);
|
|
1644
|
+
if (!rows[0]) throw new StorageError(409, "sync target is not initialized", "sync_not_initialized");
|
|
1645
|
+
let parsed;
|
|
1646
|
+
try {
|
|
1647
|
+
parsed = JSON.parse(String(rows[0].value));
|
|
1648
|
+
} catch {
|
|
1649
|
+
throw new StorageError(400, "malformed sync state row", "bad_sync_state");
|
|
1650
|
+
}
|
|
1651
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1652
|
+
throw new StorageError(400, "malformed sync state row", "bad_sync_state");
|
|
1653
|
+
}
|
|
1654
|
+
const state = parsed;
|
|
1655
|
+
if (!isSafeInt(state.schema) || state.schema < 0 || !isSafeInt(state.rev) || state.rev < 0) {
|
|
1656
|
+
throw new StorageError(400, "sync state schema/rev must be non-negative safe integers", "bad_sync_state");
|
|
1657
|
+
}
|
|
1658
|
+
return { schema: state.schema, rev: state.rev };
|
|
1659
|
+
}
|
|
1660
|
+
async function probeSyncTarget(db) {
|
|
1661
|
+
if (await tableExists(db, db.dialect, SYNC_STATE_TABLE)) {
|
|
1662
|
+
const state = await readSyncState(db);
|
|
1663
|
+
return { kind: "current", schemaVersion: state.schema, rev: state.rev };
|
|
1664
|
+
}
|
|
1665
|
+
if (await tableExists(db, db.dialect, SCHEMA_TABLE)) {
|
|
1666
|
+
const rows = await db.query(`SELECT "value" FROM "${SCHEMA_TABLE}" WHERE "key" = 'schema'`);
|
|
1667
|
+
if (rows[0]) {
|
|
1668
|
+
const guard = JSON.parse(String(rows[0].value));
|
|
1669
|
+
if (typeof guard.version === "number" && Number.isSafeInteger(guard.version) && guard.version >= 0) {
|
|
1670
|
+
return { kind: "legacy", schemaVersion: guard.version };
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
return { kind: "legacy", schemaVersion: 0 };
|
|
1674
|
+
}
|
|
1675
|
+
const others = await db.query(
|
|
1676
|
+
db.dialect === "postgres" ? `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'` : `SELECT name FROM sqlite_master WHERE type = 'table'`
|
|
1677
|
+
);
|
|
1678
|
+
const internal = (name) => name.startsWith("_wodwiki_") || name.startsWith("sqlite_");
|
|
1679
|
+
return others.some((row) => !internal(String(Object.values(row)[0]))) ? { kind: "unknown" } : "empty";
|
|
1680
|
+
}
|
|
1681
|
+
async function initSyncTarget(db, schemaVersion) {
|
|
1682
|
+
await db.transaction(async (tx) => {
|
|
1683
|
+
await tx.exec(`CREATE TABLE IF NOT EXISTS "${SYNC_STATE_TABLE}" ("key" TEXT PRIMARY KEY, "value" TEXT NOT NULL)`);
|
|
1684
|
+
await tx.exec(
|
|
1685
|
+
`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)`
|
|
1686
|
+
);
|
|
1687
|
+
await tx.exec(
|
|
1688
|
+
`CREATE INDEX IF NOT EXISTS "${SYNC_OPS_TABLE}_rev" ON "${SYNC_OPS_TABLE}" ("rev", "idx")`
|
|
1689
|
+
);
|
|
1690
|
+
await tx.exec(
|
|
1691
|
+
`INSERT INTO "${SYNC_STATE_TABLE}" ("key", "value") VALUES (?, ?) ON CONFLICT ("key") DO NOTHING`,
|
|
1692
|
+
[STATE_KEY, JSON.stringify({ schema: schemaVersion, rev: 0 })]
|
|
1693
|
+
);
|
|
1694
|
+
});
|
|
1695
|
+
}
|
|
1696
|
+
function encodeMutation(mutation) {
|
|
1697
|
+
if (mutation.op !== "put") return JSON.stringify(mutation);
|
|
1698
|
+
const encoded = encodeRowJson(mutation.value, BINARY_FIELD2[mutation.store]);
|
|
1699
|
+
return `{"key":${JSON.stringify(mutation.key)},"op":"put","store":${JSON.stringify(mutation.store)},"value":${encoded}}`;
|
|
1700
|
+
}
|
|
1701
|
+
function decodeMutation(data) {
|
|
1702
|
+
if (typeof data !== "string") {
|
|
1703
|
+
throw new StorageError(400, "sync op data must be a string", "bad_sync_mutation");
|
|
1704
|
+
}
|
|
1705
|
+
let parsed;
|
|
1706
|
+
try {
|
|
1707
|
+
parsed = JSON.parse(data);
|
|
1708
|
+
} catch {
|
|
1709
|
+
throw new StorageError(400, "sync op data is not valid JSON", "bad_sync_mutation");
|
|
1710
|
+
}
|
|
1711
|
+
const mutation = validateMutation(parsed);
|
|
1712
|
+
if (mutation.op !== "put") return mutation;
|
|
1713
|
+
return {
|
|
1714
|
+
op: "put",
|
|
1715
|
+
store: mutation.store,
|
|
1716
|
+
key: mutation.key,
|
|
1717
|
+
value: decodeRowJson(JSON.stringify(mutation.value), BINARY_FIELD2[mutation.store])
|
|
1718
|
+
};
|
|
1719
|
+
}
|
|
1720
|
+
async function applySyncBatch(db, entries, opts = {}) {
|
|
1721
|
+
if (entries.length === 0) return 0;
|
|
1722
|
+
for (const entry of entries) validateEntry(entry);
|
|
1723
|
+
return db.transaction(async (tx) => {
|
|
1724
|
+
const state = await readSyncState(tx);
|
|
1725
|
+
if (opts.expectSchema !== void 0 && state.schema !== opts.expectSchema) {
|
|
1726
|
+
throw new StorageError(
|
|
1727
|
+
409,
|
|
1728
|
+
`sync target schema changed from v${opts.expectSchema} to v${state.schema} between probe and write \u2014 retry the cycle`,
|
|
1729
|
+
"sync_schema_conflict"
|
|
1730
|
+
);
|
|
1731
|
+
}
|
|
1732
|
+
const firstIds = entries.map((entry) => `${entry.id}:0`);
|
|
1733
|
+
const present = /* @__PURE__ */ new Set();
|
|
1734
|
+
for (let i = 0; i < firstIds.length; i += 100) {
|
|
1735
|
+
const chunk = firstIds.slice(i, i + 100);
|
|
1736
|
+
const rows = await tx.query(
|
|
1737
|
+
`SELECT "op_id" FROM "${SYNC_OPS_TABLE}" WHERE "op_id" IN (${chunk.map(() => "?").join(", ")})`,
|
|
1738
|
+
chunk
|
|
1739
|
+
);
|
|
1740
|
+
for (const row of rows) present.add(String(row.op_id));
|
|
1741
|
+
}
|
|
1742
|
+
const statements = [];
|
|
1743
|
+
let rev = state.rev;
|
|
1744
|
+
let committed = 0;
|
|
1745
|
+
for (const entry of entries) {
|
|
1746
|
+
if (present.has(`${entry.id}:0`)) continue;
|
|
1747
|
+
rev += 1;
|
|
1748
|
+
committed += 1;
|
|
1749
|
+
entry.ops.forEach((mutation, idx) => {
|
|
1750
|
+
statements.push({
|
|
1751
|
+
sql: `INSERT INTO "${SYNC_OPS_TABLE}" ("op_id", "rev", "idx", "at", "data") VALUES (?, ?, ?, ?, ?) ON CONFLICT ("op_id") DO NOTHING`,
|
|
1752
|
+
params: [`${entry.id}:${idx}`, rev, idx, entry.at, encodeMutation(mutation)]
|
|
1753
|
+
});
|
|
1754
|
+
});
|
|
1755
|
+
}
|
|
1756
|
+
const nextSchema = opts.toSchemaVersion ?? state.schema;
|
|
1757
|
+
statements.push({
|
|
1758
|
+
sql: `UPDATE "${SYNC_STATE_TABLE}" SET "value" = ? WHERE "key" = ?`,
|
|
1759
|
+
params: [JSON.stringify({ schema: nextSchema, rev }), STATE_KEY]
|
|
1760
|
+
});
|
|
1761
|
+
await runStatements(tx, statements);
|
|
1762
|
+
return committed;
|
|
1763
|
+
});
|
|
1764
|
+
}
|
|
1765
|
+
async function fetchSyncOps(db, afterRev, limit) {
|
|
1766
|
+
if (!isSafeInt(afterRev) || afterRev < 0) {
|
|
1767
|
+
throw new StorageError(400, "fetchSyncOps cursor must be a non-negative safe integer", "bad_sync_cursor");
|
|
1768
|
+
}
|
|
1769
|
+
const bounded = Math.min(Math.max(1, Math.floor(limit)), 4294967295);
|
|
1770
|
+
const revRows = await db.query(
|
|
1771
|
+
`SELECT DISTINCT "rev" FROM (SELECT "rev" FROM "${SYNC_OPS_TABLE}" WHERE "rev" > ? ORDER BY "rev" ASC LIMIT ?)`,
|
|
1772
|
+
[afterRev, bounded]
|
|
1773
|
+
);
|
|
1774
|
+
const revs = revRows.map((row) => {
|
|
1775
|
+
const rev = Number(row.rev);
|
|
1776
|
+
if (!Number.isSafeInteger(rev) || rev < 0) {
|
|
1777
|
+
throw new StorageError(400, "sync op rev must be a non-negative safe integer", "bad_sync_row");
|
|
1778
|
+
}
|
|
1779
|
+
return rev;
|
|
1780
|
+
});
|
|
1781
|
+
if (revs.length === 0) return [];
|
|
1782
|
+
const rows = await db.query(
|
|
1783
|
+
`SELECT "op_id", "rev", "idx", "at", "data" FROM "${SYNC_OPS_TABLE}" WHERE "rev" IN (${revs.map(() => "?").join(", ")}) ORDER BY "rev", "idx"`,
|
|
1784
|
+
revs
|
|
1785
|
+
);
|
|
1786
|
+
return rows.map((row, at) => {
|
|
1787
|
+
const opId = row.op_id;
|
|
1788
|
+
const rev = Number(row.rev);
|
|
1789
|
+
const idx = Number(row.idx);
|
|
1790
|
+
const atMs = Number(row.at);
|
|
1791
|
+
if (typeof opId !== "string" || opId.length === 0 || !Number.isSafeInteger(rev) || rev < 0 || !Number.isSafeInteger(idx) || idx < 0 || !Number.isSafeInteger(atMs) || atMs < 0) {
|
|
1792
|
+
throw new StorageError(400, `sync op row ${at} has malformed shape`, "bad_sync_row");
|
|
1793
|
+
}
|
|
1794
|
+
return { opId, rev, idx, at: atMs, mutation: decodeMutation(row.data) };
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1797
|
+
async function replaceSyncLog(db, entries, toSchemaVersion) {
|
|
1798
|
+
if (entries.length === 0) throw new StorageError(400, "replacement log must contain the converted snapshot", "bad_sync_entry");
|
|
1799
|
+
for (const entry of entries) validateEntry(entry);
|
|
1800
|
+
await db.transaction(async (tx) => {
|
|
1801
|
+
await readSyncState(tx);
|
|
1802
|
+
await tx.exec(
|
|
1803
|
+
`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)`
|
|
1804
|
+
);
|
|
1805
|
+
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}"`);
|
|
1806
|
+
await tx.exec(`DELETE FROM "${SYNC_OPS_TABLE}"`);
|
|
1807
|
+
const statements = [];
|
|
1808
|
+
let rev = 0;
|
|
1809
|
+
for (const entry of entries) {
|
|
1810
|
+
rev += 1;
|
|
1811
|
+
entry.ops.forEach((mutation, idx) => {
|
|
1812
|
+
statements.push({
|
|
1813
|
+
sql: `INSERT INTO "${SYNC_OPS_TABLE}" ("op_id", "rev", "idx", "at", "data") VALUES (?, ?, ?, ?, ?) ON CONFLICT ("op_id") DO NOTHING`,
|
|
1814
|
+
params: [`${entry.id}:${idx}`, rev, idx, entry.at, encodeMutation(mutation)]
|
|
1815
|
+
});
|
|
1816
|
+
});
|
|
1817
|
+
}
|
|
1818
|
+
statements.push({
|
|
1819
|
+
sql: `UPDATE "${SYNC_STATE_TABLE}" SET "value" = ? WHERE "key" = ?`,
|
|
1820
|
+
params: [JSON.stringify({ schema: toSchemaVersion, rev }), STATE_KEY]
|
|
1821
|
+
});
|
|
1822
|
+
await runStatements(tx, statements);
|
|
1823
|
+
});
|
|
1824
|
+
}
|
|
1825
|
+
async function readRawSyncOps(db) {
|
|
1826
|
+
const rows = await db.query(`SELECT "rev", "idx", "data" FROM "${SYNC_OPS_TABLE}" ORDER BY "rev", "idx"`);
|
|
1827
|
+
return rows.map((row) => {
|
|
1828
|
+
const rev = Number(row.rev);
|
|
1829
|
+
const idx = Number(row.idx);
|
|
1830
|
+
if (!Number.isSafeInteger(rev) || rev < 0 || !Number.isSafeInteger(idx) || idx < 0 || typeof row.data !== "string") {
|
|
1831
|
+
throw new StorageError(400, "sync op row has malformed shape", "bad_sync_row");
|
|
1832
|
+
}
|
|
1833
|
+
let parsed;
|
|
1834
|
+
try {
|
|
1835
|
+
parsed = JSON.parse(row.data);
|
|
1836
|
+
} catch {
|
|
1837
|
+
throw new StorageError(400, "sync op data is not valid JSON", "bad_sync_row");
|
|
1838
|
+
}
|
|
1839
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1840
|
+
throw new StorageError(400, "sync op data must be an object", "bad_sync_row");
|
|
1841
|
+
}
|
|
1842
|
+
const mutation = parsed;
|
|
1843
|
+
if (typeof mutation.op !== "string" || typeof mutation.store !== "string") {
|
|
1844
|
+
throw new StorageError(400, "sync op must carry string op/store", "bad_sync_row");
|
|
1845
|
+
}
|
|
1846
|
+
return {
|
|
1847
|
+
op: mutation.op,
|
|
1848
|
+
store: mutation.store,
|
|
1849
|
+
key: mutation.key,
|
|
1850
|
+
value: mutation.value === void 0 || mutation.value === null ? void 0 : decodeRowJson(JSON.stringify(mutation.value), BINARY_FIELD2[mutation.store])
|
|
1851
|
+
};
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
async function readLegacyRows(db, table) {
|
|
1855
|
+
if (!await tableExists(db, db.dialect, table)) return [];
|
|
1856
|
+
const rows = await db.query(`SELECT "data" FROM "${table}"`);
|
|
1857
|
+
return rows.map((row) => decodeRowJson(String(row.data), BINARY_FIELD2[table]));
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
export { BrowserTursoStorage, MAX_BODY, SCHEMA_TABLE, SYNC_ARCHIVE_TABLE, SYNC_OPS_TABLE, SYNC_STATE_TABLE, SqlStorage, applySyncBatch, clearRows, countRows, decodeRowJson, deleteRows, encodeRowJson, fetchSyncOps, getRow, getRows, handleDomainQuery, initSyncTarget, initializeDomain, initializeStore, libsqlDatabase, listRows, listRowsFromIndexMany, probeSyncTarget, putRow, putRows, readLegacyRows, readRawSyncOps, rebuildProjections, refreshDomain, replaceSyncLog, storeDefinition, validateTursoUrl, wipeDomain, wipeRows };
|
|
1408
1861
|
//# sourceMappingURL=index.mjs.map
|
|
1409
1862
|
//# sourceMappingURL=index.mjs.map
|