@haverstack/record-adapter-sqlite 0.25.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -24,14 +24,23 @@ var RECORD_SCHEMA_SQL = `
24
24
  updated_by TEXT,
25
25
  updated_via TEXT,
26
26
  deleted_at INTEGER,
27
- unlisted_at INTEGER,
28
- permissions TEXT CHECK (permissions IS NULL OR json_valid(permissions))
27
+ unlisted_at INTEGER
29
28
  ) STRICT;
30
29
 
30
+ -- Every edge a record carries, authority and data alike: a permission
31
+ -- entry has element identity and a delta the same shape as a tag's, so
32
+ -- it earns the same table and the same durability tier. The permissions
33
+ -- and associations fields are projections over it, partitioned by
34
+ -- kind. See docs/spec/access-control.md \xA7 Record-level permissions.
35
+ --
31
36
  -- A relationship's target is (related_scope, related_id) plus one
32
37
  -- qualifier: related_stack for a record in another stack, related_ns for
33
38
  -- a foreign namespace. All four are in the primary key, so two targets
34
- -- differing only by namespace are two associations.
39
+ -- differing only by namespace are two associations. A permission's
40
+ -- grantee reuses them \u2014 related_scope 'entity' or 'group', related_id the
41
+ -- DID or group id \u2014 plus related_role, which only a group grantee sets
42
+ -- and which is in the key because member and admin name two different
43
+ -- sets of people.
35
44
  --
36
45
  -- attachment_record_id stays out of the primary key: it annotates a
37
46
  -- reference rather than naming one, so re-pointing it updates the row an
@@ -43,22 +52,21 @@ var RECORD_SCHEMA_SQL = `
43
52
  -- for the filter directions it cannot answer.
44
53
  CREATE TABLE IF NOT EXISTS associations (
45
54
  record_id TEXT NOT NULL REFERENCES records(id),
46
- kind TEXT NOT NULL CHECK (kind IN ('tag', 'attachment', 'relationship')),
55
+ kind TEXT NOT NULL CHECK (kind IN ('tag', 'attachment', 'relationship', 'permission', 'anyone')),
47
56
  label TEXT NOT NULL,
48
57
  file_id TEXT NOT NULL DEFAULT '',
49
- related_scope TEXT NOT NULL DEFAULT '' CHECK (related_scope IN ('', 'record', 'entity', 'external')),
58
+ related_scope TEXT NOT NULL DEFAULT '' CHECK (related_scope IN ('', 'record', 'entity', 'external', 'group')),
50
59
  related_id TEXT NOT NULL DEFAULT '',
51
60
  related_ns TEXT NOT NULL DEFAULT '',
52
61
  related_stack TEXT NOT NULL DEFAULT '',
62
+ related_role TEXT NOT NULL DEFAULT '' CHECK (related_role IN ('', 'member', 'admin')),
53
63
  attachment_record_id TEXT NOT NULL DEFAULT '',
54
- PRIMARY KEY (record_id, kind, label, file_id, related_scope, related_id, related_ns, related_stack)
64
+ PRIMARY KEY (record_id, kind, label, file_id, related_scope, related_id, related_ns, related_stack, related_role)
55
65
  ) STRICT;
56
66
 
57
- -- parent_id holds the container the record sat in, NULL for the root --
58
- -- the same encoding records.parent_id uses, since a snapshot states
59
- -- where the record was rather than an instruction to apply.
60
- -- No associations column: associate()/dissociate() don't bump version,
61
- -- so no version ever snapshots the association set. See
67
+ -- Content and the type it is read under. No parent_id, unlisted_at or
68
+ -- associations column: none of those aspects bumps version, so no
69
+ -- version ever snapshots one. See
62
70
  -- docs/spec/versioning.md \xA7 Version history.
