@haverstack/core 0.42.0 → 0.43.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 +5 -7
- package/dist/access.d.ts +30 -32
- package/dist/access.d.ts.map +1 -1
- package/dist/access.js +40 -98
- package/dist/access.js.map +1 -1
- package/dist/adapter/combine.d.ts.map +1 -1
- package/dist/adapter/combine.js +11 -3
- package/dist/adapter/combine.js.map +1 -1
- package/dist/adapter/index.d.ts +6 -4
- package/dist/adapter/index.d.ts.map +1 -1
- package/dist/adapter/index.js +4 -3
- package/dist/adapter/index.js.map +1 -1
- package/dist/adapter/reach.d.ts +14 -0
- package/dist/adapter/reach.d.ts.map +1 -0
- package/dist/adapter/reach.js +31 -0
- package/dist/adapter/reach.js.map +1 -0
- package/dist/associations/identity.d.ts +18 -42
- package/dist/associations/identity.d.ts.map +1 -1
- package/dist/associations/identity.js +60 -84
- package/dist/associations/identity.js.map +1 -1
- package/dist/associations/validation.d.ts +1 -8
- package/dist/associations/validation.d.ts.map +1 -1
- package/dist/associations/validation.js +3 -30
- package/dist/associations/validation.js.map +1 -1
- package/dist/collection.d.ts +7 -11
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +24 -34
- package/dist/collection.js.map +1 -1
- package/dist/content-match.d.ts +2 -2
- package/dist/content-match.js +2 -2
- package/dist/errors.d.ts +23 -8
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +28 -9
- package/dist/errors.js.map +1 -1
- package/dist/grants.d.ts +3 -3
- package/dist/grants.d.ts.map +1 -1
- package/dist/grants.js +4 -21
- package/dist/grants.js.map +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/lazy.d.ts +13 -0
- package/dist/lazy.d.ts.map +1 -0
- package/dist/lazy.js +27 -0
- package/dist/lazy.js.map +1 -0
- package/dist/merge.d.ts +1 -1
- package/dist/merge.js +1 -1
- package/dist/query-validation.d.ts +41 -28
- package/dist/query-validation.d.ts.map +1 -1
- package/dist/query-validation.js +85 -71
- package/dist/query-validation.js.map +1 -1
- package/dist/record-changes.d.ts +12 -36
- package/dist/record-changes.d.ts.map +1 -1
- package/dist/record-changes.js +9 -98
- package/dist/record-changes.js.map +1 -1
- package/dist/scoped-stack/authority-snapshot.d.ts +37 -0
- package/dist/scoped-stack/authority-snapshot.d.ts.map +1 -0
- package/dist/scoped-stack/authority-snapshot.js +49 -0
- package/dist/scoped-stack/authority-snapshot.js.map +1 -0
- package/dist/scoped-stack/authority.d.ts +51 -38
- package/dist/scoped-stack/authority.d.ts.map +1 -1
- package/dist/scoped-stack/authority.js +160 -136
- package/dist/scoped-stack/authority.js.map +1 -1
- package/dist/scoped-stack/family-policy.d.ts +59 -0
- package/dist/scoped-stack/family-policy.d.ts.map +1 -0
- package/dist/scoped-stack/family-policy.js +68 -0
- package/dist/scoped-stack/family-policy.js.map +1 -0
- package/dist/scoped-stack/scoped-stack.d.ts +66 -86
- package/dist/scoped-stack/scoped-stack.d.ts.map +1 -1
- package/dist/scoped-stack/scoped-stack.js +243 -324
- package/dist/scoped-stack/scoped-stack.js.map +1 -1
- package/dist/scoped-stack/subscription.d.ts +14 -33
- package/dist/scoped-stack/subscription.d.ts.map +1 -1
- package/dist/scoped-stack/subscription.js +16 -57
- package/dist/scoped-stack/subscription.js.map +1 -1
- package/dist/stack/arguments.d.ts +27 -0
- package/dist/stack/arguments.d.ts.map +1 -0
- package/dist/stack/arguments.js +53 -0
- package/dist/stack/arguments.js.map +1 -0
- package/dist/stack/attachments.d.ts +1 -1
- package/dist/stack/attachments.js +1 -1
- package/dist/stack/changes.d.ts +4 -10
- package/dist/stack/changes.d.ts.map +1 -1
- package/dist/stack/changes.js +8 -8
- package/dist/stack/changes.js.map +1 -1
- package/dist/stack/client.d.ts +34 -58
- package/dist/stack/client.d.ts.map +1 -1
- package/dist/stack/install-app.d.ts.map +1 -1
- package/dist/stack/install-app.js +7 -5
- package/dist/stack/install-app.js.map +1 -1
- package/dist/stack/integrity.js +2 -2
- package/dist/stack/integrity.js.map +1 -1
- package/dist/stack/limits.d.ts +1 -1
- package/dist/stack/limits.js +1 -1
- package/dist/stack/reads.d.ts +9 -5
- package/dist/stack/reads.d.ts.map +1 -1
- package/dist/stack/reads.js +24 -4
- package/dist/stack/reads.js.map +1 -1
- package/dist/stack/stack.d.ts +77 -71
- package/dist/stack/stack.d.ts.map +1 -1
- package/dist/stack/stack.js +229 -256
- package/dist/stack/stack.js.map +1 -1
- package/dist/stack/type-grants.d.ts.map +1 -1
- package/dist/stack/type-grants.js +4 -4
- package/dist/stack/type-grants.js.map +1 -1
- package/dist/stack/write-expectation.d.ts +13 -12
- package/dist/stack/write-expectation.d.ts.map +1 -1
- package/dist/stack/write-expectation.js.map +1 -1
- package/dist/stack/write-gate.d.ts +23 -0
- package/dist/stack/write-gate.d.ts.map +1 -0
- package/dist/stack/write-gate.js +17 -0
- package/dist/stack/write-gate.js.map +1 -0
- package/dist/testing.d.ts +25 -9
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +147 -123
- package/dist/testing.js.map +1 -1
- package/dist/types/adapter.d.ts +90 -31
- package/dist/types/adapter.d.ts.map +1 -1
- package/dist/types/associations.d.ts +1 -17
- package/dist/types/associations.d.ts.map +1 -1
- package/dist/types/associations.js +1 -8
- package/dist/types/associations.js.map +1 -1
- package/dist/types/capabilities.d.ts +8 -8
- package/dist/types/capabilities.d.ts.map +1 -1
- package/dist/types/events.d.ts +3 -8
- package/dist/types/events.d.ts.map +1 -1
- package/dist/types/query.d.ts +4 -4
- package/dist/types/system.d.ts +1 -1
- package/dist/validate.d.ts +2 -0
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +2 -1
- package/dist/validate.js.map +1 -1
- package/dist/wire/body.d.ts +32 -9
- package/dist/wire/body.d.ts.map +1 -1
- package/dist/wire/body.js +69 -8
- package/dist/wire/body.js.map +1 -1
- package/dist/wire/index.d.ts +4 -4
- package/dist/wire/index.d.ts.map +1 -1
- package/dist/wire/index.js +3 -3
- package/dist/wire/index.js.map +1 -1
- package/dist/wire/record.d.ts +1 -8
- package/dist/wire/record.d.ts.map +1 -1
- package/dist/wire/record.js +0 -48
- package/dist/wire/record.js.map +1 -1
- package/dist/wire/request.d.ts +18 -18
- package/dist/wire/request.d.ts.map +1 -1
- package/dist/wire/request.js +41 -36
- package/dist/wire/request.js.map +1 -1
- package/package.json +1 -1
package/dist/stack/stack.js
CHANGED
|
@@ -24,17 +24,18 @@ import { compareRecordedAttachments } from '../attachment-records.js';
|
|
|
24
24
|
import { stampGroupAdmin, validatePermissions } from '../access.js';
|
|
25
25
|
import { ChangeEmitter, RelayDelivery, PendingChange, assertSinceUsable } from './changes.js';
|
|
26
26
|
import { SYSTEM_TYPES } from '../types/index.js';
|
|
27
|
-
import { UseAfterCloseError, InvalidAdapterError, StackConflictError, StackMigrationError, StackNotFoundError, StackBadRequestError, StackSchemaDriftError, StackValidationError, StackVersionConflictError, ARGUMENTS_INVALID, SCHEMA_INVALID, } from '../errors.js';
|
|
28
|
-
import { assertQueryCapabilities,
|
|
29
|
-
import { assertAssociationEdits,
|
|
30
|
-
import { applyAssociationEdits, associationDelta
|
|
27
|
+
import { ElementSetMovedError, UseAfterCloseError, ScopeUnavailableError, InvalidAdapterError, StackConflictError, StackMigrationError, StackNotFoundError, StackBadRequestError, StackSchemaDriftError, StackValidationError, StackVersionConflictError, ARGUMENTS_INVALID, SCHEMA_INVALID, softDeletedRefusal, } from '../errors.js';
|
|
28
|
+
import { assertQueryCapabilities, assertValidBaseIdFilter, assertValidJournalQuery, assertValidVersionsQuery, resolveQuery, splitQuery, filtersContent, assertFamilyId, } from '../query-validation.js';
|
|
29
|
+
import { assertAssociationEdits, assertAuthorityAssociations, assertDataAssociations, validateAssociations, } from '../associations/validation.js';
|
|
30
|
+
import { applyAssociationEdits, associationDelta } from '../associations/identity.js';
|
|
31
31
|
import { validateGrantee, validateGrantBaseId } from '../grants.js';
|
|
32
32
|
import { bindingFieldsOf } from './identity-bindings.js';
|
|
33
33
|
import { validateInstall } from '../install.js';
|
|
34
34
|
import { assertAttachmentSize, assertContentSize } from './limits.js';
|
|
35
35
|
import { validateRecordId, validateClockField, validateIdTimestampSkew, DEFAULT_ID_TIMESTAMP_SKEW_MS, } from './record-id.js';
|
|
36
|
-
import { queryAllPages, lookupEntityByDid
|
|
37
|
-
import {
|
|
36
|
+
import { queryAllPages, lookupEntityByDid } from './reads.js';
|
|
37
|
+
import { contentEqual } from '../record-changes.js';
|
|
38
|
+
import { assertAssociationSet, assertContentPatch, assertParentArgument, assertUnlistedArgument, } from './arguments.js';
|
|
38
39
|
import { ScopedStack, scopeToken } from '../scoped-stack/scoped-stack.js';
|
|
39
40
|
import { MigrationRegistry } from './migrations.js';
|
|
40
41
|
import * as apps from './install-app.js';
|
|
@@ -43,6 +44,7 @@ import * as attachments from './attachments.js';
|
|
|
43
44
|
import { assertAttachmentImmutable, assertGroupAdminRemains, assertNoParentCycle, assertParentExists, checkAttachmentAssociationPointers, checkAttachmentMimeTypeOnCreate, checkBindingImmutable, checkBindingsOnCreate, checkBindingsOnMigrate, checkBindingsOnUpdate, checkConfigEntityIdUnchanged, } from './integrity.js';
|
|
44
45
|
import { systemTypeDefinitions } from './system-types.js';
|
|
45
46
|
import { checkFamily, checkSnapshotFamily, checkStoredVersion, WRITE_EXPECTATION, } from './write-expectation.js';
|
|
47
|
+
import { WRITE_GATE } from './write-gate.js';
|
|
46
48
|
import { createCollection } from '../collection.js';
|
|
47
49
|
// -------------------------------------------------------
|
|
48
50
|
// Supporting definitions
|
|
@@ -66,8 +68,8 @@ function normalizeActor(actor) {
|
|
|
66
68
|
? { subjectId }
|
|
67
69
|
: { subjectId, principalId };
|
|
68
70
|
}
|
|
69
|
-
/**
|
|
70
|
-
const
|
|
71
|
+
/** How many Records migrateAll() reads and migrates per page. */
|
|
72
|
+
const MIGRATE_ALL_BATCH = 100;
|
|
71
73
|
/**
|
|
72
74
|
* One refusal for every problem a call carries, content and arguments alike,
|
|
73
75
|
* so a caller fixes them in one round trip. Content is itself an argument,
|
|
@@ -123,7 +125,7 @@ export class Stack {
|
|
|
123
125
|
/**
|
|
124
126
|
* Announce a mutation that has already been persisted. Called after the
|
|
125
127
|
* adapter write resolves and before the mutating method settles, so
|
|
126
|
-
* `await stack.
|
|
128
|
+
* `await stack.patchContent(...)` guarantees subscribers have been notified —
|
|
127
129
|
* and nothing about work they deferred. A handler cannot fail the write:
|
|
128
130
|
* there is nothing left to fail. See docs/spec/events.md § Handlers.
|
|
129
131
|
*/
|
|
@@ -218,8 +220,24 @@ export class Stack {
|
|
|
218
220
|
const { subjectId, principalId = subjectId } = normalizeActor(actor);
|
|
219
221
|
return this.scope(principalId, subjectId);
|
|
220
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* A scoped view needs a stack that owns its storage: one whose adapter
|
|
225
|
+
* queries within a reach and relays no changes from elsewhere. A
|
|
226
|
+
* server-backed stack is scoped by the session that opened it instead.
|
|
227
|
+
* See docs/spec/access-control.md § Enforcement: Stack.asEntity().
|
|
228
|
+
*/
|
|
221
229
|
scope(principalId, subjectId) {
|
|
222
230
|
this.assertOpen();
|
|
231
|
+
const queryReadable = this.adapter.queryReadable?.bind(this.adapter);
|
|
232
|
+
if (!queryReadable) {
|
|
233
|
+
throw new ScopeUnavailableError('This stack cannot be scoped: its adapter does not implement queryReadable(), so a ' +
|
|
234
|
+
"query could not be kept to the requester's reach. Every local adapter must; a " +
|
|
235
|
+
'server-backed stack is scoped by opening it with the session you mean.');
|
|
236
|
+
}
|
|
237
|
+
if (this.relaysChanges) {
|
|
238
|
+
throw new ScopeUnavailableError('This stack cannot be scoped: it relays changes made elsewhere, whose frames were ' +
|
|
239
|
+
'scoped by the session that opened the feed. Open it with the session you mean.');
|
|
240
|
+
}
|
|
223
241
|
return new ScopedStack(scopeToken, {
|
|
224
242
|
stack: this,
|
|
225
243
|
principalId,
|
|
@@ -228,8 +246,8 @@ export class Stack {
|
|
|
228
246
|
adapter: this.adapter,
|
|
229
247
|
changes: this.changes,
|
|
230
248
|
assertOpen: () => this.assertOpen(),
|
|
231
|
-
relaysChanges: this.relaysChanges,
|
|
232
249
|
migrations: this.migrations,
|
|
250
|
+
queryReadable: (split, reach) => this.dispatchQuery(split, (resolved) => queryReadable(resolved, reach)),
|
|
233
251
|
});
|
|
234
252
|
}
|
|
235
253
|
collection(handle) {
|
|
@@ -335,7 +353,7 @@ export class Stack {
|
|
|
335
353
|
async migrateAll(baseId, opts = {}) {
|
|
336
354
|
this.assertOpen();
|
|
337
355
|
assertFamilyId(baseId, 'migrateAll');
|
|
338
|
-
const familyTypeIds = await this.
|
|
356
|
+
const familyTypeIds = typeIdsOfFamilies(await this.adapter.listTypes(), [baseId]);
|
|
339
357
|
if (familyTypeIds.length === 0) {
|
|
340
358
|
throw new StackMigrationError(`migrateAll: no registered types found for baseId "${baseId}"`);
|
|
341
359
|
}
|
|
@@ -355,11 +373,11 @@ export class Stack {
|
|
|
355
373
|
}
|
|
356
374
|
let cursor;
|
|
357
375
|
do {
|
|
358
|
-
const result = await this.adapter.queryRecords({
|
|
376
|
+
const result = await this.adapter.queryRecords(resolveQuery({
|
|
359
377
|
filter: { typeId, includeDeleted: true, includeUnlisted: !listedOnly },
|
|
360
|
-
limit:
|
|
378
|
+
limit: MIGRATE_ALL_BATCH,
|
|
361
379
|
cursor,
|
|
362
|
-
});
|
|
380
|
+
}));
|
|
363
381
|
for (const record of result.records) {
|
|
364
382
|
if (listedOnly && record.deletedAt) {
|
|
365
383
|
skipped++;
|
|
@@ -508,230 +526,210 @@ export class Stack {
|
|
|
508
526
|
return opts.presentAt === 'latest' ? this.migrations.presentAtLatest(record) : record;
|
|
509
527
|
}
|
|
510
528
|
/**
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
* against the record's current stored type.
|
|
529
|
+
* Merge a patch into a record's content, checked against its current
|
|
530
|
+
* stored type. A patch the content already satisfies writes nothing.
|
|
514
531
|
* See docs/spec/data-model.md § Mutations.
|
|
515
532
|
*/
|
|
516
|
-
async
|
|
533
|
+
async patchContent(id, patch, opts = {}) {
|
|
517
534
|
this.assertOpen();
|
|
518
|
-
|
|
519
|
-
const existing = await this.
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
// Checked before validation, so a caller that lost the race learns its
|
|
523
|
-
// version is stale rather than that its patch is bad, and before the
|
|
524
|
-
// no-op short-circuit below, so a precondition is never satisfied by a
|
|
525
|
-
// write that turned out to move nothing.
|
|
526
|
-
if (takesIfVersion(changes))
|
|
527
|
-
this.checkIfVersion(existing, opts.ifVersion);
|
|
528
|
-
checkStoredVersion(existing, opts, changes.contentPatch !== undefined);
|
|
529
|
-
const merged = await this.validateChangeSet(id, existing, changes);
|
|
530
|
-
const ops = changeSetOps(existing, changes, merged);
|
|
531
|
-
// Nothing moved, so there is nothing to version. Decided here rather
|
|
532
|
-
// than in the adapter: "did this change anything" is a question about
|
|
533
|
-
// the record's meaning, and an adapter is never handed a write that
|
|
534
|
-
// writes nothing.
|
|
535
|
-
if (ops.length === 0)
|
|
535
|
+
assertContentPatch(patch);
|
|
536
|
+
const existing = await this.requireWritable(id, opts, true);
|
|
537
|
+
const merged = await this.validatePatch(id, existing, patch);
|
|
538
|
+
if (contentEqual(existing.content, merged))
|
|
536
539
|
return existing;
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
// record stamp, since a non-bumping write never touches `updatedBy`.
|
|
541
|
-
// See docs/spec/versioning.md § Version history.
|
|
542
|
-
const bumps = bumpsVersion(ops);
|
|
543
|
-
// From the same before/after changeSetOps compared, so `ops` and the
|
|
544
|
-
// journal cannot disagree; ACL and data edits travel as one list.
|
|
545
|
-
// See docs/spec/events.md § The event shape.
|
|
546
|
-
const assocDelta = [
|
|
547
|
-
...(changes.associations
|
|
548
|
-
? associationDelta(existing.associations ?? [], changes.associations)
|
|
549
|
-
: []),
|
|
550
|
-
...(changes.permissions
|
|
551
|
-
? associationDelta(existing.permissions ?? [], changes.permissions)
|
|
552
|
-
: []),
|
|
553
|
-
];
|
|
554
|
-
const previousParentId = existing.parentId ?? null;
|
|
555
|
-
const change = new PendingChange(ops, {
|
|
556
|
-
actor: normalizeActor(opts.actor),
|
|
557
|
-
...(ops.includes('reparent') && { previousParentId }),
|
|
558
|
-
...(assocDelta.length && { associations: assocDelta }),
|
|
559
|
-
});
|
|
560
|
-
const updated = await this.adapter.mutateRecord(id, effectiveChanges(changes, ops), {
|
|
561
|
-
...(bumps ? this.writeOptions(existing, opts) : { actor: normalizeActor(opts.actor) }),
|
|
562
|
-
bumpsVersion: bumps,
|
|
540
|
+
const change = new PendingChange('patch', { actor: normalizeActor(opts.actor) });
|
|
541
|
+
const updated = await this.adapter.patchContent(id, patch, {
|
|
542
|
+
...this.writeOptions(existing, opts),
|
|
563
543
|
journal: change.journal,
|
|
564
544
|
});
|
|
565
545
|
this.announce(change, updated);
|
|
566
546
|
return updated;
|
|
567
547
|
}
|
|
568
548
|
/**
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
549
|
+
* Every check a content patch owes, all before anything is written.
|
|
550
|
+
* Returns the merged content, which the caller compares to decide whether
|
|
551
|
+
* content moved.
|
|
572
552
|
*/
|
|
573
|
-
async
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
* the merged content for a content patch, which the caller compares to
|
|
579
|
-
* decide whether content moved. A parent is checked only when it changes.
|
|
580
|
-
*/
|
|
581
|
-
async validateChangeSet(id, existing, changes) {
|
|
582
|
-
const { contentPatch, permissions, associations, parentId } = changes;
|
|
583
|
-
// Each key replaces only within its own domain. Asked before the
|
|
584
|
-
// shape checks below so a misrouted element is reported as the wrong
|
|
585
|
-
// surface rather than as a malformed value of the right one, and
|
|
586
|
-
// asked here rather than in `ScopedStack` so an unscoped `Stack`, an
|
|
587
|
-
// import and a server mapping a request body are all held to it.
|
|
588
|
-
if (associations)
|
|
589
|
-
assertDataAssociations(associations, 'associations');
|
|
590
|
-
if (permissions)
|
|
591
|
-
assertAuthorityAssociations(permissions, 'permissions');
|
|
592
|
-
const argumentErrors = [
|
|
593
|
-
...(permissions
|
|
594
|
-
? [...validateAssociations(permissions, 'permissions'), ...validatePermissions(permissions)]
|
|
595
|
-
: []),
|
|
596
|
-
...(associations ? validateAssociations(associations) : []),
|
|
553
|
+
async validatePatch(id, existing, patch) {
|
|
554
|
+
const patchErrors = [
|
|
555
|
+
...validateReservedKeys(patch),
|
|
556
|
+
...validatePatchValues(patch),
|
|
557
|
+
...validateContentKeys(patch),
|
|
597
558
|
];
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
throw new StackValidationError(contentErrors);
|
|
620
|
-
if (existing.typeId === `${SYSTEM_TYPES.ATTACHMENT}@1`) {
|
|
621
|
-
// Presence decides mimeType and value decides the rest: re-sending
|
|
622
|
-
// the mimeType a record already holds is refused outright, while a
|
|
623
|
-
// client round-tripping fileId or size unchanged has claimed nothing.
|
|
624
|
-
assertAttachmentImmutable((field) => Object.prototype.hasOwnProperty.call(contentPatch, field) &&
|
|
625
|
-
(field === 'mimeType' ||
|
|
626
|
-
merged[field] !==
|
|
627
|
-
existing.content[field]));
|
|
628
|
-
}
|
|
629
|
-
await checkBindingsOnUpdate(this, existing.typeId, id, contentPatch, existing.content, merged);
|
|
630
|
-
if (id === SYSTEM_TYPES.CONFIG) {
|
|
631
|
-
checkConfigEntityIdUnchanged(existing.content.entityId, merged.entityId);
|
|
632
|
-
}
|
|
559
|
+
if (patchErrors.length > 0)
|
|
560
|
+
throw new StackValidationError(patchErrors);
|
|
561
|
+
// The patch is what travels, so the patch is what's measured — a
|
|
562
|
+
// small patch against a large record is not an oversized request.
|
|
563
|
+
assertContentSize(patch, this.capabilities.limits.contentBytes, 'Patch');
|
|
564
|
+
const type = await this.getTypeCached(existing.typeId);
|
|
565
|
+
if (!type) {
|
|
566
|
+
throw new StackBadRequestError(`Unknown type: "${existing.typeId}"`);
|
|
567
|
+
}
|
|
568
|
+
const merged = applyMergePatch(existing.content, patch);
|
|
569
|
+
const contentErrors = typedContentErrors(existing.typeId, merged, type.schema);
|
|
570
|
+
if (contentErrors.length > 0)
|
|
571
|
+
throw new StackValidationError(contentErrors);
|
|
572
|
+
if (existing.typeId === `${SYSTEM_TYPES.ATTACHMENT}@1`) {
|
|
573
|
+
// Presence decides mimeType and value decides the rest: re-sending
|
|
574
|
+
// the mimeType a record already holds is refused outright, while a
|
|
575
|
+
// client round-tripping fileId or size unchanged has claimed nothing.
|
|
576
|
+
assertAttachmentImmutable((field) => Object.prototype.hasOwnProperty.call(patch, field) &&
|
|
577
|
+
(field === 'mimeType' ||
|
|
578
|
+
merged[field] !==
|
|
579
|
+
existing.content[field]));
|
|
633
580
|
}
|
|
634
|
-
|
|
635
|
-
|
|
581
|
+
await checkBindingsOnUpdate(this, existing.typeId, id, patch, existing.content, merged);
|
|
582
|
+
if (id === SYSTEM_TYPES.CONFIG) {
|
|
583
|
+
checkConfigEntityIdUnchanged(existing.content.entityId, merged.entityId);
|
|
636
584
|
}
|
|
637
|
-
|
|
585
|
+
return merged;
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Move a record under another, or to the root with `null`. The
|
|
589
|
+
* destination is checked only when it changes: it has to exist, and the
|
|
590
|
+
* move must not make the record its own ancestor.
|
|
591
|
+
* See docs/spec/data-model.md § Reparenting.
|
|
592
|
+
*/
|
|
593
|
+
async setParent(id, parentId, opts = {}) {
|
|
594
|
+
this.assertOpen();
|
|
595
|
+
assertParentArgument(parentId);
|
|
596
|
+
const existing = await this.requireWritable(id, opts);
|
|
597
|
+
const previousParentId = existing.parentId ?? null;
|
|
598
|
+
if (parentId === previousParentId)
|
|
599
|
+
return existing;
|
|
600
|
+
if (parentId !== null) {
|
|
638
601
|
await assertParentExists(this.readRecord, id, parentId);
|
|
639
602
|
await assertNoParentCycle(this.readRecord, id, parentId);
|
|
640
603
|
}
|
|
641
|
-
|
|
604
|
+
const change = new PendingChange('reparent', {
|
|
605
|
+
actor: normalizeActor(opts.actor),
|
|
606
|
+
previousParentId,
|
|
607
|
+
});
|
|
608
|
+
return this.writeAspect(change, opts, (o) => this.adapter.setParent(id, parentId, o));
|
|
642
609
|
}
|
|
643
610
|
/**
|
|
644
|
-
*
|
|
645
|
-
*
|
|
646
|
-
*
|
|
647
|
-
* a reference came from.
|
|
611
|
+
* Withhold a record from enumeration, or restore it. Restating the
|
|
612
|
+
* current state writes nothing, so `unlistedAt` is never dragged forward.
|
|
613
|
+
* See docs/spec/unlisted.md.
|
|
648
614
|
*/
|
|
649
|
-
async
|
|
650
|
-
|
|
651
|
-
|
|
615
|
+
async setUnlisted(id, unlisted, opts = {}) {
|
|
616
|
+
this.assertOpen();
|
|
617
|
+
assertUnlistedArgument(unlisted);
|
|
618
|
+
const existing = await this.requireWritable(id, opts);
|
|
619
|
+
if (Boolean(existing.unlistedAt) === unlisted)
|
|
620
|
+
return existing;
|
|
621
|
+
const change = new PendingChange(unlisted ? 'unlist' : 'list', {
|
|
622
|
+
actor: normalizeActor(opts.actor),
|
|
623
|
+
});
|
|
624
|
+
return this.writeAspect(change, opts, (o) => this.adapter.setUnlisted(id, unlisted, o));
|
|
652
625
|
}
|
|
653
626
|
/**
|
|
654
|
-
*
|
|
655
|
-
*
|
|
656
|
-
*
|
|
657
|
-
* `attachmentRecordId`
|
|
658
|
-
*
|
|
627
|
+
* Apply adds and removes to a record's data associations: one adapter
|
|
628
|
+
* write, one journal entry carrying each element in full, so the entry is
|
|
629
|
+
* as undoable as a single-element one. An add naming a held identity with
|
|
630
|
+
* a new `attachmentRecordId` re-points it — see docs/spec/attachments.md
|
|
631
|
+
* § Naming the upload a reference came from.
|
|
659
632
|
*/
|
|
660
|
-
async
|
|
661
|
-
|
|
662
|
-
|
|
633
|
+
async editAssociations(id, edits, opts = {}) {
|
|
634
|
+
this.assertOpen();
|
|
635
|
+
assertAssociationEdits(edits, 'editAssociations()', 'data');
|
|
636
|
+
return this.untilSetHolds(async () => {
|
|
637
|
+
const existing = await this.requireWritable(id, opts);
|
|
638
|
+
const next = applyAssociationEdits(existing.associations ?? [], edits);
|
|
639
|
+
return this.writeAssociations(existing, next, opts, (o) => this.adapter.editAssociations(id, edits, o));
|
|
640
|
+
});
|
|
663
641
|
}
|
|
664
642
|
/**
|
|
665
|
-
*
|
|
666
|
-
*
|
|
667
|
-
* element in full — a remove with its annotation, a re-point with what it
|
|
668
|
-
* overwrote — so the entry is as undoable as a single-element one.
|
|
669
|
-
* See docs/spec/data-model.md § Mutations.
|
|
643
|
+
* Make a record's data associations exactly `associations`. Its
|
|
644
|
+
* permissions are another aspect, which this never reads or writes.
|
|
670
645
|
*/
|
|
671
|
-
async
|
|
646
|
+
async setAssociations(id, associations, opts = {}) {
|
|
672
647
|
this.assertOpen();
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
648
|
+
assertAssociationSet(associations, 'associations');
|
|
649
|
+
return this.untilSetHolds(async () => {
|
|
650
|
+
const existing = await this.requireWritable(id, opts);
|
|
651
|
+
return this.writeAssociations(existing, associations, opts, (o) => this.adapter.setAssociations(id, associations, o));
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* The shared half of the two data-association writes, from the set the
|
|
656
|
+
* write would leave: nothing moved writes nothing, a roster keeps an
|
|
657
|
+
* admin, and every new attachment pointer names its own upload.
|
|
658
|
+
*/
|
|
659
|
+
async writeAssociations(existing, next, opts, write) {
|
|
677
660
|
const current = existing.associations ?? [];
|
|
678
|
-
const next = applyAssociationEdits(current, changes);
|
|
679
661
|
const delta = associationDelta(current, next);
|
|
680
662
|
if (delta.length === 0)
|
|
681
663
|
return existing;
|
|
682
|
-
// Only a roster can lose its last admin, and only the post-state says so.
|
|
683
664
|
assertGroupAdminRemains(existing, next);
|
|
684
|
-
await checkAttachmentAssociationPointers(this.readRecord,
|
|
665
|
+
await checkAttachmentAssociationPointers(this.readRecord, next, current);
|
|
685
666
|
const change = new PendingChange([
|
|
686
667
|
...(delta.some((c) => c.op !== 'remove') ? ['associate'] : []),
|
|
687
668
|
...(delta.some((c) => c.op === 'remove') ? ['dissociate'] : []),
|
|
688
669
|
], { actor: normalizeActor(opts.actor), associations: delta });
|
|
689
|
-
|
|
690
|
-
this.announce(change, updated);
|
|
691
|
-
return updated;
|
|
670
|
+
return this.writeAspect(change, opts, (o) => write({ ...o, expectSet: current }));
|
|
692
671
|
}
|
|
693
672
|
/**
|
|
694
|
-
*
|
|
695
|
-
*
|
|
696
|
-
*
|
|
697
|
-
* granted. No-bump, like associate(); an element the record already
|
|
698
|
-
* carries is a no-op. `write` never implies `read` — name both.
|
|
673
|
+
* Apply adds and removes to who reaches a record. The write-implies-read
|
|
674
|
+
* invariant is asked once, of the set the whole list produces, so a
|
|
675
|
+
* downgrade is one call. See docs/spec/access-control.md § Write implies read.
|
|
699
676
|
*/
|
|
700
|
-
async
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
*/
|
|
709
|
-
async revokeAccess(id, permissions, opts = {}) {
|
|
710
|
-
assertAssociationList(permissions, 'revokeAccess()', 'permissions', 'authority');
|
|
711
|
-
return this.amendAccess(id, editsOf('remove', permissions), opts, 'revokeAccess()');
|
|
677
|
+
async editAccess(id, edits, opts = {}) {
|
|
678
|
+
this.assertOpen();
|
|
679
|
+
assertAssociationEdits(edits, 'editAccess()', 'authority');
|
|
680
|
+
return this.untilSetHolds(async () => {
|
|
681
|
+
const existing = await this.requireWritable(id, opts);
|
|
682
|
+
const next = applyAssociationEdits(existing.permissions ?? [], edits);
|
|
683
|
+
return this.writeAccess(existing, next, opts, (o) => this.adapter.editAccess(id, edits, o));
|
|
684
|
+
});
|
|
712
685
|
}
|
|
713
|
-
/**
|
|
714
|
-
|
|
715
|
-
* write-implies-read invariant is asked once, of the set the whole list
|
|
716
|
-
* produces. See docs/spec/access-control.md § Write implies read.
|
|
717
|
-
*/
|
|
718
|
-
async amendAccess(id, changes, opts = {}, surface = 'amendAccess()') {
|
|
686
|
+
/** Make who reaches a record exactly `permissions`; `[]` is private. */
|
|
687
|
+
async setAccess(id, permissions, opts = {}) {
|
|
719
688
|
this.assertOpen();
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
689
|
+
assertAssociationSet(permissions, 'permissions');
|
|
690
|
+
return this.untilSetHolds(async () => {
|
|
691
|
+
const existing = await this.requireWritable(id, opts);
|
|
692
|
+
return this.writeAccess(existing, permissions, opts, (o) => this.adapter.setAccess(id, permissions, o));
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
async writeAccess(existing, next, opts, write) {
|
|
724
696
|
const current = existing.permissions ?? [];
|
|
725
|
-
const next = applyAssociationEdits(current, changes);
|
|
726
697
|
const delta = associationDelta(current, next);
|
|
727
698
|
if (delta.length === 0)
|
|
728
699
|
return existing;
|
|
700
|
+
// After the no-op check: a call that moves nothing is not refused for
|
|
701
|
+
// a stored set it did not write.
|
|
729
702
|
this.assertPermissionSet(next);
|
|
730
703
|
const change = new PendingChange('reshare', {
|
|
731
704
|
actor: normalizeActor(opts.actor),
|
|
732
705
|
associations: delta,
|
|
733
706
|
});
|
|
734
|
-
|
|
707
|
+
return this.writeAspect(change, opts, (o) => write({ ...o, expectSet: current }));
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Run an element-set write from its read onward, again from a fresh read
|
|
711
|
+
* each time the adapter finds the set moved since the last one, so the
|
|
712
|
+
* rules over the resulting set are asked of the set the write lands on.
|
|
713
|
+
* Unbounded: each refusal means another write landed.
|
|
714
|
+
* See docs/spec/versioning.md § Element-set writes compare the set they read.
|
|
715
|
+
*/
|
|
716
|
+
async untilSetHolds(attempt) {
|
|
717
|
+
for (;;) {
|
|
718
|
+
try {
|
|
719
|
+
return await attempt();
|
|
720
|
+
}
|
|
721
|
+
catch (err) {
|
|
722
|
+
if (!(err instanceof ElementSetMovedError))
|
|
723
|
+
throw err;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Land a write that never bumps. Its precondition and journal entry
|
|
729
|
+
* travel together here, so no aspect verb can drop either.
|
|
730
|
+
*/
|
|
731
|
+
async writeAspect(change, opts, write) {
|
|
732
|
+
const updated = await write({ ifVersion: opts.ifVersion, journal: change.journal });
|
|
735
733
|
this.announce(change, updated);
|
|
736
734
|
return updated;
|
|
737
735
|
}
|
|
@@ -752,9 +750,24 @@ export class Stack {
|
|
|
752
750
|
* not applied to a tombstone.
|
|
753
751
|
*/
|
|
754
752
|
refuseIfDeleted(record) {
|
|
755
|
-
if (record.deletedAt)
|
|
756
|
-
throw
|
|
757
|
-
|
|
753
|
+
if (record.deletedAt)
|
|
754
|
+
throw softDeletedRefusal(record.id);
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* The record a write addresses, past every refusal owed before
|
|
758
|
+
* validation: missing, another family, a caller's gate, soft-deleted,
|
|
759
|
+
* then a stale `ifVersion` — before validation, so a caller that lost the
|
|
760
|
+
* race hears that first, and before any no-op, so a write that moved
|
|
761
|
+
* nothing never satisfies a precondition.
|
|
762
|
+
*/
|
|
763
|
+
async requireWritable(id, opts, patchesContent = false) {
|
|
764
|
+
const existing = await this.requireRecord(id);
|
|
765
|
+
checkFamily(existing, opts);
|
|
766
|
+
await opts[WRITE_GATE]?.(existing);
|
|
767
|
+
this.refuseIfDeleted(existing);
|
|
768
|
+
this.checkIfVersion(existing, opts.ifVersion);
|
|
769
|
+
checkStoredVersion(existing, opts, patchesContent);
|
|
770
|
+
return existing;
|
|
758
771
|
}
|
|
759
772
|
/**
|
|
760
773
|
* A by-id read straight from the adapter, for the integrity checks a write
|
|
@@ -770,22 +783,13 @@ export class Stack {
|
|
|
770
783
|
return record;
|
|
771
784
|
}
|
|
772
785
|
/**
|
|
773
|
-
* Soft-delete a record, or purge it with its history
|
|
774
|
-
*
|
|
775
|
-
*
|
|
776
|
-
* docs/spec/versioning.md § Deletion and docs/spec/attachments.md
|
|
777
|
-
* purge strands the bytes it referenced.
|
|
786
|
+
* Soft-delete a record, or purge it with its history, reporting the
|
|
787
|
+
* record as the write left it — or as a purge destroyed it — captured in
|
|
788
|
+
* the same write, so nothing lands unseen between a read and the delete.
|
|
789
|
+
* See docs/spec/versioning.md § Deletion and docs/spec/attachments.md
|
|
790
|
+
* § A purge strands the bytes it referenced.
|
|
778
791
|
*/
|
|
779
792
|
async delete(id, opts = {}) {
|
|
780
|
-
const { referencedFileIds } = await this.deleteAndReturn(id, opts);
|
|
781
|
-
return { referencedFileIds };
|
|
782
|
-
}
|
|
783
|
-
/**
|
|
784
|
-
* delete(), plus the record it acted on — before destruction for a purge,
|
|
785
|
-
* the tombstone for a soft delete — captured inside the same write, so no
|
|
786
|
-
* concurrent write can land unseen between a read and the delete.
|
|
787
|
-
*/
|
|
788
|
-
async deleteAndReturn(id, opts = {}) {
|
|
789
793
|
this.assertOpen();
|
|
790
794
|
if (id === SYSTEM_TYPES.CONFIG) {
|
|
791
795
|
throw new StackConflictError("Cannot delete the _config record: it holds the stack's identity and is required for every permission check.");
|
|
@@ -869,61 +873,27 @@ export class Stack {
|
|
|
869
873
|
return undeleted;
|
|
870
874
|
}
|
|
871
875
|
/**
|
|
872
|
-
* Query records. filter.baseId matches every version of a type family
|
|
873
|
-
*
|
|
874
|
-
*
|
|
875
|
-
* docs/spec/data-model.md § Queries.
|
|
876
|
+
* Query records. filter.baseId matches every version of a type family.
|
|
877
|
+
* Results come back exactly as stored; pass presentAt: 'latest' to
|
|
878
|
+
* migrate in memory. See docs/spec/data-model.md § Queries.
|
|
876
879
|
*/
|
|
877
880
|
async query(query = {}) {
|
|
878
881
|
this.assertOpen();
|
|
879
|
-
const
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
assertValidSort(query.sort);
|
|
883
|
-
assertSortCapability(query.sort, this.adapter.capabilities);
|
|
884
|
-
assertValidAssociationFilters(filter);
|
|
885
|
-
assertValidBaseIdFilter(filter);
|
|
886
|
-
const limit = rawLimit !== undefined ? Math.min(rawLimit, MAX_QUERY_LIMIT) : undefined;
|
|
887
|
-
const resolvedFilter = await this.resolveBaseIdFilter(filter);
|
|
888
|
-
if (resolvedFilter === EMPTY_FAMILY) {
|
|
889
|
-
return { records: [], cursor: null };
|
|
890
|
-
}
|
|
891
|
-
const result = await this.adapter.queryRecords({
|
|
892
|
-
...rest,
|
|
893
|
-
...(query.sort && { sort: normalizeSort(query.sort) }),
|
|
894
|
-
...(resolvedFilter !== undefined && { filter: resolvedFilter }),
|
|
895
|
-
...(limit !== undefined && { limit }),
|
|
896
|
-
});
|
|
897
|
-
if (presentAt !== 'latest')
|
|
898
|
-
return result;
|
|
899
|
-
return { ...result, records: result.records.map((r) => this.migrations.presentAtLatest(r)) };
|
|
900
|
-
}
|
|
901
|
-
/**
|
|
902
|
-
* Resolve filter.baseId into a concrete typeId set (intersected with
|
|
903
|
-
* filter.typeId when both are given), so adapters never need their own
|
|
904
|
-
* baseId concept. Returns EMPTY_FAMILY when the resolved set is empty so
|
|
905
|
-
* the caller can short-circuit without an adapter round trip.
|
|
906
|
-
*/
|
|
907
|
-
async resolveBaseIdFilter(filter) {
|
|
908
|
-
if (filter?.baseId === undefined)
|
|
909
|
-
return filter;
|
|
910
|
-
const { baseId, typeId, ...rest } = filter;
|
|
911
|
-
const requestedBaseIds = Array.isArray(baseId) ? baseId : [baseId];
|
|
912
|
-
const familyTypeIds = await this.familyTypeIds(requestedBaseIds);
|
|
913
|
-
const resolvedTypeIds = typeId === undefined
|
|
914
|
-
? familyTypeIds
|
|
915
|
-
: familyTypeIds.filter((id) => Array.isArray(typeId) ? typeId.includes(id) : typeId === id);
|
|
916
|
-
if (resolvedTypeIds.length === 0)
|
|
917
|
-
return EMPTY_FAMILY;
|
|
918
|
-
return { ...rest, typeId: resolvedTypeIds };
|
|
882
|
+
const split = splitQuery(query);
|
|
883
|
+
assertQueryCapabilities(split.query, this.adapter.capabilities);
|
|
884
|
+
return this.dispatchQuery(split, (q) => this.adapter.queryRecords(q));
|
|
919
885
|
}
|
|
920
886
|
/**
|
|
921
|
-
*
|
|
922
|
-
*
|
|
887
|
+
* Everything query() does around the adapter call once the query is
|
|
888
|
+
* split and checked: the migration on the way out. `read` is the call,
|
|
889
|
+
* so a scoped query within a reach gets the same treatment as any other.
|
|
923
890
|
*/
|
|
924
|
-
async
|
|
925
|
-
|
|
926
|
-
|
|
891
|
+
async dispatchQuery({ query, presentAt }, read) {
|
|
892
|
+
this.assertOpen();
|
|
893
|
+
const result = await read(query);
|
|
894
|
+
if (presentAt !== 'latest')
|
|
895
|
+
return result;
|
|
896
|
+
return { ...result, records: result.records.map((r) => this.migrations.presentAtLatest(r)) };
|
|
927
897
|
}
|
|
928
898
|
// -------------------------------------------------------
|
|
929
899
|
// Versions
|
|
@@ -1132,7 +1102,7 @@ export class Stack {
|
|
|
1132
1102
|
// Purged inside the adapter's own transaction, so these never reach
|
|
1133
1103
|
// delete() and are announced here instead — from the records it
|
|
1134
1104
|
// hands back, which are the last copies that will ever exist.
|
|
1135
|
-
deletedRecords = await this.adapter.deleteUnreferencedAttachmentRecords(fileId,
|
|
1105
|
+
deletedRecords = await this.adapter.deleteUnreferencedAttachmentRecords(fileId, SYSTEM_TYPES.ATTACHMENT);
|
|
1136
1106
|
const at = new Date();
|
|
1137
1107
|
for (const record of deletedRecords) {
|
|
1138
1108
|
this.announce(new PendingChange('purge', { actor: normalizeActor(opts.actor) }), record, at);
|
|
@@ -1203,7 +1173,6 @@ export class Stack {
|
|
|
1203
1173
|
...(opts.filter !== undefined && { filter: opts.filter }),
|
|
1204
1174
|
...(opts.since !== undefined && { since: opts.since }),
|
|
1205
1175
|
...(opts.includeRecords !== undefined && { includeRecords: opts.includeRecords }),
|
|
1206
|
-
...(opts.includeUnlisted !== undefined && { includeUnlisted: opts.includeUnlisted }),
|
|
1207
1176
|
...(opts.onError !== undefined && { onError: opts.onError }),
|
|
1208
1177
|
...(opts.onReset !== undefined && { onReset: opts.onReset }),
|
|
1209
1178
|
}, (change) => delivery.deliver(change));
|
|
@@ -1363,4 +1332,8 @@ export class Stack {
|
|
|
1363
1332
|
};
|
|
1364
1333
|
}
|
|
1365
1334
|
}
|
|
1335
|
+
/** Every registered version of the named families, as concrete TypeIds. */
|
|
1336
|
+
function typeIdsOfFamilies(types, baseIds) {
|
|
1337
|
+
return types.filter((t) => baseIds.includes(t.baseId)).map((t) => t.id);
|
|
1338
|
+
}
|
|
1366
1339
|
//# sourceMappingURL=stack.js.map
|