@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.
- package/README.md +79 -16
- package/dist/access.js +1 -1
- package/dist/access.js.map +1 -1
- package/dist/adapter-entry.d.ts +2 -1
- package/dist/adapter-entry.d.ts.map +1 -1
- package/dist/adapter-entry.js +2 -1
- package/dist/adapter-entry.js.map +1 -1
- package/dist/changes.js +3 -3
- package/dist/combine.d.ts.map +1 -1
- package/dist/combine.js +2 -3
- package/dist/combine.js.map +1 -1
- package/dist/errors.d.ts +16 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +23 -2
- package/dist/errors.js.map +1 -1
- package/dist/grants.d.ts +9 -1
- package/dist/grants.d.ts.map +1 -1
- package/dist/grants.js +44 -4
- package/dist/grants.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/query-validation.d.ts +51 -1
- package/dist/query-validation.d.ts.map +1 -1
- package/dist/query-validation.js +155 -1
- package/dist/query-validation.js.map +1 -1
- package/dist/record-changes.d.ts +15 -1
- package/dist/record-changes.d.ts.map +1 -1
- package/dist/record-changes.js +33 -3
- package/dist/record-changes.js.map +1 -1
- package/dist/record-id.js +1 -1
- package/dist/record-id.js.map +1 -1
- package/dist/schema.d.ts +6 -7
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +33 -10
- package/dist/schema.js.map +1 -1
- package/dist/scoped-stack.d.ts +27 -17
- package/dist/scoped-stack.d.ts.map +1 -1
- package/dist/scoped-stack.js +111 -120
- package/dist/scoped-stack.js.map +1 -1
- package/dist/stack.d.ts +120 -44
- package/dist/stack.d.ts.map +1 -1
- package/dist/stack.js +153 -189
- package/dist/stack.js.map +1 -1
- package/dist/testing.d.ts +28 -11
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +42 -37
- package/dist/testing.js.map +1 -1
- package/dist/type-handle.d.ts +157 -0
- package/dist/type-handle.d.ts.map +1 -0
- package/dist/type-handle.js +131 -0
- package/dist/type-handle.js.map +1 -0
- package/dist/types.d.ts +63 -20
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/validate.d.ts +7 -0
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +122 -7
- package/dist/validate.js.map +1 -1
- package/dist/wire-body.d.ts +79 -0
- package/dist/wire-body.d.ts.map +1 -0
- package/dist/wire-body.js +177 -0
- package/dist/wire-body.js.map +1 -0
- package/dist/wire-entry.d.ts +4 -2
- package/dist/wire-entry.d.ts.map +1 -1
- package/dist/wire-entry.js +8 -3
- package/dist/wire-entry.js.map +1 -1
- package/dist/wire-record.d.ts.map +1 -1
- package/dist/wire-record.js +27 -14
- package/dist/wire-record.js.map +1 -1
- package/dist/wire-request.d.ts +52 -2
- package/dist/wire-request.d.ts.map +1 -1
- package/dist/wire-request.js +222 -23
- package/dist/wire-request.js.map +1 -1
- 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,
|
|
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,
|
|
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,
|
|
26
|
-
import { GRANT_ACTION_SET, READ_COMPANIONS, grantConveys, grantReach, matchesGrantTarget, validateGrantTarget, validateGrantee,
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
|
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,
|
|
760
|
-
this.
|
|
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
|
|
788
|
-
* label and payload; a no-op
|
|
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,
|
|
794
|
-
this.
|
|
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
|
-
*
|
|
827
|
-
*
|
|
828
|
-
*
|
|
829
|
-
*
|
|
830
|
-
*
|
|
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
|
|
773
|
+
async amendAssociations(id, changes, opts = {}, surface = 'amendAssociations()') {
|
|
833
774
|
this.assertOpen();
|
|
834
|
-
|
|
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
|
-
|
|
840
|
-
|
|
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
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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
|
-
*
|
|
853
|
-
*
|
|
854
|
-
*
|
|
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,
|
|
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
|
-
|
|
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
|
|
865
|
-
|
|
823
|
+
const next = applyAssociationEdits(current, changes);
|
|
824
|
+
const delta = associationDelta(current, next);
|
|
825
|
+
if (delta.length === 0)
|
|
866
826
|
return existing;
|
|
867
|
-
this.assertPermissionSet(
|
|
827
|
+
this.assertPermissionSet(next);
|
|
868
828
|
const change = new PendingChange('reshare', {
|
|
869
829
|
actor: normalizeActor(opts.actor),
|
|
870
|
-
associations:
|
|
830
|
+
associations: delta,
|
|
871
831
|
});
|
|
872
|
-
const updated = await this.adapter.
|
|
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
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
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
|
-
* `
|
|
1789
|
-
*
|
|
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(
|
|
1760
|
+
async grantType(baseId, grant) {
|
|
1793
1761
|
this.assertOpen();
|
|
1794
1762
|
validateGrantTarget(grant.grantee);
|
|
1795
|
-
this.checkGrantValid(
|
|
1763
|
+
this.checkGrantValid(baseId, grant.actions);
|
|
1796
1764
|
return this.create(`${SYSTEM_TYPES.GRANT}@1`, {
|
|
1797
|
-
|
|
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
|
-
* `
|
|
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(
|
|
1825
|
+
async revokeType(baseId, grant) {
|
|
1858
1826
|
this.assertOpen();
|
|
1859
1827
|
validateGrantTarget(grant.grantee);
|
|
1860
|
-
const
|
|
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
|
|
1885
|
-
*
|
|
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(
|
|
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
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
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
|
|
1902
|
-
|
|
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
|
-
|
|
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
|