@haverstack/core 0.42.0 → 0.43.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 (150) hide show
  1. package/README.md +5 -7
  2. package/dist/access.d.ts +30 -32
  3. package/dist/access.d.ts.map +1 -1
  4. package/dist/access.js +40 -98
  5. package/dist/access.js.map +1 -1
  6. package/dist/adapter/combine.d.ts.map +1 -1
  7. package/dist/adapter/combine.js +11 -3
  8. package/dist/adapter/combine.js.map +1 -1
  9. package/dist/adapter/index.d.ts +6 -4
  10. package/dist/adapter/index.d.ts.map +1 -1
  11. package/dist/adapter/index.js +4 -3
  12. package/dist/adapter/index.js.map +1 -1
  13. package/dist/adapter/reach.d.ts +14 -0
  14. package/dist/adapter/reach.d.ts.map +1 -0
  15. package/dist/adapter/reach.js +31 -0
  16. package/dist/adapter/reach.js.map +1 -0
  17. package/dist/associations/identity.d.ts +18 -42
  18. package/dist/associations/identity.d.ts.map +1 -1
  19. package/dist/associations/identity.js +60 -84
  20. package/dist/associations/identity.js.map +1 -1
  21. package/dist/associations/validation.d.ts +1 -8
  22. package/dist/associations/validation.d.ts.map +1 -1
  23. package/dist/associations/validation.js +3 -30
  24. package/dist/associations/validation.js.map +1 -1
  25. package/dist/collection.d.ts +7 -11
  26. package/dist/collection.d.ts.map +1 -1
  27. package/dist/collection.js +24 -34
  28. package/dist/collection.js.map +1 -1
  29. package/dist/content-match.d.ts +2 -2
  30. package/dist/content-match.js +2 -2
  31. package/dist/errors.d.ts +23 -8
  32. package/dist/errors.d.ts.map +1 -1
  33. package/dist/errors.js +28 -9
  34. package/dist/errors.js.map +1 -1
  35. package/dist/grants.d.ts +3 -3
  36. package/dist/grants.d.ts.map +1 -1
  37. package/dist/grants.js +4 -21
  38. package/dist/grants.js.map +1 -1
  39. package/dist/index.d.ts +5 -5
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +2 -2
  42. package/dist/index.js.map +1 -1
  43. package/dist/lazy.d.ts +13 -0
  44. package/dist/lazy.d.ts.map +1 -0
  45. package/dist/lazy.js +27 -0
  46. package/dist/lazy.js.map +1 -0
  47. package/dist/merge.d.ts +1 -1
  48. package/dist/merge.js +1 -1
  49. package/dist/query-validation.d.ts +41 -28
  50. package/dist/query-validation.d.ts.map +1 -1
  51. package/dist/query-validation.js +85 -71
  52. package/dist/query-validation.js.map +1 -1
  53. package/dist/record-changes.d.ts +12 -36
  54. package/dist/record-changes.d.ts.map +1 -1
  55. package/dist/record-changes.js +9 -98
  56. package/dist/record-changes.js.map +1 -1
  57. package/dist/scoped-stack/authority-snapshot.d.ts +37 -0
  58. package/dist/scoped-stack/authority-snapshot.d.ts.map +1 -0
  59. package/dist/scoped-stack/authority-snapshot.js +49 -0
  60. package/dist/scoped-stack/authority-snapshot.js.map +1 -0
  61. package/dist/scoped-stack/authority.d.ts +51 -38
  62. package/dist/scoped-stack/authority.d.ts.map +1 -1
  63. package/dist/scoped-stack/authority.js +160 -136
  64. package/dist/scoped-stack/authority.js.map +1 -1
  65. package/dist/scoped-stack/family-policy.d.ts +59 -0
  66. package/dist/scoped-stack/family-policy.d.ts.map +1 -0
  67. package/dist/scoped-stack/family-policy.js +68 -0
  68. package/dist/scoped-stack/family-policy.js.map +1 -0
  69. package/dist/scoped-stack/scoped-stack.d.ts +66 -86
  70. package/dist/scoped-stack/scoped-stack.d.ts.map +1 -1
  71. package/dist/scoped-stack/scoped-stack.js +243 -324
  72. package/dist/scoped-stack/scoped-stack.js.map +1 -1
  73. package/dist/scoped-stack/subscription.d.ts +14 -33
  74. package/dist/scoped-stack/subscription.d.ts.map +1 -1
  75. package/dist/scoped-stack/subscription.js +16 -57
  76. package/dist/scoped-stack/subscription.js.map +1 -1
  77. package/dist/stack/arguments.d.ts +27 -0
  78. package/dist/stack/arguments.d.ts.map +1 -0
  79. package/dist/stack/arguments.js +53 -0
  80. package/dist/stack/arguments.js.map +1 -0
  81. package/dist/stack/attachments.d.ts +1 -1
  82. package/dist/stack/attachments.js +1 -1
  83. package/dist/stack/changes.d.ts +4 -10
  84. package/dist/stack/changes.d.ts.map +1 -1
  85. package/dist/stack/changes.js +8 -8
  86. package/dist/stack/changes.js.map +1 -1
  87. package/dist/stack/client.d.ts +34 -58
  88. package/dist/stack/client.d.ts.map +1 -1
  89. package/dist/stack/install-app.d.ts.map +1 -1
  90. package/dist/stack/install-app.js +7 -5
  91. package/dist/stack/install-app.js.map +1 -1
  92. package/dist/stack/integrity.js +2 -2
  93. package/dist/stack/integrity.js.map +1 -1
  94. package/dist/stack/limits.d.ts +1 -1
  95. package/dist/stack/limits.js +1 -1
  96. package/dist/stack/reads.d.ts +9 -5
  97. package/dist/stack/reads.d.ts.map +1 -1
  98. package/dist/stack/reads.js +24 -4
  99. package/dist/stack/reads.js.map +1 -1
  100. package/dist/stack/stack.d.ts +77 -71
  101. package/dist/stack/stack.d.ts.map +1 -1
  102. package/dist/stack/stack.js +229 -256
  103. package/dist/stack/stack.js.map +1 -1
  104. package/dist/stack/type-grants.d.ts.map +1 -1
  105. package/dist/stack/type-grants.js +4 -4
  106. package/dist/stack/type-grants.js.map +1 -1
  107. package/dist/stack/write-expectation.d.ts +13 -12
  108. package/dist/stack/write-expectation.d.ts.map +1 -1
  109. package/dist/stack/write-expectation.js.map +1 -1
  110. package/dist/stack/write-gate.d.ts +23 -0
  111. package/dist/stack/write-gate.d.ts.map +1 -0
  112. package/dist/stack/write-gate.js +17 -0
  113. package/dist/stack/write-gate.js.map +1 -0
  114. package/dist/testing.d.ts +25 -9
  115. package/dist/testing.d.ts.map +1 -1
  116. package/dist/testing.js +147 -123
  117. package/dist/testing.js.map +1 -1
  118. package/dist/types/adapter.d.ts +90 -31
  119. package/dist/types/adapter.d.ts.map +1 -1
  120. package/dist/types/associations.d.ts +1 -17
  121. package/dist/types/associations.d.ts.map +1 -1
  122. package/dist/types/associations.js +1 -8
  123. package/dist/types/associations.js.map +1 -1
  124. package/dist/types/capabilities.d.ts +8 -8
  125. package/dist/types/capabilities.d.ts.map +1 -1
  126. package/dist/types/events.d.ts +3 -8
  127. package/dist/types/events.d.ts.map +1 -1
  128. package/dist/types/query.d.ts +4 -4
  129. package/dist/types/system.d.ts +1 -1
  130. package/dist/validate.d.ts +2 -0
  131. package/dist/validate.d.ts.map +1 -1
  132. package/dist/validate.js +2 -1
  133. package/dist/validate.js.map +1 -1
  134. package/dist/wire/body.d.ts +32 -9
  135. package/dist/wire/body.d.ts.map +1 -1
  136. package/dist/wire/body.js +69 -8
  137. package/dist/wire/body.js.map +1 -1
  138. package/dist/wire/index.d.ts +4 -4
  139. package/dist/wire/index.d.ts.map +1 -1
  140. package/dist/wire/index.js +3 -3
  141. package/dist/wire/index.js.map +1 -1
  142. package/dist/wire/record.d.ts +1 -8
  143. package/dist/wire/record.d.ts.map +1 -1
  144. package/dist/wire/record.js +0 -48
  145. package/dist/wire/record.js.map +1 -1
  146. package/dist/wire/request.d.ts +18 -18
  147. package/dist/wire/request.d.ts.map +1 -1
  148. package/dist/wire/request.js +41 -36
  149. package/dist/wire/request.js.map +1 -1
  150. package/package.json +1 -1
