@volter/world-core 3.0.155 → 3.0.157
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/attach.cjs +2 -0
- package/capability-decision.cjs +6 -4
- package/dist/attach.cjs +2 -0
- package/dist/capability-decision.cjs +6 -4
- package/dist/generated/pack-facts.json +204 -4
- package/dist/inject.cjs +150 -86
- package/dist/network-policy.cjs +11 -1
- package/dist/network-policy.d.cts +1 -0
- package/dist/pack-facts.d.cts +3 -0
- package/dist/required-credential-transport.cjs +29 -0
- package/dist/required-credential-transport.d.cts +3 -0
- package/dist/src/actions.d.ts +15 -0
- package/dist/src/actions.js +82 -21
- package/dist/src/ancestry.d.ts +5 -4
- package/dist/src/ancestry.js +53 -28
- package/dist/src/blob-store.d.ts +16 -0
- package/dist/src/blob-store.js +76 -1
- package/dist/src/changeset.js +1 -1
- package/dist/src/clickhouse/compression.d.ts +28 -0
- package/dist/src/clickhouse/compression.js +281 -0
- package/dist/src/clickhouse/index.d.ts +1 -0
- package/dist/src/clickhouse/index.js +1 -0
- package/dist/src/derived-core.d.ts +84 -7
- package/dist/src/derived-core.js +336 -61
- package/dist/src/derived-real.js +97 -15
- package/dist/src/derived.d.ts +5 -0
- package/dist/src/derived.js +3 -0
- package/dist/src/executor.d.ts +7 -0
- package/dist/src/executor.js +15 -1
- package/dist/src/fork.js +1 -1
- package/dist/src/git/lfs.d.ts +13 -3
- package/dist/src/git/lfs.js +41 -9
- package/dist/src/grpc-wire.js +2 -1
- package/dist/src/h2.js +63 -10
- package/dist/src/head.d.ts +5 -0
- package/dist/src/head.js +15 -0
- package/dist/src/history.d.ts +1 -1
- package/dist/src/history.js +7 -7
- package/dist/src/index.d.ts +5 -1
- package/dist/src/index.js +6 -1
- package/dist/src/live/ingest.js +9 -13
- package/dist/src/live/rtmp.js +7 -5
- package/dist/src/live/segmenter.js +7 -1
- package/dist/src/log.d.ts +23 -3
- package/dist/src/log.js +160 -37
- package/dist/src/machines.js +5 -1
- package/dist/src/managed-go-programs.d.ts +55 -0
- package/dist/src/managed-go-programs.js +133 -0
- package/dist/src/managed-original-inputs.d.ts +101 -0
- package/dist/src/managed-original-inputs.js +125 -0
- package/dist/src/mongodb/client.d.ts +8 -0
- package/dist/src/mongodb/client.js +27 -0
- package/dist/src/mongodb/index.d.ts +2 -0
- package/dist/src/mongodb/index.js +5 -0
- package/dist/src/mongodb/stream.d.ts +97 -0
- package/dist/src/mongodb/stream.js +206 -0
- package/dist/src/observe.js +1 -1
- package/dist/src/pack-fetch.d.ts +6 -1
- package/dist/src/pack-fetch.js +69 -14
- package/dist/src/pack-paths.d.ts +2 -1
- package/dist/src/pack-paths.js +6 -1
- package/dist/src/packRegistry.d.ts +10 -14
- package/dist/src/packRegistry.js +2 -13
- package/dist/src/protocol.d.ts +13 -0
- package/dist/src/protocol.js +13 -0
- package/dist/src/redis/engine.d.ts +10 -4
- package/dist/src/redis/engine.js +103 -32
- package/dist/src/redis/member-storage.js +20 -8
- package/dist/src/redis/service-times.d.ts +25 -0
- package/dist/src/redis/service-times.js +87 -0
- package/dist/src/redis/stream.d.ts +2 -1
- package/dist/src/redis/stream.js +109 -20
- package/dist/src/resource-blob.d.ts +4 -0
- package/dist/src/resource-blob.js +8 -1
- package/dist/src/runtime-state.d.ts +11 -0
- package/dist/src/runtime-state.js +13 -0
- package/dist/src/runtime.d.ts +7 -5
- package/dist/src/runtime.js +7 -4
- package/dist/src/serve-http.d.ts +2 -0
- package/dist/src/serve-http.js +73 -6
- package/dist/src/serve.d.ts +10 -0
- package/dist/src/serve.js +43 -3
- package/dist/src/sockets.d.ts +4 -1
- package/dist/src/sockets.js +59 -6
- package/dist/src/storage.d.ts +2 -1
- package/dist/src/storage.js +6 -3
- package/dist/src/vendor-call.js +58 -0
- package/dist/src/world-boot.d.ts +1 -0
- package/dist/src/world-boot.js +6 -0
- package/dist/src/world-clock.js +6 -2
- package/dist/src/world-store.d.ts +122 -7
- package/dist/src/world-store.js +959 -103
- package/dist/src/xml.d.ts +30 -0
- package/dist/src/xml.js +74 -0
- package/generated/pack-facts.json +204 -4
- package/inject.cjs +150 -86
- package/network-policy.cjs +11 -1
- package/network-policy.d.cts +1 -0
- package/pack-facts.d.cts +3 -0
- package/package.json +27 -1
- package/required-credential-transport.cjs +29 -0
- package/required-credential-transport.d.cts +3 -0
- package/src/actions.ts +78 -21
- package/src/ancestry.ts +46 -24
- package/src/blob-store.ts +59 -1
- package/src/changeset.ts +1 -1
- package/src/clickhouse/compression.ts +172 -0
- package/src/clickhouse/index.ts +2 -0
- package/src/derived-core.ts +356 -61
- package/src/derived-real.ts +80 -14
- package/src/derived.ts +9 -1
- package/src/executor.ts +17 -3
- package/src/fork.ts +1 -1
- package/src/git/lfs.ts +40 -9
- package/src/grpc-wire.ts +2 -1
- package/src/h2.ts +46 -5
- package/src/head.ts +15 -0
- package/src/history.ts +7 -7
- package/src/index.ts +7 -1
- package/src/live/ingest.ts +7 -3
- package/src/live/rtmp.ts +7 -5
- package/src/live/segmenter.ts +7 -1
- package/src/log.ts +147 -32
- package/src/machines.ts +5 -1
- package/src/managed-go-programs.ts +146 -0
- package/src/managed-original-inputs.ts +163 -0
- package/src/mongodb/client.ts +27 -0
- package/src/mongodb/index.ts +5 -0
- package/src/mongodb/stream.ts +223 -0
- package/src/observe.ts +1 -1
- package/src/pack-fetch.ts +66 -16
- package/src/pack-paths.ts +5 -1
- package/src/packRegistry.ts +3 -15
- package/src/protocol.ts +13 -0
- package/src/redis/engine.ts +100 -57
- package/src/redis/member-storage.ts +17 -9
- package/src/redis/service-times.ts +81 -0
- package/src/redis/stream.ts +66 -14
- package/src/resource-blob.ts +9 -1
- package/src/runtime-state.ts +14 -0
- package/src/runtime.ts +10 -3
- package/src/serve-http.ts +65 -6
- package/src/serve.ts +45 -3
- package/src/sockets.ts +39 -8
- package/src/storage.ts +5 -3
- package/src/vendor-call.ts +54 -0
- package/src/world-boot.ts +6 -0
- package/src/world-clock.ts +6 -2
- package/src/world-store.ts +768 -91
- package/src/xml.ts +71 -0
package/src/derived-core.ts
CHANGED
|
@@ -6,8 +6,9 @@ import { parseExactJson, compareExactNumbers, exactInteger, isExactIntegerValue
|
|
|
6
6
|
// serves CRUD generically and applies declared transitions, over the kernel's tree and write path.
|
|
7
7
|
// Nothing here knows a vendor: every vendor difference is a manifest value.
|
|
8
8
|
|
|
9
|
-
import { applyTwinWrite, applyTwinWriteAtomic, twinResources, type TwinResource } from './serve.ts';
|
|
10
|
-
import { copyResource, ownFields, parentStamp, subjectHistory, treeChangesSince, treeStamp } from './log.ts';
|
|
9
|
+
import { applyTwinWrite, applyTwinWriteAtomic, applyTwinWrites, twinResources, type TwinResource } from './serve.ts';
|
|
10
|
+
import { copyResource, foldLocalWrite, heldSubject, ownFields, parentStamp, subjectHistory, treeChangesSince, treeResources, treeStamp, type Subject } from './log.ts';
|
|
11
|
+
import { performsAtHeadNow } from './head.ts';
|
|
11
12
|
import { getActiveWorldStore } from './world-store.ts';
|
|
12
13
|
import { getActiveBlobStore } from './blob-store.ts';
|
|
13
14
|
import { worldPaths } from './storage.ts';
|
|
@@ -24,8 +25,9 @@ import { askApplication, deliverEvents, type ApplicationAnswer, type Application
|
|
|
24
25
|
import type { RateBudgetDeclaration } from './rateBudget.ts';
|
|
25
26
|
import { handlerCrypto, hmac, ksuidFrom, lettersFrom, sha256, uuidFrom, type HandlerCrypto } from './signing.ts';
|
|
26
27
|
import { hashFieldValue } from './hash.ts';
|
|
27
|
-
import { readResourceBlob, removeResourceBlob, writeResourceBlob } from './resource-blob.ts';
|
|
28
|
+
import { readResourceBlob, readResourceBlobRange, removeResourceBlob, resourceBlobSize, writeResourceBlob, writeResourceBlobStream } from './resource-blob.ts';
|
|
28
29
|
import { managedDatabase, unboundDatabase, type ManagedDatabase } from './managed-database.ts';
|
|
30
|
+
import { redisServiceTimes } from './redis/service-times.ts';
|
|
29
31
|
import { execRedisRun, type RedisDialect, type RunItem } from './redis/engine.ts';
|
|
30
32
|
import { sendMail, type Mail, type SmtpRoute } from './smtp.ts';
|
|
31
33
|
import { ownField, parseBracketForm } from './form.ts';
|
|
@@ -82,6 +84,8 @@ export type Transition = {
|
|
|
82
84
|
export type StateField = {
|
|
83
85
|
initial: string | boolean;
|
|
84
86
|
transitions: Transition[];
|
|
87
|
+
/** Plain updates may repeat the current representation without another state move. */
|
|
88
|
+
unchanged?: 'allow';
|
|
85
89
|
derive?: Array<{ state: string; when: { equals?: unknown; gt?: number; truthy?: boolean; absent?: boolean } }>;
|
|
86
90
|
vendorInitial?: string;
|
|
87
91
|
};
|
|
@@ -147,6 +151,8 @@ export type ResourceDecl = {
|
|
|
147
151
|
* fields when its stored id is not what the path names (a repository stored as `repo:7` with `owner`
|
|
148
152
|
* and `name`, addressed as `{owner}/{repo}`): each stored field against a template over the path. */
|
|
149
153
|
parent?: { param?: string; params?: string[]; value?: string; field: string; resource: string; allowDeleted?: boolean; where?: Record<string, string>;
|
|
154
|
+
/** Global subject paths omit the collection's parent labels; tenancy still applies. */
|
|
155
|
+
whenNamed?: true;
|
|
150
156
|
/** private fields a child observed under its parent keeps from the parent's parameters (`_jurisdiction:
|
|
151
157
|
* '{jurisdiction}'`), where the child's own read names none and a later read under it needs one ("Refresh") */
|
|
152
158
|
keep?: Record<string, string> };
|
|
@@ -199,7 +205,9 @@ export type ResourceDecl = {
|
|
|
199
205
|
* holds this one's id (or vendor `parentKey`), only those with `present` set or `absent` unset when named and without
|
|
200
206
|
* any matching `unless` field, plus `plus` (a customer's
|
|
201
207
|
* `next_invoice_sequence` is one past its numbered invoices). `by` may be a dot path; exact field names take
|
|
202
|
-
* precedence. The kernel keeps each whenever a child is written
|
|
208
|
+
* precedence. The kernel keeps each whenever a child is written, the parent it left too when the write moves it; `of`
|
|
209
|
+
* may name a bookkeeping type no resource declares, written with `ctx.record`. A count is kept by the lane that
|
|
210
|
+
* declares it: a child another lane writes is not counted there. */
|
|
203
211
|
counts?: Record<string, { of: string; by: string; parentKey?: string; present?: string; absent?: string; unless?: Record<string, unknown>; plus?: number }>;
|
|
204
212
|
/** how the vendor's state of this type is read back into a root (the real-system adapters, "Refresh"): the list that
|
|
205
213
|
* enumerates it (once per parent, under `parent`; `complete: false` when it is not the whole type; `items`, the answer's
|
|
@@ -215,7 +223,7 @@ export type ResourceDecl = {
|
|
|
215
223
|
* jurisdictions). A blob read by an `operation` (a GET of the unit's surface, filled from the subject's fields and
|
|
216
224
|
* its parent's parameters) keeps the bytes in the World's blobs, their key in `field`, and in `headersField` the
|
|
217
225
|
* answer's headers `keepHeaders` names (a name ending `*` a prefix: `x-amz-meta-*`), both fields private. */
|
|
218
|
-
refresh?: ({ list: string; unpaginated?: true; keyed?: true; detail?: string; detailMerge?: string; detailWith?: Record<string, string>; complete?: false; items?: string; shape?: RefreshShape; next?: string | Record<string, string>; nextLink?: { header: string; rel: string; query: string; when?: Record<string, string> }; cursor?: string; more?: string } | { get: string; known?: true }) & { params?: Record<string, string>; headers?: Record<string, string>; variants?: ReadonlyArray<{ headers: Record<string, string> }>; blobs?: ReadonlyArray<{ path: string; key: string; resource?: string; fields?: Record<string, string> } | { operation: string; field: string; headers?: Record<string, string>; headersField?: string; keepHeaders?: string[] }>; query?: Record<string, string>; idAs?: string; graphql?: { query: string; variables?: Record<string, unknown>; items: string; pageInfo?: string; cursor?: string } } | { none: string };
|
|
226
|
+
refresh?: ({ list: string; unpaginated?: true; keyed?: true; detail?: string; detailMerge?: string; detailWith?: Record<string, string>; complete?: false; items?: string; shape?: RefreshShape; next?: string | Record<string, string>; nextLink?: { header: string; rel: string; query: string; when?: Record<string, string> }; cursor?: string; more?: string } | { get: string; known?: true; gone?: { status: number; says: string } }) & { params?: Record<string, string>; headers?: Record<string, string>; variants?: ReadonlyArray<{ headers: Record<string, string> }>; blobs?: ReadonlyArray<{ path: string; key: string; resource?: string; fields?: Record<string, string> } | { operation: string; field: string; headers?: Record<string, string>; headersField?: string; keepHeaders?: string[] }>; query?: Record<string, string>; idAs?: string; graphql?: { query: string; variables?: Record<string, unknown>; items: string; pageInfo?: string; cursor?: string } } | { none: string };
|
|
219
227
|
/** the other resources one create of this one makes, and where its answer names each's id (a JSON path) */
|
|
220
228
|
companions?: Record<string, string>;
|
|
221
229
|
/** the field telling a newer copy of a subject from an older (an `updated_at`): an ingested event older than the
|
|
@@ -272,6 +280,11 @@ export type VendorManifest = { vendor: string; discovery?: DerivedManifest['disc
|
|
|
272
280
|
|
|
273
281
|
/** `code` as the vendor writes it: a string, or a number (Discord's JSON error codes are integers) */
|
|
274
282
|
export type ErrorSpec = { status: number; message: string; code?: string | number; param?: string; kind?: string };
|
|
283
|
+
/** How a form value outside its spec's list is refused (DerivedOperation.enums): the error, its message filled with
|
|
284
|
+
* `{param}` (the bracket name) and `{values}` (the values it takes); `documented`, values the vendor documents outside
|
|
285
|
+
* its spec, by operation and path; `version`, the header naming the API version a request is made at and the version
|
|
286
|
+
* the spec is, so a request pinned to another version, whose lists the pack does not hold, is not checked. */
|
|
287
|
+
export type EnumRefusal = ErrorSpec & { documented?: Record<string, Record<string, string[]>>; version?: { header: string; served: string } };
|
|
275
288
|
|
|
276
289
|
/** A vendor screen a World serves (docs/contributing/architecture.md, "Screens"). A `flow` is a hosted
|
|
277
290
|
* page an application sends its user through, held to its documented round trip; a `workspace` is a
|
|
@@ -298,6 +311,11 @@ export type ScreenDecl = {
|
|
|
298
311
|
* takes, with its demand (an upload bucket a page posts a lease's form to: Reddit's S3 hosts) (architecture, "A page
|
|
299
312
|
* at a World's place") */
|
|
300
313
|
takes?: ReadonlyArray<'POST'>;
|
|
314
|
+
/** a workspace page that shows one record: the stored type and the path parameter its id fills (a pull request's
|
|
315
|
+
* page, `{number}`); `?show=<type>:<id>` asked of the pack's workspaces opens it (architecture, "Workspace") */
|
|
316
|
+
shows?: { type: string; param: string };
|
|
317
|
+
/** the stored types a workspace page lists, where `?show=` lands for a record no page shows by itself */
|
|
318
|
+
lists?: ReadonlyArray<string>;
|
|
301
319
|
/** the vendor's documentation of the round trip or the screen */
|
|
302
320
|
source: string;
|
|
303
321
|
};
|
|
@@ -333,13 +351,20 @@ export type DerivedManifest = {
|
|
|
333
351
|
/** `strict`: a vendor that refuses a body field its operation does not declare, a value of another type, or a number
|
|
334
352
|
* outside the spec's bounds (a NestJS validation pipe: Supabase's "property x should not exist"), with its messages;
|
|
335
353
|
* `{name}`, `{type}` (`a string`, `an integer`), `{min}` and `{max}` are filled. `ctx.fields` checks a body so. */
|
|
336
|
-
body: { form?: { coerce: boolean }; json?: 'always'; validation?: { operations?: ReadonlyArray<string>; status: number; missing: unknown; type: unknown; range: unknown }; strict?: { status: number; code?: string; unknown: string; type: string; range: string } };
|
|
354
|
+
body: { form?: { coerce: boolean; enumRefusal?: EnumRefusal }; json?: 'always'; validation?: { operations?: ReadonlyArray<string>; status: number; missing: unknown; type: unknown; range: unknown }; strict?: { status: number; code?: string; unknown: string; type: string; range: string; arrays?: Record<string, 'string'> } };
|
|
337
355
|
/** `acceptProvided`: a create that names its own `id` keeps it (a vendor that seeds by id). The template counts
|
|
338
356
|
* (`{prefix}_{n}`), or is `{uuid}` for a vendor whose ids are UUIDs: the next is derived from the resource and its
|
|
339
357
|
* count, so a World mints the same ids every run */
|
|
340
358
|
ids: { template: string; acceptProvided?: boolean };
|
|
341
359
|
/** operations the derived core must not claim though their resource is declared: a gap until someone models them */
|
|
342
360
|
unmodeled?: string[];
|
|
361
|
+
/** documented variants of a served operation that no demand asks for, by operation: each a parameter present
|
|
362
|
+
* (`pause_collection`), present on any item of a list (`items[].price_data`), or with a value
|
|
363
|
+
* (`payment_method_data.type=sepa_debit`, `payment_method_types[]=sepa_debit`), or holding one entry of a comma-separated
|
|
364
|
+
* value (`tweet.fields~=geo`; a parameter whose name holds a dot is read by that name). A request asking one is answered the
|
|
365
|
+
* manifest's gap before its handler runs, as an operation no handler serves is (architecture, "Creating a pack",
|
|
366
|
+
* step 7), so no pack line answers it */
|
|
367
|
+
variants?: Record<string, string[]>;
|
|
343
368
|
/** operations that only read though nothing in the spec says so (a POST that returns data):
|
|
344
369
|
* a read-only twin answers them */
|
|
345
370
|
reads?: string[];
|
|
@@ -368,6 +393,9 @@ export type DerivedManifest = {
|
|
|
368
393
|
readOnly: ErrorSpec;
|
|
369
394
|
/** what a body labelled JSON that does not parse answers; absent, a 400 in the vendor's error body */
|
|
370
395
|
malformedBody?: ErrorSpec;
|
|
396
|
+
/** the vendor's front merges repeated slashes in a path before it routes (nginx's default `merge_slashes`): a request
|
|
397
|
+
* to `//r/x//about` is `/r/x/about`, merged on arrival before a door, a screen or an operation is matched */
|
|
398
|
+
mergeSlashes?: boolean;
|
|
371
399
|
/** a request header that selects the vendor's API version, and the answer to a malformed one
|
|
372
400
|
* (`{value}` in its message is the header's value) */
|
|
373
401
|
version?: { header: string; pattern: string; error: ErrorSpec };
|
|
@@ -473,7 +501,9 @@ export type DerivedManifest = {
|
|
|
473
501
|
};
|
|
474
502
|
/** The World's doors (architecture, "Doors, screens and the gap"): each a method and a path under `/_twin/` (with
|
|
475
503
|
* `{param}` segments), answered by the export of `semantics/doors.ts` its id names, over the contract's context. */
|
|
476
|
-
doors?: Array<{ id: string; method: string; path: string; note?: string }>;
|
|
504
|
+
doors?: Array<{ id: string; method: string; path: string; note?: string; redactBody?: string[] }>;
|
|
505
|
+
/** REST refresh binds observed tenant metadata through the root account identity endpoint. */
|
|
506
|
+
refreshIdentity?: { operation: string; field: string };
|
|
477
507
|
/** The vendor's GraphQL API beside its REST one (`semantics/graphql.ts` over `generated/graphql-sdl.gen.json`): the
|
|
478
508
|
* paths it is POSTed to, and the answer to a query selecting a field nothing models (`{field}`, `{type}`). */
|
|
479
509
|
graphql?: { paths: ReadonlyArray<string>; unmodeled: { message: string; type?: string; status?: number } };
|
|
@@ -566,12 +596,13 @@ export function strictFields(m: DerivedManifest, operation: DerivedOperation, bo
|
|
|
566
596
|
const field = declared.get(name);
|
|
567
597
|
if (!field) return refuse(strict.unknown, { name });
|
|
568
598
|
const { type } = field;
|
|
569
|
-
const fits = value === null
|
|
599
|
+
const fits = (value === null && !(type === 'array' && strict.arrays?.[name]))
|
|
570
600
|
|| (type === 'boolean' && typeof value === 'boolean')
|
|
571
601
|
|| (type === 'integer' && isExactIntegerValue(value))
|
|
572
602
|
|| (type === 'number' && (typeof value === 'number' || isExactIntegerValue(value)))
|
|
573
603
|
|| (type === 'string' && typeof value === 'string')
|
|
574
|
-
||
|
|
604
|
+
|| (type === 'array' && Array.isArray(value) && (!strict.arrays?.[name] || value.every(item => typeof item === strict.arrays![name])))
|
|
605
|
+
|| !['boolean', 'integer', 'number', 'string', 'array'].includes(type);
|
|
575
606
|
if (!fits) return refuse(strict.type, { name, type: type === 'integer' ? 'an integer' : `a ${type}` });
|
|
576
607
|
if ((typeof value === 'number' || isExactIntegerValue(value)) && ((field.minimum !== undefined && compareExactNumbers(value, field.minimum) < 0) || (field.maximum !== undefined && compareExactNumbers(value, field.maximum) > 0))) {
|
|
577
608
|
return refuse(strict.range, { name, min: field.minimum ?? null, max: field.maximum ?? null });
|
|
@@ -738,13 +769,15 @@ export function resourcesOfType(service: string, type: string, root?: string): T
|
|
|
738
769
|
for (const [k, r] of byKey) if (!delta.appended.includes(k)) (held.byType.get(r.type) ?? held.byType.set(r.type, new Map()).get(r.type)!).set(k, r);
|
|
739
770
|
for (const k of delta.appended) { const r = byKey.get(k)!; (held.byType.get(r.type) ?? held.byType.set(r.type, new Map()).get(r.type)!).set(k, r); }
|
|
740
771
|
held.stamp = delta.stamp;
|
|
741
|
-
} else
|
|
742
|
-
// the stamp is read before the fold, so a write between them can only leave a newer fold under an older stamp
|
|
772
|
+
} else {
|
|
773
|
+
// the stamp is read once, before the fold, so a write between them can only leave a newer fold under an older stamp
|
|
743
774
|
const stamp = treeStamp(service, root);
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
775
|
+
if (!held || held.stamp !== stamp) {
|
|
776
|
+
const byType = new Map<string, Map<string, TwinResource>>();
|
|
777
|
+
for (const r of twinResources(service, root)) (byType.get(r.type) ?? byType.set(r.type, new Map()).get(r.type)!).set(`${r.type}:${r.id}`, r);
|
|
778
|
+
held = { stamp, byType };
|
|
779
|
+
indexes.set(key, held);
|
|
780
|
+
}
|
|
748
781
|
}
|
|
749
782
|
return [...(held.byType.get(type)?.values() ?? [])].map(copyResource);
|
|
750
783
|
}
|
|
@@ -775,12 +808,20 @@ function parentField(row: unknown, path: string): unknown {
|
|
|
775
808
|
return value;
|
|
776
809
|
}
|
|
777
810
|
|
|
778
|
-
function
|
|
779
|
-
const parent =
|
|
811
|
+
function parentOfCall(decl: ResourceDecl | undefined, call: DerivedCall): ResourceDecl['parent'] {
|
|
812
|
+
const parent = decl?.parent;
|
|
813
|
+
const names = parent?.params ?? (parent?.param === undefined ? [] : [parent.param]);
|
|
814
|
+
return parent?.whenNamed && names.length && !names.some((name) => Object.hasOwn(call.params, name))
|
|
815
|
+
? undefined : parent;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function missingParent(m: DerivedManifest, resource: string, call: DerivedCall, root?: string, tenant?: string): Response | undefined {
|
|
819
|
+
const parent = parentOfCall(m.resources[resource], call);
|
|
780
820
|
if (!parent) return undefined;
|
|
781
821
|
const id = parentValue(parent, call.params);
|
|
782
822
|
const where = Object.entries(parent.where ?? {}).map(([field, template]) => [field, String(fill(template, call.params))] as const);
|
|
783
|
-
const found = (r: TwinResource): boolean => (where.length ? where.every(([field, v]) => String(parentField(r, field)) === v) : r.id === id)
|
|
823
|
+
const found = (r: TwinResource): boolean => (where.length ? where.every(([field, v]) => String(parentField(r, field)) === v) : r.id === id)
|
|
824
|
+
&& (!m.tenant || m.resources[parent.resource]?.shared || (r as Record<string, unknown>)[m.tenant.field] === tenant);
|
|
784
825
|
return id !== undefined && stored(m, parent.resource, root, { withDeleted: parent.allowDeleted === true }).some(found) ? undefined : notFound(m, parent.resource, String(id), parent.param ?? parent.params?.at(-1));
|
|
785
826
|
}
|
|
786
827
|
|
|
@@ -1164,7 +1205,11 @@ function recordedInput(m: DerivedManifest, call: DerivedCall, merged: Record<str
|
|
|
1164
1205
|
const id = call.operation.id;
|
|
1165
1206
|
const sent = sentOf.get(call);
|
|
1166
1207
|
const activity = call.request.headers.get('x-volter-world-activity') === 'provisioning' ? { activity: 'provisioning' } : {};
|
|
1167
|
-
const
|
|
1208
|
+
const door = m.doors?.find(d => d.id === id);
|
|
1209
|
+
const body = sent?.body ?? merged;
|
|
1210
|
+
const safeBody = door?.redactBody?.length && body && typeof body === 'object' && !Array.isArray(body)
|
|
1211
|
+
? Object.fromEntries(Object.entries(body).filter(([key]) => !door.redactBody!.includes(key))) : body;
|
|
1212
|
+
const asSent = sent ? { ...activity, path: sent.path, query: sent.query, body: safeBody, headers: sent.headers, ...(sent.lane ? { lane: sent.lane } : {}), ...(sent.requestId ? { requestId: sent.requestId } : {}), ...(sent.type ? { contentType: sent.type } : {}) } : { ...activity, path: call.params, body: safeBody };
|
|
1168
1213
|
if (id === 'graphql') {
|
|
1169
1214
|
const b = (sent?.body ?? {}) as { query?: unknown; variables?: unknown; operationName?: unknown };
|
|
1170
1215
|
return { ...activity, graphql: { query: b.query, variables: b.variables ?? {}, ...(b.operationName ? { operationName: b.operationName } : {}) }, ...(sent?.lane ? { lane: sent.lane } : {}), headers: sent?.headers ?? {} };
|
|
@@ -1182,34 +1227,33 @@ const requestOfCall = new WeakMap<DerivedCall, Request>();
|
|
|
1182
1227
|
|
|
1183
1228
|
async function writeDetailed(m: DerivedManifest, call: DerivedCall, resource: string, id: string, fields: Record<string, unknown>, operation: string, params: Record<string, unknown>, root: string | undefined, occurredAt: string, checks?: StateChecks): Promise<{ body: Record<string, unknown>; id: string; vendorData?: unknown }> {
|
|
1184
1229
|
const before = stateBeforeWrite(m, resource, id, root);
|
|
1230
|
+
const prior = counted(m, resource) ? stored(m, resource, root).find((r) => r.id === id) as Record<string, unknown> | undefined : undefined;
|
|
1185
1231
|
const withFiles = async (input: Record<string, unknown>): Promise<Record<string, unknown>> => { const files = input.operationId ? await keptFiles(m, call, root) : undefined; return files ? { ...input, files } : input; };
|
|
1186
1232
|
const { resource: row, result } = await applyTwinWrite(
|
|
1187
1233
|
m.service,
|
|
1188
|
-
// who made it: the login the pack's identity reads off the request, when it declares one, so a subject's
|
|
1189
|
-
// history can say who did each thing
|
|
1190
1234
|
// a vendor's own move (ctx.asVendor, the clock) is the World's, as the clock's writes are: no API call a head performs
|
|
1191
1235
|
inVendorMove()
|
|
1192
1236
|
? { operation, subjectType: storedType(m, resource), subjectId: id, fields, input: { clock: true }, occurredAt, actor: { kind: 'system' } }
|
|
1193
|
-
: { operation, subjectType: storedType(m, resource), subjectId: id, fields, input: await withFiles(recordedInput(m, call, params)), occurredAt, actor: { kind: 'agent'
|
|
1237
|
+
: { operation, subjectType: storedType(m, resource), subjectId: id, fields, input: await withFiles(recordedInput(m, call, params)), occurredAt, actor: { kind: 'agent' } },
|
|
1194
1238
|
root,
|
|
1195
1239
|
);
|
|
1196
1240
|
observeStateWrite(m, result, before, root, checks);
|
|
1197
|
-
return finishWrite(m, call, resource, row, result, operation, params, root, occurredAt);
|
|
1241
|
+
return finishWrite(m, call, resource, row, result, operation, params, root, occurredAt, prior && { ...prior });
|
|
1198
1242
|
}
|
|
1199
1243
|
|
|
1200
1244
|
async function atomicWrite(m: DerivedManifest, call: DerivedCall, resource: string,
|
|
1201
1245
|
decide: (snapshot: readonly TwinResource[]) => { id: string; fields: Record<string, unknown> } | undefined,
|
|
1202
|
-
operation: string, params: Record<string, unknown>, root: string | undefined, occurredAt: string, unchangedId?: string, checks?: StateChecks,
|
|
1246
|
+
operation: string, params: Record<string, unknown>, root: string | undefined, occurredAt: string, unchangedId?: string, checks?: StateChecks, omitInput = false,
|
|
1203
1247
|
): Promise<Record<string, unknown> | undefined> {
|
|
1204
|
-
const input = recordedInput(m, call, params);
|
|
1205
|
-
const files = input
|
|
1248
|
+
const input = omitInput ? undefined : recordedInput(m, call, params);
|
|
1249
|
+
const files = input?.operationId ? await keptFiles(m, call, root) : undefined;
|
|
1206
1250
|
let before: TwinResource | undefined;
|
|
1207
1251
|
const committed = await applyTwinWriteAtomic(m.service, (snapshot) => {
|
|
1208
1252
|
const selected = decide(snapshot);
|
|
1209
|
-
if (selected && transitionObserver) before = snapshot.find((r) => r.type === storedType(m, resource) && r.id === selected.id);
|
|
1253
|
+
if (selected && (transitionObserver || counted(m, resource))) before = snapshot.find((r) => r.type === storedType(m, resource) && r.id === selected.id);
|
|
1210
1254
|
return selected ? { kind: 'write', value: selected.id, write: {
|
|
1211
1255
|
operation, subjectType: storedType(m, resource), subjectId: selected.id, fields: selected.fields,
|
|
1212
|
-
input: files ? { ...input, files } : input, occurredAt, actor: { kind: 'agent' as const, ...(m.identity ? { id: m.identity(call.request.headers.get('authorization'), root) } : {}) },
|
|
1256
|
+
...(input ? { input: files ? { ...input, files } : input } : {}), occurredAt, actor: { kind: 'agent' as const, ...(m.identity ? { id: m.identity(call.request.headers.get('authorization'), root) } : {}) },
|
|
1213
1257
|
} } : { kind: 'skip', value: undefined };
|
|
1214
1258
|
}, root);
|
|
1215
1259
|
if (!committed.result || !committed.value) {
|
|
@@ -1218,14 +1262,14 @@ async function atomicWrite(m: DerivedManifest, call: DerivedCall, resource: stri
|
|
|
1218
1262
|
}
|
|
1219
1263
|
observeStateWrite(m, committed.result, before, root, checks);
|
|
1220
1264
|
const row = resourcesOfType(m.service, storedType(m, resource), root).find((r) => r.id === resolveSubjectId(m.service, storedType(m, resource), committed.value!, root))!;
|
|
1221
|
-
return (await finishWrite(m, call, resource, row, committed.result, operation, params, root, occurredAt)).body;
|
|
1265
|
+
return (await finishWrite(m, call, resource, row, committed.result, operation, params, root, occurredAt, before as unknown as Record<string, unknown> | undefined)).body;
|
|
1222
1266
|
}
|
|
1223
1267
|
|
|
1224
1268
|
async function finishWrite(m: DerivedManifest, call: DerivedCall, resource: string, row: TwinResource,
|
|
1225
1269
|
result: { externalId?: string; vendorData?: unknown }, operation: string, params: Record<string, unknown>,
|
|
1226
|
-
root: string | undefined, occurredAt: string,
|
|
1270
|
+
root: string | undefined, occurredAt: string, prior?: Record<string, unknown>,
|
|
1227
1271
|
): Promise<{ body: Record<string, unknown>; id: string; vendorData?: unknown }> {
|
|
1228
|
-
await keepCounts(m, call, resource, row as unknown as Record<string, unknown>, params, root, occurredAt);
|
|
1272
|
+
await keepCounts(m, call, resource, row as unknown as Record<string, unknown>, params, root, occurredAt, prior);
|
|
1229
1273
|
const body = view(m, resource, row);
|
|
1230
1274
|
// the write's own context for its hooks, in the scope the call was served in (a walk's application answers, a tenant,
|
|
1231
1275
|
// the managed database), at the write's moment
|
|
@@ -1246,24 +1290,30 @@ async function finishWrite(m: DerivedManifest, call: DerivedCall, resource: stri
|
|
|
1246
1290
|
|
|
1247
1291
|
/** The declared counts a written subject is a child in (a resource's `counts`), each kept on its parent: written when
|
|
1248
1292
|
* the count moved, through the write path, so the parent's history says when. */
|
|
1249
|
-
async function keepCounts(m: DerivedManifest, call: DerivedCall, resource: string, row: Record<string, unknown>, params: Record<string, unknown>, root: string | undefined, occurredAt: string): Promise<void> {
|
|
1293
|
+
async function keepCounts(m: DerivedManifest, call: DerivedCall, resource: string, row: Record<string, unknown>, params: Record<string, unknown>, root: string | undefined, occurredAt: string, prior?: Record<string, unknown>): Promise<void> {
|
|
1250
1294
|
for (const [parent, decl] of Object.entries(m.resources)) {
|
|
1251
1295
|
for (const [field, c] of Object.entries(decl.counts ?? {})) {
|
|
1252
1296
|
if (storedType(m, c.of) !== storedType(m, resource)) continue;
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
const pid
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1297
|
+
// the parent the subject is under now, and the one it left when the write moved it: both counts changed
|
|
1298
|
+
const pids = new Set([parentField(row, c.by), prior === undefined ? undefined : parentField(prior, c.by)].filter((v) => v !== undefined && v !== null).map(String));
|
|
1299
|
+
for (const pid of pids) {
|
|
1300
|
+
const held = stored(m, parent, root).find((r) => String(c.parentKey ? ownFields(r)[c.parentKey] : r.id) === pid) as Record<string, unknown> | undefined;
|
|
1301
|
+
if (!held) continue;
|
|
1302
|
+
const n = stored(m, c.of, root).filter((r) => {
|
|
1303
|
+
const x = r as Record<string, unknown>;
|
|
1304
|
+
return String(parentField(x, c.by)) === pid && (c.present === undefined || (x[c.present] !== undefined && x[c.present] !== null)) && (c.absent === undefined || parentField(ownFields(r), c.absent) === undefined || parentField(ownFields(r), c.absent) === null) && Object.entries(c.unless ?? {}).every(([key, value]) => ownFields(r)[key] !== value);
|
|
1305
|
+
}).length + (c.plus ?? 0);
|
|
1306
|
+
if (held[field] !== n) await writeDetailed(m, call, parent, String(held.id), { [field]: n }, `${storedType(m, parent)}.counted`, params, root, occurredAt);
|
|
1307
|
+
}
|
|
1263
1308
|
}
|
|
1264
1309
|
}
|
|
1265
1310
|
}
|
|
1266
1311
|
|
|
1312
|
+
/** Whether any resource counts this one (`counts`): only then does a write read the subject as it stood, for the parent
|
|
1313
|
+
* it may leave. */
|
|
1314
|
+
const counted = (m: DerivedManifest, resource: string): boolean =>
|
|
1315
|
+
Object.values(m.resources).some((d) => Object.values(d.counts ?? {}).some((c) => storedType(m, c.of) === storedType(m, resource)));
|
|
1316
|
+
|
|
1267
1317
|
/** An answer in the vendor's success envelope, under the operation's key, when the vendor has one. */
|
|
1268
1318
|
function envelope(m: DerivedManifest, op: DerivedOperation, body: Record<string, unknown>): Record<string, unknown> {
|
|
1269
1319
|
return m.success && op.answers?.key ? { ...m.success, [op.answers.key]: body } : body;
|
|
@@ -1596,14 +1646,14 @@ export async function serveCore(m: DerivedManifest, call: DerivedCall, scope: Co
|
|
|
1596
1646
|
// the body is read from a copy, so the call's own request is still unread for the write hook's context
|
|
1597
1647
|
const params = await boundaryParams(m, { ...call, request: call.request.clone() }, root);
|
|
1598
1648
|
const idParam = subjectOf(m, resource, call);
|
|
1599
|
-
const
|
|
1600
|
-
if (orphan) return { served: orphan };
|
|
1601
|
-
const parent = decl.parent;
|
|
1649
|
+
const parent = parentOfCall(decl, call);
|
|
1602
1650
|
const parentId = parent ? parentValue(parent, call.params) : undefined;
|
|
1603
1651
|
// the caller's tenant, when the vendor has tenancy and the resource is not shared by every tenant
|
|
1604
1652
|
const tenantField = m.tenant && !decl.shared ? m.tenant.field : undefined;
|
|
1605
1653
|
const tenant = tenantField && scope.tenant ? await scope.tenant(await contextFor(m, { ...call, request: call.request.clone() }, scope)) : undefined;
|
|
1606
1654
|
if (tenantField && tenant === undefined) return { unmodeled: `the caller names no tenant (semantics/tenant.ts) for ${resource}` };
|
|
1655
|
+
const orphan = missingParent(m, resource, call, root, tenant);
|
|
1656
|
+
if (orphan) return { served: orphan };
|
|
1607
1657
|
const underParent = (r: TwinResource): boolean => (!parent || (r as Record<string, unknown>)[parent.field] === parentId)
|
|
1608
1658
|
&& (!tenantField || (r as Record<string, unknown>)[tenantField] === tenant);
|
|
1609
1659
|
const paths = expandPaths(m, params);
|
|
@@ -1677,6 +1727,7 @@ export async function serveCore(m: DerivedManifest, call: DerivedCall, scope: Co
|
|
|
1677
1727
|
for (const [field, sdecl] of Object.entries(decl.state ?? {})) {
|
|
1678
1728
|
const requested = op.class === 'update' ? data[field] : undefined;
|
|
1679
1729
|
if (op.class === 'update' && requested === undefined) continue;
|
|
1730
|
+
if (op.class === 'update' && sdecl.unchanged === 'allow' && stateOf(sdecl, current[field]) === stateOf(sdecl, requested)) { changes[field] = requested; continue; }
|
|
1680
1731
|
const { move, refusal } = transitionFor(field, sdecl, op.id, current[field], requested, hit.id);
|
|
1681
1732
|
if (refusal) return { served: vendorError(m, refusal) };
|
|
1682
1733
|
if (!move) {
|
|
@@ -1764,8 +1815,6 @@ export type SemanticsContext = {
|
|
|
1764
1815
|
/** A mail the vendor sends a person, over SMTP to the route its settings name (smtp.ts), under the World's egress rule;
|
|
1765
1816
|
* throws when it cannot be delivered. */
|
|
1766
1817
|
mail(route: SmtpRoute, mail: Mail, headers?: Record<string, string>): Promise<void>;
|
|
1767
|
-
/** who is calling, when the manifest says how to tell */
|
|
1768
|
-
actor: string | undefined;
|
|
1769
1818
|
now(): unknown;
|
|
1770
1819
|
/** Render a stored row or vendor view using its resource declaration, retaining the stored subject id fallback. */
|
|
1771
1820
|
render(row: Record<string, unknown>, resource?: string): Record<string, unknown>;
|
|
@@ -1804,10 +1853,21 @@ export type SemanticsContext = {
|
|
|
1804
1853
|
/** Allocate a stored occurrence and append its fields under one action lock. The optional field factory is pure. */
|
|
1805
1854
|
create(resource: string, fields: Record<string, unknown> | ((id: string) => Record<string, unknown>), operation: string): Promise<Record<string, unknown>>;
|
|
1806
1855
|
/** Create under declared unique keys and the action lock; a collision returns the existing own-field view without a write. */
|
|
1807
|
-
createUnique(resource: string, fields: Record<string, unknown> | ((id: string) => Record<string, unknown>), operation: string): Promise<{ created: boolean; row: Record<string, unknown> }>;
|
|
1856
|
+
createUnique(resource: string, fields: Record<string, unknown> | ((id: string) => Record<string, unknown>), operation: string, options?: { input: 'omit' }): Promise<{ created: boolean; row: Record<string, unknown> }>;
|
|
1808
1857
|
/** Decide an update from the current live stored row under the action lock; undefined leaves it unchanged. */
|
|
1809
|
-
change(resource: string, id: string, decide: (row: Record<string, unknown>) => Record<string, unknown> | undefined, operation: string): Promise<Record<string, unknown> | undefined>;
|
|
1858
|
+
change(resource: string, id: string, decide: (row: Record<string, unknown>) => Record<string, unknown> | undefined, operation: string, options?: { input: 'omit' }): Promise<Record<string, unknown> | undefined>;
|
|
1810
1859
|
write(resource: string, id: string, fields: Record<string, unknown>, operation: string): Promise<Record<string, unknown>>;
|
|
1860
|
+
/**
|
|
1861
|
+
* One command's writes as one group (S2 R17; docs/contributing/architecture.md, "A command's writes as one group").
|
|
1862
|
+
* Inside `fn`, `write` stages its write and answers the row as it will stand; `row` and `rowsRaw` read the tree with
|
|
1863
|
+
* the staged writes folded over it, as it will be once they land. When `fn` returns or throws, every staged write
|
|
1864
|
+
* lands, in order, as one append; then its value is answered or its error thrown. Every other way to write, or to
|
|
1865
|
+
* wait on a write, lands the staged writes first; any other read of the tree (`get`, `rows`, `tree`, `history`, ...)
|
|
1866
|
+
* while writes are staged is refused, never answered without them. Each write keeps its own action, id, ordinal,
|
|
1867
|
+
* moment and recorded request. Where the pack has write hooks, events, sockets or counts, a state observer is on,
|
|
1868
|
+
* a head performs, or ids are aliased, `fn` simply runs, each write landing as it is made.
|
|
1869
|
+
*/
|
|
1870
|
+
batch<T>(fn: () => Promise<T>): Promise<T>;
|
|
1811
1871
|
/** Add numeric deltas to an existing bookkeeping row under the kernel's atomic write seam. */
|
|
1812
1872
|
accumulate(resource: string, id: string, deltas: Record<string, number>, operation: string): Promise<void>;
|
|
1813
1873
|
/** `write`, with what a live vendor answered when the head performed it */
|
|
@@ -1894,7 +1954,16 @@ export type SemanticsContext = {
|
|
|
1894
1954
|
parts(): Promise<MultipartPart[]>;
|
|
1895
1955
|
/** The pack's resource blobs (an object's bytes), by key, on the World's branch: they branch with the World, and a
|
|
1896
1956
|
* read falls back to an ancestor's. The row that names a blob is the pack's; the bytes are here. */
|
|
1897
|
-
blobs: {
|
|
1957
|
+
blobs: {
|
|
1958
|
+
put(key: string, bytes: Uint8Array): Promise<void>; get(key: string): Promise<Uint8Array | null>; remove(key: string): Promise<void>;
|
|
1959
|
+
/** a body written as it arrives (a request's own stream), kept only when `accept` holds for its size and SHA-256:
|
|
1960
|
+
* an upload of a large object never held whole. True when kept */
|
|
1961
|
+
putStream(key: string, body: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>, accept: (written: { size: number; sha256: string }) => boolean): Promise<boolean>;
|
|
1962
|
+
/** a blob's length, or null when there is none: a large object is answered without loading it */
|
|
1963
|
+
size(key: string): Promise<number | null>;
|
|
1964
|
+
/** the bytes `start`..`endInclusive` of a blob (a ranged read where the store has one), or null when there is none */
|
|
1965
|
+
range(key: string, start: number, endInclusive: number): Promise<Uint8Array | null>;
|
|
1966
|
+
};
|
|
1898
1967
|
/** A body's fields checked against the operation's declared ones under the manifest's `body.strict`: the fields, or
|
|
1899
1968
|
* the vendor's refusal of the first that is unknown, of another type or out of its bounds. */
|
|
1900
1969
|
fields(body: Record<string, unknown>): { fields: Record<string, unknown> } | { refused: Response };
|
|
@@ -1917,11 +1986,17 @@ export type HandlerContext = Omit<SemanticsContext, 'now' | 'core' | 'atomically
|
|
|
1917
1986
|
};
|
|
1918
1987
|
/** What a write hook reads: the writing call's context without its ways to write, so a hook that renders an event
|
|
1919
1988
|
* cannot write again (and run itself again). */
|
|
1920
|
-
export type WriteHookContext = Omit<HandlerContext, 'write' | 'create' | 'createUnique' | 'change' | 'issue' | 'record' | 'accumulate' | 'at' | 'over'>;
|
|
1989
|
+
export type WriteHookContext = Omit<HandlerContext, 'write' | 'batch' | 'create' | 'createUnique' | 'change' | 'issue' | 'record' | 'accumulate' | 'at' | 'over'>;
|
|
1921
1990
|
/** A derived pack's handler: one operation, by its operationId, over the contract's context. */
|
|
1922
1991
|
export type Handler = (ctx: HandlerContext) => Promise<Response>;
|
|
1923
1992
|
|
|
1924
1993
|
/** The context the kernel opens for a call (a handler's, a door's, a screen's). Kernel-internal: packs are given it. */
|
|
1994
|
+
/** A media type whose body the core reads for a context (and records with a write): JSON, a form, multipart, text,
|
|
1995
|
+
* or none named. */
|
|
1996
|
+
const coreReadsBody = (type: string | null): boolean => {
|
|
1997
|
+
const media = (type ?? '').split(';')[0]!.trim().toLowerCase();
|
|
1998
|
+
return media === '' || media === 'application/json' || media.endsWith('+json') || media === 'application/x-www-form-urlencoded' || media.startsWith('multipart/') || media.startsWith('text/');
|
|
1999
|
+
};
|
|
1925
2000
|
export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: CoreScope): Promise<SemanticsContext> {
|
|
1926
2001
|
const root = scope.root;
|
|
1927
2002
|
const asAsked = call;
|
|
@@ -1931,8 +2006,10 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
1931
2006
|
// the guard's parse when it made one (taken once), else this context's own
|
|
1932
2007
|
// a body that is its payload's bytes (S3's PutObject) is never read as JSON, whatever its label
|
|
1933
2008
|
const payload = bytesPayload(call.operation);
|
|
1934
|
-
|
|
1935
|
-
const
|
|
2009
|
+
// a screen's body the core does not read (an upload's bytes) is left to its handler, unread (DerivedCall.streamed)
|
|
2010
|
+
const opaque = call.streamed === true && !coreReadsBody(call.request.headers.get('content-type'));
|
|
2011
|
+
const parsed = payload || opaque ? undefined : takeParsedBody(call.request);
|
|
2012
|
+
const text = opaque ? '' : await bodyTextOf(call.request.clone()).catch(() => '');
|
|
1936
2013
|
let body: unknown = parsed?.value;
|
|
1937
2014
|
if (!parsed) { if (payload) body = text || undefined; else { try { body = text ? parseExactJson(text) : undefined; } catch { body = text; } } }
|
|
1938
2015
|
// the request as it came, before the core's parameter merge and alias adoption: what a write records, so a perform
|
|
@@ -1963,7 +2040,7 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
1963
2040
|
rawResources.set(row as Record<string, unknown>, name ?? resource);
|
|
1964
2041
|
return row as Record<string, unknown>;
|
|
1965
2042
|
};
|
|
1966
|
-
|
|
2043
|
+
const context: SemanticsContext = {
|
|
1967
2044
|
call,
|
|
1968
2045
|
params,
|
|
1969
2046
|
id: Object.values(call.params).at(-1),
|
|
@@ -1981,7 +2058,6 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
1981
2058
|
return { store: new GitObjectStore(getActiveBlobStore(), `${plane}/repos/${name}`), refs: new GitRefs(getActiveWorldStore(), `${plane}/repos/${name}`) };
|
|
1982
2059
|
},
|
|
1983
2060
|
mail: (route, mail, headers) => sendMail(route, mail, headers),
|
|
1984
|
-
actor: m.identity ? m.identity(call.request.headers.get('authorization'), root) : undefined,
|
|
1985
2061
|
now: () => now(m, at),
|
|
1986
2062
|
render: (row, resource) => {
|
|
1987
2063
|
const raw = rawResources.has(row);
|
|
@@ -2018,10 +2094,15 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
2018
2094
|
secret: async (label) => hmac(await worldSeed(m.service, root, at), label, 'base64url'),
|
|
2019
2095
|
record: async (type, fields, id, operation) => {
|
|
2020
2096
|
if (!type.startsWith('_')) throw new Error(`semantics: ${type} is not a bookkeeping type (bookkeeping types start with _)`);
|
|
2097
|
+
// a bookkeeping subject a resource counts (`counts` naming it: sentry's issue users) keeps that count as any
|
|
2098
|
+
// child does, the parent it left recounted too
|
|
2099
|
+
const keeps = counted(m, type);
|
|
2021
2100
|
if (id !== undefined) {
|
|
2022
2101
|
const before = operation ? stateBeforeWrite(m, type, id, root) : undefined;
|
|
2023
|
-
const
|
|
2102
|
+
const prior = keeps ? resourcesOfType(m.service, type, root).find((r) => r.id === id) : undefined;
|
|
2103
|
+
const { resource: row, result } = await applyTwinWrite(m.service, { operation: operation ?? `${type.slice(1)}.record`, subjectType: type, subjectId: id, fields, ...provisioningInput(call), occurredAt: at, actor: { kind: 'system' } }, root);
|
|
2024
2104
|
if (operation) observeStateWrite(m, result, before, root, stateChecks);
|
|
2105
|
+
if (keeps) await keepCounts(m, call, type, row as unknown as Record<string, unknown>, params, root, at, prior && { ...prior } as Record<string, unknown>);
|
|
2025
2106
|
return id;
|
|
2026
2107
|
}
|
|
2027
2108
|
const { value, result } = await applyTwinWriteAtomic(m.service, (resources) => {
|
|
@@ -2039,6 +2120,8 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
2039
2120
|
return { kind: 'write', value: subject, write: { operation: operation ?? `${type.slice(1)}.record`, subjectType: type, subjectId: subject, fields, ...provisioningInput(call), occurredAt: at, actor: { kind: 'system' } } };
|
|
2040
2121
|
}, root, () => resourcesOfType(m.service, type, root));
|
|
2041
2122
|
if (operation && result) observeStateWrite(m, result, undefined, root, stateChecks);
|
|
2123
|
+
const made = keeps && value !== undefined ? resourcesOfType(m.service, type, root).find((r) => r.id === value) : undefined;
|
|
2124
|
+
if (made) await keepCounts(m, call, type, made as unknown as Record<string, unknown>, params, root, at);
|
|
2042
2125
|
return value;
|
|
2043
2126
|
},
|
|
2044
2127
|
get machines() {
|
|
@@ -2051,7 +2134,8 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
2051
2134
|
const id = mintId(m, resource, root, at, snapshot);
|
|
2052
2135
|
return { id, fields: typeof fields === 'function' ? fields(id) : fields };
|
|
2053
2136
|
}, operation, params, root, at, undefined, stateChecks).then((row) => row!),
|
|
2054
|
-
createUnique: async (resource, fields, operation) => {
|
|
2137
|
+
createUnique: async (resource, fields, operation, options) => {
|
|
2138
|
+
if (options?.input === 'omit' && !resource.startsWith('_')) throw new Error('only bookkeeping writes may omit request input');
|
|
2055
2139
|
const rules = m.resources[resource]?.unique;
|
|
2056
2140
|
if (!rules?.length) throw new Error('createUnique requires declared unique keys');
|
|
2057
2141
|
let existing: Record<string, unknown> | undefined;
|
|
@@ -2061,15 +2145,18 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
2061
2145
|
const hit = snapshot.find((r) => r.type === storedType(m, resource) && r.deleted !== true && rules.some((rule) => rule.fields.every((f) => selected[f] !== undefined && view(m, resource, r)[f] === selected[f])));
|
|
2062
2146
|
if (hit) { existing = view(m, resource, hit); return undefined; }
|
|
2063
2147
|
return { id, fields: selected };
|
|
2064
|
-
}, operation, params, root, at, undefined, stateChecks);
|
|
2148
|
+
}, operation, params, root, at, undefined, stateChecks, options?.input === 'omit');
|
|
2065
2149
|
return existing ? { created: false, row: existing } : { created: true, row: row! };
|
|
2066
2150
|
},
|
|
2067
|
-
change: (resource, id, decide, operation
|
|
2151
|
+
change: (resource, id, decide, operation, options) => {
|
|
2152
|
+
if (options?.input === 'omit' && !resource.startsWith('_')) throw new Error('only bookkeeping writes may omit request input');
|
|
2153
|
+
return atomicWrite(m, call, resource, (snapshot) => {
|
|
2068
2154
|
const row = snapshot.find((r) => r.type === storedType(m, resource) && r.id === id && r.deleted !== true);
|
|
2069
2155
|
if (!row) return undefined;
|
|
2070
2156
|
const fields = decide(row as Record<string, unknown>);
|
|
2071
2157
|
return fields === undefined ? undefined : { id, fields };
|
|
2072
|
-
}, operation, params, root, at, id, stateChecks)
|
|
2158
|
+
}, operation, params, root, at, id, stateChecks, options?.input === 'omit');
|
|
2159
|
+
},
|
|
2073
2160
|
write: (resource, id, fields, operation) => write(m, call, resource, id, fields, operation, params, root, at, stateChecks),
|
|
2074
2161
|
accumulate: async (resource, id, deltas, operation) => {
|
|
2075
2162
|
if (!resource.startsWith('_') || !m.resources[resource]) throw new Error('accumulate requires a declared bookkeeping resource');
|
|
@@ -2151,8 +2238,11 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
2151
2238
|
live: liveAccess({ rows: (type) => resourcesOfType(m.service, type, root) as unknown as Array<Record<string, unknown>>, blob: (key) => readResourceBlob(m.service, key, root) }, call.request),
|
|
2152
2239
|
blobs: {
|
|
2153
2240
|
put: (key, bytes) => writeResourceBlob(m.service, key, bytes, root),
|
|
2241
|
+
putStream: (key, body, accept) => writeResourceBlobStream(m.service, key, body, accept, root),
|
|
2154
2242
|
get: (key) => readResourceBlob(m.service, key, root),
|
|
2155
2243
|
remove: (key) => removeResourceBlob(m.service, key, root),
|
|
2244
|
+
size: (key) => resourceBlobSize(m.service, key, root),
|
|
2245
|
+
range: (key, start, endInclusive) => readResourceBlobRange(m.service, key, start, endInclusive, root),
|
|
2156
2246
|
},
|
|
2157
2247
|
merge: (resource, prior, next) => mergeField(m.resources[resource]?.update, prior, next),
|
|
2158
2248
|
conflict: (resource, fields, except) => {
|
|
@@ -2172,13 +2262,218 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
2172
2262
|
over: (other) => contextFor(other, { ...asAsked, request: asAsked.request.clone() }, { ...scope, clock: () => at }),
|
|
2173
2263
|
core: async () => { const out = await serveCore(m, { ...call, request: pristine.clone() }, { ...scope, clock: () => at }); return 'served' in out ? out.served : undefined; },
|
|
2174
2264
|
own: (row) => ownFields(row as unknown as TwinResource),
|
|
2265
|
+
batch: (fn) => fn(),
|
|
2266
|
+
};
|
|
2267
|
+
if (batchable(m, root)) bindBatch(m, call, context, params, root, at, rawRow);
|
|
2268
|
+
return context;
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
/**
|
|
2272
|
+
* Whether a call's writes may be grouped (`ctx.batch`) with nothing about them changing: every effect a write has
|
|
2273
|
+
* besides its action and the tree (write hooks, events, sockets, a parent's counts, a state observer, a head that
|
|
2274
|
+
* performs it, an alias a read resolves through) is one the grouped path would move, so where any is present the
|
|
2275
|
+
* writes land one at a time, as they always have.
|
|
2276
|
+
*/
|
|
2277
|
+
function batchable(m: DerivedManifest, root: string | undefined): boolean {
|
|
2278
|
+
return !m.onWrite && !m.events && !m.sockets?.length && !transitionObserver
|
|
2279
|
+
&& !Object.values(m.resources).some((decl) => decl.counts && Object.keys(decl.counts).length > 0)
|
|
2280
|
+
&& !performsAtHeadNow(m.service, root) && subjectAliases(m.service, root).size === 0;
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
/** A staged write of `ctx.batch`, as `write` was asked it. */
|
|
2284
|
+
type StagedWrite = { resource: string; id: string; fields: Record<string, unknown>; operation: string };
|
|
2285
|
+
|
|
2286
|
+
/** The members of a context that read the tree other than `row`/`rowsRaw`: refused while writes are staged. */
|
|
2287
|
+
const BATCH_REFUSED_READS = ['get', 'rows', 'tree', 'history', 'find', 'list', 'expand', 'mint', 'conflict', 'ownerRow', 'render'] as const;
|
|
2288
|
+
/** The members that write, or wait on writes, or answer as another context: each lands the staged writes first. */
|
|
2289
|
+
const BATCH_LANDING_MEMBERS = ['create', 'createUnique', 'change', 'accumulate', 'writeDetailed', 'record', 'atomically', 'remove', 'issue', 'at', 'over', 'core', 'awaitWrite', 'awaitCondition',
|
|
2290
|
+
// a World's seed and signing keys are written when first asked (worldSeed, worldSigningKey, as a vendor's move)
|
|
2291
|
+
'secret', 'signingKey'] as const;
|
|
2292
|
+
|
|
2293
|
+
/** `ctx.batch` over a context whose writes may be grouped (see `batchable`). */
|
|
2294
|
+
function bindBatch(m: DerivedManifest, call: DerivedCall, context: SemanticsContext, params: Record<string, unknown>, root: string | undefined, at: string,
|
|
2295
|
+
rawRow: (resource: string, row: TwinResource) => Record<string, unknown>): void {
|
|
2296
|
+
let staged: StagedWrite[] | undefined;
|
|
2297
|
+
// each staged subject as it will stand, by `type:id`, in the order first written
|
|
2298
|
+
const subjects = new Map<string, Subject>();
|
|
2299
|
+
const land = async (): Promise<void> => {
|
|
2300
|
+
if (!staged?.length) return;
|
|
2301
|
+
const writes = staged;
|
|
2302
|
+
staged = [];
|
|
2303
|
+
subjects.clear();
|
|
2304
|
+
await writeGroup(m, call, writes, params, root, at);
|
|
2305
|
+
};
|
|
2306
|
+
const ctx = context as unknown as Record<string, unknown>;
|
|
2307
|
+
for (const name of BATCH_LANDING_MEMBERS) {
|
|
2308
|
+
const plain = ctx[name];
|
|
2309
|
+
if (typeof plain !== 'function') continue;
|
|
2310
|
+
ctx[name] = async (...args: unknown[]) => { await land(); return (plain as (...a: unknown[]) => unknown)(...args); };
|
|
2311
|
+
}
|
|
2312
|
+
for (const name of BATCH_REFUSED_READS) {
|
|
2313
|
+
const plain = ctx[name];
|
|
2314
|
+
if (typeof plain !== 'function') continue;
|
|
2315
|
+
ctx[name] = (...args: unknown[]) => {
|
|
2316
|
+
if (staged?.length) throw new Error(`ctx.${name} reads the tree while ctx.batch holds staged writes: only row and rowsRaw see them`);
|
|
2317
|
+
return (plain as (...a: unknown[]) => unknown)(...args);
|
|
2318
|
+
};
|
|
2319
|
+
}
|
|
2320
|
+
const plainWrite = context.write, plainRow = context.row, plainRowsRaw = context.rowsRaw;
|
|
2321
|
+
context.write = async (resource, id, fields, operation) => {
|
|
2322
|
+
// a vendor's own move (ctx.asVendor) is the clock's write, recorded as the system's: it lands alone, in its place
|
|
2323
|
+
if (!staged || inVendorMove()) { await land(); return plainWrite(resource, id, fields, operation); }
|
|
2324
|
+
const type = storedType(m, resource), key = `${type}:${id}`;
|
|
2325
|
+
// the one subject, as the memoized tree holds it (readTreeMap would copy the whole tree on every staged write)
|
|
2326
|
+
const subject = foldLocalWrite(subjects.get(key) ?? heldSubject(m.service, type, id, root), type, id, fields as SubjectFields, at);
|
|
2327
|
+
subjects.set(key, subject);
|
|
2328
|
+
staged.push({ resource, id, fields, operation });
|
|
2329
|
+
return view(m, resource, treeResources(new Map([[key, subject]]))[0]!);
|
|
2330
|
+
};
|
|
2331
|
+
// the stored rows of a resource's type with the staged subjects folded over them, filtered as `stored` filters
|
|
2332
|
+
const overlaid = (resource: string, opts?: { withDeleted?: boolean }): TwinResource[] | undefined => {
|
|
2333
|
+
if (!staged?.length) return undefined;
|
|
2334
|
+
const type = storedType(m, resource), prefix = `${type}:`;
|
|
2335
|
+
const mine = [...subjects].filter(([key]) => key.startsWith(prefix));
|
|
2336
|
+
if (!mine.length) return undefined;
|
|
2337
|
+
const replaced = new Map(mine.map(([key, subject]) => [key, treeResources(new Map([[key, subject]]))[0]!]));
|
|
2338
|
+
const base = resourcesOfType(m.service, type, root);
|
|
2339
|
+
// a subject the tree holds stays where it stands; one new to it goes to the end, in the order first written
|
|
2340
|
+
const rows = base.map((row) => replaced.get(`${prefix}${row.id}`) ?? row);
|
|
2341
|
+
const held = new Set(base.map((row) => `${prefix}${row.id}`));
|
|
2342
|
+
for (const [key, row] of replaced) if (!held.has(key)) rows.push(row);
|
|
2343
|
+
const when = Object.entries(m.resources[resource]?.readableWhen ?? {});
|
|
2344
|
+
return rows.filter((r) => (opts?.withDeleted || r.deleted !== true) && when.every(([k, v]) => (r as Record<string, unknown>)[k] === v));
|
|
2345
|
+
};
|
|
2346
|
+
context.row = (resource, id, opts) => {
|
|
2347
|
+
const rows = overlaid(resource, opts);
|
|
2348
|
+
if (!rows) return plainRow(resource, id, opts);
|
|
2349
|
+
const row = rows.find((r) => r.id === id);
|
|
2350
|
+
return row ? rawRow(resource, row) : undefined;
|
|
2351
|
+
};
|
|
2352
|
+
context.rowsRaw = (resource, opts) => {
|
|
2353
|
+
const rows = overlaid(resource, opts);
|
|
2354
|
+
return rows ? rows.map((row) => rawRow(resource, row)) : plainRowsRaw(resource, opts);
|
|
2355
|
+
};
|
|
2356
|
+
context.batch = async (fn) => {
|
|
2357
|
+
// a batch inside a batch is the outer one's
|
|
2358
|
+
if (staged !== undefined) return fn();
|
|
2359
|
+
staged = [];
|
|
2360
|
+
try { return await fn(); }
|
|
2361
|
+
finally {
|
|
2362
|
+
// every write `write` answered lands, whether fn returned or threw: an ordered insert's prefix stays written
|
|
2363
|
+
try { await land(); } finally { staged = undefined; subjects.clear(); }
|
|
2364
|
+
}
|
|
2175
2365
|
};
|
|
2176
2366
|
}
|
|
2177
2367
|
|
|
2368
|
+
/**
|
|
2369
|
+
* A command's staged writes landed as one group: each recorded with the request as `writeDetailed` records it (one
|
|
2370
|
+
* recordedInput, the same for every write of the call), the call's moment and actor, in order, under one lock and one
|
|
2371
|
+
* append (serve.ts applyTwinWrites); then a held read on each written type is told, as each write tells it.
|
|
2372
|
+
*/
|
|
2373
|
+
async function writeGroup(m: DerivedManifest, call: DerivedCall, writes: readonly StagedWrite[], params: Record<string, unknown>,
|
|
2374
|
+
root: string | undefined, occurredAt: string): Promise<void> {
|
|
2375
|
+
const input = recordedInput(m, call, params);
|
|
2376
|
+
const files = input.operationId ? await keptFiles(m, call, root) : undefined;
|
|
2377
|
+
const recorded = files ? { ...input, files } : input;
|
|
2378
|
+
const actor = { kind: 'agent' as const, ...(m.identity ? { id: m.identity(call.request.headers.get('authorization'), root) } : {}) };
|
|
2379
|
+
await applyTwinWrites(m.service, writes.map((write) => ({ operation: write.operation, subjectType: storedType(m, write.resource),
|
|
2380
|
+
subjectId: write.id, fields: write.fields, input: recorded, occurredAt, actor })), root);
|
|
2381
|
+
for (const type of new Set(writes.map((write) => storedType(m, write.resource)))) heldReadsWrite(m.service, root, type);
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2178
2384
|
/** A pack's semantics handlers as the dispatch's handlers. */
|
|
2179
2385
|
export function bindSemantics(m: DerivedManifest, handlers: Record<string, Semantics>, scope: CoreScope = {}): Record<string, DerivedHandler> {
|
|
2180
2386
|
prepareViewOmissions(m);
|
|
2181
|
-
return Object.fromEntries(Object.entries(handlers).map(([id, h]) => [id, async (call: DerivedCall) =>
|
|
2387
|
+
return Object.fromEntries(Object.entries(handlers).map(([id, h]) => [id, async (call: DerivedCall) => {
|
|
2388
|
+
const ctx = await contextFor(m, call, scope);
|
|
2389
|
+
redisServiceTimes.lap('6c the handler\'s own context opened');
|
|
2390
|
+
// an operation's own variants, else the ones every operation shares (`'*'`)
|
|
2391
|
+
const variants = m.variants?.[id] ?? m.variants?.['*'];
|
|
2392
|
+
if (variants && variantAsked(ctx.params as Record<string, unknown>, variants, call.request.headers)) return variantGap(m, call.request);
|
|
2393
|
+
const outside = enumRefused(m, call, ctx.params as Record<string, unknown>);
|
|
2394
|
+
return outside ?? h(ctx);
|
|
2395
|
+
}]));
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
/** The first of a request's form parameters whose value is not one its spec lists (DerivedOperation.enums), with the
|
|
2399
|
+
* bracket name a form gives it (`spending_controls[spending_limits][0][interval]`) and the values it takes. */
|
|
2400
|
+
export function valueOutside(params: Record<string, unknown>, enums: Readonly<Record<string, ReadonlyArray<string>>>): { param: string; values: ReadonlyArray<string> } | undefined {
|
|
2401
|
+
const at = (value: unknown, path: string[], name: string, values: ReadonlyArray<string>): { param: string; values: ReadonlyArray<string> } | undefined => {
|
|
2402
|
+
if (value === undefined || value === null) return undefined;
|
|
2403
|
+
if (!path.length) return typeof value === 'object' || values.includes(String(value)) ? undefined : { param: name, values };
|
|
2404
|
+
const [head, ...rest] = path;
|
|
2405
|
+
if (head === '[]') {
|
|
2406
|
+
const items = Array.isArray(value) ? value.map((v, i) => [String(i), v] as const) : value && typeof value === 'object' ? Object.entries(value) : [];
|
|
2407
|
+
for (const [i, v] of items) { const found = at(v, rest, `${name}[${i}]`, values); if (found) return found; }
|
|
2408
|
+
return undefined;
|
|
2409
|
+
}
|
|
2410
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? at((value as Record<string, unknown>)[head!], rest, `${name}[${head}]`, values) : undefined;
|
|
2411
|
+
};
|
|
2412
|
+
for (const [path, values] of Object.entries(enums)) {
|
|
2413
|
+
const [first, ...rest] = path.split('.');
|
|
2414
|
+
const found = at(params[first!], rest, first!, values);
|
|
2415
|
+
if (found) return found;
|
|
2416
|
+
}
|
|
2417
|
+
return undefined;
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
/** The manifest's refusal of a form value outside the operation's spec lists (and the values documented beside
|
|
2421
|
+
* them), unless the request names an API version other than the one the spec is. */
|
|
2422
|
+
function enumRefused(m: DerivedManifest, call: DerivedCall, params: Record<string, unknown>): Response | undefined {
|
|
2423
|
+
const refusal = m.body.form?.enumRefusal;
|
|
2424
|
+
if (!refusal || !call.operation.enums) return undefined;
|
|
2425
|
+
const pinned = refusal.version ? call.request.headers.get(refusal.version.header) : null;
|
|
2426
|
+
if (pinned && pinned !== refusal.version!.served) return undefined;
|
|
2427
|
+
const documented = refusal.documented?.[call.operation.id] ?? {};
|
|
2428
|
+
const enums = Object.fromEntries(Object.entries(call.operation.enums).map(([path, values]) => [path, [...values, ...(documented[path] ?? [])]]));
|
|
2429
|
+
const outside = valueOutside(params, enums);
|
|
2430
|
+
if (!outside) return undefined;
|
|
2431
|
+
const { documented: _d, version: _v, ...error } = refusal;
|
|
2432
|
+
return vendorError(m, { ...error, param: outside.param, message: error.message.replace(/\{param\}/g, outside.param).replace(/\{values\}/g, oneOfList(outside.values)) });
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
/** A list of values as a sentence names them: `a, b, or c`. */
|
|
2436
|
+
const oneOfList = (values: ReadonlyArray<string>): string => (values.length < 2 ? values.join('') : `${values.slice(0, -1).join(', ')}, or ${values[values.length - 1]}`);
|
|
2437
|
+
|
|
2438
|
+
/** Whether a request's parameters ask one of the operation's undemanded variants (DerivedManifest.variants). */
|
|
2439
|
+
export function variantAsked(params: Record<string, unknown>, variants: readonly string[], headers?: Headers): boolean {
|
|
2440
|
+
const valuesAt = (value: unknown, path: string[]): unknown[] => {
|
|
2441
|
+
if (!path.length) return value === undefined ? [] : [value];
|
|
2442
|
+
const [head, ...rest] = path;
|
|
2443
|
+
// `!name` is the field left out: the value itself where it has none (`items[].!id`, an item naming no id)
|
|
2444
|
+
if (head!.startsWith('!')) return value && typeof value === 'object' && (value as Record<string, unknown>)[head!.slice(1)] === undefined ? [value] : [];
|
|
2445
|
+
const list = head!.endsWith('[]');
|
|
2446
|
+
const key = list ? head!.slice(0, -2) : head!;
|
|
2447
|
+
const next = value && typeof value === 'object' ? (value as Record<string, unknown>)[key] : undefined;
|
|
2448
|
+
if (!list) return valuesAt(next, rest);
|
|
2449
|
+
const items = Array.isArray(next) ? next : next && typeof next === 'object' ? Object.values(next) : [];
|
|
2450
|
+
return items.flatMap((item) => valuesAt(item, rest));
|
|
2451
|
+
};
|
|
2452
|
+
// a variant joined with `&` is its parts asked together (`trial_end=now&items`): the combination, not either alone;
|
|
2453
|
+
// a part led by `!` is a parameter left out (`items[].price&!items[].id`, an item added rather than changed)
|
|
2454
|
+
return variants.some((variant) => variant.split('&').every((part) => {
|
|
2455
|
+
const absent = part.startsWith('!');
|
|
2456
|
+
// `name~=value` is one entry of a comma-separated value (`tweet.fields~=geo`, X's fields lists); `name=value` the whole
|
|
2457
|
+
const body = absent ? part.slice(1) : part;
|
|
2458
|
+
const listed = body.includes('~=');
|
|
2459
|
+
const [path, wanted] = listed ? body.split('~=') : body.split('=');
|
|
2460
|
+
// a part led by `@` names a request header (`@stripe-account&application_fee_amount`, a charge made on a connected
|
|
2461
|
+
// account with the platform's fee)
|
|
2462
|
+
const header = path!.startsWith('@') ? headers?.get(path!.slice(1)) : undefined;
|
|
2463
|
+
// a parameter whose own name holds a dot (X's `tweet.fields`) is read by that name before the path is walked
|
|
2464
|
+
const found = path!.startsWith('@') ? (header === null || header === undefined ? [] : [header])
|
|
2465
|
+
: Object.hasOwn(params, path!) ? (params[path!] === undefined ? [] : [params[path!]]) : valuesAt(params, path!.split('.'));
|
|
2466
|
+
const asked = wanted === undefined ? found.length > 0
|
|
2467
|
+
: found.some((value) => (listed ? String(value).split(',').map((entry) => entry.trim()).includes(wanted) : String(value) === wanted));
|
|
2468
|
+
return absent ? !asked : asked;
|
|
2469
|
+
}));
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
/** The manifest's gap, as a variant is answered: the unknown-request refusal (else its not-found). */
|
|
2473
|
+
function variantGap(m: DerivedManifest, request: Request): Response {
|
|
2474
|
+
const nf = m.notFound;
|
|
2475
|
+
const gap: ErrorSpec = m.gap ?? { status: nf.status, message: nf.message, ...(nf.code !== undefined ? { code: nf.code } : {}), ...(nf.kind !== undefined ? { kind: nf.kind } : {}) };
|
|
2476
|
+
return vendorError(m, { ...gap, message: gap.message.replace(/\{method\}/g, request.method).replace(/\{path\}/g, new URL(request.url).pathname) });
|
|
2182
2477
|
}
|
|
2183
2478
|
|
|
2184
2479
|
/** The derived core as the dispatch's core: it owns every operation on a resource the manifest declares. */
|