@haverstack/core 0.38.0 → 0.40.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 (76) 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 +44 -4
  19. package/dist/grants.js.map +1 -1
  20. package/dist/index.d.ts +4 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +3 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/query-validation.d.ts +51 -1
  25. package/dist/query-validation.d.ts.map +1 -1
  26. package/dist/query-validation.js +155 -1
  27. package/dist/query-validation.js.map +1 -1
  28. package/dist/record-changes.d.ts +15 -1
  29. package/dist/record-changes.d.ts.map +1 -1
  30. package/dist/record-changes.js +33 -3
  31. package/dist/record-changes.js.map +1 -1
  32. package/dist/record-id.js +1 -1
  33. package/dist/record-id.js.map +1 -1
  34. package/dist/schema.d.ts +6 -7
  35. package/dist/schema.d.ts.map +1 -1
  36. package/dist/schema.js +33 -10
  37. package/dist/schema.js.map +1 -1
  38. package/dist/scoped-stack.d.ts +27 -17
  39. package/dist/scoped-stack.d.ts.map +1 -1
  40. package/dist/scoped-stack.js +111 -120
  41. package/dist/scoped-stack.js.map +1 -1
  42. package/dist/stack.d.ts +120 -44
  43. package/dist/stack.d.ts.map +1 -1
  44. package/dist/stack.js +153 -189
  45. package/dist/stack.js.map +1 -1
  46. package/dist/testing.d.ts +28 -11
  47. package/dist/testing.d.ts.map +1 -1
  48. package/dist/testing.js +42 -37
  49. package/dist/testing.js.map +1 -1
  50. package/dist/type-handle.d.ts +157 -0
  51. package/dist/type-handle.d.ts.map +1 -0
  52. package/dist/type-handle.js +131 -0
  53. package/dist/type-handle.js.map +1 -0
  54. package/dist/types.d.ts +63 -20
  55. package/dist/types.d.ts.map +1 -1
  56. package/dist/types.js.map +1 -1
  57. package/dist/validate.d.ts +7 -0
  58. package/dist/validate.d.ts.map +1 -1
  59. package/dist/validate.js +122 -7
  60. package/dist/validate.js.map +1 -1
  61. package/dist/wire-body.d.ts +79 -0
  62. package/dist/wire-body.d.ts.map +1 -0
  63. package/dist/wire-body.js +177 -0
  64. package/dist/wire-body.js.map +1 -0
  65. package/dist/wire-entry.d.ts +4 -2
  66. package/dist/wire-entry.d.ts.map +1 -1
  67. package/dist/wire-entry.js +8 -3
  68. package/dist/wire-entry.js.map +1 -1
  69. package/dist/wire-record.d.ts.map +1 -1
  70. package/dist/wire-record.js +27 -14
  71. package/dist/wire-record.js.map +1 -1
  72. package/dist/wire-request.d.ts +52 -2
  73. package/dist/wire-request.d.ts.map +1 -1
  74. package/dist/wire-request.js +222 -23
  75. package/dist/wire-request.js.map +1 -1
  76. package/package.json +1 -1
package/dist/stack.js CHANGED
@@ -14,22 +14,23 @@
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
28
  import { assertAttachmentSize, assertContentSize } from './limits.js';
29
29
  import { validateParentId, validateRecordId, validateClockField, validateIdTimestampSkew, DEFAULT_ID_TIMESTAMP_SKEW_MS, } from './record-id.js';
30
30
  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';
31
+ import { applyAssociationEdits, associationDelta, associationIdentical, assertNonEmptyChangeSet, changeSetOps, bumpsVersion, takesIfVersion, effectiveChanges, stampGroupAdmin, isGroupRecord, } from './record-changes.js';
32
32
  import { ScopedStack, scopeToken } from './scoped-stack.js';
33
+ import { isTypeHandle, typedCreate, typedGet, typedMutate, typedQuery, typedSubscribe, } from './type-handle.js';
33
34
  // -------------------------------------------------------
34
35
  // Supporting types
35
36
  // -------------------------------------------------------