@@ -24,17 +24,18 @@ import { compareRecordedAttachments } from '../attachment-records.js';
24
24
  import { stampGroupAdmin, validatePermissions } from '../access.js';
25
25
  import { ChangeEmitter, RelayDelivery, PendingChange, assertSinceUsable } from './changes.js';
26
26
  import { SYSTEM_TYPES } from '../types/index.js';
27
- import { UseAfterCloseError, InvalidAdapterError, StackConflictError, StackMigrationError, StackNotFoundError, StackBadRequestError, StackSchemaDriftError, StackValidationError, StackVersionConflictError, ARGUMENTS_INVALID, SCHEMA_INVALID, } from '../errors.js';
28
- import { assertQueryCapabilities, assertValidContentByType, assertSortCapability, assertValidAssociationFilters, assertValidBaseIdFilter, assertValidJournalQuery, assertValidVersionsQuery, assertValidSort, normalizeSort, filtersContent, assertFamilyId, } from '../query-validation.js';
29
- import { assertAssociationEdits, assertAssociationList, assertAuthorityAssociations, assertDataAssociations, validateAssociations, } from '../associations/validation.js';
30
- import { applyAssociationEdits, associationDelta, editsOf } from '../associations/identity.js';
27
+ import { ElementSetMovedError, UseAfterCloseError, ScopeUnavailableError, InvalidAdapterError, StackConflictError, StackMigrationError, StackNotFoundError, StackBadRequestError, StackSchemaDriftError, StackValidationError, StackVersionConflictError, ARGUMENTS_INVALID, SCHEMA_INVALID, softDeletedRefusal, } from '../errors.js';
28
+ import { assertQueryCapabilities, assertValidBaseIdFilter, assertValidJournalQuery, assertValidVersionsQuery, resolveQuery, splitQuery, filtersContent, assertFamilyId, } from '../query-validation.js';
29
+ import { assertAssociationEdits, assertAuthorityAssociations, assertDataAssociations, validateAssociations, } from '../associations/validation.js';
30
+ import { applyAssociationEdits, associationDelta } from '../associations/identity.js';
31
31
  import { validateGrantee, validateGrantBaseId } from '../grants.js';
32
32
  import { bindingFieldsOf } from './identity-bindings.js';
33
33
  import { validateInstall } from '../install.js';
34
34
  import { assertAttachmentSize, assertContentSize } from './limits.js';
35
35
  import { validateRecordId, validateClockField, validateIdTimestampSkew, DEFAULT_ID_TIMESTAMP_SKEW_MS, } from './record-id.js';
