@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
|
@@ -0,0 +1,1366 @@
|
|
|
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, and validation of every caller-supplied field
|
|
8
|
+
* - Type definition, schema hashing and drift detection
|
|
9
|
+
* - Content validation on write, and the integrity rules in integrity.ts
|
|
10
|
+
* - Migrations: the registry, commitMigration() and owner-driven migrateAll()
|
|
11
|
+
* - Version snapshots, the change journal, soft delete and purge
|
|
12
|
+
* - Association and permission edits, and the change feed
|
|
13
|
+
* - Attachments, type-level grants and app installs, whose bodies live
|
|
14
|
+
* beside this file
|
|
15
|
+
* - asEntity()/asActor(), the scoped views that enforce access control
|
|
16
|
+
*
|
|
17
|
+
* Apps should never talk to a StackAdapter directly.
|
|
18
|
+
*/
|
|
19
|
+
import { generateId, generateIdForTimestamp } from '../id.js';
|
|
20
|
+
import { hashSchema, isCompatible, parseTypeId, baseIdOf, diffSchemas, fileRefFields, lineageProblem, } from '../schema.js';
|
|
21
|
+
import { dropAbsentFields, validateContent, validateContentKeys, validatePatchValues, validateReservedKeys, validateSchemaFieldNames, validateSchemaReservedNames, validateSchemaShape, } from '../validate.js';
|
|
22
|
+
import { applyMergePatch } from '../merge.js';
|
|
23
|
+
import { compareRecordedAttachments } from '../attachment-records.js';
|
|
24
|
+
import { stampGroupAdmin, validatePermissions } from '../access.js';
|
|
25
|
+
import { ChangeEmitter, RelayDelivery, PendingChange, assertSinceUsable } from './changes.js';
|
|
26
|
+
import { SYSTEM_TYPES } from '../types/index.js';
|
|
27
|
+
import { UseAfterCloseError, InvalidAdapterError, StackConflictError, StackMigrationError, StackNotFoundError, StackBadRequestError, StackSchemaDriftError, StackValidationError, StackVersionConflictError, ARGUMENTS_INVALID, SCHEMA_INVALID, } from '../errors.js';
|
|
28
|
+
import { assertQueryCapabilities, assertValidContentByType, assertSortCapability, assertValidAssociationFilters, assertValidBaseIdFilter, assertValidJournalQuery, assertValidVersionsQuery, assertValidSort, normalizeSort, filtersContent, assertFamilyId, } from '../query-validation.js';
|
|
29
|
+
import { assertAssociationEdits, assertAssociationList, assertAuthorityAssociations, assertDataAssociations, validateAssociations, } from '../associations/validation.js';
|
|
30
|
+
import { applyAssociationEdits, associationDelta, editsOf } from '../associations/identity.js';
|
|
31
|
+
import { validateGrantee, validateGrantBaseId } from '../grants.js';
|
|
32
|
+
import { bindingFieldsOf } from './identity-bindings.js';
|
|
33
|
+
import { validateInstall } from '../install.js';
|
|
34
|
+
import { assertAttachmentSize, assertContentSize } from './limits.js';
|
|
35
|
+
import { validateRecordId, validateClockField, validateIdTimestampSkew, DEFAULT_ID_TIMESTAMP_SKEW_MS, } from './record-id.js';
|
|
36
|
+
import { queryAllPages, lookupEntityByDid, MAX_QUERY_LIMIT } from './reads.js';
|
|
37
|
+
import { assertNonEmptyChangeSet, bumpsVersion, changeSetOps, effectiveChanges, takesIfVersion, } from '../record-changes.js';
|
|
38
|
+
import { ScopedStack, scopeToken } from '../scoped-stack/scoped-stack.js';
|
|
39
|
+
import { MigrationRegistry } from './migrations.js';
|
|
40
|
+
import * as apps from './install-app.js';
|
|
41
|
+
import * as typeGrants from './type-grants.js';
|
|
42
|
+
import * as attachments from './attachments.js';
|
|
43
|
+
import { assertAttachmentImmutable, assertGroupAdminRemains, assertNoParentCycle, assertParentExists, checkAttachmentAssociationPointers, checkAttachmentMimeTypeOnCreate, checkBindingImmutable, checkBindingsOnCreate, checkBindingsOnMigrate, checkBindingsOnUpdate, checkConfigEntityIdUnchanged, } from './integrity.js';
|
|
44
|
+
import { systemTypeDefinitions } from './system-types.js';
|
|
45
|
+
import { checkFamily, checkSnapshotFamily, checkStoredVersion, WRITE_EXPECTATION, } from './write-expectation.js';
|
|
46
|
+
import { createCollection } from '../collection.js';
|
|
47
|
+
// -------------------------------------------------------
|
|
48
|
+
// Supporting definitions
|
|
49
|
+
// -------------------------------------------------------
|
|
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
|
+
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
|
+
* One refusal for every problem a call carries, content and arguments alike,
|
|
73
|
+
* so a caller fixes them in one round trip. Content is itself an argument,
|
|
74
|
+
* so a mixed set reads "Invalid arguments"; content alone keeps its header.
|
|
75
|
+
*/
|
|
76
|
+
function throwValidation(contentErrors, argumentErrors) {
|
|
77
|
+
if (argumentErrors.length > 0) {
|
|
78
|
+
throw new StackValidationError([...argumentErrors, ...contentErrors], ARGUMENTS_INVALID);
|
|
79
|
+
}
|
|
80
|
+
if (contentErrors.length > 0)
|
|
81
|
+
throw new StackValidationError(contentErrors);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Every problem `content` has as a record of `typeId`: its schema, plus the
|
|
85
|
+
* rules the `_grant` and `_install` families hold their content to on top
|
|
86
|
+
* of one. Owed by every write that sets content, whatever shape it takes.
|
|
87
|
+
*/
|
|
88
|
+
function typedContentErrors(typeId, content, schema) {
|
|
89
|
+
return [
|
|
90
|
+
...validateContent(content, schema),
|
|
91
|
+
...validateGrantee(typeId, content),
|
|
92
|
+
...validateGrantBaseId(typeId, content),
|
|
93
|
+
...validateInstall(typeId, content),
|
|
94
|
+
];
|
|
95
|
+
}
|
|
96
|
+
// -------------------------------------------------------
|
|
97
|
+
// Stack class
|
|
98
|
+
// -------------------------------------------------------
|
|
99
|
+
export class Stack {
|
|
100
|
+
adapter;
|
|
101
|
+
idTimestampSkewMsValue;
|
|
102
|
+
migrations;
|
|
103
|
+
/**
|
|
104
|
+
* Types this instance has fetched or defined, keyed by versioned id. A
|
|
105
|
+
* Type's schema is immutable once defined, so entries are never
|
|
106
|
+
* invalidated, only added; listTypes() refreshes wholesale. See
|
|
107
|
+
* docs/spec/data-model.md § Type cache.
|
|
108
|
+
*/
|
|
109
|
+
typeCache = new Map();
|
|
110
|
+
/** Set by close(). See docs/spec/adapters.md § Lifecycle. */
|
|
111
|
+
closed = false;
|
|
112
|
+
/**
|
|
113
|
+
* Every change made through this Stack. `ScopedStack` filters this same
|
|
114
|
+
* stream rather than opening its own, so no scoped view can observe a
|
|
115
|
+
* change the stack did not emit. See docs/spec/events.md.
|
|
116
|
+
*/
|
|
117
|
+
changes = new ChangeEmitter();
|
|
118
|
+
constructor(adapter, idTimestampSkewMsValue, migrations) {
|
|
119
|
+
this.adapter = adapter;
|
|
120
|
+
this.idTimestampSkewMsValue = idTimestampSkewMsValue;
|
|
121
|
+
this.migrations = migrations;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Announce a mutation that has already been persisted. Called after the
|
|
125
|
+
* adapter write resolves and before the mutating method settles, so
|
|
126
|
+
* `await stack.mutate(...)` guarantees subscribers have been notified —
|
|
127
|
+
* and nothing about work they deferred. A handler cannot fail the write:
|
|
128
|
+
* there is nothing left to fail. See docs/spec/events.md § Handlers.
|
|
129
|
+
*/
|
|
130
|
+
announce(change, record, at) {
|
|
131
|
+
this.changes.emit(change.emission(record, at));
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The actor for a `create` journal entry, read off the record as just
|
|
135
|
+
* built. Create is the one journaled write that stamps the requester
|
|
136
|
+
* onto the row itself, so the record in hand is authoritative — and it
|
|
137
|
+
* carries `appId`, which ActorOptions has no field for.
|
|
138
|
+
*/
|
|
139
|
+
static createActor(record) {
|
|
140
|
+
if (!record.updatedBy)
|
|
141
|
+
return undefined;
|
|
142
|
+
return { ...record.updatedBy, ...(record.appId !== undefined && { appId: record.appId }) };
|
|
143
|
+
}
|
|
144
|
+
async getTypeCached(id) {
|
|
145
|
+
const cached = this.typeCache.get(id);
|
|
146
|
+
if (cached)
|
|
147
|
+
return cached;
|
|
148
|
+
const type = await this.adapter.getType(id);
|
|
149
|
+
if (type)
|
|
150
|
+
this.typeCache.set(id, type);
|
|
151
|
+
return type;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Open a Stack over an adapter. Reads ownerEntityId and timezone from the adapter.
|
|
155
|
+
*/
|
|
156
|
+
static async open(adapter, opts = {}) {
|
|
157
|
+
if (!adapter.ownerEntityId) {
|
|
158
|
+
throw new InvalidAdapterError('Invalid adapter: adapter has no ownerEntityId. ' +
|
|
159
|
+
'Initialize the adapter with an ownerEntityId before calling Stack.open().');
|
|
160
|
+
}
|
|
161
|
+
const stack = new Stack(adapter, opts.idTimestampSkewMs === undefined ? DEFAULT_ID_TIMESTAMP_SKEW_MS : opts.idTimestampSkewMs, new MigrationRegistry(opts.migrations ?? []));
|
|
162
|
+
for (const type of systemTypeDefinitions())
|
|
163
|
+
await stack.defineType(type);
|
|
164
|
+
if (opts.ownerProfile) {
|
|
165
|
+
await stack.ensureOwnerEntity(opts.ownerProfile);
|
|
166
|
+
}
|
|
167
|
+
return stack;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Idempotent bootstrap for StackOptions.ownerProfile. Its probe sees every
|
|
171
|
+
* card the binding rules do, deleted and migrated ones included, or it
|
|
172
|
+
* would mint one they then refuse and the stack would not reopen.
|
|
173
|
+
* See docs/spec/identity.md § Entity.
|
|
174
|
+
*/
|
|
175
|
+
async ensureOwnerEntity(profile) {
|
|
176
|
+
const entityTypeId = `${SYSTEM_TYPES.ENTITY}@1`;
|
|
177
|
+
const existing = await this.getEntityByDid(this.ownerEntityId);
|
|
178
|
+
if (existing)
|
|
179
|
+
return;
|
|
180
|
+
await this.create(entityTypeId, {
|
|
181
|
+
did: this.ownerEntityId,
|
|
182
|
+
name: profile.name,
|
|
183
|
+
...(profile.handle && { handle: profile.handle }),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
get ownerEntityId() {
|
|
187
|
+
return this.adapter.ownerEntityId;
|
|
188
|
+
}
|
|
189
|
+
async getEntityByDid(did) {
|
|
190
|
+
return lookupEntityByDid((q) => this.query(q), did, filtersContent(this.capabilities), true);
|
|
191
|
+
}
|
|
192
|
+
async getOwnerEntity() {
|
|
193
|
+
return this.getEntityByDid(this.ownerEntityId);
|
|
194
|
+
}
|
|
195
|
+
get timezone() {
|
|
196
|
+
return this.adapter.timezone;
|
|
197
|
+
}
|
|
198
|
+
get capabilities() {
|
|
199
|
+
return this.adapter.capabilities;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Get a permission-scoped view of this Stack, as if requests came from
|
|
203
|
+
* the given entity acting as itself (null = anonymous). Plain Stack
|
|
204
|
+
* methods are unscoped; use asEntity() when one Stack serves multiple,
|
|
205
|
+
* possibly untrusted, entities. See
|
|
206
|
+
* docs/spec/access-control.md § Enforcement: Stack.asEntity().
|
|
207
|
+
*/
|
|
208
|
+
asEntity(entityId) {
|
|
209
|
+
return entityId === null ? this.scope(null, null) : this.asActor({ subjectId: entityId });
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Scope to an Actor — a delegated app acting for its user, or a
|
|
213
|
+
* TokenSession as StackTokenStore.lookupToken() returns it, which is what
|
|
214
|
+
* a server should pass at its request boundary.
|
|
215
|
+
* See docs/spec/access-control.md § Delegation: principal and subject.
|
|
216
|
+
*/
|
|
217
|
+
asActor(actor) {
|
|
218
|
+
const { subjectId, principalId = subjectId } = normalizeActor(actor);
|
|
219
|
+
return this.scope(principalId, subjectId);
|
|
220
|
+
}
|
|
221
|
+
scope(principalId, subjectId) {
|
|
222
|
+
this.assertOpen();
|
|
223
|
+
return new ScopedStack(scopeToken, {
|
|
224
|
+
stack: this,
|
|
225
|
+
principalId,
|
|
226
|
+
subjectId,
|
|
227
|
+
idTimestampSkewMs: this.idTimestampSkewMsValue,
|
|
228
|
+
adapter: this.adapter,
|
|
229
|
+
changes: this.changes,
|
|
230
|
+
assertOpen: () => this.assertOpen(),
|
|
231
|
+
relaysChanges: this.relaysChanges,
|
|
232
|
+
migrations: this.migrations,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
collection(handle) {
|
|
236
|
+
this.assertOpen();
|
|
237
|
+
return createCollection(this, this.migrations, handle, () => this.listTypes());
|
|
238
|
+
}
|
|
239
|
+
// -------------------------------------------------------
|
|
240
|
+
// Types
|
|
241
|
+
// -------------------------------------------------------
|
|
242
|
+
/**
|
|
243
|
+
* Define and persist a Type; call at app startup before creating records
|
|
244
|
+
* of the type. Redefining an existing typeId is checked against the
|
|
245
|
+
* stored schema: identical is a no-op, a name-only change persists, and
|
|
246
|
+
* anything beyond additive evolution throws StackSchemaDriftError. See
|
|
247
|
+
* docs/spec/data-model.md § Schema drift detection.
|
|
248
|
+
*/
|
|
249
|
+
async defineType({ id, name, schema: declared, migratesFrom, }) {
|
|
250
|
+
this.assertOpen();
|
|
251
|
+
// Only read below; the readonly view exists so a handle's literal fits.
|
|
252
|
+
const schema = declared;
|
|
253
|
+
const parsed = parseTypeId(id);
|
|
254
|
+
if (!parsed) {
|
|
255
|
+
throw new StackBadRequestError(`Invalid TypeId format: "${id}". Expected "namespace/name@version", e.g. "com.example.myapp/note@1".`);
|
|
256
|
+
}
|
|
257
|
+
// Even on the idempotent-no-op path below — see noteDefined().
|
|
258
|
+
this.migrations.noteDefined(parsed.baseId, parsed.version);
|
|
259
|
+
// Shape first: the name checks and hashSchema() below both read the
|
|
260
|
+
// schema as a well-formed one, and a schema off the wire is parsed
|
|
261
|
+
// JSON that no compiler has seen.
|
|
262
|
+
const shapeErrors = validateSchemaShape(schema);
|
|
263
|
+
if (shapeErrors.length > 0) {
|
|
264
|
+
throw new StackValidationError(shapeErrors, SCHEMA_INVALID);
|
|
265
|
+
}
|
|
266
|
+
const nameErrors = [
|
|
267
|
+
...validateSchemaReservedNames(schema),
|
|
268
|
+
...validateSchemaFieldNames(schema),
|
|
269
|
+
];
|
|
270
|
+
if (nameErrors.length > 0) {
|
|
271
|
+
throw new StackValidationError(nameErrors, SCHEMA_INVALID);
|
|
272
|
+
}
|
|
273
|
+
const lineage = lineageProblem(id, migratesFrom);
|
|
274
|
+
if (lineage)
|
|
275
|
+
throw new StackBadRequestError(lineage);
|
|
276
|
+
const schemaHash = await hashSchema(schema);
|
|
277
|
+
const existing = await this.getTypeCached(id);
|
|
278
|
+
if (existing) {
|
|
279
|
+
if (existing.schemaHash === schemaHash) {
|
|
280
|
+
if (existing.name === name)
|
|
281
|
+
return existing;
|
|
282
|
+
// else: name-only change — falls through to the write below,
|
|
283
|
+
// schema/hash/createdAt all carried over unchanged.
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
const violations = diffSchemas(existing.schema, schema);
|
|
287
|
+
if (violations.length > 0) {
|
|
288
|
+
throw new StackSchemaDriftError(id, violations);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const type = {
|
|
293
|
+
id,
|
|
294
|
+
baseId: parsed.baseId,
|
|
295
|
+
version: parsed.version,
|
|
296
|
+
name,
|
|
297
|
+
schema,
|
|
298
|
+
schemaHash,
|
|
299
|
+
createdAt: existing?.createdAt ?? new Date(),
|
|
300
|
+
...(migratesFrom && { migratesFrom }),
|
|
301
|
+
};
|
|
302
|
+
await this.adapter.saveType(type);
|
|
303
|
+
this.typeCache.set(id, type);
|
|
304
|
+
return type;
|
|
305
|
+
}
|
|
306
|
+
async getType(id) {
|
|
307
|
+
this.assertOpen();
|
|
308
|
+
return this.getTypeCached(id);
|
|
309
|
+
}
|
|
310
|
+
/** Refreshes typeCache wholesale — the explicit way to see a rename made by another writer. */
|
|
311
|
+
async listTypes() {
|
|
312
|
+
this.assertOpen();
|
|
313
|
+
const types = await this.adapter.listTypes();
|
|
314
|
+
for (const type of types)
|
|
315
|
+
this.typeCache.set(type.id, type);
|
|
316
|
+
return types;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Check whether a record's type is compatible with a required schema.
|
|
320
|
+
* Useful for duck-typed consumption across types.
|
|
321
|
+
*/
|
|
322
|
+
async typeIsCompatible(typeId, requiredSchema) {
|
|
323
|
+
this.assertOpen();
|
|
324
|
+
const type = await this.getTypeCached(typeId);
|
|
325
|
+
if (!type)
|
|
326
|
+
return false;
|
|
327
|
+
return isCompatible(type.schema, requiredSchema);
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Migrate every record of a family, deleted ones included, to the latest
|
|
331
|
+
* version, aborting on the first that fails validation. `sweep: 'listed'`
|
|
332
|
+
* is for a contained app that cannot see unlisted or deleted content.
|
|
333
|
+
* See docs/spec/data-model.md § Type migrations.
|
|
334
|
+
*/
|
|
335
|
+
async migrateAll(baseId, opts = {}) {
|
|
336
|
+
this.assertOpen();
|
|
337
|
+
assertFamilyId(baseId, 'migrateAll');
|
|
338
|
+
const familyTypeIds = await this.familyTypeIds([baseId]);
|
|
339
|
+
if (familyTypeIds.length === 0) {
|
|
340
|
+
throw new StackMigrationError(`migrateAll: no registered types found for baseId "${baseId}"`);
|
|
341
|
+
}
|
|
342
|
+
const listedOnly = opts.sweep === 'listed';
|
|
343
|
+
let migrated = 0;
|
|
344
|
+
let skipped = 0;
|
|
345
|
+
for (const typeId of familyTypeIds) {
|
|
346
|
+
const latestId = this.migrations.latest(typeId);
|
|
347
|
+
if (typeId === latestId)
|
|
348
|
+
continue;
|
|
349
|
+
const migrateFn = this.migrations.path(typeId, latestId);
|
|
350
|
+
if (!migrateFn)
|
|
351
|
+
continue;
|
|
352
|
+
const latestType = await this.getTypeCached(latestId);
|
|
353
|
+
if (!latestType) {
|
|
354
|
+
throw new StackMigrationError(`migrateAll: target type "${latestId}" is not defined.`);
|
|
355
|
+
}
|
|
356
|
+
let cursor;
|
|
357
|
+
do {
|
|
358
|
+
const result = await this.adapter.queryRecords({
|
|
359
|
+
filter: { typeId, includeDeleted: true, includeUnlisted: !listedOnly },
|
|
360
|
+
limit: 100,
|
|
361
|
+
cursor,
|
|
362
|
+
});
|
|
363
|
+
for (const record of result.records) {
|
|
364
|
+
if (listedOnly && record.deletedAt) {
|
|
365
|
+
skipped++;
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
// Same checked path commitMigration() takes — a migration
|
|
369
|
+
// function is no more entitled to move a DID binding or repoint
|
|
370
|
+
// an attachment than a request body is. No ifVersion: a batch
|
|
371
|
+
// pass doesn't know each record's version going in.
|
|
372
|
+
await this.commitMigrationChecked(record, latestId, migrateFn(record.content));
|
|
373
|
+
migrated++;
|
|
374
|
+
}
|
|
375
|
+
cursor = result.cursor ?? undefined;
|
|
376
|
+
} while (cursor);
|
|
377
|
+
}
|
|
378
|
+
return { migrated, skipped };
|
|
379
|
+
}
|
|
380
|
+
// -------------------------------------------------------
|
|
381
|
+
// Records
|
|
382
|
+
// -------------------------------------------------------
|
|
383
|
+
/**
|
|
384
|
+
* Create a record, validated against its type's schema. The one site that
|
|
385
|
+
* stamps a `_group`'s author as its first `admin`, for scoped creates too.
|
|
386
|
+
* Backdating is accepted unconditionally here; ScopedStack decides who may.
|
|
387
|
+
* See docs/spec/data-model.md § Backdating on import.
|
|
388
|
+
*/
|
|
389
|
+
async create(typeId, input, opts = {}) {
|
|
390
|
+
this.assertOpen();
|
|
391
|
+
const type = await this.getTypeCached(typeId);
|
|
392
|
+
if (!type) {
|
|
393
|
+
throw new StackBadRequestError(`Unknown type: "${typeId}". Call defineType() first.`);
|
|
394
|
+
}
|
|
395
|
+
const content = dropAbsentFields(input, type.schema);
|
|
396
|
+
// Copied, never aliased: an import loop reusing one Date would otherwise
|
|
397
|
+
// retro-edit every record it had written. `instanceof`, so a non-Date
|
|
398
|
+
// off the wire reaches validateClockField() below rather than throwing
|
|
399
|
+
// here; the fallback never reaches storage.
|
|
400
|
+
const createdAt = opts.createdAt instanceof Date ? new Date(opts.createdAt.getTime()) : new Date();
|
|
401
|
+
const updatedAt = opts.updatedAt instanceof Date ? new Date(opts.updatedAt.getTime()) : createdAt;
|
|
402
|
+
// Ahead of every other check on these two lists: a caller that named
|
|
403
|
+
// the wrong surface asked for something this layer does not offer, and
|
|
404
|
+
// reporting its grantee as a malformed relationship target would
|
|
405
|
+
// describe the mistake as the wrong kind of problem.
|
|
406
|
+
assertDataAssociations(opts.associations ?? [], 'associations');
|
|
407
|
+
assertAuthorityAssociations(opts.permissions ?? [], 'permissions');
|
|
408
|
+
const argumentErrors = [
|
|
409
|
+
...validateAssociations(opts.permissions, 'permissions'),
|
|
410
|
+
...validatePermissions(opts.permissions),
|
|
411
|
+
...validateAssociations(opts.associations),
|
|
412
|
+
...validateClockField(opts.createdAt, 'createdAt'),
|
|
413
|
+
...validateClockField(opts.updatedAt, 'updatedAt'),
|
|
414
|
+
];
|
|
415
|
+
// Compared against the *effective* createdAt, so an updatedAt supplied
|
|
416
|
+
// on its own is caught too: defaulted-createdAt is now, which a
|
|
417
|
+
// backdated updatedAt alone would still precede.
|
|
418
|
+
if (opts.updatedAt !== undefined && updatedAt.getTime() < createdAt.getTime()) {
|
|
419
|
+
argumentErrors.push({ path: 'updatedAt', message: 'updatedAt cannot precede createdAt.' });
|
|
420
|
+
}
|
|
421
|
+
const contentErrors = [
|
|
422
|
+
...validateReservedKeys(content),
|
|
423
|
+
...validateContentKeys(content),
|
|
424
|
+
...typedContentErrors(typeId, content, type.schema),
|
|
425
|
+
];
|
|
426
|
+
throwValidation(contentErrors, argumentErrors);
|
|
427
|
+
assertContentSize(content, this.capabilities.limits.contentBytes, 'Content');
|
|
428
|
+
if (typeId === `${SYSTEM_TYPES.ATTACHMENT}@1`) {
|
|
429
|
+
await checkAttachmentMimeTypeOnCreate(this, content);
|
|
430
|
+
}
|
|
431
|
+
await checkAttachmentAssociationPointers(this.readRecord, opts.associations);
|
|
432
|
+
await checkBindingsOnCreate(this, typeId, content);
|
|
433
|
+
if (opts.id !== undefined) {
|
|
434
|
+
validateRecordId(opts.id);
|
|
435
|
+
// Only when both are explicit: an `id` alone is a pure position
|
|
436
|
+
// choice, with no second timestamp to agree with.
|
|
437
|
+
if (opts.createdAt !== undefined) {
|
|
438
|
+
validateIdTimestampSkew(opts.id, this.idTimestampSkewMsValue, opts.createdAt.getTime(), 'createdAt');
|
|
439
|
+
}
|
|
440
|
+
// A generated id names nothing, so a create under a parent cannot
|
|
441
|
+
// close a loop. A caller-supplied one can: existing records may
|
|
442
|
+
// already point at it, and the chain above the parent can lead back
|
|
443
|
+
// to it — so the walk is asked only for that combination.
|
|
444
|
+
// See docs/spec/data-model.md § Reparenting.
|
|
445
|
+
if (opts.parentId !== undefined)
|
|
446
|
+
await assertNoParentCycle(this.readRecord, opts.id, opts.parentId);
|
|
447
|
+
}
|
|
448
|
+
const createdBy = normalizeActor(opts.createdBy);
|
|
449
|
+
const associations = baseIdOf(typeId) === SYSTEM_TYPES.GROUP
|
|
450
|
+
? stampGroupAdmin(opts.associations, createdBy?.subjectId ?? this.ownerEntityId)
|
|
451
|
+
: opts.associations;
|
|
452
|
+
// createdAt drives the id, so the two agree by construction rather than
|
|
453
|
+
// by coincidence. An explicit one mints via generateIdForTimestamp(),
|
|
454
|
+
// which never clamps to "now": generateId()'s monotonic floor would
|
|
455
|
+
// otherwise pull a deliberately historical id forward once this process
|
|
456
|
+
// has minted any live id past it.
|
|
457
|
+
const id = opts.id ??
|
|
458
|
+
(opts.createdAt !== undefined
|
|
459
|
+
? generateIdForTimestamp(createdAt.getTime())
|
|
460
|
+
: generateId(createdAt.getTime()));
|
|
461
|
+
// An id field a caller supplies is a value or it is absent — never the
|
|
462
|
+
// empty string, which names nobody. Refused rather than dropped, so the
|
|
463
|
+
// caller is never silently ignored.
|
|
464
|
+
if (opts.appId === '') {
|
|
465
|
+
throw new StackBadRequestError('Invalid appId: the empty string is not an id.');
|
|
466
|
+
}
|
|
467
|
+
// Every create naming a parent owes the reference check, whether or not
|
|
468
|
+
// it supplied an id: a destination a caller names has to exist.
|
|
469
|
+
if (opts.parentId !== undefined)
|
|
470
|
+
await assertParentExists(this.readRecord, id, opts.parentId);
|
|
471
|
+
const record = {
|
|
472
|
+
id,
|
|
473
|
+
typeId,
|
|
474
|
+
createdAt,
|
|
475
|
+
updatedAt,
|
|
476
|
+
content,
|
|
477
|
+
version: 1,
|
|
478
|
+
// Presence, not truthiness: '' is refused above, so absence is the
|
|
479
|
+
// only thing a falsy value could mean here.
|
|
480
|
+
...(opts.parentId !== undefined && { parentId: opts.parentId }),
|
|
481
|
+
...(opts.appId !== undefined && { appId: opts.appId }),
|
|
482
|
+
// A create's actor is its author, so `updatedBy` is derived rather
|
|
483
|
+
// than taken: stamping it here keeps "absent means an unscoped write"
|
|
484
|
+
// true of version 1 as it is of every later version.
|
|
485
|
+
...(createdBy && { createdBy, updatedBy: createdBy }),
|
|
486
|
+
...(opts.permissions?.length && { permissions: opts.permissions }),
|
|
487
|
+
...(associations?.length && { associations }),
|
|
488
|
+
...(opts.unlisted && { unlistedAt: createdAt }),
|
|
489
|
+
};
|
|
490
|
+
const change = new PendingChange('create', { actor: Stack.createActor(record) });
|
|
491
|
+
const created = await this.adapter.createRecord(record, { journal: change.journal });
|
|
492
|
+
this.announce(change, created);
|
|
493
|
+
return created;
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Get a record by ID, exactly as stored — no implicit migration. Pass
|
|
497
|
+
* { presentAt: 'latest' } to migrate in memory; only migrateAll()
|
|
498
|
+
* commits migrations to disk. A soft-deleted record answers `null` unless
|
|
499
|
+
* { includeDeleted: true } is passed.
|
|
500
|
+
*/
|
|
501
|
+
async get(id, opts = {}) {
|
|
502
|
+
this.assertOpen();
|
|
503
|
+
const record = await this.adapter.getRecord(id);
|
|
504
|
+
if (!record)
|
|
505
|
+
return null;
|
|
506
|
+
if (record.deletedAt && !opts.includeDeleted)
|
|
507
|
+
return null;
|
|
508
|
+
return opts.presentAt === 'latest' ? this.migrations.presentAtLatest(record) : record;
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Apply a change set as one atomic write — see StackClient.mutate(). A
|
|
512
|
+
* set the record already satisfies writes nothing, and content is checked
|
|
513
|
+
* against the record's current stored type.
|
|
514
|
+
* See docs/spec/data-model.md § Mutations.
|
|
515
|
+
*/
|
|
516
|
+
async mutate(id, changes, opts = {}) {
|
|
517
|
+
this.assertOpen();
|
|
518
|
+
assertNonEmptyChangeSet(changes);
|
|
519
|
+
const existing = await this.requireRecord(id);
|
|
520
|
+
checkFamily(existing, opts);
|
|
521
|
+
this.refuseIfDeleted(existing);
|
|
522
|
+
// Checked before validation, so a caller that lost the race learns its
|
|
523
|
+
// version is stale rather than that its patch is bad, and before the
|
|
524
|
+
// no-op short-circuit below, so a precondition is never satisfied by a
|
|
525
|
+
// write that turned out to move nothing.
|
|
526
|
+
if (takesIfVersion(changes))
|
|
527
|
+
this.checkIfVersion(existing, opts.ifVersion);
|
|
528
|
+
checkStoredVersion(existing, opts, changes.contentPatch !== undefined);
|
|
529
|
+
const merged = await this.validateChangeSet(id, existing, changes);
|
|
530
|
+
const ops = changeSetOps(existing, changes, merged);
|
|
531
|
+
// Nothing moved, so there is nothing to version. Decided here rather
|
|
532
|
+
// than in the adapter: "did this change anything" is a question about
|
|
533
|
+
// the record's meaning, and an adapter is never handed a write that
|
|
534
|
+
// writes nothing.
|
|
535
|
+
if (ops.length === 0)
|
|
536
|
+
return existing;
|
|
537
|
+
// A change set naming only aspects the journal already keeps in full
|
|
538
|
+
// doesn't bump — the same rule associate()/dissociate() follow
|
|
539
|
+
// unconditionally. Its actor travels as an explicit opt rather than a
|
|
540
|
+
// record stamp, since a non-bumping write never touches `updatedBy`.
|
|
541
|
+
// See docs/spec/versioning.md § Version history.
|
|
542
|
+
const bumps = bumpsVersion(ops);
|
|
543
|
+
// From the same before/after changeSetOps compared, so `ops` and the
|
|
544
|
+
// journal cannot disagree; ACL and data edits travel as one list.
|
|
545
|
+
// See docs/spec/events.md § The event shape.
|
|
546
|
+
const assocDelta = [
|
|
547
|
+
...(changes.associations
|
|
548
|
+
? associationDelta(existing.associations ?? [], changes.associations)
|
|
549
|
+
: []),
|
|
550
|
+
...(changes.permissions
|
|
551
|
+
? associationDelta(existing.permissions ?? [], changes.permissions)
|
|
552
|
+
: []),
|
|
553
|
+
];
|
|
554
|
+
const previousParentId = existing.parentId ?? null;
|
|
555
|
+
const change = new PendingChange(ops, {
|
|
556
|
+
actor: normalizeActor(opts.actor),
|
|
557
|
+
...(ops.includes('reparent') && { previousParentId }),
|
|
558
|
+
...(assocDelta.length && { associations: assocDelta }),
|
|
559
|
+
});
|
|
560
|
+
const updated = await this.adapter.mutateRecord(id, effectiveChanges(changes, ops), {
|
|
561
|
+
...(bumps ? this.writeOptions(existing, opts) : { actor: normalizeActor(opts.actor) }),
|
|
562
|
+
bumpsVersion: bumps,
|
|
563
|
+
journal: change.journal,
|
|
564
|
+
});
|
|
565
|
+
this.announce(change, updated);
|
|
566
|
+
return updated;
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* mutate() with `contentPatch` alone. The common case by a wide margin,
|
|
570
|
+
* and named for what it does rather than for a symmetry with create()
|
|
571
|
+
* that a patch does not have.
|
|
572
|
+
*/
|
|
573
|
+
async patchContent(id, patch, opts) {
|
|
574
|
+
return this.mutate(id, { contentPatch: patch }, opts);
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Every check a change set owes, all before anything is written. Returns
|
|
578
|
+
* the merged content for a content patch, which the caller compares to
|
|
579
|
+
* decide whether content moved. A parent is checked only when it changes.
|
|
580
|
+
*/
|
|
581
|
+
async validateChangeSet(id, existing, changes) {
|
|
582
|
+
const { contentPatch, permissions, associations, parentId } = changes;
|
|
583
|
+
// Each key replaces only within its own domain. Asked before the
|
|
584
|
+
// shape checks below so a misrouted element is reported as the wrong
|
|
585
|
+
// surface rather than as a malformed value of the right one, and
|
|
586
|
+
// asked here rather than in `ScopedStack` so an unscoped `Stack`, an
|
|
587
|
+
// import and a server mapping a request body are all held to it.
|
|
588
|
+
if (associations)
|
|
589
|
+
assertDataAssociations(associations, 'associations');
|
|
590
|
+
if (permissions)
|
|
591
|
+
assertAuthorityAssociations(permissions, 'permissions');
|
|
592
|
+
const argumentErrors = [
|
|
593
|
+
...(permissions
|
|
594
|
+
? [...validateAssociations(permissions, 'permissions'), ...validatePermissions(permissions)]
|
|
595
|
+
: []),
|
|
596
|
+
...(associations ? validateAssociations(associations) : []),
|
|
597
|
+
];
|
|
598
|
+
const patchErrors = contentPatch
|
|
599
|
+
? [
|
|
600
|
+
...validateReservedKeys(contentPatch),
|
|
601
|
+
...validatePatchValues(contentPatch),
|
|
602
|
+
...validateContentKeys(contentPatch),
|
|
603
|
+
]
|
|
604
|
+
: [];
|
|
605
|
+
throwValidation(patchErrors, argumentErrors);
|
|
606
|
+
await checkAttachmentAssociationPointers(this.readRecord, associations, existing.associations);
|
|
607
|
+
let merged;
|
|
608
|
+
if (contentPatch) {
|
|
609
|
+
// The patch is what travels, so the patch is what's measured — a
|
|
610
|
+
// small patch against a large record is not an oversized request.
|
|
611
|
+
assertContentSize(contentPatch, this.capabilities.limits.contentBytes, 'Patch');
|
|
612
|
+
const type = await this.getTypeCached(existing.typeId);
|
|
613
|
+
if (!type) {
|
|
614
|
+
throw new StackBadRequestError(`Unknown type: "${existing.typeId}"`);
|
|
615
|
+
}
|
|
616
|
+
merged = applyMergePatch(existing.content, contentPatch);
|
|
617
|
+
const contentErrors = typedContentErrors(existing.typeId, merged, type.schema);
|
|
618
|
+
if (contentErrors.length > 0)
|
|
619
|
+
throw new StackValidationError(contentErrors);
|
|
620
|
+
if (existing.typeId === `${SYSTEM_TYPES.ATTACHMENT}@1`) {
|
|
621
|
+
// Presence decides mimeType and value decides the rest: re-sending
|
|
622
|
+
// the mimeType a record already holds is refused outright, while a
|
|
623
|
+
// client round-tripping fileId or size unchanged has claimed nothing.
|
|
624
|
+
assertAttachmentImmutable((field) => Object.prototype.hasOwnProperty.call(contentPatch, field) &&
|
|
625
|
+
(field === 'mimeType' ||
|
|
626
|
+
merged[field] !==
|
|
627
|
+
existing.content[field]));
|
|
628
|
+
}
|
|
629
|
+
await checkBindingsOnUpdate(this, existing.typeId, id, contentPatch, existing.content, merged);
|
|
630
|
+
if (id === SYSTEM_TYPES.CONFIG) {
|
|
631
|
+
checkConfigEntityIdUnchanged(existing.content.entityId, merged.entityId);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
if (associations !== undefined) {
|
|
635
|
+
assertGroupAdminRemains(existing, associations);
|
|
636
|
+
}
|
|
637
|
+
if (parentId !== undefined && parentId !== null && parentId !== (existing.parentId ?? null)) {
|
|
638
|
+
await assertParentExists(this.readRecord, id, parentId);
|
|
639
|
+
await assertNoParentCycle(this.readRecord, id, parentId);
|
|
640
|
+
}
|
|
641
|
+
return merged;
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Add associations to a record, without a version bump. One matching an
|
|
645
|
+
* existing association's identity but a different `attachmentRecordId`
|
|
646
|
+
* re-points it in place. See docs/spec/attachments.md § Naming the upload
|
|
647
|
+
* a reference came from.
|
|
648
|
+
*/
|
|
649
|
+
async associate(id, associations, opts = {}) {
|
|
650
|
+
assertAssociationList(associations, 'associate()', 'associations', 'data');
|
|
651
|
+
return this.amendAssociations(id, editsOf('add', associations), opts, 'associate()');
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Remove associations from a record — see associate(). Matched by kind,
|
|
655
|
+
* label and payload; an element not found is a no-op. The emitted event
|
|
656
|
+
* names each association by identity only, since an attachment's
|
|
657
|
+
* `attachmentRecordId` describes nothing current once it is gone; the
|
|
658
|
+
* journal is where it survives.
|
|
659
|
+
*/
|
|
660
|
+
async dissociate(id, associations, opts = {}) {
|
|
661
|
+
assertAssociationList(associations, 'dissociate()', 'associations', 'data');
|
|
662
|
+
return this.amendAssociations(id, editsOf('remove', associations), opts, 'dissociate()');
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* The atomic form of associate() and dissociate(): one list of adds and
|
|
666
|
+
* removes, one adapter write, one journal entry. The journal records each
|
|
667
|
+
* element in full — a remove with its annotation, a re-point with what it
|
|
668
|
+
* overwrote — so the entry is as undoable as a single-element one.
|
|
669
|
+
* See docs/spec/data-model.md § Mutations.
|
|
670
|
+
*/
|
|
671
|
+
async amendAssociations(id, changes, opts = {}, surface = 'amendAssociations()') {
|
|
672
|
+
this.assertOpen();
|
|
673
|
+
assertAssociationEdits(changes, surface, 'data');
|
|
674
|
+
const existing = await this.requireRecord(id);
|
|
675
|
+
checkFamily(existing, opts);
|
|
676
|
+
this.refuseIfDeleted(existing);
|
|
677
|
+
const current = existing.associations ?? [];
|
|
678
|
+
const next = applyAssociationEdits(current, changes);
|
|
679
|
+
const delta = associationDelta(current, next);
|
|
680
|
+
if (delta.length === 0)
|
|
681
|
+
return existing;
|
|
682
|
+
// Only a roster can lose its last admin, and only the post-state says so.
|
|
683
|
+
assertGroupAdminRemains(existing, next);
|
|
684
|
+
await checkAttachmentAssociationPointers(this.readRecord, changes.flatMap((c) => (c.op === 'add' ? [c.association] : [])), current);
|
|
685
|
+
const change = new PendingChange([
|
|
686
|
+
...(delta.some((c) => c.op !== 'remove') ? ['associate'] : []),
|
|
687
|
+
...(delta.some((c) => c.op === 'remove') ? ['dissociate'] : []),
|
|
688
|
+
], { actor: normalizeActor(opts.actor), associations: delta });
|
|
689
|
+
const updated = await this.adapter.amendAssociations(id, changes, { journal: change.journal });
|
|
690
|
+
this.announce(change, updated);
|
|
691
|
+
return updated;
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Extend who reaches a record. The spelling that survives two admins
|
|
695
|
+
* sharing a record at once: a `permissions` key write replaces the whole
|
|
696
|
+
* set, so the later of two concurrent ones drops what the earlier
|
|
697
|
+
* granted. No-bump, like associate(); an element the record already
|
|
698
|
+
* carries is a no-op. `write` never implies `read` — name both.
|
|
699
|
+
*/
|
|
700
|
+
async grantAccess(id, permissions, opts = {}) {
|
|
701
|
+
assertAssociationList(permissions, 'grantAccess()', 'permissions', 'authority');
|
|
702
|
+
return this.amendAccess(id, editsOf('add', permissions), opts, 'grantAccess()');
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Withdraw elements of who reaches a record — see grantAccess() for why
|
|
706
|
+
* this is a verb rather than a key write. An element the record does not
|
|
707
|
+
* carry is a no-op. Returns the record as it now stands.
|
|
708
|
+
*/
|
|
709
|
+
async revokeAccess(id, permissions, opts = {}) {
|
|
710
|
+
assertAssociationList(permissions, 'revokeAccess()', 'permissions', 'authority');
|
|
711
|
+
return this.amendAccess(id, editsOf('remove', permissions), opts, 'revokeAccess()');
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* The atomic form of grantAccess() and revokeAccess(). The
|
|
715
|
+
* write-implies-read invariant is asked once, of the set the whole list
|
|
716
|
+
* produces. See docs/spec/access-control.md § Write implies read.
|
|
717
|
+
*/
|
|
718
|
+
async amendAccess(id, changes, opts = {}, surface = 'amendAccess()') {
|
|
719
|
+
this.assertOpen();
|
|
720
|
+
assertAssociationEdits(changes, surface, 'authority');
|
|
721
|
+
const existing = await this.requireRecord(id);
|
|
722
|
+
checkFamily(existing, opts);
|
|
723
|
+
this.refuseIfDeleted(existing);
|
|
724
|
+
const current = existing.permissions ?? [];
|
|
725
|
+
const next = applyAssociationEdits(current, changes);
|
|
726
|
+
const delta = associationDelta(current, next);
|
|
727
|
+
if (delta.length === 0)
|
|
728
|
+
return existing;
|
|
729
|
+
this.assertPermissionSet(next);
|
|
730
|
+
const change = new PendingChange('reshare', {
|
|
731
|
+
actor: normalizeActor(opts.actor),
|
|
732
|
+
associations: delta,
|
|
733
|
+
});
|
|
734
|
+
const updated = await this.adapter.amendAssociations(id, changes, { journal: change.journal });
|
|
735
|
+
this.announce(change, updated);
|
|
736
|
+
return updated;
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Hold a permission set to the cross-element invariant — see
|
|
740
|
+
* validatePermissions() in access.ts for why it reads the post-state.
|
|
741
|
+
*/
|
|
742
|
+
assertPermissionSet(next) {
|
|
743
|
+
const errors = validatePermissions(next);
|
|
744
|
+
if (errors.length > 0)
|
|
745
|
+
throw new StackValidationError(errors, ARGUMENTS_INVALID);
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* A soft-deleted record has no current state to edit, so the verbs that
|
|
749
|
+
* edit one refuse it; undelete() and commitMigration() do not call this.
|
|
750
|
+
* Asked after the record is found and, under ScopedStack, after the
|
|
751
|
+
* authority decision. See docs/spec/versioning.md § Mutations are refused,
|
|
752
|
+
* not applied to a tombstone.
|
|
753
|
+
*/
|
|
754
|
+
refuseIfDeleted(record) {
|
|
755
|
+
if (record.deletedAt) {
|
|
756
|
+
throw new StackConflictError(`Record "${record.id}" is soft-deleted; undelete it before mutating it.`);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* A by-id read straight from the adapter, for the integrity checks a write
|
|
761
|
+
* runs once it is under way: they read past the open-check, as the write
|
|
762
|
+
* they belong to was admitted before any close() began.
|
|
763
|
+
*/
|
|
764
|
+
readRecord = (id) => this.adapter.getRecord(id);
|
|
765
|
+
/** The record, or the not-found refusal every mutating verb owes. */
|
|
766
|
+
async requireRecord(id) {
|
|
767
|
+
const record = await this.adapter.getRecord(id);
|
|
768
|
+
if (!record)
|
|
769
|
+
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
770
|
+
return record;
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Soft-delete a record, or purge it with its history. A purge reports
|
|
774
|
+
* the files the record referenced, deleting none of them. Use
|
|
775
|
+
* deleteAndReturn() when the record is needed too. See
|
|
776
|
+
* docs/spec/versioning.md § Deletion and docs/spec/attachments.md § A
|
|
777
|
+
* purge strands the bytes it referenced.
|
|
778
|
+
*/
|
|
779
|
+
async delete(id, opts = {}) {
|
|
780
|
+
const { referencedFileIds } = await this.deleteAndReturn(id, opts);
|
|
781
|
+
return { referencedFileIds };
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* delete(), plus the record it acted on — before destruction for a purge,
|
|
785
|
+
* the tombstone for a soft delete — captured inside the same write, so no
|
|
786
|
+
* concurrent write can land unseen between a read and the delete.
|
|
787
|
+
*/
|
|
788
|
+
async deleteAndReturn(id, opts = {}) {
|
|
789
|
+
this.assertOpen();
|
|
790
|
+
if (id === SYSTEM_TYPES.CONFIG) {
|
|
791
|
+
throw new StackConflictError("Cannot delete the _config record: it holds the stack's identity and is required for every permission check.");
|
|
792
|
+
}
|
|
793
|
+
if (opts.purge) {
|
|
794
|
+
// A purge otherwise reads nothing first, so an expectation costs it
|
|
795
|
+
// one read, and the purge is pinned to that read: a write in between
|
|
796
|
+
// is a conflict, never a purge of a record that was not checked.
|
|
797
|
+
let ifVersion = opts.ifVersion;
|
|
798
|
+
if (opts[WRITE_EXPECTATION]) {
|
|
799
|
+
const current = await this.adapter.getRecord(id);
|
|
800
|
+
if (!current)
|
|
801
|
+
return { record: null, referencedFileIds: [] };
|
|
802
|
+
checkFamily(current, opts);
|
|
803
|
+
ifVersion ??= current.version;
|
|
804
|
+
}
|
|
805
|
+
// The adapter hands back what it destroyed, captured inside the same
|
|
806
|
+
// write: a read here instead would race the delete, and afterwards
|
|
807
|
+
// there is nothing left to read. Null means there was no record, so
|
|
808
|
+
// nothing was purged and nothing is announced.
|
|
809
|
+
const change = new PendingChange('purge', { actor: normalizeActor(opts.actor) });
|
|
810
|
+
const purged = await this.adapter.deleteRecord(id, {
|
|
811
|
+
purge: true,
|
|
812
|
+
ifVersion,
|
|
813
|
+
journal: change.journal,
|
|
814
|
+
});
|
|
815
|
+
if (!purged)
|
|
816
|
+
return { record: null, referencedFileIds: [] };
|
|
817
|
+
this.announce(change, purged);
|
|
818
|
+
return { record: purged, referencedFileIds: await this.referencedFileIds(purged) };
|
|
819
|
+
}
|
|
820
|
+
const existing = await this.requireRecord(id);
|
|
821
|
+
checkFamily(existing, opts);
|
|
822
|
+
this.checkIfVersion(existing, opts.ifVersion);
|
|
823
|
+
// A tombstone's references stand — undelete() must find its
|
|
824
|
+
// attachments intact — so nothing is stranded and nothing is reported.
|
|
825
|
+
if (existing.deletedAt)
|
|
826
|
+
return { record: existing, referencedFileIds: [] };
|
|
827
|
+
const change = new PendingChange('delete', { actor: normalizeActor(opts.actor) });
|
|
828
|
+
const deleted = await this.adapter.deleteRecord(id, {
|
|
829
|
+
...this.writeOptions(existing, opts),
|
|
830
|
+
journal: change.journal,
|
|
831
|
+
});
|
|
832
|
+
if (deleted)
|
|
833
|
+
this.announce(change, deleted);
|
|
834
|
+
return { record: deleted ?? existing, referencedFileIds: [] };
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* The files a record references, by the same definition
|
|
838
|
+
* deleteAttachment() and the garbage sweep use: attachment associations
|
|
839
|
+
* and top-level `file-ref` content fields. An `_attachment` record's own
|
|
840
|
+
* `fileId` is a plain string by design and is not one, which is why
|
|
841
|
+
* purging a metadata record reports nothing — see systemTypeDefinitions().
|
|
842
|
+
*/
|
|
843
|
+
async referencedFileIds(record) {
|
|
844
|
+
const fromAssociations = (record.associations ?? []).flatMap((a) => a.kind === 'attachment' ? [a.fileId] : []);
|
|
845
|
+
const type = await this.getType(record.typeId);
|
|
846
|
+
const content = record.content;
|
|
847
|
+
const fromContent = fileRefFields(type?.schema ?? {}).flatMap((field) => typeof content[field] === 'string' ? [content[field]] : []);
|
|
848
|
+
return [...new Set([...fromAssociations, ...fromContent])];
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Reverse a soft delete. Idempotent — undeleting a record that isn't
|
|
852
|
+
* deleted returns it unchanged. Purged records are gone, so this
|
|
853
|
+
* throws StackNotFoundError for them just like any other missing record.
|
|
854
|
+
* Snapshots and bumps version, same as delete().
|
|
855
|
+
*/
|
|
856
|
+
async undelete(id, opts = {}) {
|
|
857
|
+
this.assertOpen();
|
|
858
|
+
const existing = await this.requireRecord(id);
|
|
859
|
+
checkFamily(existing, opts);
|
|
860
|
+
this.checkIfVersion(existing, opts.ifVersion);
|
|
861
|
+
if (!existing.deletedAt)
|
|
862
|
+
return existing;
|
|
863
|
+
const change = new PendingChange('undelete', { actor: normalizeActor(opts.actor) });
|
|
864
|
+
const undeleted = await this.adapter.undeleteRecord(id, {
|
|
865
|
+
...this.writeOptions(existing, opts),
|
|
866
|
+
journal: change.journal,
|
|
867
|
+
});
|
|
868
|
+
this.announce(change, undeleted);
|
|
869
|
+
return undeleted;
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Query records. filter.baseId matches every version of a type family,
|
|
873
|
+
* resolved against registered Types. Results come back exactly as
|
|
874
|
+
* stored; pass presentAt: 'latest' to migrate in memory. See
|
|
875
|
+
* docs/spec/data-model.md § Queries.
|
|
876
|
+
*/
|
|
877
|
+
async query(query = {}) {
|
|
878
|
+
this.assertOpen();
|
|
879
|
+
const { presentAt, filter, limit: rawLimit, ...rest } = query;
|
|
880
|
+
assertValidContentByType(filter);
|
|
881
|
+
assertQueryCapabilities(filter, this.adapter.capabilities);
|
|
882
|
+
assertValidSort(query.sort);
|
|
883
|
+
assertSortCapability(query.sort, this.adapter.capabilities);
|
|
884
|
+
assertValidAssociationFilters(filter);
|
|
885
|
+
assertValidBaseIdFilter(filter);
|
|
886
|
+
const limit = rawLimit !== undefined ? Math.min(rawLimit, MAX_QUERY_LIMIT) : undefined;
|
|
887
|
+
const resolvedFilter = await this.resolveBaseIdFilter(filter);
|
|
888
|
+
if (resolvedFilter === EMPTY_FAMILY) {
|
|
889
|
+
return { records: [], cursor: null };
|
|
890
|
+
}
|
|
891
|
+
const result = await this.adapter.queryRecords({
|
|
892
|
+
...rest,
|
|
893
|
+
...(query.sort && { sort: normalizeSort(query.sort) }),
|
|
894
|
+
...(resolvedFilter !== undefined && { filter: resolvedFilter }),
|
|
895
|
+
...(limit !== undefined && { limit }),
|
|
896
|
+
});
|
|
897
|
+
if (presentAt !== 'latest')
|
|
898
|
+
return result;
|
|
899
|
+
return { ...result, records: result.records.map((r) => this.migrations.presentAtLatest(r)) };
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* Resolve filter.baseId into a concrete typeId set (intersected with
|
|
903
|
+
* filter.typeId when both are given), so adapters never need their own
|
|
904
|
+
* baseId concept. Returns EMPTY_FAMILY when the resolved set is empty so
|
|
905
|
+
* the caller can short-circuit without an adapter round trip.
|
|
906
|
+
*/
|
|
907
|
+
async resolveBaseIdFilter(filter) {
|
|
908
|
+
if (filter?.baseId === undefined)
|
|
909
|
+
return filter;
|
|
910
|
+
const { baseId, typeId, ...rest } = filter;
|
|
911
|
+
const requestedBaseIds = Array.isArray(baseId) ? baseId : [baseId];
|
|
912
|
+
const familyTypeIds = await this.familyTypeIds(requestedBaseIds);
|
|
913
|
+
const resolvedTypeIds = typeId === undefined
|
|
914
|
+
? familyTypeIds
|
|
915
|
+
: familyTypeIds.filter((id) => Array.isArray(typeId) ? typeId.includes(id) : typeId === id);
|
|
916
|
+
if (resolvedTypeIds.length === 0)
|
|
917
|
+
return EMPTY_FAMILY;
|
|
918
|
+
return { ...rest, typeId: resolvedTypeIds };
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Every registered version of the named families, as concrete typeIds —
|
|
922
|
+
* resolved here because an adapter has no baseId concept of its own.
|
|
923
|
+
*/
|
|
924
|
+
async familyTypeIds(baseIds) {
|
|
925
|
+
const types = await this.adapter.listTypes();
|
|
926
|
+
return types.filter((t) => baseIds.includes(t.baseId)).map((t) => t.id);
|
|
927
|
+
}
|
|
928
|
+
// -------------------------------------------------------
|
|
929
|
+
// Versions
|
|
930
|
+
// -------------------------------------------------------
|
|
931
|
+
async getVersions(id, query = {}) {
|
|
932
|
+
this.assertOpen();
|
|
933
|
+
assertValidVersionsQuery(query);
|
|
934
|
+
return this.adapter.getVersions(id, query);
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* A record's change journal, oldest first, ungated. A missing or purged
|
|
938
|
+
* record is StackNotFoundError, since a destroyed log and an empty one
|
|
939
|
+
* are not the same answer. See docs/spec/journal.md § Reading it.
|
|
940
|
+
*/
|
|
941
|
+
async getJournal(id, query = {}) {
|
|
942
|
+
this.assertOpen();
|
|
943
|
+
assertValidJournalQuery(query);
|
|
944
|
+
return this.adapter.getJournal(id, query);
|
|
945
|
+
}
|
|
946
|
+
async getVersion(id, version) {
|
|
947
|
+
this.assertOpen();
|
|
948
|
+
return this.adapter.getVersion(id, version);
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* Restore a record to a previous version by creating a new version —
|
|
952
|
+
* never rewrites history. The snapshot is validated against its own
|
|
953
|
+
* stored typeId, and puts back `content` and `typeId` alone: a snapshot
|
|
954
|
+
* carries nothing else. See docs/spec/versioning.md § Restore semantics.
|
|
955
|
+
*/
|
|
956
|
+
async restoreVersion(id, version, opts = {}) {
|
|
957
|
+
this.assertOpen();
|
|
958
|
+
const existing = await this.requireRecord(id);
|
|
959
|
+
// A snapshot is validated against its own stored Type, never the
|
|
960
|
+
// record's current one, so only the family is held to the expectation:
|
|
961
|
+
// the record's here, and the snapshot's once it is read.
|
|
962
|
+
checkFamily(existing, opts);
|
|
963
|
+
this.refuseIfDeleted(existing);
|
|
964
|
+
this.checkIfVersion(existing, opts.ifVersion);
|
|
965
|
+
const target = await this.adapter.getVersion(id, version);
|
|
966
|
+
if (!target) {
|
|
967
|
+
throw new StackNotFoundError(`Version ${version} not found for record "${id}"`);
|
|
968
|
+
}
|
|
969
|
+
checkSnapshotFamily(id, target, opts);
|
|
970
|
+
const type = await this.getTypeCached(target.typeId);
|
|
971
|
+
if (!type) {
|
|
972
|
+
throw new StackBadRequestError(`Unknown type: "${target.typeId}"`);
|
|
973
|
+
}
|
|
974
|
+
const errors = typedContentErrors(target.typeId, target.content, type.schema);
|
|
975
|
+
if (errors.length > 0) {
|
|
976
|
+
throw new StackValidationError(errors);
|
|
977
|
+
}
|
|
978
|
+
if (id === SYSTEM_TYPES.CONFIG) {
|
|
979
|
+
checkConfigEntityIdUnchanged(existing.content.entityId, target.content.entityId);
|
|
980
|
+
}
|
|
981
|
+
// Restoring is a write like any other, so it owes the same immutability
|
|
982
|
+
// check a content patch pays: a snapshot taken before a card claimed
|
|
983
|
+
// its DID would otherwise move the binding by rolling content back.
|
|
984
|
+
// Uniqueness needs none — a restore can only put back a value this same
|
|
985
|
+
// card already held.
|
|
986
|
+
for (const field of bindingFieldsOf(baseIdOf(target.typeId))) {
|
|
987
|
+
checkBindingImmutable(baseIdOf(target.typeId), field, existing.content[field], target.content[field]);
|
|
988
|
+
}
|
|
989
|
+
// A restore adds no containment edge and takes none away, so there is
|
|
990
|
+
// no cycle for it to close and nothing for the destination checks to
|
|
991
|
+
// gate. See docs/spec/versioning.md § Restore semantics.
|
|
992
|
+
const change = new PendingChange('restore', {
|
|
993
|
+
actor: normalizeActor(opts.actor),
|
|
994
|
+
previousTypeId: existing.typeId,
|
|
995
|
+
});
|
|
996
|
+
const restored = await this.adapter.restoreVersion(id, version, {
|
|
997
|
+
...this.writeOptions(existing, opts),
|
|
998
|
+
journal: change.journal,
|
|
999
|
+
});
|
|
1000
|
+
this.announce(change, restored);
|
|
1001
|
+
return restored;
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Commit a per-record migration with caller-supplied content. Create-shaped
|
|
1005
|
+
* at the destination and update-shaped over the record, so it owes both
|
|
1006
|
+
* sets of integrity checks. See docs/spec/data-model.md § Type migrations.
|
|
1007
|
+
*/
|
|
1008
|
+
async commitMigration(id, toTypeId, content, opts = {}) {
|
|
1009
|
+
this.assertOpen();
|
|
1010
|
+
const existing = await this.requireRecord(id);
|
|
1011
|
+
this.checkIfVersion(existing, opts.ifVersion);
|
|
1012
|
+
return this.commitMigrationChecked(existing, toTypeId, content, opts);
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* The checked migration write behind commitMigration() and migrateAll(),
|
|
1016
|
+
* taking the record in hand so a batch pass needs no re-read. A Migration
|
|
1017
|
+
* function is held to the same checks as a request body: app code is no
|
|
1018
|
+
* trust boundary here.
|
|
1019
|
+
*/
|
|
1020
|
+
async commitMigrationChecked(existing, toTypeId, input, opts = {}) {
|
|
1021
|
+
const id = existing.id;
|
|
1022
|
+
const type = await this.getTypeCached(toTypeId);
|
|
1023
|
+
if (!type) {
|
|
1024
|
+
throw new StackBadRequestError(`Unknown type: "${toTypeId}". Call defineType() first.`);
|
|
1025
|
+
}
|
|
1026
|
+
const content = dropAbsentFields(input, type.schema);
|
|
1027
|
+
const errors = [
|
|
1028
|
+
...validateReservedKeys(content),
|
|
1029
|
+
...validateContentKeys(content),
|
|
1030
|
+
...typedContentErrors(toTypeId, content, type.schema),
|
|
1031
|
+
];
|
|
1032
|
+
if (errors.length > 0) {
|
|
1033
|
+
throw new StackValidationError(errors);
|
|
1034
|
+
}
|
|
1035
|
+
assertContentSize(content, this.capabilities.limits.contentBytes, 'Content');
|
|
1036
|
+
const fromFamily = baseIdOf(existing.typeId);
|
|
1037
|
+
const toFamily = baseIdOf(toTypeId);
|
|
1038
|
+
const existingContent = existing.content;
|
|
1039
|
+
// Only create() stamps a group's first `admin`, so a record migrated
|
|
1040
|
+
// *into* `_group` would have an empty roster. Version-to-version within
|
|
1041
|
+
// the family keeps its roster. See docs/spec/data-model.md § Type migrations.
|
|
1042
|
+
if (toFamily === SYSTEM_TYPES.GROUP && fromFamily !== SYSTEM_TYPES.GROUP) {
|
|
1043
|
+
throw new StackConflictError('Cannot migrate a record into _group: a group’s admin roster is stamped at creation. ' +
|
|
1044
|
+
'Create the group instead.');
|
|
1045
|
+
}
|
|
1046
|
+
if (fromFamily === SYSTEM_TYPES.ATTACHMENT) {
|
|
1047
|
+
// Value-wise throughout: a migration re-sends all three required
|
|
1048
|
+
// fields, so presence would refuse every migration of the family.
|
|
1049
|
+
assertAttachmentImmutable((field) => content[field] !==
|
|
1050
|
+
existingContent[field]);
|
|
1051
|
+
}
|
|
1052
|
+
else if (toFamily === SYSTEM_TYPES.ATTACHMENT) {
|
|
1053
|
+
// A record arriving from outside the family stakes a fresh claim on
|
|
1054
|
+
// its fileId, exactly as create() does — so it owes create()'s check.
|
|
1055
|
+
await checkAttachmentMimeTypeOnCreate(this, content);
|
|
1056
|
+
}
|
|
1057
|
+
await checkBindingsOnMigrate(this, existing.typeId, toTypeId, id, existingContent, content);
|
|
1058
|
+
if (id === SYSTEM_TYPES.CONFIG) {
|
|
1059
|
+
checkConfigEntityIdUnchanged(existing.content.entityId, content.entityId);
|
|
1060
|
+
}
|
|
1061
|
+
const change = new PendingChange('migrate', {
|
|
1062
|
+
actor: normalizeActor(opts.actor),
|
|
1063
|
+
previousTypeId: existing.typeId,
|
|
1064
|
+
});
|
|
1065
|
+
const migrated = await this.adapter.commitMigration(id, toTypeId, content, {
|
|
1066
|
+
...this.writeOptions(existing, opts),
|
|
1067
|
+
journal: change.journal,
|
|
1068
|
+
});
|
|
1069
|
+
this.announce(change, migrated);
|
|
1070
|
+
return migrated;
|
|
1071
|
+
}
|
|
1072
|
+
// -------------------------------------------------------
|
|
1073
|
+
// Attachments
|
|
1074
|
+
// -------------------------------------------------------
|
|
1075
|
+
/**
|
|
1076
|
+
* Store bytes and create their `_attachment@1` metadata record, returning
|
|
1077
|
+
* it: `content.fileId` addresses the bytes, `id` the metadata. Uses the
|
|
1078
|
+
* adapter's atomic putAttachmentWithMetadata() where there is one.
|
|
1079
|
+
* See docs/spec/wire-format.md § Attachments.
|
|
1080
|
+
*/
|
|
1081
|
+
async putAttachment(data, opts) {
|
|
1082
|
+
this.assertOpen();
|
|
1083
|
+
assertAttachmentSize(data.byteLength, this.capabilities.limits.attachmentBytes);
|
|
1084
|
+
if (this.adapter.putAttachmentWithMetadata) {
|
|
1085
|
+
// The metadata record is written inside the adapter, so create()
|
|
1086
|
+
// never sees it and this is the only place it can be announced.
|
|
1087
|
+
const record = await this.adapter.putAttachmentWithMetadata(data, opts);
|
|
1088
|
+
// The one emission with no journal half of its own: the far side
|
|
1089
|
+
// wrote the record, so it appended the entry in that same write —
|
|
1090
|
+
// the same division saveVersion() follows over this adapter. See
|
|
1091
|
+
// docs/spec/journal.md § The entry set is the event set.
|
|
1092
|
+
this.announce(new PendingChange('create'), record);
|
|
1093
|
+
return record;
|
|
1094
|
+
}
|
|
1095
|
+
const fileId = await this.adapter.putBlob(data);
|
|
1096
|
+
return this.create(`${SYSTEM_TYPES.ATTACHMENT}@1`, attachments.uploadContent(fileId, data, opts), { appId: opts.appId });
|
|
1097
|
+
}
|
|
1098
|
+
async getAttachment(fileId) {
|
|
1099
|
+
this.assertOpen();
|
|
1100
|
+
return this.adapter.getBlob(fileId);
|
|
1101
|
+
}
|
|
1102
|
+
/**
|
|
1103
|
+
* Every `_attachment` record describing `fileId`, earliest-recorded first.
|
|
1104
|
+
* Not on StackClient: it presents an access decision already made, which
|
|
1105
|
+
* a scoped version would only check a second, different way. See
|
|
1106
|
+
* docs/spec/attachments.md § Finding a `fileId`'s metadata records.
|
|
1107
|
+
*/
|
|
1108
|
+
async getAttachmentRecords(fileId) {
|
|
1109
|
+
this.assertOpen();
|
|
1110
|
+
const results = await queryAllPages((q) => this.query(q), {
|
|
1111
|
+
filter: {
|
|
1112
|
+
baseId: SYSTEM_TYPES.ATTACHMENT,
|
|
1113
|
+
includeDeleted: true,
|
|
1114
|
+
includeUnlisted: true,
|
|
1115
|
+
...(filtersContent(this.capabilities) && { content: { fileId } }),
|
|
1116
|
+
},
|
|
1117
|
+
});
|
|
1118
|
+
return results
|
|
1119
|
+
.filter((r) => r.content.fileId === fileId)
|
|
1120
|
+
.sort(compareRecordedAttachments);
|
|
1121
|
+
}
|
|
1122
|
+
/**
|
|
1123
|
+
* Delete an attachment's bytes and every _attachment metadata record for
|
|
1124
|
+
* it, family-wide. Throws StackConflictError if any record in the stack
|
|
1125
|
+
* still references the file, StackNotFoundError if neither metadata
|
|
1126
|
+
* records nor bytes exist.
|
|
1127
|
+
*/
|
|
1128
|
+
async deleteAttachment(fileId, opts = {}) {
|
|
1129
|
+
this.assertOpen();
|
|
1130
|
+
let deletedRecords;
|
|
1131
|
+
if (this.adapter.deleteUnreferencedAttachmentRecords) {
|
|
1132
|
+
// Purged inside the adapter's own transaction, so these never reach
|
|
1133
|
+
// delete() and are announced here instead — from the records it
|
|
1134
|
+
// hands back, which are the last copies that will ever exist.
|
|
1135
|
+
deletedRecords = await this.adapter.deleteUnreferencedAttachmentRecords(fileId, await this.familyTypeIds([SYSTEM_TYPES.ATTACHMENT]));
|
|
1136
|
+
const at = new Date();
|
|
1137
|
+
for (const record of deletedRecords) {
|
|
1138
|
+
this.announce(new PendingChange('purge', { actor: normalizeActor(opts.actor) }), record, at);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
else {
|
|
1142
|
+
deletedRecords = await attachments.deleteUnreferencedAttachmentRecordsFallback(this, fileId, opts);
|
|
1143
|
+
}
|
|
1144
|
+
if (!deletedRecords.length) {
|
|
1145
|
+
try {
|
|
1146
|
+
await this.adapter.getBlob(fileId);
|
|
1147
|
+
}
|
|
1148
|
+
catch {
|
|
1149
|
+
throw new StackNotFoundError(`Attachment not found: "${fileId}"`);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
await this.adapter.deleteBlob(fileId);
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* Sweep for attachment bytes unreachable from any record — live or
|
|
1156
|
+
* soft-deleted — and delete bytes + metadata. Deletion goes through
|
|
1157
|
+
* deleteAttachment(), so a file re-referenced by sweep time is skipped,
|
|
1158
|
+
* not a failure. See docs/spec/attachments.md § Garbage collection.
|
|
1159
|
+
*/
|
|
1160
|
+
async collectAttachmentGarbage(opts = {}) {
|
|
1161
|
+
this.assertOpen();
|
|
1162
|
+
return attachments.collectAttachmentGarbage(this, this.adapter.listBlobs?.bind(this.adapter), opts);
|
|
1163
|
+
}
|
|
1164
|
+
// -------------------------------------------------------
|
|
1165
|
+
// Lifecycle
|
|
1166
|
+
// -------------------------------------------------------
|
|
1167
|
+
/**
|
|
1168
|
+
* Observe every change made through this Stack, unfiltered. Async so a
|
|
1169
|
+
* remote stack resolves once its feed is live, making subscribe-then-query
|
|
1170
|
+
* gap-free. See docs/spec/events.md § Subscribing.
|
|
1171
|
+
*/
|
|
1172
|
+
async subscribe(handler, opts = {}) {
|
|
1173
|
+
this.assertOpen();
|
|
1174
|
+
assertSinceUsable(opts.since, this.relaysChanges);
|
|
1175
|
+
assertValidBaseIdFilter(opts.filter);
|
|
1176
|
+
const unsubscribe = this.changes.subscribe(handler, opts);
|
|
1177
|
+
let stopRelay;
|
|
1178
|
+
try {
|
|
1179
|
+
stopRelay = await this.openRelay(handler, opts);
|
|
1180
|
+
}
|
|
1181
|
+
catch (err) {
|
|
1182
|
+
// The local half is already registered, and a failed subscribe()
|
|
1183
|
+
// must leave nothing behind for the caller to unsubscribe from.
|
|
1184
|
+
unsubscribe();
|
|
1185
|
+
throw err;
|
|
1186
|
+
}
|
|
1187
|
+
return () => {
|
|
1188
|
+
unsubscribe();
|
|
1189
|
+
stopRelay?.();
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* Ask the adapter to relay changes made elsewhere, if it can. One relay
|
|
1194
|
+
* per subscription, carrying its filter to the far end, which holds the
|
|
1195
|
+
* records that filter needs. See docs/spec/events.md § Where events come
|
|
1196
|
+
* from.
|
|
1197
|
+
*/
|
|
1198
|
+
async openRelay(handler, opts) {
|
|
1199
|
+
if (!this.adapter.subscribeChanges)
|
|
1200
|
+
return undefined;
|
|
1201
|
+
const delivery = new RelayDelivery(handler, opts);
|
|
1202
|
+
const stop = await this.adapter.subscribeChanges({
|
|
1203
|
+
...(opts.filter !== undefined && { filter: opts.filter }),
|
|
1204
|
+
...(opts.since !== undefined && { since: opts.since }),
|
|
1205
|
+
...(opts.includeRecords !== undefined && { includeRecords: opts.includeRecords }),
|
|
1206
|
+
...(opts.includeUnlisted !== undefined && { includeUnlisted: opts.includeUnlisted }),
|
|
1207
|
+
...(opts.onError !== undefined && { onError: opts.onError }),
|
|
1208
|
+
...(opts.onReset !== undefined && { onReset: opts.onReset }),
|
|
1209
|
+
}, (change) => delivery.deliver(change));
|
|
1210
|
+
return () => {
|
|
1211
|
+
delivery.close();
|
|
1212
|
+
void stop();
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
/** Whether changes reach this stack from elsewhere. */
|
|
1216
|
+
get relaysChanges() {
|
|
1217
|
+
return typeof this.adapter.subscribeChanges === 'function';
|
|
1218
|
+
}
|
|
1219
|
+
/**
|
|
1220
|
+
* Flush pending writes to the underlying storage. A no-op for adapters
|
|
1221
|
+
* that commit on every call (SQLite, the API adapter); meaningful for
|
|
1222
|
+
* ones that buffer, and for checkpointing a stack that stays open —
|
|
1223
|
+
* close() covers the teardown case on its own.
|
|
1224
|
+
*/
|
|
1225
|
+
async flush() {
|
|
1226
|
+
this.assertOpen();
|
|
1227
|
+
await this.adapter.flush?.();
|
|
1228
|
+
}
|
|
1229
|
+
/**
|
|
1230
|
+
* Flush, then release any resources the adapter holds (connections, file
|
|
1231
|
+
* handles, lock files). A failed flush still releases them before it
|
|
1232
|
+
* propagates: an unwritable stack must not also leak a lock file.
|
|
1233
|
+
* See docs/spec/adapters.md § Lifecycle.
|
|
1234
|
+
*/
|
|
1235
|
+
async close() {
|
|
1236
|
+
if (this.closed)
|
|
1237
|
+
return;
|
|
1238
|
+
// Marked closed up front so a failed flush can't leave the stack
|
|
1239
|
+
// half-open and invite a second close() onto an already-closed adapter.
|
|
1240
|
+
// Flushes through the adapter directly, past the now-tripped guard.
|
|
1241
|
+
this.closed = true;
|
|
1242
|
+
this.changes.closeAll();
|
|
1243
|
+
try {
|
|
1244
|
+
await this.adapter.flush?.();
|
|
1245
|
+
}
|
|
1246
|
+
finally {
|
|
1247
|
+
await this.adapter.close?.();
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
/** Throws once close() has run. */
|
|
1251
|
+
assertOpen() {
|
|
1252
|
+
if (this.closed)
|
|
1253
|
+
throw new UseAfterCloseError();
|
|
1254
|
+
}
|
|
1255
|
+
// -------------------------------------------------------
|
|
1256
|
+
// Grants
|
|
1257
|
+
// -------------------------------------------------------
|
|
1258
|
+
/**
|
|
1259
|
+
* Create a `_grant` authorizing `grantee` to take `actions` on `baseId`'s
|
|
1260
|
+
* family. An app's `-own` is read as the bare verb when it acts for
|
|
1261
|
+
* someone, so grant it the types it needs, not the narrowest-looking
|
|
1262
|
+
* suffix. See docs/spec/access-control.md § Type-level grants.
|
|
1263
|
+
*/
|
|
1264
|
+
async grantType(baseId, grant) {
|
|
1265
|
+
this.assertOpen();
|
|
1266
|
+
return typeGrants.grantType(this, baseId, grant);
|
|
1267
|
+
}
|
|
1268
|
+
/**
|
|
1269
|
+
* List `_grant` records: all of them, or those `grantType()` would have
|
|
1270
|
+
* written for the same grantee. The `entity` arm instead lists what
|
|
1271
|
+
* currently applies to that entity, so it is not a preview of what
|
|
1272
|
+
* `revokeType()` would withdraw. See docs/spec/access-control.md § Listing
|
|
1273
|
+
* and revoking.
|
|
1274
|
+
*/
|
|
1275
|
+
async listTypeGrants(query) {
|
|
1276
|
+
this.assertOpen();
|
|
1277
|
+
return typeGrants.listTypeGrants(this, query);
|
|
1278
|
+
}
|
|
1279
|
+
/**
|
|
1280
|
+
* The inverse of grantType(): soft-deletes the matching `_grant` records,
|
|
1281
|
+
* grantee and actions matched exactly, and returns them. Matching nothing
|
|
1282
|
+
* is not an error, so re-running a revocation stays safe.
|
|
1283
|
+
* See docs/spec/access-control.md § Listing and revoking.
|
|
1284
|
+
*/
|
|
1285
|
+
async revokeType(baseId, grant) {
|
|
1286
|
+
this.assertOpen();
|
|
1287
|
+
return typeGrants.revokeType(this, baseId, grant);
|
|
1288
|
+
}
|
|
1289
|
+
// -------------------------------------------------------
|
|
1290
|
+
// App installs
|
|
1291
|
+
// -------------------------------------------------------
|
|
1292
|
+
/**
|
|
1293
|
+
* What applying `manifest` for the key `did` would change, for the owner
|
|
1294
|
+
* to review before installApp(). Writes nothing. Refuses outright what no
|
|
1295
|
+
* approval could make valid: a type outside the app's own namespace and
|
|
1296
|
+
* the commons, a request the grant rules refuse, or a `did` already
|
|
1297
|
+
* registered to a different app. See docs/spec/apps.md § Plan, then apply.
|
|
1298
|
+
*/
|
|
1299
|
+
async planInstall(submitted, opts) {
|
|
1300
|
+
this.assertOpen();
|
|
1301
|
+
return apps.planInstall(this, submitted, opts);
|
|
1302
|
+
}
|
|
1303
|
+
/**
|
|
1304
|
+
* Apply an approved plan: types, the key's `_app` card, the `_install`
|
|
1305
|
+
* Record and every linked key's grants. A plan the stack has moved on
|
|
1306
|
+
* from is a StackConflictError, so what lands is what was approved.
|
|
1307
|
+
* See docs/spec/apps.md § Plan, then apply.
|
|
1308
|
+
*/
|
|
1309
|
+
async installApp(plan) {
|
|
1310
|
+
this.assertOpen();
|
|
1311
|
+
return apps.installApp(this, plan);
|
|
1312
|
+
}
|
|
1313
|
+
/**
|
|
1314
|
+
* Withdraw every grant an install produced and soft-delete it. The app's
|
|
1315
|
+
* records and `_app` cards stay: the data is the owner's, and the cards
|
|
1316
|
+
* are what its records' attribution resolves through. Returns the
|
|
1317
|
+
* install's tombstone. See docs/spec/apps.md § Uninstalling.
|
|
1318
|
+
*/
|
|
1319
|
+
async uninstallApp(appId) {
|
|
1320
|
+
this.assertOpen();
|
|
1321
|
+
return apps.uninstallApp(this, appId);
|
|
1322
|
+
}
|
|
1323
|
+
// -------------------------------------------------------
|
|
1324
|
+
// Private helpers
|
|
1325
|
+
// -------------------------------------------------------
|
|
1326
|
+
/**
|
|
1327
|
+
* Fast-fail for the ifVersion precondition using the already-fetched
|
|
1328
|
+
* record. The adapter re-checks atomically at write time (the source of
|
|
1329
|
+
* truth for concurrent writers) — this just skips validation and
|
|
1330
|
+
* snapshotting work when the mismatch is already visible.
|
|
1331
|
+
*/
|
|
1332
|
+
checkIfVersion(existing, ifVersion) {
|
|
1333
|
+
if (ifVersion === undefined || existing.version === ifVersion)
|
|
1334
|
+
return;
|
|
1335
|
+
throw new StackVersionConflictError(`Record "${existing.id}" is at version ${existing.version}, expected ${ifVersion}`, existing.id, ifVersion, existing.version);
|
|
1336
|
+
}
|
|
1337
|
+
/**
|
|
1338
|
+
* The options every version-bumping adapter write carries — precondition,
|
|
1339
|
+
* prior-state snapshot and actor — taken together so a new verb cannot
|
|
1340
|
+
* omit one. See docs/spec/versioning.md § Version history.
|
|
1341
|
+
*/
|
|
1342
|
+
writeOptions(existing, opts) {
|
|
1343
|
+
return {
|
|
1344
|
+
ifVersion: opts.ifVersion,
|
|
1345
|
+
snapshot: this.buildVersionSnapshot(existing),
|
|
1346
|
+
actor: normalizeActor(opts.actor),
|
|
1347
|
+
};
|
|
1348
|
+
}
|
|
1349
|
+
/**
|
|
1350
|
+
* A record's prior state for the version it is about to leave: `content`
|
|
1351
|
+
* and the `typeId` it is read under, which only a snapshot keeps. The
|
|
1352
|
+
* journal keeps everything else. See docs/spec/versioning.md § Version
|
|
1353
|
+
* history.
|
|
1354
|
+
*/
|
|
1355
|
+
buildVersionSnapshot(record) {
|
|
1356
|
+
return {
|
|
1357
|
+
version: record.version,
|
|
1358
|
+
typeId: record.typeId,
|
|
1359
|
+
content: record.content,
|
|
1360
|
+
updatedAt: record.updatedAt,
|
|
1361
|
+
...(record.createdBy && { createdBy: record.createdBy }),
|
|
1362
|
+
...(record.updatedBy && { updatedBy: record.updatedBy }),
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
//# sourceMappingURL=stack.js.map
|