@haverstack/core 0.41.0 → 0.42.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.
Files changed (287) hide show
  1. package/README.md +121 -59
  2. package/dist/access.d.ts +34 -36
  3. package/dist/access.d.ts.map +1 -1
  4. package/dist/access.js +65 -42
  5. package/dist/access.js.map +1 -1
  6. package/dist/{combine.d.ts → adapter/combine.d.ts} +8 -1
  7. package/dist/adapter/combine.d.ts.map +1 -0
  8. package/dist/{combine.js → adapter/combine.js} +7 -0
  9. package/dist/adapter/combine.js.map +1 -0
  10. package/dist/{adapter-entry.d.ts → adapter/index.d.ts} +6 -5
  11. package/dist/adapter/index.d.ts.map +1 -0
  12. package/dist/{adapter-entry.js → adapter/index.js} +5 -4
  13. package/dist/adapter/index.js.map +1 -0
  14. package/dist/{sort.d.ts → adapter/sort.d.ts} +20 -20
  15. package/dist/adapter/sort.d.ts.map +1 -0
  16. package/dist/{sort.js → adapter/sort.js} +37 -27
  17. package/dist/adapter/sort.js.map +1 -0
  18. package/dist/associations/identity.d.ts +93 -0
  19. package/dist/associations/identity.d.ts.map +1 -0
  20. package/dist/associations/identity.js +198 -0
  21. package/dist/associations/identity.js.map +1 -0
  22. package/dist/associations/validation.d.ts +90 -0
  23. package/dist/associations/validation.d.ts.map +1 -0
  24. package/dist/associations/validation.js +339 -0
  25. package/dist/associations/validation.js.map +1 -0
  26. package/dist/attachment-records.d.ts +48 -0
  27. package/dist/attachment-records.d.ts.map +1 -0
  28. package/dist/attachment-records.js +52 -0
  29. package/dist/attachment-records.js.map +1 -0
  30. package/dist/collection.d.ts +182 -0
  31. package/dist/collection.d.ts.map +1 -0
  32. package/dist/collection.js +407 -0
  33. package/dist/collection.js.map +1 -0
  34. package/dist/content-match.d.ts +25 -0
  35. package/dist/content-match.d.ts.map +1 -0
  36. package/dist/content-match.js +51 -0
  37. package/dist/content-match.js.map +1 -0
  38. package/dist/content-translation.d.ts +42 -0
  39. package/dist/content-translation.d.ts.map +1 -0
  40. package/dist/content-translation.js +108 -0
  41. package/dist/content-translation.js.map +1 -0
  42. package/dist/did/did.d.ts.map +1 -0
  43. package/dist/{did.js → did/did.js} +3 -4
  44. package/dist/did/did.js.map +1 -0
  45. package/dist/{did-entry.d.ts → did/index.d.ts} +1 -1
  46. package/dist/did/index.d.ts.map +1 -0
  47. package/dist/{did-entry.js → did/index.js} +3 -3
  48. package/dist/did/index.js.map +1 -0
  49. package/dist/errors.d.ts +36 -63
  50. package/dist/errors.d.ts.map +1 -1
  51. package/dist/errors.js +35 -58
  52. package/dist/errors.js.map +1 -1
  53. package/dist/grants.d.ts +18 -42
  54. package/dist/grants.d.ts.map +1 -1
  55. package/dist/grants.js +68 -133
  56. package/dist/grants.js.map +1 -1
  57. package/dist/id.d.ts +13 -31
  58. package/dist/id.d.ts.map +1 -1
  59. package/dist/id.js +13 -31
  60. package/dist/id.js.map +1 -1
  61. package/dist/index.d.ts +12 -8
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +8 -5
  64. package/dist/index.js.map +1 -1
  65. package/dist/install.d.ts +3 -6
  66. package/dist/install.d.ts.map +1 -1
  67. package/dist/install.js +6 -10
  68. package/dist/install.js.map +1 -1
  69. package/dist/merge.d.ts.map +1 -1
  70. package/dist/merge.js +5 -11
  71. package/dist/merge.js.map +1 -1
  72. package/dist/migration-steps.d.ts +102 -0
  73. package/dist/migration-steps.d.ts.map +1 -0
  74. package/dist/migration-steps.js +293 -0
  75. package/dist/migration-steps.js.map +1 -0
  76. package/dist/query-validation.d.ts +28 -94
  77. package/dist/query-validation.d.ts.map +1 -1
  78. package/dist/query-validation.js +156 -336
  79. package/dist/query-validation.js.map +1 -1
  80. package/dist/record-changes.d.ts +2 -117
  81. package/dist/record-changes.d.ts.map +1 -1
  82. package/dist/record-changes.js +10 -212
  83. package/dist/record-changes.js.map +1 -1
  84. package/dist/schema.d.ts +18 -7
  85. package/dist/schema.d.ts.map +1 -1
  86. package/dist/schema.js +59 -30
  87. package/dist/schema.js.map +1 -1
  88. package/dist/scoped-stack/authority.d.ts +156 -0
  89. package/dist/scoped-stack/authority.d.ts.map +1 -0
  90. package/dist/scoped-stack/authority.js +384 -0
  91. package/dist/scoped-stack/authority.js.map +1 -0
  92. package/dist/scoped-stack/scoped-stack.d.ts +338 -0
  93. package/dist/scoped-stack/scoped-stack.d.ts.map +1 -0
  94. package/dist/scoped-stack/scoped-stack.js +776 -0
  95. package/dist/scoped-stack/scoped-stack.js.map +1 -0
  96. package/dist/scoped-stack/subscription.d.ts +55 -0
  97. package/dist/scoped-stack/subscription.d.ts.map +1 -0
  98. package/dist/scoped-stack/subscription.js +110 -0
  99. package/dist/scoped-stack/subscription.js.map +1 -0
  100. package/dist/stack/attachments.d.ts +24 -0
  101. package/dist/stack/attachments.d.ts.map +1 -0
  102. package/dist/stack/attachments.js +113 -0
  103. package/dist/stack/attachments.js.map +1 -0
  104. package/dist/{changes.d.ts → stack/changes.d.ts} +29 -65
  105. package/dist/stack/changes.d.ts.map +1 -0
  106. package/dist/{changes.js → stack/changes.js} +32 -66
  107. package/dist/stack/changes.js.map +1 -0
  108. package/dist/stack/client.d.ts +271 -0
  109. package/dist/stack/client.d.ts.map +1 -0
  110. package/dist/stack/client.js +9 -0
  111. package/dist/stack/client.js.map +1 -0
  112. package/dist/stack/identity-bindings.d.ts.map +1 -0
  113. package/dist/{identity-bindings.js → stack/identity-bindings.js} +5 -14
  114. package/dist/stack/identity-bindings.js.map +1 -0
  115. package/dist/stack/install-app.d.ts +22 -0
  116. package/dist/stack/install-app.d.ts.map +1 -0
  117. package/dist/stack/install-app.js +260 -0
  118. package/dist/stack/install-app.js.map +1 -0
  119. package/dist/stack/integrity.d.ts +94 -0
  120. package/dist/stack/integrity.d.ts.map +1 -0
  121. package/dist/stack/integrity.js +268 -0
  122. package/dist/stack/integrity.js.map +1 -0
  123. package/dist/{limits.d.ts → stack/limits.d.ts} +2 -5
  124. package/dist/stack/limits.d.ts.map +1 -0
  125. package/dist/{limits.js → stack/limits.js} +3 -6
  126. package/dist/stack/limits.js.map +1 -0
  127. package/dist/stack/migrations.d.ts +59 -0
  128. package/dist/stack/migrations.d.ts.map +1 -0
  129. package/dist/stack/migrations.js +141 -0
  130. package/dist/stack/migrations.js.map +1 -0
  131. package/dist/{stack-reads.d.ts → stack/reads.d.ts} +33 -6
  132. package/dist/stack/reads.d.ts.map +1 -0
  133. package/dist/{stack-reads.js → stack/reads.js} +49 -7
  134. package/dist/stack/reads.js.map +1 -0
  135. package/dist/stack/record-id.d.ts +35 -0
  136. package/dist/stack/record-id.d.ts.map +1 -0
  137. package/dist/stack/record-id.js +82 -0
  138. package/dist/stack/record-id.js.map +1 -0
  139. package/dist/stack/stack.d.ts +438 -0
  140. package/dist/stack/stack.d.ts.map +1 -0
  141. package/dist/stack/stack.js +1366 -0
  142. package/dist/stack/stack.js.map +1 -0
  143. package/dist/stack/system-types.d.ts +16 -0
  144. package/dist/stack/system-types.d.ts.map +1 -0
  145. package/dist/stack/system-types.js +111 -0
  146. package/dist/stack/system-types.js.map +1 -0
  147. package/dist/stack/type-grants.d.ts +20 -0
  148. package/dist/stack/type-grants.d.ts.map +1 -0
  149. package/dist/stack/type-grants.js +69 -0
  150. package/dist/stack/type-grants.js.map +1 -0
  151. package/dist/stack/write-expectation.d.ts +78 -0
  152. package/dist/stack/write-expectation.d.ts.map +1 -0
  153. package/dist/stack/write-expectation.js +71 -0
  154. package/dist/stack/write-expectation.js.map +1 -0
  155. package/dist/testing.d.ts +21 -26
  156. package/dist/testing.d.ts.map +1 -1
  157. package/dist/testing.js +50 -70
  158. package/dist/testing.js.map +1 -1
  159. package/dist/type-handle.d.ts +67 -78
  160. package/dist/type-handle.d.ts.map +1 -1
  161. package/dist/type-handle.js +44 -111
  162. package/dist/type-handle.js.map +1 -1
  163. package/dist/types/adapter.d.ts +223 -0
  164. package/dist/types/adapter.d.ts.map +1 -0
  165. package/dist/types/adapter.js +5 -0
  166. package/dist/types/adapter.js.map +1 -0
  167. package/dist/types/associations.d.ts +156 -0
  168. package/dist/types/associations.d.ts.map +1 -0
  169. package/dist/types/associations.js +13 -0
  170. package/dist/types/associations.js.map +1 -0
  171. package/dist/types/capabilities.d.ts +89 -0
  172. package/dist/types/capabilities.d.ts.map +1 -0
  173. package/dist/types/capabilities.js +5 -0
  174. package/dist/types/capabilities.js.map +1 -0
  175. package/dist/types/events.d.ts +155 -0
  176. package/dist/types/events.d.ts.map +1 -0
  177. package/dist/types/events.js +5 -0
  178. package/dist/types/events.js.map +1 -0
  179. package/dist/types/ids.d.ts +43 -0
  180. package/dist/types/ids.d.ts.map +1 -0
  181. package/dist/types/ids.js +5 -0
  182. package/dist/types/ids.js.map +1 -0
  183. package/dist/types/index.d.ts +19 -0
  184. package/dist/types/index.d.ts.map +1 -0
  185. package/dist/types/index.js +19 -0
  186. package/dist/types/index.js.map +1 -0
  187. package/dist/types/journal.d.ts +47 -0
  188. package/dist/types/journal.d.ts.map +1 -0
  189. package/dist/types/journal.js +5 -0
  190. package/dist/types/journal.js.map +1 -0
  191. package/dist/types/query.d.ts +185 -0
  192. package/dist/types/query.d.ts.map +1 -0
  193. package/dist/types/query.js +10 -0
  194. package/dist/types/query.js.map +1 -0
  195. package/dist/types/records.d.ts +76 -0
  196. package/dist/types/records.d.ts.map +1 -0
  197. package/dist/types/records.js +5 -0
  198. package/dist/types/records.js.map +1 -0
  199. package/dist/types/schema.d.ts +74 -0
  200. package/dist/types/schema.d.ts.map +1 -0
  201. package/dist/types/schema.js +5 -0
  202. package/dist/types/schema.js.map +1 -0
  203. package/dist/types/system.d.ts +161 -0
  204. package/dist/types/system.d.ts.map +1 -0
  205. package/dist/types/system.js +32 -0
  206. package/dist/types/system.js.map +1 -0
  207. package/dist/types/tokens.d.ts +39 -0
  208. package/dist/types/tokens.d.ts.map +1 -0
  209. package/dist/types/tokens.js +5 -0
  210. package/dist/types/tokens.js.map +1 -0
  211. package/dist/validate.d.ts +29 -70
  212. package/dist/validate.d.ts.map +1 -1
  213. package/dist/validate.js +50 -91
  214. package/dist/validate.js.map +1 -1
  215. package/dist/{attachment-download.d.ts → wire/attachment-download.d.ts} +0 -57
  216. package/dist/wire/attachment-download.d.ts.map +1 -0
  217. package/dist/{attachment-download.js → wire/attachment-download.js} +0 -61
  218. package/dist/wire/attachment-download.js.map +1 -0
  219. package/dist/{auth.d.ts → wire/auth.d.ts} +5 -8
  220. package/dist/wire/auth.d.ts.map +1 -0
  221. package/dist/{auth.js → wire/auth.js} +5 -8
  222. package/dist/wire/auth.js.map +1 -0
  223. package/dist/{wire-body.d.ts → wire/body.d.ts} +4 -4
  224. package/dist/wire/body.d.ts.map +1 -0
  225. package/dist/{wire-body.js → wire/body.js} +5 -5
  226. package/dist/wire/body.js.map +1 -0
  227. package/dist/{wire-entry.d.ts → wire/index.d.ts} +12 -12
  228. package/dist/wire/index.d.ts.map +1 -0
  229. package/dist/{wire-entry.js → wire/index.js} +13 -17
  230. package/dist/wire/index.js.map +1 -0
  231. package/dist/{wire-record.d.ts → wire/record.d.ts} +10 -22
  232. package/dist/wire/record.d.ts.map +1 -0
  233. package/dist/{wire-record.js → wire/record.js} +12 -24
  234. package/dist/wire/record.js.map +1 -0
  235. package/dist/{wire-request.d.ts → wire/request.d.ts} +20 -37
  236. package/dist/wire/request.d.ts.map +1 -0
  237. package/dist/{wire-request.js → wire/request.js} +79 -54
  238. package/dist/wire/request.js.map +1 -0
  239. package/package.json +7 -7
  240. package/dist/adapter-entry.d.ts.map +0 -1
  241. package/dist/adapter-entry.js.map +0 -1
  242. package/dist/attachment-download.d.ts.map +0 -1
  243. package/dist/attachment-download.js.map +0 -1
  244. package/dist/auth.d.ts.map +0 -1
  245. package/dist/auth.js.map +0 -1
  246. package/dist/changes.d.ts.map +0 -1
  247. package/dist/changes.js.map +0 -1
  248. package/dist/combine.d.ts.map +0 -1
  249. package/dist/combine.js.map +0 -1
  250. package/dist/did-entry.d.ts.map +0 -1
  251. package/dist/did-entry.js.map +0 -1
  252. package/dist/did.d.ts.map +0 -1
  253. package/dist/did.js.map +0 -1
  254. package/dist/identity-bindings.d.ts.map +0 -1
  255. package/dist/identity-bindings.js.map +0 -1
  256. package/dist/limits.d.ts.map +0 -1
  257. package/dist/limits.js.map +0 -1
  258. package/dist/record-id.d.ts +0 -70
  259. package/dist/record-id.d.ts.map +0 -1
  260. package/dist/record-id.js +0 -127
  261. package/dist/record-id.js.map +0 -1
  262. package/dist/scoped-stack.d.ts +0 -549
  263. package/dist/scoped-stack.d.ts.map +0 -1
  264. package/dist/scoped-stack.js +0 -1301
  265. package/dist/scoped-stack.js.map +0 -1
  266. package/dist/sort.d.ts.map +0 -1
  267. package/dist/sort.js.map +0 -1
  268. package/dist/stack-reads.d.ts.map +0 -1
  269. package/dist/stack-reads.js.map +0 -1
  270. package/dist/stack.d.ts +0 -1032
  271. package/dist/stack.d.ts.map +0 -1
  272. package/dist/stack.js +0 -2337
  273. package/dist/stack.js.map +0 -1
  274. package/dist/types.d.ts +0 -1278
  275. package/dist/types.d.ts.map +0 -1
  276. package/dist/types.js +0 -51
  277. package/dist/types.js.map +0 -1
  278. package/dist/wire-body.d.ts.map +0 -1
  279. package/dist/wire-body.js.map +0 -1
  280. package/dist/wire-entry.d.ts.map +0 -1
  281. package/dist/wire-entry.js.map +0 -1
  282. package/dist/wire-record.d.ts.map +0 -1
  283. package/dist/wire-record.js.map +0 -1
  284. package/dist/wire-request.d.ts.map +0 -1
  285. package/dist/wire-request.js.map +0 -1
  286. /package/dist/{did.d.ts → did/did.d.ts} +0 -0
  287. /package/dist/{identity-bindings.d.ts → stack/identity-bindings.d.ts} +0 -0