36
- import { queryAllPages, lookupEntityByDid, MAX_QUERY_LIMIT } from './reads.js';
37
- import { assertNonEmptyChangeSet, bumpsVersion, changeSetOps, effectiveChanges, takesIfVersion, } from '../record-changes.js';
36
+ import { queryAllPages, lookupEntityByDid } from './reads.js';
37
+ import { contentEqual } from '../record-changes.js';
38
+ import { assertAssociationSet, assertContentPatch, assertParentArgument, assertUnlistedArgument, } from './arguments.js';
38
39
  import { ScopedStack, scopeToken } from '../scoped-stack/scoped-stack.js';
39
40
  import { MigrationRegistry } from './migrations.js';
40
41
  import * as apps from './install-app.js';
@@ -43,6 +44,7 @@ import * as attachments from './attachments.js';
43
44
  import { assertAttachmentImmutable, assertGroupAdminRemains, assertNoParentCycle, assertParentExists, checkAttachmentAssociationPointers, checkAttachmentMimeTypeOnCreate, checkBindingImmutable, checkBindingsOnCreate, checkBindingsOnMigrate, checkBindingsOnUpdate, checkConfigEntityIdUnchanged, } from './integrity.js';
44
45
  import { systemTypeDefinitions } from './system-types.js';
45
46
  import { checkFamily, checkSnapshotFamily, checkStoredVersion, WRITE_EXPECTATION, } from './write-expectation.js';
47
+ import { WRITE_GATE } from './write-gate.js';
46
48
  import { createCollection } from '../collection.js';
47
49
  // -------------------------------------------------------
48
50
  // Supporting definitions
@@ -66,8 +68,8 @@ function normalizeActor(actor) {
66
68
  ? { subjectId }
67
69
  : { subjectId, principalId };
68
70
  }
69
- /** Sentinel: filter.baseId resolved to zero matching types. */
70
- const EMPTY_FAMILY = Symbol('empty-family');
71
+ /** How many Records migrateAll() reads and migrates per page. */
72
+ const MIGRATE_ALL_BATCH = 100;
71
73
  /**
72
74
  * One refusal for every problem a call carries, content and arguments alike,
73
75
  * so a caller fixes them in one round trip. Content is itself an argument,
@@ -123,7 +125,7 @@ export class Stack {
123
125
  /**
124
126
  * Announce a mutation that has already been persisted. Called after the
125
127
  * adapter write resolves and before the mutating method settles, so
126
- * `await stack.mutate(...)` guarantees subscribers have been notified —
128
+ * `await stack.patchContent(...)` guarantees subscribers have been notified —
127
129
  * and nothing about work they deferred. A handler cannot fail the write:
128
130
  * there is nothing left to fail. See docs/spec/events.md § Handlers.
129
131
  */
@@ -218,8 +220,24 @@ export class Stack {
218
220
  const { subjectId, principalId = subjectId } = normalizeActor(actor);
219
221
  return this.scope(principalId, subjectId);
220
222
  }
