@haverstack/record-adapter-do-sqlite 0.18.0 → 0.21.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
@@ -17,14 +17,23 @@ var RECORD_SCHEMA_SQL = `
17
17
  updated_by TEXT,
18
18
  updated_via TEXT,
19
19
  deleted_at INTEGER,
20
- unlisted_at INTEGER,
21
- permissions TEXT CHECK (permissions IS NULL OR json_valid(permissions))
20
+ unlisted_at INTEGER
22
21
  ) STRICT;
23
22
 
23
+ -- Every edge a record carries, authority and data alike: a permission
24
+ -- entry has element identity and a delta the same shape as a tag's, so
25
+ -- it earns the same table and the same durability tier. The permissions
26
+ -- and associations fields are projections over it, partitioned by
27
+ -- kind. See docs/spec/access-control.md \xA7 Record-level permissions.
28
+ --
24
29
  -- A relationship's target is (related_scope, related_id) plus one
25
30
  -- qualifier: related_stack for a record in another stack, related_ns for
26
31
  -- a foreign namespace. All four are in the primary key, so two targets
27
- -- differing only by namespace are two associations.
32
+ -- differing only by namespace are two associations. A permission's
33
+ -- grantee reuses them \u2014 related_scope 'entity' or 'group', related_id the
34
+ -- DID or group id \u2014 plus related_role, which only a group grantee sets
35
+ -- and which is in the key because member and admin name two different
36
+ -- sets of people.
28
37
  --
29
38
  -- attachment_record_id stays out of the primary key: it annotates a
30
39
  -- reference rather than naming one, so re-pointing it updates the row an
@@ -36,22 +45,21 @@ var RECORD_SCHEMA_SQL = `
36
45
  -- for the filter directions it cannot answer.
37
46
  CREATE TABLE IF NOT EXISTS associations (
38
47
  record_id TEXT NOT NULL REFERENCES records(id),
39
- kind TEXT NOT NULL CHECK (kind IN ('tag', 'attachment', 'relationship')),
48
+ kind TEXT NOT NULL CHECK (kind IN ('tag', 'attachment', 'relationship', 'permission', 'anyone')),
40
49
  label TEXT NOT NULL,
41
50
  file_id TEXT NOT NULL DEFAULT '',
42
- related_scope TEXT NOT NULL DEFAULT '' CHECK (related_scope IN ('', 'record', 'entity', 'external')),
51
+ related_scope TEXT NOT NULL DEFAULT '' CHECK (related_scope IN ('', 'record', 'entity', 'external', 'group')),
43
52
  related_id TEXT NOT NULL DEFAULT '',
44
53
  related_ns TEXT NOT NULL DEFAULT '',
45
54
  related_stack TEXT NOT NULL DEFAULT '',
55
+ related_role TEXT NOT NULL DEFAULT '' CHECK (related_role IN ('', 'member', 'admin')),
46
56
  attachment_record_id TEXT NOT NULL DEFAULT '',
47
- PRIMARY KEY (record_id, kind, label, file_id, related_scope, related_id, related_ns, related_stack)
57
+ PRIMARY KEY (record_id, kind, label, file_id, related_scope, related_id, related_ns, related_stack, related_role)
48
58
  ) STRICT;
49
59
 
50
- -- parent_id holds the container the record sat in, NULL for the root --
51
- -- the same encoding records.parent_id uses, since a snapshot states
52
- -- where the record was rather than an instruction to apply.
53
- -- No associations column: associate()/dissociate() don't bump version,
54
- -- so no version ever snapshots the association set. See
60
+ -- Content and the type it is read under. No parent_id, unlisted_at or
61
+ -- associations column: none of those aspects bumps version, so no
62
+ -- version ever snapshots one. See
55
63
  -- docs/spec/versioning.md \xA7 Version history.
