@haverstack/record-adapter-sqlite 0.33.0 → 0.35.0

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