package/dist/stack.js DELETED
@@ -1,2337 +0,0 @@
1
- /**
2
- * Stack — Core Stack Class
3
- * -------------------------------------------------------
4
- * The Stack class is the primary interface for apps. It sits
5
- * on top of a StackAdapter and adds:
6
- *
7
- * - ID generation
8
- * - Type definition and schema hashing
9
- * - Content validation on write
10
- * - Migration registry and explicit, owner-driven migrateAll()
11
- * - Version snapshotting on update
12
- * - Soft delete and purge
13
- *
14
- * Apps should never talk to a StackAdapter directly.
15
- */
16
- import { generateId, generateIdForTimestamp } from './id.js';
17
- import { hashSchema, isCompatible, parseTypeId, baseIdOf, diffSchemas, familyIdProblem, } from './schema.js';
18
- import { dropAbsentFields, validateContent, validateContentKeys, validatePatchValues, validateReservedKeys, validateSchemaFieldNames, validateSchemaReservedNames, validateSchemaShape, } from './validate.js';
19
- import { applyMergePatch } from './merge.js';
20
- import { hasGroupAdmin, validatePermissions } from './access.js';
21
- import { compareRecordedAttachments } from './attachment-download.js';
22
- import { ChangeEmitter, RelayDelivery, PendingChange, assertSinceUsable } from './changes.js';
23
- import { SYSTEM_TYPES } from './types.js';
24
- import { UseAfterCloseError, InvalidAdapterError, StackConflictError, StackMigrationError, StackNotFoundError, StackBadRequestError, StackSchemaDriftError, StackValidationError, StackVersionConflictError, } from './errors.js';
25
- import { assertQueryCapabilities, assertSortCapability, assertValidAssociationFilters, assertValidBaseIdFilter, assertValidJournalQuery, assertValidVersionsQuery, assertValidSort, normalizeSort, assertAssociationEdits, assertAuthorityAssociations, assertDataAssociations, filtersContent, validateAssociations, } from './query-validation.js';
26
- import { GRANT_ACTION_SET, READ_COMPANIONS, grantConveys, grantReach, matchesGrantTarget, validateGrantTarget, validateGrantee, validateGrantBaseId, grantCoversGrantee, loadGrantRecords, } from './grants.js';
27
- import { bindingFieldsOf, uniqueBindingFieldsOf } from './identity-bindings.js';
28
- import { claimedFamilies, familyStanding, grantIsRequest, installAppLink, installGrantLink, installReader, linkedIds, namespaceOf, ownTypeIds, planFingerprint, sameRequest, snapshotManifest, validateInstall, INSTALL_APP_LABEL, INSTALL_GRANT_LABEL, } from './install.js';
29
- import { assertAttachmentSize, assertContentSize } from './limits.js';
30
- import { validateParentId, validateRecordId, validateClockField, validateIdTimestampSkew, DEFAULT_ID_TIMESTAMP_SKEW_MS, } from './record-id.js';
31
- import { queryAllPages, findFirstMatch, lookupEntityByDid, MAX_QUERY_LIMIT, } from './stack-reads.js';
32
- import { applyAssociationEdits, associationDelta, associationIdentical, assertNonEmptyChangeSet, changeSetOps, bumpsVersion, takesIfVersion, effectiveChanges, stampGroupAdmin, isGroupRecord, } from './record-changes.js';
33
- import { ScopedStack, scopeToken } from './scoped-stack.js';
34
- import { isTypeHandle, typedCreate, typedGet, typedMutate, typedQuery, typedSubscribe, } from './type-handle.js';
35
- // -------------------------------------------------------
36
- // Supporting types
37
- // -------------------------------------------------------
38
- /**
39
- * Default grace period for Stack.collectAttachmentGarbage(), covering the
40
- * upload-then-associate window. See docs/spec/attachments.md § Garbage
41
- * collection.
42
- */
43
- const DEFAULT_GC_GRACE_MS = 24 * 60 * 60 * 1000;
44
- /**
45
- * How far a change set's `parentId` will walk a proposed ancestor chain before refusing
46
- * the move. Bounds the reads one call can cost; a hierarchy deeper than
47
- * this is beyond what `parentId` is for. See docs/spec/data-model.md
48
- * § Reparenting.
49
- */
50
- const MAX_PARENT_DEPTH = 64;
51
- /**
52
- * An Actor in the one form `Stack` stores: a `principalId` equal to the
53
- * subject is dropped, so "acted as itself" has a single spelling, and an
54
- * empty id is refused rather than stored as a name for nobody.
55
- * See docs/spec/data-model.md § Actor.
56
- */
57
- export function normalizeActor(actor) {
58
- if (!actor)
59
- return undefined;
60
- for (const field of ['subjectId', 'principalId']) {
61
- if (actor[field] === '') {
62
- throw new StackBadRequestError(`Invalid ${field}: the empty string is not an id.`);
63
- }
64
- }
65
- const { subjectId, principalId } = actor;
66
- return principalId === undefined || principalId === subjectId
67
- ? { subjectId }
68
- : { subjectId, principalId };
69
- }
70
- /** Sentinel: filter.baseId resolved to zero matching types. */
71
- const EMPTY_FAMILY = Symbol('empty-family');
72
- /**
73
- * The `_attachment@1` fields a write may not move, each with the message it
74
- * is refused by. Ordered as reported. See docs/spec/attachments.md § The
75
- * `_attachment` record type.
76
- */
77
- const ATTACHMENT_IMMUTABLE_FIELDS = [
78
- ['mimeType', 'mimeType is immutable after creation; delete and re-upload to change it'],
79
- ['fileId', 'fileId is immutable'],
80
- ['size', 'size is immutable'],
81
- ];
82
- // -------------------------------------------------------
83
- // Stack class
84
- // -------------------------------------------------------
85
- export class Stack {
86
- adapter;
87
- idTimestampSkewMsValue;
88
- migrations = new Map();
89
- /**
90
- * Highest version this instance has defineType()'d, per baseId — what
91
- * this app process understands, as distinct from what exists in shared
92
- * storage. Used to detect the stale-writer case in presentAtLatest().
93
- */
94
- maxDefinedVersion = new Map();
95
- /**
96
- * Types this instance has fetched or defined, keyed by versioned id. A
97
- * Type's schema is immutable once defined, so entries are never
98
- * invalidated, only added; listTypes() refreshes wholesale. See
99
- * docs/spec/data-model.md § Type cache.
100
- */
101
- typeCache = new Map();
102
- /** Set by close(). See docs/spec/adapters.md § Lifecycle. */
103
- closed = false;
104
- /**
105
- * Every change made through this Stack. `ScopedStack` filters this same
106
- * stream rather than opening its own, so no scoped view can observe a
107
- * change the stack did not emit. See docs/spec/events.md.
108
- */
109
- changes = new ChangeEmitter();
110
- constructor(adapter, idTimestampSkewMsValue) {
111
- this.adapter = adapter;
112
- this.idTimestampSkewMsValue = idTimestampSkewMsValue;
113
- }
114
- /**
115
- * Announce a mutation that has already been persisted. Called after the
116
- * adapter write resolves and before the mutating method settles, so
117
- * `await stack.mutate(...)` guarantees subscribers have been notified —
118
- * and nothing about work they deferred. A handler cannot fail the write:
119
- * there is nothing left to fail. See docs/spec/events.md § Handlers.
120
- */
121
- announce(change, record, at) {
122
- this.changes.emit(change.emission(record, at));
123
- }
124
- /**
125
- * The actor for a `create` journal entry, read off the record as just
126
- * built. Create is the one journaled write that stamps the requester
127
- * onto the row itself, so the record in hand is authoritative — and it
128
- * carries `appId`, which ActorOptions has no field for.
129
- */
130
- static createActor(record) {
131
- if (!record.updatedBy)
132
- return undefined;
133
- return { ...record.updatedBy, ...(record.appId !== undefined && { appId: record.appId }) };
134
- }
135
- async getTypeCached(id) {
136
- const cached = this.typeCache.get(id);
137
- if (cached)
138
- return cached;
139
- const type = await this.adapter.getType(id);
140
- if (type)
141
- this.typeCache.set(id, type);
142
- return type;
143
- }
144
- /**
145
- * Open a Stack over an adapter. Reads ownerEntityId and timezone from the adapter.
146
- */
147
- static async open(adapter, opts = {}) {
148
- if (!adapter.ownerEntityId) {
149
- throw new InvalidAdapterError('Invalid adapter: adapter has no ownerEntityId. ' +
150
- 'Initialize the adapter with an ownerEntityId before calling Stack.open().');
151
- }
152
- const stack = new Stack(adapter, opts.idTimestampSkewMs === undefined ? DEFAULT_ID_TIMESTAMP_SKEW_MS : opts.idTimestampSkewMs);
153
- await stack.seedSystemTypes();
154
- if (opts.ownerProfile) {
155
- await stack.ensureOwnerEntity(opts.ownerProfile);
156
- }
157
- return stack;
158
- }
159
- /**
160
- * Idempotent bootstrap for StackOptions.ownerProfile. Filters by family
161
- * only (content filtering is capability-gated) and matches `content.did`
162
- * in memory, cursor-walking so an owner card past page one isn't missed
163
- * and duplicated. The probe must be blind to nothing the binding rules
164
- * see, or it mints a card they then refuse and the stack won't open with
165
- * `ownerProfile`: soft-deleted cards still reserve their `did`, and a card
166
- * migrated to a later version still holds one.
167
- * See docs/spec/identity.md § DID bindings.
168
- */
169
- async ensureOwnerEntity(profile) {
170
- const entityTypeId = `${SYSTEM_TYPES.ENTITY}@1`;
171
- const existing = await this.getEntityByDid(this.ownerEntityId);
172
- if (existing)
173
- return;
174
- await this.create(entityTypeId, {
175
- did: this.ownerEntityId,
176
- name: profile.name,
177
- ...(profile.handle && { handle: profile.handle }),
178
- });
179
- }
180
- get ownerEntityId() {
181
- return this.adapter.ownerEntityId;
182
- }
183
- async getEntityByDid(did) {
184
- return lookupEntityByDid((q) => this.query(q), did, filtersContent(this.capabilities), true);
185
- }
186
- async getOwnerEntity() {
187
- return this.getEntityByDid(this.ownerEntityId);
188
- }
189
- get timezone() {
190
- return this.adapter.timezone;
191
- }
192
- get capabilities() {
193
- return this.adapter.capabilities;
194
- }
195
- /**
196
- * Get a permission-scoped view of this Stack, as if requests came from
197
- * the given entity acting as itself (null = anonymous). Plain Stack
198
- * methods are unscoped; use asEntity() when one Stack serves multiple,
199
- * possibly untrusted, entities. See
200
- * docs/spec/access-control.md § Enforcement: Stack.asEntity().
201
- */
202
- asEntity(entityId) {
203
- return entityId === null ? this.scope(null, null) : this.asActor({ subjectId: entityId });
204
- }
205
- /**
206
- * Scope to an Actor — a delegated app acting for its user, or a
207
- * TokenSession exactly as StackTokenStore.lookupToken() returns it, which
208
- * is what a server should reach for at its request boundary. Authority is
209
- * the intersection of both parties' grants, while authorship and `-own`
210
- * resolve against the subject. Taking the pair whole leaves no order to
211
- * swap. See docs/spec/access-control.md § Delegation: principal and subject.
212
- */
213
- asActor(actor) {
214
- const { subjectId, principalId = subjectId } = normalizeActor(actor);
215
- return this.scope(principalId, subjectId);
216
- }
217
- scope(principalId, subjectId) {
218
- this.assertOpen();
219
- return new ScopedStack(scopeToken, {
220
- stack: this,
221
- principalId,
222
- subjectId,
223
- idTimestampSkewMs: this.idTimestampSkewMsValue,
224
- adapter: this.adapter,
225
- changes: this.changes,
226
- assertOpen: () => this.assertOpen(),
227
- relaysChanges: this.relaysChanges,
228
- });
229
- }
230
- // -------------------------------------------------------
231
- // Types
232
- // -------------------------------------------------------
233
- /**
234
- * Define and persist a Type; call at app startup before creating records
235
- * of the type. Redefining an existing typeId is checked against the
236
- * stored schema: identical is a no-op, a name-only change persists, and
237
- * anything beyond additive evolution throws StackSchemaDriftError. See
238
- * docs/spec/data-model.md § Schema drift detection.
239
- */
240
- async defineType({ id, name, schema: declared, migratesFrom, }) {
241
- this.assertOpen();
242
- // Only read below; the readonly view exists so a handle's literal fits.
243
- const schema = declared;
244
- const parsed = parseTypeId(id);
245
- if (!parsed) {
246
- throw new StackBadRequestError(`Invalid TypeId format: "${id}". Expected "namespace/name@version", e.g. "com.example.myapp/note@1".`);
247
- }
248
- // Register this version even on the idempotent-no-op path —
249
- // presentAtLatest()'s stale-writer detection depends on it.
250
- const priorMax = this.maxDefinedVersion.get(parsed.baseId) ?? 0;
251
- if (parsed.version > priorMax)
252
- this.maxDefinedVersion.set(parsed.baseId, parsed.version);
253
- // Shape first: the name checks and hashSchema() below both read the
254
- // schema as a well-formed one, and a schema off the wire is parsed
255
- // JSON that no compiler has seen.
256
- const shapeErrors = validateSchemaShape(schema);
257
- if (shapeErrors.length > 0) {
258
- throw new StackValidationError(shapeErrors);
259
- }
260
- const nameErrors = [
261
- ...validateSchemaReservedNames(schema),
262
- ...validateSchemaFieldNames(schema),
263
- ];
264
- if (nameErrors.length > 0) {
265
- throw new StackValidationError(nameErrors);
266
- }
267
- const schemaHash = await hashSchema(schema);
268
- const existing = await this.getTypeCached(id);
269
- if (existing) {
270
- if (existing.schemaHash === schemaHash) {
271
- if (existing.name === name)
272
- return existing;
273
- // else: name-only change — falls through to the write below,
274
- // schema/hash/createdAt all carried over unchanged.
275
- }
276
- else {
277
- const violations = diffSchemas(existing.schema, schema);
278
- if (violations.length > 0) {
279
- throw new StackSchemaDriftError(id, violations);
280
- }
281
- }
282
- }
283
- const type = {
284
- id,
285
- baseId: parsed.baseId,
286
- version: parsed.version,
287
- name,
288
- schema,
289
- schemaHash,
290
- createdAt: existing?.createdAt ?? new Date(),
291
- ...(migratesFrom && { migratesFrom }),
292
- };
293
- await this.adapter.saveType(type);
294
- this.typeCache.set(id, type);
295
- return type;
296
- }
297
- async getType(id) {
298
- this.assertOpen();
299
- return this.getTypeCached(id);
300
- }
301
- /** Refreshes typeCache wholesale — the explicit way to see a rename made by another writer. */
302
- async listTypes() {
303
- this.assertOpen();
304
- const types = await this.adapter.listTypes();
305
- for (const type of types)
306
- this.typeCache.set(type.id, type);
307
- return types;
308
- }
309
- /**
310
- * Check whether a record's type is compatible with a required schema.
311
- * Useful for duck-typed consumption across types.
312
- */
313
- async typeIsCompatible(typeId, requiredSchema) {
314
- this.assertOpen();
315
- const type = await this.getTypeCached(typeId);
316
- if (!type)
317
- return false;
318
- return isCompatible(type.schema, requiredSchema);
319
- }
320
- // -------------------------------------------------------
321
- // Migration registry
322
- // -------------------------------------------------------
323
- /**
324
- * Register a migration function between two adjacent Type versions; call
325
- * at app startup after defineType(). Runs in-memory — nothing is written
326
- * until migrateAll(). Adjacent migrations are composed into chains
327
- * automatically.
328
- */
329
- registerMigration(migration) {
330
- this.assertOpen();
331
- if (this.migrations.has(migration.from)) {
332
- throw new StackMigrationError(`A migration from "${migration.from}" is already registered.`);
333
- }
334
- this.migrations.set(migration.from, migration);
335
- }
336
- /**
337
- * Find and compose a migration path from one TypeId to another.
338
- * Returns null if no path exists.
339
- */
340
- resolveMigrationPath(fromId, toId) {
341
- if (fromId === toId)
342
- return (content) => content;
343
- const fns = [];
344
- let current = fromId;
345
- const visited = new Set();
346
- while (current !== toId) {
347
- if (visited.has(current)) {
348
- throw new StackMigrationError(`Migration cycle detected at "${current}"`);
349
- }
350
- visited.add(current);
351
- const migration = this.migrations.get(current);
352
- if (!migration)
353
- return null;
354
- fns.push(migration.migrate);
355
- current = migration.to;
356
- }
357
- return (content) => fns.reduce((c, fn) => fn(c), content);
358
- }
359
- /**
360
- * Find the latest registered version of a type family.
361
- * Follows the migration chain from the given typeId to the end.
362
- */
363
- latestTypeId(fromId) {
364
- let current = fromId;
365
- const visited = new Set();
366
- while (this.migrations.has(current)) {
367
- if (visited.has(current)) {
368
- throw new StackMigrationError(`Migration cycle detected at "${current}"`);
369
- }
370
- visited.add(current);
371
- current = this.migrations.get(current).to;
372
- }
373
- return current;
374
- }
375
- /**
376
- * Eagerly migrate all records of a type family to the latest version —
377
- * the only way disk state changes version. Sweeps soft-deleted records
378
- * too, validates each result before writing, and aborts on the first
379
- * validation failure. See docs/spec/data-model.md § Type migrations.
380
- *
381
- * `sweep: 'listed'` is for a contained app over `APIAdapter`, which can
382
- * neither enumerate unlisted records nor read a deleted one's content:
383
- * it migrates live, listed records and counts the deleted ones it passed
384
- * over in `skipped`. See docs/spec/apps.md § Migrating an installed app's types.
385
- */
386
- async migrateAll(baseId, opts = {}) {
387
- this.assertOpen();
388
- const problem = familyIdProblem(baseId, 'migrateAll');
389
- if (problem)
390
- throw new StackValidationError([{ path: 'baseId', message: problem }]);
391
- const types = await this.adapter.listTypes();
392
- const familyTypeIds = types.filter((t) => t.baseId === baseId).map((t) => t.id);
393
- if (familyTypeIds.length === 0) {
394
- throw new StackMigrationError(`migrateAll: no registered types found for baseId "${baseId}"`);
395
- }
396
- const listedOnly = opts.sweep === 'listed';
397
- let migrated = 0;
398
- let skipped = 0;
399
- for (const typeId of familyTypeIds) {
400
- const latestId = this.latestTypeId(typeId);
401
- if (typeId === latestId)
402
- continue;
403
- const migrateFn = this.resolveMigrationPath(typeId, latestId);
404
- if (!migrateFn)
405
- continue;
406
- const latestType = await this.getTypeCached(latestId);
407
- if (!latestType) {
408
- throw new StackMigrationError(`migrateAll: target type "${latestId}" is not defined.`);
409
- }
410
- let cursor;
411
- do {
412
- const result = await this.adapter.queryRecords({
413
- filter: { typeId, includeDeleted: true, includeUnlisted: !listedOnly },
414
- limit: 100,
415
- cursor,
416
- });
417
- for (const record of result.records) {
418
- if (listedOnly && record.deletedAt) {
419
- skipped++;
420
- continue;
421
- }
422
- // Same checked path commitMigration() takes — a migration
423
- // function is no more entitled to move a DID binding or repoint
424
- // an attachment than a request body is. No ifVersion: a batch
425
- // pass doesn't know each record's version going in.
426
- await this.commitMigrationChecked(record, latestId, migrateFn(record.content));
427
- migrated++;
428
- }
429
- cursor = result.cursor ?? undefined;
430
- } while (cursor);
431
- }
432
- return { migrated, skipped };
433
- }
434
- async create(typeIdOrHandle, input, opts = {}) {
435
- if (isTypeHandle(typeIdOrHandle))
436
- return typedCreate(this, typeIdOrHandle, input, opts);
437
- const typeId = typeIdOrHandle;
438
- this.assertOpen();
439
- const type = await this.getTypeCached(typeId);
440
- if (!type) {
441
- throw new StackBadRequestError(`Unknown type: "${typeId}". Call defineType() first.`);
442
- }
443
- const content = dropAbsentFields(input, type.schema);
444
- // Copied, never aliased: an import loop that reuses one Date across rows
445
- // (`d.setTime(...)` per record) would otherwise retro-edit every record
446
- // it had already written, with no version bump and no change event.
447
- // `instanceof Date`, not `!== undefined`, because this runs ahead of the
448
- // error block below and .getTime() on a non-Date off the wire would
449
- // throw before validateClockField() could report it; the fallback value
450
- // never reaches storage, since that recorded error still throws.
451
- const createdAt = opts.createdAt instanceof Date ? new Date(opts.createdAt.getTime()) : new Date();
452
- const updatedAt = opts.updatedAt instanceof Date ? new Date(opts.updatedAt.getTime()) : createdAt;
453
- // Ahead of every other check on these two lists: a caller that named
454
- // the wrong surface asked for something this layer does not offer, and
455
- // reporting its grantee as a malformed relationship target would
456
- // describe the mistake as the wrong kind of problem.
457
- assertDataAssociations(opts.associations ?? [], 'associations');
458
- assertAuthorityAssociations(opts.permissions ?? [], 'permissions');
459
- const errors = [
460
- ...validateReservedKeys(content),
461
- ...validateContentKeys(content),
462
- ...validateContent(content, type.schema),
463
- ...validateGrantee(typeId, content),
464
- ...validateGrantBaseId(typeId, content),
465
- ...validateInstall(typeId, content),
466
- ...validateAssociations(opts.permissions, 'permissions'),
467
- ...validatePermissions(opts.permissions),
468
- ...validateAssociations(opts.associations),
469
- ...validateClockField(opts.createdAt, 'createdAt'),
470
- ...validateClockField(opts.updatedAt, 'updatedAt'),
471
- ];
472
- // Compared against the *effective* createdAt, so an updatedAt supplied
473
- // on its own is caught too: defaulted-createdAt is now, which a
474
- // backdated updatedAt alone would still precede.
475
- if (opts.updatedAt !== undefined && updatedAt.getTime() < createdAt.getTime()) {
476
- errors.push({ path: 'updatedAt', message: 'updatedAt cannot precede createdAt.' });
477
- }
478
- if (errors.length > 0) {
479
- throw new StackValidationError(errors);
480
- }
481
- assertContentSize(content, this.capabilities.limits.contentBytes, 'Content');
482
- if (typeId === `${SYSTEM_TYPES.ATTACHMENT}@1`) {
483
- await this.checkAttachmentMimeTypeOnCreate(content);
484
- }
485
- await this.checkAttachmentAssociationPointers(opts.associations);
486
- await this.checkBindingsOnCreate(typeId, content);
487
- if (opts.id !== undefined) {
488
- validateRecordId(opts.id);
489
- // Only when both are explicit: an `id` alone is a pure position
490
- // choice, with no second timestamp to agree with.
491
- if (opts.createdAt !== undefined) {
492
- validateIdTimestampSkew(opts.id, this.idTimestampSkewMsValue, opts.createdAt.getTime(), 'createdAt');
493
- }
494
- // A generated id names nothing, so a create under a parent cannot
495
- // close a loop. A caller-supplied one can: existing records may
496
- // already point at it, and the chain above the parent can lead back
497
- // to it — so the walk is asked only for that combination.
498
- // See docs/spec/data-model.md § Reparenting.
499
- if (opts.parentId !== undefined)
500
- await this.assertNoParentCycle(opts.id, opts.parentId);
501
- }
502
- const createdBy = normalizeActor(opts.createdBy);
503
- const associations = baseIdOf(typeId) === SYSTEM_TYPES.GROUP
504
- ? stampGroupAdmin(opts.associations, createdBy?.subjectId ?? this.ownerEntityId)
505
- : opts.associations;
506
- // createdAt drives the id, so the two agree by construction rather than
507
- // by coincidence. An explicit one mints via generateIdForTimestamp(),
508
- // which never clamps to "now": generateId()'s monotonic floor would
509
- // otherwise pull a deliberately historical id forward once this process
510
- // has minted any live id past it.
511
- const id = opts.id ??
512
- (opts.createdAt !== undefined
513
- ? generateIdForTimestamp(createdAt.getTime())
514
- : generateId(createdAt.getTime()));
515
- // An id field a caller supplies is a value or it is absent — never the
516
- // empty string, which names nobody. Refused rather than dropped, so the
517
- // caller is never silently ignored.
518
- if (opts.appId === '') {
519
- throw new StackBadRequestError('Invalid appId: the empty string is not an id.');
520
- }
521
- // Every create naming a parent owes the reference check, whether or not
522
- // it supplied an id: a destination a caller names has to exist.
523
- if (opts.parentId !== undefined)
524
- await this.assertParentExists(id, opts.parentId);
525
- const record = {
526
- id,
527
- typeId,
528
- createdAt,
529
- updatedAt,
530
- content,
531
- version: 1,
532
- // Presence, not truthiness: '' is refused above, so absence is the
533
- // only thing a falsy value could mean here.
534
- ...(opts.parentId !== undefined && { parentId: opts.parentId }),
535
- ...(opts.appId !== undefined && { appId: opts.appId }),
536
- // A create's actor is its author, so `updatedBy` is derived rather
537
- // than taken: stamping it here keeps "absent means an unscoped write"
538
- // true of version 1 as it is of every later version.
539
- ...(createdBy && { createdBy, updatedBy: createdBy }),
540
- ...(opts.permissions?.length && { permissions: opts.permissions }),
541
- ...(associations?.length && { associations }),
542
- ...(opts.unlisted && { unlistedAt: createdAt }),
543
- };
544
- const change = new PendingChange('create', { actor: Stack.createActor(record) });
545
- const created = await this.adapter.createRecord(record, { journal: change.journal });
546
- this.announce(change, created);
547
- return created;
548
- }
549
- /**
550
- * Apply the registered migration chain in memory, for presentAt:
551
- * 'latest'. Never writes back. Throws StackMigrationError when the
552
- * record's version can't be reconciled with what this instance has
553
- * registered — the stale-writer case. See docs/spec/data-model.md
554
- * § Type migrations.
555
- */
556
- presentAtLatest(record) {
557
- const latestId = this.latestTypeId(record.typeId);
558
- if (latestId !== record.typeId) {
559
- // latestTypeId() found this by walking the same migration graph
560
- // resolveMigrationPath() walks, from the same starting point, so a
561
- // path is always resolvable here.
562
- const migrateFn = this.resolveMigrationPath(record.typeId, latestId);
563
- return { ...record, typeId: latestId, content: migrateFn(record.content) };
564
- }
565
- const parsed = parseTypeId(record.typeId);
566
- const knownMax = parsed ? this.maxDefinedVersion.get(parsed.baseId) : undefined;
567
- if (parsed && knownMax !== undefined && parsed.version !== knownMax) {
568
- const direction = parsed.version > knownMax
569
- ? `the record is newer than this app instance understands — update the app, or register the missing migrations`
570
- : `no registered migration bridges the gap — call stack.registerMigration()`;
571
- throw new StackMigrationError(`Record "${record.id}" is at "${record.typeId}", but this app instance has defined ` +
572
- `up to "${parsed.baseId}@${knownMax}": ${direction}. Omit presentAt: "latest" to ` +
573
- `read the record as stored.`);
574
- }
575
- return record;
576
- }
577
- async get(idOrHandle, idOrOpts = {}) {
578
- if (isTypeHandle(idOrHandle))
579
- return typedGet(this, idOrHandle, idOrOpts);
580
- const id = idOrHandle;
581
- const opts = idOrOpts;
582
- this.assertOpen();
583
- const record = await this.adapter.getRecord(id);
584
- if (!record)
585
- return null;
586
- if (record.deletedAt && !opts.includeDeleted)
587
- return null;
588
- return opts.presentAt === 'latest' ? this.presentAtLatest(record) : record;
589
- }
590
- async mutate(first, second, third, fourth = {}) {
591
- if (isTypeHandle(first)) {
592
- return typedMutate(this, first, second, third, fourth);
593
- }
594
- const id = first;
595
- const changes = second;
596
- const opts = (third ?? {});
597
- this.assertOpen();
598
- assertNonEmptyChangeSet(changes);
599
- const existing = await this.adapter.getRecord(id);
600
- if (!existing) {
601
- throw new StackNotFoundError(`Record not found: "${id}"`);
602
- }
603
- this.refuseIfDeleted(existing);
604
- // Checked before validation, so a caller that lost the race learns its
605
- // version is stale rather than that its patch is bad, and before the
606
- // no-op short-circuit below, so a precondition is never satisfied by a
607
- // write that turned out to move nothing.
608
- if (takesIfVersion(changes))
609
- this.checkIfVersion(existing, opts.ifVersion);
610
- const merged = await this.validateChangeSet(id, existing, changes);
611
- const ops = changeSetOps(existing, changes, merged);
612
- // Nothing moved, so there is nothing to version. Decided here rather
613
- // than in the adapter: "did this change anything" is a question about
614
- // the record's meaning, and an adapter is never handed a write that
615
- // writes nothing.
616
- if (ops.length === 0)
617
- return existing;
618
- // A change set naming only aspects the journal already keeps in full
619
- // doesn't bump — the same rule associate()/dissociate() follow
620
- // unconditionally. Its actor travels as an explicit opt rather than a
621
- // record stamp, since a non-bumping write never touches `updatedBy`.
622
- // See docs/spec/versioning.md § Version history.
623
- const bumps = bumpsVersion(ops);
624
- // Computed against the same before/after changeSetOps compared, so
625
- // whether associate/dissociate/reshare appear in `ops` and what
626
- // the journal lists can never disagree. Both halves of the partition
627
- // produce the same tagged edits and travel as one list: the journal's
628
- // argument is prior state, and an ACL element's is no different from a
629
- // tag's. See docs/spec/events.md § The event shape.
630
- const assocDelta = [
631
- ...(changes.associations
632
- ? associationDelta(existing.associations ?? [], changes.associations)
633
- : []),
634
- ...(changes.permissions
635
- ? associationDelta(existing.permissions ?? [], changes.permissions)
636
- : []),
637
- ];
638
- const previousParentId = existing.parentId ?? null;
639
- const change = new PendingChange(ops, {
640
- actor: normalizeActor(opts.actor),
641
- ...(ops.includes('reparent') && { previousParentId }),
642
- ...(assocDelta.length && { associations: assocDelta }),
643
- });
644
- const updated = await this.adapter.mutateRecord(id, effectiveChanges(changes, ops), {
645
- ...(bumps ? this.writeOptions(existing, opts) : { actor: normalizeActor(opts.actor) }),
646
- bumpsVersion: bumps,
647
- journal: change.journal,
648
- });
649
- this.announce(change, updated);
650
- return updated;
651
- }
652
- async patchContent(first, second, third, fourth) {
653
- if (isTypeHandle(first)) {
654
- return this.mutate(first, second, { contentPatch: third }, fourth);
655
- }
656
- return this.mutate(first, { contentPatch: second }, third);
657
- }
658
- /**
659
- * Every check a change set owes before anything is written, in one pass:
660
- * one refused key refuses the call, so none of them may run after a
661
- * partial write. Returns the merged content when the set carries a
662
- * content patch, since the caller needs it to decide whether content
663
- * actually moved.
664
- *
665
- * Parent existence and acyclicity are asked only when the destination
666
- * differs from where the record already sits — a move to where it
667
- * already is names no new edge, and would otherwise cost a read apiece.
668
- */
669
- async validateChangeSet(id, existing, changes) {
670
- const { contentPatch, permissions, associations, parentId } = changes;
671
- // Each key replaces only within its own domain. Asked before the
672
- // shape checks below so a misrouted element is reported as the wrong
673
- // surface rather than as a malformed value of the right one, and
674
- // asked here rather than in `ScopedStack` so an unscoped `Stack`, an
675
- // import and a server mapping a request body are all held to it.
676
- if (associations)
677
- assertDataAssociations(associations, 'associations');
678
- if (permissions)
679
- assertAuthorityAssociations(permissions, 'permissions');
680
- const errors = [
681
- ...(contentPatch
682
- ? [
683
- ...validateReservedKeys(contentPatch),
684
- ...validatePatchValues(contentPatch),
685
- ...validateContentKeys(contentPatch),
686
- ]
687
- : []),
688
- ...(permissions
689
- ? [...validateAssociations(permissions, 'permissions'), ...validatePermissions(permissions)]
690
- : []),
691
- ...(associations ? validateAssociations(associations) : []),
692
- ];
693
- if (errors.length > 0)
694
- throw new StackValidationError(errors);
695
- await this.checkAttachmentAssociationPointers(associations, existing.associations);
696
- let merged;
697
- if (contentPatch) {
698
- // The patch is what travels, so the patch is what's measured — a
699
- // small patch against a large record is not an oversized request.
700
- assertContentSize(contentPatch, this.capabilities.limits.contentBytes, 'Patch');
701
- const type = await this.getTypeCached(existing.typeId);
702
- if (!type) {
703
- throw new StackBadRequestError(`Unknown type: "${existing.typeId}"`);
704
- }
705
- merged = applyMergePatch(existing.content, contentPatch);
706
- const contentErrors = [
707
- ...validateContent(merged, type.schema),
708
- ...validateGrantee(existing.typeId, merged),
709
- ...validateGrantBaseId(existing.typeId, merged),
710
- ...validateInstall(existing.typeId, merged),
711
- ];
712
- if (contentErrors.length > 0)
713
- throw new StackValidationError(contentErrors);
714
- if (existing.typeId === `${SYSTEM_TYPES.ATTACHMENT}@1`) {
715
- // Presence decides mimeType and value decides the rest: re-sending
716
- // the mimeType a record already holds is refused outright, while a
717
- // client round-tripping fileId or size unchanged has claimed nothing.
718
- this.assertAttachmentImmutable((field) => Object.prototype.hasOwnProperty.call(contentPatch, field) &&
719
- (field === 'mimeType' ||
720
- merged[field] !==
721
- existing.content[field]));
722
- }
723
- await this.checkBindingsOnUpdate(existing.typeId, id, contentPatch, existing.content, merged);
724
- if (id === SYSTEM_TYPES.CONFIG) {
725
- this.checkConfigEntityIdUnchanged(existing.content.entityId, merged.entityId);
726
- }
727
- }
728
- if (associations !== undefined) {
729
- this.assertGroupAdminRemains(existing, associations);
730
- }
731
- if (parentId !== undefined && parentId !== null && parentId !== (existing.parentId ?? null)) {
732
- await this.assertParentExists(id, parentId);
733
- await this.assertNoParentCycle(id, parentId);
734
- }
735
- return merged;
736
- }
737
- /**
738
- * Refuse a write that would leave a `_group` Record with no `admin` on its
739
- * roster. Asked of the roster the write would *produce*, which is the
740
- * whole of the self-removal question: an admin removing themselves passes
741
- * while another remains and is refused when they are the last, without
742
- * either case naming who is going.
743
- *
744
- * An integrity constraint on the Record, not a permission question, so it
745
- * lives here rather than in `ScopedStack` and binds every requester — the
746
- * stack owner included. See docs/spec/identity.md § Group.
747
- */
748
- assertGroupAdminRemains(record, next) {
749
- if (!isGroupRecord(record))
750
- return;
751
- if (hasGroupAdmin(next))
752
- return;
753
- throw new StackConflictError(`Cannot leave group "${record.id}" without an admin: a _group record's roster keeps at ` +
754
- 'least one `admin` relationship association. Name the incoming admin in the same write.');
755
- }
756
- /**
757
- * Add associations to a record — no-bump and no snapshot, per the
758
- * StackClient declaration above.
759
- *
760
- * An association the record already holds, saying the same thing, is a
761
- * no-op; one matching an existing association's identity but naming a
762
- * different `attachmentRecordId` — or none, which clears the one stored —
763
- * re-points it in place rather than adding a second reference to the same
764
- * file. What a re-point overwrote is kept only by the journal, on that
765
- * entry's `repoint`. See docs/spec/journal.md § The entry.
766
- */
767
- async associate(id, associations, opts = {}) {
768
- return this.amendAssociations(id, associations.map((association) => ({ op: 'add', association })), opts, 'associate()');
769
- }
770
- /**
771
- * Remove associations from a record — see associate(). Matched by kind,
772
- * label and payload; an element not found is a no-op. The emitted event
773
- * names each association by identity only, since an attachment's
774
- * `attachmentRecordId` describes nothing current once it is gone; the
775
- * journal is where it survives.
776
- */
777
- async dissociate(id, associations, opts = {}) {
778
- return this.amendAssociations(id, associations.map((association) => ({ op: 'remove', association })), opts, 'dissociate()');
779
- }
780
- /**
781
- * The atomic form of associate() and dissociate(): one list of adds and
782
- * removes, one adapter write, one journal entry. The journal records each
783
- * element in full — a remove with its annotation, a re-point with what it
784
- * overwrote — so the entry is as undoable as a single-element one.
785
- * See docs/spec/data-model.md § Mutations.
786
- */
787
- async amendAssociations(id, changes, opts = {}, surface = 'amendAssociations()') {
788
- this.assertOpen();
789
- assertAssociationEdits(changes, surface, 'data');
790
- const existing = await this.requireRecord(id);
791
- this.refuseIfDeleted(existing);
792
- const current = existing.associations ?? [];
793
- const next = applyAssociationEdits(current, changes);
794
- const delta = associationDelta(current, next);
795
- if (delta.length === 0)
796
- return existing;
797
- // Only a roster can lose its last admin, and only the post-state says so.
798
- this.assertGroupAdminRemains(existing, next);
799
- await this.checkAttachmentAssociationPointers(changes.flatMap((c) => (c.op === 'add' ? [c.association] : [])), current);
800
- const change = new PendingChange([
801
- ...(delta.some((c) => c.op !== 'remove') ? ['associate'] : []),
802
- ...(delta.some((c) => c.op === 'remove') ? ['dissociate'] : []),
803
- ], { actor: normalizeActor(opts.actor), associations: delta });
804
- const updated = await this.adapter.amendAssociations(id, changes, { journal: change.journal });
805
- this.announce(change, updated);
806
- return updated;
807
- }
808
- /**
809
- * Extend who reaches a record. The spelling that survives two admins
810
- * sharing a record at once: a `permissions` key write replaces the whole
811
- * set, so the later of two concurrent ones drops what the earlier
812
- * granted. No-bump, like associate(); an element the record already
813
- * carries is a no-op. `write` never implies `read` — name both.
814
- */
815
- async grantAccess(id, permissions, opts = {}) {
816
- return this.amendAccess(id, permissions.map((association) => ({ op: 'add', association })), opts, 'grantAccess()');
817
- }
818
- /**
819
- * Withdraw elements of who reaches a record — see grantAccess() for why
820
- * this is a verb rather than a key write. An element the record does not
821
- * carry is a no-op. Returns the record as it now stands.
822
- */
823
- async revokeAccess(id, permissions, opts = {}) {
824
- return this.amendAccess(id, permissions.map((association) => ({ op: 'remove', association })), opts, 'revokeAccess()');
825
- }
826
- /**
827
- * The atomic form of grantAccess() and revokeAccess(). The
828
- * write-implies-read invariant is asked once, of the set the whole list
829
- * produces. See docs/spec/access-control.md § Write implies read.
830
- */
831
- async amendAccess(id, changes, opts = {}, surface = 'amendAccess()') {
832
- this.assertOpen();
833
- assertAssociationEdits(changes, surface, 'authority');
834
- const existing = await this.requireRecord(id);
835
- this.refuseIfDeleted(existing);
836
- const current = existing.permissions ?? [];
837
- const next = applyAssociationEdits(current, changes);
838
- const delta = associationDelta(current, next);
839
- if (delta.length === 0)
840
- return existing;
841
- this.assertPermissionSet(next);
842
- const change = new PendingChange('reshare', {
843
- actor: normalizeActor(opts.actor),
844
- associations: delta,
845
- });
846
- const updated = await this.adapter.amendAssociations(id, changes, { journal: change.journal });
847
- this.announce(change, updated);
848
- return updated;
849
- }
850
- /**
851
- * Hold a permission set to the cross-element invariant — see
852
- * validatePermissions() in access.ts for why it reads the post-state.
853
- */
854
- assertPermissionSet(next) {
855
- const errors = validatePermissions(next);
856
- if (errors.length > 0)
857
- throw new StackValidationError(errors);
858
- }
859
- /**
860
- * A soft-deleted record has no current state to edit, so the verbs that
861
- * edit one refuse it; undelete() and commitMigration() do not call this.
862
- * Asked after the record is found and, under ScopedStack, after the
863
- * authority decision. See docs/spec/versioning.md § Mutations are refused,
864
- * not applied to a tombstone.
865
- */
866
- refuseIfDeleted(record) {
867
- if (record.deletedAt) {
868
- throw new StackConflictError(`Record "${record.id}" is soft-deleted; undelete it before mutating it.`);
869
- }
870
- }
871
- /** The record, or the not-found refusal every mutating verb owes. */
872
- async requireRecord(id) {
873
- const record = await this.adapter.getRecord(id);
874
- if (!record)
875
- throw new StackNotFoundError(`Record not found: "${id}"`);
876
- return record;
877
- }
878
- /**
879
- * The checks a *caller-named* destination owes, before the cycle walk:
880
- * `parentId` is a real, well-formed record id. Format first, so a
881
- * malformed one is a 400 naming the problem rather than a read that
882
- * cannot match. Existence closes the gap between the owner path and a
883
- * non-owner's, where canReadReferent() already refuses a parent that
884
- * isn't there.
885
- *
886
- * restoreVersion() deliberately does not call this — see its own comment.
887
- * See docs/spec/data-model.md § Reparenting.
888
- */
889
- async assertParentExists(id, parentId) {
890
- validateParentId(parentId);
891
- if (!(await this.adapter.getRecord(parentId))) {
892
- throw new StackConflictError(`Cannot parent record "${id}" to "${parentId}": no such record. A container has to ` +
893
- 'exist when it is named.');
894
- }
895
- }
896
- /**
897
- * Refuse an edge that would make a record its own ancestor — asked at
898
- * every site that adds one. Nothing downstream (a generator deriving a
899
- * page path, a folder view) is written to survive a cycle.
900
- *
901
- * Walks with the unscoped adapter: a walk that skipped the links a
902
- * requester cannot read would let a cycle be assembled through them and
903
- * break the invariant for every reader.
904
- *
905
- * Read-then-write, so two moves racing on opposite ends of one chain can
906
- * both pass — the same deferral as checkBindingUnique() above, since
907
- * closing it would put a graph constraint in the storage contract.
908
- * Consumers walking `parentId` should carry a visited set rather than
909
- * trust this alone. See docs/spec/data-model.md § Reparenting.
910
- */
911
- async assertNoParentCycle(id, parentId) {
912
- let cursor = parentId;
913
- for (let depth = 0; cursor !== undefined; depth++) {
914
- if (cursor === id) {
915
- throw new StackConflictError(`Cannot parent record "${id}" to "${parentId}": it is a descendant of "${id}", ` +
916
- 'and the move would make the record its own ancestor.');
917
- }
918
- // The cap bounds work, and guarantees this terminates on a chain that
919
- // is already cyclic. It does not refuse the move: a chain this long is
920
- // past what the check can speak to, not evidence of a loop, and
921
- // refusing it would claim an invariant core does not maintain.
922
- if (depth >= MAX_PARENT_DEPTH)
923
- return;
924
- cursor = (await this.adapter.getRecord(cursor))?.parentId;
925
- }
926
- }
927
- /**
928
- * Soft-delete a record (default) or purge it permanently, removing
929
- * the record and its version history. Soft delete snapshots and bumps
930
- * version; a no-op if already deleted. `_config` is never deletable
931
- * (docs/spec.md § The `_config` record). See docs/spec/versioning.md
932
- * § Deletion.
933
- *
934
- * A purge reports the files the record referenced. It deletes none of
935
- * them — byte lifetime is not the purged record's to decide — but it
936
- * destroys the only rows naming them, so a caller who means to erase the
937
- * bytes too has nowhere else to read the argument from afterwards. See
938
- * docs/spec/attachments.md § A purge strands the bytes it referenced.
939
- *
940
- * deleteAndReturn() is this plus the record itself; reach for that
941
- * instead when the caller needs the record too — building a response
942
- * body from a separate read beforehand is exactly the race and the extra
943
- * round trip it exists to remove.
944
- */
945
- async delete(id, opts = {}) {
946
- const { referencedFileIds } = await this.deleteAndReturn(id, opts);
947
- return { referencedFileIds };
948
- }
949
- /**
950
- * delete(), reporting the record it acted on alongside the files it
951
- * stranded: as it stood immediately before destruction for a
952
- * purge, or as the resulting tombstone for a soft delete. Both are
953
- * captured inside the same write that destroys or tombstones the record,
954
- * never a read beforehand — a read-then-delete would leave a window
955
- * where a concurrent write can land, get destroyed or overwritten by
956
- * this call, and never appear in the record this returns.
957
- */
958
- async deleteAndReturn(id, opts = {}) {
959
- this.assertOpen();
960
- if (id === SYSTEM_TYPES.CONFIG) {
961
- throw new StackConflictError("Cannot delete the _config record: it holds the stack's identity and is required for every permission check.");
962
- }
963
- if (opts.purge) {
964
- // The adapter hands back what it destroyed, captured inside the same
965
- // write: a read here instead would race the delete, and afterwards
966
- // there is nothing left to read. Null means there was no record, so
967
- // nothing was purged and nothing is announced.
968
- const change = new PendingChange('purge', { actor: normalizeActor(opts.actor) });
969
- const purged = await this.adapter.deleteRecord(id, {
970
- purge: true,
971
- ifVersion: opts.ifVersion,
972
- journal: change.journal,
973
- });
974
- if (!purged)
975
- return { record: null, referencedFileIds: [] };
976
- this.announce(change, purged);
977
- return { record: purged, referencedFileIds: await this.referencedFileIds(purged) };
978
- }
979
- const existing = await this.adapter.getRecord(id);
980
- if (!existing) {
981
- throw new StackNotFoundError(`Record not found: "${id}"`);
982
- }
983
- this.checkIfVersion(existing, opts.ifVersion);
984
- // A tombstone's references stand — undelete() must find its
985
- // attachments intact — so nothing is stranded and nothing is reported.
986
- if (existing.deletedAt)
987
- return { record: existing, referencedFileIds: [] };
988
- const change = new PendingChange('delete', { actor: normalizeActor(opts.actor) });
989
- const deleted = await this.adapter.deleteRecord(id, {
990
- ...this.writeOptions(existing, opts),
991
- journal: change.journal,
992
- });
993
- if (deleted)
994
- this.announce(change, deleted);
995
- return { record: deleted ?? existing, referencedFileIds: [] };
996
- }
997
- /**
998
- * The files a record references, by the same definition
999
- * deleteAttachment() and the garbage sweep use: attachment associations
1000
- * and top-level `file-ref` content fields. An `_attachment` record's own
1001
- * `fileId` is a plain string by design and is not one, which is why
1002
- * purging a metadata record reports nothing — see initSystemTypes().
1003
- */
1004
- async referencedFileIds(record) {
1005
- const fromAssociations = (record.associations ?? []).flatMap((a) => a.kind === 'attachment' ? [a.fileId] : []);
1006
- const type = await this.getType(record.typeId);
1007
- const content = record.content;
1008
- const fromContent = Object.entries(type?.schema ?? {}).flatMap(([field, def]) => def.kind === 'file-ref' && typeof content[field] === 'string' ? [content[field]] : []);
1009
- return [...new Set([...fromAssociations, ...fromContent])];
1010
- }
1011
- /**
1012
- * Reverse a soft delete. Idempotent — undeleting a record that isn't
1013
- * deleted returns it unchanged. Purged records are gone, so this
1014
- * throws StackNotFoundError for them just like any other missing record.
1015
- * Snapshots and bumps version, same as delete().
1016
- */
1017
- async undelete(id, opts = {}) {
1018
- this.assertOpen();
1019
- const existing = await this.adapter.getRecord(id);
1020
- if (!existing) {
1021
- throw new StackNotFoundError(`Record not found: "${id}"`);
1022
- }
1023
- this.checkIfVersion(existing, opts.ifVersion);
1024
- if (!existing.deletedAt)
1025
- return existing;
1026
- const change = new PendingChange('undelete', { actor: normalizeActor(opts.actor) });
1027
- const undeleted = await this.adapter.undeleteRecord(id, {
1028
- ...this.writeOptions(existing, opts),
1029
- journal: change.journal,
1030
- });
1031
- this.announce(change, undeleted);
1032
- return undeleted;
1033
- }
1034
- async query(first = {}, typedQueryArg) {
1035
- if (isTypeHandle(first))
1036
- return typedQuery(this, first, typedQueryArg);
1037
- const query = first;
1038
- this.assertOpen();
1039
- const { presentAt, filter, limit: rawLimit, ...rest } = query;
1040
- assertQueryCapabilities(filter, this.adapter.capabilities);
1041
- assertValidSort(query.sort);
1042
- assertSortCapability(query.sort, this.adapter.capabilities);
1043
- assertValidAssociationFilters(filter);
1044
- assertValidBaseIdFilter(filter);
1045
- const limit = rawLimit !== undefined ? Math.min(rawLimit, MAX_QUERY_LIMIT) : undefined;
1046
- const resolvedFilter = await this.resolveBaseIdFilter(filter);
1047
- if (resolvedFilter === EMPTY_FAMILY) {
1048
- return { records: [], cursor: null };
1049
- }
1050
- const result = await this.adapter.queryRecords({
1051
- ...rest,
1052
- ...(query.sort && { sort: normalizeSort(query.sort) }),
1053
- ...(resolvedFilter !== undefined && { filter: resolvedFilter }),
1054
- ...(limit !== undefined && { limit }),
1055
- });
1056
- if (presentAt !== 'latest')
1057
- return result;
1058
- return { ...result, records: result.records.map((r) => this.presentAtLatest(r)) };
1059
- }
1060
- /**
1061
- * Resolve filter.baseId into a concrete typeId set (intersected with
1062
- * filter.typeId when both are given), so adapters never need their own
1063
- * baseId concept. Returns EMPTY_FAMILY when the resolved set is empty so
1064
- * the caller can short-circuit without an adapter round trip.
1065
- */
1066
- async resolveBaseIdFilter(filter) {
1067
- if (filter?.baseId === undefined)
1068
- return filter;
1069
- const { baseId, typeId, ...rest } = filter;
1070
- const requestedBaseIds = Array.isArray(baseId) ? baseId : [baseId];
1071
- const types = await this.adapter.listTypes();
1072
- const familyTypeIds = types.filter((t) => requestedBaseIds.includes(t.baseId)).map((t) => t.id);
1073
- const resolvedTypeIds = typeId === undefined
1074
- ? familyTypeIds
1075
- : familyTypeIds.filter((id) => Array.isArray(typeId) ? typeId.includes(id) : typeId === id);
1076
- if (resolvedTypeIds.length === 0)
1077
- return EMPTY_FAMILY;
1078
- return { ...rest, typeId: resolvedTypeIds };
1079
- }
1080
- // -------------------------------------------------------
1081
- // Versions
1082
- // -------------------------------------------------------
1083
- async getVersions(id, query = {}) {
1084
- this.assertOpen();
1085
- assertValidVersionsQuery(query);
1086
- return this.adapter.getVersions(id, query);
1087
- }
1088
- /**
1089
- * A record's change journal, oldest first — what moved, who moved it, and
1090
- * the association deltas nothing else retains. `ScopedStack` applies the
1091
- * mutate-surface gate; this layer is unscoped and trusted by definition.
1092
- *
1093
- * A record that isn't there is StackNotFoundError, a purged one included:
1094
- * a destroyed log and an empty one are not the same answer.
1095
- * See docs/spec/journal.md § Reading it.
1096
- */
1097
- async getJournal(id, query = {}) {
1098
- this.assertOpen();
1099
- assertValidJournalQuery(query);
1100
- return this.adapter.getJournal(id, query);
1101
- }
1102
- async getVersion(id, version) {
1103
- this.assertOpen();
1104
- return this.adapter.getVersion(id, version);
1105
- }
1106
- /**
1107
- * Restore a record to a previous version by creating a new version —
1108
- * never rewrites history. The snapshot is validated against its own
1109
- * stored typeId, and puts back `content` and `typeId` alone: a snapshot
1110
- * carries nothing else. See docs/spec/versioning.md § Restore semantics.
1111
- */
1112
- async restoreVersion(id, version, opts = {}) {
1113
- this.assertOpen();
1114
- const existing = await this.adapter.getRecord(id);
1115
- if (!existing) {
1116
- throw new StackNotFoundError(`Record not found: "${id}"`);
1117
- }
1118
- this.refuseIfDeleted(existing);
1119
- this.checkIfVersion(existing, opts.ifVersion);
1120
- const target = await this.adapter.getVersion(id, version);
1121
- if (!target) {
1122
- throw new StackNotFoundError(`Version ${version} not found for record "${id}"`);
1123
- }
1124
- const type = await this.getTypeCached(target.typeId);
1125
- if (!type) {
1126
- throw new StackBadRequestError(`Unknown type: "${target.typeId}"`);
1127
- }
1128
- const errors = [
1129
- ...validateContent(target.content, type.schema),
1130
- ...validateGrantee(target.typeId, target.content),
1131
- ...validateGrantBaseId(target.typeId, target.content),
1132
- ...validateInstall(target.typeId, target.content),
1133
- ];
1134
- if (errors.length > 0) {
1135
- throw new StackValidationError(errors);
1136
- }
1137
- if (id === SYSTEM_TYPES.CONFIG) {
1138
- this.checkConfigEntityIdUnchanged(existing.content.entityId, target.content.entityId);
1139
- }
1140
- // Restoring is a write like any other, so it owes the same immutability
1141
- // check a content patch pays: a snapshot taken before a card claimed
1142
- // its DID would otherwise move the binding by rolling content back.
1143
- // Uniqueness needs none — a restore can only put back a value this same
1144
- // card already held.
1145
- for (const field of bindingFieldsOf(baseIdOf(target.typeId))) {
1146
- this.checkBindingImmutable(baseIdOf(target.typeId), field, existing.content[field], target.content[field]);
1147
- }
1148
- // A restore adds no containment edge and takes none away, so there is
1149
- // no cycle for it to close and nothing for the destination checks to
1150
- // gate. See docs/spec/versioning.md § Restore semantics.
1151
- const change = new PendingChange('restore', {
1152
- actor: normalizeActor(opts.actor),
1153
- previousTypeId: existing.typeId,
1154
- });
1155
- const restored = await this.adapter.restoreVersion(id, version, {
1156
- ...this.writeOptions(existing, opts),
1157
- journal: change.journal,
1158
- });
1159
- this.announce(change, restored);
1160
- return restored;
1161
- }
1162
- /**
1163
- * Commit a per-record migration — the single-record counterpart to
1164
- * migrateAll(), with `content` supplied by the caller rather than by a
1165
- * registered Migration function. See docs/spec/wire-format.md § Migration
1166
- * commit.
1167
- *
1168
- * Because `content` is a full replacement written under a new `typeId`,
1169
- * this is create-shaped at the destination *and* update-shaped over the
1170
- * record as it stands, so it owes both sets of integrity checks. Missing
1171
- * either half would make migrate a second, unguarded write path to a
1172
- * state create()/mutate() refuse to reach.
1173
- */
1174
- async commitMigration(id, toTypeId, content, opts = {}) {
1175
- this.assertOpen();
1176
- const existing = await this.adapter.getRecord(id);
1177
- if (!existing) {
1178
- throw new StackNotFoundError(`Record not found: "${id}"`);
1179
- }
1180
- this.checkIfVersion(existing, opts.ifVersion);
1181
- return this.commitMigrationChecked(existing, toTypeId, content, opts);
1182
- }
1183
- /**
1184
- * The checked migration write, shared by commitMigration() and
1185
- * migrateAll(). Takes the record already in hand rather than an id: a
1186
- * batch pass holds each record from its own query page, and re-fetching
1187
- * per record would cost a read apiece for nothing.
1188
- *
1189
- * Both callers owe the same checks. migrateAll()'s content comes from a
1190
- * registered Migration function rather than a request body, but "app
1191
- * code" is not a trust boundary here — the app calling commitMigration()
1192
- * is the same app that registered the function, and neither may move a
1193
- * DID binding or repoint an attachment. registerMigration() also places
1194
- * no constraint on `from` and `to` sharing a baseId, so a migration path
1195
- * can cross type families; family-crossing is exactly what the checks
1196
- * below care about.
1197
- */
1198
- async commitMigrationChecked(existing, toTypeId, input, opts = {}) {
1199
- const id = existing.id;
1200
- const type = await this.getTypeCached(toTypeId);
1201
- if (!type) {
1202
- throw new StackBadRequestError(`Unknown type: "${toTypeId}". Call defineType() first.`);
1203
- }
1204
- const content = dropAbsentFields(input, type.schema);
1205
- const errors = [
1206
- ...validateReservedKeys(content),
1207
- ...validateContentKeys(content),
1208
- ...validateContent(content, type.schema),
1209
- ...validateGrantee(toTypeId, content),
1210
- ...validateGrantBaseId(toTypeId, content),
1211
- ...validateInstall(toTypeId, content),
1212
- ];
1213
- if (errors.length > 0) {
1214
- throw new StackValidationError(errors);
1215
- }
1216
- assertContentSize(content, this.capabilities.limits.contentBytes, 'Content');
1217
- const fromFamily = baseIdOf(existing.typeId);
1218
- const toFamily = baseIdOf(toTypeId);
1219
- const existingContent = existing.content;
1220
- // A `_group` record's `admin` roster entry is stamped by create(), the
1221
- // single site that does it — migrate cannot, since the adapter's
1222
- // commitMigration() writes `typeId` and `content` alone and leaves
1223
- // associations untouched. Minting one here would produce a group with
1224
- // an empty roster, manageable by nobody but the owner, so migrating
1225
- // *into* the family is refused. Version-to-version stays open and
1226
- // carries the existing roster with it.
1227
- if (toFamily === SYSTEM_TYPES.GROUP && fromFamily !== SYSTEM_TYPES.GROUP) {
1228
- throw new StackConflictError('Cannot migrate a record into _group: a group’s admin roster is stamped at creation. ' +
1229
- 'Create the group instead.');
1230
- }
1231
- if (fromFamily === SYSTEM_TYPES.ATTACHMENT) {
1232
- // Value-wise throughout: a migration re-sends all three required
1233
- // fields, so presence would refuse every migration of the family.
1234
- this.assertAttachmentImmutable((field) => content[field] !==
1235
- existingContent[field]);
1236
- }
1237
- else if (toFamily === SYSTEM_TYPES.ATTACHMENT) {
1238
- // A record arriving from outside the family stakes a fresh claim on
1239
- // its fileId, exactly as create() does — so it owes create()'s check.
1240
- await this.checkAttachmentMimeTypeOnCreate(content);
1241
- }
1242
- await this.checkBindingsOnMigrate(existing.typeId, toTypeId, id, existingContent, content);
1243
- if (id === SYSTEM_TYPES.CONFIG) {
1244
- this.checkConfigEntityIdUnchanged(existing.content.entityId, content.entityId);
1245
- }
1246
- const change = new PendingChange('migrate', {
1247
- actor: normalizeActor(opts.actor),
1248
- previousTypeId: existing.typeId,
1249
- });
1250
- const migrated = await this.adapter.commitMigration(id, toTypeId, content, {
1251
- ...this.writeOptions(existing, opts),
1252
- journal: change.journal,
1253
- });
1254
- this.announce(change, migrated);
1255
- return migrated;
1256
- }
1257
- /** Uniqueness for every unique binding field a newly created card claims. */
1258
- async checkBindingsOnCreate(typeId, content) {
1259
- const family = baseIdOf(typeId);
1260
- for (const field of uniqueBindingFieldsOf(family)) {
1261
- await this.checkBindingUnique(family, field, content[field]);
1262
- }
1263
- }
1264
- /**
1265
- * Immutability for every binding field a patch touches, then uniqueness
1266
- * for the subset that carries it. Fields absent from the patch carry no
1267
- * new claim — a content patch is a merge, so an untouched binding is the one the
1268
- * card already holds.
1269
- */
1270
- async checkBindingsOnUpdate(typeId, id, patch, existing, merged) {
1271
- const family = baseIdOf(typeId);
1272
- const unique = uniqueBindingFieldsOf(family);
1273
- for (const field of bindingFieldsOf(family)) {
1274
- if (!(field in patch))
1275
- continue;
1276
- this.checkBindingImmutable(family, field, existing[field], merged[field]);
1277
- if (unique.includes(field)) {
1278
- await this.checkBindingUnique(family, field, merged[field], id);
1279
- }
1280
- }
1281
- }
1282
- /**
1283
- * Bindings across a migration. `content` is a full replacement, so every
1284
- * binding field either keeps its value, moves to a new one, or is shed by
1285
- * omission — and immutability refuses the last two. Asked across the
1286
- * union of both families' binding fields, so a card can neither shed its
1287
- * DID by migrating out of `_entity`/`_app` nor pick one up on the way in.
1288
- *
1289
- * Uniqueness is asked only of the destination family, excluding the
1290
- * record itself. See docs/spec/identity.md § DID bindings.
1291
- */
1292
- async checkBindingsOnMigrate(fromTypeId, toTypeId, id, existing, content) {
1293
- const fromFamily = baseIdOf(fromTypeId);
1294
- const toFamily = baseIdOf(toTypeId);
1295
- const checked = new Set();
1296
- for (const family of fromFamily === toFamily ? [fromFamily] : [fromFamily, toFamily]) {
1297
- for (const field of bindingFieldsOf(family)) {
1298
- if (checked.has(field))
1299
- continue;
1300
- checked.add(field);
1301
- this.checkBindingImmutable(family, field, existing[field], content[field]);
1302
- }
1303
- }
1304
- for (const field of uniqueBindingFieldsOf(toFamily)) {
1305
- await this.checkBindingUnique(toFamily, field, content[field], id);
1306
- }
1307
- }
1308
- /**
1309
- * A unique binding field is what a lookup resolves *by* — an Actor's
1310
- * `principalId` by `_app.did`, its `subjectId` by `_entity.did`. Two cards
1311
- * claiming one value would leave that lookup without a single answer, and
1312
- * ambiguity is all an impersonating card needs. Enforced here rather than
1313
- * by schema, since uniqueness is a property of the set, not of the value.
1314
- *
1315
- * Read-then-write, so two creates racing on one value can both pass:
1316
- * closing that means a unique index over a JSON field, which is a
1317
- * decision about where uniqueness lives rather than a local fix.
1318
- * See docs/spec/identity.md § DID bindings.
1319
- */
1320
- async checkBindingUnique(family, field, value, excludeId) {
1321
- if (typeof value !== 'string' || value === '')
1322
- return;
1323
- const clash = await findFirstMatch((q) => this.query(q), {
1324
- filter: {
1325
- baseId: family,
1326
- includeDeleted: true,
1327
- includeUnlisted: true,
1328
- ...(filtersContent(this.capabilities) && { content: { [field]: value } }),
1329
- },
1330
- }, (r) => r.id !== excludeId && r.content[field] === value);
1331
- if (clash) {
1332
- throw new StackConflictError(`Another ${family} record already claims the ${field} "${value}"`);
1333
- }
1334
- }
1335
- /**
1336
- * A binding is permanent once made: uniqueness stops a second card
1337
- * claiming a value, but only immutability stops an existing card being
1338
- * moved onto one, which reaches the same impersonation by another route.
1339
- * Adopting a value is therefore a one-way step, and a subject whose key
1340
- * changes gets a new card — matching identity.md's deferral of key
1341
- * rotation, where a new key is a new identity rather than the same one
1342
- * relabelled. See docs/spec/identity.md § DID bindings.
1343
- */
1344
- checkBindingImmutable(family, field, existing, next) {
1345
- if (typeof existing !== 'string' || existing === '')
1346
- return;
1347
- if (next === existing)
1348
- return;
1349
- throw new StackValidationError([
1350
- {
1351
- path: field,
1352
- message: `${field} is immutable once set; register a new ${family} record instead`,
1353
- },
1354
- ]);
1355
- }
1356
- // -------------------------------------------------------
1357
- // Attachments
1358
- // -------------------------------------------------------
1359
- /**
1360
- * mimeType is a property of the fileId, not the uploader's perspective:
1361
- * the first metadata record for a fileId fixes it, and a conflicting
1362
- * later upload is rejected. See docs/spec/attachments.md § The
1363
- * `_attachment` record type.
1364
- *
1365
- * Best-effort by construction — check-then-create with no storage-level
1366
- * uniqueness behind it, so two racing first uploads can both land on a
1367
- * concurrent server. What survives that race is the *resolution*:
1368
- * getAttachmentRecords() returns the candidates in the same total order
1369
- * a server serving Content-Type applies, so both sides name the same
1370
- * winner however many records exist.
1371
- */
1372
- async checkAttachmentMimeTypeOnCreate(content) {
1373
- const { fileId, mimeType } = content;
1374
- if (typeof fileId !== 'string')
1375
- return; // schema validation already rejected this
1376
- const existing = await this.getAttachmentRecords(fileId);
1377
- if (existing.length === 0)
1378
- return;
1379
- const establishedMimeType = existing[0].content.mimeType;
1380
- if (mimeType !== establishedMimeType) {
1381
- // Deliberately does not name the established mimeType — that would
1382
- // confirm a guessed fileId's content type. See the anti-oracle rule
1383
- // in docs/spec/attachments.md.
1384
- throw new StackValidationError([
1385
- {
1386
- path: 'mimeType',
1387
- message: 'mimeType conflicts with the mimeType already established for this fileId',
1388
- },
1389
- ]);
1390
- }
1391
- }
1392
- /**
1393
- * An attachment association's `attachmentRecordId` names an `_attachment`
1394
- * record for the same `fileId` — checked here so a reference can't be
1395
- * annotated with an unrelated record's filename.
1396
- *
1397
- * Best-effort, like the mimeType check above: the named record can be
1398
- * deleted afterwards, so every reader of the field falls back rather than
1399
- * trusting it. `stored` is what the record already holds, asked so a
1400
- * change set restating an association is never refused for a pointer the
1401
- * record has carried since before the named record was deleted.
1402
- * See docs/spec/attachments.md § Naming the upload a reference came from.
1403
- */
1404
- async checkAttachmentAssociationPointers(associations, stored = []) {
1405
- const pointed = (associations ?? []).flatMap((association) => association.kind === 'attachment' &&
1406
- association.attachmentRecordId !== undefined &&
1407
- !stored.some((a) => associationIdentical(a, association))
1408
- ? [{ fileId: association.fileId, attachmentRecordId: association.attachmentRecordId }]
1409
- : []);
1410
- // One round trip per pointer, taken together: a change set carries a
1411
- // whole association list, and each pointer is an independent read.
1412
- const named = await Promise.all(pointed.map(({ attachmentRecordId }) => this.adapter.getRecord(attachmentRecordId)));
1413
- pointed.forEach(({ fileId }, i) => {
1414
- const record = named[i];
1415
- const content = record?.content;
1416
- if (record &&
1417
- baseIdOf(record.typeId) === SYSTEM_TYPES.ATTACHMENT &&
1418
- content?.fileId === fileId) {
1419
- return;
1420
- }
1421
- // One message for every way of failing: a missing record and a record
1422
- // for other bytes must not be distinguishable, or this becomes an
1423
- // existence oracle for records the caller cannot read. A write that
1424
- // succeeds does confirm the record it names, but only to a caller who
1425
- // already has file access for those bytes.
1426
- // See the anti-oracle rule in docs/spec/attachments.md.
1427
- throw new StackValidationError([
1428
- {
1429
- path: 'attachmentRecordId',
1430
- message: 'attachmentRecordId must name an `_attachment` record for this fileId',
1431
- },
1432
- ]);
1433
- });
1434
- }
1435
- /**
1436
- * filename is the only mutable field on an `_attachment@1` record; fileId,
1437
- * size and mimeType are immutable, and the correction flow is delete +
1438
- * re-upload. `violates` decides what counts as touching one, because the
1439
- * two write shapes disagree: a patch names only what it changes, while a
1440
- * migration replaces content wholesale and necessarily re-sends all three.
1441
- *
1442
- * Repointing `fileId` is the one that matters most: an `_attachment@1`
1443
- * record naming a fileId is what canAccessFile()'s uploader clause reads,
1444
- * so moving an existing record onto another file's hash is a route to
1445
- * bytes the record's author never uploaded.
1446
- * See docs/spec/attachments.md § The `_attachment` record type.
1447
- */
1448
- assertAttachmentImmutable(violates) {
1449
- const errors = ATTACHMENT_IMMUTABLE_FIELDS.filter(([field]) => violates(field)).map(([path, message]) => ({ path, message }));
1450
- if (errors.length > 0) {
1451
- throw new StackValidationError(errors);
1452
- }
1453
- }
1454
- /**
1455
- * `_config.entityId` defines stack ownership; neither patchContent() nor
1456
- * restoreVersion() may change it. A conflict with stack integrity, not a
1457
- * schema violation — hence StackConflictError. See docs/spec.md § The
1458
- * `_config` record.
1459
- */
1460
- checkConfigEntityIdUnchanged(existingEntityId, newEntityId) {
1461
- if (newEntityId !== existingEntityId) {
1462
- throw new StackConflictError('Cannot change _config.entityId: it defines stack ownership. ' +
1463
- 'Ownership transfer is not a supported operation.');
1464
- }
1465
- }
1466
- /**
1467
- * Store bytes and create an _attachment@1 metadata record (owner-
1468
- * attributed, no createdBy), returning that record — `content.fileId`
1469
- * addresses the bytes, `id` addresses the metadata. Delegates to the
1470
- * adapter's atomic putAttachmentWithMetadata() when implemented, trusting
1471
- * the returned record as backend-authoritative; otherwise falls back to
1472
- * bytes-then-create(). See docs/spec/wire-format.md § Attachments.
1473
- */
1474
- async putAttachment(data, opts) {
1475
- const { mimeType, filename, appId } = opts;
1476
- this.assertOpen();
1477
- assertAttachmentSize(data.byteLength, this.capabilities.limits.attachmentBytes);
1478
- if (this.adapter.putAttachmentWithMetadata) {
1479
- // The metadata record is written inside the adapter, so create()
1480
- // never sees it and this is the only place it can be announced.
1481
- const record = await this.adapter.putAttachmentWithMetadata(data, opts);
1482
- // The one emission with no journal half of its own: the far side
1483
- // wrote the record, so it appended the entry in that same write —
1484
- // the same division saveVersion() follows over this adapter. See
1485
- // docs/spec/journal.md § The entry set is the event set.
1486
- this.announce(new PendingChange('create'), record);
1487
- return record;
1488
- }
1489
- const fileId = await this.adapter.putBlob(data);
1490
- return this.create(`${SYSTEM_TYPES.ATTACHMENT}@1`, {
1491
- fileId,
1492
- mimeType,
1493
- size: data.byteLength,
1494
- ...(filename && { filename }),
1495
- }, { appId });
1496
- }
1497
- async getAttachment(fileId) {
1498
- this.assertOpen();
1499
- return this.adapter.getBlob(fileId);
1500
- }
1501
- /**
1502
- * Every `_attachment` record describing `fileId`, earliest-recorded
1503
- * first — so `records[0]` is the one that establishes the file's
1504
- * mimeType, and firstRecordedAttachment() is only needed by a caller
1505
- * narrowing the set further. See docs/spec/attachments.md § Finding a
1506
- * `fileId`'s metadata records.
1507
- *
1508
- * On `Stack` and not `StackClient`: the lookup answers a presentation
1509
- * question about an access decision already made, so a scoped version
1510
- * would impose a second, different permission check rather than a
1511
- * narrower correct answer.
1512
- */
1513
- async getAttachmentRecords(fileId) {
1514
- this.assertOpen();
1515
- const results = await queryAllPages((q) => this.query(q), {
1516
- filter: {
1517
- baseId: SYSTEM_TYPES.ATTACHMENT,
1518
- includeDeleted: true,
1519
- includeUnlisted: true,
1520
- ...(filtersContent(this.capabilities) && { content: { fileId } }),
1521
- },
1522
- });
1523
- return results
1524
- .filter((r) => r.content.fileId === fileId)
1525
- .sort(compareRecordedAttachments);
1526
- }
1527
- /**
1528
- * The `_attachment` family as concrete typeIds. Resolved here rather
1529
- * than in the adapter, which has no baseId concept of its own — see
1530
- * resolveBaseIdFilter().
1531
- */
1532
- async attachmentTypeIds() {
1533
- const types = await this.adapter.listTypes();
1534
- return types.filter((t) => t.baseId === SYSTEM_TYPES.ATTACHMENT).map((t) => t.id);
1535
- }
1536
- /**
1537
- * Delete an attachment's bytes and every _attachment metadata record for
1538
- * it, family-wide. Throws StackConflictError if any record in the stack
1539
- * still references the file, StackNotFoundError if neither metadata
1540
- * records nor bytes exist.
1541
- */
1542
- async deleteAttachment(fileId, opts = {}) {
1543
- this.assertOpen();
1544
- let deletedRecords;
1545
- if (this.adapter.deleteUnreferencedAttachmentRecords) {
1546
- // Purged inside the adapter's own transaction, so these never reach
1547
- // delete() and are announced here instead — from the records it
1548
- // hands back, which are the last copies that will ever exist.
1549
- deletedRecords = await this.adapter.deleteUnreferencedAttachmentRecords(fileId, await this.attachmentTypeIds());
1550
- const at = new Date();
1551
- for (const record of deletedRecords) {
1552
- this.announce(new PendingChange('purge', { actor: normalizeActor(opts.actor) }), record, at);
1553
- }
1554
- }
1555
- else {
1556
- deletedRecords = await this.deleteUnreferencedAttachmentRecordsFallback(fileId, opts);
1557
- }
1558
- if (!deletedRecords.length) {
1559
- try {
1560
- await this.adapter.getBlob(fileId);
1561
- }
1562
- catch {
1563
- throw new StackNotFoundError(`Attachment not found: "${fileId}"`);
1564
- }
1565
- }
1566
- await this.adapter.deleteBlob(fileId);
1567
- }
1568
- /**
1569
- * Non-atomic fallback for adapters that don't implement
1570
- * deleteUnreferencedAttachmentRecords(): a concurrent associate() can
1571
- * race between the reference check below and the deletes it guards.
1572
- */
1573
- async deleteUnreferencedAttachmentRecordsFallback(fileId, opts = {}) {
1574
- // A soft-deleted or unlisted record still counts as a reference — it
1575
- // must find its attachments intact on undelete or relisting. See
1576
- // docs/spec/attachments.md § Deleting attachments.
1577
- const refResult = await this.query({
1578
- filter: { referencesFileId: fileId, includeDeleted: true, includeUnlisted: true },
1579
- limit: 1,
1580
- });
1581
- if (refResult.records.length > 0) {
1582
- throw new StackConflictError('Attachment is still referenced by one or more records');
1583
- }
1584
- // Soft-deleted, unlisted, and later-version metadata is cleaned up
1585
- // too — none of it may be left pointing at deleted bytes.
1586
- const metaRecords = await this.getAttachmentRecords(fileId);
1587
- for (const record of metaRecords) {
1588
- await this.delete(record.id, { purge: true, ...opts });
1589
- }
1590
- return metaRecords;
1591
- }
1592
- /**
1593
- * Sweep for attachment bytes unreachable from any record — live or
1594
- * soft-deleted — and delete bytes + metadata. Deletion goes through
1595
- * deleteAttachment(), so a file re-referenced by sweep time is skipped,
1596
- * not a failure. See docs/spec/attachments.md § Garbage collection.
1597
- */
1598
- async collectAttachmentGarbage(opts = {}) {
1599
- this.assertOpen();
1600
- const graceMs = opts.graceMs ?? DEFAULT_GC_GRACE_MS;
1601
- const dryRun = opts.dryRun ?? false;
1602
- const now = Date.now();
1603
- const metaRecords = await queryAllPages((q) => this.query(q), {
1604
- filter: { baseId: SYSTEM_TYPES.ATTACHMENT, includeDeleted: true, includeUnlisted: true },
1605
- });
1606
- // Newest metadata record's createdAt per fileId, and its size (constant
1607
- // across records sharing a fileId, since content-addressing guarantees
1608
- // identical bytes) — used for the grace check and reclaimedBytes.
1609
- const metaByFile = new Map();
1610
- for (const record of metaRecords) {
1611
- const content = record.content;
1612
- const createdAt = record.createdAt.getTime();
1613
- const existing = metaByFile.get(content.fileId);
1614
- if (!existing || createdAt > existing.newestAt) {
1615
- metaByFile.set(content.fileId, { newestAt: createdAt, size: content.size });
1616
- }
1617
- }
1618
- // Bare-bytes orphans: blobs with zero metadata records, only
1619
- // discoverable if the blob adapter implements listBlobs().
1620
- const blobByFile = new Map();
1621
- if (this.adapter.listBlobs) {
1622
- for (const file of await this.adapter.listBlobs()) {
1623
- blobByFile.set(file.fileId, { modifiedAt: file.modifiedAt.getTime(), size: file.size });
1624
- }
1625
- }
1626
- const candidateFileIds = new Set([...metaByFile.keys(), ...blobByFile.keys()]);
1627
- const deletedFileIds = [];
1628
- let reclaimedBytes = 0;
1629
- for (const fileId of candidateFileIds) {
1630
- const refResult = await this.query({
1631
- filter: { referencesFileId: fileId, includeDeleted: true, includeUnlisted: true },
1632
- limit: 1,
1633
- });
1634
- if (refResult.records.length > 0)
1635
- continue;
1636
- const meta = metaByFile.get(fileId);
1637
- const blob = blobByFile.get(fileId);
1638
- const newestAt = meta?.newestAt ?? blob?.modifiedAt;
1639
- if (newestAt !== undefined && now - newestAt < graceMs)
1640
- continue;
1641
- const size = meta?.size ?? blob?.size ?? 0;
1642
- if (dryRun) {
1643
- deletedFileIds.push(fileId);
1644
- reclaimedBytes += size;
1645
- continue;
1646
- }
1647
- try {
1648
- await this.deleteAttachment(fileId, { actor: opts.actor });
1649
- }
1650
- catch (err) {
1651
- // Raced with a new reference, or another sweep/call already removed
1652
- // it — not a sweep failure, just move on to the next candidate.
1653
- if (err instanceof StackConflictError || err instanceof StackNotFoundError)
1654
- continue;
1655
- throw err;
1656
- }
1657
- deletedFileIds.push(fileId);
1658
- reclaimedBytes += size;
1659
- }
1660
- return { deletedFileIds, reclaimedBytes };
1661
- }
1662
- async subscribe(first, second, third) {
1663
- if (isTypeHandle(first)) {
1664
- return typedSubscribe(this, first, second, third);
1665
- }
1666
- const handler = first;
1667
- const opts = (second ?? {});
1668
- this.assertOpen();
1669
- assertSinceUsable(opts.since, this.relaysChanges);
1670
- assertValidBaseIdFilter(opts.filter);
1671
- const unsubscribe = this.changes.subscribe(handler, opts);
1672
- let stopRelay;
1673
- try {
1674
- stopRelay = await this.openRelay(handler, opts);
1675
- }
1676
- catch (err) {
1677
- // The local half is already registered, and a failed subscribe()
1678
- // must leave nothing behind for the caller to unsubscribe from.
1679
- unsubscribe();
1680
- throw err;
1681
- }
1682
- return () => {
1683
- unsubscribe();
1684
- stopRelay?.();
1685
- };
1686
- }
1687
- /**
1688
- * Ask the adapter to relay changes that originated elsewhere. Absent on
1689
- * every local adapter, where one process owns the storage and there is
1690
- * no third party whose writes could have been missed.
1691
- *
1692
- * The subscription's own filter goes to the relay rather than being
1693
- * applied on the way back: the emitter at the far end holds the record,
1694
- * so it can answer `createdBy` and `parentId`, which the envelope
1695
- * deliberately does not carry. That is also why a relay is opened per
1696
- * subscription rather than shared.
1697
- */
1698
- async openRelay(handler, opts) {
1699
- if (!this.adapter.subscribeChanges)
1700
- return undefined;
1701
- const delivery = new RelayDelivery(handler, opts);
1702
- const stop = await this.adapter.subscribeChanges({
1703
- ...(opts.filter !== undefined && { filter: opts.filter }),
1704
- ...(opts.since !== undefined && { since: opts.since }),
1705
- ...(opts.includeRecords !== undefined && { includeRecords: opts.includeRecords }),
1706
- ...(opts.includeUnlisted !== undefined && { includeUnlisted: opts.includeUnlisted }),
1707
- ...(opts.onError !== undefined && { onError: opts.onError }),
1708
- ...(opts.onReset !== undefined && { onReset: opts.onReset }),
1709
- }, (change) => delivery.deliver(change));
1710
- return () => {
1711
- delivery.close();
1712
- void stop();
1713
- };
1714
- }
1715
- /** Whether changes reach this stack from elsewhere. */
1716
- get relaysChanges() {
1717
- return typeof this.adapter.subscribeChanges === 'function';
1718
- }
1719
- /**
1720
- * Flush pending writes to the underlying storage. A no-op for adapters
1721
- * that commit on every call (SQLite, the API adapter); meaningful for
1722
- * ones that buffer, and for checkpointing a stack that stays open —
1723
- * close() covers the teardown case on its own.
1724
- */
1725
- async flush() {
1726
- this.assertOpen();
1727
- await this.adapter.flush?.();
1728
- }
1729
- /**
1730
- * Flush, then release any resources the adapter holds (connections, file
1731
- * handles, lock files). A failed flush still releases them before it
1732
- * propagates: an unwritable stack must not also leak a lock file.
1733
- * See docs/spec/adapters.md § Lifecycle.
1734
- */
1735
- async close() {
1736
- if (this.closed)
1737
- return;
1738
- // Marked closed up front so a failed flush can't leave the stack
1739
- // half-open and invite a second close() onto an already-closed adapter.
1740
- // Flushes through the adapter directly, past the now-tripped guard.
1741
- this.closed = true;
1742
- this.changes.closeAll();
1743
- try {
1744
- await this.adapter.flush?.();
1745
- }
1746
- finally {
1747
- await this.adapter.close?.();
1748
- }
1749
- }
1750
- /** Throws once close() has run. */
1751
- assertOpen() {
1752
- if (this.closed)
1753
- throw new UseAfterCloseError();
1754
- }
1755
- // -------------------------------------------------------
1756
- // Grants
1757
- // -------------------------------------------------------
1758
- /**
1759
- * Create a _grant record authorizing `grantee` to take `actions` on
1760
- * records of `typeId`: `{ kind: 'entity' }` for one DID, `{ kind: 'group' }`
1761
- * for a `_group` Record's roster at a role, `{ kind: 'authenticated' }` for
1762
- * any authenticated entity. The type-level mirror of grantAccess() —
1763
- * subject first, grantee inside the element.
1764
- *
1765
- * Granting an **app** a `-own` action does not contain it the way the
1766
- * suffix suggests: when that app acts for someone, `-own` is read as the
1767
- * bare verb and the subject decides which records are in reach, so in a
1768
- * personal stack — where nearly every record is owner-authored — a
1769
- * delegated `read-own` is close to `read-any`. Grant an app the types it
1770
- * needs, not the suffix that looks narrowest. See
1771
- * docs/spec/access-control.md § Delegation: principal and subject.
1772
- *
1773
- * `baseId` names the whole family and is refused when it carries an
1774
- * `@version` suffix. See docs/spec/access-control.md § Type-level grants.
1775
- */
1776
- async grantType(baseId, grant) {
1777
- this.assertOpen();
1778
- validateGrantTarget(grant.grantee);
1779
- this.checkGrantValid(baseId, grant.actions);
1780
- return this.create(`${SYSTEM_TYPES.GRANT}@1`, {
1781
- baseId,
1782
- actions: grant.actions,
1783
- grantee: grant.grantee,
1784
- });
1785
- }
1786
- /**
1787
- * List _grant records. Omit `query` for all grants;
1788
- * `{ kind: 'authenticated' }` for only default grants;
1789
- * `{ kind: 'group' }` for grants naming that exact group and role, or
1790
- * `role: 'any'` for every grant naming the group;
1791
- * `{ kind: 'entity' }` for the grants that currently apply to that entity
1792
- * (ones naming them, ones naming a group they belong to at a role they
1793
- * hold, plus every default grant) — the same resolution hasGrant() uses.
1794
- *
1795
- * Every arm but `entity` answers identity — what `grantType()` would have
1796
- * written with the same argument. The `entity` arm answers coverage, so
1797
- * its result is not a preview of what `revokeType()` would withdraw.
1798
- * See docs/spec/access-control.md § Type-level grants.
1799
- */
1800
- async listTypeGrants(query) {
1801
- this.assertOpen();
1802
- if (query !== undefined)
1803
- validateGrantTarget(query, true);
1804
- const all = await loadGrantRecords((q) => this.query(q));
1805
- if (query === undefined)
1806
- return all;
1807
- if (query.kind !== 'entity') {
1808
- return all.filter((r) => matchesGrantTarget(r.content, query));
1809
- }
1810
- // An entity query resolves group rosters, since a grant naming a group
1811
- // the entity belongs to also currently applies to them. Shares
1812
- // grantCoversGrantee() with the access checks, so a listing can't
1813
- // disagree with them about who a grant covers.
1814
- const groupRoles = new Map();
1815
- const result = [];
1816
- for (const r of all) {
1817
- const covers = await grantCoversGrantee(r.content, query.entityId, {
1818
- allowDefault: true,
1819
- allowGroup: true,
1820
- groupRoles,
1821
- resolveRecord: (id) => this.get(id, { includeDeleted: true }),
1822
- });
1823
- if (covers)
1824
- result.push(r);
1825
- }
1826
- return result;
1827
- }
1828
- /**
1829
- * The inverse of grantType(): soft-deletes the _grant records on
1830
- * `baseId`'s family matching `grant`, at the same granularity
1831
- * grantType() writes — the grantee is matched whole, role included, and
1832
- * the actions exactly. A soft delete like any other — the owner can
1833
- * undelete a revocation.
1834
- *
1835
- * Returns the grants it withdrew, as they stood: an array, where
1836
- * revokeAccess() returns one Record, because one target can match several
1837
- * _grant records. An empty result is not an error — re-running a
1838
- * revocation has to stay safe. See
1839
- * docs/spec/access-control.md § Listing and revoking.
1840
- */
1841
- async revokeType(baseId, grant) {
1842
- this.assertOpen();
1843
- validateGrantTarget(grant.grantee);
1844
- const problem = familyIdProblem(baseId, 'revokeType');
1845
- if (problem)
1846
- throw new StackValidationError([{ path: 'baseId', message: problem }]);
1847
- const familyId = baseId;
1848
- const actionSet = new Set(grant.actions);
1849
- const all = await loadGrantRecords((q) => this.query(q));
1850
- const matches = all.filter((r) => {
1851
- const c = r.content;
1852
- // Establishes the family and that `actions` is a list, so the exact
1853
- // match below reads a real one. Matched against the stored list
1854
- // rather than the reach, so a grant carrying an action this
1855
- // vocabulary drops is not withdrawn by a target that omits it.
1856
- const reach = grantReach(c);
1857
- if (!reach || reach.familyId !== familyId)
1858
- return false;
1859
- if (!matchesGrantTarget(c, grant.grantee))
1860
- return false;
1861
- return c.actions.length === actionSet.size && c.actions.every((a) => actionSet.has(a));
1862
- });
1863
- for (const match of matches)
1864
- await this.delete(match.id);
1865
- return matches;
1866
- }
1867
- // -------------------------------------------------------
1868
- // App installs
1869
- // -------------------------------------------------------
1870
- /**
1871
- * What applying `manifest` for the key `did` would change, for the owner
1872
- * to review before installApp(). Writes nothing. Refuses outright what no
1873
- * approval could make valid: a type outside the app's own namespace and
1874
- * the commons, a request the grant rules refuse, or a `did` already
1875
- * registered to a different app. See docs/spec/apps.md § Plan, then apply.
1876
- */
1877
- async planInstall(submitted, opts) {
1878
- this.assertOpen();
1879
- const { did } = opts;
1880
- const manifest = snapshotManifest(submitted);
1881
- this.checkManifest(manifest, did);
1882
- const installs = await this.loadInstalls();
1883
- const existing = installs.find((r) => r.content.appId === manifest.appId) ?? null;
1884
- const ownVersions = ownTypeIds(manifest);
1885
- const ownFamilies = new Set(ownVersions.map(baseIdOf));
1886
- const card = await this.findAppCard(did);
1887
- if (card && card.content.appId !== manifest.appId) {
1888
- throw new StackConflictError(`${did} is registered to "${card.content.appId}", not "${manifest.appId}"`);
1889
- }
1890
- const claimed = existing ? claimedFamilies(existing.content) : new Set();
1891
- const defined = new Set(existing?.content.defines ?? []);
1892
- const prior = existing?.content.requests ?? [];
1893
- const foreignRequests = [];
1894
- for (const r of manifest.requests) {
1895
- const standing = familyStanding(r.baseId, manifest.appId);
1896
- if (standing === 'own')
1897
- continue;
1898
- const owner = standing === 'foreign'
1899
- ? namespaceOf(r.baseId)
1900
- : standing === 'commons'
1901
- ? 'commons'
1902
- : 'system';
1903
- foreignRequests.push({ ...r, owner });
1904
- }
1905
- const typeChanges = [];
1906
- for (const t of manifest.types) {
1907
- const current = await this.getTypeCached(t.id);
1908
- if (!current)
1909
- typeChanges.push({ id: t.id, change: 'new' });
1910
- else if (current.schemaHash !== (await hashSchema(t.schema))) {
1911
- // Refused here, not left to defineType(): installApp() defines types one
1912
- // by one, so a drift found there leaves the earlier ones written.
1913
- const violations = diffSchemas(current.schema, t.schema);
1914
- if (violations.length > 0)
1915
- throw new StackSchemaDriftError(t.id, violations);
1916
- typeChanges.push({ id: t.id, change: 'schema' });
1917
- }
1918
- else if (current.name !== t.name)
1919
- typeChanges.push({ id: t.id, change: 'name' });
1920
- }
1921
- const linkedKeys = [];
1922
- for (const id of existing ? linkedIds(existing, INSTALL_APP_LABEL) : []) {
1923
- const key = (await this.get(id))?.content?.did;
1924
- if (typeof key === 'string')
1925
- linkedKeys.push(key);
1926
- }
1927
- return {
1928
- manifest,
1929
- did,
1930
- existing,
1931
- newFamilies: [...ownFamilies].filter((f) => !claimed.has(f)),
1932
- newVersions: ownVersions.filter((id) => !defined.has(id)),
1933
- requestsAdded: manifest.requests.filter((r) => !prior.some((p) => sameRequest(p, r))),
1934
- requestsRemoved: prior.filter((p) => !manifest.requests.some((r) => sameRequest(p, r))),
1935
- foreignRequests,
1936
- typeChanges,
1937
- newKey: !existing ||
1938
- !card ||
1939
- card.deletedAt !== undefined ||
1940
- !linkedIds(existing, INSTALL_APP_LABEL).includes(card.id),
1941
- linkedKeys,
1942
- };
1943
- }
1944
- /**
1945
- * Apply an approved plan: define the manifest's types, register the
1946
- * key's `_app` card if it has none, write the `_install` Record, and
1947
- * bring every linked key's grants to exactly what the manifest requests.
1948
- * Refuses a plan the stack has moved on from with StackConflictError, so
1949
- * what is applied is what was approved. Reinstalls a soft-deleted
1950
- * install. See docs/spec/apps.md § Plan, then apply.
1951
- */
1952
- async installApp(plan) {
1953
- this.assertOpen();
1954
- const fresh = await this.planInstall(plan.manifest, { did: plan.did });
1955
- if (planFingerprint(fresh) !== planFingerprint(plan)) {
1956
- throw new StackConflictError(`The stack changed since the install of "${plan.manifest.appId}" was planned; plan it again`);
1957
- }
1958
- const { manifest, did, existing } = fresh;
1959
- for (const type of manifest.types)
1960
- await this.defineType(type);
1961
- const card = await this.ensureAppCard(manifest, did);
1962
- const defines = [...new Set([...(existing?.content.defines ?? []), ...ownTypeIds(manifest)])];
1963
- const requests = manifest.requests.map((r) => ({
1964
- baseId: r.baseId,
1965
- actions: [...r.actions],
1966
- }));
1967
- let install;
1968
- if (!existing) {
1969
- install = await this.create(`${SYSTEM_TYPES.INSTALL}@1`, {
1970
- appId: manifest.appId,
1971
- name: manifest.name,
1972
- ...(manifest.version !== undefined && { version: manifest.version }),
1973
- defines,
1974
- requests,
1975
- }, { associations: [installAppLink(card.id)] });
1976
- }
1977
- else {
1978
- const current = existing.deletedAt ? await this.undelete(existing.id) : existing;
1979
- install = await this.patchContent(existing.id, { name: manifest.name, version: manifest.version ?? null, defines, requests }, { ifVersion: current.version });
1980
- if (!linkedIds(install, INSTALL_APP_LABEL).includes(card.id)) {
1981
- install = await this.associate(install.id, [installAppLink(card.id)]);
1982
- }
1983
- }
1984
- return (await this.reconcileInstallGrants(install));
1985
- }
1986
- /**
1987
- * Withdraw every grant an install produced and soft-delete it. The app's
1988
- * records and `_app` cards stay: the data is the owner's, and the cards
1989
- * are what its records' attribution resolves through. Returns the
1990
- * install's tombstone. See docs/spec/apps.md § Uninstalling.
1991
- */
1992
- async uninstallApp(appId) {
1993
- this.assertOpen();
1994
- const install = (await this.loadInstalls()).find((r) => r.content.appId === appId && !r.deletedAt);
1995
- if (!install)
1996
- throw new StackNotFoundError(`No install for "${appId}"`);
1997
- const edits = [];
1998
- for (const id of linkedIds(install, INSTALL_GRANT_LABEL)) {
1999
- if (await this.get(id))
2000
- await this.delete(id);
2001
- edits.push({ op: 'remove', association: installGrantLink(id) });
2002
- }
2003
- if (edits.length > 0)
2004
- await this.amendAssociations(install.id, edits);
2005
- const { record } = await this.deleteAndReturn(install.id);
2006
- return record;
2007
- }
2008
- /** A manifest's own shape, and every request held to grantType()'s rules. */
2009
- checkManifest(manifest, did) {
2010
- const errors = [];
2011
- if (typeof did !== 'string' || !did.startsWith('did:')) {
2012
- errors.push({ path: 'did', message: 'Expected a DID' });
2013
- }
2014
- if (typeof manifest.appId !== 'string' || manifest.appId === '') {
2015
- errors.push({ path: 'appId', message: 'Expected a non-empty appId' });
2016
- }
2017
- if (typeof manifest.name !== 'string' || manifest.name === '') {
2018
- errors.push({ path: 'name', message: 'Expected a non-empty name' });
2019
- }
2020
- const seen = new Set();
2021
- manifest.types.forEach((t, i) => {
2022
- if (seen.has(t.id)) {
2023
- errors.push({ path: `types[${i}].id`, message: `"${t.id}" is listed more than once` });
2024
- }
2025
- seen.add(t.id);
2026
- const schemaPath = `types[${i}].schema`;
2027
- const shapeErrors = validateSchemaShape(t.schema, schemaPath);
2028
- errors.push(...shapeErrors);
2029
- if (shapeErrors.length === 0) {
2030
- const schema = t.schema;
2031
- for (const e of validateSchemaReservedNames(schema)) {
2032
- errors.push({ ...e, path: `${schemaPath}.${e.path}` });
2033
- }
2034
- validateSchemaFieldNames(schema, schemaPath, errors);
2035
- }
2036
- const parsed = parseTypeId(t.id);
2037
- if (!parsed) {
2038
- errors.push({ path: `types[${i}].id`, message: 'Expected a versioned TypeId' });
2039
- }
2040
- else {
2041
- const standing = familyStanding(parsed.baseId, manifest.appId);
2042
- if (standing === 'system' || standing === 'foreign') {
2043
- errors.push({
2044
- path: `types[${i}].id`,
2045
- message: standing === 'system'
2046
- ? `"${parsed.baseId}" is a system type; no app can define it`
2047
- : `"${parsed.baseId}" is outside the namespace "${manifest.appId}"; request access to it instead of defining it`,
2048
- });
2049
- }
2050
- }
2051
- });
2052
- if (errors.length > 0)
2053
- throw new StackValidationError(errors);
2054
- for (const r of manifest.requests)
2055
- this.checkGrantValid(r.baseId, r.actions);
2056
- }
2057
- /** Every `_install` Record, deleted and unlisted included — a deleted install keeps its claims. */
2058
- async loadInstalls() {
2059
- const records = await queryAllPages((q) => this.query(q), {
2060
- filter: { baseId: SYSTEM_TYPES.INSTALL, includeDeleted: true, includeUnlisted: true },
2061
- });
2062
- return records;
2063
- }
2064
- /** The `_app` card claiming `did`, deleted and unlisted included. */
2065
- findAppCard(did) {
2066
- return findFirstMatch((q) => this.query(q), {
2067
- filter: {
2068
- baseId: SYSTEM_TYPES.APP,
2069
- includeDeleted: true,
2070
- includeUnlisted: true,
2071
- ...(filtersContent(this.capabilities) && { content: { did } }),
2072
- },
2073
- }, (r) => r.content.did === did);
2074
- }
2075
- /** The key's `_app` card, created or undeleted as needed. */
2076
- async ensureAppCard(manifest, did) {
2077
- const card = await this.findAppCard(did);
2078
- if (!card) {
2079
- return this.create(`${SYSTEM_TYPES.APP}@1`, {
2080
- appId: manifest.appId,
2081
- name: manifest.name,
2082
- ...(manifest.version !== undefined && { version: manifest.version }),
2083
- did,
2084
- });
2085
- }
2086
- return card.deletedAt ? this.undelete(card.id) : card;
2087
- }
2088
- /**
2089
- * Make the grants linked to `install` exactly its `requests`, once per
2090
- * live linked key: withdraw any that no longer match, write any missing,
2091
- * and drop links to grants that are gone.
2092
- */
2093
- async reconcileInstallGrants(install) {
2094
- const { requests } = install.content;
2095
- const dids = [];
2096
- for (const id of linkedIds(install, INSTALL_APP_LABEL)) {
2097
- const did = (await this.get(id))?.content?.did;
2098
- if (typeof did === 'string')
2099
- dids.push(did);
2100
- }
2101
- const edits = [];
2102
- const held = [];
2103
- for (const id of linkedIds(install, INSTALL_GRANT_LABEL)) {
2104
- const grant = await this.get(id);
2105
- const content = grant?.content;
2106
- const wanted = content !== undefined &&
2107
- baseIdOf(grant.typeId) === SYSTEM_TYPES.GRANT &&
2108
- dids.some((did) => requests.some((r) => grantIsRequest(content, r, did)));
2109
- if (wanted) {
2110
- held.push(content);
2111
- continue;
2112
- }
2113
- if (grant)
2114
- await this.delete(id);
2115
- edits.push({ op: 'remove', association: installGrantLink(id) });
2116
- }
2117
- for (const did of dids) {
2118
- for (const r of requests) {
2119
- if (held.some((g) => grantIsRequest(g, r, did)))
2120
- continue;
2121
- const grant = await this.grantType(r.baseId, {
2122
- actions: r.actions,
2123
- grantee: { kind: 'entity', entityId: did },
2124
- });
2125
- held.push(grant.content);
2126
- edits.push({ op: 'add', association: installGrantLink(grant.id) });
2127
- }
2128
- }
2129
- let result = edits.length > 0 ? await this.amendAssociations(install.id, edits) : install;
2130
- // Each linked key may read its own install, which is how an app learns
2131
- // what was approved; a key of this app no longer linked may not.
2132
- // See docs/spec/apps.md § Over the wire.
2133
- const appKeys = new Set((await queryAllPages((q) => this.query(q), {
2134
- filter: { baseId: SYSTEM_TYPES.APP, includeDeleted: true, includeUnlisted: true },
2135
- }))
2136
- .map((r) => r.content)
2137
- .filter((c) => c.appId === install.content.appId)
2138
- .map((c) => c.did));
2139
- const readerOf = (p) => p.kind === 'permission' && p.label === 'read' && p.grantee.kind === 'entity'
2140
- ? p.grantee.entityId
2141
- : null;
2142
- const current = (result.permissions ?? []).map(readerOf);
2143
- const access = [
2144
- ...dids
2145
- .filter((did) => !current.includes(did))
2146
- .map((entityId) => ({ op: 'add', association: installReader(entityId) })),
2147
- ...current
2148
- .filter((did) => did !== null && appKeys.has(did) && !dids.includes(did))
2149
- .map((entityId) => ({ op: 'remove', association: installReader(entityId) })),
2150
- ];
2151
- if (access.length > 0)
2152
- result = await this.amendAccess(install.id, access);
2153
- return result;
2154
- }
2155
- // -------------------------------------------------------
2156
- // Private helpers
2157
- // -------------------------------------------------------
2158
- /**
2159
- * Actions must be known GrantAction values; the target is held to the
2160
- * same rule every `_grant` write meets — see validateGrantBaseId().
2161
- */
2162
- checkGrantValid(baseId, actions) {
2163
- const errors = [];
2164
- actions.forEach((action, j) => {
2165
- if (!GRANT_ACTION_SET.has(action)) {
2166
- errors.push({ path: `actions[${j}]`, message: `Unknown grant action "${action}"` });
2167
- }
2168
- });
2169
- const problem = familyIdProblem(baseId, 'grantType');
2170
- if (problem) {
2171
- errors.push({ path: 'baseId', message: problem });
2172
- }
2173
- else {
2174
- errors.push(...validateGrantBaseId(`${SYSTEM_TYPES.GRANT}@1`, { baseId }));
2175
- }
2176
- actions.forEach((action, j) => {
2177
- if (grantConveys(actions, action))
2178
- return;
2179
- const companions = READ_COMPANIONS.get(action);
2180
- if (!companions)
2181
- return;
2182
- errors.push({
2183
- path: `actions[${j}]`,
2184
- message: `"${action}" requires ${companions.map((c) => `"${c}"`).join(' or ')} in the same grant: a mutate verb reaches the record and its history, so it conveys nothing without read. Name both in one grant: actions: [${companions.map((c) => `'${c}'`).join(' | ')}, '${action}']`,
2185
- });
2186
- });
2187
- if (errors.length > 0) {
2188
- throw new StackValidationError(errors);
2189
- }
2190
- }
2191
- async seedSystemTypes() {
2192
- await this.defineType({
2193
- id: `${SYSTEM_TYPES.CONFIG}@1`,
2194
- name: 'Config',
2195
- schema: {
2196
- entityId: { kind: 'string', required: true },
2197
- // Optional passthrough app metadata — see ConfigContent.timezone.
2198
- timezone: { kind: 'string' },
2199
- },
2200
- });
2201
- await this.defineType({
2202
- id: `${SYSTEM_TYPES.ENTITY}@1`,
2203
- name: 'Entity',
2204
- schema: {
2205
- did: { kind: 'string', required: true },
2206
- name: { kind: 'string', required: true },
2207
- handle: { kind: 'string' },
2208
- },
2209
- });
2210
- await this.defineType({
2211
- id: `${SYSTEM_TYPES.APP}@1`,
2212
- name: 'App',
2213
- schema: {
2214
- appId: { kind: 'string', required: true },
2215
- name: { kind: 'string', required: true },
2216
- version: { kind: 'string' },
2217
- did: { kind: 'string' },
2218
- },
2219
- });
2220
- await this.defineType({
2221
- id: `${SYSTEM_TYPES.GROUP}@1`,
2222
- name: 'Group',
2223
- schema: {
2224
- name: { kind: 'string', required: true },
2225
- handle: { kind: 'string' },
2226
- stackUrl: { kind: 'string' },
2227
- },
2228
- });
2229
- await this.defineType({
2230
- id: `${SYSTEM_TYPES.GRANT}@1`,
2231
- name: 'Grant',
2232
- schema: {
2233
- baseId: { kind: 'string', required: true },
2234
- actions: { kind: 'array', items: { kind: 'string' }, required: true },
2235
- // Required, and closed: a Grant's reach is spelled by its `grantee`,
2236
- // so a record arriving without one is refused here rather than read
2237
- // as a grant to every authenticated entity. The arms differ in which
2238
- // fields they carry, which a schema cannot express — evaluation reads
2239
- // the `kind` and confers nothing on one it does not recognize.
2240
- // See docs/spec/access-control.md § Type-level grants.
2241
- grantee: {
2242
- kind: 'object',
2243
- required: true,
2244
- properties: {
2245
- kind: { kind: 'string', required: true },
2246
- entityId: { kind: 'string' },
2247
- groupId: { kind: 'string' },
2248
- role: { kind: 'string' },
2249
- },
2250
- },
2251
- },
2252
- });
2253
- await this.defineType({
2254
- id: `${SYSTEM_TYPES.ATTACHMENT}@1`,
2255
- name: 'Attachment',
2256
- schema: {
2257
- // Deliberately `string`, not `file-ref`: referencesFileId matching
2258
- // (deleteAttachment()/collectAttachmentGarbage()'s reference scan) is
2259
- // schema-driven, so a `file-ref` fileId here would make every
2260
- // metadata record its own file's reference — nothing would ever be
2261
- // deletable or collectible. See docs/spec/attachments.md § Deleting
2262
- // attachments / Garbage collection.
2263
- fileId: { kind: 'string', required: true },
2264
- mimeType: { kind: 'string', required: true },
2265
- size: { kind: 'number', required: true },
2266
- filename: { kind: 'string' },
2267
- },
2268
- });
2269
- await this.defineType({
2270
- id: `${SYSTEM_TYPES.INSTALL}@1`,
2271
- name: 'Install',
2272
- schema: {
2273
- appId: { kind: 'string', required: true },
2274
- name: { kind: 'string', required: true },
2275
- version: { kind: 'string' },
2276
- defines: { kind: 'array', items: { kind: 'string' }, required: true },
2277
- requests: {
2278
- kind: 'array',
2279
- required: true,
2280
- items: {
2281
- kind: 'object',
2282
- properties: {
2283
- baseId: { kind: 'string', required: true },
2284
- actions: { kind: 'array', items: { kind: 'string' }, required: true },
2285
- },
2286
- },
2287
- },
2288
- },
2289
- });
2290
- }
2291
- /**
2292
- * Fast-fail for the ifVersion precondition using the already-fetched
2293
- * record. The adapter re-checks atomically at write time (the source of
2294
- * truth for concurrent writers) — this just skips validation and
2295
- * snapshotting work when the mismatch is already visible.
2296
- */
2297
- checkIfVersion(existing, ifVersion) {
2298
- if (ifVersion === undefined || existing.version === ifVersion)
2299
- return;
2300
- throw new StackVersionConflictError(`Record "${existing.id}" is at version ${existing.version}, expected ${ifVersion}`, existing.id, ifVersion, existing.version);
2301
- }
2302
- /**
2303
- * The options every version-bumping adapter write carries: the `ifVersion`
2304
- * precondition, the prior-state snapshot that has to land in the same
2305
- * atomic write, and who to attribute the change to. Taken together so a
2306
- * new mutating verb cannot quietly omit one — the actor's `principalId`
2307
- * most of all, whose absence reads as an undelegated write.
2308
- * See docs/spec/versioning.md § Version history.
2309
- */
2310
- writeOptions(existing, opts) {
2311
- return {
2312
- ifVersion: opts.ifVersion,
2313
- snapshot: this.buildVersionSnapshot(existing),
2314
- actor: normalizeActor(opts.actor),
2315
- };
2316
- }
2317
- /**
2318
- * Snapshot of a record's prior state, passed with the mutating adapter
2319
- * call so snapshot and mutation land in one atomic write. Carries what
2320
- * only a snapshot preserves: `content` and the `typeId` it is read
2321
- * under. Containment, listing and associations — the authority ones
2322
- * among them — are all kept by the journal instead, so no version ever
2323
- * snapshots them and there is nothing for a restore to roll them back
2324
- * to. See docs/spec/versioning.md § Version history.
2325
- */
2326
- buildVersionSnapshot(record) {
2327
- return {
2328
- version: record.version,
2329
- typeId: record.typeId,
2330
- content: record.content,
2331
- updatedAt: record.updatedAt,
2332
- ...(record.createdBy && { createdBy: record.createdBy }),
2333
- ...(record.updatedBy && { updatedBy: record.updatedBy }),
2334
- };
2335
- }
2336
- }
2337
- //# sourceMappingURL=stack.js.map