56
64
  CREATE TABLE IF NOT EXISTS versions (
57
65
  record_id TEXT NOT NULL REFERENCES records(id),
@@ -62,8 +70,6 @@ var RECORD_SCHEMA_SQL = `
62
70
  entity_id TEXT,
63
71
  updated_by TEXT,
64
72
  updated_via TEXT,
65
- parent_id TEXT,
66
- permissions TEXT CHECK (permissions IS NULL OR json_valid(permissions)),
67
73
  PRIMARY KEY (record_id, version)
68
74
  ) STRICT;
69
75
 
@@ -71,17 +77,15 @@ var RECORD_SCHEMA_SQL = `
71
77
  -- them, and the association deltas nothing else retains. Envelope-level
72
78
  -- by design \u2014 content lives on a versions row, and copying it here
73
79
  -- would make the journal the larger of the two stores.
74
- --
75
- -- seq is dense from 1 per record and is the only ordering: at is wall
76
- -- clock, and version stands still across an association change, so
77
- -- neither orders the log alone. It is allocated inside the appending
78
- -- write, which is why this table needs none of the collision healing
79
- -- versions does. See docs/spec/versioning.md \xA7 The change journal.
80
+ -- See docs/spec/journal.md.
80
81
  CREATE TABLE IF NOT EXISTS journal (
81
82
  record_id TEXT NOT NULL REFERENCES records(id),
82
83
  seq INTEGER NOT NULL,
83
84
  at INTEGER NOT NULL,
84
- kind TEXT NOT NULL CHECK (kind IN ('created', 'changed', 'deleted', 'purged')),
85
+ -- No 'purged': a purge destroys the log rather than appending to it,
86
+ -- so the constraint names exactly the kinds an entry can carry.
87
+ -- See docs/spec/journal.md \xA7 A hard delete destroys the journal.
88
+ kind TEXT NOT NULL CHECK (kind IN ('created', 'changed', 'deleted')),
85
89
  ops TEXT NOT NULL CHECK (json_valid(ops)),
86
90
  version INTEGER NOT NULL,
87
91
  type_id TEXT NOT NULL,
@@ -92,9 +96,9 @@ var RECORD_SCHEMA_SQL = `
92
96
  -- the same input/state split parentId follows everywhere.
93
97
  previous_parent_id TEXT,
94
98
  actor TEXT CHECK (actor IS NULL OR json_valid(actor)),
95
- associations_added TEXT CHECK (associations_added IS NULL OR json_valid(associations_added)),
96
- associations_removed TEXT CHECK (associations_removed IS NULL OR json_valid(associations_removed)),
97
- associations_replaced TEXT CHECK (associations_replaced IS NULL OR json_valid(associations_replaced)),
99
+ -- One tagged edit per association the write moved, each carrying the
100
+ -- state it displaced. See docs/spec/journal.md \xA7 The entry.
101
+ associations TEXT CHECK (associations IS NULL OR json_valid(associations)),
98
102
  PRIMARY KEY (record_id, seq)
99
103
  ) STRICT;
100
104
 
@@ -564,6 +568,10 @@ var buildQueryPlan = (query) => {
564
568
  // ../sqlite-shared/dist/mappers.js
565
569
  var toMs = (d) => d.getTime();
566
570
  var fromMs = (ms) => new Date(ms);
571
+ var partitionAssociations = (associations) => ({
572
+ associations: associations.filter((a) => a.kind !== "permission" && a.kind !== "anyone"),
573
+ permissions: associations.filter((a) => a.kind === "permission" || a.kind === "anyone")
574
+ });
567
575
  var rowToRecord = (row, associations) => {
568
576
  const record = {
569
577
  id: row.id,
@@ -589,16 +597,27 @@ var rowToRecord = (row, associations) => {
589
597
  record.deletedAt = fromMs(row.deleted_at);
590
598
  if (row.unlisted_at != null)
591
599
  record.unlistedAt = fromMs(row.unlisted_at);
592
- if (row.permissions != null)
593
- record.permissions = JSON.parse(row.permissions);
594
- if (associations.length)
595
- record.associations = associations;
600
+ const partitioned = partitionAssociations(associations);
601
+ if (partitioned.associations.length)
602
+ record.associations = partitioned.associations;
603
+ if (partitioned.permissions.length)
604
+ record.permissions = partitioned.permissions;
596
605
  return record;
597
606
  };
598
607
  var rowToAssociation = (row) => {
599
608
  if (row.kind === "tag") {
600
609
  return { kind: "tag", label: row.label };
601
610
  }
611
+ if (row.kind === "anyone") {
612
+ return { kind: "anyone", label: "read" };
613
+ }
614
+ if (row.kind === "permission") {
615
+ return {
616
+ kind: "permission",
617
+ label: row.label,
618
+ grantee: rowToGrantee(row)
619
+ };
620
+ }
602
621
  if (row.kind === "attachment") {
603
622
  const attachmentRecordId = row.attachment_record_id;
604
623
  return {
@@ -619,16 +638,25 @@ var rowToAssociation = (row) => {
619
638
  };
620
639
  var associationKeyColumns = (a) => {
621
640
  if (a.kind === "attachment")
622
- return [a.fileId, "", "", "", ""];
641
+ return [a.fileId, "", "", "", "", ""];
642
+ if (a.kind === "permission") {
643
+ const g = a.grantee;
644
+ return g.scope === "entity" ? ["", "entity", g.entityId, "", "", ""] : ["", "group", g.groupId, "", "", g.role];
645
+ }
623
646
  if (a.kind !== "relationship")
624
- return ["", "", "", "", ""];
647
+ return ["", "", "", "", "", ""];
625
648
  const t = a.target;
626
649
  if (t.scope === "entity")
627
- return ["", "entity", t.entityId, "", ""];
650
+ return ["", "entity", t.entityId, "", "", ""];
628
651
  if (t.scope === "external")
629
- return ["", "external", t.id, t.ns, ""];
630
- return ["", "record", t.recordId, "", t.stackUrl ?? ""];
652
+ return ["", "external", t.id, t.ns, "", ""];
653
+ return ["", "record", t.recordId, "", t.stackUrl ?? "", ""];
631
654
  };
655
+ var rowToGrantee = (row) => row.related_scope === "group" ? {
656
+ scope: "group",
657
+ groupId: row.related_id,
658
+ role: row.related_role
659
+ } : { scope: "entity", entityId: row.related_id };
632
660
  var rowToTarget = (row) => {
633
661
  const id = row.related_id;
634
662
  if (row.related_scope === "entity")
@@ -666,10 +694,6 @@ var rowToVersion = (row) => {
666
694
  v.updatedBy = row.updated_by;
667
695
  if (row.updated_via != null)
668
696
  v.updatedVia = row.updated_via;
669
- if (row.parent_id != null)
670
- v.parentId = row.parent_id;
671
- if (row.permissions != null)
672
- v.permissions = JSON.parse(row.permissions);
673
697
  return v;
674
698
  };
675
699
  var rowToJournalEntry = (row) => {
@@ -688,15 +712,8 @@ var rowToJournalEntry = (row) => {
688
712
  }
689
713
  if (row.actor != null)
690
714
  e.actor = JSON.parse(row.actor);
691
- if (row.associations_added != null) {
692
- e.associationsAdded = JSON.parse(row.associations_added);
693
- }
694
- if (row.associations_removed != null) {
695
- e.associationsRemoved = JSON.parse(row.associations_removed);
696
- }
697
- if (row.associations_replaced != null) {
698
- e.associationsReplaced = JSON.parse(row.associations_replaced);
699
- }
715
+ if (row.associations != null)
716
+ e.associations = JSON.parse(row.associations);
700
717
  return e;
701
718
  };
702
719
 
@@ -735,9 +752,7 @@ var VERSION_COLUMNS = [
735
752
  "updated_at",
736
753
  "entity_id",
737
754
  "updated_by",
738
- "updated_via",
739
- "parent_id",
740
- "permissions"
755
+ "updated_via"
741
756
  ];
742
757
  var versionRowValues = (version) => [
743
758
  version.typeId,
@@ -745,9 +760,7 @@ var versionRowValues = (version) => [
745
760
  toMs(version.updatedAt),
746
761
  version.entityId ?? null,
747
762
  version.updatedBy ?? null,
748
- version.updatedVia ?? null,
749
- version.parentId ?? null,
750
- version.permissions ? JSON.stringify(version.permissions) : null
763
+ version.updatedVia ?? null
751
764
  ];
752
765
  var indexedFieldKinds = (schema) => {
753
766
  const kinds = /* @__PURE__ */ new Map();
@@ -758,7 +771,7 @@ var indexedFieldKinds = (schema) => {
758
771
  }
759
772
  return kinds;
760
773
  };
761
- var SharedSqlRecordLogic = class {
774
+ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
762
775
  deps;
763
776
  /**
764
777
  * Per-typeId cache of the fields content_index covers and their declared
@@ -864,8 +877,8 @@ var SharedSqlRecordLogic = class {
864
877
  this.exec.run(`INSERT INTO records
865
878
  (id, type_id, created_at, updated_at, content, version,
866
879
  parent_id, entity_id, app_id, principal_id, updated_by, updated_via,
867
- deleted_at, unlisted_at, permissions)
868
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
880
+ deleted_at, unlisted_at)
881
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
869
882
  record.id,
870
883
  record.typeId,
871
884
  toMs(record.createdAt),
@@ -879,8 +892,7 @@ var SharedSqlRecordLogic = class {
879
892
  record.updatedBy ?? null,
880
893
  record.updatedVia ?? null,
881
894
  record.deletedAt ? toMs(record.deletedAt) : null,
882
- record.unlistedAt ? toMs(record.unlistedAt) : null,
883
- record.permissions ? JSON.stringify(record.permissions) : null
895
+ record.unlistedAt ? toMs(record.unlistedAt) : null
884
896
  ]);