63
71
  CREATE TABLE IF NOT EXISTS versions (
64
72
  record_id TEXT NOT NULL REFERENCES records(id),
@@ -69,8 +77,6 @@ var RECORD_SCHEMA_SQL = `
69
77
  entity_id TEXT,
70
78
  updated_by TEXT,
71
79
  updated_via TEXT,
72
- parent_id TEXT,
73
- permissions TEXT CHECK (permissions IS NULL OR json_valid(permissions)),
74
80
  PRIMARY KEY (record_id, version)
75
81
  ) STRICT;
76
82
 
@@ -78,17 +84,15 @@ var RECORD_SCHEMA_SQL = `
78
84
  -- them, and the association deltas nothing else retains. Envelope-level
79
85
  -- by design \u2014 content lives on a versions row, and copying it here
80
86
  -- would make the journal the larger of the two stores.
81
- --
82
- -- seq is dense from 1 per record and is the only ordering: at is wall
83
- -- clock, and version stands still across an association change, so
84
- -- neither orders the log alone. It is allocated inside the appending
85
- -- write, which is why this table needs none of the collision healing
86
- -- versions does. See docs/spec/versioning.md \xA7 The change journal.
87
+ -- See docs/spec/journal.md.
87
88
  CREATE TABLE IF NOT EXISTS journal (
88
89
  record_id TEXT NOT NULL REFERENCES records(id),
89
90
  seq INTEGER NOT NULL,
90
91
  at INTEGER NOT NULL,
91
- kind TEXT NOT NULL CHECK (kind IN ('created', 'changed', 'deleted', 'purged')),
92
+ -- No 'purged': a purge destroys the log rather than appending to it,
93
+ -- so the constraint names exactly the kinds an entry can carry.
94
+ -- See docs/spec/journal.md \xA7 A hard delete destroys the journal.
95
+ kind TEXT NOT NULL CHECK (kind IN ('created', 'changed', 'deleted')),
92
96
  ops TEXT NOT NULL CHECK (json_valid(ops)),
93
97
  version INTEGER NOT NULL,
94
98
  type_id TEXT NOT NULL,
@@ -99,9 +103,9 @@ var RECORD_SCHEMA_SQL = `
99
103
  -- the same input/state split parentId follows everywhere.
100
104
  previous_parent_id TEXT,
101
105
  actor TEXT CHECK (actor IS NULL OR json_valid(actor)),
102
- associations_added TEXT CHECK (associations_added IS NULL OR json_valid(associations_added)),
103
- associations_removed TEXT CHECK (associations_removed IS NULL OR json_valid(associations_removed)),
104
- associations_replaced TEXT CHECK (associations_replaced IS NULL OR json_valid(associations_replaced)),
106
+ -- One tagged edit per association the write moved, each carrying the
107
+ -- state it displaced. See docs/spec/journal.md \xA7 The entry.
108
+ associations TEXT CHECK (associations IS NULL OR json_valid(associations)),
105
109
  PRIMARY KEY (record_id, seq)
106
110
  ) STRICT;
107
111
 
@@ -584,6 +588,10 @@ var buildQueryPlan = (query) => {
584
588
  // ../sqlite-shared/dist/mappers.js
585
589
  var toMs = (d) => d.getTime();
586
590
  var fromMs = (ms) => new Date(ms);
591
+ var partitionAssociations = (associations) => ({
592
+ associations: associations.filter((a) => a.kind !== "permission" && a.kind !== "anyone"),
593
+ permissions: associations.filter((a) => a.kind === "permission" || a.kind === "anyone")
594
+ });
587
595
  var rowToRecord = (row, associations) => {
588
596
  const record = {
589
597
  id: row.id,
@@ -609,16 +617,27 @@ var rowToRecord = (row, associations) => {
609
617
  record.deletedAt = fromMs(row.deleted_at);
610
618
  if (row.unlisted_at != null)
611
619
  record.unlistedAt = fromMs(row.unlisted_at);
612
- if (row.permissions != null)
613
- record.permissions = JSON.parse(row.permissions);
614
- if (associations.length)
615
- record.associations = associations;
620
+ const partitioned = partitionAssociations(associations);
621
+ if (partitioned.associations.length)
622
+ record.associations = partitioned.associations;
623
+ if (partitioned.permissions.length)
624
+ record.permissions = partitioned.permissions;
616
625
  return record;
617
626
  };
618
627
  var rowToAssociation = (row) => {
619
628
  if (row.kind === "tag") {
620
629
  return { kind: "tag", label: row.label };
621
630
  }
631
+ if (row.kind === "anyone") {
632
+ return { kind: "anyone", label: "read" };
633
+ }
634
+ if (row.kind === "permission") {
635
+ return {
636
+ kind: "permission",
637
+ label: row.label,
638
+ grantee: rowToGrantee(row)
639
+ };
640
+ }
622
641
  if (row.kind === "attachment") {
623
642
  const attachmentRecordId = row.attachment_record_id;
624
643
  return {
@@ -639,16 +658,25 @@ var rowToAssociation = (row) => {
639
658
  };
640
659
  var associationKeyColumns = (a) => {
641
660
  if (a.kind === "attachment")
642
- return [a.fileId, "", "", "", ""];
661
+ return [a.fileId, "", "", "", "", ""];
662
+ if (a.kind === "permission") {
663
+ const g = a.grantee;
664
+ return g.scope === "entity" ? ["", "entity", g.entityId, "", "", ""] : ["", "group", g.groupId, "", "", g.role];
665
+ }
643
666
  if (a.kind !== "relationship")
644
- return ["", "", "", "", ""];
667
+ return ["", "", "", "", "", ""];
645
668
  const t = a.target;
646
669
  if (t.scope === "entity")
647
- return ["", "entity", t.entityId, "", ""];
670
+ return ["", "entity", t.entityId, "", "", ""];
648
671
  if (t.scope === "external")
649
- return ["", "external", t.id, t.ns, ""];
650
- return ["", "record", t.recordId, "", t.stackUrl ?? ""];
672
+ return ["", "external", t.id, t.ns, "", ""];
673
+ return ["", "record", t.recordId, "", t.stackUrl ?? "", ""];
651
674
  };
675
+ var rowToGrantee = (row) => row.related_scope === "group" ? {
676
+ scope: "group",
677
+ groupId: row.related_id,
678
+ role: row.related_role
679
+ } : { scope: "entity", entityId: row.related_id };
652
680
  var rowToTarget = (row) => {
653
681
  const id = row.related_id;
654
682
  if (row.related_scope === "entity")
@@ -686,10 +714,6 @@ var rowToVersion = (row) => {
686
714
  v.updatedBy = row.updated_by;
687
715
  if (row.updated_via != null)
688
716
  v.updatedVia = row.updated_via;
689
- if (row.parent_id != null)
690
- v.parentId = row.parent_id;
691
- if (row.permissions != null)
692
- v.permissions = JSON.parse(row.permissions);
693
717
  return v;
694
718
  };
695
719
  var rowToJournalEntry = (row) => {
@@ -708,15 +732,8 @@ var rowToJournalEntry = (row) => {
708
732
  }
709
733
  if (row.actor != null)
710
734
  e.actor = JSON.parse(row.actor);
711
- if (row.associations_added != null) {
712
- e.associationsAdded = JSON.parse(row.associations_added);
713
- }
714
- if (row.associations_removed != null) {
715
- e.associationsRemoved = JSON.parse(row.associations_removed);
716
- }
717
- if (row.associations_replaced != null) {
718
- e.associationsReplaced = JSON.parse(row.associations_replaced);
719
- }
735
+ if (row.associations != null)
736
+ e.associations = JSON.parse(row.associations);
720
737
  return e;
721
738
  };
722
739
 
@@ -761,9 +778,7 @@ var VERSION_COLUMNS = [
761
778
  "updated_at",
762
779
  "entity_id",
763
780
  "updated_by",
764
- "updated_via",
765
- "parent_id",
766
- "permissions"
781
+ "updated_via"
767
782
  ];
768
783
  var versionRowValues = (version) => [
769
784
  version.typeId,
@@ -771,9 +786,7 @@ var versionRowValues = (version) => [
771
786
  toMs(version.updatedAt),
772
787
  version.entityId ?? null,
773
788
  version.updatedBy ?? null,
774
- version.updatedVia ?? null,
775
- version.parentId ?? null,
776
- version.permissions ? JSON.stringify(version.permissions) : null
789
+ version.updatedVia ?? null
777
790
  ];
778
791
  var indexedFieldKinds = (schema) => {
779
792
  const kinds = /* @__PURE__ */ new Map();
@@ -784,7 +797,7 @@ var indexedFieldKinds = (schema) => {
784
797
  }
785
798
  return kinds;
786
799
  };
787
- var SharedSqlRecordLogic = class {
800
+ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
788
801
  deps;
789
802
  /**
790
803
  * Per-typeId cache of the fields content_index covers and their declared
@@ -890,8 +903,8 @@ var SharedSqlRecordLogic = class {
890
903
  this.exec.run(`INSERT INTO records
891
904
  (id, type_id, created_at, updated_at, content, version,
892
905
  parent_id, entity_id, app_id, principal_id, updated_by, updated_via,
893
- deleted_at, unlisted_at, permissions)
894
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
906
+ deleted_at, unlisted_at)
907
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
895
908
  record.id,
896
909
  record.typeId,
897
910
  toMs(record.createdAt),
@@ -905,8 +918,7 @@ var SharedSqlRecordLogic = class {
905
918
  record.updatedBy ?? null,
906
919
  record.updatedVia ?? null,
907
920
  record.deletedAt ? toMs(record.deletedAt) : null,
908
- record.unlistedAt ? toMs(record.unlistedAt) : null,
909
- record.permissions ? JSON.stringify(record.permissions) : null
921
+ record.unlistedAt ? toMs(record.unlistedAt) : null
910
922
  ]);