@@ -235,8 +236,10 @@ export class Stack {
235
236
  * anything beyond additive evolution throws StackSchemaDriftError. See
236
237
  * docs/spec/data-model.md § Schema drift detection.
237
238
  */
238
- async defineType({ id, name, schema, migratesFrom }) {
239
+ async defineType({ id, name, schema: declared, migratesFrom, }) {
239
240
  this.assertOpen();
241
+ // Only read below; the readonly view exists so a handle's literal fits.
242
+ const schema = declared;
240
243
  const parsed = parseTypeId(id);
241
244
  if (!parsed) {
242
245
  throw new StackBadRequestError(`Invalid TypeId format: "${id}". Expected "namespace/name@version", e.g. "com.example.myapp/note@1".`);
@@ -376,6 +379,9 @@ export class Stack {
376
379
  */
377
380
  async migrateAll(baseId) {
378
381
  this.assertOpen();
382
+ const problem = familyIdProblem(baseId, 'migrateAll');
383
+ if (problem)
384
+ throw new StackValidationError([{ path: 'baseId', message: problem }]);
379
385
  const types = await this.adapter.listTypes();
380
386
  const familyTypeIds = types.filter((t) => t.baseId === baseId).map((t) => t.id);
381
387
  if (familyTypeIds.length === 0) {
@@ -413,26 +419,16 @@ export class Stack {
413
419
  }
414
420
  return { migrated };
415
421
  }
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 = {}) {
422
+ async create(typeIdOrHandle, input, opts = {}) {
423
+ if (isTypeHandle(typeIdOrHandle))
424
+ return typedCreate(this, typeIdOrHandle, input, opts);
425
+ const typeId = typeIdOrHandle;
431
426
  this.assertOpen();
432
427
  const type = await this.getTypeCached(typeId);
433
428
  if (!type) {
434
429
  throw new StackBadRequestError(`Unknown type: "${typeId}". Call defineType() first.`);
435
430
  }
431
+ const content = dropAbsentFields(input, type.schema);
436
432
  // Copied, never aliased: an import loop that reuses one Date across rows
437
433
  // (`d.setTime(...)` per record) would otherwise retro-edit every record
438
434
  // it had already written, with no version bump and no change event.
@@ -453,6 +449,7 @@ export class Stack {
453
449
  ...validateContentKeys(content),
454
450
  ...validateContent(content, type.schema),
455
451
  ...validateGrantee(typeId, content),
452
+ ...validateGrantBaseId(typeId, content),
456
453
  ...validateAssociations(opts.permissions, 'permissions'),
457
454
  ...validatePermissions(opts.permissions),
458
455
  ...validateAssociations(opts.associations),
@@ -564,35 +561,33 @@ export class Stack {
564
561
  }
565
562
  return record;
566
563
  }
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 = {}) {
564
+ async get(idOrHandle, idOrOpts = {}) {
565
+ if (isTypeHandle(idOrHandle))
566
+ return typedGet(this, idOrHandle, idOrOpts);
567
+ const id = idOrHandle;
568
+ const opts = idOrOpts;
573
569
  this.assertOpen();
574
570
  const record = await this.adapter.getRecord(id);
575
571
  if (!record)
576
572
  return null;
573
+ if (record.deletedAt && !opts.includeDeleted)
574
+ return null;
577
575
  return opts.presentAt === 'latest' ? this.presentAtLatest(record) : record;
578
576
  }
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 = {}) {
577
+ async mutate(first, second, third, fourth = {}) {
578
+ if (isTypeHandle(first)) {
579
+ return typedMutate(this, first, second, third, fourth);
580
+ }
581
+ const id = first;
582
+ const changes = second;
583
+ const opts = (third ?? {});
590
584
  this.assertOpen();
591
585
  assertNonEmptyChangeSet(changes);
592
586
  const existing = await this.adapter.getRecord(id);
593
587
  if (!existing) {
594
588
  throw new StackNotFoundError(`Record not found: "${id}"`);
595
589
  }
590
+ this.refuseIfDeleted(existing);
596
591
  // Checked before validation, so a caller that lost the race learns its
597
592
  // version is stale rather than that its patch is bad, and before the
598
593
  // no-op short-circuit below, so a precondition is never satisfied by a
@@ -641,13 +636,11 @@ export class Stack {
641
636
  this.announce(change, updated);
642
637
  return updated;
643
638
  }
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);
639
+ async patchContent(first, second, third, fourth) {
640
+ if (isTypeHandle(first)) {
641
+ return this.mutate(first, second, { contentPatch: third }, fourth);
642
+ }
643
+ return this.mutate(first, { contentPatch: second }, third);
651
644
  }
652
645
  /**
653
646
  * Every check a change set owes before anything is written, in one pass:
@@ -700,6 +693,7 @@ export class Stack {
700
693
  const contentErrors = [
701
694
  ...validateContent(merged, type.schema),
702
695
  ...validateGrantee(existing.typeId, merged),
696
+ ...validateGrantBaseId(existing.typeId, merged),
703
697
  ];
704
698
  if (contentErrors.length > 0)
705
699
  throw new StackValidationError(contentErrors);
@@ -746,7 +740,7 @@ export class Stack {
746
740
  'least one `admin` relationship association. Name the incoming admin in the same write.');
747
741
  }
748
742
  /**
749
- * Add an association to a record — no-bump and no snapshot, per the
743
+ * Add associations to a record — no-bump and no snapshot, per the
750
744
  * StackClient declaration above.
751
745
  *
752
746
  * An association the record already holds, saying the same thing, is a
@@ -756,120 +750,86 @@ export class Stack {
756
750
  * file. What a re-point overwrote is kept only by the journal, on that
757
751
  * entry's `repoint`. See docs/spec/journal.md § The entry.
758
752
  */
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;
753
+ async associate(id, associations, opts = {}) {
754
+ return this.amendAssociations(id, associations.map((association) => ({ op: 'add', association })), opts, 'associate()');
785
755
  }
786
756
  /**
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
757
+ * Remove associations from a record — see associate(). Matched by kind,
758
+ * label and payload; an element not found is a no-op. The emitted event
759
+ * names each association by identity only, since an attachment's
790
760
  * `attachmentRecordId` describes nothing current once it is gone; the
791
761
  * journal is where it survives.
792
762
  */
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;
763
+ async dissociate(id, associations, opts = {}) {
764
+ return this.amendAssociations(id, associations.map((association) => ({ op: 'remove', association })), opts, 'dissociate()');
824
765
  }
825
766
  /**
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.
767
+ * The atomic form of associate() and dissociate(): one list of adds and
768
+ * removes, one adapter write, one journal entry. The journal records each
769
+ * element in full — a remove with its annotation, a re-point with what it
770
+ * overwrote — so the entry is as undoable as a single-element one.
771
+ * See docs/spec/data-model.md § Mutations.
831
772
  */
832
- async grantAccess(id, permission, opts = {}) {
773
+ async amendAssociations(id, changes, opts = {}, surface = 'amendAssociations()') {
833
774
  this.assertOpen();
834
- assertAuthorityAssociations([permission], 'grantAccess()');
835
- const errors = validateAssociation(permission, 'permission');
836
- if (errors.length > 0)
837
- throw new StackValidationError(errors);
775
+ assertAssociationEdits(changes, surface, 'data');
838
776
  const existing = await this.requireRecord(id);
839
- const current = existing.permissions ?? [];
840
- if (current.some((p) => associationEqual(p, permission)))
777
+ this.refuseIfDeleted(existing);
778
+ const current = existing.associations ?? [];
779
+ const next = applyAssociationEdits(current, changes);
780
+ const delta = associationDelta(current, next);
781
+ if (delta.length === 0)
841
782
  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 });
783
+ // Only a roster can lose its last admin, and only the post-state says so.
784
+ this.assertGroupAdminRemains(existing, next);
785
+ await this.checkAttachmentAssociationPointers(changes.flatMap((c) => (c.op === 'add' ? [c.association] : [])), current);
786
+ const change = new PendingChange([
787
+ ...(delta.some((c) => c.op !== 'remove') ? ['associate'] : []),
788
+ ...(delta.some((c) => c.op === 'remove') ? ['dissociate'] : []),
789
+ ], { actor: normalizeActor(opts.actor), associations: delta });
790
+ const updated = await this.adapter.amendAssociations(id, changes, { journal: change.journal });
848
791
  this.announce(change, updated);
849
792
  return updated;
850
793
  }
851
794
  /**
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.
795
+ * Extend who reaches a record. The spelling that survives two admins
796
+ * sharing a record at once: a `permissions` key write replaces the whole
797
+ * set, so the later of two concurrent ones drops what the earlier
798
+ * granted. No-bump, like associate(); an element the record already
799
+ * carries is a no-op. `write` never implies `read` — name both.
800
+ */
801
+ async grantAccess(id, permissions, opts = {}) {
802
+ return this.amendAccess(id, permissions.map((association) => ({ op: 'add', association })), opts, 'grantAccess()');
803
+ }
804
+ /**
805
+ * Withdraw elements of who reaches a record — see grantAccess() for why
806
+ * this is a verb rather than a key write. An element the record does not
807
+ * carry is a no-op. Returns the record as it now stands.
855
808
  */
856
- async revokeAccess(id, permission, opts = {}) {
809
+ async revokeAccess(id, permissions, opts = {}) {
810
+ return this.amendAccess(id, permissions.map((association) => ({ op: 'remove', association })), opts, 'revokeAccess()');
811
+ }
812
+ /**
813
+ * The atomic form of grantAccess() and revokeAccess(). The
814
+ * write-implies-read invariant is asked once, of the set the whole list
815
+ * produces. See docs/spec/access-control.md § Write implies read.
816
+ */
817
+ async amendAccess(id, changes, opts = {}, surface = 'amendAccess()') {
857
818
  this.assertOpen();
858
- assertAuthorityAssociations([permission], 'revokeAccess()');
859
- const errors = validateAssociation(permission, 'permission');
860
- if (errors.length > 0)
861
- throw new StackValidationError(errors);
819
+ assertAssociationEdits(changes, surface, 'authority');
862
820
  const existing = await this.requireRecord(id);
821
+ this.refuseIfDeleted(existing);
863
822
  const current = existing.permissions ?? [];
864
- const matched = current.find((p) => associationEqual(p, permission));
865
- if (!matched)
823
+ const next = applyAssociationEdits(current, changes);
824
+ const delta = associationDelta(current, next);
825
+ if (delta.length === 0)
866
826
  return existing;
867
- this.assertPermissionSet(current.filter((p) => !associationEqual(p, permission)));
827
+ this.assertPermissionSet(next);
868
828
  const change = new PendingChange('reshare', {
869
829
  actor: normalizeActor(opts.actor),
870
- associations: [{ op: 'remove', previous: matched }],
830
+ associations: delta,
871
831
  });
872
- const updated = await this.adapter.dissociate(id, permission, { journal: change.journal });
832
+ const updated = await this.adapter.amendAssociations(id, changes, { journal: change.journal });
873
833
  this.announce(change, updated);
874
834
  return updated;
875
835
  }
@@ -882,6 +842,18 @@ export class Stack {
882
842
  if (errors.length > 0)
883
843
  throw new StackValidationError(errors);
884
844
  }
845
+ /**
846
+ * A soft-deleted record has no current state to edit, so the verbs that
847
+ * edit one refuse it; undelete() and commitMigration() do not call this.
848
+ * Asked after the record is found and, under ScopedStack, after the
849
+ * authority decision. See docs/spec/versioning.md § Mutations are refused,
850
+ * not applied to a tombstone.
851
+ */
852
+ refuseIfDeleted(record) {
853
+ if (record.deletedAt) {
854
+ throw new StackConflictError(`Record "${record.id}" is soft-deleted; undelete it before mutating it.`);
855
+ }
856
+ }
885
857
  /** The record, or the not-found refusal every mutating verb owes. */
886
858
  async requireRecord(id) {
887
859
  const record = await this.adapter.getRecord(id);
@@ -1045,19 +1017,17 @@ export class Stack {
1045
1017
  this.announce(change, undeleted);
1046
1018
  return undeleted;
1047
1019
  }
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 = {}) {
1020
+ async query(first = {}, typedQueryArg) {
1021
+ if (isTypeHandle(first))
1022
+ return typedQuery(this, first, typedQueryArg);
1023
+ const query = first;
1055
1024
  this.assertOpen();
1056
1025
  const { presentAt, filter, limit: rawLimit, ...rest } = query;
1057
1026
  assertQueryCapabilities(filter, this.adapter.capabilities);
1058
1027
  assertValidSort(query.sort);
1059
1028
  assertSortCapability(query.sort, this.adapter.capabilities);
1060
1029
  assertValidAssociationFilters(filter);
1030
+ assertValidBaseIdFilter(filter);
1061
1031
  const limit = rawLimit !== undefined ? Math.min(rawLimit, MAX_QUERY_LIMIT) : undefined;
1062
1032
  const resolvedFilter = await this.resolveBaseIdFilter(filter);
1063
1033
  if (resolvedFilter === EMPTY_FAMILY) {
@@ -1065,6 +1035,7 @@ export class Stack {
1065
1035
  }
1066
1036
  const result = await this.adapter.queryRecords({
1067
1037
  ...rest,
1038
+ ...(query.sort && { sort: normalizeSort(query.sort) }),
1068
1039
  ...(resolvedFilter !== undefined && { filter: resolvedFilter }),
1069
1040
  ...(limit !== undefined && { limit }),
1070
1041
  });
@@ -1095,9 +1066,10 @@ export class Stack {
1095
1066
  // -------------------------------------------------------
1096
1067
  // Versions
1097
1068
  // -------------------------------------------------------
1098
- async getVersions(id) {
1069
+ async getVersions(id, query = {}) {
1099
1070
  this.assertOpen();
1100
- return this.adapter.getVersions(id);
1071
+ assertValidVersionsQuery(query);
1072
+ return this.adapter.getVersions(id, query);
1101
1073
  }
1102
1074
  /**
1103
1075
  * A record's change journal, oldest first — what moved, who moved it, and
@@ -1129,6 +1101,7 @@ export class Stack {
1129
1101
  if (!existing) {
1130
1102
  throw new StackNotFoundError(`Record not found: "${id}"`);
1131
1103
  }
1104
+ this.refuseIfDeleted(existing);
1132
1105
  this.checkIfVersion(existing, opts.ifVersion);
1133
1106
  const target = await this.adapter.getVersion(id, version);
1134
1107
  if (!target) {
@@ -1141,6 +1114,7 @@ export class Stack {
1141
1114
  const errors = [
1142
1115
  ...validateContent(target.content, type.schema),
1143
1116
  ...validateGrantee(target.typeId, target.content),
1117
+ ...validateGrantBaseId(target.typeId, target.content),
1144
1118
  ];
1145
1119
  if (errors.length > 0) {
1146
1120
  throw new StackValidationError(errors);
@@ -1206,17 +1180,19 @@ export class Stack {
1206
1180
  * can cross type families; family-crossing is exactly what the checks
1207
1181
  * below care about.
1208
1182
  */
1209
- async commitMigrationChecked(existing, toTypeId, content, opts = {}) {
1183
+ async commitMigrationChecked(existing, toTypeId, input, opts = {}) {
1210
1184
  const id = existing.id;
1211
1185
  const type = await this.getTypeCached(toTypeId);
1212
1186
  if (!type) {
1213
1187
  throw new StackBadRequestError(`Unknown type: "${toTypeId}". Call defineType() first.`);
1214
1188
  }
1189
+ const content = dropAbsentFields(input, type.schema);
1215
1190
  const errors = [
1216
1191
  ...validateReservedKeys(content),
1217
1192
  ...validateContentKeys(content),
1218
1193
  ...validateContent(content, type.schema),
1219
1194
  ...validateGrantee(toTypeId, content),
1195
+ ...validateGrantBaseId(toTypeId, content),
1220
1196
  ];
1221
1197
  if (errors.length > 0) {
1222
1198
  throw new StackValidationError(errors);
@@ -1667,22 +1643,15 @@ export class Stack {
1667
1643
  }
1668
1644
  return { deletedFileIds, reclaimedBytes };
1669
1645
  }
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 = {}) {
1646
+ async subscribe(first, second, third) {
1647
+ if (isTypeHandle(first)) {
1648
+ return typedSubscribe(this, first, second, third);
1649
+ }
1650
+ const handler = first;
1651
+ const opts = (second ?? {});
1684
1652
  this.assertOpen();
1685
1653
  assertSinceUsable(opts.since, this.relaysChanges);
1654
+ assertValidBaseIdFilter(opts.filter);
1686
1655
  const unsubscribe = this.changes.subscribe(handler, opts);
1687
1656
  let stopRelay;
1688
1657
  try {
@@ -1785,16 +1754,15 @@ export class Stack {
1785
1754
  * needs, not the suffix that looks narrowest. See
1786
1755
  * docs/spec/access-control.md § Delegation: principal and subject.
1787
1756
  *
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.
1757
+ * `baseId` names the whole family and is refused when it carries an
1758
+ * `@version` suffix. See docs/spec/access-control.md § Type-level grants.
1791
1759
  */
1792
- async grantType(typeOrBaseId, grant) {
1760
+ async grantType(baseId, grant) {
1793
1761
  this.assertOpen();
1794
1762
  validateGrantTarget(grant.grantee);
1795
- this.checkGrantValid(typeOrBaseId, grant.actions);
1763
+ this.checkGrantValid(baseId, grant.actions);
1796
1764
  return this.create(`${SYSTEM_TYPES.GRANT}@1`, {
1797
- typeId: typeOrBaseId,
1765
+ baseId,
1798
1766
  actions: grant.actions,
1799
1767
  grantee: grant.grantee,
1800
1768
  });
@@ -1834,7 +1802,7 @@ export class Stack {
1834
1802
  allowDefault: true,
1835
1803
  allowGroup: true,
1836
1804
  groupRoles,
1837
- resolveRecord: (id) => this.get(id),
1805
+ resolveRecord: (id) => this.get(id, { includeDeleted: true }),
1838
1806
  });
1839
1807
  if (covers)
1840
1808
  result.push(r);
@@ -1843,7 +1811,7 @@ export class Stack {
1843
1811
  }
1844
1812
  /**
1845
1813
  * The inverse of grantType(): soft-deletes the _grant records on
1846
- * `typeOrBaseId`'s family matching `grant`, at the same granularity
1814
+ * `baseId`'s family matching `grant`, at the same granularity
1847
1815
  * grantType() writes — the grantee is matched whole, role included, and
1848
1816
  * the actions exactly. A soft delete like any other — the owner can
1849
1817
  * undelete a revocation.
@@ -1854,10 +1822,13 @@ export class Stack {
1854
1822
  * revocation has to stay safe. See
1855
1823
  * docs/spec/access-control.md § Listing and revoking.
1856
1824
  */
1857
- async revokeType(typeOrBaseId, grant) {
1825
+ async revokeType(baseId, grant) {
1858
1826
  this.assertOpen();
1859
1827
  validateGrantTarget(grant.grantee);
1860
- const familyId = baseIdOf(typeOrBaseId);
1828
+ const problem = familyIdProblem(baseId, 'revokeType');
1829
+ if (problem)
1830
+ throw new StackValidationError([{ path: 'baseId', message: problem }]);
1831
+ const familyId = baseId;
1861
1832
  const actionSet = new Set(grant.actions);
1862
1833
  const all = await loadGrantRecords((q) => this.query(q));
1863
1834
  const matches = all.filter((r) => {
@@ -1881,29 +1852,22 @@ export class Stack {
1881
1852
  // Private helpers
1882
1853
  // -------------------------------------------------------
1883
1854
  /**
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.
1855
+ * Actions must be known GrantAction values; the target is held to the
1856
+ * same rule every `_grant` write meets — see validateGrantBaseId().
1887
1857
  */
1888
- checkGrantValid(typeId, actions) {
1858
+ checkGrantValid(baseId, actions) {
1889
1859
  const errors = [];
1890
1860
  actions.forEach((action, j) => {
1891
1861
  if (!GRANT_ACTION_SET.has(action)) {
1892
1862
  errors.push({ path: `actions[${j}]`, message: `Unknown grant action "${action}"` });
1893
1863
  }
1894
1864
  });
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
- });
1865
+ const problem = familyIdProblem(baseId, 'grantType');
1866
+ if (problem) {
1867
+ errors.push({ path: 'baseId', message: problem });
1900
1868
  }
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
- });
1869
+ else {
1870
+ errors.push(...validateGrantBaseId(`${SYSTEM_TYPES.GRANT}@1`, { baseId }));
1907
1871
  }
1908
1872
  actions.forEach((action, j) => {
1909
1873
  if (grantConveys(actions, action))
@@ -1913,7 +1877,7 @@ export class Stack {
1913
1877
  return;
1914
1878
  errors.push({
1915
1879
  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`,
1880
+ 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
1881
  });
1918
1882
  });
1919
1883
  if (errors.length > 0) {
@@ -1962,7 +1926,7 @@ export class Stack {
1962
1926
  id: `${SYSTEM_TYPES.GRANT}@1`,
1963
1927
  name: 'Grant',
1964
1928
  schema: {
1965
- typeId: { kind: 'string', required: true },
1929
+ baseId: { kind: 'string', required: true },
1966
1930
  actions: { kind: 'array', items: { kind: 'string' }, required: true },
1967
1931
  // Required, and closed: a Grant's reach is spelled by its `grantee`,
1968
1932
  // so a record arriving without one is refused here rather than read