@haverstack/core 0.34.0 → 0.36.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 (48) hide show
  1. package/README.md +1 -1
  2. package/dist/access.d.ts +39 -34
  3. package/dist/access.d.ts.map +1 -1
  4. package/dist/access.js +94 -68
  5. package/dist/access.js.map +1 -1
  6. package/dist/changes.d.ts +22 -33
  7. package/dist/changes.d.ts.map +1 -1
  8. package/dist/changes.js +34 -48
  9. package/dist/changes.js.map +1 -1
  10. package/dist/combine.d.ts.map +1 -1
  11. package/dist/combine.js +7 -0
  12. package/dist/combine.js.map +1 -1
  13. package/dist/grants.d.ts +78 -33
  14. package/dist/grants.d.ts.map +1 -1
  15. package/dist/grants.js +164 -72
  16. package/dist/grants.js.map +1 -1
  17. package/dist/index.d.ts +2 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/query-validation.d.ts +20 -1
  21. package/dist/query-validation.d.ts.map +1 -1
  22. package/dist/query-validation.js +107 -3
  23. package/dist/query-validation.js.map +1 -1
  24. package/dist/record-changes.d.ts +71 -63
  25. package/dist/record-changes.d.ts.map +1 -1
  26. package/dist/record-changes.js +129 -85
  27. package/dist/record-changes.js.map +1 -1
  28. package/dist/scoped-stack.d.ts +118 -116
  29. package/dist/scoped-stack.d.ts.map +1 -1
  30. package/dist/scoped-stack.js +210 -188
  31. package/dist/scoped-stack.js.map +1 -1
  32. package/dist/stack.d.ts +143 -160
  33. package/dist/stack.d.ts.map +1 -1
  34. package/dist/stack.js +271 -225
  35. package/dist/stack.js.map +1 -1
  36. package/dist/testing.d.ts +3 -8
  37. package/dist/testing.d.ts.map +1 -1
  38. package/dist/testing.js +42 -49
  39. package/dist/testing.js.map +1 -1
  40. package/dist/types.d.ts +128 -101
  41. package/dist/types.d.ts.map +1 -1
  42. package/dist/types.js.map +1 -1
  43. package/dist/validate.d.ts.map +1 -1
  44. package/dist/validate.js +0 -2
  45. package/dist/validate.js.map +1 -1
  46. package/dist/wire-record.d.ts.map +1 -1
  47. package/dist/wire-record.js.map +1 -1
  48. package/package.json +1 -1
package/dist/stack.js CHANGED
@@ -22,8 +22,8 @@ import { compareRecordedAttachments } from './attachment-download.js';
22
22
  import { ChangeEmitter, RelayDelivery, PendingChange, assertSinceUsable } from './changes.js';
23
23
  import { SYSTEM_TYPES } from './types.js';
24
24
  import { StackClosedError, StackConflictError, StackMigrationError, StackNotFoundError, StackPermissionError, StackQueryError, StackSchemaDriftError, StackValidationError, StackVersionConflictError, } from './errors.js';
25
- import { assertQueryCapabilities, assertSortCapability, assertValidRelatedTo, assertValidJournalQuery, assertValidSort, filtersContent, validateAssociation, validateAssociations, } from './query-validation.js';
26
- import { GRANT_ACTION_SET, READ_COMPANIONS, grantConveys, matchesGrantTarget, validateGrantTarget, grantCoversGrantee, UNGRANTABLE_SYSTEM_TYPES, loadGrantRecords, } from './grants.js';
25
+ import { assertQueryCapabilities, assertSortCapability, assertValidRelatedTo, assertValidJournalQuery, assertValidSort, assertAuthorityAssociations, assertDataAssociations, filtersContent, validateAssociation, validateAssociations, } from './query-validation.js';
26
+ import { GRANT_ACTION_SET, READ_COMPANIONS, grantConveys, grantReach, matchesGrantTarget, validateGrantTarget, validateGrantee, grantCoversGrantee, UNGRANTABLE_SYSTEM_TYPES, loadGrantRecords, } from './grants.js';
27
27
  import { bindingFieldsOf, uniqueBindingFieldsOf } from './identity-bindings.js';
28
28
  import { assertAttachmentSize, assertContentSize } from './limits.js';
29
29
  import { validateParentId, validateRecordId, validateClockField, validateIdTimestampSkew, DEFAULT_ID_TIMESTAMP_SKEW_MS, } from './record-id.js';
