@haverstack/record-adapter-do-sqlite 0.26.0 → 0.27.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 +418 -429
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { OwnerMismatchError, contentSortEntry,
|
|
1
|
+
import { StackConflictError, applyMergePatch, StackNotFoundError, assertOneSurface, StackVersionConflictError, StackBadRequestError, NATIVE_SORT_FIELDS } from '@haverstack/core';
|
|
2
|
+
import { OwnerMismatchError, AUTHORITY_ASSOCIATION_KINDS, contentSortEntry, partitionAssociations, constantSortEntry, contentSortKey, parseContentFilterKey } from '@haverstack/core/adapter';
|
|
3
3
|
|
|
4
4
|
// ../sqlite-shared/dist/schema.js
|
|
5
5
|
var RECORD_SCHEMA_SQL = `
|
|
@@ -21,39 +21,32 @@ var RECORD_SCHEMA_SQL = `
|
|
|
21
21
|
) STRICT;
|
|
22
22
|
|
|
23
23
|
-- Every edge a record carries, authority and data alike: a permission
|
|
24
|
-
--
|
|
25
|
-
--
|
|
26
|
-
--
|
|
27
|
-
--
|
|
28
|
-
--
|
|
29
|
-
-- A relationship's target is (related_scope, related_id) plus one
|
|
30
|
-
-- qualifier: related_stack for a record in another stack, related_ns for
|
|
31
|
-
-- a foreign namespace. All four are in the primary key, so two targets
|
|
32
|
-
-- differing only by namespace are two associations. A permission's
|
|
33
|
-
-- grantee reuses them \u2014 related_scope 'entity' or 'group', related_id the
|
|
34
|
-
-- DID or group id \u2014 plus related_role, which only a group grantee sets
|
|
35
|
-
-- and which is in the key because member and admin name two different
|
|
36
|
-
-- sets of people.
|
|
37
|
-
--
|
|
38
|
-
-- attachment_record_id stays out of the primary key: it annotates a
|
|
39
|
-
-- reference rather than naming one, so re-pointing it updates the row an
|
|
40
|
-
-- an amendAssociations() add lands on instead of writing a second reference to the same
|
|
41
|
-
-- file. See docs/spec/attachments.md \xA7 Naming the upload a reference came from.
|
|
42
|
-
--
|
|
43
|
-
-- The primary key leads with record_id, so its implicit index already
|
|
44
|
-
-- serves every by-record read and delete; the indexes below exist only
|
|
45
|
-
-- for the filter directions it cannot answer.
|
|
24
|
+
-- has element identity and a delta shaped like a tag's, so it earns the
|
|
25
|
+
-- same table. The permissions and associations fields are projections
|
|
26
|
+
-- over it, partitioned by kind.
|
|
27
|
+
-- See docs/spec/access-control.md \xA7 Record-level permissions.
|
|
46
28
|
CREATE TABLE IF NOT EXISTS associations (
|
|
47
29
|
record_id TEXT NOT NULL REFERENCES records(id),
|
|
48
30
|
kind TEXT NOT NULL CHECK (kind IN ('tag', 'attachment', 'relationship', 'permission', 'anyone')),
|
|
49
31
|
label TEXT NOT NULL,
|
|
50
32
|
file_id TEXT NOT NULL DEFAULT '',
|
|
33
|
+
-- A relationship target is (related_scope, related_id) qualified by
|
|
34
|
+
-- related_ns (a foreign namespace) or related_stack (another stack); a
|
|
35
|
+
-- permission's grantee reuses scope and id. All are in the key, so
|
|
36
|
+
-- targets differing only by a qualifier are distinct associations.
|
|
51
37
|
related_scope TEXT NOT NULL DEFAULT '' CHECK (related_scope IN ('', 'record', 'entity', 'external', 'group')),
|
|
52
38
|
related_id TEXT NOT NULL DEFAULT '',
|
|
53
39
|
related_ns TEXT NOT NULL DEFAULT '',
|
|
54
40
|
related_stack TEXT NOT NULL DEFAULT '',
|
|
41
|
+
-- Set only by a group grantee; in the key because member and admin name
|
|
42
|
+
-- two different sets of people.
|
|
55
43
|
related_role TEXT NOT NULL DEFAULT '' CHECK (related_role IN ('', 'member', 'admin')),
|
|
44
|
+
-- Out of the key: it annotates a reference rather than naming one, so
|
|
45
|
+
-- re-pointing it updates the existing row. See docs/spec/attachments.md
|
|
46
|
+
-- \xA7 Naming the upload a reference came from.
|
|
56
47
|
attachment_record_id TEXT NOT NULL DEFAULT '',
|
|
48
|
+
-- Leads with record_id, so its implicit index serves every by-record
|
|
49
|
+
-- read and delete; the indexes below cover only the filter directions.
|
|
57
50
|
PRIMARY KEY (record_id, kind, label, file_id, related_scope, related_id, related_ns, related_stack, related_role)
|
|
58
51
|
) STRICT;
|
|
59
52
|
|
|
@@ -116,7 +109,6 @@ var RECORD_SCHEMA_SQL = `
|
|
|
116
109
|
|
|
117
110
|
-- One row per top-level scalar content field a record holds a value at:
|
|
118
111
|
-- what it orders as, and the file it references. See
|
|
119
|
-
-- docs/spec/adapters.md \xA7 Adapter backends and
|
|
120
112
|
-- docs/spec/data-model.md \xA7 Sorting by a content field.
|
|
121
113
|
--
|
|
122
114
|
-- value_rank is 0 for a numeric value and 1 for a text one \u2014 the same
|
|
@@ -133,14 +125,11 @@ var RECORD_SCHEMA_SQL = `
|
|
|
133
125
|
PRIMARY KEY (record_id, field)
|
|
134
126
|
) STRICT;
|
|
135
127
|
|
|
136
|
-
--
|
|
137
|
-
--
|
|
138
|
-
--
|
|
139
|
-
--
|
|
140
|
-
--
|
|
141
|
-
-- deleted_at and unlisted_at deliberately have none: nothing searches
|
|
142
|
-
-- for the rows they mark, and an index on a column every query tests
|
|
143
|
-
-- for IS NULL only lures the planner off the one answering ORDER BY.
|
|
128
|
+
-- Each filter column continues into (created_at, id), the default order
|
|
129
|
+
-- and its tiebreak: these columns are low-cardinality, and a narrow index
|
|
130
|
+
-- leaves the planner sorting every match before it can honor LIMIT.
|
|
131
|
+
-- deleted_at and unlisted_at get none: an index on a column every query
|
|
132
|
+
-- tests IS NULL only lures the planner off the one answering ORDER BY.
|
|
144
133
|
CREATE INDEX IF NOT EXISTS idx_records_type_id ON records(type_id, created_at, id);
|
|
145
134
|
CREATE INDEX IF NOT EXISTS idx_records_parent_id ON records(parent_id, created_at, id);
|
|
146
135
|
CREATE INDEX IF NOT EXISTS idx_records_entity_id ON records(entity_id, created_at, id);
|
|
@@ -181,71 +170,6 @@ var applyRecordSchema = (exec, opts) => {
|
|
|
181
170
|
exec.exec(RECORD_SCHEMA_SQL);
|
|
182
171
|
exec.exec(FTS5_SCHEMA_SQL);
|
|
183
172
|
};
|
|
184
|
-
var SORT_FIELDS = NATIVE_SORT_FIELDS;
|
|
185
|
-
var toBase64 = (text) => {
|
|
186
|
-
const bytes = new TextEncoder().encode(text);
|
|
187
|
-
let binary = "";
|
|
188
|
-
for (const byte of bytes)
|
|
189
|
-
binary += String.fromCharCode(byte);
|
|
190
|
-
return btoa(binary);
|
|
191
|
-
};
|
|
192
|
-
var fromBase64 = (cursor) => {
|
|
193
|
-
const binary = atob(cursor);
|
|
194
|
-
const bytes = new Uint8Array(binary.length);
|
|
195
|
-
for (let i = 0; i < binary.length; i++)
|
|
196
|
-
bytes[i] = binary.charCodeAt(i);
|
|
197
|
-
return new TextDecoder().decode(bytes);
|
|
198
|
-
};
|
|
199
|
-
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 }));
|
|
200
|
-
var decodeCursor = (cursor) => {
|
|
201
|
-
const malformed = () => new StackBadRequestError(`Invalid cursor: malformed "${cursor}"`);
|
|
202
|
-
let parsed;
|
|
203
|
-
try {
|
|
204
|
-
parsed = JSON.parse(fromBase64(cursor));
|
|
205
|
-
} catch {
|
|
206
|
-
throw malformed();
|
|
207
|
-
}
|
|
208
|
-
if (!parsed || typeof parsed !== "object")
|
|
209
|
-
throw malformed();
|
|
210
|
-
const { k, f, i, v } = parsed;
|
|
211
|
-
if (typeof f !== "string" || !f || typeof i !== "string")
|
|
212
|
-
throw malformed();
|
|
213
|
-
if (k === "absent")
|
|
214
|
-
return { kind: "absent", field: f, id: i };
|
|
215
|
-
if (k === "text") {
|
|
216
|
-
if (typeof v !== "string")
|
|
217
|
-
throw malformed();
|
|
218
|
-
return { kind: "text", field: f, value: v, id: i };
|
|
219
|
-
}
|
|
220
|
-
if (k === "num" || k === "native") {
|
|
221
|
-
if (typeof v !== "number" || !isFinite(v)) {
|
|
222
|
-
throw new StackBadRequestError(`Invalid cursor: non-numeric sort value`);
|
|
223
|
-
}
|
|
224
|
-
if (k === "native") {
|
|
225
|
-
if (!SORT_FIELDS.includes(f)) {
|
|
226
|
-
throw new StackBadRequestError(`Invalid cursor: unknown sort field "${f}"`);
|
|
227
|
-
}
|
|
228
|
-
return { kind: "native", field: f, value: v, id: i };
|
|
229
|
-
}
|
|
230
|
-
return { kind: "num", field: f, value: v, id: i };
|
|
231
|
-
}
|
|
232
|
-
throw malformed();
|
|
233
|
-
};
|
|
234
|
-
var getSortField = (query) => query.sort?.field ?? "createdAt";
|
|
235
|
-
var getSortColumn = (field) => field === "createdAt" ? "created_at" : field === "updatedAt" ? "updated_at" : "version";
|
|
236
|
-
var makeCursor = (record, sort, kindOf) => {
|
|
237
|
-
const contentField = sort?.contentField;
|
|
238
|
-
if (contentField === void 0) {
|
|
239
|
-
const field = getSortField({ sort });
|
|
240
|
-
const value = field === "updatedAt" ? record.updatedAt.getTime() : field === "version" ? record.version : record.createdAt.getTime();
|
|
241
|
-
return encodeCursor({ kind: "native", field, value, id: record.id });
|
|
242
|
-
}
|
|
243
|
-
const kind = kindOf?.(record, contentField);
|
|
244
|
-
const entry = kind ? contentSortEntry(kind, record.content[contentField]) : null;
|
|
245
|
-
if (!entry)
|
|
246
|
-
return encodeCursor({ kind: "absent", field: contentField, id: record.id });
|
|
247
|
-
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 });
|
|
248
|
-
};
|
|
249
173
|
|
|
250
174
|
// ../sqlite-shared/dist/fts5.js
|
|
251
175
|
var OUTSIDE_PHRASE_DISALLOWED = /[^\p{L}\p{N}\p{M}_\s()"]/gu;
|
|
@@ -259,7 +183,7 @@ var sanitizeFts5Query = (query, maxDepth = 2) => {
|
|
|
259
183
|
clean = clean.replace(/\bNEAR\s*\(/gi, "(");
|
|
260
184
|
clean = clean.replace(/"[^"]*"|[^"]+/g, (span) => span.startsWith('"') ? span : span.replace(OUTSIDE_PHRASE_DISALLOWED, " "));
|
|
261
185
|
clean = collapseWhitespace(clean);
|
|
262
|
-
clean = clean.replace(/(
|
|
186
|
+
clean = clean.replace(/"[^"]*"?|(^|\(\s*)NOT\s+/gi, (m, lead) => lead ?? m);
|
|
263
187
|
let currentDepth = 0;
|
|
264
188
|
let inPhrase = false;
|
|
265
189
|
let result = "";
|
|
@@ -326,27 +250,101 @@ var restoreImplicitAnd = (query) => withPhrasesMasked(query, (masked) => masked.
|
|
|
326
250
|
const next = /^\s*([\p{L}\p{N}_]+)/u.exec(whole.slice(offset + match.length));
|
|
327
251
|
return next && isOperator(next[1]) ? match : ") AND ";
|
|
328
252
|
}).replace(new RegExp(String.raw`(?<!${OPERAND_CHAR})(${OPERAND_CHAR}+)\s*(?=\()`, "gu"), (match, word) => isOperator(word) ? match : `${word} AND `));
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
253
|
+
|
|
254
|
+
// ../sqlite-shared/dist/executor.js
|
|
255
|
+
var isForeignKeyViolation = (err) => err instanceof Error && err.message.includes("FOREIGN KEY constraint failed");
|
|
256
|
+
var isUniqueConstraintViolation = (err) => err instanceof Error && err.message.includes("UNIQUE constraint failed");
|
|
257
|
+
|
|
258
|
+
// ../sqlite-shared/dist/config.js
|
|
259
|
+
var CONFIG_ROW_SQL = `SELECT content FROM records WHERE id = '_config'`;
|
|
260
|
+
var insertConfigRecord = (exec, entityId, timezone) => {
|
|
261
|
+
const now = Date.now();
|
|
262
|
+
exec.run(`INSERT INTO records (id, type_id, created_at, updated_at, content, version)
|
|
263
|
+
VALUES ('_config', '_config@1', ?, ?, ?, 1)`, [now, now, JSON.stringify({ entityId, timezone })]);
|
|
264
|
+
return { entityId, timezone };
|
|
265
|
+
};
|
|
266
|
+
var tryReadStackConfig = (exec) => {
|
|
267
|
+
const row = exec.get(CONFIG_ROW_SQL);
|
|
268
|
+
if (!row)
|
|
269
|
+
return null;
|
|
270
|
+
const content = JSON.parse(row.content);
|
|
271
|
+
return { entityId: content.entityId, timezone: content.timezone };
|
|
272
|
+
};
|
|
273
|
+
var toBase64 = (text) => {
|
|
274
|
+
const bytes = new TextEncoder().encode(text);
|
|
275
|
+
let binary = "";
|
|
276
|
+
for (const byte of bytes)
|
|
277
|
+
binary += String.fromCharCode(byte);
|
|
278
|
+
return btoa(binary);
|
|
279
|
+
};
|
|
280
|
+
var fromBase64 = (cursor) => {
|
|
281
|
+
const binary = atob(cursor);
|
|
282
|
+
const bytes = new Uint8Array(binary.length);
|
|
283
|
+
for (let i = 0; i < binary.length; i++)
|
|
284
|
+
bytes[i] = binary.charCodeAt(i);
|
|
285
|
+
return new TextDecoder().decode(bytes);
|
|
286
|
+
};
|
|
287
|
+
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 }));
|
|
288
|
+
var decodeCursor = (cursor) => {
|
|
289
|
+
const malformed = () => new StackBadRequestError(`Invalid cursor: malformed "${cursor}"`);
|
|
290
|
+
let parsed;
|
|
291
|
+
try {
|
|
292
|
+
parsed = JSON.parse(fromBase64(cursor));
|
|
293
|
+
} catch {
|
|
294
|
+
throw malformed();
|
|
295
|
+
}
|
|
296
|
+
if (!parsed || typeof parsed !== "object")
|
|
297
|
+
throw malformed();
|
|
298
|
+
const { k, f, i, v } = parsed;
|
|
299
|
+
if (typeof f !== "string" || !f || typeof i !== "string")
|
|
300
|
+
throw malformed();
|
|
301
|
+
if (k === "absent")
|
|
302
|
+
return { kind: "absent", field: f, id: i };
|
|
303
|
+
if (k === "text") {
|
|
304
|
+
if (typeof v !== "string")
|
|
305
|
+
throw malformed();
|
|
306
|
+
return { kind: "text", field: f, value: v, id: i };
|
|
307
|
+
}
|
|
308
|
+
if (k === "num" || k === "native") {
|
|
309
|
+
if (typeof v !== "number" || !isFinite(v)) {
|
|
310
|
+
throw new StackBadRequestError(`Invalid cursor: non-numeric sort value`);
|
|
311
|
+
}
|
|
312
|
+
if (k === "native") {
|
|
313
|
+
if (!NATIVE_SORT_FIELDS.includes(f)) {
|
|
314
|
+
throw new StackBadRequestError(`Invalid cursor: unknown sort field "${f}"`);
|
|
315
|
+
}
|
|
316
|
+
return { kind: "native", field: f, value: v, id: i };
|
|
317
|
+
}
|
|
318
|
+
return { kind: "num", field: f, value: v, id: i };
|
|
319
|
+
}
|
|
320
|
+
throw malformed();
|
|
321
|
+
};
|
|
322
|
+
var byTypeDescriptor = (byType) => JSON.stringify(Object.entries(byType).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
|
|
323
|
+
var getSortField = (query) => query.sort?.field ?? "createdAt";
|
|
324
|
+
var getSortColumn = (field) => field === "createdAt" ? "created_at" : field === "updatedAt" ? "updated_at" : "version";
|
|
325
|
+
var makeCursor = (record, sort, kindOf) => {
|
|
326
|
+
const byType = sort?.contentFieldByType;
|
|
327
|
+
if (byType) {
|
|
328
|
+
const field = byTypeDescriptor(byType);
|
|
329
|
+
const key = Object.hasOwn(byType, record.typeId) ? byType[record.typeId] : void 0;
|
|
330
|
+
const kind2 = key?.field === void 0 ? void 0 : kindOf?.(record, key.field);
|
|
331
|
+
const entry2 = key?.field !== void 0 ? kind2 && contentSortEntry(kind2, record.content[key.field]) : key && constantSortEntry(key.value ?? null);
|
|
332
|
+
return encodeEntry(field, entry2 || null, record.id);
|
|
349
333
|
}
|
|
334
|
+
const contentField = sort?.contentField;
|
|
335
|
+
if (contentField === void 0) {
|
|
336
|
+
const field = getSortField({ sort });
|
|
337
|
+
const value = field === "updatedAt" ? record.updatedAt.getTime() : field === "version" ? record.version : record.createdAt.getTime();
|
|
338
|
+
return encodeCursor({ kind: "native", field, value, id: record.id });
|
|
339
|
+
}
|
|
340
|
+
const kind = kindOf?.(record, contentField);
|
|
341
|
+
const entry = kind ? contentSortEntry(kind, record.content[contentField]) : null;
|
|
342
|
+
return encodeEntry(contentField, entry, record.id);
|
|
343
|
+
};
|
|
344
|
+
var encodeEntry = (field, entry, id) => {
|
|
345
|
+
if (!entry)
|
|
346
|
+
return encodeCursor({ kind: "absent", field, id });
|
|
347
|
+
return entry.kind === "num" ? encodeCursor({ kind: "num", field, value: entry.num, id }) : encodeCursor({ kind: "text", field, value: entry.text, id });
|
|
350
348
|
};
|
|
351
349
|
|
|
352
350
|
// ../sqlite-shared/dist/query.js
|
|
@@ -393,10 +391,31 @@ var contentCursor = (cursor, field) => {
|
|
|
393
391
|
throw cursorMismatch(cursor, field);
|
|
394
392
|
return cursor;
|
|
395
393
|
};
|
|
394
|
+
var contentConditions = (predicate, params, nextAlias) => {
|
|
395
|
+
const conditions = [];
|
|
396
|
+
for (const [key, value] of Object.entries(predicate.content ?? {})) {
|
|
397
|
+
const segments = parseContentFilterKey(key);
|
|
398
|
+
if (value === null || value === void 0) {
|
|
399
|
+
const isNull = contentPathExists(segments, (a) => `${a}.value IS NULL`, params, nextAlias);
|
|
400
|
+
const anyValue = contentPathExists(segments, (a) => `${a}.value IS NOT NULL`, params, nextAlias);
|
|
401
|
+
conditions.push(`(${isNull} OR NOT ${anyValue})`);
|
|
402
|
+
} else {
|
|
403
|
+
const match = contentPathExists(segments, equalsLeaf, params, nextAlias);
|
|
404
|
+
params.push(value);
|
|
405
|
+
conditions.push(match);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
for (const key of predicate.contentPresent ?? []) {
|
|
409
|
+
conditions.push(contentPathExists(parseContentFilterKey(key), (a) => `${a}.value IS NOT NULL`, params, nextAlias));
|
|
410
|
+
}
|
|
411
|
+
return conditions;
|
|
412
|
+
};
|
|
396
413
|
var recordConditions = (query) => {
|
|
397
414
|
const conditions = ["r.id != '_config'"];
|
|
398
415
|
const params = [];
|
|
399
416
|
const f = query.filter ?? {};
|
|
417
|
+
let aliasSeq = 0;
|
|
418
|
+
const nextContentAlias = () => `cp${aliasSeq++}`;
|
|
400
419
|
const inFilter = (column, value) => {
|
|
401
420
|
if (value === void 0)
|
|
402
421
|
return;
|
|
@@ -485,28 +504,15 @@ var recordConditions = (query) => {
|
|
|
485
504
|
}
|
|
486
505
|
conditions.push(hasAssociation("relationship", clauses));
|
|
487
506
|
}
|
|
488
|
-
if (f.content) {
|
|
489
|
-
|
|
490
|
-
const nextAlias = () => `cp${aliasSeq++}`;
|
|
491
|
-
for (const [key, value] of Object.entries(f.content)) {
|
|
492
|
-
const segments = parseContentFilterKey(key);
|
|
493
|
-
if (value === null || value === void 0) {
|
|
494
|
-
const isNull = contentPathExists(segments, (a) => `${a}.value IS NULL`, params, nextAlias);
|
|
495
|
-
const anyValue = contentPathExists(segments, (a) => `${a}.value IS NOT NULL`, params, nextAlias);
|
|
496
|
-
conditions.push(`(${isNull} OR NOT ${anyValue})`);
|
|
497
|
-
} else {
|
|
498
|
-
const match = contentPathExists(segments, equalsLeaf, params, nextAlias);
|
|
499
|
-
params.push(value);
|
|
500
|
-
conditions.push(match);
|
|
501
|
-
}
|
|
502
|
-
}
|
|
507
|
+
if (f.content || f.contentPresent?.length) {
|
|
508
|
+
conditions.push(...contentConditions(f, params, nextContentAlias));
|
|
503
509
|
}
|
|
504
|
-
if (f.
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
}
|
|
510
|
+
if (f.contentByType) {
|
|
511
|
+
const arms = Object.entries(f.contentByType).map(([typeId, arm]) => {
|
|
512
|
+
params.push(typeId);
|
|
513
|
+
return ["r.type_id = ?", ...contentConditions(arm, params, nextContentAlias)].join(" AND ");
|
|
514
|
+
});
|
|
515
|
+
conditions.push(`(${arms.map((arm) => `(${arm})`).join(" OR ")})`);
|
|
510
516
|
}
|
|
511
517
|
if (f.search) {
|
|
512
518
|
const sanitized = sanitizeFts5Query(f.search);
|
|
@@ -536,12 +542,16 @@ var buildQueryPlan = (query) => {
|
|
|
536
542
|
const dir = sqlDirection(query);
|
|
537
543
|
const op = dir === "ASC" ? ">" : "<";
|
|
538
544
|
const decoded = query.cursor ? decodeCursor(query.cursor) : null;
|
|
545
|
+
if (query.sort?.contentFieldByType) {
|
|
546
|
+
return byTypePlan(query, query.sort.contentFieldByType, dir, op, decoded);
|
|
547
|
+
}
|
|
539
548
|
const contentField = query.sort?.contentField;
|
|
540
549
|
if (contentField === void 0) {
|
|
541
550
|
const { conditions, params } = recordConditions(query);
|
|
542
|
-
const
|
|
551
|
+
const field = getSortField(query);
|
|
552
|
+
const col = getSortColumn(field);
|
|
543
553
|
if (decoded) {
|
|
544
|
-
const cursor2 = nativeCursor(decoded,
|
|
554
|
+
const cursor2 = nativeCursor(decoded, field);
|
|
545
555
|
conditions.push(`(r.${col} ${op} ? OR (r.${col} = ? AND r.id ${op} ?))`);
|
|
546
556
|
params.push(cursor2.value, cursor2.value, cursor2.id);
|
|
547
557
|
}
|
|
@@ -573,18 +583,154 @@ var buildQueryPlan = (query) => {
|
|
|
573
583
|
}
|
|
574
584
|
return pages;
|
|
575
585
|
};
|
|
576
|
-
|
|
577
|
-
|
|
586
|
+
var byTypePlan = (query, byType, dir, op, decoded) => {
|
|
587
|
+
const cursor = decoded ? contentCursor(decoded, byTypeDescriptor(byType)) : null;
|
|
588
|
+
const fields = [];
|
|
589
|
+
const constants = [];
|
|
590
|
+
for (const [typeId, key] of Object.entries(byType)) {
|
|
591
|
+
if (key.field !== void 0) {
|
|
592
|
+
fields.push([typeId, key.field]);
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
const entry = constantSortEntry(key.value ?? null);
|
|
596
|
+
if (entry)
|
|
597
|
+
constants.push([typeId, entry]);
|
|
598
|
+
}
|
|
599
|
+
const pages = [];
|
|
600
|
+
const fieldOf = (alias, record) => fields.map(() => `(${record}.type_id = ? AND ${alias}.field = ?)`).join(" OR ");
|
|
601
|
+
if (cursor?.kind !== "absent" && fields.length + constants.length > 0) {
|
|
602
|
+
const sources = [];
|
|
603
|
+
const sourceParams = [];
|
|
604
|
+
if (fields.length > 0) {
|
|
605
|
+
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")}`);
|
|
606
|
+
for (const [typeId, field] of fields)
|
|
607
|
+
sourceParams.push(typeId, field);
|
|
608
|
+
}
|
|
609
|
+
for (const [typeId, entry] of constants) {
|
|
610
|
+
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 = ?");
|
|
611
|
+
sourceParams.push(...entry.kind === "num" ? [0, entry.num, null, null] : [1, null, entry.key, entry.text], typeId);
|
|
612
|
+
}
|
|
613
|
+
const { conditions, params } = recordConditions(query);
|
|
614
|
+
if (cursor)
|
|
615
|
+
conditions.push(presentCursorCondition(cursor, op, params));
|
|
616
|
+
const from = `(${sources.join(" UNION ALL ")}) ${SORT_ALIAS} JOIN records r ON r.id = ${SORT_ALIAS}.record_id`;
|
|
617
|
+
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}`;
|
|
618
|
+
pages.push(statement("r.*", from, conditions, [...sourceParams, ...params], ` ORDER BY ${order} LIMIT ?`));
|
|
619
|
+
}
|
|
620
|
+
{
|
|
621
|
+
const { conditions, params } = recordConditions(query);
|
|
622
|
+
if (fields.length > 0) {
|
|
623
|
+
conditions.push(`NOT EXISTS (SELECT 1 FROM content_index ${SORT_ALIAS} WHERE ${SORT_ALIAS}.record_id = r.id AND (${fieldOf(SORT_ALIAS, "r")}))`);
|
|
624
|
+
for (const [typeId, field] of fields)
|
|
625
|
+
params.push(typeId, field);
|
|
626
|
+
}
|
|
627
|
+
if (constants.length > 0) {
|
|
628
|
+
conditions.push(`r.type_id NOT IN (${constants.map(() => "?").join(",")})`);
|
|
629
|
+
params.push(...constants.map(([typeId]) => typeId));
|
|
630
|
+
}
|
|
631
|
+
if (cursor?.kind === "absent") {
|
|
632
|
+
conditions.push(`r.id ${op} ?`);
|
|
633
|
+
params.push(cursor.id);
|
|
634
|
+
}
|
|
635
|
+
pages.push(statement("r.*", "records r", conditions, params, ` ORDER BY r.id ${dir} LIMIT ?`));
|
|
636
|
+
}
|
|
637
|
+
return pages;
|
|
638
|
+
};
|
|
639
|
+
var SearchIndex = class {
|
|
640
|
+
exec;
|
|
641
|
+
constructor(exec) {
|
|
642
|
+
this.exec = exec;
|
|
643
|
+
}
|
|
644
|
+
/** Index a record that has no FTS entry yet (used right after an INSERT). */
|
|
645
|
+
insert(recordId, content) {
|
|
646
|
+
this.exec.run(`INSERT INTO records_fts(rowid, content) SELECT rowid, ? FROM records WHERE id = ?`, [content, recordId]);
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Remove a record's FTS entry. MUST be called before the records row's
|
|
650
|
+
* content changes or the row is deleted — see the note above.
|
|
651
|
+
*/
|
|
652
|
+
remove(recordId) {
|
|
653
|
+
const row = this.exec.get("SELECT rowid, content FROM records WHERE id = ?", [recordId]);
|
|
654
|
+
if (!row)
|
|
655
|
+
return;
|
|
656
|
+
this.exec.run(`INSERT INTO records_fts(records_fts, rowid, content) VALUES('delete', ?, ?)`, [
|
|
657
|
+
row.rowid,
|
|
658
|
+
row.content
|
|
659
|
+
]);
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
var indexedFieldKinds = (schema) => {
|
|
663
|
+
const kinds = /* @__PURE__ */ new Map();
|
|
664
|
+
for (const [field, def] of Object.entries(schema)) {
|
|
665
|
+
if (!def.kind || def.kind === "array" || def.kind === "object")
|
|
666
|
+
continue;
|
|
667
|
+
kinds.set(field, def.kind);
|
|
668
|
+
}
|
|
669
|
+
return kinds;
|
|
670
|
+
};
|
|
671
|
+
var ContentIndex = class {
|
|
672
|
+
exec;
|
|
673
|
+
fieldsByType = /* @__PURE__ */ new Map();
|
|
674
|
+
constructor(exec) {
|
|
675
|
+
this.exec = exec;
|
|
676
|
+
}
|
|
677
|
+
/** Prime the cache from a type as it is saved. */
|
|
678
|
+
rememberType(type) {
|
|
679
|
+
this.fieldsByType.set(type.id, indexedFieldKinds(type.schema));
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Replace a record's rows with what its content currently holds, on every
|
|
683
|
+
* write that can change content or typeId, so neither the ordering nor the
|
|
684
|
+
* file-reference lookup can drift from the record. A field holding no
|
|
685
|
+
* orderable value gets no row at all, which is what puts the record at the
|
|
686
|
+
* end of a sort on that field.
|
|
687
|
+
*/
|
|
688
|
+
sync(recordId, typeId, content) {
|
|
689
|
+
this.remove(recordId);
|
|
690
|
+
for (const [field, kind] of this.fieldsOf(typeId)) {
|
|
691
|
+
const entry = contentSortEntry(kind, content[field]);
|
|
692
|
+
if (!entry)
|
|
693
|
+
continue;
|
|
694
|
+
const sql = `INSERT OR IGNORE INTO content_index
|
|
695
|
+
(record_id, field, value_rank, num_value, text_key, text_value, file_id)
|
|
696
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`;
|
|
697
|
+
if (entry.kind === "num") {
|
|
698
|
+
this.exec.run(sql, [recordId, field, 0, entry.num, null, null, null]);
|
|
699
|
+
} else {
|
|
700
|
+
const fileId = kind === "file-ref" ? entry.text : null;
|
|
701
|
+
this.exec.run(sql, [recordId, field, 1, null, entry.key, entry.text, fileId]);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
remove(recordId) {
|
|
706
|
+
this.exec.run("DELETE FROM content_index WHERE record_id = ?", [recordId]);
|
|
707
|
+
}
|
|
708
|
+
/** The declared kind of a type's top-level `field`, if it has one. */
|
|
709
|
+
kindOf(typeId, field) {
|
|
710
|
+
return this.fieldsOf(typeId).get(field);
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* From the cache, falling back to a `types` lookup only for a typeId this
|
|
714
|
+
* process hasn't seen via rememberType() yet, e.g. right after open().
|
|
715
|
+
*/
|
|
716
|
+
fieldsOf(typeId) {
|
|
717
|
+
const cached = this.fieldsByType.get(typeId);
|
|
718
|
+
if (cached)
|
|
719
|
+
return cached;
|
|
720
|
+
const typeRow = this.exec.get("SELECT schema FROM types WHERE id = ?", [
|
|
721
|
+
typeId
|
|
722
|
+
]);
|
|
723
|
+
const fields = typeRow ? indexedFieldKinds(JSON.parse(typeRow.schema)) : /* @__PURE__ */ new Map();
|
|
724
|
+
this.fieldsByType.set(typeId, fields);
|
|
725
|
+
return fields;
|
|
726
|
+
}
|
|
727
|
+
};
|
|
578
728
|
var toMs = (d) => d.getTime();
|
|
579
729
|
var actorFromColumns = (subject, principal) => subject == null ? void 0 : {
|
|
580
730
|
subjectId: subject,
|
|
581
731
|
...principal != null && { principalId: principal }
|
|
582
732
|
};
|
|
583
733
|
var fromMs = (ms) => new Date(ms);
|
|
584
|
-
var partitionAssociations = (associations) => ({
|
|
585
|
-
associations: associations.filter((a) => a.kind !== "permission" && a.kind !== "anyone"),
|
|
586
|
-
permissions: associations.filter((a) => a.kind === "permission" || a.kind === "anyone")
|
|
587
|
-
});
|
|
588
734
|
var rowToRecord = (row, associations) => {
|
|
589
735
|
const record = {
|
|
590
736
|
id: row.id,
|
|
@@ -728,25 +874,7 @@ var rowToJournalEntry = (row) => {
|
|
|
728
874
|
return e;
|
|
729
875
|
};
|
|
730
876
|
|
|
731
|
-
// ../sqlite-shared/dist/
|
|
732
|
-
var isForeignKeyViolation = (err) => err instanceof Error && err.message.includes("FOREIGN KEY constraint failed");
|
|
733
|
-
var isUniqueConstraintViolation = (err) => err instanceof Error && err.message.includes("UNIQUE constraint failed");
|
|
734
|
-
|
|
735
|
-
// ../sqlite-shared/dist/config.js
|
|
736
|
-
var CONFIG_ROW_SQL = `SELECT content FROM records WHERE id = '_config'`;
|
|
737
|
-
var insertConfigRecord = (exec, entityId, timezone) => {
|
|
738
|
-
const now = Date.now();
|
|
739
|
-
exec.run(`INSERT INTO records (id, type_id, created_at, updated_at, content, version)
|
|
740
|
-
VALUES ('_config', '_config@1', ?, ?, ?, 1)`, [now, now, JSON.stringify({ entityId, timezone })]);
|
|
741
|
-
return { entityId, timezone };
|
|
742
|
-
};
|
|
743
|
-
var tryReadStackConfig = (exec) => {
|
|
744
|
-
const row = exec.get(CONFIG_ROW_SQL);
|
|
745
|
-
if (!row)
|
|
746
|
-
return null;
|
|
747
|
-
const content = JSON.parse(row.content);
|
|
748
|
-
return { entityId: content.entityId, timezone: content.timezone };
|
|
749
|
-
};
|
|
877
|
+
// ../sqlite-shared/dist/record-logic.js
|
|
750
878
|
var asStackBadRequestError = (query, run) => {
|
|
751
879
|
try {
|
|
752
880
|
return run();
|
|
@@ -757,6 +885,7 @@ var asStackBadRequestError = (query, run) => {
|
|
|
757
885
|
}
|
|
758
886
|
};
|
|
759
887
|
var versionConflict = (id, expected, actual) => new StackVersionConflictError(`Record "${id}" is at version ${actual}, expected ${expected}`, id, expected, actual);
|
|
888
|
+
var recordNotFound = (id) => new StackNotFoundError(`Record not found: "${id}"`);
|
|
760
889
|
var VERSION_COLUMNS = [
|
|
761
890
|
"type_id",
|
|
762
891
|
"content",
|
|
@@ -775,44 +904,20 @@ var versionRowValues = (version) => [
|
|
|
775
904
|
version.updatedBy?.subjectId ?? null,
|
|
776
905
|
version.updatedBy?.principalId ?? null
|
|
777
906
|
];
|
|
778
|
-
var
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
* Per-typeId cache of the fields content_index covers and their declared
|
|
791
|
-
* kinds, so syncContentIndex() doesn't hit the `types` table and
|
|
792
|
-
* re-parse schema JSON on every write. Populated eagerly in saveType()
|
|
793
|
-
* and lazily via getIndexedFields().
|
|
794
|
-
*/
|
|
795
|
-
indexedFieldsByType = /* @__PURE__ */ new Map();
|
|
796
|
-
constructor(deps) {
|
|
797
|
-
this.deps = deps;
|
|
798
|
-
}
|
|
799
|
-
get exec() {
|
|
800
|
-
return this.deps.exec;
|
|
801
|
-
}
|
|
802
|
-
/**
|
|
803
|
-
* Precondition check for a mutation that has already read the record —
|
|
804
|
-
* either because it can't fold the check into its own statement
|
|
805
|
-
* (purgeRecord: children reference the row, so the DELETE can't
|
|
806
|
-
* carry it), or because it has to settle the precondition *before*
|
|
807
|
-
* fts5Strategy.remove() runs, which needs the records row still holding
|
|
808
|
-
* the old content. In the latter case versionedUpdate() re-checks it
|
|
809
|
-
* inside the statement, and that is the enforcement; this is what lets
|
|
810
|
-
* the work before it be skipped.
|
|
811
|
-
*/
|
|
812
|
-
checkExpectedVersion(record, expectedVersion) {
|
|
813
|
-
if (expectedVersion === void 0 || record.version === expectedVersion)
|
|
907
|
+
var SharedSqlRecordLogic = class {
|
|
908
|
+
exec;
|
|
909
|
+
searchIndex;
|
|
910
|
+
contentIndex;
|
|
911
|
+
constructor({ exec }) {
|
|
912
|
+
this.exec = exec;
|
|
913
|
+
this.searchIndex = new SearchIndex(exec);
|
|
914
|
+
this.contentIndex = new ContentIndex(exec);
|
|
915
|
+
}
|
|
916
|
+
/** Check `ifVersion` against a version read inside the caller's transaction. */
|
|
917
|
+
checkExpectedVersion(id, actual, expected) {
|
|
918
|
+
if (expected === void 0 || actual === expected)
|
|
814
919
|
return;
|
|
815
|
-
throw versionConflict(
|
|
920
|
+
throw versionConflict(id, expected, actual);
|
|
816
921
|
}
|
|
817
922
|
/**
|
|
818
923
|
* Called after a version-gated statement affected zero rows.
|
|
@@ -821,23 +926,31 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
821
926
|
* the actual current version for the caller to act on).
|
|
822
927
|
*/
|
|
823
928
|
throwVersionConflict(id, expectedVersion) {
|
|
824
|
-
const
|
|
825
|
-
|
|
826
|
-
|
|
929
|
+
const version = this.currentVersion(id);
|
|
930
|
+
if (version === void 0)
|
|
931
|
+
throw recordNotFound(id);
|
|
932
|
+
throw versionConflict(id, expectedVersion, version);
|
|
933
|
+
}
|
|
934
|
+
currentVersion(id) {
|
|
935
|
+
return this.exec.get("SELECT version FROM records WHERE id = ?", [id])?.version;
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* What a mutation builds on, read inside its transaction so nothing can
|
|
939
|
+
* land between the read and the write: a patch applied to an earlier read
|
|
940
|
+
* would overwrite whatever changed since. Throws for a missing record or
|
|
941
|
+
* a failed `ifVersion`.
|
|
942
|
+
*/
|
|
943
|
+
readForWrite(id, ifVersion) {
|
|
944
|
+
const row = this.exec.get("SELECT version, type_id, content FROM records WHERE id = ?", [id]);
|
|
827
945
|
if (!row)
|
|
828
|
-
throw
|
|
829
|
-
|
|
946
|
+
throw recordNotFound(id);
|
|
947
|
+
this.checkExpectedVersion(id, row.version, ifVersion);
|
|
948
|
+
return { typeId: row.type_id, content: JSON.parse(row.content) };
|
|
830
949
|
}
|
|
831
950
|
/**
|
|
832
|
-
*
|
|
833
|
-
*
|
|
834
|
-
*
|
|
835
|
-
* precondition and failing loudly when nothing matched.
|
|
836
|
-
*
|
|
837
|
-
* The guard rides in the WHERE clause even where the caller has already
|
|
838
|
-
* checked the version against a record it read: that read is outside this
|
|
839
|
-
* statement, so a writer slipping in between the two is caught here
|
|
840
|
-
* rather than silently overwritten.
|
|
951
|
+
* Every version-bumping UPDATE. The `ifVersion` guard rides in the WHERE
|
|
952
|
+
* clause, so a write that reads nothing first (a soft delete, an
|
|
953
|
+
* undelete) enforces it with no separate read.
|
|
841
954
|
*/
|
|
842
955
|
versionedUpdate(id, sets, values, opts, now = toMs(/* @__PURE__ */ new Date())) {
|
|
843
956
|
const guarded = opts.ifVersion !== void 0;
|
|
@@ -853,15 +966,9 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
853
966
|
this.throwVersionConflict(id, opts.ifVersion);
|
|
854
967
|
}
|
|
855
968
|
/**
|
|
856
|
-
* Read a record back after its own mutation committed
|
|
857
|
-
*
|
|
858
|
-
*
|
|
859
|
-
* breach it would be.
|
|
860
|
-
*
|
|
861
|
-
* Synchronous, via readRecord() rather than getRecord(), so the read
|
|
862
|
-
* cannot be separated from the commit by anything: an async caller
|
|
863
|
-
* returning this still hands back a record read in the same tick its
|
|
864
|
-
* transaction committed. See readRecord() for why that matters.
|
|
969
|
+
* Read a record back after its own mutation committed. Absence here is an
|
|
970
|
+
* invariant breach, not a "not found". Synchronous, so nothing can run
|
|
971
|
+
* between the commit and the read — see readRecord().
|
|
865
972
|
*/
|
|
866
973
|
reread(id, verb) {
|
|
867
974
|
const updated = this.readRecord(id);
|
|
@@ -873,16 +980,10 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
873
980
|
// Records
|
|
874
981
|
// -------------------------------------------------------
|
|
875
982
|
/**
|
|
876
|
-
*
|
|
877
|
-
*
|
|
878
|
-
*
|
|
879
|
-
*
|
|
880
|
-
* One transaction, because a record and the four tables that describe it
|
|
881
|
-
* are one fact. A half-applied create is worse than a failed one: the
|
|
882
|
-
* call raises, so the caller believes nothing landed, while the records
|
|
883
|
-
* row survives to fail their retry on the PK — and the row it leaves is
|
|
884
|
-
* invisible to `filter.search` and mis-ordered by `sort.contentField`
|
|
885
|
-
* until something writes it again.
|
|
983
|
+
* One transaction, because a record and the tables that describe it are
|
|
984
|
+
* one fact: a half-applied create raises, so the caller believes nothing
|
|
985
|
+
* landed, while the surviving row fails their retry on the PK and is
|
|
986
|
+
* missing from both indexes. A duplicate id maps to StackConflictError.
|
|
886
987
|
*/
|
|
887
988
|
async createRecord(record, opts = {}) {
|
|
888
989
|
this.exec.transaction(() => {
|
|
@@ -916,8 +1017,8 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
916
1017
|
const edges = [...record.associations ?? [], ...record.permissions ?? []];
|
|
917
1018
|
if (edges.length)
|
|
918
1019
|
this.insertAssociations(record.id, edges);
|
|
919
|
-
|
|
920
|
-
this.
|
|
1020
|
+
this.searchIndex.insert(record.id, JSON.stringify(record.content));
|
|
1021
|
+
this.contentIndex.sync(record.id, record.typeId, record.content);
|
|
921
1022
|
this.appendJournal(record.id, opts.journal);
|
|
922
1023
|
});
|
|
923
1024
|
return record;
|
|
@@ -926,53 +1027,24 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
926
1027
|
return this.readRecord(id);
|
|
927
1028
|
}
|
|
928
1029
|
/**
|
|
929
|
-
* The synchronous read behind getRecord()
|
|
930
|
-
*
|
|
931
|
-
*
|
|
932
|
-
*
|
|
933
|
-
* reason (see SqlExecutor.transaction).
|
|
934
|
-
*
|
|
935
|
-
* The same synchrony is what lets the post-commit reads below report the
|
|
936
|
-
* version their own mutation produced: reread() calls this directly, so
|
|
937
|
-
* nothing interleaves between the commit and the read even though the
|
|
938
|
-
* method returning it is async. A backend that made these reads genuinely
|
|
939
|
-
* asynchronous would open that window, and a concurrent mutation could be
|
|
940
|
-
* reported under this one's verb.
|
|
1030
|
+
* The synchronous read behind getRecord(), for use inside
|
|
1031
|
+
* exec.transaction(), whose callback can't await. reread() relies on the
|
|
1032
|
+
* same synchrony: nothing interleaves between a commit and its read, so a
|
|
1033
|
+
* concurrent mutation is never reported under this one's verb.
|
|
941
1034
|
*/
|
|
942
1035
|
readRecord(id) {
|
|
943
1036
|
const row = this.exec.get("SELECT * FROM records WHERE id = ?", [id]);
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
1037
|
+
return row ? this.hydrate(row) : null;
|
|
1038
|
+
}
|
|
1039
|
+
/** A records row with its association set attached. */
|
|
1040
|
+
hydrate(row) {
|
|
1041
|
+
return rowToRecord(row, this.getAssociationsForRecord(row.id));
|
|
948
1042
|
}
|
|
949
1043
|
/**
|
|
950
|
-
*
|
|
951
|
-
*
|
|
952
|
-
* replaced when it carries one, and the snapshot written alongside — so
|
|
953
|
-
* one call is one version whatever it moved.
|
|
954
|
-
*
|
|
955
|
-
* `opts.bumpsVersion: false` is a change set that touches only
|
|
956
|
-
* `associations`, `parentId` and/or `unlisted` — Stack computes this from
|
|
957
|
-
* which ops the set actually moves. Such a write still lands its columns,
|
|
958
|
-
* but leaves `version`, `updatedAt` and the actor stamps exactly as they
|
|
959
|
-
* stood and takes no snapshot. `ifVersion`, if given, is re-checked
|
|
960
|
-
* synchronously inside the transaction — the CAS guard the bumping path
|
|
961
|
-
* gets from its UPDATE's WHERE clause, restated as a read because the
|
|
962
|
-
* UPDATE here carries no version predicate. See
|
|
963
|
-
* docs/spec/versioning.md § Version history.
|
|
964
|
-
*
|
|
965
|
-
* The ifVersion check for a bumping write is standalone rather than
|
|
966
|
-
* folded into the UPDATE alone, because a content change needs
|
|
967
|
-
* fts.remove() to run *before* the records-table content changes. The
|
|
968
|
-
* guard is still in the UPDATE's WHERE, so a writer that slipped in
|
|
969
|
-
* between the read and the write is caught there rather than overwriting.
|
|
1044
|
+
* One transaction, so one call is one version whatever it moved, and the
|
|
1045
|
+
* patch applies to the content as it stands when the write lands.
|
|
970
1046
|
*/
|
|
971
1047
|
async mutateRecord(id, changes, opts = {}) {
|
|
972
|
-
const existing = await this.getRecord(id);
|
|
973
|
-
if (!existing)
|
|
974
|
-
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
975
|
-
this.checkExpectedVersion(existing, opts.ifVersion);
|
|
976
1048
|
const now = toMs(/* @__PURE__ */ new Date());
|
|
977
1049
|
const sets = [];
|
|
978
1050
|
const values = [];
|
|
@@ -984,35 +1056,30 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
984
1056
|
sets.push("unlisted_at = ?");
|
|
985
1057
|
values.push(changes.unlisted ? now : null);
|
|
986
1058
|
}
|
|
987
|
-
|
|
988
|
-
this.
|
|
989
|
-
|
|
990
|
-
if (!current)
|
|
991
|
-
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
992
|
-
this.checkExpectedVersion(current, opts.ifVersion);
|
|
1059
|
+
this.exec.transaction(() => {
|
|
1060
|
+
const current = this.readForWrite(id, opts.ifVersion);
|
|
1061
|
+
if (opts.bumpsVersion === false) {
|
|
993
1062
|
if (sets.length > 0) {
|
|
994
1063
|
this.exec.run(`UPDATE records SET ${sets.join(", ")} WHERE id = ?`, [...values, id]);
|
|
995
1064
|
}
|
|
996
1065
|
this.replaceAssociationHalves(id, changes);
|
|
997
1066
|
this.appendJournal(id, opts.journal);
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
}
|
|
1006
|
-
this.exec.transaction(() => {
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
const merged = changes.contentPatch ? applyMergePatch(current.content, changes.contentPatch) : void 0;
|
|
1070
|
+
if (merged !== void 0) {
|
|
1071
|
+
sets.unshift("content = ?");
|
|
1072
|
+
values.unshift(JSON.stringify(merged));
|
|
1073
|
+
}
|
|
1007
1074
|
if (opts.snapshot)
|
|
1008
1075
|
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
1009
1076
|
if (merged !== void 0)
|
|
1010
|
-
|
|
1077
|
+
this.searchIndex.remove(id);
|
|
1011
1078
|
this.versionedUpdate(id, sets, values, opts, now);
|
|
1012
1079
|
this.replaceAssociationHalves(id, changes);
|
|
1013
1080
|
if (merged !== void 0) {
|
|
1014
|
-
|
|
1015
|
-
this.
|
|
1081
|
+
this.searchIndex.insert(id, JSON.stringify(merged));
|
|
1082
|
+
this.contentIndex.sync(id, current.typeId, merged);
|
|
1016
1083
|
}
|
|
1017
1084
|
this.appendJournal(id, opts.journal);
|
|
1018
1085
|
});
|
|
@@ -1032,27 +1099,23 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1032
1099
|
return this.reread(id, "deleteRecord");
|
|
1033
1100
|
}
|
|
1034
1101
|
/**
|
|
1035
|
-
*
|
|
1036
|
-
*
|
|
1037
|
-
*
|
|
1038
|
-
* the deletes. Null when there was no such record. No write() call —
|
|
1039
|
-
* callers batch it. ifVersion is checked first so a lost CAS race
|
|
1040
|
-
* leaves nothing touched (children reference the row, so the check can't
|
|
1041
|
-
* fold into the final DELETE).
|
|
1102
|
+
* Delete a record and everything that refers to it, returning the record
|
|
1103
|
+
* as it stood (null if there was none). Read here, inside the caller's
|
|
1104
|
+
* transaction, so nothing can overtake the last copy that will ever exist.
|
|
1042
1105
|
*/
|
|
1043
1106
|
purgeRecord(id, ifVersion) {
|
|
1044
1107
|
const purged = this.readRecord(id);
|
|
1045
1108
|
if (!purged) {
|
|
1046
1109
|
if (ifVersion !== void 0)
|
|
1047
|
-
throw
|
|
1110
|
+
throw recordNotFound(id);
|
|
1048
1111
|
return null;
|
|
1049
1112
|
}
|
|
1050
|
-
this.checkExpectedVersion(purged, ifVersion);
|
|
1051
|
-
|
|
1113
|
+
this.checkExpectedVersion(id, purged.version, ifVersion);
|
|
1114
|
+
this.searchIndex.remove(id);
|
|
1052
1115
|
this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
|
|
1053
1116
|
this.exec.run("DELETE FROM versions WHERE record_id = ?", [id]);
|
|
1054
1117
|
this.exec.run("DELETE FROM journal WHERE record_id = ?", [id]);
|
|
1055
|
-
this.
|
|
1118
|
+
this.contentIndex.remove(id);
|
|
1056
1119
|
this.exec.run("DELETE FROM records WHERE id = ?", [id]);
|
|
1057
1120
|
return purged;
|
|
1058
1121
|
}
|
|
@@ -1066,14 +1129,11 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1066
1129
|
return this.reread(id, "undelete");
|
|
1067
1130
|
}
|
|
1068
1131
|
async restoreVersion(id, version, opts = {}) {
|
|
1069
|
-
const existing = await this.getRecord(id);
|
|
1070
|
-
if (!existing)
|
|
1071
|
-
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
1072
|
-
this.checkExpectedVersion(existing, opts.ifVersion);
|
|
1073
|
-
const target = await this.getVersion(id, version);
|
|
1074
|
-
if (!target)
|
|
1075
|
-
throw new StackNotFoundError(`Version not found: "${id}"@${version}`);
|
|
1076
1132
|
this.exec.transaction(() => {
|
|
1133
|
+
this.readForWrite(id, opts.ifVersion);
|
|
1134
|
+
const target = this.readVersion(id, version);
|
|
1135
|
+
if (!target)
|
|
1136
|
+
throw new StackNotFoundError(`Version not found: "${id}"@${version}`);
|
|
1077
1137
|
if (opts.snapshot)
|
|
1078
1138
|
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
1079
1139
|
this.rewriteContent(id, target.typeId, target.content, opts);
|
|
@@ -1082,11 +1142,8 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1082
1142
|
return this.reread(id, "restoreVersion");
|
|
1083
1143
|
}
|
|
1084
1144
|
async commitMigration(id, toTypeId, content, opts = {}) {
|
|
1085
|
-
const existing = await this.getRecord(id);
|
|
1086
|
-
if (!existing)
|
|
1087
|
-
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
1088
|
-
this.checkExpectedVersion(existing, opts.ifVersion);
|
|
1089
1145
|
this.exec.transaction(() => {
|
|
1146
|
+
this.readForWrite(id, opts.ifVersion);
|
|
1090
1147
|
if (opts.snapshot)
|
|
1091
1148
|
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
1092
1149
|
this.rewriteContent(id, toTypeId, content, opts);
|
|
@@ -1095,21 +1152,16 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1095
1152
|
return this.reread(id, "commitMigration");
|
|
1096
1153
|
}
|
|
1097
1154
|
/**
|
|
1098
|
-
* Replace a record's type and content wholesale
|
|
1099
|
-
*
|
|
1100
|
-
*
|
|
1101
|
-
* The order is the constraint: fts5Strategy.remove() has to read the old
|
|
1102
|
-
* content off the records row, so it runs before the UPDATE, and the
|
|
1103
|
-
* re-index runs after. Every caller must already sit inside
|
|
1104
|
-
* exec.transaction() — a failure between the two halves would otherwise
|
|
1105
|
-
* leave the record unindexed.
|
|
1155
|
+
* Replace a record's type and content wholesale. Unindex, write, re-index,
|
|
1156
|
+
* in that order (docs/spec/adapters.md § The search index is unindexed
|
|
1157
|
+
* before the row moves); callers must be inside exec.transaction().
|
|
1106
1158
|
*/
|
|
1107
1159
|
rewriteContent(id, typeId, content, opts) {
|
|
1108
1160
|
const json = JSON.stringify(content);
|
|
1109
|
-
|
|
1161
|
+
this.searchIndex.remove(id);
|
|
1110
1162
|
this.versionedUpdate(id, ["type_id = ?", "content = ?"], [typeId, json], opts);
|
|
1111
|
-
|
|
1112
|
-
this.
|
|
1163
|
+
this.searchIndex.insert(id, json);
|
|
1164
|
+
this.contentIndex.sync(id, typeId, content);
|
|
1113
1165
|
}
|
|
1114
1166
|
async queryRecords(query) {
|
|
1115
1167
|
const limit = query.limit ?? 50;
|
|
@@ -1124,20 +1176,14 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1124
1176
|
}
|
|
1125
1177
|
const hasMore = rows.length > limit;
|
|
1126
1178
|
const page = hasMore ? rows.slice(0, limit) : rows;
|
|
1127
|
-
const records = page.map((row) =>
|
|
1128
|
-
const associations = this.getAssociationsForRecord(row.id);
|
|
1129
|
-
return rowToRecord(row, associations);
|
|
1130
|
-
});
|
|
1179
|
+
const records = page.map((row) => this.hydrate(row));
|
|
1131
1180
|
const lastRecord = records[records.length - 1];
|
|
1132
|
-
const cursor = hasMore && lastRecord ? makeCursor(lastRecord, query.sort, (record, field) => this.
|
|
1181
|
+
const cursor = hasMore && lastRecord ? makeCursor(lastRecord, query.sort, (record, field) => this.contentIndex.kindOf(record.typeId, field)) : null;
|
|
1133
1182
|
return { records, cursor };
|
|
1134
1183
|
}
|
|
1135
1184
|
/**
|
|
1136
|
-
*
|
|
1137
|
-
*
|
|
1138
|
-
* deleteUnreferencedAttachmentRecords on the adapter contract). A real
|
|
1139
|
-
* SQL transaction of synchronous calls — no `await` between the check
|
|
1140
|
-
* and the deletes, so nothing interleaves.
|
|
1185
|
+
* The reference check and the purges share one synchronous transaction,
|
|
1186
|
+
* so nothing can add a reference between the two.
|
|
1141
1187
|
*/
|
|
1142
1188
|
async deleteUnreferencedAttachmentRecords(fileId, metadataTypeIds) {
|
|
1143
1189
|
return this.exec.transaction(() => {
|
|
@@ -1176,6 +1222,9 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1176
1222
|
return rows.map(rowToVersion);
|
|
1177
1223
|
}
|
|
1178
1224
|
async getVersion(id, version) {
|
|
1225
|
+
return this.readVersion(id, version);
|
|
1226
|
+
}
|
|
1227
|
+
readVersion(id, version) {
|
|
1179
1228
|
const row = this.exec.get("SELECT * FROM versions WHERE record_id = ? AND version = ?", [id, version]);
|
|
1180
1229
|
return row ? rowToVersion(row) : null;
|
|
1181
1230
|
}
|
|
@@ -1229,10 +1278,7 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1229
1278
|
} catch (err) {
|
|
1230
1279
|
if (!(err instanceof StackConflictError))
|
|
1231
1280
|
throw err;
|
|
1232
|
-
|
|
1233
|
-
id
|
|
1234
|
-
]);
|
|
1235
|
-
if (!row || row.version !== version.version)
|
|
1281
|
+
if (this.currentVersion(id) !== version.version)
|
|
1236
1282
|
throw err;
|
|
1237
1283
|
this.overwriteVersionRow(id, version);
|
|
1238
1284
|
}
|
|
@@ -1241,21 +1287,17 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1241
1287
|
// Change journal
|
|
1242
1288
|
// -------------------------------------------------------
|
|
1243
1289
|
/**
|
|
1244
|
-
* Append one entry,
|
|
1245
|
-
*
|
|
1246
|
-
*
|
|
1247
|
-
*
|
|
1248
|
-
* `seq` is allocated here, from the log's own max, rather than computed
|
|
1249
|
-
* by the caller. That is what spares this table the collision healing
|
|
1250
|
-
* versions needs: nothing outside this statement ever holds a seq it
|
|
1251
|
-
* expects to be free. See docs/spec/journal.md § Ordering.
|
|
1290
|
+
* Append one entry, stamped from the row as it now stands, so callers run
|
|
1291
|
+
* this inside their write's transaction, after the write. `seq` is
|
|
1292
|
+
* allocated in the INSERT itself, so no caller ever holds one it expects
|
|
1293
|
+
* to be free. See docs/spec/journal.md § Ordering.
|
|
1252
1294
|
*/
|
|
1253
1295
|
appendJournal(recordId, entry) {
|
|
1254
1296
|
if (!entry)
|
|
1255
1297
|
return;
|
|
1256
1298
|
const row = this.exec.get("SELECT version, type_id, parent_id FROM records WHERE id = ?", [recordId]);
|
|
1257
1299
|
if (!row)
|
|
1258
|
-
throw
|
|
1300
|
+
throw recordNotFound(recordId);
|
|
1259
1301
|
this.exec.run(`INSERT INTO journal
|
|
1260
1302
|
(record_id, seq, at, kind, ops, version, type_id, parent_id,
|
|
1261
1303
|
previous_parent_id, actor, associations)
|
|
@@ -1279,7 +1321,7 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1279
1321
|
}
|
|
1280
1322
|
async getJournal(id, query = {}) {
|
|
1281
1323
|
if (!this.exec.get("SELECT 1 FROM records WHERE id = ?", [id])) {
|
|
1282
|
-
throw
|
|
1324
|
+
throw recordNotFound(id);
|
|
1283
1325
|
}
|
|
1284
1326
|
const conditions = ["record_id = ?"];
|
|
1285
1327
|
const values = [id];
|
|
@@ -1306,8 +1348,7 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1306
1348
|
type.migratesFrom ?? null,
|
|
1307
1349
|
toMs(type.createdAt)
|
|
1308
1350
|
]);
|
|
1309
|
-
|
|
1310
|
-
this.indexedFieldsByType.set(type.id, indexedFieldKinds(schema));
|
|
1351
|
+
this.contentIndex.rememberType(type);
|
|
1311
1352
|
}
|
|
1312
1353
|
async getType(id) {
|
|
1313
1354
|
const row = this.exec.get("SELECT * FROM types WHERE id = ?", [id]);
|
|
@@ -1322,15 +1363,14 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1322
1363
|
// -------------------------------------------------------
|
|
1323
1364
|
/**
|
|
1324
1365
|
* Never bumps `version`/`updatedAt` and never snapshots — see
|
|
1325
|
-
* StackRecordAdapter.amendAssociations().
|
|
1326
|
-
*
|
|
1327
|
-
* removes first, then adds.
|
|
1366
|
+
* StackRecordAdapter.amendAssociations(). Removes apply before adds, so
|
|
1367
|
+
* removing and re-adding one identity in a call leaves it present.
|
|
1328
1368
|
*/
|
|
1329
1369
|
async amendAssociations(recordId, changes, opts = {}) {
|
|
1330
1370
|
assertOneSurface(changes);
|
|
1331
1371
|
this.exec.transaction(() => {
|
|
1332
|
-
if (
|
|
1333
|
-
throw
|
|
1372
|
+
if (this.currentVersion(recordId) === void 0)
|
|
1373
|
+
throw recordNotFound(recordId);
|
|
1334
1374
|
for (const change of changes) {
|
|
1335
1375
|
if (change.op !== "remove")
|
|
1336
1376
|
continue;
|
|
@@ -1353,43 +1393,33 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1353
1393
|
});
|
|
1354
1394
|
return this.reread(recordId, "amendAssociations");
|
|
1355
1395
|
}
|
|
1356
|
-
/** The kinds each change-set key replaces, and nothing else. */
|
|
1357
|
-
static DATA_KINDS = ["tag", "attachment", "relationship"];
|
|
1358
|
-
static AUTHORITY_KINDS = ["permission", "anyone"];
|
|
1359
1396
|
/**
|
|
1360
|
-
*
|
|
1361
|
-
*
|
|
1362
|
-
* tag write cannot drop an ACL: the `associations` key deletes data
|
|
1363
|
-
* kinds alone, so the authority rows beside them are never in scope.
|
|
1364
|
-
* Caller-transactional, like every write below.
|
|
1397
|
+
* Each key replaces only within its own half of the partition, which is
|
|
1398
|
+
* why a tag write cannot drop an ACL. Caller-transactional.
|
|
1365
1399
|
* See docs/spec/access-control.md § Record-level permissions.
|
|
1366
1400
|
*/
|
|
1367
1401
|
replaceAssociationHalves(recordId, changes) {
|
|
1368
1402
|
if (changes.associations !== void 0) {
|
|
1369
|
-
this.replaceAssociations(recordId, changes.associations,
|
|
1403
|
+
this.replaceAssociations(recordId, changes.associations, "data");
|
|
1370
1404
|
}
|
|
1371
1405
|
if (changes.permissions !== void 0) {
|
|
1372
|
-
this.replaceAssociations(recordId, changes.permissions,
|
|
1406
|
+
this.replaceAssociations(recordId, changes.permissions, "authority");
|
|
1373
1407
|
}
|
|
1374
1408
|
}
|
|
1375
1409
|
/**
|
|
1376
1410
|
* Swap one half of a record's association set wholesale — the "replace,
|
|
1377
|
-
* don't merge" semantics a change set's association list carries.
|
|
1411
|
+
* don't merge" semantics a change set's association list carries. The data
|
|
1412
|
+
* half is "not an authority kind", so core's list is the only one.
|
|
1378
1413
|
*/
|
|
1379
|
-
replaceAssociations(recordId, associations,
|
|
1380
|
-
this.exec.run(`DELETE FROM associations WHERE record_id = ? AND kind IN (${
|
|
1414
|
+
replaceAssociations(recordId, associations, half) {
|
|
1415
|
+
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]);
|
|
1381
1416
|
if (associations.length)
|
|
1382
1417
|
this.insertAssociations(recordId, associations);
|
|
1383
1418
|
}
|
|
1384
1419
|
/**
|
|
1385
|
-
* Upserts on association identity
|
|
1386
|
-
*
|
|
1387
|
-
*
|
|
1388
|
-
*
|
|
1389
|
-
* FK enforcement (PRAGMA_FOREIGN_KEYS_ON) means inserting an
|
|
1390
|
-
* association against a record that doesn't exist throws — mapped
|
|
1391
|
-
* here to StackNotFoundError so amendAssociations() on a nonexistent record
|
|
1392
|
-
* fails loudly instead of silently creating an orphan row.
|
|
1420
|
+
* Upserts on association identity, so re-pointing `attachment_record_id`
|
|
1421
|
+
* (outside the key) costs no second row. A missing record trips the
|
|
1422
|
+
* foreign key, reported as StackNotFoundError rather than an orphan row.
|
|
1393
1423
|
*/
|
|
1394
1424
|
insertAssociations(recordId, associations) {
|
|
1395
1425
|
for (const assoc of associations) {
|
|
@@ -1407,7 +1437,7 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1407
1437
|
]);
|
|
1408
1438
|
} catch (err) {
|
|
1409
1439
|
if (isForeignKeyViolation(err)) {
|
|
1410
|
-
throw
|
|
1440
|
+
throw recordNotFound(recordId);
|
|
1411
1441
|
}
|
|
1412
1442
|
throw err;
|
|
1413
1443
|
}
|
|
@@ -1417,51 +1447,6 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1417
1447
|
const rows = this.exec.all("SELECT * FROM associations WHERE record_id = ?", [recordId]);
|
|
1418
1448
|
return rows.map(rowToAssociation);
|
|
1419
1449
|
}
|
|
1420
|
-
/**
|
|
1421
|
-
* The fields content_index covers for typeId, from the in-memory cache
|
|
1422
|
-
* — falling back to a `types` lookup (and populating the cache) only for
|
|
1423
|
-
* a typeId this process hasn't seen via saveType() yet, e.g. right after
|
|
1424
|
-
* open().
|
|
1425
|
-
*/
|
|
1426
|
-
getIndexedFields(typeId) {
|
|
1427
|
-
const cached = this.indexedFieldsByType.get(typeId);
|
|
1428
|
-
if (cached)
|
|
1429
|
-
return cached;
|
|
1430
|
-
const typeRow = this.exec.get("SELECT schema FROM types WHERE id = ?", [
|
|
1431
|
-
typeId
|
|
1432
|
-
]);
|
|
1433
|
-
const fields = typeRow ? indexedFieldKinds(JSON.parse(typeRow.schema)) : /* @__PURE__ */ new Map();
|
|
1434
|
-
this.indexedFieldsByType.set(typeId, fields);
|
|
1435
|
-
return fields;
|
|
1436
|
-
}
|
|
1437
|
-
/**
|
|
1438
|
-
* Replace a record's content_index rows with what its content currently
|
|
1439
|
-
* holds, on every write that can change content or typeId, so neither
|
|
1440
|
-
* the ordering nor the file-reference lookup can drift from the record.
|
|
1441
|
-
* A field holding no orderable value gets no row at all, which is what
|
|
1442
|
-
* puts the record at the end of a sort on that field.
|
|
1443
|
-
*/
|
|
1444
|
-
syncContentIndex(recordId, typeId, content) {
|
|
1445
|
-
this.exec.run("DELETE FROM content_index WHERE record_id = ?", [recordId]);
|
|
1446
|
-
for (const [field, kind] of this.getIndexedFields(typeId)) {
|
|
1447
|
-
const entry = contentSortEntry(kind, content[field]);
|
|
1448
|
-
if (!entry)
|
|
1449
|
-
continue;
|
|
1450
|
-
const sql = `INSERT OR IGNORE INTO content_index
|
|
1451
|
-
(record_id, field, value_rank, num_value, text_key, text_value, file_id)
|
|
1452
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)`;
|
|
1453
|
-
if (entry.kind === "num") {
|
|
1454
|
-
this.exec.run(sql, [recordId, field, 0, entry.num, null, null, null]);
|
|
1455
|
-
} else {
|
|
1456
|
-
const fileId = kind === "file-ref" ? entry.text : null;
|
|
1457
|
-
this.exec.run(sql, [recordId, field, 1, null, entry.key, entry.text, fileId]);
|
|
1458
|
-
}
|
|
1459
|
-
}
|
|
1460
|
-
}
|
|
1461
|
-
/** The declared kind of a record's top-level `field`, if it has one. */
|
|
1462
|
-
sortFieldKind(record, field) {
|
|
1463
|
-
return this.getIndexedFields(record.typeId).get(field);
|
|
1464
|
-
}
|
|
1465
1450
|
};
|
|
1466
1451
|
|
|
1467
1452
|
// ../sqlite-shared/dist/record-adapter.js
|
|
@@ -1469,11 +1454,15 @@ var SQLITE_RECORD_CAPABILITIES = {
|
|
|
1469
1454
|
filter: {
|
|
1470
1455
|
content: "path",
|
|
1471
1456
|
contentPresent: true,
|
|
1457
|
+
// Inside SQLite's limits on expression depth and compound selects; see
|
|
1458
|
+
// docs/spec/adapters.md § Adapter capabilities.
|
|
1459
|
+
contentByType: 64,
|
|
1472
1460
|
search: true
|
|
1473
1461
|
},
|
|
1474
1462
|
sort: {
|
|
1475
1463
|
fields: ["createdAt", "updatedAt", "version"],
|
|
1476
|
-
contentField: true
|
|
1464
|
+
contentField: true,
|
|
1465
|
+
contentFieldByType: 64
|
|
1477
1466
|
},
|
|
1478
1467
|
limits: {
|
|
1479
1468
|
attachmentBytes: null,
|