@haverstack/record-adapter-do-sqlite 0.26.0 → 0.28.0

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