@bitcobblers/wod-wiki-storage 0.35.2116 → 0.35.2117
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-DAGT3P7M.mjs → chunk-Y576CUFQ.mjs} +118 -36
- package/dist/chunk-Y576CUFQ.mjs.map +1 -0
- package/dist/index.cjs +182 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +12 -5
- package/dist/index.d.ts +12 -5
- package/dist/index.mjs +62 -16
- package/dist/index.mjs.map +1 -1
- package/dist/sql/index.cjs +507 -286
- package/dist/sql/index.cjs.map +1 -1
- package/dist/sql/index.d.cts +16 -61
- package/dist/sql/index.d.ts +16 -61
- package/dist/sql/index.mjs +422 -270
- package/dist/sql/index.mjs.map +1 -1
- package/dist/wire-Cfvz-I0h.d.cts +207 -0
- package/dist/wire-Cfvz-I0h.d.ts +207 -0
- package/package.json +6 -9
- package/dist/chunk-DAGT3P7M.mjs.map +0 -1
- package/dist/wire-C7ZtlBKe.d.cts +0 -489
- package/dist/wire-C7ZtlBKe.d.ts +0 -489
package/dist/sql/index.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-Y576CUFQ.mjs';
|
|
2
|
+
export { StorageError } from '../chunk-Y576CUFQ.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, 500)) {
|
|
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, 500)) {
|
|
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,6 +560,34 @@ 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) => {
|
|
@@ -475,7 +609,7 @@ async function initializeStore(db) {
|
|
|
475
609
|
if (stored !== DB_VERSION) {
|
|
476
610
|
throw new StorageError(
|
|
477
611
|
500,
|
|
478
|
-
`database schema v${stored}
|
|
612
|
+
`database schema v${stored} is incompatible with v${DB_VERSION}; recreation required using an explicitly disposable database`,
|
|
479
613
|
"schema_incompatible"
|
|
480
614
|
);
|
|
481
615
|
}
|
|
@@ -495,7 +629,7 @@ async function initializeStore(db) {
|
|
|
495
629
|
}
|
|
496
630
|
});
|
|
497
631
|
}
|
|
498
|
-
var TABLES = ["domain_note", "query_block"
|
|
632
|
+
var TABLES = ["domain_note", "query_block"];
|
|
499
633
|
var PROJECTION_GUARD_KEY = "domain_projection";
|
|
500
634
|
function sourceItem(row) {
|
|
501
635
|
const item = {};
|
|
@@ -514,7 +648,7 @@ function ensureDdl(db) {
|
|
|
514
648
|
const n = db.dialect === "postgres" ? " DOUBLE PRECISION" : "";
|
|
515
649
|
const t = db.dialect === "postgres" ? ' TEXT COLLATE "C"' : " TEXT";
|
|
516
650
|
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)`,
|
|
651
|
+
`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
652
|
`CREATE INDEX IF NOT EXISTS "idx_domain_note_date" ON "domain_note" ("date", "note_id")`,
|
|
519
653
|
`CREATE INDEX IF NOT EXISTS "idx_domain_note_created" ON "domain_note" ("created_at", "note_id")`,
|
|
520
654
|
`CREATE INDEX IF NOT EXISTS "idx_domain_note_source_path" ON "domain_note" ("source_path")`,
|
|
@@ -523,169 +657,140 @@ function ensureDdl(db) {
|
|
|
523
657
|
`CREATE INDEX IF NOT EXISTS "idx_query_block_parent" ON "query_block" ("parent_note_id", "ordinal")`,
|
|
524
658
|
`CREATE INDEX IF NOT EXISTS "idx_query_block_content" ON "query_block" ("content_id")`,
|
|
525
659
|
`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")`
|
|
660
|
+
`CREATE INDEX IF NOT EXISTS "idx_query_block_source_note" ON "query_block" ("source_note_id")`
|
|
529
661
|
];
|
|
530
662
|
return db.transaction(async (tx) => {
|
|
531
663
|
for (const sql of ddl) await tx.exec(sql);
|
|
532
664
|
});
|
|
533
665
|
}
|
|
534
|
-
|
|
666
|
+
var DOMAIN_NOTE_COLS = ["note_id", "note_type", "catalog", "source_id", "source_path", "title", "title_search", "created_at", "date", "text_search", "journal", "payload"];
|
|
667
|
+
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"];
|
|
668
|
+
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 ");
|
|
669
|
+
function domainNoteRow(note) {
|
|
535
670
|
const id = text(note.id);
|
|
536
|
-
if (!id) return;
|
|
671
|
+
if (!id) return void 0;
|
|
537
672
|
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
|
-
}));
|
|
673
|
+
return [
|
|
674
|
+
id,
|
|
675
|
+
T(note.type),
|
|
676
|
+
T(catalogOfItem(source)),
|
|
677
|
+
T(note.sourceId),
|
|
678
|
+
T(note.sourcePath),
|
|
679
|
+
T(note.title) ?? "",
|
|
680
|
+
(text(note.title) ?? "").toLowerCase(),
|
|
681
|
+
N(note.createdAt),
|
|
682
|
+
num(note.date),
|
|
683
|
+
(text(note.rawContent) ?? "").toLowerCase(),
|
|
684
|
+
sourceMatches(source, "journal") ? 1 : 0,
|
|
685
|
+
encodeRowJson(note)
|
|
686
|
+
];
|
|
595
687
|
}
|
|
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;
|
|
688
|
+
async function upsertDomainNotes(conn, notes) {
|
|
689
|
+
const rows = notes.map(domainNoteRow).filter((r) => r !== void 0);
|
|
690
|
+
await batchInsert(conn, "domain_note", [...DOMAIN_NOTE_COLS], rows, conflictUpdate(DOMAIN_NOTE_COLS, ["note_id"]));
|
|
609
691
|
}
|
|
610
|
-
|
|
692
|
+
function queryBlockRow(block, parent) {
|
|
611
693
|
const id = text(block.id);
|
|
612
|
-
if (!id) return;
|
|
694
|
+
if (!id) return void 0;
|
|
613
695
|
const source = sourceItem(block);
|
|
614
696
|
const createdAt = N(block.createdAt);
|
|
615
|
-
const parent = await resolveParent(conn, block);
|
|
616
697
|
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
|
-
);
|
|
698
|
+
return [
|
|
699
|
+
id,
|
|
700
|
+
T(block.noteId),
|
|
701
|
+
parent?.note_id ?? null,
|
|
702
|
+
T(block.blockContentId),
|
|
703
|
+
T(block.blockId),
|
|
704
|
+
num(block.position),
|
|
705
|
+
T(block.dataType),
|
|
706
|
+
T(block.sourceId),
|
|
707
|
+
T(block.sourcePath),
|
|
708
|
+
T(catalogOfItem(source)),
|
|
709
|
+
effective,
|
|
710
|
+
createdAt,
|
|
711
|
+
parent?.note.title ?? T(block.noteTitle),
|
|
712
|
+
(text(block.rawContent) ?? "").toLowerCase(),
|
|
713
|
+
sourceMatches(source, "journal") ? 1 : 0,
|
|
714
|
+
encodeRowJson(parent ? { ...block, noteTitle: parent.note.title } : block)
|
|
715
|
+
];
|
|
646
716
|
}
|
|
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
|
-
}
|
|
717
|
+
async function upsertQueryBlocks(conn, blocks) {
|
|
718
|
+
const rows = blocks.map(([b, p]) => queryBlockRow(b, p)).filter((r) => r !== void 0);
|
|
719
|
+
await batchInsert(conn, "query_block", [...QUERY_BLOCK_COLS], rows, conflictUpdate(QUERY_BLOCK_COLS, ["occurrence_id"]));
|
|
663
720
|
}
|
|
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
|
-
}
|
|
721
|
+
function indexNoteIdentities(notes) {
|
|
722
|
+
const byNoteId = /* @__PURE__ */ new Map();
|
|
723
|
+
for (const note of notes) {
|
|
724
|
+
const id = text(note.id);
|
|
725
|
+
if (id) byNoteId.set(id, { note_id: id, date: num(note.date), created_at: num(note.createdAt) ?? 0, note });
|
|
676
726
|
}
|
|
677
|
-
|
|
678
|
-
|
|
727
|
+
return { byNoteId };
|
|
728
|
+
}
|
|
729
|
+
function resolveParentFrom(index, block) {
|
|
730
|
+
const parent = typeof block.noteId === "string" ? index.byNoteId.get(block.noteId) : void 0;
|
|
731
|
+
const mapped = typeof block.blockId === "string" ? parent?.note.blocks[block.blockId] : void 0;
|
|
732
|
+
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;
|
|
679
733
|
}
|
|
680
|
-
async function
|
|
681
|
-
const
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
734
|
+
async function fetchParentIndex(conn, noteIds) {
|
|
735
|
+
const notes = await getRows(conn, storeDefinition("notes"), noteIds);
|
|
736
|
+
return indexNoteIdentities(notes.filter((row) => row !== void 0));
|
|
737
|
+
}
|
|
738
|
+
var keyHead = (key) => Array.isArray(key) ? key[0] : key;
|
|
739
|
+
async function selectIn(conn, selectCol, table, col, vals) {
|
|
740
|
+
const IN_CHUNK = 500;
|
|
741
|
+
const out = [];
|
|
742
|
+
for (let i = 0; i < vals.length; i += IN_CHUNK) {
|
|
743
|
+
const part = vals.slice(i, i + IN_CHUNK);
|
|
744
|
+
const rows = await conn.query(`SELECT "${selectCol}" FROM "${table}" WHERE "${col}" IN (${part.map(() => "?").join(", ")})`, part);
|
|
745
|
+
for (const r of rows) out.push(String(r[selectCol]));
|
|
746
|
+
}
|
|
747
|
+
return out;
|
|
748
|
+
}
|
|
749
|
+
async function healQueryBlocksForNotes(conn, noteIds) {
|
|
750
|
+
const ids = await selectIn(conn, "occurrence_id", "query_block", "source_note_id", noteIds);
|
|
751
|
+
await refreshBlocks(conn, [...new Set(ids)]);
|
|
752
|
+
}
|
|
753
|
+
async function refreshNotes(conn, ids) {
|
|
754
|
+
const ks = [...new Set(ids.map((id) => String(keyHead(id))))];
|
|
755
|
+
if (ks.length === 0) return;
|
|
756
|
+
const found = /* @__PURE__ */ new Map();
|
|
757
|
+
for (let i = 0; i < ks.length; i += 500) {
|
|
758
|
+
const part = ks.slice(i, i + 500);
|
|
759
|
+
const rows = await conn.query(`SELECT "k0", "data" FROM "notes" WHERE "k0" IN (${part.map(() => "?").join(", ")})`, part);
|
|
760
|
+
for (const row of rows) found.set(String(row.k0), decodeRowJson(String(row.data)));
|
|
761
|
+
}
|
|
762
|
+
const present = [];
|
|
763
|
+
const missing = [];
|
|
764
|
+
for (const k of ks) {
|
|
765
|
+
const note = found.get(k);
|
|
766
|
+
if (note) present.push(note);
|
|
767
|
+
else missing.push(k);
|
|
768
|
+
}
|
|
769
|
+
await upsertDomainNotes(conn, present);
|
|
770
|
+
const presentIds = present.map((n) => text(n.id)).filter((x) => !!x);
|
|
771
|
+
await healQueryBlocksForNotes(conn, presentIds);
|
|
772
|
+
await deleteIn(conn, "domain_note", "note_id", missing);
|
|
773
|
+
await deleteIn(conn, "query_block", "source_note_id", missing);
|
|
774
|
+
await deleteIn(conn, "block_index", "i_by_note_0", missing);
|
|
775
|
+
await deleteIn(conn, "block_efforts", "i_by_note_0", missing);
|
|
776
|
+
}
|
|
777
|
+
async function refreshBlocks(conn, ids) {
|
|
778
|
+
const ks = [...new Set(ids.map((id) => String(keyHead(id))))];
|
|
779
|
+
if (ks.length === 0) return;
|
|
780
|
+
const found = /* @__PURE__ */ new Map();
|
|
781
|
+
for (let i = 0; i < ks.length; i += 500) {
|
|
782
|
+
const part = ks.slice(i, i + 500);
|
|
783
|
+
const rows = await conn.query(`SELECT "k0", "data" FROM "block_index" WHERE "k0" IN (${part.map(() => "?").join(", ")})`, part);
|
|
784
|
+
for (const row of rows) found.set(String(row.k0), decodeRowJson(String(row.data)));
|
|
785
|
+
}
|
|
786
|
+
const blocks = [...found.values()];
|
|
787
|
+
const noteIds = [...new Set(blocks.map((b) => text(b.noteId)).filter((x) => !!x))];
|
|
788
|
+
const index = await fetchParentIndex(conn, noteIds);
|
|
789
|
+
await upsertQueryBlocks(conn, blocks.map((b) => [b, resolveParentFrom(index, b)]));
|
|
790
|
+
await deleteIn(conn, "query_block", "occurrence_id", ks.filter((k) => !found.has(k)));
|
|
685
791
|
}
|
|
686
792
|
var TRACKED = {
|
|
687
793
|
notes: "notes",
|
|
688
|
-
segments: "segments",
|
|
689
794
|
block_index: "blocks"
|
|
690
795
|
};
|
|
691
796
|
async function deleteTargetKeys(conn, def, target) {
|
|
@@ -693,29 +798,54 @@ async function deleteTargetKeys(conn, def, target) {
|
|
|
693
798
|
const rows = await listRows(conn, def, target);
|
|
694
799
|
return rows.map((row) => keyFromValue(def, row));
|
|
695
800
|
}
|
|
696
|
-
async function refreshDomain(conn, ops
|
|
801
|
+
async function refreshDomain(conn, ops) {
|
|
697
802
|
const impact = {
|
|
698
803
|
notes: [],
|
|
699
|
-
segments: [],
|
|
700
804
|
blocks: [],
|
|
805
|
+
pinnedNotes: [],
|
|
806
|
+
pinnedSessions: [],
|
|
701
807
|
notesClear: false,
|
|
702
|
-
segmentsClear: false,
|
|
703
808
|
blocksClear: false
|
|
704
809
|
};
|
|
705
810
|
const results = [];
|
|
811
|
+
for (const op of ops) if (op === null || typeof op !== "object" || Array.isArray(op)) throw new StorageError(400, "tx op must be an object");
|
|
812
|
+
const checks = ops.filter((op) => op.op === "check");
|
|
813
|
+
const checkStores = [...new Set(checks.map((op) => op.store))];
|
|
814
|
+
for (const store of checkStores) {
|
|
815
|
+
const scoped = checks.filter((op) => op.store === store);
|
|
816
|
+
const actual = await getRows(conn, storeDefinition(store), scoped.map((op) => op.key));
|
|
817
|
+
scoped.forEach((op, i) => {
|
|
818
|
+
if (!rowsEqual(actual[i] ?? null, op.value)) throw new StorageError(409, "transaction read changed before commit", "transaction_conflict");
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
let pending;
|
|
822
|
+
const flushPuts = async () => {
|
|
823
|
+
if (!pending) return;
|
|
824
|
+
const batch = pending;
|
|
825
|
+
pending = void 0;
|
|
826
|
+
if (batch.values.length === 0) return;
|
|
827
|
+
const changed = [];
|
|
828
|
+
const keys = await putRows(conn, batch.def, batch.values, changed);
|
|
829
|
+
const slot = TRACKED[batch.def.name];
|
|
830
|
+
if (slot) impact[slot].push(...changed);
|
|
831
|
+
const changedSet = new Set(changed.map((key) => JSON.stringify(key)));
|
|
832
|
+
if (batch.def.name === "notes") impact.pinnedNotes.push(...batch.values.filter((value) => changedSet.has(JSON.stringify(keyFromValue(batch.def, value)))));
|
|
833
|
+
if (batch.def.name === "sessions") impact.pinnedSessions.push(...batch.values.filter((value) => changedSet.has(JSON.stringify(keyFromValue(batch.def, value)))));
|
|
834
|
+
results.push(...keys);
|
|
835
|
+
};
|
|
706
836
|
for (const op of ops) {
|
|
707
|
-
if (op ===
|
|
708
|
-
|
|
837
|
+
if (op.op === "check") {
|
|
838
|
+
results.push(null);
|
|
839
|
+
continue;
|
|
709
840
|
}
|
|
710
841
|
const store = "store" in op ? op.store : void 0;
|
|
711
842
|
const def = storeDefinition(typeof store === "string" ? store : String(store));
|
|
712
843
|
const kind = typeof op.op === "string" ? op.op : void 0;
|
|
713
844
|
if (op.op === "put") {
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
if (slot) impact[slot].push(key);
|
|
717
|
-
results.push(key);
|
|
845
|
+
if (pending && pending.def.name !== def.name) await flushPuts();
|
|
846
|
+
(pending ??= { def, values: [] }).values.push(op.value);
|
|
718
847
|
} else if (op.op === "delete") {
|
|
848
|
+
await flushPuts();
|
|
719
849
|
const slot = TRACKED[def.name];
|
|
720
850
|
if (slot) {
|
|
721
851
|
for (const key of await deleteTargetKeys(conn, def, op.key)) impact[slot].push(key);
|
|
@@ -723,28 +853,32 @@ async function refreshDomain(conn, ops, opts = {}) {
|
|
|
723
853
|
await deleteRows(conn, def, op.key);
|
|
724
854
|
results.push(null);
|
|
725
855
|
} else if (op.op === "clear") {
|
|
856
|
+
await flushPuts();
|
|
726
857
|
await clearRows(conn, def);
|
|
727
858
|
if (def.name === "notes") impact.notesClear = true;
|
|
728
|
-
else if (def.name === "segments") impact.segmentsClear = true;
|
|
729
859
|
else if (def.name === "block_index") impact.blocksClear = true;
|
|
730
860
|
results.push(null);
|
|
731
861
|
} else {
|
|
732
|
-
throw new StorageError(400, `tx op must be put | delete | clear, got: ${String(kind)}`);
|
|
862
|
+
throw new StorageError(400, `tx op must be check | put | delete | clear, got: ${String(kind)}`);
|
|
733
863
|
}
|
|
734
864
|
}
|
|
735
|
-
|
|
865
|
+
await flushPuts();
|
|
866
|
+
const finalNotes = (await getRows(conn, storeDefinition("notes"), [...new Set(impact.pinnedNotes.map((note) => note.id))])).filter(Boolean);
|
|
867
|
+
const finalSessions = (await getRows(conn, storeDefinition("sessions"), [...new Set(impact.pinnedSessions.map((session) => session.id))])).filter(Boolean);
|
|
868
|
+
await validateStoredPins(finalNotes, finalSessions, (keys) => getRows(conn, storeDefinition("block_revisions"), keys));
|
|
869
|
+
await refresh(conn, impact);
|
|
736
870
|
return results;
|
|
737
871
|
}
|
|
738
872
|
async function refresh(conn, impact) {
|
|
739
873
|
if (impact.notesClear) {
|
|
740
874
|
await conn.exec(`DELETE FROM "domain_note"`);
|
|
741
|
-
await conn.exec(`
|
|
875
|
+
await conn.exec(`DELETE FROM "query_block" WHERE "source_note_id" NOT IN (SELECT "k0" FROM "notes")`);
|
|
876
|
+
await conn.exec(`DELETE FROM "block_index" WHERE "i_by_note_0" NOT IN (SELECT "k0" FROM "notes")`);
|
|
877
|
+
await conn.exec(`DELETE FROM "block_efforts" WHERE "i_by_note_0" NOT IN (SELECT "k0" FROM "notes")`);
|
|
742
878
|
}
|
|
743
|
-
if (impact.segmentsClear) await conn.exec(`DELETE FROM "segment_head"`);
|
|
744
879
|
if (impact.blocksClear) await conn.exec(`DELETE FROM "query_block"`);
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
for (const id of impact.blocks) await refreshBlock(conn, id);
|
|
880
|
+
await refreshNotes(conn, impact.notes);
|
|
881
|
+
await refreshBlocks(conn, impact.blocks);
|
|
748
882
|
}
|
|
749
883
|
async function rebuildProjections(conn) {
|
|
750
884
|
await conn.exec(
|
|
@@ -752,18 +886,12 @@ async function rebuildProjections(conn) {
|
|
|
752
886
|
);
|
|
753
887
|
for (const t of TABLES) await conn.exec(`DELETE FROM "${t}"`);
|
|
754
888
|
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);
|
|
889
|
+
const noteObjs = notes.map((row) => decodeRowJson(String(row.data)));
|
|
890
|
+
await upsertDomainNotes(conn, noteObjs);
|
|
765
891
|
const blocks = await conn.query(`SELECT "data" FROM "block_index"`);
|
|
766
|
-
|
|
892
|
+
const blockObjs = blocks.map((row) => decodeRowJson(String(row.data)));
|
|
893
|
+
const index = indexNoteIdentities(noteObjs);
|
|
894
|
+
await upsertQueryBlocks(conn, blockObjs.map((b) => [b, resolveParentFrom(index, b)]));
|
|
767
895
|
await conn.exec(
|
|
768
896
|
`INSERT INTO "${SCHEMA_TABLE}" ("key", "value") VALUES (?, ?) ON CONFLICT ("key") DO UPDATE SET "value" = excluded."value"`,
|
|
769
897
|
[PROJECTION_GUARD_KEY, JSON.stringify({ version: DOMAIN_PROJECTION_VERSION })]
|
|
@@ -1000,13 +1128,8 @@ function predicateSql(ctx, p, plan) {
|
|
|
1000
1128
|
p.negate === true,
|
|
1001
1129
|
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
1130
|
);
|
|
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
|
-
}
|
|
1131
|
+
case "text":
|
|
1132
|
+
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
1133
|
case "date": {
|
|
1011
1134
|
const expr = blocks ? 'q."effective_date"' : 'COALESCE(n."date", n."created_at")';
|
|
1012
1135
|
const lo = ctx.bind(p.start);
|
|
@@ -1050,7 +1173,7 @@ async function gate(conn) {
|
|
|
1050
1173
|
if (stale > 0) {
|
|
1051
1174
|
throw new StorageError(
|
|
1052
1175
|
409,
|
|
1053
|
-
`domain projections stale: ${stale} block rows have unresolved
|
|
1176
|
+
`domain projections stale: ${stale} block rows have invalid or unresolved current parents`,
|
|
1054
1177
|
"domain_projection_unavailable"
|
|
1055
1178
|
);
|
|
1056
1179
|
}
|
|
@@ -1069,47 +1192,43 @@ async function execute(conn, q, pg) {
|
|
|
1069
1192
|
const fullWhere = selWhere && filterWhere ? `${selWhere} AND ${filterWhere.replace(/^ WHERE /, "")}` : selWhere || filterWhere;
|
|
1070
1193
|
const count = async (where, params) => Number((await conn.query(`SELECT COUNT(*) AS "n" FROM ${table}${where}`, params))[0]?.n ?? 0);
|
|
1071
1194
|
const selectedCount = await count(selWhere, selCtx.params);
|
|
1072
|
-
const matchedCount = await count(fullWhere, allParams);
|
|
1195
|
+
const matchedCount = q.filters.length === 0 ? selectedCount : await count(fullWhere, allParams);
|
|
1073
1196
|
const ordering = orderSql(q.order, plan);
|
|
1074
1197
|
const paging = pagingSql(q, pg);
|
|
1075
1198
|
if (plan !== "entries") {
|
|
1076
1199
|
const pageSql2 = `SELECT "payload" FROM ${table}${fullWhere} ORDER BY ${ordering}${paging}`;
|
|
1077
1200
|
const rows2 = (await conn.query(pageSql2, allParams)).map((r) => decodeRowJson(String(r.payload)));
|
|
1078
|
-
const
|
|
1079
|
-
return
|
|
1201
|
+
const result = { projectionVersion: DOMAIN_PROJECTION_VERSION, selectedCount, matchedCount, plan, rows: rows2 };
|
|
1202
|
+
return result;
|
|
1080
1203
|
}
|
|
1081
1204
|
const pageSql = `SELECT "note_id", "payload" FROM ${table}${fullWhere} ORDER BY ${ordering}${paging}`;
|
|
1082
1205
|
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(() => [[], [], [], []]);
|
|
1206
|
+
const buckets = noteRows.map(() => [[], [], []]);
|
|
1099
1207
|
const index = /* @__PURE__ */ new Map();
|
|
1100
|
-
noteRows.forEach((
|
|
1101
|
-
for (
|
|
1102
|
-
|
|
1208
|
+
noteRows.forEach((row, i) => index.set(String(row.note_id), i));
|
|
1209
|
+
for (let start = 0; start < noteRows.length; start += 500) {
|
|
1210
|
+
const ids = noteRows.slice(start, start + 500).map((row) => String(row.note_id));
|
|
1211
|
+
const placeholders = ids.map(() => "?").join(", ");
|
|
1212
|
+
const statements = [
|
|
1213
|
+
{ 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 },
|
|
1214
|
+
{ 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 },
|
|
1215
|
+
{ 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 }
|
|
1216
|
+
];
|
|
1217
|
+
const relations = conn.batch ? await conn.batch(statements) : await (async () => {
|
|
1218
|
+
const rows2 = [];
|
|
1219
|
+
for (const statement of statements) rows2.push(await conn.query(statement.sql, statement.params));
|
|
1220
|
+
return rows2;
|
|
1221
|
+
})();
|
|
1222
|
+
relations.forEach((rows2, relation) => rows2.forEach((row) => {
|
|
1103
1223
|
const i = index.get(String(row.note_id));
|
|
1104
|
-
if (i !== void 0) buckets[i][
|
|
1105
|
-
}
|
|
1224
|
+
if (i !== void 0) buckets[i][relation].push(decodeRowJson(String(row.payload)));
|
|
1225
|
+
}));
|
|
1106
1226
|
}
|
|
1107
|
-
const rows = noteRows.map((
|
|
1108
|
-
const [
|
|
1109
|
-
return { note: decodeRowJson(String(
|
|
1227
|
+
const rows = noteRows.map((row, i) => {
|
|
1228
|
+
const [tags, links, pages] = buckets[i];
|
|
1229
|
+
return { note: decodeRowJson(String(row.payload)), tags, links, pages };
|
|
1110
1230
|
});
|
|
1111
|
-
|
|
1112
|
-
return result;
|
|
1231
|
+
return { projectionVersion: DOMAIN_PROJECTION_VERSION, selectedCount, matchedCount, plan, rows };
|
|
1113
1232
|
}
|
|
1114
1233
|
async function handleDomainQuery(db, body) {
|
|
1115
1234
|
const query = parseQuery(body);
|
|
@@ -1176,18 +1295,14 @@ var SqlStorage = class {
|
|
|
1176
1295
|
}
|
|
1177
1296
|
async transaction(stores, mode, fn) {
|
|
1178
1297
|
await this.ensureReady();
|
|
1179
|
-
return this.runTx(stores, mode === "readwrite", fn
|
|
1298
|
+
return this.runTx(stores, mode === "readwrite", fn);
|
|
1180
1299
|
}
|
|
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
|
-
*/
|
|
1300
|
+
/** Bulk writes use the same affected-key refresh as ordinary transactions. */
|
|
1186
1301
|
async bulkTransaction(stores, fn) {
|
|
1187
1302
|
await this.ensureReady();
|
|
1188
|
-
return this.runTx(stores, true, fn
|
|
1303
|
+
return this.runTx(stores, true, fn);
|
|
1189
1304
|
}
|
|
1190
|
-
runTx(stores, mutableMode, fn
|
|
1305
|
+
runTx(stores, mutableMode, fn) {
|
|
1191
1306
|
const scoped = new Set(stores);
|
|
1192
1307
|
const buffer = /* @__PURE__ */ new Map();
|
|
1193
1308
|
const cleared = /* @__PURE__ */ new Set();
|
|
@@ -1213,8 +1328,7 @@ var SqlStorage = class {
|
|
|
1213
1328
|
const result = await fn(tx);
|
|
1214
1329
|
const ops = bufferToOps(STORE_DEFS.map((d) => d.name), buffer, cleared);
|
|
1215
1330
|
if (ops.length > 0) {
|
|
1216
|
-
await refreshDomain(conn, ops
|
|
1217
|
-
if (deferProjection) await rebuildProjections(conn);
|
|
1331
|
+
await refreshDomain(conn, ops);
|
|
1218
1332
|
}
|
|
1219
1333
|
return result;
|
|
1220
1334
|
});
|
|
@@ -1289,6 +1403,20 @@ var SqlStore = class {
|
|
|
1289
1403
|
if (this.cleared?.has(this.store)) return void 0;
|
|
1290
1404
|
return getRow(this.conn, this.def, k);
|
|
1291
1405
|
}
|
|
1406
|
+
async getMany(keys) {
|
|
1407
|
+
await this.owner.ensureReady();
|
|
1408
|
+
const normalized = keys.map((key) => this.pointKey(key));
|
|
1409
|
+
const committed = this.cleared?.has(this.store) ? keys.map(() => void 0) : await getRows(this.conn, this.def, normalized);
|
|
1410
|
+
return normalized.map((key, i) => {
|
|
1411
|
+
const state = bufferedGet(this.store, key, this.buffer);
|
|
1412
|
+
return state ? state.hit ? state.value : void 0 : committed[i];
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
async getAllFromIndexMany(indexName, keys) {
|
|
1416
|
+
await this.owner.ensureReady();
|
|
1417
|
+
const rows = await listRowsFromIndexMany(this.conn, this.def, indexName, keys.map((key) => this.pointKey(key)));
|
|
1418
|
+
return rows.map((values, i) => overlayRows(this.def, this.store, this.buffer, this.cleared, values, void 0, { only: this.pointKey(keys[i]) }, indexName));
|
|
1419
|
+
}
|
|
1292
1420
|
async getAll(query, count) {
|
|
1293
1421
|
await this.owner.ensureReady();
|
|
1294
1422
|
const range = rangeFromIDB(query);
|
|
@@ -1320,6 +1448,10 @@ var SqlStore = class {
|
|
|
1320
1448
|
this.assertMutable();
|
|
1321
1449
|
const stored = this.owner.stamp(this.store, value);
|
|
1322
1450
|
const key = keyFromValue(this.def, stored);
|
|
1451
|
+
if (this.store === "block_revisions") {
|
|
1452
|
+
const previous = this.buffer?.get(bufferSlot(this.store, key));
|
|
1453
|
+
if (previous?.kind === "put" && !rowsEqual(previous.value, stored)) throw new StorageError(409, "immutable block revision collision", "revision_conflict");
|
|
1454
|
+
}
|
|
1323
1455
|
if (this.buffer) {
|
|
1324
1456
|
this.buffer.set(bufferSlot(this.store, key), { kind: "put", value: stored });
|
|
1325
1457
|
return key;
|
|
@@ -1327,9 +1459,28 @@ var SqlStore = class {
|
|
|
1327
1459
|
await this.owner.applyOps([{ op: "put", store: this.store, value: stored }]);
|
|
1328
1460
|
return key;
|
|
1329
1461
|
}
|
|
1462
|
+
async putMany(values, _keys) {
|
|
1463
|
+
await this.owner.ensureReady();
|
|
1464
|
+
this.assertMutable();
|
|
1465
|
+
const stored = values.map((value) => this.owner.stamp(this.store, value));
|
|
1466
|
+
const keys = stored.map((value) => keyFromValue(this.def, value));
|
|
1467
|
+
if (this.store === "block_revisions") {
|
|
1468
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1469
|
+
keys.forEach((key, i) => {
|
|
1470
|
+
const slot = bufferSlot(this.store, key), previous = this.buffer?.get(slot);
|
|
1471
|
+
const value = seen.has(slot) ? seen.get(slot) : previous?.kind === "put" ? previous.value : void 0;
|
|
1472
|
+
if (value !== void 0 && !rowsEqual(value, stored[i])) throw new StorageError(409, "immutable block revision collision", "revision_conflict");
|
|
1473
|
+
seen.set(slot, stored[i]);
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
if (this.buffer) keys.forEach((key, i) => this.buffer.set(bufferSlot(this.store, key), { kind: "put", value: stored[i] }));
|
|
1477
|
+
else await this.owner.applyOps(stored.map((value) => ({ op: "put", store: this.store, value })));
|
|
1478
|
+
return keys;
|
|
1479
|
+
}
|
|
1330
1480
|
async delete(key) {
|
|
1331
1481
|
await this.owner.ensureReady();
|
|
1332
1482
|
this.assertMutable();
|
|
1483
|
+
if (this.store === "block_revisions") throw new StorageError(409, "block history requires explicit wipe", "revision_conflict");
|
|
1333
1484
|
const range = isRange(key) ? key : void 0;
|
|
1334
1485
|
const target = range ?? key;
|
|
1335
1486
|
if (this.buffer) {
|
|
@@ -1349,6 +1500,7 @@ var SqlStore = class {
|
|
|
1349
1500
|
async clear() {
|
|
1350
1501
|
await this.owner.ensureReady();
|
|
1351
1502
|
this.assertMutable();
|
|
1503
|
+
if (this.store === "block_revisions") throw new StorageError(409, "block history requires explicit wipe", "revision_conflict");
|
|
1352
1504
|
if (this.buffer) {
|
|
1353
1505
|
this.cleared?.add(this.store);
|
|
1354
1506
|
const prefix = `${this.store}\0`;
|
|
@@ -1404,6 +1556,6 @@ var BrowserTursoStorage = class extends SqlStorage {
|
|
|
1404
1556
|
}
|
|
1405
1557
|
};
|
|
1406
1558
|
|
|
1407
|
-
export { BrowserTursoStorage, MAX_BODY, SCHEMA_TABLE, SqlStorage,
|
|
1559
|
+
export { BrowserTursoStorage, MAX_BODY, SCHEMA_TABLE, SqlStorage, clearRows, countRows, decodeRowJson, deleteRows, encodeRowJson, getRow, getRows, handleDomainQuery, initializeDomain, initializeStore, libsqlDatabase, listRows, listRowsFromIndexMany, putRow, putRows, rebuildProjections, refreshDomain, storeDefinition, validateTursoUrl, wipeDomain, wipeRows };
|
|
1408
1560
|
//# sourceMappingURL=index.mjs.map
|
|
1409
1561
|
//# sourceMappingURL=index.mjs.map
|