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