@haverstack/record-adapter-do-sqlite 0.27.0 → 0.29.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,9 +1,17 @@
1
- import { StackConflictError, applyMergePatch, StackNotFoundError, assertOneSurface, StackVersionConflictError, StackBadRequestError, NATIVE_SORT_FIELDS } from '@haverstack/core';
2
- import { OwnerMismatchError, AUTHORITY_ASSOCIATION_KINDS, contentSortEntry, partitionAssociations, constantSortEntry, contentSortKey, parseContentFilterKey } from '@haverstack/core/adapter';
1
+ import { OwnerMismatchError, newStoreSeed, softDeletedRefusal, sameAssociationSet, ElementSetMovedError, FormatMismatchError, contentSortEntry, constantSortEntry, contentSortKey, parseContentFilterKey } from '@haverstack/core/adapter';
2
+ import { StackConflictError, applyMergePatch, StackNotFoundError, StackVersionConflictError, StackBadRequestError, NATIVE_SORT_FIELDS } from '@haverstack/core';
3
3
 
4
4
  // ../sqlite-shared/dist/schema.js
5
+ var FORMAT_VERSION = 1;
5
6
  var RECORD_SCHEMA_SQL = `
6
- CREATE TABLE IF NOT EXISTS records (
7
+ -- Which layout this store is, in its one row, so an export names the one
8
+ -- a reader holds. See docs/spec/portability.md \xA7 The portable format.
9
+ CREATE TABLE format (
10
+ id INTEGER PRIMARY KEY CHECK (id = 1),
11
+ version INTEGER NOT NULL
12
+ ) STRICT;
13
+
14
+ CREATE TABLE records (
7
15
  id TEXT PRIMARY KEY,
8
16
  type_id TEXT NOT NULL,
9
17
  created_at INTEGER NOT NULL,
@@ -20,41 +28,50 @@ var RECORD_SCHEMA_SQL = `
20
28
  unlisted_at INTEGER
21
29
  ) STRICT;
22
30
 
