@haverstack/record-adapter-sqlite 0.33.0 → 0.34.0
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/index.d.ts +11 -30
- package/dist/index.js +428 -442
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, writeFileSync, unlinkSync, readFileSync } from 'fs';
|
|
2
|
-
import {
|
|
3
|
-
import { OwnerMismatchError, contentSortEntry,
|
|
2
|
+
import { StackConflictError, applyMergePatch, StackNotFoundError, assertOneSurface, StackVersionConflictError, StackBadRequestError, NATIVE_SORT_FIELDS } from '@haverstack/core';
|
|
3
|
+
import { OwnerMismatchError, AUTHORITY_ASSOCIATION_KINDS, contentSortEntry, partitionAssociations, constantSortEntry, contentSortKey, parseContentFilterKey } from '@haverstack/core/adapter';
|
|
4
4
|
import { randomBytes, createHash } from 'crypto';
|
|
5
5
|
|
|
6
6
|
// src/node-sqlite.ts
|
|
@@ -28,39 +28,32 @@ var RECORD_SCHEMA_SQL = `
|
|
|
28
28
|
) STRICT;
|
|
29
29
|
|
|
30
30
|
-- Every edge a record carries, authority and data alike: a permission
|
|
31
|
-
--
|
|
32
|
-
--
|
|
33
|
-
--
|
|
34
|
-
--
|
|
35
|
-
--
|
|
36
|
-
-- A relationship's target is (related_scope, related_id) plus one
|
|
37
|
-
-- qualifier: related_stack for a record in another stack, related_ns for
|
|
38
|
-
-- a foreign namespace. All four are in the primary key, so two targets
|
|
39
|
-
-- differing only by namespace are two associations. A permission's
|
|
40
|
-
-- grantee reuses them \u2014 related_scope 'entity' or 'group', related_id the
|
|
41
|
-
-- DID or group id \u2014 plus related_role, which only a group grantee sets
|
|
42
|
-
-- and which is in the key because member and admin name two different
|
|
43
|
-
-- sets of people.
|
|
44
|
-
--
|
|
45
|
-
-- attachment_record_id stays out of the primary key: it annotates a
|
|
46
|
-
-- reference rather than naming one, so re-pointing it updates the row an
|
|
47
|
-
-- an amendAssociations() add lands on instead of writing a second reference to the same
|
|
48
|
-
-- file. See docs/spec/attachments.md \xA7 Naming the upload a reference came from.
|
|
49
|
-
--
|
|
50
|
-
-- The primary key leads with record_id, so its implicit index already
|
|
51
|
-
-- serves every by-record read and delete; the indexes below exist only
|
|
52
|
-
-- for the filter directions it cannot answer.
|
|
31
|
+
-- has element identity and a delta shaped like a tag's, so it earns the
|
|
32
|
+
-- same table. The permissions and associations fields are projections
|
|
33
|
+
-- over it, partitioned by kind.
|
|
34
|
+
-- See docs/spec/access-control.md \xA7 Record-level permissions.
|
|
53
35
|
CREATE TABLE IF NOT EXISTS associations (
|
|
54
36
|
record_id TEXT NOT NULL REFERENCES records(id),
|
|
55
37
|
kind TEXT NOT NULL CHECK (kind IN ('tag', 'attachment', 'relationship', 'permission', 'anyone')),
|
|
56
38
|
label TEXT NOT NULL,
|
|
57
39
|
file_id TEXT NOT NULL DEFAULT '',
|
|
40
|
+
-- A relationship target is (related_scope, related_id) qualified by
|
|
41
|
+
-- related_ns (a foreign namespace) or related_stack (another stack); a
|
|
42
|
+
-- permission's grantee reuses scope and id. All are in the key, so
|
|
43
|
+
-- targets differing only by a qualifier are distinct associations.
|
|
58
44
|
related_scope TEXT NOT NULL DEFAULT '' CHECK (related_scope IN ('', 'record', 'entity', 'external', 'group')),
|
|
59
45
|
related_id TEXT NOT NULL DEFAULT '',
|
|
60
46
|
related_ns TEXT NOT NULL DEFAULT '',
|
|
61
47
|
related_stack TEXT NOT NULL DEFAULT '',
|
|
48
|
+
-- Set only by a group grantee; in the key because member and admin name
|
|
49
|
+
-- two different sets of people.
|
|
62
50
|
related_role TEXT NOT NULL DEFAULT '' CHECK (related_role IN ('', 'member', 'admin')),
|
|
51
|
+
-- Out of the key: it annotates a reference rather than naming one, so
|
|
52
|
+
-- re-pointing it updates the existing row. See docs/spec/attachments.md
|
|
53
|
+
-- \xA7 Naming the upload a reference came from.
|
|
63
54
|
attachment_record_id TEXT NOT NULL DEFAULT '',
|
|
55
|
+
-- Leads with record_id, so its implicit index serves every by-record
|
|
56
|
+
-- read and delete; the indexes below cover only the filter directions.
|
|
64
57
|
PRIMARY KEY (record_id, kind, label, file_id, related_scope, related_id, related_ns, related_stack, related_role)
|
|
65
58
|
) STRICT;
|
|
66
59
|
|
|
@@ -123,7 +116,6 @@ var RECORD_SCHEMA_SQL = `
|
|
|
123
116
|
|
|
124
117
|
-- One row per top-level scalar content field a record holds a value at:
|
|
125
118
|
-- what it orders as, and the file it references. See
|
|
126
|
-
-- docs/spec/adapters.md \xA7 Adapter backends and
|
|
127
119
|
-- docs/spec/data-model.md \xA7 Sorting by a content field.
|
|
128
120
|
--
|
|
129
121
|
-- value_rank is 0 for a numeric value and 1 for a text one \u2014 the same
|
|
@@ -140,14 +132,11 @@ var RECORD_SCHEMA_SQL = `
|
|
|
140
132
|
PRIMARY KEY (record_id, field)
|
|
141
133
|
) STRICT;
|
|
142
134
|
|
|
143
|
-
--
|
|
144
|
-
--
|
|
145
|
-
--
|
|
146
|
-
--
|
|
147
|
-
--
|
|
148
|
-
-- deleted_at and unlisted_at deliberately have none: nothing searches
|
|
149
|
-
-- for the rows they mark, and an index on a column every query tests
|
|
150
|
-
-- for IS NULL only lures the planner off the one answering ORDER BY.
|
|
135
|
+
-- Each filter column continues into (created_at, id), the default order
|
|
136
|
+
-- and its tiebreak: these columns are low-cardinality, and a narrow index
|
|
137
|
+
-- leaves the planner sorting every match before it can honor LIMIT.
|
|
138
|
+
-- deleted_at and unlisted_at get none: an index on a column every query
|
|
139
|
+
-- tests IS NULL only lures the planner off the one answering ORDER BY.
|
|
151
140
|
CREATE INDEX IF NOT EXISTS idx_records_type_id ON records(type_id, created_at, id);
|
|
152
141
|
CREATE INDEX IF NOT EXISTS idx_records_parent_id ON records(parent_id, created_at, id);
|
|
153
142
|
CREATE INDEX IF NOT EXISTS idx_records_entity_id ON records(entity_id, created_at, id);
|
|
@@ -201,71 +190,6 @@ var applyRecordSchema = (exec, opts) => {
|
|
|
201
190
|
exec.exec(RECORD_SCHEMA_SQL);
|
|
202
191
|
exec.exec(FTS5_SCHEMA_SQL);
|
|
203
192
|
};
|
|
204
|
-
var SORT_FIELDS = NATIVE_SORT_FIELDS;
|
|
205
|
-
var toBase64 = (text) => {
|
|
206
|
-
const bytes = new TextEncoder().encode(text);
|
|
207
|
-
let binary = "";
|
|
208
|
-
for (const byte of bytes)
|
|
209
|
-
binary += String.fromCharCode(byte);
|
|
210
|
-
return btoa(binary);
|
|
211
|
-
};
|
|
212
|
-
var fromBase64 = (cursor) => {
|
|
213
|
-
const binary = atob(cursor);
|
|
214
|
-
const bytes = new Uint8Array(binary.length);
|
|
215
|
-
for (let i = 0; i < binary.length; i++)
|
|
216
|
-
bytes[i] = binary.charCodeAt(i);
|
|
217
|
-
return new TextDecoder().decode(bytes);
|
|
218
|
-
};
|
|
219
|
-
var encodeCursor = (decoded) => toBase64(JSON.stringify(decoded.kind === "absent" ? { k: decoded.kind, f: decoded.field, i: decoded.id } : { k: decoded.kind, f: decoded.field, i: decoded.id, v: decoded.value }));
|
|
220
|
-
var decodeCursor = (cursor) => {
|
|
221
|
-
const malformed = () => new StackBadRequestError(`Invalid cursor: malformed "${cursor}"`);
|
|
222
|
-
let parsed;
|
|
223
|
-
try {
|
|
224
|
-
parsed = JSON.parse(fromBase64(cursor));
|
|
225
|
-
} catch {
|
|
226
|
-
throw malformed();
|
|
227
|
-
}
|
|
228
|
-
if (!parsed || typeof parsed !== "object")
|
|
229
|
-
throw malformed();
|
|
230
|
-
const { k, f, i, v } = parsed;
|
|
231
|
-
if (typeof f !== "string" || !f || typeof i !== "string")
|
|
232
|
-
throw malformed();
|
|
233
|
-
if (k === "absent")
|
|
234
|
-
return { kind: "absent", field: f, id: i };
|
|
235
|
-
if (k === "text") {
|
|
236
|
-
if (typeof v !== "string")
|
|
237
|
-
throw malformed();
|
|
238
|
-
return { kind: "text", field: f, value: v, id: i };
|
|
239
|
-
}
|
|
240
|
-
if (k === "num" || k === "native") {
|
|
241
|
-
if (typeof v !== "number" || !isFinite(v)) {
|
|
242
|
-
throw new StackBadRequestError(`Invalid cursor: non-numeric sort value`);
|
|
243
|
-
}
|
|
244
|
-
if (k === "native") {
|
|
245
|
-
if (!SORT_FIELDS.includes(f)) {
|
|
246
|
-
throw new StackBadRequestError(`Invalid cursor: unknown sort field "${f}"`);
|
|
247
|
-
}
|
|
248
|
-
return { kind: "native", field: f, value: v, id: i };
|
|
249
|
-
}
|
|
250
|
-
return { kind: "num", field: f, value: v, id: i };
|
|
251
|
-
}
|
|
252
|
-
throw malformed();
|
|
253
|
-
};
|
|
254
|
-
var getSortField = (query) => query.sort?.field ?? "createdAt";
|
|
255
|
-
var getSortColumn = (field) => field === "createdAt" ? "created_at" : field === "updatedAt" ? "updated_at" : "version";
|
|
256
|
-
var makeCursor = (record, sort, kindOf) => {
|
|
257
|
-
const contentField = sort?.contentField;
|
|
258
|
-
if (contentField === void 0) {
|
|
259
|
-
const field = getSortField({ sort });
|
|
260
|
-
const value = field === "updatedAt" ? record.updatedAt.getTime() : field === "version" ? record.version : record.createdAt.getTime();
|
|
261
|
-
return encodeCursor({ kind: "native", field, value, id: record.id });
|
|
262
|
-
}
|
|
263
|
-
const kind = kindOf?.(record, contentField);
|
|
264
|
-
const entry = kind ? contentSortEntry(kind, record.content[contentField]) : null;
|
|
265
|
-
if (!entry)
|
|
266
|
-
return encodeCursor({ kind: "absent", field: contentField, id: record.id });
|
|
267
|
-
return entry.kind === "num" ? encodeCursor({ kind: "num", field: contentField, value: entry.num, id: record.id }) : encodeCursor({ kind: "text", field: contentField, value: entry.text, id: record.id });
|
|
268
|
-
};
|
|
269
193
|
|
|
270
194
|
// ../sqlite-shared/dist/fts5.js
|
|
271
195
|
var OUTSIDE_PHRASE_DISALLOWED = /[^\p{L}\p{N}\p{M}_\s()"]/gu;
|
|
@@ -279,7 +203,7 @@ var sanitizeFts5Query = (query, maxDepth = 2) => {
|
|
|
279
203
|
clean = clean.replace(/\bNEAR\s*\(/gi, "(");
|
|
280
204
|
clean = clean.replace(/"[^"]*"|[^"]+/g, (span) => span.startsWith('"') ? span : span.replace(OUTSIDE_PHRASE_DISALLOWED, " "));
|
|
281
205
|
clean = collapseWhitespace(clean);
|
|
282
|
-
clean = clean.replace(/(
|
|
206
|
+
clean = clean.replace(/"[^"]*"?|(^|\(\s*)NOT\s+/gi, (m, lead) => lead ?? m);
|
|
283
207
|
let currentDepth = 0;
|
|
284
208
|
let inPhrase = false;
|
|
285
209
|
let result = "";
|
|
@@ -346,27 +270,107 @@ var restoreImplicitAnd = (query) => withPhrasesMasked(query, (masked) => masked.
|
|
|
346
270
|
const next = /^\s*([\p{L}\p{N}_]+)/u.exec(whole.slice(offset + match.length));
|
|
347
271
|
return next && isOperator(next[1]) ? match : ") AND ";
|
|
348
272
|
}).replace(new RegExp(String.raw`(?<!${OPERAND_CHAR})(${OPERAND_CHAR}+)\s*(?=\()`, "gu"), (match, word) => isOperator(word) ? match : `${word} AND `));
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
273
|
+
|
|
274
|
+
// ../sqlite-shared/dist/executor.js
|
|
275
|
+
var isForeignKeyViolation = (err) => err instanceof Error && err.message.includes("FOREIGN KEY constraint failed");
|
|
276
|
+
var isUniqueConstraintViolation = (err) => err instanceof Error && err.message.includes("UNIQUE constraint failed");
|
|
277
|
+
|
|
278
|
+
// ../sqlite-shared/dist/config.js
|
|
279
|
+
var CONFIG_ROW_SQL = `SELECT content FROM records WHERE id = '_config'`;
|
|
280
|
+
var insertConfigRecord = (exec, entityId, timezone) => {
|
|
281
|
+
const now = Date.now();
|
|
282
|
+
exec.run(`INSERT INTO records (id, type_id, created_at, updated_at, content, version)
|
|
283
|
+
VALUES ('_config', '_config@1', ?, ?, ?, 1)`, [now, now, JSON.stringify({ entityId, timezone })]);
|
|
284
|
+
return { entityId, timezone };
|
|
285
|
+
};
|
|
286
|
+
var tryReadStackConfig = (exec) => {
|
|
287
|
+
const row = exec.get(CONFIG_ROW_SQL);
|
|
288
|
+
if (!row)
|
|
289
|
+
return null;
|
|
290
|
+
const content = JSON.parse(row.content);
|
|
291
|
+
return { entityId: content.entityId, timezone: content.timezone };
|
|
292
|
+
};
|
|
293
|
+
var readStackConfig = (exec) => {
|
|
294
|
+
const config = tryReadStackConfig(exec);
|
|
295
|
+
if (!config)
|
|
296
|
+
throw new Error("Stack database is missing its config record.");
|
|
297
|
+
return config;
|
|
298
|
+
};
|
|
299
|
+
var toBase64 = (text) => {
|
|
300
|
+
const bytes = new TextEncoder().encode(text);
|
|
301
|
+
let binary = "";
|
|
302
|
+
for (const byte of bytes)
|
|
303
|
+
binary += String.fromCharCode(byte);
|
|
304
|
+
return btoa(binary);
|
|
305
|
+
};
|
|
306
|
+
var fromBase64 = (cursor) => {
|
|
307
|
+
const binary = atob(cursor);
|
|
308
|
+
const bytes = new Uint8Array(binary.length);
|
|
309
|
+
for (let i = 0; i < binary.length; i++)
|
|
310
|
+
bytes[i] = binary.charCodeAt(i);
|
|
311
|
+
return new TextDecoder().decode(bytes);
|
|
312
|
+
};
|
|
313
|
+
var encodeCursor = (decoded) => toBase64(JSON.stringify(decoded.kind === "absent" ? { k: decoded.kind, f: decoded.field, i: decoded.id } : { k: decoded.kind, f: decoded.field, i: decoded.id, v: decoded.value }));
|
|
314
|
+
var decodeCursor = (cursor) => {
|
|
315
|
+
const malformed = () => new StackBadRequestError(`Invalid cursor: malformed "${cursor}"`);
|
|
316
|
+
let parsed;
|
|
317
|
+
try {
|
|
318
|
+
parsed = JSON.parse(fromBase64(cursor));
|
|
319
|
+
} catch {
|
|
320
|
+
throw malformed();
|
|
369
321
|
}
|
|
322
|
+
if (!parsed || typeof parsed !== "object")
|
|
323
|
+
throw malformed();
|
|
324
|
+
const { k, f, i, v } = parsed;
|
|
325
|
+
if (typeof f !== "string" || !f || typeof i !== "string")
|
|
326
|
+
throw malformed();
|
|
327
|
+
if (k === "absent")
|
|
328
|
+
return { kind: "absent", field: f, id: i };
|
|
329
|
+
if (k === "text") {
|
|
330
|
+
if (typeof v !== "string")
|
|
331
|
+
throw malformed();
|
|
332
|
+
return { kind: "text", field: f, value: v, id: i };
|
|
333
|
+
}
|
|
334
|
+
if (k === "num" || k === "native") {
|
|
335
|
+
if (typeof v !== "number" || !isFinite(v)) {
|
|
336
|
+
throw new StackBadRequestError(`Invalid cursor: non-numeric sort value`);
|
|
337
|
+
}
|
|
338
|
+
if (k === "native") {
|
|
339
|
+
if (!NATIVE_SORT_FIELDS.includes(f)) {
|
|
340
|
+
throw new StackBadRequestError(`Invalid cursor: unknown sort field "${f}"`);
|
|
341
|
+
}
|
|
342
|
+
return { kind: "native", field: f, value: v, id: i };
|
|
343
|
+
}
|
|
344
|
+
return { kind: "num", field: f, value: v, id: i };
|
|
345
|
+
}
|
|
346
|
+
throw malformed();
|
|
347
|
+
};
|
|
348
|
+
var byTypeDescriptor = (byType) => JSON.stringify(Object.entries(byType).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
|
|
349
|
+
var getSortField = (query) => query.sort?.field ?? "createdAt";
|
|
350
|
+
var getSortColumn = (field) => field === "createdAt" ? "created_at" : field === "updatedAt" ? "updated_at" : "version";
|
|
351
|
+
var makeCursor = (record, sort, kindOf) => {
|
|
352
|
+
const byType = sort?.contentFieldByType;
|
|
353
|
+
if (byType) {
|
|
354
|
+
const field = byTypeDescriptor(byType);
|
|
355
|
+
const key = Object.hasOwn(byType, record.typeId) ? byType[record.typeId] : void 0;
|
|
356
|
+
const kind2 = key?.field === void 0 ? void 0 : kindOf?.(record, key.field);
|
|
357
|
+
const entry2 = key?.field !== void 0 ? kind2 && contentSortEntry(kind2, record.content[key.field]) : key && constantSortEntry(key.value ?? null);
|
|
358
|
+
return encodeEntry(field, entry2 || null, record.id);
|
|
359
|
+
}
|
|
360
|
+
const contentField = sort?.contentField;
|
|
361
|
+
if (contentField === void 0) {
|
|
362
|
+
const field = getSortField({ sort });
|
|
363
|
+
const value = field === "updatedAt" ? record.updatedAt.getTime() : field === "version" ? record.version : record.createdAt.getTime();
|
|
364
|
+
return encodeCursor({ kind: "native", field, value, id: record.id });
|
|
365
|
+
}
|
|
366
|
+
const kind = kindOf?.(record, contentField);
|
|
367
|
+
const entry = kind ? contentSortEntry(kind, record.content[contentField]) : null;
|
|
368
|
+
return encodeEntry(contentField, entry, record.id);
|
|
369
|
+
};
|
|
370
|
+
var encodeEntry = (field, entry, id) => {
|
|
371
|
+
if (!entry)
|
|
372
|
+
return encodeCursor({ kind: "absent", field, id });
|
|
373
|
+
return entry.kind === "num" ? encodeCursor({ kind: "num", field, value: entry.num, id }) : encodeCursor({ kind: "text", field, value: entry.text, id });
|
|
370
374
|
};
|
|
371
375
|
|
|
372
376
|
// ../sqlite-shared/dist/query.js
|
|
@@ -413,10 +417,31 @@ var contentCursor = (cursor, field) => {
|
|
|
413
417
|
throw cursorMismatch(cursor, field);
|
|
414
418
|
return cursor;
|
|
415
419
|
};
|
|
420
|
+
var contentConditions = (predicate, params, nextAlias) => {
|
|
421
|
+
const conditions = [];
|
|
422
|
+
for (const [key, value] of Object.entries(predicate.content ?? {})) {
|
|
423
|
+
const segments = parseContentFilterKey(key);
|
|
424
|
+
if (value === null || value === void 0) {
|
|
425
|
+
const isNull = contentPathExists(segments, (a) => `${a}.value IS NULL`, params, nextAlias);
|
|
426
|
+
const anyValue = contentPathExists(segments, (a) => `${a}.value IS NOT NULL`, params, nextAlias);
|
|
427
|
+
conditions.push(`(${isNull} OR NOT ${anyValue})`);
|
|
428
|
+
} else {
|
|
429
|
+
const match = contentPathExists(segments, equalsLeaf, params, nextAlias);
|
|
430
|
+
params.push(value);
|
|
431
|
+
conditions.push(match);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
for (const key of predicate.contentPresent ?? []) {
|
|
435
|
+
conditions.push(contentPathExists(parseContentFilterKey(key), (a) => `${a}.value IS NOT NULL`, params, nextAlias));
|
|
436
|
+
}
|
|
437
|
+
return conditions;
|
|
438
|
+
};
|
|
416
439
|
var recordConditions = (query) => {
|
|
417
440
|
const conditions = ["r.id != '_config'"];
|
|
418
441
|
const params = [];
|
|
419
442
|
const f = query.filter ?? {};
|
|
443
|
+
let aliasSeq = 0;
|
|
444
|
+
const nextContentAlias = () => `cp${aliasSeq++}`;
|
|
420
445
|
const inFilter = (column, value) => {
|
|
421
446
|
if (value === void 0)
|
|
422
447
|
return;
|
|
@@ -505,28 +530,15 @@ var recordConditions = (query) => {
|
|
|
505
530
|
}
|
|
506
531
|
conditions.push(hasAssociation("relationship", clauses));
|
|
507
532
|
}
|
|
508
|
-
if (f.content) {
|
|
509
|
-
|
|
510
|
-
const nextAlias = () => `cp${aliasSeq++}`;
|
|
511
|
-
for (const [key, value] of Object.entries(f.content)) {
|
|
512
|
-
const segments = parseContentFilterKey(key);
|
|
513
|
-
if (value === null || value === void 0) {
|
|
514
|
-
const isNull = contentPathExists(segments, (a) => `${a}.value IS NULL`, params, nextAlias);
|
|
515
|
-
const anyValue = contentPathExists(segments, (a) => `${a}.value IS NOT NULL`, params, nextAlias);
|
|
516
|
-
conditions.push(`(${isNull} OR NOT ${anyValue})`);
|
|
517
|
-
} else {
|
|
518
|
-
const match = contentPathExists(segments, equalsLeaf, params, nextAlias);
|
|
519
|
-
params.push(value);
|
|
520
|
-
conditions.push(match);
|
|
521
|
-
}
|
|
522
|
-
}
|
|
533
|
+
if (f.content || f.contentPresent?.length) {
|
|
534
|
+
conditions.push(...contentConditions(f, params, nextContentAlias));
|
|
523
535
|
}
|
|
524
|
-
if (f.
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
}
|
|
536
|
+
if (f.contentByType) {
|
|
537
|
+
const arms = Object.entries(f.contentByType).map(([typeId, arm]) => {
|
|
538
|
+
params.push(typeId);
|
|
539
|
+
return ["r.type_id = ?", ...contentConditions(arm, params, nextContentAlias)].join(" AND ");
|
|
540
|
+
});
|
|
541
|
+
conditions.push(`(${arms.map((arm) => `(${arm})`).join(" OR ")})`);
|
|
530
542
|
}
|
|
531
543
|
if (f.search) {
|
|
532
544
|
const sanitized = sanitizeFts5Query(f.search);
|
|
@@ -556,12 +568,16 @@ var buildQueryPlan = (query) => {
|
|
|
556
568
|
const dir = sqlDirection(query);
|
|
557
569
|
const op = dir === "ASC" ? ">" : "<";
|
|
558
570
|
const decoded = query.cursor ? decodeCursor(query.cursor) : null;
|
|
571
|
+
if (query.sort?.contentFieldByType) {
|
|
572
|
+
return byTypePlan(query, query.sort.contentFieldByType, dir, op, decoded);
|
|
573
|
+
}
|
|
559
574
|
const contentField = query.sort?.contentField;
|
|
560
575
|
if (contentField === void 0) {
|
|
561
576
|
const { conditions, params } = recordConditions(query);
|
|
562
|
-
const
|
|
577
|
+
const field = getSortField(query);
|
|
578
|
+
const col = getSortColumn(field);
|
|
563
579
|
if (decoded) {
|
|
564
|
-
const cursor2 = nativeCursor(decoded,
|
|
580
|
+
const cursor2 = nativeCursor(decoded, field);
|
|
565
581
|
conditions.push(`(r.${col} ${op} ? OR (r.${col} = ? AND r.id ${op} ?))`);
|
|
566
582
|
params.push(cursor2.value, cursor2.value, cursor2.id);
|
|
567
583
|
}
|
|
@@ -593,18 +609,154 @@ var buildQueryPlan = (query) => {
|
|
|
593
609
|
}
|
|
594
610
|
return pages;
|
|
595
611
|
};
|
|
596
|
-
|
|
597
|
-
|
|
612
|
+
var byTypePlan = (query, byType, dir, op, decoded) => {
|
|
613
|
+
const cursor = decoded ? contentCursor(decoded, byTypeDescriptor(byType)) : null;
|
|
614
|
+
const fields = [];
|
|
615
|
+
const constants = [];
|
|
616
|
+
for (const [typeId, key] of Object.entries(byType)) {
|
|
617
|
+
if (key.field !== void 0) {
|
|
618
|
+
fields.push([typeId, key.field]);
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
const entry = constantSortEntry(key.value ?? null);
|
|
622
|
+
if (entry)
|
|
623
|
+
constants.push([typeId, entry]);
|
|
624
|
+
}
|
|
625
|
+
const pages = [];
|
|
626
|
+
const fieldOf = (alias, record) => fields.map(() => `(${record}.type_id = ? AND ${alias}.field = ?)`).join(" OR ");
|
|
627
|
+
if (cursor?.kind !== "absent" && fields.length + constants.length > 0) {
|
|
628
|
+
const sources = [];
|
|
629
|
+
const sourceParams = [];
|
|
630
|
+
if (fields.length > 0) {
|
|
631
|
+
sources.push(`SELECT c.record_id AS record_id, c.value_rank AS value_rank, c.num_value AS num_value, c.text_key AS text_key, c.text_value AS text_value FROM content_index c JOIN records t ON t.id = c.record_id WHERE ${fieldOf("c", "t")}`);
|
|
632
|
+
for (const [typeId, field] of fields)
|
|
633
|
+
sourceParams.push(typeId, field);
|
|
634
|
+
}
|
|
635
|
+
for (const [typeId, entry] of constants) {
|
|
636
|
+
sources.push("SELECT t.id AS record_id, ? AS value_rank, ? AS num_value, ? AS text_key, ? AS text_value FROM records t WHERE t.type_id = ?");
|
|
637
|
+
sourceParams.push(...entry.kind === "num" ? [0, entry.num, null, null] : [1, null, entry.key, entry.text], typeId);
|
|
638
|
+
}
|
|
639
|
+
const { conditions, params } = recordConditions(query);
|
|
640
|
+
if (cursor)
|
|
641
|
+
conditions.push(presentCursorCondition(cursor, op, params));
|
|
642
|
+
const from = `(${sources.join(" UNION ALL ")}) ${SORT_ALIAS} JOIN records r ON r.id = ${SORT_ALIAS}.record_id`;
|
|
643
|
+
const order = `${SORT_ALIAS}.value_rank ${dir}, ${SORT_ALIAS}.num_value ${dir}, ${SORT_ALIAS}.text_key ${dir}, ${SORT_ALIAS}.text_value ${dir}, ${SORT_ALIAS}.record_id ${dir}`;
|
|
644
|
+
pages.push(statement("r.*", from, conditions, [...sourceParams, ...params], ` ORDER BY ${order} LIMIT ?`));
|
|
645
|
+
}
|
|
646
|
+
{
|
|
647
|
+
const { conditions, params } = recordConditions(query);
|
|
648
|
+
if (fields.length > 0) {
|
|
649
|
+
conditions.push(`NOT EXISTS (SELECT 1 FROM content_index ${SORT_ALIAS} WHERE ${SORT_ALIAS}.record_id = r.id AND (${fieldOf(SORT_ALIAS, "r")}))`);
|
|
650
|
+
for (const [typeId, field] of fields)
|
|
651
|
+
params.push(typeId, field);
|
|
652
|
+
}
|
|
653
|
+
if (constants.length > 0) {
|
|
654
|
+
conditions.push(`r.type_id NOT IN (${constants.map(() => "?").join(",")})`);
|
|
655
|
+
params.push(...constants.map(([typeId]) => typeId));
|
|
656
|
+
}
|
|
657
|
+
if (cursor?.kind === "absent") {
|
|
658
|
+
conditions.push(`r.id ${op} ?`);
|
|
659
|
+
params.push(cursor.id);
|
|
660
|
+
}
|
|
661
|
+
pages.push(statement("r.*", "records r", conditions, params, ` ORDER BY r.id ${dir} LIMIT ?`));
|
|
662
|
+
}
|
|
663
|
+
return pages;
|
|
664
|
+
};
|
|
665
|
+
var SearchIndex = class {
|
|
666
|
+
exec;
|
|
667
|
+
constructor(exec) {
|
|
668
|
+
this.exec = exec;
|
|
669
|
+
}
|
|
670
|
+
/** Index a record that has no FTS entry yet (used right after an INSERT). */
|
|
671
|
+
insert(recordId, content) {
|
|
672
|
+
this.exec.run(`INSERT INTO records_fts(rowid, content) SELECT rowid, ? FROM records WHERE id = ?`, [content, recordId]);
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Remove a record's FTS entry. MUST be called before the records row's
|
|
676
|
+
* content changes or the row is deleted — see the note above.
|
|
677
|
+
*/
|
|
678
|
+
remove(recordId) {
|
|
679
|
+
const row = this.exec.get("SELECT rowid, content FROM records WHERE id = ?", [recordId]);
|
|
680
|
+
if (!row)
|
|
681
|
+
return;
|
|
682
|
+
this.exec.run(`INSERT INTO records_fts(records_fts, rowid, content) VALUES('delete', ?, ?)`, [
|
|
683
|
+
row.rowid,
|
|
684
|
+
row.content
|
|
685
|
+
]);
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
var indexedFieldKinds = (schema) => {
|
|
689
|
+
const kinds = /* @__PURE__ */ new Map();
|
|
690
|
+
for (const [field, def] of Object.entries(schema)) {
|
|
691
|
+
if (!def.kind || def.kind === "array" || def.kind === "object")
|
|
692
|
+
continue;
|
|
693
|
+
kinds.set(field, def.kind);
|
|
694
|
+
}
|
|
695
|
+
return kinds;
|
|
696
|
+
};
|
|
697
|
+
var ContentIndex = class {
|
|
698
|
+
exec;
|
|
699
|
+
fieldsByType = /* @__PURE__ */ new Map();
|
|
700
|
+
constructor(exec) {
|
|
701
|
+
this.exec = exec;
|
|
702
|
+
}
|
|
703
|
+
/** Prime the cache from a type as it is saved. */
|
|
704
|
+
rememberType(type) {
|
|
705
|
+
this.fieldsByType.set(type.id, indexedFieldKinds(type.schema));
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Replace a record's rows with what its content currently holds, on every
|
|
709
|
+
* write that can change content or typeId, so neither the ordering nor the
|
|
710
|
+
* file-reference lookup can drift from the record. A field holding no
|
|
711
|
+
* orderable value gets no row at all, which is what puts the record at the
|
|
712
|
+
* end of a sort on that field.
|
|
713
|
+
*/
|
|
714
|
+
sync(recordId, typeId, content) {
|
|
715
|
+
this.remove(recordId);
|
|
716
|
+
for (const [field, kind] of this.fieldsOf(typeId)) {
|
|
717
|
+
const entry = contentSortEntry(kind, content[field]);
|
|
718
|
+
if (!entry)
|
|
719
|
+
continue;
|
|
720
|
+
const sql = `INSERT OR IGNORE INTO content_index
|
|
721
|
+
(record_id, field, value_rank, num_value, text_key, text_value, file_id)
|
|
722
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`;
|
|
723
|
+
if (entry.kind === "num") {
|
|
724
|
+
this.exec.run(sql, [recordId, field, 0, entry.num, null, null, null]);
|
|
725
|
+
} else {
|
|
726
|
+
const fileId = kind === "file-ref" ? entry.text : null;
|
|
727
|
+
this.exec.run(sql, [recordId, field, 1, null, entry.key, entry.text, fileId]);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
remove(recordId) {
|
|
732
|
+
this.exec.run("DELETE FROM content_index WHERE record_id = ?", [recordId]);
|
|
733
|
+
}
|
|
734
|
+
/** The declared kind of a type's top-level `field`, if it has one. */
|
|
735
|
+
kindOf(typeId, field) {
|
|
736
|
+
return this.fieldsOf(typeId).get(field);
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* From the cache, falling back to a `types` lookup only for a typeId this
|
|
740
|
+
* process hasn't seen via rememberType() yet, e.g. right after open().
|
|
741
|
+
*/
|
|
742
|
+
fieldsOf(typeId) {
|
|
743
|
+
const cached = this.fieldsByType.get(typeId);
|
|
744
|
+
if (cached)
|
|
745
|
+
return cached;
|
|
746
|
+
const typeRow = this.exec.get("SELECT schema FROM types WHERE id = ?", [
|
|
747
|
+
typeId
|
|
748
|
+
]);
|
|
749
|
+
const fields = typeRow ? indexedFieldKinds(JSON.parse(typeRow.schema)) : /* @__PURE__ */ new Map();
|
|
750
|
+
this.fieldsByType.set(typeId, fields);
|
|
751
|
+
return fields;
|
|
752
|
+
}
|
|
753
|
+
};
|
|
598
754
|
var toMs = (d) => d.getTime();
|
|
599
755
|
var actorFromColumns = (subject, principal) => subject == null ? void 0 : {
|
|
600
756
|
subjectId: subject,
|
|
601
757
|
...principal != null && { principalId: principal }
|
|
602
758
|
};
|
|
603
759
|
var fromMs = (ms) => new Date(ms);
|
|
604
|
-
var partitionAssociations = (associations) => ({
|
|
605
|
-
associations: associations.filter((a) => a.kind !== "permission" && a.kind !== "anyone"),
|
|
606
|
-
permissions: associations.filter((a) => a.kind === "permission" || a.kind === "anyone")
|
|
607
|
-
});
|
|
608
760
|
var rowToRecord = (row, associations) => {
|
|
609
761
|
const record = {
|
|
610
762
|
id: row.id,
|
|
@@ -748,31 +900,7 @@ var rowToJournalEntry = (row) => {
|
|
|
748
900
|
return e;
|
|
749
901
|
};
|
|
750
902
|
|
|
751
|
-
// ../sqlite-shared/dist/
|
|
752
|
-
var isForeignKeyViolation = (err) => err instanceof Error && err.message.includes("FOREIGN KEY constraint failed");
|
|
753
|
-
var isUniqueConstraintViolation = (err) => err instanceof Error && err.message.includes("UNIQUE constraint failed");
|
|
754
|
-
|
|
755
|
-
// ../sqlite-shared/dist/config.js
|
|
756
|
-
var CONFIG_ROW_SQL = `SELECT content FROM records WHERE id = '_config'`;
|
|
757
|
-
var insertConfigRecord = (exec, entityId, timezone) => {
|
|
758
|
-
const now = Date.now();
|
|
759
|
-
exec.run(`INSERT INTO records (id, type_id, created_at, updated_at, content, version)
|
|
760
|
-
VALUES ('_config', '_config@1', ?, ?, ?, 1)`, [now, now, JSON.stringify({ entityId, timezone })]);
|
|
761
|
-
return { entityId, timezone };
|
|
762
|
-
};
|
|
763
|
-
var tryReadStackConfig = (exec) => {
|
|
764
|
-
const row = exec.get(CONFIG_ROW_SQL);
|
|
765
|
-
if (!row)
|
|
766
|
-
return null;
|
|
767
|
-
const content = JSON.parse(row.content);
|
|
768
|
-
return { entityId: content.entityId, timezone: content.timezone };
|
|
769
|
-
};
|
|
770
|
-
var readStackConfig = (exec) => {
|
|
771
|
-
const config = tryReadStackConfig(exec);
|
|
772
|
-
if (!config)
|
|
773
|
-
throw new Error("Stack database is missing its config record.");
|
|
774
|
-
return config;
|
|
775
|
-
};
|
|
903
|
+
// ../sqlite-shared/dist/record-logic.js
|
|
776
904
|
var asStackBadRequestError = (query, run) => {
|
|
777
905
|
try {
|
|
778
906
|
return run();
|
|
@@ -783,6 +911,7 @@ var asStackBadRequestError = (query, run) => {
|
|
|
783
911
|
}
|
|
784
912
|
};
|
|
785
913
|
var versionConflict = (id, expected, actual) => new StackVersionConflictError(`Record "${id}" is at version ${actual}, expected ${expected}`, id, expected, actual);
|
|
914
|
+
var recordNotFound = (id) => new StackNotFoundError(`Record not found: "${id}"`);
|
|
786
915
|
var VERSION_COLUMNS = [
|
|
787
916
|
"type_id",
|
|
788
917
|
"content",
|
|
@@ -801,44 +930,20 @@ var versionRowValues = (version) => [
|
|
|
801
930
|
version.updatedBy?.subjectId ?? null,
|
|
802
931
|
version.updatedBy?.principalId ?? null
|
|
803
932
|
];
|
|
804
|
-
var
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
* Per-typeId cache of the fields content_index covers and their declared
|
|
817
|
-
* kinds, so syncContentIndex() doesn't hit the `types` table and
|
|
818
|
-
* re-parse schema JSON on every write. Populated eagerly in saveType()
|
|
819
|
-
* and lazily via getIndexedFields().
|
|
820
|
-
*/
|
|
821
|
-
indexedFieldsByType = /* @__PURE__ */ new Map();
|
|
822
|
-
constructor(deps) {
|
|
823
|
-
this.deps = deps;
|
|
824
|
-
}
|
|
825
|
-
get exec() {
|
|
826
|
-
return this.deps.exec;
|
|
827
|
-
}
|
|
828
|
-
/**
|
|
829
|
-
* Precondition check for a mutation that has already read the record —
|
|
830
|
-
* either because it can't fold the check into its own statement
|
|
831
|
-
* (purgeRecord: children reference the row, so the DELETE can't
|
|
832
|
-
* carry it), or because it has to settle the precondition *before*
|
|
833
|
-
* fts5Strategy.remove() runs, which needs the records row still holding
|
|
834
|
-
* the old content. In the latter case versionedUpdate() re-checks it
|
|
835
|
-
* inside the statement, and that is the enforcement; this is what lets
|
|
836
|
-
* the work before it be skipped.
|
|
837
|
-
*/
|
|
838
|
-
checkExpectedVersion(record, expectedVersion) {
|
|
839
|
-
if (expectedVersion === void 0 || record.version === expectedVersion)
|
|
933
|
+
var SharedSqlRecordLogic = class {
|
|
934
|
+
exec;
|
|
935
|
+
searchIndex;
|
|
936
|
+
contentIndex;
|
|
937
|
+
constructor({ exec }) {
|
|
938
|
+
this.exec = exec;
|
|
939
|
+
this.searchIndex = new SearchIndex(exec);
|
|
940
|
+
this.contentIndex = new ContentIndex(exec);
|
|
941
|
+
}
|
|
942
|
+
/** Check `ifVersion` against a version read inside the caller's transaction. */
|
|
943
|
+
checkExpectedVersion(id, actual, expected) {
|
|
944
|
+
if (expected === void 0 || actual === expected)
|
|
840
945
|
return;
|
|
841
|
-
throw versionConflict(
|
|
946
|
+
throw versionConflict(id, expected, actual);
|
|
842
947
|
}
|
|
843
948
|
/**
|
|
844
949
|
* Called after a version-gated statement affected zero rows.
|
|
@@ -847,23 +952,31 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
847
952
|
* the actual current version for the caller to act on).
|
|
848
953
|
*/
|
|
849
954
|
throwVersionConflict(id, expectedVersion) {
|
|
850
|
-
const
|
|
851
|
-
|
|
852
|
-
|
|
955
|
+
const version = this.currentVersion(id);
|
|
956
|
+
if (version === void 0)
|
|
957
|
+
throw recordNotFound(id);
|
|
958
|
+
throw versionConflict(id, expectedVersion, version);
|
|
959
|
+
}
|
|
960
|
+
currentVersion(id) {
|
|
961
|
+
return this.exec.get("SELECT version FROM records WHERE id = ?", [id])?.version;
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* What a mutation builds on, read inside its transaction so nothing can
|
|
965
|
+
* land between the read and the write: a patch applied to an earlier read
|
|
966
|
+
* would overwrite whatever changed since. Throws for a missing record or
|
|
967
|
+
* a failed `ifVersion`.
|
|
968
|
+
*/
|
|
969
|
+
readForWrite(id, ifVersion) {
|
|
970
|
+
const row = this.exec.get("SELECT version, type_id, content FROM records WHERE id = ?", [id]);
|
|
853
971
|
if (!row)
|
|
854
|
-
throw
|
|
855
|
-
|
|
972
|
+
throw recordNotFound(id);
|
|
973
|
+
this.checkExpectedVersion(id, row.version, ifVersion);
|
|
974
|
+
return { typeId: row.type_id, content: JSON.parse(row.content) };
|
|
856
975
|
}
|
|
857
976
|
/**
|
|
858
|
-
*
|
|
859
|
-
*
|
|
860
|
-
*
|
|
861
|
-
* precondition and failing loudly when nothing matched.
|
|
862
|
-
*
|
|
863
|
-
* The guard rides in the WHERE clause even where the caller has already
|
|
864
|
-
* checked the version against a record it read: that read is outside this
|
|
865
|
-
* statement, so a writer slipping in between the two is caught here
|
|
866
|
-
* rather than silently overwritten.
|
|
977
|
+
* Every version-bumping UPDATE. The `ifVersion` guard rides in the WHERE
|
|
978
|
+
* clause, so a write that reads nothing first (a soft delete, an
|
|
979
|
+
* undelete) enforces it with no separate read.
|
|
867
980
|
*/
|
|
868
981
|
versionedUpdate(id, sets, values, opts, now = toMs(/* @__PURE__ */ new Date())) {
|
|
869
982
|
const guarded = opts.ifVersion !== void 0;
|
|
@@ -879,15 +992,9 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
879
992
|
this.throwVersionConflict(id, opts.ifVersion);
|
|
880
993
|
}
|
|
881
994
|
/**
|
|
882
|
-
* Read a record back after its own mutation committed
|
|
883
|
-
*
|
|
884
|
-
*
|
|
885
|
-
* breach it would be.
|
|
886
|
-
*
|
|
887
|
-
* Synchronous, via readRecord() rather than getRecord(), so the read
|
|
888
|
-
* cannot be separated from the commit by anything: an async caller
|
|
889
|
-
* returning this still hands back a record read in the same tick its
|
|
890
|
-
* transaction committed. See readRecord() for why that matters.
|
|
995
|
+
* Read a record back after its own mutation committed. Absence here is an
|
|
996
|
+
* invariant breach, not a "not found". Synchronous, so nothing can run
|
|
997
|
+
* between the commit and the read — see readRecord().
|
|
891
998
|
*/
|
|
892
999
|
reread(id, verb) {
|
|
893
1000
|
const updated = this.readRecord(id);
|
|
@@ -899,16 +1006,10 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
899
1006
|
// Records
|
|
900
1007
|
// -------------------------------------------------------
|
|
901
1008
|
/**
|
|
902
|
-
*
|
|
903
|
-
*
|
|
904
|
-
*
|
|
905
|
-
*
|
|
906
|
-
* One transaction, because a record and the four tables that describe it
|
|
907
|
-
* are one fact. A half-applied create is worse than a failed one: the
|
|
908
|
-
* call raises, so the caller believes nothing landed, while the records
|
|
909
|
-
* row survives to fail their retry on the PK — and the row it leaves is
|
|
910
|
-
* invisible to `filter.search` and mis-ordered by `sort.contentField`
|
|
911
|
-
* until something writes it again.
|
|
1009
|
+
* One transaction, because a record and the tables that describe it are
|
|
1010
|
+
* one fact: a half-applied create raises, so the caller believes nothing
|
|
1011
|
+
* landed, while the surviving row fails their retry on the PK and is
|
|
1012
|
+
* missing from both indexes. A duplicate id maps to StackConflictError.
|
|
912
1013
|
*/
|
|
913
1014
|
async createRecord(record, opts = {}) {
|
|
914
1015
|
this.exec.transaction(() => {
|
|
@@ -942,8 +1043,8 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
942
1043
|
const edges = [...record.associations ?? [], ...record.permissions ?? []];
|
|
943
1044
|
if (edges.length)
|
|
944
1045
|
this.insertAssociations(record.id, edges);
|
|
945
|
-
|
|
946
|
-
this.
|
|
1046
|
+
this.searchIndex.insert(record.id, JSON.stringify(record.content));
|
|
1047
|
+
this.contentIndex.sync(record.id, record.typeId, record.content);
|
|
947
1048
|
this.appendJournal(record.id, opts.journal);
|
|
948
1049
|
});
|
|
949
1050
|
return record;
|
|
@@ -952,53 +1053,24 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
952
1053
|
return this.readRecord(id);
|
|
953
1054
|
}
|
|
954
1055
|
/**
|
|
955
|
-
* The synchronous read behind getRecord()
|
|
956
|
-
*
|
|
957
|
-
*
|
|
958
|
-
*
|
|
959
|
-
* reason (see SqlExecutor.transaction).
|
|
960
|
-
*
|
|
961
|
-
* The same synchrony is what lets the post-commit reads below report the
|
|
962
|
-
* version their own mutation produced: reread() calls this directly, so
|
|
963
|
-
* nothing interleaves between the commit and the read even though the
|
|
964
|
-
* method returning it is async. A backend that made these reads genuinely
|
|
965
|
-
* asynchronous would open that window, and a concurrent mutation could be
|
|
966
|
-
* reported under this one's verb.
|
|
1056
|
+
* The synchronous read behind getRecord(), for use inside
|
|
1057
|
+
* exec.transaction(), whose callback can't await. reread() relies on the
|
|
1058
|
+
* same synchrony: nothing interleaves between a commit and its read, so a
|
|
1059
|
+
* concurrent mutation is never reported under this one's verb.
|
|
967
1060
|
*/
|
|
968
1061
|
readRecord(id) {
|
|
969
1062
|
const row = this.exec.get("SELECT * FROM records WHERE id = ?", [id]);
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1063
|
+
return row ? this.hydrate(row) : null;
|
|
1064
|
+
}
|
|
1065
|
+
/** A records row with its association set attached. */
|
|
1066
|
+
hydrate(row) {
|
|
1067
|
+
return rowToRecord(row, this.getAssociationsForRecord(row.id));
|
|
974
1068
|
}
|
|
975
1069
|
/**
|
|
976
|
-
*
|
|
977
|
-
*
|
|
978
|
-
* replaced when it carries one, and the snapshot written alongside — so
|
|
979
|
-
* one call is one version whatever it moved.
|
|
980
|
-
*
|
|
981
|
-
* `opts.bumpsVersion: false` is a change set that touches only
|
|
982
|
-
* `associations`, `parentId` and/or `unlisted` — Stack computes this from
|
|
983
|
-
* which ops the set actually moves. Such a write still lands its columns,
|
|
984
|
-
* but leaves `version`, `updatedAt` and the actor stamps exactly as they
|
|
985
|
-
* stood and takes no snapshot. `ifVersion`, if given, is re-checked
|
|
986
|
-
* synchronously inside the transaction — the CAS guard the bumping path
|
|
987
|
-
* gets from its UPDATE's WHERE clause, restated as a read because the
|
|
988
|
-
* UPDATE here carries no version predicate. See
|
|
989
|
-
* docs/spec/versioning.md § Version history.
|
|
990
|
-
*
|
|
991
|
-
* The ifVersion check for a bumping write is standalone rather than
|
|
992
|
-
* folded into the UPDATE alone, because a content change needs
|
|
993
|
-
* fts.remove() to run *before* the records-table content changes. The
|
|
994
|
-
* guard is still in the UPDATE's WHERE, so a writer that slipped in
|
|
995
|
-
* between the read and the write is caught there rather than overwriting.
|
|
1070
|
+
* One transaction, so one call is one version whatever it moved, and the
|
|
1071
|
+
* patch applies to the content as it stands when the write lands.
|
|
996
1072
|
*/
|
|
997
1073
|
async mutateRecord(id, changes, opts = {}) {
|
|
998
|
-
const existing = await this.getRecord(id);
|
|
999
|
-
if (!existing)
|
|
1000
|
-
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
1001
|
-
this.checkExpectedVersion(existing, opts.ifVersion);
|
|
1002
1074
|
const now = toMs(/* @__PURE__ */ new Date());
|
|
1003
1075
|
const sets = [];
|
|
1004
1076
|
const values = [];
|
|
@@ -1010,35 +1082,30 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1010
1082
|
sets.push("unlisted_at = ?");
|
|
1011
1083
|
values.push(changes.unlisted ? now : null);
|
|
1012
1084
|
}
|
|
1013
|
-
|
|
1014
|
-
this.
|
|
1015
|
-
|
|
1016
|
-
if (!current)
|
|
1017
|
-
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
1018
|
-
this.checkExpectedVersion(current, opts.ifVersion);
|
|
1085
|
+
this.exec.transaction(() => {
|
|
1086
|
+
const current = this.readForWrite(id, opts.ifVersion);
|
|
1087
|
+
if (opts.bumpsVersion === false) {
|
|
1019
1088
|
if (sets.length > 0) {
|
|
1020
1089
|
this.exec.run(`UPDATE records SET ${sets.join(", ")} WHERE id = ?`, [...values, id]);
|
|
1021
1090
|
}
|
|
1022
1091
|
this.replaceAssociationHalves(id, changes);
|
|
1023
1092
|
this.appendJournal(id, opts.journal);
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
}
|
|
1032
|
-
this.exec.transaction(() => {
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
const merged = changes.contentPatch ? applyMergePatch(current.content, changes.contentPatch) : void 0;
|
|
1096
|
+
if (merged !== void 0) {
|
|
1097
|
+
sets.unshift("content = ?");
|
|
1098
|
+
values.unshift(JSON.stringify(merged));
|
|
1099
|
+
}
|
|
1033
1100
|
if (opts.snapshot)
|
|
1034
1101
|
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
1035
1102
|
if (merged !== void 0)
|
|
1036
|
-
|
|
1103
|
+
this.searchIndex.remove(id);
|
|
1037
1104
|
this.versionedUpdate(id, sets, values, opts, now);
|
|
1038
1105
|
this.replaceAssociationHalves(id, changes);
|
|
1039
1106
|
if (merged !== void 0) {
|
|
1040
|
-
|
|
1041
|
-
this.
|
|
1107
|
+
this.searchIndex.insert(id, JSON.stringify(merged));
|
|
1108
|
+
this.contentIndex.sync(id, current.typeId, merged);
|
|
1042
1109
|
}
|
|
1043
1110
|
this.appendJournal(id, opts.journal);
|
|
1044
1111
|
});
|
|
@@ -1058,27 +1125,23 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1058
1125
|
return this.reread(id, "deleteRecord");
|
|
1059
1126
|
}
|
|
1060
1127
|
/**
|
|
1061
|
-
*
|
|
1062
|
-
*
|
|
1063
|
-
*
|
|
1064
|
-
* the deletes. Null when there was no such record. No write() call —
|
|
1065
|
-
* callers batch it. ifVersion is checked first so a lost CAS race
|
|
1066
|
-
* leaves nothing touched (children reference the row, so the check can't
|
|
1067
|
-
* fold into the final DELETE).
|
|
1128
|
+
* Delete a record and everything that refers to it, returning the record
|
|
1129
|
+
* as it stood (null if there was none). Read here, inside the caller's
|
|
1130
|
+
* transaction, so nothing can overtake the last copy that will ever exist.
|
|
1068
1131
|
*/
|
|
1069
1132
|
purgeRecord(id, ifVersion) {
|
|
1070
1133
|
const purged = this.readRecord(id);
|
|
1071
1134
|
if (!purged) {
|
|
1072
1135
|
if (ifVersion !== void 0)
|
|
1073
|
-
throw
|
|
1136
|
+
throw recordNotFound(id);
|
|
1074
1137
|
return null;
|
|
1075
1138
|
}
|
|
1076
|
-
this.checkExpectedVersion(purged, ifVersion);
|
|
1077
|
-
|
|
1139
|
+
this.checkExpectedVersion(id, purged.version, ifVersion);
|
|
1140
|
+
this.searchIndex.remove(id);
|
|
1078
1141
|
this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
|
|
1079
1142
|
this.exec.run("DELETE FROM versions WHERE record_id = ?", [id]);
|
|
1080
1143
|
this.exec.run("DELETE FROM journal WHERE record_id = ?", [id]);
|
|
1081
|
-
this.
|
|
1144
|
+
this.contentIndex.remove(id);
|
|
1082
1145
|
this.exec.run("DELETE FROM records WHERE id = ?", [id]);
|
|
1083
1146
|
return purged;
|
|
1084
1147
|
}
|
|
@@ -1092,14 +1155,11 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1092
1155
|
return this.reread(id, "undelete");
|
|
1093
1156
|
}
|
|
1094
1157
|
async restoreVersion(id, version, opts = {}) {
|
|
1095
|
-
const existing = await this.getRecord(id);
|
|
1096
|
-
if (!existing)
|
|
1097
|
-
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
1098
|
-
this.checkExpectedVersion(existing, opts.ifVersion);
|
|
1099
|
-
const target = await this.getVersion(id, version);
|
|
1100
|
-
if (!target)
|
|
1101
|
-
throw new StackNotFoundError(`Version not found: "${id}"@${version}`);
|
|
1102
1158
|
this.exec.transaction(() => {
|
|
1159
|
+
this.readForWrite(id, opts.ifVersion);
|
|
1160
|
+
const target = this.readVersion(id, version);
|
|
1161
|
+
if (!target)
|
|
1162
|
+
throw new StackNotFoundError(`Version not found: "${id}"@${version}`);
|
|
1103
1163
|
if (opts.snapshot)
|
|
1104
1164
|
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
1105
1165
|
this.rewriteContent(id, target.typeId, target.content, opts);
|
|
@@ -1108,11 +1168,8 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1108
1168
|
return this.reread(id, "restoreVersion");
|
|
1109
1169
|
}
|
|
1110
1170
|
async commitMigration(id, toTypeId, content, opts = {}) {
|
|
1111
|
-
const existing = await this.getRecord(id);
|
|
1112
|
-
if (!existing)
|
|
1113
|
-
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
1114
|
-
this.checkExpectedVersion(existing, opts.ifVersion);
|
|
1115
1171
|
this.exec.transaction(() => {
|
|
1172
|
+
this.readForWrite(id, opts.ifVersion);
|
|
1116
1173
|
if (opts.snapshot)
|
|
1117
1174
|
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
1118
1175
|
this.rewriteContent(id, toTypeId, content, opts);
|
|
@@ -1121,21 +1178,16 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1121
1178
|
return this.reread(id, "commitMigration");
|
|
1122
1179
|
}
|
|
1123
1180
|
/**
|
|
1124
|
-
* Replace a record's type and content wholesale
|
|
1125
|
-
*
|
|
1126
|
-
*
|
|
1127
|
-
* The order is the constraint: fts5Strategy.remove() has to read the old
|
|
1128
|
-
* content off the records row, so it runs before the UPDATE, and the
|
|
1129
|
-
* re-index runs after. Every caller must already sit inside
|
|
1130
|
-
* exec.transaction() — a failure between the two halves would otherwise
|
|
1131
|
-
* leave the record unindexed.
|
|
1181
|
+
* Replace a record's type and content wholesale. Unindex, write, re-index,
|
|
1182
|
+
* in that order (docs/spec/adapters.md § The search index is unindexed
|
|
1183
|
+
* before the row moves); callers must be inside exec.transaction().
|
|
1132
1184
|
*/
|
|
1133
1185
|
rewriteContent(id, typeId, content, opts) {
|
|
1134
1186
|
const json = JSON.stringify(content);
|
|
1135
|
-
|
|
1187
|
+
this.searchIndex.remove(id);
|
|
1136
1188
|
this.versionedUpdate(id, ["type_id = ?", "content = ?"], [typeId, json], opts);
|
|
1137
|
-
|
|
1138
|
-
this.
|
|
1189
|
+
this.searchIndex.insert(id, json);
|
|
1190
|
+
this.contentIndex.sync(id, typeId, content);
|
|
1139
1191
|
}
|
|
1140
1192
|
async queryRecords(query) {
|
|
1141
1193
|
const limit = query.limit ?? 50;
|
|
@@ -1150,20 +1202,14 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1150
1202
|
}
|
|
1151
1203
|
const hasMore = rows.length > limit;
|
|
1152
1204
|
const page = hasMore ? rows.slice(0, limit) : rows;
|
|
1153
|
-
const records = page.map((row) =>
|
|
1154
|
-
const associations = this.getAssociationsForRecord(row.id);
|
|
1155
|
-
return rowToRecord(row, associations);
|
|
1156
|
-
});
|
|
1205
|
+
const records = page.map((row) => this.hydrate(row));
|
|
1157
1206
|
const lastRecord = records[records.length - 1];
|
|
1158
|
-
const cursor = hasMore && lastRecord ? makeCursor(lastRecord, query.sort, (record, field) => this.
|
|
1207
|
+
const cursor = hasMore && lastRecord ? makeCursor(lastRecord, query.sort, (record, field) => this.contentIndex.kindOf(record.typeId, field)) : null;
|
|
1159
1208
|
return { records, cursor };
|
|
1160
1209
|
}
|
|
1161
1210
|
/**
|
|
1162
|
-
*
|
|
1163
|
-
*
|
|
1164
|
-
* deleteUnreferencedAttachmentRecords on the adapter contract). A real
|
|
1165
|
-
* SQL transaction of synchronous calls — no `await` between the check
|
|
1166
|
-
* and the deletes, so nothing interleaves.
|
|
1211
|
+
* The reference check and the purges share one synchronous transaction,
|
|
1212
|
+
* so nothing can add a reference between the two.
|
|
1167
1213
|
*/
|
|
1168
1214
|
async deleteUnreferencedAttachmentRecords(fileId, metadataTypeIds) {
|
|
1169
1215
|
return this.exec.transaction(() => {
|
|
@@ -1202,6 +1248,9 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1202
1248
|
return rows.map(rowToVersion);
|
|
1203
1249
|
}
|
|
1204
1250
|
async getVersion(id, version) {
|
|
1251
|
+
return this.readVersion(id, version);
|
|
1252
|
+
}
|
|
1253
|
+
readVersion(id, version) {
|
|
1205
1254
|
const row = this.exec.get("SELECT * FROM versions WHERE record_id = ? AND version = ?", [id, version]);
|
|
1206
1255
|
return row ? rowToVersion(row) : null;
|
|
1207
1256
|
}
|
|
@@ -1255,10 +1304,7 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1255
1304
|
} catch (err) {
|
|
1256
1305
|
if (!(err instanceof StackConflictError))
|
|
1257
1306
|
throw err;
|
|
1258
|
-
|
|
1259
|
-
id
|
|
1260
|
-
]);
|
|
1261
|
-
if (!row || row.version !== version.version)
|
|
1307
|
+
if (this.currentVersion(id) !== version.version)
|
|
1262
1308
|
throw err;
|
|
1263
1309
|
this.overwriteVersionRow(id, version);
|
|
1264
1310
|
}
|
|
@@ -1267,21 +1313,17 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1267
1313
|
// Change journal
|
|
1268
1314
|
// -------------------------------------------------------
|
|
1269
1315
|
/**
|
|
1270
|
-
* Append one entry,
|
|
1271
|
-
*
|
|
1272
|
-
*
|
|
1273
|
-
*
|
|
1274
|
-
* `seq` is allocated here, from the log's own max, rather than computed
|
|
1275
|
-
* by the caller. That is what spares this table the collision healing
|
|
1276
|
-
* versions needs: nothing outside this statement ever holds a seq it
|
|
1277
|
-
* expects to be free. See docs/spec/journal.md § Ordering.
|
|
1316
|
+
* Append one entry, stamped from the row as it now stands, so callers run
|
|
1317
|
+
* this inside their write's transaction, after the write. `seq` is
|
|
1318
|
+
* allocated in the INSERT itself, so no caller ever holds one it expects
|
|
1319
|
+
* to be free. See docs/spec/journal.md § Ordering.
|
|
1278
1320
|
*/
|
|
1279
1321
|
appendJournal(recordId, entry) {
|
|
1280
1322
|
if (!entry)
|
|
1281
1323
|
return;
|
|
1282
1324
|
const row = this.exec.get("SELECT version, type_id, parent_id FROM records WHERE id = ?", [recordId]);
|
|
1283
1325
|
if (!row)
|
|
1284
|
-
throw
|
|
1326
|
+
throw recordNotFound(recordId);
|
|
1285
1327
|
this.exec.run(`INSERT INTO journal
|
|
1286
1328
|
(record_id, seq, at, kind, ops, version, type_id, parent_id,
|
|
1287
1329
|
previous_parent_id, actor, associations)
|
|
@@ -1305,7 +1347,7 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1305
1347
|
}
|
|
1306
1348
|
async getJournal(id, query = {}) {
|
|
1307
1349
|
if (!this.exec.get("SELECT 1 FROM records WHERE id = ?", [id])) {
|
|
1308
|
-
throw
|
|
1350
|
+
throw recordNotFound(id);
|
|
1309
1351
|
}
|
|
1310
1352
|
const conditions = ["record_id = ?"];
|
|
1311
1353
|
const values = [id];
|
|
@@ -1332,8 +1374,7 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1332
1374
|
type.migratesFrom ?? null,
|
|
1333
1375
|
toMs(type.createdAt)
|
|
1334
1376
|
]);
|
|
1335
|
-
|
|
1336
|
-
this.indexedFieldsByType.set(type.id, indexedFieldKinds(schema));
|
|
1377
|
+
this.contentIndex.rememberType(type);
|
|
1337
1378
|
}
|
|
1338
1379
|
async getType(id) {
|
|
1339
1380
|
const row = this.exec.get("SELECT * FROM types WHERE id = ?", [id]);
|
|
@@ -1348,15 +1389,14 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1348
1389
|
// -------------------------------------------------------
|
|
1349
1390
|
/**
|
|
1350
1391
|
* Never bumps `version`/`updatedAt` and never snapshots — see
|
|
1351
|
-
* StackRecordAdapter.amendAssociations().
|
|
1352
|
-
*
|
|
1353
|
-
* removes first, then adds.
|
|
1392
|
+
* StackRecordAdapter.amendAssociations(). Removes apply before adds, so
|
|
1393
|
+
* removing and re-adding one identity in a call leaves it present.
|
|
1354
1394
|
*/
|
|
1355
1395
|
async amendAssociations(recordId, changes, opts = {}) {
|
|
1356
1396
|
assertOneSurface(changes);
|
|
1357
1397
|
this.exec.transaction(() => {
|
|
1358
|
-
if (
|
|
1359
|
-
throw
|
|
1398
|
+
if (this.currentVersion(recordId) === void 0)
|
|
1399
|
+
throw recordNotFound(recordId);
|
|
1360
1400
|
for (const change of changes) {
|
|
1361
1401
|
if (change.op !== "remove")
|
|
1362
1402
|
continue;
|
|
@@ -1379,43 +1419,33 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1379
1419
|
});
|
|
1380
1420
|
return this.reread(recordId, "amendAssociations");
|
|
1381
1421
|
}
|
|
1382
|
-
/** The kinds each change-set key replaces, and nothing else. */
|
|
1383
|
-
static DATA_KINDS = ["tag", "attachment", "relationship"];
|
|
1384
|
-
static AUTHORITY_KINDS = ["permission", "anyone"];
|
|
1385
1422
|
/**
|
|
1386
|
-
*
|
|
1387
|
-
*
|
|
1388
|
-
* tag write cannot drop an ACL: the `associations` key deletes data
|
|
1389
|
-
* kinds alone, so the authority rows beside them are never in scope.
|
|
1390
|
-
* Caller-transactional, like every write below.
|
|
1423
|
+
* Each key replaces only within its own half of the partition, which is
|
|
1424
|
+
* why a tag write cannot drop an ACL. Caller-transactional.
|
|
1391
1425
|
* See docs/spec/access-control.md § Record-level permissions.
|
|
1392
1426
|
*/
|
|
1393
1427
|
replaceAssociationHalves(recordId, changes) {
|
|
1394
1428
|
if (changes.associations !== void 0) {
|
|
1395
|
-
this.replaceAssociations(recordId, changes.associations,
|
|
1429
|
+
this.replaceAssociations(recordId, changes.associations, "data");
|
|
1396
1430
|
}
|
|
1397
1431
|
if (changes.permissions !== void 0) {
|
|
1398
|
-
this.replaceAssociations(recordId, changes.permissions,
|
|
1432
|
+
this.replaceAssociations(recordId, changes.permissions, "authority");
|
|
1399
1433
|
}
|
|
1400
1434
|
}
|
|
1401
1435
|
/**
|
|
1402
1436
|
* Swap one half of a record's association set wholesale — the "replace,
|
|
1403
|
-
* don't merge" semantics a change set's association list carries.
|
|
1437
|
+
* don't merge" semantics a change set's association list carries. The data
|
|
1438
|
+
* half is "not an authority kind", so core's list is the only one.
|
|
1404
1439
|
*/
|
|
1405
|
-
replaceAssociations(recordId, associations,
|
|
1406
|
-
this.exec.run(`DELETE FROM associations WHERE record_id = ? AND kind IN (${
|
|
1440
|
+
replaceAssociations(recordId, associations, half) {
|
|
1441
|
+
this.exec.run(`DELETE FROM associations WHERE record_id = ? AND kind ${half === "data" ? "NOT IN" : "IN"} (${AUTHORITY_ASSOCIATION_KINDS.map(() => "?").join(", ")})`, [recordId, ...AUTHORITY_ASSOCIATION_KINDS]);
|
|
1407
1442
|
if (associations.length)
|
|
1408
1443
|
this.insertAssociations(recordId, associations);
|
|
1409
1444
|
}
|
|
1410
1445
|
/**
|
|
1411
|
-
* Upserts on association identity
|
|
1412
|
-
*
|
|
1413
|
-
*
|
|
1414
|
-
*
|
|
1415
|
-
* FK enforcement (PRAGMA_FOREIGN_KEYS_ON) means inserting an
|
|
1416
|
-
* association against a record that doesn't exist throws — mapped
|
|
1417
|
-
* here to StackNotFoundError so amendAssociations() on a nonexistent record
|
|
1418
|
-
* fails loudly instead of silently creating an orphan row.
|
|
1446
|
+
* Upserts on association identity, so re-pointing `attachment_record_id`
|
|
1447
|
+
* (outside the key) costs no second row. A missing record trips the
|
|
1448
|
+
* foreign key, reported as StackNotFoundError rather than an orphan row.
|
|
1419
1449
|
*/
|
|
1420
1450
|
insertAssociations(recordId, associations) {
|
|
1421
1451
|
for (const assoc of associations) {
|
|
@@ -1433,7 +1463,7 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1433
1463
|
]);
|
|
1434
1464
|
} catch (err) {
|
|
1435
1465
|
if (isForeignKeyViolation(err)) {
|
|
1436
|
-
throw
|
|
1466
|
+
throw recordNotFound(recordId);
|
|
1437
1467
|
}
|
|
1438
1468
|
throw err;
|
|
1439
1469
|
}
|
|
@@ -1443,51 +1473,6 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1443
1473
|
const rows = this.exec.all("SELECT * FROM associations WHERE record_id = ?", [recordId]);
|
|
1444
1474
|
return rows.map(rowToAssociation);
|
|
1445
1475
|
}
|
|
1446
|
-
/**
|
|
1447
|
-
* The fields content_index covers for typeId, from the in-memory cache
|
|
1448
|
-
* — falling back to a `types` lookup (and populating the cache) only for
|
|
1449
|
-
* a typeId this process hasn't seen via saveType() yet, e.g. right after
|
|
1450
|
-
* open().
|
|
1451
|
-
*/
|
|
1452
|
-
getIndexedFields(typeId) {
|
|
1453
|
-
const cached = this.indexedFieldsByType.get(typeId);
|
|
1454
|
-
if (cached)
|
|
1455
|
-
return cached;
|
|
1456
|
-
const typeRow = this.exec.get("SELECT schema FROM types WHERE id = ?", [
|
|
1457
|
-
typeId
|
|
1458
|
-
]);
|
|
1459
|
-
const fields = typeRow ? indexedFieldKinds(JSON.parse(typeRow.schema)) : /* @__PURE__ */ new Map();
|
|
1460
|
-
this.indexedFieldsByType.set(typeId, fields);
|
|
1461
|
-
return fields;
|
|
1462
|
-
}
|
|
1463
|
-
/**
|
|
1464
|
-
* Replace a record's content_index rows with what its content currently
|
|
1465
|
-
* holds, on every write that can change content or typeId, so neither
|
|
1466
|
-
* the ordering nor the file-reference lookup can drift from the record.
|
|
1467
|
-
* A field holding no orderable value gets no row at all, which is what
|
|
1468
|
-
* puts the record at the end of a sort on that field.
|
|
1469
|
-
*/
|
|
1470
|
-
syncContentIndex(recordId, typeId, content) {
|
|
1471
|
-
this.exec.run("DELETE FROM content_index WHERE record_id = ?", [recordId]);
|
|
1472
|
-
for (const [field, kind] of this.getIndexedFields(typeId)) {
|
|
1473
|
-
const entry = contentSortEntry(kind, content[field]);
|
|
1474
|
-
if (!entry)
|
|
1475
|
-
continue;
|
|
1476
|
-
const sql = `INSERT OR IGNORE INTO content_index
|
|
1477
|
-
(record_id, field, value_rank, num_value, text_key, text_value, file_id)
|
|
1478
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)`;
|
|
1479
|
-
if (entry.kind === "num") {
|
|
1480
|
-
this.exec.run(sql, [recordId, field, 0, entry.num, null, null, null]);
|
|
1481
|
-
} else {
|
|
1482
|
-
const fileId = kind === "file-ref" ? entry.text : null;
|
|
1483
|
-
this.exec.run(sql, [recordId, field, 1, null, entry.key, entry.text, fileId]);
|
|
1484
|
-
}
|
|
1485
|
-
}
|
|
1486
|
-
}
|
|
1487
|
-
/** The declared kind of a record's top-level `field`, if it has one. */
|
|
1488
|
-
sortFieldKind(record, field) {
|
|
1489
|
-
return this.getIndexedFields(record.typeId).get(field);
|
|
1490
|
-
}
|
|
1491
1476
|
};
|
|
1492
1477
|
|
|
1493
1478
|
// ../sqlite-shared/dist/record-adapter.js
|
|
@@ -1495,11 +1480,15 @@ var SQLITE_RECORD_CAPABILITIES = {
|
|
|
1495
1480
|
filter: {
|
|
1496
1481
|
content: "path",
|
|
1497
1482
|
contentPresent: true,
|
|
1483
|
+
// Inside SQLite's limits on expression depth and compound selects; see
|
|
1484
|
+
// docs/spec/adapters.md § Adapter capabilities.
|
|
1485
|
+
contentByType: 64,
|
|
1498
1486
|
search: true
|
|
1499
1487
|
},
|
|
1500
1488
|
sort: {
|
|
1501
1489
|
fields: ["createdAt", "updatedAt", "version"],
|
|
1502
|
-
contentField: true
|
|
1490
|
+
contentField: true,
|
|
1491
|
+
contentFieldByType: 64
|
|
1503
1492
|
},
|
|
1504
1493
|
limits: {
|
|
1505
1494
|
attachmentBytes: null,
|
|
@@ -1594,7 +1583,7 @@ var isProcessAlive = (pid) => {
|
|
|
1594
1583
|
};
|
|
1595
1584
|
var acquireLock = (dbPath, force) => {
|
|
1596
1585
|
const lockPath = lockPathFor(dbPath);
|
|
1597
|
-
const info =
|
|
1586
|
+
const info = readLock(lockPath);
|
|
1598
1587
|
if (info) {
|
|
1599
1588
|
const ownedBySelf = info.pid === process.pid;
|
|
1600
1589
|
if (!ownedBySelf && !force && isProcessAlive(info.pid)) {
|
|
@@ -1621,12 +1610,9 @@ var releaseLock = (dbPath) => {
|
|
|
1621
1610
|
};
|
|
1622
1611
|
var hashToken = (token) => createHash("sha256").update(token).digest("hex");
|
|
1623
1612
|
var SharedTokenLogic = class {
|
|
1624
|
-
|
|
1625
|
-
constructor(
|
|
1626
|
-
this.
|
|
1627
|
-
}
|
|
1628
|
-
get exec() {
|
|
1629
|
-
return this.deps.exec;
|
|
1613
|
+
exec;
|
|
1614
|
+
constructor({ exec }) {
|
|
1615
|
+
this.exec = exec;
|
|
1630
1616
|
}
|
|
1631
1617
|
async createToken(actor, opts = {}) {
|
|
1632
1618
|
const id = randomBytes(8).toString("hex");
|