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