23
- -- Every edge a record carries, authority and data alike: a permission
24
- -- has element identity and a delta shaped like a tag's, so it earns the
25
- -- same table. The permissions and associations fields are projections
26
- -- over it, partitioned by kind.
27
- -- See docs/spec/access-control.md \xA7 Record-level permissions.
28
- CREATE TABLE IF NOT EXISTS associations (
31
+ -- A record's data associations: tags, attachments and relationships.
32
+ -- Its permissions are in their own table below, so no predicate over this
33
+ -- one can match a grantee as a relationship target.
34
+ CREATE TABLE associations (
29
35
  record_id TEXT NOT NULL REFERENCES records(id),
30
- kind TEXT NOT NULL CHECK (kind IN ('tag', 'attachment', 'relationship', 'permission', 'anyone')),
36
+ kind TEXT NOT NULL CHECK (kind IN ('tag', 'attachment', 'relationship')),
31
37
  label TEXT NOT NULL,
32
38
  file_id TEXT NOT NULL DEFAULT '',
33
39
  -- A relationship target is (related_scope, related_id) qualified by
34
- -- related_ns (a foreign namespace) or related_stack (another stack); a
35
- -- permission's grantee reuses scope and id. All are in the key, so
36
- -- targets differing only by a qualifier are distinct associations.
37
- related_scope TEXT NOT NULL DEFAULT '' CHECK (related_scope IN ('', 'record', 'entity', 'external', 'group')),
40
+ -- related_ns (a foreign namespace) or related_stack (another stack).
41
+ -- All are in the key, so targets differing only by a qualifier are
42
+ -- distinct associations.
43
+ related_scope TEXT NOT NULL DEFAULT '' CHECK (related_scope IN ('', 'record', 'entity', 'external')),
38
44
  related_id TEXT NOT NULL DEFAULT '',
39
45
  related_ns TEXT NOT NULL DEFAULT '',
40
46
  related_stack TEXT NOT NULL DEFAULT '',
41
- -- Set only by a group grantee; in the key because member and admin name
42
- -- two different sets of people.
43
- related_role TEXT NOT NULL DEFAULT '' CHECK (related_role IN ('', 'member', 'admin')),
44
47
  -- Out of the key: it annotates a reference rather than naming one, so
45
48
  -- re-pointing it updates the existing row. See docs/spec/attachments.md
46
49
  -- \xA7 Naming the upload a reference came from.
47
50
  attachment_record_id TEXT NOT NULL DEFAULT '',
48
51
  -- Leads with record_id, so its implicit index serves every by-record
49
52
  -- read and delete; the indexes below cover only the filter directions.
50
- PRIMARY KEY (record_id, kind, label, file_id, related_scope, related_id, related_ns, related_stack, related_role)
53
+ PRIMARY KEY (record_id, kind, label, file_id, related_scope, related_id, related_ns, related_stack)
54
+ ) STRICT;
55
+
56
+ -- Who reaches a record, one row per element. An 'anyone' row names no
57
+ -- grantee; a group grantee's role is in the key, because member and
58
+ -- admin name two different sets of people.
59
+ -- See docs/spec/access-control.md \xA7 Record-level permissions.
60
+ CREATE TABLE permissions (
61
+ record_id TEXT NOT NULL REFERENCES records(id),
62
+ kind TEXT NOT NULL CHECK (kind IN ('permission', 'anyone')),
63
+ label TEXT NOT NULL CHECK (label IN ('read', 'write')),
64
+ grantee_kind TEXT NOT NULL DEFAULT '' CHECK (grantee_kind IN ('', 'entity', 'group')),
65
+ grantee_id TEXT NOT NULL DEFAULT '',
66
+ role TEXT NOT NULL DEFAULT '' CHECK (role IN ('', 'member', 'admin')),
67
+ PRIMARY KEY (record_id, kind, label, grantee_kind, grantee_id, role)
51
68
  ) STRICT;
52
69
 
53
70
  -- Content and the type it is read under. No parent_id, unlisted_at or
54
71
  -- associations column: none of those aspects bumps version, so no
55
72
  -- version ever snapshots one. See
56
73
  -- docs/spec/versioning.md \xA7 Version history.
57
- CREATE TABLE IF NOT EXISTS versions (
74
+ CREATE TABLE versions (
58
75
  record_id TEXT NOT NULL REFERENCES records(id),
59
76
  version INTEGER NOT NULL,
60
77
  type_id TEXT NOT NULL,
@@ -72,7 +89,7 @@ var RECORD_SCHEMA_SQL = `
72
89
  -- by design \u2014 content lives on a versions row, and copying it here
73
90
  -- would make the journal the larger of the two stores.
74
91
  -- See docs/spec/journal.md.
75
- CREATE TABLE IF NOT EXISTS journal (
92
+ CREATE TABLE journal (
76
93
  record_id TEXT NOT NULL REFERENCES records(id),
77
94
  seq INTEGER NOT NULL,
78
95
  at INTEGER NOT NULL,
@@ -96,7 +113,7 @@ var RECORD_SCHEMA_SQL = `
96
113
  PRIMARY KEY (record_id, seq)
97
114
  ) STRICT;
98
115
 
99
- CREATE TABLE IF NOT EXISTS types (
116
+ CREATE TABLE types (
100
117
  id TEXT PRIMARY KEY,
101
118
  base_id TEXT NOT NULL,
102
119
  version INTEGER NOT NULL,
@@ -114,7 +131,7 @@ var RECORD_SCHEMA_SQL = `
114
131
  -- value_rank is 0 for a numeric value and 1 for a text one \u2014 the same
115
132
  -- split as "num_value IS NULL", but non-null, so one index can carry
116
133
  -- the whole ordering. file_id is set only for a 'file-ref' field.
117
- CREATE TABLE IF NOT EXISTS content_index (
134
+ CREATE TABLE content_index (
118
135
  record_id TEXT NOT NULL REFERENCES records(id),
119
136
  field TEXT NOT NULL,
120
137
  value_rank INTEGER NOT NULL CHECK (value_rank IN (0, 1)),
@@ -130,34 +147,39 @@ var RECORD_SCHEMA_SQL = `
130
147
  -- leaves the planner sorting every match before it can honor LIMIT.
131
148
  -- deleted_at and unlisted_at get none: an index on a column every query
132
149
  -- tests IS NULL only lures the planner off the one answering ORDER BY.
133
- CREATE INDEX IF NOT EXISTS idx_records_type_id ON records(type_id, created_at, id);
134
- CREATE INDEX IF NOT EXISTS idx_records_parent_id ON records(parent_id, created_at, id);
135
- CREATE INDEX IF NOT EXISTS idx_records_entity_id ON records(entity_id, created_at, id);
136
- CREATE INDEX IF NOT EXISTS idx_records_app_id ON records(app_id, created_at, id);
137
- CREATE INDEX IF NOT EXISTS idx_records_principal_id ON records(principal_id, created_at, id);
138
- CREATE INDEX IF NOT EXISTS idx_records_created_at ON records(created_at, id);
139
- CREATE INDEX IF NOT EXISTS idx_records_updated_at ON records(updated_at, id);
140
- CREATE INDEX IF NOT EXISTS idx_records_version ON records(version, id);
150
+ CREATE INDEX idx_records_type_id ON records(type_id, created_at, id);
151
+ CREATE INDEX idx_records_parent_id ON records(parent_id, created_at, id);
152
+ CREATE INDEX idx_records_entity_id ON records(entity_id, created_at, id);
153
+ CREATE INDEX idx_records_app_id ON records(app_id, created_at, id);
154
+ CREATE INDEX idx_records_principal_id ON records(principal_id, created_at, id);
155
+ CREATE INDEX idx_records_created_at ON records(created_at, id);
156
+ CREATE INDEX idx_records_updated_at ON records(updated_at, id);
157
+ CREATE INDEX idx_records_version ON records(version, id);
141
158
 
142
159
  -- Each association index ends with record_id so the semi-joins in
143
160
  -- query.ts read the matching record ids out of the index alone.
144
161
  -- idx_assoc_related carries related_ns ahead of related_id because a
145
162
  -- record- or entity-scoped target always stores '' there, which
146
163
  -- buildQueryPlan states explicitly to keep the prefix contiguous.
147
- CREATE INDEX IF NOT EXISTS idx_assoc_kind_label ON associations(kind, label, record_id);
148
- CREATE INDEX IF NOT EXISTS idx_assoc_kind_file_id ON associations(kind, file_id, record_id);
149
- CREATE INDEX IF NOT EXISTS idx_assoc_related ON associations(kind, related_scope, related_ns, related_id, record_id);
150
- CREATE INDEX IF NOT EXISTS idx_types_base_id ON types(base_id, version);
164
+ CREATE INDEX idx_assoc_kind_label ON associations(kind, label, record_id);
165
+ CREATE INDEX idx_assoc_kind_file_id ON associations(kind, file_id, record_id);
166
+ CREATE INDEX idx_assoc_related ON associations(kind, related_scope, related_ns, related_id, record_id);
167
+ CREATE INDEX idx_types_base_id ON types(base_id, version);
168
+
169
+ -- The permissions key with record_id moved last, so a scoped query reads
170
+ -- the records carrying an element straight out of the index.
171
+ CREATE INDEX idx_permissions_element
172
+ ON permissions(kind, label, grantee_kind, grantee_id, role, record_id);
151
173
 
152
174
  -- The whole content ordering, in index order, for one field at a time:
153
175
  -- a content-sorted page is an ordered walk of a slice of this index.
154
- CREATE INDEX IF NOT EXISTS idx_content_index_sort
176
+ CREATE INDEX idx_content_index_sort
155
177
  ON content_index(field, value_rank, num_value, text_key, text_value, record_id);
156
- CREATE INDEX IF NOT EXISTS idx_content_index_file
178
+ CREATE INDEX idx_content_index_file
157
179
  ON content_index(file_id, record_id) WHERE file_id IS NOT NULL;
158
180
  `;
159
181
  var FTS5_SCHEMA_SQL = `
160
- CREATE VIRTUAL TABLE IF NOT EXISTS records_fts USING fts5(
182
+ CREATE VIRTUAL TABLE records_fts USING fts5(
161
183
  content,
162
184
  content='records',
163
185
  content_rowid='rowid',
@@ -165,10 +187,11 @@ var FTS5_SCHEMA_SQL = `
165
187
  );
166
188
  `;
167
189
  var PRAGMA_FOREIGN_KEYS_ON = `PRAGMA foreign_keys = ON;`;
168
- var applyRecordSchema = (exec, opts) => {
190
+ var PRAGMA_JOURNAL_MODE_WAL = `PRAGMA journal_mode = WAL;`;
191
+ var applyConnectionPragmas = (exec, opts) => {
169
192
  exec.exec(PRAGMA_FOREIGN_KEYS_ON);
170
- exec.exec(RECORD_SCHEMA_SQL);
171
- exec.exec(FTS5_SCHEMA_SQL);
193
+ if (opts.wal)
194
+ exec.exec(PRAGMA_JOURNAL_MODE_WAL);
172
195
  };
173
196
 
174
197
  // ../sqlite-shared/dist/fts5.js
@@ -254,22 +277,6 @@ var restoreImplicitAnd = (query) => withPhrasesMasked(query, (masked) => masked.
254
277
  // ../sqlite-shared/dist/executor.js
255
278
  var isForeignKeyViolation = (err) => err instanceof Error && err.message.includes("FOREIGN KEY constraint failed");
256
279
  var isUniqueConstraintViolation = (err) => err instanceof Error && err.message.includes("UNIQUE constraint failed");
257
-
258
- // ../sqlite-shared/dist/config.js
259
- var CONFIG_ROW_SQL = `SELECT content FROM records WHERE id = '_config'`;
260
- var insertConfigRecord = (exec, entityId, timezone) => {
261
- const now = Date.now();
262
- exec.run(`INSERT INTO records (id, type_id, created_at, updated_at, content, version)
263
- VALUES ('_config', '_config@1', ?, ?, ?, 1)`, [now, now, JSON.stringify({ entityId, timezone })]);
264
- return { entityId, timezone };
265
- };
266
- var tryReadStackConfig = (exec) => {
267
- const row = exec.get(CONFIG_ROW_SQL);
268
- if (!row)
269
- return null;
270
- const content = JSON.parse(row.content);
271
- return { entityId: content.entityId, timezone: content.timezone };
272
- };
273
280
  var toBase64 = (text) => {
274
281
  const bytes = new TextEncoder().encode(text);
275
282
  let binary = "";
@@ -320,24 +327,22 @@ var decodeCursor = (cursor) => {
320
327
  throw malformed();
321
328
  };
322
329
  var byTypeDescriptor = (byType) => JSON.stringify(Object.entries(byType).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
323
- var getSortField = (query) => query.sort?.field ?? "createdAt";
324
330
  var getSortColumn = (field) => field === "createdAt" ? "created_at" : field === "updatedAt" ? "updated_at" : "version";
325
- var makeCursor = (record, sort, kindOf) => {
326
- const byType = sort?.contentFieldByType;
331
+ var makeCursor = (record, sort, kindOf2) => {
332
+ const byType = sort.contentFieldByType;
327
333
  if (byType) {
328
- const field = byTypeDescriptor(byType);
334
+ const field2 = byTypeDescriptor(byType);
329
335
  const key = Object.hasOwn(byType, record.typeId) ? byType[record.typeId] : void 0;
330
- const kind2 = key?.field === void 0 ? void 0 : kindOf?.(record, key.field);
336
+ const kind2 = key?.field === void 0 ? void 0 : kindOf2?.(record, key.field);
331
337
  const entry2 = key?.field !== void 0 ? kind2 && contentSortEntry(kind2, record.content[key.field]) : key && constantSortEntry(key.value ?? null);
332
- return encodeEntry(field, entry2 || null, record.id);
338
+ return encodeEntry(field2, entry2 || null, record.id);
333
339
  }
334
- const contentField = sort?.contentField;
335
- if (contentField === void 0) {
336
- const field = getSortField({ sort });
340
+ const { contentField, field } = sort;
341
+ if (field !== void 0) {
337
342
  const value = field === "updatedAt" ? record.updatedAt.getTime() : field === "version" ? record.version : record.createdAt.getTime();
338
343
  return encodeCursor({ kind: "native", field, value, id: record.id });
339
344
  }
340
- const kind = kindOf?.(record, contentField);
345
+ const kind = kindOf2?.(record, contentField);
341
346
  const entry = kind ? contentSortEntry(kind, record.content[contentField]) : null;
342
347
  return encodeEntry(contentField, entry, record.id);
343
348
  };
@@ -347,38 +352,182 @@ var encodeEntry = (field, entry, id) => {
347
352
  return entry.kind === "num" ? encodeCursor({ kind: "num", field, value: entry.num, id }) : encodeCursor({ kind: "text", field, value: entry.text, id });
348
353
  };
349
354
 
355
+ // ../sqlite-shared/dist/mappers.js
356
+ var toMs = (d) => d.getTime();
357
+ var actorFromColumns = (subject, principal) => subject == null ? void 0 : {
358
+ subjectId: subject,
359
+ ...principal != null && { principalId: principal }
360
+ };
361
+ var fromMs = (ms) => new Date(ms);
362
+ var rowToRecord = (row, associations, permissions) => {
363
+ const record = {
364
+ id: row.id,
365
+ typeId: row.type_id,
366
+ createdAt: fromMs(row.created_at),
367
+ updatedAt: fromMs(row.updated_at),
368
+ content: JSON.parse(row.content),
369
+ version: row.version
370
+ };
371
+ if (row.parent_id != null)
372
+ record.parentId = row.parent_id;
373
+ if (row.app_id != null)
374
+ record.appId = row.app_id;
375
+ const createdBy = actorFromColumns(row.entity_id, row.principal_id);
376
+ if (createdBy)
377
+ record.createdBy = createdBy;
378
+ const updatedBy = actorFromColumns(row.updated_by, row.updated_via);
379
+ if (updatedBy)
380
+ record.updatedBy = updatedBy;
381
+ if (row.deleted_at != null)
382
+ record.deletedAt = fromMs(row.deleted_at);
383
+ if (row.unlisted_at != null)
384
+ record.unlistedAt = fromMs(row.unlisted_at);
385
+ if (associations.length)
386
+ record.associations = associations;
387
+ if (permissions.length)
388
+ record.permissions = permissions;
389
+ return record;
390
+ };
391
+ var rowToAssociation = (row) => {
392
+ if (row.kind === "tag") {
393
+ return { kind: "tag", label: row.label };
394
+ }
395
+ if (row.kind === "attachment") {
396
+ const attachmentRecordId = row.attachment_record_id;
397
+ return {
398
+ kind: "attachment",
399
+ label: row.label,
400
+ fileId: row.file_id,
401
+ // Stored as '' when absent, like every other optional column here —
402
+ // the field is omitted rather than served empty, so a round trip
403
+ // produces the association the caller wrote.
404
+ ...attachmentRecordId && { attachmentRecordId }
405
+ };
406
+ }
407
+ return {
408
+ kind: "relationship",
409
+ label: row.label,
410
+ target: rowToTarget(row)
411
+ };
412
+ };
413
+ var associationKeyColumns = (a) => {
414
+ if (a.kind === "attachment")
415
+ return [a.fileId, "", "", "", ""];
416
+ if (a.kind !== "relationship")
417
+ return ["", "", "", "", ""];
418
+ const t = a.target;
419
+ if (t.kind === "entity")
420
+ return ["", "entity", t.entityId, "", ""];
421
+ if (t.kind === "external")
422
+ return ["", "external", t.id, t.ns, ""];
423
+ return ["", "record", t.recordId, "", t.stackUrl ?? ""];
424
+ };
425
+ var permissionKeyColumns = (p) => {
426
+ if (p.kind === "anyone")
427
+ return ["", "", ""];
428
+ const g = p.grantee;
429
+ return g.kind === "entity" ? ["entity", g.entityId, ""] : ["group", g.groupId, g.role];
430
+ };
431
+ var rowToPermission = (row) => {
432
+ if (row.kind === "anyone")
433
+ return { kind: "anyone", label: "read" };
434
+ const id = row.grantee_id;
435
+ return {
436
+ kind: "permission",
437
+ label: row.label,
438
+ grantee: row.grantee_kind === "group" ? { kind: "group", groupId: id, role: row.role } : { kind: "entity", entityId: id }
439
+ };
440
+ };
441
+ var rowToTarget = (row) => {
442
+ const id = row.related_id;
443
+ if (row.related_scope === "entity")
444
+ return { kind: "entity", entityId: id };
445
+ if (row.related_scope === "external") {
446
+ return { kind: "external", ns: row.related_ns, id };
447
+ }
448
+ const stackUrl = row.related_stack;
449
+ return { kind: "record", recordId: id, ...stackUrl && { stackUrl } };
450
+ };
451
+ var rowToType = (row) => {
452
+ const t = {
453
+ id: row.id,
454
+ baseId: row.base_id,
455
+ version: row.version,
456
+ name: row.name,
457
+ schema: JSON.parse(row.schema),
458
+ schemaHash: row.schema_hash,
459
+ createdAt: fromMs(row.created_at)
460
+ };
461
+ if (row.migrates_from)
462
+ t.migratesFrom = row.migrates_from;
463
+ return t;
464
+ };
465
+ var rowToVersion = (row) => {
466
+ const v = {
467
+ version: row.version,
468
+ typeId: row.type_id,
469
+ content: JSON.parse(row.content),
470
+ updatedAt: fromMs(row.updated_at)
471
+ };
472
+ const createdBy = actorFromColumns(row.entity_id, row.principal_id);
473
+ if (createdBy)
474
+ v.createdBy = createdBy;
475
+ const updatedBy = actorFromColumns(row.updated_by, row.updated_via);
476
+ if (updatedBy)
477
+ v.updatedBy = updatedBy;
478
+ return v;
479
+ };
480
+ var rowToJournalEntry = (row) => {
481
+ const e = {
482
+ seq: row.seq,
483
+ at: fromMs(row.at),
484
+ kind: row.kind,
485
+ ops: JSON.parse(row.ops),
486
+ version: row.version,
487
+ typeId: row.type_id
488
+ };
489
+ if (row.parent_id != null)
490
+ e.parentId = row.parent_id;
491
+ if (row.previous_parent_id != null) {
492
+ e.previousParentId = row.previous_parent_id === "" ? null : row.previous_parent_id;
493
+ }
494
+ if (row.actor != null)
495
+ e.actor = JSON.parse(row.actor);
496
+ if (row.associations != null)
497
+ e.associations = JSON.parse(row.associations);
498
+ return e;
499
+ };
500
+
350
501
  // ../sqlite-shared/dist/query.js
351
- var atBudget = (page, rows) => ({
502
+ var atBudget = (page, rows2) => ({
352
503
  sql: page.sql,
353
- params: [...page.params, rows]
504
+ params: [...page.params, rows2]
354
505
  });
355
- var sqlDirection = (query) => {
356
- const dir = query.sort ? query.sort.direction : "desc";
357
- if (dir !== "asc" && dir !== "desc") {
358
- throw new StackBadRequestError(`Invalid sort direction "${dir}": expected "asc" or "desc".`);
359
- }
360
- return dir === "asc" ? "ASC" : "DESC";
506
+ var sqlDirection = (query) => query.sort.direction === "asc" ? "ASC" : "DESC";
507
+ 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`;
508
+ var members = (alias) => `CASE WHEN ${alias}.t = 'object' THEN ${alias}.v ELSE '{}' END`;
509
+ var emptyStatement = () => ({
510
+ tables: [],
511
+ tableParams: [],
512
+ conditions: ["r.id != '_config'"],
513
+ params: []
514
+ });
515
+ var rows = (s, columns, tuples) => {
516
+ const name = `t${s.tables.length}`;
517
+ s.tables.push(`${name} AS MATERIALIZED ${rowsTable(columns)}`);
518
+ s.tableParams.push(JSON.stringify(tuples));
519
+ return name;
361
520
  };
362
- 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`;
363
- var members = (alias) => `CASE WHEN ${alias}.type = 'object' THEN ${alias}.value ELSE '{}' END`;
364
- var contentPathExists = (segments, leaf, params, nextAlias) => {
365
- let key = nextAlias();
366
- let value = nextAlias();
367
- const from = [`json_each(r.content) AS ${key}`, `json_each(${spread(key)}) AS ${value}`];
368
- const where = [`${key}.key = ?`];
369
- params.push(segments[0]);
370
- for (const segment of segments.slice(1)) {
371
- key = nextAlias();
372
- from.push(`json_each(${members(value)}) AS ${key}`);
373
- value = nextAlias();
374
- from.push(`json_each(${spread(key)}) AS ${value}`);
375
- where.push(`${key}.key = ?`);
376
- params.push(segment);
377
- }
378
- where.push(leaf(value));
379
- return `EXISTS (SELECT 1 FROM ${from.join(", ")} WHERE ${where.join(" AND ")})`;
521
+ var rowsTable = (columns) => {
522
+ const named = columns.map((column, i) => `json_extract(value, '$[${i}]') AS ${column}`);
523
+ return `(SELECT ${named.join(", ")} FROM json_each(?))`;
524
+ };
525
+ var CONDITION_COLUMNS = ["scope", "path", "op", "val", "kind"];
526
+ var kindOf = (type) => `CASE ${type} WHEN 'integer' THEN 'number' WHEN 'real' THEN 'number' ELSE ${type} END`;
527
+ var holds = (c, r) => {
528
+ const depth = `json_array_length(${c}.path)`;
529
+ 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})`;
380
530
  };
381
- var equalsLeaf = (alias) => `${alias}.type NOT IN ('object', 'array') AND ${alias}.value = ?`;
382
531
  var SORT_ALIAS = "cs";
383
532
  var cursorMismatch = (cursor, field) => new StackBadRequestError(`Cursor sort field "${cursor.field}" does not match query sort field "${field}"`);
384
533
  var nativeCursor = (cursor, field) => {
@@ -391,37 +540,77 @@ var contentCursor = (cursor, field) => {
391
540
  throw cursorMismatch(cursor, field);
392
541
  return cursor;
393
542
  };
394
- var contentConditions = (predicate, params, nextAlias) => {
395
- const conditions = [];
396
- for (const [key, value] of Object.entries(predicate.content ?? {})) {
397
- const segments = parseContentFilterKey(key);
398
- if (value === null || value === void 0) {
399
- const isNull = contentPathExists(segments, (a) => `${a}.value IS NULL`, params, nextAlias);
400
- const anyValue = contentPathExists(segments, (a) => `${a}.value IS NOT NULL`, params, nextAlias);
401
- conditions.push(`(${isNull} OR NOT ${anyValue})`);
402
- } else {
403
- const match = contentPathExists(segments, equalsLeaf, params, nextAlias);
404
- params.push(value);
405
- conditions.push(match);
406
- }
543
+ var valueKind = (value) => {
544
+ if (typeof value === "boolean")
545
+ return `${value}`;
546
+ if (typeof value === "number")
547
+ return "number";
548
+ return typeof value === "string" ? "text" : null;
549
+ };
550
+ var contentRows = (scope, predicate) => {
551
+ const row = (key, op, value = null) => {
552
+ const path = parseContentFilterKey(key);
553
+ return [scope, path, op, value, valueKind(value)];
554
+ };
555
+ return [
556
+ ...Object.entries(predicate.content ?? {}).map(([key, value]) => (
557
+ // undefined is the same absence null names — JSON drops it on the way
558
+ // over the wire, so treating it as a value would make an in-process
559
+ // query and its wire equivalent disagree.
560
+ value === null || value === void 0 ? row(key, "isNull") : row(key, "equals", value)
561
+ )),
562
+ ...(predicate.contentPresent ?? []).map((key) => row(key, "present"))
563
+ ];
564
+ };
565
+ var inList = (s, column, values) => {
566
+ if (values.length === 1) {
567
+ s.params.push(values[0]);
568
+ return `${column} = ?`;
407
569
  }
408
- for (const key of predicate.contentPresent ?? []) {
409
- conditions.push(contentPathExists(parseContentFilterKey(key), (a) => `${a}.value IS NOT NULL`, params, nextAlias));
570
+ const table = rows(s, ["value"], values.map((value) => [value]));
571
+ return `${column} IN (SELECT value FROM ${table})`;
572
+ };
573
+ var familyRange = (baseId) => [`${baseId}@`, `${baseId}A`];
574
+ var withinFamily = (column, lo, hi) => `(${column} >= ${lo} AND ${column} < ${hi})`;
575
+ var inFamily = (column, baseId) => ({
576
+ sql: withinFamily(column, "?", "?"),
577
+ params: familyRange(baseId)
578
+ });
579
+ var inFamilies = (s, record, baseIds) => {
580
+ if (baseIds.length === 1) {
581
+ const one = inFamily(`${record}.type_id`, baseIds[0]);
582
+ s.params.push(...one.params);
583
+ return one.sql;
584
+ }
585
+ const table = rows(s, ["lo", "hi"], baseIds.map(familyRange));
586
+ return `${record}.id IN (SELECT fr.id FROM ${table} f JOIN records fr ON ${withinFamily("fr.type_id", "f.lo", "f.hi")})`;
587
+ };
588
+ var carriesPermissions = (s, sets) => {
589
+ 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)])));
590
+ 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))`;
591
+ };
592
+ var reachConditions = (s, reach) => {
593
+ if (reach.fence)
594
+ s.conditions.push(inFamilies(s, "r", reach.fence));
595
+ const within = reach.within;
596
+ if (within) {
597
+ const arms = [inFamilies(s, "r", within.baseIds)];
598
+ if (within.authored) {
599
+ s.params.push(within.authored.subjectId);
600
+ arms.push(`(r.entity_id = ? AND ${inFamilies(s, "r", within.authored.baseIds)})`);
601
+ }
602
+ arms.push(carriesPermissions(s, within.permissions));
603
+ s.conditions.push(`(${arms.join(" OR ")})`);
410
604
  }
411
- return conditions;
412
605
  };
413
- var recordConditions = (query) => {
414
- const conditions = ["r.id != '_config'"];
415
- const params = [];
606
+ var recordConditions = (query, reach) => {
607
+ const s = emptyStatement();
608
+ const { conditions, params } = s;
416
609
  const f = query.filter ?? {};
417
- let aliasSeq = 0;
418
- const nextContentAlias = () => `cp${aliasSeq++}`;
419
610
  const inFilter = (column, value) => {
420
611
  if (value === void 0)
421
612
  return;
422
- const values = Array.isArray(value) ? value : [value];
423
- conditions.push(`r.${column} IN (${values.map(() => "?").join(",")})`);
424
- params.push(...values);
613
+ conditions.push(inList(s, `r.${column}`, Array.isArray(value) ? value : [value]));
425
614
  };
426
615
  const rangeFilter = (column, range) => {
427
616
  if (range?.after) {
@@ -439,7 +628,12 @@ var recordConditions = (query) => {
439
628
  if (!f.includeUnlisted) {
440
629
  conditions.push("r.unlisted_at IS NULL");
441
630
  }
631
+ if (reach)
632
+ reachConditions(s, reach);
442
633
  inFilter("type_id", f.typeId);
634
+ if (f.baseId !== void 0) {
635
+ conditions.push(inFamilies(s, "r", Array.isArray(f.baseId) ? f.baseId : [f.baseId]));
636
+ }
443
637
  if (f.parentId !== void 0) {
444
638
  if (f.parentId === null) {
445
639
  conditions.push("r.parent_id IS NULL");
@@ -455,10 +649,10 @@ var recordConditions = (query) => {
455
649
  rangeFilter("updated_at", f.updatedAt);
456
650
  const hasAssociation = (kind, clauses) => `r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = '${kind}'` + clauses.map((c) => ` AND ${c}`).join("") + `)`;
457
651
  if (f.tags?.length) {
458
- for (const tag of f.tags) {
459
- conditions.push(hasAssociation("tag", ["a.label = ?"]));
460
- params.push(tag);
461
- }
652
+ const tags = [...new Set(f.tags)];
653
+ const label = inList(s, "a.label", tags);
654
+ 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) = ?)`);
655
+ params.push(tags.length);
462
656
  }
463
657
  if (f.attachment) {
464
658
  const clauses = [];
@@ -504,15 +698,17 @@ var recordConditions = (query) => {
504
698
  }
505
699
  conditions.push(hasAssociation("relationship", clauses));
506
700
  }
507
- if (f.content || f.contentPresent?.length) {
508
- conditions.push(...contentConditions(f, params, nextContentAlias));
509
- }
701
+ const content = contentRows(null, f);
510
702
  if (f.contentByType) {
511
- const arms = Object.entries(f.contentByType).map(([typeId, arm]) => {
512
- params.push(typeId);
513
- return ["r.type_id = ?", ...contentConditions(arm, params, nextContentAlias)].join(" AND ");
514
- });
515
- conditions.push(`(${arms.map((arm) => `(${arm})`).join(" OR ")})`);
703
+ const arms = Object.entries(f.contentByType);
704
+ const typeIds = arms.map(([typeId]) => typeId);
705
+ conditions.push(inList(s, "r.type_id", typeIds));
706
+ for (const [typeId, arm] of arms)
707
+ content.push(...contentRows(typeId, arm));
708
+ }
709
+ if (content.length > 0) {
710
+ const table = rows(s, CONDITION_COLUMNS, content);
711
+ 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")})`);
516
712
  }
517
713
  if (f.search) {
518
714
  const sanitized = sanitizeFts5Query(f.search);
@@ -523,7 +719,7 @@ var recordConditions = (query) => {
523
719
  conditions.push("0");
524
720
  }
525
721
  }
526
- return { conditions, params };
722
+ return s;
527
723
  };
528
724
  var presentCursorCondition = (cursor, op, params) => {
529
725
  if (cursor.kind === "num") {
@@ -534,56 +730,54 @@ var presentCursorCondition = (cursor, op, params) => {
534
730
  params.push(key, key, cursor.value, cursor.value, cursor.id);
535
731
  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} ?))))))`;
536
732
  };
537
- var statement = (select, from, conditions, params, tail = "") => ({
538
- sql: `SELECT ${select} FROM ${from} WHERE ${conditions.join(" AND ")}${tail}`,
539
- params
540
- });
541
- var buildQueryPlan = (query) => {
733
+ var statement = (s, from, tail) => {
734
+ const select = `SELECT r.* FROM ${from} WHERE ${s.conditions.join(" AND ")}${tail}`;
735
+ const sql = s.tables.length === 0 ? select : `WITH ${s.tables.join(", ")} ${select}`;
736
+ return { sql, params: [...s.tableParams, ...s.params] };
737
+ };
738
+ var buildQueryPlan = (query, reach) => {
542
739
  const dir = sqlDirection(query);
543
740
  const op = dir === "ASC" ? ">" : "<";
544
741
  const decoded = query.cursor ? decodeCursor(query.cursor) : null;
545
- if (query.sort?.contentFieldByType) {
546
- return byTypePlan(query, query.sort.contentFieldByType, dir, op, decoded);
742
+ if (query.sort.contentFieldByType) {
743
+ return byTypePlan(query, reach, query.sort.contentFieldByType, dir, op, decoded);
547
744
  }
548
- const contentField = query.sort?.contentField;
549
- if (contentField === void 0) {
550
- const { conditions, params } = recordConditions(query);
551
- const field = getSortField(query);
745
+ const { contentField, field } = query.sort;
746
+ if (field !== void 0) {
747
+ const where = recordConditions(query, reach);
552
748
  const col = getSortColumn(field);
553
749
  if (decoded) {
554
750
  const cursor2 = nativeCursor(decoded, field);
555
- conditions.push(`(r.${col} ${op} ? OR (r.${col} = ? AND r.id ${op} ?))`);
556
- params.push(cursor2.value, cursor2.value, cursor2.id);
751
+ where.conditions.push(`(r.${col} ${op} ? OR (r.${col} = ? AND r.id ${op} ?))`);
752
+ where.params.push(cursor2.value, cursor2.value, cursor2.id);
557
753
  }
558
- return [
559
- statement("r.*", "records r", conditions, params, ` ORDER BY r.${col} ${dir}, r.id ${dir} LIMIT ?`)
560
- ];
754
+ return [statement(where, "records r", ` ORDER BY r.${col} ${dir}, r.id ${dir} LIMIT ?`)];
561
755
  }
562
756
  const cursor = decoded ? contentCursor(decoded, contentField) : null;
563
757
  const pages = [];
564
758
  if (cursor?.kind !== "absent") {
565
- const { conditions, params } = recordConditions(query);
759
+ const where = recordConditions(query, reach);
566
760
  const from = `content_index ${SORT_ALIAS} JOIN records r ON r.id = ${SORT_ALIAS}.record_id`;
567
- conditions.push(`${SORT_ALIAS}.field = ?`);
568
- params.push(contentField);
761
+ where.conditions.push(`${SORT_ALIAS}.field = ?`);
762
+ where.params.push(contentField);
569
763
  if (cursor)
570
- conditions.push(presentCursorCondition(cursor, op, params));
764
+ where.conditions.push(presentCursorCondition(cursor, op, where.params));
571
765
  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}`;
572
- pages.push(statement("r.*", from, conditions, params, ` ORDER BY ${order} LIMIT ?`));
766
+ pages.push(statement(where, from, ` ORDER BY ${order} LIMIT ?`));
573
767
  }
574
768
  {
575
- const { conditions, params } = recordConditions(query);
576
- conditions.push(`NOT EXISTS (SELECT 1 FROM content_index ${SORT_ALIAS} WHERE ${SORT_ALIAS}.record_id = r.id AND ${SORT_ALIAS}.field = ?)`);
577
- params.push(contentField);
769
+ const where = recordConditions(query, reach);
770
+ where.conditions.push(`NOT EXISTS (SELECT 1 FROM content_index ${SORT_ALIAS} WHERE ${SORT_ALIAS}.record_id = r.id AND ${SORT_ALIAS}.field = ?)`);
771
+ where.params.push(contentField);
578
772
  if (cursor?.kind === "absent") {
579
- conditions.push(`r.id ${op} ?`);
580
- params.push(cursor.id);
773
+ where.conditions.push(`r.id ${op} ?`);
774
+ where.params.push(cursor.id);
581
775
  }
582
- pages.push(statement("r.*", "records r", conditions, params, ` ORDER BY r.id ${dir} LIMIT ?`));
776
+ pages.push(statement(where, "records r", ` ORDER BY r.id ${dir} LIMIT ?`));
583
777
  }
584
778
  return pages;
585
779
  };
586
- var byTypePlan = (query, byType, dir, op, decoded) => {
780
+ var byTypePlan = (query, reach, byType, dir, op, decoded) => {
587
781
  const cursor = decoded ? contentCursor(decoded, byTypeDescriptor(byType)) : null;
588
782
  const fields = [];
589
783
  const constants = [];
@@ -597,42 +791,38 @@ var byTypePlan = (query, byType, dir, op, decoded) => {
597
791
  constants.push([typeId, entry]);
598
792
  }
599
793
  const pages = [];
600
- const fieldOf = (alias, record) => fields.map(() => `(${record}.type_id = ? AND ${alias}.field = ?)`).join(" OR ");
601
794
  if (cursor?.kind !== "absent" && fields.length + constants.length > 0) {
795
+ const where = recordConditions(query, reach);
602
796
  const sources = [];
603
- const sourceParams = [];
604
797
  if (fields.length > 0) {
605
- sources.push(`SELECT c.record_id AS record_id, c.value_rank AS value_rank, c.num_value AS num_value, c.text_key AS text_key, c.text_value AS text_value FROM content_index c JOIN records t ON t.id = c.record_id WHERE ${fieldOf("c", "t")}`);
606
- for (const [typeId, field] of fields)
607
- sourceParams.push(typeId, field);
798
+ const byField = rows(where, ["type_id", "field"], fields);
799
+ 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`);
608
800
  }
609
- for (const [typeId, entry] of constants) {
610
- sources.push("SELECT t.id AS record_id, ? AS value_rank, ? AS num_value, ? AS text_key, ? AS text_value FROM records t WHERE t.type_id = ?");
611
- sourceParams.push(...entry.kind === "num" ? [0, entry.num, null, null] : [1, null, entry.key, entry.text], typeId);
801
+ if (constants.length > 0) {
802
+ 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]));
803
+ 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`);
612
804
  }
613
- const { conditions, params } = recordConditions(query);
614
805
  if (cursor)
615
- conditions.push(presentCursorCondition(cursor, op, params));
806
+ where.conditions.push(presentCursorCondition(cursor, op, where.params));
616
807
  const from = `(${sources.join(" UNION ALL ")}) ${SORT_ALIAS} JOIN records r ON r.id = ${SORT_ALIAS}.record_id`;
617
808
  const order = `${SORT_ALIAS}.value_rank ${dir}, ${SORT_ALIAS}.num_value ${dir}, ${SORT_ALIAS}.text_key ${dir}, ${SORT_ALIAS}.text_value ${dir}, ${SORT_ALIAS}.record_id ${dir}`;
618
- pages.push(statement("r.*", from, conditions, [...sourceParams, ...params], ` ORDER BY ${order} LIMIT ?`));
809
+ pages.push(statement(where, from, ` ORDER BY ${order} LIMIT ?`));
619
810
  }
620
811
  {
621
- const { conditions, params } = recordConditions(query);
812
+ const where = recordConditions(query, reach);
622
813
  if (fields.length > 0) {
623
- conditions.push(`NOT EXISTS (SELECT 1 FROM content_index ${SORT_ALIAS} WHERE ${SORT_ALIAS}.record_id = r.id AND (${fieldOf(SORT_ALIAS, "r")}))`);
624
- for (const [typeId, field] of fields)
625
- params.push(typeId, field);
814
+ const byField = rows(where, ["type_id", "field"], fields);
815
+ 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)`);
626
816
  }
627
817
  if (constants.length > 0) {
628
- conditions.push(`r.type_id NOT IN (${constants.map(() => "?").join(",")})`);
629
- params.push(...constants.map(([typeId]) => typeId));
818
+ const typeIds = constants.map(([typeId]) => typeId);
819
+ where.conditions.push(`NOT (${inList(where, "r.type_id", typeIds)})`);
630
820
  }
631
821
  if (cursor?.kind === "absent") {
632
- conditions.push(`r.id ${op} ?`);
633
- params.push(cursor.id);
822
+ where.conditions.push(`r.id ${op} ?`);
823
+ where.params.push(cursor.id);
634
824
  }
635
- pages.push(statement("r.*", "records r", conditions, params, ` ORDER BY r.id ${dir} LIMIT ?`));
825
+ pages.push(statement(where, "records r", ` ORDER BY r.id ${dir} LIMIT ?`));
636
826
  }
637
827
  return pages;
638
828
  };
@@ -725,154 +915,6 @@ var ContentIndex = class {
725
915
  return fields;
726
916
  }
727
917
  };
728
- var toMs = (d) => d.getTime();
729
- var actorFromColumns = (subject, principal) => subject == null ? void 0 : {
730
- subjectId: subject,
731
- ...principal != null && { principalId: principal }
732
- };
733
- var fromMs = (ms) => new Date(ms);
734
- var rowToRecord = (row, associations) => {
735
- const record = {
736
- id: row.id,
737
- typeId: row.type_id,
738
- createdAt: fromMs(row.created_at),
739
- updatedAt: fromMs(row.updated_at),
740
- content: JSON.parse(row.content),
741
- version: row.version
742
- };
743
- if (row.parent_id != null)
744
- record.parentId = row.parent_id;
745
- if (row.app_id != null)
746
- record.appId = row.app_id;
747
- const createdBy = actorFromColumns(row.entity_id, row.principal_id);
748
- if (createdBy)
749
- record.createdBy = createdBy;
750
- const updatedBy = actorFromColumns(row.updated_by, row.updated_via);
751
- if (updatedBy)
752
- record.updatedBy = updatedBy;
753
- if (row.deleted_at != null)
754
- record.deletedAt = fromMs(row.deleted_at);
755
- if (row.unlisted_at != null)
756
- record.unlistedAt = fromMs(row.unlisted_at);
757
- const partitioned = partitionAssociations(associations);
758
- if (partitioned.associations.length)
759
- record.associations = partitioned.associations;
760
- if (partitioned.permissions.length)
761
- record.permissions = partitioned.permissions;
762
- return record;
763
- };
764
- var rowToAssociation = (row) => {
765
- if (row.kind === "tag") {
766
- return { kind: "tag", label: row.label };
767
- }
768
- if (row.kind === "anyone") {
769
- return { kind: "anyone", label: "read" };
770
- }
771
- if (row.kind === "permission") {
772
- return {
773
- kind: "permission",
774
- label: row.label,
775
- grantee: rowToGrantee(row)
776
- };
777
- }
778
- if (row.kind === "attachment") {
779
- const attachmentRecordId = row.attachment_record_id;
780
- return {
781
- kind: "attachment",
782
- label: row.label,
783
- fileId: row.file_id,
784
- // Stored as '' when absent, like every other optional column here —
785
- // the field is omitted rather than served empty, so a round trip
786
- // produces the association the caller wrote.
787
- ...attachmentRecordId && { attachmentRecordId }
788
- };
789
- }
790
- return {
791
- kind: "relationship",
792
- label: row.label,
793
- target: rowToTarget(row)
794
- };
795
- };
796
- var associationKeyColumns = (a) => {
797
- if (a.kind === "attachment")
798
- return [a.fileId, "", "", "", "", ""];
799
- if (a.kind === "permission") {
800
- const g = a.grantee;
801
- return g.kind === "entity" ? ["", "entity", g.entityId, "", "", ""] : ["", "group", g.groupId, "", "", g.role];
802
- }
803
- if (a.kind !== "relationship")
804
- return ["", "", "", "", "", ""];
805
- const t = a.target;
806
- if (t.kind === "entity")
807
- return ["", "entity", t.entityId, "", "", ""];
808
- if (t.kind === "external")
809
- return ["", "external", t.id, t.ns, "", ""];
810
- return ["", "record", t.recordId, "", t.stackUrl ?? "", ""];
811
- };
812
- var rowToGrantee = (row) => row.related_scope === "group" ? {
813
- kind: "group",
814
- groupId: row.related_id,
815
- role: row.related_role
816
- } : { kind: "entity", entityId: row.related_id };
817
- var rowToTarget = (row) => {
818
- const id = row.related_id;
819
- if (row.related_scope === "entity")
820
- return { kind: "entity", entityId: id };
821
- if (row.related_scope === "external") {
822
- return { kind: "external", ns: row.related_ns, id };
823
- }
824
- const stackUrl = row.related_stack;
825
- return { kind: "record", recordId: id, ...stackUrl && { stackUrl } };
826
- };
827
- var rowToType = (row) => {
828
- const t = {
829
- id: row.id,
830
- baseId: row.base_id,
831
- version: row.version,
832
- name: row.name,
833
- schema: JSON.parse(row.schema),
834
- schemaHash: row.schema_hash,
835
- createdAt: fromMs(row.created_at)
836
- };
837
- if (row.migrates_from)
838
- t.migratesFrom = row.migrates_from;
839
- return t;
840
- };
841
- var rowToVersion = (row) => {
842
- const v = {
843
- version: row.version,
844
- typeId: row.type_id,
845
- content: JSON.parse(row.content),
846
- updatedAt: fromMs(row.updated_at)
847
- };
848
- const createdBy = actorFromColumns(row.entity_id, row.principal_id);
849
- if (createdBy)
850
- v.createdBy = createdBy;
851
- const updatedBy = actorFromColumns(row.updated_by, row.updated_via);
852
- if (updatedBy)
853
- v.updatedBy = updatedBy;
854
- return v;
855
- };
856
- var rowToJournalEntry = (row) => {
857
- const e = {
858
- seq: row.seq,
859
- at: fromMs(row.at),
860
- kind: row.kind,
861
- ops: JSON.parse(row.ops),
862
- version: row.version,
863
- typeId: row.type_id
864
- };
865
- if (row.parent_id != null)
866
- e.parentId = row.parent_id;
867
- if (row.previous_parent_id != null) {
868
- e.previousParentId = row.previous_parent_id === "" ? null : row.previous_parent_id;
869
- }
870
- if (row.actor != null)
871
- e.actor = JSON.parse(row.actor);
872
- if (row.associations != null)
873
- e.associations = JSON.parse(row.associations);
874
- return e;
875
- };
876
918
 
877
919
  // ../sqlite-shared/dist/record-logic.js
878
920
  var asStackBadRequestError = (query, run) => {
@@ -904,6 +946,35 @@ var versionRowValues = (version) => [
904
946
  version.updatedBy?.subjectId ?? null,
905
947
  version.updatedBy?.principalId ?? null
906
948
  ];
949
+ var JOURNAL_COLUMNS = [
950
+ "record_id",
951
+ "seq",
952
+ "at",
953
+ "kind",
954
+ "ops",
955
+ "previous_parent_id",
956
+ "actor",
957
+ "associations",
958
+ "version",
959
+ "type_id",
960
+ "parent_id"
961
+ ];
962
+ var INSERT_JOURNAL_SQL = `INSERT INTO journal (${JOURNAL_COLUMNS.join(", ")})
963
+ VALUES (${JOURNAL_COLUMNS.map(() => "?").join(", ")})`;
964
+ var journalValues = (recordId, seq, at, entry, row) => [
965
+ recordId,
966
+ seq,
967
+ toMs(at),
968
+ entry.kind,
969
+ JSON.stringify(entry.ops),
970
+ // '' is the root; NULL is an entry that names no origin at all.
971
+ entry.previousParentId === void 0 ? null : entry.previousParentId ?? "",
972
+ entry.actor ? JSON.stringify(entry.actor) : null,
973
+ entry.associations?.length ? JSON.stringify(entry.associations) : null,
974
+ row.version,
975
+ row.typeId,
976
+ row.parentId
977
+ ];
907
978
  var SharedSqlRecordLogic = class {
908
979
  exec;
909
980
  searchIndex;
@@ -931,6 +1002,18 @@ var SharedSqlRecordLogic = class {
931
1002
  throw recordNotFound(id);
932
1003
  throw versionConflict(id, expectedVersion, version);
933
1004
  }
1005
+ /**
1006
+ * An aspect write's record, inside its transaction: missing is not found,
1007
+ * a tombstone is refused, and anything else answers its version.
1008
+ */
1009
+ liveVersion(id) {
1010
+ const row = this.exec.get("SELECT version, deleted_at FROM records WHERE id = ?", [id]);
1011
+ if (!row)
1012
+ throw recordNotFound(id);
1013
+ if (row.deleted_at !== null)
1014
+ throw softDeletedRefusal(id);
1015
+ return row.version;
1016
+ }
934
1017
  currentVersion(id) {
935
1018
  return this.exec.get("SELECT version FROM records WHERE id = ?", [id])?.version;
936
1019
  }
@@ -987,41 +1070,71 @@ var SharedSqlRecordLogic = class {
987
1070
  */
988
1071
  async createRecord(record, opts = {}) {
989
1072
  this.exec.transaction(() => {
990
- try {
991
- this.exec.run(`INSERT INTO records
1073
+ this.insertRecord(record);
1074
+ this.appendJournal(record.id, opts.journal);
1075
+ });
1076
+ return record;
1077
+ }
1078
+ /**
1079
+ * createRecord() carrying the record's history as recorded: its version
1080
+ * rows and journal entries, `seq` and `at` included, in the same
1081
+ * transaction. Appends no entry of its own. See
1082
+ * docs/spec/portability.md § Writing history.
1083
+ */
1084
+ async importRecord(record, history) {
1085
+ this.exec.transaction(() => this.writeImported(record, history));
1086
+ return record;
1087
+ }
1088
+ /**
1089
+ * importRecord()'s write, for a caller already inside a transaction:
1090
+ * writeNewStore() writes a store's `_config` seed through it, so the one
1091
+ * Record a store starts with lands as every other Record does.
1092
+ */
1093
+ writeImported(record, history) {
1094
+ this.insertRecord(record);
1095
+ for (const version of history.versions)
1096
+ this.insertVersionRow(record.id, version);
1097
+ for (const entry of history.journal)
1098
+ this.insertJournalRow(record.id, entry);
1099
+ }
1100
+ /**
1101
+ * The record row and every table describing it, for a caller already
1102
+ * inside a transaction. A duplicate id maps to StackConflictError.
1103
+ */
1104
+ insertRecord(record) {
1105
+ try {
1106
+ this.exec.run(`INSERT INTO records
992
1107
  (id, type_id, created_at, updated_at, content, version,
993
1108
  parent_id, entity_id, app_id, principal_id, updated_by, updated_via,
994
1109
  deleted_at, unlisted_at)
995
1110
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
996
- record.id,
997
- record.typeId,
998
- toMs(record.createdAt),
999
- toMs(record.updatedAt),
1000
- JSON.stringify(record.content),
1001
- record.version,
1002
- record.parentId ?? null,
1003
- record.createdBy?.subjectId ?? null,
1004
- record.appId ?? null,
1005
- record.createdBy?.principalId ?? null,
1006
- record.updatedBy?.subjectId ?? null,
1007
- record.updatedBy?.principalId ?? null,
1008
- record.deletedAt ? toMs(record.deletedAt) : null,
1009
- record.unlistedAt ? toMs(record.unlistedAt) : null
1010
- ]);
1011
- } catch (err) {
1012
- if (isUniqueConstraintViolation(err)) {
1013
- throw new StackConflictError(`Record already exists: "${record.id}"`);
1014
- }
1015
- throw err;
1111
+ record.id,
1112
+ record.typeId,
1113
+ toMs(record.createdAt),
1114
+ toMs(record.updatedAt),
1115
+ JSON.stringify(record.content),
1116
+ record.version,
1117
+ record.parentId ?? null,
1118
+ record.createdBy?.subjectId ?? null,
1119
+ record.appId ?? null,
1120
+ record.createdBy?.principalId ?? null,
1121
+ record.updatedBy?.subjectId ?? null,
1122
+ record.updatedBy?.principalId ?? null,
1123
+ record.deletedAt ? toMs(record.deletedAt) : null,
1124
+ record.unlistedAt ? toMs(record.unlistedAt) : null
1125
+ ]);
1126
+ } catch (err) {
1127
+ if (isUniqueConstraintViolation(err)) {
1128
+ throw new StackConflictError(`Record already exists: "${record.id}"`);
1016
1129
  }
1017
- const edges = [...record.associations ?? [], ...record.permissions ?? []];
1018
- if (edges.length)
1019
- this.insertAssociations(record.id, edges);
1020
- this.searchIndex.insert(record.id, JSON.stringify(record.content));
1021
- this.contentIndex.sync(record.id, record.typeId, record.content);
1022
- this.appendJournal(record.id, opts.journal);
1023
- });
1024
- return record;
1130
+ throw err;
1131
+ }
1132
+ if (record.associations?.length)
1133
+ this.insertAssociations(record.id, record.associations);
1134
+ if (record.permissions?.length)
1135
+ this.insertPermissions(record.id, record.permissions);
1136
+ this.searchIndex.insert(record.id, JSON.stringify(record.content));
1137
+ this.contentIndex.sync(record.id, record.typeId, record.content);
1025
1138
  }
1026
1139
  async getRecord(id) {
1027
1140
  return this.readRecord(id);
@@ -1034,56 +1147,55 @@ var SharedSqlRecordLogic = class {
1034
1147
  */
1035
1148
  readRecord(id) {
1036
1149
  const row = this.exec.get("SELECT * FROM records WHERE id = ?", [id]);
1037
- return row ? this.hydrate(row) : null;
1150
+ return row ? this.hydrate([row])[0] : null;
1038
1151
  }
1039
- /** A records row with its association set attached. */
1040
- hydrate(row) {
1041
- return rowToRecord(row, this.getAssociationsForRecord(row.id));
1152
+ /**
1153
+ * Records rows with their associations and permissions attached: one
1154
+ * statement per element table for the whole list, so a page costs three
1155
+ * reads however many records it holds.
1156
+ */
1157
+ hydrate(rows2) {
1158
+ const ids = rows2.map((row) => row.id);
1159
+ const associations = this.elementsByRecord("associations", ids, rowToAssociation);
1160
+ const permissions = this.elementsByRecord("permissions", ids, rowToPermission);
1161
+ return rows2.map((row) => rowToRecord(row, associations.get(row.id) ?? [], permissions.get(row.id) ?? []));
1042
1162
  }
1043
1163
  /**
1044
- * One transaction, so one call is one version whatever it moved, and the
1045
- * patch applies to the content as it stands when the write lands.
1164
+ * The patch applies to the content as it stands when the write lands,
1165
+ * read inside the same transaction.
1046
1166
  */
1047
- async mutateRecord(id, changes, opts = {}) {
1048
- const now = toMs(/* @__PURE__ */ new Date());
1049
- const sets = [];
1050
- const values = [];
1051
- if (changes.parentId !== void 0) {
1052
- sets.push("parent_id = ?");
1053
- values.push(changes.parentId);
1054
- }
1055
- if (changes.unlisted !== void 0) {
1056
- sets.push("unlisted_at = ?");
1057
- values.push(changes.unlisted ? now : null);
1058
- }
1167
+ async patchContent(id, patch, opts = {}) {
1059
1168
  this.exec.transaction(() => {
1060
1169
  const current = this.readForWrite(id, opts.ifVersion);
1061
- if (opts.bumpsVersion === false) {
1062
- if (sets.length > 0) {
1063
- this.exec.run(`UPDATE records SET ${sets.join(", ")} WHERE id = ?`, [...values, id]);
1064
- }
1065
- this.replaceAssociationHalves(id, changes);
1066
- this.appendJournal(id, opts.journal);
1067
- return;
1068
- }
1069
- const merged = changes.contentPatch ? applyMergePatch(current.content, changes.contentPatch) : void 0;
1070
- if (merged !== void 0) {
1071
- sets.unshift("content = ?");
1072
- values.unshift(JSON.stringify(merged));
1073
- }
1074
1170
  if (opts.snapshot)
1075
1171
  this.snapshotBeforeMutation(id, opts.snapshot);
1076
- if (merged !== void 0)
1077
- this.searchIndex.remove(id);
1078
- this.versionedUpdate(id, sets, values, opts, now);
1079
- this.replaceAssociationHalves(id, changes);
1080
- if (merged !== void 0) {
1081
- this.searchIndex.insert(id, JSON.stringify(merged));
1082
- this.contentIndex.sync(id, current.typeId, merged);
1172
+ this.rewriteContent(id, current.typeId, applyMergePatch(current.content, patch), opts);
1173
+ this.appendJournal(id, opts.journal);
1174
+ });
1175
+ return this.reread(id, "patchContent");
1176
+ }
1177
+ async setParent(id, parentId, opts = {}) {
1178
+ return this.aspectUpdate(id, "parent_id = ?", parentId, opts, "setParent");
1179
+ }
1180
+ /** Stamped fresh: `Stack` hands this only a transition. */
1181
+ async setUnlisted(id, unlisted, opts = {}) {
1182
+ return this.aspectUpdate(id, "unlisted_at = ?", unlisted ? toMs(/* @__PURE__ */ new Date()) : null, opts, "setUnlisted");
1183
+ }
1184
+ /**
1185
+ * One records column that never bumps. The tombstone refusal and the
1186
+ * `ifVersion` guard ride in the WHERE clause, as on a versioned update.
1187
+ */
1188
+ aspectUpdate(id, set, value, opts, verb) {
1189
+ const guarded = opts.ifVersion !== void 0;
1190
+ this.exec.transaction(() => {
1191
+ const changed = this.exec.run(`UPDATE records SET ${set}
1192
+ WHERE id = ? AND deleted_at IS NULL${guarded ? " AND version = ?" : ""}`, [value, id, ...guarded ? [opts.ifVersion] : []]);
1193
+ if (changed === 0) {
1194
+ throw versionConflict(id, opts.ifVersion, this.liveVersion(id));
1083
1195
  }
1084
1196
  this.appendJournal(id, opts.journal);
1085
1197
  });
1086
- return this.reread(id, "mutateRecord");
1198
+ return this.reread(id, verb);
1087
1199
  }
1088
1200
  async deleteRecord(id, opts = {}) {
1089
1201
  if (opts.purge) {
@@ -1113,6 +1225,7 @@ var SharedSqlRecordLogic = class {
1113
1225
  this.checkExpectedVersion(id, purged.version, ifVersion);
1114
1226
  this.searchIndex.remove(id);
1115
1227
  this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
1228
+ this.exec.run("DELETE FROM permissions WHERE record_id = ?", [id]);
1116
1229
  this.exec.run("DELETE FROM versions WHERE record_id = ?", [id]);
1117
1230
  this.exec.run("DELETE FROM journal WHERE record_id = ?", [id]);
1118
1231
  this.contentIndex.remove(id);
@@ -1163,20 +1276,27 @@ var SharedSqlRecordLogic = class {
1163
1276
  this.searchIndex.insert(id, json);
1164
1277
  this.contentIndex.sync(id, typeId, content);
1165
1278
  }
1166
- async queryRecords(query) {
1167
- const limit = query.limit ?? 50;
1279
+ queryRecords(query) {
1280
+ return this.page(query);
1281
+ }
1282
+ queryReadable(query, reach) {
1283
+ return this.page(query, reach);
1284
+ }
1285
+ /** `reach`, when given, is one more condition of every statement in the plan. */
1286
+ async page(query, reach) {
1287
+ const { limit } = query;
1168
1288
  const fetch = limit + 1;
1169
- const rows = [];
1170
- for (const page2 of buildQueryPlan(query)) {
1171
- const budget = fetch - rows.length;
1289
+ const rows2 = [];
1290
+ for (const page2 of buildQueryPlan(query, reach)) {
1291
+ const budget = fetch - rows2.length;
1172
1292
  if (budget <= 0)
1173
1293
  break;
1174
1294
  const statement2 = atBudget(page2, budget);
1175
- rows.push(...asStackBadRequestError(query, () => this.exec.all(statement2.sql, statement2.params)));
1295
+ rows2.push(...asStackBadRequestError(query, () => this.exec.all(statement2.sql, statement2.params)));
1176
1296
  }
1177
- const hasMore = rows.length > limit;
1178
- const page = hasMore ? rows.slice(0, limit) : rows;
1179
- const records = page.map((row) => this.hydrate(row));
1297
+ const hasMore = rows2.length > limit;
1298
+ const page = hasMore ? rows2.slice(0, limit) : rows2;
1299
+ const records = this.hydrate(page);
1180
1300
  const lastRecord = records[records.length - 1];
1181
1301
  const cursor = hasMore && lastRecord ? makeCursor(lastRecord, query.sort, (record, field) => this.contentIndex.kindOf(record.typeId, field)) : null;
1182
1302
  return { records, cursor };
@@ -1185,7 +1305,7 @@ var SharedSqlRecordLogic = class {
1185
1305
  * The reference check and the purges share one synchronous transaction,
1186
1306
  * so nothing can add a reference between the two.
1187
1307
  */
1188
- async deleteUnreferencedAttachmentRecords(fileId, metadataTypeIds) {
1308
+ async deleteUnreferencedAttachmentRecords(fileId, metadataBaseId) {
1189
1309
  return this.exec.transaction(() => {
1190
1310
  const referenced = this.exec.all(`SELECT 1 as found FROM associations WHERE kind = 'attachment' AND file_id = ?
1191
1311
  UNION ALL
@@ -1194,11 +1314,9 @@ var SharedSqlRecordLogic = class {
1194
1314
  if (referenced.length) {
1195
1315
  throw new StackConflictError("Attachment is still referenced by one or more records");
1196
1316
  }
1197
- if (metadataTypeIds.length === 0)
1198
- return [];
1199
- const placeholders = metadataTypeIds.map(() => "?").join(", ");
1317
+ const family = inFamily("type_id", metadataBaseId);
1200
1318
  const metaRows = this.exec.all(`SELECT id FROM records
1201
- WHERE type_id IN (${placeholders}) AND json_extract(content, '$.fileId') = ?`, [...metadataTypeIds, fileId]);
1319
+ WHERE ${family.sql} AND json_extract(content, '$.fileId') = ?`, [...family.params, fileId]);
1202
1320
  const deleted = [];
1203
1321
  for (const row of metaRows) {
1204
1322
  const purged = this.purgeRecord(row.id);
@@ -1218,8 +1336,8 @@ var SharedSqlRecordLogic = class {
1218
1336
  conditions.push("version < ?");
1219
1337
  values.push(query.beforeVersion);
1220
1338
  }
1221
- 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);
1222
- return rows.map(rowToVersion);
1339
+ 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);
1340
+ return rows2.map(rowToVersion);
1223
1341
  }
1224
1342
  async getVersion(id, version) {
1225
1343
  return this.readVersion(id, version);
@@ -1241,7 +1359,7 @@ var SharedSqlRecordLogic = class {
1241
1359
  VALUES (?, ?, ${VERSION_COLUMNS.map(() => "?").join(", ")})`, [id, version.version, ...versionRowValues(version)]);
1242
1360
  } catch (err) {
1243
1361
  if (isUniqueConstraintViolation(err)) {
1244
- throw new StackConflictError(`Version ${version.version} already exists for record "${id}" \u2014 a concurrent writer raced past this version. Use ifVersion to detect this before it happens.`);
1362
+ throw new StackConflictError(`Version ${version.version} already exists for record "${id}"`);
1245
1363
  }
1246
1364
  throw err;
1247
1365
  }
@@ -1257,14 +1375,6 @@ var SharedSqlRecordLogic = class {
1257
1375
  SET ${VERSION_COLUMNS.map((c) => `${c} = ?`).join(", ")}
1258
1376
  WHERE record_id = ? AND version = ?`, [...versionRowValues(version), id, version.version]);
1259
1377
  }
1260
- /**
1261
- * Standalone snapshot write for tooling and tests — loud on any
1262
- * collision, since nothing here is about to bump the version. Mutating
1263
- * methods take a `snapshot` option instead; see snapshotBeforeMutation.
1264
- */
1265
- async saveVersion(id, version) {
1266
- this.insertVersionRow(id, version);
1267
- }
1268
1378
  /**
1269
1379
  * The snapshot half of a mutating method's atomic snapshot-then-mutate
1270
1380
  * transaction. A collision is a racing-writer conflict, except an orphan
@@ -1278,8 +1388,9 @@ var SharedSqlRecordLogic = class {
1278
1388
  } catch (err) {
1279
1389
  if (!(err instanceof StackConflictError))
1280
1390
  throw err;
1281
- if (this.currentVersion(id) !== version.version)
1282
- throw err;
1391
+ if (this.currentVersion(id) !== version.version) {
1392
+ throw new StackConflictError(`Version ${version.version} already exists for record "${id}" \u2014 a concurrent writer raced past this version. Use ifVersion to detect this before it happens.`);
1393
+ }
1283
1394
  this.overwriteVersionRow(id, version);
1284
1395
  }
1285
1396
  }
@@ -1288,9 +1399,9 @@ var SharedSqlRecordLogic = class {
1288
1399
  // -------------------------------------------------------
1289
1400
  /**
1290
1401
  * Append one entry, stamped from the row as it now stands, so callers run
1291
- * this inside their write's transaction, after the write. `seq` is
1292
- * allocated in the INSERT itself, so no caller ever holds one it expects
1293
- * to be free. See docs/spec/journal.md § Ordering.
1402
+ * this inside their write's transaction, after the write. `seq` is read
1403
+ * from the log's maximum in that same transaction, so no caller ever holds
1404
+ * one it expects to be free. See docs/spec/journal.md § Ordering.
1294
1405
  */
1295
1406
  appendJournal(recordId, entry) {
1296
1407
  if (!entry)
@@ -1298,26 +1409,20 @@ var SharedSqlRecordLogic = class {
1298
1409
  const row = this.exec.get("SELECT version, type_id, parent_id FROM records WHERE id = ?", [recordId]);
1299
1410
  if (!row)
1300
1411
  throw recordNotFound(recordId);
1301
- this.exec.run(`INSERT INTO journal
1302
- (record_id, seq, at, kind, ops, version, type_id, parent_id,
1303
- previous_parent_id, actor, associations)
1304
- VALUES (
1305
- ?,
1306
- (SELECT COALESCE(MAX(seq), 0) + 1 FROM journal WHERE record_id = ?),
1307
- ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1308
- recordId,
1309
- recordId,
1310
- toMs(/* @__PURE__ */ new Date()),
1311
- entry.kind,
1312
- JSON.stringify(entry.ops),
1313
- row.version,
1314
- row.type_id,
1315
- row.parent_id,
1316
- // '' is the root; NULL is an entry that names no origin at all.
1317
- entry.previousParentId === void 0 ? null : entry.previousParentId ?? "",
1318
- entry.actor ? JSON.stringify(entry.actor) : null,
1319
- entry.associations?.length ? JSON.stringify(entry.associations) : null
1320
- ]);
1412
+ const { seq } = this.exec.get("SELECT COALESCE(MAX(seq), 0) + 1 AS seq FROM journal WHERE record_id = ?", [recordId]);
1413
+ this.exec.run(INSERT_JOURNAL_SQL, journalValues(recordId, seq, /* @__PURE__ */ new Date(), entry, {
1414
+ version: row.version,
1415
+ typeId: row.type_id,
1416
+ parentId: row.parent_id
1417
+ }));
1418
+ }
1419
+ /** A whole entry as recorded, `seq` and `at` included — importRecord()'s half of the journal. */
1420
+ insertJournalRow(recordId, entry) {
1421
+ this.exec.run(INSERT_JOURNAL_SQL, journalValues(recordId, entry.seq, entry.at, entry, {
1422
+ version: entry.version,
1423
+ typeId: entry.typeId,
1424
+ parentId: entry.parentId ?? null
1425
+ }));
1321
1426
  }
1322
1427
  async getJournal(id, query = {}) {
1323
1428
  if (!this.exec.get("SELECT 1 FROM records WHERE id = ?", [id])) {
@@ -1329,8 +1434,8 @@ var SharedSqlRecordLogic = class {
1329
1434
  conditions.push("seq > ?");
1330
1435
  values.push(query.afterSeq);
1331
1436
  }
1332
- 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);
1333
- return rows.map(rowToJournalEntry);
1437
+ 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);
1438
+ return rows2.map(rowToJournalEntry);
1334
1439
  }
1335
1440
  // -------------------------------------------------------
1336
1441
  // Types
@@ -1355,66 +1460,71 @@ var SharedSqlRecordLogic = class {
1355
1460
  return row ? rowToType(row) : null;
1356
1461
  }
1357
1462
  async listTypes() {
1358
- const rows = this.exec.all("SELECT * FROM types ORDER BY base_id, version");
1359
- return rows.map(rowToType);
1463
+ const rows2 = this.exec.all("SELECT * FROM types ORDER BY base_id, version");
1464
+ return rows2.map(rowToType);
1360
1465
  }
1361
1466
  // -------------------------------------------------------
1362
1467
  // Associations
1363
1468
  // -------------------------------------------------------
1364
1469
  /**
1365
- * Never bumps `version`/`updatedAt` and never snapshots — see
1366
- * StackRecordAdapter.amendAssociations(). Removes apply before adds, so
1367
- * removing and re-adding one identity in a call leaves it present.
1470
+ * A write to one of a record's two element tables, inside a transaction
1471
+ * that first checks the record exists, is not soft-deleted, is at
1472
+ * `ifVersion`, and that the table holds `expectSet`. Never bumps and
1473
+ * never snapshots.
1368
1474
  */
1369
- async amendAssociations(recordId, changes, opts = {}) {
1370
- assertOneSurface(changes);
1475
+ elementWrite(id, table, opts, write, verb) {
1371
1476
  this.exec.transaction(() => {
1372
- if (this.currentVersion(recordId) === void 0)
1373
- throw recordNotFound(recordId);
1374
- for (const change of changes) {
1375
- if (change.op !== "remove")
1376
- continue;
1377
- const { association } = change;
1378
- this.exec.run(`DELETE FROM associations
1379
- WHERE record_id = ?
1380
- AND kind = ?
1381
- AND label = ?
1382
- AND file_id = ?
1383
- AND related_scope = ?
1384
- AND related_id = ?
1385
- AND related_ns = ?
1386
- AND related_stack = ?
1387
- AND related_role = ?`, [recordId, association.kind, association.label, ...associationKeyColumns(association)]);
1477
+ this.checkExpectedVersion(id, this.liveVersion(id), opts.ifVersion);
1478
+ if (opts.expectSet) {
1479
+ const map = table === "associations" ? rowToAssociation : rowToPermission;
1480
+ const stored = this.elementsByRecord(table, [id], map).get(id) ?? [];
1481
+ if (!sameAssociationSet(stored, opts.expectSet))
1482
+ throw new ElementSetMovedError(id);
1388
1483
  }
1389
- const adds = changes.flatMap((c) => c.op === "add" ? [c.association] : []);
1390
- if (adds.length)
1391
- this.insertAssociations(recordId, adds);
1392
- this.appendJournal(recordId, opts.journal);
1484
+ write();
1485
+ this.appendJournal(id, opts.journal);
1393
1486
  });
1394
- return this.reread(recordId, "amendAssociations");
1487
+ return this.reread(id, verb);
1395
1488
  }
1396
- /**
1397
- * Each key replaces only within its own half of the partition, which is
1398
- * why a tag write cannot drop an ACL. Caller-transactional.
1399
- * See docs/spec/access-control.md § Record-level permissions.
1400
- */
1401
- replaceAssociationHalves(recordId, changes) {
1402
- if (changes.associations !== void 0) {
1403
- this.replaceAssociations(recordId, changes.associations, "data");
1404
- }
1405
- if (changes.permissions !== void 0) {
1406
- this.replaceAssociations(recordId, changes.permissions, "authority");
1407
- }
1489
+ async editAssociations(id, edits, opts = {}) {
1490
+ return this.elementWrite(id, "associations", opts, () => {
1491
+ for (const edit of edits) {
1492
+ if (edit.op !== "remove")
1493
+ continue;
1494
+ const association = edit.association;
1495
+ this.exec.run(`DELETE FROM associations
1496
+ WHERE record_id = ? AND kind = ? AND label = ?
1497
+ AND file_id = ? AND related_scope = ? AND related_id = ?
1498
+ AND related_ns = ? AND related_stack = ?`, [id, association.kind, association.label, ...associationKeyColumns(association)]);
1499
+ }
1500
+ this.insertAssociations(id, addsOf(edits));
1501
+ }, "editAssociations");
1502
+ }
1503
+ async setAssociations(id, associations, opts = {}) {
1504
+ return this.elementWrite(id, "associations", opts, () => {
1505
+ this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
1506
+ this.insertAssociations(id, associations);
1507
+ }, "setAssociations");
1508
+ }
1509
+ /** editAssociations() over the permissions table. */
1510
+ async editAccess(id, edits, opts = {}) {
1511
+ return this.elementWrite(id, "permissions", opts, () => {
1512
+ for (const edit of edits) {
1513
+ if (edit.op !== "remove")
1514
+ continue;
1515
+ const permission = edit.association;
1516
+ this.exec.run(`DELETE FROM permissions
1517
+ WHERE record_id = ? AND kind = ? AND label = ?
1518
+ AND grantee_kind = ? AND grantee_id = ? AND role = ?`, [id, permission.kind, permission.label, ...permissionKeyColumns(permission)]);
1519
+ }
1520
+ this.insertPermissions(id, addsOf(edits));
1521
+ }, "editAccess");
1408
1522
  }
1409
- /**
1410
- * Swap one half of a record's association set wholesale — the "replace,
1411
- * don't merge" semantics a change set's association list carries. The data
1412
- * half is "not an authority kind", so core's list is the only one.
1413
- */
1414
- replaceAssociations(recordId, associations, half) {
1415
- this.exec.run(`DELETE FROM associations WHERE record_id = ? AND kind ${half === "data" ? "NOT IN" : "IN"} (${AUTHORITY_ASSOCIATION_KINDS.map(() => "?").join(", ")})`, [recordId, ...AUTHORITY_ASSOCIATION_KINDS]);
1416
- if (associations.length)
1417
- this.insertAssociations(recordId, associations);
1523
+ async setAccess(id, permissions, opts = {}) {
1524
+ return this.elementWrite(id, "permissions", opts, () => {
1525
+ this.exec.run("DELETE FROM permissions WHERE record_id = ?", [id]);
1526
+ this.insertPermissions(id, permissions);
1527
+ }, "setAccess");
1418
1528
  }
1419
1529
  /**
1420
1530
  * Upserts on association identity, so re-pointing `attachment_record_id`
@@ -1423,46 +1533,118 @@ var SharedSqlRecordLogic = class {
1423
1533
  */
1424
1534
  insertAssociations(recordId, associations) {
1425
1535
  for (const assoc of associations) {
1426
- try {
1427
- this.exec.run(`INSERT INTO associations
1536
+ this.insertRow(recordId, () => this.exec.run(`INSERT INTO associations
1428
1537
  (record_id, kind, label, file_id, related_scope, related_id, related_ns,
1429
- related_stack, related_role, attachment_record_id)
1430
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1538
+ related_stack, attachment_record_id)
1539
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1431
1540
  ON CONFLICT DO UPDATE SET attachment_record_id = excluded.attachment_record_id`, [
1432
- recordId,
1433
- assoc.kind,
1434
- assoc.label,
1435
- ...associationKeyColumns(assoc),
1436
- assoc.kind === "attachment" ? assoc.attachmentRecordId ?? "" : ""
1437
- ]);
1438
- } catch (err) {
1439
- if (isForeignKeyViolation(err)) {
1440
- throw recordNotFound(recordId);
1441
- }
1442
- throw err;
1443
- }
1541
+ recordId,
1542
+ assoc.kind,
1543
+ assoc.label,
1544
+ ...associationKeyColumns(assoc),
1545
+ assoc.kind === "attachment" ? assoc.attachmentRecordId ?? "" : ""
1546
+ ]));
1547
+ }
1548
+ }
1549
+ /** Every column is in the key, so a repeated element is the same row. */
1550
+ insertPermissions(recordId, permissions) {
1551
+ for (const p of permissions) {
1552
+ this.insertRow(recordId, () => this.exec.run(`INSERT INTO permissions (record_id, kind, label, grantee_kind, grantee_id, role)
1553
+ VALUES (?, ?, ?, ?, ?, ?)
1554
+ ON CONFLICT DO NOTHING`, [recordId, p.kind, p.label, ...permissionKeyColumns(p)]));
1555
+ }
1556
+ }
1557
+ insertRow(recordId, insert) {
1558
+ try {
1559
+ insert();
1560
+ } catch (err) {
1561
+ if (isForeignKeyViolation(err))
1562
+ throw recordNotFound(recordId);
1563
+ throw err;
1444
1564
  }
1445
1565
  }
1446
- getAssociationsForRecord(recordId) {
1447
- const rows = this.exec.all("SELECT * FROM associations WHERE record_id = ?", [recordId]);
1448
- return rows.map(rowToAssociation);
1566
+ /**
1567
+ * One element table's rows for every record in `ids`, in primary-key
1568
+ * order — the order a single record's read has always had.
1569
+ */
1570
+ elementsByRecord(table, ids, map) {
1571
+ const byRecord = /* @__PURE__ */ new Map();
1572
+ const rows2 = this.exec.all(`WITH ids AS MATERIALIZED ${rowsTable(["id"])}
1573
+ SELECT * FROM ${table} WHERE record_id IN (SELECT id FROM ids)
1574
+ ORDER BY ${table === "associations" ? ASSOCIATION_KEY : PERMISSION_KEY}`, [JSON.stringify(ids.map((id) => [id]))]);
1575
+ for (const row of rows2) {
1576
+ const id = row.record_id;
1577
+ const list = byRecord.get(id);
1578
+ if (list)
1579
+ list.push(map(row));
1580
+ else
1581
+ byRecord.set(id, [map(row)]);
1582
+ }
1583
+ return byRecord;
1449
1584
  }
1450
1585
  };
1586
+ var ASSOCIATION_KEY = "record_id, kind, label, file_id, related_scope, related_id, related_ns, related_stack";
1587
+ var PERMISSION_KEY = "record_id, kind, label, grantee_kind, grantee_id, role";
1588
+ var addsOf = (edits) => edits.flatMap((e) => e.op === "add" ? [e.association] : []);
1589
+
1590
+ // ../sqlite-shared/dist/config.js
1591
+ var CONFIG_ROW_SQL = `SELECT content FROM records WHERE id = '_config'`;
1592
+ var missingStoreError = (where) => new Error(`Cannot open: no database found at ${where}. Pass create: 'ifMissing' or 'exclusive' to create one.`);
1593
+ var writeNewStore = (exec, seed) => {
1594
+ exec.transaction(() => {
1595
+ exec.exec(RECORD_SCHEMA_SQL);
1596
+ exec.exec(FTS5_SCHEMA_SQL);
1597
+ exec.run("INSERT INTO format (id, version) VALUES (1, ?)", [FORMAT_VERSION]);
1598
+ new SharedSqlRecordLogic({ exec }).writeImported(seed.config, seed.history);
1599
+ });
1600
+ return seed.config.content;
1601
+ };
1602
+ var readStoredConfig = (exec, where) => {
1603
+ const hasFormat = exec.get(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'format'`) !== void 0;
1604
+ if (!hasFormat)
1605
+ return null;
1606
+ const version = exec.get("SELECT version FROM format WHERE id = 1")?.version;
1607
+ if (version !== FORMAT_VERSION)
1608
+ throw new FormatMismatchError(FORMAT_VERSION, version, where);
1609
+ const { content } = exec.get(CONFIG_ROW_SQL);
1610
+ return JSON.parse(content);
1611
+ };
1612
+ var openStore = async (exec, opts) => {
1613
+ const stored = readStoredConfig(exec, opts.where);
1614
+ if (!stored && opts.mode === "never")
1615
+ throw missingStoreError(opts.where);
1616
+ if (stored && (opts.mode === "exclusive" || opts.seed)) {
1617
+ throw new Error(`Cannot create: database already exists at ${opts.where}. Pass create: 'never' or 'ifMissing' without a seed to open it.`);
1618
+ }
1619
+ applyConnectionPragmas(exec, { wal: opts.wal });
1620
+ if (stored) {
1621
+ const expected = typeof opts.ownerEntityId === "string" ? opts.ownerEntityId : void 0;
1622
+ if (expected !== void 0 && expected !== stored.ownerEntityId) {
1623
+ throw new OwnerMismatchError(expected, stored.ownerEntityId, opts.where);
1624
+ }
1625
+ return stored;
1626
+ }
1627
+ if (opts.seed)
1628
+ return writeNewStore(exec, opts.seed);
1629
+ const owner = typeof opts.ownerEntityId === "function" ? await opts.ownerEntityId() : opts.ownerEntityId;
1630
+ if (!owner) {
1631
+ throw new Error(`Cannot create: no ownerEntityId given for the new database at ${opts.where}.`);
1632
+ }
1633
+ return writeNewStore(exec, newStoreSeed({ ownerEntityId: owner }));
1634
+ };
1451
1635
 
1452
1636
  // ../sqlite-shared/dist/record-adapter.js
1453
1637
  var SQLITE_RECORD_CAPABILITIES = {
1454
1638
  filter: {
1455
1639
  content: "path",
1456
1640
  contentPresent: true,
1457
- // Inside SQLite's limits on expression depth and compound selects; see
1458
- // docs/spec/adapters.md § Adapter capabilities.
1459
- contentByType: 64,
1641
+ contentByType: true,
1460
1642
  search: true
1461
1643
  },
1462
1644
  sort: {
1463
1645
  fields: ["createdAt", "updatedAt", "version"],
1464
1646
  contentField: true,
1465
- contentFieldByType: 64
1647
+ contentFieldByType: true
1466
1648
  },
1467
1649
  limits: {
1468
1650
  attachmentBytes: null,
@@ -1472,12 +1654,10 @@ var SQLITE_RECORD_CAPABILITIES = {
1472
1654
  var SharedSqlRecordAdapter = class {
1473
1655
  capabilities = SQLITE_RECORD_CAPABILITIES;
1474
1656
  ownerEntityId;
1475
- timezone;
1476
1657
  record;
1477
1658
  constructor(exec, config) {
1478
1659
  this.record = new SharedSqlRecordLogic({ exec });
1479
- this.ownerEntityId = config.entityId;
1480
- this.timezone = config.timezone;
1660
+ this.ownerEntityId = config.ownerEntityId;
1481
1661
  }
1482
1662
  // -------------------------------------------------------
1483
1663
  // Records
@@ -1485,11 +1665,20 @@ var SharedSqlRecordAdapter = class {
1485
1665
  createRecord(record, opts) {
1486
1666
  return this.record.createRecord(record, opts);
1487
1667
  }
1668
+ importRecord(record, history) {
1669
+ return this.record.importRecord(record, history);
1670
+ }
1488
1671
  getRecord(id) {
1489
1672
  return this.record.getRecord(id);
1490
1673
  }
1491
- mutateRecord(id, changes, opts) {
1492
- return this.record.mutateRecord(id, changes, opts);
1674
+ patchContent(id, patch, opts) {
1675
+ return this.record.patchContent(id, patch, opts);
1676
+ }
1677
+ setParent(id, parentId, opts) {
1678
+ return this.record.setParent(id, parentId, opts);
1679
+ }
1680
+ setUnlisted(id, unlisted, opts) {
1681
+ return this.record.setUnlisted(id, unlisted, opts);
1493
1682
  }
1494
1683
  deleteRecord(id, opts) {
1495
1684
  return this.record.deleteRecord(id, opts);
@@ -1506,8 +1695,11 @@ var SharedSqlRecordAdapter = class {
1506
1695
  queryRecords(query) {
1507
1696
  return this.record.queryRecords(query);
1508
1697
  }
1509
- deleteUnreferencedAttachmentRecords(fileId, metadataTypeIds) {
1510
- return this.record.deleteUnreferencedAttachmentRecords(fileId, metadataTypeIds);
1698
+ queryReadable(query, reach) {
1699
+ return this.record.queryReadable(query, reach);
1700
+ }
1701
+ deleteUnreferencedAttachmentRecords(fileId, metadataBaseId) {
1702
+ return this.record.deleteUnreferencedAttachmentRecords(fileId, metadataBaseId);
1511
1703
  }
1512
1704
  // -------------------------------------------------------
1513
1705
  // Versions
@@ -1518,9 +1710,6 @@ var SharedSqlRecordAdapter = class {
1518
1710
  getVersion(id, version) {
1519
1711
  return this.record.getVersion(id, version);
1520
1712
  }
1521
- saveVersion(id, version) {
1522
- return this.record.saveVersion(id, version);
1523
- }
1524
1713
  // -------------------------------------------------------
1525
1714
  // Change journal
1526
1715
  // -------------------------------------------------------
@@ -1540,10 +1729,19 @@ var SharedSqlRecordAdapter = class {
1540
1729
  return this.record.listTypes();
1541
1730
  }
1542
1731
  // -------------------------------------------------------
1543
- // Associations
1732
+ // Associations and permissions
1544
1733
  // -------------------------------------------------------
1545
- amendAssociations(recordId, changes, opts) {
1546
- return this.record.amendAssociations(recordId, changes, opts);
1734
+ editAssociations(id, edits, opts) {
1735
+ return this.record.editAssociations(id, edits, opts);
1736
+ }
1737
+ setAssociations(id, associations, opts) {
1738
+ return this.record.setAssociations(id, associations, opts);
1739
+ }
1740
+ editAccess(id, edits, opts) {
1741
+ return this.record.editAccess(id, edits, opts);
1742
+ }
1743
+ setAccess(id, permissions, opts) {
1744
+ return this.record.setAccess(id, permissions, opts);
1547
1745
  }
1548
1746
  };
1549
1747
 
@@ -1564,8 +1762,8 @@ var DurableObjectSqliteExecutor = class {
1564
1762
  return cursor.rowsWritten;
1565
1763
  }
1566
1764
  get(sql, params = []) {
1567
- const rows = this.sql.exec(sql, ...params).toArray();
1568
- return rows[0];
1765
+ const rows2 = this.sql.exec(sql, ...params).toArray();
1766
+ return rows2[0];
1569
1767
  }
1570
1768
  all(sql, params = []) {
1571
1769
  return this.sql.exec(sql, ...params).toArray();
@@ -1583,9 +1781,9 @@ var DoSQLiteRecordAdapter = class _DoSQLiteRecordAdapter extends SharedSqlRecord
1583
1781
  /**
1584
1782
  * Open the adapter for a DO instance, creating its storage on first use.
1585
1783
  * A DO id either already has a config record (reattach) or it doesn't
1586
- * (first call — opts.ownerEntityId/timezone become the config), so there
1587
- * is no `create` option. Schema DDL is `CREATE TABLE IF NOT EXISTS`, so
1588
- * running it every call is idempotent and cheap.
1784
+ * (first call — opts.ownerEntityId becomes the config), so there
1785
+ * is no `create` option. A new store's tables are written in the same
1786
+ * transaction as its config record, so storage holds a whole store or none.
1589
1787
  *
1590
1788
  * DO SQLite manages its own durability and rejects PRAGMA journal_mode
1591
1789
  * outright ("not authorized") — verified against the real runtime, not
@@ -1593,20 +1791,12 @@ var DoSQLiteRecordAdapter = class _DoSQLiteRecordAdapter extends SharedSqlRecord
1593
1791
  */
1594
1792
  static async open(storage, opts) {
1595
1793
  const exec = new DurableObjectSqliteExecutor(storage);
1596
- applyRecordSchema(exec);
1597
- let config = tryReadStackConfig(exec);
1598
- if (config) {
1599
- if (typeof opts.ownerEntityId === "string" && opts.ownerEntityId !== config.entityId) {
1600
- throw new OwnerMismatchError(
1601
- opts.ownerEntityId,
1602
- config.entityId,
1603
- "the Durable Object storage"
1604
- );
1605
- }
1606
- } else {
1607
- const ownerEntityId = typeof opts.ownerEntityId === "function" ? await opts.ownerEntityId() : opts.ownerEntityId;
1608
- config = insertConfigRecord(exec, ownerEntityId, opts.timezone);
1609
- }
1794
+ const config = await openStore(exec, {
1795
+ ...opts,
1796
+ mode: "ifMissing",
1797
+ where: "the Durable Object storage",
1798
+ wal: false
1799
+ });
1610
1800
  return new _DoSQLiteRecordAdapter(exec, config);
1611
1801
  }
1612
1802
  // -------------------------------------------------------