885
897
  } catch (err) {
886
898
  if (isUniqueConstraintViolation(err)) {
@@ -888,9 +900,9 @@ var SharedSqlRecordLogic = class {
888
900
  }
889
901
  throw err;
890
902
  }
891
- if (record.associations?.length) {
892
- this.insertAssociations(record.id, record.associations);
893
- }
903
+ const edges = [...record.associations ?? [], ...record.permissions ?? []];
904
+ if (edges.length)
905
+ this.insertAssociations(record.id, edges);
894
906
  fts5Strategy.insert(this.exec, record.id, JSON.stringify(record.content));
895
907
  this.syncContentIndex(record.id, record.typeId, record.content);
896
908
  this.appendJournal(record.id, opts.journal);
@@ -928,13 +940,13 @@ var SharedSqlRecordLogic = class {
928
940
  * one call is one version whatever it moved.
929
941
  *
930
942
  * `opts.bumpsVersion: false` is a change set that touches only
931
- * `associations` — Stack computes this from which ops the set actually
932
- * moves. Such a write skips the records-table UPDATE (and the snapshot)
933
- * entirely: nothing on that row changes, so there is nothing to bump and
934
- * no version to snapshot. `expectedVersion`, if given, is still
935
- * re-checked synchronously inside the transaction — the CAS guard the
936
- * bumping path gets from its UPDATE's WHERE clause, restated as a read
937
- * because there is no UPDATE here to carry it. See
943
+ * `associations`, `parentId` and/or `unlisted` — Stack computes this from
944
+ * which ops the set actually moves. Such a write still lands its columns,
945
+ * but leaves `version`, `updatedAt` and the actor stamps exactly as they
946
+ * stood and takes no snapshot. `expectedVersion`, if given, is re-checked
947
+ * synchronously inside the transaction — the CAS guard the bumping path
948
+ * gets from its UPDATE's WHERE clause, restated as a read because the
949
+ * UPDATE here carries no version predicate. See
938
950
  * docs/spec/versioning.md § Version history.
939
951
  *
940
952
  * The expectedVersion check for a bumping write is standalone rather than
@@ -948,38 +960,35 @@ var SharedSqlRecordLogic = class {
948
960
  if (!existing)
949
961
  throw new StackNotFoundError(`Record not found: "${id}"`);
950
962
  this.checkExpectedVersion(existing, opts.expectedVersion);
963
+ const now = toMs(/* @__PURE__ */ new Date());
964
+ const sets = [];
965
+ const values = [];
966
+ if (changes.parentId !== void 0) {
967
+ sets.push("parent_id = ?");
968
+ values.push(changes.parentId);
969
+ }
970
+ if (changes.unlisted !== void 0) {
971
+ sets.push("unlisted_at = ?");
972
+ values.push(changes.unlisted ? now : null);
973
+ }
951
974
  if (opts.bumpsVersion === false) {
952
975
  this.exec.transaction(() => {
953
976
  const current = this.readRecord(id);
954
977
  if (!current)
955
978
  throw new StackNotFoundError(`Record not found: "${id}"`);
956
979
  this.checkExpectedVersion(current, opts.expectedVersion);
957
- if (changes.associations !== void 0) {
958
- this.replaceAssociations(id, changes.associations);
980
+ if (sets.length > 0) {
981
+ this.exec.run(`UPDATE records SET ${sets.join(", ")} WHERE id = ?`, [...values, id]);
959
982
  }
983
+ this.replaceAssociationHalves(id, changes);
960
984
  this.appendJournal(id, opts.journal);
961
985
  });
962
986
  return this.reread(id, "mutateRecord");
963
987
  }
964
988
  const merged = changes.contentPatch ? applyMergePatch(existing.content, changes.contentPatch) : void 0;
965
- const sets = [];
966
- const values = [];
967
989
  if (merged !== void 0) {
968
- sets.push("content = ?");
969
- values.push(JSON.stringify(merged));
970
- }
971
- if (changes.parentId !== void 0) {
972
- sets.push("parent_id = ?");
973
- values.push(changes.parentId);
974
- }
975
- if (changes.permissions !== void 0) {
976
- sets.push("permissions = ?");
977
- values.push(changes.permissions.length ? JSON.stringify(changes.permissions) : null);
978
- }
979
- const now = toMs(/* @__PURE__ */ new Date());
980
- if (changes.unlisted !== void 0) {
981
- sets.push("unlisted_at = ?");
982
- values.push(changes.unlisted ? now : null);
990
+ sets.unshift("content = ?");
991
+ values.unshift(JSON.stringify(merged));
983
992
  }
984
993
  this.exec.transaction(() => {
985
994
  if (opts.snapshot)
@@ -987,9 +996,7 @@ var SharedSqlRecordLogic = class {
987
996
  if (merged !== void 0)
988
997
  fts5Strategy.remove(this.exec, id);
989
998
  this.versionedUpdate(id, sets, values, opts, now);
990
- if (changes.associations !== void 0) {
991
- this.replaceAssociations(id, changes.associations);
992
- }
999
+ this.replaceAssociationHalves(id, changes);
993
1000
  if (merged !== void 0) {
994
1001
  fts5Strategy.insert(this.exec, id, JSON.stringify(merged));
995
1002
  this.syncContentIndex(id, existing.typeId, merged);
@@ -1048,15 +1055,15 @@ var SharedSqlRecordLogic = class {
1048
1055
  async restoreVersion(id, version, opts = {}) {
1049
1056
  const existing = await this.getRecord(id);
1050
1057
  if (!existing)
1051
- throw new Error(`Record not found: "${id}"`);
1058
+ throw new StackNotFoundError(`Record not found: "${id}"`);
1052
1059
  this.checkExpectedVersion(existing, opts.expectedVersion);
1053
1060
  const target = await this.getVersion(id, version);
1054
1061
  if (!target)
1055
- throw new Error(`Version not found: ${id}@${version}`);
1062
+ throw new StackNotFoundError(`Version not found: "${id}"@${version}`);
1056
1063
  this.exec.transaction(() => {
1057
1064
  if (opts.snapshot)
1058
1065
  this.snapshotBeforeMutation(id, opts.snapshot);
1059
- this.rewriteContent(id, target.typeId, target.content, opts, ["parent_id = ?"], [target.parentId ?? null]);
1066
+ this.rewriteContent(id, target.typeId, target.content, opts);
1060
1067
  this.appendJournal(id, opts.journal);
1061
1068
  });
1062
1069
  return this.reread(id, "restoreVersion");
@@ -1064,7 +1071,7 @@ var SharedSqlRecordLogic = class {
1064
1071
  async commitMigration(id, toTypeId, content, opts = {}) {
1065
1072
  const existing = await this.getRecord(id);
1066
1073
  if (!existing)
1067
- throw new Error(`Record not found: "${id}"`);
1074
+ throw new StackNotFoundError(`Record not found: "${id}"`);
1068
1075
  this.checkExpectedVersion(existing, opts.expectedVersion);
1069
1076
  this.exec.transaction(() => {
1070
1077
  if (opts.snapshot)
@@ -1076,8 +1083,7 @@ var SharedSqlRecordLogic = class {
1076
1083
  }
1077
1084
  /**
1078
1085
  * Replace a record's type and content wholesale — what restoreVersion and
1079
- * commitMigration each do, differing only in where the content came from
1080
- * and whether the container moves with it.
1086
+ * commitMigration each do, differing only in where the content came from.
1081
1087
  *
1082
1088
  * The order is the constraint: fts5Strategy.remove() has to read the old
1083
1089
  * content off the records row, so it runs before the UPDATE, and the
@@ -1085,10 +1091,10 @@ var SharedSqlRecordLogic = class {
1085
1091
  * exec.transaction() — a failure between the two halves would otherwise
1086
1092
  * leave the record unindexed.
1087
1093
  */
1088
- rewriteContent(id, typeId, content, opts, extraSets = [], extraValues = []) {
1094
+ rewriteContent(id, typeId, content, opts) {
1089
1095
  const json = JSON.stringify(content);
1090
1096
  fts5Strategy.remove(this.exec, id);
1091
- this.versionedUpdate(id, ["type_id = ?", "content = ?", ...extraSets], [typeId, json, ...extraValues], opts);
1097
+ this.versionedUpdate(id, ["type_id = ?", "content = ?"], [typeId, json], opts);
1092
1098
  fts5Strategy.insert(this.exec, id, json);
1093
1099
  this.syncContentIndex(id, typeId, content);
1094
1100
  }
@@ -1223,7 +1229,7 @@ var SharedSqlRecordLogic = class {
1223
1229
  * `seq` is allocated here, from the log's own max, rather than computed
1224
1230
  * by the caller. That is what spares this table the collision healing
1225
1231
  * versions needs: nothing outside this statement ever holds a seq it
1226
- * expects to be free. See docs/spec/versioning.md § The change journal.
1232
+ * expects to be free. See docs/spec/journal.md § Ordering.
1227
1233
  */
1228
1234
  appendJournal(recordId, entry) {
1229
1235
  if (!entry)
@@ -1233,12 +1239,11 @@ var SharedSqlRecordLogic = class {
1233
1239
  throw new StackNotFoundError(`Record not found: "${recordId}"`);
1234
1240
  this.exec.run(`INSERT INTO journal
1235
1241
  (record_id, seq, at, kind, ops, version, type_id, parent_id,
1236
- previous_parent_id, actor, associations_added, associations_removed,
1237
- associations_replaced)
1242
+ previous_parent_id, actor, associations)
1238
1243
  VALUES (
1239
1244
  ?,
1240
1245
  (SELECT COALESCE(MAX(seq), 0) + 1 FROM journal WHERE record_id = ?),
1241
- ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1246
+ ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1242
1247
  recordId,
1243
1248
  recordId,
1244
1249
  toMs(/* @__PURE__ */ new Date()),
@@ -1250,12 +1255,13 @@ var SharedSqlRecordLogic = class {
1250
1255
  // '' is the root; NULL is an entry that names no origin at all.
1251
1256
  entry.previousParentId === void 0 ? null : entry.previousParentId ?? "",
1252
1257
  entry.actor ? JSON.stringify(entry.actor) : null,
1253
- entry.associationsAdded?.length ? JSON.stringify(entry.associationsAdded) : null,
1254
- entry.associationsRemoved?.length ? JSON.stringify(entry.associationsRemoved) : null,
1255
- entry.associationsReplaced?.length ? JSON.stringify(entry.associationsReplaced) : null
1258
+ entry.associations?.length ? JSON.stringify(entry.associations) : null
1256
1259
  ]);
1257
1260
  }
1258
1261
  async getJournal(id, query = {}) {
1262
+ if (!this.exec.get("SELECT 1 FROM records WHERE id = ?", [id])) {
1263
+ throw new StackNotFoundError(`Record not found: "${id}"`);
1264
+ }
1259
1265
  const conditions = ["record_id = ?"];
1260
1266
  const values = [id];
1261
1267
  if (query.sinceSeq !== void 0) {
@@ -1296,11 +1302,9 @@ var SharedSqlRecordLogic = class {
1296
1302
  // Associations
1297
1303
  // -------------------------------------------------------
1298
1304
  /**
1299
- * Never bumps `version`/`updatedAt` and never snapshots — a set-add
1300
- * composes correctly regardless of write order, so it needs neither OCC
1301
- * nor rollback history. The records row is untouched entirely; only the
1302
- * associations table changes. See docs/spec/versioning.md § Version
1303
- * history.
1305
+ * Never bumps `version`/`updatedAt` and never snapshots — see
1306
+ * StackRecordAdapter.associate(). The records row is untouched entirely;
1307
+ * only the associations table changes.
1304
1308
  */
1305
1309
  async associate(recordId, association, opts = {}) {
1306
1310
  this.exec.transaction(() => {
@@ -1324,19 +1328,37 @@ var SharedSqlRecordLogic = class {
1324
1328
  AND related_scope = ?
1325
1329
  AND related_id = ?
1326
1330
  AND related_ns = ?
1327
- AND related_stack = ?`, [recordId, association.kind, association.label, ...associationKeyColumns(association)]);
1331
+ AND related_stack = ?
1332
+ AND related_role = ?`, [recordId, association.kind, association.label, ...associationKeyColumns(association)]);
1328
1333
  this.appendJournal(recordId, opts.journal);
1329
1334
  });
1330
1335
  return this.reread(recordId, "dissociate");
1331
1336
  }
1337
+ /** The kinds each change-set key replaces, and nothing else. */
1338
+ static DATA_KINDS = ["tag", "attachment", "relationship"];
1339
+ static AUTHORITY_KINDS = ["permission", "anyone"];
1340
+ /**
1341
+ * Apply a change set's two association keys, each replacing only within
1342
+ * its own half of the partition. That partition is the whole reason a
1343
+ * tag write cannot drop an ACL: the `associations` key deletes data
1344
+ * kinds alone, so the authority rows beside them are never in scope.
1345
+ * Caller-transactional, like every write below.
1346
+ * See docs/spec/access-control.md § Record-level permissions.
1347
+ */
1348
+ replaceAssociationHalves(recordId, changes) {
1349
+ if (changes.associations !== void 0) {
1350
+ this.replaceAssociations(recordId, changes.associations, _SharedSqlRecordLogic.DATA_KINDS);
1351
+ }
1352
+ if (changes.permissions !== void 0) {
1353
+ this.replaceAssociations(recordId, changes.permissions, _SharedSqlRecordLogic.AUTHORITY_KINDS);
1354
+ }
1355
+ }
1332
1356
  /**
1333
- * Swap a record's association set for `associations` wholesale — the
1334
- * "replace, don't merge" semantics a change set's association list and a
1335
- * version snapshot's both carry. Caller-transactional, like every write
1336
- * below.
1357
+ * Swap one half of a record's association set wholesale — the "replace,
1358
+ * don't merge" semantics a change set's association list carries.
1337
1359
  */
1338
- replaceAssociations(recordId, associations) {
1339
- this.exec.run("DELETE FROM associations WHERE record_id = ?", [recordId]);
1360
+ replaceAssociations(recordId, associations, kinds) {
1361
+ this.exec.run(`DELETE FROM associations WHERE record_id = ? AND kind IN (${kinds.map(() => "?").join(", ")})`, [recordId, ...kinds]);
1340
1362
  if (associations.length)
1341
1363
  this.insertAssociations(recordId, associations);
1342
1364
  }
@@ -1355,8 +1377,8 @@ var SharedSqlRecordLogic = class {
1355
1377
  try {
1356
1378
  this.exec.run(`INSERT INTO associations
1357
1379
  (record_id, kind, label, file_id, related_scope, related_id, related_ns,
1358
- related_stack, attachment_record_id)
1359
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1380
+ related_stack, related_role, attachment_record_id)
1381
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1360
1382
  ON CONFLICT DO UPDATE SET attachment_record_id = excluded.attachment_record_id`, [
1361
1383
  recordId,
1362
1384
  assoc.kind,