@@ -433,26 +433,27 @@ export class Stack {
433
433
  if (!type) {
434
434
  throw new StackQueryError(`Unknown type: "${typeId}". Call defineType() first.`);
435
435
  }
436
- // updatedAt defaults to createdAt, not to the actual current time, so a
437
- // plain import doesn't fabricate a fake edit.
438
- //
439
- // Copied, never aliased: the caller keeps its own reference to any Date
440
- // it passed, and an import loop that reuses one Date across rows
441
- // (`d.setTime(...)` per record — the obvious way to write one) would
442
- // otherwise retro-edit every record it had already written, with no
443
- // version bump and no change event. createdAt drives updatedAt and
444
- // unlistedAt below, so one copy taken here covers all three.
445
- // `instanceof Date`, not `!== undefined`: this runs before the error
446
- // block below, so a non-Date reaching here off the wire would throw a
447
- // raw TypeError out of .getTime() before validateClockField() could
448
- // report it. Falling back keeps this line total; the error it recorded
449
- // still throws below, so the fallback value is never actually stored.
436
+ // Copied, never aliased: an import loop that reuses one Date across rows
437
+ // (`d.setTime(...)` per record) would otherwise retro-edit every record
438
+ // it had already written, with no version bump and no change event.
439
+ // `instanceof Date`, not `!== undefined`, because this runs ahead of the
440
+ // error block below and .getTime() on a non-Date off the wire would
441
+ // throw before validateClockField() could report it; the fallback value
442
+ // never reaches storage, since that recorded error still throws.
450
443
  const createdAt = opts.createdAt instanceof Date ? new Date(opts.createdAt.getTime()) : new Date();
451
444
  const updatedAt = opts.updatedAt instanceof Date ? new Date(opts.updatedAt.getTime()) : createdAt;
445
+ // Ahead of every other check on these two lists: a caller that named
446
+ // the wrong surface asked for something this layer does not offer, and
447
+ // reporting its grantee as a malformed relationship target would
448
+ // describe the mistake as the wrong kind of problem.
449
+ assertDataAssociations(opts.associations ?? [], 'associations');
450
+ assertAuthorityAssociations(opts.permissions ?? [], 'permissions');
452
451
  const errors = [
453
452
  ...validateReservedKeys(content),
454
453
  ...validateContentKeys(content),
455
454
  ...validateContent(content, type.schema),
455
+ ...validateGrantee(typeId, content),
456
+ ...validateAssociations(opts.permissions, 'permissions'),
456
457
  ...validatePermissions(opts.permissions),
457
458
  ...validateAssociations(opts.associations),
458
459
  ...validateClockField(opts.createdAt, 'createdAt'),
@@ -475,8 +476,8 @@ export class Stack {
475
476
  await this.checkBindingsOnCreate(typeId, content);
476
477
  if (opts.id !== undefined) {
477
478
  validateRecordId(opts.id);
478
- // Only when both are explicit: an `id` alone (no createdAt) stays a
479
- // pure position choice, exactly as before this option existed.
479
+ // Only when both are explicit: an `id` alone is a pure position
480
+ // choice, with no second timestamp to agree with.
480
481
  if (opts.createdAt !== undefined) {
481
482
  validateIdTimestampSkew(opts.id, this.idTimestampSkewMsValue, opts.createdAt.getTime(), 'createdAt');
482
483
  }
@@ -491,31 +492,25 @@ export class Stack {
491
492
  const associations = baseIdOf(typeId) === SYSTEM_TYPES.GROUP
492
493
  ? stampGroupAdmin(opts.associations, opts.entityId ?? this.ownerEntityId)
493
494
  : opts.associations;
494
- // createdAt (hoisted above, alongside its validation) drives the ID
495
- // when the caller doesn't supply one, so the two agree by construction
496
- // rather than by coincidence — the same relationship an explicit `id`
497
- // is checked against above.
498
- // An explicit createdAt mints via generateIdForTimestamp(), which never
499
- // clamps to "now" — generateId()'s monotonic floor would otherwise
500
- // silently pull a deliberately historical id forward once this process
501
- // has minted any live id past it. The no-createdAt path keeps
502
- // generateId(), unaffected and still monotonic-safe.
495
+ // createdAt drives the id, so the two agree by construction rather than
496
+ // by coincidence. An explicit one mints via generateIdForTimestamp(),
497
+ // which never clamps to "now": generateId()'s monotonic floor would
498
+ // otherwise pull a deliberately historical id forward once this process
499
+ // has minted any live id past it.
503
500
  const id = opts.id ??
504
501
  (opts.createdAt !== undefined
505
502
  ? generateIdForTimestamp(createdAt.getTime())
506
503
  : generateId(createdAt.getTime()));
507
504
  // An id field a caller supplies is a value or it is absent — never the
508
- // empty string, which names nobody. Refused rather than dropped: an
509
- // ignored field is the silent-normalization this mapper family was just
510
- // fixed for, one layer up.
505
+ // empty string, which names nobody. Refused rather than dropped, so the
506
+ // caller is never silently ignored.
511
507
  for (const field of ['entityId', 'appId', 'principalId']) {
512
508
  if (opts[field] === '') {
513
509
  throw new StackQueryError(`Invalid ${field}: the empty string is not an id.`);
514
510
  }
515
511
  }
516
512
  // Every create naming a parent owes the reference check, whether or not
517
- // it supplied an id: a destination a caller names has to be one that
518
- // exists. This is the read an ordinary create used to skip.
513
+ // it supplied an id: a destination a caller names has to exist.
519
514
  if (opts.parentId !== undefined)
520
515
  await this.assertParentExists(id, opts.parentId);
521
516
  const record = {
@@ -525,10 +520,8 @@ export class Stack {
525
520
  updatedAt,
526
521
  content,
527
522
  version: 1,
528
- // Read for presence, not truthiness — every one of these is checked
529
- // above, so '' never reaches here and absence is the only thing a
530
- // falsy value could mean. Presence says that outright instead of
531
- // relying on it.
523
+ // Presence, not truthiness: '' is refused above, so absence is the
524
+ // only thing a falsy value could mean here.
532
525
  ...(opts.parentId !== undefined && { parentId: opts.parentId }),
533
526
  ...(opts.entityId !== undefined && { entityId: opts.entityId }),
534
527
  ...(opts.appId !== undefined && { appId: opts.appId }),
@@ -588,20 +581,14 @@ export class Stack {
588
581
  return opts.presentAt === 'latest' ? this.presentAtLatest(record) : record;
589
582
  }
590
583
  /**
591
- * Apply a change set — content patch, `parentId`, `permissions`,
592
- * `associations`, `unlisted`, in any combination — as one atomic write
593
- * producing one version and one snapshot. Keys are read for presence, so
594
- * `unlisted: false` and `parentId: null` are changes.
584
+ * Apply a change set as one atomic write — see the StackClient
585
+ * declaration above for what it accepts. Atomicity is what it is for: a
586
+ * publish is one act, so nothing it names may half-land.
595
587
  *
596
- * Every key present is checked against the record as it stands, and a
597
- * change set already satisfied in all of them writes nothing and returns
598
- * the record unchanged. The ops the change event carries are derived
599
- * from that same comparison, so naming an aspect without moving it is
600
- * never reported as moving it.
601
- *
602
- * Content is validated against the record's *current* stored type and
603
- * `typeId` never changes (see docs/spec/data-model.md § Type
604
- * migrations). See docs/spec/data-model.md § Mutations.
588
+ * Every key present is checked against the record as it stands, and a set
589
+ * already satisfied in all of them writes nothing. Content is validated
590
+ * against the record's *current* stored type; `typeId` never changes here.
591
+ * See docs/spec/data-model.md § Mutations.
605
592
  */
606
593
  async mutate(id, changes, opts = {}) {
607
594
  this.assertOpen();
@@ -624,23 +611,31 @@ export class Stack {
624
611
  // writes nothing.
625
612
  if (ops.length === 0)
626
613
  return existing;
627
- // A change set touching only `associations` doesn't bump — the same
628
- // rule associate()/dissociate() follow unconditionally. Its actor
629
- // travels as an explicit opt rather than a record stamp, since a
630
- // non-bumping write never touches updatedBy/updatedVia.
631
- // See docs/spec/versioning.md § Version history.
614
+ // A change set naming only aspects the journal already keeps in full
615
+ // doesn't bump — the same rule associate()/dissociate() follow
616
+ // unconditionally. Its actor travels as an explicit opt rather than a
617
+ // record stamp, since a non-bumping write never touches
618
+ // updatedBy/updatedVia. See docs/spec/versioning.md § Version history.
632
619
  const bumps = bumpsVersion(ops);
633
620
  // Computed against the same before/after changeSetOps compared, so
634
- // whether associate/dissociate appear in `ops` and what it lists can
635
- // never disagree. See docs/spec/events.md § The event shape.
636
- const assocDelta = changes.associations
637
- ? associationDelta(existing.associations ?? [], changes.associations)
638
- : undefined;
621
+ // whether associate/dissociate/permissions appear in `ops` and what
622
+ // the journal lists can never disagree. Both halves of the partition
623
+ // produce the same tagged edits and travel as one list: the journal's
624
+ // argument is prior state, and an ACL element's is no different from a
625
+ // tag's. See docs/spec/events.md § The event shape.
626
+ const assocDelta = [
627
+ ...(changes.associations
628
+ ? associationDelta(existing.associations ?? [], changes.associations)
629
+ : []),
630
+ ...(changes.permissions
631
+ ? associationDelta(existing.permissions ?? [], changes.permissions)
632
+ : []),
633
+ ];
639
634
  const previousParentId = existing.parentId ?? null;
640
635
  const change = new PendingChange(ops, {
641
636
  actor: Stack.actorFrom(opts),
642
637
  ...(ops.includes('reparent') && { previousParentId }),
643
- ...(assocDelta && { associations: assocDelta }),
638
+ ...(assocDelta.length && { associations: assocDelta }),
644
639
  });
645
640
  const updated = await this.adapter.mutateRecord(id, effectiveChanges(changes, ops), {
646
641
  ...(bumps
@@ -676,6 +671,15 @@ export class Stack {
676
671
  */
677
672
  async validateChangeSet(id, existing, changes) {
678
673
  const { contentPatch, permissions, associations, parentId } = changes;
674
+ // Each key replaces only within its own domain. Asked before the
675
+ // shape checks below so a misrouted element is reported as the wrong
676
+ // surface rather than as a malformed value of the right one, and
677
+ // asked here rather than in `ScopedStack` so an unscoped `Stack`, an
678
+ // import and a server mapping a request body are all held to it.
679
+ if (associations)
680
+ assertDataAssociations(associations, 'associations');
681
+ if (permissions)
682
+ assertAuthorityAssociations(permissions, 'permissions');
679
683
  const errors = [
680
684
  ...(contentPatch
681
685
  ? [
@@ -684,7 +688,9 @@ export class Stack {
684
688
  ...validateContentKeys(contentPatch),
685
689
  ]
686
690
  : []),
687
- ...(permissions ? validatePermissions(permissions) : []),
691
+ ...(permissions
692
+ ? [...validateAssociations(permissions, 'permissions'), ...validatePermissions(permissions)]
693
+ : []),
688
694
  ...(associations ? validateAssociations(associations) : []),
689
695
  ];
690
696
  if (errors.length > 0)
@@ -700,7 +706,10 @@ export class Stack {
700
706
  throw new StackQueryError(`Unknown type: "${existing.typeId}"`);
701
707
  }
702
708
  merged = applyMergePatch(existing.content, contentPatch);
703
- const contentErrors = validateContent(merged, type.schema);
709
+ const contentErrors = [
710
+ ...validateContent(merged, type.schema),
711
+ ...validateGrantee(existing.typeId, merged),
712
+ ];
704
713
  if (contentErrors.length > 0)
705
714
  throw new StackValidationError(contentErrors);
706
715
  if (existing.typeId === `${SYSTEM_TYPES.ATTACHMENT}@1`) {
@@ -727,22 +736,15 @@ export class Stack {
727
736
  return merged;
728
737
  }
729
738
  /**
730
- * Refuse a write that would leave a `_group` Record with no `admin` on
731
- * its roster. Asked of the roster the write would *produce* — the one
732
- * place a change-set check reads the post-state rather than the
733
- * pre-state, because the rule is about what is left behind rather than
734
- * what the caller named.
735
- *
736
- * That framing is the whole of the self-removal question: an `admin`
737
- * removing themselves passes while another remains and is refused when
738
- * they are the last, and neither case needs to know whose entry went.
739
+ * Refuse a write that would leave a `_group` Record with no `admin` on its
740
+ * roster. Asked of the roster the write would *produce*, which is the
741
+ * whole of the self-removal question: an admin removing themselves passes
742
+ * while another remains and is refused when they are the last, without
743
+ * either case naming who is going.
739
744
  *
740
- * Lives here rather than in `ScopedStack` deliberately. Who may write a
741
- * `_group` is a permission question and belongs to the gate up there;
742
- * this is an integrity constraint on the Record, so it binds every
743
- * requester — the stack owner included, who would otherwise be the hole
744
- * that stops anything downstream relying on the invariant.
745
- * See docs/spec/identity.md § Group.
745
+ * An integrity constraint on the Record, not a permission question, so it
746
+ * lives here rather than in `ScopedStack` and binds every requester — the
747
+ * stack owner included. See docs/spec/identity.md § Group.
746
748
  */
747
749
  assertGroupAdminRemains(record, next) {
748
750
  if (!isGroupRecord(record))
@@ -753,25 +755,19 @@ export class Stack {
753
755
  'least one `admin` relationship association. Name the incoming admin in the same write.');
754
756
  }
755
757
  /**
756
- * Add an association to a record. Never bumps `version`/`updatedAt` and
757
- * never snapshots — a set-add composes correctly regardless of write
758
- * order, so it needs neither the OCC `ifVersion` guards content,
759
- * `parentId` and `permissions`, nor the rollback history that guards.
760
- * See docs/spec/versioning.md § Version history.
758
+ * Add an association to a record — no-bump and no snapshot, per the
759
+ * StackClient declaration above.
761
760
  *
762
761
  * An association the record already holds, saying the same thing, is a
763
762
  * no-op; one matching an existing association's identity but naming a
764
763
  * different `attachmentRecordId` — or none, which clears the one stored —
765
- * re-points that association in place, rather than adding a second
766
- * reference to the same file. The emitted event's `associationsAdded`
767
- * carries the association as it now stands either way; what a re-point
768
- * overwrote is kept only by the journal, on that entry's `repoint`. See
769
- * docs/spec/events.md § The event shape and docs/spec/journal.md
770
- * § The entry. Returns the record as it now stands — unchanged on a
771
- * no-op.
764
+ * re-points it in place rather than adding a second reference to the same
765
+ * file. What a re-point overwrote is kept only by the journal, on that
766
+ * entry's `repoint`. See docs/spec/journal.md § The entry.
772
767
  */
773
768
  async associate(id, association, opts = {}) {
774
769
  this.assertOpen();
770
+ assertDataAssociations([association], 'associate()');
775
771
  const errors = validateAssociation(association);
776
772
  if (errors.length > 0)
777
773
  throw new StackValidationError(errors);
@@ -797,16 +793,15 @@ export class Stack {
797
793
  return updated;
798
794
  }
799
795
  /**
800
- * Remove an association from a record. Never bumps `version`/`updatedAt`
801
- * — see associate(). Matched by kind, label, and payload. No-op if not
802
- * found. The emitted event's `associationsRemoved` names the association
803
- * by identity only — an attachment's `attachmentRecordId` is not
804
- * repeated there, since it no longer describes anything current, and the
805
- * journal is where it survives. See docs/spec/events.md § The event
806
- * shape. Returns the record as it now stands — unchanged on a no-op.
796
+ * Remove an association from a record — see associate(). Matched by kind,
797
+ * label and payload; a no-op if not found. The emitted event names the
798
+ * association by identity only, since an attachment's
799
+ * `attachmentRecordId` describes nothing current once it is gone; the
800
+ * journal is where it survives.
807
801
  */
808
802
  async dissociate(id, association, opts = {}) {
809
803
  this.assertOpen();
804
+ assertDataAssociations([association], 'dissociate()');
810
805
  const errors = validateAssociation(association);
811
806
  if (errors.length > 0)
812
807
  throw new StackValidationError(errors);
@@ -836,6 +831,73 @@ export class Stack {
836
831
  this.announce(change, updated);
837
832
  return updated;
838
833
  }
834
+ /**
835
+ * Extend who reaches a record by one element. The spelling that survives
836
+ * two admins sharing a record at once: a `permissions` key write replaces
837
+ * the whole set, so the later of two concurrent ones drops what the
838
+ * earlier granted. No-bump, like associate(); an element the record
839
+ * already carries is a no-op.
840
+ */
841
+ async grantAccess(id, permission, opts = {}) {
842
+ this.assertOpen();
843
+ assertAuthorityAssociations([permission], 'grantAccess()');
844
+ const errors = validateAssociation(permission, 'permission');
845
+ if (errors.length > 0)
846
+ throw new StackValidationError(errors);
847
+ const existing = await this.requireRecord(id);
848
+ const current = existing.permissions ?? [];
849
+ if (current.some((p) => associationEqual(p, permission)))
850
+ return existing;
851
+ this.assertPermissionSet([...current, permission]);
852
+ const change = new PendingChange('permissions', {
853
+ actor: Stack.actorFrom(opts),
854
+ associations: [{ op: 'add', association: permission }],
855
+ });
856
+ const updated = await this.adapter.associate(id, permission, { journal: change.journal });
857
+ this.announce(change, updated);
858
+ return updated;
859
+ }
860
+ /**
861
+ * Withdraw one element of who reaches a record — see grantAccess() for
862
+ * why this is a verb rather than a key write. An element the record does
863
+ * not carry is a no-op. Returns the record as it now stands.
864
+ */
865
+ async revokeAccess(id, permission, opts = {}) {
866
+ this.assertOpen();
867
+ assertAuthorityAssociations([permission], 'revokeAccess()');
868
+ const errors = validateAssociation(permission, 'permission');
869
+ if (errors.length > 0)
870
+ throw new StackValidationError(errors);
871
+ const existing = await this.requireRecord(id);
872
+ const current = existing.permissions ?? [];
873
+ const matched = current.find((p) => associationEqual(p, permission));
874
+ if (!matched)
875
+ return existing;
876
+ this.assertPermissionSet(current.filter((p) => !associationEqual(p, permission)));
877
+ const change = new PendingChange('permissions', {
878
+ actor: Stack.actorFrom(opts),
879
+ associations: [{ op: 'remove', previous: matched }],
880
+ });
881
+ const updated = await this.adapter.dissociate(id, permission, { journal: change.journal });
882
+ this.announce(change, updated);
883
+ return updated;
884
+ }
885
+ /**
886
+ * Hold a permission set to the cross-element invariant — see
887
+ * validatePermissions() in access.ts for why it reads the post-state.
888
+ */
889
+ assertPermissionSet(next) {
890
+ const errors = validatePermissions(next);
891
+ if (errors.length > 0)
892
+ throw new StackValidationError(errors);
893
+ }
894
+ /** The record, or the not-found refusal every mutating verb owes. */
895
+ async requireRecord(id) {
896
+ const record = await this.adapter.getRecord(id);
897
+ if (!record)
898
+ throw new StackNotFoundError(`Record not found: "${id}"`);
899
+ return record;
900
+ }
839
901
  /**
840
902
  * The checks a *caller-named* destination owes, before the cycle walk:
841
903
  * `parentId` is a real, well-formed record id. Format first, so a
@@ -856,24 +918,18 @@ export class Stack {
856
918
  }
857
919
  /**
858
920
  * Refuse an edge that would make a record its own ancestor — asked at
859
- * every site that adds one: a change set naming `parentId`, a restore
860
- * putting a container back, and a create naming both its own id and a parent. Nothing
861
- * downstream (a generator deriving a page path, a folder view) is
862
- * written to survive a cycle.
921
+ * every site that adds one. Nothing downstream (a generator deriving a
922
+ * page path, a folder view) is written to survive a cycle.
863
923
  *
864
- * Walks with the unscoped adapter deliberately: a walk that skipped the
865
- * links a requester cannot read would let a cycle be assembled through
866
- * them and break the invariant for every reader. The walk is bounded
867
- * because a chain long enough to exhaust it is already pathological.
924
+ * Walks with the unscoped adapter: a walk that skipped the links a
925
+ * requester cannot read would let a cycle be assembled through them and
926
+ * break the invariant for every reader.
868
927
  *
869
928
  * Read-then-write, so two moves racing on opposite ends of one chain can
870
- * both pass — closing that means the invariant lives in the adapter,
871
- * where the write is atomic, which puts a graph constraint in the
872
- * storage contract every adapter then implements. Same deferral as
873
- * checkBindingUnique() above. Consumers that walk `parentId` should
874
- * carry a visited set rather than trust this alone; MAX_PARENT_DEPTH is
875
- * the same posture applied here.
876
- * See docs/spec/data-model.md § Reparenting.
929
+ * both pass — the same deferral as checkBindingUnique() above, since
930
+ * closing it would put a graph constraint in the storage contract.
931
+ * Consumers walking `parentId` should carry a visited set rather than
932
+ * trust this alone. See docs/spec/data-model.md § Reparenting.
877
933
  */
878
934
  async assertNoParentCycle(id, parentId) {
879
935
  let cursor = parentId;
@@ -1035,18 +1091,12 @@ export class Stack {
1035
1091
  return this.adapter.getVersions(id);
1036
1092
  }
1037
1093
  /**
1038
- * A record's change journal, oldest first — what moved, who moved it,
1039
- * and the association deltas nothing else retains. The mutation
1040
- * surface's read, on the same footing as getVersions(): a log of who
1041
- * changed what, gated on current read access, would make a record's past
1042
- * exactly as reachable as its present. `ScopedStack` applies that gate;
1043
- * this layer is unscoped and trusted by definition.
1094
+ * A record's change journal, oldest first — what moved, who moved it, and
1095
+ * the association deltas nothing else retains. `ScopedStack` applies the
1096
+ * mutate-surface gate; this layer is unscoped and trusted by definition.
1044
1097
  *
1045
- * An adapter with no journal to read refuses rather than answering an
1046
- * empty log — "nothing changed" and "this stack does not remember" are
1047
- * not the same answer, and a caller reconstructing an association's
1048
- * history cannot tell them apart. A record that isn't there is
1049
- * StackNotFoundError for the same reason, a purged one included.
1098
+ * A record that isn't there is StackNotFoundError, a purged one included:
1099
+ * a destroyed log and an empty one are not the same answer.
1050
1100
  * See docs/spec/journal.md § Reading it.
1051
1101
  */
1052
1102
  async getJournal(id, query = {}) {
@@ -1061,10 +1111,8 @@ export class Stack {
1061
1111
  /**
1062
1112
  * Restore a record to a previous version by creating a new version —
1063
1113
  * never rewrites history. The snapshot is validated against its own
1064
- * stored typeId (not the record's current type), restores `parentId`
1065
- * (absent on the snapshot is the root, so a restore always settles
1066
- * containment), and restores neither associations nor permissions. See
1067
- * docs/spec/versioning.md § Restore semantics.
1114
+ * stored typeId, and puts back `content` and `typeId` alone: a snapshot
1115
+ * carries nothing else. See docs/spec/versioning.md § Restore semantics.
1068
1116
  */
1069
1117
  async restoreVersion(id, version, opts = {}) {
1070
1118
  this.assertOpen();
@@ -1081,7 +1129,10 @@ export class Stack {
1081
1129
  if (!type) {
1082
1130
  throw new StackQueryError(`Unknown type: "${target.typeId}"`);
1083
1131
  }
1084
- const errors = validateContent(target.content, type.schema);
1132
+ const errors = [
1133
+ ...validateContent(target.content, type.schema),
1134
+ ...validateGrantee(target.typeId, target.content),
1135
+ ];
1085
1136
  if (errors.length > 0) {
1086
1137
  throw new StackValidationError(errors);
1087
1138
  }
@@ -1089,36 +1140,17 @@ export class Stack {
1089
1140
  this.checkConfigEntityIdUnchanged(existing.content.entityId, target.content.entityId);
1090
1141
  }
1091
1142
  // Restoring is a write like any other, so it owes the same immutability
1092
- // check a content patch pays — a snapshot taken before a card claimed its DID
1093
- // would otherwise move the binding by rolling content back. Uniqueness
1094
- // needs no separate check here: a restore can only put back a value this
1095
- // same card already held, which immutability already refuses to change.
1143
+ // check a content patch pays: a snapshot taken before a card claimed
1144
+ // its DID would otherwise move the binding by rolling content back.
1145
+ // Uniqueness needs none — a restore can only put back a value this same
1146
+ // card already held.
1096
1147
  for (const field of bindingFieldsOf(baseIdOf(target.typeId))) {
1097
1148
  this.checkBindingImmutable(baseIdOf(target.typeId), field, existing.content[field], target.content[field]);
1098
1149
  }
1099
- // A restore that puts a container back is an edge-adding site like
1100
- // a change set's `parentId`, and the chain above that container may have moved since
1101
- // the snapshot was taken. The cycle walk therefore applies — but
1102
- // assertParentExists() deliberately does not: a restore is not a caller
1103
- // naming a destination, it is history being put back, and the container
1104
- // may have been hard-deleted since. Refusing here would make an
1105
- // unrelated deletion cost the record its content rollback, which is the
1106
- // recoverability this whole verb exists for. A dangling parentId is
1107
- // legal at rest anyway — deleting a container never touches its
1108
- // children. See docs/spec/versioning.md § Restore semantics.
1109
- const previousParentId = existing.parentId ?? null;
1110
- const targetParentId = target.parentId ?? null;
1111
- const moves = targetParentId !== previousParentId;
1112
- if (moves && targetParentId !== null) {
1113
- await this.assertNoParentCycle(id, targetParentId);
1114
- }
1115
- // Associations never restore, for any Record — a snapshot never
1116
- // carries them, so there's nothing here for the adapter to roll back.
1117
- // See docs/spec/versioning.md § Restore semantics.
1118
- const change = new PendingChange('restore', {
1119
- actor: Stack.actorFrom(opts),
1120
- ...(moves && { previousParentId }),
1121
- });
1150
+ // A restore adds no containment edge and takes none away, so there is
1151
+ // no cycle for it to close and nothing for the destination checks to
1152
+ // gate. See docs/spec/versioning.md § Restore semantics.
1153
+ const change = new PendingChange('restore', { actor: Stack.actorFrom(opts) });
1122
1154
  const restored = await this.adapter.restoreVersion(id, version, {
1123
1155
  ...this.writeOptions(existing, opts),
1124
1156
  journal: change.journal,
@@ -1127,23 +1159,15 @@ export class Stack {
1127
1159
  return restored;
1128
1160
  }
1129
1161
  /**
1130
- * Commit a per-record migration: replace `content` and `typeId` together
1131
- * in one step, validated against `toTypeId`'s schema exactly as
1132
- * create()/mutate() validate against a type's schema. The single-record
1133
- * counterpart to migrateAll() — content here is supplied by the caller
1134
- * (computed client-side by the type's owning app, per
1135
- * docs/spec/wire-format.md § Migration commit) rather than a registered
1136
- * Migration function. Snapshots the prior state to version history, same
1137
- * as mutate()/restoreVersion(), and takes the same optional `ifVersion`
1138
- * precondition every version-bumping mutation takes — checked atomically
1139
- * at the adapter, not here (see docs/spec/versioning.md § Optimistic
1140
- * concurrency).
1162
+ * Commit a per-record migration — the single-record counterpart to
1163
+ * migrateAll(), with `content` supplied by the caller rather than by a
1164
+ * registered Migration function. See docs/spec/wire-format.md § Migration
1165
+ * commit.
1141
1166
  *
1142
1167
  * Because `content` is a full replacement written under a new `typeId`,
1143
1168
  * this is create-shaped at the destination *and* update-shaped over the
1144
- * record as it stands, so it owes both sets of integrity checks — the
1145
- * binding rules, the attachment rules, and `_config`'s. Missing either
1146
- * half would make migrate a second, unguarded write path to the same
1169
+ * record as it stands, so it owes both sets of integrity checks. Missing
1170
+ * either half would make migrate a second, unguarded write path to a
1147
1171
  * state create()/mutate() refuse to reach.
1148
1172
  */
1149
1173
  async commitMigration(id, toTypeId, content, opts = {}) {
@@ -1180,6 +1204,7 @@ export class Stack {
1180
1204
  ...validateReservedKeys(content),
1181
1205
  ...validateContentKeys(content),
1182
1206
  ...validateContent(content, type.schema),
1207
+ ...validateGrantee(toTypeId, content),
1183
1208
  ];
1184
1209
  if (errors.length > 0) {
1185
1210
  throw new StackValidationError(errors);
@@ -1248,18 +1273,14 @@ export class Stack {
1248
1273
  }
1249
1274
  }
1250
1275
  /**
1251
- * Bindings across a migration. `content` is a full replacement rather
1252
- * than a patch, so there is no "field absent from the patch" case to
1253
- * exempt: every binding field either keeps its value, moves to a new
1254
- * one, or is shed by omission — and immutability refuses the last two
1255
- * whichever family they happen in. Asked across the union of the two
1256
- * families' binding fields, so a card can neither shed its DID by
1257
- * migrating out of `_entity`/`_app` nor pick one up on the way in.
1276
+ * Bindings across a migration. `content` is a full replacement, so every
1277
+ * binding field either keeps its value, moves to a new one, or is shed by
1278
+ * omission — and immutability refuses the last two. Asked across the
1279
+ * union of both families' binding fields, so a card can neither shed its
1280
+ * DID by migrating out of `_entity`/`_app` nor pick one up on the way in.
1258
1281
  *
1259
- * Uniqueness is asked only of the destination family, which is where the
1260
- * record's claim lives once the write lands, and excludes the record
1261
- * itself — re-sending the value it already holds claims nothing.
1262
- * See docs/spec/identity.md § DID bindings.
1282
+ * Uniqueness is asked only of the destination family, excluding the
1283
+ * record itself. See docs/spec/identity.md § DID bindings.
1263
1284
  */
1264
1285
  async checkBindingsOnMigrate(fromTypeId, toTypeId, id, existing, content) {
1265
1286
  const fromFamily = baseIdOf(fromTypeId);
@@ -1283,16 +1304,10 @@ export class Stack {
1283
1304
  * claiming one value would leave that lookup without a single answer, and
1284
1305
  * ambiguity is all an impersonating card needs. Enforced here rather than
1285
1306
  * by schema, since uniqueness is a property of the set, not of the value.
1286
- * Called by the paths that can introduce a binding: create and update.
1287
- *
1288
- * Short-circuits on the first clash rather than materialising the family,
1289
- * so a stack whose `_entity` family is larger than a single scan settles
1290
- * the common case — the value is already taken — without walking the rest.
1291
1307
  *
1292
- * Read-then-write, so two creates racing on one value can both pass.
1293
- * Closing that properly means a unique index over a JSON field, which
1294
- * each adapter would enforce separately — a decision about where
1295
- * uniqueness lives, not a local fix.
1308
+ * Read-then-write, so two creates racing on one value can both pass:
1309
+ * closing that means a unique index over a JSON field, which is a
1310
+ * decision about where uniqueness lives rather than a local fix.
1296
1311
  * See docs/spec/identity.md § DID bindings.
1297
1312
  */
1298
1313
  async checkBindingUnique(family, field, value, excludeId) {
@@ -1747,9 +1762,10 @@ export class Stack {
1747
1762
  // -------------------------------------------------------
1748
1763
  /**
1749
1764
  * Create _grant records authorizing entities to act on records of
1750
- * specific types; null target writes a default grant (any authenticated
1751
- * entity); `{ groupId }` targets a `_group` Record's roster instead of a
1752
- * single entity.
1765
+ * specific types. The target is the grantee the record carries:
1766
+ * `{ kind: 'entity' }` for one DID, `{ kind: 'group' }` for a `_group`
1767
+ * Record's roster at a role, `{ kind: 'authenticated' }` for any
1768
+ * authenticated entity.
1753
1769
  *
1754
1770
  * Granting an **app** a `-own` action does not contain it the way the
1755
1771
  * suffix suggests: when that app acts for someone, `-own` is read as the
@@ -1759,9 +1775,8 @@ export class Stack {
1759
1775
  * needs, not the suffix that looks narrowest. See
1760
1776
  * docs/spec/access-control.md § Delegation: principal and subject.
1761
1777
  *
1762
- * The grantee lives in content.granteeEntityId / content.granteeGroupId,
1763
- * not record.entityId. See docs/spec/access-control.md § Type-level
1764
- * grants.
1778
+ * The grantee lives in content.grantee, not record.entityId. See
1779
+ * docs/spec/access-control.md § Type-level grants.
1765
1780
  */
1766
1781
  async grant(target, grants) {
1767
1782
  this.assertOpen();
@@ -1772,37 +1787,43 @@ export class Stack {
1772
1787
  records.push(await this.create(`${SYSTEM_TYPES.GRANT}@1`, {
1773
1788
  typeId: g.typeId,
1774
1789
  actions: g.actions,
1775
- ...(typeof target === 'string' && { granteeEntityId: target }),
1776
- ...(target !== null && typeof target === 'object' && { granteeGroupId: target.groupId }),
1790
+ grantee: target,
1777
1791
  }));
1778
1792
  }
1779
1793
  return records;
1780
1794
  }
1781
1795
  /**
1782
- * List _grant records. Omit `target` for all grants; pass null for only
1783
- * default grants; pass `{ groupId }` for grants naming that exact group;
1784
- * pass a specific entityId for the grants that currently apply to that
1785
- * entity (ones naming them, ones naming a group they belong to, plus
1786
- * every default grant) — the same resolution hasGrant() uses.
1796
+ * List _grant records. Omit `query` for all grants;
1797
+ * `{ kind: 'authenticated' }` for only default grants;
1798
+ * `{ kind: 'group' }` for grants naming that exact group and role, or
1799
+ * `role: 'any'` for every grant naming the group;
1800
+ * `{ kind: 'entity' }` for the grants that currently apply to that entity
1801
+ * (ones naming them, ones naming a group they belong to at a role they
1802
+ * hold, plus every default grant) — the same resolution hasGrant() uses.
1803
+ *
1804
+ * Every arm but `entity` answers identity — what `grant()` would have
1805
+ * written with the same argument. The `entity` arm answers coverage, so
1806
+ * its result is not a preview of what `revoke()` would withdraw.
1807
+ * See docs/spec/access-control.md § Type-level grants.
1787
1808
  */
1788
- async listGrants(target) {
1809
+ async listGrants(query) {
1789
1810
  this.assertOpen();
1790
- if (target !== undefined)
1791
- validateGrantTarget(target);
1811
+ if (query !== undefined)
1812
+ validateGrantTarget(query, true);
1792
1813
  const all = await loadGrantRecords((q) => this.query(q));
1793
- if (target === undefined)
1814
+ if (query === undefined)
1794
1815
  return all;
1795
- if (target === null || typeof target === 'object') {
1796
- return all.filter((r) => matchesGrantTarget(r.content, target));
1816
+ if (query.kind !== 'entity') {
1817
+ return all.filter((r) => matchesGrantTarget(r.content, query));
1797
1818
  }
1798
- // target is an EntityId: resolve group rosters, since a grant naming a
1799
- // group the entity belongs to also currently applies to them. Shares
1819
+ // An entity query resolves group rosters, since a grant naming a group
1820
+ // the entity belongs to also currently applies to them. Shares
1800
1821
  // grantCoversGrantee() with the access checks, so a listing can't
1801
1822
  // disagree with them about who a grant covers.
1802
1823
  const groupRoles = new Map();
1803
1824
  const result = [];
1804
1825
  for (const r of all) {
1805
- const covers = await grantCoversGrantee(r.content, target, {
1826
+ const covers = await grantCoversGrantee(r.content, query.entityId, {
1806
1827
  allowDefault: true,
1807
1828
  allowGroup: true,
1808
1829
  groupRoles,
@@ -1815,20 +1836,32 @@ export class Stack {
1815
1836
  }
1816
1837
  /**
1817
1838
  * The inverse of grant(): soft-deletes _grant records matching `target`
1818
- * (null for default grants) and each `{ typeId, actions }` pair, at the
1819
- * same granularity grant() writes. A soft delete like any other — the
1820
- * owner can undelete a revocation.
1839
+ * and each `{ typeId, actions }` pair, at the same granularity grant()
1840
+ * writes — the grantee is matched whole, role included. A soft delete
1841
+ * like any other — the owner can undelete a revocation.
1842
+ *
1843
+ * Returns the grants it withdrew, as they stood, so a revocation that
1844
+ * matched nothing says so rather than passing in silence. An empty result
1845
+ * is not an error — a grant already withdrawn or never written is the
1846
+ * ordinary case, and re-running a revocation has to stay safe.
1847
+ * See docs/spec/access-control.md § Type-level grants.
1821
1848
  */
1822
1849
  async revoke(target, grants) {
1823
1850
  this.assertOpen();
1824
1851
  validateGrantTarget(target);
1852
+ const revoked = [];
1825
1853
  const all = await loadGrantRecords((q) => this.query(q));
1826
1854
  for (const g of grants) {
1827
1855
  const familyId = baseIdOf(g.typeId);
1828
1856
  const actionSet = new Set(g.actions);
1829
1857
  const matches = all.filter((r) => {
1830
1858
  const c = r.content;
1831
- if (baseIdOf(c.typeId) !== familyId)
1859
+ // Establishes the family and that `actions` is a list, so the exact
1860
+ // match below reads a real one. Matched against the stored list
1861
+ // rather than the reach, so a grant carrying an action this
1862
+ // vocabulary drops is not withdrawn by a target that omits it.
1863
+ const reach = grantReach(c);
1864
+ if (!reach || reach.familyId !== familyId)
1832
1865
  return false;
1833
1866
  if (!matchesGrantTarget(c, target))
1834
1867
  return false;
@@ -1836,8 +1869,10 @@ export class Stack {
1836
1869
  });
1837
1870
  for (const match of matches) {
1838
1871
  await this.delete(match.id);
1872
+ revoked.push(match);
1839
1873
  }
1840
1874
  }
1875
+ return revoked;
1841
1876
  }
1842
1877
  // -------------------------------------------------------
1843
1878
  // Private helpers
@@ -1915,8 +1950,22 @@ export class Stack {
1915
1950
  await this.defineType(`${SYSTEM_TYPES.GRANT}@1`, 'Grant', {
1916
1951
  typeId: { kind: 'string', required: true },
1917
1952
  actions: { kind: 'array', items: { kind: 'string' }, required: true },
1918
- granteeEntityId: { kind: 'string' },
1919
- granteeGroupId: { kind: 'string' },
1953
+ // Required, and closed: a Grant's reach is spelled by its `grantee`,
1954
+ // so a record arriving without one is refused here rather than read
1955
+ // as a grant to every authenticated entity. The arms differ in which
1956
+ // fields they carry, which a schema cannot express — evaluation reads
1957
+ // the `kind` and confers nothing on one it does not recognize.
1958
+ // See docs/spec/access-control.md § Type-level grants.
1959
+ grantee: {
1960
+ kind: 'object',
1961
+ required: true,
1962
+ properties: {
1963
+ kind: { kind: 'string', required: true },
1964
+ entityId: { kind: 'string' },
1965
+ groupId: { kind: 'string' },
1966
+ role: { kind: 'string' },
1967
+ },
1968
+ },
1920
1969
  });
1921
1970
  await this.defineType(`${SYSTEM_TYPES.ATTACHMENT}@1`, 'Attachment', {
1922
1971
  // Deliberately `string`, not `file-ref`: attachmentFileId matching
@@ -1960,13 +2009,12 @@ export class Stack {
1960
2009
  }
1961
2010
  /**
1962
2011
  * Snapshot of a record's prior state, passed with the mutating adapter
1963
- * call so snapshot and mutation land in one atomic write. `parentId` is
1964
- * always present (`null` where the record has none) so restore can
1965
- * distinguish "at the root" from a snapshot that omits the key entirely
1966
- * ("leave as-is"). Never carries `associations` — associate()/
1967
- * dissociate() don't bump, so no version ever snapshots the association
1968
- * set, and there is nothing for a restore to roll back to. See
1969
- * docs/spec/versioning.md § Version history.
2012
+ * call so snapshot and mutation land in one atomic write. Carries what
2013
+ * only a snapshot preserves: `content` and the `typeId` it is read
2014
+ * under. Containment, listing and associations — the authority ones
2015
+ * among them — are all kept by the journal instead, so no version ever
2016
+ * snapshots them and there is nothing for a restore to roll them back
2017
+ * to. See docs/spec/versioning.md § Version history.
1970
2018
  */
1971
2019
  buildVersionSnapshot(record) {
1972
2020
  return {
@@ -1977,8 +2025,6 @@ export class Stack {
1977
2025
  ...(record.entityId && { entityId: record.entityId }),
1978
2026
  ...(record.updatedBy && { updatedBy: record.updatedBy }),
1979
2027
  ...(record.updatedVia && { updatedVia: record.updatedVia }),
1980
- ...(record.parentId !== undefined && { parentId: record.parentId }),
1981
- ...(record.permissions && { permissions: record.permissions }),
1982
2028
  };
1983
2029
  }
1984
2030
  }