@haverstack/record-adapter-sqlite 0.34.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, writeFileSync, unlinkSync, readFileSync } from 'fs';
2
- import { 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';
2
+ import { StackConflictError, applyMergePatch, StackNotFoundError, StackVersionConflictError, StackBadRequestError, NATIVE_SORT_FIELDS } from '@haverstack/core';
3
+ import { OwnerMismatchError, softDeletedRefusal, sameAssociationSet, ElementSetMovedError, contentSortEntry, constantSortEntry, contentSortKey, parseContentFilterKey } from '@haverstack/core/adapter';
4
4
  import { randomBytes, createHash } from 'crypto';
5
5
 
6
6
  // src/node-sqlite.ts
@@ -27,34 +27,43 @@ var RECORD_SCHEMA_SQL = `
27
27
  unlisted_at INTEGER
28
28
  ) STRICT;
29
29
 
30
- -- Every edge a record carries, authority and data alike: a permission
31
- -- 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.
30
+ -- A record's data associations: tags, attachments and relationships.
31
+ -- Its permissions are in their own table below, so no predicate over this
32
+ -- one can match a grantee as a relationship target.
35
33
  CREATE TABLE IF NOT EXISTS associations (
36
34
  record_id TEXT NOT NULL REFERENCES records(id),
37
- kind TEXT NOT NULL CHECK (kind IN ('tag', 'attachment', 'relationship', 'permission', 'anyone')),
35
+ kind TEXT NOT NULL CHECK (kind IN ('tag', 'attachment', 'relationship')),
38
36
  label TEXT NOT NULL,
39
37
  file_id TEXT NOT NULL DEFAULT '',
40
38
  -- 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')),
39
+ -- related_ns (a foreign namespace) or related_stack (another stack).
40
+ -- All are in the key, so targets differing only by a qualifier are
41
+ -- distinct associations.
42
+ related_scope TEXT NOT NULL DEFAULT '' CHECK (related_scope IN ('', 'record', 'entity', 'external')),
45
43
  related_id TEXT NOT NULL DEFAULT '',
46
44
  related_ns TEXT NOT NULL DEFAULT '',
47
45
  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
46
  -- Out of the key: it annotates a reference rather than naming one, so
52
47
  -- re-pointing it updates the existing row. See docs/spec/attachments.md
53
48
  -- \xA7 Naming the upload a reference came from.
54
49
  attachment_record_id TEXT NOT NULL DEFAULT '',
55
50
  -- Leads with record_id, so its implicit index serves every by-record
56
51
  -- 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)
52
+ PRIMARY KEY (record_id, kind, label, file_id, related_scope, related_id, related_ns, related_stack)
53
+ ) STRICT;
54
+
55
+ -- Who reaches a record, one row per element. An 'anyone' row names no
56
+ -- grantee; a group grantee's role is in the key, because member and
57
+ -- admin name two different sets of people.
58
+ -- See docs/spec/access-control.md \xA7 Record-level permissions.
59
+ CREATE TABLE IF NOT EXISTS permissions (
60
+ record_id TEXT NOT NULL REFERENCES records(id),
61
+ kind TEXT NOT NULL CHECK (kind IN ('permission', 'anyone')),
62
+ label TEXT NOT NULL CHECK (label IN ('read', 'write')),
63
+ grantee_kind TEXT NOT NULL DEFAULT '' CHECK (grantee_kind IN ('', 'entity', 'group')),
64
+ grantee_id TEXT NOT NULL DEFAULT '',
65
+ role TEXT NOT NULL DEFAULT '' CHECK (role IN ('', 'member', 'admin')),
66
+ PRIMARY KEY (record_id, kind, label, grantee_kind, grantee_id, role)
58
67
  ) STRICT;
59
68
 
60
69
  -- Content and the type it is read under. No parent_id, unlisted_at or
@@ -156,6 +165,11 @@ var RECORD_SCHEMA_SQL = `
156
165
  CREATE INDEX IF NOT EXISTS idx_assoc_related ON associations(kind, related_scope, related_ns, related_id, record_id);
157
166
  CREATE INDEX IF NOT EXISTS idx_types_base_id ON types(base_id, version);
158
167
 
168
+ -- The permissions key with record_id moved last, so a scoped query reads
169
+ -- the records carrying an element straight out of the index.
170
+ CREATE INDEX IF NOT EXISTS idx_permissions_element
171
+ ON permissions(kind, label, grantee_kind, grantee_id, role, record_id);
172
+
159
173
  -- The whole content ordering, in index order, for one field at a time:
160
174
  -- a content-sorted page is an ordered walk of a slice of this index.
161
175
  CREATE INDEX IF NOT EXISTS idx_content_index_sort
@@ -346,24 +360,22 @@ var decodeCursor = (cursor) => {
346
360
  throw malformed();
347
361
  };
348
362
  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
363
  var getSortColumn = (field) => field === "createdAt" ? "created_at" : field === "updatedAt" ? "updated_at" : "version";
351
- var makeCursor = (record, sort, kindOf) => {
352
- const byType = sort?.contentFieldByType;
364
+ var makeCursor = (record, sort, kindOf2) => {
365
+ const byType = sort.contentFieldByType;
353
366
  if (byType) {
354
- const field = byTypeDescriptor(byType);
367
+ const field2 = byTypeDescriptor(byType);
355
368
  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);
369
+ const kind2 = key?.field === void 0 ? void 0 : kindOf2?.(record, key.field);
357
370
  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);
371
+ return encodeEntry(field2, entry2 || null, record.id);
359
372
  }
360
- const contentField = sort?.contentField;
361
- if (contentField === void 0) {
362
- const field = getSortField({ sort });
373
+ const { contentField, field } = sort;
374
+ if (field !== void 0) {
363
375
  const value = field === "updatedAt" ? record.updatedAt.getTime() : field === "version" ? record.version : record.createdAt.getTime();
364
376
  return encodeCursor({ kind: "native", field, value, id: record.id });
365
377
  }
366
- const kind = kindOf?.(record, contentField);
378
+ const kind = kindOf2?.(record, contentField);
367
379
  const entry = kind ? contentSortEntry(kind, record.content[contentField]) : null;
368
380
  return encodeEntry(contentField, entry, record.id);
369
381
  };
@@ -373,38 +385,182 @@ var encodeEntry = (field, entry, id) => {
373
385
  return entry.kind === "num" ? encodeCursor({ kind: "num", field, value: entry.num, id }) : encodeCursor({ kind: "text", field, value: entry.text, id });
374
386
  };
375
387
 
388
+ // ../sqlite-shared/dist/mappers.js
389
+ var toMs = (d) => d.getTime();
390
+ var actorFromColumns = (subject, principal) => subject == null ? void 0 : {
391
+ subjectId: subject,
392
+ ...principal != null && { principalId: principal }
393
+ };
394
+ var fromMs = (ms) => new Date(ms);
395
+ var rowToRecord = (row, associations, permissions) => {
396
+ const record = {
397
+ id: row.id,
398
+ typeId: row.type_id,
399
+ createdAt: fromMs(row.created_at),
400
+ updatedAt: fromMs(row.updated_at),
401
+ content: JSON.parse(row.content),
402
+ version: row.version
403
+ };
404
+ if (row.parent_id != null)
405
+ record.parentId = row.parent_id;
406
+ if (row.app_id != null)
407
+ record.appId = row.app_id;
408
+ const createdBy = actorFromColumns(row.entity_id, row.principal_id);
409
+ if (createdBy)
410
+ record.createdBy = createdBy;
411
+ const updatedBy = actorFromColumns(row.updated_by, row.updated_via);
412
+ if (updatedBy)
413
+ record.updatedBy = updatedBy;
414
+ if (row.deleted_at != null)
415
+ record.deletedAt = fromMs(row.deleted_at);
416
+ if (row.unlisted_at != null)
417
+ record.unlistedAt = fromMs(row.unlisted_at);
418
+ if (associations.length)
419
+ record.associations = associations;
420
+ if (permissions.length)
421
+ record.permissions = permissions;
422
+ return record;
423
+ };
424
+ var rowToAssociation = (row) => {
425
+ if (row.kind === "tag") {
426
+ return { kind: "tag", label: row.label };
427
+ }
428
+ if (row.kind === "attachment") {
429
+ const attachmentRecordId = row.attachment_record_id;
430
+ return {
431
+ kind: "attachment",
432
+ label: row.label,
433
+ fileId: row.file_id,
434
+ // Stored as '' when absent, like every other optional column here —
435
+ // the field is omitted rather than served empty, so a round trip
436
+ // produces the association the caller wrote.
437
+ ...attachmentRecordId && { attachmentRecordId }
438
+ };
439
+ }
440
+ return {
441
+ kind: "relationship",
442
+ label: row.label,
443
+ target: rowToTarget(row)
444
+ };
445
+ };
446
+ var associationKeyColumns = (a) => {
447
+ if (a.kind === "attachment")
448
+ return [a.fileId, "", "", "", ""];
449
+ if (a.kind !== "relationship")
450
+ return ["", "", "", "", ""];
451
+ const t = a.target;
452
+ if (t.kind === "entity")
453
+ return ["", "entity", t.entityId, "", ""];
454
+ if (t.kind === "external")
455
+ return ["", "external", t.id, t.ns, ""];
456
+ return ["", "record", t.recordId, "", t.stackUrl ?? ""];
457
+ };
458
+ var permissionKeyColumns = (p) => {
459
+ if (p.kind === "anyone")
460
+ return ["", "", ""];
461
+ const g = p.grantee;
462
+ return g.kind === "entity" ? ["entity", g.entityId, ""] : ["group", g.groupId, g.role];
463
+ };
464
+ var rowToPermission = (row) => {
465
+ if (row.kind === "anyone")
466
+ return { kind: "anyone", label: "read" };
467
+ const id = row.grantee_id;
468
+ return {
469
+ kind: "permission",
470
+ label: row.label,
471
+ grantee: row.grantee_kind === "group" ? { kind: "group", groupId: id, role: row.role } : { kind: "entity", entityId: id }
472
+ };
473
+ };
474
+ var rowToTarget = (row) => {
475
+ const id = row.related_id;
476
+ if (row.related_scope === "entity")
477
+ return { kind: "entity", entityId: id };
478
+ if (row.related_scope === "external") {
479
+ return { kind: "external", ns: row.related_ns, id };
480
+ }
481
+ const stackUrl = row.related_stack;
482
+ return { kind: "record", recordId: id, ...stackUrl && { stackUrl } };
483
+ };
484
+ var rowToType = (row) => {
485
+ const t = {
486
+ id: row.id,
487
+ baseId: row.base_id,
488
+ version: row.version,
489
+ name: row.name,
490
+ schema: JSON.parse(row.schema),
491
+ schemaHash: row.schema_hash,
492
+ createdAt: fromMs(row.created_at)
493
+ };
494
+ if (row.migrates_from)
495
+ t.migratesFrom = row.migrates_from;
496
+ return t;
497
+ };
498
+ var rowToVersion = (row) => {
499
+ const v = {
500
+ version: row.version,
501
+ typeId: row.type_id,
502
+ content: JSON.parse(row.content),
503
+ updatedAt: fromMs(row.updated_at)
504
+ };
505
+ const createdBy = actorFromColumns(row.entity_id, row.principal_id);
506
+ if (createdBy)
507
+ v.createdBy = createdBy;
508
+ const updatedBy = actorFromColumns(row.updated_by, row.updated_via);
509
+ if (updatedBy)
510
+ v.updatedBy = updatedBy;
511
+ return v;
512
+ };
513
+ var rowToJournalEntry = (row) => {
514
+ const e = {
515
+ seq: row.seq,
516
+ at: fromMs(row.at),
517
+ kind: row.kind,
518
+ ops: JSON.parse(row.ops),
519
+ version: row.version,
520
+ typeId: row.type_id
521
+ };
522
+ if (row.parent_id != null)
523
+ e.parentId = row.parent_id;
524
+ if (row.previous_parent_id != null) {
525
+ e.previousParentId = row.previous_parent_id === "" ? null : row.previous_parent_id;
526
+ }
527
+ if (row.actor != null)
528
+ e.actor = JSON.parse(row.actor);
529
+ if (row.associations != null)
530
+ e.associations = JSON.parse(row.associations);
531
+ return e;
532
+ };
533
+
376
534
  // ../sqlite-shared/dist/query.js
377
- var atBudget = (page, rows) => ({
535
+ var atBudget = (page, rows2) => ({
378
536
  sql: page.sql,
379
- params: [...page.params, rows]
537
+ params: [...page.params, rows2]
380
538
  });
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";
539
+ var sqlDirection = (query) => query.sort.direction === "asc" ? "ASC" : "DESC";
540
+ var spread = (alias) => `CASE ${alias}.type WHEN 'array' THEN ${alias}.value WHEN 'object' THEN json_array(json(${alias}.value)) WHEN 'true' THEN '[true]' WHEN 'false' THEN '[false]' ELSE json_array(${alias}.value) END`;
541
+ var members = (alias) => `CASE WHEN ${alias}.t = 'object' THEN ${alias}.v ELSE '{}' END`;
542
+ var emptyStatement = () => ({
543
+ tables: [],
544
+ tableParams: [],
545
+ conditions: ["r.id != '_config'"],
546
+ params: []
547
+ });
548
+ var rows = (s, columns, tuples) => {
549
+ const name = `t${s.tables.length}`;
550
+ s.tables.push(`${name} AS MATERIALIZED ${rowsTable(columns)}`);
551
+ s.tableParams.push(JSON.stringify(tuples));
552
+ return name;
553
+ };
554
+ var rowsTable = (columns) => {
555
+ const named = columns.map((column, i) => `json_extract(value, '$[${i}]') AS ${column}`);
556
+ return `(SELECT ${named.join(", ")} FROM json_each(?))`;
387
557
  };
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 ")})`;
558
+ var CONDITION_COLUMNS = ["scope", "path", "op", "val", "kind"];
559
+ var kindOf = (type) => `CASE ${type} WHEN 'integer' THEN 'number' WHEN 'real' THEN 'number' ELSE ${type} END`;
560
+ var holds = (c, r) => {
561
+ const depth = `json_array_length(${c}.path)`;
562
+ return `(WITH RECURSIVE w(d, v, t) AS (SELECT 0, ${r}.content, 'object' UNION ALL SELECT w.d + 1, e.value, e.type FROM w JOIN json_each(${members("w")}) m JOIN json_each(${spread("m")}) e WHERE w.d < ${depth} AND m.key = json_extract(${c}.path, '$[' || w.d || ']')) SELECT CASE ${c}.op WHEN 'equals' THEN IFNULL(MAX(${kindOf("w.t")} = ${c}.kind AND w.v = ${c}.val), 0) WHEN 'present' THEN IFNULL(MAX(w.t != 'null'), 0) WHEN 'isNull' THEN IFNULL(MAX(w.t = 'null'), 0) OR NOT IFNULL(MAX(w.t != 'null'), 0) END FROM w WHERE w.d = ${depth})`;
406
563
  };
407
- var equalsLeaf = (alias) => `${alias}.type NOT IN ('object', 'array') AND ${alias}.value = ?`;
408
564
  var SORT_ALIAS = "cs";
409
565
  var cursorMismatch = (cursor, field) => new StackBadRequestError(`Cursor sort field "${cursor.field}" does not match query sort field "${field}"`);
410
566
  var nativeCursor = (cursor, field) => {
@@ -417,37 +573,77 @@ var contentCursor = (cursor, field) => {
417
573
  throw cursorMismatch(cursor, field);
418
574
  return cursor;
419
575
  };
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
- }
576
+ var valueKind = (value) => {
577
+ if (typeof value === "boolean")
578
+ return `${value}`;
579
+ if (typeof value === "number")
580
+ return "number";
581
+ return typeof value === "string" ? "text" : null;
582
+ };
583
+ var contentRows = (scope, predicate) => {
584
+ const row = (key, op, value = null) => {
585
+ const path = parseContentFilterKey(key);
586
+ return [scope, path, op, value, valueKind(value)];
587
+ };
588
+ return [
589
+ ...Object.entries(predicate.content ?? {}).map(([key, value]) => (
590
+ // undefined is the same absence null names — JSON drops it on the way
591
+ // over the wire, so treating it as a value would make an in-process
592
+ // query and its wire equivalent disagree.
593
+ value === null || value === void 0 ? row(key, "isNull") : row(key, "equals", value)
594
+ )),
595
+ ...(predicate.contentPresent ?? []).map((key) => row(key, "present"))
596
+ ];
597
+ };
598
+ var inList = (s, column, values) => {
599
+ if (values.length === 1) {
600
+ s.params.push(values[0]);
601
+ return `${column} = ?`;
433
602
  }
434
- for (const key of predicate.contentPresent ?? []) {
435
- conditions.push(contentPathExists(parseContentFilterKey(key), (a) => `${a}.value IS NOT NULL`, params, nextAlias));
603
+ const table = rows(s, ["value"], values.map((value) => [value]));
604
+ return `${column} IN (SELECT value FROM ${table})`;
605
+ };
606
+ var familyRange = (baseId) => [`${baseId}@`, `${baseId}A`];
607
+ var withinFamily = (column, lo, hi) => `(${column} >= ${lo} AND ${column} < ${hi})`;
608
+ var inFamily = (column, baseId) => ({
609
+ sql: withinFamily(column, "?", "?"),
610
+ params: familyRange(baseId)
611
+ });
612
+ var inFamilies = (s, record, baseIds) => {
613
+ if (baseIds.length === 1) {
614
+ const one = inFamily(`${record}.type_id`, baseIds[0]);
615
+ s.params.push(...one.params);
616
+ return one.sql;
617
+ }
618
+ const table = rows(s, ["lo", "hi"], baseIds.map(familyRange));
619
+ return `${record}.id IN (SELECT fr.id FROM ${table} f JOIN records fr ON ${withinFamily("fr.type_id", "f.lo", "f.hi")})`;
620
+ };
621
+ var carriesPermissions = (s, sets) => {
622
+ const elements = rows(s, ["set_index", "set_size", "kind", "label", "grantee_kind", "grantee_id", "role"], sets.flatMap((set, i) => set.map((p) => [i, set.length, p.kind, p.label, ...permissionKeyColumns(p)])));
623
+ return `r.id IN (SELECT p.record_id FROM ${elements} e JOIN permissions p ON p.kind = e.kind AND p.label = e.label AND p.grantee_kind = e.grantee_kind AND p.grantee_id = e.grantee_id AND p.role = e.role GROUP BY p.record_id, e.set_index HAVING COUNT(*) = MAX(e.set_size))`;
624
+ };
625
+ var reachConditions = (s, reach) => {
626
+ if (reach.fence)
627
+ s.conditions.push(inFamilies(s, "r", reach.fence));
628
+ const within = reach.within;
629
+ if (within) {
630
+ const arms = [inFamilies(s, "r", within.baseIds)];
631
+ if (within.authored) {
632
+ s.params.push(within.authored.subjectId);
633
+ arms.push(`(r.entity_id = ? AND ${inFamilies(s, "r", within.authored.baseIds)})`);
634
+ }
635
+ arms.push(carriesPermissions(s, within.permissions));
636
+ s.conditions.push(`(${arms.join(" OR ")})`);
436
637
  }
437
- return conditions;
438
638
  };
439
- var recordConditions = (query) => {
440
- const conditions = ["r.id != '_config'"];
441
- const params = [];
639
+ var recordConditions = (query, reach) => {
640
+ const s = emptyStatement();
641
+ const { conditions, params } = s;
442
642
  const f = query.filter ?? {};
443
- let aliasSeq = 0;
444
- const nextContentAlias = () => `cp${aliasSeq++}`;
445
643
  const inFilter = (column, value) => {
446
644
  if (value === void 0)
447
645
  return;
448
- const values = Array.isArray(value) ? value : [value];
449
- conditions.push(`r.${column} IN (${values.map(() => "?").join(",")})`);
450
- params.push(...values);
646
+ conditions.push(inList(s, `r.${column}`, Array.isArray(value) ? value : [value]));
451
647
  };
452
648
  const rangeFilter = (column, range) => {
453
649
  if (range?.after) {
@@ -465,7 +661,12 @@ var recordConditions = (query) => {
465
661
  if (!f.includeUnlisted) {
466
662
  conditions.push("r.unlisted_at IS NULL");
467
663
  }
664
+ if (reach)
665
+ reachConditions(s, reach);
468
666
  inFilter("type_id", f.typeId);
667
+ if (f.baseId !== void 0) {
668
+ conditions.push(inFamilies(s, "r", Array.isArray(f.baseId) ? f.baseId : [f.baseId]));
669
+ }
469
670
  if (f.parentId !== void 0) {
470
671
  if (f.parentId === null) {
471
672
  conditions.push("r.parent_id IS NULL");
@@ -481,10 +682,10 @@ var recordConditions = (query) => {
481
682
  rangeFilter("updated_at", f.updatedAt);
482
683
  const hasAssociation = (kind, clauses) => `r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = '${kind}'` + clauses.map((c) => ` AND ${c}`).join("") + `)`;
483
684
  if (f.tags?.length) {
484
- for (const tag of f.tags) {
485
- conditions.push(hasAssociation("tag", ["a.label = ?"]));
486
- params.push(tag);
487
- }
685
+ const tags = [...new Set(f.tags)];
686
+ const label = inList(s, "a.label", tags);
687
+ conditions.push(`r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = 'tag' AND ${label} GROUP BY a.record_id HAVING COUNT(DISTINCT a.label) = ?)`);
688
+ params.push(tags.length);
488
689
  }
489
690
  if (f.attachment) {
490
691
  const clauses = [];
@@ -530,15 +731,17 @@ var recordConditions = (query) => {
530
731
  }
531
732
  conditions.push(hasAssociation("relationship", clauses));
532
733
  }
533
- if (f.content || f.contentPresent?.length) {
534
- conditions.push(...contentConditions(f, params, nextContentAlias));
535
- }
734
+ const content = contentRows(null, f);
536
735
  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 ")})`);
736
+ const arms = Object.entries(f.contentByType);
737
+ const typeIds = arms.map(([typeId]) => typeId);
738
+ conditions.push(inList(s, "r.type_id", typeIds));
739
+ for (const [typeId, arm] of arms)
740
+ content.push(...contentRows(typeId, arm));
741
+ }
742
+ if (content.length > 0) {
743
+ const table = rows(s, CONDITION_COLUMNS, content);
744
+ conditions.push(`NOT EXISTS (SELECT 1 FROM ${table} c WHERE (c.scope IS NULL OR c.scope = r.type_id) AND NOT ${holds("c", "r")})`);
542
745
  }
543
746
  if (f.search) {
544
747
  const sanitized = sanitizeFts5Query(f.search);
@@ -549,7 +752,7 @@ var recordConditions = (query) => {
549
752
  conditions.push("0");
550
753
  }
551
754
  }
552
- return { conditions, params };
755
+ return s;
553
756
  };
554
757
  var presentCursorCondition = (cursor, op, params) => {
555
758
  if (cursor.kind === "num") {
@@ -560,56 +763,54 @@ var presentCursorCondition = (cursor, op, params) => {
560
763
  params.push(key, key, cursor.value, cursor.value, cursor.id);
561
764
  return `(${SORT_ALIAS}.value_rank ${op} 1 OR (${SORT_ALIAS}.value_rank = 1 AND (${SORT_ALIAS}.text_key ${op} ? OR (${SORT_ALIAS}.text_key = ? AND (${SORT_ALIAS}.text_value ${op} ? OR (${SORT_ALIAS}.text_value = ? AND ${SORT_ALIAS}.record_id ${op} ?))))))`;
562
765
  };
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) => {
766
+ var statement = (s, from, tail) => {
767
+ const select = `SELECT r.* FROM ${from} WHERE ${s.conditions.join(" AND ")}${tail}`;
768
+ const sql = s.tables.length === 0 ? select : `WITH ${s.tables.join(", ")} ${select}`;
769
+ return { sql, params: [...s.tableParams, ...s.params] };
770
+ };
771
+ var buildQueryPlan = (query, reach) => {
568
772
  const dir = sqlDirection(query);
569
773
  const op = dir === "ASC" ? ">" : "<";
570
774
  const decoded = query.cursor ? decodeCursor(query.cursor) : null;
571
- if (query.sort?.contentFieldByType) {
572
- return byTypePlan(query, query.sort.contentFieldByType, dir, op, decoded);
775
+ if (query.sort.contentFieldByType) {
776
+ return byTypePlan(query, reach, query.sort.contentFieldByType, dir, op, decoded);
573
777
  }
574
- const contentField = query.sort?.contentField;
575
- if (contentField === void 0) {
576
- const { conditions, params } = recordConditions(query);
577
- const field = getSortField(query);
778
+ const { contentField, field } = query.sort;
779
+ if (field !== void 0) {
780
+ const where = recordConditions(query, reach);
578
781
  const col = getSortColumn(field);
579
782
  if (decoded) {
580
783
  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);
784
+ where.conditions.push(`(r.${col} ${op} ? OR (r.${col} = ? AND r.id ${op} ?))`);
785
+ where.params.push(cursor2.value, cursor2.value, cursor2.id);
583
786
  }
584
- return [
585
- statement("r.*", "records r", conditions, params, ` ORDER BY r.${col} ${dir}, r.id ${dir} LIMIT ?`)
586
- ];
787
+ return [statement(where, "records r", ` ORDER BY r.${col} ${dir}, r.id ${dir} LIMIT ?`)];
587
788
  }
588
789
  const cursor = decoded ? contentCursor(decoded, contentField) : null;
589
790
  const pages = [];
590
791
  if (cursor?.kind !== "absent") {
591
- const { conditions, params } = recordConditions(query);
792
+ const where = recordConditions(query, reach);
592
793
  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);
794
+ where.conditions.push(`${SORT_ALIAS}.field = ?`);
795
+ where.params.push(contentField);
595
796
  if (cursor)
596
- conditions.push(presentCursorCondition(cursor, op, params));
797
+ where.conditions.push(presentCursorCondition(cursor, op, where.params));
597
798
  const order = `${SORT_ALIAS}.value_rank ${dir}, ${SORT_ALIAS}.num_value ${dir}, ${SORT_ALIAS}.text_key ${dir}, ${SORT_ALIAS}.text_value ${dir}, ${SORT_ALIAS}.record_id ${dir}`;
598
- pages.push(statement("r.*", from, conditions, params, ` ORDER BY ${order} LIMIT ?`));
799
+ pages.push(statement(where, from, ` ORDER BY ${order} LIMIT ?`));
599
800
  }
600
801
  {
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);
802
+ const where = recordConditions(query, reach);
803
+ where.conditions.push(`NOT EXISTS (SELECT 1 FROM content_index ${SORT_ALIAS} WHERE ${SORT_ALIAS}.record_id = r.id AND ${SORT_ALIAS}.field = ?)`);
804
+ where.params.push(contentField);
604
805
  if (cursor?.kind === "absent") {
605
- conditions.push(`r.id ${op} ?`);
606
- params.push(cursor.id);
806
+ where.conditions.push(`r.id ${op} ?`);
807
+ where.params.push(cursor.id);
607
808
  }
608
- pages.push(statement("r.*", "records r", conditions, params, ` ORDER BY r.id ${dir} LIMIT ?`));
809
+ pages.push(statement(where, "records r", ` ORDER BY r.id ${dir} LIMIT ?`));
609
810
  }
610
811
  return pages;
611
812
  };
612
- var byTypePlan = (query, byType, dir, op, decoded) => {
813
+ var byTypePlan = (query, reach, byType, dir, op, decoded) => {
613
814
  const cursor = decoded ? contentCursor(decoded, byTypeDescriptor(byType)) : null;
614
815
  const fields = [];
615
816
  const constants = [];
@@ -623,42 +824,38 @@ var byTypePlan = (query, byType, dir, op, decoded) => {
623
824
  constants.push([typeId, entry]);
624
825
  }
625
826
  const pages = [];
626
- const fieldOf = (alias, record) => fields.map(() => `(${record}.type_id = ? AND ${alias}.field = ?)`).join(" OR ");
627
827
  if (cursor?.kind !== "absent" && fields.length + constants.length > 0) {
828
+ const where = recordConditions(query, reach);
628
829
  const sources = [];
629
- const sourceParams = [];
630
830
  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);
831
+ const byField = rows(where, ["type_id", "field"], fields);
832
+ sources.push(`SELECT ci.record_id AS record_id, ci.value_rank AS value_rank, ci.num_value AS num_value, ci.text_key AS text_key, ci.text_value AS text_value FROM ${byField} f JOIN content_index ci ON ci.field = f.field JOIN records t ON t.id = ci.record_id AND t.type_id = f.type_id`);
634
833
  }
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);
834
+ if (constants.length > 0) {
835
+ const byConstant = rows(where, ["type_id", "value_rank", "num_value", "text_key", "text_value"], constants.map(([typeId, entry]) => entry.kind === "num" ? [typeId, 0, entry.num, null, null] : [typeId, 1, null, entry.key, entry.text]));
836
+ sources.push(`SELECT t.id AS record_id, k.value_rank AS value_rank, k.num_value AS num_value, k.text_key AS text_key, k.text_value AS text_value FROM ${byConstant} k JOIN records t ON t.type_id = k.type_id`);
638
837
  }
639
- const { conditions, params } = recordConditions(query);
640
838
  if (cursor)
641
- conditions.push(presentCursorCondition(cursor, op, params));
839
+ where.conditions.push(presentCursorCondition(cursor, op, where.params));
642
840
  const from = `(${sources.join(" UNION ALL ")}) ${SORT_ALIAS} JOIN records r ON r.id = ${SORT_ALIAS}.record_id`;
643
841
  const order = `${SORT_ALIAS}.value_rank ${dir}, ${SORT_ALIAS}.num_value ${dir}, ${SORT_ALIAS}.text_key ${dir}, ${SORT_ALIAS}.text_value ${dir}, ${SORT_ALIAS}.record_id ${dir}`;
644
- pages.push(statement("r.*", from, conditions, [...sourceParams, ...params], ` ORDER BY ${order} LIMIT ?`));
842
+ pages.push(statement(where, from, ` ORDER BY ${order} LIMIT ?`));
645
843
  }
646
844
  {
647
- const { conditions, params } = recordConditions(query);
845
+ const where = recordConditions(query, reach);
648
846
  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);
847
+ const byField = rows(where, ["type_id", "field"], fields);
848
+ where.conditions.push(`NOT EXISTS (SELECT 1 FROM ${byField} f JOIN content_index ${SORT_ALIAS} ON ${SORT_ALIAS}.record_id = r.id AND ${SORT_ALIAS}.field = f.field WHERE f.type_id = r.type_id)`);
652
849
  }
653
850
  if (constants.length > 0) {
654
- conditions.push(`r.type_id NOT IN (${constants.map(() => "?").join(",")})`);
655
- params.push(...constants.map(([typeId]) => typeId));
851
+ const typeIds = constants.map(([typeId]) => typeId);
852
+ where.conditions.push(`NOT (${inList(where, "r.type_id", typeIds)})`);
656
853
  }
657
854
  if (cursor?.kind === "absent") {
658
- conditions.push(`r.id ${op} ?`);
659
- params.push(cursor.id);
855
+ where.conditions.push(`r.id ${op} ?`);
856
+ where.params.push(cursor.id);
660
857
  }
661
- pages.push(statement("r.*", "records r", conditions, params, ` ORDER BY r.id ${dir} LIMIT ?`));
858
+ pages.push(statement(where, "records r", ` ORDER BY r.id ${dir} LIMIT ?`));
662
859
  }
663
860
  return pages;
664
861
  };
@@ -751,154 +948,6 @@ var ContentIndex = class {
751
948
  return fields;
752
949
  }
753
950
  };
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
951
 
903
952
  // ../sqlite-shared/dist/record-logic.js
904
953
  var asStackBadRequestError = (query, run) => {
@@ -957,6 +1006,18 @@ var SharedSqlRecordLogic = class {
957
1006
  throw recordNotFound(id);
958
1007
  throw versionConflict(id, expectedVersion, version);
959
1008
  }
1009
+ /**
1010
+ * An aspect write's record, inside its transaction: missing is not found,
1011
+ * a tombstone is refused, and anything else answers its version.
1012
+ */
1013
+ liveVersion(id) {
1014
+ const row = this.exec.get("SELECT version, deleted_at FROM records WHERE id = ?", [id]);
1015
+ if (!row)
1016
+ throw recordNotFound(id);
1017
+ if (row.deleted_at !== null)
1018
+ throw softDeletedRefusal(id);
1019
+ return row.version;
1020
+ }
960
1021
  currentVersion(id) {
961
1022
  return this.exec.get("SELECT version FROM records WHERE id = ?", [id])?.version;
962
1023
  }
@@ -1040,9 +1101,10 @@ var SharedSqlRecordLogic = class {
1040
1101
  }
1041
1102
  throw err;
1042
1103
  }
1043
- const edges = [...record.associations ?? [], ...record.permissions ?? []];
1044
- if (edges.length)
1045
- this.insertAssociations(record.id, edges);
1104
+ if (record.associations?.length)
1105
+ this.insertAssociations(record.id, record.associations);
1106
+ if (record.permissions?.length)
1107
+ this.insertPermissions(record.id, record.permissions);
1046
1108
  this.searchIndex.insert(record.id, JSON.stringify(record.content));
1047
1109
  this.contentIndex.sync(record.id, record.typeId, record.content);
1048
1110
  this.appendJournal(record.id, opts.journal);
@@ -1060,56 +1122,55 @@ var SharedSqlRecordLogic = class {
1060
1122
  */
1061
1123
  readRecord(id) {
1062
1124
  const row = this.exec.get("SELECT * FROM records WHERE id = ?", [id]);
1063
- return row ? this.hydrate(row) : null;
1125
+ return row ? this.hydrate([row])[0] : null;
1064
1126
  }
1065
- /** A records row with its association set attached. */
1066
- hydrate(row) {
1067
- return rowToRecord(row, this.getAssociationsForRecord(row.id));
1127
+ /**
1128
+ * Records rows with their associations and permissions attached: one
1129
+ * statement per element table for the whole list, so a page costs three
1130
+ * reads however many records it holds.
1131
+ */
1132
+ hydrate(rows2) {
1133
+ const ids = rows2.map((row) => row.id);
1134
+ const associations = this.elementsByRecord("associations", ids, rowToAssociation);
1135
+ const permissions = this.elementsByRecord("permissions", ids, rowToPermission);
1136
+ return rows2.map((row) => rowToRecord(row, associations.get(row.id) ?? [], permissions.get(row.id) ?? []));
1068
1137
  }
1069
1138
  /**
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.
1139
+ * The patch applies to the content as it stands when the write lands,
1140
+ * read inside the same transaction.
1072
1141
  */
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
- }
1142
+ async patchContent(id, patch, opts = {}) {
1085
1143
  this.exec.transaction(() => {
1086
1144
  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
1145
  if (opts.snapshot)
1101
1146
  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);
1147
+ this.rewriteContent(id, current.typeId, applyMergePatch(current.content, patch), opts);
1148
+ this.appendJournal(id, opts.journal);
1149
+ });
1150
+ return this.reread(id, "patchContent");
1151
+ }
1152
+ async setParent(id, parentId, opts = {}) {
1153
+ return this.aspectUpdate(id, "parent_id = ?", parentId, opts, "setParent");
1154
+ }
1155
+ /** Stamped fresh: `Stack` hands this only a transition. */
1156
+ async setUnlisted(id, unlisted, opts = {}) {
1157
+ return this.aspectUpdate(id, "unlisted_at = ?", unlisted ? toMs(/* @__PURE__ */ new Date()) : null, opts, "setUnlisted");
1158
+ }
1159
+ /**
1160
+ * One records column that never bumps. The tombstone refusal and the
1161
+ * `ifVersion` guard ride in the WHERE clause, as on a versioned update.
1162
+ */
1163
+ aspectUpdate(id, set, value, opts, verb) {
1164
+ const guarded = opts.ifVersion !== void 0;
1165
+ this.exec.transaction(() => {
1166
+ const changed = this.exec.run(`UPDATE records SET ${set}
1167
+ WHERE id = ? AND deleted_at IS NULL${guarded ? " AND version = ?" : ""}`, [value, id, ...guarded ? [opts.ifVersion] : []]);
1168
+ if (changed === 0) {
1169
+ throw versionConflict(id, opts.ifVersion, this.liveVersion(id));
1109
1170
  }
1110
1171
  this.appendJournal(id, opts.journal);
1111
1172
  });
1112
- return this.reread(id, "mutateRecord");
1173
+ return this.reread(id, verb);
1113
1174
  }
1114
1175
  async deleteRecord(id, opts = {}) {
1115
1176
  if (opts.purge) {
@@ -1139,6 +1200,7 @@ var SharedSqlRecordLogic = class {
1139
1200
  this.checkExpectedVersion(id, purged.version, ifVersion);
1140
1201
  this.searchIndex.remove(id);
1141
1202
  this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
1203
+ this.exec.run("DELETE FROM permissions WHERE record_id = ?", [id]);
1142
1204
  this.exec.run("DELETE FROM versions WHERE record_id = ?", [id]);
1143
1205
  this.exec.run("DELETE FROM journal WHERE record_id = ?", [id]);
1144
1206
  this.contentIndex.remove(id);
@@ -1189,20 +1251,27 @@ var SharedSqlRecordLogic = class {
1189
1251
  this.searchIndex.insert(id, json);
1190
1252
  this.contentIndex.sync(id, typeId, content);
1191
1253
  }
1192
- async queryRecords(query) {
1193
- const limit = query.limit ?? 50;
1254
+ queryRecords(query) {
1255
+ return this.page(query);
1256
+ }
1257
+ queryReadable(query, reach) {
1258
+ return this.page(query, reach);
1259
+ }
1260
+ /** `reach`, when given, is one more condition of every statement in the plan. */
1261
+ async page(query, reach) {
1262
+ const { limit } = query;
1194
1263
  const fetch = limit + 1;
1195
- const rows = [];
1196
- for (const page2 of buildQueryPlan(query)) {
1197
- const budget = fetch - rows.length;
1264
+ const rows2 = [];
1265
+ for (const page2 of buildQueryPlan(query, reach)) {
1266
+ const budget = fetch - rows2.length;
1198
1267
  if (budget <= 0)
1199
1268
  break;
1200
1269
  const statement2 = atBudget(page2, budget);
1201
- rows.push(...asStackBadRequestError(query, () => this.exec.all(statement2.sql, statement2.params)));
1270
+ rows2.push(...asStackBadRequestError(query, () => this.exec.all(statement2.sql, statement2.params)));
1202
1271
  }
1203
- const hasMore = rows.length > limit;
1204
- const page = hasMore ? rows.slice(0, limit) : rows;
1205
- const records = page.map((row) => this.hydrate(row));
1272
+ const hasMore = rows2.length > limit;
1273
+ const page = hasMore ? rows2.slice(0, limit) : rows2;
1274
+ const records = this.hydrate(page);
1206
1275
  const lastRecord = records[records.length - 1];
1207
1276
  const cursor = hasMore && lastRecord ? makeCursor(lastRecord, query.sort, (record, field) => this.contentIndex.kindOf(record.typeId, field)) : null;
1208
1277
  return { records, cursor };
@@ -1211,7 +1280,7 @@ var SharedSqlRecordLogic = class {
1211
1280
  * The reference check and the purges share one synchronous transaction,
1212
1281
  * so nothing can add a reference between the two.
1213
1282
  */
1214
- async deleteUnreferencedAttachmentRecords(fileId, metadataTypeIds) {
1283
+ async deleteUnreferencedAttachmentRecords(fileId, metadataBaseId) {
1215
1284
  return this.exec.transaction(() => {
1216
1285
  const referenced = this.exec.all(`SELECT 1 as found FROM associations WHERE kind = 'attachment' AND file_id = ?
1217
1286
  UNION ALL
@@ -1220,11 +1289,9 @@ var SharedSqlRecordLogic = class {
1220
1289
  if (referenced.length) {
1221
1290
  throw new StackConflictError("Attachment is still referenced by one or more records");
1222
1291
  }
1223
- if (metadataTypeIds.length === 0)
1224
- return [];
1225
- const placeholders = metadataTypeIds.map(() => "?").join(", ");
1292
+ const family = inFamily("type_id", metadataBaseId);
1226
1293
  const metaRows = this.exec.all(`SELECT id FROM records
1227
- WHERE type_id IN (${placeholders}) AND json_extract(content, '$.fileId') = ?`, [...metadataTypeIds, fileId]);
1294
+ WHERE ${family.sql} AND json_extract(content, '$.fileId') = ?`, [...family.params, fileId]);
1228
1295
  const deleted = [];
1229
1296
  for (const row of metaRows) {
1230
1297
  const purged = this.purgeRecord(row.id);
@@ -1244,8 +1311,8 @@ var SharedSqlRecordLogic = class {
1244
1311
  conditions.push("version < ?");
1245
1312
  values.push(query.beforeVersion);
1246
1313
  }
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);
1314
+ const rows2 = this.exec.all(`SELECT * FROM versions WHERE ${conditions.join(" AND ")} ORDER BY version DESC${query.limit !== void 0 ? " LIMIT ?" : ""}`, query.limit !== void 0 ? [...values, query.limit] : values);
1315
+ return rows2.map(rowToVersion);
1249
1316
  }
1250
1317
  async getVersion(id, version) {
1251
1318
  return this.readVersion(id, version);
@@ -1355,8 +1422,8 @@ var SharedSqlRecordLogic = class {
1355
1422
  conditions.push("seq > ?");
1356
1423
  values.push(query.afterSeq);
1357
1424
  }
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);
1425
+ const rows2 = this.exec.all(`SELECT * FROM journal WHERE ${conditions.join(" AND ")} ORDER BY seq ASC${query.limit !== void 0 ? " LIMIT ?" : ""}`, query.limit !== void 0 ? [...values, query.limit] : values);
1426
+ return rows2.map(rowToJournalEntry);
1360
1427
  }
1361
1428
  // -------------------------------------------------------
1362
1429
  // Types
@@ -1381,66 +1448,71 @@ var SharedSqlRecordLogic = class {
1381
1448
  return row ? rowToType(row) : null;
1382
1449
  }
1383
1450
  async listTypes() {
1384
- const rows = this.exec.all("SELECT * FROM types ORDER BY base_id, version");
1385
- return rows.map(rowToType);
1451
+ const rows2 = this.exec.all("SELECT * FROM types ORDER BY base_id, version");
1452
+ return rows2.map(rowToType);
1386
1453
  }
1387
1454
  // -------------------------------------------------------
1388
1455
  // Associations
1389
1456
  // -------------------------------------------------------
1390
1457
  /**
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.
1458
+ * A write to one of a record's two element tables, inside a transaction
1459
+ * that first checks the record exists, is not soft-deleted, is at
1460
+ * `ifVersion`, and that the table holds `expectSet`. Never bumps and
1461
+ * never snapshots.
1394
1462
  */
1395
- async amendAssociations(recordId, changes, opts = {}) {
1396
- assertOneSurface(changes);
1463
+ elementWrite(id, table, opts, write, verb) {
1397
1464
  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)]);
1465
+ this.checkExpectedVersion(id, this.liveVersion(id), opts.ifVersion);
1466
+ if (opts.expectSet) {
1467
+ const map = table === "associations" ? rowToAssociation : rowToPermission;
1468
+ const stored = this.elementsByRecord(table, [id], map).get(id) ?? [];
1469
+ if (!sameAssociationSet(stored, opts.expectSet))
1470
+ throw new ElementSetMovedError(id);
1414
1471
  }
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);
1472
+ write();
1473
+ this.appendJournal(id, opts.journal);
1419
1474
  });
1420
- return this.reread(recordId, "amendAssociations");
1475
+ return this.reread(id, verb);
1421
1476
  }
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
- }
1477
+ async editAssociations(id, edits, opts = {}) {
1478
+ return this.elementWrite(id, "associations", opts, () => {
1479
+ for (const edit of edits) {
1480
+ if (edit.op !== "remove")
1481
+ continue;
1482
+ const association = edit.association;
1483
+ this.exec.run(`DELETE FROM associations
1484
+ WHERE record_id = ? AND kind = ? AND label = ?
1485
+ AND file_id = ? AND related_scope = ? AND related_id = ?
1486
+ AND related_ns = ? AND related_stack = ?`, [id, association.kind, association.label, ...associationKeyColumns(association)]);
1487
+ }
1488
+ this.insertAssociations(id, addsOf(edits));
1489
+ }, "editAssociations");
1490
+ }
1491
+ async setAssociations(id, associations, opts = {}) {
1492
+ return this.elementWrite(id, "associations", opts, () => {
1493
+ this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
1494
+ this.insertAssociations(id, associations);
1495
+ }, "setAssociations");
1496
+ }
1497
+ /** editAssociations() over the permissions table. */
1498
+ async editAccess(id, edits, opts = {}) {
1499
+ return this.elementWrite(id, "permissions", opts, () => {
1500
+ for (const edit of edits) {
1501
+ if (edit.op !== "remove")
1502
+ continue;
1503
+ const permission = edit.association;
1504
+ this.exec.run(`DELETE FROM permissions
1505
+ WHERE record_id = ? AND kind = ? AND label = ?
1506
+ AND grantee_kind = ? AND grantee_id = ? AND role = ?`, [id, permission.kind, permission.label, ...permissionKeyColumns(permission)]);
1507
+ }
1508
+ this.insertPermissions(id, addsOf(edits));
1509
+ }, "editAccess");
1434
1510
  }
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);
1511
+ async setAccess(id, permissions, opts = {}) {
1512
+ return this.elementWrite(id, "permissions", opts, () => {
1513
+ this.exec.run("DELETE FROM permissions WHERE record_id = ?", [id]);
1514
+ this.insertPermissions(id, permissions);
1515
+ }, "setAccess");
1444
1516
  }
1445
1517
  /**
1446
1518
  * Upserts on association identity, so re-pointing `attachment_record_id`
@@ -1449,46 +1521,72 @@ var SharedSqlRecordLogic = class {
1449
1521
  */
1450
1522
  insertAssociations(recordId, associations) {
1451
1523
  for (const assoc of associations) {
1452
- try {
1453
- this.exec.run(`INSERT INTO associations
1524
+ this.insertRow(recordId, () => this.exec.run(`INSERT INTO associations
1454
1525
  (record_id, kind, label, file_id, related_scope, related_id, related_ns,
1455
- related_stack, related_role, attachment_record_id)
1456
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1526
+ related_stack, attachment_record_id)
1527
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1457
1528
  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
- }
1529
+ recordId,
1530
+ assoc.kind,
1531
+ assoc.label,
1532
+ ...associationKeyColumns(assoc),
1533
+ assoc.kind === "attachment" ? assoc.attachmentRecordId ?? "" : ""
1534
+ ]));
1470
1535
  }
1471
1536
  }
1472
- getAssociationsForRecord(recordId) {
1473
- const rows = this.exec.all("SELECT * FROM associations WHERE record_id = ?", [recordId]);
1474
- return rows.map(rowToAssociation);
1537
+ /** Every column is in the key, so a repeated element is the same row. */
1538
+ insertPermissions(recordId, permissions) {
1539
+ for (const p of permissions) {
1540
+ this.insertRow(recordId, () => this.exec.run(`INSERT INTO permissions (record_id, kind, label, grantee_kind, grantee_id, role)
1541
+ VALUES (?, ?, ?, ?, ?, ?)
1542
+ ON CONFLICT DO NOTHING`, [recordId, p.kind, p.label, ...permissionKeyColumns(p)]));
1543
+ }
1544
+ }
1545
+ insertRow(recordId, insert) {
1546
+ try {
1547
+ insert();
1548
+ } catch (err) {
1549
+ if (isForeignKeyViolation(err))
1550
+ throw recordNotFound(recordId);
1551
+ throw err;
1552
+ }
1553
+ }
1554
+ /**
1555
+ * One element table's rows for every record in `ids`, in primary-key
1556
+ * order — the order a single record's read has always had.
1557
+ */
1558
+ elementsByRecord(table, ids, map) {
1559
+ const byRecord = /* @__PURE__ */ new Map();
1560
+ const rows2 = this.exec.all(`WITH ids AS MATERIALIZED ${rowsTable(["id"])}
1561
+ SELECT * FROM ${table} WHERE record_id IN (SELECT id FROM ids)
1562
+ ORDER BY ${table === "associations" ? ASSOCIATION_KEY : PERMISSION_KEY}`, [JSON.stringify(ids.map((id) => [id]))]);
1563
+ for (const row of rows2) {
1564
+ const id = row.record_id;
1565
+ const list = byRecord.get(id);
1566
+ if (list)
1567
+ list.push(map(row));
1568
+ else
1569
+ byRecord.set(id, [map(row)]);
1570
+ }
1571
+ return byRecord;
1475
1572
  }
1476
1573
  };
1574
+ var ASSOCIATION_KEY = "record_id, kind, label, file_id, related_scope, related_id, related_ns, related_stack";
1575
+ var PERMISSION_KEY = "record_id, kind, label, grantee_kind, grantee_id, role";
1576
+ var addsOf = (edits) => edits.flatMap((e) => e.op === "add" ? [e.association] : []);
1477
1577
 
1478
1578
  // ../sqlite-shared/dist/record-adapter.js
1479
1579
  var SQLITE_RECORD_CAPABILITIES = {
1480
1580
  filter: {
1481
1581
  content: "path",
1482
1582
  contentPresent: true,
1483
- // Inside SQLite's limits on expression depth and compound selects; see
1484
- // docs/spec/adapters.md § Adapter capabilities.
1485
- contentByType: 64,
1583
+ contentByType: true,
1486
1584
  search: true
1487
1585
  },
1488
1586
  sort: {
1489
1587
  fields: ["createdAt", "updatedAt", "version"],
1490
1588
  contentField: true,
1491
- contentFieldByType: 64
1589
+ contentFieldByType: true
1492
1590
  },
1493
1591
  limits: {
1494
1592
  attachmentBytes: null,
@@ -1514,8 +1612,14 @@ var SharedSqlRecordAdapter = class {
1514
1612
  getRecord(id) {
1515
1613
  return this.record.getRecord(id);
1516
1614
  }
1517
- mutateRecord(id, changes, opts) {
1518
- return this.record.mutateRecord(id, changes, opts);
1615
+ patchContent(id, patch, opts) {
1616
+ return this.record.patchContent(id, patch, opts);
1617
+ }
1618
+ setParent(id, parentId, opts) {
1619
+ return this.record.setParent(id, parentId, opts);
1620
+ }
1621
+ setUnlisted(id, unlisted, opts) {
1622
+ return this.record.setUnlisted(id, unlisted, opts);
1519
1623
  }
1520
1624
  deleteRecord(id, opts) {
1521
1625
  return this.record.deleteRecord(id, opts);
@@ -1532,8 +1636,11 @@ var SharedSqlRecordAdapter = class {
1532
1636
  queryRecords(query) {
1533
1637
  return this.record.queryRecords(query);
1534
1638
  }
1535
- deleteUnreferencedAttachmentRecords(fileId, metadataTypeIds) {
1536
- return this.record.deleteUnreferencedAttachmentRecords(fileId, metadataTypeIds);
1639
+ queryReadable(query, reach) {
1640
+ return this.record.queryReadable(query, reach);
1641
+ }
1642
+ deleteUnreferencedAttachmentRecords(fileId, metadataBaseId) {
1643
+ return this.record.deleteUnreferencedAttachmentRecords(fileId, metadataBaseId);
1537
1644
  }
1538
1645
  // -------------------------------------------------------
1539
1646
  // Versions
@@ -1566,10 +1673,19 @@ var SharedSqlRecordAdapter = class {
1566
1673
  return this.record.listTypes();
1567
1674
  }
1568
1675
  // -------------------------------------------------------
1569
- // Associations
1676
+ // Associations and permissions
1570
1677
  // -------------------------------------------------------
1571
- amendAssociations(recordId, changes, opts) {
1572
- return this.record.amendAssociations(recordId, changes, opts);
1678
+ editAssociations(id, edits, opts) {
1679
+ return this.record.editAssociations(id, edits, opts);
1680
+ }
1681
+ setAssociations(id, associations, opts) {
1682
+ return this.record.setAssociations(id, associations, opts);
1683
+ }
1684
+ editAccess(id, edits, opts) {
1685
+ return this.record.editAccess(id, edits, opts);
1686
+ }
1687
+ setAccess(id, permissions, opts) {
1688
+ return this.record.setAccess(id, permissions, opts);
1573
1689
  }
1574
1690
  };
1575
1691
  var lockPathFor = (dbPath) => `${dbPath}.lock`;
@@ -1641,8 +1757,8 @@ var SharedTokenLogic = class {
1641
1757
  return { principalId: row.principal_id, subjectId: row.subject_id };
1642
1758
  }
1643
1759
  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) => ({
1760
+ const rows2 = this.exec.all("SELECT id, principal_id, subject_id, label, created_at, expires_at FROM tokens ORDER BY created_at DESC");
1761
+ return rows2.map((row) => ({
1646
1762
  id: row.id,
1647
1763
  principalId: row.principal_id,
1648
1764
  subjectId: row.subject_id,