@haverstack/core 0.41.0 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +124 -64
- package/dist/access.d.ts +43 -47
- package/dist/access.d.ts.map +1 -1
- package/dist/access.js +66 -101
- 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} +18 -3
- package/dist/adapter/combine.js.map +1 -0
- package/dist/adapter/index.d.ts +19 -0
- package/dist/adapter/index.d.ts.map +1 -0
- package/dist/adapter/index.js +16 -0
- package/dist/adapter/index.js.map +1 -0
- package/dist/adapter/reach.d.ts +14 -0
- package/dist/adapter/reach.d.ts.map +1 -0
- package/dist/adapter/reach.js +31 -0
- package/dist/adapter/reach.js.map +1 -0
- package/dist/{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 +69 -0
- package/dist/associations/identity.d.ts.map +1 -0
- package/dist/associations/identity.js +174 -0
- package/dist/associations/identity.js.map +1 -0
- package/dist/associations/validation.d.ts +83 -0
- package/dist/associations/validation.d.ts.map +1 -0
- package/dist/associations/validation.js +312 -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 +178 -0
- package/dist/collection.d.ts.map +1 -0
- package/dist/collection.js +397 -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 +54 -66
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +58 -62
- package/dist/errors.js.map +1 -1
- package/dist/grants.d.ts +20 -44
- package/dist/grants.d.ts.map +1 -1
- package/dist/grants.js +69 -151
- 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 +13 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -6
- package/dist/index.js.map +1 -1
- package/dist/install.d.ts +3 -6
- package/dist/install.d.ts.map +1 -1
- package/dist/install.js +6 -10
- package/dist/install.js.map +1 -1
- package/dist/lazy.d.ts +13 -0
- package/dist/lazy.d.ts.map +1 -0
- package/dist/lazy.js +27 -0
- package/dist/lazy.js.map +1 -0
- package/dist/merge.d.ts +1 -1
- package/dist/merge.d.ts.map +1 -1
- package/dist/merge.js +6 -12
- 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 +68 -121
- package/dist/query-validation.d.ts.map +1 -1
- package/dist/query-validation.js +214 -380
- package/dist/query-validation.js.map +1 -1
- package/dist/record-changes.d.ts +11 -150
- package/dist/record-changes.d.ts.map +1 -1
- package/dist/record-changes.js +14 -305
- 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-snapshot.d.ts +37 -0
- package/dist/scoped-stack/authority-snapshot.d.ts.map +1 -0
- package/dist/scoped-stack/authority-snapshot.js +49 -0
- package/dist/scoped-stack/authority-snapshot.js.map +1 -0
- package/dist/scoped-stack/authority.d.ts +169 -0
- package/dist/scoped-stack/authority.d.ts.map +1 -0
- package/dist/scoped-stack/authority.js +408 -0
- package/dist/scoped-stack/authority.js.map +1 -0
- package/dist/scoped-stack/family-policy.d.ts +59 -0
- package/dist/scoped-stack/family-policy.d.ts.map +1 -0
- package/dist/scoped-stack/family-policy.js +68 -0
- package/dist/scoped-stack/family-policy.js.map +1 -0
- package/dist/scoped-stack/scoped-stack.d.ts +318 -0
- package/dist/scoped-stack/scoped-stack.d.ts.map +1 -0
- package/dist/scoped-stack/scoped-stack.js +695 -0
- package/dist/scoped-stack/scoped-stack.js.map +1 -0
- package/dist/scoped-stack/subscription.d.ts +36 -0
- package/dist/scoped-stack/subscription.d.ts.map +1 -0
- package/dist/scoped-stack/subscription.js +69 -0
- package/dist/scoped-stack/subscription.js.map +1 -0
- package/dist/stack/arguments.d.ts +27 -0
- package/dist/stack/arguments.d.ts.map +1 -0
- package/dist/stack/arguments.js +53 -0
- package/dist/stack/arguments.js.map +1 -0
- package/dist/stack/attachments.d.ts +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} +30 -72
- package/dist/stack/changes.d.ts.map +1 -0
- package/dist/{changes.js → stack/changes.js} +40 -74
- package/dist/stack/changes.js.map +1 -0
- package/dist/stack/client.d.ts +247 -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} +5 -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 +262 -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 +69 -0
- package/dist/stack/reads.d.ts.map +1 -0
- package/dist/stack/reads.js +141 -0
- 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 +444 -0
- package/dist/stack/stack.d.ts.map +1 -0
- package/dist/stack/stack.js +1339 -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 +79 -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/stack/write-gate.d.ts +23 -0
- package/dist/stack/write-gate.d.ts.map +1 -0
- package/dist/stack/write-gate.js +17 -0
- package/dist/stack/write-gate.js.map +1 -0
- package/dist/testing.d.ts +44 -33
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +180 -176
- 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 +282 -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 +140 -0
- package/dist/types/associations.d.ts.map +1 -0
- package/dist/types/associations.js +6 -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 +150 -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 +31 -70
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +52 -92
- 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} +35 -12
- package/dist/wire/body.d.ts.map +1 -0
- package/dist/{wire-body.js → wire/body.js} +74 -13
- 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 +31 -0
- package/dist/wire/record.d.ts.map +1 -0
- package/dist/{wire-record.js → wire/record.js} +7 -67
- package/dist/wire/record.js.map +1 -0
- package/dist/{wire-request.d.ts → wire/request.d.ts} +32 -49
- package/dist/wire/request.d.ts.map +1 -0
- package/dist/{wire-request.js → wire/request.js} +113 -83
- package/dist/wire/request.js.map +1 -0
- package/package.json +7 -7
- package/dist/adapter-entry.d.ts +0 -16
- package/dist/adapter-entry.d.ts.map +0 -1
- package/dist/adapter-entry.js +0 -14
- 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 -549
- package/dist/scoped-stack.d.ts.map +0 -1
- package/dist/scoped-stack.js +0 -1301
- 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 +0 -38
- package/dist/stack-reads.d.ts.map +0 -1
- package/dist/stack-reads.js +0 -79
- package/dist/stack-reads.js.map +0 -1
- package/dist/stack.d.ts +0 -1032
- package/dist/stack.d.ts.map +0 -1
- package/dist/stack.js +0 -2337
- package/dist/stack.js.map +0 -1
- package/dist/types.d.ts +0 -1278
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -51
- 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 +0 -50
- 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/query-validation.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Query
|
|
2
|
+
* Query validation
|
|
3
3
|
* -------------------------------------------------------
|
|
4
|
-
* The sanitizers a query passes through before any adapter sees it
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* raw JSON that no compiler has seen.
|
|
4
|
+
* The sanitizers a query passes through before any adapter sees it. A
|
|
5
|
+
* `QuerySort` field or a filter's discriminated union is a compile-time
|
|
6
|
+
* promise, and a server mapping a request onto either supplies raw JSON
|
|
7
|
+
* that no compiler has seen.
|
|
9
8
|
*
|
|
10
9
|
* These live in the invariant layer for the reason validation and
|
|
11
10
|
* `_config` protection do — so no adapter can forget one. `assertQueryCapabilities`
|
|
@@ -16,43 +15,105 @@
|
|
|
16
15
|
* the query — one rule, one answer.
|
|
17
16
|
* See docs/spec/data-model.md § Capability-gated filters.
|
|
18
17
|
*/
|
|
19
|
-
import { StackBadRequestError, StackValidationError } from './errors.js';
|
|
20
|
-
import { familyIdProblem } from './schema.js';
|
|
21
|
-
import {
|
|
22
|
-
import { CONTENT_SEGMENT_METACHARACTERS, SEGMENT_METACHARACTER_RE } from './validate.js';
|
|
23
|
-
import { NATIVE_SORT_FIELDS } from './types.js';
|
|
18
|
+
import { ARGUMENTS_INVALID, StackBadRequestError, StackValidationError } from './errors.js';
|
|
19
|
+
import { familyIdProblem, parseTypeId } from './schema.js';
|
|
20
|
+
import { targetErrors } from './associations/validation.js';
|
|
21
|
+
import { CONTENT_SEGMENT_METACHARACTERS, MAX_VALIDATION_DEPTH, SEGMENT_METACHARACTER_RE, isPlainObject, } from './validate.js';
|
|
22
|
+
import { NATIVE_SORT_FIELDS } from './types/index.js';
|
|
24
23
|
/**
|
|
25
|
-
* Fail loud rather than silently widen: a filter
|
|
26
|
-
* would otherwise be dropped,
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
24
|
+
* Fail loud rather than silently widen or reorder: a filter or sort the
|
|
25
|
+
* adapter can't honor would otherwise be dropped, answering with some
|
|
26
|
+
* other result, which a caller reading one bounded page cannot detect.
|
|
27
|
+
* Every refusal names the capability. The companion to resolveQuery(),
|
|
28
|
+
* which refuses the query's own shape; every entry point runs both. See
|
|
29
|
+
* docs/spec/data-model.md § Capability-gated filters.
|
|
30
30
|
*/
|
|
31
|
-
export function assertQueryCapabilities(
|
|
32
|
-
|
|
31
|
+
export function assertQueryCapabilities(query, capabilities) {
|
|
32
|
+
assertFilterCapability(query.filter, capabilities);
|
|
33
|
+
assertSortCapability(query.sort, capabilities);
|
|
34
|
+
}
|
|
35
|
+
function assertFilterCapability(filter, capabilities) {
|
|
36
|
+
const { content: reach, contentPresent, contentByType, search } = capabilities.filter;
|
|
33
37
|
if (filter?.search && !search) {
|
|
34
38
|
throw new StackBadRequestError('Query uses filter.search, but this adapter does not declare the filter.search capability.', 'filter.search');
|
|
35
39
|
}
|
|
36
|
-
|
|
37
|
-
|
|
40
|
+
// An arm's keys are gated as the same keys at the top of the filter are.
|
|
41
|
+
const arms = Object.values(filter?.contentByType ?? {});
|
|
42
|
+
const parts = [filter ?? {}, ...arms];
|
|
43
|
+
const keys = parts.flatMap((part) => Object.keys(part.content ?? {}));
|
|
44
|
+
const present = parts.flatMap((part) => part.contentPresent ?? []);
|
|
45
|
+
if (!filter?.content && present.length === 0 && arms.length === 0)
|
|
38
46
|
return;
|
|
39
47
|
if (reach === 'none') {
|
|
40
48
|
// Named as `filter.content` whichever key the query used: the reach
|
|
41
49
|
// is what is absent, and it is the entry to look at in discovery.
|
|
42
|
-
|
|
43
|
-
|
|
50
|
+
const used = arms.length > 0
|
|
51
|
+
? 'filter.contentByType'
|
|
52
|
+
: present.length > 0 && !filter?.content
|
|
53
|
+
? 'filter.contentPresent'
|
|
54
|
+
: 'filter.content';
|
|
55
|
+
throw new StackBadRequestError(`Query uses ${used}, but this adapter declares filter.content: "none".`, 'filter.content');
|
|
56
|
+
}
|
|
57
|
+
if (arms.length > 0 && !contentByType) {
|
|
58
|
+
throw new StackBadRequestError('Query uses filter.contentByType, but this adapter does not declare the ' +
|
|
59
|
+
'filter.contentByType capability.', 'filter.contentByType');
|
|
44
60
|
}
|
|
45
|
-
if (present && !contentPresent) {
|
|
61
|
+
if (present.length > 0 && !contentPresent) {
|
|
46
62
|
throw new StackBadRequestError('Query uses filter.contentPresent, but this adapter does not declare the ' +
|
|
47
63
|
'filter.contentPresent capability.', 'filter.contentPresent');
|
|
48
64
|
}
|
|
49
|
-
for (const key of [...
|
|
65
|
+
for (const key of [...keys, ...present]) {
|
|
50
66
|
if (parseContentFilterKey(key).length > 1 && reach !== 'path') {
|
|
51
67
|
throw new StackBadRequestError(`Query uses the nested content path "${key}", but this adapter declares ` +
|
|
52
68
|
`filter.content: "${reach}".`, 'filter.content');
|
|
53
69
|
}
|
|
54
70
|
}
|
|
55
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Hold `filter.contentByType` to a split along Types: at least one arm,
|
|
74
|
+
* each pinned to a TypeId and flat, and never beside a top-level content
|
|
75
|
+
* predicate, whose relation to the arms would be ambiguous. A map that
|
|
76
|
+
* could arrive empty would match everything once it crossed the wire. See
|
|
77
|
+
* docs/spec/data-model.md § Content by type.
|
|
78
|
+
*/
|
|
79
|
+
export function assertValidContentByType(filter) {
|
|
80
|
+
const arms = filter?.contentByType;
|
|
81
|
+
if (arms === undefined)
|
|
82
|
+
return;
|
|
83
|
+
if (!isPlainObject(arms) || Object.keys(arms).length === 0) {
|
|
84
|
+
throw new StackBadRequestError('filter.contentByType must name at least one typeId.');
|
|
85
|
+
}
|
|
86
|
+
if (filter.content !== undefined || filter.contentPresent !== undefined) {
|
|
87
|
+
throw new StackBadRequestError('filter.contentByType cannot be combined with filter.content or filter.contentPresent.');
|
|
88
|
+
}
|
|
89
|
+
for (const [typeId, arm] of Object.entries(arms)) {
|
|
90
|
+
if (!parseTypeId(typeId)) {
|
|
91
|
+
throw new StackBadRequestError(`filter.contentByType key "${typeId}" is not a versioned TypeId.`);
|
|
92
|
+
}
|
|
93
|
+
if (!isPlainObject(arm)) {
|
|
94
|
+
throw new StackBadRequestError(`filter.contentByType["${typeId}"] must be an object.`);
|
|
95
|
+
}
|
|
96
|
+
for (const key of Object.keys(arm)) {
|
|
97
|
+
if (key !== 'content' && key !== 'contentPresent') {
|
|
98
|
+
throw new StackBadRequestError(`filter.contentByType["${typeId}"] takes content and contentPresent only, not "${key}".`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (arm.content !== undefined && !isPlainObject(arm.content)) {
|
|
102
|
+
throw new StackBadRequestError(`filter.contentByType["${typeId}"].content must be an object.`);
|
|
103
|
+
}
|
|
104
|
+
if (arm.contentPresent !== undefined &&
|
|
105
|
+
(!Array.isArray(arm.contentPresent) ||
|
|
106
|
+
!arm.contentPresent.every((path) => typeof path === 'string'))) {
|
|
107
|
+
throw new StackBadRequestError(`filter.contentByType["${typeId}"].contentPresent must be an array of strings.`);
|
|
108
|
+
}
|
|
109
|
+
for (const key of [
|
|
110
|
+
...Object.keys(arm.content ?? {}),
|
|
111
|
+
...(arm.contentPresent ?? []),
|
|
112
|
+
]) {
|
|
113
|
+
parseContentFilterKey(key);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
56
117
|
/**
|
|
57
118
|
* Whether a single-segment content filter can be pushed down to the
|
|
58
119
|
* adapter. Callers that read a content field pair this with an in-memory
|
|
@@ -63,8 +124,13 @@ export function assertQueryCapabilities(filter, capabilities) {
|
|
|
63
124
|
export function filtersContent(features) {
|
|
64
125
|
return features.filter.content !== 'none';
|
|
65
126
|
}
|
|
66
|
-
/**
|
|
67
|
-
|
|
127
|
+
/**
|
|
128
|
+
* The longest path that can reach a stored value: a top-level field sits at
|
|
129
|
+
* depth 0 and each segment descends one level, so a path past this reaches
|
|
130
|
+
* nothing a write accepts. See docs/spec/data-model.md § Nested content
|
|
131
|
+
* paths.
|
|
132
|
+
*/
|
|
133
|
+
const MAX_CONTENT_PATH_SEGMENTS = MAX_VALIDATION_DEPTH + 1;
|
|
68
134
|
/**
|
|
69
135
|
* Split a content filter key into path segments. Write-time validation
|
|
70
136
|
* keeps stored field names free of the path metacharacters, but a filter
|
|
@@ -73,9 +139,6 @@ const MAX_CONTENT_PATH_SEGMENTS = 32;
|
|
|
73
139
|
*/
|
|
74
140
|
export function parseContentFilterKey(key) {
|
|
75
141
|
const segments = key.split('.');
|
|
76
|
-
// A SQLite adapter spends two json_each joins per segment against a
|
|
77
|
-
// 64-table join limit, so a path past the cap is one the engine could
|
|
78
|
-
// not execute. See docs/spec/data-model.md § Nested content paths.
|
|
79
142
|
if (segments.length > MAX_CONTENT_PATH_SEGMENTS) {
|
|
80
143
|
throw new StackBadRequestError(`Invalid content filter path "${key}": at most ${MAX_CONTENT_PATH_SEGMENTS} segments.`);
|
|
81
144
|
}
|
|
@@ -100,357 +163,142 @@ const VALID_SORT_FIELDS = new Set(NATIVE_SORT_FIELDS);
|
|
|
100
163
|
/** The only two sort directions; see assertValidSort. */
|
|
101
164
|
const VALID_SORT_DIRECTIONS = new Set(['asc', 'desc']);
|
|
102
165
|
/**
|
|
103
|
-
* Reject a sort
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* adapter interpolates the direction straight into `ORDER BY`, so an
|
|
108
|
-
* unvalidated value there is a SQL-injection sink reachable from every
|
|
109
|
-
* untrusted caller. Validating in the invariant layer — the same reason
|
|
110
|
-
* emission and _config protection live here — means no adapter can forget
|
|
111
|
-
* it. See docs/spec/data-model.md § Sorting and pagination.
|
|
166
|
+
* Reject a sort outside the closed set the types promise. A type is not a
|
|
167
|
+
* runtime guard, and a SQLite adapter interpolates the direction straight
|
|
168
|
+
* into `ORDER BY`, so an unvalidated one is an injection sink reachable by
|
|
169
|
+
* any untrusted caller. See docs/spec/data-model.md § Sorting and pagination.
|
|
112
170
|
*/
|
|
113
171
|
export function assertValidSort(sort) {
|
|
114
172
|
if (!sort)
|
|
115
173
|
return;
|
|
116
|
-
|
|
117
|
-
|
|
174
|
+
const named = [sort.field, sort.contentField, sort.contentFieldByType].filter((member) => member !== undefined);
|
|
175
|
+
if (named.length !== 1) {
|
|
176
|
+
throw new StackBadRequestError('A sort names exactly one of a native field, a content field or a content field per type.');
|
|
118
177
|
}
|
|
119
178
|
if (sort.field !== undefined && !VALID_SORT_FIELDS.has(sort.field)) {
|
|
120
179
|
throw new StackBadRequestError(`Invalid sort field "${sort.field}": expected one of ${NATIVE_SORT_FIELDS.join(', ')}.`);
|
|
121
180
|
}
|
|
122
|
-
if (sort.contentField !== undefined)
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
// position to order its record by (docs/spec/data-model.md
|
|
127
|
-
// § Sorting by a content field).
|
|
128
|
-
if (parseContentFilterKey(sort.contentField).length > 1) {
|
|
129
|
-
throw new StackBadRequestError(`Invalid sort content field "${sort.contentField}": sorting reaches top-level fields only.`);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
181
|
+
if (sort.contentField !== undefined)
|
|
182
|
+
assertSortableField(sort.contentField);
|
|
183
|
+
if (sort.contentFieldByType !== undefined)
|
|
184
|
+
assertValidSortByType(sort.contentFieldByType);
|
|
132
185
|
if (sort.direction !== undefined && !VALID_SORT_DIRECTIONS.has(sort.direction)) {
|
|
133
186
|
throw new StackBadRequestError(`Invalid sort direction "${sort.direction}": expected "asc" or "desc".`);
|
|
134
187
|
}
|
|
135
188
|
}
|
|
136
189
|
/**
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
* `sort.fields`. See docs/spec/data-model.md § Sorting and pagination.
|
|
190
|
+
* Parsed by the same rule a filter key is, so a name a filter could never
|
|
191
|
+
* address is not one a sort can either — then held to one segment, because
|
|
192
|
+
* a value inside an array or object has no single position to order its
|
|
193
|
+
* record by (docs/spec/data-model.md § Sorting by a content field).
|
|
142
194
|
*/
|
|
143
|
-
|
|
144
|
-
if (
|
|
145
|
-
|
|
146
|
-
return { ...sort, direction: sort.direction ?? 'asc' };
|
|
147
|
-
}
|
|
148
|
-
/**
|
|
149
|
-
* Fail loud rather than silently reorder: an adapter that can't honor the
|
|
150
|
-
* requested sort would otherwise answer in some other order, which a
|
|
151
|
-
* caller paging a bounded window has no way to notice. The companion to
|
|
152
|
-
* assertQueryCapabilities(), split from it because a sort is not a filter
|
|
153
|
-
* — see docs/spec/data-model.md § Capability-gated filters.
|
|
154
|
-
*/
|
|
155
|
-
export function assertSortCapability(sort, capabilities) {
|
|
156
|
-
if (!sort)
|
|
157
|
-
return;
|
|
158
|
-
if (sort.contentField !== undefined) {
|
|
159
|
-
if (!capabilities.sort.contentField) {
|
|
160
|
-
throw new StackBadRequestError('Query uses sort.contentField, but this adapter does not declare the sort.contentField ' +
|
|
161
|
-
'capability.', 'sort.contentField');
|
|
162
|
-
}
|
|
163
|
-
return;
|
|
195
|
+
const assertSortableField = (field) => {
|
|
196
|
+
if (typeof field !== 'string') {
|
|
197
|
+
throw new StackBadRequestError('Invalid sort content field: expected a string.');
|
|
164
198
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
throw new StackBadRequestError(`Query sorts by "${field}", which this adapter does not declare in sort.fields.`, 'sort.fields');
|
|
199
|
+
if (parseContentFilterKey(field).length > 1) {
|
|
200
|
+
throw new StackBadRequestError(`Invalid sort content field "${field}": sorting reaches top-level fields only.`);
|
|
168
201
|
}
|
|
169
|
-
}
|
|
170
|
-
/** The identifier spaces a relationship target may name. */
|
|
171
|
-
const TARGET_KINDS = new Set(['record', 'entity', 'external']);
|
|
172
|
-
/**
|
|
173
|
-
* The keys of one arm of a union, listed as an object so the compiler
|
|
174
|
-
* holds the list to the type: a field added to the arm fails to compile
|
|
175
|
-
* here until it is listed.
|
|
176
|
-
*/
|
|
177
|
-
const keysOf = (keys) => Object.keys(keys);
|
|
178
|
-
/** Every key each relationship target arm defines. */
|
|
179
|
-
export const TARGET_KEYS = {
|
|
180
|
-
record: keysOf({ kind: true, recordId: true, stackUrl: true }),
|
|
181
|
-
entity: keysOf({ kind: true, entityId: true }),
|
|
182
|
-
external: keysOf({ kind: true, ns: true, id: true }),
|
|
183
|
-
};
|
|
184
|
-
/** Every key each association kind defines. */
|
|
185
|
-
const ASSOCIATION_KEYS = {
|
|
186
|
-
tag: keysOf({ kind: true, label: true }),
|
|
187
|
-
attachment: keysOf({
|
|
188
|
-
kind: true,
|
|
189
|
-
label: true,
|
|
190
|
-
fileId: true,
|
|
191
|
-
attachmentRecordId: true,
|
|
192
|
-
}),
|
|
193
|
-
relationship: keysOf({ kind: true, label: true, target: true }),
|
|
194
|
-
permission: keysOf({ kind: true, label: true, grantee: true }),
|
|
195
|
-
anyone: keysOf({ kind: true, label: true }),
|
|
196
|
-
};
|
|
197
|
-
/** Every key each grantee arm defines, the default tier included. */
|
|
198
|
-
export const GRANTEE_KEYS = {
|
|
199
|
-
entity: keysOf({ kind: true, entityId: true }),
|
|
200
|
-
group: keysOf({ kind: true, groupId: true, role: true }),
|
|
201
|
-
authenticated: keysOf({ kind: true }),
|
|
202
202
|
};
|
|
203
203
|
/**
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
* columns for, so the rest would answer 200 and then vanish.
|
|
207
|
-
* See docs/spec/data-model.md § Associations.
|
|
204
|
+
* Each key names a TypeId and orders by one field of it or one reduced
|
|
205
|
+
* constant. See docs/spec/data-model.md § Sorting by a content field.
|
|
208
206
|
*/
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
/** unknownKeys() as a refusal. 400, not 422: the key addresses nothing. */
|
|
213
|
-
export function assertKnownKeys(value, keys, path) {
|
|
214
|
-
const unknown = unknownKeys(value, keys);
|
|
215
|
-
if (unknown.length > 0)
|
|
216
|
-
throw new StackBadRequestError(`Unknown key${unknown.length > 1 ? 's' : ''} in ${path}: ${unknown.join(', ')}`);
|
|
217
|
-
}
|
|
218
|
-
/**
|
|
219
|
-
* Collect what makes a relationship target malformed. Absence is
|
|
220
|
-
* meaningful on `stackUrl` and an external `id` — this stack, and the
|
|
221
|
-
* whole namespace — so every part that names something must be non-empty:
|
|
222
|
-
* an empty string stores and matches as though it were absent.
|
|
223
|
-
* See docs/spec/data-model.md § Relationship targets.
|
|
224
|
-
*/
|
|
225
|
-
function targetErrors(target, path, opts = {}) {
|
|
226
|
-
const fail = (message) => [{ path, message }];
|
|
227
|
-
if (!target || typeof target !== 'object')
|
|
228
|
-
return fail('A relationship target must be an object.');
|
|
229
|
-
if (!TARGET_KINDS.has(target.kind)) {
|
|
230
|
-
return fail(`Unknown relationship target kind "${target.kind}": expected "record", "entity" or "external".`);
|
|
207
|
+
const assertValidSortByType = (byType) => {
|
|
208
|
+
if (!isPlainObject(byType) || Object.keys(byType).length === 0) {
|
|
209
|
+
throw new StackBadRequestError('sort.contentFieldByType must name at least one typeId.');
|
|
231
210
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
211
|
+
for (const [typeId, key] of Object.entries(byType)) {
|
|
212
|
+
if (!parseTypeId(typeId)) {
|
|
213
|
+
throw new StackBadRequestError(`sort.contentFieldByType key "${typeId}" is not a versioned TypeId.`);
|
|
214
|
+
}
|
|
215
|
+
const shape = isPlainObject(key) ? Object.keys(key) : [];
|
|
216
|
+
if (shape.length !== 1 || (shape[0] !== 'field' && shape[0] !== 'value')) {
|
|
217
|
+
throw new StackBadRequestError(`sort.contentFieldByType["${typeId}"] must be { field } or { value }.`);
|
|
218
|
+
}
|
|
219
|
+
const { field, value } = key;
|
|
220
|
+
if (shape[0] === 'field') {
|
|
221
|
+
assertSortableField(field);
|
|
222
|
+
}
|
|
223
|
+
else if (value !== null &&
|
|
224
|
+
typeof value !== 'string' &&
|
|
225
|
+
!(typeof value === 'number' && Number.isFinite(value))) {
|
|
226
|
+
throw new StackBadRequestError(`sort.contentFieldByType["${typeId}"].value must be a string, a finite number or null.`);
|
|
238
227
|
}
|
|
239
|
-
return [];
|
|
240
|
-
}
|
|
241
|
-
if (target.kind === 'entity') {
|
|
242
|
-
return target.entityId ? [] : fail('An entity target requires a non-empty entityId.');
|
|
243
|
-
}
|
|
244
|
-
if (!target.ns)
|
|
245
|
-
return fail('An external target requires a non-empty ns.');
|
|
246
|
-
if (target.id === undefined) {
|
|
247
|
-
return opts.externalIdOptional ? [] : fail('An external target requires an id.');
|
|
248
|
-
}
|
|
249
|
-
return target.id ? [] : fail("An external target's id must be non-empty when present.");
|
|
250
|
-
}
|
|
251
|
-
/**
|
|
252
|
-
* Reject a relationship target outside the closed set the types promise.
|
|
253
|
-
* A discriminated union is not a runtime guard — a server mapping a
|
|
254
|
-
* request body onto an association supplies raw JSON — and an
|
|
255
|
-
* unrecognized kind would otherwise be stored under the one arm that
|
|
256
|
-
* names a Record in this stack. See docs/spec/data-model.md
|
|
257
|
-
* § Relationship targets.
|
|
258
|
-
*
|
|
259
|
-
* A key the element's kind does not define is thrown as a 400 rather than
|
|
260
|
-
* collected, at every depth: it addresses nothing, the way an unknown key
|
|
261
|
-
* on a request body does. See docs/spec/data-model.md § Associations.
|
|
262
|
-
*/
|
|
263
|
-
export function validateAssociation(association, path = 'association') {
|
|
264
|
-
if (!namesKind(association)) {
|
|
265
|
-
return [
|
|
266
|
-
{
|
|
267
|
-
path: `${path}.kind`,
|
|
268
|
-
message: `Unknown association kind "${String(association?.kind)}": expected ${[...ASSOCIATION_KINDS].map((k) => `"${k}"`).join(', ')}.`,
|
|
269
|
-
},
|
|
270
|
-
];
|
|
271
|
-
}
|
|
272
|
-
assertKnownKeys(association, ASSOCIATION_KEYS[association.kind], path);
|
|
273
|
-
if (association.kind === 'permission') {
|
|
274
|
-
return granteeErrors(association, `${path}.grantee`);
|
|
275
|
-
}
|
|
276
|
-
if (association.kind === 'anyone') {
|
|
277
|
-
return association.label === 'read'
|
|
278
|
-
? []
|
|
279
|
-
: [{ path: `${path}.label`, message: 'An `anyone` association carries only `read`.' }];
|
|
280
228
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
* Who a duplicated permission names, since kind and label alone don't
|
|
287
|
-
* distinguish two grants of the same bit to different grantees.
|
|
288
|
-
*/
|
|
289
|
-
function granteeSuffix(association) {
|
|
290
|
-
if (association.kind !== 'permission')
|
|
291
|
-
return '';
|
|
292
|
-
const g = association.grantee;
|
|
293
|
-
return g.kind === 'entity' ? ` for "${g.entityId}"` : ` for ${g.role}s of "${g.groupId}"`;
|
|
294
|
-
}
|
|
295
|
-
/** The kinds an association may name — the closed set every surface reads. */
|
|
296
|
-
const ASSOCIATION_KINDS = new Set(['tag', 'attachment', 'relationship', 'permission', 'anyone']);
|
|
229
|
+
};
|
|
230
|
+
/** The page a query reads when it names no `limit`. */
|
|
231
|
+
export const DEFAULT_QUERY_LIMIT = 50;
|
|
232
|
+
/** The largest page a query reads, whatever it asks for. */
|
|
233
|
+
export const MAX_QUERY_LIMIT = 1000;
|
|
297
234
|
/**
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
235
|
+
* The query an adapter receives: its shape checked, and every default
|
|
236
|
+
* resolved so no adapter carries one — a named sort runs ascending, no
|
|
237
|
+
* sort is `createdAt` newest first, and the page is DEFAULT_QUERY_LIMIT
|
|
238
|
+
* up to MAX_QUERY_LIMIT. Shape refusals name no capability; see
|
|
239
|
+
* assertQueryCapabilities() for those that do. See docs/spec/data-model.md
|
|
240
|
+
* § Sorting and pagination.
|
|
302
241
|
*/
|
|
303
|
-
function
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
242
|
+
export function resolveQuery(query) {
|
|
243
|
+
const { sort, limit, ...rest } = query;
|
|
244
|
+
assertValidBaseIdFilter(query.filter);
|
|
245
|
+
assertValidContentByType(query.filter);
|
|
246
|
+
assertValidSort(sort);
|
|
247
|
+
assertWindowInteger(limit, 'limit', 1);
|
|
248
|
+
assertValidAssociationFilters(query.filter);
|
|
249
|
+
return {
|
|
250
|
+
...rest,
|
|
251
|
+
sort: sort
|
|
252
|
+
? { ...sort, direction: sort.direction ?? 'asc' }
|
|
253
|
+
: { field: 'createdAt', direction: 'desc' },
|
|
254
|
+
limit: Math.min(limit ?? DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT),
|
|
255
|
+
};
|
|
307
256
|
}
|
|
308
|
-
/** The bits a permission element may name — the whole of its meaning. */
|
|
309
|
-
const PERMISSION_LABELS = new Set(['read', 'write']);
|
|
310
257
|
/**
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
* § Record-level permissions.
|
|
258
|
+
* Split a query at the door into what an adapter sees, resolved, and
|
|
259
|
+
* `presentAt`, which `Stack` applies to the results. Every check runs
|
|
260
|
+
* here, before the adapter is asked. See docs/spec/data-model.md
|
|
261
|
+
* § Sorting and pagination.
|
|
316
262
|
*/
|
|
317
|
-
function
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
message: `Unknown permission label "${association.label}": expected "read" or "write".`,
|
|
324
|
-
},
|
|
325
|
-
];
|
|
326
|
-
}
|
|
327
|
-
const grantee = association.grantee;
|
|
328
|
-
if (!grantee || typeof grantee !== 'object')
|
|
329
|
-
return fail('A permission requires a grantee.');
|
|
330
|
-
if (grantee.kind === 'entity' || grantee.kind === 'group')
|
|
331
|
-
assertKnownKeys(grantee, GRANTEE_KEYS[grantee.kind], path);
|
|
332
|
-
if (grantee.kind === 'entity') {
|
|
333
|
-
return grantee.entityId ? [] : fail('An entity grantee requires a non-empty entityId.');
|
|
334
|
-
}
|
|
335
|
-
if (grantee.kind === 'group') {
|
|
336
|
-
if (!grantee.groupId)
|
|
337
|
-
return fail('A group grantee requires a non-empty groupId.');
|
|
338
|
-
return grantee.role === 'member' || grantee.role === 'admin'
|
|
339
|
-
? []
|
|
340
|
-
: fail('A group grantee requires a role of "member" or "admin".');
|
|
341
|
-
}
|
|
342
|
-
return fail(`Unknown permission grantee kind "${String(grantee.kind)}": expected "entity" or "group".`);
|
|
263
|
+
export function splitQuery(query) {
|
|
264
|
+
const { presentAt, ...plain } = query;
|
|
265
|
+
return {
|
|
266
|
+
query: resolveQuery(plain),
|
|
267
|
+
...(presentAt !== undefined && { presentAt }),
|
|
268
|
+
};
|
|
343
269
|
}
|
|
344
270
|
/**
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
* caller named the wrong surface, not a malformed value, and the verb it
|
|
350
|
-
* wanted is `grantAccess()`/`revokeAccess()`.
|
|
351
|
-
*
|
|
352
|
-
* Lives here, in the invariant layer, so an unscoped `Stack`, an import
|
|
353
|
-
* and a server mapping a request body are all held to it.
|
|
354
|
-
* See docs/spec/access-control.md § Record-level permissions.
|
|
271
|
+
* Hold a window bound — a page size, a cursor position — to an integer
|
|
272
|
+
* at or above `min`, the one shape every adapter reads alike: a negative
|
|
273
|
+
* `limit` drops entries from a JS slice but empties or lifts a SQL
|
|
274
|
+
* `LIMIT`. See docs/spec/data-model.md § Sorting and pagination.
|
|
355
275
|
*/
|
|
356
|
-
export function
|
|
357
|
-
|
|
358
|
-
if (!authority)
|
|
276
|
+
export function assertWindowInteger(value, label, min) {
|
|
277
|
+
if (value === undefined)
|
|
359
278
|
return;
|
|
360
|
-
|
|
361
|
-
|
|
279
|
+
if (!Number.isInteger(value) || value < min) {
|
|
280
|
+
throw new StackBadRequestError(`Invalid ${label} ${String(value)}: expected ${min === 0 ? 'a non-negative' : 'a positive'} integer.`);
|
|
281
|
+
}
|
|
362
282
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
const data = permissions.find((a) => namesKind(a) && !isAuthorityAssociation(a));
|
|
370
|
-
if (!data)
|
|
283
|
+
function assertSortCapability(sort, capabilities) {
|
|
284
|
+
if (sort.contentFieldByType !== undefined) {
|
|
285
|
+
if (!capabilities.sort.contentFieldByType) {
|
|
286
|
+
throw new StackBadRequestError('Query uses sort.contentFieldByType, but this adapter does not declare the ' +
|
|
287
|
+
'sort.contentFieldByType capability.', 'sort.contentFieldByType');
|
|
288
|
+
}
|
|
371
289
|
return;
|
|
372
|
-
throw new StackBadRequestError(`${surface} carries authority alone: a "${data.kind}" association belongs to the ` +
|
|
373
|
-
'`associations` surface — use associate()/dissociate(), or the `associations` change-set key.');
|
|
374
|
-
}
|
|
375
|
-
/** validateAssociation() over a create's `associations` array. */
|
|
376
|
-
/**
|
|
377
|
-
* Every association in a list, plus the one rule a list has that a single
|
|
378
|
-
* association does not: **identities are distinct**. A list naming one
|
|
379
|
-
* identity twice describes a state no store can hold — an adapter keys
|
|
380
|
-
* associations by identity, so the second entry displaces the first — and
|
|
381
|
-
* which of the two the record ends up with is a question the caller did
|
|
382
|
-
* not mean to ask. Refused rather than collapsed, for the reason an empty
|
|
383
|
-
* change set is refused: every way of producing one is a caller bug.
|
|
384
|
-
* See docs/spec/data-model.md § Associations.
|
|
385
|
-
*/
|
|
386
|
-
export function validateAssociations(associations, path = 'associations') {
|
|
387
|
-
const list = associations ?? [];
|
|
388
|
-
return [
|
|
389
|
-
...list.flatMap((a, i) => validateAssociation(a, `${path}[${i}]`)),
|
|
390
|
-
...list.flatMap((a, i) => list.slice(0, i).some((b) => associationEqual(a, b))
|
|
391
|
-
? [
|
|
392
|
-
{
|
|
393
|
-
path: `${path}[${i}]`,
|
|
394
|
-
message: `Duplicate association identity: ${a.kind} "${a.label}"${granteeSuffix(a)} is named more than once.`,
|
|
395
|
-
},
|
|
396
|
-
]
|
|
397
|
-
: []),
|
|
398
|
-
];
|
|
399
|
-
}
|
|
400
|
-
/**
|
|
401
|
-
* Hold an association edit list to the rules every verb shares: non-empty,
|
|
402
|
-
* every element a known `op` over a well-formed association, one surface,
|
|
403
|
-
* and each identity named once — a list that both removes and adds one
|
|
404
|
-
* identity is the same ambiguity as naming it twice. `repoint` is the
|
|
405
|
-
* journal's word, never a request. Asked of the whole list before anything
|
|
406
|
-
* is read or written. See docs/spec/data-model.md § Mutations.
|
|
407
|
-
*/
|
|
408
|
-
export function assertAssociationEdits(changes, surface, half) {
|
|
409
|
-
if (!Array.isArray(changes) || changes.length === 0) {
|
|
410
|
-
throw new StackValidationError([
|
|
411
|
-
{ path: 'changes', message: `${surface} names at least one change.` },
|
|
412
|
-
]);
|
|
413
290
|
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
errors.push({ path, message: `${path} must be an object.` });
|
|
419
|
-
return;
|
|
420
|
-
}
|
|
421
|
-
const edit = raw;
|
|
422
|
-
if (edit.op !== 'repoint')
|
|
423
|
-
assertKnownKeys(edit, ['op', 'association'], path);
|
|
424
|
-
if (edit.op === 'repoint') {
|
|
425
|
-
errors.push({
|
|
426
|
-
path: `${path}.op`,
|
|
427
|
-
message: 'op: "repoint" is recorded by the journal, not requested. ' +
|
|
428
|
-
"Send { op: 'add', association }: an add naming an attachment the record already holds re-points it in place.",
|
|
429
|
-
});
|
|
430
|
-
}
|
|
431
|
-
else if (edit.op !== 'add' && edit.op !== 'remove') {
|
|
432
|
-
errors.push({ path: `${path}.op`, message: 'op must be "add" or "remove".' });
|
|
433
|
-
}
|
|
434
|
-
errors.push(...validateAssociation(edit.association, `${path}.association`));
|
|
435
|
-
});
|
|
436
|
-
if (errors.length > 0)
|
|
437
|
-
throw new StackValidationError(errors);
|
|
438
|
-
const associations = changes.map((c) => c.association);
|
|
439
|
-
if (half === 'data')
|
|
440
|
-
assertDataAssociations(associations, surface);
|
|
441
|
-
else
|
|
442
|
-
assertAuthorityAssociations(associations, surface);
|
|
443
|
-
const duplicates = [];
|
|
444
|
-
associations.forEach((a, i) => {
|
|
445
|
-
if (associations.slice(0, i).some((b) => associationEqual(a, b))) {
|
|
446
|
-
duplicates.push({
|
|
447
|
-
path: `changes[${i}]`,
|
|
448
|
-
message: `Duplicate association identity: ${a.kind} "${a.label}"${granteeSuffix(a)} is named more than once.`,
|
|
449
|
-
});
|
|
291
|
+
if (sort.contentField !== undefined) {
|
|
292
|
+
if (!capabilities.sort.contentField) {
|
|
293
|
+
throw new StackBadRequestError('Query uses sort.contentField, but this adapter does not declare the sort.contentField ' +
|
|
294
|
+
'capability.', 'sort.contentField');
|
|
450
295
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const { field } = sort;
|
|
299
|
+
if (!capabilities.sort.fields.includes(field)) {
|
|
300
|
+
throw new StackBadRequestError(`Query sorts by "${field}", which this adapter does not declare in sort.fields.`, 'sort.fields');
|
|
301
|
+
}
|
|
454
302
|
}
|
|
455
303
|
/**
|
|
456
304
|
* Reject an association filter that names neither of its halves, or whose
|
|
@@ -478,6 +326,17 @@ export function assertValidAssociationFilters(filter) {
|
|
|
478
326
|
if (errors.length > 0)
|
|
479
327
|
throw new StackBadRequestError(errors[0].message);
|
|
480
328
|
}
|
|
329
|
+
/**
|
|
330
|
+
* Refuse a `baseId` argument that cannot name a type family, as an
|
|
331
|
+
* argument error prefixed with the `surface` it was passed to. See
|
|
332
|
+
* familyIdProblem().
|
|
333
|
+
*/
|
|
334
|
+
export function assertFamilyId(baseId, surface) {
|
|
335
|
+
const problem = familyIdProblem(baseId, surface);
|
|
336
|
+
if (problem) {
|
|
337
|
+
throw new StackValidationError([{ path: 'baseId', message: problem }], ARGUMENTS_INVALID);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
481
340
|
/**
|
|
482
341
|
* Refuse a `baseId` carrying an `@version` suffix. A family filter resolves
|
|
483
342
|
* against registered families, so a TypeId there would match nothing and
|
|
@@ -491,48 +350,23 @@ export function assertValidBaseIdFilter(filter) {
|
|
|
491
350
|
.filter((m) => m !== null)
|
|
492
351
|
.map((message) => ({ path: 'filter.baseId', message }));
|
|
493
352
|
if (errors.length > 0)
|
|
494
|
-
throw new StackValidationError(errors);
|
|
353
|
+
throw new StackValidationError(errors, ARGUMENTS_INVALID);
|
|
495
354
|
}
|
|
496
355
|
/**
|
|
497
|
-
*
|
|
498
|
-
*
|
|
499
|
-
*
|
|
500
|
-
* `slice(0, -1)` drops the newest entry, while SQLite reads a negative
|
|
501
|
-
* LIMIT as "no ceiling" and returns the whole log. Neither is what the
|
|
502
|
-
* caller asked for, and the disagreement is invisible until a stack
|
|
503
|
-
* changes adapters. Refuse at the surface so no adapter has to guess.
|
|
504
|
-
*
|
|
505
|
-
* No ceiling is imposed: unlike a query, omitting `limit` reads the whole
|
|
506
|
-
* log by contract, so a clamp would silently truncate exactly the caller
|
|
507
|
-
* reconstructing an association's full history.
|
|
356
|
+
* A journal window: `afterSeq` 0 is the start of the log. No ceiling is
|
|
357
|
+
* imposed — omitting `limit` reads the whole log by contract, which a
|
|
358
|
+
* clamp would silently truncate.
|
|
508
359
|
*/
|
|
509
360
|
export function assertValidJournalQuery(query) {
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
for (const key of ['afterSeq', 'limit']) {
|
|
513
|
-
const value = query[key];
|
|
514
|
-
if (value === undefined)
|
|
515
|
-
continue;
|
|
516
|
-
if (!Number.isInteger(value) || value < 0) {
|
|
517
|
-
throw new StackBadRequestError(`Invalid journal ${key} ${String(value)}: expected a non-negative integer.`);
|
|
518
|
-
}
|
|
519
|
-
}
|
|
361
|
+
assertWindowInteger(query?.afterSeq, 'journal afterSeq', 0);
|
|
362
|
+
assertWindowInteger(query?.limit, 'journal limit', 1);
|
|
520
363
|
}
|
|
521
364
|
/**
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
* omitting `limit` reads every version by contract.
|
|
365
|
+
* A `getVersions()` window. No ceiling is imposed: omitting `limit` reads
|
|
366
|
+
* every version by contract.
|
|
525
367
|
*/
|
|
526
368
|
export function assertValidVersionsQuery(query) {
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
for (const key of ['beforeVersion', 'limit']) {
|
|
530
|
-
const value = query[key];
|
|
531
|
-
if (value === undefined)
|
|
532
|
-
continue;
|
|
533
|
-
if (!Number.isInteger(value) || value < 1) {
|
|
534
|
-
throw new StackBadRequestError(`Invalid versions ${key} ${String(value)}: expected a positive integer.`);
|
|
535
|
-
}
|
|
536
|
-
}
|
|
369
|
+
assertWindowInteger(query?.beforeVersion, 'versions beforeVersion', 1);
|
|
370
|
+
assertWindowInteger(query?.limit, 'versions limit', 1);
|
|
537
371
|
}
|
|
538
372
|
//# sourceMappingURL=query-validation.js.map
|