@haverstack/record-adapter-do-sqlite 0.26.0 → 0.28.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 +21 -34
- package/dist/index.js +839 -734
- 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, StackVersionConflictError, StackBadRequestError, NATIVE_SORT_FIELDS } from '@haverstack/core';
|
|
2
|
+
import { OwnerMismatchError, softDeletedRefusal, sameAssociationSet, ElementSetMovedError, contentSortEntry, constantSortEntry, contentSortKey, parseContentFilterKey } from '@haverstack/core/adapter';
|
|
3
3
|
|
|
4
4
|
// ../sqlite-shared/dist/schema.js
|
|
5
5
|
var RECORD_SCHEMA_SQL = `
|
|
@@ -20,41 +20,43 @@ var RECORD_SCHEMA_SQL = `
|
|
|
20
20
|
unlisted_at INTEGER
|
|
21
21
|
) STRICT;
|
|
22
22
|
|
|
23
|
-
--
|
|
24
|
-
--
|
|
25
|
-
--
|
|
26
|
-
-- and associations fields are projections over it, partitioned by
|
|
27
|
-
-- kind. See docs/spec/access-control.md \xA7 Record-level permissions.
|
|
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.
|
|
23
|
+
-- A record's data associations: tags, attachments and relationships.
|
|
24
|
+
-- Its permissions are in their own table below, so no predicate over this
|
|
25
|
+
-- one can match a grantee as a relationship target.
|
|
46
26
|
CREATE TABLE IF NOT EXISTS associations (
|
|
47
27
|
record_id TEXT NOT NULL REFERENCES records(id),
|
|
48
|
-
kind TEXT NOT NULL CHECK (kind IN ('tag', 'attachment', 'relationship'
|
|
28
|
+
kind TEXT NOT NULL CHECK (kind IN ('tag', 'attachment', 'relationship')),
|
|
49
29
|
label TEXT NOT NULL,
|
|
50
30
|
file_id TEXT NOT NULL DEFAULT '',
|
|
51
|
-
|
|
31
|
+
-- A relationship target is (related_scope, related_id) qualified by
|
|
32
|
+
-- related_ns (a foreign namespace) or related_stack (another stack).
|
|
33
|
+
-- All are in the key, so targets differing only by a qualifier are
|
|
34
|
+
-- distinct associations.
|
|
35
|
+
related_scope TEXT NOT NULL DEFAULT '' CHECK (related_scope IN ('', 'record', 'entity', 'external')),
|
|
52
36
|
related_id TEXT NOT NULL DEFAULT '',
|
|
53
37
|
related_ns TEXT NOT NULL DEFAULT '',
|
|
54
38
|
related_stack TEXT NOT NULL DEFAULT '',
|
|
55
|
-
|
|
39
|
+
-- Out of the key: it annotates a reference rather than naming one, so
|
|
40
|
+
-- re-pointing it updates the existing row. See docs/spec/attachments.md
|
|
41
|
+
-- \xA7 Naming the upload a reference came from.
|
|
56
42
|
attachment_record_id TEXT NOT NULL DEFAULT '',
|
|
57
|
-
|
|
43
|
+
-- Leads with record_id, so its implicit index serves every by-record
|
|
44
|
+
-- read and delete; the indexes below cover only the filter directions.
|
|
45
|
+
PRIMARY KEY (record_id, kind, label, file_id, related_scope, related_id, related_ns, related_stack)
|
|
46
|
+
) STRICT;
|
|
47
|
+
|
|
48
|
+
-- Who reaches a record, one row per element. An 'anyone' row names no
|
|
49
|
+
-- grantee; a group grantee's role is in the key, because member and
|
|
50
|
+
-- admin name two different sets of people.
|
|
51
|
+
-- See docs/spec/access-control.md \xA7 Record-level permissions.
|
|
52
|
+
CREATE TABLE IF NOT EXISTS permissions (
|
|
53
|
+
record_id TEXT NOT NULL REFERENCES records(id),
|
|
54
|
+
kind TEXT NOT NULL CHECK (kind IN ('permission', 'anyone')),
|
|
55
|
+
label TEXT NOT NULL CHECK (label IN ('read', 'write')),
|
|
56
|
+
grantee_kind TEXT NOT NULL DEFAULT '' CHECK (grantee_kind IN ('', 'entity', 'group')),
|
|
57
|
+
grantee_id TEXT NOT NULL DEFAULT '',
|
|
58
|
+
role TEXT NOT NULL DEFAULT '' CHECK (role IN ('', 'member', 'admin')),
|
|
59
|
+
PRIMARY KEY (record_id, kind, label, grantee_kind, grantee_id, role)
|
|
58
60
|
) STRICT;
|
|
59
61
|
|
|
60
62
|
-- Content and the type it is read under. No parent_id, unlisted_at or
|
|
@@ -116,7 +118,6 @@ var RECORD_SCHEMA_SQL = `
|
|
|
116
118
|
|
|
117
119
|
-- One row per top-level scalar content field a record holds a value at:
|
|
118
120
|
-- what it orders as, and the file it references. See
|
|
119
|
-
-- docs/spec/adapters.md \xA7 Adapter backends and
|
|
120
121
|
-- docs/spec/data-model.md \xA7 Sorting by a content field.
|
|
121
122
|
--
|
|
122
123
|
-- value_rank is 0 for a numeric value and 1 for a text one \u2014 the same
|
|
@@ -133,14 +134,11 @@ var RECORD_SCHEMA_SQL = `
|
|
|
133
134
|
PRIMARY KEY (record_id, field)
|
|
134
135
|
) STRICT;
|
|
135
136
|
|
|
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.
|
|
137
|
+
-- Each filter column continues into (created_at, id), the default order
|
|
138
|
+
-- and its tiebreak: these columns are low-cardinality, and a narrow index
|
|
139
|
+
-- leaves the planner sorting every match before it can honor LIMIT.
|
|
140
|
+
-- deleted_at and unlisted_at get none: an index on a column every query
|
|
141
|
+
-- tests IS NULL only lures the planner off the one answering ORDER BY.
|
|
144
142
|
CREATE INDEX IF NOT EXISTS idx_records_type_id ON records(type_id, created_at, id);
|
|
145
143
|
CREATE INDEX IF NOT EXISTS idx_records_parent_id ON records(parent_id, created_at, id);
|
|
146
144
|
CREATE INDEX IF NOT EXISTS idx_records_entity_id ON records(entity_id, created_at, id);
|
|
@@ -160,6 +158,11 @@ var RECORD_SCHEMA_SQL = `
|
|
|
160
158
|
CREATE INDEX IF NOT EXISTS idx_assoc_related ON associations(kind, related_scope, related_ns, related_id, record_id);
|
|
161
159
|
CREATE INDEX IF NOT EXISTS idx_types_base_id ON types(base_id, version);
|
|
162
160
|
|
|
161
|
+
-- The permissions key with record_id moved last, so a scoped query reads
|
|
162
|
+
-- the records carrying an element straight out of the index.
|
|
163
|
+
CREATE INDEX IF NOT EXISTS idx_permissions_element
|
|
164
|
+
ON permissions(kind, label, grantee_kind, grantee_id, role, record_id);
|
|
165
|
+
|
|
163
166
|
-- The whole content ordering, in index order, for one field at a time:
|
|
164
167
|
-- a content-sorted page is an ordered walk of a slice of this index.
|
|
165
168
|
CREATE INDEX IF NOT EXISTS idx_content_index_sort
|
|
@@ -181,71 +184,6 @@ var applyRecordSchema = (exec, opts) => {
|
|
|
181
184
|
exec.exec(RECORD_SCHEMA_SQL);
|
|
182
185
|
exec.exec(FTS5_SCHEMA_SQL);
|
|
183
186
|
};
|
|
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
187
|
|
|
250
188
|
// ../sqlite-shared/dist/fts5.js
|
|
251
189
|
var OUTSIDE_PHRASE_DISALLOWED = /[^\p{L}\p{N}\p{M}_\s()"]/gu;
|
|
@@ -259,7 +197,7 @@ var sanitizeFts5Query = (query, maxDepth = 2) => {
|
|
|
259
197
|
clean = clean.replace(/\bNEAR\s*\(/gi, "(");
|
|
260
198
|
clean = clean.replace(/"[^"]*"|[^"]+/g, (span) => span.startsWith('"') ? span : span.replace(OUTSIDE_PHRASE_DISALLOWED, " "));
|
|
261
199
|
clean = collapseWhitespace(clean);
|
|
262
|
-
clean = clean.replace(/(
|
|
200
|
+
clean = clean.replace(/"[^"]*"?|(^|\(\s*)NOT\s+/gi, (m, lead) => lead ?? m);
|
|
263
201
|
let currentDepth = 0;
|
|
264
202
|
let inPhrase = false;
|
|
265
203
|
let result = "";
|
|
@@ -326,61 +264,277 @@ var restoreImplicitAnd = (query) => withPhrasesMasked(query, (masked) => masked.
|
|
|
326
264
|
const next = /^\s*([\p{L}\p{N}_]+)/u.exec(whole.slice(offset + match.length));
|
|
327
265
|
return next && isOperator(next[1]) ? match : ") AND ";
|
|
328
266
|
}).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
|
-
|
|
267
|
+
|
|
268
|
+
// ../sqlite-shared/dist/executor.js
|
|
269
|
+
var isForeignKeyViolation = (err) => err instanceof Error && err.message.includes("FOREIGN KEY constraint failed");
|
|
270
|
+
var isUniqueConstraintViolation = (err) => err instanceof Error && err.message.includes("UNIQUE constraint failed");
|
|
271
|
+
|
|
272
|
+
// ../sqlite-shared/dist/config.js
|
|
273
|
+
var CONFIG_ROW_SQL = `SELECT content FROM records WHERE id = '_config'`;
|
|
274
|
+
var insertConfigRecord = (exec, entityId, timezone) => {
|
|
275
|
+
const now = Date.now();
|
|
276
|
+
exec.run(`INSERT INTO records (id, type_id, created_at, updated_at, content, version)
|
|
277
|
+
VALUES ('_config', '_config@1', ?, ?, ?, 1)`, [now, now, JSON.stringify({ entityId, timezone })]);
|
|
278
|
+
return { entityId, timezone };
|
|
279
|
+
};
|
|
280
|
+
var tryReadStackConfig = (exec) => {
|
|
281
|
+
const row = exec.get(CONFIG_ROW_SQL);
|
|
282
|
+
if (!row)
|
|
283
|
+
return null;
|
|
284
|
+
const content = JSON.parse(row.content);
|
|
285
|
+
return { entityId: content.entityId, timezone: content.timezone };
|
|
286
|
+
};
|
|
287
|
+
var toBase64 = (text) => {
|
|
288
|
+
const bytes = new TextEncoder().encode(text);
|
|
289
|
+
let binary = "";
|
|
290
|
+
for (const byte of bytes)
|
|
291
|
+
binary += String.fromCharCode(byte);
|
|
292
|
+
return btoa(binary);
|
|
293
|
+
};
|
|
294
|
+
var fromBase64 = (cursor) => {
|
|
295
|
+
const binary = atob(cursor);
|
|
296
|
+
const bytes = new Uint8Array(binary.length);
|
|
297
|
+
for (let i = 0; i < binary.length; i++)
|
|
298
|
+
bytes[i] = binary.charCodeAt(i);
|
|
299
|
+
return new TextDecoder().decode(bytes);
|
|
300
|
+
};
|
|
301
|
+
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 }));
|
|
302
|
+
var decodeCursor = (cursor) => {
|
|
303
|
+
const malformed = () => new StackBadRequestError(`Invalid cursor: malformed "${cursor}"`);
|
|
304
|
+
let parsed;
|
|
305
|
+
try {
|
|
306
|
+
parsed = JSON.parse(fromBase64(cursor));
|
|
307
|
+
} catch {
|
|
308
|
+
throw malformed();
|
|
309
|
+
}
|
|
310
|
+
if (!parsed || typeof parsed !== "object")
|
|
311
|
+
throw malformed();
|
|
312
|
+
const { k, f, i, v } = parsed;
|
|
313
|
+
if (typeof f !== "string" || !f || typeof i !== "string")
|
|
314
|
+
throw malformed();
|
|
315
|
+
if (k === "absent")
|
|
316
|
+
return { kind: "absent", field: f, id: i };
|
|
317
|
+
if (k === "text") {
|
|
318
|
+
if (typeof v !== "string")
|
|
319
|
+
throw malformed();
|
|
320
|
+
return { kind: "text", field: f, value: v, id: i };
|
|
321
|
+
}
|
|
322
|
+
if (k === "num" || k === "native") {
|
|
323
|
+
if (typeof v !== "number" || !isFinite(v)) {
|
|
324
|
+
throw new StackBadRequestError(`Invalid cursor: non-numeric sort value`);
|
|
325
|
+
}
|
|
326
|
+
if (k === "native") {
|
|
327
|
+
if (!NATIVE_SORT_FIELDS.includes(f)) {
|
|
328
|
+
throw new StackBadRequestError(`Invalid cursor: unknown sort field "${f}"`);
|
|
329
|
+
}
|
|
330
|
+
return { kind: "native", field: f, value: v, id: i };
|
|
331
|
+
}
|
|
332
|
+
return { kind: "num", field: f, value: v, id: i };
|
|
333
|
+
}
|
|
334
|
+
throw malformed();
|
|
335
|
+
};
|
|
336
|
+
var byTypeDescriptor = (byType) => JSON.stringify(Object.entries(byType).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
|
|
337
|
+
var getSortColumn = (field) => field === "createdAt" ? "created_at" : field === "updatedAt" ? "updated_at" : "version";
|
|
338
|
+
var makeCursor = (record, sort, kindOf2) => {
|
|
339
|
+
const byType = sort.contentFieldByType;
|
|
340
|
+
if (byType) {
|
|
341
|
+
const field2 = byTypeDescriptor(byType);
|
|
342
|
+
const key = Object.hasOwn(byType, record.typeId) ? byType[record.typeId] : void 0;
|
|
343
|
+
const kind2 = key?.field === void 0 ? void 0 : kindOf2?.(record, key.field);
|
|
344
|
+
const entry2 = key?.field !== void 0 ? kind2 && contentSortEntry(kind2, record.content[key.field]) : key && constantSortEntry(key.value ?? null);
|
|
345
|
+
return encodeEntry(field2, entry2 || null, record.id);
|
|
346
|
+
}
|
|
347
|
+
const { contentField, field } = sort;
|
|
348
|
+
if (field !== void 0) {
|
|
349
|
+
const value = field === "updatedAt" ? record.updatedAt.getTime() : field === "version" ? record.version : record.createdAt.getTime();
|
|
350
|
+
return encodeCursor({ kind: "native", field, value, id: record.id });
|
|
349
351
|
}
|
|
352
|
+
const kind = kindOf2?.(record, contentField);
|
|
353
|
+
const entry = kind ? contentSortEntry(kind, record.content[contentField]) : null;
|
|
354
|
+
return encodeEntry(contentField, entry, record.id);
|
|
355
|
+
};
|
|
356
|
+
var encodeEntry = (field, entry, id) => {
|
|
357
|
+
if (!entry)
|
|
358
|
+
return encodeCursor({ kind: "absent", field, id });
|
|
359
|
+
return entry.kind === "num" ? encodeCursor({ kind: "num", field, value: entry.num, id }) : encodeCursor({ kind: "text", field, value: entry.text, id });
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
// ../sqlite-shared/dist/mappers.js
|
|
363
|
+
var toMs = (d) => d.getTime();
|
|
364
|
+
var actorFromColumns = (subject, principal) => subject == null ? void 0 : {
|
|
365
|
+
subjectId: subject,
|
|
366
|
+
...principal != null && { principalId: principal }
|
|
367
|
+
};
|
|
368
|
+
var fromMs = (ms) => new Date(ms);
|
|
369
|
+
var rowToRecord = (row, associations, permissions) => {
|
|
370
|
+
const record = {
|
|
371
|
+
id: row.id,
|
|
372
|
+
typeId: row.type_id,
|
|
373
|
+
createdAt: fromMs(row.created_at),
|
|
374
|
+
updatedAt: fromMs(row.updated_at),
|
|
375
|
+
content: JSON.parse(row.content),
|
|
376
|
+
version: row.version
|
|
377
|
+
};
|
|
378
|
+
if (row.parent_id != null)
|
|
379
|
+
record.parentId = row.parent_id;
|
|
380
|
+
if (row.app_id != null)
|
|
381
|
+
record.appId = row.app_id;
|
|
382
|
+
const createdBy = actorFromColumns(row.entity_id, row.principal_id);
|
|
383
|
+
if (createdBy)
|
|
384
|
+
record.createdBy = createdBy;
|
|
385
|
+
const updatedBy = actorFromColumns(row.updated_by, row.updated_via);
|
|
386
|
+
if (updatedBy)
|
|
387
|
+
record.updatedBy = updatedBy;
|
|
388
|
+
if (row.deleted_at != null)
|
|
389
|
+
record.deletedAt = fromMs(row.deleted_at);
|
|
390
|
+
if (row.unlisted_at != null)
|
|
391
|
+
record.unlistedAt = fromMs(row.unlisted_at);
|
|
392
|
+
if (associations.length)
|
|
393
|
+
record.associations = associations;
|
|
394
|
+
if (permissions.length)
|
|
395
|
+
record.permissions = permissions;
|
|
396
|
+
return record;
|
|
397
|
+
};
|
|
398
|
+
var rowToAssociation = (row) => {
|
|
399
|
+
if (row.kind === "tag") {
|
|
400
|
+
return { kind: "tag", label: row.label };
|
|
401
|
+
}
|
|
402
|
+
if (row.kind === "attachment") {
|
|
403
|
+
const attachmentRecordId = row.attachment_record_id;
|
|
404
|
+
return {
|
|
405
|
+
kind: "attachment",
|
|
406
|
+
label: row.label,
|
|
407
|
+
fileId: row.file_id,
|
|
408
|
+
// Stored as '' when absent, like every other optional column here —
|
|
409
|
+
// the field is omitted rather than served empty, so a round trip
|
|
410
|
+
// produces the association the caller wrote.
|
|
411
|
+
...attachmentRecordId && { attachmentRecordId }
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
return {
|
|
415
|
+
kind: "relationship",
|
|
416
|
+
label: row.label,
|
|
417
|
+
target: rowToTarget(row)
|
|
418
|
+
};
|
|
419
|
+
};
|
|
420
|
+
var associationKeyColumns = (a) => {
|
|
421
|
+
if (a.kind === "attachment")
|
|
422
|
+
return [a.fileId, "", "", "", ""];
|
|
423
|
+
if (a.kind !== "relationship")
|
|
424
|
+
return ["", "", "", "", ""];
|
|
425
|
+
const t = a.target;
|
|
426
|
+
if (t.kind === "entity")
|
|
427
|
+
return ["", "entity", t.entityId, "", ""];
|
|
428
|
+
if (t.kind === "external")
|
|
429
|
+
return ["", "external", t.id, t.ns, ""];
|
|
430
|
+
return ["", "record", t.recordId, "", t.stackUrl ?? ""];
|
|
431
|
+
};
|
|
432
|
+
var permissionKeyColumns = (p) => {
|
|
433
|
+
if (p.kind === "anyone")
|
|
434
|
+
return ["", "", ""];
|
|
435
|
+
const g = p.grantee;
|
|
436
|
+
return g.kind === "entity" ? ["entity", g.entityId, ""] : ["group", g.groupId, g.role];
|
|
437
|
+
};
|
|
438
|
+
var rowToPermission = (row) => {
|
|
439
|
+
if (row.kind === "anyone")
|
|
440
|
+
return { kind: "anyone", label: "read" };
|
|
441
|
+
const id = row.grantee_id;
|
|
442
|
+
return {
|
|
443
|
+
kind: "permission",
|
|
444
|
+
label: row.label,
|
|
445
|
+
grantee: row.grantee_kind === "group" ? { kind: "group", groupId: id, role: row.role } : { kind: "entity", entityId: id }
|
|
446
|
+
};
|
|
447
|
+
};
|
|
448
|
+
var rowToTarget = (row) => {
|
|
449
|
+
const id = row.related_id;
|
|
450
|
+
if (row.related_scope === "entity")
|
|
451
|
+
return { kind: "entity", entityId: id };
|
|
452
|
+
if (row.related_scope === "external") {
|
|
453
|
+
return { kind: "external", ns: row.related_ns, id };
|
|
454
|
+
}
|
|
455
|
+
const stackUrl = row.related_stack;
|
|
456
|
+
return { kind: "record", recordId: id, ...stackUrl && { stackUrl } };
|
|
457
|
+
};
|
|
458
|
+
var rowToType = (row) => {
|
|
459
|
+
const t = {
|
|
460
|
+
id: row.id,
|
|
461
|
+
baseId: row.base_id,
|
|
462
|
+
version: row.version,
|
|
463
|
+
name: row.name,
|
|
464
|
+
schema: JSON.parse(row.schema),
|
|
465
|
+
schemaHash: row.schema_hash,
|
|
466
|
+
createdAt: fromMs(row.created_at)
|
|
467
|
+
};
|
|
468
|
+
if (row.migrates_from)
|
|
469
|
+
t.migratesFrom = row.migrates_from;
|
|
470
|
+
return t;
|
|
471
|
+
};
|
|
472
|
+
var rowToVersion = (row) => {
|
|
473
|
+
const v = {
|
|
474
|
+
version: row.version,
|
|
475
|
+
typeId: row.type_id,
|
|
476
|
+
content: JSON.parse(row.content),
|
|
477
|
+
updatedAt: fromMs(row.updated_at)
|
|
478
|
+
};
|
|
479
|
+
const createdBy = actorFromColumns(row.entity_id, row.principal_id);
|
|
480
|
+
if (createdBy)
|
|
481
|
+
v.createdBy = createdBy;
|
|
482
|
+
const updatedBy = actorFromColumns(row.updated_by, row.updated_via);
|
|
483
|
+
if (updatedBy)
|
|
484
|
+
v.updatedBy = updatedBy;
|
|
485
|
+
return v;
|
|
486
|
+
};
|
|
487
|
+
var rowToJournalEntry = (row) => {
|
|
488
|
+
const e = {
|
|
489
|
+
seq: row.seq,
|
|
490
|
+
at: fromMs(row.at),
|
|
491
|
+
kind: row.kind,
|
|
492
|
+
ops: JSON.parse(row.ops),
|
|
493
|
+
version: row.version,
|
|
494
|
+
typeId: row.type_id
|
|
495
|
+
};
|
|
496
|
+
if (row.parent_id != null)
|
|
497
|
+
e.parentId = row.parent_id;
|
|
498
|
+
if (row.previous_parent_id != null) {
|
|
499
|
+
e.previousParentId = row.previous_parent_id === "" ? null : row.previous_parent_id;
|
|
500
|
+
}
|
|
501
|
+
if (row.actor != null)
|
|
502
|
+
e.actor = JSON.parse(row.actor);
|
|
503
|
+
if (row.associations != null)
|
|
504
|
+
e.associations = JSON.parse(row.associations);
|
|
505
|
+
return e;
|
|
350
506
|
};
|
|
351
507
|
|
|
352
508
|
// ../sqlite-shared/dist/query.js
|
|
353
|
-
var atBudget = (page,
|
|
509
|
+
var atBudget = (page, rows2) => ({
|
|
354
510
|
sql: page.sql,
|
|
355
|
-
params: [...page.params,
|
|
511
|
+
params: [...page.params, rows2]
|
|
356
512
|
});
|
|
357
|
-
var sqlDirection = (query) =>
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
513
|
+
var sqlDirection = (query) => query.sort.direction === "asc" ? "ASC" : "DESC";
|
|
514
|
+
var spread = (alias) => `CASE ${alias}.type WHEN 'array' THEN ${alias}.value WHEN 'object' THEN json_array(json(${alias}.value)) WHEN 'true' THEN '[true]' WHEN 'false' THEN '[false]' ELSE json_array(${alias}.value) END`;
|
|
515
|
+
var members = (alias) => `CASE WHEN ${alias}.t = 'object' THEN ${alias}.v ELSE '{}' END`;
|
|
516
|
+
var emptyStatement = () => ({
|
|
517
|
+
tables: [],
|
|
518
|
+
tableParams: [],
|
|
519
|
+
conditions: ["r.id != '_config'"],
|
|
520
|
+
params: []
|
|
521
|
+
});
|
|
522
|
+
var rows = (s, columns, tuples) => {
|
|
523
|
+
const name = `t${s.tables.length}`;
|
|
524
|
+
s.tables.push(`${name} AS MATERIALIZED ${rowsTable(columns)}`);
|
|
525
|
+
s.tableParams.push(JSON.stringify(tuples));
|
|
526
|
+
return name;
|
|
363
527
|
};
|
|
364
|
-
var
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
key = nextAlias();
|
|
374
|
-
from.push(`json_each(${members(value)}) AS ${key}`);
|
|
375
|
-
value = nextAlias();
|
|
376
|
-
from.push(`json_each(${spread(key)}) AS ${value}`);
|
|
377
|
-
where.push(`${key}.key = ?`);
|
|
378
|
-
params.push(segment);
|
|
379
|
-
}
|
|
380
|
-
where.push(leaf(value));
|
|
381
|
-
return `EXISTS (SELECT 1 FROM ${from.join(", ")} WHERE ${where.join(" AND ")})`;
|
|
528
|
+
var rowsTable = (columns) => {
|
|
529
|
+
const named = columns.map((column, i) => `json_extract(value, '$[${i}]') AS ${column}`);
|
|
530
|
+
return `(SELECT ${named.join(", ")} FROM json_each(?))`;
|
|
531
|
+
};
|
|
532
|
+
var CONDITION_COLUMNS = ["scope", "path", "op", "val", "kind"];
|
|
533
|
+
var kindOf = (type) => `CASE ${type} WHEN 'integer' THEN 'number' WHEN 'real' THEN 'number' ELSE ${type} END`;
|
|
534
|
+
var holds = (c, r) => {
|
|
535
|
+
const depth = `json_array_length(${c}.path)`;
|
|
536
|
+
return `(WITH RECURSIVE w(d, v, t) AS (SELECT 0, ${r}.content, 'object' UNION ALL SELECT w.d + 1, e.value, e.type FROM w JOIN json_each(${members("w")}) m JOIN json_each(${spread("m")}) e WHERE w.d < ${depth} AND m.key = json_extract(${c}.path, '$[' || w.d || ']')) SELECT CASE ${c}.op WHEN 'equals' THEN IFNULL(MAX(${kindOf("w.t")} = ${c}.kind AND w.v = ${c}.val), 0) WHEN 'present' THEN IFNULL(MAX(w.t != 'null'), 0) WHEN 'isNull' THEN IFNULL(MAX(w.t = 'null'), 0) OR NOT IFNULL(MAX(w.t != 'null'), 0) END FROM w WHERE w.d = ${depth})`;
|
|
382
537
|
};
|
|
383
|
-
var equalsLeaf = (alias) => `${alias}.type NOT IN ('object', 'array') AND ${alias}.value = ?`;
|
|
384
538
|
var SORT_ALIAS = "cs";
|
|
385
539
|
var cursorMismatch = (cursor, field) => new StackBadRequestError(`Cursor sort field "${cursor.field}" does not match query sort field "${field}"`);
|
|
386
540
|
var nativeCursor = (cursor, field) => {
|
|
@@ -393,16 +547,77 @@ var contentCursor = (cursor, field) => {
|
|
|
393
547
|
throw cursorMismatch(cursor, field);
|
|
394
548
|
return cursor;
|
|
395
549
|
};
|
|
396
|
-
var
|
|
397
|
-
|
|
398
|
-
|
|
550
|
+
var valueKind = (value) => {
|
|
551
|
+
if (typeof value === "boolean")
|
|
552
|
+
return `${value}`;
|
|
553
|
+
if (typeof value === "number")
|
|
554
|
+
return "number";
|
|
555
|
+
return typeof value === "string" ? "text" : null;
|
|
556
|
+
};
|
|
557
|
+
var contentRows = (scope, predicate) => {
|
|
558
|
+
const row = (key, op, value = null) => {
|
|
559
|
+
const path = parseContentFilterKey(key);
|
|
560
|
+
return [scope, path, op, value, valueKind(value)];
|
|
561
|
+
};
|
|
562
|
+
return [
|
|
563
|
+
...Object.entries(predicate.content ?? {}).map(([key, value]) => (
|
|
564
|
+
// undefined is the same absence null names — JSON drops it on the way
|
|
565
|
+
// over the wire, so treating it as a value would make an in-process
|
|
566
|
+
// query and its wire equivalent disagree.
|
|
567
|
+
value === null || value === void 0 ? row(key, "isNull") : row(key, "equals", value)
|
|
568
|
+
)),
|
|
569
|
+
...(predicate.contentPresent ?? []).map((key) => row(key, "present"))
|
|
570
|
+
];
|
|
571
|
+
};
|
|
572
|
+
var inList = (s, column, values) => {
|
|
573
|
+
if (values.length === 1) {
|
|
574
|
+
s.params.push(values[0]);
|
|
575
|
+
return `${column} = ?`;
|
|
576
|
+
}
|
|
577
|
+
const table = rows(s, ["value"], values.map((value) => [value]));
|
|
578
|
+
return `${column} IN (SELECT value FROM ${table})`;
|
|
579
|
+
};
|
|
580
|
+
var familyRange = (baseId) => [`${baseId}@`, `${baseId}A`];
|
|
581
|
+
var withinFamily = (column, lo, hi) => `(${column} >= ${lo} AND ${column} < ${hi})`;
|
|
582
|
+
var inFamily = (column, baseId) => ({
|
|
583
|
+
sql: withinFamily(column, "?", "?"),
|
|
584
|
+
params: familyRange(baseId)
|
|
585
|
+
});
|
|
586
|
+
var inFamilies = (s, record, baseIds) => {
|
|
587
|
+
if (baseIds.length === 1) {
|
|
588
|
+
const one = inFamily(`${record}.type_id`, baseIds[0]);
|
|
589
|
+
s.params.push(...one.params);
|
|
590
|
+
return one.sql;
|
|
591
|
+
}
|
|
592
|
+
const table = rows(s, ["lo", "hi"], baseIds.map(familyRange));
|
|
593
|
+
return `${record}.id IN (SELECT fr.id FROM ${table} f JOIN records fr ON ${withinFamily("fr.type_id", "f.lo", "f.hi")})`;
|
|
594
|
+
};
|
|
595
|
+
var carriesPermissions = (s, sets) => {
|
|
596
|
+
const elements = rows(s, ["set_index", "set_size", "kind", "label", "grantee_kind", "grantee_id", "role"], sets.flatMap((set, i) => set.map((p) => [i, set.length, p.kind, p.label, ...permissionKeyColumns(p)])));
|
|
597
|
+
return `r.id IN (SELECT p.record_id FROM ${elements} e JOIN permissions p ON p.kind = e.kind AND p.label = e.label AND p.grantee_kind = e.grantee_kind AND p.grantee_id = e.grantee_id AND p.role = e.role GROUP BY p.record_id, e.set_index HAVING COUNT(*) = MAX(e.set_size))`;
|
|
598
|
+
};
|
|
599
|
+
var reachConditions = (s, reach) => {
|
|
600
|
+
if (reach.fence)
|
|
601
|
+
s.conditions.push(inFamilies(s, "r", reach.fence));
|
|
602
|
+
const within = reach.within;
|
|
603
|
+
if (within) {
|
|
604
|
+
const arms = [inFamilies(s, "r", within.baseIds)];
|
|
605
|
+
if (within.authored) {
|
|
606
|
+
s.params.push(within.authored.subjectId);
|
|
607
|
+
arms.push(`(r.entity_id = ? AND ${inFamilies(s, "r", within.authored.baseIds)})`);
|
|
608
|
+
}
|
|
609
|
+
arms.push(carriesPermissions(s, within.permissions));
|
|
610
|
+
s.conditions.push(`(${arms.join(" OR ")})`);
|
|
611
|
+
}
|
|
612
|
+
};
|
|
613
|
+
var recordConditions = (query, reach) => {
|
|
614
|
+
const s = emptyStatement();
|
|
615
|
+
const { conditions, params } = s;
|
|
399
616
|
const f = query.filter ?? {};
|
|
400
617
|
const inFilter = (column, value) => {
|
|
401
618
|
if (value === void 0)
|
|
402
619
|
return;
|
|
403
|
-
|
|
404
|
-
conditions.push(`r.${column} IN (${values.map(() => "?").join(",")})`);
|
|
405
|
-
params.push(...values);
|
|
620
|
+
conditions.push(inList(s, `r.${column}`, Array.isArray(value) ? value : [value]));
|
|
406
621
|
};
|
|
407
622
|
const rangeFilter = (column, range) => {
|
|
408
623
|
if (range?.after) {
|
|
@@ -420,7 +635,12 @@ var recordConditions = (query) => {
|
|
|
420
635
|
if (!f.includeUnlisted) {
|
|
421
636
|
conditions.push("r.unlisted_at IS NULL");
|
|
422
637
|
}
|
|
638
|
+
if (reach)
|
|
639
|
+
reachConditions(s, reach);
|
|
423
640
|
inFilter("type_id", f.typeId);
|
|
641
|
+
if (f.baseId !== void 0) {
|
|
642
|
+
conditions.push(inFamilies(s, "r", Array.isArray(f.baseId) ? f.baseId : [f.baseId]));
|
|
643
|
+
}
|
|
424
644
|
if (f.parentId !== void 0) {
|
|
425
645
|
if (f.parentId === null) {
|
|
426
646
|
conditions.push("r.parent_id IS NULL");
|
|
@@ -436,10 +656,10 @@ var recordConditions = (query) => {
|
|
|
436
656
|
rangeFilter("updated_at", f.updatedAt);
|
|
437
657
|
const hasAssociation = (kind, clauses) => `r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = '${kind}'` + clauses.map((c) => ` AND ${c}`).join("") + `)`;
|
|
438
658
|
if (f.tags?.length) {
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
659
|
+
const tags = [...new Set(f.tags)];
|
|
660
|
+
const label = inList(s, "a.label", tags);
|
|
661
|
+
conditions.push(`r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = 'tag' AND ${label} GROUP BY a.record_id HAVING COUNT(DISTINCT a.label) = ?)`);
|
|
662
|
+
params.push(tags.length);
|
|
443
663
|
}
|
|
444
664
|
if (f.attachment) {
|
|
445
665
|
const clauses = [];
|
|
@@ -485,28 +705,17 @@ var recordConditions = (query) => {
|
|
|
485
705
|
}
|
|
486
706
|
conditions.push(hasAssociation("relationship", clauses));
|
|
487
707
|
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
const
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
}
|
|
708
|
+
const content = contentRows(null, f);
|
|
709
|
+
if (f.contentByType) {
|
|
710
|
+
const arms = Object.entries(f.contentByType);
|
|
711
|
+
const typeIds = arms.map(([typeId]) => typeId);
|
|
712
|
+
conditions.push(inList(s, "r.type_id", typeIds));
|
|
713
|
+
for (const [typeId, arm] of arms)
|
|
714
|
+
content.push(...contentRows(typeId, arm));
|
|
503
715
|
}
|
|
504
|
-
if (
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
for (const key of f.contentPresent) {
|
|
508
|
-
conditions.push(contentPathExists(parseContentFilterKey(key), (a) => `${a}.value IS NOT NULL`, params, nextAlias));
|
|
509
|
-
}
|
|
716
|
+
if (content.length > 0) {
|
|
717
|
+
const table = rows(s, CONDITION_COLUMNS, content);
|
|
718
|
+
conditions.push(`NOT EXISTS (SELECT 1 FROM ${table} c WHERE (c.scope IS NULL OR c.scope = r.type_id) AND NOT ${holds("c", "r")})`);
|
|
510
719
|
}
|
|
511
720
|
if (f.search) {
|
|
512
721
|
const sanitized = sanitizeFts5Query(f.search);
|
|
@@ -517,7 +726,7 @@ var recordConditions = (query) => {
|
|
|
517
726
|
conditions.push("0");
|
|
518
727
|
}
|
|
519
728
|
}
|
|
520
|
-
return
|
|
729
|
+
return s;
|
|
521
730
|
};
|
|
522
731
|
var presentCursorCondition = (cursor, op, params) => {
|
|
523
732
|
if (cursor.kind === "num") {
|
|
@@ -528,225 +737,193 @@ var presentCursorCondition = (cursor, op, params) => {
|
|
|
528
737
|
params.push(key, key, cursor.value, cursor.value, cursor.id);
|
|
529
738
|
return `(${SORT_ALIAS}.value_rank ${op} 1 OR (${SORT_ALIAS}.value_rank = 1 AND (${SORT_ALIAS}.text_key ${op} ? OR (${SORT_ALIAS}.text_key = ? AND (${SORT_ALIAS}.text_value ${op} ? OR (${SORT_ALIAS}.text_value = ? AND ${SORT_ALIAS}.record_id ${op} ?))))))`;
|
|
530
739
|
};
|
|
531
|
-
var statement = (
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
}
|
|
535
|
-
|
|
740
|
+
var statement = (s, from, tail) => {
|
|
741
|
+
const select = `SELECT r.* FROM ${from} WHERE ${s.conditions.join(" AND ")}${tail}`;
|
|
742
|
+
const sql = s.tables.length === 0 ? select : `WITH ${s.tables.join(", ")} ${select}`;
|
|
743
|
+
return { sql, params: [...s.tableParams, ...s.params] };
|
|
744
|
+
};
|
|
745
|
+
var buildQueryPlan = (query, reach) => {
|
|
536
746
|
const dir = sqlDirection(query);
|
|
537
747
|
const op = dir === "ASC" ? ">" : "<";
|
|
538
748
|
const decoded = query.cursor ? decodeCursor(query.cursor) : null;
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
749
|
+
if (query.sort.contentFieldByType) {
|
|
750
|
+
return byTypePlan(query, reach, query.sort.contentFieldByType, dir, op, decoded);
|
|
751
|
+
}
|
|
752
|
+
const { contentField, field } = query.sort;
|
|
753
|
+
if (field !== void 0) {
|
|
754
|
+
const where = recordConditions(query, reach);
|
|
755
|
+
const col = getSortColumn(field);
|
|
543
756
|
if (decoded) {
|
|
544
|
-
const cursor2 = nativeCursor(decoded,
|
|
545
|
-
conditions.push(`(r.${col} ${op} ? OR (r.${col} = ? AND r.id ${op} ?))`);
|
|
546
|
-
params.push(cursor2.value, cursor2.value, cursor2.id);
|
|
757
|
+
const cursor2 = nativeCursor(decoded, field);
|
|
758
|
+
where.conditions.push(`(r.${col} ${op} ? OR (r.${col} = ? AND r.id ${op} ?))`);
|
|
759
|
+
where.params.push(cursor2.value, cursor2.value, cursor2.id);
|
|
547
760
|
}
|
|
548
|
-
return [
|
|
549
|
-
statement("r.*", "records r", conditions, params, ` ORDER BY r.${col} ${dir}, r.id ${dir} LIMIT ?`)
|
|
550
|
-
];
|
|
761
|
+
return [statement(where, "records r", ` ORDER BY r.${col} ${dir}, r.id ${dir} LIMIT ?`)];
|
|
551
762
|
}
|
|
552
763
|
const cursor = decoded ? contentCursor(decoded, contentField) : null;
|
|
553
764
|
const pages = [];
|
|
554
765
|
if (cursor?.kind !== "absent") {
|
|
555
|
-
const
|
|
766
|
+
const where = recordConditions(query, reach);
|
|
556
767
|
const from = `content_index ${SORT_ALIAS} JOIN records r ON r.id = ${SORT_ALIAS}.record_id`;
|
|
557
|
-
conditions.push(`${SORT_ALIAS}.field = ?`);
|
|
558
|
-
params.push(contentField);
|
|
768
|
+
where.conditions.push(`${SORT_ALIAS}.field = ?`);
|
|
769
|
+
where.params.push(contentField);
|
|
559
770
|
if (cursor)
|
|
560
|
-
conditions.push(presentCursorCondition(cursor, op, params));
|
|
771
|
+
where.conditions.push(presentCursorCondition(cursor, op, where.params));
|
|
561
772
|
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}`;
|
|
562
|
-
pages.push(statement(
|
|
773
|
+
pages.push(statement(where, from, ` ORDER BY ${order} LIMIT ?`));
|
|
563
774
|
}
|
|
564
775
|
{
|
|
565
|
-
const
|
|
566
|
-
conditions.push(`NOT EXISTS (SELECT 1 FROM content_index ${SORT_ALIAS} WHERE ${SORT_ALIAS}.record_id = r.id AND ${SORT_ALIAS}.field = ?)`);
|
|
567
|
-
params.push(contentField);
|
|
776
|
+
const where = recordConditions(query, reach);
|
|
777
|
+
where.conditions.push(`NOT EXISTS (SELECT 1 FROM content_index ${SORT_ALIAS} WHERE ${SORT_ALIAS}.record_id = r.id AND ${SORT_ALIAS}.field = ?)`);
|
|
778
|
+
where.params.push(contentField);
|
|
568
779
|
if (cursor?.kind === "absent") {
|
|
569
|
-
conditions.push(`r.id ${op} ?`);
|
|
570
|
-
params.push(cursor.id);
|
|
780
|
+
where.conditions.push(`r.id ${op} ?`);
|
|
781
|
+
where.params.push(cursor.id);
|
|
571
782
|
}
|
|
572
|
-
pages.push(statement(
|
|
783
|
+
pages.push(statement(where, "records r", ` ORDER BY r.id ${dir} LIMIT ?`));
|
|
573
784
|
}
|
|
574
785
|
return pages;
|
|
575
786
|
};
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
787
|
+
var byTypePlan = (query, reach, byType, dir, op, decoded) => {
|
|
788
|
+
const cursor = decoded ? contentCursor(decoded, byTypeDescriptor(byType)) : null;
|
|
789
|
+
const fields = [];
|
|
790
|
+
const constants = [];
|
|
791
|
+
for (const [typeId, key] of Object.entries(byType)) {
|
|
792
|
+
if (key.field !== void 0) {
|
|
793
|
+
fields.push([typeId, key.field]);
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
const entry = constantSortEntry(key.value ?? null);
|
|
797
|
+
if (entry)
|
|
798
|
+
constants.push([typeId, entry]);
|
|
799
|
+
}
|
|
800
|
+
const pages = [];
|
|
801
|
+
if (cursor?.kind !== "absent" && fields.length + constants.length > 0) {
|
|
802
|
+
const where = recordConditions(query, reach);
|
|
803
|
+
const sources = [];
|
|
804
|
+
if (fields.length > 0) {
|
|
805
|
+
const byField = rows(where, ["type_id", "field"], fields);
|
|
806
|
+
sources.push(`SELECT ci.record_id AS record_id, ci.value_rank AS value_rank, ci.num_value AS num_value, ci.text_key AS text_key, ci.text_value AS text_value FROM ${byField} f JOIN content_index ci ON ci.field = f.field JOIN records t ON t.id = ci.record_id AND t.type_id = f.type_id`);
|
|
807
|
+
}
|
|
808
|
+
if (constants.length > 0) {
|
|
809
|
+
const byConstant = rows(where, ["type_id", "value_rank", "num_value", "text_key", "text_value"], constants.map(([typeId, entry]) => entry.kind === "num" ? [typeId, 0, entry.num, null, null] : [typeId, 1, null, entry.key, entry.text]));
|
|
810
|
+
sources.push(`SELECT t.id AS record_id, k.value_rank AS value_rank, k.num_value AS num_value, k.text_key AS text_key, k.text_value AS text_value FROM ${byConstant} k JOIN records t ON t.type_id = k.type_id`);
|
|
811
|
+
}
|
|
812
|
+
if (cursor)
|
|
813
|
+
where.conditions.push(presentCursorCondition(cursor, op, where.params));
|
|
814
|
+
const from = `(${sources.join(" UNION ALL ")}) ${SORT_ALIAS} JOIN records r ON r.id = ${SORT_ALIAS}.record_id`;
|
|
815
|
+
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}`;
|
|
816
|
+
pages.push(statement(where, from, ` ORDER BY ${order} LIMIT ?`));
|
|
817
|
+
}
|
|
818
|
+
{
|
|
819
|
+
const where = recordConditions(query, reach);
|
|
820
|
+
if (fields.length > 0) {
|
|
821
|
+
const byField = rows(where, ["type_id", "field"], fields);
|
|
822
|
+
where.conditions.push(`NOT EXISTS (SELECT 1 FROM ${byField} f JOIN content_index ${SORT_ALIAS} ON ${SORT_ALIAS}.record_id = r.id AND ${SORT_ALIAS}.field = f.field WHERE f.type_id = r.type_id)`);
|
|
823
|
+
}
|
|
824
|
+
if (constants.length > 0) {
|
|
825
|
+
const typeIds = constants.map(([typeId]) => typeId);
|
|
826
|
+
where.conditions.push(`NOT (${inList(where, "r.type_id", typeIds)})`);
|
|
827
|
+
}
|
|
828
|
+
if (cursor?.kind === "absent") {
|
|
829
|
+
where.conditions.push(`r.id ${op} ?`);
|
|
830
|
+
where.params.push(cursor.id);
|
|
831
|
+
}
|
|
832
|
+
pages.push(statement(where, "records r", ` ORDER BY r.id ${dir} LIMIT ?`));
|
|
833
|
+
}
|
|
834
|
+
return pages;
|
|
582
835
|
};
|
|
583
|
-
var
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
id
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
if (updatedBy)
|
|
606
|
-
record.updatedBy = updatedBy;
|
|
607
|
-
if (row.deleted_at != null)
|
|
608
|
-
record.deletedAt = fromMs(row.deleted_at);
|
|
609
|
-
if (row.unlisted_at != null)
|
|
610
|
-
record.unlistedAt = fromMs(row.unlisted_at);
|
|
611
|
-
const partitioned = partitionAssociations(associations);
|
|
612
|
-
if (partitioned.associations.length)
|
|
613
|
-
record.associations = partitioned.associations;
|
|
614
|
-
if (partitioned.permissions.length)
|
|
615
|
-
record.permissions = partitioned.permissions;
|
|
616
|
-
return record;
|
|
836
|
+
var SearchIndex = class {
|
|
837
|
+
exec;
|
|
838
|
+
constructor(exec) {
|
|
839
|
+
this.exec = exec;
|
|
840
|
+
}
|
|
841
|
+
/** Index a record that has no FTS entry yet (used right after an INSERT). */
|
|
842
|
+
insert(recordId, content) {
|
|
843
|
+
this.exec.run(`INSERT INTO records_fts(rowid, content) SELECT rowid, ? FROM records WHERE id = ?`, [content, recordId]);
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Remove a record's FTS entry. MUST be called before the records row's
|
|
847
|
+
* content changes or the row is deleted — see the note above.
|
|
848
|
+
*/
|
|
849
|
+
remove(recordId) {
|
|
850
|
+
const row = this.exec.get("SELECT rowid, content FROM records WHERE id = ?", [recordId]);
|
|
851
|
+
if (!row)
|
|
852
|
+
return;
|
|
853
|
+
this.exec.run(`INSERT INTO records_fts(records_fts, rowid, content) VALUES('delete', ?, ?)`, [
|
|
854
|
+
row.rowid,
|
|
855
|
+
row.content
|
|
856
|
+
]);
|
|
857
|
+
}
|
|
617
858
|
};
|
|
618
|
-
var
|
|
619
|
-
|
|
620
|
-
|
|
859
|
+
var indexedFieldKinds = (schema) => {
|
|
860
|
+
const kinds = /* @__PURE__ */ new Map();
|
|
861
|
+
for (const [field, def] of Object.entries(schema)) {
|
|
862
|
+
if (!def.kind || def.kind === "array" || def.kind === "object")
|
|
863
|
+
continue;
|
|
864
|
+
kinds.set(field, def.kind);
|
|
621
865
|
}
|
|
622
|
-
|
|
623
|
-
|
|
866
|
+
return kinds;
|
|
867
|
+
};
|
|
868
|
+
var ContentIndex = class {
|
|
869
|
+
exec;
|
|
870
|
+
fieldsByType = /* @__PURE__ */ new Map();
|
|
871
|
+
constructor(exec) {
|
|
872
|
+
this.exec = exec;
|
|
624
873
|
}
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
label: row.label,
|
|
629
|
-
grantee: rowToGrantee(row)
|
|
630
|
-
};
|
|
874
|
+
/** Prime the cache from a type as it is saved. */
|
|
875
|
+
rememberType(type) {
|
|
876
|
+
this.fieldsByType.set(type.id, indexedFieldKinds(type.schema));
|
|
631
877
|
}
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
878
|
+
/**
|
|
879
|
+
* Replace a record's rows with what its content currently holds, on every
|
|
880
|
+
* write that can change content or typeId, so neither the ordering nor the
|
|
881
|
+
* file-reference lookup can drift from the record. A field holding no
|
|
882
|
+
* orderable value gets no row at all, which is what puts the record at the
|
|
883
|
+
* end of a sort on that field.
|
|
884
|
+
*/
|
|
885
|
+
sync(recordId, typeId, content) {
|
|
886
|
+
this.remove(recordId);
|
|
887
|
+
for (const [field, kind] of this.fieldsOf(typeId)) {
|
|
888
|
+
const entry = contentSortEntry(kind, content[field]);
|
|
889
|
+
if (!entry)
|
|
890
|
+
continue;
|
|
891
|
+
const sql = `INSERT OR IGNORE INTO content_index
|
|
892
|
+
(record_id, field, value_rank, num_value, text_key, text_value, file_id)
|
|
893
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`;
|
|
894
|
+
if (entry.kind === "num") {
|
|
895
|
+
this.exec.run(sql, [recordId, field, 0, entry.num, null, null, null]);
|
|
896
|
+
} else {
|
|
897
|
+
const fileId = kind === "file-ref" ? entry.text : null;
|
|
898
|
+
this.exec.run(sql, [recordId, field, 1, null, entry.key, entry.text, fileId]);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
643
901
|
}
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
label: row.label,
|
|
647
|
-
target: rowToTarget(row)
|
|
648
|
-
};
|
|
649
|
-
};
|
|
650
|
-
var associationKeyColumns = (a) => {
|
|
651
|
-
if (a.kind === "attachment")
|
|
652
|
-
return [a.fileId, "", "", "", "", ""];
|
|
653
|
-
if (a.kind === "permission") {
|
|
654
|
-
const g = a.grantee;
|
|
655
|
-
return g.kind === "entity" ? ["", "entity", g.entityId, "", "", ""] : ["", "group", g.groupId, "", "", g.role];
|
|
902
|
+
remove(recordId) {
|
|
903
|
+
this.exec.run("DELETE FROM content_index WHERE record_id = ?", [recordId]);
|
|
656
904
|
}
|
|
657
|
-
if
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
if (t.kind === "entity")
|
|
661
|
-
return ["", "entity", t.entityId, "", "", ""];
|
|
662
|
-
if (t.kind === "external")
|
|
663
|
-
return ["", "external", t.id, t.ns, "", ""];
|
|
664
|
-
return ["", "record", t.recordId, "", t.stackUrl ?? "", ""];
|
|
665
|
-
};
|
|
666
|
-
var rowToGrantee = (row) => row.related_scope === "group" ? {
|
|
667
|
-
kind: "group",
|
|
668
|
-
groupId: row.related_id,
|
|
669
|
-
role: row.related_role
|
|
670
|
-
} : { kind: "entity", entityId: row.related_id };
|
|
671
|
-
var rowToTarget = (row) => {
|
|
672
|
-
const id = row.related_id;
|
|
673
|
-
if (row.related_scope === "entity")
|
|
674
|
-
return { kind: "entity", entityId: id };
|
|
675
|
-
if (row.related_scope === "external") {
|
|
676
|
-
return { kind: "external", ns: row.related_ns, id };
|
|
905
|
+
/** The declared kind of a type's top-level `field`, if it has one. */
|
|
906
|
+
kindOf(typeId, field) {
|
|
907
|
+
return this.fieldsOf(typeId).get(field);
|
|
677
908
|
}
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
t.migratesFrom = row.migrates_from;
|
|
693
|
-
return t;
|
|
694
|
-
};
|
|
695
|
-
var rowToVersion = (row) => {
|
|
696
|
-
const v = {
|
|
697
|
-
version: row.version,
|
|
698
|
-
typeId: row.type_id,
|
|
699
|
-
content: JSON.parse(row.content),
|
|
700
|
-
updatedAt: fromMs(row.updated_at)
|
|
701
|
-
};
|
|
702
|
-
const createdBy = actorFromColumns(row.entity_id, row.principal_id);
|
|
703
|
-
if (createdBy)
|
|
704
|
-
v.createdBy = createdBy;
|
|
705
|
-
const updatedBy = actorFromColumns(row.updated_by, row.updated_via);
|
|
706
|
-
if (updatedBy)
|
|
707
|
-
v.updatedBy = updatedBy;
|
|
708
|
-
return v;
|
|
709
|
-
};
|
|
710
|
-
var rowToJournalEntry = (row) => {
|
|
711
|
-
const e = {
|
|
712
|
-
seq: row.seq,
|
|
713
|
-
at: fromMs(row.at),
|
|
714
|
-
kind: row.kind,
|
|
715
|
-
ops: JSON.parse(row.ops),
|
|
716
|
-
version: row.version,
|
|
717
|
-
typeId: row.type_id
|
|
718
|
-
};
|
|
719
|
-
if (row.parent_id != null)
|
|
720
|
-
e.parentId = row.parent_id;
|
|
721
|
-
if (row.previous_parent_id != null) {
|
|
722
|
-
e.previousParentId = row.previous_parent_id === "" ? null : row.previous_parent_id;
|
|
909
|
+
/**
|
|
910
|
+
* From the cache, falling back to a `types` lookup only for a typeId this
|
|
911
|
+
* process hasn't seen via rememberType() yet, e.g. right after open().
|
|
912
|
+
*/
|
|
913
|
+
fieldsOf(typeId) {
|
|
914
|
+
const cached = this.fieldsByType.get(typeId);
|
|
915
|
+
if (cached)
|
|
916
|
+
return cached;
|
|
917
|
+
const typeRow = this.exec.get("SELECT schema FROM types WHERE id = ?", [
|
|
918
|
+
typeId
|
|
919
|
+
]);
|
|
920
|
+
const fields = typeRow ? indexedFieldKinds(JSON.parse(typeRow.schema)) : /* @__PURE__ */ new Map();
|
|
921
|
+
this.fieldsByType.set(typeId, fields);
|
|
922
|
+
return fields;
|
|
723
923
|
}
|
|
724
|
-
if (row.actor != null)
|
|
725
|
-
e.actor = JSON.parse(row.actor);
|
|
726
|
-
if (row.associations != null)
|
|
727
|
-
e.associations = JSON.parse(row.associations);
|
|
728
|
-
return e;
|
|
729
924
|
};
|
|
730
925
|
|
|
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
|
-
};
|
|
926
|
+
// ../sqlite-shared/dist/record-logic.js
|
|
750
927
|
var asStackBadRequestError = (query, run) => {
|
|
751
928
|
try {
|
|
752
929
|
return run();
|
|
@@ -757,6 +934,7 @@ var asStackBadRequestError = (query, run) => {
|
|
|
757
934
|
}
|
|
758
935
|
};
|
|
759
936
|
var versionConflict = (id, expected, actual) => new StackVersionConflictError(`Record "${id}" is at version ${actual}, expected ${expected}`, id, expected, actual);
|
|
937
|
+
var recordNotFound = (id) => new StackNotFoundError(`Record not found: "${id}"`);
|
|
760
938
|
var VERSION_COLUMNS = [
|
|
761
939
|
"type_id",
|
|
762
940
|
"content",
|
|
@@ -775,44 +953,20 @@ var versionRowValues = (version) => [
|
|
|
775
953
|
version.updatedBy?.subjectId ?? null,
|
|
776
954
|
version.updatedBy?.principalId ?? null
|
|
777
955
|
];
|
|
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)
|
|
956
|
+
var SharedSqlRecordLogic = class {
|
|
957
|
+
exec;
|
|
958
|
+
searchIndex;
|
|
959
|
+
contentIndex;
|
|
960
|
+
constructor({ exec }) {
|
|
961
|
+
this.exec = exec;
|
|
962
|
+
this.searchIndex = new SearchIndex(exec);
|
|
963
|
+
this.contentIndex = new ContentIndex(exec);
|
|
964
|
+
}
|
|
965
|
+
/** Check `ifVersion` against a version read inside the caller's transaction. */
|
|
966
|
+
checkExpectedVersion(id, actual, expected) {
|
|
967
|
+
if (expected === void 0 || actual === expected)
|
|
814
968
|
return;
|
|
815
|
-
throw versionConflict(
|
|
969
|
+
throw versionConflict(id, expected, actual);
|
|
816
970
|
}
|
|
817
971
|
/**
|
|
818
972
|
* Called after a version-gated statement affected zero rows.
|
|
@@ -821,23 +975,43 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
821
975
|
* the actual current version for the caller to act on).
|
|
822
976
|
*/
|
|
823
977
|
throwVersionConflict(id, expectedVersion) {
|
|
824
|
-
const
|
|
825
|
-
|
|
826
|
-
|
|
978
|
+
const version = this.currentVersion(id);
|
|
979
|
+
if (version === void 0)
|
|
980
|
+
throw recordNotFound(id);
|
|
981
|
+
throw versionConflict(id, expectedVersion, version);
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* An aspect write's record, inside its transaction: missing is not found,
|
|
985
|
+
* a tombstone is refused, and anything else answers its version.
|
|
986
|
+
*/
|
|
987
|
+
liveVersion(id) {
|
|
988
|
+
const row = this.exec.get("SELECT version, deleted_at FROM records WHERE id = ?", [id]);
|
|
827
989
|
if (!row)
|
|
828
|
-
throw
|
|
829
|
-
|
|
990
|
+
throw recordNotFound(id);
|
|
991
|
+
if (row.deleted_at !== null)
|
|
992
|
+
throw softDeletedRefusal(id);
|
|
993
|
+
return row.version;
|
|
994
|
+
}
|
|
995
|
+
currentVersion(id) {
|
|
996
|
+
return this.exec.get("SELECT version FROM records WHERE id = ?", [id])?.version;
|
|
830
997
|
}
|
|
831
998
|
/**
|
|
832
|
-
*
|
|
833
|
-
*
|
|
834
|
-
*
|
|
835
|
-
*
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
999
|
+
* What a mutation builds on, read inside its transaction so nothing can
|
|
1000
|
+
* land between the read and the write: a patch applied to an earlier read
|
|
1001
|
+
* would overwrite whatever changed since. Throws for a missing record or
|
|
1002
|
+
* a failed `ifVersion`.
|
|
1003
|
+
*/
|
|
1004
|
+
readForWrite(id, ifVersion) {
|
|
1005
|
+
const row = this.exec.get("SELECT version, type_id, content FROM records WHERE id = ?", [id]);
|
|
1006
|
+
if (!row)
|
|
1007
|
+
throw recordNotFound(id);
|
|
1008
|
+
this.checkExpectedVersion(id, row.version, ifVersion);
|
|
1009
|
+
return { typeId: row.type_id, content: JSON.parse(row.content) };
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Every version-bumping UPDATE. The `ifVersion` guard rides in the WHERE
|
|
1013
|
+
* clause, so a write that reads nothing first (a soft delete, an
|
|
1014
|
+
* undelete) enforces it with no separate read.
|
|
841
1015
|
*/
|
|
842
1016
|
versionedUpdate(id, sets, values, opts, now = toMs(/* @__PURE__ */ new Date())) {
|
|
843
1017
|
const guarded = opts.ifVersion !== void 0;
|
|
@@ -853,15 +1027,9 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
853
1027
|
this.throwVersionConflict(id, opts.ifVersion);
|
|
854
1028
|
}
|
|
855
1029
|
/**
|
|
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.
|
|
1030
|
+
* Read a record back after its own mutation committed. Absence here is an
|
|
1031
|
+
* invariant breach, not a "not found". Synchronous, so nothing can run
|
|
1032
|
+
* between the commit and the read — see readRecord().
|
|
865
1033
|
*/
|
|
866
1034
|
reread(id, verb) {
|
|
867
1035
|
const updated = this.readRecord(id);
|
|
@@ -873,16 +1041,10 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
873
1041
|
// Records
|
|
874
1042
|
// -------------------------------------------------------
|
|
875
1043
|
/**
|
|
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.
|
|
1044
|
+
* One transaction, because a record and the tables that describe it are
|
|
1045
|
+
* one fact: a half-applied create raises, so the caller believes nothing
|
|
1046
|
+
* landed, while the surviving row fails their retry on the PK and is
|
|
1047
|
+
* missing from both indexes. A duplicate id maps to StackConflictError.
|
|
886
1048
|
*/
|
|
887
1049
|
async createRecord(record, opts = {}) {
|
|
888
1050
|
this.exec.transaction(() => {
|
|
@@ -913,11 +1075,12 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
913
1075
|
}
|
|
914
1076
|
throw err;
|
|
915
1077
|
}
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
this.
|
|
1078
|
+
if (record.associations?.length)
|
|
1079
|
+
this.insertAssociations(record.id, record.associations);
|
|
1080
|
+
if (record.permissions?.length)
|
|
1081
|
+
this.insertPermissions(record.id, record.permissions);
|
|
1082
|
+
this.searchIndex.insert(record.id, JSON.stringify(record.content));
|
|
1083
|
+
this.contentIndex.sync(record.id, record.typeId, record.content);
|
|
921
1084
|
this.appendJournal(record.id, opts.journal);
|
|
922
1085
|
});
|
|
923
1086
|
return record;
|
|
@@ -926,97 +1089,62 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
926
1089
|
return this.readRecord(id);
|
|
927
1090
|
}
|
|
928
1091
|
/**
|
|
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.
|
|
1092
|
+
* The synchronous read behind getRecord(), for use inside
|
|
1093
|
+
* exec.transaction(), whose callback can't await. reread() relies on the
|
|
1094
|
+
* same synchrony: nothing interleaves between a commit and its read, so a
|
|
1095
|
+
* concurrent mutation is never reported under this one's verb.
|
|
941
1096
|
*/
|
|
942
1097
|
readRecord(id) {
|
|
943
1098
|
const row = this.exec.get("SELECT * FROM records WHERE id = ?", [id]);
|
|
944
|
-
|
|
945
|
-
return null;
|
|
946
|
-
const associations = this.getAssociationsForRecord(id);
|
|
947
|
-
return rowToRecord(row, associations);
|
|
1099
|
+
return row ? this.hydrate([row])[0] : null;
|
|
948
1100
|
}
|
|
949
1101
|
/**
|
|
950
|
-
*
|
|
951
|
-
*
|
|
952
|
-
*
|
|
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.
|
|
1102
|
+
* Records rows with their associations and permissions attached: one
|
|
1103
|
+
* statement per element table for the whole list, so a page costs three
|
|
1104
|
+
* reads however many records it holds.
|
|
970
1105
|
*/
|
|
971
|
-
|
|
972
|
-
const
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
}
|
|
983
|
-
if (changes.unlisted !== void 0) {
|
|
984
|
-
sets.push("unlisted_at = ?");
|
|
985
|
-
values.push(changes.unlisted ? now : null);
|
|
986
|
-
}
|
|
987
|
-
if (opts.bumpsVersion === false) {
|
|
988
|
-
this.exec.transaction(() => {
|
|
989
|
-
const current = this.readRecord(id);
|
|
990
|
-
if (!current)
|
|
991
|
-
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
992
|
-
this.checkExpectedVersion(current, opts.ifVersion);
|
|
993
|
-
if (sets.length > 0) {
|
|
994
|
-
this.exec.run(`UPDATE records SET ${sets.join(", ")} WHERE id = ?`, [...values, id]);
|
|
995
|
-
}
|
|
996
|
-
this.replaceAssociationHalves(id, changes);
|
|
997
|
-
this.appendJournal(id, opts.journal);
|
|
998
|
-
});
|
|
999
|
-
return this.reread(id, "mutateRecord");
|
|
1000
|
-
}
|
|
1001
|
-
const merged = changes.contentPatch ? applyMergePatch(existing.content, changes.contentPatch) : void 0;
|
|
1002
|
-
if (merged !== void 0) {
|
|
1003
|
-
sets.unshift("content = ?");
|
|
1004
|
-
values.unshift(JSON.stringify(merged));
|
|
1005
|
-
}
|
|
1106
|
+
hydrate(rows2) {
|
|
1107
|
+
const ids = rows2.map((row) => row.id);
|
|
1108
|
+
const associations = this.elementsByRecord("associations", ids, rowToAssociation);
|
|
1109
|
+
const permissions = this.elementsByRecord("permissions", ids, rowToPermission);
|
|
1110
|
+
return rows2.map((row) => rowToRecord(row, associations.get(row.id) ?? [], permissions.get(row.id) ?? []));
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* The patch applies to the content as it stands when the write lands,
|
|
1114
|
+
* read inside the same transaction.
|
|
1115
|
+
*/
|
|
1116
|
+
async patchContent(id, patch, opts = {}) {
|
|
1006
1117
|
this.exec.transaction(() => {
|
|
1118
|
+
const current = this.readForWrite(id, opts.ifVersion);
|
|
1007
1119
|
if (opts.snapshot)
|
|
1008
1120
|
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1121
|
+
this.rewriteContent(id, current.typeId, applyMergePatch(current.content, patch), opts);
|
|
1122
|
+
this.appendJournal(id, opts.journal);
|
|
1123
|
+
});
|
|
1124
|
+
return this.reread(id, "patchContent");
|
|
1125
|
+
}
|
|
1126
|
+
async setParent(id, parentId, opts = {}) {
|
|
1127
|
+
return this.aspectUpdate(id, "parent_id = ?", parentId, opts, "setParent");
|
|
1128
|
+
}
|
|
1129
|
+
/** Stamped fresh: `Stack` hands this only a transition. */
|
|
1130
|
+
async setUnlisted(id, unlisted, opts = {}) {
|
|
1131
|
+
return this.aspectUpdate(id, "unlisted_at = ?", unlisted ? toMs(/* @__PURE__ */ new Date()) : null, opts, "setUnlisted");
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* One records column that never bumps. The tombstone refusal and the
|
|
1135
|
+
* `ifVersion` guard ride in the WHERE clause, as on a versioned update.
|
|
1136
|
+
*/
|
|
1137
|
+
aspectUpdate(id, set, value, opts, verb) {
|
|
1138
|
+
const guarded = opts.ifVersion !== void 0;
|
|
1139
|
+
this.exec.transaction(() => {
|
|
1140
|
+
const changed = this.exec.run(`UPDATE records SET ${set}
|
|
1141
|
+
WHERE id = ? AND deleted_at IS NULL${guarded ? " AND version = ?" : ""}`, [value, id, ...guarded ? [opts.ifVersion] : []]);
|
|
1142
|
+
if (changed === 0) {
|
|
1143
|
+
throw versionConflict(id, opts.ifVersion, this.liveVersion(id));
|
|
1016
1144
|
}
|
|
1017
1145
|
this.appendJournal(id, opts.journal);
|
|
1018
1146
|
});
|
|
1019
|
-
return this.reread(id,
|
|
1147
|
+
return this.reread(id, verb);
|
|
1020
1148
|
}
|
|
1021
1149
|
async deleteRecord(id, opts = {}) {
|
|
1022
1150
|
if (opts.purge) {
|
|
@@ -1032,27 +1160,24 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1032
1160
|
return this.reread(id, "deleteRecord");
|
|
1033
1161
|
}
|
|
1034
1162
|
/**
|
|
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).
|
|
1163
|
+
* Delete a record and everything that refers to it, returning the record
|
|
1164
|
+
* as it stood (null if there was none). Read here, inside the caller's
|
|
1165
|
+
* transaction, so nothing can overtake the last copy that will ever exist.
|
|
1042
1166
|
*/
|
|
1043
1167
|
purgeRecord(id, ifVersion) {
|
|
1044
1168
|
const purged = this.readRecord(id);
|
|
1045
1169
|
if (!purged) {
|
|
1046
1170
|
if (ifVersion !== void 0)
|
|
1047
|
-
throw
|
|
1171
|
+
throw recordNotFound(id);
|
|
1048
1172
|
return null;
|
|
1049
1173
|
}
|
|
1050
|
-
this.checkExpectedVersion(purged, ifVersion);
|
|
1051
|
-
|
|
1174
|
+
this.checkExpectedVersion(id, purged.version, ifVersion);
|
|
1175
|
+
this.searchIndex.remove(id);
|
|
1052
1176
|
this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
|
|
1177
|
+
this.exec.run("DELETE FROM permissions WHERE record_id = ?", [id]);
|
|
1053
1178
|
this.exec.run("DELETE FROM versions WHERE record_id = ?", [id]);
|
|
1054
1179
|
this.exec.run("DELETE FROM journal WHERE record_id = ?", [id]);
|
|
1055
|
-
this.
|
|
1180
|
+
this.contentIndex.remove(id);
|
|
1056
1181
|
this.exec.run("DELETE FROM records WHERE id = ?", [id]);
|
|
1057
1182
|
return purged;
|
|
1058
1183
|
}
|
|
@@ -1066,14 +1191,11 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1066
1191
|
return this.reread(id, "undelete");
|
|
1067
1192
|
}
|
|
1068
1193
|
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
1194
|
this.exec.transaction(() => {
|
|
1195
|
+
this.readForWrite(id, opts.ifVersion);
|
|
1196
|
+
const target = this.readVersion(id, version);
|
|
1197
|
+
if (!target)
|
|
1198
|
+
throw new StackNotFoundError(`Version not found: "${id}"@${version}`);
|
|
1077
1199
|
if (opts.snapshot)
|
|
1078
1200
|
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
1079
1201
|
this.rewriteContent(id, target.typeId, target.content, opts);
|
|
@@ -1082,11 +1204,8 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1082
1204
|
return this.reread(id, "restoreVersion");
|
|
1083
1205
|
}
|
|
1084
1206
|
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
1207
|
this.exec.transaction(() => {
|
|
1208
|
+
this.readForWrite(id, opts.ifVersion);
|
|
1090
1209
|
if (opts.snapshot)
|
|
1091
1210
|
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
1092
1211
|
this.rewriteContent(id, toTypeId, content, opts);
|
|
@@ -1095,51 +1214,47 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1095
1214
|
return this.reread(id, "commitMigration");
|
|
1096
1215
|
}
|
|
1097
1216
|
/**
|
|
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.
|
|
1217
|
+
* Replace a record's type and content wholesale. Unindex, write, re-index,
|
|
1218
|
+
* in that order (docs/spec/adapters.md § The search index is unindexed
|
|
1219
|
+
* before the row moves); callers must be inside exec.transaction().
|
|
1106
1220
|
*/
|
|
1107
1221
|
rewriteContent(id, typeId, content, opts) {
|
|
1108
1222
|
const json = JSON.stringify(content);
|
|
1109
|
-
|
|
1223
|
+
this.searchIndex.remove(id);
|
|
1110
1224
|
this.versionedUpdate(id, ["type_id = ?", "content = ?"], [typeId, json], opts);
|
|
1111
|
-
|
|
1112
|
-
this.
|
|
1225
|
+
this.searchIndex.insert(id, json);
|
|
1226
|
+
this.contentIndex.sync(id, typeId, content);
|
|
1227
|
+
}
|
|
1228
|
+
queryRecords(query) {
|
|
1229
|
+
return this.page(query);
|
|
1113
1230
|
}
|
|
1114
|
-
|
|
1115
|
-
|
|
1231
|
+
queryReadable(query, reach) {
|
|
1232
|
+
return this.page(query, reach);
|
|
1233
|
+
}
|
|
1234
|
+
/** `reach`, when given, is one more condition of every statement in the plan. */
|
|
1235
|
+
async page(query, reach) {
|
|
1236
|
+
const { limit } = query;
|
|
1116
1237
|
const fetch = limit + 1;
|
|
1117
|
-
const
|
|
1118
|
-
for (const page2 of buildQueryPlan(query)) {
|
|
1119
|
-
const budget = fetch -
|
|
1238
|
+
const rows2 = [];
|
|
1239
|
+
for (const page2 of buildQueryPlan(query, reach)) {
|
|
1240
|
+
const budget = fetch - rows2.length;
|
|
1120
1241
|
if (budget <= 0)
|
|
1121
1242
|
break;
|
|
1122
1243
|
const statement2 = atBudget(page2, budget);
|
|
1123
|
-
|
|
1244
|
+
rows2.push(...asStackBadRequestError(query, () => this.exec.all(statement2.sql, statement2.params)));
|
|
1124
1245
|
}
|
|
1125
|
-
const hasMore =
|
|
1126
|
-
const page = hasMore ?
|
|
1127
|
-
const records =
|
|
1128
|
-
const associations = this.getAssociationsForRecord(row.id);
|
|
1129
|
-
return rowToRecord(row, associations);
|
|
1130
|
-
});
|
|
1246
|
+
const hasMore = rows2.length > limit;
|
|
1247
|
+
const page = hasMore ? rows2.slice(0, limit) : rows2;
|
|
1248
|
+
const records = this.hydrate(page);
|
|
1131
1249
|
const lastRecord = records[records.length - 1];
|
|
1132
|
-
const cursor = hasMore && lastRecord ? makeCursor(lastRecord, query.sort, (record, field) => this.
|
|
1250
|
+
const cursor = hasMore && lastRecord ? makeCursor(lastRecord, query.sort, (record, field) => this.contentIndex.kindOf(record.typeId, field)) : null;
|
|
1133
1251
|
return { records, cursor };
|
|
1134
1252
|
}
|
|
1135
1253
|
/**
|
|
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.
|
|
1254
|
+
* The reference check and the purges share one synchronous transaction,
|
|
1255
|
+
* so nothing can add a reference between the two.
|
|
1141
1256
|
*/
|
|
1142
|
-
async deleteUnreferencedAttachmentRecords(fileId,
|
|
1257
|
+
async deleteUnreferencedAttachmentRecords(fileId, metadataBaseId) {
|
|
1143
1258
|
return this.exec.transaction(() => {
|
|
1144
1259
|
const referenced = this.exec.all(`SELECT 1 as found FROM associations WHERE kind = 'attachment' AND file_id = ?
|
|
1145
1260
|
UNION ALL
|
|
@@ -1148,11 +1263,9 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1148
1263
|
if (referenced.length) {
|
|
1149
1264
|
throw new StackConflictError("Attachment is still referenced by one or more records");
|
|
1150
1265
|
}
|
|
1151
|
-
|
|
1152
|
-
return [];
|
|
1153
|
-
const placeholders = metadataTypeIds.map(() => "?").join(", ");
|
|
1266
|
+
const family = inFamily("type_id", metadataBaseId);
|
|
1154
1267
|
const metaRows = this.exec.all(`SELECT id FROM records
|
|
1155
|
-
WHERE
|
|
1268
|
+
WHERE ${family.sql} AND json_extract(content, '$.fileId') = ?`, [...family.params, fileId]);
|
|
1156
1269
|
const deleted = [];
|
|
1157
1270
|
for (const row of metaRows) {
|
|
1158
1271
|
const purged = this.purgeRecord(row.id);
|
|
@@ -1172,10 +1285,13 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1172
1285
|
conditions.push("version < ?");
|
|
1173
1286
|
values.push(query.beforeVersion);
|
|
1174
1287
|
}
|
|
1175
|
-
const
|
|
1176
|
-
return
|
|
1288
|
+
const rows2 = this.exec.all(`SELECT * FROM versions WHERE ${conditions.join(" AND ")} ORDER BY version DESC${query.limit !== void 0 ? " LIMIT ?" : ""}`, query.limit !== void 0 ? [...values, query.limit] : values);
|
|
1289
|
+
return rows2.map(rowToVersion);
|
|
1177
1290
|
}
|
|
1178
1291
|
async getVersion(id, version) {
|
|
1292
|
+
return this.readVersion(id, version);
|
|
1293
|
+
}
|
|
1294
|
+
readVersion(id, version) {
|
|
1179
1295
|
const row = this.exec.get("SELECT * FROM versions WHERE record_id = ? AND version = ?", [id, version]);
|
|
1180
1296
|
return row ? rowToVersion(row) : null;
|
|
1181
1297
|
}
|
|
@@ -1229,10 +1345,7 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1229
1345
|
} catch (err) {
|
|
1230
1346
|
if (!(err instanceof StackConflictError))
|
|
1231
1347
|
throw err;
|
|
1232
|
-
|
|
1233
|
-
id
|
|
1234
|
-
]);
|
|
1235
|
-
if (!row || row.version !== version.version)
|
|
1348
|
+
if (this.currentVersion(id) !== version.version)
|
|
1236
1349
|
throw err;
|
|
1237
1350
|
this.overwriteVersionRow(id, version);
|
|
1238
1351
|
}
|
|
@@ -1241,21 +1354,17 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1241
1354
|
// Change journal
|
|
1242
1355
|
// -------------------------------------------------------
|
|
1243
1356
|
/**
|
|
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.
|
|
1357
|
+
* Append one entry, stamped from the row as it now stands, so callers run
|
|
1358
|
+
* this inside their write's transaction, after the write. `seq` is
|
|
1359
|
+
* allocated in the INSERT itself, so no caller ever holds one it expects
|
|
1360
|
+
* to be free. See docs/spec/journal.md § Ordering.
|
|
1252
1361
|
*/
|
|
1253
1362
|
appendJournal(recordId, entry) {
|
|
1254
1363
|
if (!entry)
|
|
1255
1364
|
return;
|
|
1256
1365
|
const row = this.exec.get("SELECT version, type_id, parent_id FROM records WHERE id = ?", [recordId]);
|
|
1257
1366
|
if (!row)
|
|
1258
|
-
throw
|
|
1367
|
+
throw recordNotFound(recordId);
|
|
1259
1368
|
this.exec.run(`INSERT INTO journal
|
|
1260
1369
|
(record_id, seq, at, kind, ops, version, type_id, parent_id,
|
|
1261
1370
|
previous_parent_id, actor, associations)
|
|
@@ -1279,7 +1388,7 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1279
1388
|
}
|
|
1280
1389
|
async getJournal(id, query = {}) {
|
|
1281
1390
|
if (!this.exec.get("SELECT 1 FROM records WHERE id = ?", [id])) {
|
|
1282
|
-
throw
|
|
1391
|
+
throw recordNotFound(id);
|
|
1283
1392
|
}
|
|
1284
1393
|
const conditions = ["record_id = ?"];
|
|
1285
1394
|
const values = [id];
|
|
@@ -1287,8 +1396,8 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1287
1396
|
conditions.push("seq > ?");
|
|
1288
1397
|
values.push(query.afterSeq);
|
|
1289
1398
|
}
|
|
1290
|
-
const
|
|
1291
|
-
return
|
|
1399
|
+
const rows2 = this.exec.all(`SELECT * FROM journal WHERE ${conditions.join(" AND ")} ORDER BY seq ASC${query.limit !== void 0 ? " LIMIT ?" : ""}`, query.limit !== void 0 ? [...values, query.limit] : values);
|
|
1400
|
+
return rows2.map(rowToJournalEntry);
|
|
1292
1401
|
}
|
|
1293
1402
|
// -------------------------------------------------------
|
|
1294
1403
|
// Types
|
|
@@ -1306,174 +1415,152 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
|
|
|
1306
1415
|
type.migratesFrom ?? null,
|
|
1307
1416
|
toMs(type.createdAt)
|
|
1308
1417
|
]);
|
|
1309
|
-
|
|
1310
|
-
this.indexedFieldsByType.set(type.id, indexedFieldKinds(schema));
|
|
1418
|
+
this.contentIndex.rememberType(type);
|
|
1311
1419
|
}
|
|
1312
1420
|
async getType(id) {
|
|
1313
1421
|
const row = this.exec.get("SELECT * FROM types WHERE id = ?", [id]);
|
|
1314
1422
|
return row ? rowToType(row) : null;
|
|
1315
1423
|
}
|
|
1316
1424
|
async listTypes() {
|
|
1317
|
-
const
|
|
1318
|
-
return
|
|
1425
|
+
const rows2 = this.exec.all("SELECT * FROM types ORDER BY base_id, version");
|
|
1426
|
+
return rows2.map(rowToType);
|
|
1319
1427
|
}
|
|
1320
1428
|
// -------------------------------------------------------
|
|
1321
1429
|
// Associations
|
|
1322
1430
|
// -------------------------------------------------------
|
|
1323
1431
|
/**
|
|
1324
|
-
*
|
|
1325
|
-
*
|
|
1326
|
-
*
|
|
1327
|
-
*
|
|
1432
|
+
* A write to one of a record's two element tables, inside a transaction
|
|
1433
|
+
* that first checks the record exists, is not soft-deleted, is at
|
|
1434
|
+
* `ifVersion`, and that the table holds `expectSet`. Never bumps and
|
|
1435
|
+
* never snapshots.
|
|
1328
1436
|
*/
|
|
1329
|
-
|
|
1330
|
-
assertOneSurface(changes);
|
|
1437
|
+
elementWrite(id, table, opts, write, verb) {
|
|
1331
1438
|
this.exec.transaction(() => {
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
this.exec.run(`DELETE FROM associations
|
|
1339
|
-
WHERE record_id = ?
|
|
1340
|
-
AND kind = ?
|
|
1341
|
-
AND label = ?
|
|
1342
|
-
AND file_id = ?
|
|
1343
|
-
AND related_scope = ?
|
|
1344
|
-
AND related_id = ?
|
|
1345
|
-
AND related_ns = ?
|
|
1346
|
-
AND related_stack = ?
|
|
1347
|
-
AND related_role = ?`, [recordId, association.kind, association.label, ...associationKeyColumns(association)]);
|
|
1439
|
+
this.checkExpectedVersion(id, this.liveVersion(id), opts.ifVersion);
|
|
1440
|
+
if (opts.expectSet) {
|
|
1441
|
+
const map = table === "associations" ? rowToAssociation : rowToPermission;
|
|
1442
|
+
const stored = this.elementsByRecord(table, [id], map).get(id) ?? [];
|
|
1443
|
+
if (!sameAssociationSet(stored, opts.expectSet))
|
|
1444
|
+
throw new ElementSetMovedError(id);
|
|
1348
1445
|
}
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
this.insertAssociations(recordId, adds);
|
|
1352
|
-
this.appendJournal(recordId, opts.journal);
|
|
1446
|
+
write();
|
|
1447
|
+
this.appendJournal(id, opts.journal);
|
|
1353
1448
|
});
|
|
1354
|
-
return this.reread(
|
|
1449
|
+
return this.reread(id, verb);
|
|
1355
1450
|
}
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
this.
|
|
1373
|
-
|
|
1451
|
+
async editAssociations(id, edits, opts = {}) {
|
|
1452
|
+
return this.elementWrite(id, "associations", opts, () => {
|
|
1453
|
+
for (const edit of edits) {
|
|
1454
|
+
if (edit.op !== "remove")
|
|
1455
|
+
continue;
|
|
1456
|
+
const association = edit.association;
|
|
1457
|
+
this.exec.run(`DELETE FROM associations
|
|
1458
|
+
WHERE record_id = ? AND kind = ? AND label = ?
|
|
1459
|
+
AND file_id = ? AND related_scope = ? AND related_id = ?
|
|
1460
|
+
AND related_ns = ? AND related_stack = ?`, [id, association.kind, association.label, ...associationKeyColumns(association)]);
|
|
1461
|
+
}
|
|
1462
|
+
this.insertAssociations(id, addsOf(edits));
|
|
1463
|
+
}, "editAssociations");
|
|
1464
|
+
}
|
|
1465
|
+
async setAssociations(id, associations, opts = {}) {
|
|
1466
|
+
return this.elementWrite(id, "associations", opts, () => {
|
|
1467
|
+
this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
|
|
1468
|
+
this.insertAssociations(id, associations);
|
|
1469
|
+
}, "setAssociations");
|
|
1470
|
+
}
|
|
1471
|
+
/** editAssociations() over the permissions table. */
|
|
1472
|
+
async editAccess(id, edits, opts = {}) {
|
|
1473
|
+
return this.elementWrite(id, "permissions", opts, () => {
|
|
1474
|
+
for (const edit of edits) {
|
|
1475
|
+
if (edit.op !== "remove")
|
|
1476
|
+
continue;
|
|
1477
|
+
const permission = edit.association;
|
|
1478
|
+
this.exec.run(`DELETE FROM permissions
|
|
1479
|
+
WHERE record_id = ? AND kind = ? AND label = ?
|
|
1480
|
+
AND grantee_kind = ? AND grantee_id = ? AND role = ?`, [id, permission.kind, permission.label, ...permissionKeyColumns(permission)]);
|
|
1481
|
+
}
|
|
1482
|
+
this.insertPermissions(id, addsOf(edits));
|
|
1483
|
+
}, "editAccess");
|
|
1374
1484
|
}
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
this.exec.run(`DELETE FROM associations WHERE record_id = ? AND kind IN (${kinds.map(() => "?").join(", ")})`, [recordId, ...kinds]);
|
|
1381
|
-
if (associations.length)
|
|
1382
|
-
this.insertAssociations(recordId, associations);
|
|
1485
|
+
async setAccess(id, permissions, opts = {}) {
|
|
1486
|
+
return this.elementWrite(id, "permissions", opts, () => {
|
|
1487
|
+
this.exec.run("DELETE FROM permissions WHERE record_id = ?", [id]);
|
|
1488
|
+
this.insertPermissions(id, permissions);
|
|
1489
|
+
}, "setAccess");
|
|
1383
1490
|
}
|
|
1384
1491
|
/**
|
|
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.
|
|
1492
|
+
* Upserts on association identity, so re-pointing `attachment_record_id`
|
|
1493
|
+
* (outside the key) costs no second row. A missing record trips the
|
|
1494
|
+
* foreign key, reported as StackNotFoundError rather than an orphan row.
|
|
1393
1495
|
*/
|
|
1394
1496
|
insertAssociations(recordId, associations) {
|
|
1395
1497
|
for (const assoc of associations) {
|
|
1396
|
-
|
|
1397
|
-
this.exec.run(`INSERT INTO associations
|
|
1498
|
+
this.insertRow(recordId, () => this.exec.run(`INSERT INTO associations
|
|
1398
1499
|
(record_id, kind, label, file_id, related_scope, related_id, related_ns,
|
|
1399
|
-
related_stack,
|
|
1400
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?,
|
|
1500
|
+
related_stack, attachment_record_id)
|
|
1501
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1401
1502
|
ON CONFLICT DO UPDATE SET attachment_record_id = excluded.attachment_record_id`, [
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
} catch (err) {
|
|
1409
|
-
if (isForeignKeyViolation(err)) {
|
|
1410
|
-
throw new StackNotFoundError(`Record not found: "${recordId}"`);
|
|
1411
|
-
}
|
|
1412
|
-
throw err;
|
|
1413
|
-
}
|
|
1503
|
+
recordId,
|
|
1504
|
+
assoc.kind,
|
|
1505
|
+
assoc.label,
|
|
1506
|
+
...associationKeyColumns(assoc),
|
|
1507
|
+
assoc.kind === "attachment" ? assoc.attachmentRecordId ?? "" : ""
|
|
1508
|
+
]));
|
|
1414
1509
|
}
|
|
1415
1510
|
}
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1511
|
+
/** Every column is in the key, so a repeated element is the same row. */
|
|
1512
|
+
insertPermissions(recordId, permissions) {
|
|
1513
|
+
for (const p of permissions) {
|
|
1514
|
+
this.insertRow(recordId, () => this.exec.run(`INSERT INTO permissions (record_id, kind, label, grantee_kind, grantee_id, role)
|
|
1515
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
1516
|
+
ON CONFLICT DO NOTHING`, [recordId, p.kind, p.label, ...permissionKeyColumns(p)]));
|
|
1517
|
+
}
|
|
1419
1518
|
}
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
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;
|
|
1519
|
+
insertRow(recordId, insert) {
|
|
1520
|
+
try {
|
|
1521
|
+
insert();
|
|
1522
|
+
} catch (err) {
|
|
1523
|
+
if (isForeignKeyViolation(err))
|
|
1524
|
+
throw recordNotFound(recordId);
|
|
1525
|
+
throw err;
|
|
1526
|
+
}
|
|
1436
1527
|
}
|
|
1437
1528
|
/**
|
|
1438
|
-
*
|
|
1439
|
-
*
|
|
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.
|
|
1529
|
+
* One element table's rows for every record in `ids`, in primary-key
|
|
1530
|
+
* order — the order a single record's read has always had.
|
|
1443
1531
|
*/
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
const
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
const fileId = kind === "file-ref" ? entry.text : null;
|
|
1457
|
-
this.exec.run(sql, [recordId, field, 1, null, entry.key, entry.text, fileId]);
|
|
1458
|
-
}
|
|
1532
|
+
elementsByRecord(table, ids, map) {
|
|
1533
|
+
const byRecord = /* @__PURE__ */ new Map();
|
|
1534
|
+
const rows2 = this.exec.all(`WITH ids AS MATERIALIZED ${rowsTable(["id"])}
|
|
1535
|
+
SELECT * FROM ${table} WHERE record_id IN (SELECT id FROM ids)
|
|
1536
|
+
ORDER BY ${table === "associations" ? ASSOCIATION_KEY : PERMISSION_KEY}`, [JSON.stringify(ids.map((id) => [id]))]);
|
|
1537
|
+
for (const row of rows2) {
|
|
1538
|
+
const id = row.record_id;
|
|
1539
|
+
const list = byRecord.get(id);
|
|
1540
|
+
if (list)
|
|
1541
|
+
list.push(map(row));
|
|
1542
|
+
else
|
|
1543
|
+
byRecord.set(id, [map(row)]);
|
|
1459
1544
|
}
|
|
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);
|
|
1545
|
+
return byRecord;
|
|
1464
1546
|
}
|
|
1465
1547
|
};
|
|
1548
|
+
var ASSOCIATION_KEY = "record_id, kind, label, file_id, related_scope, related_id, related_ns, related_stack";
|
|
1549
|
+
var PERMISSION_KEY = "record_id, kind, label, grantee_kind, grantee_id, role";
|
|
1550
|
+
var addsOf = (edits) => edits.flatMap((e) => e.op === "add" ? [e.association] : []);
|
|
1466
1551
|
|
|
1467
1552
|
// ../sqlite-shared/dist/record-adapter.js
|
|
1468
1553
|
var SQLITE_RECORD_CAPABILITIES = {
|
|
1469
1554
|
filter: {
|
|
1470
1555
|
content: "path",
|
|
1471
1556
|
contentPresent: true,
|
|
1557
|
+
contentByType: true,
|
|
1472
1558
|
search: true
|
|
1473
1559
|
},
|
|
1474
1560
|
sort: {
|
|
1475
1561
|
fields: ["createdAt", "updatedAt", "version"],
|
|
1476
|
-
contentField: true
|
|
1562
|
+
contentField: true,
|
|
1563
|
+
contentFieldByType: true
|
|
1477
1564
|
},
|
|
1478
1565
|
limits: {
|
|
1479
1566
|
attachmentBytes: null,
|
|
@@ -1499,8 +1586,14 @@ var SharedSqlRecordAdapter = class {
|
|
|
1499
1586
|
getRecord(id) {
|
|
1500
1587
|
return this.record.getRecord(id);
|
|
1501
1588
|
}
|
|
1502
|
-
|
|
1503
|
-
return this.record.
|
|
1589
|
+
patchContent(id, patch, opts) {
|
|
1590
|
+
return this.record.patchContent(id, patch, opts);
|
|
1591
|
+
}
|
|
1592
|
+
setParent(id, parentId, opts) {
|
|
1593
|
+
return this.record.setParent(id, parentId, opts);
|
|
1594
|
+
}
|
|
1595
|
+
setUnlisted(id, unlisted, opts) {
|
|
1596
|
+
return this.record.setUnlisted(id, unlisted, opts);
|
|
1504
1597
|
}
|
|
1505
1598
|
deleteRecord(id, opts) {
|
|
1506
1599
|
return this.record.deleteRecord(id, opts);
|
|
@@ -1517,8 +1610,11 @@ var SharedSqlRecordAdapter = class {
|
|
|
1517
1610
|
queryRecords(query) {
|
|
1518
1611
|
return this.record.queryRecords(query);
|
|
1519
1612
|
}
|
|
1520
|
-
|
|
1521
|
-
return this.record.
|
|
1613
|
+
queryReadable(query, reach) {
|
|
1614
|
+
return this.record.queryReadable(query, reach);
|
|
1615
|
+
}
|
|
1616
|
+
deleteUnreferencedAttachmentRecords(fileId, metadataBaseId) {
|
|
1617
|
+
return this.record.deleteUnreferencedAttachmentRecords(fileId, metadataBaseId);
|
|
1522
1618
|
}
|
|
1523
1619
|
// -------------------------------------------------------
|
|
1524
1620
|
// Versions
|
|
@@ -1551,10 +1647,19 @@ var SharedSqlRecordAdapter = class {
|
|
|
1551
1647
|
return this.record.listTypes();
|
|
1552
1648
|
}
|
|
1553
1649
|
// -------------------------------------------------------
|
|
1554
|
-
// Associations
|
|
1650
|
+
// Associations and permissions
|
|
1555
1651
|
// -------------------------------------------------------
|
|
1556
|
-
|
|
1557
|
-
return this.record.
|
|
1652
|
+
editAssociations(id, edits, opts) {
|
|
1653
|
+
return this.record.editAssociations(id, edits, opts);
|
|
1654
|
+
}
|
|
1655
|
+
setAssociations(id, associations, opts) {
|
|
1656
|
+
return this.record.setAssociations(id, associations, opts);
|
|
1657
|
+
}
|
|
1658
|
+
editAccess(id, edits, opts) {
|
|
1659
|
+
return this.record.editAccess(id, edits, opts);
|
|
1660
|
+
}
|
|
1661
|
+
setAccess(id, permissions, opts) {
|
|
1662
|
+
return this.record.setAccess(id, permissions, opts);
|
|
1558
1663
|
}
|
|
1559
1664
|
};
|
|
1560
1665
|
|
|
@@ -1575,8 +1680,8 @@ var DurableObjectSqliteExecutor = class {
|
|
|
1575
1680
|
return cursor.rowsWritten;
|
|
1576
1681
|
}
|
|
1577
1682
|
get(sql, params = []) {
|
|
1578
|
-
const
|
|
1579
|
-
return
|
|
1683
|
+
const rows2 = this.sql.exec(sql, ...params).toArray();
|
|
1684
|
+
return rows2[0];
|
|
1580
1685
|
}
|
|
1581
1686
|
all(sql, params = []) {
|
|
1582
1687
|
return this.sql.exec(sql, ...params).toArray();
|