@bitcobblers/wod-wiki-storage 0.0.0-stage → 0.35.2116
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 +405 -0
- package/dist/chunk-DAGT3P7M.mjs.map +1 -0
- package/dist/index.cjs +623 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +60 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.mjs +206 -0
- package/dist/index.mjs.map +1 -0
- package/dist/sql/index.cjs +1779 -0
- package/dist/sql/index.cjs.map +1 -0
- package/dist/sql/index.d.cts +220 -0
- package/dist/sql/index.d.ts +220 -0
- package/dist/sql/index.mjs +1409 -0
- package/dist/sql/index.mjs.map +1 -0
- package/dist/wire-C7ZtlBKe.d.cts +489 -0
- package/dist/wire-C7ZtlBKe.d.ts +489 -0
- package/package.json +50 -4
- package/README.md +0 -3
|
@@ -0,0 +1,1779 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var wodWikiWql = require('@bitcobblers/wod-wiki-wql');
|
|
4
|
+
var web = require('@libsql/client/web');
|
|
5
|
+
|
|
6
|
+
// src/sql/errors.ts
|
|
7
|
+
var StorageError = class extends Error {
|
|
8
|
+
constructor(status, message, code) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.status = status;
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.name = "StorageError";
|
|
13
|
+
}
|
|
14
|
+
status;
|
|
15
|
+
code;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// src/sql/connection.ts
|
|
19
|
+
function libsqlRows(result) {
|
|
20
|
+
return result.rows;
|
|
21
|
+
}
|
|
22
|
+
function libsqlDatabase(client) {
|
|
23
|
+
let chain = Promise.resolve();
|
|
24
|
+
const serialize = (fn) => {
|
|
25
|
+
const next = chain.then(fn, fn);
|
|
26
|
+
chain = next.then(() => void 0, () => void 0);
|
|
27
|
+
return next;
|
|
28
|
+
};
|
|
29
|
+
return {
|
|
30
|
+
dialect: "sqlite",
|
|
31
|
+
query: (sql, params = []) => serialize(() => client.execute({ sql, args: params }).then(libsqlRows)),
|
|
32
|
+
exec: async (sql, params = []) => {
|
|
33
|
+
await serialize(() => client.execute({ sql, args: params }));
|
|
34
|
+
},
|
|
35
|
+
transaction(fn) {
|
|
36
|
+
return serialize(async () => {
|
|
37
|
+
const tx = await client.transaction("write");
|
|
38
|
+
try {
|
|
39
|
+
const txConn = {
|
|
40
|
+
query: (sql, params = []) => tx.execute({ sql, args: params }).then(libsqlRows),
|
|
41
|
+
exec: async (sql, params = []) => {
|
|
42
|
+
await tx.execute({ sql, args: params });
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
const result = await fn(txConn);
|
|
46
|
+
await tx.commit();
|
|
47
|
+
return result;
|
|
48
|
+
} catch (err) {
|
|
49
|
+
try {
|
|
50
|
+
await tx.rollback();
|
|
51
|
+
} catch {
|
|
52
|
+
}
|
|
53
|
+
throw err;
|
|
54
|
+
} finally {
|
|
55
|
+
tx.close();
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
},
|
|
59
|
+
close: async () => {
|
|
60
|
+
await chain;
|
|
61
|
+
client.close();
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// src/sql/codec.ts
|
|
67
|
+
var TAG = "$binary";
|
|
68
|
+
var LEN = "$bytes";
|
|
69
|
+
function bytesToBase64(bytes) {
|
|
70
|
+
let binary = "";
|
|
71
|
+
const CHUNK = 32768;
|
|
72
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
73
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
74
|
+
}
|
|
75
|
+
return btoa(binary);
|
|
76
|
+
}
|
|
77
|
+
function base64ToBytes(base64) {
|
|
78
|
+
const binary = atob(base64);
|
|
79
|
+
const bytes = new Uint8Array(binary.length);
|
|
80
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
81
|
+
return bytes;
|
|
82
|
+
}
|
|
83
|
+
function reviveBinary(value) {
|
|
84
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
|
|
85
|
+
const keys = Object.keys(value);
|
|
86
|
+
if (keys.length !== 2 || !keys.includes(TAG) || !keys.includes(LEN)) return value;
|
|
87
|
+
const tag = value;
|
|
88
|
+
if (typeof tag[TAG] !== "string" || typeof tag[LEN] !== "number") return value;
|
|
89
|
+
const bytes = base64ToBytes(tag[TAG]);
|
|
90
|
+
if (bytes.length !== tag[LEN]) {
|
|
91
|
+
throw new Error(`corrupt binary cell: declared ${tag[LEN]} bytes, decoded ${bytes.length}`);
|
|
92
|
+
}
|
|
93
|
+
return bytes.buffer;
|
|
94
|
+
}
|
|
95
|
+
function encodeRowJson(value, binaryField) {
|
|
96
|
+
if (binaryField && value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
97
|
+
const row = value;
|
|
98
|
+
const data = row[binaryField];
|
|
99
|
+
if (data instanceof ArrayBuffer) {
|
|
100
|
+
const bytes = new Uint8Array(data);
|
|
101
|
+
return JSON.stringify({ ...row, [binaryField]: { [TAG]: bytesToBase64(bytes), [LEN]: bytes.length } });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return JSON.stringify(value);
|
|
105
|
+
}
|
|
106
|
+
function decodeRowJson(text2, binaryField) {
|
|
107
|
+
const row = JSON.parse(text2);
|
|
108
|
+
if (binaryField && row !== null && typeof row === "object" && !Array.isArray(row)) {
|
|
109
|
+
const record = row;
|
|
110
|
+
const revived = reviveBinary(record[binaryField]);
|
|
111
|
+
if (revived !== record[binaryField]) record[binaryField] = revived;
|
|
112
|
+
}
|
|
113
|
+
return row;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// src/schema.ts
|
|
117
|
+
var DB_VERSION = 28;
|
|
118
|
+
var COLUMN_TYPES = {
|
|
119
|
+
notes: { date: "number", createdAt: "number" },
|
|
120
|
+
page: { createdAt: "number" },
|
|
121
|
+
// page.date is a civil 'YYYY-MM-DD' string → TEXT
|
|
122
|
+
page_notes: { position: "number", createdAt: "number" },
|
|
123
|
+
tags: { createdAt: "number" },
|
|
124
|
+
tag_types: { createdAt: "number" },
|
|
125
|
+
note_tags: {},
|
|
126
|
+
segments: {
|
|
127
|
+
version: "number",
|
|
128
|
+
position: "number",
|
|
129
|
+
createdAt: "number",
|
|
130
|
+
updatedAt: "number",
|
|
131
|
+
isHistory: "boolean"
|
|
132
|
+
},
|
|
133
|
+
sessions: {
|
|
134
|
+
segmentVersion: "number",
|
|
135
|
+
version: "number",
|
|
136
|
+
startTime: "number",
|
|
137
|
+
endTime: "number",
|
|
138
|
+
duration: "number",
|
|
139
|
+
roundsCompleted: "number",
|
|
140
|
+
totalRounds: "number",
|
|
141
|
+
repsCompleted: "number",
|
|
142
|
+
completed: "boolean",
|
|
143
|
+
createdAt: "number"
|
|
144
|
+
},
|
|
145
|
+
attachments: { createdAt: "number" },
|
|
146
|
+
events: {
|
|
147
|
+
timestamp: "number",
|
|
148
|
+
segmentVersion: "number",
|
|
149
|
+
line: "number",
|
|
150
|
+
sourceStatementId: "number",
|
|
151
|
+
stackLevel: "number",
|
|
152
|
+
"timeSpan.started": "number",
|
|
153
|
+
"timeSpan.ended": "number"
|
|
154
|
+
},
|
|
155
|
+
field_catalog: {},
|
|
156
|
+
field_sources: {},
|
|
157
|
+
field_values: {},
|
|
158
|
+
field_catalog_meta: {},
|
|
159
|
+
efforts: { "baseAttributes.met": "number" },
|
|
160
|
+
block_index: {
|
|
161
|
+
segmentVersion: "number",
|
|
162
|
+
position: "number",
|
|
163
|
+
createdAt: "number",
|
|
164
|
+
isStatic: "boolean"
|
|
165
|
+
},
|
|
166
|
+
block_efforts: { createdAt: "number", isStatic: "boolean" },
|
|
167
|
+
meta: {},
|
|
168
|
+
memberships: { createdAt: "number" }
|
|
169
|
+
};
|
|
170
|
+
var STORE_DEFS = [
|
|
171
|
+
// 1. notes
|
|
172
|
+
{ name: "notes", keyPath: ["id"], indexes: [
|
|
173
|
+
{ name: "by-date", keyPath: ["date"] },
|
|
174
|
+
{ name: "by-created", keyPath: ["createdAt"] },
|
|
175
|
+
{ name: "by-user", keyPath: ["userId"] }
|
|
176
|
+
] },
|
|
177
|
+
// 2. page
|
|
178
|
+
{ name: "page", keyPath: ["id"], indexes: [
|
|
179
|
+
{ name: "by-date", keyPath: ["date"], unique: true },
|
|
180
|
+
{ name: "by-slug", keyPath: ["slug"], unique: true },
|
|
181
|
+
{ name: "by-user", keyPath: ["userId"] }
|
|
182
|
+
] },
|
|
183
|
+
// 2b. page_notes (V22)
|
|
184
|
+
{ name: "page_notes", keyPath: ["id"], indexes: [
|
|
185
|
+
{ name: "by-page", keyPath: ["pageId"] },
|
|
186
|
+
{ name: "by-note", keyPath: ["noteId"] },
|
|
187
|
+
{ name: "by-page-note", keyPath: ["pageId", "noteId"], unique: true },
|
|
188
|
+
{ name: "by-user", keyPath: ["userId"] }
|
|
189
|
+
] },
|
|
190
|
+
// 3. tags
|
|
191
|
+
{ name: "tags", keyPath: ["id"], indexes: [
|
|
192
|
+
{ name: "by-label", keyPath: ["label"], unique: true },
|
|
193
|
+
{ name: "by-type", keyPath: ["type"] },
|
|
194
|
+
{ name: "by-user", keyPath: ["userId"] }
|
|
195
|
+
] },
|
|
196
|
+
// 3a. tag_types (V23)
|
|
197
|
+
{ name: "tag_types", keyPath: ["id"], indexes: [
|
|
198
|
+
{ name: "by-name", keyPath: ["name"], unique: true },
|
|
199
|
+
{ name: "by-user", keyPath: ["userId"] }
|
|
200
|
+
] },
|
|
201
|
+
// 4. note_tags
|
|
202
|
+
{ name: "note_tags", keyPath: ["id"], indexes: [
|
|
203
|
+
{ name: "by-note", keyPath: ["noteId"] },
|
|
204
|
+
{ name: "by-tag", keyPath: ["tagId"] },
|
|
205
|
+
{ name: "by-note-tag", keyPath: ["noteId", "tagId"], unique: true },
|
|
206
|
+
{ name: "by-user", keyPath: ["userId"] }
|
|
207
|
+
] },
|
|
208
|
+
// 5. segments
|
|
209
|
+
{ name: "segments", keyPath: ["id", "version"], indexes: [
|
|
210
|
+
{ name: "by-note", keyPath: ["noteId"] },
|
|
211
|
+
{ name: "by-type", keyPath: ["dataType"] },
|
|
212
|
+
{ name: "by-page", keyPath: ["pageId"] },
|
|
213
|
+
{ name: "by-history", keyPath: ["isHistory"] },
|
|
214
|
+
{ name: "by-user", keyPath: ["userId"] }
|
|
215
|
+
] },
|
|
216
|
+
// 6. sessions
|
|
217
|
+
{ name: "sessions", keyPath: ["id"], indexes: [
|
|
218
|
+
{ name: "by-segment", keyPath: ["segmentId"] },
|
|
219
|
+
{ name: "by-note", keyPath: ["noteId"] },
|
|
220
|
+
{ name: "by-completed", keyPath: ["createdAt"] },
|
|
221
|
+
{ name: "by-content", keyPath: ["blockContentId"] },
|
|
222
|
+
{ name: "by-block", keyPath: ["blockId"] },
|
|
223
|
+
{ name: "by-page", keyPath: ["pageId"] },
|
|
224
|
+
{ name: "by-origin", keyPath: ["origin"] },
|
|
225
|
+
{ name: "by-user", keyPath: ["userId"] }
|
|
226
|
+
] },
|
|
227
|
+
// 7. results — removed in V26: legacy alias of sessions. The V26 upgrade
|
|
228
|
+
// migrates any residual rows into `sessions` (projecting pre-V21 data.logs
|
|
229
|
+
// into event rows) before dropping the store; no compatibility alias.
|
|
230
|
+
// 8. attachments
|
|
231
|
+
{ name: "attachments", keyPath: ["id"], indexes: [
|
|
232
|
+
{ name: "by-note", keyPath: ["noteId"] },
|
|
233
|
+
{ name: "by-time", keyPath: ["createdAt"] },
|
|
234
|
+
{ name: "by-page", keyPath: ["pageId"] },
|
|
235
|
+
{ name: "by-result", keyPath: ["resultId"] },
|
|
236
|
+
{ name: "by-user", keyPath: ["userId"] }
|
|
237
|
+
] },
|
|
238
|
+
// 9. events
|
|
239
|
+
{ name: "events", keyPath: ["id"], indexes: [
|
|
240
|
+
{ name: "by-timestamp", keyPath: ["timestamp"] },
|
|
241
|
+
{ name: "by-result-grain", keyPath: ["resultId", "grain"] },
|
|
242
|
+
{ name: "by-content-grain", keyPath: ["blockContentId", "grain"] },
|
|
243
|
+
{ name: "by-effort", keyPath: ["effortSlug"] },
|
|
244
|
+
{ name: "by-outputType", keyPath: ["outputType"] },
|
|
245
|
+
{ name: "by-grain", keyPath: ["grain"] },
|
|
246
|
+
{ name: "by-metric-date", keyPath: ["metricDateKeys"], multiEntry: true },
|
|
247
|
+
{ name: "by-user", keyPath: ["userId"] }
|
|
248
|
+
] },
|
|
249
|
+
// 10. field catalog
|
|
250
|
+
{ name: "field_catalog", keyPath: ["id"], indexes: [
|
|
251
|
+
{ name: "by-path", keyPath: ["path"] }
|
|
252
|
+
] },
|
|
253
|
+
{ name: "field_sources", keyPath: ["id"], indexes: [] },
|
|
254
|
+
{ name: "field_values", keyPath: ["key"], indexes: [
|
|
255
|
+
{ name: "by-field", keyPath: ["fieldId"] }
|
|
256
|
+
] },
|
|
257
|
+
{ name: "field_catalog_meta", keyPath: ["id"], indexes: [] },
|
|
258
|
+
// 11. efforts
|
|
259
|
+
{ name: "efforts", keyPath: ["slug"], indexes: [
|
|
260
|
+
{ name: "by-discipline", keyPath: ["baseAttributes.discipline"] },
|
|
261
|
+
{ name: "by-source", keyPath: ["registrySource"] },
|
|
262
|
+
{ name: "by-user", keyPath: ["userId"] }
|
|
263
|
+
] },
|
|
264
|
+
// 12. block_index
|
|
265
|
+
{ name: "block_index", keyPath: ["id"], indexes: [
|
|
266
|
+
{ name: "by-note", keyPath: ["noteId"] },
|
|
267
|
+
{ name: "by-content", keyPath: ["blockContentId"] },
|
|
268
|
+
{ name: "by-type", keyPath: ["dataType"] },
|
|
269
|
+
{ name: "by-source", keyPath: ["sourceId"] },
|
|
270
|
+
{ name: "by-user", keyPath: ["userId"] }
|
|
271
|
+
] },
|
|
272
|
+
// 13. block_efforts
|
|
273
|
+
{ name: "block_efforts", keyPath: ["id"], indexes: [
|
|
274
|
+
{ name: "by-note", keyPath: ["noteId"] },
|
|
275
|
+
{ name: "by-effort", keyPath: ["effortSlug"] },
|
|
276
|
+
{ name: "by-block", keyPath: ["blockContentId"] },
|
|
277
|
+
{ name: "by-user", keyPath: ["userId"] }
|
|
278
|
+
] },
|
|
279
|
+
// 14. meta
|
|
280
|
+
{ name: "meta", keyPath: ["key"], indexes: [] },
|
|
281
|
+
// 15. memberships (V25, system — survives wipe; truth lives in
|
|
282
|
+
// localStorage locally, in the SQL table on the API)
|
|
283
|
+
{ name: "memberships", keyPath: ["id"], indexes: [], system: true }
|
|
284
|
+
];
|
|
285
|
+
|
|
286
|
+
// src/wire.ts
|
|
287
|
+
function isRange(arg) {
|
|
288
|
+
return arg !== null && typeof arg === "object" && !Array.isArray(arg) && ("only" in arg || "lower" in arg || "upper" in arg || "lowerOpen" in arg || "upperOpen" in arg);
|
|
289
|
+
}
|
|
290
|
+
function extractKeyPart(value, dottedPath) {
|
|
291
|
+
let cur = value;
|
|
292
|
+
for (const part of dottedPath.split(".")) {
|
|
293
|
+
if (cur === null || typeof cur !== "object") return void 0;
|
|
294
|
+
const row = cur;
|
|
295
|
+
if (!(part in row)) return void 0;
|
|
296
|
+
cur = row[part];
|
|
297
|
+
}
|
|
298
|
+
if (cur === null || typeof cur === "object" || cur === void 0) return void 0;
|
|
299
|
+
return cur;
|
|
300
|
+
}
|
|
301
|
+
function keyFromValue(def, value) {
|
|
302
|
+
const parts = def.keyPath.map((p) => extractKeyPart(value, p));
|
|
303
|
+
for (const [i, part] of parts.entries()) {
|
|
304
|
+
if (part === void 0) {
|
|
305
|
+
throw new Error(`keyFromValue(${def.name}): keyPath part '${def.keyPath[i]}' missing on value`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const key = parts;
|
|
309
|
+
return key.length === 1 ? key[0] : key;
|
|
310
|
+
}
|
|
311
|
+
function rangeFromIDB(query) {
|
|
312
|
+
if (query === void 0) return void 0;
|
|
313
|
+
if (!isRange(query)) {
|
|
314
|
+
const key = query;
|
|
315
|
+
return { only: key };
|
|
316
|
+
}
|
|
317
|
+
if (query.only !== void 0) return query;
|
|
318
|
+
const wire = {};
|
|
319
|
+
if (query.lower !== void 0 && query.lower !== null) {
|
|
320
|
+
wire.lower = query.lower;
|
|
321
|
+
wire.lowerOpen = query.lowerOpen === true;
|
|
322
|
+
}
|
|
323
|
+
if (query.upper !== void 0 && query.upper !== null) {
|
|
324
|
+
wire.upper = query.upper;
|
|
325
|
+
wire.upperOpen = query.upperOpen === true;
|
|
326
|
+
}
|
|
327
|
+
return wire.lower !== void 0 || wire.upper !== void 0 ? wire : void 0;
|
|
328
|
+
}
|
|
329
|
+
function compareKeys(a, b) {
|
|
330
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
331
|
+
if (!Array.isArray(a) || !Array.isArray(b)) return Array.isArray(a) ? 1 : -1;
|
|
332
|
+
for (let i = 0; i < Math.min(a.length, b.length); i++) {
|
|
333
|
+
const c = compareKeys(a[i], b[i]);
|
|
334
|
+
if (c !== 0) return c;
|
|
335
|
+
}
|
|
336
|
+
return a.length - b.length;
|
|
337
|
+
}
|
|
338
|
+
if (typeof a === "number" && typeof b === "number") {
|
|
339
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
340
|
+
}
|
|
341
|
+
if (typeof a === "number" || typeof b === "number") return typeof a === "number" ? -1 : 1;
|
|
342
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
343
|
+
}
|
|
344
|
+
function keyInRange(key, range) {
|
|
345
|
+
if (range.only !== void 0) return compareKeys(key, range.only) === 0;
|
|
346
|
+
if (range.lower !== void 0) {
|
|
347
|
+
const c = compareKeys(key, range.lower);
|
|
348
|
+
if (c < 0 || c === 0 && range.lowerOpen === true) return false;
|
|
349
|
+
}
|
|
350
|
+
if (range.upper !== void 0) {
|
|
351
|
+
const c = compareKeys(key, range.upper);
|
|
352
|
+
if (c > 0 || c === 0 && range.upperOpen === true) return false;
|
|
353
|
+
}
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// src/sql/store.ts
|
|
358
|
+
var SCHEMA_TABLE = "_wodwiki_schema";
|
|
359
|
+
var SCHEMA_KEY = "schema";
|
|
360
|
+
var BINARY_FIELD = { attachments: "data" };
|
|
361
|
+
var binaryFieldOf = (def) => BINARY_FIELD[def.name];
|
|
362
|
+
function storeDefinition(name) {
|
|
363
|
+
const def = STORE_DEFS.find((d) => d.name === name);
|
|
364
|
+
if (!def) throw new StorageError(404, `unknown store: ${name}`);
|
|
365
|
+
return def;
|
|
366
|
+
}
|
|
367
|
+
function indexDefinition(def, indexName) {
|
|
368
|
+
const idx = def.indexes.find((i) => i.name === indexName);
|
|
369
|
+
if (!idx) throw new StorageError(404, `unknown index: ${def.name}.${indexName}`);
|
|
370
|
+
return idx;
|
|
371
|
+
}
|
|
372
|
+
function sanitize(name) {
|
|
373
|
+
return name.replace(/[^A-Za-z0-9_]/g, "_");
|
|
374
|
+
}
|
|
375
|
+
var indexCol = (indexName, part) => `i_${sanitize(indexName)}_${part}`;
|
|
376
|
+
function keyCols(def) {
|
|
377
|
+
return def.keyPath.map((_, i) => `k${i}`);
|
|
378
|
+
}
|
|
379
|
+
var junctionTable = (def, idx) => `${def.name}__${sanitize(idx.name)}`;
|
|
380
|
+
var multiEntryIndex = (def) => def.indexes.find((i) => i.multiEntry);
|
|
381
|
+
function pathType(def, path) {
|
|
382
|
+
return COLUMN_TYPES[def.name][path] ?? "text";
|
|
383
|
+
}
|
|
384
|
+
function colSql(dialect, type) {
|
|
385
|
+
if (dialect === "sqlite") return "";
|
|
386
|
+
return type === "text" ? ' TEXT COLLATE "C"' : " DOUBLE PRECISION";
|
|
387
|
+
}
|
|
388
|
+
function toSqlScalar(v) {
|
|
389
|
+
if (v === null || v === void 0) return null;
|
|
390
|
+
if (typeof v === "boolean") return v ? 1 : 0;
|
|
391
|
+
if (typeof v === "number" || typeof v === "string") return v;
|
|
392
|
+
return JSON.stringify(v);
|
|
393
|
+
}
|
|
394
|
+
function validateKey(key) {
|
|
395
|
+
const scalar = (v) => typeof v === "string" || typeof v === "number" && Number.isFinite(v);
|
|
396
|
+
if (!scalar(key) && !(Array.isArray(key) && key.length > 0 && key.every(scalar))) {
|
|
397
|
+
throw new StorageError(400, "key must be a string, finite number, or non-empty scalar tuple");
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
function validateRange(range) {
|
|
401
|
+
if (range === void 0) return;
|
|
402
|
+
if (range === null || typeof range !== "object" || Array.isArray(range)) {
|
|
403
|
+
throw new StorageError(400, "range must be an object");
|
|
404
|
+
}
|
|
405
|
+
for (const key of [range.only, range.lower, range.upper]) {
|
|
406
|
+
if (key !== void 0) validateKey(key);
|
|
407
|
+
}
|
|
408
|
+
for (const open of [range.lowerOpen, range.upperOpen]) {
|
|
409
|
+
if (open !== void 0 && typeof open !== "boolean") {
|
|
410
|
+
throw new StorageError(400, "range open flags must be boolean");
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
function toParts(key) {
|
|
415
|
+
validateKey(key);
|
|
416
|
+
return Array.isArray(key) ? key : [key];
|
|
417
|
+
}
|
|
418
|
+
function arrayPart(value, dotted) {
|
|
419
|
+
let cur = value;
|
|
420
|
+
for (const part of dotted.split(".")) {
|
|
421
|
+
if (cur === null || typeof cur !== "object") return void 0;
|
|
422
|
+
const row = cur;
|
|
423
|
+
cur = row[part];
|
|
424
|
+
}
|
|
425
|
+
return Array.isArray(cur) ? cur : void 0;
|
|
426
|
+
}
|
|
427
|
+
function requireKey(def, value) {
|
|
428
|
+
const parts = def.keyPath.map((p) => extractKeyPart(value, p));
|
|
429
|
+
for (const [i, part] of parts.entries()) {
|
|
430
|
+
if (part === void 0) {
|
|
431
|
+
throw new StorageError(400, `keyPath part '${def.keyPath[i]}' missing on ${def.name} value`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
const key = parts;
|
|
435
|
+
return key.length === 1 ? key[0] : key;
|
|
436
|
+
}
|
|
437
|
+
function rangeWhere(cols, range, params) {
|
|
438
|
+
if (!range) return "";
|
|
439
|
+
if (range.only !== void 0) {
|
|
440
|
+
const parts = toParts(range.only);
|
|
441
|
+
if (parts.length > cols.length) return null;
|
|
442
|
+
const clauses2 = parts.map((p, i) => {
|
|
443
|
+
params.push(toSqlScalar(p));
|
|
444
|
+
return `"${cols[i]}" = ?`;
|
|
445
|
+
});
|
|
446
|
+
return ` WHERE ${clauses2.join(" AND ")}`;
|
|
447
|
+
}
|
|
448
|
+
const bounds = [];
|
|
449
|
+
if (range.lower !== void 0 && range.lower !== null) {
|
|
450
|
+
bounds.push([toParts(range.lower), range.lowerOpen ? ">" : ">="]);
|
|
451
|
+
}
|
|
452
|
+
if (range.upper !== void 0 && range.upper !== null) {
|
|
453
|
+
bounds.push([toParts(range.upper), range.upperOpen ? "<" : "<="]);
|
|
454
|
+
}
|
|
455
|
+
const clauses = [];
|
|
456
|
+
for (const [parts, op] of bounds) {
|
|
457
|
+
if (parts.length > cols.length) return null;
|
|
458
|
+
if (parts.length === 1) {
|
|
459
|
+
params.push(toSqlScalar(parts[0]));
|
|
460
|
+
clauses.push(`"${cols[0]}" ${op} ?`);
|
|
461
|
+
} else {
|
|
462
|
+
const ors = parts.map((p, i) => {
|
|
463
|
+
const eqs = [];
|
|
464
|
+
for (let j = 0; j < i; j++) {
|
|
465
|
+
params.push(toSqlScalar(parts[j]));
|
|
466
|
+
eqs.push(`"${cols[j]}" = ?`);
|
|
467
|
+
}
|
|
468
|
+
params.push(toSqlScalar(p));
|
|
469
|
+
const cmpOp = i === parts.length - 1 ? op : op[0];
|
|
470
|
+
eqs.push(`"${cols[i]}" ${cmpOp} ?`);
|
|
471
|
+
return `(${eqs.join(" AND ")})`;
|
|
472
|
+
});
|
|
473
|
+
clauses.push(`(${ors.join(" OR ")})`);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return clauses.length ? ` WHERE ${clauses.join(" AND ")}` : "";
|
|
477
|
+
}
|
|
478
|
+
function clampLimit(count) {
|
|
479
|
+
if (count === void 0) return void 0;
|
|
480
|
+
if (typeof count !== "number" || !Number.isFinite(count)) {
|
|
481
|
+
throw new StorageError(400, `invalid count: ${String(count)}`);
|
|
482
|
+
}
|
|
483
|
+
return Math.max(0, Math.floor(count));
|
|
484
|
+
}
|
|
485
|
+
var limitClause = (count) => count === void 0 ? "" : ` LIMIT ${clampLimit(count)}`;
|
|
486
|
+
function parseRows(rows, binaryField) {
|
|
487
|
+
return rows.map((r) => decodeRowJson(r.data, binaryField));
|
|
488
|
+
}
|
|
489
|
+
async function scanRows(conn, def, idx, range, limit) {
|
|
490
|
+
const rows = await conn.query(`SELECT "data" FROM "${def.name}"`);
|
|
491
|
+
const keyed = [];
|
|
492
|
+
for (const row of rows) {
|
|
493
|
+
const value = decodeRowJson(row.data, binaryFieldOf(def));
|
|
494
|
+
if (idx) {
|
|
495
|
+
const parts = idx.keyPath.map((p) => extractKeyPart(value, p));
|
|
496
|
+
if (!parts.every((p) => p !== void 0)) continue;
|
|
497
|
+
const key = parts.length === 1 ? parts[0] : parts;
|
|
498
|
+
if (range && !keyInRange(key, range)) continue;
|
|
499
|
+
keyed.push([key, value]);
|
|
500
|
+
} else {
|
|
501
|
+
const key = requireKey(def, value);
|
|
502
|
+
if (range && !keyInRange(key, range)) continue;
|
|
503
|
+
keyed.push([key, value]);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
keyed.sort((a, b) => compareKeys(a[0], b[0]));
|
|
507
|
+
const out = keyed.map(([, v]) => v);
|
|
508
|
+
return limit === void 0 ? out : out.slice(0, limit);
|
|
509
|
+
}
|
|
510
|
+
async function getRow(conn, def, key) {
|
|
511
|
+
const parts = toParts(key);
|
|
512
|
+
if (parts.length !== def.keyPath.length) {
|
|
513
|
+
throw new StorageError(400, `key arity ${parts.length} != keyPath arity ${def.keyPath.length} for ${def.name}`);
|
|
514
|
+
}
|
|
515
|
+
const cond = keyCols(def).map((c) => `"${c}" = ?`).join(" AND ");
|
|
516
|
+
const rows = await conn.query(
|
|
517
|
+
`SELECT "data" FROM "${def.name}" WHERE ${cond}`,
|
|
518
|
+
parts.map(toSqlScalar)
|
|
519
|
+
);
|
|
520
|
+
return rows[0] ? decodeRowJson(rows[0].data, binaryFieldOf(def)) : void 0;
|
|
521
|
+
}
|
|
522
|
+
async function listRows(conn, def, range, count, indexName) {
|
|
523
|
+
validateRange(range);
|
|
524
|
+
const idx = indexName ? indexDefinition(def, indexName) : void 0;
|
|
525
|
+
const binaryField = binaryFieldOf(def);
|
|
526
|
+
if (idx?.multiEntry) {
|
|
527
|
+
const only = range?.only;
|
|
528
|
+
if (only === void 0 || typeof only === "object") {
|
|
529
|
+
throw new StorageError(400, `multi-entry index ${def.name}.${idx.name} supports exact-match queries only`);
|
|
530
|
+
}
|
|
531
|
+
const joinOn = keyCols(def).map((c) => `j."${c}" = e."${c}"`).join(" AND ");
|
|
532
|
+
const order2 = ['j."v"', ...keyCols(def).map((c) => `j."${c}"`)].join(", ");
|
|
533
|
+
const rows2 = await conn.query(
|
|
534
|
+
`SELECT e."data" AS "data" FROM "${def.name}" e JOIN "${junctionTable(def, idx)}" j ON ${joinOn} WHERE j."v" = ? ORDER BY ${order2}${limitClause(count)}`,
|
|
535
|
+
[JSON.stringify(only)]
|
|
536
|
+
);
|
|
537
|
+
return parseRows(rows2, binaryField);
|
|
538
|
+
}
|
|
539
|
+
const cols = idx ? idx.keyPath.map((_, part) => indexCol(idx.name, part)) : keyCols(def);
|
|
540
|
+
const params = [];
|
|
541
|
+
const where = rangeWhere(cols, range, params);
|
|
542
|
+
if (where === null) return scanRows(conn, def, idx, range, clampLimit(count));
|
|
543
|
+
const notNull = cols.map((c) => `"${c}" IS NOT NULL`).join(" AND ");
|
|
544
|
+
const cond = where ? `${where} AND ${notNull}` : ` WHERE ${notNull}`;
|
|
545
|
+
const keyNames = keyCols(def).filter((k) => !cols.includes(k));
|
|
546
|
+
const order = ` ORDER BY ${[...cols, ...keyNames].map((c) => `"${c}"`).join(", ")}`;
|
|
547
|
+
const rows = await conn.query(
|
|
548
|
+
`SELECT "data" FROM "${def.name}"${cond}${order}${limitClause(count)}`,
|
|
549
|
+
params
|
|
550
|
+
);
|
|
551
|
+
return parseRows(rows, binaryField);
|
|
552
|
+
}
|
|
553
|
+
async function countRows(conn, def, range, indexName) {
|
|
554
|
+
validateRange(range);
|
|
555
|
+
const idx = indexName ? indexDefinition(def, indexName) : void 0;
|
|
556
|
+
if (idx?.multiEntry) {
|
|
557
|
+
const only = range?.only;
|
|
558
|
+
if (only === void 0 || typeof only === "object") {
|
|
559
|
+
throw new StorageError(400, `multi-entry index ${def.name}.${idx.name} supports exact-match queries only`);
|
|
560
|
+
}
|
|
561
|
+
const joinOn = keyCols(def).map((c) => `j."${c}" = e."${c}"`).join(" AND ");
|
|
562
|
+
const rows2 = await conn.query(
|
|
563
|
+
`SELECT COUNT(*) AS "n" FROM "${def.name}" e JOIN "${junctionTable(def, idx)}" j ON ${joinOn} WHERE j."v" = ?`,
|
|
564
|
+
[JSON.stringify(only)]
|
|
565
|
+
);
|
|
566
|
+
return Number(rows2[0]?.n ?? 0);
|
|
567
|
+
}
|
|
568
|
+
const cols = idx ? idx.keyPath.map((_, part) => indexCol(idx.name, part)) : keyCols(def);
|
|
569
|
+
const params = [];
|
|
570
|
+
const where = rangeWhere(cols, range, params);
|
|
571
|
+
if (where === null) {
|
|
572
|
+
const all = await scanRows(conn, def, idx, range, void 0);
|
|
573
|
+
return all.length;
|
|
574
|
+
}
|
|
575
|
+
const cond = idx ? where ? `${where} AND ${cols.map((c) => `"${c}" IS NOT NULL`).join(" AND ")}` : ` WHERE ${cols.map((c) => `"${c}" IS NOT NULL`).join(" AND ")}` : where;
|
|
576
|
+
const rows = await conn.query(`SELECT COUNT(*) AS "n" FROM "${def.name}"${cond}`, params);
|
|
577
|
+
return Number(rows[0]?.n ?? 0);
|
|
578
|
+
}
|
|
579
|
+
async function putRow(conn, def, value) {
|
|
580
|
+
const key = requireKey(def, value);
|
|
581
|
+
const keyParts = toParts(key).map(toSqlScalar);
|
|
582
|
+
const keys = keyCols(def);
|
|
583
|
+
const cols = [...keys];
|
|
584
|
+
const vals = [...keyParts];
|
|
585
|
+
for (const idx of def.indexes) {
|
|
586
|
+
if (idx.multiEntry) continue;
|
|
587
|
+
idx.keyPath.forEach((path, part) => {
|
|
588
|
+
cols.push(indexCol(idx.name, part));
|
|
589
|
+
vals.push(toSqlScalar(extractKeyPart(value, path) ?? null));
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
cols.push("data");
|
|
593
|
+
vals.push(encodeRowJson(value, binaryFieldOf(def)));
|
|
594
|
+
const keySet = new Set(keys);
|
|
595
|
+
const updates = cols.filter((c) => !keySet.has(c)).map((c) => `"${c}" = excluded."${c}"`).join(", ");
|
|
596
|
+
const collist = cols.map((c) => `"${c}"`).join(", ");
|
|
597
|
+
const placeholders = cols.map(() => "?").join(", ");
|
|
598
|
+
const pk = keys.map((k) => `"${k}"`).join(", ");
|
|
599
|
+
await conn.exec(
|
|
600
|
+
`INSERT INTO "${def.name}" (${collist}) VALUES (${placeholders}) ON CONFLICT (${pk}) DO UPDATE SET ${updates}`,
|
|
601
|
+
vals
|
|
602
|
+
);
|
|
603
|
+
const multi = multiEntryIndex(def);
|
|
604
|
+
if (multi) {
|
|
605
|
+
const jt = junctionTable(def, multi);
|
|
606
|
+
const cond = keys.map((c) => `"${c}" = ?`).join(" AND ");
|
|
607
|
+
await conn.exec(`DELETE FROM "${jt}" WHERE ${cond}`, keyParts);
|
|
608
|
+
const items = arrayPart(value, multi.keyPath[0]);
|
|
609
|
+
if (Array.isArray(items)) {
|
|
610
|
+
const jcols = [...keys, "v"];
|
|
611
|
+
const jcollist = jcols.map((c) => `"${c}"`).join(", ");
|
|
612
|
+
const jph = jcols.map(() => "?").join(", ");
|
|
613
|
+
for (const item of items) {
|
|
614
|
+
await conn.exec(
|
|
615
|
+
`INSERT INTO "${jt}" (${jcollist}) VALUES (${jph}) ON CONFLICT DO NOTHING`,
|
|
616
|
+
[...keyParts, JSON.stringify(item)]
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return key;
|
|
622
|
+
}
|
|
623
|
+
async function deleteRows(conn, def, keyOrRange) {
|
|
624
|
+
const multi = multiEntryIndex(def);
|
|
625
|
+
if (!isRange(keyOrRange)) {
|
|
626
|
+
const parts = toParts(keyOrRange);
|
|
627
|
+
if (parts.length > def.keyPath.length) {
|
|
628
|
+
throw new StorageError(400, `key arity ${parts.length} > keyPath arity ${def.keyPath.length} for ${def.name}`);
|
|
629
|
+
}
|
|
630
|
+
const n = Math.min(parts.length, def.keyPath.length);
|
|
631
|
+
const cond = keyCols(def).slice(0, n).map((c) => `"${c}" = ?`).join(" AND ");
|
|
632
|
+
const params2 = parts.slice(0, n).map(toSqlScalar);
|
|
633
|
+
if (multi) await conn.exec(`DELETE FROM "${junctionTable(def, multi)}" WHERE ${cond}`, params2);
|
|
634
|
+
await conn.exec(`DELETE FROM "${def.name}" WHERE ${cond}`, params2);
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
validateRange(keyOrRange);
|
|
638
|
+
const cols = keyCols(def);
|
|
639
|
+
const params = [];
|
|
640
|
+
const where = rangeWhere(cols, keyOrRange, params);
|
|
641
|
+
if (where === null) {
|
|
642
|
+
for (const row of await listRows(conn, def, keyOrRange)) {
|
|
643
|
+
await deleteRows(conn, def, requireKey(def, row));
|
|
644
|
+
}
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
const collist = cols.map((c) => `"${c}"`).join(", ");
|
|
648
|
+
if (multi) {
|
|
649
|
+
await conn.exec(
|
|
650
|
+
`DELETE FROM "${junctionTable(def, multi)}" WHERE (${collist}) IN (SELECT ${collist} FROM "${def.name}"${where})`,
|
|
651
|
+
params
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
await conn.exec(`DELETE FROM "${def.name}"${where}`, params);
|
|
655
|
+
}
|
|
656
|
+
async function clearRows(conn, def) {
|
|
657
|
+
const multi = multiEntryIndex(def);
|
|
658
|
+
if (multi) await conn.exec(`DELETE FROM "${junctionTable(def, multi)}"`);
|
|
659
|
+
await conn.exec(`DELETE FROM "${def.name}"`);
|
|
660
|
+
}
|
|
661
|
+
async function wipeRows(conn) {
|
|
662
|
+
for (const def of STORE_DEFS) {
|
|
663
|
+
if (!def.system) await clearRows(conn, def);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
function tableListSql(dialect) {
|
|
667
|
+
return dialect === "sqlite" ? `SELECT "name" FROM "sqlite_master" WHERE "type" = 'table'` : `SELECT "table_name" FROM "information_schema"."tables" WHERE "table_schema" = current_schema()`;
|
|
668
|
+
}
|
|
669
|
+
async function ensureStoreTables(conn, dialect, def) {
|
|
670
|
+
const keys = keyCols(def);
|
|
671
|
+
const cols = keys.map((k, i) => [k, colSql(dialect, pathType(def, def.keyPath[i]))]);
|
|
672
|
+
for (const idx of def.indexes) {
|
|
673
|
+
if (idx.multiEntry) continue;
|
|
674
|
+
idx.keyPath.forEach((path, part) => {
|
|
675
|
+
cols.push([indexCol(idx.name, part), colSql(dialect, pathType(def, path))]);
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
cols.push(["data", " TEXT NOT NULL"]);
|
|
679
|
+
const colDefs = cols.map(([c, t]) => `"${c}"${t}`);
|
|
680
|
+
await conn.exec(
|
|
681
|
+
`CREATE TABLE IF NOT EXISTS "${def.name}" (${colDefs.join(", ")}, PRIMARY KEY (${keys.map((k) => `"${k}"`).join(", ")}))`
|
|
682
|
+
);
|
|
683
|
+
for (const idx of def.indexes) {
|
|
684
|
+
if (idx.multiEntry) continue;
|
|
685
|
+
const parts = idx.keyPath.map((_, part) => `"${indexCol(idx.name, part)}"`).join(", ");
|
|
686
|
+
await conn.exec(
|
|
687
|
+
`CREATE ${idx.unique ? "UNIQUE " : ""}INDEX IF NOT EXISTS "${def.name}__${sanitize(idx.name)}" ON "${def.name}" (${parts})`
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
const multi = multiEntryIndex(def);
|
|
691
|
+
if (multi) {
|
|
692
|
+
const jt = junctionTable(def, multi);
|
|
693
|
+
const jkeys = keys.map((k, i) => `"${k}"${colSql(dialect, pathType(def, def.keyPath[i]))}`);
|
|
694
|
+
await conn.exec(
|
|
695
|
+
`CREATE TABLE IF NOT EXISTS "${jt}" (${jkeys.join(", ")}, "v"${colSql(dialect, "text")}, PRIMARY KEY (${[...keys, '"v"'].join(", ")}))`
|
|
696
|
+
);
|
|
697
|
+
await conn.exec(`CREATE INDEX IF NOT EXISTS "${jt}__v" ON "${jt}" ("v")`);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
async function initializeStore(db) {
|
|
701
|
+
await db.transaction(async (tx) => {
|
|
702
|
+
await tx.exec(
|
|
703
|
+
`CREATE TABLE IF NOT EXISTS "${SCHEMA_TABLE}" ("key" TEXT PRIMARY KEY, "value" TEXT NOT NULL)`
|
|
704
|
+
);
|
|
705
|
+
const storeTables = new Set(STORE_DEFS.map((d) => d.name));
|
|
706
|
+
const tableNames = new Set(
|
|
707
|
+
(await tx.query(tableListSql(db.dialect))).map((r) => String(Object.values(r)[0]))
|
|
708
|
+
);
|
|
709
|
+
const guardRows = await tx.query(`SELECT "value" FROM "${SCHEMA_TABLE}" WHERE "key" = ?`, [SCHEMA_KEY]);
|
|
710
|
+
let stored;
|
|
711
|
+
if (guardRows[0]) {
|
|
712
|
+
const guard = JSON.parse(String(guardRows[0].value));
|
|
713
|
+
if (typeof guard.version !== "number") {
|
|
714
|
+
throw new StorageError(500, `corrupt ${SCHEMA_TABLE} guard row`, "bad_schema_guard");
|
|
715
|
+
}
|
|
716
|
+
stored = guard.version;
|
|
717
|
+
if (stored !== DB_VERSION) {
|
|
718
|
+
throw new StorageError(
|
|
719
|
+
500,
|
|
720
|
+
`database schema v${stored} does not match this server (v${DB_VERSION}); use a compatible server or migrate a backed-up database`,
|
|
721
|
+
"schema_incompatible"
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
} else if ([...tableNames].some((name) => storeTables.has(name))) {
|
|
725
|
+
throw new StorageError(
|
|
726
|
+
500,
|
|
727
|
+
"database contains store tables but no wod-wiki schema guard; refusing to adopt a foreign database \u2014 point SQLITE_PATH/DATABASE_URL at a fresh database",
|
|
728
|
+
"foreign_database"
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
for (const def of STORE_DEFS) await ensureStoreTables(tx, db.dialect, def);
|
|
732
|
+
if (stored !== DB_VERSION) {
|
|
733
|
+
await tx.exec(
|
|
734
|
+
`INSERT INTO "${SCHEMA_TABLE}" ("key", "value") VALUES (?, ?) ON CONFLICT ("key") DO UPDATE SET "value" = excluded."value"`,
|
|
735
|
+
[SCHEMA_KEY, JSON.stringify({ version: DB_VERSION })]
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// src/domain.ts
|
|
742
|
+
var DOMAIN_PROJECTION_VERSION = 1;
|
|
743
|
+
var TABLES = ["domain_note", "query_block", "segment_head"];
|
|
744
|
+
var PROJECTION_GUARD_KEY = "domain_projection";
|
|
745
|
+
function sourceItem(row) {
|
|
746
|
+
const item = {};
|
|
747
|
+
for (const key of ["id", "noteId", "sourceId", "type", "seedOrigin", "seedChunkId", "sourcePath", "catalog"]) {
|
|
748
|
+
const value = row[key];
|
|
749
|
+
if (typeof value === "string") item[key] = value;
|
|
750
|
+
else if (value != null) throw new StorageError(400, `${key} must be a string`);
|
|
751
|
+
}
|
|
752
|
+
return item;
|
|
753
|
+
}
|
|
754
|
+
var num = (v) => typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
755
|
+
var text = (v) => typeof v === "string" ? v : null;
|
|
756
|
+
var T = (v) => text(v) ?? null;
|
|
757
|
+
var N = (v) => num(v) ?? 0;
|
|
758
|
+
function ensureDdl(db) {
|
|
759
|
+
const n = db.dialect === "postgres" ? " DOUBLE PRECISION" : "";
|
|
760
|
+
const t = db.dialect === "postgres" ? ' TEXT COLLATE "C"' : " TEXT";
|
|
761
|
+
const ddl = [
|
|
762
|
+
`CREATE TABLE IF NOT EXISTS "domain_note" ("note_id"${t} PRIMARY KEY NOT NULL, "note_type"${t}, "catalog"${t}, "source_id"${t}, "source_path"${t}, "title"${t} NOT NULL, "created_at"${n} NOT NULL, "date"${n}, "text_search"${t} NOT NULL, "journal"${n} NOT NULL, "payload"${t} NOT NULL)`,
|
|
763
|
+
`CREATE INDEX IF NOT EXISTS "idx_domain_note_date" ON "domain_note" ("date", "note_id")`,
|
|
764
|
+
`CREATE INDEX IF NOT EXISTS "idx_domain_note_created" ON "domain_note" ("created_at", "note_id")`,
|
|
765
|
+
`CREATE INDEX IF NOT EXISTS "idx_domain_note_source_path" ON "domain_note" ("source_path")`,
|
|
766
|
+
`CREATE TABLE IF NOT EXISTS "query_block" ("occurrence_id"${t} PRIMARY KEY NOT NULL, "source_note_id"${t}, "parent_note_id"${t}, "content_id"${t}, "section_key"${t}, "ordinal"${n}, "data_type"${t}, "source_id"${t}, "source_path"${t}, "catalog"${t}, "effective_date"${n} NOT NULL, "created_at"${n} NOT NULL, "note_title"${t}, "normalized_text"${t} NOT NULL, "journal"${n} NOT NULL, "payload"${t} NOT NULL)`,
|
|
767
|
+
`CREATE INDEX IF NOT EXISTS "idx_query_block_type" ON "query_block" ("data_type", "effective_date", "occurrence_id")`,
|
|
768
|
+
`CREATE INDEX IF NOT EXISTS "idx_query_block_parent" ON "query_block" ("parent_note_id", "ordinal")`,
|
|
769
|
+
`CREATE INDEX IF NOT EXISTS "idx_query_block_content" ON "query_block" ("content_id")`,
|
|
770
|
+
`CREATE INDEX IF NOT EXISTS "idx_query_block_source_path" ON "query_block" ("source_path")`,
|
|
771
|
+
`CREATE INDEX IF NOT EXISTS "idx_query_block_source_note" ON "query_block" ("source_note_id")`,
|
|
772
|
+
`CREATE TABLE IF NOT EXISTS "segment_head" ("id"${t} PRIMARY KEY NOT NULL, "note_id"${t} NOT NULL, "current_version"${n} NOT NULL, "position"${n}, "search_text"${t} NOT NULL)`,
|
|
773
|
+
`CREATE INDEX IF NOT EXISTS "idx_segment_head_note" ON "segment_head" ("note_id", "id")`
|
|
774
|
+
];
|
|
775
|
+
return db.transaction(async (tx) => {
|
|
776
|
+
for (const sql of ddl) await tx.exec(sql);
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
async function upsertDomainNote(conn, note) {
|
|
780
|
+
const id = text(note.id);
|
|
781
|
+
if (!id) return;
|
|
782
|
+
const source = sourceItem(note);
|
|
783
|
+
await conn.exec(
|
|
784
|
+
`INSERT INTO "domain_note" ("note_id","note_type","catalog","source_id","source_path","title","created_at","date","text_search","journal","payload")
|
|
785
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
|
786
|
+
ON CONFLICT ("note_id") DO UPDATE SET "note_type"=excluded."note_type","catalog"=excluded."catalog","source_id"=excluded."source_id",
|
|
787
|
+
"source_path"=excluded."source_path","title"=excluded."title","created_at"=excluded."created_at","date"=excluded."date",
|
|
788
|
+
"text_search"=excluded."text_search","journal"=excluded."journal","payload"=excluded."payload"`,
|
|
789
|
+
[
|
|
790
|
+
id,
|
|
791
|
+
T(note.type),
|
|
792
|
+
T(wodWikiWql.catalogOfItem(source)),
|
|
793
|
+
T(note.sourceId),
|
|
794
|
+
T(note.sourcePath),
|
|
795
|
+
T(note.title) ?? "",
|
|
796
|
+
N(note.createdAt),
|
|
797
|
+
num(note.date),
|
|
798
|
+
(text(note.title) ?? "").toLowerCase(),
|
|
799
|
+
wodWikiWql.sourceMatches(source, "journal") ? 1 : 0,
|
|
800
|
+
encodeRowJson(note)
|
|
801
|
+
]
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
async function healQueryBlocksForNote(conn, note) {
|
|
805
|
+
const id = text(note.id);
|
|
806
|
+
if (!id) return;
|
|
807
|
+
const rows = await conn.query(
|
|
808
|
+
`SELECT "occurrence_id" FROM "query_block"
|
|
809
|
+
WHERE "source_note_id" = ? OR ("source_path" IS NOT NULL AND "source_path" = ?)`,
|
|
810
|
+
[id, T(note.sourcePath)]
|
|
811
|
+
);
|
|
812
|
+
for (const row of rows) await refreshBlock(conn, String(row.occurrence_id));
|
|
813
|
+
}
|
|
814
|
+
async function upsertSegmentHead(conn, id, head) {
|
|
815
|
+
await conn.exec(
|
|
816
|
+
`INSERT INTO "segment_head" ("id","note_id","current_version","position","search_text")
|
|
817
|
+
VALUES (?,?,?,?,?)
|
|
818
|
+
ON CONFLICT ("id") DO UPDATE SET "note_id"=excluded."note_id","current_version"=excluded."current_version",
|
|
819
|
+
"position"=excluded."position","search_text"=excluded."search_text"`,
|
|
820
|
+
[
|
|
821
|
+
id,
|
|
822
|
+
T(head.noteId) ?? "",
|
|
823
|
+
N(head.version),
|
|
824
|
+
// Local latest-segments order is position ?? createdAt — same fallback here.
|
|
825
|
+
num(head.position) ?? num(head.createdAt),
|
|
826
|
+
(text(head.rawContent) ?? "").toLowerCase()
|
|
827
|
+
]
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
async function identityBy(conn, col, val) {
|
|
831
|
+
const rows = await conn.query(
|
|
832
|
+
`SELECT "note_id", "date", "created_at" FROM "domain_note" WHERE "${col}" = ?`,
|
|
833
|
+
[val]
|
|
834
|
+
);
|
|
835
|
+
return rows.map((r) => ({
|
|
836
|
+
note_id: String(r.note_id),
|
|
837
|
+
date: num(r.date),
|
|
838
|
+
created_at: num(r.created_at) ?? 0
|
|
839
|
+
}));
|
|
840
|
+
}
|
|
841
|
+
async function resolveParent(conn, block) {
|
|
842
|
+
const byPath = async (sp2) => {
|
|
843
|
+
const rows = await identityBy(conn, "source_path", sp2);
|
|
844
|
+
return rows.length === 1 ? rows[0] : void 0;
|
|
845
|
+
};
|
|
846
|
+
const nid = text(block.noteId);
|
|
847
|
+
if (nid) {
|
|
848
|
+
const rows = await identityBy(conn, "note_id", nid);
|
|
849
|
+
if (rows.length === 1) return rows[0];
|
|
850
|
+
if (rows.length > 1) return void 0;
|
|
851
|
+
}
|
|
852
|
+
const sp = text(block.sourcePath);
|
|
853
|
+
return sp ? byPath(sp) : void 0;
|
|
854
|
+
}
|
|
855
|
+
async function upsertQueryBlock(conn, block) {
|
|
856
|
+
const id = text(block.id);
|
|
857
|
+
if (!id) return;
|
|
858
|
+
const source = sourceItem(block);
|
|
859
|
+
const createdAt = N(block.createdAt);
|
|
860
|
+
const parent = await resolveParent(conn, block);
|
|
861
|
+
const effective = parent ? parent.date ?? parent.created_at : createdAt;
|
|
862
|
+
await conn.exec(
|
|
863
|
+
`INSERT INTO "query_block" ("occurrence_id","source_note_id","parent_note_id","content_id","section_key","ordinal",
|
|
864
|
+
"data_type","source_id","source_path","catalog","effective_date","created_at","note_title","normalized_text","journal","payload")
|
|
865
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
866
|
+
ON CONFLICT ("occurrence_id") DO UPDATE SET "source_note_id"=excluded."source_note_id","parent_note_id"=excluded."parent_note_id",
|
|
867
|
+
"content_id"=excluded."content_id","section_key"=excluded."section_key","ordinal"=excluded."ordinal",
|
|
868
|
+
"data_type"=excluded."data_type","source_id"=excluded."source_id","source_path"=excluded."source_path",
|
|
869
|
+
"catalog"=excluded."catalog","effective_date"=excluded."effective_date","created_at"=excluded."created_at",
|
|
870
|
+
"note_title"=excluded."note_title","normalized_text"=excluded."normalized_text","journal"=excluded."journal",
|
|
871
|
+
"payload"=excluded."payload"`,
|
|
872
|
+
[
|
|
873
|
+
id,
|
|
874
|
+
T(block.noteId),
|
|
875
|
+
parent?.note_id ?? null,
|
|
876
|
+
T(block.blockContentId),
|
|
877
|
+
T(block.segmentId),
|
|
878
|
+
num(block.position),
|
|
879
|
+
T(block.dataType),
|
|
880
|
+
T(block.sourceId),
|
|
881
|
+
T(block.sourcePath),
|
|
882
|
+
T(wodWikiWql.catalogOfItem(source)),
|
|
883
|
+
effective,
|
|
884
|
+
createdAt,
|
|
885
|
+
T(block.noteTitle),
|
|
886
|
+
(text(block.rawContent) ?? "").toLowerCase(),
|
|
887
|
+
wodWikiWql.sourceMatches(source, "journal") ? 1 : 0,
|
|
888
|
+
encodeRowJson(block)
|
|
889
|
+
]
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
var keyHead = (key) => Array.isArray(key) ? key[0] : key;
|
|
893
|
+
async function refreshNote(conn, id) {
|
|
894
|
+
const k = keyHead(id);
|
|
895
|
+
const rows = await conn.query(`SELECT "data" FROM "notes" WHERE "k0" = ?`, [k]);
|
|
896
|
+
const note = rows[0] ? decodeRowJson(String(rows[0].data)) : void 0;
|
|
897
|
+
if (note) {
|
|
898
|
+
await upsertDomainNote(conn, note);
|
|
899
|
+
await healQueryBlocksForNote(conn, note);
|
|
900
|
+
} else {
|
|
901
|
+
const orphans = await conn.query(
|
|
902
|
+
`SELECT "occurrence_id" FROM "query_block" WHERE "parent_note_id" = ? OR "source_note_id" = ?`,
|
|
903
|
+
[k, k]
|
|
904
|
+
);
|
|
905
|
+
await conn.exec(`DELETE FROM "domain_note" WHERE "note_id" = ?`, [k]);
|
|
906
|
+
for (const row of orphans) await refreshBlock(conn, String(row.occurrence_id));
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
async function refreshSegment(conn, id) {
|
|
910
|
+
const k = keyHead(id);
|
|
911
|
+
const rows = await conn.query(`SELECT "data" FROM "segments" WHERE "k0" = ?`, [k]);
|
|
912
|
+
let head;
|
|
913
|
+
let headVersion = -Infinity;
|
|
914
|
+
for (const row of rows) {
|
|
915
|
+
const seg = decodeRowJson(String(row.data));
|
|
916
|
+
const v = num(seg.version) ?? 0;
|
|
917
|
+
if (!head || v > headVersion) {
|
|
918
|
+
head = seg;
|
|
919
|
+
headVersion = v;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
if (head) await upsertSegmentHead(conn, k, head);
|
|
923
|
+
else await conn.exec(`DELETE FROM "segment_head" WHERE "id" = ?`, [k]);
|
|
924
|
+
}
|
|
925
|
+
async function refreshBlock(conn, id) {
|
|
926
|
+
const k = keyHead(id);
|
|
927
|
+
const rows = await conn.query(`SELECT "data" FROM "block_index" WHERE "k0" = ?`, [k]);
|
|
928
|
+
if (rows[0]) await upsertQueryBlock(conn, decodeRowJson(String(rows[0].data)));
|
|
929
|
+
else await conn.exec(`DELETE FROM "query_block" WHERE "occurrence_id" = ?`, [k]);
|
|
930
|
+
}
|
|
931
|
+
var TRACKED = {
|
|
932
|
+
notes: "notes",
|
|
933
|
+
segments: "segments",
|
|
934
|
+
block_index: "blocks"
|
|
935
|
+
};
|
|
936
|
+
async function deleteTargetKeys(conn, def, target) {
|
|
937
|
+
if (!isRange(target)) return [target];
|
|
938
|
+
const rows = await listRows(conn, def, target);
|
|
939
|
+
return rows.map((row) => keyFromValue(def, row));
|
|
940
|
+
}
|
|
941
|
+
async function refreshDomain(conn, ops, opts = {}) {
|
|
942
|
+
const impact = {
|
|
943
|
+
notes: [],
|
|
944
|
+
segments: [],
|
|
945
|
+
blocks: [],
|
|
946
|
+
notesClear: false,
|
|
947
|
+
segmentsClear: false,
|
|
948
|
+
blocksClear: false
|
|
949
|
+
};
|
|
950
|
+
const results = [];
|
|
951
|
+
for (const op of ops) {
|
|
952
|
+
if (op === null || typeof op !== "object" || Array.isArray(op)) {
|
|
953
|
+
throw new StorageError(400, "tx op must be an object");
|
|
954
|
+
}
|
|
955
|
+
const store = "store" in op ? op.store : void 0;
|
|
956
|
+
const def = storeDefinition(typeof store === "string" ? store : String(store));
|
|
957
|
+
const kind = typeof op.op === "string" ? op.op : void 0;
|
|
958
|
+
if (op.op === "put") {
|
|
959
|
+
const key = await putRow(conn, def, op.value);
|
|
960
|
+
const slot = TRACKED[def.name];
|
|
961
|
+
if (slot) impact[slot].push(key);
|
|
962
|
+
results.push(key);
|
|
963
|
+
} else if (op.op === "delete") {
|
|
964
|
+
const slot = TRACKED[def.name];
|
|
965
|
+
if (slot) {
|
|
966
|
+
for (const key of await deleteTargetKeys(conn, def, op.key)) impact[slot].push(key);
|
|
967
|
+
}
|
|
968
|
+
await deleteRows(conn, def, op.key);
|
|
969
|
+
results.push(null);
|
|
970
|
+
} else if (op.op === "clear") {
|
|
971
|
+
await clearRows(conn, def);
|
|
972
|
+
if (def.name === "notes") impact.notesClear = true;
|
|
973
|
+
else if (def.name === "segments") impact.segmentsClear = true;
|
|
974
|
+
else if (def.name === "block_index") impact.blocksClear = true;
|
|
975
|
+
results.push(null);
|
|
976
|
+
} else {
|
|
977
|
+
throw new StorageError(400, `tx op must be put | delete | clear, got: ${String(kind)}`);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
if (!opts.deferProjection) await refresh(conn, impact);
|
|
981
|
+
return results;
|
|
982
|
+
}
|
|
983
|
+
async function refresh(conn, impact) {
|
|
984
|
+
if (impact.notesClear) {
|
|
985
|
+
await conn.exec(`DELETE FROM "domain_note"`);
|
|
986
|
+
await conn.exec(`UPDATE "query_block" SET "parent_note_id" = NULL`);
|
|
987
|
+
}
|
|
988
|
+
if (impact.segmentsClear) await conn.exec(`DELETE FROM "segment_head"`);
|
|
989
|
+
if (impact.blocksClear) await conn.exec(`DELETE FROM "query_block"`);
|
|
990
|
+
for (const id of impact.notes) await refreshNote(conn, id);
|
|
991
|
+
for (const key of impact.segments) await refreshSegment(conn, key);
|
|
992
|
+
for (const id of impact.blocks) await refreshBlock(conn, id);
|
|
993
|
+
}
|
|
994
|
+
async function rebuildProjections(conn) {
|
|
995
|
+
await conn.exec(
|
|
996
|
+
`CREATE TABLE IF NOT EXISTS "${SCHEMA_TABLE}" ("key" TEXT PRIMARY KEY, "value" TEXT NOT NULL)`
|
|
997
|
+
);
|
|
998
|
+
for (const t of TABLES) await conn.exec(`DELETE FROM "${t}"`);
|
|
999
|
+
const notes = await conn.query(`SELECT "data" FROM "notes"`);
|
|
1000
|
+
for (const row of notes) await upsertDomainNote(conn, decodeRowJson(String(row.data)));
|
|
1001
|
+
const segments = await conn.query(`SELECT "k0", "data" FROM "segments"`);
|
|
1002
|
+
const heads = /* @__PURE__ */ new Map();
|
|
1003
|
+
for (const row of segments) {
|
|
1004
|
+
const seg = decodeRowJson(String(row.data));
|
|
1005
|
+
const v = num(seg.version) ?? 0;
|
|
1006
|
+
const prev = heads.get(String(row.k0));
|
|
1007
|
+
if (!prev || v > prev.version) heads.set(String(row.k0), { id: row.k0, row: seg, version: v });
|
|
1008
|
+
}
|
|
1009
|
+
for (const head of heads.values()) await upsertSegmentHead(conn, head.id, head.row);
|
|
1010
|
+
const blocks = await conn.query(`SELECT "data" FROM "block_index"`);
|
|
1011
|
+
for (const row of blocks) await upsertQueryBlock(conn, decodeRowJson(String(row.data)));
|
|
1012
|
+
await conn.exec(
|
|
1013
|
+
`INSERT INTO "${SCHEMA_TABLE}" ("key", "value") VALUES (?, ?) ON CONFLICT ("key") DO UPDATE SET "value" = excluded."value"`,
|
|
1014
|
+
[PROJECTION_GUARD_KEY, JSON.stringify({ version: DOMAIN_PROJECTION_VERSION })]
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
async function initializeDomain(db) {
|
|
1018
|
+
await ensureDdl(db);
|
|
1019
|
+
await db.transaction(async (tx) => {
|
|
1020
|
+
await tx.exec(
|
|
1021
|
+
`CREATE TABLE IF NOT EXISTS "${SCHEMA_TABLE}" ("key" TEXT PRIMARY KEY, "value" TEXT NOT NULL)`
|
|
1022
|
+
);
|
|
1023
|
+
const guardRows = await tx.query(`SELECT "value" FROM "${SCHEMA_TABLE}" WHERE "key" = ?`, [PROJECTION_GUARD_KEY]);
|
|
1024
|
+
let guard;
|
|
1025
|
+
if (guardRows[0]) {
|
|
1026
|
+
try {
|
|
1027
|
+
guard = JSON.parse(String(guardRows[0].value));
|
|
1028
|
+
} catch {
|
|
1029
|
+
guard = void 0;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
if (guard?.version === DOMAIN_PROJECTION_VERSION) return;
|
|
1033
|
+
await rebuildProjections(tx);
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
async function wipeDomain(conn) {
|
|
1037
|
+
for (const t of TABLES) await conn.exec(`DELETE FROM "${t}"`);
|
|
1038
|
+
}
|
|
1039
|
+
var MAX_BODY = 64 * 1024;
|
|
1040
|
+
var MAX_CLAUSES = 32;
|
|
1041
|
+
var MAX_VALUES = 64;
|
|
1042
|
+
var MAX_VALUE_LEN = 256;
|
|
1043
|
+
var MAX_ORDER = 4;
|
|
1044
|
+
var listFields = ["id", "noteId", "type", "catalog", "source", "tags", "effort"];
|
|
1045
|
+
var onlyKeys = (obj, allowed) => Object.keys(obj).every((k) => allowed.includes(k));
|
|
1046
|
+
function bad(msg) {
|
|
1047
|
+
return new StorageError(400, msg);
|
|
1048
|
+
}
|
|
1049
|
+
function strArray(v, what) {
|
|
1050
|
+
if (!Array.isArray(v) || v.length < 1 || v.length > MAX_VALUES) {
|
|
1051
|
+
throw bad(`${what} needs 1..=${MAX_VALUES} values`);
|
|
1052
|
+
}
|
|
1053
|
+
return v.map((x) => {
|
|
1054
|
+
if (typeof x !== "string" || x.length < 1 || x.length > MAX_VALUE_LEN) {
|
|
1055
|
+
throw bad(`${what} values must be 1..=${MAX_VALUE_LEN} chars`);
|
|
1056
|
+
}
|
|
1057
|
+
return x;
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
function parsePredicate(v, plan) {
|
|
1061
|
+
if (v === null || typeof v !== "object" || Array.isArray(v)) throw bad("predicate must be an object");
|
|
1062
|
+
const obj = v;
|
|
1063
|
+
const field = obj.field;
|
|
1064
|
+
if (typeof field !== "string") throw bad("predicate.field required");
|
|
1065
|
+
let negate = false;
|
|
1066
|
+
if ("negate" in obj) {
|
|
1067
|
+
if (typeof obj.negate !== "boolean") throw bad("predicate.negate must be boolean");
|
|
1068
|
+
negate = obj.negate;
|
|
1069
|
+
}
|
|
1070
|
+
if (listFields.includes(field)) {
|
|
1071
|
+
if (!onlyKeys(obj, ["field", "values", "negate"])) {
|
|
1072
|
+
throw bad(`predicate ${field} accepts only field/values/negate`);
|
|
1073
|
+
}
|
|
1074
|
+
return { field, values: strArray(obj.values, `predicate ${field}.values`), ...negate ? { negate } : {} };
|
|
1075
|
+
}
|
|
1076
|
+
if (field === "text") {
|
|
1077
|
+
if (!onlyKeys(obj, ["field", "value"]) || negate) throw bad("predicate text accepts only {field, value}");
|
|
1078
|
+
if (typeof obj.value !== "string" || obj.value.length < 1 || obj.value.length > MAX_VALUE_LEN) {
|
|
1079
|
+
throw bad("predicate text.value must be 1..=256 chars");
|
|
1080
|
+
}
|
|
1081
|
+
return { field: "text", value: obj.value };
|
|
1082
|
+
}
|
|
1083
|
+
if (field === "date") {
|
|
1084
|
+
if (!onlyKeys(obj, ["field", "start", "end", "endExclusive"])) {
|
|
1085
|
+
throw bad("predicate date accepts only field/start/end/endExclusive");
|
|
1086
|
+
}
|
|
1087
|
+
const n = (k) => {
|
|
1088
|
+
if (typeof obj[k] !== "number" || !Number.isFinite(obj[k])) throw bad(`predicate date.${k} must be a finite number`);
|
|
1089
|
+
return obj[k];
|
|
1090
|
+
};
|
|
1091
|
+
if ("endExclusive" in obj && typeof obj.endExclusive !== "boolean") {
|
|
1092
|
+
throw bad("predicate date.endExclusive must be boolean");
|
|
1093
|
+
}
|
|
1094
|
+
return { field: "date", start: n("start"), end: n("end"), ...obj.endExclusive === true ? { endExclusive: true } : {} };
|
|
1095
|
+
}
|
|
1096
|
+
if (field === "page") {
|
|
1097
|
+
if (plan === "blocks") throw bad("predicate page does not apply to the blocks plan");
|
|
1098
|
+
if (!onlyKeys(obj, ["field", "value"])) throw bad("predicate page accepts only {field, value}");
|
|
1099
|
+
if (typeof obj.value !== "boolean") throw bad("predicate page.value must be boolean");
|
|
1100
|
+
return { field: "page", value: obj.value };
|
|
1101
|
+
}
|
|
1102
|
+
if (field === "sourceFence") {
|
|
1103
|
+
if (!onlyKeys(obj, ["field"])) throw bad("predicate sourceFence accepts only {field}");
|
|
1104
|
+
return { field: "sourceFence" };
|
|
1105
|
+
}
|
|
1106
|
+
if (field === "defaultNotes") {
|
|
1107
|
+
if (plan === "blocks") throw bad("predicate defaultNotes does not apply to the blocks plan");
|
|
1108
|
+
if (!onlyKeys(obj, ["field", "feeds"])) throw bad("predicate defaultNotes accepts only {field, feeds}");
|
|
1109
|
+
if (typeof obj.feeds !== "boolean") throw bad("predicate defaultNotes.feeds must be boolean");
|
|
1110
|
+
return { field: "defaultNotes", feeds: obj.feeds };
|
|
1111
|
+
}
|
|
1112
|
+
throw bad(`unknown predicate field: ${field}`);
|
|
1113
|
+
}
|
|
1114
|
+
function parseClauses(v, plan, what) {
|
|
1115
|
+
if (v === void 0) return [];
|
|
1116
|
+
if (!Array.isArray(v)) throw bad(`${what} must be an array`);
|
|
1117
|
+
if (v.length > MAX_CLAUSES) throw bad(`${what} supports at most ${MAX_CLAUSES} clauses`);
|
|
1118
|
+
return v.map((p) => parsePredicate(p, plan));
|
|
1119
|
+
}
|
|
1120
|
+
function orderCols(field, plan) {
|
|
1121
|
+
if (field === "id" || field === "createdAt") return field;
|
|
1122
|
+
if (plan === "blocks") {
|
|
1123
|
+
if (field === "noteTitle" || field === "dataType" || field === "position") return field;
|
|
1124
|
+
throw bad(field === "date" || field === "title" ? `order ${field} does not apply to the blocks plan` : `unknown order field: ${field}`);
|
|
1125
|
+
}
|
|
1126
|
+
if (field === "date" || field === "title") return field;
|
|
1127
|
+
throw bad(
|
|
1128
|
+
field === "noteTitle" || field === "dataType" || field === "position" ? `order ${field} only applies to the blocks plan` : `unknown order field: ${field}`
|
|
1129
|
+
);
|
|
1130
|
+
}
|
|
1131
|
+
function parseQuery(body) {
|
|
1132
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) throw bad("body must be a DomainQuery object");
|
|
1133
|
+
const obj = body;
|
|
1134
|
+
const plan = obj.plan;
|
|
1135
|
+
if (plan !== "notes" && plan !== "blocks" && plan !== "entries") {
|
|
1136
|
+
throw bad("body.plan must be notes | blocks | entries");
|
|
1137
|
+
}
|
|
1138
|
+
for (const k of Object.keys(obj)) {
|
|
1139
|
+
if (!["plan", "selection", "filters", "order", "offset", "limit"].includes(k)) {
|
|
1140
|
+
throw bad(`unknown DomainQuery field: ${k}`);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
const order = [];
|
|
1144
|
+
if (obj.order !== void 0) {
|
|
1145
|
+
if (!Array.isArray(obj.order)) throw bad("order must be an array");
|
|
1146
|
+
if (obj.order.length > MAX_ORDER) throw bad(`order supports at most ${MAX_ORDER} keys`);
|
|
1147
|
+
for (const entry of obj.order) {
|
|
1148
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) throw bad("order entries must be objects");
|
|
1149
|
+
const e = entry;
|
|
1150
|
+
if (typeof e.field !== "string") throw bad("order.field required");
|
|
1151
|
+
if (!onlyKeys(e, ["field", "direction"])) throw bad("order entries accept only field/direction");
|
|
1152
|
+
if (e.direction !== void 0 && e.direction !== "asc" && e.direction !== "desc") {
|
|
1153
|
+
throw bad("order.direction must be asc | desc");
|
|
1154
|
+
}
|
|
1155
|
+
order.push({ field: orderCols(e.field, plan), desc: e.direction === "desc" });
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
const intIn = (k) => {
|
|
1159
|
+
const v = obj[k];
|
|
1160
|
+
if (v === void 0 || v === null) return void 0;
|
|
1161
|
+
if (typeof v !== "number" || !Number.isInteger(v)) throw bad(`${k} must be an integer`);
|
|
1162
|
+
if (v < 0) throw bad(`${k} must be >= 0`);
|
|
1163
|
+
return v;
|
|
1164
|
+
};
|
|
1165
|
+
return {
|
|
1166
|
+
plan,
|
|
1167
|
+
selection: parseClauses(obj.selection, plan, "selection"),
|
|
1168
|
+
filters: parseClauses(obj.filters, plan, "filters"),
|
|
1169
|
+
order,
|
|
1170
|
+
offset: intIn("offset") ?? 0,
|
|
1171
|
+
limit: intIn("limit")
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
var Sql = class {
|
|
1175
|
+
constructor(pg) {
|
|
1176
|
+
this.pg = pg;
|
|
1177
|
+
}
|
|
1178
|
+
pg;
|
|
1179
|
+
params = [];
|
|
1180
|
+
bind(v) {
|
|
1181
|
+
this.params.push(v);
|
|
1182
|
+
return "?";
|
|
1183
|
+
}
|
|
1184
|
+
/** Literal substring over a lowercased materialized column — no wildcards. */
|
|
1185
|
+
contains(col, needle) {
|
|
1186
|
+
const p = this.bind(needle.toLowerCase());
|
|
1187
|
+
return this.pg ? `strpos(${col}, ${p}) > 0` : `instr(${col}, ${p}) > 0`;
|
|
1188
|
+
}
|
|
1189
|
+
inList(col, values) {
|
|
1190
|
+
return `${col} IN (${values.map((v) => this.bind(v)).join(", ")})`;
|
|
1191
|
+
}
|
|
1192
|
+
};
|
|
1193
|
+
var SOURCE_ALL_KINDS = ["journal", "feeds", "guides", "playground", "dashboards", "efforts"];
|
|
1194
|
+
function sourceKindSql(ctx, kind, sid, typeExpr, journalCol) {
|
|
1195
|
+
switch (kind) {
|
|
1196
|
+
case "journal":
|
|
1197
|
+
return `(${journalCol} = 1)`;
|
|
1198
|
+
case "feed":
|
|
1199
|
+
case "feeds":
|
|
1200
|
+
return `(substr(${sid}, 1, 11) = 'collection:' OR substr(${sid}, 1, 16) = 'page:collection:')`;
|
|
1201
|
+
case "page":
|
|
1202
|
+
case "pages":
|
|
1203
|
+
return `(${sid} = 'page' OR substr(${sid}, 1, 5) = 'page:' OR substr(${sid}, 1, 7) = 'guides:')`;
|
|
1204
|
+
case "guide":
|
|
1205
|
+
case "guides":
|
|
1206
|
+
return `(substr(${sid}, 1, 7) = 'guides:')`;
|
|
1207
|
+
case "playground":
|
|
1208
|
+
return typeExpr ? `(${sid} = 'playground' OR (${typeExpr} IS NOT NULL AND ${typeExpr} = 'playground'))` : `(${sid} = 'playground')`;
|
|
1209
|
+
default:
|
|
1210
|
+
return `(${sid} = ${ctx.bind(kind)})`;
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
function sourcePredicateSql(ctx, values, negate, sid, typeExpr, journalCol) {
|
|
1214
|
+
const matched = values.map((v) => sourceKindSql(ctx, v, sid, typeExpr, journalCol)).join(" OR ");
|
|
1215
|
+
return negate ? `NOT COALESCE((${matched}), FALSE)` : matched;
|
|
1216
|
+
}
|
|
1217
|
+
var wrap = (negate, matchSql) => negate ? `NOT COALESCE ((${matchSql}), FALSE)` : matchSql;
|
|
1218
|
+
function predicateSql(ctx, p, plan) {
|
|
1219
|
+
const blocks = plan === "blocks";
|
|
1220
|
+
const id = blocks ? 'q."occurrence_id"' : 'n."note_id"';
|
|
1221
|
+
const sid = blocks ? 'q."source_id"' : 'n."source_id"';
|
|
1222
|
+
const typeExpr = blocks ? null : 'n."note_type"';
|
|
1223
|
+
const typeCol = blocks ? 'q."data_type"' : 'n."note_type"';
|
|
1224
|
+
const catalogCol = blocks ? 'q."catalog"' : 'n."catalog"';
|
|
1225
|
+
const journalCol = blocks ? 'q."journal"' : 'n."journal"';
|
|
1226
|
+
const noteId = blocks ? 'q."source_note_id"' : 'n."note_id"';
|
|
1227
|
+
switch (p.field) {
|
|
1228
|
+
case "id":
|
|
1229
|
+
return wrap(p.negate === true, ctx.inList(id, p.values));
|
|
1230
|
+
case "noteId":
|
|
1231
|
+
return wrap(p.negate === true, ctx.inList(noteId, p.values));
|
|
1232
|
+
case "type":
|
|
1233
|
+
return wrap(p.negate === true, ctx.inList(typeCol, p.values));
|
|
1234
|
+
case "catalog":
|
|
1235
|
+
return wrap(p.negate === true, ctx.inList(catalogCol, p.values));
|
|
1236
|
+
case "source":
|
|
1237
|
+
return sourcePredicateSql(ctx, p.values, p.negate === true, sid, typeExpr, journalCol);
|
|
1238
|
+
case "tags":
|
|
1239
|
+
return wrap(
|
|
1240
|
+
p.negate === true,
|
|
1241
|
+
`EXISTS (SELECT 1 FROM "note_tags" nt JOIN "tags" t ON t."k0" = nt."i_by_tag_0" WHERE nt."i_by_note_0" = ${noteId} AND t."i_by_label_0" IN (${p.values.map((v) => ctx.bind(v)).join(", ")}))`
|
|
1242
|
+
);
|
|
1243
|
+
case "effort":
|
|
1244
|
+
return wrap(
|
|
1245
|
+
p.negate === true,
|
|
1246
|
+
blocks ? `(q."content_id" IS NOT NULL AND EXISTS (SELECT 1 FROM "block_efforts" be WHERE be."i_by_block_0" = q."content_id" AND be."i_by_effort_0" IN (${p.values.map((v) => ctx.bind(v)).join(", ")})))` : `EXISTS (SELECT 1 FROM "block_efforts" be WHERE be."i_by_note_0" = ${noteId} AND be."i_by_effort_0" IN (${p.values.map((v) => ctx.bind(v)).join(", ")}))`
|
|
1247
|
+
);
|
|
1248
|
+
case "text": {
|
|
1249
|
+
const textCol = blocks ? 'q."normalized_text"' : 'n."text_search"';
|
|
1250
|
+
const title = ctx.contains(textCol, p.value);
|
|
1251
|
+
if (plan !== "entries") return title;
|
|
1252
|
+
const body = ctx.contains('h."search_text"', p.value);
|
|
1253
|
+
return `(${title} OR EXISTS (SELECT 1 FROM "segment_head" h JOIN "segments" s ON s."k0" = h."id" AND s."k1" = h."current_version" WHERE h."note_id" = ${noteId} AND COALESCE(s."i_by_history_0", 0) = 0 AND ${body}))`;
|
|
1254
|
+
}
|
|
1255
|
+
case "date": {
|
|
1256
|
+
const expr = blocks ? 'q."effective_date"' : 'COALESCE(n."date", n."created_at")';
|
|
1257
|
+
const lo = ctx.bind(p.start);
|
|
1258
|
+
const hi = ctx.bind(p.end);
|
|
1259
|
+
const cmp = p.endExclusive ? "<" : "<=";
|
|
1260
|
+
return `(${expr} >= ${lo} AND ${expr} ${cmp} ${hi})`;
|
|
1261
|
+
}
|
|
1262
|
+
case "page":
|
|
1263
|
+
return p.value ? `n."note_type" = 'page'` : `(n."note_type" IS NULL OR n."note_type" <> 'page')`;
|
|
1264
|
+
case "sourceFence": {
|
|
1265
|
+
const all = SOURCE_ALL_KINDS.map((k) => sourceKindSql(ctx, k, sid, typeExpr, journalCol)).join(" OR ");
|
|
1266
|
+
return `(${sid} IS NULL OR ${sid} = '' OR ${all})`;
|
|
1267
|
+
}
|
|
1268
|
+
case "defaultNotes":
|
|
1269
|
+
return p.feeds ? `(n."note_type" IS NULL OR n."note_type" <> 'page')` : `(n."note_type" = 'note' OR (substr(COALESCE(n."source_id", ''), 1, 5) <> 'page:' AND substr(COALESCE(n."source_id", ''), 1, 7) <> 'guides:' AND COALESCE(n."note_type", '') NOT IN ('feed','syntax','behavior','analytics','dashboard','home','page')))`;
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
function clausesSql(ctx, clauses, plan) {
|
|
1273
|
+
if (clauses.length === 0) return "";
|
|
1274
|
+
return ` WHERE ${clauses.map((p) => predicateSql(ctx, p, plan)).join(" AND ")}`;
|
|
1275
|
+
}
|
|
1276
|
+
function orderSql(order, plan) {
|
|
1277
|
+
const idCol = plan === "blocks" ? 'q."occurrence_id"' : 'n."note_id"';
|
|
1278
|
+
const cols = plan === "blocks" ? { id: 'q."occurrence_id"', createdAt: 'q."created_at"', noteTitle: 'q."note_title"', dataType: 'q."data_type"', position: 'q."ordinal"' } : { id: 'n."note_id"', createdAt: 'n."created_at"', date: 'n."date"', title: 'n."title"' };
|
|
1279
|
+
const parts = order.map(
|
|
1280
|
+
({ field, desc }) => desc ? `${cols[field]} DESC NULLS FIRST` : `${cols[field]} ASC NULLS LAST`
|
|
1281
|
+
);
|
|
1282
|
+
if (!order.some((o) => o.field === "id")) parts.push(`${idCol} ASC`);
|
|
1283
|
+
return parts.join(", ");
|
|
1284
|
+
}
|
|
1285
|
+
function pagingSql(q, pg) {
|
|
1286
|
+
let s = "";
|
|
1287
|
+
if (q.limit !== void 0) s += ` LIMIT ${q.limit}`;
|
|
1288
|
+
else if (q.offset > 0 && !pg) s += " LIMIT -1";
|
|
1289
|
+
if (q.offset > 0) s += ` OFFSET ${q.offset}`;
|
|
1290
|
+
return s;
|
|
1291
|
+
}
|
|
1292
|
+
async function gate(conn) {
|
|
1293
|
+
const rows = await conn.query(`SELECT COUNT(*) AS "n" FROM "query_block" WHERE "parent_note_id" IS NULL`);
|
|
1294
|
+
const stale = Number(rows[0]?.n ?? 0);
|
|
1295
|
+
if (stale > 0) {
|
|
1296
|
+
throw new StorageError(
|
|
1297
|
+
409,
|
|
1298
|
+
`domain projections stale: ${stale} block rows have unresolved static parents`,
|
|
1299
|
+
"domain_projection_unavailable"
|
|
1300
|
+
);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
function planTable(plan) {
|
|
1304
|
+
return plan === "blocks" ? '"query_block" q' : '"domain_note" n';
|
|
1305
|
+
}
|
|
1306
|
+
async function execute(conn, q, pg) {
|
|
1307
|
+
const plan = q.plan;
|
|
1308
|
+
const table = planTable(plan);
|
|
1309
|
+
const selCtx = new Sql(pg);
|
|
1310
|
+
const selWhere = clausesSql(selCtx, q.selection, plan);
|
|
1311
|
+
const filterCtx = new Sql(pg);
|
|
1312
|
+
const filterWhere = clausesSql(filterCtx, q.filters, plan);
|
|
1313
|
+
const allParams = [...selCtx.params, ...filterCtx.params];
|
|
1314
|
+
const fullWhere = selWhere && filterWhere ? `${selWhere} AND ${filterWhere.replace(/^ WHERE /, "")}` : selWhere || filterWhere;
|
|
1315
|
+
const count = async (where, params) => Number((await conn.query(`SELECT COUNT(*) AS "n" FROM ${table}${where}`, params))[0]?.n ?? 0);
|
|
1316
|
+
const selectedCount = await count(selWhere, selCtx.params);
|
|
1317
|
+
const matchedCount = await count(fullWhere, allParams);
|
|
1318
|
+
const ordering = orderSql(q.order, plan);
|
|
1319
|
+
const paging = pagingSql(q, pg);
|
|
1320
|
+
if (plan !== "entries") {
|
|
1321
|
+
const pageSql2 = `SELECT "payload" FROM ${table}${fullWhere} ORDER BY ${ordering}${paging}`;
|
|
1322
|
+
const rows2 = (await conn.query(pageSql2, allParams)).map((r) => decodeRowJson(String(r.payload)));
|
|
1323
|
+
const result2 = { projectionVersion: DOMAIN_PROJECTION_VERSION, selectedCount, matchedCount, plan, rows: rows2 };
|
|
1324
|
+
return result2;
|
|
1325
|
+
}
|
|
1326
|
+
const pageSql = `SELECT "note_id", "payload" FROM ${table}${fullWhere} ORDER BY ${ordering}${paging}`;
|
|
1327
|
+
const noteRows = await conn.query(pageSql, allParams);
|
|
1328
|
+
const relations = [
|
|
1329
|
+
`SELECT h."note_id" AS "note_id", s."data" AS "payload" FROM selected n
|
|
1330
|
+
JOIN "segment_head" h ON h."note_id" = n."note_id"
|
|
1331
|
+
JOIN "segments" s ON s."k0" = h."id" AND s."k1" = h."current_version"
|
|
1332
|
+
WHERE COALESCE(s."i_by_history_0", 0) = 0
|
|
1333
|
+
ORDER BY h."position" ASC NULLS LAST, s."k0"`,
|
|
1334
|
+
`SELECT nt."i_by_note_0" AS "note_id", t."data" AS "payload" FROM selected n
|
|
1335
|
+
JOIN "note_tags" nt ON nt."i_by_note_0" = n."note_id"
|
|
1336
|
+
JOIN "tags" t ON t."k0" = nt."i_by_tag_0" ORDER BY t."k0"`,
|
|
1337
|
+
`SELECT p."i_by_note_0" AS "note_id", p."data" AS "payload" FROM selected n
|
|
1338
|
+
JOIN "page_notes" p ON p."i_by_note_0" = n."note_id" ORDER BY p."k0"`,
|
|
1339
|
+
`SELECT DISTINCT p."i_by_note_0" AS "note_id", pg."data" AS "payload" FROM selected n
|
|
1340
|
+
JOIN "page_notes" p ON p."i_by_note_0" = n."note_id"
|
|
1341
|
+
JOIN "page" pg ON pg."k0" = p."i_by_page_0" ORDER BY pg."data"`
|
|
1342
|
+
];
|
|
1343
|
+
const buckets = noteRows.map(() => [[], [], [], []]);
|
|
1344
|
+
const index = /* @__PURE__ */ new Map();
|
|
1345
|
+
noteRows.forEach((r, i) => index.set(String(r.note_id), i));
|
|
1346
|
+
for (const [rel, relation] of relations.entries()) {
|
|
1347
|
+
for (const row of await conn.query(`WITH selected AS (${pageSql}) ${relation}`, allParams)) {
|
|
1348
|
+
const i = index.get(String(row.note_id));
|
|
1349
|
+
if (i !== void 0) buckets[i][rel].push(decodeRowJson(String(row.payload)));
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
const rows = noteRows.map((r, i) => {
|
|
1353
|
+
const [segments, tags, links, pages] = buckets[i];
|
|
1354
|
+
return { note: decodeRowJson(String(r.payload)), segments, tags, links, pages };
|
|
1355
|
+
});
|
|
1356
|
+
const result = { projectionVersion: DOMAIN_PROJECTION_VERSION, selectedCount, matchedCount, plan, rows };
|
|
1357
|
+
return result;
|
|
1358
|
+
}
|
|
1359
|
+
async function handleDomainQuery(db, body) {
|
|
1360
|
+
const query = parseQuery(body);
|
|
1361
|
+
return db.transaction(async (tx) => {
|
|
1362
|
+
if (db.dialect === "postgres") await tx.exec("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY");
|
|
1363
|
+
await gate(tx);
|
|
1364
|
+
return execute(tx, query, db.dialect === "postgres");
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// src/membership.ts
|
|
1369
|
+
var USER_OWNED_STORES = [
|
|
1370
|
+
"notes",
|
|
1371
|
+
"page",
|
|
1372
|
+
"page_notes",
|
|
1373
|
+
"tags",
|
|
1374
|
+
"tag_types",
|
|
1375
|
+
"note_tags",
|
|
1376
|
+
"segments",
|
|
1377
|
+
"sessions",
|
|
1378
|
+
"attachments",
|
|
1379
|
+
"events",
|
|
1380
|
+
"efforts",
|
|
1381
|
+
"block_index",
|
|
1382
|
+
"block_efforts"
|
|
1383
|
+
];
|
|
1384
|
+
var STAMPABLE = Object.fromEntries(
|
|
1385
|
+
USER_OWNED_STORES.map((s) => [s, true])
|
|
1386
|
+
);
|
|
1387
|
+
function stampUserOwned(store, value, userId) {
|
|
1388
|
+
if (!STAMPABLE[store]) return value;
|
|
1389
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
|
|
1390
|
+
const row = value;
|
|
1391
|
+
if (row.seedOrigin === "seed") return value;
|
|
1392
|
+
if (row.userId === userId) return value;
|
|
1393
|
+
return { ...row, userId };
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
// src/overlay.ts
|
|
1397
|
+
var bufferSlot = (store, key) => `${store}\0${JSON.stringify(key)}`;
|
|
1398
|
+
function isDirty(store, buffer, cleared) {
|
|
1399
|
+
if (cleared?.has(store)) return true;
|
|
1400
|
+
if (!buffer) return false;
|
|
1401
|
+
const prefix = `${store}\0`;
|
|
1402
|
+
for (const slot of buffer.keys()) {
|
|
1403
|
+
if (slot.startsWith(prefix)) return true;
|
|
1404
|
+
}
|
|
1405
|
+
return false;
|
|
1406
|
+
}
|
|
1407
|
+
function bufferedGet(store, key, buffer) {
|
|
1408
|
+
if (!buffer) return void 0;
|
|
1409
|
+
const entry = buffer.get(bufferSlot(store, key));
|
|
1410
|
+
if (!entry) return void 0;
|
|
1411
|
+
return entry.kind === "put" ? { hit: true, value: entry.value } : { hit: false };
|
|
1412
|
+
}
|
|
1413
|
+
function overlayRows(def, store, buffer, cleared, rows, count, range, indexName) {
|
|
1414
|
+
if (!buffer || !isDirty(store, buffer, cleared)) return count !== void 0 ? rows.slice(0, count) : rows;
|
|
1415
|
+
const out = /* @__PURE__ */ new Map();
|
|
1416
|
+
if (!cleared?.has(store)) {
|
|
1417
|
+
for (const row of rows) out.set(JSON.stringify(keyFromValue(def, row)), row);
|
|
1418
|
+
}
|
|
1419
|
+
const prefix = `${store}\0`;
|
|
1420
|
+
for (const [slot, entry] of buffer) {
|
|
1421
|
+
if (!slot.startsWith(prefix)) continue;
|
|
1422
|
+
if (entry.kind === "put") out.set(slot.slice(prefix.length), entry.value);
|
|
1423
|
+
else out.delete(slot.slice(prefix.length));
|
|
1424
|
+
}
|
|
1425
|
+
const index = indexName ? def.indexes.find((i) => i.name === indexName) : void 0;
|
|
1426
|
+
if (indexName && !index) throw new Error(`unknown index '${indexName}' for ${store}`);
|
|
1427
|
+
const keyOf = (row) => {
|
|
1428
|
+
if (!index) return keyFromValue(def, row);
|
|
1429
|
+
const parts = index.keyPath.map((path) => extractKeyPart(row, path));
|
|
1430
|
+
if (parts.some((part) => part === void 0)) return void 0;
|
|
1431
|
+
const keys = parts.filter((part) => part !== void 0);
|
|
1432
|
+
return keys.length === 1 ? keys[0] : keys;
|
|
1433
|
+
};
|
|
1434
|
+
const matches = (row) => {
|
|
1435
|
+
if (index?.multiEntry) {
|
|
1436
|
+
let values = row;
|
|
1437
|
+
for (const part of index.keyPath[0].split(".")) {
|
|
1438
|
+
if (values === null || typeof values !== "object" || !(part in values)) return false;
|
|
1439
|
+
values = Reflect.get(values, part);
|
|
1440
|
+
}
|
|
1441
|
+
return Array.isArray(values) && values.some((value) => (typeof value === "string" || typeof value === "number") && (!range || keyInRange(value, range)));
|
|
1442
|
+
}
|
|
1443
|
+
const key = keyOf(row);
|
|
1444
|
+
return key !== void 0 && (!range || keyInRange(key, range));
|
|
1445
|
+
};
|
|
1446
|
+
const merged = [...out.values()].filter(matches);
|
|
1447
|
+
merged.sort((a, b) => {
|
|
1448
|
+
const primary = compareKeys(keyFromValue(def, a), keyFromValue(def, b));
|
|
1449
|
+
if (index?.multiEntry) return primary;
|
|
1450
|
+
const ak = keyOf(a), bk = keyOf(b);
|
|
1451
|
+
return ak !== void 0 && bk !== void 0 ? compareKeys(ak, bk) || primary : primary;
|
|
1452
|
+
});
|
|
1453
|
+
return count !== void 0 ? merged.slice(0, count) : merged;
|
|
1454
|
+
}
|
|
1455
|
+
function bufferToOps(storeNames, buffer, cleared) {
|
|
1456
|
+
const known = new Set(storeNames);
|
|
1457
|
+
const ops = [];
|
|
1458
|
+
for (const store of cleared) {
|
|
1459
|
+
if (known.has(store)) ops.push({ op: "clear", store });
|
|
1460
|
+
}
|
|
1461
|
+
for (const [slot, entry] of buffer) {
|
|
1462
|
+
const sep = slot.indexOf("\0");
|
|
1463
|
+
const store = slot.slice(0, sep);
|
|
1464
|
+
if (!known.has(store)) continue;
|
|
1465
|
+
if (entry.kind === "put") ops.push({ op: "put", store, value: entry.value });
|
|
1466
|
+
else ops.push({ op: "delete", store, key: JSON.parse(slot.slice(sep + 1)) });
|
|
1467
|
+
}
|
|
1468
|
+
return ops;
|
|
1469
|
+
}
|
|
1470
|
+
var SqlStorage = class {
|
|
1471
|
+
db;
|
|
1472
|
+
options;
|
|
1473
|
+
defs;
|
|
1474
|
+
readyPromise;
|
|
1475
|
+
constructor(db, options = {}) {
|
|
1476
|
+
this.db = db;
|
|
1477
|
+
this.options = options;
|
|
1478
|
+
this.defs = Object.fromEntries(STORE_DEFS.map((d) => [d.name, d]));
|
|
1479
|
+
}
|
|
1480
|
+
/**
|
|
1481
|
+
* Idempotent schema + projection initialization. Cheap when already
|
|
1482
|
+
* compatible (both initializers are guard-protected); a failed attempt
|
|
1483
|
+
* clears the memo so the next call retries instead of caching the error.
|
|
1484
|
+
*/
|
|
1485
|
+
ready() {
|
|
1486
|
+
this.readyPromise ??= (async () => {
|
|
1487
|
+
try {
|
|
1488
|
+
await initializeStore(this.db);
|
|
1489
|
+
await initializeDomain(this.db);
|
|
1490
|
+
} catch (e) {
|
|
1491
|
+
this.readyPromise = void 0;
|
|
1492
|
+
throw e;
|
|
1493
|
+
}
|
|
1494
|
+
})();
|
|
1495
|
+
return this.readyPromise;
|
|
1496
|
+
}
|
|
1497
|
+
/** Every store operation starts here, so views created before (or during)
|
|
1498
|
+
* an in-flight ready() block until initialization actually succeeded. */
|
|
1499
|
+
ensureReady() {
|
|
1500
|
+
return this.ready();
|
|
1501
|
+
}
|
|
1502
|
+
def(store) {
|
|
1503
|
+
const def = this.defs[store];
|
|
1504
|
+
if (!def) throw new StorageError(404, `SqlStorage: unknown store '${store}'`);
|
|
1505
|
+
return def;
|
|
1506
|
+
}
|
|
1507
|
+
makeView(store, conn, mutable, buffer, cleared) {
|
|
1508
|
+
return new SqlStore(this, this.def(store), store, conn, mutable, buffer, cleared);
|
|
1509
|
+
}
|
|
1510
|
+
readonly(store) {
|
|
1511
|
+
return this.makeView(store, this.db, false);
|
|
1512
|
+
}
|
|
1513
|
+
readwrite(store) {
|
|
1514
|
+
return this.makeView(store, this.db, true);
|
|
1515
|
+
}
|
|
1516
|
+
async queryDomain(query) {
|
|
1517
|
+
await this.ensureReady();
|
|
1518
|
+
try {
|
|
1519
|
+
return await handleDomainQuery(this.db, query);
|
|
1520
|
+
} catch (e) {
|
|
1521
|
+
if (e instanceof StorageError && e.code === "domain_projection_unavailable") return void 0;
|
|
1522
|
+
throw e;
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
async transaction(stores, mode, fn) {
|
|
1526
|
+
await this.ensureReady();
|
|
1527
|
+
return this.runTx(stores, mode === "readwrite", fn, false);
|
|
1528
|
+
}
|
|
1529
|
+
/**
|
|
1530
|
+
* Bulk load for migration destinations: like transaction(), but the
|
|
1531
|
+
* buffered writes skip per-key projection refresh and derived state is
|
|
1532
|
+
* rebuilt once — everything inside one SQL transaction (throw → rollback).
|
|
1533
|
+
*/
|
|
1534
|
+
async bulkTransaction(stores, fn) {
|
|
1535
|
+
await this.ensureReady();
|
|
1536
|
+
return this.runTx(stores, true, fn, true);
|
|
1537
|
+
}
|
|
1538
|
+
runTx(stores, mutableMode, fn, deferProjection) {
|
|
1539
|
+
const scoped = new Set(stores);
|
|
1540
|
+
const buffer = /* @__PURE__ */ new Map();
|
|
1541
|
+
const cleared = /* @__PURE__ */ new Set();
|
|
1542
|
+
return this.db.transaction(async (conn) => {
|
|
1543
|
+
const checkScope = (store) => {
|
|
1544
|
+
if (!scoped.has(store)) {
|
|
1545
|
+
throw new StorageError(400, `store '${store}' is outside this transaction's scope`, "store_out_of_scope");
|
|
1546
|
+
}
|
|
1547
|
+
};
|
|
1548
|
+
const tx = {
|
|
1549
|
+
readonly: (store) => {
|
|
1550
|
+
checkScope(store);
|
|
1551
|
+
return this.makeView(store, conn, false, buffer, cleared);
|
|
1552
|
+
},
|
|
1553
|
+
readwrite: (store) => {
|
|
1554
|
+
checkScope(store);
|
|
1555
|
+
if (!mutableMode) {
|
|
1556
|
+
throw new StorageError(403, "transaction was opened readonly", "read_only_transaction");
|
|
1557
|
+
}
|
|
1558
|
+
return this.makeView(store, conn, true, buffer, cleared);
|
|
1559
|
+
}
|
|
1560
|
+
};
|
|
1561
|
+
const result = await fn(tx);
|
|
1562
|
+
const ops = bufferToOps(STORE_DEFS.map((d) => d.name), buffer, cleared);
|
|
1563
|
+
if (ops.length > 0) {
|
|
1564
|
+
await refreshDomain(conn, ops, deferProjection ? { deferProjection: true } : {});
|
|
1565
|
+
if (deferProjection) await rebuildProjections(conn);
|
|
1566
|
+
}
|
|
1567
|
+
return result;
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
/**
|
|
1571
|
+
* Proves the backend accepts writes: one real INSERT into `meta` inside a
|
|
1572
|
+
* transaction that is then deliberately rolled back — zero rows committed.
|
|
1573
|
+
* A read-only backend, bad token or transport failure rejects here.
|
|
1574
|
+
*/
|
|
1575
|
+
async verifyWritable() {
|
|
1576
|
+
await this.ensureReady();
|
|
1577
|
+
const sentinel = new Error("verifyWritable: deliberate rollback sentinel");
|
|
1578
|
+
try {
|
|
1579
|
+
await this.db.transaction(async (conn) => {
|
|
1580
|
+
await putRow(conn, this.def("meta"), {
|
|
1581
|
+
key: `__wodwiki_write_check_${crypto.randomUUID()}`,
|
|
1582
|
+
value: ""
|
|
1583
|
+
});
|
|
1584
|
+
throw sentinel;
|
|
1585
|
+
});
|
|
1586
|
+
} catch (e) {
|
|
1587
|
+
if (e !== sentinel) throw e;
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
async wipe() {
|
|
1591
|
+
await this.ensureReady();
|
|
1592
|
+
await this.db.transaction(async (tx) => {
|
|
1593
|
+
await wipeRows(tx);
|
|
1594
|
+
await wipeDomain(tx);
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
async close() {
|
|
1598
|
+
await this.db.close();
|
|
1599
|
+
}
|
|
1600
|
+
// Internal seam for SqlStore views (same module): immediate op application
|
|
1601
|
+
// and write stamping.
|
|
1602
|
+
async applyOps(ops) {
|
|
1603
|
+
await this.db.transaction((tx) => refreshDomain(tx, ops));
|
|
1604
|
+
}
|
|
1605
|
+
stamp(store, value) {
|
|
1606
|
+
const userId = this.options.getUserId?.();
|
|
1607
|
+
return userId ? stampUserOwned(store, value, userId) : value;
|
|
1608
|
+
}
|
|
1609
|
+
};
|
|
1610
|
+
var SqlStore = class {
|
|
1611
|
+
constructor(owner, def, store, conn, mutable, buffer, cleared) {
|
|
1612
|
+
this.owner = owner;
|
|
1613
|
+
this.def = def;
|
|
1614
|
+
this.store = store;
|
|
1615
|
+
this.conn = conn;
|
|
1616
|
+
this.mutable = mutable;
|
|
1617
|
+
this.buffer = buffer;
|
|
1618
|
+
this.cleared = cleared;
|
|
1619
|
+
}
|
|
1620
|
+
owner;
|
|
1621
|
+
def;
|
|
1622
|
+
store;
|
|
1623
|
+
conn;
|
|
1624
|
+
mutable;
|
|
1625
|
+
buffer;
|
|
1626
|
+
cleared;
|
|
1627
|
+
assertMutable() {
|
|
1628
|
+
if (!this.mutable) {
|
|
1629
|
+
throw new StorageError(403, `${this.store} is opened read-only`, "read_only_view");
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
async get(key) {
|
|
1633
|
+
await this.owner.ensureReady();
|
|
1634
|
+
const k = this.pointKey(key);
|
|
1635
|
+
const state = bufferedGet(this.store, k, this.buffer);
|
|
1636
|
+
if (state) return state.hit ? state.value : void 0;
|
|
1637
|
+
if (this.cleared?.has(this.store)) return void 0;
|
|
1638
|
+
return getRow(this.conn, this.def, k);
|
|
1639
|
+
}
|
|
1640
|
+
async getAll(query, count) {
|
|
1641
|
+
await this.owner.ensureReady();
|
|
1642
|
+
const range = rangeFromIDB(query);
|
|
1643
|
+
const rows = await listRows(this.conn, this.def, range, isDirty(this.store, this.buffer, this.cleared) ? void 0 : count);
|
|
1644
|
+
return overlayRows(this.def, this.store, this.buffer, this.cleared, rows, count, range);
|
|
1645
|
+
}
|
|
1646
|
+
async getAllFromIndex(indexName, query, count) {
|
|
1647
|
+
await this.owner.ensureReady();
|
|
1648
|
+
const range = rangeFromIDB(query);
|
|
1649
|
+
const rows = await listRows(this.conn, this.def, range, isDirty(this.store, this.buffer, this.cleared) ? void 0 : count, indexName);
|
|
1650
|
+
return overlayRows(this.def, this.store, this.buffer, this.cleared, rows, count, range, indexName);
|
|
1651
|
+
}
|
|
1652
|
+
async count(query) {
|
|
1653
|
+
await this.owner.ensureReady();
|
|
1654
|
+
const range = rangeFromIDB(query);
|
|
1655
|
+
if (!isDirty(this.store, this.buffer, this.cleared)) return countRows(this.conn, this.def, range);
|
|
1656
|
+
const rows = await listRows(this.conn, this.def, range);
|
|
1657
|
+
return overlayRows(this.def, this.store, this.buffer, this.cleared, rows, void 0, range).length;
|
|
1658
|
+
}
|
|
1659
|
+
async countFromIndex(indexName, query) {
|
|
1660
|
+
await this.owner.ensureReady();
|
|
1661
|
+
const range = rangeFromIDB(query);
|
|
1662
|
+
if (!isDirty(this.store, this.buffer, this.cleared)) return countRows(this.conn, this.def, range, indexName);
|
|
1663
|
+
const rows = await listRows(this.conn, this.def, range, void 0, indexName);
|
|
1664
|
+
return overlayRows(this.def, this.store, this.buffer, this.cleared, rows, void 0, range, indexName).length;
|
|
1665
|
+
}
|
|
1666
|
+
async put(value, _key) {
|
|
1667
|
+
await this.owner.ensureReady();
|
|
1668
|
+
this.assertMutable();
|
|
1669
|
+
const stored = this.owner.stamp(this.store, value);
|
|
1670
|
+
const key = keyFromValue(this.def, stored);
|
|
1671
|
+
if (this.buffer) {
|
|
1672
|
+
this.buffer.set(bufferSlot(this.store, key), { kind: "put", value: stored });
|
|
1673
|
+
return key;
|
|
1674
|
+
}
|
|
1675
|
+
await this.owner.applyOps([{ op: "put", store: this.store, value: stored }]);
|
|
1676
|
+
return key;
|
|
1677
|
+
}
|
|
1678
|
+
async delete(key) {
|
|
1679
|
+
await this.owner.ensureReady();
|
|
1680
|
+
this.assertMutable();
|
|
1681
|
+
const range = isRange(key) ? key : void 0;
|
|
1682
|
+
const target = range ?? key;
|
|
1683
|
+
if (this.buffer) {
|
|
1684
|
+
if (range) {
|
|
1685
|
+
for (const row of await this.getAll(range)) {
|
|
1686
|
+
const rowKey = keyFromValue(this.def, row);
|
|
1687
|
+
this.buffer.set(bufferSlot(this.store, rowKey), { kind: "delete", key: rowKey });
|
|
1688
|
+
}
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1691
|
+
const point = target;
|
|
1692
|
+
this.buffer.set(bufferSlot(this.store, point), { kind: "delete", key: point });
|
|
1693
|
+
return;
|
|
1694
|
+
}
|
|
1695
|
+
await this.owner.applyOps([{ op: "delete", store: this.store, key: target }]);
|
|
1696
|
+
}
|
|
1697
|
+
async clear() {
|
|
1698
|
+
await this.owner.ensureReady();
|
|
1699
|
+
this.assertMutable();
|
|
1700
|
+
if (this.buffer) {
|
|
1701
|
+
this.cleared?.add(this.store);
|
|
1702
|
+
const prefix = `${this.store}\0`;
|
|
1703
|
+
for (const slot of [...this.buffer.keys()]) {
|
|
1704
|
+
if (slot.startsWith(prefix)) this.buffer.delete(slot);
|
|
1705
|
+
}
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
await this.owner.applyOps([{ op: "clear", store: this.store }]);
|
|
1709
|
+
}
|
|
1710
|
+
/** Normalizes get() arguments to a wire key (bounded ranges match no row). */
|
|
1711
|
+
pointKey(key) {
|
|
1712
|
+
if (isRange(key)) {
|
|
1713
|
+
if (key.only === void 0) {
|
|
1714
|
+
throw new StorageError(400, "get() needs a plain key or {only}; bounded ranges match no single row");
|
|
1715
|
+
}
|
|
1716
|
+
return key.only;
|
|
1717
|
+
}
|
|
1718
|
+
return key;
|
|
1719
|
+
}
|
|
1720
|
+
};
|
|
1721
|
+
function validateTursoUrl(url) {
|
|
1722
|
+
let parsed;
|
|
1723
|
+
try {
|
|
1724
|
+
parsed = new URL(url);
|
|
1725
|
+
} catch {
|
|
1726
|
+
throw new StorageError(400, "invalid Turso URL", "bad_turso_url");
|
|
1727
|
+
}
|
|
1728
|
+
if (url.includes("?") || url.includes("#")) {
|
|
1729
|
+
throw new StorageError(400, "Turso URL must not carry a query or fragment; pass options/secrets via authToken", "bad_turso_url");
|
|
1730
|
+
}
|
|
1731
|
+
if (parsed.username || parsed.password) {
|
|
1732
|
+
throw new StorageError(400, "Turso URL must not embed credentials; pass them via authToken", "bad_turso_url");
|
|
1733
|
+
}
|
|
1734
|
+
const loopback = parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost" || parsed.hostname === "[::1]";
|
|
1735
|
+
if (parsed.protocol === "http:") {
|
|
1736
|
+
if (!loopback) {
|
|
1737
|
+
throw new StorageError(400, "refusing plain http Turso URL (token would traverse the network unencrypted); use libsql:/https:", "bad_turso_url");
|
|
1738
|
+
}
|
|
1739
|
+
return url;
|
|
1740
|
+
}
|
|
1741
|
+
if (parsed.protocol !== "libsql:" && parsed.protocol !== "https:" && parsed.protocol !== "wss:") {
|
|
1742
|
+
throw new StorageError(400, `unsupported Turso URL scheme ${parsed.protocol} (expected libsql:, https:, wss:)`, "bad_turso_url");
|
|
1743
|
+
}
|
|
1744
|
+
return url;
|
|
1745
|
+
}
|
|
1746
|
+
var BrowserTursoStorage = class extends SqlStorage {
|
|
1747
|
+
constructor(settings, options = {}) {
|
|
1748
|
+
super(
|
|
1749
|
+
libsqlDatabase(web.createClient({ url: validateTursoUrl(settings.url), authToken: settings.authToken })),
|
|
1750
|
+
options
|
|
1751
|
+
);
|
|
1752
|
+
}
|
|
1753
|
+
};
|
|
1754
|
+
|
|
1755
|
+
exports.BrowserTursoStorage = BrowserTursoStorage;
|
|
1756
|
+
exports.MAX_BODY = MAX_BODY;
|
|
1757
|
+
exports.SCHEMA_TABLE = SCHEMA_TABLE;
|
|
1758
|
+
exports.SqlStorage = SqlStorage;
|
|
1759
|
+
exports.StorageError = StorageError;
|
|
1760
|
+
exports.clearRows = clearRows;
|
|
1761
|
+
exports.countRows = countRows;
|
|
1762
|
+
exports.decodeRowJson = decodeRowJson;
|
|
1763
|
+
exports.deleteRows = deleteRows;
|
|
1764
|
+
exports.encodeRowJson = encodeRowJson;
|
|
1765
|
+
exports.getRow = getRow;
|
|
1766
|
+
exports.handleDomainQuery = handleDomainQuery;
|
|
1767
|
+
exports.initializeDomain = initializeDomain;
|
|
1768
|
+
exports.initializeStore = initializeStore;
|
|
1769
|
+
exports.libsqlDatabase = libsqlDatabase;
|
|
1770
|
+
exports.listRows = listRows;
|
|
1771
|
+
exports.putRow = putRow;
|
|
1772
|
+
exports.rebuildProjections = rebuildProjections;
|
|
1773
|
+
exports.refreshDomain = refreshDomain;
|
|
1774
|
+
exports.storeDefinition = storeDefinition;
|
|
1775
|
+
exports.validateTursoUrl = validateTursoUrl;
|
|
1776
|
+
exports.wipeDomain = wipeDomain;
|
|
1777
|
+
exports.wipeRows = wipeRows;
|
|
1778
|
+
//# sourceMappingURL=index.cjs.map
|
|
1779
|
+
//# sourceMappingURL=index.cjs.map
|