@haverstack/core 0.39.0 → 0.41.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 (81) hide show
  1. package/README.md +79 -16
  2. package/dist/access.js +1 -1
  3. package/dist/access.js.map +1 -1
  4. package/dist/adapter-entry.d.ts +2 -1
  5. package/dist/adapter-entry.d.ts.map +1 -1
  6. package/dist/adapter-entry.js +2 -1
  7. package/dist/adapter-entry.js.map +1 -1
  8. package/dist/changes.js +3 -3
  9. package/dist/combine.d.ts.map +1 -1
  10. package/dist/combine.js +2 -3
  11. package/dist/combine.js.map +1 -1
  12. package/dist/errors.d.ts +16 -3
  13. package/dist/errors.d.ts.map +1 -1
  14. package/dist/errors.js +23 -2
  15. package/dist/errors.js.map +1 -1
  16. package/dist/grants.d.ts +9 -1
  17. package/dist/grants.d.ts.map +1 -1
  18. package/dist/grants.js +34 -4
  19. package/dist/grants.js.map +1 -1
  20. package/dist/identity-bindings.d.ts.map +1 -1
  21. package/dist/identity-bindings.js +3 -0
  22. package/dist/identity-bindings.js.map +1 -1
  23. package/dist/index.d.ts +7 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +4 -0
  26. package/dist/index.js.map +1 -1
  27. package/dist/install.d.ts +144 -0
  28. package/dist/install.d.ts.map +1 -0
  29. package/dist/install.js +223 -0
  30. package/dist/install.js.map +1 -0
  31. package/dist/query-validation.d.ts +34 -1
  32. package/dist/query-validation.d.ts.map +1 -1
  33. package/dist/query-validation.js +101 -1
  34. package/dist/query-validation.js.map +1 -1
  35. package/dist/record-changes.d.ts +15 -1
  36. package/dist/record-changes.d.ts.map +1 -1
  37. package/dist/record-changes.js +33 -3
  38. package/dist/record-changes.js.map +1 -1
  39. package/dist/record-id.js +1 -1
  40. package/dist/record-id.js.map +1 -1
  41. package/dist/schema.d.ts +6 -7
  42. package/dist/schema.d.ts.map +1 -1
  43. package/dist/schema.js +33 -10
  44. package/dist/schema.js.map +1 -1
  45. package/dist/scoped-stack.d.ts +50 -23
  46. package/dist/scoped-stack.d.ts.map +1 -1
  47. package/dist/scoped-stack.js +202 -129
  48. package/dist/scoped-stack.js.map +1 -1
  49. package/dist/stack.d.ts +182 -45
  50. package/dist/stack.d.ts.map +1 -1
  51. package/dist/stack.js +481 -192
  52. package/dist/stack.js.map +1 -1
  53. package/dist/testing.d.ts +28 -11
  54. package/dist/testing.d.ts.map +1 -1
  55. package/dist/testing.js +42 -37
  56. package/dist/testing.js.map +1 -1
  57. package/dist/type-handle.d.ts +157 -0
  58. package/dist/type-handle.d.ts.map +1 -0
  59. package/dist/type-handle.js +131 -0
  60. package/dist/type-handle.js.map +1 -0
  61. package/dist/types.d.ts +91 -20
  62. package/dist/types.d.ts.map +1 -1
  63. package/dist/types.js +2 -0
  64. package/dist/types.js.map +1 -1
  65. package/dist/validate.d.ts +7 -0
  66. package/dist/validate.d.ts.map +1 -1
  67. package/dist/validate.js +131 -7
  68. package/dist/validate.js.map +1 -1
  69. package/dist/wire-body.d.ts +19 -3
  70. package/dist/wire-body.d.ts.map +1 -1
  71. package/dist/wire-body.js +99 -2
  72. package/dist/wire-body.js.map +1 -1
  73. package/dist/wire-entry.d.ts +2 -2
  74. package/dist/wire-entry.d.ts.map +1 -1
  75. package/dist/wire-entry.js +4 -4
  76. package/dist/wire-entry.js.map +1 -1
  77. package/dist/wire-request.d.ts +18 -1
  78. package/dist/wire-request.d.ts.map +1 -1
  79. package/dist/wire-request.js +33 -1
  80. package/dist/wire-request.js.map +1 -1
  81. package/package.json +1 -1
