@volter/world-core 3.0.156 → 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 +77 -8
- 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/blob-store.d.ts +16 -0
- package/dist/src/blob-store.js +76 -1
- package/dist/src/derived-core.d.ts +72 -6
- package/dist/src/derived-core.js +192 -51
- package/dist/src/derived-real.js +97 -15
- package/dist/src/derived.d.ts +5 -0
- package/dist/src/executor.d.ts +7 -0
- package/dist/src/executor.js +15 -1
- package/dist/src/git/lfs.d.ts +13 -3
- package/dist/src/git/lfs.js +41 -9
- package/dist/src/head.d.ts +2 -0
- package/dist/src/head.js +6 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- 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/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/mongodb/stream.d.ts +23 -0
- package/dist/src/pack-fetch.d.ts +6 -1
- package/dist/src/pack-fetch.js +49 -6
- package/dist/src/pack-paths.d.ts +2 -1
- package/dist/src/pack-paths.js +6 -1
- package/dist/src/resource-blob.d.ts +4 -0
- package/dist/src/resource-blob.js +8 -1
- package/dist/src/runtime.d.ts +3 -3
- package/dist/src/runtime.js +2 -2
- package/dist/src/serve-http.js +53 -3
- package/dist/src/sockets.d.ts +4 -1
- package/dist/src/sockets.js +59 -6
- package/dist/src/vendor-call.js +58 -0
- package/dist/src/world-store.d.ts +15 -1
- package/dist/src/world-store.js +78 -2
- package/generated/pack-facts.json +77 -8
- package/network-policy.cjs +11 -1
- package/network-policy.d.cts +1 -0
- package/pack-facts.d.cts +3 -0
- package/package.json +8 -1
- package/required-credential-transport.cjs +29 -0
- package/required-credential-transport.d.cts +3 -0
- package/src/blob-store.ts +59 -1
- package/src/derived-core.ts +215 -51
- package/src/derived-real.ts +80 -14
- package/src/derived.ts +6 -1
- package/src/executor.ts +17 -3
- package/src/git/lfs.ts +40 -9
- package/src/head.ts +6 -0
- package/src/index.ts +1 -0
- package/src/live/ingest.ts +7 -3
- package/src/live/rtmp.ts +7 -5
- package/src/live/segmenter.ts +7 -1
- package/src/machines.ts +5 -1
- package/src/managed-go-programs.ts +146 -0
- package/src/mongodb/stream.ts +10 -0
- package/src/pack-fetch.ts +46 -8
- package/src/pack-paths.ts +5 -1
- package/src/resource-blob.ts +9 -1
- package/src/runtime.ts +4 -2
- package/src/serve-http.ts +43 -3
- package/src/sockets.ts +39 -8
- package/src/vendor-call.ts +54 -0
- package/src/world-store.ts +66 -2
package/src/derived-core.ts
CHANGED
|
@@ -25,7 +25,7 @@ import { askApplication, deliverEvents, type ApplicationAnswer, type Application
|
|
|
25
25
|
import type { RateBudgetDeclaration } from './rateBudget.ts';
|
|
26
26
|
import { handlerCrypto, hmac, ksuidFrom, lettersFrom, sha256, uuidFrom, type HandlerCrypto } from './signing.ts';
|
|
27
27
|
import { hashFieldValue } from './hash.ts';
|
|
28
|
-
import { readResourceBlob, removeResourceBlob, writeResourceBlob } from './resource-blob.ts';
|
|
28
|
+
import { readResourceBlob, readResourceBlobRange, removeResourceBlob, resourceBlobSize, writeResourceBlob, writeResourceBlobStream } from './resource-blob.ts';
|
|
29
29
|
import { managedDatabase, unboundDatabase, type ManagedDatabase } from './managed-database.ts';
|
|
30
30
|
import { redisServiceTimes } from './redis/service-times.ts';
|
|
31
31
|
import { execRedisRun, type RedisDialect, type RunItem } from './redis/engine.ts';
|
|
@@ -84,6 +84,8 @@ export type Transition = {
|
|
|
84
84
|
export type StateField = {
|
|
85
85
|
initial: string | boolean;
|
|
86
86
|
transitions: Transition[];
|
|
87
|
+
/** Plain updates may repeat the current representation without another state move. */
|
|
88
|
+
unchanged?: 'allow';
|
|
87
89
|
derive?: Array<{ state: string; when: { equals?: unknown; gt?: number; truthy?: boolean; absent?: boolean } }>;
|
|
88
90
|
vendorInitial?: string;
|
|
89
91
|
};
|
|
@@ -149,6 +151,8 @@ export type ResourceDecl = {
|
|
|
149
151
|
* fields when its stored id is not what the path names (a repository stored as `repo:7` with `owner`
|
|
150
152
|
* and `name`, addressed as `{owner}/{repo}`): each stored field against a template over the path. */
|
|
151
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;
|
|
152
156
|
/** private fields a child observed under its parent keeps from the parent's parameters (`_jurisdiction:
|
|
153
157
|
* '{jurisdiction}'`), where the child's own read names none and a later read under it needs one ("Refresh") */
|
|
154
158
|
keep?: Record<string, string> };
|
|
@@ -201,7 +205,9 @@ export type ResourceDecl = {
|
|
|
201
205
|
* holds this one's id (or vendor `parentKey`), only those with `present` set or `absent` unset when named and without
|
|
202
206
|
* any matching `unless` field, plus `plus` (a customer's
|
|
203
207
|
* `next_invoice_sequence` is one past its numbered invoices). `by` may be a dot path; exact field names take
|
|
204
|
-
* 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. */
|
|
205
211
|
counts?: Record<string, { of: string; by: string; parentKey?: string; present?: string; absent?: string; unless?: Record<string, unknown>; plus?: number }>;
|
|
206
212
|
/** how the vendor's state of this type is read back into a root (the real-system adapters, "Refresh"): the list that
|
|
207
213
|
* enumerates it (once per parent, under `parent`; `complete: false` when it is not the whole type; `items`, the answer's
|
|
@@ -217,7 +223,7 @@ export type ResourceDecl = {
|
|
|
217
223
|
* jurisdictions). A blob read by an `operation` (a GET of the unit's surface, filled from the subject's fields and
|
|
218
224
|
* its parent's parameters) keeps the bytes in the World's blobs, their key in `field`, and in `headersField` the
|
|
219
225
|
* answer's headers `keepHeaders` names (a name ending `*` a prefix: `x-amz-meta-*`), both fields private. */
|
|
220
|
-
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 };
|
|
221
227
|
/** the other resources one create of this one makes, and where its answer names each's id (a JSON path) */
|
|
222
228
|
companions?: Record<string, string>;
|
|
223
229
|
/** the field telling a newer copy of a subject from an older (an `updated_at`): an ingested event older than the
|
|
@@ -274,6 +280,11 @@ export type VendorManifest = { vendor: string; discovery?: DerivedManifest['disc
|
|
|
274
280
|
|
|
275
281
|
/** `code` as the vendor writes it: a string, or a number (Discord's JSON error codes are integers) */
|
|
276
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 } };
|
|
277
288
|
|
|
278
289
|
/** A vendor screen a World serves (docs/contributing/architecture.md, "Screens"). A `flow` is a hosted
|
|
279
290
|
* page an application sends its user through, held to its documented round trip; a `workspace` is a
|
|
@@ -300,6 +311,11 @@ export type ScreenDecl = {
|
|
|
300
311
|
* takes, with its demand (an upload bucket a page posts a lease's form to: Reddit's S3 hosts) (architecture, "A page
|
|
301
312
|
* at a World's place") */
|
|
302
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>;
|
|
303
319
|
/** the vendor's documentation of the round trip or the screen */
|
|
304
320
|
source: string;
|
|
305
321
|
};
|
|
@@ -335,13 +351,20 @@ export type DerivedManifest = {
|
|
|
335
351
|
/** `strict`: a vendor that refuses a body field its operation does not declare, a value of another type, or a number
|
|
336
352
|
* outside the spec's bounds (a NestJS validation pipe: Supabase's "property x should not exist"), with its messages;
|
|
337
353
|
* `{name}`, `{type}` (`a string`, `an integer`), `{min}` and `{max}` are filled. `ctx.fields` checks a body so. */
|
|
338
|
-
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'> } };
|
|
339
355
|
/** `acceptProvided`: a create that names its own `id` keeps it (a vendor that seeds by id). The template counts
|
|
340
356
|
* (`{prefix}_{n}`), or is `{uuid}` for a vendor whose ids are UUIDs: the next is derived from the resource and its
|
|
341
357
|
* count, so a World mints the same ids every run */
|
|
342
358
|
ids: { template: string; acceptProvided?: boolean };
|
|
343
359
|
/** operations the derived core must not claim though their resource is declared: a gap until someone models them */
|
|
344
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[]>;
|
|
345
368
|
/** operations that only read though nothing in the spec says so (a POST that returns data):
|
|
346
369
|
* a read-only twin answers them */
|
|
347
370
|
reads?: string[];
|
|
@@ -370,6 +393,9 @@ export type DerivedManifest = {
|
|
|
370
393
|
readOnly: ErrorSpec;
|
|
371
394
|
/** what a body labelled JSON that does not parse answers; absent, a 400 in the vendor's error body */
|
|
372
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;
|
|
373
399
|
/** a request header that selects the vendor's API version, and the answer to a malformed one
|
|
374
400
|
* (`{value}` in its message is the header's value) */
|
|
375
401
|
version?: { header: string; pattern: string; error: ErrorSpec };
|
|
@@ -475,7 +501,9 @@ export type DerivedManifest = {
|
|
|
475
501
|
};
|
|
476
502
|
/** The World's doors (architecture, "Doors, screens and the gap"): each a method and a path under `/_twin/` (with
|
|
477
503
|
* `{param}` segments), answered by the export of `semantics/doors.ts` its id names, over the contract's context. */
|
|
478
|
-
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 };
|
|
479
507
|
/** The vendor's GraphQL API beside its REST one (`semantics/graphql.ts` over `generated/graphql-sdl.gen.json`): the
|
|
480
508
|
* paths it is POSTed to, and the answer to a query selecting a field nothing models (`{field}`, `{type}`). */
|
|
481
509
|
graphql?: { paths: ReadonlyArray<string>; unmodeled: { message: string; type?: string; status?: number } };
|
|
@@ -568,12 +596,13 @@ export function strictFields(m: DerivedManifest, operation: DerivedOperation, bo
|
|
|
568
596
|
const field = declared.get(name);
|
|
569
597
|
if (!field) return refuse(strict.unknown, { name });
|
|
570
598
|
const { type } = field;
|
|
571
|
-
const fits = value === null
|
|
599
|
+
const fits = (value === null && !(type === 'array' && strict.arrays?.[name]))
|
|
572
600
|
|| (type === 'boolean' && typeof value === 'boolean')
|
|
573
601
|
|| (type === 'integer' && isExactIntegerValue(value))
|
|
574
602
|
|| (type === 'number' && (typeof value === 'number' || isExactIntegerValue(value)))
|
|
575
603
|
|| (type === 'string' && typeof value === 'string')
|
|
576
|
-
||
|
|
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);
|
|
577
606
|
if (!fits) return refuse(strict.type, { name, type: type === 'integer' ? 'an integer' : `a ${type}` });
|
|
578
607
|
if ((typeof value === 'number' || isExactIntegerValue(value)) && ((field.minimum !== undefined && compareExactNumbers(value, field.minimum) < 0) || (field.maximum !== undefined && compareExactNumbers(value, field.maximum) > 0))) {
|
|
579
608
|
return refuse(strict.range, { name, min: field.minimum ?? null, max: field.maximum ?? null });
|
|
@@ -779,12 +808,20 @@ function parentField(row: unknown, path: string): unknown {
|
|
|
779
808
|
return value;
|
|
780
809
|
}
|
|
781
810
|
|
|
782
|
-
function
|
|
783
|
-
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);
|
|
784
820
|
if (!parent) return undefined;
|
|
785
821
|
const id = parentValue(parent, call.params);
|
|
786
822
|
const where = Object.entries(parent.where ?? {}).map(([field, template]) => [field, String(fill(template, call.params))] as const);
|
|
787
|
-
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);
|
|
788
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));
|
|
789
826
|
}
|
|
790
827
|
|
|
@@ -1168,7 +1205,11 @@ function recordedInput(m: DerivedManifest, call: DerivedCall, merged: Record<str
|
|
|
1168
1205
|
const id = call.operation.id;
|
|
1169
1206
|
const sent = sentOf.get(call);
|
|
1170
1207
|
const activity = call.request.headers.get('x-volter-world-activity') === 'provisioning' ? { activity: 'provisioning' } : {};
|
|
1171
|
-
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 };
|
|
1172
1213
|
if (id === 'graphql') {
|
|
1173
1214
|
const b = (sent?.body ?? {}) as { query?: unknown; variables?: unknown; operationName?: unknown };
|
|
1174
1215
|
return { ...activity, graphql: { query: b.query, variables: b.variables ?? {}, ...(b.operationName ? { operationName: b.operationName } : {}) }, ...(sent?.lane ? { lane: sent.lane } : {}), headers: sent?.headers ?? {} };
|
|
@@ -1186,34 +1227,33 @@ const requestOfCall = new WeakMap<DerivedCall, Request>();
|
|
|
1186
1227
|
|
|
1187
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 }> {
|
|
1188
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;
|
|
1189
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; };
|
|
1190
1232
|
const { resource: row, result } = await applyTwinWrite(
|
|
1191
1233
|
m.service,
|
|
1192
|
-
// who made it: the login the pack's identity reads off the request, when it declares one, so a subject's
|
|
1193
|
-
// history can say who did each thing
|
|
1194
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
|
|
1195
1235
|
inVendorMove()
|
|
1196
1236
|
? { operation, subjectType: storedType(m, resource), subjectId: id, fields, input: { clock: true }, occurredAt, actor: { kind: 'system' } }
|
|
1197
|
-
: { 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' } },
|
|
1198
1238
|
root,
|
|
1199
1239
|
);
|
|
1200
1240
|
observeStateWrite(m, result, before, root, checks);
|
|
1201
|
-
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 });
|
|
1202
1242
|
}
|
|
1203
1243
|
|
|
1204
1244
|
async function atomicWrite(m: DerivedManifest, call: DerivedCall, resource: string,
|
|
1205
1245
|
decide: (snapshot: readonly TwinResource[]) => { id: string; fields: Record<string, unknown> } | undefined,
|
|
1206
|
-
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,
|
|
1207
1247
|
): Promise<Record<string, unknown> | undefined> {
|
|
1208
|
-
const input = recordedInput(m, call, params);
|
|
1209
|
-
const files = input
|
|
1248
|
+
const input = omitInput ? undefined : recordedInput(m, call, params);
|
|
1249
|
+
const files = input?.operationId ? await keptFiles(m, call, root) : undefined;
|
|
1210
1250
|
let before: TwinResource | undefined;
|
|
1211
1251
|
const committed = await applyTwinWriteAtomic(m.service, (snapshot) => {
|
|
1212
1252
|
const selected = decide(snapshot);
|
|
1213
|
-
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);
|
|
1214
1254
|
return selected ? { kind: 'write', value: selected.id, write: {
|
|
1215
1255
|
operation, subjectType: storedType(m, resource), subjectId: selected.id, fields: selected.fields,
|
|
1216
|
-
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) } : {}) },
|
|
1217
1257
|
} } : { kind: 'skip', value: undefined };
|
|
1218
1258
|
}, root);
|
|
1219
1259
|
if (!committed.result || !committed.value) {
|
|
@@ -1222,14 +1262,14 @@ async function atomicWrite(m: DerivedManifest, call: DerivedCall, resource: stri
|
|
|
1222
1262
|
}
|
|
1223
1263
|
observeStateWrite(m, committed.result, before, root, checks);
|
|
1224
1264
|
const row = resourcesOfType(m.service, storedType(m, resource), root).find((r) => r.id === resolveSubjectId(m.service, storedType(m, resource), committed.value!, root))!;
|
|
1225
|
-
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;
|
|
1226
1266
|
}
|
|
1227
1267
|
|
|
1228
1268
|
async function finishWrite(m: DerivedManifest, call: DerivedCall, resource: string, row: TwinResource,
|
|
1229
1269
|
result: { externalId?: string; vendorData?: unknown }, operation: string, params: Record<string, unknown>,
|
|
1230
|
-
root: string | undefined, occurredAt: string,
|
|
1270
|
+
root: string | undefined, occurredAt: string, prior?: Record<string, unknown>,
|
|
1231
1271
|
): Promise<{ body: Record<string, unknown>; id: string; vendorData?: unknown }> {
|
|
1232
|
-
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);
|
|
1233
1273
|
const body = view(m, resource, row);
|
|
1234
1274
|
// the write's own context for its hooks, in the scope the call was served in (a walk's application answers, a tenant,
|
|
1235
1275
|
// the managed database), at the write's moment
|
|
@@ -1250,24 +1290,30 @@ async function finishWrite(m: DerivedManifest, call: DerivedCall, resource: stri
|
|
|
1250
1290
|
|
|
1251
1291
|
/** The declared counts a written subject is a child in (a resource's `counts`), each kept on its parent: written when
|
|
1252
1292
|
* the count moved, through the write path, so the parent's history says when. */
|
|
1253
|
-
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> {
|
|
1254
1294
|
for (const [parent, decl] of Object.entries(m.resources)) {
|
|
1255
1295
|
for (const [field, c] of Object.entries(decl.counts ?? {})) {
|
|
1256
1296
|
if (storedType(m, c.of) !== storedType(m, resource)) continue;
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
const pid
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
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
|
+
}
|
|
1267
1308
|
}
|
|
1268
1309
|
}
|
|
1269
1310
|
}
|
|
1270
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
|
+
|
|
1271
1317
|
/** An answer in the vendor's success envelope, under the operation's key, when the vendor has one. */
|
|
1272
1318
|
function envelope(m: DerivedManifest, op: DerivedOperation, body: Record<string, unknown>): Record<string, unknown> {
|
|
1273
1319
|
return m.success && op.answers?.key ? { ...m.success, [op.answers.key]: body } : body;
|
|
@@ -1600,14 +1646,14 @@ export async function serveCore(m: DerivedManifest, call: DerivedCall, scope: Co
|
|
|
1600
1646
|
// the body is read from a copy, so the call's own request is still unread for the write hook's context
|
|
1601
1647
|
const params = await boundaryParams(m, { ...call, request: call.request.clone() }, root);
|
|
1602
1648
|
const idParam = subjectOf(m, resource, call);
|
|
1603
|
-
const
|
|
1604
|
-
if (orphan) return { served: orphan };
|
|
1605
|
-
const parent = decl.parent;
|
|
1649
|
+
const parent = parentOfCall(decl, call);
|
|
1606
1650
|
const parentId = parent ? parentValue(parent, call.params) : undefined;
|
|
1607
1651
|
// the caller's tenant, when the vendor has tenancy and the resource is not shared by every tenant
|
|
1608
1652
|
const tenantField = m.tenant && !decl.shared ? m.tenant.field : undefined;
|
|
1609
1653
|
const tenant = tenantField && scope.tenant ? await scope.tenant(await contextFor(m, { ...call, request: call.request.clone() }, scope)) : undefined;
|
|
1610
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 };
|
|
1611
1657
|
const underParent = (r: TwinResource): boolean => (!parent || (r as Record<string, unknown>)[parent.field] === parentId)
|
|
1612
1658
|
&& (!tenantField || (r as Record<string, unknown>)[tenantField] === tenant);
|
|
1613
1659
|
const paths = expandPaths(m, params);
|
|
@@ -1681,6 +1727,7 @@ export async function serveCore(m: DerivedManifest, call: DerivedCall, scope: Co
|
|
|
1681
1727
|
for (const [field, sdecl] of Object.entries(decl.state ?? {})) {
|
|
1682
1728
|
const requested = op.class === 'update' ? data[field] : undefined;
|
|
1683
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; }
|
|
1684
1731
|
const { move, refusal } = transitionFor(field, sdecl, op.id, current[field], requested, hit.id);
|
|
1685
1732
|
if (refusal) return { served: vendorError(m, refusal) };
|
|
1686
1733
|
if (!move) {
|
|
@@ -1768,8 +1815,6 @@ export type SemanticsContext = {
|
|
|
1768
1815
|
/** A mail the vendor sends a person, over SMTP to the route its settings name (smtp.ts), under the World's egress rule;
|
|
1769
1816
|
* throws when it cannot be delivered. */
|
|
1770
1817
|
mail(route: SmtpRoute, mail: Mail, headers?: Record<string, string>): Promise<void>;
|
|
1771
|
-
/** who is calling, when the manifest says how to tell */
|
|
1772
|
-
actor: string | undefined;
|
|
1773
1818
|
now(): unknown;
|
|
1774
1819
|
/** Render a stored row or vendor view using its resource declaration, retaining the stored subject id fallback. */
|
|
1775
1820
|
render(row: Record<string, unknown>, resource?: string): Record<string, unknown>;
|
|
@@ -1808,9 +1853,9 @@ export type SemanticsContext = {
|
|
|
1808
1853
|
/** Allocate a stored occurrence and append its fields under one action lock. The optional field factory is pure. */
|
|
1809
1854
|
create(resource: string, fields: Record<string, unknown> | ((id: string) => Record<string, unknown>), operation: string): Promise<Record<string, unknown>>;
|
|
1810
1855
|
/** Create under declared unique keys and the action lock; a collision returns the existing own-field view without a write. */
|
|
1811
|
-
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> }>;
|
|
1812
1857
|
/** Decide an update from the current live stored row under the action lock; undefined leaves it unchanged. */
|
|
1813
|
-
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>;
|
|
1814
1859
|
write(resource: string, id: string, fields: Record<string, unknown>, operation: string): Promise<Record<string, unknown>>;
|
|
1815
1860
|
/**
|
|
1816
1861
|
* One command's writes as one group (S2 R17; docs/contributing/architecture.md, "A command's writes as one group").
|
|
@@ -1909,7 +1954,16 @@ export type SemanticsContext = {
|
|
|
1909
1954
|
parts(): Promise<MultipartPart[]>;
|
|
1910
1955
|
/** The pack's resource blobs (an object's bytes), by key, on the World's branch: they branch with the World, and a
|
|
1911
1956
|
* read falls back to an ancestor's. The row that names a blob is the pack's; the bytes are here. */
|
|
1912
|
-
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
|
+
};
|
|
1913
1967
|
/** A body's fields checked against the operation's declared ones under the manifest's `body.strict`: the fields, or
|
|
1914
1968
|
* the vendor's refusal of the first that is unknown, of another type or out of its bounds. */
|
|
1915
1969
|
fields(body: Record<string, unknown>): { fields: Record<string, unknown> } | { refused: Response };
|
|
@@ -1937,6 +1991,12 @@ export type WriteHookContext = Omit<HandlerContext, 'write' | 'batch' | 'create'
|
|
|
1937
1991
|
export type Handler = (ctx: HandlerContext) => Promise<Response>;
|
|
1938
1992
|
|
|
1939
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
|
+
};
|
|
1940
2000
|
export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: CoreScope): Promise<SemanticsContext> {
|
|
1941
2001
|
const root = scope.root;
|
|
1942
2002
|
const asAsked = call;
|
|
@@ -1946,8 +2006,10 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
1946
2006
|
// the guard's parse when it made one (taken once), else this context's own
|
|
1947
2007
|
// a body that is its payload's bytes (S3's PutObject) is never read as JSON, whatever its label
|
|
1948
2008
|
const payload = bytesPayload(call.operation);
|
|
1949
|
-
|
|
1950
|
-
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(() => '');
|
|
1951
2013
|
let body: unknown = parsed?.value;
|
|
1952
2014
|
if (!parsed) { if (payload) body = text || undefined; else { try { body = text ? parseExactJson(text) : undefined; } catch { body = text; } } }
|
|
1953
2015
|
// the request as it came, before the core's parameter merge and alias adoption: what a write records, so a perform
|
|
@@ -1996,7 +2058,6 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
1996
2058
|
return { store: new GitObjectStore(getActiveBlobStore(), `${plane}/repos/${name}`), refs: new GitRefs(getActiveWorldStore(), `${plane}/repos/${name}`) };
|
|
1997
2059
|
},
|
|
1998
2060
|
mail: (route, mail, headers) => sendMail(route, mail, headers),
|
|
1999
|
-
actor: m.identity ? m.identity(call.request.headers.get('authorization'), root) : undefined,
|
|
2000
2061
|
now: () => now(m, at),
|
|
2001
2062
|
render: (row, resource) => {
|
|
2002
2063
|
const raw = rawResources.has(row);
|
|
@@ -2033,10 +2094,15 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
2033
2094
|
secret: async (label) => hmac(await worldSeed(m.service, root, at), label, 'base64url'),
|
|
2034
2095
|
record: async (type, fields, id, operation) => {
|
|
2035
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);
|
|
2036
2100
|
if (id !== undefined) {
|
|
2037
2101
|
const before = operation ? stateBeforeWrite(m, type, id, root) : undefined;
|
|
2038
|
-
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);
|
|
2039
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>);
|
|
2040
2106
|
return id;
|
|
2041
2107
|
}
|
|
2042
2108
|
const { value, result } = await applyTwinWriteAtomic(m.service, (resources) => {
|
|
@@ -2054,6 +2120,8 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
2054
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' } } };
|
|
2055
2121
|
}, root, () => resourcesOfType(m.service, type, root));
|
|
2056
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);
|
|
2057
2125
|
return value;
|
|
2058
2126
|
},
|
|
2059
2127
|
get machines() {
|
|
@@ -2066,7 +2134,8 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
2066
2134
|
const id = mintId(m, resource, root, at, snapshot);
|
|
2067
2135
|
return { id, fields: typeof fields === 'function' ? fields(id) : fields };
|
|
2068
2136
|
}, operation, params, root, at, undefined, stateChecks).then((row) => row!),
|
|
2069
|
-
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');
|
|
2070
2139
|
const rules = m.resources[resource]?.unique;
|
|
2071
2140
|
if (!rules?.length) throw new Error('createUnique requires declared unique keys');
|
|
2072
2141
|
let existing: Record<string, unknown> | undefined;
|
|
@@ -2076,15 +2145,18 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
2076
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])));
|
|
2077
2146
|
if (hit) { existing = view(m, resource, hit); return undefined; }
|
|
2078
2147
|
return { id, fields: selected };
|
|
2079
|
-
}, operation, params, root, at, undefined, stateChecks);
|
|
2148
|
+
}, operation, params, root, at, undefined, stateChecks, options?.input === 'omit');
|
|
2080
2149
|
return existing ? { created: false, row: existing } : { created: true, row: row! };
|
|
2081
2150
|
},
|
|
2082
|
-
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) => {
|
|
2083
2154
|
const row = snapshot.find((r) => r.type === storedType(m, resource) && r.id === id && r.deleted !== true);
|
|
2084
2155
|
if (!row) return undefined;
|
|
2085
2156
|
const fields = decide(row as Record<string, unknown>);
|
|
2086
2157
|
return fields === undefined ? undefined : { id, fields };
|
|
2087
|
-
}, operation, params, root, at, id, stateChecks)
|
|
2158
|
+
}, operation, params, root, at, id, stateChecks, options?.input === 'omit');
|
|
2159
|
+
},
|
|
2088
2160
|
write: (resource, id, fields, operation) => write(m, call, resource, id, fields, operation, params, root, at, stateChecks),
|
|
2089
2161
|
accumulate: async (resource, id, deltas, operation) => {
|
|
2090
2162
|
if (!resource.startsWith('_') || !m.resources[resource]) throw new Error('accumulate requires a declared bookkeeping resource');
|
|
@@ -2166,8 +2238,11 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
2166
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),
|
|
2167
2239
|
blobs: {
|
|
2168
2240
|
put: (key, bytes) => writeResourceBlob(m.service, key, bytes, root),
|
|
2241
|
+
putStream: (key, body, accept) => writeResourceBlobStream(m.service, key, body, accept, root),
|
|
2169
2242
|
get: (key) => readResourceBlob(m.service, key, root),
|
|
2170
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),
|
|
2171
2246
|
},
|
|
2172
2247
|
merge: (resource, prior, next) => mergeField(m.resources[resource]?.update, prior, next),
|
|
2173
2248
|
conflict: (resource, fields, except) => {
|
|
@@ -2309,7 +2384,96 @@ async function writeGroup(m: DerivedManifest, call: DerivedCall, writes: readonl
|
|
|
2309
2384
|
/** A pack's semantics handlers as the dispatch's handlers. */
|
|
2310
2385
|
export function bindSemantics(m: DerivedManifest, handlers: Record<string, Semantics>, scope: CoreScope = {}): Record<string, DerivedHandler> {
|
|
2311
2386
|
prepareViewOmissions(m);
|
|
2312
|
-
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) });
|
|
2313
2477
|
}
|
|
2314
2478
|
|
|
2315
2479
|
/** The derived core as the dispatch's core: it owns every operation on a resource the manifest declares. */
|