911
923
  } catch (err) {
912
924
  if (isUniqueConstraintViolation(err)) {
@@ -914,9 +926,9 @@ var SharedSqlRecordLogic = class {
914
926
  }
915
927
  throw err;
916
928
  }
917
- if (record.associations?.length) {
918
- this.insertAssociations(record.id, record.associations);
919
- }
929
+ const edges = [...record.associations ?? [], ...record.permissions ?? []];
930
+ if (edges.length)
931
+ this.insertAssociations(record.id, edges);
920
932
  fts5Strategy.insert(this.exec, record.id, JSON.stringify(record.content));
921
933
  this.syncContentIndex(record.id, record.typeId, record.content);
922
934
  this.appendJournal(record.id, opts.journal);
@@ -954,13 +966,13 @@ var SharedSqlRecordLogic = class {
954
966
  * one call is one version whatever it moved.
955
967
  *
956
968
  * `opts.bumpsVersion: false` is a change set that touches only
957
- * `associations` — Stack computes this from which ops the set actually
958
- * moves. Such a write skips the records-table UPDATE (and the snapshot)
959
- * entirely: nothing on that row changes, so there is nothing to bump and
960
- * no version to snapshot. `expectedVersion`, if given, is still
961
- * re-checked synchronously inside the transaction — the CAS guard the
962
- * bumping path gets from its UPDATE's WHERE clause, restated as a read
963
- * because there is no UPDATE here to carry it. See
969
+ * `associations`, `parentId` and/or `unlisted` — Stack computes this from
970
+ * which ops the set actually moves. Such a write still lands its columns,
971
+ * but leaves `version`, `updatedAt` and the actor stamps exactly as they
972
+ * stood and takes no snapshot. `expectedVersion`, if given, is re-checked
973
+ * synchronously inside the transaction — the CAS guard the bumping path
974
+ * gets from its UPDATE's WHERE clause, restated as a read because the
975
+ * UPDATE here carries no version predicate. See
964
976
  * docs/spec/versioning.md § Version history.
965
977
  *
966
978
  * The expectedVersion check for a bumping write is standalone rather than
@@ -974,38 +986,35 @@ var SharedSqlRecordLogic = class {
974
986
  if (!existing)
975
987
  throw new StackNotFoundError(`Record not found: "${id}"`);
976
988
  this.checkExpectedVersion(existing, opts.expectedVersion);
989
+ const now = toMs(/* @__PURE__ */ new Date());
990
+ const sets = [];
991
+ const values = [];
992
+ if (changes.parentId !== void 0) {
993
+ sets.push("parent_id = ?");
994
+ values.push(changes.parentId);
995
+ }
996
+ if (changes.unlisted !== void 0) {
997
+ sets.push("unlisted_at = ?");
998
+ values.push(changes.unlisted ? now : null);
999
+ }
977
1000
  if (opts.bumpsVersion === false) {
978
1001
  this.exec.transaction(() => {
979
1002
  const current = this.readRecord(id);
980
1003
  if (!current)
981
1004
  throw new StackNotFoundError(`Record not found: "${id}"`);
982
1005
  this.checkExpectedVersion(current, opts.expectedVersion);
983
- if (changes.associations !== void 0) {
984
- this.replaceAssociations(id, changes.associations);
1006
+ if (sets.length > 0) {
1007
+ this.exec.run(`UPDATE records SET ${sets.join(", ")} WHERE id = ?`, [...values, id]);
985
1008
  }
1009
+ this.replaceAssociationHalves(id, changes);
986
1010
  this.appendJournal(id, opts.journal);
987
1011
  });
988
1012
  return this.reread(id, "mutateRecord");
989
1013
  }
990
1014
  const merged = changes.contentPatch ? applyMergePatch(existing.content, changes.contentPatch) : void 0;
991
- const sets = [];
992
- const values = [];
993
1015
  if (merged !== void 0) {
994
- sets.push("content = ?");
995
- values.push(JSON.stringify(merged));
996
- }
997
- if (changes.parentId !== void 0) {
998
- sets.push("parent_id = ?");
999
- values.push(changes.parentId);
1000
- }
1001
- if (changes.permissions !== void 0) {
1002
- sets.push("permissions = ?");
1003
- values.push(changes.permissions.length ? JSON.stringify(changes.permissions) : null);
1004
- }
1005
- const now = toMs(/* @__PURE__ */ new Date());
1006
- if (changes.unlisted !== void 0) {
1007
- sets.push("unlisted_at = ?");
1008
- values.push(changes.unlisted ? now : null);
1016
+ sets.unshift("content = ?");
1017
+ values.unshift(JSON.stringify(merged));
1009
1018
  }
1010
1019
  this.exec.transaction(() => {
1011
1020
  if (opts.snapshot)
@@ -1013,9 +1022,7 @@ var SharedSqlRecordLogic = class {
1013
1022
  if (merged !== void 0)
1014
1023
  fts5Strategy.remove(this.exec, id);
1015
1024
  this.versionedUpdate(id, sets, values, opts, now);
1016
- if (changes.associations !== void 0) {
1017
- this.replaceAssociations(id, changes.associations);
1018
- }
1025
+ this.replaceAssociationHalves(id, changes);
1019
1026
  if (merged !== void 0) {
1020
1027
  fts5Strategy.insert(this.exec, id, JSON.stringify(merged));
1021
1028
  this.syncContentIndex(id, existing.typeId, merged);
@@ -1074,15 +1081,15 @@ var SharedSqlRecordLogic = class {
1074
1081
  async restoreVersion(id, version, opts = {}) {
1075
1082
  const existing = await this.getRecord(id);
1076
1083
  if (!existing)
1077
- throw new Error(`Record not found: "${id}"`);
1084
+ throw new StackNotFoundError(`Record not found: "${id}"`);
1078
1085
  this.checkExpectedVersion(existing, opts.expectedVersion);
1079
1086
  const target = await this.getVersion(id, version);
1080
1087
  if (!target)
1081
- throw new Error(`Version not found: ${id}@${version}`);
1088
+ throw new StackNotFoundError(`Version not found: "${id}"@${version}`);
1082
1089
  this.exec.transaction(() => {
1083
1090
  if (opts.snapshot)
1084
1091
  this.snapshotBeforeMutation(id, opts.snapshot);
1085
- this.rewriteContent(id, target.typeId, target.content, opts, ["parent_id = ?"], [target.parentId ?? null]);
1092
+ this.rewriteContent(id, target.typeId, target.content, opts);
1086
1093
  this.appendJournal(id, opts.journal);
1087
1094
  });
1088
1095
  return this.reread(id, "restoreVersion");
@@ -1090,7 +1097,7 @@ var SharedSqlRecordLogic = class {
1090
1097
  async commitMigration(id, toTypeId, content, opts = {}) {
1091
1098
  const existing = await this.getRecord(id);
1092
1099
  if (!existing)
1093
- throw new Error(`Record not found: "${id}"`);
1100
+ throw new StackNotFoundError(`Record not found: "${id}"`);
1094
1101
  this.checkExpectedVersion(existing, opts.expectedVersion);
1095
1102
  this.exec.transaction(() => {
1096
1103
  if (opts.snapshot)
@@ -1102,8 +1109,7 @@ var SharedSqlRecordLogic = class {
1102
1109
  }
1103
1110
  /**
1104
1111
  * Replace a record's type and content wholesale — what restoreVersion and
1105
- * commitMigration each do, differing only in where the content came from
1106
- * and whether the container moves with it.
1112
+ * commitMigration each do, differing only in where the content came from.
1107
1113
  *
1108
1114
  * The order is the constraint: fts5Strategy.remove() has to read the old
1109
1115
  * content off the records row, so it runs before the UPDATE, and the
@@ -1111,10 +1117,10 @@ var SharedSqlRecordLogic = class {
1111
1117
  * exec.transaction() — a failure between the two halves would otherwise
1112
1118
  * leave the record unindexed.
1113
1119
  */
1114
- rewriteContent(id, typeId, content, opts, extraSets = [], extraValues = []) {
1120
+ rewriteContent(id, typeId, content, opts) {
1115
1121
  const json = JSON.stringify(content);
1116
1122
  fts5Strategy.remove(this.exec, id);
1117
- this.versionedUpdate(id, ["type_id = ?", "content = ?", ...extraSets], [typeId, json, ...extraValues], opts);
1123
+ this.versionedUpdate(id, ["type_id = ?", "content = ?"], [typeId, json], opts);
1118
1124
  fts5Strategy.insert(this.exec, id, json);
1119
1125
  this.syncContentIndex(id, typeId, content);
1120
1126
  }
@@ -1249,7 +1255,7 @@ var SharedSqlRecordLogic = class {
1249
1255
  * `seq` is allocated here, from the log's own max, rather than computed
1250
1256
  * by the caller. That is what spares this table the collision healing
1251
1257
  * versions needs: nothing outside this statement ever holds a seq it
1252
- * expects to be free. See docs/spec/versioning.md § The change journal.
1258
+ * expects to be free. See docs/spec/journal.md § Ordering.
1253
1259
  */
1254
1260
  appendJournal(recordId, entry) {
1255
1261
  if (!entry)
@@ -1259,12 +1265,11 @@ var SharedSqlRecordLogic = class {
1259
1265
  throw new StackNotFoundError(`Record not found: "${recordId}"`);
1260
1266
  this.exec.run(`INSERT INTO journal
1261
1267
  (record_id, seq, at, kind, ops, version, type_id, parent_id,
1262
- previous_parent_id, actor, associations_added, associations_removed,
1263
- associations_replaced)
1268
+ previous_parent_id, actor, associations)
1264
1269
  VALUES (
1265
1270
  ?,
1266
1271
  (SELECT COALESCE(MAX(seq), 0) + 1 FROM journal WHERE record_id = ?),
1267
- ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1272
+ ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1268
1273
  recordId,
1269
1274
  recordId,
1270
1275
  toMs(/* @__PURE__ */ new Date()),
@@ -1276,12 +1281,13 @@ var SharedSqlRecordLogic = class {
1276
1281
  // '' is the root; NULL is an entry that names no origin at all.
1277
1282
  entry.previousParentId === void 0 ? null : entry.previousParentId ?? "",
1278
1283
  entry.actor ? JSON.stringify(entry.actor) : null,
1279
- entry.associationsAdded?.length ? JSON.stringify(entry.associationsAdded) : null,
1280
- entry.associationsRemoved?.length ? JSON.stringify(entry.associationsRemoved) : null,
1281
- entry.associationsReplaced?.length ? JSON.stringify(entry.associationsReplaced) : null
1284
+ entry.associations?.length ? JSON.stringify(entry.associations) : null
1282
1285
  ]);
1283
1286
  }
1284
1287
  async getJournal(id, query = {}) {
1288
+ if (!this.exec.get("SELECT 1 FROM records WHERE id = ?", [id])) {
1289
+ throw new StackNotFoundError(`Record not found: "${id}"`);
1290
+ }
1285
1291
  const conditions = ["record_id = ?"];
1286
1292
  const values = [id];
1287
1293
  if (query.sinceSeq !== void 0) {
@@ -1322,11 +1328,9 @@ var SharedSqlRecordLogic = class {
1322
1328
  // Associations
1323
1329
  // -------------------------------------------------------
1324
1330
  /**
1325
- * Never bumps `version`/`updatedAt` and never snapshots — a set-add
1326
- * composes correctly regardless of write order, so it needs neither OCC
1327
- * nor rollback history. The records row is untouched entirely; only the
1328
- * associations table changes. See docs/spec/versioning.md § Version
1329
- * history.
1331
+ * Never bumps `version`/`updatedAt` and never snapshots — see
1332
+ * StackRecordAdapter.associate(). The records row is untouched entirely;
1333
+ * only the associations table changes.
1330
1334
  */
1331
1335
  async associate(recordId, association, opts = {}) {
1332
1336
  this.exec.transaction(() => {
@@ -1350,19 +1354,37 @@ var SharedSqlRecordLogic = class {
1350
1354
  AND related_scope = ?
1351
1355
  AND related_id = ?
1352
1356
  AND related_ns = ?
1353
- AND related_stack = ?`, [recordId, association.kind, association.label, ...associationKeyColumns(association)]);
1357
+ AND related_stack = ?
1358
+ AND related_role = ?`, [recordId, association.kind, association.label, ...associationKeyColumns(association)]);
1354
1359
  this.appendJournal(recordId, opts.journal);
1355
1360
  });
1356
1361
  return this.reread(recordId, "dissociate");
1357
1362
  }
1363
+ /** The kinds each change-set key replaces, and nothing else. */
1364
+ static DATA_KINDS = ["tag", "attachment", "relationship"];
1365
+ static AUTHORITY_KINDS = ["permission", "anyone"];
1366
+ /**
1367
+ * Apply a change set's two association keys, each replacing only within
1368
+ * its own half of the partition. That partition is the whole reason a
1369
+ * tag write cannot drop an ACL: the `associations` key deletes data
1370
+ * kinds alone, so the authority rows beside them are never in scope.
1371
+ * Caller-transactional, like every write below.
1372
+ * See docs/spec/access-control.md § Record-level permissions.
1373
+ */
1374
+ replaceAssociationHalves(recordId, changes) {
1375
+ if (changes.associations !== void 0) {
1376
+ this.replaceAssociations(recordId, changes.associations, _SharedSqlRecordLogic.DATA_KINDS);
1377
+ }
1378
+ if (changes.permissions !== void 0) {
1379
+ this.replaceAssociations(recordId, changes.permissions, _SharedSqlRecordLogic.AUTHORITY_KINDS);
1380
+ }
1381
+ }
1358
1382
  /**
1359
- * Swap a record's association set for `associations` wholesale — the
1360
- * "replace, don't merge" semantics a change set's association list and a
1361
- * version snapshot's both carry. Caller-transactional, like every write
1362
- * below.
1383
+ * Swap one half of a record's association set wholesale — the "replace,
1384
+ * don't merge" semantics a change set's association list carries.
1363
1385
  */
1364
- replaceAssociations(recordId, associations) {
1365
- this.exec.run("DELETE FROM associations WHERE record_id = ?", [recordId]);
1386
+ replaceAssociations(recordId, associations, kinds) {
1387
+ this.exec.run(`DELETE FROM associations WHERE record_id = ? AND kind IN (${kinds.map(() => "?").join(", ")})`, [recordId, ...kinds]);
1366
1388
  if (associations.length)
1367
1389
  this.insertAssociations(recordId, associations);
1368
1390
  }
@@ -1381,8 +1403,8 @@ var SharedSqlRecordLogic = class {
1381
1403
  try {
1382
1404
  this.exec.run(`INSERT INTO associations
1383
1405
  (record_id, kind, label, file_id, related_scope, related_id, related_ns,
1384
- related_stack, attachment_record_id)
1385
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1406
+ related_stack, related_role, attachment_record_id)
1407
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1386
1408
  ON CONFLICT DO UPDATE SET attachment_record_id = excluded.attachment_record_id`, [
1387
1409
  recordId,
1388
1410
  assoc.kind,