package/dist/stack.js CHANGED
@@ -14,22 +14,24 @@
14
14
  * Apps should never talk to a StackAdapter directly.
15
15
  */
16
16
  import { generateId, generateIdForTimestamp } from './id.js';
17
- import { hashSchema, isCompatible, parseTypeId, baseIdOf, diffSchemas, isWellFormedTypeId, } from './schema.js';
18
- import { validateContent, validateContentKeys, validatePatchValues, validateReservedKeys, validateSchemaFieldNames, validateSchemaReservedNames, validateSchemaShape, } from './validate.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
19
  import { applyMergePatch } from './merge.js';
20
- import { hasGroupAdmin, isGroupAdminAssociation, validatePermissions } from './access.js';
20
+ import { hasGroupAdmin, validatePermissions } from './access.js';
21
21
  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 { UseAfterCloseError, InvalidAdapterError, StackConflictError, StackMigrationError, StackNotFoundError, StackBadRequestError, StackSchemaDriftError, StackValidationError, StackVersionConflictError, } from './errors.js';
25
- import { assertQueryCapabilities, assertSortCapability, assertValidAssociationFilters, 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';
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
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';
28
29
  import { assertAttachmentSize, assertContentSize } from './limits.js';
29
30
  import { validateParentId, validateRecordId, validateClockField, validateIdTimestampSkew, DEFAULT_ID_TIMESTAMP_SKEW_MS, } from './record-id.js';
30
31
  import { queryAllPages, findFirstMatch, lookupEntityByDid, MAX_QUERY_LIMIT, } from './stack-reads.js';
31
- import { associationDelta, associationEqual, associationIdentical, assertNonEmptyChangeSet, changeSetOps, bumpsVersion, takesIfVersion, effectiveChanges, stampGroupAdmin, isGroupRecord, } from './record-changes.js';
32
+ import { applyAssociationEdits, associationDelta, associationIdentical, assertNonEmptyChangeSet, changeSetOps, bumpsVersion, takesIfVersion, effectiveChanges, stampGroupAdmin, isGroupRecord, } from './record-changes.js';
32
33
  import { ScopedStack, scopeToken } from './scoped-stack.js';
34
+ import { isTypeHandle, typedCreate, typedGet, typedMutate, typedQuery, typedSubscribe, } from './type-handle.js';
33
35
  // -------------------------------------------------------
34
36
  // Supporting types
35
37
  // -------------------------------------------------------
@@ -235,8 +237,10 @@ export class Stack {
235
237
  * anything beyond additive evolution throws StackSchemaDriftError. See
236
238
  * docs/spec/data-model.md § Schema drift detection.
237
239
  */
238
- async defineType({ id, name, schema, migratesFrom }) {
240
+ async defineType({ id, name, schema: declared, migratesFrom, }) {
239
241
  this.assertOpen();
242
+ // Only read below; the readonly view exists so a handle's literal fits.
243
+ const schema = declared;
240
244
  const parsed = parseTypeId(id);
241
245
  if (!parsed) {
242
246
  throw new StackBadRequestError(`Invalid TypeId format: "${id}". Expected "namespace/name@version", e.g. "com.example.myapp/note@1".`);
@@ -373,15 +377,25 @@ export class Stack {
373
377
  * the only way disk state changes version. Sweeps soft-deleted records
374
378
  * too, validates each result before writing, and aborts on the first
375
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.
376
385
  */
377
- async migrateAll(baseId) {
386
+ async migrateAll(baseId, opts = {}) {
378
387
  this.assertOpen();
388
+ const problem = familyIdProblem(baseId, 'migrateAll');
389
+ if (problem)
390
+ throw new StackValidationError([{ path: 'baseId', message: problem }]);
379
391
  const types = await this.adapter.listTypes();
380
392
  const familyTypeIds = types.filter((t) => t.baseId === baseId).map((t) => t.id);
381
393
  if (familyTypeIds.length === 0) {
382
394
  throw new StackMigrationError(`migrateAll: no registered types found for baseId "${baseId}"`);
383
395
  }
396
+ const listedOnly = opts.sweep === 'listed';
384
397
  let migrated = 0;
398
+ let skipped = 0;
385
399
  for (const typeId of familyTypeIds) {
386
400
  const latestId = this.latestTypeId(typeId);
387
401
  if (typeId === latestId)
@@ -396,11 +410,15 @@ export class Stack {
396
410
  let cursor;
397
411
  do {
398
412
  const result = await this.adapter.queryRecords({
399
- filter: { typeId, includeDeleted: true, includeUnlisted: true },
413
+ filter: { typeId, includeDeleted: true, includeUnlisted: !listedOnly },
400
414
  limit: 100,
401
415
  cursor,
402
416
  });
403
417
  for (const record of result.records) {
418
+ if (listedOnly && record.deletedAt) {
419
+ skipped++;
420
+ continue;
421
+ }
404
422
  // Same checked path commitMigration() takes — a migration
405
423
  // function is no more entitled to move a DID binding or repoint
406
424
  // an attachment than a request body is. No ifVersion: a batch
@@ -411,28 +429,18 @@ export class Stack {
411
429
  cursor = result.cursor ?? undefined;
412
430
  } while (cursor);
413
431
  }
414
- return { migrated };
432
+ return { migrated, skipped };
415
433
  }
416
- // -------------------------------------------------------
417
- // Records
418
- // -------------------------------------------------------
419
- /**
420
- * Create a new record. Validates content against the type's schema.
421
- * `_group` records get their author stamped as the first `admin` roster
422
- * association here — the single stamping site for both Stack.create()
423
- * and ScopedStack.create(). `opts.createdAt`/`updatedAt` let a caller
424
- * backdate an imported record's clock fields — unconditionally here;
425
- * ScopedStack.create() forwards to this same method, but only reaches
426
- * this far with them when the requester is the stack owner acting alone
427
- * — see BackdatableCreateRecordOptions and docs/spec/data-model.md §
428
- * Record IDs.
429
- */
430
- async create(typeId, content, opts = {}) {
434
+ async create(typeIdOrHandle, input, opts = {}) {
435
+ if (isTypeHandle(typeIdOrHandle))
436
+ return typedCreate(this, typeIdOrHandle, input, opts);
437
+ const typeId = typeIdOrHandle;
431
438
  this.assertOpen();
432
439
  const type = await this.getTypeCached(typeId);
433
440
  if (!type) {
434
441
  throw new StackBadRequestError(`Unknown type: "${typeId}". Call defineType() first.`);
435
442
  }
443
+ const content = dropAbsentFields(input, type.schema);
436
444
  // Copied, never aliased: an import loop that reuses one Date across rows
437
445
  // (`d.setTime(...)` per record) would otherwise retro-edit every record
438
446
  // it had already written, with no version bump and no change event.
@@ -453,6 +461,8 @@ export class Stack {
453
461
  ...validateContentKeys(content),
454
462
  ...validateContent(content, type.schema),
455
463
  ...validateGrantee(typeId, content),
464
+ ...validateGrantBaseId(typeId, content),
465
+ ...validateInstall(typeId, content),
456
466
  ...validateAssociations(opts.permissions, 'permissions'),
457
467
  ...validatePermissions(opts.permissions),
458
468
  ...validateAssociations(opts.associations),
@@ -564,35 +574,33 @@ export class Stack {
564
574
  }
565
575
  return record;
566
576
  }
567
- /**
568
- * Get a record by ID, exactly as stored — no implicit migration. Pass
569
- * { presentAt: 'latest' } to migrate in memory; only migrateAll()
570
- * commits migrations to disk.
571
- */
572
- async get(id, opts = {}) {
577
+ async get(idOrHandle, idOrOpts = {}) {
578
+ if (isTypeHandle(idOrHandle))
579
+ return typedGet(this, idOrHandle, idOrOpts);
580
+ const id = idOrHandle;
581
+ const opts = idOrOpts;
573
582
  this.assertOpen();
574
583
  const record = await this.adapter.getRecord(id);
575
584
  if (!record)
576
585
  return null;
586
+ if (record.deletedAt && !opts.includeDeleted)
587
+ return null;
577
588
  return opts.presentAt === 'latest' ? this.presentAtLatest(record) : record;
578
589
  }
579
- /**
580
- * Apply a change set as one atomic write — see the StackClient
581
- * declaration above for what it accepts. Atomicity is what it is for: a
582
- * publish is one act, so nothing it names may half-land.
583
- *
584
- * Every key present is checked against the record as it stands, and a set
585
- * already satisfied in all of them writes nothing. Content is validated
586
- * against the record's *current* stored type; `typeId` never changes here.
587
- * See docs/spec/data-model.md § Mutations.
588
- */
589
- async mutate(id, changes, opts = {}) {
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 ?? {});
590
597
  this.assertOpen();
591
598
  assertNonEmptyChangeSet(changes);
592
599
  const existing = await this.adapter.getRecord(id);
593
600
  if (!existing) {
594
601
  throw new StackNotFoundError(`Record not found: "${id}"`);
595
602
  }
603
+ this.refuseIfDeleted(existing);
596
604
  // Checked before validation, so a caller that lost the race learns its
597
605
  // version is stale rather than that its patch is bad, and before the
598
606
  // no-op short-circuit below, so a precondition is never satisfied by a
@@ -641,13 +649,11 @@ export class Stack {
641
649
  this.announce(change, updated);
642
650
  return updated;
643
651
  }
644
- /**
645
- * mutate() with `contentPatch` alone. The common case by a wide margin,
646
- * and named for what it does rather than for a symmetry with create()
647
- * that a patch does not have.
648
- */
649
- async patchContent(id, patch, opts = {}) {
650
- return this.mutate(id, { contentPatch: patch }, opts);
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);
651
657
  }
652
658
  /**
653
659
  * Every check a change set owes before anything is written, in one pass:
@@ -700,6 +706,8 @@ export class Stack {
700
706
  const contentErrors = [
701
707
  ...validateContent(merged, type.schema),
702
708
  ...validateGrantee(existing.typeId, merged),
709
+ ...validateGrantBaseId(existing.typeId, merged),
710
+ ...validateInstall(existing.typeId, merged),
703
711
  ];
704
712
  if (contentErrors.length > 0)
705
713
  throw new StackValidationError(contentErrors);
@@ -746,7 +754,7 @@ export class Stack {
746
754
  'least one `admin` relationship association. Name the incoming admin in the same write.');
747
755
  }
748
756
  /**
749
- * Add an association to a record — no-bump and no snapshot, per the
757
+ * Add associations to a record — no-bump and no snapshot, per the
750
758
  * StackClient declaration above.
751
759
  *
752
760
  * An association the record already holds, saying the same thing, is a
@@ -756,120 +764,86 @@ export class Stack {
756
764
  * file. What a re-point overwrote is kept only by the journal, on that
757
765
  * entry's `repoint`. See docs/spec/journal.md § The entry.
758
766
  */
759
- async associate(id, association, opts = {}) {
760
- this.assertOpen();
761
- assertDataAssociations([association], 'associate()');
762
- const errors = validateAssociation(association);
763
- if (errors.length > 0)
764
- throw new StackValidationError(errors);
765
- const existing = await this.adapter.getRecord(id);
766
- if (!existing) {
767
- throw new StackNotFoundError(`Record not found: "${id}"`);
768
- }
769
- if ((existing.associations ?? []).some((a) => associationIdentical(a, association))) {
770
- return existing;
771
- }
772
- const replaced = (existing.associations ?? []).find((a) => associationEqual(a, association));
773
- await this.checkAttachmentAssociationPointers([association]);
774
- const change = new PendingChange('associate', {
775
- actor: normalizeActor(opts.actor),
776
- // A re-point carries what it overwrote: nothing else retains the
777
- // attachmentRecordId that association held.
778
- associations: [
779
- replaced ? { op: 'repoint', association, previous: replaced } : { op: 'add', association },
780
- ],
781
- });
782
- const updated = await this.adapter.associate(id, association, { journal: change.journal });
783
- this.announce(change, updated);
784
- return updated;
767
+ async associate(id, associations, opts = {}) {
768
+ return this.amendAssociations(id, associations.map((association) => ({ op: 'add', association })), opts, 'associate()');
785
769
  }
786
770
  /**
787
- * Remove an association from a record — see associate(). Matched by kind,
788
- * label and payload; a no-op if not found. The emitted event names the
789
- * association by identity only, since an attachment's
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
790
774
  * `attachmentRecordId` describes nothing current once it is gone; the
791
775
  * journal is where it survives.
792
776
  */
793
- async dissociate(id, association, opts = {}) {
794
- this.assertOpen();
795
- assertDataAssociations([association], 'dissociate()');
796
- const errors = validateAssociation(association);
797
- if (errors.length > 0)
798
- throw new StackValidationError(errors);
799
- const existing = await this.adapter.getRecord(id);
800
- if (!existing) {
801
- throw new StackNotFoundError(`Record not found: "${id}"`);
802
- }
803
- const matched = (existing.associations ?? []).find((a) => associationEqual(a, association));
804
- if (!matched) {
805
- return existing;
806
- }
807
- // Removing anything else cannot take the roster to zero, so only an
808
- // admin entry is worth deriving the post-state for. Derived rather than
809
- // counted so this path and the change set's reach the invariant through
810
- // the same helper, asking the same question of the same shape of list.
811
- if (isGroupAdminAssociation(association)) {
812
- this.assertGroupAdminRemains(existing, (existing.associations ?? []).filter((a) => !associationEqual(a, association)));
813
- }
814
- const change = new PendingChange('dissociate', {
815
- actor: normalizeActor(opts.actor),
816
- // In full, annotation included — what makes a removal as undoable
817
- // from the log as a re-point is. The frame this becomes still names
818
- // identity only. See docs/spec/journal.md § The entry.
819
- associations: [{ op: 'remove', previous: matched }],
820
- });
821
- const updated = await this.adapter.dissociate(id, association, { journal: change.journal });
822
- this.announce(change, updated);
823
- return updated;
777
+ async dissociate(id, associations, opts = {}) {
778
+ return this.amendAssociations(id, associations.map((association) => ({ op: 'remove', association })), opts, 'dissociate()');
824
779
  }
825
780
  /**
826
- * Extend who reaches a record by one element. The spelling that survives
827
- * two admins sharing a record at once: a `permissions` key write replaces
828
- * the whole set, so the later of two concurrent ones drops what the
829
- * earlier granted. No-bump, like associate(); an element the record
830
- * already carries is a no-op.
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.
831
786
  */
832
- async grantAccess(id, permission, opts = {}) {
787
+ async amendAssociations(id, changes, opts = {}, surface = 'amendAssociations()') {
833
788
  this.assertOpen();
834
- assertAuthorityAssociations([permission], 'grantAccess()');
835
- const errors = validateAssociation(permission, 'permission');
836
- if (errors.length > 0)
837
- throw new StackValidationError(errors);
789
+ assertAssociationEdits(changes, surface, 'data');
838
790
  const existing = await this.requireRecord(id);
839
- const current = existing.permissions ?? [];
840
- if (current.some((p) => associationEqual(p, permission)))
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)
841
796
  return existing;
842
- this.assertPermissionSet([...current, permission]);
843
- const change = new PendingChange('reshare', {
844
- actor: normalizeActor(opts.actor),
845
- associations: [{ op: 'add', association: permission }],
846
- });
847
- const updated = await this.adapter.associate(id, permission, { journal: change.journal });
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 });
848
805
  this.announce(change, updated);
849
806
  return updated;
850
807
  }
851
808
  /**
852
- * Withdraw one element of who reaches a record — see grantAccess() for
853
- * why this is a verb rather than a key write. An element the record does
854
- * not carry is a no-op. Returns the record as it now stands.
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.
855
830
  */
856
- async revokeAccess(id, permission, opts = {}) {
831
+ async amendAccess(id, changes, opts = {}, surface = 'amendAccess()') {
857
832
  this.assertOpen();
858
- assertAuthorityAssociations([permission], 'revokeAccess()');
859
- const errors = validateAssociation(permission, 'permission');
860
- if (errors.length > 0)
861
- throw new StackValidationError(errors);
833
+ assertAssociationEdits(changes, surface, 'authority');
862
834
  const existing = await this.requireRecord(id);
835
+ this.refuseIfDeleted(existing);
863
836
  const current = existing.permissions ?? [];
864
- const matched = current.find((p) => associationEqual(p, permission));
865
- if (!matched)
837
+ const next = applyAssociationEdits(current, changes);
838
+ const delta = associationDelta(current, next);
839
+ if (delta.length === 0)
866
840
  return existing;
867
- this.assertPermissionSet(current.filter((p) => !associationEqual(p, permission)));
841
+ this.assertPermissionSet(next);
868
842
  const change = new PendingChange('reshare', {
869
843
  actor: normalizeActor(opts.actor),
870
- associations: [{ op: 'remove', previous: matched }],
844
+ associations: delta,
871
845
  });
872
- const updated = await this.adapter.dissociate(id, permission, { journal: change.journal });
846
+ const updated = await this.adapter.amendAssociations(id, changes, { journal: change.journal });
873
847
  this.announce(change, updated);
874
848
  return updated;
875
849
  }
@@ -882,6 +856,18 @@ export class Stack {
882
856
  if (errors.length > 0)
883
857
  throw new StackValidationError(errors);
884
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
+ }
885
871
  /** The record, or the not-found refusal every mutating verb owes. */
886
872
  async requireRecord(id) {
887
873
  const record = await this.adapter.getRecord(id);
@@ -1045,19 +1031,17 @@ export class Stack {
1045
1031
  this.announce(change, undeleted);
1046
1032
  return undeleted;
1047
1033
  }
1048
- /**
1049
- * Query records. filter.baseId matches every version of a type family,
1050
- * resolved against registered Types. Results come back exactly as
1051
- * stored; pass presentAt: 'latest' to migrate in memory. See
1052
- * docs/spec/data-model.md § Queries.
1053
- */
1054
- async query(query = {}) {
1034
+ async query(first = {}, typedQueryArg) {
1035
+ if (isTypeHandle(first))
1036
+ return typedQuery(this, first, typedQueryArg);
1037
+ const query = first;
1055
1038
  this.assertOpen();
1056
1039
  const { presentAt, filter, limit: rawLimit, ...rest } = query;
1057
1040
  assertQueryCapabilities(filter, this.adapter.capabilities);
1058
1041
  assertValidSort(query.sort);
1059
1042
  assertSortCapability(query.sort, this.adapter.capabilities);
1060
1043
  assertValidAssociationFilters(filter);
1044
+ assertValidBaseIdFilter(filter);
1061
1045
  const limit = rawLimit !== undefined ? Math.min(rawLimit, MAX_QUERY_LIMIT) : undefined;
1062
1046
  const resolvedFilter = await this.resolveBaseIdFilter(filter);
1063
1047
  if (resolvedFilter === EMPTY_FAMILY) {
@@ -1065,6 +1049,7 @@ export class Stack {
1065
1049
  }
1066
1050
  const result = await this.adapter.queryRecords({
1067
1051
  ...rest,
1052
+ ...(query.sort && { sort: normalizeSort(query.sort) }),
1068
1053
  ...(resolvedFilter !== undefined && { filter: resolvedFilter }),
1069
1054
  ...(limit !== undefined && { limit }),
1070
1055
  });
@@ -1095,9 +1080,10 @@ export class Stack {
1095
1080
  // -------------------------------------------------------
1096
1081
  // Versions
1097
1082
  // -------------------------------------------------------
1098
- async getVersions(id) {
1083
+ async getVersions(id, query = {}) {
1099
1084
  this.assertOpen();
1100
- return this.adapter.getVersions(id);
1085
+ assertValidVersionsQuery(query);
1086
+ return this.adapter.getVersions(id, query);
1101
1087
  }
1102
1088
  /**
1103
1089
  * A record's change journal, oldest first — what moved, who moved it, and
@@ -1129,6 +1115,7 @@ export class Stack {
1129
1115
  if (!existing) {
1130
1116
  throw new StackNotFoundError(`Record not found: "${id}"`);
1131
1117
  }
1118
+ this.refuseIfDeleted(existing);
1132
1119
  this.checkIfVersion(existing, opts.ifVersion);
1133
1120
  const target = await this.adapter.getVersion(id, version);
1134
1121
  if (!target) {
@@ -1141,6 +1128,8 @@ export class Stack {
1141
1128
  const errors = [
1142
1129
  ...validateContent(target.content, type.schema),
1143
1130
  ...validateGrantee(target.typeId, target.content),
1131
+ ...validateGrantBaseId(target.typeId, target.content),
1132
+ ...validateInstall(target.typeId, target.content),
1144
1133
  ];
1145
1134
  if (errors.length > 0) {
1146
1135
  throw new StackValidationError(errors);
@@ -1206,17 +1195,20 @@ export class Stack {
1206
1195
  * can cross type families; family-crossing is exactly what the checks
1207
1196
  * below care about.
1208
1197
  */
1209
- async commitMigrationChecked(existing, toTypeId, content, opts = {}) {
1198
+ async commitMigrationChecked(existing, toTypeId, input, opts = {}) {
1210
1199
  const id = existing.id;
1211
1200
  const type = await this.getTypeCached(toTypeId);
1212
1201
  if (!type) {
1213
1202
  throw new StackBadRequestError(`Unknown type: "${toTypeId}". Call defineType() first.`);
1214
1203
  }
1204
+ const content = dropAbsentFields(input, type.schema);
1215
1205
  const errors = [
1216
1206
  ...validateReservedKeys(content),
1217
1207
  ...validateContentKeys(content),
1218
1208
  ...validateContent(content, type.schema),
1219
1209
  ...validateGrantee(toTypeId, content),
1210
+ ...validateGrantBaseId(toTypeId, content),
1211
+ ...validateInstall(toTypeId, content),
1220
1212
  ];
1221
1213
  if (errors.length > 0) {
1222
1214
  throw new StackValidationError(errors);
@@ -1667,22 +1659,15 @@ export class Stack {
1667
1659
  }
1668
1660
  return { deletedFileIds, reclaimedBytes };
1669
1661
  }
1670
- // -------------------------------------------------------
1671
- // Lifecycle
1672
- // -------------------------------------------------------
1673
- /**
1674
- * Observe every change made through this Stack. Unscoped, so no
1675
- * permission filter applies — a caller holding a `Stack` already reaches
1676
- * every record by other means; `ScopedStack.subscribe()` is the filtered
1677
- * view. See docs/spec/events.md.
1678
- *
1679
- * Async so that a remote stack can resolve once its feed is live, which
1680
- * makes subscribe-then-query the gap-free startup order everywhere. A
1681
- * local stack is live immediately.
1682
- */
1683
- async subscribe(handler, opts = {}) {
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 ?? {});
1684
1668
  this.assertOpen();
1685
1669
  assertSinceUsable(opts.since, this.relaysChanges);
1670
+ assertValidBaseIdFilter(opts.filter);
1686
1671
  const unsubscribe = this.changes.subscribe(handler, opts);
1687
1672
  let stopRelay;
1688
1673
  try {
@@ -1785,16 +1770,15 @@ export class Stack {
1785
1770
  * needs, not the suffix that looks narrowest. See
1786
1771
  * docs/spec/access-control.md § Delegation: principal and subject.
1787
1772
  *
1788
- * `typeOrBaseId` is a versioned TypeId (`"baseId@version"`) or a bare
1789
- * baseId; either names the whole family. See
1790
- * docs/spec/access-control.md § Type-level grants.
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.
1791
1775
  */
1792
- async grantType(typeOrBaseId, grant) {
1776
+ async grantType(baseId, grant) {
1793
1777
  this.assertOpen();
1794
1778
  validateGrantTarget(grant.grantee);
1795
- this.checkGrantValid(typeOrBaseId, grant.actions);
1779
+ this.checkGrantValid(baseId, grant.actions);
1796
1780
  return this.create(`${SYSTEM_TYPES.GRANT}@1`, {
1797
- typeId: typeOrBaseId,
1781
+ baseId,
1798
1782
  actions: grant.actions,
1799
1783
  grantee: grant.grantee,
1800
1784
  });
@@ -1834,7 +1818,7 @@ export class Stack {
1834
1818
  allowDefault: true,
1835
1819
  allowGroup: true,
1836
1820
  groupRoles,
1837
- resolveRecord: (id) => this.get(id),
1821
+ resolveRecord: (id) => this.get(id, { includeDeleted: true }),
1838
1822
  });
1839
1823
  if (covers)
1840
1824
  result.push(r);
@@ -1843,7 +1827,7 @@ export class Stack {
1843
1827
  }
1844
1828
  /**
1845
1829
  * The inverse of grantType(): soft-deletes the _grant records on
1846
- * `typeOrBaseId`'s family matching `grant`, at the same granularity
1830
+ * `baseId`'s family matching `grant`, at the same granularity
1847
1831
  * grantType() writes — the grantee is matched whole, role included, and
1848
1832
  * the actions exactly. A soft delete like any other — the owner can
1849
1833
  * undelete a revocation.
@@ -1854,10 +1838,13 @@ export class Stack {
1854
1838
  * revocation has to stay safe. See
1855
1839
  * docs/spec/access-control.md § Listing and revoking.
1856
1840
  */
1857
- async revokeType(typeOrBaseId, grant) {
1841
+ async revokeType(baseId, grant) {
1858
1842
  this.assertOpen();
1859
1843
  validateGrantTarget(grant.grantee);
1860
- const familyId = baseIdOf(typeOrBaseId);
1844
+ const problem = familyIdProblem(baseId, 'revokeType');
1845
+ if (problem)
1846
+ throw new StackValidationError([{ path: 'baseId', message: problem }]);
1847
+ const familyId = baseId;
1861
1848
  const actionSet = new Set(grant.actions);
1862
1849
  const all = await loadGrantRecords((q) => this.query(q));
1863
1850
  const matches = all.filter((r) => {
@@ -1878,32 +1865,313 @@ export class Stack {
1878
1865
  return matches;
1879
1866
  }
1880
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
+ // -------------------------------------------------------
1881
2156
  // Private helpers
1882
2157
  // -------------------------------------------------------
1883
2158
  /**
1884
- * Actions must be known GrantAction values, typeIds must be well-formed,
1885
- * and the _grant/_config families are refused — see
1886
- * docs/spec/access-control.md § Type-level grants.
2159
+ * Actions must be known GrantAction values; the target is held to the
2160
+ * same rule every `_grant` write meets — see validateGrantBaseId().
1887
2161
  */
1888
- checkGrantValid(typeId, actions) {
2162
+ checkGrantValid(baseId, actions) {
1889
2163
  const errors = [];
1890
2164
  actions.forEach((action, j) => {
1891
2165
  if (!GRANT_ACTION_SET.has(action)) {
1892
2166
  errors.push({ path: `actions[${j}]`, message: `Unknown grant action "${action}"` });
1893
2167
  }
1894
2168
  });
1895
- if (!isWellFormedTypeId(typeId)) {
1896
- errors.push({
1897
- path: 'typeId',
1898
- message: `"${typeId}" is not a well-formed baseId or versioned TypeId (expected "baseId" or "baseId@version")`,
1899
- });
2169
+ const problem = familyIdProblem(baseId, 'grantType');
2170
+ if (problem) {
2171
+ errors.push({ path: 'baseId', message: problem });
1900
2172
  }
1901
- else if (UNGRANTABLE_SYSTEM_TYPES.has(baseIdOf(typeId))) {
1902
- const refused = [...UNGRANTABLE_SYSTEM_TYPES].join(', ');
1903
- errors.push({
1904
- path: 'typeId',
1905
- message: `Cannot grant on "${baseIdOf(typeId)}": grants on ${refused} are refused to prevent privilege escalation`,
1906
- });
2173
+ else {
2174
+ errors.push(...validateGrantBaseId(`${SYSTEM_TYPES.GRANT}@1`, { baseId }));
1907
2175
  }
1908
2176
  actions.forEach((action, j) => {
1909
2177
  if (grantConveys(actions, action))
@@ -1913,7 +2181,7 @@ export class Stack {
1913
2181
  return;
1914
2182
  errors.push({
1915
2183
  path: `actions[${j}]`,
1916
- 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`,
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}']`,
1917
2185
  });
1918
2186
  });
1919
2187
  if (errors.length > 0) {
@@ -1962,7 +2230,7 @@ export class Stack {
1962
2230
  id: `${SYSTEM_TYPES.GRANT}@1`,
1963
2231
  name: 'Grant',
1964
2232
  schema: {
1965
- typeId: { kind: 'string', required: true },
2233
+ baseId: { kind: 'string', required: true },
1966
2234
  actions: { kind: 'array', items: { kind: 'string' }, required: true },
1967
2235
  // Required, and closed: a Grant's reach is spelled by its `grantee`,
1968
2236
  // so a record arriving without one is refused here rather than read
@@ -1998,6 +2266,27 @@ export class Stack {
1998
2266
  filename: { kind: 'string' },
1999
2267
  },
2000
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
+ });
2001
2290
  }
2002
2291
  /**
2003
2292
  * Fast-fail for the ifVersion precondition using the already-fetched