@haverstack/core 0.33.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.
- package/README.md +1 -1
- package/dist/access.d.ts +39 -34
- package/dist/access.d.ts.map +1 -1
- package/dist/access.js +94 -68
- package/dist/access.js.map +1 -1
- package/dist/changes.d.ts +74 -58
- package/dist/changes.d.ts.map +1 -1
- package/dist/changes.js +115 -101
- package/dist/changes.js.map +1 -1
- package/dist/combine.d.ts.map +1 -1
- package/dist/combine.js +7 -0
- package/dist/combine.js.map +1 -1
- package/dist/grants.d.ts +78 -33
- package/dist/grants.d.ts.map +1 -1
- package/dist/grants.js +164 -72
- package/dist/grants.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/query-validation.d.ts +30 -1
- package/dist/query-validation.d.ts.map +1 -1
- package/dist/query-validation.js +128 -2
- package/dist/query-validation.js.map +1 -1
- package/dist/record-changes.d.ts +77 -57
- package/dist/record-changes.d.ts.map +1 -1
- package/dist/record-changes.js +153 -89
- package/dist/record-changes.js.map +1 -1
- package/dist/scoped-stack.d.ts +120 -120
- package/dist/scoped-stack.d.ts.map +1 -1
- package/dist/scoped-stack.js +210 -190
- package/dist/scoped-stack.js.map +1 -1
- package/dist/stack.d.ts +178 -168
- package/dist/stack.d.ts.map +1 -1
- package/dist/stack.js +346 -277
- package/dist/stack.js.map +1 -1
- package/dist/testing.d.ts +16 -5
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +124 -52
- package/dist/testing.js.map +1 -1
- package/dist/types.d.ts +155 -113
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +0 -2
- package/dist/validate.js.map +1 -1
- package/dist/wire-record.d.ts.map +1 -1
- package/dist/wire-record.js.map +1 -1
- package/package.json +1 -1
package/dist/stack.js
CHANGED
|
@@ -19,16 +19,16 @@ import { validateContent, validateContentKeys, validatePatchValues, validateRese
|
|
|
19
19
|
import { applyMergePatch } from './merge.js';
|
|
20
20
|
import { hasGroupAdmin, isGroupAdminAssociation, validatePermissions } from './access.js';
|
|
21
21
|
import { compareRecordedAttachments } from './attachment-download.js';
|
|
22
|
-
import { ChangeEmitter, RelayDelivery,
|
|
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';
|
|
30
30
|
import { queryAllPages, findFirstMatch, lookupEntityByDid, MAX_QUERY_LIMIT, } from './stack-reads.js';
|
|
31
|
-
import { associationDelta, associationEqual, associationIdentical, assertNonEmptyChangeSet, changeSetOps, bumpsVersion, takesIfVersion, effectiveChanges, stampGroupAdmin,
|
|
31
|
+
import { associationDelta, associationEqual, associationIdentical, assertNonEmptyChangeSet, changeSetOps, bumpsVersion, takesIfVersion, effectiveChanges, stampGroupAdmin, isGroupRecord, } from './record-changes.js';
|
|
32
32
|
import { ScopedStack } from './scoped-stack.js';
|
|
33
33
|
// -------------------------------------------------------
|
|
34
34
|
// Supporting types
|
|
@@ -97,8 +97,8 @@ export class Stack {
|
|
|
97
97
|
* and nothing about work they deferred. A handler cannot fail the write:
|
|
98
98
|
* there is nothing left to fail. See docs/spec/events.md § Handlers.
|
|
99
99
|
*/
|
|
100
|
-
|
|
101
|
-
this.changes.emit(
|
|
100
|
+
announce(change, record, at) {
|
|
101
|
+
this.changes.emit(change.emission(record, at));
|
|
102
102
|
}
|
|
103
103
|
/**
|
|
104
104
|
* The actor for a `create` journal entry, read off the record as just
|
|
@@ -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
|
-
//
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
// (
|
|
442
|
-
//
|
|
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
|
|
479
|
-
//
|
|
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
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
//
|
|
498
|
-
//
|
|
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
|
|
509
|
-
//
|
|
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
|
|
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
|
-
//
|
|
529
|
-
//
|
|
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 }),
|
|
@@ -542,10 +535,9 @@ export class Stack {
|
|
|
542
535
|
...(associations?.length && { associations }),
|
|
543
536
|
...(opts.unlisted && { unlistedAt: createdAt }),
|
|
544
537
|
};
|
|
545
|
-
const
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
this.emitChange('create', created);
|
|
538
|
+
const change = new PendingChange('create', { actor: Stack.createActor(record) });
|
|
539
|
+
const created = await this.adapter.createRecord(record, { journal: change.journal });
|
|
540
|
+
this.announce(change, created);
|
|
549
541
|
return created;
|
|
550
542
|
}
|
|
551
543
|
/**
|
|
@@ -589,20 +581,14 @@ export class Stack {
|
|
|
589
581
|
return opts.presentAt === 'latest' ? this.presentAtLatest(record) : record;
|
|
590
582
|
}
|
|
591
583
|
/**
|
|
592
|
-
* Apply a change set —
|
|
593
|
-
*
|
|
594
|
-
*
|
|
595
|
-
* `unlisted: false` and `parentId: null` are changes.
|
|
596
|
-
*
|
|
597
|
-
* Every key present is checked against the record as it stands, and a
|
|
598
|
-
* change set already satisfied in all of them writes nothing and returns
|
|
599
|
-
* the record unchanged. The ops the change event carries are derived
|
|
600
|
-
* from that same comparison, so naming an aspect without moving it is
|
|
601
|
-
* never reported as moving it.
|
|
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.
|
|
602
587
|
*
|
|
603
|
-
*
|
|
604
|
-
*
|
|
605
|
-
*
|
|
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.
|
|
606
592
|
*/
|
|
607
593
|
async mutate(id, changes, opts = {}) {
|
|
608
594
|
this.assertOpen();
|
|
@@ -625,19 +611,32 @@ export class Stack {
|
|
|
625
611
|
// writes nothing.
|
|
626
612
|
if (ops.length === 0)
|
|
627
613
|
return existing;
|
|
628
|
-
// A change set
|
|
629
|
-
// rule associate()/dissociate() follow
|
|
630
|
-
// travels as an explicit opt rather than a
|
|
631
|
-
// non-bumping write never touches
|
|
632
|
-
// 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.
|
|
633
619
|
const bumps = bumpsVersion(ops);
|
|
634
620
|
// Computed against the same before/after changeSetOps compared, so
|
|
635
|
-
// whether associate/dissociate appear in `ops` and what
|
|
636
|
-
// never disagree.
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
+
];
|
|
640
634
|
const previousParentId = existing.parentId ?? null;
|
|
635
|
+
const change = new PendingChange(ops, {
|
|
636
|
+
actor: Stack.actorFrom(opts),
|
|
637
|
+
...(ops.includes('reparent') && { previousParentId }),
|
|
638
|
+
...(assocDelta.length && { associations: assocDelta }),
|
|
639
|
+
});
|
|
641
640
|
const updated = await this.adapter.mutateRecord(id, effectiveChanges(changes, ops), {
|
|
642
641
|
...(bumps
|
|
643
642
|
? this.writeOptions(existing, opts)
|
|
@@ -646,22 +645,9 @@ export class Stack {
|
|
|
646
645
|
updatedVia: opts.updatedVia,
|
|
647
646
|
}),
|
|
648
647
|
bumpsVersion: bumps,
|
|
649
|
-
journal:
|
|
650
|
-
actor: Stack.actorFrom(opts),
|
|
651
|
-
...(ops.includes('reparent') && { previousParentId }),
|
|
652
|
-
...(assocDelta && {
|
|
653
|
-
associationsAdded: assocDelta.added,
|
|
654
|
-
associationsRemoved: assocDelta.removed,
|
|
655
|
-
associationsReplaced: assocDelta.replaced,
|
|
656
|
-
}),
|
|
657
|
-
}),
|
|
658
|
-
});
|
|
659
|
-
this.emitChange(ops, updated, {
|
|
660
|
-
...(ops.includes('reparent') && { previousParentId }),
|
|
661
|
-
...(!bumps && { actor: Stack.actorFrom(opts) }),
|
|
662
|
-
...(assocDelta?.added.length && { associationsAdded: assocDelta.added }),
|
|
663
|
-
...(assocDelta?.removed.length && { associationsRemoved: assocDelta.removed }),
|
|
648
|
+
journal: change.journal,
|
|
664
649
|
});
|
|
650
|
+
this.announce(change, updated);
|
|
665
651
|
return updated;
|
|
666
652
|
}
|
|
667
653
|
/**
|
|
@@ -685,6 +671,15 @@ export class Stack {
|
|
|
685
671
|
*/
|
|
686
672
|
async validateChangeSet(id, existing, changes) {
|
|
687
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');
|
|
688
683
|
const errors = [
|
|
689
684
|
...(contentPatch
|
|
690
685
|
? [
|
|
@@ -693,7 +688,9 @@ export class Stack {
|
|
|
693
688
|
...validateContentKeys(contentPatch),
|
|
694
689
|
]
|
|
695
690
|
: []),
|
|
696
|
-
...(permissions
|
|
691
|
+
...(permissions
|
|
692
|
+
? [...validateAssociations(permissions, 'permissions'), ...validatePermissions(permissions)]
|
|
693
|
+
: []),
|
|
697
694
|
...(associations ? validateAssociations(associations) : []),
|
|
698
695
|
];
|
|
699
696
|
if (errors.length > 0)
|
|
@@ -709,7 +706,10 @@ export class Stack {
|
|
|
709
706
|
throw new StackQueryError(`Unknown type: "${existing.typeId}"`);
|
|
710
707
|
}
|
|
711
708
|
merged = applyMergePatch(existing.content, contentPatch);
|
|
712
|
-
const contentErrors =
|
|
709
|
+
const contentErrors = [
|
|
710
|
+
...validateContent(merged, type.schema),
|
|
711
|
+
...validateGrantee(existing.typeId, merged),
|
|
712
|
+
];
|
|
713
713
|
if (contentErrors.length > 0)
|
|
714
714
|
throw new StackValidationError(contentErrors);
|
|
715
715
|
if (existing.typeId === `${SYSTEM_TYPES.ATTACHMENT}@1`) {
|
|
@@ -736,22 +736,15 @@ export class Stack {
|
|
|
736
736
|
return merged;
|
|
737
737
|
}
|
|
738
738
|
/**
|
|
739
|
-
* Refuse a write that would leave a `_group` Record with no `admin` on
|
|
740
|
-
*
|
|
741
|
-
*
|
|
742
|
-
*
|
|
743
|
-
*
|
|
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.
|
|
744
744
|
*
|
|
745
|
-
*
|
|
746
|
-
*
|
|
747
|
-
*
|
|
748
|
-
*
|
|
749
|
-
* Lives here rather than in `ScopedStack` deliberately. Who may write a
|
|
750
|
-
* `_group` is a permission question and belongs to the gate up there;
|
|
751
|
-
* this is an integrity constraint on the Record, so it binds every
|
|
752
|
-
* requester — the stack owner included, who would otherwise be the hole
|
|
753
|
-
* that stops anything downstream relying on the invariant.
|
|
754
|
-
* 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.
|
|
755
748
|
*/
|
|
756
749
|
assertGroupAdminRemains(record, next) {
|
|
757
750
|
if (!isGroupRecord(record))
|
|
@@ -762,24 +755,19 @@ export class Stack {
|
|
|
762
755
|
'least one `admin` relationship association. Name the incoming admin in the same write.');
|
|
763
756
|
}
|
|
764
757
|
/**
|
|
765
|
-
* Add an association to a record
|
|
766
|
-
*
|
|
767
|
-
* order, so it needs neither the OCC `ifVersion` guards content,
|
|
768
|
-
* `parentId` and `permissions`, nor the rollback history that guards.
|
|
769
|
-
* 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.
|
|
770
760
|
*
|
|
771
761
|
* An association the record already holds, saying the same thing, is a
|
|
772
762
|
* no-op; one matching an existing association's identity but naming a
|
|
773
763
|
* different `attachmentRecordId` — or none, which clears the one stored —
|
|
774
|
-
* re-points
|
|
775
|
-
*
|
|
776
|
-
*
|
|
777
|
-
* old `attachmentRecordId` is not reported anywhere; nothing keeps it
|
|
778
|
-
* once this call lands. See docs/spec/events.md § The event shape.
|
|
779
|
-
* Returns the record as it now stands — unchanged on a 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.
|
|
780
767
|
*/
|
|
781
768
|
async associate(id, association, opts = {}) {
|
|
782
769
|
this.assertOpen();
|
|
770
|
+
assertDataAssociations([association], 'associate()');
|
|
783
771
|
const errors = validateAssociation(association);
|
|
784
772
|
if (errors.length > 0)
|
|
785
773
|
throw new StackValidationError(errors);
|
|
@@ -792,32 +780,28 @@ export class Stack {
|
|
|
792
780
|
}
|
|
793
781
|
const replaced = (existing.associations ?? []).find((a) => associationEqual(a, association));
|
|
794
782
|
await this.checkAttachmentAssociationPointers([association]);
|
|
795
|
-
const
|
|
796
|
-
journal: buildJournalEntry('associate', {
|
|
797
|
-
actor: Stack.actorFrom(opts),
|
|
798
|
-
associationsAdded: [association],
|
|
799
|
-
// The association this call overwrites in place, if any. Nothing
|
|
800
|
-
// else retains the attachmentRecordId it carried.
|
|
801
|
-
...(replaced && { associationsReplaced: [replaced] }),
|
|
802
|
-
}),
|
|
803
|
-
});
|
|
804
|
-
this.emitChange('associate', updated, {
|
|
783
|
+
const change = new PendingChange('associate', {
|
|
805
784
|
actor: Stack.actorFrom(opts),
|
|
806
|
-
|
|
785
|
+
// A re-point carries what it overwrote: nothing else retains the
|
|
786
|
+
// attachmentRecordId that association held.
|
|
787
|
+
associations: [
|
|
788
|
+
replaced ? { op: 'repoint', association, previous: replaced } : { op: 'add', association },
|
|
789
|
+
],
|
|
807
790
|
});
|
|
791
|
+
const updated = await this.adapter.associate(id, association, { journal: change.journal });
|
|
792
|
+
this.announce(change, updated);
|
|
808
793
|
return updated;
|
|
809
794
|
}
|
|
810
795
|
/**
|
|
811
|
-
* Remove an association from a record.
|
|
812
|
-
*
|
|
813
|
-
*
|
|
814
|
-
*
|
|
815
|
-
*
|
|
816
|
-
* docs/spec/events.md § The event shape. Returns the record as it now
|
|
817
|
-
* 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.
|
|
818
801
|
*/
|
|
819
802
|
async dissociate(id, association, opts = {}) {
|
|
820
803
|
this.assertOpen();
|
|
804
|
+
assertDataAssociations([association], 'dissociate()');
|
|
821
805
|
const errors = validateAssociation(association);
|
|
822
806
|
if (errors.length > 0)
|
|
823
807
|
throw new StackValidationError(errors);
|
|
@@ -836,18 +820,84 @@ export class Stack {
|
|
|
836
820
|
if (isGroupAdminAssociation(association)) {
|
|
837
821
|
this.assertGroupAdminRemains(existing, (existing.associations ?? []).filter((a) => !associationEqual(a, association)));
|
|
838
822
|
}
|
|
839
|
-
const
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
823
|
+
const change = new PendingChange('dissociate', {
|
|
824
|
+
actor: Stack.actorFrom(opts),
|
|
825
|
+
// In full, annotation included — what makes a removal as undoable
|
|
826
|
+
// from the log as a re-point is. The frame this becomes still names
|
|
827
|
+
// identity only. See docs/spec/journal.md § The entry.
|
|
828
|
+
associations: [{ op: 'remove', previous: matched }],
|
|
844
829
|
});
|
|
845
|
-
this.
|
|
830
|
+
const updated = await this.adapter.dissociate(id, association, { journal: change.journal });
|
|
831
|
+
this.announce(change, updated);
|
|
832
|
+
return updated;
|
|
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', {
|
|
846
878
|
actor: Stack.actorFrom(opts),
|
|
847
|
-
|
|
879
|
+
associations: [{ op: 'remove', previous: matched }],
|
|
848
880
|
});
|
|
881
|
+
const updated = await this.adapter.dissociate(id, permission, { journal: change.journal });
|
|
882
|
+
this.announce(change, updated);
|
|
849
883
|
return updated;
|
|
850
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
|
+
}
|
|
851
901
|
/**
|
|
852
902
|
* The checks a *caller-named* destination owes, before the cycle walk:
|
|
853
903
|
* `parentId` is a real, well-formed record id. Format first, so a
|
|
@@ -868,24 +918,18 @@ export class Stack {
|
|
|
868
918
|
}
|
|
869
919
|
/**
|
|
870
920
|
* Refuse an edge that would make a record its own ancestor — asked at
|
|
871
|
-
* every site that adds one
|
|
872
|
-
*
|
|
873
|
-
* downstream (a generator deriving a page path, a folder view) is
|
|
874
|
-
* 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.
|
|
875
923
|
*
|
|
876
|
-
* Walks with the unscoped adapter
|
|
877
|
-
*
|
|
878
|
-
*
|
|
879
|
-
* 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.
|
|
880
927
|
*
|
|
881
928
|
* Read-then-write, so two moves racing on opposite ends of one chain can
|
|
882
|
-
* both pass —
|
|
883
|
-
*
|
|
884
|
-
*
|
|
885
|
-
*
|
|
886
|
-
* carry a visited set rather than trust this alone; MAX_PARENT_DEPTH is
|
|
887
|
-
* the same posture applied here.
|
|
888
|
-
* 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.
|
|
889
933
|
*/
|
|
890
934
|
async assertNoParentCycle(id, parentId) {
|
|
891
935
|
let cursor = parentId;
|
|
@@ -909,6 +953,12 @@ export class Stack {
|
|
|
909
953
|
* version; a no-op if already deleted. `_config` is never deletable
|
|
910
954
|
* (docs/spec.md § The `_config` record). See docs/spec/versioning.md
|
|
911
955
|
* § Deletion.
|
|
956
|
+
*
|
|
957
|
+
* A purge reports the files the record referenced. It deletes none of
|
|
958
|
+
* them — byte lifetime is not the purged record's to decide — but it
|
|
959
|
+
* destroys the only rows naming them, so a caller who means to erase the
|
|
960
|
+
* bytes too has nowhere else to read the argument from afterwards. See
|
|
961
|
+
* docs/spec/attachments.md § A purge strands the bytes it referenced.
|
|
912
962
|
*/
|
|
913
963
|
async delete(id, opts = {}) {
|
|
914
964
|
this.assertOpen();
|
|
@@ -920,27 +970,48 @@ export class Stack {
|
|
|
920
970
|
// write: a read here instead would race the delete, and afterwards
|
|
921
971
|
// there is nothing left to read. Null means there was no record, so
|
|
922
972
|
// nothing was purged and nothing is announced.
|
|
973
|
+
const change = new PendingChange('hard-delete', { actor: Stack.actorFrom(opts) });
|
|
923
974
|
const purged = await this.adapter.deleteRecord(id, {
|
|
924
975
|
hard: true,
|
|
925
976
|
expectedVersion: opts.ifVersion,
|
|
977
|
+
journal: change.journal,
|
|
926
978
|
});
|
|
927
|
-
if (purged)
|
|
928
|
-
|
|
929
|
-
|
|
979
|
+
if (!purged)
|
|
980
|
+
return { referencedFileIds: [] };
|
|
981
|
+
this.announce(change, purged);
|
|
982
|
+
return { referencedFileIds: await this.referencedFileIds(purged) };
|
|
930
983
|
}
|
|
931
984
|
const existing = await this.adapter.getRecord(id);
|
|
932
985
|
if (!existing) {
|
|
933
986
|
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
934
987
|
}
|
|
935
988
|
this.checkIfVersion(existing, opts.ifVersion);
|
|
989
|
+
// A tombstone's references stand — undelete() must find its
|
|
990
|
+
// attachments intact — so nothing is stranded and nothing is reported.
|
|
936
991
|
if (existing.deletedAt)
|
|
937
|
-
return;
|
|
992
|
+
return { referencedFileIds: [] };
|
|
993
|
+
const change = new PendingChange('delete', { actor: Stack.actorFrom(opts) });
|
|
938
994
|
const deleted = await this.adapter.deleteRecord(id, {
|
|
939
995
|
...this.writeOptions(existing, opts),
|
|
940
|
-
journal:
|
|
996
|
+
journal: change.journal,
|
|
941
997
|
});
|
|
942
998
|
if (deleted)
|
|
943
|
-
this.
|
|
999
|
+
this.announce(change, deleted);
|
|
1000
|
+
return { referencedFileIds: [] };
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* The files a record references, by the same definition
|
|
1004
|
+
* deleteAttachment() and the garbage sweep use: attachment associations
|
|
1005
|
+
* and top-level `file-ref` content fields. An `_attachment` record's own
|
|
1006
|
+
* `fileId` is a plain string by design and is not one, which is why
|
|
1007
|
+
* purging a metadata record reports nothing — see initSystemTypes().
|
|
1008
|
+
*/
|
|
1009
|
+
async referencedFileIds(record) {
|
|
1010
|
+
const fromAssociations = (record.associations ?? []).flatMap((a) => a.kind === 'attachment' ? [a.fileId] : []);
|
|
1011
|
+
const type = await this.getType(record.typeId);
|
|
1012
|
+
const content = record.content;
|
|
1013
|
+
const fromContent = Object.entries(type?.schema ?? {}).flatMap(([field, def]) => def.kind === 'file-ref' && typeof content[field] === 'string' ? [content[field]] : []);
|
|
1014
|
+
return [...new Set([...fromAssociations, ...fromContent])];
|
|
944
1015
|
}
|
|
945
1016
|
/**
|
|
946
1017
|
* Reverse a soft delete. Idempotent — undeleting a record that isn't
|
|
@@ -957,11 +1028,12 @@ export class Stack {
|
|
|
957
1028
|
this.checkIfVersion(existing, opts.ifVersion);
|
|
958
1029
|
if (!existing.deletedAt)
|
|
959
1030
|
return existing;
|
|
1031
|
+
const change = new PendingChange('undelete', { actor: Stack.actorFrom(opts) });
|
|
960
1032
|
const undeleted = await this.adapter.undeleteRecord(id, {
|
|
961
1033
|
...this.writeOptions(existing, opts),
|
|
962
|
-
journal:
|
|
1034
|
+
journal: change.journal,
|
|
963
1035
|
});
|
|
964
|
-
this.
|
|
1036
|
+
this.announce(change, undeleted);
|
|
965
1037
|
return undeleted;
|
|
966
1038
|
}
|
|
967
1039
|
/**
|
|
@@ -1019,18 +1091,13 @@ export class Stack {
|
|
|
1019
1091
|
return this.adapter.getVersions(id);
|
|
1020
1092
|
}
|
|
1021
1093
|
/**
|
|
1022
|
-
* A record's change journal, oldest first — what moved, who moved it,
|
|
1023
|
-
*
|
|
1024
|
-
* surface
|
|
1025
|
-
* changed what, gated on current read access, would make a record's past
|
|
1026
|
-
* exactly as reachable as its present. `ScopedStack` applies that gate;
|
|
1027
|
-
* 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.
|
|
1028
1097
|
*
|
|
1029
|
-
*
|
|
1030
|
-
*
|
|
1031
|
-
*
|
|
1032
|
-
* history cannot tell them apart. The refusal is the adapter's own, so
|
|
1033
|
-
* it can name why. See docs/spec/versioning.md § The change journal.
|
|
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.
|
|
1100
|
+
* See docs/spec/journal.md § Reading it.
|
|
1034
1101
|
*/
|
|
1035
1102
|
async getJournal(id, query = {}) {
|
|
1036
1103
|
this.assertOpen();
|
|
@@ -1044,10 +1111,8 @@ export class Stack {
|
|
|
1044
1111
|
/**
|
|
1045
1112
|
* Restore a record to a previous version by creating a new version —
|
|
1046
1113
|
* never rewrites history. The snapshot is validated against its own
|
|
1047
|
-
* stored typeId
|
|
1048
|
-
*
|
|
1049
|
-
* containment), and restores neither associations nor permissions. See
|
|
1050
|
-
* 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.
|
|
1051
1116
|
*/
|
|
1052
1117
|
async restoreVersion(id, version, opts = {}) {
|
|
1053
1118
|
this.assertOpen();
|
|
@@ -1064,7 +1129,10 @@ export class Stack {
|
|
|
1064
1129
|
if (!type) {
|
|
1065
1130
|
throw new StackQueryError(`Unknown type: "${target.typeId}"`);
|
|
1066
1131
|
}
|
|
1067
|
-
const errors =
|
|
1132
|
+
const errors = [
|
|
1133
|
+
...validateContent(target.content, type.schema),
|
|
1134
|
+
...validateGrantee(target.typeId, target.content),
|
|
1135
|
+
];
|
|
1068
1136
|
if (errors.length > 0) {
|
|
1069
1137
|
throw new StackValidationError(errors);
|
|
1070
1138
|
}
|
|
@@ -1072,60 +1140,34 @@ export class Stack {
|
|
|
1072
1140
|
this.checkConfigEntityIdUnchanged(existing.content.entityId, target.content.entityId);
|
|
1073
1141
|
}
|
|
1074
1142
|
// Restoring is a write like any other, so it owes the same immutability
|
|
1075
|
-
// check a content patch pays
|
|
1076
|
-
// would otherwise move the binding by rolling content back.
|
|
1077
|
-
// needs
|
|
1078
|
-
//
|
|
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.
|
|
1079
1147
|
for (const field of bindingFieldsOf(baseIdOf(target.typeId))) {
|
|
1080
1148
|
this.checkBindingImmutable(baseIdOf(target.typeId), field, existing.content[field], target.content[field]);
|
|
1081
1149
|
}
|
|
1082
|
-
// A restore
|
|
1083
|
-
//
|
|
1084
|
-
//
|
|
1085
|
-
|
|
1086
|
-
// naming a destination, it is history being put back, and the container
|
|
1087
|
-
// may have been hard-deleted since. Refusing here would make an
|
|
1088
|
-
// unrelated deletion cost the record its content rollback, which is the
|
|
1089
|
-
// recoverability this whole verb exists for. A dangling parentId is
|
|
1090
|
-
// legal at rest anyway — deleting a container never touches its
|
|
1091
|
-
// children. See docs/spec/versioning.md § Restore semantics.
|
|
1092
|
-
const previousParentId = existing.parentId ?? null;
|
|
1093
|
-
const targetParentId = target.parentId ?? null;
|
|
1094
|
-
const moves = targetParentId !== previousParentId;
|
|
1095
|
-
if (moves && targetParentId !== null) {
|
|
1096
|
-
await this.assertNoParentCycle(id, targetParentId);
|
|
1097
|
-
}
|
|
1098
|
-
// Associations never restore, for any Record — a snapshot never
|
|
1099
|
-
// carries them, so there's nothing here for the adapter to roll back.
|
|
1100
|
-
// See docs/spec/versioning.md § Restore semantics.
|
|
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) });
|
|
1101
1154
|
const restored = await this.adapter.restoreVersion(id, version, {
|
|
1102
1155
|
...this.writeOptions(existing, opts),
|
|
1103
|
-
journal:
|
|
1104
|
-
actor: Stack.actorFrom(opts),
|
|
1105
|
-
...(moves && { previousParentId }),
|
|
1106
|
-
}),
|
|
1156
|
+
journal: change.journal,
|
|
1107
1157
|
});
|
|
1108
|
-
this.
|
|
1158
|
+
this.announce(change, restored);
|
|
1109
1159
|
return restored;
|
|
1110
1160
|
}
|
|
1111
1161
|
/**
|
|
1112
|
-
* Commit a per-record migration
|
|
1113
|
-
*
|
|
1114
|
-
*
|
|
1115
|
-
*
|
|
1116
|
-
* (computed client-side by the type's owning app, per
|
|
1117
|
-
* docs/spec/wire-format.md § Migration commit) rather than a registered
|
|
1118
|
-
* Migration function. Snapshots the prior state to version history, same
|
|
1119
|
-
* as mutate()/restoreVersion(), and takes the same optional `ifVersion`
|
|
1120
|
-
* precondition every version-bumping mutation takes — checked atomically
|
|
1121
|
-
* at the adapter, not here (see docs/spec/versioning.md § Optimistic
|
|
1122
|
-
* 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.
|
|
1123
1166
|
*
|
|
1124
1167
|
* Because `content` is a full replacement written under a new `typeId`,
|
|
1125
1168
|
* this is create-shaped at the destination *and* update-shaped over the
|
|
1126
|
-
* record as it stands, so it owes both sets of integrity checks
|
|
1127
|
-
*
|
|
1128
|
-
* 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
|
|
1129
1171
|
* state create()/mutate() refuse to reach.
|
|
1130
1172
|
*/
|
|
1131
1173
|
async commitMigration(id, toTypeId, content, opts = {}) {
|
|
@@ -1162,6 +1204,7 @@ export class Stack {
|
|
|
1162
1204
|
...validateReservedKeys(content),
|
|
1163
1205
|
...validateContentKeys(content),
|
|
1164
1206
|
...validateContent(content, type.schema),
|
|
1207
|
+
...validateGrantee(toTypeId, content),
|
|
1165
1208
|
];
|
|
1166
1209
|
if (errors.length > 0) {
|
|
1167
1210
|
throw new StackValidationError(errors);
|
|
@@ -1196,11 +1239,12 @@ export class Stack {
|
|
|
1196
1239
|
if (id === SYSTEM_TYPES.CONFIG) {
|
|
1197
1240
|
this.checkConfigEntityIdUnchanged(existing.content.entityId, content.entityId);
|
|
1198
1241
|
}
|
|
1242
|
+
const change = new PendingChange('migrate', { actor: Stack.actorFrom(opts) });
|
|
1199
1243
|
const migrated = await this.adapter.commitMigration(id, toTypeId, content, {
|
|
1200
1244
|
...this.writeOptions(existing, opts),
|
|
1201
|
-
journal:
|
|
1245
|
+
journal: change.journal,
|
|
1202
1246
|
});
|
|
1203
|
-
this.
|
|
1247
|
+
this.announce(change, migrated);
|
|
1204
1248
|
return migrated;
|
|
1205
1249
|
}
|
|
1206
1250
|
/** Uniqueness for every unique binding field a newly created card claims. */
|
|
@@ -1229,18 +1273,14 @@ export class Stack {
|
|
|
1229
1273
|
}
|
|
1230
1274
|
}
|
|
1231
1275
|
/**
|
|
1232
|
-
* Bindings across a migration. `content` is a full replacement
|
|
1233
|
-
*
|
|
1234
|
-
*
|
|
1235
|
-
*
|
|
1236
|
-
*
|
|
1237
|
-
* families' binding fields, so a card can neither shed its DID by
|
|
1238
|
-
* 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.
|
|
1239
1281
|
*
|
|
1240
|
-
* Uniqueness is asked only of the destination family,
|
|
1241
|
-
* record
|
|
1242
|
-
* itself — re-sending the value it already holds claims nothing.
|
|
1243
|
-
* 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.
|
|
1244
1284
|
*/
|
|
1245
1285
|
async checkBindingsOnMigrate(fromTypeId, toTypeId, id, existing, content) {
|
|
1246
1286
|
const fromFamily = baseIdOf(fromTypeId);
|
|
@@ -1264,16 +1304,10 @@ export class Stack {
|
|
|
1264
1304
|
* claiming one value would leave that lookup without a single answer, and
|
|
1265
1305
|
* ambiguity is all an impersonating card needs. Enforced here rather than
|
|
1266
1306
|
* by schema, since uniqueness is a property of the set, not of the value.
|
|
1267
|
-
* Called by the paths that can introduce a binding: create and update.
|
|
1268
1307
|
*
|
|
1269
|
-
*
|
|
1270
|
-
*
|
|
1271
|
-
*
|
|
1272
|
-
*
|
|
1273
|
-
* Read-then-write, so two creates racing on one value can both pass.
|
|
1274
|
-
* Closing that properly means a unique index over a JSON field, which
|
|
1275
|
-
* each adapter would enforce separately — a decision about where
|
|
1276
|
-
* 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.
|
|
1277
1311
|
* See docs/spec/identity.md § DID bindings.
|
|
1278
1312
|
*/
|
|
1279
1313
|
async checkBindingUnique(family, field, value, excludeId) {
|
|
@@ -1437,7 +1471,11 @@ export class Stack {
|
|
|
1437
1471
|
// The metadata record is written inside the adapter, so create()
|
|
1438
1472
|
// never sees it and this is the only place it can be announced.
|
|
1439
1473
|
const record = await this.adapter.putAttachmentWithMetadata(data, mimeType, filename, appId);
|
|
1440
|
-
|
|
1474
|
+
// The one emission with no journal half of its own: the far side
|
|
1475
|
+
// wrote the record, so it appended the entry in that same write —
|
|
1476
|
+
// the same division saveVersion() follows over this adapter. See
|
|
1477
|
+
// docs/spec/journal.md § The entry set is the event set.
|
|
1478
|
+
this.announce(new PendingChange('create'), record);
|
|
1441
1479
|
return record;
|
|
1442
1480
|
}
|
|
1443
1481
|
const fileId = await this.adapter.putAttachment(data);
|
|
@@ -1503,7 +1541,7 @@ export class Stack {
|
|
|
1503
1541
|
deletedRecords = await this.adapter.deleteUnreferencedAttachmentRecords(fileId, await this.attachmentTypeIds());
|
|
1504
1542
|
const at = new Date();
|
|
1505
1543
|
for (const record of deletedRecords) {
|
|
1506
|
-
this.
|
|
1544
|
+
this.announce(new PendingChange('hard-delete', { actor: Stack.actorFrom(opts) }), record, at);
|
|
1507
1545
|
}
|
|
1508
1546
|
}
|
|
1509
1547
|
else {
|
|
@@ -1724,9 +1762,10 @@ export class Stack {
|
|
|
1724
1762
|
// -------------------------------------------------------
|
|
1725
1763
|
/**
|
|
1726
1764
|
* Create _grant records authorizing entities to act on records of
|
|
1727
|
-
* specific types
|
|
1728
|
-
*
|
|
1729
|
-
*
|
|
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.
|
|
1730
1769
|
*
|
|
1731
1770
|
* Granting an **app** a `-own` action does not contain it the way the
|
|
1732
1771
|
* suffix suggests: when that app acts for someone, `-own` is read as the
|
|
@@ -1736,9 +1775,8 @@ export class Stack {
|
|
|
1736
1775
|
* needs, not the suffix that looks narrowest. See
|
|
1737
1776
|
* docs/spec/access-control.md § Delegation: principal and subject.
|
|
1738
1777
|
*
|
|
1739
|
-
* The grantee lives in content.
|
|
1740
|
-
*
|
|
1741
|
-
* grants.
|
|
1778
|
+
* The grantee lives in content.grantee, not record.entityId. See
|
|
1779
|
+
* docs/spec/access-control.md § Type-level grants.
|
|
1742
1780
|
*/
|
|
1743
1781
|
async grant(target, grants) {
|
|
1744
1782
|
this.assertOpen();
|
|
@@ -1749,37 +1787,43 @@ export class Stack {
|
|
|
1749
1787
|
records.push(await this.create(`${SYSTEM_TYPES.GRANT}@1`, {
|
|
1750
1788
|
typeId: g.typeId,
|
|
1751
1789
|
actions: g.actions,
|
|
1752
|
-
|
|
1753
|
-
...(target !== null && typeof target === 'object' && { granteeGroupId: target.groupId }),
|
|
1790
|
+
grantee: target,
|
|
1754
1791
|
}));
|
|
1755
1792
|
}
|
|
1756
1793
|
return records;
|
|
1757
1794
|
}
|
|
1758
1795
|
/**
|
|
1759
|
-
* List _grant records. Omit `
|
|
1760
|
-
*
|
|
1761
|
-
*
|
|
1762
|
-
*
|
|
1763
|
-
*
|
|
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.
|
|
1764
1808
|
*/
|
|
1765
|
-
async listGrants(
|
|
1809
|
+
async listGrants(query) {
|
|
1766
1810
|
this.assertOpen();
|
|
1767
|
-
if (
|
|
1768
|
-
validateGrantTarget(
|
|
1811
|
+
if (query !== undefined)
|
|
1812
|
+
validateGrantTarget(query, true);
|
|
1769
1813
|
const all = await loadGrantRecords((q) => this.query(q));
|
|
1770
|
-
if (
|
|
1814
|
+
if (query === undefined)
|
|
1771
1815
|
return all;
|
|
1772
|
-
if (
|
|
1773
|
-
return all.filter((r) => matchesGrantTarget(r.content,
|
|
1816
|
+
if (query.kind !== 'entity') {
|
|
1817
|
+
return all.filter((r) => matchesGrantTarget(r.content, query));
|
|
1774
1818
|
}
|
|
1775
|
-
//
|
|
1776
|
-
//
|
|
1819
|
+
// An entity query resolves group rosters, since a grant naming a group
|
|
1820
|
+
// the entity belongs to also currently applies to them. Shares
|
|
1777
1821
|
// grantCoversGrantee() with the access checks, so a listing can't
|
|
1778
1822
|
// disagree with them about who a grant covers.
|
|
1779
1823
|
const groupRoles = new Map();
|
|
1780
1824
|
const result = [];
|
|
1781
1825
|
for (const r of all) {
|
|
1782
|
-
const covers = await grantCoversGrantee(r.content,
|
|
1826
|
+
const covers = await grantCoversGrantee(r.content, query.entityId, {
|
|
1783
1827
|
allowDefault: true,
|
|
1784
1828
|
allowGroup: true,
|
|
1785
1829
|
groupRoles,
|
|
@@ -1792,20 +1836,32 @@ export class Stack {
|
|
|
1792
1836
|
}
|
|
1793
1837
|
/**
|
|
1794
1838
|
* The inverse of grant(): soft-deletes _grant records matching `target`
|
|
1795
|
-
*
|
|
1796
|
-
*
|
|
1797
|
-
* 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.
|
|
1798
1848
|
*/
|
|
1799
1849
|
async revoke(target, grants) {
|
|
1800
1850
|
this.assertOpen();
|
|
1801
1851
|
validateGrantTarget(target);
|
|
1852
|
+
const revoked = [];
|
|
1802
1853
|
const all = await loadGrantRecords((q) => this.query(q));
|
|
1803
1854
|
for (const g of grants) {
|
|
1804
1855
|
const familyId = baseIdOf(g.typeId);
|
|
1805
1856
|
const actionSet = new Set(g.actions);
|
|
1806
1857
|
const matches = all.filter((r) => {
|
|
1807
1858
|
const c = r.content;
|
|
1808
|
-
|
|
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)
|
|
1809
1865
|
return false;
|
|
1810
1866
|
if (!matchesGrantTarget(c, target))
|
|
1811
1867
|
return false;
|
|
@@ -1813,8 +1869,10 @@ export class Stack {
|
|
|
1813
1869
|
});
|
|
1814
1870
|
for (const match of matches) {
|
|
1815
1871
|
await this.delete(match.id);
|
|
1872
|
+
revoked.push(match);
|
|
1816
1873
|
}
|
|
1817
1874
|
}
|
|
1875
|
+
return revoked;
|
|
1818
1876
|
}
|
|
1819
1877
|
// -------------------------------------------------------
|
|
1820
1878
|
// Private helpers
|
|
@@ -1892,8 +1950,22 @@ export class Stack {
|
|
|
1892
1950
|
await this.defineType(`${SYSTEM_TYPES.GRANT}@1`, 'Grant', {
|
|
1893
1951
|
typeId: { kind: 'string', required: true },
|
|
1894
1952
|
actions: { kind: 'array', items: { kind: 'string' }, required: true },
|
|
1895
|
-
|
|
1896
|
-
|
|
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
|
+
},
|
|
1897
1969
|
});
|
|
1898
1970
|
await this.defineType(`${SYSTEM_TYPES.ATTACHMENT}@1`, 'Attachment', {
|
|
1899
1971
|
// Deliberately `string`, not `file-ref`: attachmentFileId matching
|
|
@@ -1937,13 +2009,12 @@ export class Stack {
|
|
|
1937
2009
|
}
|
|
1938
2010
|
/**
|
|
1939
2011
|
* Snapshot of a record's prior state, passed with the mutating adapter
|
|
1940
|
-
* call so snapshot and mutation land in one atomic write.
|
|
1941
|
-
*
|
|
1942
|
-
*
|
|
1943
|
-
*
|
|
1944
|
-
*
|
|
1945
|
-
*
|
|
1946
|
-
* 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.
|
|
1947
2018
|
*/
|
|
1948
2019
|
buildVersionSnapshot(record) {
|
|
1949
2020
|
return {
|
|
@@ -1954,8 +2025,6 @@ export class Stack {
|
|
|
1954
2025
|
...(record.entityId && { entityId: record.entityId }),
|
|
1955
2026
|
...(record.updatedBy && { updatedBy: record.updatedBy }),
|
|
1956
2027
|
...(record.updatedVia && { updatedVia: record.updatedVia }),
|
|
1957
|
-
...(record.parentId !== undefined && { parentId: record.parentId }),
|
|
1958
|
-
...(record.permissions && { permissions: record.permissions }),
|
|
1959
2028
|
};
|
|
1960
2029
|
}
|
|
1961
2030
|
}
|