@haverstack/core 0.40.0 → 0.42.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 +121 -59
- package/dist/access.d.ts +34 -36
- package/dist/access.d.ts.map +1 -1
- package/dist/access.js +65 -42
- package/dist/access.js.map +1 -1
- package/dist/{combine.d.ts → adapter/combine.d.ts} +8 -1
- package/dist/adapter/combine.d.ts.map +1 -0
- package/dist/{combine.js → adapter/combine.js} +7 -0
- package/dist/adapter/combine.js.map +1 -0
- package/dist/{adapter-entry.d.ts → adapter/index.d.ts} +6 -5
- package/dist/adapter/index.d.ts.map +1 -0
- package/dist/{adapter-entry.js → adapter/index.js} +5 -4
- package/dist/adapter/index.js.map +1 -0
- package/dist/{sort.d.ts → adapter/sort.d.ts} +20 -20
- package/dist/adapter/sort.d.ts.map +1 -0
- package/dist/{sort.js → adapter/sort.js} +37 -27
- package/dist/adapter/sort.js.map +1 -0
- package/dist/associations/identity.d.ts +93 -0
- package/dist/associations/identity.d.ts.map +1 -0
- package/dist/associations/identity.js +198 -0
- package/dist/associations/identity.js.map +1 -0
- package/dist/associations/validation.d.ts +90 -0
- package/dist/associations/validation.d.ts.map +1 -0
- package/dist/associations/validation.js +339 -0
- package/dist/associations/validation.js.map +1 -0
- package/dist/attachment-records.d.ts +48 -0
- package/dist/attachment-records.d.ts.map +1 -0
- package/dist/attachment-records.js +52 -0
- package/dist/attachment-records.js.map +1 -0
- package/dist/collection.d.ts +182 -0
- package/dist/collection.d.ts.map +1 -0
- package/dist/collection.js +407 -0
- package/dist/collection.js.map +1 -0
- package/dist/content-match.d.ts +25 -0
- package/dist/content-match.d.ts.map +1 -0
- package/dist/content-match.js +51 -0
- package/dist/content-match.js.map +1 -0
- package/dist/content-translation.d.ts +42 -0
- package/dist/content-translation.d.ts.map +1 -0
- package/dist/content-translation.js +108 -0
- package/dist/content-translation.js.map +1 -0
- package/dist/did/did.d.ts.map +1 -0
- package/dist/{did.js → did/did.js} +3 -4
- package/dist/did/did.js.map +1 -0
- package/dist/{did-entry.d.ts → did/index.d.ts} +1 -1
- package/dist/did/index.d.ts.map +1 -0
- package/dist/{did-entry.js → did/index.js} +3 -3
- package/dist/did/index.js.map +1 -0
- package/dist/errors.d.ts +36 -63
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +35 -58
- package/dist/errors.js.map +1 -1
- package/dist/grants.d.ts +20 -43
- package/dist/grants.d.ts.map +1 -1
- package/dist/grants.js +71 -134
- package/dist/grants.js.map +1 -1
- package/dist/id.d.ts +13 -31
- package/dist/id.d.ts.map +1 -1
- package/dist/id.js +13 -31
- package/dist/id.js.map +1 -1
- package/dist/index.d.ts +14 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -5
- package/dist/index.js.map +1 -1
- package/dist/install.d.ts +141 -0
- package/dist/install.d.ts.map +1 -0
- package/dist/install.js +219 -0
- package/dist/install.js.map +1 -0
- package/dist/merge.d.ts.map +1 -1
- package/dist/merge.js +5 -11
- package/dist/merge.js.map +1 -1
- package/dist/migration-steps.d.ts +102 -0
- package/dist/migration-steps.d.ts.map +1 -0
- package/dist/migration-steps.js +293 -0
- package/dist/migration-steps.js.map +1 -0
- package/dist/query-validation.d.ts +28 -94
- package/dist/query-validation.d.ts.map +1 -1
- package/dist/query-validation.js +156 -336
- package/dist/query-validation.js.map +1 -1
- package/dist/record-changes.d.ts +2 -117
- package/dist/record-changes.d.ts.map +1 -1
- package/dist/record-changes.js +10 -212
- package/dist/record-changes.js.map +1 -1
- package/dist/schema.d.ts +18 -7
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +59 -30
- package/dist/schema.js.map +1 -1
- package/dist/scoped-stack/authority.d.ts +156 -0
- package/dist/scoped-stack/authority.d.ts.map +1 -0
- package/dist/scoped-stack/authority.js +384 -0
- package/dist/scoped-stack/authority.js.map +1 -0
- package/dist/scoped-stack/scoped-stack.d.ts +338 -0
- package/dist/scoped-stack/scoped-stack.d.ts.map +1 -0
- package/dist/scoped-stack/scoped-stack.js +776 -0
- package/dist/scoped-stack/scoped-stack.js.map +1 -0
- package/dist/scoped-stack/subscription.d.ts +55 -0
- package/dist/scoped-stack/subscription.d.ts.map +1 -0
- package/dist/scoped-stack/subscription.js +110 -0
- package/dist/scoped-stack/subscription.js.map +1 -0
- package/dist/stack/attachments.d.ts +24 -0
- package/dist/stack/attachments.d.ts.map +1 -0
- package/dist/stack/attachments.js +113 -0
- package/dist/stack/attachments.js.map +1 -0
- package/dist/{changes.d.ts → stack/changes.d.ts} +29 -65
- package/dist/stack/changes.d.ts.map +1 -0
- package/dist/{changes.js → stack/changes.js} +32 -66
- package/dist/stack/changes.js.map +1 -0
- package/dist/stack/client.d.ts +271 -0
- package/dist/stack/client.d.ts.map +1 -0
- package/dist/stack/client.js +9 -0
- package/dist/stack/client.js.map +1 -0
- package/dist/stack/identity-bindings.d.ts.map +1 -0
- package/dist/{identity-bindings.js → stack/identity-bindings.js} +8 -14
- package/dist/stack/identity-bindings.js.map +1 -0
- package/dist/stack/install-app.d.ts +22 -0
- package/dist/stack/install-app.d.ts.map +1 -0
- package/dist/stack/install-app.js +260 -0
- package/dist/stack/install-app.js.map +1 -0
- package/dist/stack/integrity.d.ts +94 -0
- package/dist/stack/integrity.d.ts.map +1 -0
- package/dist/stack/integrity.js +268 -0
- package/dist/stack/integrity.js.map +1 -0
- package/dist/{limits.d.ts → stack/limits.d.ts} +2 -5
- package/dist/stack/limits.d.ts.map +1 -0
- package/dist/{limits.js → stack/limits.js} +3 -6
- package/dist/stack/limits.js.map +1 -0
- package/dist/stack/migrations.d.ts +59 -0
- package/dist/stack/migrations.d.ts.map +1 -0
- package/dist/stack/migrations.js +141 -0
- package/dist/stack/migrations.js.map +1 -0
- package/dist/{stack-reads.d.ts → stack/reads.d.ts} +33 -6
- package/dist/stack/reads.d.ts.map +1 -0
- package/dist/{stack-reads.js → stack/reads.js} +49 -7
- package/dist/stack/reads.js.map +1 -0
- package/dist/stack/record-id.d.ts +35 -0
- package/dist/stack/record-id.d.ts.map +1 -0
- package/dist/stack/record-id.js +82 -0
- package/dist/stack/record-id.js.map +1 -0
- package/dist/stack/stack.d.ts +438 -0
- package/dist/stack/stack.d.ts.map +1 -0
- package/dist/stack/stack.js +1366 -0
- package/dist/stack/stack.js.map +1 -0
- package/dist/stack/system-types.d.ts +16 -0
- package/dist/stack/system-types.d.ts.map +1 -0
- package/dist/stack/system-types.js +111 -0
- package/dist/stack/system-types.js.map +1 -0
- package/dist/stack/type-grants.d.ts +20 -0
- package/dist/stack/type-grants.d.ts.map +1 -0
- package/dist/stack/type-grants.js +69 -0
- package/dist/stack/type-grants.js.map +1 -0
- package/dist/stack/write-expectation.d.ts +78 -0
- package/dist/stack/write-expectation.d.ts.map +1 -0
- package/dist/stack/write-expectation.js +71 -0
- package/dist/stack/write-expectation.js.map +1 -0
- package/dist/testing.d.ts +21 -26
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +50 -70
- package/dist/testing.js.map +1 -1
- package/dist/type-handle.d.ts +67 -78
- package/dist/type-handle.d.ts.map +1 -1
- package/dist/type-handle.js +44 -111
- package/dist/type-handle.js.map +1 -1
- package/dist/types/adapter.d.ts +223 -0
- package/dist/types/adapter.d.ts.map +1 -0
- package/dist/types/adapter.js +5 -0
- package/dist/types/adapter.js.map +1 -0
- package/dist/types/associations.d.ts +156 -0
- package/dist/types/associations.d.ts.map +1 -0
- package/dist/types/associations.js +13 -0
- package/dist/types/associations.js.map +1 -0
- package/dist/types/capabilities.d.ts +89 -0
- package/dist/types/capabilities.d.ts.map +1 -0
- package/dist/types/capabilities.js +5 -0
- package/dist/types/capabilities.js.map +1 -0
- package/dist/types/events.d.ts +155 -0
- package/dist/types/events.d.ts.map +1 -0
- package/dist/types/events.js +5 -0
- package/dist/types/events.js.map +1 -0
- package/dist/types/ids.d.ts +43 -0
- package/dist/types/ids.d.ts.map +1 -0
- package/dist/types/ids.js +5 -0
- package/dist/types/ids.js.map +1 -0
- package/dist/types/index.d.ts +19 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +19 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/journal.d.ts +47 -0
- package/dist/types/journal.d.ts.map +1 -0
- package/dist/types/journal.js +5 -0
- package/dist/types/journal.js.map +1 -0
- package/dist/types/query.d.ts +185 -0
- package/dist/types/query.d.ts.map +1 -0
- package/dist/types/query.js +10 -0
- package/dist/types/query.js.map +1 -0
- package/dist/types/records.d.ts +76 -0
- package/dist/types/records.d.ts.map +1 -0
- package/dist/types/records.js +5 -0
- package/dist/types/records.js.map +1 -0
- package/dist/types/schema.d.ts +74 -0
- package/dist/types/schema.d.ts.map +1 -0
- package/dist/types/schema.js +5 -0
- package/dist/types/schema.js.map +1 -0
- package/dist/types/system.d.ts +161 -0
- package/dist/types/system.d.ts.map +1 -0
- package/dist/types/system.js +32 -0
- package/dist/types/system.js.map +1 -0
- package/dist/types/tokens.d.ts +39 -0
- package/dist/types/tokens.d.ts.map +1 -0
- package/dist/types/tokens.js +5 -0
- package/dist/types/tokens.js.map +1 -0
- package/dist/validate.d.ts +29 -70
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +65 -97
- package/dist/validate.js.map +1 -1
- package/dist/{attachment-download.d.ts → wire/attachment-download.d.ts} +0 -57
- package/dist/wire/attachment-download.d.ts.map +1 -0
- package/dist/{attachment-download.js → wire/attachment-download.js} +0 -61
- package/dist/wire/attachment-download.js.map +1 -0
- package/dist/{auth.d.ts → wire/auth.d.ts} +5 -8
- package/dist/wire/auth.d.ts.map +1 -0
- package/dist/{auth.js → wire/auth.js} +5 -8
- package/dist/wire/auth.js.map +1 -0
- package/dist/{wire-body.d.ts → wire/body.d.ts} +13 -5
- package/dist/wire/body.d.ts.map +1 -0
- package/dist/{wire-body.js → wire/body.js} +65 -7
- package/dist/wire/body.js.map +1 -0
- package/dist/{wire-entry.d.ts → wire/index.d.ts} +12 -12
- package/dist/wire/index.d.ts.map +1 -0
- package/dist/{wire-entry.js → wire/index.js} +13 -17
- package/dist/wire/index.js.map +1 -0
- package/dist/{wire-record.d.ts → wire/record.d.ts} +10 -22
- package/dist/wire/record.d.ts.map +1 -0
- package/dist/{wire-record.js → wire/record.js} +12 -24
- package/dist/wire/record.js.map +1 -0
- package/dist/{wire-request.d.ts → wire/request.d.ts} +20 -37
- package/dist/wire/request.d.ts.map +1 -0
- package/dist/{wire-request.js → wire/request.js} +79 -54
- package/dist/wire/request.js.map +1 -0
- package/package.json +7 -7
- package/dist/adapter-entry.d.ts.map +0 -1
- package/dist/adapter-entry.js.map +0 -1
- package/dist/attachment-download.d.ts.map +0 -1
- package/dist/attachment-download.js.map +0 -1
- package/dist/auth.d.ts.map +0 -1
- package/dist/auth.js.map +0 -1
- package/dist/changes.d.ts.map +0 -1
- package/dist/changes.js.map +0 -1
- package/dist/combine.d.ts.map +0 -1
- package/dist/combine.js.map +0 -1
- package/dist/did-entry.d.ts.map +0 -1
- package/dist/did-entry.js.map +0 -1
- package/dist/did.d.ts.map +0 -1
- package/dist/did.js.map +0 -1
- package/dist/identity-bindings.d.ts.map +0 -1
- package/dist/identity-bindings.js.map +0 -1
- package/dist/limits.d.ts.map +0 -1
- package/dist/limits.js.map +0 -1
- package/dist/record-id.d.ts +0 -70
- package/dist/record-id.d.ts.map +0 -1
- package/dist/record-id.js +0 -127
- package/dist/record-id.js.map +0 -1
- package/dist/scoped-stack.d.ts +0 -532
- package/dist/scoped-stack.d.ts.map +0 -1
- package/dist/scoped-stack.js +0 -1219
- package/dist/scoped-stack.js.map +0 -1
- package/dist/sort.d.ts.map +0 -1
- package/dist/sort.js.map +0 -1
- package/dist/stack-reads.d.ts.map +0 -1
- package/dist/stack-reads.js.map +0 -1
- package/dist/stack.d.ts +0 -971
- package/dist/stack.d.ts.map +0 -1
- package/dist/stack.js +0 -2012
- package/dist/stack.js.map +0 -1
- package/dist/types.d.ts +0 -1250
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -49
- package/dist/types.js.map +0 -1
- package/dist/wire-body.d.ts.map +0 -1
- package/dist/wire-body.js.map +0 -1
- package/dist/wire-entry.d.ts.map +0 -1
- package/dist/wire-entry.js.map +0 -1
- package/dist/wire-record.d.ts.map +0 -1
- package/dist/wire-record.js.map +0 -1
- package/dist/wire-request.d.ts.map +0 -1
- package/dist/wire-request.js.map +0 -1
- /package/dist/{did.d.ts → did/did.d.ts} +0 -0
- /package/dist/{identity-bindings.d.ts → stack/identity-bindings.d.ts} +0 -0
package/dist/stack.js
DELETED
|
@@ -1,2012 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Stack — Core Stack Class
|
|
3
|
-
* -------------------------------------------------------
|
|
4
|
-
* The Stack class is the primary interface for apps. It sits
|
|
5
|
-
* on top of a StackAdapter and adds:
|
|
6
|
-
*
|
|
7
|
-
* - ID generation
|
|
8
|
-
* - Type definition and schema hashing
|
|
9
|
-
* - Content validation on write
|
|
10
|
-
* - Migration registry and explicit, owner-driven migrateAll()
|
|
11
|
-
* - Version snapshotting on update
|
|
12
|
-
* - Soft delete and purge
|
|
13
|
-
*
|
|
14
|
-
* Apps should never talk to a StackAdapter directly.
|
|
15
|
-
*/
|
|
16
|
-
import { generateId, generateIdForTimestamp } from './id.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
|
-
import { applyMergePatch } from './merge.js';
|
|
20
|
-
import { hasGroupAdmin, validatePermissions } from './access.js';
|
|
21
|
-
import { compareRecordedAttachments } from './attachment-download.js';
|
|
22
|
-
import { ChangeEmitter, RelayDelivery, PendingChange, assertSinceUsable } from './changes.js';
|
|
23
|
-
import { SYSTEM_TYPES } from './types.js';
|
|
24
|
-
import { UseAfterCloseError, InvalidAdapterError, StackConflictError, StackMigrationError, StackNotFoundError, StackBadRequestError, StackSchemaDriftError, StackValidationError, StackVersionConflictError, } from './errors.js';
|
|
25
|
-
import { assertQueryCapabilities, assertSortCapability, assertValidAssociationFilters, assertValidBaseIdFilter, assertValidJournalQuery, assertValidVersionsQuery, assertValidSort, normalizeSort, assertAssociationEdits, assertAuthorityAssociations, assertDataAssociations, filtersContent, validateAssociations, } from './query-validation.js';
|
|
26
|
-
import { GRANT_ACTION_SET, READ_COMPANIONS, grantConveys, grantReach, matchesGrantTarget, validateGrantTarget, validateGrantee, validateGrantBaseId, grantCoversGrantee, loadGrantRecords, } from './grants.js';
|
|
27
|
-
import { bindingFieldsOf, uniqueBindingFieldsOf } from './identity-bindings.js';
|
|
28
|
-
import { assertAttachmentSize, assertContentSize } from './limits.js';
|
|
29
|
-
import { validateParentId, validateRecordId, validateClockField, validateIdTimestampSkew, DEFAULT_ID_TIMESTAMP_SKEW_MS, } from './record-id.js';
|
|
30
|
-
import { queryAllPages, findFirstMatch, lookupEntityByDid, MAX_QUERY_LIMIT, } from './stack-reads.js';
|
|
31
|
-
import { applyAssociationEdits, associationDelta, associationIdentical, assertNonEmptyChangeSet, changeSetOps, bumpsVersion, takesIfVersion, effectiveChanges, stampGroupAdmin, isGroupRecord, } from './record-changes.js';
|
|
32
|
-
import { ScopedStack, scopeToken } from './scoped-stack.js';
|
|
33
|
-
import { isTypeHandle, typedCreate, typedGet, typedMutate, typedQuery, typedSubscribe, } from './type-handle.js';
|
|
34
|
-
// -------------------------------------------------------
|
|
35
|
-
// Supporting types
|
|
36
|
-
// -------------------------------------------------------
|
|
37
|
-
/**
|
|
38
|
-
* Default grace period for Stack.collectAttachmentGarbage(), covering the
|
|
39
|
-
* upload-then-associate window. See docs/spec/attachments.md § Garbage
|
|
40
|
-
* collection.
|
|
41
|
-
*/
|
|
42
|
-
const DEFAULT_GC_GRACE_MS = 24 * 60 * 60 * 1000;
|
|
43
|
-
/**
|
|
44
|
-
* How far a change set's `parentId` will walk a proposed ancestor chain before refusing
|
|
45
|
-
* the move. Bounds the reads one call can cost; a hierarchy deeper than
|
|
46
|
-
* this is beyond what `parentId` is for. See docs/spec/data-model.md
|
|
47
|
-
* § Reparenting.
|
|
48
|
-
*/
|
|
49
|
-
const MAX_PARENT_DEPTH = 64;
|
|
50
|
-
/**
|
|
51
|
-
* An Actor in the one form `Stack` stores: a `principalId` equal to the
|
|
52
|
-
* subject is dropped, so "acted as itself" has a single spelling, and an
|
|
53
|
-
* empty id is refused rather than stored as a name for nobody.
|
|
54
|
-
* See docs/spec/data-model.md § Actor.
|
|
55
|
-
*/
|
|
56
|
-
export function normalizeActor(actor) {
|
|
57
|
-
if (!actor)
|
|
58
|
-
return undefined;
|
|
59
|
-
for (const field of ['subjectId', 'principalId']) {
|
|
60
|
-
if (actor[field] === '') {
|
|
61
|
-
throw new StackBadRequestError(`Invalid ${field}: the empty string is not an id.`);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
const { subjectId, principalId } = actor;
|
|
65
|
-
return principalId === undefined || principalId === subjectId
|
|
66
|
-
? { subjectId }
|
|
67
|
-
: { subjectId, principalId };
|
|
68
|
-
}
|
|
69
|
-
/** Sentinel: filter.baseId resolved to zero matching types. */
|
|
70
|
-
const EMPTY_FAMILY = Symbol('empty-family');
|
|
71
|
-
/**
|
|
72
|
-
* The `_attachment@1` fields a write may not move, each with the message it
|
|
73
|
-
* is refused by. Ordered as reported. See docs/spec/attachments.md § The
|
|
74
|
-
* `_attachment` record type.
|
|
75
|
-
*/
|
|
76
|
-
const ATTACHMENT_IMMUTABLE_FIELDS = [
|
|
77
|
-
['mimeType', 'mimeType is immutable after creation; delete and re-upload to change it'],
|
|
78
|
-
['fileId', 'fileId is immutable'],
|
|
79
|
-
['size', 'size is immutable'],
|
|
80
|
-
];
|
|
81
|
-
// -------------------------------------------------------
|
|
82
|
-
// Stack class
|
|
83
|
-
// -------------------------------------------------------
|
|
84
|
-
export class Stack {
|
|
85
|
-
adapter;
|
|
86
|
-
idTimestampSkewMsValue;
|
|
87
|
-
migrations = new Map();
|
|
88
|
-
/**
|
|
89
|
-
* Highest version this instance has defineType()'d, per baseId — what
|
|
90
|
-
* this app process understands, as distinct from what exists in shared
|
|
91
|
-
* storage. Used to detect the stale-writer case in presentAtLatest().
|
|
92
|
-
*/
|
|
93
|
-
maxDefinedVersion = new Map();
|
|
94
|
-
/**
|
|
95
|
-
* Types this instance has fetched or defined, keyed by versioned id. A
|
|
96
|
-
* Type's schema is immutable once defined, so entries are never
|
|
97
|
-
* invalidated, only added; listTypes() refreshes wholesale. See
|
|
98
|
-
* docs/spec/data-model.md § Type cache.
|
|
99
|
-
*/
|
|
100
|
-
typeCache = new Map();
|
|
101
|
-
/** Set by close(). See docs/spec/adapters.md § Lifecycle. */
|
|
102
|
-
closed = false;
|
|
103
|
-
/**
|
|
104
|
-
* Every change made through this Stack. `ScopedStack` filters this same
|
|
105
|
-
* stream rather than opening its own, so no scoped view can observe a
|
|
106
|
-
* change the stack did not emit. See docs/spec/events.md.
|
|
107
|
-
*/
|
|
108
|
-
changes = new ChangeEmitter();
|
|
109
|
-
constructor(adapter, idTimestampSkewMsValue) {
|
|
110
|
-
this.adapter = adapter;
|
|
111
|
-
this.idTimestampSkewMsValue = idTimestampSkewMsValue;
|
|
112
|
-
}
|
|
113
|
-
/**
|
|
114
|
-
* Announce a mutation that has already been persisted. Called after the
|
|
115
|
-
* adapter write resolves and before the mutating method settles, so
|
|
116
|
-
* `await stack.mutate(...)` guarantees subscribers have been notified —
|
|
117
|
-
* and nothing about work they deferred. A handler cannot fail the write:
|
|
118
|
-
* there is nothing left to fail. See docs/spec/events.md § Handlers.
|
|
119
|
-
*/
|
|
120
|
-
announce(change, record, at) {
|
|
121
|
-
this.changes.emit(change.emission(record, at));
|
|
122
|
-
}
|
|
123
|
-
/**
|
|
124
|
-
* The actor for a `create` journal entry, read off the record as just
|
|
125
|
-
* built. Create is the one journaled write that stamps the requester
|
|
126
|
-
* onto the row itself, so the record in hand is authoritative — and it
|
|
127
|
-
* carries `appId`, which ActorOptions has no field for.
|
|
128
|
-
*/
|
|
129
|
-
static createActor(record) {
|
|
130
|
-
if (!record.updatedBy)
|
|
131
|
-
return undefined;
|
|
132
|
-
return { ...record.updatedBy, ...(record.appId !== undefined && { appId: record.appId }) };
|
|
133
|
-
}
|
|
134
|
-
async getTypeCached(id) {
|
|
135
|
-
const cached = this.typeCache.get(id);
|
|
136
|
-
if (cached)
|
|
137
|
-
return cached;
|
|
138
|
-
const type = await this.adapter.getType(id);
|
|
139
|
-
if (type)
|
|
140
|
-
this.typeCache.set(id, type);
|
|
141
|
-
return type;
|
|
142
|
-
}
|
|
143
|
-
/**
|
|
144
|
-
* Open a Stack over an adapter. Reads ownerEntityId and timezone from the adapter.
|
|
145
|
-
*/
|
|
146
|
-
static async open(adapter, opts = {}) {
|
|
147
|
-
if (!adapter.ownerEntityId) {
|
|
148
|
-
throw new InvalidAdapterError('Invalid adapter: adapter has no ownerEntityId. ' +
|
|
149
|
-
'Initialize the adapter with an ownerEntityId before calling Stack.open().');
|
|
150
|
-
}
|
|
151
|
-
const stack = new Stack(adapter, opts.idTimestampSkewMs === undefined ? DEFAULT_ID_TIMESTAMP_SKEW_MS : opts.idTimestampSkewMs);
|
|
152
|
-
await stack.seedSystemTypes();
|
|
153
|
-
if (opts.ownerProfile) {
|
|
154
|
-
await stack.ensureOwnerEntity(opts.ownerProfile);
|
|
155
|
-
}
|
|
156
|
-
return stack;
|
|
157
|
-
}
|
|
158
|
-
/**
|
|
159
|
-
* Idempotent bootstrap for StackOptions.ownerProfile. Filters by family
|
|
160
|
-
* only (content filtering is capability-gated) and matches `content.did`
|
|
161
|
-
* in memory, cursor-walking so an owner card past page one isn't missed
|
|
162
|
-
* and duplicated. The probe must be blind to nothing the binding rules
|
|
163
|
-
* see, or it mints a card they then refuse and the stack won't open with
|
|
164
|
-
* `ownerProfile`: soft-deleted cards still reserve their `did`, and a card
|
|
165
|
-
* migrated to a later version still holds one.
|
|
166
|
-
* See docs/spec/identity.md § DID bindings.
|
|
167
|
-
*/
|
|
168
|
-
async ensureOwnerEntity(profile) {
|
|
169
|
-
const entityTypeId = `${SYSTEM_TYPES.ENTITY}@1`;
|
|
170
|
-
const existing = await this.getEntityByDid(this.ownerEntityId);
|
|
171
|
-
if (existing)
|
|
172
|
-
return;
|
|
173
|
-
await this.create(entityTypeId, {
|
|
174
|
-
did: this.ownerEntityId,
|
|
175
|
-
name: profile.name,
|
|
176
|
-
...(profile.handle && { handle: profile.handle }),
|
|
177
|
-
});
|
|
178
|
-
}
|
|
179
|
-
get ownerEntityId() {
|
|
180
|
-
return this.adapter.ownerEntityId;
|
|
181
|
-
}
|
|
182
|
-
async getEntityByDid(did) {
|
|
183
|
-
return lookupEntityByDid((q) => this.query(q), did, filtersContent(this.capabilities), true);
|
|
184
|
-
}
|
|
185
|
-
async getOwnerEntity() {
|
|
186
|
-
return this.getEntityByDid(this.ownerEntityId);
|
|
187
|
-
}
|
|
188
|
-
get timezone() {
|
|
189
|
-
return this.adapter.timezone;
|
|
190
|
-
}
|
|
191
|
-
get capabilities() {
|
|
192
|
-
return this.adapter.capabilities;
|
|
193
|
-
}
|
|
194
|
-
/**
|
|
195
|
-
* Get a permission-scoped view of this Stack, as if requests came from
|
|
196
|
-
* the given entity acting as itself (null = anonymous). Plain Stack
|
|
197
|
-
* methods are unscoped; use asEntity() when one Stack serves multiple,
|
|
198
|
-
* possibly untrusted, entities. See
|
|
199
|
-
* docs/spec/access-control.md § Enforcement: Stack.asEntity().
|
|
200
|
-
*/
|
|
201
|
-
asEntity(entityId) {
|
|
202
|
-
return entityId === null ? this.scope(null, null) : this.asActor({ subjectId: entityId });
|
|
203
|
-
}
|
|
204
|
-
/**
|
|
205
|
-
* Scope to an Actor — a delegated app acting for its user, or a
|
|
206
|
-
* TokenSession exactly as StackTokenStore.lookupToken() returns it, which
|
|
207
|
-
* is what a server should reach for at its request boundary. Authority is
|
|
208
|
-
* the intersection of both parties' grants, while authorship and `-own`
|
|
209
|
-
* resolve against the subject. Taking the pair whole leaves no order to
|
|
210
|
-
* swap. See docs/spec/access-control.md § Delegation: principal and subject.
|
|
211
|
-
*/
|
|
212
|
-
asActor(actor) {
|
|
213
|
-
const { subjectId, principalId = subjectId } = normalizeActor(actor);
|
|
214
|
-
return this.scope(principalId, subjectId);
|
|
215
|
-
}
|
|
216
|
-
scope(principalId, subjectId) {
|
|
217
|
-
this.assertOpen();
|
|
218
|
-
return new ScopedStack(scopeToken, {
|
|
219
|
-
stack: this,
|
|
220
|
-
principalId,
|
|
221
|
-
subjectId,
|
|
222
|
-
idTimestampSkewMs: this.idTimestampSkewMsValue,
|
|
223
|
-
adapter: this.adapter,
|
|
224
|
-
changes: this.changes,
|
|
225
|
-
assertOpen: () => this.assertOpen(),
|
|
226
|
-
relaysChanges: this.relaysChanges,
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
// -------------------------------------------------------
|
|
230
|
-
// Types
|
|
231
|
-
// -------------------------------------------------------
|
|
232
|
-
/**
|
|
233
|
-
* Define and persist a Type; call at app startup before creating records
|
|
234
|
-
* of the type. Redefining an existing typeId is checked against the
|
|
235
|
-
* stored schema: identical is a no-op, a name-only change persists, and
|
|
236
|
-
* anything beyond additive evolution throws StackSchemaDriftError. See
|
|
237
|
-
* docs/spec/data-model.md § Schema drift detection.
|
|
238
|
-
*/
|
|
239
|
-
async defineType({ id, name, schema: declared, migratesFrom, }) {
|
|
240
|
-
this.assertOpen();
|
|
241
|
-
// Only read below; the readonly view exists so a handle's literal fits.
|
|
242
|
-
const schema = declared;
|
|
243
|
-
const parsed = parseTypeId(id);
|
|
244
|
-
if (!parsed) {
|
|
245
|
-
throw new StackBadRequestError(`Invalid TypeId format: "${id}". Expected "namespace/name@version", e.g. "com.example.myapp/note@1".`);
|
|
246
|
-
}
|
|
247
|
-
// Register this version even on the idempotent-no-op path —
|
|
248
|
-
// presentAtLatest()'s stale-writer detection depends on it.
|
|
249
|
-
const priorMax = this.maxDefinedVersion.get(parsed.baseId) ?? 0;
|
|
250
|
-
if (parsed.version > priorMax)
|
|
251
|
-
this.maxDefinedVersion.set(parsed.baseId, parsed.version);
|
|
252
|
-
// Shape first: the name checks and hashSchema() below both read the
|
|
253
|
-
// schema as a well-formed one, and a schema off the wire is parsed
|
|
254
|
-
// JSON that no compiler has seen.
|
|
255
|
-
const shapeErrors = validateSchemaShape(schema);
|
|
256
|
-
if (shapeErrors.length > 0) {
|
|
257
|
-
throw new StackValidationError(shapeErrors);
|
|
258
|
-
}
|
|
259
|
-
const nameErrors = [
|
|
260
|
-
...validateSchemaReservedNames(schema),
|
|
261
|
-
...validateSchemaFieldNames(schema),
|
|
262
|
-
];
|
|
263
|
-
if (nameErrors.length > 0) {
|
|
264
|
-
throw new StackValidationError(nameErrors);
|
|
265
|
-
}
|
|
266
|
-
const schemaHash = await hashSchema(schema);
|
|
267
|
-
const existing = await this.getTypeCached(id);
|
|
268
|
-
if (existing) {
|
|
269
|
-
if (existing.schemaHash === schemaHash) {
|
|
270
|
-
if (existing.name === name)
|
|
271
|
-
return existing;
|
|
272
|
-
// else: name-only change — falls through to the write below,
|
|
273
|
-
// schema/hash/createdAt all carried over unchanged.
|
|
274
|
-
}
|
|
275
|
-
else {
|
|
276
|
-
const violations = diffSchemas(existing.schema, schema);
|
|
277
|
-
if (violations.length > 0) {
|
|
278
|
-
throw new StackSchemaDriftError(id, violations);
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
const type = {
|
|
283
|
-
id,
|
|
284
|
-
baseId: parsed.baseId,
|
|
285
|
-
version: parsed.version,
|
|
286
|
-
name,
|
|
287
|
-
schema,
|
|
288
|
-
schemaHash,
|
|
289
|
-
createdAt: existing?.createdAt ?? new Date(),
|
|
290
|
-
...(migratesFrom && { migratesFrom }),
|
|
291
|
-
};
|
|
292
|
-
await this.adapter.saveType(type);
|
|
293
|
-
this.typeCache.set(id, type);
|
|
294
|
-
return type;
|
|
295
|
-
}
|
|
296
|
-
async getType(id) {
|
|
297
|
-
this.assertOpen();
|
|
298
|
-
return this.getTypeCached(id);
|
|
299
|
-
}
|
|
300
|
-
/** Refreshes typeCache wholesale — the explicit way to see a rename made by another writer. */
|
|
301
|
-
async listTypes() {
|
|
302
|
-
this.assertOpen();
|
|
303
|
-
const types = await this.adapter.listTypes();
|
|
304
|
-
for (const type of types)
|
|
305
|
-
this.typeCache.set(type.id, type);
|
|
306
|
-
return types;
|
|
307
|
-
}
|
|
308
|
-
/**
|
|
309
|
-
* Check whether a record's type is compatible with a required schema.
|
|
310
|
-
* Useful for duck-typed consumption across types.
|
|
311
|
-
*/
|
|
312
|
-
async typeIsCompatible(typeId, requiredSchema) {
|
|
313
|
-
this.assertOpen();
|
|
314
|
-
const type = await this.getTypeCached(typeId);
|
|
315
|
-
if (!type)
|
|
316
|
-
return false;
|
|
317
|
-
return isCompatible(type.schema, requiredSchema);
|
|
318
|
-
}
|
|
319
|
-
// -------------------------------------------------------
|
|
320
|
-
// Migration registry
|
|
321
|
-
// -------------------------------------------------------
|
|
322
|
-
/**
|
|
323
|
-
* Register a migration function between two adjacent Type versions; call
|
|
324
|
-
* at app startup after defineType(). Runs in-memory — nothing is written
|
|
325
|
-
* until migrateAll(). Adjacent migrations are composed into chains
|
|
326
|
-
* automatically.
|
|
327
|
-
*/
|
|
328
|
-
registerMigration(migration) {
|
|
329
|
-
this.assertOpen();
|
|
330
|
-
if (this.migrations.has(migration.from)) {
|
|
331
|
-
throw new StackMigrationError(`A migration from "${migration.from}" is already registered.`);
|
|
332
|
-
}
|
|
333
|
-
this.migrations.set(migration.from, migration);
|
|
334
|
-
}
|
|
335
|
-
/**
|
|
336
|
-
* Find and compose a migration path from one TypeId to another.
|
|
337
|
-
* Returns null if no path exists.
|
|
338
|
-
*/
|
|
339
|
-
resolveMigrationPath(fromId, toId) {
|
|
340
|
-
if (fromId === toId)
|
|
341
|
-
return (content) => content;
|
|
342
|
-
const fns = [];
|
|
343
|
-
let current = fromId;
|
|
344
|
-
const visited = new Set();
|
|
345
|
-
while (current !== toId) {
|
|
346
|
-
if (visited.has(current)) {
|
|
347
|
-
throw new StackMigrationError(`Migration cycle detected at "${current}"`);
|
|
348
|
-
}
|
|
349
|
-
visited.add(current);
|
|
350
|
-
const migration = this.migrations.get(current);
|
|
351
|
-
if (!migration)
|
|
352
|
-
return null;
|
|
353
|
-
fns.push(migration.migrate);
|
|
354
|
-
current = migration.to;
|
|
355
|
-
}
|
|
356
|
-
return (content) => fns.reduce((c, fn) => fn(c), content);
|
|
357
|
-
}
|
|
358
|
-
/**
|
|
359
|
-
* Find the latest registered version of a type family.
|
|
360
|
-
* Follows the migration chain from the given typeId to the end.
|
|
361
|
-
*/
|
|
362
|
-
latestTypeId(fromId) {
|
|
363
|
-
let current = fromId;
|
|
364
|
-
const visited = new Set();
|
|
365
|
-
while (this.migrations.has(current)) {
|
|
366
|
-
if (visited.has(current)) {
|
|
367
|
-
throw new StackMigrationError(`Migration cycle detected at "${current}"`);
|
|
368
|
-
}
|
|
369
|
-
visited.add(current);
|
|
370
|
-
current = this.migrations.get(current).to;
|
|
371
|
-
}
|
|
372
|
-
return current;
|
|
373
|
-
}
|
|
374
|
-
/**
|
|
375
|
-
* Eagerly migrate all records of a type family to the latest version —
|
|
376
|
-
* the only way disk state changes version. Sweeps soft-deleted records
|
|
377
|
-
* too, validates each result before writing, and aborts on the first
|
|
378
|
-
* validation failure. See docs/spec/data-model.md § Type migrations.
|
|
379
|
-
*/
|
|
380
|
-
async migrateAll(baseId) {
|
|
381
|
-
this.assertOpen();
|
|
382
|
-
const problem = familyIdProblem(baseId, 'migrateAll');
|
|
383
|
-
if (problem)
|
|
384
|
-
throw new StackValidationError([{ path: 'baseId', message: problem }]);
|
|
385
|
-
const types = await this.adapter.listTypes();
|
|
386
|
-
const familyTypeIds = types.filter((t) => t.baseId === baseId).map((t) => t.id);
|
|
387
|
-
if (familyTypeIds.length === 0) {
|
|
388
|
-
throw new StackMigrationError(`migrateAll: no registered types found for baseId "${baseId}"`);
|
|
389
|
-
}
|
|
390
|
-
let migrated = 0;
|
|
391
|
-
for (const typeId of familyTypeIds) {
|
|
392
|
-
const latestId = this.latestTypeId(typeId);
|
|
393
|
-
if (typeId === latestId)
|
|
394
|
-
continue;
|
|
395
|
-
const migrateFn = this.resolveMigrationPath(typeId, latestId);
|
|
396
|
-
if (!migrateFn)
|
|
397
|
-
continue;
|
|
398
|
-
const latestType = await this.getTypeCached(latestId);
|
|
399
|
-
if (!latestType) {
|
|
400
|
-
throw new StackMigrationError(`migrateAll: target type "${latestId}" is not defined.`);
|
|
401
|
-
}
|
|
402
|
-
let cursor;
|
|
403
|
-
do {
|
|
404
|
-
const result = await this.adapter.queryRecords({
|
|
405
|
-
filter: { typeId, includeDeleted: true, includeUnlisted: true },
|
|
406
|
-
limit: 100,
|
|
407
|
-
cursor,
|
|
408
|
-
});
|
|
409
|
-
for (const record of result.records) {
|
|
410
|
-
// Same checked path commitMigration() takes — a migration
|
|
411
|
-
// function is no more entitled to move a DID binding or repoint
|
|
412
|
-
// an attachment than a request body is. No ifVersion: a batch
|
|
413
|
-
// pass doesn't know each record's version going in.
|
|
414
|
-
await this.commitMigrationChecked(record, latestId, migrateFn(record.content));
|
|
415
|
-
migrated++;
|
|
416
|
-
}
|
|
417
|
-
cursor = result.cursor ?? undefined;
|
|
418
|
-
} while (cursor);
|
|
419
|
-
}
|
|
420
|
-
return { migrated };
|
|
421
|
-
}
|
|
422
|
-
async create(typeIdOrHandle, input, opts = {}) {
|
|
423
|
-
if (isTypeHandle(typeIdOrHandle))
|
|
424
|
-
return typedCreate(this, typeIdOrHandle, input, opts);
|
|
425
|
-
const typeId = typeIdOrHandle;
|
|
426
|
-
this.assertOpen();
|
|
427
|
-
const type = await this.getTypeCached(typeId);
|
|
428
|
-
if (!type) {
|
|
429
|
-
throw new StackBadRequestError(`Unknown type: "${typeId}". Call defineType() first.`);
|
|
430
|
-
}
|
|
431
|
-
const content = dropAbsentFields(input, type.schema);
|
|
432
|
-
// Copied, never aliased: an import loop that reuses one Date across rows
|
|
433
|
-
// (`d.setTime(...)` per record) would otherwise retro-edit every record
|
|
434
|
-
// it had already written, with no version bump and no change event.
|
|
435
|
-
// `instanceof Date`, not `!== undefined`, because this runs ahead of the
|
|
436
|
-
// error block below and .getTime() on a non-Date off the wire would
|
|
437
|
-
// throw before validateClockField() could report it; the fallback value
|
|
438
|
-
// never reaches storage, since that recorded error still throws.
|
|
439
|
-
const createdAt = opts.createdAt instanceof Date ? new Date(opts.createdAt.getTime()) : new Date();
|
|
440
|
-
const updatedAt = opts.updatedAt instanceof Date ? new Date(opts.updatedAt.getTime()) : createdAt;
|
|
441
|
-
// Ahead of every other check on these two lists: a caller that named
|
|
442
|
-
// the wrong surface asked for something this layer does not offer, and
|
|
443
|
-
// reporting its grantee as a malformed relationship target would
|
|
444
|
-
// describe the mistake as the wrong kind of problem.
|
|
445
|
-
assertDataAssociations(opts.associations ?? [], 'associations');
|
|
446
|
-
assertAuthorityAssociations(opts.permissions ?? [], 'permissions');
|
|
447
|
-
const errors = [
|
|
448
|
-
...validateReservedKeys(content),
|
|
449
|
-
...validateContentKeys(content),
|
|
450
|
-
...validateContent(content, type.schema),
|
|
451
|
-
...validateGrantee(typeId, content),
|
|
452
|
-
...validateGrantBaseId(typeId, content),
|
|
453
|
-
...validateAssociations(opts.permissions, 'permissions'),
|
|
454
|
-
...validatePermissions(opts.permissions),
|
|
455
|
-
...validateAssociations(opts.associations),
|
|
456
|
-
...validateClockField(opts.createdAt, 'createdAt'),
|
|
457
|
-
...validateClockField(opts.updatedAt, 'updatedAt'),
|
|
458
|
-
];
|
|
459
|
-
// Compared against the *effective* createdAt, so an updatedAt supplied
|
|
460
|
-
// on its own is caught too: defaulted-createdAt is now, which a
|
|
461
|
-
// backdated updatedAt alone would still precede.
|
|
462
|
-
if (opts.updatedAt !== undefined && updatedAt.getTime() < createdAt.getTime()) {
|
|
463
|
-
errors.push({ path: 'updatedAt', message: 'updatedAt cannot precede createdAt.' });
|
|
464
|
-
}
|
|
465
|
-
if (errors.length > 0) {
|
|
466
|
-
throw new StackValidationError(errors);
|
|
467
|
-
}
|
|
468
|
-
assertContentSize(content, this.capabilities.limits.contentBytes, 'Content');
|
|
469
|
-
if (typeId === `${SYSTEM_TYPES.ATTACHMENT}@1`) {
|
|
470
|
-
await this.checkAttachmentMimeTypeOnCreate(content);
|
|
471
|
-
}
|
|
472
|
-
await this.checkAttachmentAssociationPointers(opts.associations);
|
|
473
|
-
await this.checkBindingsOnCreate(typeId, content);
|
|
474
|
-
if (opts.id !== undefined) {
|
|
475
|
-
validateRecordId(opts.id);
|
|
476
|
-
// Only when both are explicit: an `id` alone is a pure position
|
|
477
|
-
// choice, with no second timestamp to agree with.
|
|
478
|
-
if (opts.createdAt !== undefined) {
|
|
479
|
-
validateIdTimestampSkew(opts.id, this.idTimestampSkewMsValue, opts.createdAt.getTime(), 'createdAt');
|
|
480
|
-
}
|
|
481
|
-
// A generated id names nothing, so a create under a parent cannot
|
|
482
|
-
// close a loop. A caller-supplied one can: existing records may
|
|
483
|
-
// already point at it, and the chain above the parent can lead back
|
|
484
|
-
// to it — so the walk is asked only for that combination.
|
|
485
|
-
// See docs/spec/data-model.md § Reparenting.
|
|
486
|
-
if (opts.parentId !== undefined)
|
|
487
|
-
await this.assertNoParentCycle(opts.id, opts.parentId);
|
|
488
|
-
}
|
|
489
|
-
const createdBy = normalizeActor(opts.createdBy);
|
|
490
|
-
const associations = baseIdOf(typeId) === SYSTEM_TYPES.GROUP
|
|
491
|
-
? stampGroupAdmin(opts.associations, createdBy?.subjectId ?? this.ownerEntityId)
|
|
492
|
-
: opts.associations;
|
|
493
|
-
// createdAt drives the id, so the two agree by construction rather than
|
|
494
|
-
// by coincidence. An explicit one mints via generateIdForTimestamp(),
|
|
495
|
-
// which never clamps to "now": generateId()'s monotonic floor would
|
|
496
|
-
// otherwise pull a deliberately historical id forward once this process
|
|
497
|
-
// has minted any live id past it.
|
|
498
|
-
const id = opts.id ??
|
|
499
|
-
(opts.createdAt !== undefined
|
|
500
|
-
? generateIdForTimestamp(createdAt.getTime())
|
|
501
|
-
: generateId(createdAt.getTime()));
|
|
502
|
-
// An id field a caller supplies is a value or it is absent — never the
|
|
503
|
-
// empty string, which names nobody. Refused rather than dropped, so the
|
|
504
|
-
// caller is never silently ignored.
|
|
505
|
-
if (opts.appId === '') {
|
|
506
|
-
throw new StackBadRequestError('Invalid appId: the empty string is not an id.');
|
|
507
|
-
}
|
|
508
|
-
// Every create naming a parent owes the reference check, whether or not
|
|
509
|
-
// it supplied an id: a destination a caller names has to exist.
|
|
510
|
-
if (opts.parentId !== undefined)
|
|
511
|
-
await this.assertParentExists(id, opts.parentId);
|
|
512
|
-
const record = {
|
|
513
|
-
id,
|
|
514
|
-
typeId,
|
|
515
|
-
createdAt,
|
|
516
|
-
updatedAt,
|
|
517
|
-
content,
|
|
518
|
-
version: 1,
|
|
519
|
-
// Presence, not truthiness: '' is refused above, so absence is the
|
|
520
|
-
// only thing a falsy value could mean here.
|
|
521
|
-
...(opts.parentId !== undefined && { parentId: opts.parentId }),
|
|
522
|
-
...(opts.appId !== undefined && { appId: opts.appId }),
|
|
523
|
-
// A create's actor is its author, so `updatedBy` is derived rather
|
|
524
|
-
// than taken: stamping it here keeps "absent means an unscoped write"
|
|
525
|
-
// true of version 1 as it is of every later version.
|
|
526
|
-
...(createdBy && { createdBy, updatedBy: createdBy }),
|
|
527
|
-
...(opts.permissions?.length && { permissions: opts.permissions }),
|
|
528
|
-
...(associations?.length && { associations }),
|
|
529
|
-
...(opts.unlisted && { unlistedAt: createdAt }),
|
|
530
|
-
};
|
|
531
|
-
const change = new PendingChange('create', { actor: Stack.createActor(record) });
|
|
532
|
-
const created = await this.adapter.createRecord(record, { journal: change.journal });
|
|
533
|
-
this.announce(change, created);
|
|
534
|
-
return created;
|
|
535
|
-
}
|
|
536
|
-
/**
|
|
537
|
-
* Apply the registered migration chain in memory, for presentAt:
|
|
538
|
-
* 'latest'. Never writes back. Throws StackMigrationError when the
|
|
539
|
-
* record's version can't be reconciled with what this instance has
|
|
540
|
-
* registered — the stale-writer case. See docs/spec/data-model.md
|
|
541
|
-
* § Type migrations.
|
|
542
|
-
*/
|
|
543
|
-
presentAtLatest(record) {
|
|
544
|
-
const latestId = this.latestTypeId(record.typeId);
|
|
545
|
-
if (latestId !== record.typeId) {
|
|
546
|
-
// latestTypeId() found this by walking the same migration graph
|
|
547
|
-
// resolveMigrationPath() walks, from the same starting point, so a
|
|
548
|
-
// path is always resolvable here.
|
|
549
|
-
const migrateFn = this.resolveMigrationPath(record.typeId, latestId);
|
|
550
|
-
return { ...record, typeId: latestId, content: migrateFn(record.content) };
|
|
551
|
-
}
|
|
552
|
-
const parsed = parseTypeId(record.typeId);
|
|
553
|
-
const knownMax = parsed ? this.maxDefinedVersion.get(parsed.baseId) : undefined;
|
|
554
|
-
if (parsed && knownMax !== undefined && parsed.version !== knownMax) {
|
|
555
|
-
const direction = parsed.version > knownMax
|
|
556
|
-
? `the record is newer than this app instance understands — update the app, or register the missing migrations`
|
|
557
|
-
: `no registered migration bridges the gap — call stack.registerMigration()`;
|
|
558
|
-
throw new StackMigrationError(`Record "${record.id}" is at "${record.typeId}", but this app instance has defined ` +
|
|
559
|
-
`up to "${parsed.baseId}@${knownMax}": ${direction}. Omit presentAt: "latest" to ` +
|
|
560
|
-
`read the record as stored.`);
|
|
561
|
-
}
|
|
562
|
-
return record;
|
|
563
|
-
}
|
|
564
|
-
async get(idOrHandle, idOrOpts = {}) {
|
|
565
|
-
if (isTypeHandle(idOrHandle))
|
|
566
|
-
return typedGet(this, idOrHandle, idOrOpts);
|
|
567
|
-
const id = idOrHandle;
|
|
568
|
-
const opts = idOrOpts;
|
|
569
|
-
this.assertOpen();
|
|
570
|
-
const record = await this.adapter.getRecord(id);
|
|
571
|
-
if (!record)
|
|
572
|
-
return null;
|
|
573
|
-
if (record.deletedAt && !opts.includeDeleted)
|
|
574
|
-
return null;
|
|
575
|
-
return opts.presentAt === 'latest' ? this.presentAtLatest(record) : record;
|
|
576
|
-
}
|
|
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 ?? {});
|
|
584
|
-
this.assertOpen();
|
|
585
|
-
assertNonEmptyChangeSet(changes);
|
|
586
|
-
const existing = await this.adapter.getRecord(id);
|
|
587
|
-
if (!existing) {
|
|
588
|
-
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
589
|
-
}
|
|
590
|
-
this.refuseIfDeleted(existing);
|
|
591
|
-
// Checked before validation, so a caller that lost the race learns its
|
|
592
|
-
// version is stale rather than that its patch is bad, and before the
|
|
593
|
-
// no-op short-circuit below, so a precondition is never satisfied by a
|
|
594
|
-
// write that turned out to move nothing.
|
|
595
|
-
if (takesIfVersion(changes))
|
|
596
|
-
this.checkIfVersion(existing, opts.ifVersion);
|
|
597
|
-
const merged = await this.validateChangeSet(id, existing, changes);
|
|
598
|
-
const ops = changeSetOps(existing, changes, merged);
|
|
599
|
-
// Nothing moved, so there is nothing to version. Decided here rather
|
|
600
|
-
// than in the adapter: "did this change anything" is a question about
|
|
601
|
-
// the record's meaning, and an adapter is never handed a write that
|
|
602
|
-
// writes nothing.
|
|
603
|
-
if (ops.length === 0)
|
|
604
|
-
return existing;
|
|
605
|
-
// A change set naming only aspects the journal already keeps in full
|
|
606
|
-
// doesn't bump — the same rule associate()/dissociate() follow
|
|
607
|
-
// unconditionally. Its actor travels as an explicit opt rather than a
|
|
608
|
-
// record stamp, since a non-bumping write never touches `updatedBy`.
|
|
609
|
-
// See docs/spec/versioning.md § Version history.
|
|
610
|
-
const bumps = bumpsVersion(ops);
|
|
611
|
-
// Computed against the same before/after changeSetOps compared, so
|
|
612
|
-
// whether associate/dissociate/reshare appear in `ops` and what
|
|
613
|
-
// the journal lists can never disagree. Both halves of the partition
|
|
614
|
-
// produce the same tagged edits and travel as one list: the journal's
|
|
615
|
-
// argument is prior state, and an ACL element's is no different from a
|
|
616
|
-
// tag's. See docs/spec/events.md § The event shape.
|
|
617
|
-
const assocDelta = [
|
|
618
|
-
...(changes.associations
|
|
619
|
-
? associationDelta(existing.associations ?? [], changes.associations)
|
|
620
|
-
: []),
|
|
621
|
-
...(changes.permissions
|
|
622
|
-
? associationDelta(existing.permissions ?? [], changes.permissions)
|
|
623
|
-
: []),
|
|
624
|
-
];
|
|
625
|
-
const previousParentId = existing.parentId ?? null;
|
|
626
|
-
const change = new PendingChange(ops, {
|
|
627
|
-
actor: normalizeActor(opts.actor),
|
|
628
|
-
...(ops.includes('reparent') && { previousParentId }),
|
|
629
|
-
...(assocDelta.length && { associations: assocDelta }),
|
|
630
|
-
});
|
|
631
|
-
const updated = await this.adapter.mutateRecord(id, effectiveChanges(changes, ops), {
|
|
632
|
-
...(bumps ? this.writeOptions(existing, opts) : { actor: normalizeActor(opts.actor) }),
|
|
633
|
-
bumpsVersion: bumps,
|
|
634
|
-
journal: change.journal,
|
|
635
|
-
});
|
|
636
|
-
this.announce(change, updated);
|
|
637
|
-
return updated;
|
|
638
|
-
}
|
|
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);
|
|
644
|
-
}
|
|
645
|
-
/**
|
|
646
|
-
* Every check a change set owes before anything is written, in one pass:
|
|
647
|
-
* one refused key refuses the call, so none of them may run after a
|
|
648
|
-
* partial write. Returns the merged content when the set carries a
|
|
649
|
-
* content patch, since the caller needs it to decide whether content
|
|
650
|
-
* actually moved.
|
|
651
|
-
*
|
|
652
|
-
* Parent existence and acyclicity are asked only when the destination
|
|
653
|
-
* differs from where the record already sits — a move to where it
|
|
654
|
-
* already is names no new edge, and would otherwise cost a read apiece.
|
|
655
|
-
*/
|
|
656
|
-
async validateChangeSet(id, existing, changes) {
|
|
657
|
-
const { contentPatch, permissions, associations, parentId } = changes;
|
|
658
|
-
// Each key replaces only within its own domain. Asked before the
|
|
659
|
-
// shape checks below so a misrouted element is reported as the wrong
|
|
660
|
-
// surface rather than as a malformed value of the right one, and
|
|
661
|
-
// asked here rather than in `ScopedStack` so an unscoped `Stack`, an
|
|
662
|
-
// import and a server mapping a request body are all held to it.
|
|
663
|
-
if (associations)
|
|
664
|
-
assertDataAssociations(associations, 'associations');
|
|
665
|
-
if (permissions)
|
|
666
|
-
assertAuthorityAssociations(permissions, 'permissions');
|
|
667
|
-
const errors = [
|
|
668
|
-
...(contentPatch
|
|
669
|
-
? [
|
|
670
|
-
...validateReservedKeys(contentPatch),
|
|
671
|
-
...validatePatchValues(contentPatch),
|
|
672
|
-
...validateContentKeys(contentPatch),
|
|
673
|
-
]
|
|
674
|
-
: []),
|
|
675
|
-
...(permissions
|
|
676
|
-
? [...validateAssociations(permissions, 'permissions'), ...validatePermissions(permissions)]
|
|
677
|
-
: []),
|
|
678
|
-
...(associations ? validateAssociations(associations) : []),
|
|
679
|
-
];
|
|
680
|
-
if (errors.length > 0)
|
|
681
|
-
throw new StackValidationError(errors);
|
|
682
|
-
await this.checkAttachmentAssociationPointers(associations, existing.associations);
|
|
683
|
-
let merged;
|
|
684
|
-
if (contentPatch) {
|
|
685
|
-
// The patch is what travels, so the patch is what's measured — a
|
|
686
|
-
// small patch against a large record is not an oversized request.
|
|
687
|
-
assertContentSize(contentPatch, this.capabilities.limits.contentBytes, 'Patch');
|
|
688
|
-
const type = await this.getTypeCached(existing.typeId);
|
|
689
|
-
if (!type) {
|
|
690
|
-
throw new StackBadRequestError(`Unknown type: "${existing.typeId}"`);
|
|
691
|
-
}
|
|
692
|
-
merged = applyMergePatch(existing.content, contentPatch);
|
|
693
|
-
const contentErrors = [
|
|
694
|
-
...validateContent(merged, type.schema),
|
|
695
|
-
...validateGrantee(existing.typeId, merged),
|
|
696
|
-
...validateGrantBaseId(existing.typeId, merged),
|
|
697
|
-
];
|
|
698
|
-
if (contentErrors.length > 0)
|
|
699
|
-
throw new StackValidationError(contentErrors);
|
|
700
|
-
if (existing.typeId === `${SYSTEM_TYPES.ATTACHMENT}@1`) {
|
|
701
|
-
// Presence decides mimeType and value decides the rest: re-sending
|
|
702
|
-
// the mimeType a record already holds is refused outright, while a
|
|
703
|
-
// client round-tripping fileId or size unchanged has claimed nothing.
|
|
704
|
-
this.assertAttachmentImmutable((field) => Object.prototype.hasOwnProperty.call(contentPatch, field) &&
|
|
705
|
-
(field === 'mimeType' ||
|
|
706
|
-
merged[field] !==
|
|
707
|
-
existing.content[field]));
|
|
708
|
-
}
|
|
709
|
-
await this.checkBindingsOnUpdate(existing.typeId, id, contentPatch, existing.content, merged);
|
|
710
|
-
if (id === SYSTEM_TYPES.CONFIG) {
|
|
711
|
-
this.checkConfigEntityIdUnchanged(existing.content.entityId, merged.entityId);
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
if (associations !== undefined) {
|
|
715
|
-
this.assertGroupAdminRemains(existing, associations);
|
|
716
|
-
}
|
|
717
|
-
if (parentId !== undefined && parentId !== null && parentId !== (existing.parentId ?? null)) {
|
|
718
|
-
await this.assertParentExists(id, parentId);
|
|
719
|
-
await this.assertNoParentCycle(id, parentId);
|
|
720
|
-
}
|
|
721
|
-
return merged;
|
|
722
|
-
}
|
|
723
|
-
/**
|
|
724
|
-
* Refuse a write that would leave a `_group` Record with no `admin` on its
|
|
725
|
-
* roster. Asked of the roster the write would *produce*, which is the
|
|
726
|
-
* whole of the self-removal question: an admin removing themselves passes
|
|
727
|
-
* while another remains and is refused when they are the last, without
|
|
728
|
-
* either case naming who is going.
|
|
729
|
-
*
|
|
730
|
-
* An integrity constraint on the Record, not a permission question, so it
|
|
731
|
-
* lives here rather than in `ScopedStack` and binds every requester — the
|
|
732
|
-
* stack owner included. See docs/spec/identity.md § Group.
|
|
733
|
-
*/
|
|
734
|
-
assertGroupAdminRemains(record, next) {
|
|
735
|
-
if (!isGroupRecord(record))
|
|
736
|
-
return;
|
|
737
|
-
if (hasGroupAdmin(next))
|
|
738
|
-
return;
|
|
739
|
-
throw new StackConflictError(`Cannot leave group "${record.id}" without an admin: a _group record's roster keeps at ` +
|
|
740
|
-
'least one `admin` relationship association. Name the incoming admin in the same write.');
|
|
741
|
-
}
|
|
742
|
-
/**
|
|
743
|
-
* Add associations to a record — no-bump and no snapshot, per the
|
|
744
|
-
* StackClient declaration above.
|
|
745
|
-
*
|
|
746
|
-
* An association the record already holds, saying the same thing, is a
|
|
747
|
-
* no-op; one matching an existing association's identity but naming a
|
|
748
|
-
* different `attachmentRecordId` — or none, which clears the one stored —
|
|
749
|
-
* re-points it in place rather than adding a second reference to the same
|
|
750
|
-
* file. What a re-point overwrote is kept only by the journal, on that
|
|
751
|
-
* entry's `repoint`. See docs/spec/journal.md § The entry.
|
|
752
|
-
*/
|
|
753
|
-
async associate(id, associations, opts = {}) {
|
|
754
|
-
return this.amendAssociations(id, associations.map((association) => ({ op: 'add', association })), opts, 'associate()');
|
|
755
|
-
}
|
|
756
|
-
/**
|
|
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
|
|
760
|
-
* `attachmentRecordId` describes nothing current once it is gone; the
|
|
761
|
-
* journal is where it survives.
|
|
762
|
-
*/
|
|
763
|
-
async dissociate(id, associations, opts = {}) {
|
|
764
|
-
return this.amendAssociations(id, associations.map((association) => ({ op: 'remove', association })), opts, 'dissociate()');
|
|
765
|
-
}
|
|
766
|
-
/**
|
|
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.
|
|
772
|
-
*/
|
|
773
|
-
async amendAssociations(id, changes, opts = {}, surface = 'amendAssociations()') {
|
|
774
|
-
this.assertOpen();
|
|
775
|
-
assertAssociationEdits(changes, surface, 'data');
|
|
776
|
-
const existing = await this.requireRecord(id);
|
|
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)
|
|
782
|
-
return existing;
|
|
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 });
|
|
791
|
-
this.announce(change, updated);
|
|
792
|
-
return updated;
|
|
793
|
-
}
|
|
794
|
-
/**
|
|
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.
|
|
808
|
-
*/
|
|
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()') {
|
|
818
|
-
this.assertOpen();
|
|
819
|
-
assertAssociationEdits(changes, surface, 'authority');
|
|
820
|
-
const existing = await this.requireRecord(id);
|
|
821
|
-
this.refuseIfDeleted(existing);
|
|
822
|
-
const current = existing.permissions ?? [];
|
|
823
|
-
const next = applyAssociationEdits(current, changes);
|
|
824
|
-
const delta = associationDelta(current, next);
|
|
825
|
-
if (delta.length === 0)
|
|
826
|
-
return existing;
|
|
827
|
-
this.assertPermissionSet(next);
|
|
828
|
-
const change = new PendingChange('reshare', {
|
|
829
|
-
actor: normalizeActor(opts.actor),
|
|
830
|
-
associations: delta,
|
|
831
|
-
});
|
|
832
|
-
const updated = await this.adapter.amendAssociations(id, changes, { journal: change.journal });
|
|
833
|
-
this.announce(change, updated);
|
|
834
|
-
return updated;
|
|
835
|
-
}
|
|
836
|
-
/**
|
|
837
|
-
* Hold a permission set to the cross-element invariant — see
|
|
838
|
-
* validatePermissions() in access.ts for why it reads the post-state.
|
|
839
|
-
*/
|
|
840
|
-
assertPermissionSet(next) {
|
|
841
|
-
const errors = validatePermissions(next);
|
|
842
|
-
if (errors.length > 0)
|
|
843
|
-
throw new StackValidationError(errors);
|
|
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
|
-
}
|
|
857
|
-
/** The record, or the not-found refusal every mutating verb owes. */
|
|
858
|
-
async requireRecord(id) {
|
|
859
|
-
const record = await this.adapter.getRecord(id);
|
|
860
|
-
if (!record)
|
|
861
|
-
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
862
|
-
return record;
|
|
863
|
-
}
|
|
864
|
-
/**
|
|
865
|
-
* The checks a *caller-named* destination owes, before the cycle walk:
|
|
866
|
-
* `parentId` is a real, well-formed record id. Format first, so a
|
|
867
|
-
* malformed one is a 400 naming the problem rather than a read that
|
|
868
|
-
* cannot match. Existence closes the gap between the owner path and a
|
|
869
|
-
* non-owner's, where canReadReferent() already refuses a parent that
|
|
870
|
-
* isn't there.
|
|
871
|
-
*
|
|
872
|
-
* restoreVersion() deliberately does not call this — see its own comment.
|
|
873
|
-
* See docs/spec/data-model.md § Reparenting.
|
|
874
|
-
*/
|
|
875
|
-
async assertParentExists(id, parentId) {
|
|
876
|
-
validateParentId(parentId);
|
|
877
|
-
if (!(await this.adapter.getRecord(parentId))) {
|
|
878
|
-
throw new StackConflictError(`Cannot parent record "${id}" to "${parentId}": no such record. A container has to ` +
|
|
879
|
-
'exist when it is named.');
|
|
880
|
-
}
|
|
881
|
-
}
|
|
882
|
-
/**
|
|
883
|
-
* Refuse an edge that would make a record its own ancestor — asked at
|
|
884
|
-
* every site that adds one. Nothing downstream (a generator deriving a
|
|
885
|
-
* page path, a folder view) is written to survive a cycle.
|
|
886
|
-
*
|
|
887
|
-
* Walks with the unscoped adapter: a walk that skipped the links a
|
|
888
|
-
* requester cannot read would let a cycle be assembled through them and
|
|
889
|
-
* break the invariant for every reader.
|
|
890
|
-
*
|
|
891
|
-
* Read-then-write, so two moves racing on opposite ends of one chain can
|
|
892
|
-
* both pass — the same deferral as checkBindingUnique() above, since
|
|
893
|
-
* closing it would put a graph constraint in the storage contract.
|
|
894
|
-
* Consumers walking `parentId` should carry a visited set rather than
|
|
895
|
-
* trust this alone. See docs/spec/data-model.md § Reparenting.
|
|
896
|
-
*/
|
|
897
|
-
async assertNoParentCycle(id, parentId) {
|
|
898
|
-
let cursor = parentId;
|
|
899
|
-
for (let depth = 0; cursor !== undefined; depth++) {
|
|
900
|
-
if (cursor === id) {
|
|
901
|
-
throw new StackConflictError(`Cannot parent record "${id}" to "${parentId}": it is a descendant of "${id}", ` +
|
|
902
|
-
'and the move would make the record its own ancestor.');
|
|
903
|
-
}
|
|
904
|
-
// The cap bounds work, and guarantees this terminates on a chain that
|
|
905
|
-
// is already cyclic. It does not refuse the move: a chain this long is
|
|
906
|
-
// past what the check can speak to, not evidence of a loop, and
|
|
907
|
-
// refusing it would claim an invariant core does not maintain.
|
|
908
|
-
if (depth >= MAX_PARENT_DEPTH)
|
|
909
|
-
return;
|
|
910
|
-
cursor = (await this.adapter.getRecord(cursor))?.parentId;
|
|
911
|
-
}
|
|
912
|
-
}
|
|
913
|
-
/**
|
|
914
|
-
* Soft-delete a record (default) or purge it permanently, removing
|
|
915
|
-
* the record and its version history. Soft delete snapshots and bumps
|
|
916
|
-
* version; a no-op if already deleted. `_config` is never deletable
|
|
917
|
-
* (docs/spec.md § The `_config` record). See docs/spec/versioning.md
|
|
918
|
-
* § Deletion.
|
|
919
|
-
*
|
|
920
|
-
* A purge reports the files the record referenced. It deletes none of
|
|
921
|
-
* them — byte lifetime is not the purged record's to decide — but it
|
|
922
|
-
* destroys the only rows naming them, so a caller who means to erase the
|
|
923
|
-
* bytes too has nowhere else to read the argument from afterwards. See
|
|
924
|
-
* docs/spec/attachments.md § A purge strands the bytes it referenced.
|
|
925
|
-
*
|
|
926
|
-
* deleteAndReturn() is this plus the record itself; reach for that
|
|
927
|
-
* instead when the caller needs the record too — building a response
|
|
928
|
-
* body from a separate read beforehand is exactly the race and the extra
|
|
929
|
-
* round trip it exists to remove.
|
|
930
|
-
*/
|
|
931
|
-
async delete(id, opts = {}) {
|
|
932
|
-
const { referencedFileIds } = await this.deleteAndReturn(id, opts);
|
|
933
|
-
return { referencedFileIds };
|
|
934
|
-
}
|
|
935
|
-
/**
|
|
936
|
-
* delete(), reporting the record it acted on alongside the files it
|
|
937
|
-
* stranded: as it stood immediately before destruction for a
|
|
938
|
-
* purge, or as the resulting tombstone for a soft delete. Both are
|
|
939
|
-
* captured inside the same write that destroys or tombstones the record,
|
|
940
|
-
* never a read beforehand — a read-then-delete would leave a window
|
|
941
|
-
* where a concurrent write can land, get destroyed or overwritten by
|
|
942
|
-
* this call, and never appear in the record this returns.
|
|
943
|
-
*/
|
|
944
|
-
async deleteAndReturn(id, opts = {}) {
|
|
945
|
-
this.assertOpen();
|
|
946
|
-
if (id === SYSTEM_TYPES.CONFIG) {
|
|
947
|
-
throw new StackConflictError("Cannot delete the _config record: it holds the stack's identity and is required for every permission check.");
|
|
948
|
-
}
|
|
949
|
-
if (opts.purge) {
|
|
950
|
-
// The adapter hands back what it destroyed, captured inside the same
|
|
951
|
-
// write: a read here instead would race the delete, and afterwards
|
|
952
|
-
// there is nothing left to read. Null means there was no record, so
|
|
953
|
-
// nothing was purged and nothing is announced.
|
|
954
|
-
const change = new PendingChange('purge', { actor: normalizeActor(opts.actor) });
|
|
955
|
-
const purged = await this.adapter.deleteRecord(id, {
|
|
956
|
-
purge: true,
|
|
957
|
-
ifVersion: opts.ifVersion,
|
|
958
|
-
journal: change.journal,
|
|
959
|
-
});
|
|
960
|
-
if (!purged)
|
|
961
|
-
return { record: null, referencedFileIds: [] };
|
|
962
|
-
this.announce(change, purged);
|
|
963
|
-
return { record: purged, referencedFileIds: await this.referencedFileIds(purged) };
|
|
964
|
-
}
|
|
965
|
-
const existing = await this.adapter.getRecord(id);
|
|
966
|
-
if (!existing) {
|
|
967
|
-
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
968
|
-
}
|
|
969
|
-
this.checkIfVersion(existing, opts.ifVersion);
|
|
970
|
-
// A tombstone's references stand — undelete() must find its
|
|
971
|
-
// attachments intact — so nothing is stranded and nothing is reported.
|
|
972
|
-
if (existing.deletedAt)
|
|
973
|
-
return { record: existing, referencedFileIds: [] };
|
|
974
|
-
const change = new PendingChange('delete', { actor: normalizeActor(opts.actor) });
|
|
975
|
-
const deleted = await this.adapter.deleteRecord(id, {
|
|
976
|
-
...this.writeOptions(existing, opts),
|
|
977
|
-
journal: change.journal,
|
|
978
|
-
});
|
|
979
|
-
if (deleted)
|
|
980
|
-
this.announce(change, deleted);
|
|
981
|
-
return { record: deleted ?? existing, referencedFileIds: [] };
|
|
982
|
-
}
|
|
983
|
-
/**
|
|
984
|
-
* The files a record references, by the same definition
|
|
985
|
-
* deleteAttachment() and the garbage sweep use: attachment associations
|
|
986
|
-
* and top-level `file-ref` content fields. An `_attachment` record's own
|
|
987
|
-
* `fileId` is a plain string by design and is not one, which is why
|
|
988
|
-
* purging a metadata record reports nothing — see initSystemTypes().
|
|
989
|
-
*/
|
|
990
|
-
async referencedFileIds(record) {
|
|
991
|
-
const fromAssociations = (record.associations ?? []).flatMap((a) => a.kind === 'attachment' ? [a.fileId] : []);
|
|
992
|
-
const type = await this.getType(record.typeId);
|
|
993
|
-
const content = record.content;
|
|
994
|
-
const fromContent = Object.entries(type?.schema ?? {}).flatMap(([field, def]) => def.kind === 'file-ref' && typeof content[field] === 'string' ? [content[field]] : []);
|
|
995
|
-
return [...new Set([...fromAssociations, ...fromContent])];
|
|
996
|
-
}
|
|
997
|
-
/**
|
|
998
|
-
* Reverse a soft delete. Idempotent — undeleting a record that isn't
|
|
999
|
-
* deleted returns it unchanged. Purged records are gone, so this
|
|
1000
|
-
* throws StackNotFoundError for them just like any other missing record.
|
|
1001
|
-
* Snapshots and bumps version, same as delete().
|
|
1002
|
-
*/
|
|
1003
|
-
async undelete(id, opts = {}) {
|
|
1004
|
-
this.assertOpen();
|
|
1005
|
-
const existing = await this.adapter.getRecord(id);
|
|
1006
|
-
if (!existing) {
|
|
1007
|
-
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
1008
|
-
}
|
|
1009
|
-
this.checkIfVersion(existing, opts.ifVersion);
|
|
1010
|
-
if (!existing.deletedAt)
|
|
1011
|
-
return existing;
|
|
1012
|
-
const change = new PendingChange('undelete', { actor: normalizeActor(opts.actor) });
|
|
1013
|
-
const undeleted = await this.adapter.undeleteRecord(id, {
|
|
1014
|
-
...this.writeOptions(existing, opts),
|
|
1015
|
-
journal: change.journal,
|
|
1016
|
-
});
|
|
1017
|
-
this.announce(change, undeleted);
|
|
1018
|
-
return undeleted;
|
|
1019
|
-
}
|
|
1020
|
-
async query(first = {}, typedQueryArg) {
|
|
1021
|
-
if (isTypeHandle(first))
|
|
1022
|
-
return typedQuery(this, first, typedQueryArg);
|
|
1023
|
-
const query = first;
|
|
1024
|
-
this.assertOpen();
|
|
1025
|
-
const { presentAt, filter, limit: rawLimit, ...rest } = query;
|
|
1026
|
-
assertQueryCapabilities(filter, this.adapter.capabilities);
|
|
1027
|
-
assertValidSort(query.sort);
|
|
1028
|
-
assertSortCapability(query.sort, this.adapter.capabilities);
|
|
1029
|
-
assertValidAssociationFilters(filter);
|
|
1030
|
-
assertValidBaseIdFilter(filter);
|
|
1031
|
-
const limit = rawLimit !== undefined ? Math.min(rawLimit, MAX_QUERY_LIMIT) : undefined;
|
|
1032
|
-
const resolvedFilter = await this.resolveBaseIdFilter(filter);
|
|
1033
|
-
if (resolvedFilter === EMPTY_FAMILY) {
|
|
1034
|
-
return { records: [], cursor: null };
|
|
1035
|
-
}
|
|
1036
|
-
const result = await this.adapter.queryRecords({
|
|
1037
|
-
...rest,
|
|
1038
|
-
...(query.sort && { sort: normalizeSort(query.sort) }),
|
|
1039
|
-
...(resolvedFilter !== undefined && { filter: resolvedFilter }),
|
|
1040
|
-
...(limit !== undefined && { limit }),
|
|
1041
|
-
});
|
|
1042
|
-
if (presentAt !== 'latest')
|
|
1043
|
-
return result;
|
|
1044
|
-
return { ...result, records: result.records.map((r) => this.presentAtLatest(r)) };
|
|
1045
|
-
}
|
|
1046
|
-
/**
|
|
1047
|
-
* Resolve filter.baseId into a concrete typeId set (intersected with
|
|
1048
|
-
* filter.typeId when both are given), so adapters never need their own
|
|
1049
|
-
* baseId concept. Returns EMPTY_FAMILY when the resolved set is empty so
|
|
1050
|
-
* the caller can short-circuit without an adapter round trip.
|
|
1051
|
-
*/
|
|
1052
|
-
async resolveBaseIdFilter(filter) {
|
|
1053
|
-
if (filter?.baseId === undefined)
|
|
1054
|
-
return filter;
|
|
1055
|
-
const { baseId, typeId, ...rest } = filter;
|
|
1056
|
-
const requestedBaseIds = Array.isArray(baseId) ? baseId : [baseId];
|
|
1057
|
-
const types = await this.adapter.listTypes();
|
|
1058
|
-
const familyTypeIds = types.filter((t) => requestedBaseIds.includes(t.baseId)).map((t) => t.id);
|
|
1059
|
-
const resolvedTypeIds = typeId === undefined
|
|
1060
|
-
? familyTypeIds
|
|
1061
|
-
: familyTypeIds.filter((id) => Array.isArray(typeId) ? typeId.includes(id) : typeId === id);
|
|
1062
|
-
if (resolvedTypeIds.length === 0)
|
|
1063
|
-
return EMPTY_FAMILY;
|
|
1064
|
-
return { ...rest, typeId: resolvedTypeIds };
|
|
1065
|
-
}
|
|
1066
|
-
// -------------------------------------------------------
|
|
1067
|
-
// Versions
|
|
1068
|
-
// -------------------------------------------------------
|
|
1069
|
-
async getVersions(id, query = {}) {
|
|
1070
|
-
this.assertOpen();
|
|
1071
|
-
assertValidVersionsQuery(query);
|
|
1072
|
-
return this.adapter.getVersions(id, query);
|
|
1073
|
-
}
|
|
1074
|
-
/**
|
|
1075
|
-
* A record's change journal, oldest first — what moved, who moved it, and
|
|
1076
|
-
* the association deltas nothing else retains. `ScopedStack` applies the
|
|
1077
|
-
* mutate-surface gate; this layer is unscoped and trusted by definition.
|
|
1078
|
-
*
|
|
1079
|
-
* A record that isn't there is StackNotFoundError, a purged one included:
|
|
1080
|
-
* a destroyed log and an empty one are not the same answer.
|
|
1081
|
-
* See docs/spec/journal.md § Reading it.
|
|
1082
|
-
*/
|
|
1083
|
-
async getJournal(id, query = {}) {
|
|
1084
|
-
this.assertOpen();
|
|
1085
|
-
assertValidJournalQuery(query);
|
|
1086
|
-
return this.adapter.getJournal(id, query);
|
|
1087
|
-
}
|
|
1088
|
-
async getVersion(id, version) {
|
|
1089
|
-
this.assertOpen();
|
|
1090
|
-
return this.adapter.getVersion(id, version);
|
|
1091
|
-
}
|
|
1092
|
-
/**
|
|
1093
|
-
* Restore a record to a previous version by creating a new version —
|
|
1094
|
-
* never rewrites history. The snapshot is validated against its own
|
|
1095
|
-
* stored typeId, and puts back `content` and `typeId` alone: a snapshot
|
|
1096
|
-
* carries nothing else. See docs/spec/versioning.md § Restore semantics.
|
|
1097
|
-
*/
|
|
1098
|
-
async restoreVersion(id, version, opts = {}) {
|
|
1099
|
-
this.assertOpen();
|
|
1100
|
-
const existing = await this.adapter.getRecord(id);
|
|
1101
|
-
if (!existing) {
|
|
1102
|
-
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
1103
|
-
}
|
|
1104
|
-
this.refuseIfDeleted(existing);
|
|
1105
|
-
this.checkIfVersion(existing, opts.ifVersion);
|
|
1106
|
-
const target = await this.adapter.getVersion(id, version);
|
|
1107
|
-
if (!target) {
|
|
1108
|
-
throw new StackNotFoundError(`Version ${version} not found for record "${id}"`);
|
|
1109
|
-
}
|
|
1110
|
-
const type = await this.getTypeCached(target.typeId);
|
|
1111
|
-
if (!type) {
|
|
1112
|
-
throw new StackBadRequestError(`Unknown type: "${target.typeId}"`);
|
|
1113
|
-
}
|
|
1114
|
-
const errors = [
|
|
1115
|
-
...validateContent(target.content, type.schema),
|
|
1116
|
-
...validateGrantee(target.typeId, target.content),
|
|
1117
|
-
...validateGrantBaseId(target.typeId, target.content),
|
|
1118
|
-
];
|
|
1119
|
-
if (errors.length > 0) {
|
|
1120
|
-
throw new StackValidationError(errors);
|
|
1121
|
-
}
|
|
1122
|
-
if (id === SYSTEM_TYPES.CONFIG) {
|
|
1123
|
-
this.checkConfigEntityIdUnchanged(existing.content.entityId, target.content.entityId);
|
|
1124
|
-
}
|
|
1125
|
-
// Restoring is a write like any other, so it owes the same immutability
|
|
1126
|
-
// check a content patch pays: a snapshot taken before a card claimed
|
|
1127
|
-
// its DID would otherwise move the binding by rolling content back.
|
|
1128
|
-
// Uniqueness needs none — a restore can only put back a value this same
|
|
1129
|
-
// card already held.
|
|
1130
|
-
for (const field of bindingFieldsOf(baseIdOf(target.typeId))) {
|
|
1131
|
-
this.checkBindingImmutable(baseIdOf(target.typeId), field, existing.content[field], target.content[field]);
|
|
1132
|
-
}
|
|
1133
|
-
// A restore adds no containment edge and takes none away, so there is
|
|
1134
|
-
// no cycle for it to close and nothing for the destination checks to
|
|
1135
|
-
// gate. See docs/spec/versioning.md § Restore semantics.
|
|
1136
|
-
const change = new PendingChange('restore', {
|
|
1137
|
-
actor: normalizeActor(opts.actor),
|
|
1138
|
-
previousTypeId: existing.typeId,
|
|
1139
|
-
});
|
|
1140
|
-
const restored = await this.adapter.restoreVersion(id, version, {
|
|
1141
|
-
...this.writeOptions(existing, opts),
|
|
1142
|
-
journal: change.journal,
|
|
1143
|
-
});
|
|
1144
|
-
this.announce(change, restored);
|
|
1145
|
-
return restored;
|
|
1146
|
-
}
|
|
1147
|
-
/**
|
|
1148
|
-
* Commit a per-record migration — the single-record counterpart to
|
|
1149
|
-
* migrateAll(), with `content` supplied by the caller rather than by a
|
|
1150
|
-
* registered Migration function. See docs/spec/wire-format.md § Migration
|
|
1151
|
-
* commit.
|
|
1152
|
-
*
|
|
1153
|
-
* Because `content` is a full replacement written under a new `typeId`,
|
|
1154
|
-
* this is create-shaped at the destination *and* update-shaped over the
|
|
1155
|
-
* record as it stands, so it owes both sets of integrity checks. Missing
|
|
1156
|
-
* either half would make migrate a second, unguarded write path to a
|
|
1157
|
-
* state create()/mutate() refuse to reach.
|
|
1158
|
-
*/
|
|
1159
|
-
async commitMigration(id, toTypeId, content, opts = {}) {
|
|
1160
|
-
this.assertOpen();
|
|
1161
|
-
const existing = await this.adapter.getRecord(id);
|
|
1162
|
-
if (!existing) {
|
|
1163
|
-
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
1164
|
-
}
|
|
1165
|
-
this.checkIfVersion(existing, opts.ifVersion);
|
|
1166
|
-
return this.commitMigrationChecked(existing, toTypeId, content, opts);
|
|
1167
|
-
}
|
|
1168
|
-
/**
|
|
1169
|
-
* The checked migration write, shared by commitMigration() and
|
|
1170
|
-
* migrateAll(). Takes the record already in hand rather than an id: a
|
|
1171
|
-
* batch pass holds each record from its own query page, and re-fetching
|
|
1172
|
-
* per record would cost a read apiece for nothing.
|
|
1173
|
-
*
|
|
1174
|
-
* Both callers owe the same checks. migrateAll()'s content comes from a
|
|
1175
|
-
* registered Migration function rather than a request body, but "app
|
|
1176
|
-
* code" is not a trust boundary here — the app calling commitMigration()
|
|
1177
|
-
* is the same app that registered the function, and neither may move a
|
|
1178
|
-
* DID binding or repoint an attachment. registerMigration() also places
|
|
1179
|
-
* no constraint on `from` and `to` sharing a baseId, so a migration path
|
|
1180
|
-
* can cross type families; family-crossing is exactly what the checks
|
|
1181
|
-
* below care about.
|
|
1182
|
-
*/
|
|
1183
|
-
async commitMigrationChecked(existing, toTypeId, input, opts = {}) {
|
|
1184
|
-
const id = existing.id;
|
|
1185
|
-
const type = await this.getTypeCached(toTypeId);
|
|
1186
|
-
if (!type) {
|
|
1187
|
-
throw new StackBadRequestError(`Unknown type: "${toTypeId}". Call defineType() first.`);
|
|
1188
|
-
}
|
|
1189
|
-
const content = dropAbsentFields(input, type.schema);
|
|
1190
|
-
const errors = [
|
|
1191
|
-
...validateReservedKeys(content),
|
|
1192
|
-
...validateContentKeys(content),
|
|
1193
|
-
...validateContent(content, type.schema),
|
|
1194
|
-
...validateGrantee(toTypeId, content),
|
|
1195
|
-
...validateGrantBaseId(toTypeId, content),
|
|
1196
|
-
];
|
|
1197
|
-
if (errors.length > 0) {
|
|
1198
|
-
throw new StackValidationError(errors);
|
|
1199
|
-
}
|
|
1200
|
-
assertContentSize(content, this.capabilities.limits.contentBytes, 'Content');
|
|
1201
|
-
const fromFamily = baseIdOf(existing.typeId);
|
|
1202
|
-
const toFamily = baseIdOf(toTypeId);
|
|
1203
|
-
const existingContent = existing.content;
|
|
1204
|
-
// A `_group` record's `admin` roster entry is stamped by create(), the
|
|
1205
|
-
// single site that does it — migrate cannot, since the adapter's
|
|
1206
|
-
// commitMigration() writes `typeId` and `content` alone and leaves
|
|
1207
|
-
// associations untouched. Minting one here would produce a group with
|
|
1208
|
-
// an empty roster, manageable by nobody but the owner, so migrating
|
|
1209
|
-
// *into* the family is refused. Version-to-version stays open and
|
|
1210
|
-
// carries the existing roster with it.
|
|
1211
|
-
if (toFamily === SYSTEM_TYPES.GROUP && fromFamily !== SYSTEM_TYPES.GROUP) {
|
|
1212
|
-
throw new StackConflictError('Cannot migrate a record into _group: a group’s admin roster is stamped at creation. ' +
|
|
1213
|
-
'Create the group instead.');
|
|
1214
|
-
}
|
|
1215
|
-
if (fromFamily === SYSTEM_TYPES.ATTACHMENT) {
|
|
1216
|
-
// Value-wise throughout: a migration re-sends all three required
|
|
1217
|
-
// fields, so presence would refuse every migration of the family.
|
|
1218
|
-
this.assertAttachmentImmutable((field) => content[field] !==
|
|
1219
|
-
existingContent[field]);
|
|
1220
|
-
}
|
|
1221
|
-
else if (toFamily === SYSTEM_TYPES.ATTACHMENT) {
|
|
1222
|
-
// A record arriving from outside the family stakes a fresh claim on
|
|
1223
|
-
// its fileId, exactly as create() does — so it owes create()'s check.
|
|
1224
|
-
await this.checkAttachmentMimeTypeOnCreate(content);
|
|
1225
|
-
}
|
|
1226
|
-
await this.checkBindingsOnMigrate(existing.typeId, toTypeId, id, existingContent, content);
|
|
1227
|
-
if (id === SYSTEM_TYPES.CONFIG) {
|
|
1228
|
-
this.checkConfigEntityIdUnchanged(existing.content.entityId, content.entityId);
|
|
1229
|
-
}
|
|
1230
|
-
const change = new PendingChange('migrate', {
|
|
1231
|
-
actor: normalizeActor(opts.actor),
|
|
1232
|
-
previousTypeId: existing.typeId,
|
|
1233
|
-
});
|
|
1234
|
-
const migrated = await this.adapter.commitMigration(id, toTypeId, content, {
|
|
1235
|
-
...this.writeOptions(existing, opts),
|
|
1236
|
-
journal: change.journal,
|
|
1237
|
-
});
|
|
1238
|
-
this.announce(change, migrated);
|
|
1239
|
-
return migrated;
|
|
1240
|
-
}
|
|
1241
|
-
/** Uniqueness for every unique binding field a newly created card claims. */
|
|
1242
|
-
async checkBindingsOnCreate(typeId, content) {
|
|
1243
|
-
const family = baseIdOf(typeId);
|
|
1244
|
-
for (const field of uniqueBindingFieldsOf(family)) {
|
|
1245
|
-
await this.checkBindingUnique(family, field, content[field]);
|
|
1246
|
-
}
|
|
1247
|
-
}
|
|
1248
|
-
/**
|
|
1249
|
-
* Immutability for every binding field a patch touches, then uniqueness
|
|
1250
|
-
* for the subset that carries it. Fields absent from the patch carry no
|
|
1251
|
-
* new claim — a content patch is a merge, so an untouched binding is the one the
|
|
1252
|
-
* card already holds.
|
|
1253
|
-
*/
|
|
1254
|
-
async checkBindingsOnUpdate(typeId, id, patch, existing, merged) {
|
|
1255
|
-
const family = baseIdOf(typeId);
|
|
1256
|
-
const unique = uniqueBindingFieldsOf(family);
|
|
1257
|
-
for (const field of bindingFieldsOf(family)) {
|
|
1258
|
-
if (!(field in patch))
|
|
1259
|
-
continue;
|
|
1260
|
-
this.checkBindingImmutable(family, field, existing[field], merged[field]);
|
|
1261
|
-
if (unique.includes(field)) {
|
|
1262
|
-
await this.checkBindingUnique(family, field, merged[field], id);
|
|
1263
|
-
}
|
|
1264
|
-
}
|
|
1265
|
-
}
|
|
1266
|
-
/**
|
|
1267
|
-
* Bindings across a migration. `content` is a full replacement, so every
|
|
1268
|
-
* binding field either keeps its value, moves to a new one, or is shed by
|
|
1269
|
-
* omission — and immutability refuses the last two. Asked across the
|
|
1270
|
-
* union of both families' binding fields, so a card can neither shed its
|
|
1271
|
-
* DID by migrating out of `_entity`/`_app` nor pick one up on the way in.
|
|
1272
|
-
*
|
|
1273
|
-
* Uniqueness is asked only of the destination family, excluding the
|
|
1274
|
-
* record itself. See docs/spec/identity.md § DID bindings.
|
|
1275
|
-
*/
|
|
1276
|
-
async checkBindingsOnMigrate(fromTypeId, toTypeId, id, existing, content) {
|
|
1277
|
-
const fromFamily = baseIdOf(fromTypeId);
|
|
1278
|
-
const toFamily = baseIdOf(toTypeId);
|
|
1279
|
-
const checked = new Set();
|
|
1280
|
-
for (const family of fromFamily === toFamily ? [fromFamily] : [fromFamily, toFamily]) {
|
|
1281
|
-
for (const field of bindingFieldsOf(family)) {
|
|
1282
|
-
if (checked.has(field))
|
|
1283
|
-
continue;
|
|
1284
|
-
checked.add(field);
|
|
1285
|
-
this.checkBindingImmutable(family, field, existing[field], content[field]);
|
|
1286
|
-
}
|
|
1287
|
-
}
|
|
1288
|
-
for (const field of uniqueBindingFieldsOf(toFamily)) {
|
|
1289
|
-
await this.checkBindingUnique(toFamily, field, content[field], id);
|
|
1290
|
-
}
|
|
1291
|
-
}
|
|
1292
|
-
/**
|
|
1293
|
-
* A unique binding field is what a lookup resolves *by* — an Actor's
|
|
1294
|
-
* `principalId` by `_app.did`, its `subjectId` by `_entity.did`. Two cards
|
|
1295
|
-
* claiming one value would leave that lookup without a single answer, and
|
|
1296
|
-
* ambiguity is all an impersonating card needs. Enforced here rather than
|
|
1297
|
-
* by schema, since uniqueness is a property of the set, not of the value.
|
|
1298
|
-
*
|
|
1299
|
-
* Read-then-write, so two creates racing on one value can both pass:
|
|
1300
|
-
* closing that means a unique index over a JSON field, which is a
|
|
1301
|
-
* decision about where uniqueness lives rather than a local fix.
|
|
1302
|
-
* See docs/spec/identity.md § DID bindings.
|
|
1303
|
-
*/
|
|
1304
|
-
async checkBindingUnique(family, field, value, excludeId) {
|
|
1305
|
-
if (typeof value !== 'string' || value === '')
|
|
1306
|
-
return;
|
|
1307
|
-
const clash = await findFirstMatch((q) => this.query(q), {
|
|
1308
|
-
filter: {
|
|
1309
|
-
baseId: family,
|
|
1310
|
-
includeDeleted: true,
|
|
1311
|
-
includeUnlisted: true,
|
|
1312
|
-
...(filtersContent(this.capabilities) && { content: { [field]: value } }),
|
|
1313
|
-
},
|
|
1314
|
-
}, (r) => r.id !== excludeId && r.content[field] === value);
|
|
1315
|
-
if (clash) {
|
|
1316
|
-
throw new StackConflictError(`Another ${family} record already claims the ${field} "${value}"`);
|
|
1317
|
-
}
|
|
1318
|
-
}
|
|
1319
|
-
/**
|
|
1320
|
-
* A binding is permanent once made: uniqueness stops a second card
|
|
1321
|
-
* claiming a value, but only immutability stops an existing card being
|
|
1322
|
-
* moved onto one, which reaches the same impersonation by another route.
|
|
1323
|
-
* Adopting a value is therefore a one-way step, and a subject whose key
|
|
1324
|
-
* changes gets a new card — matching identity.md's deferral of key
|
|
1325
|
-
* rotation, where a new key is a new identity rather than the same one
|
|
1326
|
-
* relabelled. See docs/spec/identity.md § DID bindings.
|
|
1327
|
-
*/
|
|
1328
|
-
checkBindingImmutable(family, field, existing, next) {
|
|
1329
|
-
if (typeof existing !== 'string' || existing === '')
|
|
1330
|
-
return;
|
|
1331
|
-
if (next === existing)
|
|
1332
|
-
return;
|
|
1333
|
-
throw new StackValidationError([
|
|
1334
|
-
{
|
|
1335
|
-
path: field,
|
|
1336
|
-
message: `${field} is immutable once set; register a new ${family} record instead`,
|
|
1337
|
-
},
|
|
1338
|
-
]);
|
|
1339
|
-
}
|
|
1340
|
-
// -------------------------------------------------------
|
|
1341
|
-
// Attachments
|
|
1342
|
-
// -------------------------------------------------------
|
|
1343
|
-
/**
|
|
1344
|
-
* mimeType is a property of the fileId, not the uploader's perspective:
|
|
1345
|
-
* the first metadata record for a fileId fixes it, and a conflicting
|
|
1346
|
-
* later upload is rejected. See docs/spec/attachments.md § The
|
|
1347
|
-
* `_attachment` record type.
|
|
1348
|
-
*
|
|
1349
|
-
* Best-effort by construction — check-then-create with no storage-level
|
|
1350
|
-
* uniqueness behind it, so two racing first uploads can both land on a
|
|
1351
|
-
* concurrent server. What survives that race is the *resolution*:
|
|
1352
|
-
* getAttachmentRecords() returns the candidates in the same total order
|
|
1353
|
-
* a server serving Content-Type applies, so both sides name the same
|
|
1354
|
-
* winner however many records exist.
|
|
1355
|
-
*/
|
|
1356
|
-
async checkAttachmentMimeTypeOnCreate(content) {
|
|
1357
|
-
const { fileId, mimeType } = content;
|
|
1358
|
-
if (typeof fileId !== 'string')
|
|
1359
|
-
return; // schema validation already rejected this
|
|
1360
|
-
const existing = await this.getAttachmentRecords(fileId);
|
|
1361
|
-
if (existing.length === 0)
|
|
1362
|
-
return;
|
|
1363
|
-
const establishedMimeType = existing[0].content.mimeType;
|
|
1364
|
-
if (mimeType !== establishedMimeType) {
|
|
1365
|
-
// Deliberately does not name the established mimeType — that would
|
|
1366
|
-
// confirm a guessed fileId's content type. See the anti-oracle rule
|
|
1367
|
-
// in docs/spec/attachments.md.
|
|
1368
|
-
throw new StackValidationError([
|
|
1369
|
-
{
|
|
1370
|
-
path: 'mimeType',
|
|
1371
|
-
message: 'mimeType conflicts with the mimeType already established for this fileId',
|
|
1372
|
-
},
|
|
1373
|
-
]);
|
|
1374
|
-
}
|
|
1375
|
-
}
|
|
1376
|
-
/**
|
|
1377
|
-
* An attachment association's `attachmentRecordId` names an `_attachment`
|
|
1378
|
-
* record for the same `fileId` — checked here so a reference can't be
|
|
1379
|
-
* annotated with an unrelated record's filename.
|
|
1380
|
-
*
|
|
1381
|
-
* Best-effort, like the mimeType check above: the named record can be
|
|
1382
|
-
* deleted afterwards, so every reader of the field falls back rather than
|
|
1383
|
-
* trusting it. `stored` is what the record already holds, asked so a
|
|
1384
|
-
* change set restating an association is never refused for a pointer the
|
|
1385
|
-
* record has carried since before the named record was deleted.
|
|
1386
|
-
* See docs/spec/attachments.md § Naming the upload a reference came from.
|
|
1387
|
-
*/
|
|
1388
|
-
async checkAttachmentAssociationPointers(associations, stored = []) {
|
|
1389
|
-
const pointed = (associations ?? []).flatMap((association) => association.kind === 'attachment' &&
|
|
1390
|
-
association.attachmentRecordId !== undefined &&
|
|
1391
|
-
!stored.some((a) => associationIdentical(a, association))
|
|
1392
|
-
? [{ fileId: association.fileId, attachmentRecordId: association.attachmentRecordId }]
|
|
1393
|
-
: []);
|
|
1394
|
-
// One round trip per pointer, taken together: a change set carries a
|
|
1395
|
-
// whole association list, and each pointer is an independent read.
|
|
1396
|
-
const named = await Promise.all(pointed.map(({ attachmentRecordId }) => this.adapter.getRecord(attachmentRecordId)));
|
|
1397
|
-
pointed.forEach(({ fileId }, i) => {
|
|
1398
|
-
const record = named[i];
|
|
1399
|
-
const content = record?.content;
|
|
1400
|
-
if (record &&
|
|
1401
|
-
baseIdOf(record.typeId) === SYSTEM_TYPES.ATTACHMENT &&
|
|
1402
|
-
content?.fileId === fileId) {
|
|
1403
|
-
return;
|
|
1404
|
-
}
|
|
1405
|
-
// One message for every way of failing: a missing record and a record
|
|
1406
|
-
// for other bytes must not be distinguishable, or this becomes an
|
|
1407
|
-
// existence oracle for records the caller cannot read. A write that
|
|
1408
|
-
// succeeds does confirm the record it names, but only to a caller who
|
|
1409
|
-
// already has file access for those bytes.
|
|
1410
|
-
// See the anti-oracle rule in docs/spec/attachments.md.
|
|
1411
|
-
throw new StackValidationError([
|
|
1412
|
-
{
|
|
1413
|
-
path: 'attachmentRecordId',
|
|
1414
|
-
message: 'attachmentRecordId must name an `_attachment` record for this fileId',
|
|
1415
|
-
},
|
|
1416
|
-
]);
|
|
1417
|
-
});
|
|
1418
|
-
}
|
|
1419
|
-
/**
|
|
1420
|
-
* filename is the only mutable field on an `_attachment@1` record; fileId,
|
|
1421
|
-
* size and mimeType are immutable, and the correction flow is delete +
|
|
1422
|
-
* re-upload. `violates` decides what counts as touching one, because the
|
|
1423
|
-
* two write shapes disagree: a patch names only what it changes, while a
|
|
1424
|
-
* migration replaces content wholesale and necessarily re-sends all three.
|
|
1425
|
-
*
|
|
1426
|
-
* Repointing `fileId` is the one that matters most: an `_attachment@1`
|
|
1427
|
-
* record naming a fileId is what canAccessFile()'s uploader clause reads,
|
|
1428
|
-
* so moving an existing record onto another file's hash is a route to
|
|
1429
|
-
* bytes the record's author never uploaded.
|
|
1430
|
-
* See docs/spec/attachments.md § The `_attachment` record type.
|
|
1431
|
-
*/
|
|
1432
|
-
assertAttachmentImmutable(violates) {
|
|
1433
|
-
const errors = ATTACHMENT_IMMUTABLE_FIELDS.filter(([field]) => violates(field)).map(([path, message]) => ({ path, message }));
|
|
1434
|
-
if (errors.length > 0) {
|
|
1435
|
-
throw new StackValidationError(errors);
|
|
1436
|
-
}
|
|
1437
|
-
}
|
|
1438
|
-
/**
|
|
1439
|
-
* `_config.entityId` defines stack ownership; neither patchContent() nor
|
|
1440
|
-
* restoreVersion() may change it. A conflict with stack integrity, not a
|
|
1441
|
-
* schema violation — hence StackConflictError. See docs/spec.md § The
|
|
1442
|
-
* `_config` record.
|
|
1443
|
-
*/
|
|
1444
|
-
checkConfigEntityIdUnchanged(existingEntityId, newEntityId) {
|
|
1445
|
-
if (newEntityId !== existingEntityId) {
|
|
1446
|
-
throw new StackConflictError('Cannot change _config.entityId: it defines stack ownership. ' +
|
|
1447
|
-
'Ownership transfer is not a supported operation.');
|
|
1448
|
-
}
|
|
1449
|
-
}
|
|
1450
|
-
/**
|
|
1451
|
-
* Store bytes and create an _attachment@1 metadata record (owner-
|
|
1452
|
-
* attributed, no createdBy), returning that record — `content.fileId`
|
|
1453
|
-
* addresses the bytes, `id` addresses the metadata. Delegates to the
|
|
1454
|
-
* adapter's atomic putAttachmentWithMetadata() when implemented, trusting
|
|
1455
|
-
* the returned record as backend-authoritative; otherwise falls back to
|
|
1456
|
-
* bytes-then-create(). See docs/spec/wire-format.md § Attachments.
|
|
1457
|
-
*/
|
|
1458
|
-
async putAttachment(data, opts) {
|
|
1459
|
-
const { mimeType, filename, appId } = opts;
|
|
1460
|
-
this.assertOpen();
|
|
1461
|
-
assertAttachmentSize(data.byteLength, this.capabilities.limits.attachmentBytes);
|
|
1462
|
-
if (this.adapter.putAttachmentWithMetadata) {
|
|
1463
|
-
// The metadata record is written inside the adapter, so create()
|
|
1464
|
-
// never sees it and this is the only place it can be announced.
|
|
1465
|
-
const record = await this.adapter.putAttachmentWithMetadata(data, opts);
|
|
1466
|
-
// The one emission with no journal half of its own: the far side
|
|
1467
|
-
// wrote the record, so it appended the entry in that same write —
|
|
1468
|
-
// the same division saveVersion() follows over this adapter. See
|
|
1469
|
-
// docs/spec/journal.md § The entry set is the event set.
|
|
1470
|
-
this.announce(new PendingChange('create'), record);
|
|
1471
|
-
return record;
|
|
1472
|
-
}
|
|
1473
|
-
const fileId = await this.adapter.putBlob(data);
|
|
1474
|
-
return this.create(`${SYSTEM_TYPES.ATTACHMENT}@1`, {
|
|
1475
|
-
fileId,
|
|
1476
|
-
mimeType,
|
|
1477
|
-
size: data.byteLength,
|
|
1478
|
-
...(filename && { filename }),
|
|
1479
|
-
}, { appId });
|
|
1480
|
-
}
|
|
1481
|
-
async getAttachment(fileId) {
|
|
1482
|
-
this.assertOpen();
|
|
1483
|
-
return this.adapter.getBlob(fileId);
|
|
1484
|
-
}
|
|
1485
|
-
/**
|
|
1486
|
-
* Every `_attachment` record describing `fileId`, earliest-recorded
|
|
1487
|
-
* first — so `records[0]` is the one that establishes the file's
|
|
1488
|
-
* mimeType, and firstRecordedAttachment() is only needed by a caller
|
|
1489
|
-
* narrowing the set further. See docs/spec/attachments.md § Finding a
|
|
1490
|
-
* `fileId`'s metadata records.
|
|
1491
|
-
*
|
|
1492
|
-
* On `Stack` and not `StackClient`: the lookup answers a presentation
|
|
1493
|
-
* question about an access decision already made, so a scoped version
|
|
1494
|
-
* would impose a second, different permission check rather than a
|
|
1495
|
-
* narrower correct answer.
|
|
1496
|
-
*/
|
|
1497
|
-
async getAttachmentRecords(fileId) {
|
|
1498
|
-
this.assertOpen();
|
|
1499
|
-
const results = await queryAllPages((q) => this.query(q), {
|
|
1500
|
-
filter: {
|
|
1501
|
-
baseId: SYSTEM_TYPES.ATTACHMENT,
|
|
1502
|
-
includeDeleted: true,
|
|
1503
|
-
includeUnlisted: true,
|
|
1504
|
-
...(filtersContent(this.capabilities) && { content: { fileId } }),
|
|
1505
|
-
},
|
|
1506
|
-
});
|
|
1507
|
-
return results
|
|
1508
|
-
.filter((r) => r.content.fileId === fileId)
|
|
1509
|
-
.sort(compareRecordedAttachments);
|
|
1510
|
-
}
|
|
1511
|
-
/**
|
|
1512
|
-
* The `_attachment` family as concrete typeIds. Resolved here rather
|
|
1513
|
-
* than in the adapter, which has no baseId concept of its own — see
|
|
1514
|
-
* resolveBaseIdFilter().
|
|
1515
|
-
*/
|
|
1516
|
-
async attachmentTypeIds() {
|
|
1517
|
-
const types = await this.adapter.listTypes();
|
|
1518
|
-
return types.filter((t) => t.baseId === SYSTEM_TYPES.ATTACHMENT).map((t) => t.id);
|
|
1519
|
-
}
|
|
1520
|
-
/**
|
|
1521
|
-
* Delete an attachment's bytes and every _attachment metadata record for
|
|
1522
|
-
* it, family-wide. Throws StackConflictError if any record in the stack
|
|
1523
|
-
* still references the file, StackNotFoundError if neither metadata
|
|
1524
|
-
* records nor bytes exist.
|
|
1525
|
-
*/
|
|
1526
|
-
async deleteAttachment(fileId, opts = {}) {
|
|
1527
|
-
this.assertOpen();
|
|
1528
|
-
let deletedRecords;
|
|
1529
|
-
if (this.adapter.deleteUnreferencedAttachmentRecords) {
|
|
1530
|
-
// Purged inside the adapter's own transaction, so these never reach
|
|
1531
|
-
// delete() and are announced here instead — from the records it
|
|
1532
|
-
// hands back, which are the last copies that will ever exist.
|
|
1533
|
-
deletedRecords = await this.adapter.deleteUnreferencedAttachmentRecords(fileId, await this.attachmentTypeIds());
|
|
1534
|
-
const at = new Date();
|
|
1535
|
-
for (const record of deletedRecords) {
|
|
1536
|
-
this.announce(new PendingChange('purge', { actor: normalizeActor(opts.actor) }), record, at);
|
|
1537
|
-
}
|
|
1538
|
-
}
|
|
1539
|
-
else {
|
|
1540
|
-
deletedRecords = await this.deleteUnreferencedAttachmentRecordsFallback(fileId, opts);
|
|
1541
|
-
}
|
|
1542
|
-
if (!deletedRecords.length) {
|
|
1543
|
-
try {
|
|
1544
|
-
await this.adapter.getBlob(fileId);
|
|
1545
|
-
}
|
|
1546
|
-
catch {
|
|
1547
|
-
throw new StackNotFoundError(`Attachment not found: "${fileId}"`);
|
|
1548
|
-
}
|
|
1549
|
-
}
|
|
1550
|
-
await this.adapter.deleteBlob(fileId);
|
|
1551
|
-
}
|
|
1552
|
-
/**
|
|
1553
|
-
* Non-atomic fallback for adapters that don't implement
|
|
1554
|
-
* deleteUnreferencedAttachmentRecords(): a concurrent associate() can
|
|
1555
|
-
* race between the reference check below and the deletes it guards.
|
|
1556
|
-
*/
|
|
1557
|
-
async deleteUnreferencedAttachmentRecordsFallback(fileId, opts = {}) {
|
|
1558
|
-
// A soft-deleted or unlisted record still counts as a reference — it
|
|
1559
|
-
// must find its attachments intact on undelete or relisting. See
|
|
1560
|
-
// docs/spec/attachments.md § Deleting attachments.
|
|
1561
|
-
const refResult = await this.query({
|
|
1562
|
-
filter: { referencesFileId: fileId, includeDeleted: true, includeUnlisted: true },
|
|
1563
|
-
limit: 1,
|
|
1564
|
-
});
|
|
1565
|
-
if (refResult.records.length > 0) {
|
|
1566
|
-
throw new StackConflictError('Attachment is still referenced by one or more records');
|
|
1567
|
-
}
|
|
1568
|
-
// Soft-deleted, unlisted, and later-version metadata is cleaned up
|
|
1569
|
-
// too — none of it may be left pointing at deleted bytes.
|
|
1570
|
-
const metaRecords = await this.getAttachmentRecords(fileId);
|
|
1571
|
-
for (const record of metaRecords) {
|
|
1572
|
-
await this.delete(record.id, { purge: true, ...opts });
|
|
1573
|
-
}
|
|
1574
|
-
return metaRecords;
|
|
1575
|
-
}
|
|
1576
|
-
/**
|
|
1577
|
-
* Sweep for attachment bytes unreachable from any record — live or
|
|
1578
|
-
* soft-deleted — and delete bytes + metadata. Deletion goes through
|
|
1579
|
-
* deleteAttachment(), so a file re-referenced by sweep time is skipped,
|
|
1580
|
-
* not a failure. See docs/spec/attachments.md § Garbage collection.
|
|
1581
|
-
*/
|
|
1582
|
-
async collectAttachmentGarbage(opts = {}) {
|
|
1583
|
-
this.assertOpen();
|
|
1584
|
-
const graceMs = opts.graceMs ?? DEFAULT_GC_GRACE_MS;
|
|
1585
|
-
const dryRun = opts.dryRun ?? false;
|
|
1586
|
-
const now = Date.now();
|
|
1587
|
-
const metaRecords = await queryAllPages((q) => this.query(q), {
|
|
1588
|
-
filter: { baseId: SYSTEM_TYPES.ATTACHMENT, includeDeleted: true, includeUnlisted: true },
|
|
1589
|
-
});
|
|
1590
|
-
// Newest metadata record's createdAt per fileId, and its size (constant
|
|
1591
|
-
// across records sharing a fileId, since content-addressing guarantees
|
|
1592
|
-
// identical bytes) — used for the grace check and reclaimedBytes.
|
|
1593
|
-
const metaByFile = new Map();
|
|
1594
|
-
for (const record of metaRecords) {
|
|
1595
|
-
const content = record.content;
|
|
1596
|
-
const createdAt = record.createdAt.getTime();
|
|
1597
|
-
const existing = metaByFile.get(content.fileId);
|
|
1598
|
-
if (!existing || createdAt > existing.newestAt) {
|
|
1599
|
-
metaByFile.set(content.fileId, { newestAt: createdAt, size: content.size });
|
|
1600
|
-
}
|
|
1601
|
-
}
|
|
1602
|
-
// Bare-bytes orphans: blobs with zero metadata records, only
|
|
1603
|
-
// discoverable if the blob adapter implements listBlobs().
|
|
1604
|
-
const blobByFile = new Map();
|
|
1605
|
-
if (this.adapter.listBlobs) {
|
|
1606
|
-
for (const file of await this.adapter.listBlobs()) {
|
|
1607
|
-
blobByFile.set(file.fileId, { modifiedAt: file.modifiedAt.getTime(), size: file.size });
|
|
1608
|
-
}
|
|
1609
|
-
}
|
|
1610
|
-
const candidateFileIds = new Set([...metaByFile.keys(), ...blobByFile.keys()]);
|
|
1611
|
-
const deletedFileIds = [];
|
|
1612
|
-
let reclaimedBytes = 0;
|
|
1613
|
-
for (const fileId of candidateFileIds) {
|
|
1614
|
-
const refResult = await this.query({
|
|
1615
|
-
filter: { referencesFileId: fileId, includeDeleted: true, includeUnlisted: true },
|
|
1616
|
-
limit: 1,
|
|
1617
|
-
});
|
|
1618
|
-
if (refResult.records.length > 0)
|
|
1619
|
-
continue;
|
|
1620
|
-
const meta = metaByFile.get(fileId);
|
|
1621
|
-
const blob = blobByFile.get(fileId);
|
|
1622
|
-
const newestAt = meta?.newestAt ?? blob?.modifiedAt;
|
|
1623
|
-
if (newestAt !== undefined && now - newestAt < graceMs)
|
|
1624
|
-
continue;
|
|
1625
|
-
const size = meta?.size ?? blob?.size ?? 0;
|
|
1626
|
-
if (dryRun) {
|
|
1627
|
-
deletedFileIds.push(fileId);
|
|
1628
|
-
reclaimedBytes += size;
|
|
1629
|
-
continue;
|
|
1630
|
-
}
|
|
1631
|
-
try {
|
|
1632
|
-
await this.deleteAttachment(fileId, { actor: opts.actor });
|
|
1633
|
-
}
|
|
1634
|
-
catch (err) {
|
|
1635
|
-
// Raced with a new reference, or another sweep/call already removed
|
|
1636
|
-
// it — not a sweep failure, just move on to the next candidate.
|
|
1637
|
-
if (err instanceof StackConflictError || err instanceof StackNotFoundError)
|
|
1638
|
-
continue;
|
|
1639
|
-
throw err;
|
|
1640
|
-
}
|
|
1641
|
-
deletedFileIds.push(fileId);
|
|
1642
|
-
reclaimedBytes += size;
|
|
1643
|
-
}
|
|
1644
|
-
return { deletedFileIds, reclaimedBytes };
|
|
1645
|
-
}
|
|
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 ?? {});
|
|
1652
|
-
this.assertOpen();
|
|
1653
|
-
assertSinceUsable(opts.since, this.relaysChanges);
|
|
1654
|
-
assertValidBaseIdFilter(opts.filter);
|
|
1655
|
-
const unsubscribe = this.changes.subscribe(handler, opts);
|
|
1656
|
-
let stopRelay;
|
|
1657
|
-
try {
|
|
1658
|
-
stopRelay = await this.openRelay(handler, opts);
|
|
1659
|
-
}
|
|
1660
|
-
catch (err) {
|
|
1661
|
-
// The local half is already registered, and a failed subscribe()
|
|
1662
|
-
// must leave nothing behind for the caller to unsubscribe from.
|
|
1663
|
-
unsubscribe();
|
|
1664
|
-
throw err;
|
|
1665
|
-
}
|
|
1666
|
-
return () => {
|
|
1667
|
-
unsubscribe();
|
|
1668
|
-
stopRelay?.();
|
|
1669
|
-
};
|
|
1670
|
-
}
|
|
1671
|
-
/**
|
|
1672
|
-
* Ask the adapter to relay changes that originated elsewhere. Absent on
|
|
1673
|
-
* every local adapter, where one process owns the storage and there is
|
|
1674
|
-
* no third party whose writes could have been missed.
|
|
1675
|
-
*
|
|
1676
|
-
* The subscription's own filter goes to the relay rather than being
|
|
1677
|
-
* applied on the way back: the emitter at the far end holds the record,
|
|
1678
|
-
* so it can answer `createdBy` and `parentId`, which the envelope
|
|
1679
|
-
* deliberately does not carry. That is also why a relay is opened per
|
|
1680
|
-
* subscription rather than shared.
|
|
1681
|
-
*/
|
|
1682
|
-
async openRelay(handler, opts) {
|
|
1683
|
-
if (!this.adapter.subscribeChanges)
|
|
1684
|
-
return undefined;
|
|
1685
|
-
const delivery = new RelayDelivery(handler, opts);
|
|
1686
|
-
const stop = await this.adapter.subscribeChanges({
|
|
1687
|
-
...(opts.filter !== undefined && { filter: opts.filter }),
|
|
1688
|
-
...(opts.since !== undefined && { since: opts.since }),
|
|
1689
|
-
...(opts.includeRecords !== undefined && { includeRecords: opts.includeRecords }),
|
|
1690
|
-
...(opts.includeUnlisted !== undefined && { includeUnlisted: opts.includeUnlisted }),
|
|
1691
|
-
...(opts.onError !== undefined && { onError: opts.onError }),
|
|
1692
|
-
...(opts.onReset !== undefined && { onReset: opts.onReset }),
|
|
1693
|
-
}, (change) => delivery.deliver(change));
|
|
1694
|
-
return () => {
|
|
1695
|
-
delivery.close();
|
|
1696
|
-
void stop();
|
|
1697
|
-
};
|
|
1698
|
-
}
|
|
1699
|
-
/** Whether changes reach this stack from elsewhere. */
|
|
1700
|
-
get relaysChanges() {
|
|
1701
|
-
return typeof this.adapter.subscribeChanges === 'function';
|
|
1702
|
-
}
|
|
1703
|
-
/**
|
|
1704
|
-
* Flush pending writes to the underlying storage. A no-op for adapters
|
|
1705
|
-
* that commit on every call (SQLite, the API adapter); meaningful for
|
|
1706
|
-
* ones that buffer, and for checkpointing a stack that stays open —
|
|
1707
|
-
* close() covers the teardown case on its own.
|
|
1708
|
-
*/
|
|
1709
|
-
async flush() {
|
|
1710
|
-
this.assertOpen();
|
|
1711
|
-
await this.adapter.flush?.();
|
|
1712
|
-
}
|
|
1713
|
-
/**
|
|
1714
|
-
* Flush, then release any resources the adapter holds (connections, file
|
|
1715
|
-
* handles, lock files). A failed flush still releases them before it
|
|
1716
|
-
* propagates: an unwritable stack must not also leak a lock file.
|
|
1717
|
-
* See docs/spec/adapters.md § Lifecycle.
|
|
1718
|
-
*/
|
|
1719
|
-
async close() {
|
|
1720
|
-
if (this.closed)
|
|
1721
|
-
return;
|
|
1722
|
-
// Marked closed up front so a failed flush can't leave the stack
|
|
1723
|
-
// half-open and invite a second close() onto an already-closed adapter.
|
|
1724
|
-
// Flushes through the adapter directly, past the now-tripped guard.
|
|
1725
|
-
this.closed = true;
|
|
1726
|
-
this.changes.closeAll();
|
|
1727
|
-
try {
|
|
1728
|
-
await this.adapter.flush?.();
|
|
1729
|
-
}
|
|
1730
|
-
finally {
|
|
1731
|
-
await this.adapter.close?.();
|
|
1732
|
-
}
|
|
1733
|
-
}
|
|
1734
|
-
/** Throws once close() has run. */
|
|
1735
|
-
assertOpen() {
|
|
1736
|
-
if (this.closed)
|
|
1737
|
-
throw new UseAfterCloseError();
|
|
1738
|
-
}
|
|
1739
|
-
// -------------------------------------------------------
|
|
1740
|
-
// Grants
|
|
1741
|
-
// -------------------------------------------------------
|
|
1742
|
-
/**
|
|
1743
|
-
* Create a _grant record authorizing `grantee` to take `actions` on
|
|
1744
|
-
* records of `typeId`: `{ kind: 'entity' }` for one DID, `{ kind: 'group' }`
|
|
1745
|
-
* for a `_group` Record's roster at a role, `{ kind: 'authenticated' }` for
|
|
1746
|
-
* any authenticated entity. The type-level mirror of grantAccess() —
|
|
1747
|
-
* subject first, grantee inside the element.
|
|
1748
|
-
*
|
|
1749
|
-
* Granting an **app** a `-own` action does not contain it the way the
|
|
1750
|
-
* suffix suggests: when that app acts for someone, `-own` is read as the
|
|
1751
|
-
* bare verb and the subject decides which records are in reach, so in a
|
|
1752
|
-
* personal stack — where nearly every record is owner-authored — a
|
|
1753
|
-
* delegated `read-own` is close to `read-any`. Grant an app the types it
|
|
1754
|
-
* needs, not the suffix that looks narrowest. See
|
|
1755
|
-
* docs/spec/access-control.md § Delegation: principal and subject.
|
|
1756
|
-
*
|
|
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.
|
|
1759
|
-
*/
|
|
1760
|
-
async grantType(baseId, grant) {
|
|
1761
|
-
this.assertOpen();
|
|
1762
|
-
validateGrantTarget(grant.grantee);
|
|
1763
|
-
this.checkGrantValid(baseId, grant.actions);
|
|
1764
|
-
return this.create(`${SYSTEM_TYPES.GRANT}@1`, {
|
|
1765
|
-
baseId,
|
|
1766
|
-
actions: grant.actions,
|
|
1767
|
-
grantee: grant.grantee,
|
|
1768
|
-
});
|
|
1769
|
-
}
|
|
1770
|
-
/**
|
|
1771
|
-
* List _grant records. Omit `query` for all grants;
|
|
1772
|
-
* `{ kind: 'authenticated' }` for only default grants;
|
|
1773
|
-
* `{ kind: 'group' }` for grants naming that exact group and role, or
|
|
1774
|
-
* `role: 'any'` for every grant naming the group;
|
|
1775
|
-
* `{ kind: 'entity' }` for the grants that currently apply to that entity
|
|
1776
|
-
* (ones naming them, ones naming a group they belong to at a role they
|
|
1777
|
-
* hold, plus every default grant) — the same resolution hasGrant() uses.
|
|
1778
|
-
*
|
|
1779
|
-
* Every arm but `entity` answers identity — what `grantType()` would have
|
|
1780
|
-
* written with the same argument. The `entity` arm answers coverage, so
|
|
1781
|
-
* its result is not a preview of what `revokeType()` would withdraw.
|
|
1782
|
-
* See docs/spec/access-control.md § Type-level grants.
|
|
1783
|
-
*/
|
|
1784
|
-
async listTypeGrants(query) {
|
|
1785
|
-
this.assertOpen();
|
|
1786
|
-
if (query !== undefined)
|
|
1787
|
-
validateGrantTarget(query, true);
|
|
1788
|
-
const all = await loadGrantRecords((q) => this.query(q));
|
|
1789
|
-
if (query === undefined)
|
|
1790
|
-
return all;
|
|
1791
|
-
if (query.kind !== 'entity') {
|
|
1792
|
-
return all.filter((r) => matchesGrantTarget(r.content, query));
|
|
1793
|
-
}
|
|
1794
|
-
// An entity query resolves group rosters, since a grant naming a group
|
|
1795
|
-
// the entity belongs to also currently applies to them. Shares
|
|
1796
|
-
// grantCoversGrantee() with the access checks, so a listing can't
|
|
1797
|
-
// disagree with them about who a grant covers.
|
|
1798
|
-
const groupRoles = new Map();
|
|
1799
|
-
const result = [];
|
|
1800
|
-
for (const r of all) {
|
|
1801
|
-
const covers = await grantCoversGrantee(r.content, query.entityId, {
|
|
1802
|
-
allowDefault: true,
|
|
1803
|
-
allowGroup: true,
|
|
1804
|
-
groupRoles,
|
|
1805
|
-
resolveRecord: (id) => this.get(id, { includeDeleted: true }),
|
|
1806
|
-
});
|
|
1807
|
-
if (covers)
|
|
1808
|
-
result.push(r);
|
|
1809
|
-
}
|
|
1810
|
-
return result;
|
|
1811
|
-
}
|
|
1812
|
-
/**
|
|
1813
|
-
* The inverse of grantType(): soft-deletes the _grant records on
|
|
1814
|
-
* `baseId`'s family matching `grant`, at the same granularity
|
|
1815
|
-
* grantType() writes — the grantee is matched whole, role included, and
|
|
1816
|
-
* the actions exactly. A soft delete like any other — the owner can
|
|
1817
|
-
* undelete a revocation.
|
|
1818
|
-
*
|
|
1819
|
-
* Returns the grants it withdrew, as they stood: an array, where
|
|
1820
|
-
* revokeAccess() returns one Record, because one target can match several
|
|
1821
|
-
* _grant records. An empty result is not an error — re-running a
|
|
1822
|
-
* revocation has to stay safe. See
|
|
1823
|
-
* docs/spec/access-control.md § Listing and revoking.
|
|
1824
|
-
*/
|
|
1825
|
-
async revokeType(baseId, grant) {
|
|
1826
|
-
this.assertOpen();
|
|
1827
|
-
validateGrantTarget(grant.grantee);
|
|
1828
|
-
const problem = familyIdProblem(baseId, 'revokeType');
|
|
1829
|
-
if (problem)
|
|
1830
|
-
throw new StackValidationError([{ path: 'baseId', message: problem }]);
|
|
1831
|
-
const familyId = baseId;
|
|
1832
|
-
const actionSet = new Set(grant.actions);
|
|
1833
|
-
const all = await loadGrantRecords((q) => this.query(q));
|
|
1834
|
-
const matches = all.filter((r) => {
|
|
1835
|
-
const c = r.content;
|
|
1836
|
-
// Establishes the family and that `actions` is a list, so the exact
|
|
1837
|
-
// match below reads a real one. Matched against the stored list
|
|
1838
|
-
// rather than the reach, so a grant carrying an action this
|
|
1839
|
-
// vocabulary drops is not withdrawn by a target that omits it.
|
|
1840
|
-
const reach = grantReach(c);
|
|
1841
|
-
if (!reach || reach.familyId !== familyId)
|
|
1842
|
-
return false;
|
|
1843
|
-
if (!matchesGrantTarget(c, grant.grantee))
|
|
1844
|
-
return false;
|
|
1845
|
-
return c.actions.length === actionSet.size && c.actions.every((a) => actionSet.has(a));
|
|
1846
|
-
});
|
|
1847
|
-
for (const match of matches)
|
|
1848
|
-
await this.delete(match.id);
|
|
1849
|
-
return matches;
|
|
1850
|
-
}
|
|
1851
|
-
// -------------------------------------------------------
|
|
1852
|
-
// Private helpers
|
|
1853
|
-
// -------------------------------------------------------
|
|
1854
|
-
/**
|
|
1855
|
-
* Actions must be known GrantAction values; the target is held to the
|
|
1856
|
-
* same rule every `_grant` write meets — see validateGrantBaseId().
|
|
1857
|
-
*/
|
|
1858
|
-
checkGrantValid(baseId, actions) {
|
|
1859
|
-
const errors = [];
|
|
1860
|
-
actions.forEach((action, j) => {
|
|
1861
|
-
if (!GRANT_ACTION_SET.has(action)) {
|
|
1862
|
-
errors.push({ path: `actions[${j}]`, message: `Unknown grant action "${action}"` });
|
|
1863
|
-
}
|
|
1864
|
-
});
|
|
1865
|
-
const problem = familyIdProblem(baseId, 'grantType');
|
|
1866
|
-
if (problem) {
|
|
1867
|
-
errors.push({ path: 'baseId', message: problem });
|
|
1868
|
-
}
|
|
1869
|
-
else {
|
|
1870
|
-
errors.push(...validateGrantBaseId(`${SYSTEM_TYPES.GRANT}@1`, { baseId }));
|
|
1871
|
-
}
|
|
1872
|
-
actions.forEach((action, j) => {
|
|
1873
|
-
if (grantConveys(actions, action))
|
|
1874
|
-
return;
|
|
1875
|
-
const companions = READ_COMPANIONS.get(action);
|
|
1876
|
-
if (!companions)
|
|
1877
|
-
return;
|
|
1878
|
-
errors.push({
|
|
1879
|
-
path: `actions[${j}]`,
|
|
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}']`,
|
|
1881
|
-
});
|
|
1882
|
-
});
|
|
1883
|
-
if (errors.length > 0) {
|
|
1884
|
-
throw new StackValidationError(errors);
|
|
1885
|
-
}
|
|
1886
|
-
}
|
|
1887
|
-
async seedSystemTypes() {
|
|
1888
|
-
await this.defineType({
|
|
1889
|
-
id: `${SYSTEM_TYPES.CONFIG}@1`,
|
|
1890
|
-
name: 'Config',
|
|
1891
|
-
schema: {
|
|
1892
|
-
entityId: { kind: 'string', required: true },
|
|
1893
|
-
// Optional passthrough app metadata — see ConfigContent.timezone.
|
|
1894
|
-
timezone: { kind: 'string' },
|
|
1895
|
-
},
|
|
1896
|
-
});
|
|
1897
|
-
await this.defineType({
|
|
1898
|
-
id: `${SYSTEM_TYPES.ENTITY}@1`,
|
|
1899
|
-
name: 'Entity',
|
|
1900
|
-
schema: {
|
|
1901
|
-
did: { kind: 'string', required: true },
|
|
1902
|
-
name: { kind: 'string', required: true },
|
|
1903
|
-
handle: { kind: 'string' },
|
|
1904
|
-
},
|
|
1905
|
-
});
|
|
1906
|
-
await this.defineType({
|
|
1907
|
-
id: `${SYSTEM_TYPES.APP}@1`,
|
|
1908
|
-
name: 'App',
|
|
1909
|
-
schema: {
|
|
1910
|
-
appId: { kind: 'string', required: true },
|
|
1911
|
-
name: { kind: 'string', required: true },
|
|
1912
|
-
version: { kind: 'string' },
|
|
1913
|
-
did: { kind: 'string' },
|
|
1914
|
-
},
|
|
1915
|
-
});
|
|
1916
|
-
await this.defineType({
|
|
1917
|
-
id: `${SYSTEM_TYPES.GROUP}@1`,
|
|
1918
|
-
name: 'Group',
|
|
1919
|
-
schema: {
|
|
1920
|
-
name: { kind: 'string', required: true },
|
|
1921
|
-
handle: { kind: 'string' },
|
|
1922
|
-
stackUrl: { kind: 'string' },
|
|
1923
|
-
},
|
|
1924
|
-
});
|
|
1925
|
-
await this.defineType({
|
|
1926
|
-
id: `${SYSTEM_TYPES.GRANT}@1`,
|
|
1927
|
-
name: 'Grant',
|
|
1928
|
-
schema: {
|
|
1929
|
-
baseId: { kind: 'string', required: true },
|
|
1930
|
-
actions: { kind: 'array', items: { kind: 'string' }, required: true },
|
|
1931
|
-
// Required, and closed: a Grant's reach is spelled by its `grantee`,
|
|
1932
|
-
// so a record arriving without one is refused here rather than read
|
|
1933
|
-
// as a grant to every authenticated entity. The arms differ in which
|
|
1934
|
-
// fields they carry, which a schema cannot express — evaluation reads
|
|
1935
|
-
// the `kind` and confers nothing on one it does not recognize.
|
|
1936
|
-
// See docs/spec/access-control.md § Type-level grants.
|
|
1937
|
-
grantee: {
|
|
1938
|
-
kind: 'object',
|
|
1939
|
-
required: true,
|
|
1940
|
-
properties: {
|
|
1941
|
-
kind: { kind: 'string', required: true },
|
|
1942
|
-
entityId: { kind: 'string' },
|
|
1943
|
-
groupId: { kind: 'string' },
|
|
1944
|
-
role: { kind: 'string' },
|
|
1945
|
-
},
|
|
1946
|
-
},
|
|
1947
|
-
},
|
|
1948
|
-
});
|
|
1949
|
-
await this.defineType({
|
|
1950
|
-
id: `${SYSTEM_TYPES.ATTACHMENT}@1`,
|
|
1951
|
-
name: 'Attachment',
|
|
1952
|
-
schema: {
|
|
1953
|
-
// Deliberately `string`, not `file-ref`: referencesFileId matching
|
|
1954
|
-
// (deleteAttachment()/collectAttachmentGarbage()'s reference scan) is
|
|
1955
|
-
// schema-driven, so a `file-ref` fileId here would make every
|
|
1956
|
-
// metadata record its own file's reference — nothing would ever be
|
|
1957
|
-
// deletable or collectible. See docs/spec/attachments.md § Deleting
|
|
1958
|
-
// attachments / Garbage collection.
|
|
1959
|
-
fileId: { kind: 'string', required: true },
|
|
1960
|
-
mimeType: { kind: 'string', required: true },
|
|
1961
|
-
size: { kind: 'number', required: true },
|
|
1962
|
-
filename: { kind: 'string' },
|
|
1963
|
-
},
|
|
1964
|
-
});
|
|
1965
|
-
}
|
|
1966
|
-
/**
|
|
1967
|
-
* Fast-fail for the ifVersion precondition using the already-fetched
|
|
1968
|
-
* record. The adapter re-checks atomically at write time (the source of
|
|
1969
|
-
* truth for concurrent writers) — this just skips validation and
|
|
1970
|
-
* snapshotting work when the mismatch is already visible.
|
|
1971
|
-
*/
|
|
1972
|
-
checkIfVersion(existing, ifVersion) {
|
|
1973
|
-
if (ifVersion === undefined || existing.version === ifVersion)
|
|
1974
|
-
return;
|
|
1975
|
-
throw new StackVersionConflictError(`Record "${existing.id}" is at version ${existing.version}, expected ${ifVersion}`, existing.id, ifVersion, existing.version);
|
|
1976
|
-
}
|
|
1977
|
-
/**
|
|
1978
|
-
* The options every version-bumping adapter write carries: the `ifVersion`
|
|
1979
|
-
* precondition, the prior-state snapshot that has to land in the same
|
|
1980
|
-
* atomic write, and who to attribute the change to. Taken together so a
|
|
1981
|
-
* new mutating verb cannot quietly omit one — the actor's `principalId`
|
|
1982
|
-
* most of all, whose absence reads as an undelegated write.
|
|
1983
|
-
* See docs/spec/versioning.md § Version history.
|
|
1984
|
-
*/
|
|
1985
|
-
writeOptions(existing, opts) {
|
|
1986
|
-
return {
|
|
1987
|
-
ifVersion: opts.ifVersion,
|
|
1988
|
-
snapshot: this.buildVersionSnapshot(existing),
|
|
1989
|
-
actor: normalizeActor(opts.actor),
|
|
1990
|
-
};
|
|
1991
|
-
}
|
|
1992
|
-
/**
|
|
1993
|
-
* Snapshot of a record's prior state, passed with the mutating adapter
|
|
1994
|
-
* call so snapshot and mutation land in one atomic write. Carries what
|
|
1995
|
-
* only a snapshot preserves: `content` and the `typeId` it is read
|
|
1996
|
-
* under. Containment, listing and associations — the authority ones
|
|
1997
|
-
* among them — are all kept by the journal instead, so no version ever
|
|
1998
|
-
* snapshots them and there is nothing for a restore to roll them back
|
|
1999
|
-
* to. See docs/spec/versioning.md § Version history.
|
|
2000
|
-
*/
|
|
2001
|
-
buildVersionSnapshot(record) {
|
|
2002
|
-
return {
|
|
2003
|
-
version: record.version,
|
|
2004
|
-
typeId: record.typeId,
|
|
2005
|
-
content: record.content,
|
|
2006
|
-
updatedAt: record.updatedAt,
|
|
2007
|
-
...(record.createdBy && { createdBy: record.createdBy }),
|
|
2008
|
-
...(record.updatedBy && { updatedBy: record.updatedBy }),
|
|
2009
|
-
};
|
|
2010
|
-
}
|
|
2011
|
-
}
|
|
2012
|
-
//# sourceMappingURL=stack.js.map
|