223
+ /**
224
+ * A scoped view needs a stack that owns its storage: one whose adapter
225
+ * queries within a reach and relays no changes from elsewhere. A
226
+ * server-backed stack is scoped by the session that opened it instead.
227
+ * See docs/spec/access-control.md § Enforcement: Stack.asEntity().
228
+ */
221
229
  scope(principalId, subjectId) {
222
230
  this.assertOpen();
231
+ const queryReadable = this.adapter.queryReadable?.bind(this.adapter);
232
+ if (!queryReadable) {
233
+ throw new ScopeUnavailableError('This stack cannot be scoped: its adapter does not implement queryReadable(), so a ' +
234
+ "query could not be kept to the requester's reach. Every local adapter must; a " +
235
+ 'server-backed stack is scoped by opening it with the session you mean.');
236
+ }
237
+ if (this.relaysChanges) {
238
+ throw new ScopeUnavailableError('This stack cannot be scoped: it relays changes made elsewhere, whose frames were ' +
239
+ 'scoped by the session that opened the feed. Open it with the session you mean.');
240
+ }
223
241
  return new ScopedStack(scopeToken, {
224
242
  stack: this,
225
243
  principalId,
@@ -228,8 +246,8 @@ export class Stack {
228
246
  adapter: this.adapter,
229
247
  changes: this.changes,
230
248
  assertOpen: () => this.assertOpen(),
231
- relaysChanges: this.relaysChanges,
232
249
  migrations: this.migrations,
250
+ queryReadable: (split, reach) => this.dispatchQuery(split, (resolved) => queryReadable(resolved, reach)),
233
251
  });
234
252
  }
235
253
  collection(handle) {
@@ -335,7 +353,7 @@ export class Stack {
335
353
  async migrateAll(baseId, opts = {}) {
336
354
  this.assertOpen();
337
355
  assertFamilyId(baseId, 'migrateAll');
338
- const familyTypeIds = await this.familyTypeIds([baseId]);
356
+ const familyTypeIds = typeIdsOfFamilies(await this.adapter.listTypes(), [baseId]);
339
357
  if (familyTypeIds.length === 0) {
340
358
  throw new StackMigrationError(`migrateAll: no registered types found for baseId "${baseId}"`);
341
359
  }
@@ -355,11 +373,11 @@ export class Stack {
355
373
  }
356
374
  let cursor;
357
375
  do {
358
- const result = await this.adapter.queryRecords({
376
+ const result = await this.adapter.queryRecords(resolveQuery({
359
377
  filter: { typeId, includeDeleted: true, includeUnlisted: !listedOnly },
360
- limit: 100,
378
+ limit: MIGRATE_ALL_BATCH,
361
379
  cursor,
362
- });
380
+ }));
363
381
  for (const record of result.records) {
364
382
  if (listedOnly && record.deletedAt) {
365
383
  skipped++;
@@ -508,230 +526,210 @@ export class Stack {
508
526
  return opts.presentAt === 'latest' ? this.migrations.presentAtLatest(record) : record;
509
527
  }
510
528
  /**
511
- * Apply a change set as one atomic write — see StackClient.mutate(). A
512
- * set the record already satisfies writes nothing, and content is checked
513
- * against the record's current stored type.
529
+ * Merge a patch into a record's content, checked against its current
530
+ * stored type. A patch the content already satisfies writes nothing.
514
531
  * See docs/spec/data-model.md § Mutations.
515
532
  */
516
- async mutate(id, changes, opts = {}) {
533
+ async patchContent(id, patch, opts = {}) {
517
534
  this.assertOpen();
518
- assertNonEmptyChangeSet(changes);
519
- const existing = await this.requireRecord(id);
520
- checkFamily(existing, opts);
521
- this.refuseIfDeleted(existing);
522
- // Checked before validation, so a caller that lost the race learns its
523
- // version is stale rather than that its patch is bad, and before the
524
- // no-op short-circuit below, so a precondition is never satisfied by a
525
- // write that turned out to move nothing.
526
- if (takesIfVersion(changes))
527
- this.checkIfVersion(existing, opts.ifVersion);
528
- checkStoredVersion(existing, opts, changes.contentPatch !== undefined);
529
- const merged = await this.validateChangeSet(id, existing, changes);
530
- const ops = changeSetOps(existing, changes, merged);
531
- // Nothing moved, so there is nothing to version. Decided here rather
532
- // than in the adapter: "did this change anything" is a question about
533
- // the record's meaning, and an adapter is never handed a write that
534
- // writes nothing.
535
- if (ops.length === 0)
535
+ assertContentPatch(patch);
536
+ const existing = await this.requireWritable(id, opts, true);
537
+ const merged = await this.validatePatch(id, existing, patch);
538
+ if (contentEqual(existing.content, merged))
536
539
  return existing;
537
- // A change set naming only aspects the journal already keeps in full
538
- // doesn't bump — the same rule associate()/dissociate() follow
539
- // unconditionally. Its actor travels as an explicit opt rather than a
540
- // record stamp, since a non-bumping write never touches `updatedBy`.
541
- // See docs/spec/versioning.md § Version history.
542
- const bumps = bumpsVersion(ops);
543
- // From the same before/after changeSetOps compared, so `ops` and the
544
- // journal cannot disagree; ACL and data edits travel as one list.
545
- // See docs/spec/events.md § The event shape.
546
- const assocDelta = [
547
- ...(changes.associations
548
- ? associationDelta(existing.associations ?? [], changes.associations)
549
- : []),
550
- ...(changes.permissions
551
- ? associationDelta(existing.permissions ?? [], changes.permissions)
552
- : []),
553
- ];
554
- const previousParentId = existing.parentId ?? null;
555
- const change = new PendingChange(ops, {
556
- actor: normalizeActor(opts.actor),
557
- ...(ops.includes('reparent') && { previousParentId }),
558
- ...(assocDelta.length && { associations: assocDelta }),
559
- });
560
- const updated = await this.adapter.mutateRecord(id, effectiveChanges(changes, ops), {
561
- ...(bumps ? this.writeOptions(existing, opts) : { actor: normalizeActor(opts.actor) }),
562
- bumpsVersion: bumps,
540
+ const change = new PendingChange('patch', { actor: normalizeActor(opts.actor) });
541
+ const updated = await this.adapter.patchContent(id, patch, {
542
+ ...this.writeOptions(existing, opts),
563
543
  journal: change.journal,
564
544
  });
565
545
  this.announce(change, updated);
566
546
  return updated;
567
547
  }
568
548
  /**
569
- * mutate() with `contentPatch` alone. The common case by a wide margin,
570
- * and named for what it does rather than for a symmetry with create()
571
- * that a patch does not have.
549
+ * Every check a content patch owes, all before anything is written.
550
+ * Returns the merged content, which the caller compares to decide whether
551
+ * content moved.
572
552
  */
573
- async patchContent(id, patch, opts) {
574
- return this.mutate(id, { contentPatch: patch }, opts);
575
- }
576
- /**
577
- * Every check a change set owes, all before anything is written. Returns
578
- * the merged content for a content patch, which the caller compares to
579
- * decide whether content moved. A parent is checked only when it changes.
580
- */
581
- async validateChangeSet(id, existing, changes) {
582
- const { contentPatch, permissions, associations, parentId } = changes;
583
- // Each key replaces only within its own domain. Asked before the
584
- // shape checks below so a misrouted element is reported as the wrong
585
- // surface rather than as a malformed value of the right one, and
586
- // asked here rather than in `ScopedStack` so an unscoped `Stack`, an
587
- // import and a server mapping a request body are all held to it.
588
- if (associations)
589
- assertDataAssociations(associations, 'associations');
590
- if (permissions)
591
- assertAuthorityAssociations(permissions, 'permissions');
592
- const argumentErrors = [
593
- ...(permissions
594
- ? [...validateAssociations(permissions, 'permissions'), ...validatePermissions(permissions)]
595
- : []),
596
- ...(associations ? validateAssociations(associations) : []),
553
+ async validatePatch(id, existing, patch) {
554
+ const patchErrors = [
555
+ ...validateReservedKeys(patch),
556
+ ...validatePatchValues(patch),
557
+ ...validateContentKeys(patch),
597
558
  ];
598
- const patchErrors = contentPatch
599
- ? [
600
- ...validateReservedKeys(contentPatch),
601
- ...validatePatchValues(contentPatch),
602
- ...validateContentKeys(contentPatch),
603
- ]
604
- : [];
605
- throwValidation(patchErrors, argumentErrors);
606
- await checkAttachmentAssociationPointers(this.readRecord, associations, existing.associations);
607
- let merged;
608
- if (contentPatch) {
609
- // The patch is what travels, so the patch is what's measured — a
610
- // small patch against a large record is not an oversized request.
611
- assertContentSize(contentPatch, this.capabilities.limits.contentBytes, 'Patch');
612
- const type = await this.getTypeCached(existing.typeId);
613
- if (!type) {
614
- throw new StackBadRequestError(`Unknown type: "${existing.typeId}"`);
615
- }
616
- merged = applyMergePatch(existing.content, contentPatch);
617
- const contentErrors = typedContentErrors(existing.typeId, merged, type.schema);
618
- if (contentErrors.length > 0)
619
- throw new StackValidationError(contentErrors);
620
- if (existing.typeId === `${SYSTEM_TYPES.ATTACHMENT}@1`) {
621
- // Presence decides mimeType and value decides the rest: re-sending
622
- // the mimeType a record already holds is refused outright, while a
623
- // client round-tripping fileId or size unchanged has claimed nothing.
624
- assertAttachmentImmutable((field) => Object.prototype.hasOwnProperty.call(contentPatch, field) &&
625
- (field === 'mimeType' ||
626
- merged[field] !==
627
- existing.content[field]));
628
- }
629
- await checkBindingsOnUpdate(this, existing.typeId, id, contentPatch, existing.content, merged);
630
- if (id === SYSTEM_TYPES.CONFIG) {
631
- checkConfigEntityIdUnchanged(existing.content.entityId, merged.entityId);
632
- }
559
+ if (patchErrors.length > 0)
560
+ throw new StackValidationError(patchErrors);
561
+ // The patch is what travels, so the patch is what's measured — a
562
+ // small patch against a large record is not an oversized request.
563
+ assertContentSize(patch, this.capabilities.limits.contentBytes, 'Patch');
564
+ const type = await this.getTypeCached(existing.typeId);
565
+ if (!type) {
566
+ throw new StackBadRequestError(`Unknown type: "${existing.typeId}"`);
567
+ }
568
+ const merged = applyMergePatch(existing.content, patch);
569
+ const contentErrors = typedContentErrors(existing.typeId, merged, type.schema);
570
+ if (contentErrors.length > 0)
571
+ throw new StackValidationError(contentErrors);
572
+ if (existing.typeId === `${SYSTEM_TYPES.ATTACHMENT}@1`) {
573
+ // Presence decides mimeType and value decides the rest: re-sending
574
+ // the mimeType a record already holds is refused outright, while a
575
+ // client round-tripping fileId or size unchanged has claimed nothing.
576
+ assertAttachmentImmutable((field) => Object.prototype.hasOwnProperty.call(patch, field) &&
577
+ (field === 'mimeType' ||
578
+ merged[field] !==
579
+ existing.content[field]));
633
580
  }
634
- if (associations !== undefined) {
635
- assertGroupAdminRemains(existing, associations);
581
+ await checkBindingsOnUpdate(this, existing.typeId, id, patch, existing.content, merged);
582
+ if (id === SYSTEM_TYPES.CONFIG) {
583
+ checkConfigEntityIdUnchanged(existing.content.entityId, merged.entityId);
636
584
  }
637
- if (parentId !== undefined && parentId !== null && parentId !== (existing.parentId ?? null)) {
585
+ return merged;
586
+ }
587
+ /**
588
+ * Move a record under another, or to the root with `null`. The
589
+ * destination is checked only when it changes: it has to exist, and the
590
+ * move must not make the record its own ancestor.
591
+ * See docs/spec/data-model.md § Reparenting.
592
+ */
593
+ async setParent(id, parentId, opts = {}) {
594
+ this.assertOpen();
595
+ assertParentArgument(parentId);
596
+ const existing = await this.requireWritable(id, opts);
597
+ const previousParentId = existing.parentId ?? null;
598
+ if (parentId === previousParentId)
599
+ return existing;
600
+ if (parentId !== null) {
638
601
  await assertParentExists(this.readRecord, id, parentId);
639
602
  await assertNoParentCycle(this.readRecord, id, parentId);
640
603
  }
641
- return merged;
604
+ const change = new PendingChange('reparent', {
605
+ actor: normalizeActor(opts.actor),
606
+ previousParentId,
607
+ });
608
+ return this.writeAspect(change, opts, (o) => this.adapter.setParent(id, parentId, o));
642
609
  }
643
610
  /**
644
- * Add associations to a record, without a version bump. One matching an
645
- * existing association's identity but a different `attachmentRecordId`
646
- * re-points it in place. See docs/spec/attachments.md § Naming the upload
647
- * a reference came from.
611
+ * Withhold a record from enumeration, or restore it. Restating the
612
+ * current state writes nothing, so `unlistedAt` is never dragged forward.
613
+ * See docs/spec/unlisted.md.
648
614
  */
649
- async associate(id, associations, opts = {}) {
650
- assertAssociationList(associations, 'associate()', 'associations', 'data');
651
- return this.amendAssociations(id, editsOf('add', associations), opts, 'associate()');
615
+ async setUnlisted(id, unlisted, opts = {}) {
616
+ this.assertOpen();
617
+ assertUnlistedArgument(unlisted);
618
+ const existing = await this.requireWritable(id, opts);
619
+ if (Boolean(existing.unlistedAt) === unlisted)
620
+ return existing;
621
+ const change = new PendingChange(unlisted ? 'unlist' : 'list', {
622
+ actor: normalizeActor(opts.actor),
623
+ });
624
+ return this.writeAspect(change, opts, (o) => this.adapter.setUnlisted(id, unlisted, o));
652
625
  }
653
626
  /**
654
- * Remove associations from a record — see associate(). Matched by kind,
655
- * label and payload; an element not found is a no-op. The emitted event
656
- * names each association by identity only, since an attachment's
657
- * `attachmentRecordId` describes nothing current once it is gone; the
658
- * journal is where it survives.
627
+ * Apply adds and removes to a record's data associations: one adapter
628
+ * write, one journal entry carrying each element in full, so the entry is
629
+ * as undoable as a single-element one. An add naming a held identity with
630
+ * a new `attachmentRecordId` re-points it — see docs/spec/attachments.md
631
+ * § Naming the upload a reference came from.
659
632
  */
660
- async dissociate(id, associations, opts = {}) {
661
- assertAssociationList(associations, 'dissociate()', 'associations', 'data');
662
- return this.amendAssociations(id, editsOf('remove', associations), opts, 'dissociate()');
633
+ async editAssociations(id, edits, opts = {}) {
634
+ this.assertOpen();
635
+ assertAssociationEdits(edits, 'editAssociations()', 'data');
636
+ return this.untilSetHolds(async () => {
637
+ const existing = await this.requireWritable(id, opts);
638
+ const next = applyAssociationEdits(existing.associations ?? [], edits);
639
+ return this.writeAssociations(existing, next, opts, (o) => this.adapter.editAssociations(id, edits, o));
640
+ });
663
641
  }
664
642
  /**
665
- * The atomic form of associate() and dissociate(): one list of adds and
666
- * removes, one adapter write, one journal entry. The journal records each
667
- * element in full — a remove with its annotation, a re-point with what it
668
- * overwrote — so the entry is as undoable as a single-element one.
669
- * See docs/spec/data-model.md § Mutations.
643
+ * Make a record's data associations exactly `associations`. Its
644
+ * permissions are another aspect, which this never reads or writes.
670
645
  */
671
- async amendAssociations(id, changes, opts = {}, surface = 'amendAssociations()') {
646
+ async setAssociations(id, associations, opts = {}) {
672
647
  this.assertOpen();
673
- assertAssociationEdits(changes, surface, 'data');
674
- const existing = await this.requireRecord(id);
675
- checkFamily(existing, opts);
676
- this.refuseIfDeleted(existing);
648
+ assertAssociationSet(associations, 'associations');
649
+ return this.untilSetHolds(async () => {
650
+ const existing = await this.requireWritable(id, opts);
651
+ return this.writeAssociations(existing, associations, opts, (o) => this.adapter.setAssociations(id, associations, o));
652
+ });
653
+ }
654
+ /**
655
+ * The shared half of the two data-association writes, from the set the
656
+ * write would leave: nothing moved writes nothing, a roster keeps an
657
+ * admin, and every new attachment pointer names its own upload.
658
+ */
659
+ async writeAssociations(existing, next, opts, write) {
677
660
  const current = existing.associations ?? [];
678
- const next = applyAssociationEdits(current, changes);
679
661
  const delta = associationDelta(current, next);
680
662
  if (delta.length === 0)
681
663
  return existing;
682
- // Only a roster can lose its last admin, and only the post-state says so.
683
664
  assertGroupAdminRemains(existing, next);
684
- await checkAttachmentAssociationPointers(this.readRecord, changes.flatMap((c) => (c.op === 'add' ? [c.association] : [])), current);
665
+ await checkAttachmentAssociationPointers(this.readRecord, next, current);
685
666
  const change = new PendingChange([
686
667
  ...(delta.some((c) => c.op !== 'remove') ? ['associate'] : []),
687
668
  ...(delta.some((c) => c.op === 'remove') ? ['dissociate'] : []),
688
669
  ], { actor: normalizeActor(opts.actor), associations: delta });
689
- const updated = await this.adapter.amendAssociations(id, changes, { journal: change.journal });
690
- this.announce(change, updated);
691
- return updated;
670
+ return this.writeAspect(change, opts, (o) => write({ ...o, expectSet: current }));
692
671
  }
693
672
  /**
694
- * Extend who reaches a record. The spelling that survives two admins
695
- * sharing a record at once: a `permissions` key write replaces the whole
696
- * set, so the later of two concurrent ones drops what the earlier
697
- * granted. No-bump, like associate(); an element the record already
698
- * carries is a no-op. `write` never implies `read` — name both.
673
+ * Apply adds and removes to who reaches a record. The write-implies-read
674
+ * invariant is asked once, of the set the whole list produces, so a
675
+ * downgrade is one call. See docs/spec/access-control.md § Write implies read.
699
676
  */
700
- async grantAccess(id, permissions, opts = {}) {
701
- assertAssociationList(permissions, 'grantAccess()', 'permissions', 'authority');
702
- return this.amendAccess(id, editsOf('add', permissions), opts, 'grantAccess()');
703
- }
704
- /**
705
- * Withdraw elements of who reaches a record — see grantAccess() for why
706
- * this is a verb rather than a key write. An element the record does not
707
- * carry is a no-op. Returns the record as it now stands.
708
- */
709
- async revokeAccess(id, permissions, opts = {}) {
710
- assertAssociationList(permissions, 'revokeAccess()', 'permissions', 'authority');
711
- return this.amendAccess(id, editsOf('remove', permissions), opts, 'revokeAccess()');
677
+ async editAccess(id, edits, opts = {}) {
678
+ this.assertOpen();
679
+ assertAssociationEdits(edits, 'editAccess()', 'authority');
680
+ return this.untilSetHolds(async () => {
681
+ const existing = await this.requireWritable(id, opts);
682
+ const next = applyAssociationEdits(existing.permissions ?? [], edits);
683
+ return this.writeAccess(existing, next, opts, (o) => this.adapter.editAccess(id, edits, o));
684
+ });
712
685
  }
713
- /**
714
- * The atomic form of grantAccess() and revokeAccess(). The
715
- * write-implies-read invariant is asked once, of the set the whole list
716
- * produces. See docs/spec/access-control.md § Write implies read.
717
- */
718
- async amendAccess(id, changes, opts = {}, surface = 'amendAccess()') {
686
+ /** Make who reaches a record exactly `permissions`; `[]` is private. */
687
+ async setAccess(id, permissions, opts = {}) {
719
688
  this.assertOpen();
720
- assertAssociationEdits(changes, surface, 'authority');
721
- const existing = await this.requireRecord(id);
722
- checkFamily(existing, opts);
723
- this.refuseIfDeleted(existing);
689
+ assertAssociationSet(permissions, 'permissions');
690
+ return this.untilSetHolds(async () => {
691
+ const existing = await this.requireWritable(id, opts);
692
+ return this.writeAccess(existing, permissions, opts, (o) => this.adapter.setAccess(id, permissions, o));
693
+ });
694
+ }
695
+ async writeAccess(existing, next, opts, write) {
724
696
  const current = existing.permissions ?? [];
725
- const next = applyAssociationEdits(current, changes);
726
697
  const delta = associationDelta(current, next);
727
698
  if (delta.length === 0)
728
699
  return existing;
700
+ // After the no-op check: a call that moves nothing is not refused for
701
+ // a stored set it did not write.
729
702
  this.assertPermissionSet(next);
730
703
  const change = new PendingChange('reshare', {
731
704
  actor: normalizeActor(opts.actor),
732
705
  associations: delta,
733
706
  });
734
- const updated = await this.adapter.amendAssociations(id, changes, { journal: change.journal });
707
+ return this.writeAspect(change, opts, (o) => write({ ...o, expectSet: current }));
708
+ }
709
+ /**
710
+ * Run an element-set write from its read onward, again from a fresh read
711
+ * each time the adapter finds the set moved since the last one, so the
712
+ * rules over the resulting set are asked of the set the write lands on.
713
+ * Unbounded: each refusal means another write landed.
714
+ * See docs/spec/versioning.md § Element-set writes compare the set they read.
715
+ */
716
+ async untilSetHolds(attempt) {
717
+ for (;;) {
718
+ try {
719
+ return await attempt();
720
+ }
721
+ catch (err) {
722
+ if (!(err instanceof ElementSetMovedError))
723
+ throw err;
724
+ }
725
+ }
726
+ }
727
+ /**
728
+ * Land a write that never bumps. Its precondition and journal entry
729
+ * travel together here, so no aspect verb can drop either.
730
+ */
731
+ async writeAspect(change, opts, write) {
732
+ const updated = await write({ ifVersion: opts.ifVersion, journal: change.journal });
735
733
  this.announce(change, updated);
736
734
  return updated;
737
735
  }
@@ -752,9 +750,24 @@ export class Stack {
752
750
  * not applied to a tombstone.
753
751
  */
754
752
  refuseIfDeleted(record) {
755
- if (record.deletedAt) {
756
- throw new StackConflictError(`Record "${record.id}" is soft-deleted; undelete it before mutating it.`);
757
- }
753
+ if (record.deletedAt)
754
+ throw softDeletedRefusal(record.id);
755
+ }
756
+ /**
757
+ * The record a write addresses, past every refusal owed before
758
+ * validation: missing, another family, a caller's gate, soft-deleted,
759
+ * then a stale `ifVersion` — before validation, so a caller that lost the
760
+ * race hears that first, and before any no-op, so a write that moved
761
+ * nothing never satisfies a precondition.
762
+ */
763
+ async requireWritable(id, opts, patchesContent = false) {
764
+ const existing = await this.requireRecord(id);
765
+ checkFamily(existing, opts);
766
+ await opts[WRITE_GATE]?.(existing);
767
+ this.refuseIfDeleted(existing);
768
+ this.checkIfVersion(existing, opts.ifVersion);
769
+ checkStoredVersion(existing, opts, patchesContent);
770
+ return existing;
758
771
  }
759
772
  /**
760
773
  * A by-id read straight from the adapter, for the integrity checks a write
@@ -770,22 +783,13 @@ export class Stack {
770
783
  return record;
771
784
  }
772
785
  /**
773
- * Soft-delete a record, or purge it with its history. A purge reports
774
- * the files the record referenced, deleting none of them. Use
775
- * deleteAndReturn() when the record is needed too. See
776
- * docs/spec/versioning.md § Deletion and docs/spec/attachments.md § A
777
- * purge strands the bytes it referenced.
786
+ * Soft-delete a record, or purge it with its history, reporting the
787
+ * record as the write left it — or as a purge destroyed it — captured in
788
+ * the same write, so nothing lands unseen between a read and the delete.
789
+ * See docs/spec/versioning.md § Deletion and docs/spec/attachments.md
790
+ * § A purge strands the bytes it referenced.
778
791
  */
779
792
  async delete(id, opts = {}) {
780
- const { referencedFileIds } = await this.deleteAndReturn(id, opts);
781
- return { referencedFileIds };
782
- }
783
- /**
784
- * delete(), plus the record it acted on — before destruction for a purge,
785
- * the tombstone for a soft delete — captured inside the same write, so no
786
- * concurrent write can land unseen between a read and the delete.
787
- */
788
- async deleteAndReturn(id, opts = {}) {
789
793
  this.assertOpen();
790
794
  if (id === SYSTEM_TYPES.CONFIG) {
791
795
  throw new StackConflictError("Cannot delete the _config record: it holds the stack's identity and is required for every permission check.");
@@ -869,61 +873,27 @@ export class Stack {
869
873
  return undeleted;
870
874
  }
871
875
  /**
872
- * Query records. filter.baseId matches every version of a type family,
873
- * resolved against registered Types. Results come back exactly as
874
- * stored; pass presentAt: 'latest' to migrate in memory. See
875
- * docs/spec/data-model.md § Queries.
876
+ * Query records. filter.baseId matches every version of a type family.
877
+ * Results come back exactly as stored; pass presentAt: 'latest' to
878
+ * migrate in memory. See docs/spec/data-model.md § Queries.
876
879
  */
877
880
  async query(query = {}) {
878
881
  this.assertOpen();
879
- const { presentAt, filter, limit: rawLimit, ...rest } = query;
880
- assertValidContentByType(filter);
881
- assertQueryCapabilities(filter, this.adapter.capabilities);
882
- assertValidSort(query.sort);
883
- assertSortCapability(query.sort, this.adapter.capabilities);
884
- assertValidAssociationFilters(filter);
885
- assertValidBaseIdFilter(filter);
886
- const limit = rawLimit !== undefined ? Math.min(rawLimit, MAX_QUERY_LIMIT) : undefined;
887
- const resolvedFilter = await this.resolveBaseIdFilter(filter);
888
- if (resolvedFilter === EMPTY_FAMILY) {
889
- return { records: [], cursor: null };
890
- }
891
- const result = await this.adapter.queryRecords({
892
- ...rest,
893
- ...(query.sort && { sort: normalizeSort(query.sort) }),
894
- ...(resolvedFilter !== undefined && { filter: resolvedFilter }),
895
- ...(limit !== undefined && { limit }),
896
- });
897
- if (presentAt !== 'latest')
898
- return result;
899
- return { ...result, records: result.records.map((r) => this.migrations.presentAtLatest(r)) };
900
- }
901
- /**
902
- * Resolve filter.baseId into a concrete typeId set (intersected with
903
- * filter.typeId when both are given), so adapters never need their own
904
- * baseId concept. Returns EMPTY_FAMILY when the resolved set is empty so
905
- * the caller can short-circuit without an adapter round trip.
906
- */
907
- async resolveBaseIdFilter(filter) {
908
- if (filter?.baseId === undefined)
909
- return filter;
910
- const { baseId, typeId, ...rest } = filter;
911
- const requestedBaseIds = Array.isArray(baseId) ? baseId : [baseId];
912
- const familyTypeIds = await this.familyTypeIds(requestedBaseIds);
913
- const resolvedTypeIds = typeId === undefined
914
- ? familyTypeIds
915
- : familyTypeIds.filter((id) => Array.isArray(typeId) ? typeId.includes(id) : typeId === id);
916
- if (resolvedTypeIds.length === 0)
917
- return EMPTY_FAMILY;
918
- return { ...rest, typeId: resolvedTypeIds };
882
+ const split = splitQuery(query);
883
+ assertQueryCapabilities(split.query, this.adapter.capabilities);
884
+ return this.dispatchQuery(split, (q) => this.adapter.queryRecords(q));
919
885
  }
920
886
  /**
921
- * Every registered version of the named families, as concrete typeIds —
922
- * resolved here because an adapter has no baseId concept of its own.
887
+ * Everything query() does around the adapter call once the query is
888
+ * split and checked: the migration on the way out. `read` is the call,
889
+ * so a scoped query within a reach gets the same treatment as any other.
923
890
  */
924
- async familyTypeIds(baseIds) {
925
- const types = await this.adapter.listTypes();
926
- return types.filter((t) => baseIds.includes(t.baseId)).map((t) => t.id);
891
+ async dispatchQuery({ query, presentAt }, read) {
892
+ this.assertOpen();
893
+ const result = await read(query);
894
+ if (presentAt !== 'latest')
895
+ return result;
896
+ return { ...result, records: result.records.map((r) => this.migrations.presentAtLatest(r)) };
927
897
  }
928
898
  // -------------------------------------------------------
929
899
  // Versions
@@ -1132,7 +1102,7 @@ export class Stack {
1132
1102
  // Purged inside the adapter's own transaction, so these never reach
1133
1103
  // delete() and are announced here instead — from the records it
1134
1104
  // hands back, which are the last copies that will ever exist.
1135
- deletedRecords = await this.adapter.deleteUnreferencedAttachmentRecords(fileId, await this.familyTypeIds([SYSTEM_TYPES.ATTACHMENT]));
1105
+ deletedRecords = await this.adapter.deleteUnreferencedAttachmentRecords(fileId, SYSTEM_TYPES.ATTACHMENT);
1136
1106
  const at = new Date();
1137
1107
  for (const record of deletedRecords) {
1138
1108
  this.announce(new PendingChange('purge', { actor: normalizeActor(opts.actor) }), record, at);
@@ -1203,7 +1173,6 @@ export class Stack {
1203
1173
  ...(opts.filter !== undefined && { filter: opts.filter }),
1204
1174
  ...(opts.since !== undefined && { since: opts.since }),
1205
1175
  ...(opts.includeRecords !== undefined && { includeRecords: opts.includeRecords }),
1206
- ...(opts.includeUnlisted !== undefined && { includeUnlisted: opts.includeUnlisted }),
1207
1176
  ...(opts.onError !== undefined && { onError: opts.onError }),
1208
1177
  ...(opts.onReset !== undefined && { onReset: opts.onReset }),
1209
1178
  }, (change) => delivery.deliver(change));
@@ -1363,4 +1332,8 @@ export class Stack {
1363
1332
  };
1364
1333
  }
1365
1334
  }
1335
+ /** Every registered version of the named families, as concrete TypeIds. */
1336
+ function typeIdsOfFamilies(types, baseIds) {
1337
+ return types.filter((t) => baseIds.includes(t.baseId)).map((t) => t.id);
1338
+ }
1366
1339
  //# sourceMappingURL=stack.js.map