@volter/world-core 3.0.38 → 3.0.39
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/app-route.cjs +26 -25
- package/app-route.d.cts +1 -0
- package/dist/app-route.cjs +26 -25
- package/dist/app-route.d.cts +1 -0
- package/dist/generated/pack-facts.json +409 -352
- package/dist/inject.cjs +474 -95
- package/dist/network-policy.cjs +108 -13
- package/dist/network-policy.d.cts +8 -3
- package/dist/src/actions.js +2 -1
- package/dist/src/anthropic-wire.d.ts +2 -0
- package/dist/src/anthropic-wire.js +5 -0
- package/dist/src/attestation.d.ts +6 -0
- package/dist/src/attestation.js +27 -0
- package/dist/src/bytes.d.ts +3 -0
- package/dist/src/bytes.js +17 -0
- package/dist/src/changeset.js +2 -1
- package/dist/src/clickhouse/sql.js +73 -14
- package/dist/src/derived-core.d.ts +210 -32
- package/dist/src/derived-core.js +510 -244
- package/dist/src/derived-real.d.ts +1 -1
- package/dist/src/derived-real.js +704 -93
- package/dist/src/derived.d.ts +18 -2
- package/dist/src/derived.js +21 -2
- package/dist/src/events.d.ts +28 -45
- package/dist/src/events.js +40 -58
- package/dist/src/exact-json.d.ts +10 -0
- package/dist/src/exact-json.js +98 -0
- package/dist/src/executor.d.ts +17 -0
- package/dist/src/executor.js +91 -30
- package/dist/src/file-response.js +1 -1
- package/dist/src/form.d.ts +8 -0
- package/dist/src/form.js +76 -0
- package/dist/src/git/format.d.ts +16 -0
- package/dist/src/git/format.js +110 -0
- package/dist/src/git/index.d.ts +1 -0
- package/dist/src/git/index.js +1 -0
- package/dist/src/git/lfs.d.ts +1 -1
- package/dist/src/git/lfs.js +4 -3
- package/dist/src/graphql-wire.d.ts +6 -0
- package/dist/src/graphql-wire.js +6 -3
- package/dist/src/grpc-wire.js +2 -1
- package/dist/src/head.d.ts +1 -0
- package/dist/src/head.js +15 -4
- package/dist/src/held-reads.d.ts +6 -0
- package/dist/src/held-reads.js +60 -0
- package/dist/src/history.js +5 -4
- package/dist/src/host-port.d.ts +5 -0
- package/dist/src/host-port.js +14 -0
- package/dist/src/index.d.ts +11 -3
- package/dist/src/index.js +9 -4
- package/dist/src/log.js +12 -7
- package/dist/src/machines.d.ts +81 -5
- package/dist/src/machines.js +125 -9
- package/dist/src/managed-database.d.ts +15 -11
- package/dist/src/managed-database.js +38 -37
- package/dist/src/multipart.d.ts +21 -3
- package/dist/src/multipart.js +112 -38
- package/dist/src/observe.d.ts +3 -0
- package/dist/src/observe.js +6 -2
- package/dist/src/openai-wire.d.ts +16 -0
- package/dist/src/openai-wire.js +43 -12
- package/dist/src/pack-fetch.d.ts +4 -6
- package/dist/src/pack-fetch.js +189 -57
- package/dist/src/packRegistry.d.ts +7 -1
- package/dist/src/packRegistry.js +20 -3
- package/dist/src/private-transport.d.ts +1 -0
- package/dist/src/private-transport.js +11 -0
- package/dist/src/protobuf.js +10 -4
- package/dist/src/rateBudget.d.ts +8 -1
- package/dist/src/rateBudget.js +1 -0
- package/dist/src/redis/engine.d.ts +14 -5
- package/dist/src/redis/engine.js +271 -72
- package/dist/src/redis/frames.d.ts +17 -0
- package/dist/src/redis/frames.js +123 -0
- package/dist/src/redis/index.d.ts +6 -0
- package/dist/src/redis/index.js +6 -0
- package/dist/src/redis/json.d.ts +2 -0
- package/dist/src/redis/json.js +14 -0
- package/dist/src/redis/lua-libs.d.ts +4 -0
- package/dist/src/redis/lua-libs.js +173 -0
- package/dist/src/redis/lua.d.ts +1 -0
- package/dist/src/redis/lua.js +36 -0
- package/dist/src/redis/member-storage.d.ts +5 -0
- package/dist/src/redis/member-storage.js +75 -0
- package/dist/src/redis/resp2.d.ts +4 -0
- package/dist/src/redis/resp2.js +29 -0
- package/dist/src/redis/stream.d.ts +14 -0
- package/dist/src/redis/stream.js +335 -0
- package/dist/src/remote-execute.d.ts +2 -2
- package/dist/src/request-body.d.ts +11 -0
- package/dist/src/request-body.js +11 -0
- package/dist/src/runtime.d.ts +6 -5
- package/dist/src/runtime.js +5 -5
- package/dist/src/s3/wire.d.ts +4 -4
- package/dist/src/s3/wire.js +5 -3
- package/dist/src/scenario.d.ts +22 -0
- package/dist/src/scenario.js +35 -31
- package/dist/src/serve-http.d.ts +2 -1
- package/dist/src/serve-http.js +10 -1
- package/dist/src/serve.js +7 -4
- package/dist/src/signing.d.ts +23 -5
- package/dist/src/signing.js +74 -11
- package/dist/src/sigv4.d.ts +3 -1
- package/dist/src/sigv4.js +4 -2
- package/dist/src/smtp.js +2 -1
- package/dist/src/sockets.js +8 -3
- package/dist/src/state-system.d.ts +3 -0
- package/dist/src/storage.js +5 -2
- package/dist/src/twin-fetch.d.ts +3 -1
- package/dist/src/twin-fetch.js +3 -2
- package/dist/src/vendor-call.d.ts +10 -3
- package/dist/src/vendor-call.js +58 -14
- package/dist/test-fixtures/attestation.SOURCE.md +1 -0
- package/dist/test-fixtures/attestation.json +50 -0
- package/generated/pack-facts.json +409 -352
- package/inject.cjs +474 -95
- package/network-policy.cjs +108 -13
- package/network-policy.d.cts +8 -3
- package/package.json +11 -4
- package/src/actions.ts +2 -1
- package/src/anthropic-wire.ts +6 -0
- package/src/attestation.ts +23 -0
- package/src/bytes.ts +21 -0
- package/src/changeset.ts +2 -1
- package/src/clickhouse/sql.ts +50 -16
- package/src/derived-core.ts +573 -249
- package/src/derived-real.ts +552 -42
- package/src/derived.ts +23 -4
- package/src/events.ts +54 -85
- package/src/exact-json.ts +71 -0
- package/src/executor.ts +86 -27
- package/src/file-response.ts +1 -1
- package/src/form.ts +66 -0
- package/src/git/format.ts +89 -0
- package/src/git/index.ts +1 -0
- package/src/git/lfs.ts +5 -4
- package/src/graphql-wire.ts +8 -3
- package/src/grpc-wire.ts +2 -1
- package/src/head.ts +15 -5
- package/src/held-reads.ts +54 -0
- package/src/history.ts +5 -4
- package/src/host-port.ts +9 -0
- package/src/index.ts +10 -4
- package/src/log.ts +11 -7
- package/src/machines.ts +135 -13
- package/src/managed-database.ts +48 -40
- package/src/multipart.ts +110 -35
- package/src/observe.ts +6 -3
- package/src/openai-wire.ts +45 -14
- package/src/pack-fetch.ts +173 -50
- package/src/packRegistry.ts +27 -4
- package/src/private-transport.ts +8 -0
- package/src/protobuf.ts +8 -4
- package/src/rateBudget.ts +6 -1
- package/src/redis/engine.ts +168 -63
- package/src/redis/frames.ts +53 -0
- package/src/redis/index.ts +6 -0
- package/src/redis/json.ts +13 -0
- package/src/redis/lua-libs.ts +62 -0
- package/src/redis/lua.ts +20 -0
- package/src/redis/member-storage.ts +58 -0
- package/src/redis/resp2.ts +25 -0
- package/src/redis/stream.ts +102 -0
- package/src/remote-execute.ts +2 -2
- package/src/request-body.ts +12 -0
- package/src/runtime.ts +6 -5
- package/src/s3/wire.ts +8 -5
- package/src/scenario.ts +34 -13
- package/src/serve-http.ts +11 -2
- package/src/serve.ts +4 -3
- package/src/signing.ts +71 -17
- package/src/sigv4.ts +5 -3
- package/src/smtp.ts +3 -2
- package/src/sockets.ts +8 -3
- package/src/state-system.ts +3 -0
- package/src/storage.ts +5 -2
- package/src/twin-fetch.ts +4 -3
- package/src/vendor-call.ts +59 -12
- package/test-fixtures/attestation.json +50 -0
package/src/derived-core.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseExactJson, compareExactNumbers, exactInteger, isExactIntegerValue } from './exact-json.ts';
|
|
1
2
|
// THE DERIVED CORE — what a derived pack does for an operation it declares no handler for
|
|
2
3
|
// (docs/contributing/architecture.md, "Protocol 3"). The generated surface says what the
|
|
3
4
|
// vendor's operations are; the pack's manifest says the vendor facts no spec carries (how ids look,
|
|
@@ -15,23 +16,26 @@ import { GitRefs } from './git/refs.ts';
|
|
|
15
16
|
import { worldNow } from './world-clock.ts';
|
|
16
17
|
import { worldEnvValue } from './world-env.ts';
|
|
17
18
|
import { openSessions, socketWrite, type SocketDecl, type SocketSession } from './sockets.ts';
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
19
|
+
import { awaitWrite, awaitCondition, heldReadsWrite } from './held-reads.ts';
|
|
20
|
+
import { MACHINE_POOL, poolFor, guardedMachinePool, type MachinePool, type MachineHttpReply, type LightPoolOptions } from './machines.ts';
|
|
21
|
+
import { askApplication, deliverEvents, type ApplicationAnswer, type ApplicationStandIn, type EventRender, type EventScheme, type EventsDecl, type EventValues } from './events.ts';
|
|
20
22
|
import type { RateBudgetDeclaration } from './rateBudget.ts';
|
|
21
|
-
import { handlerCrypto, hmac, lettersFrom, sha256, uuidFrom, type HandlerCrypto } from './signing.ts';
|
|
23
|
+
import { handlerCrypto, hmac, ksuidFrom, lettersFrom, sha256, uuidFrom, type HandlerCrypto } from './signing.ts';
|
|
22
24
|
import { hashFieldValue } from './hash.ts';
|
|
23
25
|
import { readResourceBlob, removeResourceBlob, writeResourceBlob } from './resource-blob.ts';
|
|
24
26
|
import { managedDatabase, unboundDatabase, type ManagedDatabase } from './managed-database.ts';
|
|
25
27
|
import { execRedisRun, type RedisDialect, type RunItem } from './redis/engine.ts';
|
|
26
28
|
import { sendMail, type Mail, type SmtpRoute } from './smtp.ts';
|
|
29
|
+
import { ownField, parseBracketForm } from './form.ts';
|
|
30
|
+
export { ownField, parseBracketForm } from './form.ts';
|
|
27
31
|
import { multipartBoundary, multipartParts, type MultipartPart } from './multipart.ts';
|
|
28
32
|
import type { CorsDecl } from './cors.ts';
|
|
29
|
-
import { projectOwnerResources, resolveSubjectId, subjectAliases } from './actions.ts';
|
|
33
|
+
import { listActions, projectOwnerResources, resolveSubjectId, subjectAliases, type TwinAction } from './actions.ts';
|
|
30
34
|
import { vendorFetch } from './vendor-call.ts';
|
|
31
35
|
import { packReferences } from './references.ts';
|
|
32
36
|
import type { SubjectFields } from './hash.ts';
|
|
33
37
|
import { twinPublicBase, withRequestScopes } from './twin-fetch.ts';
|
|
34
|
-
import { inVendorMove, isReadOnlyRequest, runAsVendorMove } from './request-scope.ts';
|
|
38
|
+
import { inVendorMove, isReadOnlyRequest, runAsVendorMove, writesRefused } from './request-scope.ts';
|
|
35
39
|
import type { DerivedCall, DerivedCoreOutcome, DerivedHandler, DerivedOperation } from './derived.ts';
|
|
36
40
|
import type { PackDescriptor } from './packRegistry.ts';
|
|
37
41
|
import type { ScenarioDecision, ScenarioHandler } from './scenario.ts';
|
|
@@ -57,7 +61,8 @@ export type Transition = {
|
|
|
57
61
|
operation?: string;
|
|
58
62
|
actor?: Actor;
|
|
59
63
|
from: string[] | '*';
|
|
60
|
-
/** the value it moves to; absent, the value stays (a guard that moves nothing)
|
|
64
|
+
/** the value it moves to; absent, the value stays (a guard that moves nothing), and absent with a `refusal`, the
|
|
65
|
+
* operation is refused in the `from` states (a move another transition allows from there is taken first) */
|
|
61
66
|
to?: string;
|
|
62
67
|
effects?: Record<string, FieldRule>;
|
|
63
68
|
/** placeholders `{from}`, `{to}`, `{field}`, `{id}` */
|
|
@@ -92,7 +97,7 @@ export function stateOf(decl: StateField, value: unknown): string {
|
|
|
92
97
|
const w = d.when;
|
|
93
98
|
if (w.absent !== undefined && (value === undefined || value === null) === w.absent) return d.state;
|
|
94
99
|
if (w.equals !== undefined && value === w.equals) return d.state;
|
|
95
|
-
if (w.gt !== undefined && typeof value === 'number' && value
|
|
100
|
+
if (w.gt !== undefined && (typeof value === 'number' || isExactIntegerValue(value)) && compareExactNumbers(value, w.gt) > 0) return d.state;
|
|
96
101
|
if (w.truthy !== undefined && Boolean(value) === w.truthy) return d.state;
|
|
97
102
|
}
|
|
98
103
|
return value === undefined || value === null ? String(decl.initial) : String(value);
|
|
@@ -106,8 +111,8 @@ export type ResourceDecl = {
|
|
|
106
111
|
* surface names by the schema its GET answers): keyed by the path's parameter, it answers `defaults` under what was
|
|
107
112
|
* written until first written, and an update merges into it, making it on first write */
|
|
108
113
|
setting?: { defaults: Record<string, unknown> };
|
|
109
|
-
/** this resource's id template, when it is not the manifest's (`ids.template`): `{uuid}`, `{letters:20}` (twenty
|
|
110
|
-
* lowercase letters, a Supabase project ref), `{snowflake:<epoch ms>}` (Discord's ids), or a `{prefix}`/`{n}`
|
|
114
|
+
/** this resource's id template, when it is not the manifest's (`ids.template`): `{uuid}`, `{ksuid}` (Clerk's), `{letters:20}` (twenty
|
|
115
|
+
* lowercase letters, a Supabase project ref), `{digits:16}` (fixed-width decimal), `{snowflake:<epoch ms>}` (Discord's ids), or a `{prefix}`/`{n}`
|
|
111
116
|
* template */
|
|
112
117
|
ids?: string;
|
|
113
118
|
/** fields the server assigns on create */
|
|
@@ -117,6 +122,20 @@ export type ResourceDecl = {
|
|
|
117
122
|
notState?: string[];
|
|
118
123
|
/** id-holding fields a caller may ask to receive embedded, and the resource each holds */
|
|
119
124
|
embeds?: Record<string, string>;
|
|
125
|
+
/** `embeds` fields the vendor answers embedded on every answer, asked or not (its schema gives the object with no id
|
|
126
|
+
* alternative: Stripe's issuing card's `cardholder`): still stored as the id, hydrated from the current object */
|
|
127
|
+
alwaysEmbedded?: string[];
|
|
128
|
+
/** the vendor's own fields whose names begin with `_` (GitHub's `_links`, a HAL object's), which the vendor answers:
|
|
129
|
+
* answered like any field, where every other `_` field is the pack's bookkeeping and never answered */
|
|
130
|
+
vendorUnderscored?: string[];
|
|
131
|
+
/** Stored fields omitted from vendor views, without renaming them in retained state. Raw rows and
|
|
132
|
+
* ctx.own retain them for parent scope and semantics; get, rows, render and core answers omit them. */
|
|
133
|
+
viewOmit?: readonly string[];
|
|
134
|
+
/** fields the vendor answers as a list of the current children of another resource (Stripe's subscription `items`:
|
|
135
|
+
* its subscription_items), built on every answer from the child rows whose `by` field holds this id, in the order
|
|
136
|
+
* they were made, each through the child's own view and its embedding; never a stored copy. `list` is the list's own
|
|
137
|
+
* fields around `data`, where `{id}` is this resource's id and `"{count}"` the children's number */
|
|
138
|
+
collections?: Record<string, { resource: string; by: string; list?: Record<string, unknown> }>;
|
|
120
139
|
/** this resource's deletion answer, when it is not the manifest's (`vector_store.deleted`) */
|
|
121
140
|
deleted?: unknown;
|
|
122
141
|
/** a child addressed under its parent: the path parameter naming the parent, the stored field that
|
|
@@ -125,7 +144,10 @@ export type ResourceDecl = {
|
|
|
125
144
|
* that joins them into what `field` stores (`{owner}/{repo}`). `where` finds the parent by its stored
|
|
126
145
|
* fields when its stored id is not what the path names (a repository stored as `repo:7` with `owner`
|
|
127
146
|
* and `name`, addressed as `{owner}/{repo}`): each stored field against a template over the path. */
|
|
128
|
-
parent?: { param?: string; params?: string[]; value?: string; field: string; resource: string; allowDeleted?: boolean; where?: Record<string, string
|
|
147
|
+
parent?: { param?: string; params?: string[]; value?: string; field: string; resource: string; allowDeleted?: boolean; where?: Record<string, string>;
|
|
148
|
+
/** private fields a child observed under its parent keeps from the parent's parameters (`_jurisdiction:
|
|
149
|
+
* '{jurisdiction}'`), where the child's own read names none and a later read under it needs one ("Refresh") */
|
|
150
|
+
keep?: Record<string, string> };
|
|
129
151
|
/** a number the resource counts per parent (a repository's issue numbers, a thread's messages):
|
|
130
152
|
* the stored field, set on create to one past the highest among its siblings */
|
|
131
153
|
number?: { field: string };
|
|
@@ -163,18 +185,33 @@ export type ResourceDecl = {
|
|
|
163
185
|
/** fields that name a subject as its id does (an organization by its id or its slug): a path's id and `ctx.find`
|
|
164
186
|
* match either */
|
|
165
187
|
alternateKeys?: string[];
|
|
188
|
+
/** fields that pair a subject a deploy settled with no vendor id with the vendor's copy a refresh observes (Slack's
|
|
189
|
+
* `channel_join` message, made by `conversations.join` and answered by no id: its `subtype` and `user`), where the
|
|
190
|
+
* `alternateKeys` do not; a subject missing one is never paired by them ("Refresh", adoption) */
|
|
191
|
+
adoptBy?: string[];
|
|
166
192
|
/** the list's search parameter (`query`): a row matches on an `exact` field's whole value or a `partial` field's
|
|
167
193
|
* substring, case-insensitively */
|
|
168
194
|
search?: { param: string; exact?: string[]; partial?: string[] };
|
|
169
195
|
/** fields that count this subject's children (an App's `installations_count`): the live subjects of `of` whose `by`
|
|
170
|
-
* holds this one's id, only those with `present` set when it is named
|
|
196
|
+
* holds this one's id (or vendor `parentKey`), only those with `present` set when it is named and without
|
|
197
|
+
* any matching `unless` field, plus `plus` (a customer's
|
|
171
198
|
* `next_invoice_sequence` is one past its numbered invoices). The kernel keeps each whenever a child is written. */
|
|
172
|
-
counts?: Record<string, { of: string; by: string; present?: string; plus?: number }>;
|
|
199
|
+
counts?: Record<string, { of: string; by: string; parentKey?: string; present?: string; unless?: Record<string, unknown>; plus?: number }>;
|
|
173
200
|
/** how the vendor's state of this type is read back into a root (the real-system adapters, "Refresh"): the list that
|
|
174
201
|
* enumerates it (once per parent, under `parent`; `complete: false` when it is not the whole type; `items`, the answer's
|
|
175
202
|
* path to them, when the spec does not name the list it answers), the one read of a singleton, or `none` with why the
|
|
176
|
-
* vendor offers no read-back. Every stored resource the vendor holds declares one.
|
|
177
|
-
|
|
203
|
+
* vendor offers no read-back. Every stored resource the vendor holds declares one. `{ get, known: true }`: the vendor
|
|
204
|
+
* lists no whole type, so the World's own subjects of it are each read again by `get` (a 404 is one gone), and what
|
|
205
|
+
* those reads observe anchors its children's lists; completeness is those subjects alone. */
|
|
206
|
+
/** `next`: where an answer names the next page's cursor (AWS's `NextToken`), sent back as `cursor` (the same name
|
|
207
|
+
* unless said) in the query, or in the body of a list asked by POST (an RPC list: AWS JSON's `x-amz-target`); a
|
|
208
|
+
* map of parameter to answer path where a page names several (S3's `key-marker` and `upload-id-marker`); `more`, the
|
|
209
|
+
* answer's flag that another page follows (S3's `IsTruncated`). `headers`: fixed headers of the list, `{name}` filled
|
|
210
|
+
* from the parent's parameters; `variants`: header sets each listed in turn, one type whole across them (R2's
|
|
211
|
+
* jurisdictions). A blob read by an `operation` (a GET of the unit's surface, filled from the subject's fields and
|
|
212
|
+
* its parent's parameters) keeps the bytes in the World's blobs, their key in `field`, and in `headersField` the
|
|
213
|
+
* answer's headers `keepHeaders` names (a name ending `*` a prefix: `x-amz-meta-*`), both fields private. */
|
|
214
|
+
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 };
|
|
178
215
|
/** the other resources one create of this one makes, and where its answer names each's id (a JSON path) */
|
|
179
216
|
companions?: Record<string, string>;
|
|
180
217
|
/** the field telling a newer copy of a subject from an older (an `updated_at`): an ingested event older than the
|
|
@@ -191,7 +228,18 @@ export type IngestDecl = {
|
|
|
191
228
|
object: string;
|
|
192
229
|
/** event type → the resource it carries, where `events.types` read backwards and `<resource>.created|updated|deleted`
|
|
193
230
|
* do not say it */
|
|
194
|
-
types?: Record<string, {
|
|
231
|
+
types?: Record<string, {
|
|
232
|
+
resource: string; deleted?: true;
|
|
233
|
+
/** where this type carries its object, when not at `object` (GitHub's `issue`, `pull_request`, `comment`) */
|
|
234
|
+
object?: string;
|
|
235
|
+
/** the JSON path of the value the resource's `parent.field` holds, when the event names its parent beside the object
|
|
236
|
+
* (GitHub's `repository.full_name`, the `{owner}/{repo}` its children are stored under) */
|
|
237
|
+
parent?: string;
|
|
238
|
+
/** where the object names the subject's id when not as the resource does (Resend's email events: `email_id`) */
|
|
239
|
+
id?: string;
|
|
240
|
+
/** the fields the event's type itself says (Resend's `email.delivered` is the email's `last_event: 'delivered'`) */
|
|
241
|
+
fields?: Record<string, unknown>;
|
|
242
|
+
}>;
|
|
195
243
|
/** an event answered instead of folded (Slack's url_verification): the body fields it matches, and the answer, a
|
|
196
244
|
* `$body.<path>` read from the event or a literal */
|
|
197
245
|
handshake?: { when: Record<string, string>; answer: string };
|
|
@@ -267,6 +315,8 @@ export type PerformHookArgs = {
|
|
|
267
315
|
export type PerformHook = (args: PerformHookArgs) => Promise<import('./head.ts').PushOutcome>;
|
|
268
316
|
|
|
269
317
|
export type DerivedManifest = {
|
|
318
|
+
/** Vendor data-plane hosts mapped to an already-running customer guest. */
|
|
319
|
+
machineRoutes?: ReadonlyArray<{ host: string; resource: string; where: Record<string, string>; name: string; port: number | string }>;
|
|
270
320
|
/** The pack's descriptor, as data (docs/contributing/architecture.md, "The descriptor"), every field but `vendor`, which
|
|
271
321
|
* is the manifest's: the pack registers `packOf(manifest)`, so its index is a fixed file. A lane has none. */
|
|
272
322
|
descriptor?: Omit<PackDescriptor, 'vendor'>;
|
|
@@ -287,6 +337,11 @@ export type DerivedManifest = {
|
|
|
287
337
|
/** operations that only read though nothing in the spec says so (a POST that returns data):
|
|
288
338
|
* a read-only twin answers them */
|
|
289
339
|
reads?: string[];
|
|
340
|
+
/** operations of the vendor's API that set an account up rather than use it: an operator registering an OAuth client
|
|
341
|
+
* on the vendor's own admin routes, as a person makes settings on the vendor's site. Each is recorded as a door's
|
|
342
|
+
* write is (the World's own), so a deploy never offers it to the vendor and the vendor-backed acceptance holds it as
|
|
343
|
+
* what the account already has; `why` says who performs it and where */
|
|
344
|
+
accountSetup?: Array<{ operation: string; why: string }>;
|
|
290
345
|
/** how the vendor writes an instant: Unix seconds, Unix milliseconds (Clerk's `created_at`), or ISO 8601 */
|
|
291
346
|
time: 'unix' | 'unix-ms' | 'iso';
|
|
292
347
|
/** the vendor's CORS, as its gateway answers a browser (cors.ts): the pack's fetch is served behind `withCors` */
|
|
@@ -311,7 +366,8 @@ export type DerivedManifest = {
|
|
|
311
366
|
* (`{value}` in its message is the header's value) */
|
|
312
367
|
version?: { header: string; pattern: string; error: ErrorSpec };
|
|
313
368
|
/** request headers that carry meaning to the vendor (a tenant header such as `Stripe-Account`): recorded with a write
|
|
314
|
-
* so its perform sends them again (the real-system adapters, "What a pack declares"); `version.header` is too
|
|
369
|
+
* so its perform sends them again (the real-system adapters, "What a pack declares"); `version.header` is too. A name
|
|
370
|
+
* ending `*` is a prefix: every header it begins (S3's `x-amz-meta-*`, an object's own metadata). */
|
|
315
371
|
headers?: string[];
|
|
316
372
|
/** the vendor's signed webhooks, folded into a root */
|
|
317
373
|
ingest?: IngestDecl;
|
|
@@ -329,10 +385,11 @@ export type DerivedManifest = {
|
|
|
329
385
|
* derived request, which it composes (`send`) ("Protocols a request cannot express", architecture) */
|
|
330
386
|
performs?: Record<string, PerformHook>;
|
|
331
387
|
/** `withParam`: the error names the path parameter that held the unknown id */
|
|
332
|
-
notFound: { status: number; message: string; code?: string; kind?: string; withParam?: boolean };
|
|
388
|
+
notFound: { status: number; message: string; code?: string | number; kind?: string; withParam?: boolean };
|
|
333
389
|
/** the list envelope (placeholders `{data}`, `{has_more}`, `{url}`, `{first_id}`, `{last_id}`),
|
|
334
|
-
* its page size, its cursors, and where an unknown cursor starts: the first page or past the end
|
|
335
|
-
list
|
|
390
|
+
* its page size, its cursors, and where an unknown cursor starts: the first page or past the end.
|
|
391
|
+
* Absent when the unit serves no stored list; the core does not own lists without this declaration. */
|
|
392
|
+
list?: {
|
|
336
393
|
style: 'envelope';
|
|
337
394
|
envelope: unknown;
|
|
338
395
|
limit: { param: string; default: number; max: number };
|
|
@@ -341,8 +398,8 @@ export type DerivedManifest = {
|
|
|
341
398
|
unknownCursor?: 'start' | 'end';
|
|
342
399
|
/** an opaque cursor over positions (`{next_cursor}` in the envelope; empty on the last page),
|
|
343
400
|
* in place of id cursors: `base64-offset` is base64 of `{"o":<offset>}` */
|
|
344
|
-
cursor?: { param: string; encoding: 'base64-offset' };
|
|
345
|
-
/** numbered pages (1-based
|
|
401
|
+
cursor?: { param: string; encoding: 'base64-offset' } | { param: string; encoding: 'offset-template'; template: string; link: { previous: string; next: string; results: string; cursor: string } };
|
|
402
|
+
/** numbered pages (1-based, the size in `limit.param`), with the vendor's `Link` header
|
|
346
403
|
* naming the first, previous, next and last pages (`link`); the envelope may name `{total_count}` and `{max_page}` */
|
|
347
404
|
page?: { param: string; link: boolean };
|
|
348
405
|
/** offset paging: `param` names how many items to skip; the page sits under the operation's envelope key beside the
|
|
@@ -384,14 +441,19 @@ export type DerivedManifest = {
|
|
|
384
441
|
/** operations that check a credential of their own, not the account's (Cloudflare's asset upload reads its upload
|
|
385
442
|
* session's JWT from the same header): the gate leaves them to their handler */
|
|
386
443
|
exempt?: string[];
|
|
444
|
+
/** ordered API-skin error wires, selected before scenario matching and ordinary routing */
|
|
445
|
+
wires?: Array<{ paths: string; error: DerivedManifest['error']; missing?: ErrorSpec; invalid?: ErrorSpec }>;
|
|
387
446
|
/** the vendor checks the key before it routes: a path it does not have is refused a missing or invalid key first */
|
|
388
447
|
beforeRouting?: boolean };
|
|
389
448
|
/** the twin door that makes the next request answer the vendor's rate-limit refusal */
|
|
390
|
-
/** headers every answer carries (a request id)
|
|
449
|
+
/** headers every answer carries (a request id); a value's `{hex:N}` is filled per answer, from the World's count of
|
|
450
|
+
* answers (so a second World answers the same ids in the same order), unless the handler set the header itself */
|
|
391
451
|
answerHeaders?: Record<string, string>;
|
|
392
452
|
/** an origin the pack writes into URLs it mints, rewritten in every JSON answer to where this
|
|
393
453
|
* twin is reached for the request (twinPublicBase) */
|
|
394
454
|
origin?: { placeholder: string };
|
|
455
|
+
/** Disclosed local connection fields, separate from vendor response schemas. */
|
|
456
|
+
worldFields?: Record<string, Record<string, { type: 'array' | 'object' | 'string' | 'number' | 'boolean'; why: string }>>;
|
|
395
457
|
resources: Record<string, ResourceDecl>;
|
|
396
458
|
/** Read-only cross-pack credential subjects of the same vendor (architecture A3); no root is exposed to handlers. */
|
|
397
459
|
ownerReads?: ReadonlyArray<{ owner: string; resource: string }>;
|
|
@@ -438,9 +500,19 @@ export type DerivedManifest = {
|
|
|
438
500
|
* matched at the path's start: stripped before routing, so the operation it names is the one served. Its named groups
|
|
439
501
|
* (Jira's `/ex/jira/(?<cloudId>[^/]+)`) are the operation's parameters as well. */
|
|
440
502
|
pathPrefix?: string;
|
|
503
|
+
/** A prefix the vendor serves as its own base path (Discord answers `/api` and `/api/v9` as `/api/v10`, the spec's), as
|
|
504
|
+
* a regular expression matched at the path's start: put in place of the surface's base path before routing, so an
|
|
505
|
+
* operation compiled under the spec's base answers each. Perform and refresh send the spec's base, as the SDK does. */
|
|
506
|
+
basePathAlias?: string;
|
|
441
507
|
/** A pattern over the request's vendor host whose named groups are the operation's parameters (E2B's envd answers each
|
|
442
508
|
* sandbox at `49983-<sandboxID>.e2b.app`: `^\\d+-(?<sandboxID>[a-z0-9]+)\\.`). */
|
|
443
509
|
hostParams?: string;
|
|
510
|
+
/** The vendor host a lane's calls go to when it carries the call's parameters (R2's `<account>.r2.cloudflarestorage.com`):
|
|
511
|
+
* `{name}` filled from the call's parameters, `values` a parameter's literal in the host (the real-system adapters,
|
|
512
|
+
* "Lanes"). Perform and refresh send to `https://<host><path>`. */
|
|
513
|
+
remoteHost?: { template: string; values?: Record<string, Record<string, string>> };
|
|
514
|
+
/** Named operation parameters from vendor routing headers (shared-host compute services). */
|
|
515
|
+
headerParams?: Record<string, string>;
|
|
444
516
|
/** The persistent wires the vendor's clients hold open (sockets.ts: Discord's Gateway): each served at its path by
|
|
445
517
|
* the pack's `semantics/sockets.ts`, which the kernel offers every write after its webhooks. */
|
|
446
518
|
sockets?: ReadonlyArray<SocketDecl>;
|
|
@@ -486,12 +558,12 @@ export function strictFields(m: DerivedManifest, operation: DerivedOperation, bo
|
|
|
486
558
|
const { type } = field;
|
|
487
559
|
const fits = value === null
|
|
488
560
|
|| (type === 'boolean' && typeof value === 'boolean')
|
|
489
|
-
|| (type === 'integer' &&
|
|
490
|
-
|| (type === 'number' && typeof value === 'number')
|
|
561
|
+
|| (type === 'integer' && isExactIntegerValue(value))
|
|
562
|
+
|| (type === 'number' && (typeof value === 'number' || isExactIntegerValue(value)))
|
|
491
563
|
|| (type === 'string' && typeof value === 'string')
|
|
492
564
|
|| !['boolean', 'integer', 'number', 'string'].includes(type);
|
|
493
565
|
if (!fits) return refuse(strict.type, { name, type: type === 'integer' ? 'an integer' : `a ${type}` });
|
|
494
|
-
if (typeof value === 'number' && ((field.minimum !== undefined && value
|
|
566
|
+
if ((typeof value === 'number' || isExactIntegerValue(value)) && ((field.minimum !== undefined && compareExactNumbers(value, field.minimum) < 0) || (field.maximum !== undefined && compareExactNumbers(value, field.maximum) > 0))) {
|
|
495
567
|
return refuse(strict.range, { name, min: field.minimum ?? null, max: field.maximum ?? null });
|
|
496
568
|
}
|
|
497
569
|
fields[name] = value;
|
|
@@ -513,9 +585,9 @@ function validateBody(m: DerivedManifest, operation: DerivedOperation, body: unk
|
|
|
513
585
|
continue;
|
|
514
586
|
}
|
|
515
587
|
const types = field.type.split('|');
|
|
516
|
-
const fits = types.some((type) => type === 'null' ? value === null : type === 'integer' ?
|
|
588
|
+
const fits = types.some((type) => type === 'null' ? value === null : type === 'integer' ? isExactIntegerValue(value) : type === 'array' ? Array.isArray(value) : type === 'object' ? value !== null && typeof value === 'object' && !Array.isArray(value) : type === 'string' ? typeof value === 'string' : type === 'number' ? typeof value === 'number' || isExactIntegerValue(value) : type === 'boolean' ? typeof value === 'boolean' : true);
|
|
517
589
|
if (!fits) return refuse(rule.type, { name: field.name, type: field.type, input: value });
|
|
518
|
-
if (typeof value === 'number' && ((field.minimum !== undefined && value
|
|
590
|
+
if ((typeof value === 'number' || isExactIntegerValue(value)) && ((field.minimum !== undefined && compareExactNumbers(value, field.minimum) < 0) || (field.maximum !== undefined && compareExactNumbers(value, field.maximum) > 0))) return refuse(rule.range, { name: field.name, min: field.minimum ?? null, max: field.maximum ?? null, input: value });
|
|
519
591
|
}
|
|
520
592
|
return undefined;
|
|
521
593
|
}
|
|
@@ -531,64 +603,6 @@ export function flagOf(m: Pick<DerivedManifest, 'booleans'>, value: unknown): bo
|
|
|
531
603
|
return undefined;
|
|
532
604
|
}
|
|
533
605
|
|
|
534
|
-
/** A form body's bracket notation (`a[b][0][c]=v`, `expand[]=x`) as nested objects and arrays. A form carries only
|
|
535
|
-
* text: a field the operation's spec types as a number or a boolean (`scalars`, by bracket path, written by
|
|
536
|
-
* world-tooling's formScalarsOf) is read as one when its text is that literal; every other field stays the text sent
|
|
537
|
-
* (`metadata[order]=007`, `name=2024`). */
|
|
538
|
-
/** A field set as the object's own, whatever its name (`__proto__`, `constructor` and `prototype` are data a caller
|
|
539
|
-
* may name: Stripe's metadata keys are the caller's), never through what the object inherits. */
|
|
540
|
-
export function ownField(node: Record<string, unknown> | unknown[], key: string, value: unknown): void {
|
|
541
|
-
Object.defineProperty(node, key, { value, writable: true, enumerable: true, configurable: true });
|
|
542
|
-
}
|
|
543
|
-
/** A field the object holds as its own, never one it inherits (`constructor` is every object's). */
|
|
544
|
-
const ownValue = (node: Record<string, unknown> | unknown[], key: string): unknown => (Object.hasOwn(node, key) ? (node as Record<string, unknown>)[key] : undefined);
|
|
545
|
-
|
|
546
|
-
export function parseBracketForm(text: string, scalars?: Readonly<Record<string, string | undefined>>): Record<string, unknown> {
|
|
547
|
-
const out: Record<string, unknown> = {};
|
|
548
|
-
for (const [rawKey, raw] of new URLSearchParams(text)) {
|
|
549
|
-
const parts = rawKey.replace(/\]/g, '').split('[');
|
|
550
|
-
const kind = scalars && scalarAt(scalars, parts);
|
|
551
|
-
const value: unknown = kind === 'boolean' ? (raw === 'true' ? true : raw === 'false' ? false : raw)
|
|
552
|
-
: kind === 'integer' ? (/^-?\d+$/.test(raw) ? Number(raw) : raw)
|
|
553
|
-
: kind === 'number' ? (/^-?\d+(\.\d+)?$/.test(raw) ? Number(raw) : raw)
|
|
554
|
-
: raw;
|
|
555
|
-
// every field is the node's own, read and written as such: a key naming an object's machinery (`__proto__[x]`,
|
|
556
|
-
// `constructor[prototype][x]`) is a field of that name, never a walk into what every object inherits
|
|
557
|
-
let node: Record<string, unknown> | unknown[] = out;
|
|
558
|
-
for (const [i, key] of parts.entries()) {
|
|
559
|
-
// an array holds its items by index (`a[]`, `a[0]`): a named key under one (`a[]=1&a[length]=-1`) has nowhere to
|
|
560
|
-
// go, as a key under text has none (`a=1&a[b]=2`): the first shape stands
|
|
561
|
-
if (Array.isArray(node) && key !== '' && !/^\d+$/.test(key)) break;
|
|
562
|
-
if (i === parts.length - 1) {
|
|
563
|
-
if (key === '') {
|
|
564
|
-
if (Array.isArray(node)) node.push(value);
|
|
565
|
-
} else ownField(node, key, value);
|
|
566
|
-
break;
|
|
567
|
-
}
|
|
568
|
-
const next = parts[i + 1]!;
|
|
569
|
-
if (key === '') break;
|
|
570
|
-
let child = ownValue(node, key);
|
|
571
|
-
if (child === undefined) { child = next === '' || /^\d+$/.test(next) ? [] : {}; ownField(node, key, child); }
|
|
572
|
-
if (child === null || typeof child !== 'object') break;
|
|
573
|
-
node = child as Record<string, unknown> | unknown[];
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
return out;
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
/** The type `scalars` gives a form key's parts: a segment is a property name, `[]` an array's item (an index or empty),
|
|
580
|
-
* `*` a map's key. */
|
|
581
|
-
function scalarAt(scalars: Readonly<Record<string, string | undefined>>, parts: string[]): string | undefined {
|
|
582
|
-
let paths = [''];
|
|
583
|
-
for (const [i, part] of parts.entries()) {
|
|
584
|
-
const segs = part === '' || /^\d+$/.test(part) ? ['[]', '*'] : [part, '*'];
|
|
585
|
-
paths = paths.flatMap((p) => segs.map((seg) => (i === 0 ? seg : `${p}.${seg}`)));
|
|
586
|
-
if (paths.length > 64) paths = paths.filter((p) => Object.keys(scalars).some((k) => k === p || k.startsWith(`${p}.`)));
|
|
587
|
-
}
|
|
588
|
-
for (const p of paths) if (scalars[p]) return scalars[p];
|
|
589
|
-
return undefined;
|
|
590
|
-
}
|
|
591
|
-
|
|
592
606
|
/** A request's parameters, its query's and its body's. With the manifest's `body.form.coerce`, a form's fields the
|
|
593
607
|
* operation's spec types as numbers or booleans are read as them (parseBracketForm); without an operation (a twin door)
|
|
594
608
|
* every form value is its text. */
|
|
@@ -596,7 +610,7 @@ export async function readParams(manifest: DerivedManifest, request: Request, op
|
|
|
596
610
|
const url = new URL(request.url);
|
|
597
611
|
const scalars = manifest.body.form?.coerce ? (operation?.scalars ?? {}) : undefined;
|
|
598
612
|
const query = parseBracketForm(url.search.replace(/^\?/, ''), scalars);
|
|
599
|
-
if (request.method === 'GET' || request.method === 'HEAD') return query;
|
|
613
|
+
if (request.method === 'GET' || request.method === 'HEAD' || (operation && bytesPayload(operation))) return query;
|
|
600
614
|
const type = request.headers.get('content-type') ?? '';
|
|
601
615
|
if (type.includes('multipart/form-data')) {
|
|
602
616
|
// an upload: text fields as given, each file as its name, media type, size and content (its text), with its raw
|
|
@@ -620,7 +634,7 @@ export async function readParams(manifest: DerivedManifest, request: Request, op
|
|
|
620
634
|
}
|
|
621
635
|
// NDJSON (`application/x-ndjson`) is lines of JSON, not a JSON document: the handler reads its text
|
|
622
636
|
const media = (type.split(';')[0] ?? '').trim().toLowerCase();
|
|
623
|
-
const isJson = media.includes('json') && !media.includes('ndjson');
|
|
637
|
+
const isJson = media.includes('json') && !media.includes('ndjson') && !media.includes('connect+');
|
|
624
638
|
// a form is a body labelled one (application/x-www-form-urlencoded); a body of any other type (an image, a video, an
|
|
625
639
|
// octet-stream PUT to an upload URL, a text/plain SQL statement, an NDJSON batch) is no form: its parameters are the
|
|
626
640
|
// query's, and the handler reads its text or bytes itself
|
|
@@ -633,7 +647,7 @@ export async function readParams(manifest: DerivedManifest, request: Request, op
|
|
|
633
647
|
// as a form only when it is labelled one (a sign-in page's own form, posted to the vendor's page), never a crash
|
|
634
648
|
let body: unknown;
|
|
635
649
|
if (asJson) {
|
|
636
|
-
try { body =
|
|
650
|
+
try { body = parseExactJson(text); } catch {
|
|
637
651
|
if (isJson) throw new SyntaxError('malformed JSON body');
|
|
638
652
|
if (!isForm) return query;
|
|
639
653
|
body = parseBracketForm(text, scalars);
|
|
@@ -744,27 +758,96 @@ function parentValue(parent: NonNullable<ResourceDecl['parent']>, params: Record
|
|
|
744
758
|
return parent.param !== undefined ? params[parent.param] : undefined;
|
|
745
759
|
}
|
|
746
760
|
|
|
761
|
+
function parentField(row: unknown, path: string): unknown {
|
|
762
|
+
if (row && typeof row === 'object' && Object.hasOwn(row, path)) return (row as Record<string, unknown>)[path];
|
|
763
|
+
let value = row;
|
|
764
|
+
for (const key of path.split('.')) value = value && typeof value === 'object' ? (value as Record<string, unknown>)[key] : undefined;
|
|
765
|
+
return value;
|
|
766
|
+
}
|
|
767
|
+
|
|
747
768
|
function missingParent(m: DerivedManifest, resource: string, call: DerivedCall, root?: string): Response | undefined {
|
|
748
769
|
const parent = m.resources[resource]?.parent;
|
|
749
770
|
if (!parent) return undefined;
|
|
750
771
|
const id = parentValue(parent, call.params);
|
|
751
772
|
const where = Object.entries(parent.where ?? {}).map(([field, template]) => [field, String(fill(template, call.params))] as const);
|
|
752
|
-
const found = (r: TwinResource): boolean => (where.length ? where.every(([field, v]) => String((r
|
|
773
|
+
const found = (r: TwinResource): boolean => (where.length ? where.every(([field, v]) => String(parentField(r, field)) === v) : r.id === id);
|
|
753
774
|
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));
|
|
754
775
|
}
|
|
755
776
|
|
|
756
777
|
/** The vendor's view of a stored subject: its own fields, bookkeeping (`_`) left out. */
|
|
757
778
|
/** A stored subject as the vendor's object: its id first (the subject's, unless the pack kept an id of its own), then
|
|
758
779
|
* the fields the pack wrote, bookkeeping (`_`) and the kernel's updatedAt left out. */
|
|
759
|
-
export function render(r: TwinResource): Record<string, unknown> {
|
|
760
|
-
return
|
|
780
|
+
export function render(r: TwinResource, vendorUnderscored: readonly string[] = []): Record<string, unknown> {
|
|
781
|
+
return renderOwn({ id: r.id, ...ownFields(r) }, vendorUnderscored);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/** Filter an own-field view through the same declared vendor fields as a stored row. */
|
|
785
|
+
function renderOwn(own: Record<string, unknown>, vendorUnderscored: readonly string[] = []): Record<string, unknown> {
|
|
786
|
+
return Object.fromEntries(Object.entries(own).filter(([k]) => !k.startsWith('_') || vendorUnderscored.includes(k)));
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
type ViewOmissions = { fields: Map<string, ReadonlySet<string>>; affected: Set<string>; embeds: Map<string, Array<[string, string]>> };
|
|
790
|
+
const viewOmissions = new WeakMap<DerivedManifest, ViewOmissions>();
|
|
791
|
+
// Precompute once at manifest binding: omitted fields, whether any exist, and their transitive containers through embeds.
|
|
792
|
+
function prepareViewOmissions(m: DerivedManifest): ViewOmissions {
|
|
793
|
+
const held = viewOmissions.get(m);
|
|
794
|
+
if (held) return held;
|
|
795
|
+
const fields = new Map<string, ReadonlySet<string>>();
|
|
796
|
+
for (const [resource, decl] of Object.entries(m.resources)) {
|
|
797
|
+
if (decl.viewOmit?.length) fields.set(resource, new Set(decl.viewOmit));
|
|
798
|
+
}
|
|
799
|
+
const affected = new Set(fields.keys());
|
|
800
|
+
const embeds = new Map<string, Array<[string, string]>>();
|
|
801
|
+
if (fields.size) {
|
|
802
|
+
let changed = true;
|
|
803
|
+
while (changed) {
|
|
804
|
+
changed = false;
|
|
805
|
+
for (const [resource, decl] of Object.entries(m.resources)) {
|
|
806
|
+
if (!affected.has(resource) && Object.values(decl.embeds ?? {}).some((target) => affected.has(target))) {
|
|
807
|
+
affected.add(resource);
|
|
808
|
+
changed = true;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
for (const resource of affected) {
|
|
813
|
+
embeds.set(resource, Object.entries(m.resources[resource]?.embeds ?? {}).filter(([, target]) => affected.has(target)));
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
const plan = { fields, affected, embeds };
|
|
817
|
+
viewOmissions.set(m, plan);
|
|
818
|
+
return plan;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** The one response-omission mechanism, shared by stored, constructed and expanded resource views. */
|
|
822
|
+
function omitView(m: DerivedManifest, resource: string | undefined, body: Record<string, unknown>, seen?: Map<string, WeakMap<object, Record<string, unknown>>>): Record<string, unknown> {
|
|
823
|
+
const plan = viewOmissions.get(m) ?? prepareViewOmissions(m);
|
|
824
|
+
if (!resource || !plan.affected.has(resource)) return body;
|
|
825
|
+
const held = seen?.get(resource)?.get(body);
|
|
826
|
+
if (held) return held;
|
|
827
|
+
const out = { ...body };
|
|
828
|
+
seen ??= new Map();
|
|
829
|
+
let visited = seen.get(resource);
|
|
830
|
+
if (!visited) { visited = new WeakMap(); seen.set(resource, visited); }
|
|
831
|
+
visited.set(body, out);
|
|
832
|
+
for (const field of plan.fields.get(resource) ?? []) delete out[field];
|
|
833
|
+
for (const [field, target] of plan.embeds.get(resource) ?? []) {
|
|
834
|
+
const present = out[field];
|
|
835
|
+
if (Array.isArray(present)) {
|
|
836
|
+
out[field] = present.map((child) => child && typeof child === 'object' && !Array.isArray(child) ? omitView(m, target, child, seen) : child);
|
|
837
|
+
} else if (present && typeof present === 'object') {
|
|
838
|
+
out[field] = omitView(m, target, present as Record<string, unknown>, seen);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
return out;
|
|
761
842
|
}
|
|
762
843
|
|
|
763
844
|
/** A stored subject as the vendor answers it: its own fields, its id under the name the vendor gives it. */
|
|
764
845
|
function view(m: DerivedManifest, resource: string, r: TwinResource): Record<string, unknown> {
|
|
765
846
|
const idAs = m.resources[resource]?.idAs;
|
|
766
|
-
|
|
767
|
-
const
|
|
847
|
+
const keep = m.resources[resource]?.vendorUnderscored ?? [];
|
|
848
|
+
const shown = omitView(m, resource, render(r, keep));
|
|
849
|
+
if (!idAs) return shown;
|
|
850
|
+
const { id, ...rest } = shown;
|
|
768
851
|
return { [idAs]: id, ...rest };
|
|
769
852
|
}
|
|
770
853
|
|
|
@@ -774,7 +857,12 @@ function view(m: DerivedManifest, resource: string, r: TwinResource): Record<str
|
|
|
774
857
|
* vendor's own operation (or the pack's door), made in order against the twin by the runner `volter-world init` writes
|
|
775
858
|
* beside the copied data. An ordinary path is relative to the twin base. A call naming a browser uses the
|
|
776
859
|
* vendor's absolute URL and is exported separately as `browserSeed`, after the ordinary `seed` calls. */
|
|
777
|
-
|
|
860
|
+
/** How a refresh list's answer items become subjects' fields where each is not one subject's object (architecture,
|
|
861
|
+
* "Refresh"): `scalar`, a bare item is that field; `value` (with `keyed`), a scalar map value is that field and, with
|
|
862
|
+
* `keyAs`, the map key is that field rather than the id; `spread`, an item expands into the array at `path`, each
|
|
863
|
+
* element an object or a scalar named `as`, beside the item's fields `carry` names (field: path in the item). */
|
|
864
|
+
export type RefreshShape = { scalar?: string; value?: string; keyAs?: string; spread?: { path: string; as?: string; carry?: Record<string, string> } };
|
|
865
|
+
export type SeedCall = { browser?: string; method: string; path: string; body?: unknown; headers?: Record<string, string>; /** the instant the account made it, before the World's time: the call is made with the World clock frozen there */ at?: string };
|
|
778
866
|
|
|
779
867
|
/** Where a call is answered: the World's root, its clock, and the managed Postgres the runtime binds to the pack
|
|
780
868
|
* (`database`, the descriptor's `managedDatabase`), which a handler reaches as `ctx.engine` — READ ONLY in every
|
|
@@ -856,27 +944,47 @@ function worldSigningKey(service: string, root: string | undefined, at: string,
|
|
|
856
944
|
return made;
|
|
857
945
|
}
|
|
858
946
|
|
|
859
|
-
function mintId(m: DerivedManifest, resource: string, root?: string, at?: string): string {
|
|
947
|
+
function mintId(m: DerivedManifest, resource: string, root?: string, at?: string, snapshot?: readonly TwinResource[]): string {
|
|
860
948
|
if (!m.resources[resource]) throw new Error(`mint: ${resource} is not a resource ${m.vendor}'s manifest declares`);
|
|
861
949
|
const prefix = m.resources[resource]!.idPrefix;
|
|
862
950
|
const template = m.resources[resource]!.ids ?? m.ids.template;
|
|
863
|
-
if (template === '{uuid}') return mintUuid(m, resource, root);
|
|
951
|
+
if (template === '{uuid}') return mintUuid(m, resource, root, snapshot);
|
|
864
952
|
const snowflake = /^\{snowflake:(\d+)\}$/.exec(template);
|
|
865
|
-
if (snowflake) return mintSnowflake(m, BigInt(snowflake[1]!), root, at);
|
|
866
|
-
// `{letters:N}` and `{hex:N}`, alone
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
953
|
+
if (snowflake) return mintSnowflake(m, BigInt(snowflake[1]!), root, at, snapshot);
|
|
954
|
+
// `{letters:N}` and `{hex:N}`, alone, after the resource's prefix (`{prefix}{letters:16}`: Fly's `vol_…`), or after it
|
|
955
|
+
// and a separator (`{prefix}_{hex:48}`: OpenAI's `resp_67cc…`)
|
|
956
|
+
const letters = /^(?:\{prefix\}([_-]?))?\{letters:(\d+)\}$/.exec(template);
|
|
957
|
+
if (letters) return mintLetters(m, resource, Number(letters[2]), root, letters[1] !== undefined ? `${prefix}${letters[1]}` : '', snapshot);
|
|
958
|
+
const hex = /^(?:\{prefix\}([_-]?))?\{hex:(\d+)\}$/.exec(template);
|
|
959
|
+
if (hex) return mintHex(m, resource, Number(hex[2]), root, hex[1] !== undefined ? `${prefix}${hex[1]}` : '', snapshot);
|
|
960
|
+
const digits = /^\{digits:(\d+)\}$/.exec(template);
|
|
961
|
+
if (digits) {
|
|
962
|
+
const width = Number(digits[1]);
|
|
963
|
+
if (!Number.isInteger(width) || width < 1 || width > 77) throw new Error('mint: decimal width must be between 1 and 77');
|
|
964
|
+
const held = new Set((snapshot ? snapshot.filter((r) => r.type === storedType(m, resource)) : resourcesOfType(m.service, storedType(m, resource), root)).map((r) => r.id));
|
|
965
|
+
const floor = 10n ** BigInt(width - 1);
|
|
966
|
+
for (let n = held.size + 1; ; n++) {
|
|
967
|
+
const id = String(floor + BigInt('0x' + sha256(`${m.service}:${resource}:${n}`)) % (9n * floor));
|
|
968
|
+
if (!held.has(id)) return id;
|
|
969
|
+
if (n - held.size > 9 * Number(floor)) throw new Error('mint: decimal id space exhausted');
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
// `{ksuid}` alone, after the prefix, or after it and a separator (`{prefix}_{ksuid}`: Clerk's `user_2…`)
|
|
973
|
+
// `{ksuid:N}` gives the id N characters (a vendor's own length: OpenAI's `proj_` 24, `chatcmpl-` 29), its order kept
|
|
974
|
+
const ksuid = /^(?:\{prefix\}([_-]?))?\{ksuid(?::(\d+))?\}$/.exec(template);
|
|
975
|
+
// narrower than 11 characters a KSUID no longer holds its instant and mint count, so neither order nor uniqueness
|
|
976
|
+
// within a second: such an id is minted otherwise ({letters:N}, {hex:N})
|
|
977
|
+
if (ksuid && ksuid[2] && Number(ksuid[2]) < 11) throw new Error(`${m.service}: ${resource}'s id template ${template} is narrower than a KSUID's 11 ordered characters`);
|
|
978
|
+
if (ksuid) return mintKsuid(m, resource, at, root, ksuid[1] !== undefined ? `${prefix}${ksuid[1]}` : '', snapshot, ksuid[2] ? Number(ksuid[2]) : 27);
|
|
871
979
|
const [head, tail = ''] = template.split('{n}');
|
|
872
980
|
const pattern = new RegExp(`^${escapeRe(String(fill(head!, { prefix })))}(\\d+)${escapeRe(String(fill(tail, { prefix })))}$`);
|
|
873
|
-
let max =
|
|
981
|
+
let max = 0n;
|
|
874
982
|
// the one type, from the index that follows writes (a scan of the whole World per create grew with it)
|
|
875
|
-
for (const r of resourcesOfType(m.service, storedType(m, resource), root)) {
|
|
983
|
+
for (const r of (snapshot ? snapshot.filter((r) => r.type === storedType(m, resource)) : resourcesOfType(m.service, storedType(m, resource), root))) {
|
|
876
984
|
const hit = pattern.exec(r.id);
|
|
877
|
-
if (hit) max =
|
|
985
|
+
if (hit && BigInt(hit[1]!) > max) max = BigInt(hit[1]!);
|
|
878
986
|
}
|
|
879
|
-
return String(fill(template, { prefix, n: max +
|
|
987
|
+
return String(fill(template, { prefix, n: String(max + 1n) }));
|
|
880
988
|
}
|
|
881
989
|
|
|
882
990
|
/** The next snowflake (Discord's, X's): the milliseconds since the vendor's epoch shifted left 22 bits, the low bits an
|
|
@@ -886,13 +994,13 @@ function mintId(m: DerivedManifest, resource: string, root?: string, at?: string
|
|
|
886
994
|
/** The last snowflake minted per vendor and World in this process: two mints before the first is written (a message
|
|
887
995
|
* and its attachment) must not meet. */
|
|
888
996
|
const lastSnowflake = new Map<string, bigint>();
|
|
889
|
-
function mintSnowflake(m: DerivedManifest, epoch: bigint, root?: string, at?: string): string {
|
|
997
|
+
function mintSnowflake(m: DerivedManifest, epoch: bigint, root?: string, at?: string, snapshot?: readonly TwinResource[]): string {
|
|
890
998
|
const ms = BigInt(Date.parse(at ?? '') || 0);
|
|
891
999
|
const base = ms > epoch ? (ms - epoch) << 22n : 0n;
|
|
892
1000
|
let max = -1n;
|
|
893
1001
|
for (const [name, decl] of Object.entries(m.resources)) {
|
|
894
1002
|
if (!/^\{snowflake:\d+\}$/.test(decl.ids ?? m.ids.template)) continue;
|
|
895
|
-
for (const r of resourcesOfType(m.service, storedType(m, name), root)) if (/^\d+$/.test(r.id)) { const v = BigInt(r.id); if (v > max) max = v; }
|
|
1003
|
+
for (const r of (snapshot ? snapshot.filter((r) => r.type === storedType(m, name)) : resourcesOfType(m.service, storedType(m, name), root))) if (/^\d+$/.test(r.id)) { const v = BigInt(r.id); if (v > max) max = v; }
|
|
896
1004
|
}
|
|
897
1005
|
const key = `${m.service}\0${root ?? ''}`;
|
|
898
1006
|
const last = lastSnowflake.get(key) ?? -1n;
|
|
@@ -902,10 +1010,21 @@ function mintSnowflake(m: DerivedManifest, epoch: bigint, root?: string, at?: st
|
|
|
902
1010
|
return String(id);
|
|
903
1011
|
}
|
|
904
1012
|
|
|
1013
|
+
/** The next KSUID for a resource (Clerk's and Svix's ids, `ksuidFrom`): the World's instant, then the type's mint
|
|
1014
|
+
* count, so a frozen clock still lists them as made; past any the World already holds. */
|
|
1015
|
+
function mintKsuid(m: DerivedManifest, resource: string, at: string | undefined, root?: string, prefix = '', snapshot?: readonly TwinResource[], width = 27): string {
|
|
1016
|
+
const held = new Set((snapshot ? snapshot.filter((r) => r.type === storedType(m, resource)) : resourcesOfType(m.service, storedType(m, resource), root)).map((r) => r.id));
|
|
1017
|
+
const seconds = Math.floor((Date.parse(at ?? '') || 0) / 1000);
|
|
1018
|
+
for (let n = held.size + 1; ; n++) {
|
|
1019
|
+
const id = `${prefix}${ksuidFrom(seconds, n, `${m.service}:${resource}:${n}`, width)}`;
|
|
1020
|
+
if (!held.has(id)) return id;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
905
1024
|
/** The next id of `count` lowercase letters for a resource, derived from the resource and the count before it, past
|
|
906
1025
|
* any the World already holds. */
|
|
907
|
-
function mintLetters(m: DerivedManifest, resource: string, count: number, root?: string, prefix = ''): string {
|
|
908
|
-
const held = new Set(resourcesOfType(m.service, storedType(m, resource), root).map((r) => r.id));
|
|
1026
|
+
function mintLetters(m: DerivedManifest, resource: string, count: number, root?: string, prefix = '', snapshot?: readonly TwinResource[]): string {
|
|
1027
|
+
const held = new Set((snapshot ? snapshot.filter((r) => r.type === storedType(m, resource)) : resourcesOfType(m.service, storedType(m, resource), root)).map((r) => r.id));
|
|
909
1028
|
for (let n = held.size + 1; ; n++) {
|
|
910
1029
|
const id = `${prefix}${lettersFrom(`${m.service}:${resource}:${n}`, count)}`;
|
|
911
1030
|
if (!held.has(id)) return id;
|
|
@@ -914,8 +1033,8 @@ function mintLetters(m: DerivedManifest, resource: string, count: number, root?:
|
|
|
914
1033
|
|
|
915
1034
|
/** The next hex id for a resource: N hex digits derived from the resource and the count before it, past any the World
|
|
916
1035
|
* already holds (a Cloudflare id, 32). */
|
|
917
|
-
function mintHex(m: DerivedManifest, resource: string, count: number, root?: string, prefix = ''): string {
|
|
918
|
-
const held = new Set(resourcesOfType(m.service, storedType(m, resource), root).map((r) => r.id));
|
|
1036
|
+
function mintHex(m: DerivedManifest, resource: string, count: number, root?: string, prefix = '', snapshot?: readonly TwinResource[]): string {
|
|
1037
|
+
const held = new Set((snapshot ? snapshot.filter((r) => r.type === storedType(m, resource)) : resourcesOfType(m.service, storedType(m, resource), root)).map((r) => r.id));
|
|
919
1038
|
for (let n = held.size + 1; ; n++) {
|
|
920
1039
|
const id = `${prefix}${sha256(`${m.service}:${resource}:${n}`).slice(0, count)}`;
|
|
921
1040
|
if (!held.has(id)) return id;
|
|
@@ -924,28 +1043,43 @@ function mintHex(m: DerivedManifest, resource: string, count: number, root?: str
|
|
|
924
1043
|
|
|
925
1044
|
/** The next UUID for a resource: a version-4-shaped UUID derived from the resource and the count before it, past any
|
|
926
1045
|
* the World already holds. */
|
|
927
|
-
function mintUuid(m: DerivedManifest, resource: string, root?: string): string {
|
|
1046
|
+
function mintUuid(m: DerivedManifest, resource: string, root?: string, snapshot?: readonly TwinResource[]): string {
|
|
928
1047
|
const type = storedType(m, resource);
|
|
929
|
-
const held = new Set(resourcesOfType(m.service, type, root).map((r) => r.id));
|
|
1048
|
+
const held = new Set((snapshot ? snapshot.filter((r) => r.type === type) : resourcesOfType(m.service, type, root)).map((r) => r.id));
|
|
930
1049
|
for (let n = held.size + 1; ; n++) {
|
|
931
1050
|
const id = uuidFrom(`${m.service}:${resource}:${n}`);
|
|
932
1051
|
if (!held.has(id)) return id;
|
|
933
1052
|
}
|
|
934
1053
|
}
|
|
935
1054
|
|
|
936
|
-
function expand(m: DerivedManifest, resource: string, body: Record<string, unknown>, paths: string[], root?: string): Record<string, unknown> {
|
|
1055
|
+
function expand(m: DerivedManifest, resource: string, body: Record<string, unknown>, paths: string[], root?: string, within: ReadonlySet<string> = new Set()): Record<string, unknown> {
|
|
1056
|
+
body = omitView(m, resource, body);
|
|
937
1057
|
const embeds = m.resources[resource]?.embeds ?? {};
|
|
1058
|
+
// the fields the vendor always embeds join the caller's paths; a resource already being embedded above is not embedded
|
|
1059
|
+
// again inside itself (a cycle answers the id)
|
|
1060
|
+
const always = within.has(resource) ? [] : (m.resources[resource]?.alwaysEmbedded ?? []);
|
|
1061
|
+
const inner = new Set([...within, resource]);
|
|
938
1062
|
const out = { ...body };
|
|
939
|
-
|
|
1063
|
+
const idKey = m.resources[resource]?.idAs ?? 'id';
|
|
1064
|
+
const ownId = typeof body[idKey] === 'string' ? body[idKey] as string : undefined;
|
|
1065
|
+
for (const [field, c] of within.has(resource) ? [] : Object.entries(m.resources[resource]?.collections ?? {})) {
|
|
1066
|
+
if (!ownId) continue;
|
|
1067
|
+
const childPaths = paths.filter((p) => p.startsWith(`${field}.data.`)).map((p) => p.slice(field.length + 6));
|
|
1068
|
+
const data = stored(m, c.resource, root).filter((r) => (r as Record<string, unknown>)[c.by] === ownId)
|
|
1069
|
+
.map((r) => expand(m, c.resource, view(m, c.resource, r), childPaths, root, inner));
|
|
1070
|
+
const fill = (v: unknown): unknown => v === '{count}' ? data.length : typeof v === 'string' ? v.replaceAll('{id}', ownId) : v;
|
|
1071
|
+
out[field] = { ...Object.fromEntries(Object.entries(c.list ?? {}).map(([k, v]) => [k, fill(v)])), data };
|
|
1072
|
+
}
|
|
1073
|
+
for (const path of [...new Set([...always, ...paths])]) {
|
|
940
1074
|
const [head, ...rest] = path.split('.');
|
|
941
1075
|
const target = embeds[head!];
|
|
942
1076
|
const id = out[head!];
|
|
943
1077
|
if (!target) continue;
|
|
944
1078
|
// an object already embedded (an earlier path's) is walked further, not fetched again
|
|
945
|
-
if (id && typeof id === 'object' && !Array.isArray(id)) {
|
|
1079
|
+
if (id && typeof id === 'object' && !Array.isArray(id)) { out[head!] = expand(m, target, id as Record<string, unknown>, rest.length ? [rest.join('.')] : [], root, inner); continue; }
|
|
946
1080
|
if (typeof id !== 'string') continue;
|
|
947
1081
|
const hit = stored(m, target, root).find((r) => r.id === id);
|
|
948
|
-
if (hit) out[head!] =
|
|
1082
|
+
if (hit) out[head!] = expand(m, target, view(m, target, hit), rest.length ? [rest.join('.')] : [], root, inner);
|
|
949
1083
|
}
|
|
950
1084
|
return out;
|
|
951
1085
|
}
|
|
@@ -955,8 +1089,8 @@ function expandPaths(m: DerivedManifest, params: Record<string, unknown>): strin
|
|
|
955
1089
|
return Array.isArray(raw) ? raw.map(String) : typeof raw === 'string' ? [raw] : [];
|
|
956
1090
|
}
|
|
957
1091
|
|
|
958
|
-
async function write(m: DerivedManifest, call: DerivedCall, resource: string, id: string, fields: Record<string, unknown>, operation: string, params: Record<string, unknown>, root: string | undefined, occurredAt: string): Promise<Record<string, unknown>> {
|
|
959
|
-
return (await writeDetailed(m, call, resource, id, fields, operation, params, root, occurredAt)).body;
|
|
1092
|
+
async function write(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<Record<string, unknown>> {
|
|
1093
|
+
return (await writeDetailed(m, call, resource, id, fields, operation, params, root, occurredAt, checks)).body;
|
|
960
1094
|
}
|
|
961
1095
|
|
|
962
1096
|
/** The request a context was opened for, as it came (contextFor): its path parameters, the query names the operation
|
|
@@ -969,8 +1103,9 @@ const sentOf = new WeakMap<DerivedCall, SentRequest>();
|
|
|
969
1103
|
export const LANE_HEADER = 'x-volter-lane';
|
|
970
1104
|
function sentRequest(m: DerivedManifest, call: DerivedCall, body: unknown): SentRequest {
|
|
971
1105
|
const url = new URL(call.request.url);
|
|
972
|
-
const named = [...(m.headers ?? []), ...(m.version ? [m.version.header] : [])];
|
|
973
|
-
const
|
|
1106
|
+
const named = [...(m.headers ?? []), ...(m.version ? [m.version.header] : [])].map((h) => h.toLowerCase());
|
|
1107
|
+
const recorded = (h: string): boolean => named.some((n) => (n.endsWith('*') ? h.startsWith(n.slice(0, -1)) : h === n));
|
|
1108
|
+
const headers = Object.fromEntries([...call.request.headers].filter(([h]) => recorded(h.toLowerCase())).map(([h, v]) => [h.toLowerCase(), v]));
|
|
974
1109
|
const lane = call.request.headers.get(LANE_HEADER) ?? undefined;
|
|
975
1110
|
const requestId = call.request.headers.get('x-twins-request-id') ?? undefined;
|
|
976
1111
|
const declared = new Set((call.operation.query ?? []).map((q) => q.name));
|
|
@@ -987,6 +1122,9 @@ function sentRequest(m: DerivedManifest, call: DerivedCall, body: unknown): Sent
|
|
|
987
1122
|
}
|
|
988
1123
|
/** What a write records of the call that made it: the surface operation and the request as it came, or which of the
|
|
989
1124
|
* World's own doors made it (a door or a screen, the gap, the clock), which a perform never sends. */
|
|
1125
|
+
/** An operation whose body is its payload's bytes, one `blob` field: recorded as it came and never read as JSON
|
|
1126
|
+
* ("What the derived core records with a write"). */
|
|
1127
|
+
const bytesPayload = (op: DerivedCall['operation']): boolean => op.body?.length === 1 && op.body[0]!.type === 'blob';
|
|
990
1128
|
/** A body as it came: `{ $text }` when its bytes are UTF-8, else `{ $base64 }`. */
|
|
991
1129
|
async function rawBody(request: Request): Promise<{ $text: string } | { $base64: string }> {
|
|
992
1130
|
const bytes = new Uint8Array(await request.clone().arrayBuffer());
|
|
@@ -1017,14 +1155,15 @@ function recordedInput(m: DerivedManifest, call: DerivedCall, merged: Record<str
|
|
|
1017
1155
|
}
|
|
1018
1156
|
if (id === 'clock') return { clock: true };
|
|
1019
1157
|
if (id === 'unmatched') return { gap: true, ...asSent };
|
|
1020
|
-
if ((m.doors ?? []).some((d) => d.id === id) || (m.screens ?? []).some((sc) => sc.id === id) || id === 'machine-pool' || id.startsWith('socket:')) return { door: id, ...asSent };
|
|
1158
|
+
if ((m.doors ?? []).some((d) => d.id === id) || (m.screens ?? []).some((sc) => sc.id === id) || (m.accountSetup ?? []).some((a) => a.operation === id) || id === 'machine-pool' || id.startsWith('socket:')) return { door: id, ...asSent };
|
|
1021
1159
|
return { operationId: id, ...asSent };
|
|
1022
1160
|
}
|
|
1023
1161
|
|
|
1024
1162
|
/** The scope each call is served in (serveCore, contextFor), for the contexts a write opens for its hooks. */
|
|
1025
1163
|
const scopeOfCall = new WeakMap<DerivedCall, CoreScope>();
|
|
1026
1164
|
|
|
1027
|
-
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): Promise<{ body: Record<string, unknown>; id: string; vendorData?: unknown }> {
|
|
1165
|
+
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 }> {
|
|
1166
|
+
const before = stateBeforeWrite(m, resource, id, root);
|
|
1028
1167
|
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; };
|
|
1029
1168
|
const { resource: row, result } = await applyTwinWrite(
|
|
1030
1169
|
m.service,
|
|
@@ -1036,6 +1175,38 @@ async function writeDetailed(m: DerivedManifest, call: DerivedCall, resource: st
|
|
|
1036
1175
|
: { operation, subjectType: storedType(m, resource), subjectId: id, fields, input: await withFiles(recordedInput(m, call, params)), occurredAt, actor: { kind: 'agent', ...(m.identity ? { id: m.identity(call.request.headers.get('authorization'), root) } : {}) } },
|
|
1037
1176
|
root,
|
|
1038
1177
|
);
|
|
1178
|
+
observeStateWrite(m, result, before, root, checks);
|
|
1179
|
+
return finishWrite(m, call, resource, row, result, operation, params, root, occurredAt);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
async function atomicWrite(m: DerivedManifest, call: DerivedCall, resource: string,
|
|
1183
|
+
decide: (snapshot: readonly TwinResource[]) => { id: string; fields: Record<string, unknown> } | undefined,
|
|
1184
|
+
operation: string, params: Record<string, unknown>, root: string | undefined, occurredAt: string, unchangedId?: string, checks?: StateChecks,
|
|
1185
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
1186
|
+
const input = recordedInput(m, call, params);
|
|
1187
|
+
const files = input.operationId ? await keptFiles(m, call, root) : undefined;
|
|
1188
|
+
let before: TwinResource | undefined;
|
|
1189
|
+
const committed = await applyTwinWriteAtomic(m.service, (snapshot) => {
|
|
1190
|
+
const selected = decide(snapshot);
|
|
1191
|
+
if (selected && transitionObserver) before = snapshot.find((r) => r.type === storedType(m, resource) && r.id === selected.id);
|
|
1192
|
+
return selected ? { kind: 'write', value: selected.id, write: {
|
|
1193
|
+
operation, subjectType: storedType(m, resource), subjectId: selected.id, fields: selected.fields,
|
|
1194
|
+
input: files ? { ...input, files } : input, occurredAt, actor: { kind: 'agent' as const, ...(m.identity ? { id: m.identity(call.request.headers.get('authorization'), root) } : {}) },
|
|
1195
|
+
} } : { kind: 'skip', value: undefined };
|
|
1196
|
+
}, root);
|
|
1197
|
+
if (!committed.result || !committed.value) {
|
|
1198
|
+
const row = unchangedId ? stored(m, resource, root).find((r) => r.id === unchangedId) : undefined;
|
|
1199
|
+
return row ? view(m, resource, row) : undefined;
|
|
1200
|
+
}
|
|
1201
|
+
observeStateWrite(m, committed.result, before, root, checks);
|
|
1202
|
+
const row = resourcesOfType(m.service, storedType(m, resource), root).find((r) => r.id === resolveSubjectId(m.service, storedType(m, resource), committed.value!, root))!;
|
|
1203
|
+
return (await finishWrite(m, call, resource, row, committed.result, operation, params, root, occurredAt)).body;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
async function finishWrite(m: DerivedManifest, call: DerivedCall, resource: string, row: TwinResource,
|
|
1207
|
+
result: { externalId?: string; vendorData?: unknown }, operation: string, params: Record<string, unknown>,
|
|
1208
|
+
root: string | undefined, occurredAt: string,
|
|
1209
|
+
): Promise<{ body: Record<string, unknown>; id: string; vendorData?: unknown }> {
|
|
1039
1210
|
await keepCounts(m, call, resource, row as unknown as Record<string, unknown>, params, root, occurredAt);
|
|
1040
1211
|
const body = view(m, resource, row);
|
|
1041
1212
|
// the write's own context for its hooks, in the scope the call was served in (a walk's application answers, a tenant,
|
|
@@ -1045,9 +1216,11 @@ async function writeDetailed(m: DerivedManifest, call: DerivedCall, resource: st
|
|
|
1045
1216
|
if (m.events) {
|
|
1046
1217
|
const events = m.events;
|
|
1047
1218
|
const write = { operation, storedType: storedType(m, resource), body, ...(root !== undefined ? { root } : {}), occurredAt, request: call.request };
|
|
1048
|
-
await deliverEvents(m.service, events, write, async (type) => (events.render ? events.render(await context(), write, type) : body), undefined, events.values ? async (type) => events.values!(await context(), write, type) : undefined
|
|
1219
|
+
await deliverEvents(m.service, events, write, async (type) => (events.render ? events.render(await context(), write, type) : body), undefined, events.values ? async (type) => events.values!(await context(), write, type) : undefined);
|
|
1049
1220
|
}
|
|
1050
1221
|
if (m.sockets?.length) socketWrite(m.service, root, { operation, storedType: storedType(m, resource), body, ...(root !== undefined ? { root } : {}), occurredAt, request: call.request });
|
|
1222
|
+
// a read held until the World writes (held-reads.ts) hears it
|
|
1223
|
+
heldReadsWrite(m.service, root, storedType(m, resource));
|
|
1051
1224
|
// the subject's id as it stands after the write: the vendor's, when a live head minted one
|
|
1052
1225
|
return { body, id: result.externalId ?? row.id, ...(result.vendorData !== undefined ? { vendorData: result.vendorData } : {}) };
|
|
1053
1226
|
}
|
|
@@ -1059,13 +1232,13 @@ async function keepCounts(m: DerivedManifest, call: DerivedCall, resource: strin
|
|
|
1059
1232
|
for (const [field, c] of Object.entries(decl.counts ?? {})) {
|
|
1060
1233
|
if (storedType(m, c.of) !== storedType(m, resource) || row[c.by] === undefined || row[c.by] === null) continue;
|
|
1061
1234
|
const pid = String(row[c.by]);
|
|
1062
|
-
const held = stored(m, parent, root).find((r) => r.id === pid) as Record<string, unknown> | undefined;
|
|
1235
|
+
const held = stored(m, parent, root).find((r) => String(c.parentKey ? ownFields(r)[c.parentKey] : r.id) === pid) as Record<string, unknown> | undefined;
|
|
1063
1236
|
if (!held) continue;
|
|
1064
1237
|
const n = stored(m, c.of, root).filter((r) => {
|
|
1065
1238
|
const x = r as Record<string, unknown>;
|
|
1066
|
-
return String(x[c.by]) === pid && (c.present === undefined || (x[c.present] !== undefined && x[c.present] !== null));
|
|
1239
|
+
return String(x[c.by]) === pid && (c.present === undefined || (x[c.present] !== undefined && x[c.present] !== null)) && Object.entries(c.unless ?? {}).every(([key, value]) => ownFields(r)[key] !== value);
|
|
1067
1240
|
}).length + (c.plus ?? 0);
|
|
1068
|
-
if (held[field] !== n) await writeDetailed(m, call, parent,
|
|
1241
|
+
if (held[field] !== n) await writeDetailed(m, call, parent, String(held.id), { [field]: n }, `${storedType(m, parent)}.counted`, params, root, occurredAt);
|
|
1069
1242
|
}
|
|
1070
1243
|
}
|
|
1071
1244
|
}
|
|
@@ -1079,7 +1252,7 @@ const encodeOffset = (o: number): string => btoa(JSON.stringify({ o }));
|
|
|
1079
1252
|
function decodeOffset(cursor: unknown): number {
|
|
1080
1253
|
if (typeof cursor !== 'string' || !cursor) return 0;
|
|
1081
1254
|
try {
|
|
1082
|
-
const o = (
|
|
1255
|
+
const o = (parseExactJson(atob(cursor)) as { o?: unknown }).o;
|
|
1083
1256
|
return typeof o === 'number' && o >= 0 ? o : 0;
|
|
1084
1257
|
} catch {
|
|
1085
1258
|
return 0;
|
|
@@ -1091,58 +1264,65 @@ function decodeOffset(cursor: unknown): number {
|
|
|
1091
1264
|
/** What a coverage measurement sees of a pack's declared state logic: each move a request made and each refusal a
|
|
1092
1265
|
* guard gave, by the transition that decided it. Nothing is observed unless a measurement installs an observer
|
|
1093
1266
|
* (scripts/life-coverage.ts); serving never depends on it. */
|
|
1094
|
-
export type
|
|
1267
|
+
export type TransitionEntry = { action: TwinAction; field: string; actor: Actor };
|
|
1268
|
+
export type TransitionObserver = (decl: StateField, transition: Transition, outcome: 'moved' | 'refused', from: string, entry?: TransitionEntry) => void;
|
|
1095
1269
|
let transitionObserver: TransitionObserver | undefined;
|
|
1096
1270
|
export function observeTransitions(observer: TransitionObserver | undefined): void {
|
|
1097
1271
|
transitionObserver = observer;
|
|
1098
1272
|
}
|
|
1099
1273
|
|
|
1274
|
+
type StateCheck = { transition: Transition; operation: string; from: string; to: string; actor: Actor };
|
|
1275
|
+
type StateChecks = Map<string, StateCheck[]>;
|
|
1276
|
+
const stateCheckKey = (type: string, field: string): string => JSON.stringify([type, field]);
|
|
1277
|
+
const stateValue = (row: Record<string, unknown>, field: string): unknown => field.split('.').reduce<unknown>((value, part) => value && typeof value === 'object' ? (value as Record<string, unknown>)[part] : undefined, row);
|
|
1278
|
+
function stateBeforeWrite(m: DerivedManifest, resource: string, id: string, root?: string): TwinResource | undefined {
|
|
1279
|
+
if (!transitionObserver) return undefined;
|
|
1280
|
+
const type = storedType(m, resource);
|
|
1281
|
+
const key = resolveSubjectId(m.service, type, id, root);
|
|
1282
|
+
return resourcesOfType(m.service, type, root).find((row) => row.id === key);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/** Observe the committed entry, not a legality probe's display id. Only a move the machine checks
|
|
1286
|
+
* for this entry's actual states, operation and actor is reported; no serving depends on measurement. */
|
|
1287
|
+
function observeStateWrite(m: DerivedManifest, result: { actionId: string; status: 'performed' | 'replayed' }, before: TwinResource | undefined, root?: string, checks?: StateChecks): void {
|
|
1288
|
+
if (!transitionObserver) return;
|
|
1289
|
+
const action = listActions(m.service, root).find((a) => a.id === result.actionId);
|
|
1290
|
+
if (!action?.fields) return;
|
|
1291
|
+
const prior = before ? ownFields(before) : undefined;
|
|
1292
|
+
const next = { ...prior, ...action.fields };
|
|
1293
|
+
const decisions = new Map<string, StateCheck[]>();
|
|
1294
|
+
for (const [resource, decl] of Object.entries(m.resources)) {
|
|
1295
|
+
if ((decl.storedAs ?? resource) !== action.subject.type) continue;
|
|
1296
|
+
for (const [field, machine] of Object.entries(decl.state ?? {})) {
|
|
1297
|
+
if (!(field.split('.')[0]! in action.fields)) continue;
|
|
1298
|
+
const key = stateCheckKey(action.subject.type, field);
|
|
1299
|
+
// Aliases of one stored field use the same consumed evidence on this one entry.
|
|
1300
|
+
if (!decisions.has(key)) { decisions.set(key, checks?.get(key) ?? []); checks?.delete(key); }
|
|
1301
|
+
if (result.status === 'replayed') continue;
|
|
1302
|
+
const current = prior ? stateValue(prior, field) : undefined, target = stateOf(machine, stateValue(next, field));
|
|
1303
|
+
const from = stateOf(machine, current);
|
|
1304
|
+
const proof = decisions.get(key)!.find((c) => machine.transitions.includes(c.transition) && c.from === from && c.to === target);
|
|
1305
|
+
// Initial-state and same-state moves still need an explicit check and a committed field write.
|
|
1306
|
+
if ((!prior || from === target) && !proof) continue;
|
|
1307
|
+
const actor = proof?.actor ?? (action.actor?.kind === 'system' || action.input?.clock === true ? 'vendor' : 'api');
|
|
1308
|
+
const operation = typeof action.input?.operationId === 'string' ? action.input.operationId : proof?.operation ?? (action.input?.clock === true ? 'clock' : action.operation ?? '');
|
|
1309
|
+
transitionFor(field, machine, operation, current, target, action.subject.id, actor, { action, field, actor });
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1100
1314
|
/** The transition a request asks for on one state field, or the vendor's refusal when none applies. */
|
|
1101
1315
|
/** What a declared machine says to one move: the transition that allows it, or the refusal it gives (and
|
|
1102
1316
|
* nothing when it declares neither). The derived core and `legal` ask it; so does an engine that is not
|
|
1103
1317
|
* HTTP-shaped (a line protocol's session), so one machine rules every wire. */
|
|
1104
|
-
|
|
1105
|
-
* a success resets the failure count and writes its fields; a failure counts, and a run of them makes the declared
|
|
1106
|
-
* move, asked of the endpoint's machine as the vendor's (a move the machine does not allow is not made). Read fresh
|
|
1107
|
-
* and written in one move, since the answer arrives after the write's own and answers may arrive together. */
|
|
1108
|
-
async function deliveryOutcome(m: DerivedManifest, answer: DeliveryAnswer, root?: string): Promise<void> {
|
|
1109
|
-
const o = answer.kind.outcome!;
|
|
1110
|
-
const type = answer.kind.storedAs;
|
|
1111
|
-
const ok = answer.status >= 200 && answer.status < 300;
|
|
1112
|
-
// the read and the count are one move under the action lock, so two answers at once count twice
|
|
1113
|
-
await runAsVendorMove(() => applyTwinWriteAtomic(m.service, (resources) => {
|
|
1114
|
-
const row = (resources as unknown as Array<Record<string, unknown>>).find((r) => r.type === type && String(r.id) === String(answer.row.id));
|
|
1115
|
-
if (!row || row.deleted === true) return { kind: 'skip', value: undefined };
|
|
1116
|
-
const fields: Record<string, unknown> = {};
|
|
1117
|
-
let operation = `${type}.delivered`;
|
|
1118
|
-
if (ok) {
|
|
1119
|
-
fields[o.failures] = 0;
|
|
1120
|
-
const seconds = Math.floor((Date.parse(answer.occurredAt) || 0) / 1000);
|
|
1121
|
-
for (const [k, v] of Object.entries(o.success ?? {})) fields[k] = v === '$time.iso' ? answer.occurredAt : v === '$time.s' ? seconds : v;
|
|
1122
|
-
} else {
|
|
1123
|
-
const failures = (Number(row[o.failures]) || 0) + 1;
|
|
1124
|
-
fields[o.failures] = failures;
|
|
1125
|
-
operation = `${type}.delivery-failed`;
|
|
1126
|
-
const exempt = o.unless !== undefined && row[o.unless.field] === o.unless.equals;
|
|
1127
|
-
if (o.move && o.after !== undefined && failures >= o.after && !exempt) {
|
|
1128
|
-
const decl = Object.values(m.resources).find((r) => (r.storedAs ?? '') === type) ?? m.resources[type];
|
|
1129
|
-
const machine = decl?.state?.[o.move.field];
|
|
1130
|
-
const { move } = machine ? transitionFor(o.move.field, machine, o.move.operation, row[o.move.field], o.move.to, String(row.id), 'vendor') : {};
|
|
1131
|
-
if (move) { fields[o.move.field] = o.move.to; operation = o.move.operation; }
|
|
1132
|
-
}
|
|
1133
|
-
}
|
|
1134
|
-
return { kind: 'write', value: undefined, write: { operation, subjectType: type, subjectId: String(row.id), fields, occurredAt: answer.occurredAt, actor: { kind: 'system' } } };
|
|
1135
|
-
}, root));
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
|
-
export function transitionFor(field: string, decl: StateField, operationId: string, current: unknown, requested: unknown, id?: string, actor: Actor = 'api'): { move?: Transition; refusal?: ErrorSpec } {
|
|
1318
|
+
export function transitionFor(field: string, decl: StateField, operationId: string, current: unknown, requested: unknown, id?: string, actor: Actor = 'api', entry?: TransitionEntry): { move?: Transition; refusal?: ErrorSpec } {
|
|
1139
1319
|
const now = stateOf(decl, current);
|
|
1140
|
-
const candidates = decl.transitions.filter((t) => (t.actor ?? 'api') === actor && (t.operation ? t.operation === operationId : true) && (requested === undefined || (t.to ?? now) === String(requested)));
|
|
1141
|
-
const move = candidates.find((t) => t.from === '*' || t.from.includes(now));
|
|
1142
|
-
if (move) { transitionObserver?.(decl, move, 'moved', now); return { move }; }
|
|
1143
|
-
const refused = candidates.find((t) => t.refusals?.[now]) ?? candidates.find((t) => t.refusal);
|
|
1320
|
+
const candidates = decl.transitions.filter((t) => (t.actor ?? 'api') === actor && (t.operation ? t.operation === operationId : true) && (requested === undefined || (t.to ?? now) === String(requested) || (t.to === undefined && t.refusal !== undefined)));
|
|
1321
|
+
const move = candidates.find((t) => !(t.to === undefined && t.refusal !== undefined) && (t.from === '*' || t.from.includes(now)));
|
|
1322
|
+
if (move) { transitionObserver?.(decl, move, 'moved', now, entry); return { move }; }
|
|
1323
|
+
const refused = candidates.find((t) => t.refusals?.[now]) ?? candidates.find((t) => t.refusal && (t.to !== undefined || t.from === '*' || t.from.includes(now)));
|
|
1144
1324
|
const r = refused?.refusals?.[now] ?? refused?.refusal;
|
|
1145
|
-
if (refused && r) transitionObserver?.(decl, refused, 'refused', now);
|
|
1325
|
+
if (refused && r) transitionObserver?.(decl, refused, 'refused', now, entry);
|
|
1146
1326
|
if (r) return { refusal: { status: r.status, message: String(fill(r.message, { from: current, to: refused!.to ?? now, field, id: id ?? '' })), ...(r.code ? { code: r.code } : {}) } };
|
|
1147
1327
|
return {};
|
|
1148
1328
|
}
|
|
@@ -1277,9 +1457,11 @@ function arrangeRows(m: DerivedManifest, resource: string, params: Record<string
|
|
|
1277
1457
|
const indexed = rows.map((row, i) => ({ row, i }));
|
|
1278
1458
|
indexed.sort((a, b) => {
|
|
1279
1459
|
const x = a.row[order.field]; const y = b.row[order.field];
|
|
1280
|
-
const byValue = !byText || typeof x === 'number' || typeof y === 'number' ?
|
|
1281
|
-
// ties by
|
|
1282
|
-
|
|
1460
|
+
const byValue = !byText || typeof x === 'number' || typeof y === 'number' ? compareExactNumbers(x ?? 0, y ?? 0) : String(x ?? '').localeCompare(String(y ?? ''));
|
|
1461
|
+
// ties (two made in one instant) by the order they were written: the rows come in the tree's order, which keeps each
|
|
1462
|
+
// subject where it was first written, so this is the creation order whatever the id template (an ordered KSUID or
|
|
1463
|
+
// count, or a hash-derived uuid, hex or letters id whose text says nothing of when it was made)
|
|
1464
|
+
const d = byValue || a.i - b.i;
|
|
1283
1465
|
return order.direction === 'desc' ? -d : d;
|
|
1284
1466
|
});
|
|
1285
1467
|
return indexed.map((x) => x.row);
|
|
@@ -1287,70 +1469,98 @@ function arrangeRows(m: DerivedManifest, resource: string, params: Record<string
|
|
|
1287
1469
|
|
|
1288
1470
|
/** A page of arranged rows, answered in the manifest's list envelope with its paging (cursors, pages, offsets). */
|
|
1289
1471
|
function pageRows(m: DerivedManifest, op: DerivedOperation, call: DerivedCall, params: Record<string, unknown>, rows: Array<Record<string, unknown>>, resource: string, root: string | undefined, paths: string[]): Response {
|
|
1290
|
-
const
|
|
1291
|
-
|
|
1472
|
+
const list = m.list;
|
|
1473
|
+
if (!list) return vendorError(m, m.gap ?? m.notFound);
|
|
1474
|
+
const size = list.limits?.[op.id] ?? list.limit;
|
|
1475
|
+
const asked = params[list.limit.param];
|
|
1292
1476
|
const n = asked === undefined || asked === '' ? size.default : Number(asked);
|
|
1293
1477
|
const limit = Number.isFinite(n) ? Math.min(size.max, Math.max(1, Math.trunc(n))) : size.default;
|
|
1294
|
-
const after =
|
|
1295
|
-
const before =
|
|
1478
|
+
const after = list.after ? params[list.after] : undefined;
|
|
1479
|
+
const before = list.before ? params[list.before] : undefined;
|
|
1296
1480
|
let from = 0;
|
|
1297
1481
|
let to: number;
|
|
1298
1482
|
if (typeof before === 'string') {
|
|
1299
1483
|
const at = rows.findIndex((r) => r.id === before);
|
|
1300
|
-
to = at === -1 ? (
|
|
1484
|
+
to = at === -1 ? (list.unknownCursor === 'end' ? 0 : rows.length) : at;
|
|
1301
1485
|
from = Math.max(0, to - limit);
|
|
1302
1486
|
} else {
|
|
1303
1487
|
if (typeof after === 'string') {
|
|
1304
1488
|
const at = rows.findIndex((r) => r.id === after);
|
|
1305
|
-
from = at === -1 ? (
|
|
1489
|
+
from = at === -1 ? (list.unknownCursor === 'end' ? rows.length : 0) : at + 1;
|
|
1306
1490
|
}
|
|
1307
1491
|
to = from + limit;
|
|
1308
1492
|
}
|
|
1309
|
-
const page = rows.slice(from, to).map((r) =>
|
|
1493
|
+
const page = rows.slice(from, to).map((r) => expand(m, resource, r, paths.filter((p) => p.startsWith('data.')).map((p) => p.slice(5)), root));
|
|
1310
1494
|
const hasMore = typeof before === 'string' ? from > 0 : to < rows.length;
|
|
1311
1495
|
const url = op.path.replace(/\{[^}]+\}/g, (p) => call.params[p.slice(1, -1)] ?? p);
|
|
1312
|
-
if (
|
|
1313
|
-
const n = Math.max(1, Math.floor(Number(params[
|
|
1314
|
-
const slice = rows.slice((n - 1) * limit, n * limit);
|
|
1496
|
+
if (list.page) {
|
|
1497
|
+
const n = Math.max(1, Math.floor(Number(params[list.page.param]) || 1));
|
|
1498
|
+
const slice = rows.slice((n - 1) * limit, n * limit).map((r) => expand(m, resource, r, paths.filter((p) => p.startsWith('data.')).map((p) => p.slice(5)), root));
|
|
1315
1499
|
const last = Math.max(1, Math.ceil(rows.length / limit));
|
|
1316
1500
|
const headers = new Headers();
|
|
1317
|
-
if (
|
|
1501
|
+
if (list.page.link && rows.length > limit) {
|
|
1318
1502
|
const at = (k: number): string => {
|
|
1319
1503
|
const u = new URL(call.request.url);
|
|
1320
|
-
u.searchParams.set(
|
|
1504
|
+
u.searchParams.set(list.page!.param, String(k));
|
|
1321
1505
|
return u.toString();
|
|
1322
1506
|
};
|
|
1323
1507
|
const rels = [...(n > 1 ? [`<${at(n - 1)}>; rel="prev"`] : []), ...(n < last ? [`<${at(n + 1)}>; rel="next"`, `<${at(last)}>; rel="last"`] : []), ...(n > 1 ? [`<${at(1)}>; rel="first"`] : [])];
|
|
1324
1508
|
if (rels.length) headers.set('link', rels.join(', '));
|
|
1325
1509
|
}
|
|
1326
1510
|
// `{total_count}` the count before paging, `{max_page}` the number of pages (none for an empty list: Polar's pagination)
|
|
1327
|
-
return Response.json(fill(
|
|
1511
|
+
return Response.json(fill(list.envelope, { data: slice, next_cursor: null, key: op.answers?.key ?? 'data', total_count: rows.length, max_page: Math.ceil(rows.length / limit) }), { headers });
|
|
1328
1512
|
}
|
|
1329
|
-
if (
|
|
1330
|
-
const skip = Math.max(0, Math.trunc(Number(params[
|
|
1513
|
+
if (list.offset) {
|
|
1514
|
+
const skip = Math.max(0, Math.trunc(Number(params[list.offset.param]) || 0));
|
|
1331
1515
|
const slice = rows.slice(skip, skip + limit);
|
|
1332
1516
|
const key = op.answers?.key ?? 'data';
|
|
1333
|
-
return Response.json({ ...(m.success ?? {}), [key]: slice, ...(fill(
|
|
1517
|
+
return Response.json({ ...(m.success ?? {}), [key]: slice, ...(fill(list.envelopes?.[op.id] ?? list.envelope, { data: slice, total_count: rows.length, key }) as object) });
|
|
1334
1518
|
}
|
|
1335
|
-
if (
|
|
1336
|
-
|
|
1519
|
+
if (list.cursor) {
|
|
1520
|
+
if (list.cursor.encoding === 'offset-template') {
|
|
1521
|
+
const cursor = list.cursor;
|
|
1522
|
+
const names: string[] = [];
|
|
1523
|
+
const pattern = '^' + cursor.template.split(/(\{offset\}|\{reverse\})/).map((part) => {
|
|
1524
|
+
if (part === '{offset}' || part === '{reverse}') { names.push(part.slice(1, -1)); return '(\\d+)'; }
|
|
1525
|
+
return escapeRe(part);
|
|
1526
|
+
}).join('') + '$';
|
|
1527
|
+
if (names.filter((name) => name === 'offset').length !== 1 || names.filter((name) => name === 'reverse').length !== 1)
|
|
1528
|
+
throw new Error('list: offset-template must name offset and reverse once');
|
|
1529
|
+
const value = params[cursor.param];
|
|
1530
|
+
const hit = typeof value === 'string' ? new RegExp(pattern).exec(value) : undefined;
|
|
1531
|
+
if (value !== undefined && !hit) return vendorError(m, { status: 400, message: 'Invalid cursor' });
|
|
1532
|
+
const decoded = Object.fromEntries(names.map((name, i) => [name, Number(hit?.[i + 1] ?? 0)]));
|
|
1533
|
+
if (!Number.isSafeInteger(decoded.offset) || ![0, 1].includes(decoded.reverse!)) return vendorError(m, { status: 400, message: 'Invalid cursor' });
|
|
1534
|
+
const start = decoded.reverse ? Math.max(0, decoded.offset! - limit) : decoded.offset!;
|
|
1535
|
+
const slice = rows.slice(start, start + limit);
|
|
1536
|
+
const link = (offset: number, reverse: number, rel: string, available: boolean): string => {
|
|
1537
|
+
const marker = cursor.template.replace('{offset}', String(offset)).replace('{reverse}', String(reverse));
|
|
1538
|
+
const target = new URL(call.request.url); target.searchParams.set(cursor.param, marker);
|
|
1539
|
+
return `<${target.href}>; rel="${rel}"; ${cursor.link.results}="${available}"; ${cursor.link.cursor}="${marker}"`;
|
|
1540
|
+
};
|
|
1541
|
+
return Response.json(fill(list.envelope, { data: slice, key: op.answers?.key ?? 'data' }), { headers: {
|
|
1542
|
+
link: [link(start, 1, cursor.link.previous, start > 0), link(start + limit, 0, cursor.link.next, start + limit < rows.length)].join(', '),
|
|
1543
|
+
} });
|
|
1544
|
+
}
|
|
1545
|
+
const start = decodeOffset(params[list.cursor.param]);
|
|
1337
1546
|
const slice = rows.slice(start, start + limit);
|
|
1338
1547
|
const next = start + limit < rows.length ? encodeOffset(start + limit) : '';
|
|
1339
|
-
return Response.json({ ...(m.success ?? {}), ...(fill(
|
|
1548
|
+
return Response.json({ ...(m.success ?? {}), ...(fill(list.envelope, { data: slice, next_cursor: next, key: op.answers?.key ?? 'data' }) as object), ...(op.answers?.key ? { [op.answers.key]: slice } : {}) });
|
|
1340
1549
|
}
|
|
1341
|
-
return Response.json(fill(
|
|
1550
|
+
return Response.json(fill(list.envelope, { data: page, has_more: hasMore, url, first_id: page[0]?.id ?? null, last_id: page.at(-1)?.id ?? null }));
|
|
1342
1551
|
|
|
1343
1552
|
}
|
|
1344
1553
|
|
|
1345
1554
|
/** Serve one operation from the manifest, or say why the core cannot. */
|
|
1346
|
-
/** A setting's fields as the vendor answers them: its own, without the kernel's bookkeeping
|
|
1347
|
-
*
|
|
1348
|
-
|
|
1349
|
-
|
|
1555
|
+
/** A setting's fields as the vendor answers them: its own, without the kernel's bookkeeping (a `_` field the resource
|
|
1556
|
+
* does not declare the vendor's own, as view() and render() keep it) or the parent it is kept under (a setting answers
|
|
1557
|
+
* its values, not an identity). */
|
|
1558
|
+
function settingFields(own: Record<string, unknown>, parentField?: string, vendorUnderscored: readonly string[] = []): Record<string, unknown> {
|
|
1559
|
+
return Object.fromEntries(Object.entries(own).filter(([k]) => (!k.startsWith('_') || vendorUnderscored.includes(k)) && k !== 'deleted' && k !== parentField));
|
|
1350
1560
|
}
|
|
1351
1561
|
function settingView(m: DerivedManifest, resource: string, hit: TwinResource): Record<string, unknown> {
|
|
1352
1562
|
// ownFields: the kernel's id, type and updatedAt gone, a vendor's own of those names restored
|
|
1353
|
-
return settingFields(ownFields(hit), m.resources[resource]?.parent?.field);
|
|
1563
|
+
return settingFields(ownFields(hit), m.resources[resource]?.parent?.field, m.resources[resource]?.vendorUnderscored);
|
|
1354
1564
|
}
|
|
1355
1565
|
|
|
1356
1566
|
export async function serveCore(m: DerivedManifest, call: DerivedCall, scope: CoreScope = {}): Promise<CoreOutcome> {
|
|
@@ -1379,9 +1589,10 @@ export async function serveCore(m: DerivedManifest, call: DerivedCall, scope: Co
|
|
|
1379
1589
|
// the vendor's success status from its spec: 201 for most creates, 204 with no body where it answers nothing
|
|
1380
1590
|
const status = op.successStatus ?? 200;
|
|
1381
1591
|
const answer = (body: Record<string, unknown>) => ({
|
|
1382
|
-
served: status === 204 ? new Response(null, { status }) : Response.json(envelope(m, op,
|
|
1592
|
+
served: status === 204 ? new Response(null, { status }) : Response.json(envelope(m, op, expand(m, resource,
|
|
1593
|
+
body, paths, root)), { status }),
|
|
1383
1594
|
});
|
|
1384
|
-
const control = new Set([m.expandParam, m.list
|
|
1595
|
+
const control = new Set([m.expandParam, m.list?.limit.param, m.list?.after, m.list?.before, m.list?.offset?.param, m.list?.page?.param, m.list?.cursor?.param].filter(Boolean) as string[]);
|
|
1385
1596
|
const data = Object.fromEntries(Object.entries(params).filter(([k]) => !control.has(k) && k !== 'id'));
|
|
1386
1597
|
// a strict vendor refuses a body it does not declare before the core writes it (body.strict, as ctx.fields)
|
|
1387
1598
|
if (m.body.strict && (op.class === 'create' || op.class === 'update')) {
|
|
@@ -1401,7 +1612,8 @@ export async function serveCore(m: DerivedManifest, call: DerivedCall, scope: Co
|
|
|
1401
1612
|
const under: Record<string, unknown> = { ...(parent ? { [parent.field]: parentId } : {}), ...(tenantField ? { [tenantField]: tenant } : {}) };
|
|
1402
1613
|
if (decl.number) {
|
|
1403
1614
|
const siblings = stored(m, resource, root, { withDeleted: true }).filter(underParent);
|
|
1404
|
-
|
|
1615
|
+
const next = siblings.reduce((n, r) => { const value = String((r as Record<string, unknown>)[decl.number!.field] ?? '0'); const held = /^-?\d+$/.test(value) ? BigInt(value) : 0n; return held > n ? held : n; }, 0n) + 1n;
|
|
1616
|
+
(under as Record<string, unknown>)[decl.number.field] = exactInteger(String(next));
|
|
1405
1617
|
}
|
|
1406
1618
|
const made = { ...assigned, ...initial, ...under, ...data };
|
|
1407
1619
|
const clash = conflictOf(m, resource, made, undefined, root);
|
|
@@ -1415,6 +1627,7 @@ export async function serveCore(m: DerivedManifest, call: DerivedCall, scope: Co
|
|
|
1415
1627
|
return hit ? answer(view(m, resource, hit)) : { served: notFound(m, resource, String(idParam), Object.keys(call.params).at(-1)) };
|
|
1416
1628
|
}
|
|
1417
1629
|
case 'list': {
|
|
1630
|
+
if (!m.list) return { unmodeled: 'no manifest list configuration' };
|
|
1418
1631
|
const rows = stored(m, resource, root).filter(underParent).map((r) => view(m, resource, r));
|
|
1419
1632
|
const arranged = arrangeRows(m, resource, params, rows);
|
|
1420
1633
|
if (!Array.isArray(arranged)) return { served: vendorError(m, arranged) };
|
|
@@ -1439,6 +1652,7 @@ export async function serveCore(m: DerivedManifest, call: DerivedCall, scope: Co
|
|
|
1439
1652
|
const current = view(m, resource, hit);
|
|
1440
1653
|
const changes: Record<string, unknown> = { ...(op.class === 'update' ? data : {}) };
|
|
1441
1654
|
let moved = false;
|
|
1655
|
+
const stateChecks: StateChecks = new Map();
|
|
1442
1656
|
for (const [field, sdecl] of Object.entries(decl.state ?? {})) {
|
|
1443
1657
|
const requested = op.class === 'update' ? data[field] : undefined;
|
|
1444
1658
|
if (op.class === 'update' && requested === undefined) continue;
|
|
@@ -1448,6 +1662,7 @@ export async function serveCore(m: DerivedManifest, call: DerivedCall, scope: Co
|
|
|
1448
1662
|
if (op.class === 'update') return { unmodeled: `no declared transition of ${field} to ${String(requested)} by ${op.id}` };
|
|
1449
1663
|
continue;
|
|
1450
1664
|
}
|
|
1665
|
+
if (transitionObserver) stateChecks.set(stateCheckKey(storedType(m, resource), field), [{ transition: move, operation: op.id, from: stateOf(sdecl, current[field]), to: move.to ?? stateOf(sdecl, current[field]), actor: 'api' }]);
|
|
1451
1666
|
const stored = storedState(sdecl, move.to);
|
|
1452
1667
|
if (stored !== undefined) changes[field] = stored;
|
|
1453
1668
|
for (const [k, rule] of Object.entries(move.effects ?? {})) changes[k] = applyRule(m, rule, hit.id, { ...current, ...changes }, at);
|
|
@@ -1458,7 +1673,7 @@ export async function serveCore(m: DerivedManifest, call: DerivedCall, scope: Co
|
|
|
1458
1673
|
for (const [k, v] of Object.entries(changes)) merged[k] = mergeField(decl.update, current[k], v);
|
|
1459
1674
|
const clash = conflictOf(m, resource, { ...current, ...merged }, hit.id, root);
|
|
1460
1675
|
if (clash) return { served: vendorError(m, clash) };
|
|
1461
|
-
return answer(await write(m, call, resource, hit.id, merged, `${storedType(m, resource)}.${op.class === 'update' ? 'update' : op.id}`, params, root, at));
|
|
1676
|
+
return answer(await write(m, call, resource, hit.id, merged, `${storedType(m, resource)}.${op.class === 'update' ? 'update' : op.id}`, params, root, at, stateChecks));
|
|
1462
1677
|
}
|
|
1463
1678
|
case 'delete': {
|
|
1464
1679
|
const hit = idParam ? stored(m, resource, root).find((r) => namedBy(decl, r, idParam) && underParent(r)) : undefined;
|
|
@@ -1512,14 +1727,11 @@ export type SemanticsContext = {
|
|
|
1512
1727
|
/** What the World's scenario decided for this turn (a model vendor's answer: the handler matched, or the miss), on an
|
|
1513
1728
|
* operation the pack's `semantics/scenario.ts` names; undefined elsewhere, or when the World carries no scenario. */
|
|
1514
1729
|
scenario: ScenarioDecision | undefined;
|
|
1515
|
-
/** A message the vendor sends the application's own server that no declared event is (a push notification), through
|
|
1516
|
-
* the World's route and egress rule: the receiver's status, or 0 when refused or unreachable. */
|
|
1517
|
-
deliver(url: string, init: RequestInit): Promise<number>;
|
|
1518
1730
|
/** A message the vendor sends the application's server and decides by its answer, waited on for at most `within`
|
|
1519
1731
|
* milliseconds (a real-time authorization request): its status and body, or why there was none. */
|
|
1520
1732
|
ask(url: string, init: RequestInit, within: number): Promise<ApplicationAnswer>;
|
|
1521
1733
|
/** A git repository of the World, by the pack's name for it (the git plane, world-core git/): its objects in the
|
|
1522
|
-
* World's content-addressed object store
|
|
1734
|
+
* World's repository-scoped content-addressed object store, its refs in the World's store. Served over smart HTTP
|
|
1523
1735
|
* (`git.serveSmartHttp`), read and written through the library's codecs. */
|
|
1524
1736
|
git(name: string): { store: GitObjectStore; refs: GitRefs };
|
|
1525
1737
|
/** A value the World set for its application (world-env.ts): what the app was given and the vendor must agree with (a
|
|
@@ -1534,6 +1746,8 @@ export type SemanticsContext = {
|
|
|
1534
1746
|
/** who is calling, when the manifest says how to tell */
|
|
1535
1747
|
actor: string | undefined;
|
|
1536
1748
|
now(): unknown;
|
|
1749
|
+
/** Render a stored row or vendor view using its resource declaration, retaining the stored subject id fallback. */
|
|
1750
|
+
render(row: Record<string, unknown>, resource?: string): Record<string, unknown>;
|
|
1537
1751
|
get(resource: string, id: string): Record<string, unknown> | undefined;
|
|
1538
1752
|
/** One credential subject in a manifest-declared owner's World store. Ambiguous stores refuse the read. */
|
|
1539
1753
|
ownerRow(owner: string, resource: string, id: string): Record<string, unknown> | undefined;
|
|
@@ -1544,6 +1758,11 @@ export type SemanticsContext = {
|
|
|
1544
1758
|
issue(resource: string): Promise<string>;
|
|
1545
1759
|
/** The vendor's open sessions of one of its sockets in this World (a request answered by a session: sockets.ts). */
|
|
1546
1760
|
sockets(id: string): SocketSession[];
|
|
1761
|
+
/** Hold this read until the World writes (held-reads.ts: HTTP long polling): true when a write of one of the named
|
|
1762
|
+
* resources lands in this World, false when `ms` pass or the client hangs up; at once false in a read-only World. */
|
|
1763
|
+
awaitWrite(resources: ReadonlyArray<string>, ms: number): Promise<boolean>;
|
|
1764
|
+
/** Observe a condition until it holds or the caller's timeout/cancellation, waking on writes and pollMs. */
|
|
1765
|
+
awaitCondition(resources: ReadonlyArray<string>, ms: number, pollMs: number, condition: () => Promise<boolean>): Promise<boolean>;
|
|
1547
1766
|
/** A secret for `label` (an app's client secret, a store's read token): an HMAC under a seed the World makes once, at
|
|
1548
1767
|
* random, and keeps as bookkeeping no door answers. The same label gives the same secret for the World's whole life,
|
|
1549
1768
|
* and no one can compute it from ids the World shows, as they can a value derived from them alone. */
|
|
@@ -1558,6 +1777,12 @@ export type SemanticsContext = {
|
|
|
1558
1777
|
/** The signing key for `label` when the World has made it (`signingKey`), else undefined: for code that signs where it
|
|
1559
1778
|
* cannot wait (a cookie's token, read everywhere), after a front made the key before anything is answered. */
|
|
1560
1779
|
heldSigningKey(label: string, alg?: 'RS256' | 'Ed25519'): { privatePem: string; publicPem: string } | undefined;
|
|
1780
|
+
/** Allocate a stored occurrence and append its fields under one action lock. The optional field factory is pure. */
|
|
1781
|
+
create(resource: string, fields: Record<string, unknown> | ((id: string) => Record<string, unknown>), operation: string): Promise<Record<string, unknown>>;
|
|
1782
|
+
/** Create under declared unique keys and the action lock; a collision returns the existing own-field view without a write. */
|
|
1783
|
+
createUnique(resource: string, fields: Record<string, unknown> | ((id: string) => Record<string, unknown>), operation: string): Promise<{ created: boolean; row: Record<string, unknown> }>;
|
|
1784
|
+
/** Decide an update from the current live stored row under the action lock; undefined leaves it unchanged. */
|
|
1785
|
+
change(resource: string, id: string, decide: (row: Record<string, unknown>) => Record<string, unknown> | undefined, operation: string): Promise<Record<string, unknown> | undefined>;
|
|
1561
1786
|
write(resource: string, id: string, fields: Record<string, unknown>, operation: string): Promise<Record<string, unknown>>;
|
|
1562
1787
|
/** Add numeric deltas to an existing bookkeeping row under the kernel's atomic write seam. */
|
|
1563
1788
|
accumulate(resource: string, id: string, deltas: Record<string, number>, operation: string): Promise<void>;
|
|
@@ -1578,8 +1803,8 @@ export type SemanticsContext = {
|
|
|
1578
1803
|
tree(): Array<Record<string, unknown>>;
|
|
1579
1804
|
/** The writes that touched a subject, in order: its history. */
|
|
1580
1805
|
history(resource: string, id: string): Array<{ operation?: string; fields?: Record<string, unknown>; occurredAt?: string }>;
|
|
1581
|
-
/** Write
|
|
1582
|
-
record(type: string, fields: Record<string, unknown>, id?: string): Promise<string>;
|
|
1806
|
+
/** Write bookkeeping without the incoming request; an explicit operation observes its checked state move. Mint an id when none is given. */
|
|
1807
|
+
record(type: string, fields: Record<string, unknown>, id?: string, operation?: string): Promise<string>;
|
|
1583
1808
|
/** Decide a write from the tree as it stands and make it, with nothing written between the read
|
|
1584
1809
|
* and the write (a minted number, a first poll that moves a subject). `decide` returns the write,
|
|
1585
1810
|
* or nothing to write; the answer is its `value`. */
|
|
@@ -1592,6 +1817,8 @@ export type SemanticsContext = {
|
|
|
1592
1817
|
/** Validate the request body's top-level derived schema under body.validation, before a write. */
|
|
1593
1818
|
validate(): Response | undefined;
|
|
1594
1819
|
refuse(e: ErrorSpec): Response;
|
|
1820
|
+
/** Answer the manifest's declared unserved capability gap. */
|
|
1821
|
+
gap(): Response;
|
|
1595
1822
|
notFound(resource: string, id: string, param?: string): Response;
|
|
1596
1823
|
reply(body: unknown, status?: number): Response;
|
|
1597
1824
|
/** Answer in the vendor's success envelope under this operation's key (`{ ok: true, channel }`). */
|
|
@@ -1665,7 +1892,7 @@ export type HandlerContext = Omit<SemanticsContext, 'now' | 'core' | 'atomically
|
|
|
1665
1892
|
};
|
|
1666
1893
|
/** What a write hook reads: the writing call's context without its ways to write, so a hook that renders an event
|
|
1667
1894
|
* cannot write again (and run itself again). */
|
|
1668
|
-
export type WriteHookContext = Omit<HandlerContext, 'write' | 'record' | 'accumulate' | 'at' | 'over'>;
|
|
1895
|
+
export type WriteHookContext = Omit<HandlerContext, 'write' | 'create' | 'createUnique' | 'change' | 'issue' | 'record' | 'accumulate' | 'at' | 'over'>;
|
|
1669
1896
|
/** A derived pack's handler: one operation, by its operationId, over the contract's context. */
|
|
1670
1897
|
export type Handler = (ctx: HandlerContext) => Promise<Response>;
|
|
1671
1898
|
|
|
@@ -1677,13 +1904,15 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
1677
1904
|
scopeOfCall.set(call, scope);
|
|
1678
1905
|
const at = (scope.clock ?? worldNow)();
|
|
1679
1906
|
// the guard's parse when it made one (taken once), else this context's own
|
|
1680
|
-
|
|
1681
|
-
const
|
|
1907
|
+
// a body that is its payload's bytes (S3's PutObject) is never read as JSON, whatever its label
|
|
1908
|
+
const payload = bytesPayload(call.operation);
|
|
1909
|
+
const parsed = payload ? undefined : takeParsedBody(call.request);
|
|
1910
|
+
const text = await bodyTextOf(call.request.clone()).catch(() => '');
|
|
1682
1911
|
let body: unknown = parsed?.value;
|
|
1683
|
-
if (!parsed) { try { body = text ?
|
|
1912
|
+
if (!parsed) { if (payload) body = text || undefined; else { try { body = text ? parseExactJson(text) : undefined; } catch { body = text; } } }
|
|
1684
1913
|
// the request as it came, before the core's parameter merge and alias adoption: what a write records, so a perform
|
|
1685
1914
|
// can send it again (the real-system adapters, "What the derived core records with a write")
|
|
1686
|
-
const boundary = multipartBoundary(call.request.headers.get('content-type'));
|
|
1915
|
+
const boundary = payload ? null : multipartBoundary(call.request.headers.get('content-type'));
|
|
1687
1916
|
const parts = boundary === null ? undefined : multipartParts(new Uint8Array(await call.request.clone().arrayBuffer()), boundary);
|
|
1688
1917
|
// a body that is not JSON is recorded as it came, its type with it: text as text, bytes (a gzip the vendor's client
|
|
1689
1918
|
// compressed itself, PostHog's `compression=gzip-js`) as base64
|
|
@@ -1694,8 +1923,20 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
1694
1923
|
// every read of the body is of a copy, so the handler's own `call.request` and the core's answer (`core()`) each
|
|
1695
1924
|
// still find it unread, whichever of them reads it first
|
|
1696
1925
|
const pristine = call.request.clone();
|
|
1697
|
-
const params = await boundaryParams(m, { ...call, request: call.request.clone() }, root)
|
|
1926
|
+
const params = await boundaryParams(m, { ...call, request: call.request.clone() }, root).catch((error) => {
|
|
1927
|
+
// An around context opens before the credential/JSON guard. Keep non-body parameters available to it without
|
|
1928
|
+
// turning the guard's declared malformed-body refusal into a context-construction exception.
|
|
1929
|
+
if (!(error instanceof SyntaxError) || error.message !== 'malformed JSON body') throw error;
|
|
1930
|
+
return boundaryParams(m, { ...call, request: new Request(call.request.url, { headers: call.request.headers }) }, root);
|
|
1931
|
+
});
|
|
1698
1932
|
const paths = expandPaths(m, params);
|
|
1933
|
+
const stateChecks: StateChecks = new Map();
|
|
1934
|
+
const rawResources = new WeakMap<Record<string, unknown>, string>();
|
|
1935
|
+
const rawRow = (resource: string, row: TwinResource): Record<string, unknown> => {
|
|
1936
|
+
const name = m.resources[resource] ? resource : Object.keys(m.resources).find((name) => storedType(m, name) === row.type);
|
|
1937
|
+
rawResources.set(row as Record<string, unknown>, name ?? resource);
|
|
1938
|
+
return row as Record<string, unknown>;
|
|
1939
|
+
};
|
|
1699
1940
|
return {
|
|
1700
1941
|
call,
|
|
1701
1942
|
params,
|
|
@@ -1705,18 +1946,25 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
1705
1946
|
root,
|
|
1706
1947
|
publicBase: twinPublicBase(call.request),
|
|
1707
1948
|
scenario: scenarioDecisionOf(call.request),
|
|
1708
|
-
deliver: (url, init) => deliverToApplication(url, init),
|
|
1709
1949
|
ask: (url, init, within) => askApplication(url, init, within, scope.application),
|
|
1710
1950
|
worldEnv: (name) => worldEnvValue(name),
|
|
1711
1951
|
asVendor: (fn) => runAsVendorMove(fn),
|
|
1712
1952
|
git: (name) => {
|
|
1713
1953
|
if (!/^[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.-]+)*$/.test(name) || name.split('/').some((seg) => seg === '.' || seg === '..')) throw new Error(`semantics: ${name} is not a git repository name`);
|
|
1714
1954
|
const plane = `${worldPaths(m.service, root).dir}/git`;
|
|
1715
|
-
return { store: new GitObjectStore(getActiveBlobStore(), `${plane}/
|
|
1955
|
+
return { store: new GitObjectStore(getActiveBlobStore(), `${plane}/repos/${name}`), refs: new GitRefs(getActiveWorldStore(), `${plane}/repos/${name}`) };
|
|
1716
1956
|
},
|
|
1717
1957
|
mail: (route, mail, headers) => sendMail(route, mail, headers),
|
|
1718
1958
|
actor: m.identity ? m.identity(call.request.headers.get('authorization'), root) : undefined,
|
|
1719
1959
|
now: () => now(m, at),
|
|
1960
|
+
render: (row, resource) => {
|
|
1961
|
+
const raw = rawResources.has(row);
|
|
1962
|
+
const name = resource ?? rawResources.get(row);
|
|
1963
|
+
const declared = name && (m.resources[name] ? name : Object.keys(m.resources).find((r) => storedType(m, r) === name));
|
|
1964
|
+
const keep = declared ? m.resources[declared]?.vendorUnderscored ?? [] : [];
|
|
1965
|
+
const shown = raw ? render(row as TwinResource, keep) : renderOwn(row, keep);
|
|
1966
|
+
return omitView(m, declared || undefined, shown);
|
|
1967
|
+
},
|
|
1720
1968
|
get: (resource, id) => {
|
|
1721
1969
|
const hit = stored(m, resource, root).find((r) => r.id === id);
|
|
1722
1970
|
return hit ? view(m, resource, hit) : undefined;
|
|
@@ -1729,45 +1977,74 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
1729
1977
|
rows: (resource) => stored(m, resource, root).map((r) => view(m, resource, r)),
|
|
1730
1978
|
mint: (resource) => mintId(m, resource, root, at),
|
|
1731
1979
|
sockets: (id) => openSessions(m.service, root, id),
|
|
1980
|
+
awaitWrite: (resources, ms) => (scope.readOnly === true || isReadOnlyRequest(call.request) ? Promise.resolve(false) : awaitWrite(m.service, root, resources.map((r) => storedType(m, r)), ms, call.request.signal)),
|
|
1981
|
+
awaitCondition: (resources, ms, pollMs, condition) => awaitCondition(m.service, root, resources.map((r) => storedType(m, r)), scope.readOnly === true || isReadOnlyRequest(call.request) ? 0 : ms, pollMs, condition, call.request.signal),
|
|
1732
1982
|
issue: async (resource) => {
|
|
1733
|
-
const id = mintId(m, resource, root, at);
|
|
1734
1983
|
if (!resource.startsWith('_')) throw new Error(`semantics: ${resource} is not a bookkeeping type (an issued id's type starts with _)`);
|
|
1735
|
-
if (scope.readOnly
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1984
|
+
if (scope.readOnly === true || isReadOnlyRequest(call.request)) return mintId(m, resource, root, at);
|
|
1985
|
+
const { value } = await applyTwinWriteAtomic(m.service, (snapshot) => {
|
|
1986
|
+
const id = mintId(m, resource, root, at, snapshot);
|
|
1987
|
+
return { kind: 'write', value: id, write: { operation: `${resource.slice(1)}.issue`, subjectType: storedType(m, resource), subjectId: id, fields: { issuedAt: at }, occurredAt: at, actor: { kind: 'system' } } };
|
|
1988
|
+
}, root);
|
|
1989
|
+
return value;
|
|
1739
1990
|
},
|
|
1740
1991
|
occurredAt: at,
|
|
1741
1992
|
secret: async (label) => hmac(await worldSeed(m.service, root, at), label, 'base64url'),
|
|
1742
|
-
record: async (type, fields, id) => {
|
|
1993
|
+
record: async (type, fields, id, operation) => {
|
|
1743
1994
|
if (!type.startsWith('_')) throw new Error(`semantics: ${type} is not a bookkeeping type (bookkeeping types start with _)`);
|
|
1744
1995
|
if (id !== undefined) {
|
|
1745
|
-
|
|
1996
|
+
const before = operation ? stateBeforeWrite(m, type, id, root) : undefined;
|
|
1997
|
+
const { result } = await applyTwinWrite(m.service, { operation: operation ?? `${type.slice(1)}.record`, subjectType: type, subjectId: id, fields, occurredAt: at, actor: { kind: 'system' } }, root);
|
|
1998
|
+
if (operation) observeStateWrite(m, result, before, root, stateChecks);
|
|
1746
1999
|
return id;
|
|
1747
2000
|
}
|
|
1748
|
-
const { value } = await applyTwinWriteAtomic(m.service, (resources) => {
|
|
2001
|
+
const { value, result } = await applyTwinWriteAtomic(m.service, (resources) => {
|
|
1749
2002
|
// Allocate and append under the same state lock: concurrent sign-ins must never share an issue id.
|
|
1750
2003
|
const subject = (() => {
|
|
1751
2004
|
const prefix = `${type.slice(1)}_`;
|
|
1752
|
-
let max =
|
|
2005
|
+
let max = 0n;
|
|
1753
2006
|
for (const r of resources) {
|
|
1754
2007
|
if (r.type !== type) continue;
|
|
1755
|
-
const
|
|
1756
|
-
if (
|
|
2008
|
+
const digits = r.id.startsWith(prefix) ? r.id.slice(prefix.length) : '';
|
|
2009
|
+
if (/^\d+$/.test(digits) && BigInt(digits) > max) max = BigInt(digits);
|
|
1757
2010
|
}
|
|
1758
|
-
return `${prefix}${max +
|
|
2011
|
+
return `${prefix}${max + 1n}`;
|
|
1759
2012
|
})();
|
|
1760
|
-
return { kind: 'write', value: subject, write: { operation: `${type.slice(1)}.record`, subjectType: type, subjectId: subject, fields, occurredAt: at, actor: { kind: 'system' } } };
|
|
2013
|
+
return { kind: 'write', value: subject, write: { operation: operation ?? `${type.slice(1)}.record`, subjectType: type, subjectId: subject, fields, occurredAt: at, actor: { kind: 'system' } } };
|
|
1761
2014
|
}, root, () => resourcesOfType(m.service, type, root));
|
|
2015
|
+
if (operation && result) observeStateWrite(m, result, undefined, root, stateChecks);
|
|
1762
2016
|
return value;
|
|
1763
2017
|
},
|
|
1764
2018
|
get machines() {
|
|
1765
|
-
const enrolled = resourcesOfType(m.service, MACHINE_POOL, root).find((r) => r.id === 'pool') as unknown as { kind?: MachinePool['kind'] } | undefined;
|
|
1766
|
-
return poolFor(m.service, root, enrolled?.kind ?? 'none');
|
|
2019
|
+
const enrolled = resourcesOfType(m.service, MACHINE_POOL, root).find((r) => r.id === 'pool') as unknown as (LightPoolOptions & { kind?: MachinePool['kind']; httpReplies?: MachineHttpReply[] }) | undefined;
|
|
2020
|
+
return guardedMachinePool(poolFor(m.service, root, enrolled?.kind ?? 'none', enrolled?.httpReplies, { images: enrolled?.images }), m.service, ['retrieve', 'list'].includes(call.operation.class) || (m.reads?.includes(call.operation.id) ?? false));
|
|
1767
2021
|
},
|
|
1768
2022
|
signingKey: (label, alg) => worldSigningKey(m.service, root, at, label, alg ?? 'RS256'),
|
|
1769
2023
|
heldSigningKey: (label, alg) => heldWorldSigningKey(m.service, root, label, alg ?? 'RS256'),
|
|
1770
|
-
|
|
2024
|
+
create: (resource, fields, operation) => atomicWrite(m, call, resource, (snapshot) => {
|
|
2025
|
+
const id = mintId(m, resource, root, at, snapshot);
|
|
2026
|
+
return { id, fields: typeof fields === 'function' ? fields(id) : fields };
|
|
2027
|
+
}, operation, params, root, at, undefined, stateChecks).then((row) => row!),
|
|
2028
|
+
createUnique: async (resource, fields, operation) => {
|
|
2029
|
+
const rules = m.resources[resource]?.unique;
|
|
2030
|
+
if (!rules?.length) throw new Error('createUnique requires declared unique keys');
|
|
2031
|
+
let existing: Record<string, unknown> | undefined;
|
|
2032
|
+
const row = await atomicWrite(m, call, resource, (snapshot) => {
|
|
2033
|
+
const id = mintId(m, resource, root, at, snapshot);
|
|
2034
|
+
const selected = typeof fields === 'function' ? fields(id) : fields;
|
|
2035
|
+
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])));
|
|
2036
|
+
if (hit) { existing = view(m, resource, hit); return undefined; }
|
|
2037
|
+
return { id, fields: selected };
|
|
2038
|
+
}, operation, params, root, at, undefined, stateChecks);
|
|
2039
|
+
return existing ? { created: false, row: existing } : { created: true, row: row! };
|
|
2040
|
+
},
|
|
2041
|
+
change: (resource, id, decide, operation) => atomicWrite(m, call, resource, (snapshot) => {
|
|
2042
|
+
const row = snapshot.find((r) => r.type === storedType(m, resource) && r.id === id && r.deleted !== true);
|
|
2043
|
+
if (!row) return undefined;
|
|
2044
|
+
const fields = decide(row as Record<string, unknown>);
|
|
2045
|
+
return fields === undefined ? undefined : { id, fields };
|
|
2046
|
+
}, operation, params, root, at, id, stateChecks),
|
|
2047
|
+
write: (resource, id, fields, operation) => write(m, call, resource, id, fields, operation, params, root, at, stateChecks),
|
|
1771
2048
|
accumulate: async (resource, id, deltas, operation) => {
|
|
1772
2049
|
if (!resource.startsWith('_') || !m.resources[resource]) throw new Error('accumulate requires a declared bookkeeping resource');
|
|
1773
2050
|
await applyTwinWriteAtomic(m.service, (resources) => {
|
|
@@ -1782,54 +2059,64 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
1782
2059
|
return { kind: 'write', value: undefined, write: { operation, subjectType: storedType(m, resource), subjectId: id, fields, occurredAt: at, actor: { kind: 'agent' } } };
|
|
1783
2060
|
}, root);
|
|
1784
2061
|
},
|
|
1785
|
-
writeDetailed: (resource, id, fields, operation) => writeDetailed(m, call, resource, id, fields, operation, params, root, at),
|
|
2062
|
+
writeDetailed: (resource, id, fields, operation) => writeDetailed(m, call, resource, id, fields, operation, params, root, at, stateChecks),
|
|
1786
2063
|
resolve: (resource, id) => resolveSubjectId(m.service, storedType(m, resource), id, root),
|
|
1787
2064
|
storedType: (resource) => storedType(m, resource),
|
|
1788
2065
|
ok: (fields) => Response.json({ ...(m.success ?? {}), ...fields }),
|
|
1789
|
-
row: (resource, id, opts) => stored(m, resource, root, opts).find((r) => r.id === id)
|
|
1790
|
-
rowsRaw: (resource, opts) => stored(m, resource, root, opts)
|
|
2066
|
+
row: (resource, id, opts) => { const row = stored(m, resource, root, opts).find((r) => r.id === id); return row ? rawRow(resource, row) : undefined; },
|
|
2067
|
+
rowsRaw: (resource, opts) => stored(m, resource, root, opts).map((row) => rawRow(resource, row)),
|
|
1791
2068
|
tree: () => twinResources(m.service, root) as Array<Record<string, unknown>>,
|
|
1792
2069
|
history: (resource, id) => subjectHistory(m.service, { type: storedType(m, resource), id }, root).map((e) => ({ ...(e.operation ? { operation: e.operation } : {}), ...(e.fields ? { fields: e.fields as Record<string, unknown> } : {}), ...(e.occurredAt ? { occurredAt: e.occurredAt } : {}) })),
|
|
1793
2070
|
raw: (body, init = {}) => new Response(body, { status: init.status ?? 200, headers: init.headers ?? {} }),
|
|
1794
2071
|
atomically: async (decide) => {
|
|
1795
|
-
|
|
2072
|
+
let before: TwinResource | undefined;
|
|
2073
|
+
const { value, result } = await applyTwinWriteAtomic(
|
|
1796
2074
|
m.service,
|
|
1797
2075
|
(resources) => {
|
|
1798
2076
|
const rows = (resource: string): Array<Record<string, unknown>> => resources.filter((r) => r.type === storedType(m, resource) && r.deleted !== true) as Array<Record<string, unknown>>;
|
|
1799
2077
|
const d = decide(rows);
|
|
1800
2078
|
if (!d.write) return { kind: 'skip', value: d.value };
|
|
1801
2079
|
const w = d.write;
|
|
2080
|
+
if (transitionObserver) before = resources.find((r) => r.type === storedType(m, w.resource) && r.id === w.id);
|
|
1802
2081
|
return { kind: 'write', value: d.value, write: { operation: w.operation, subjectType: storedType(m, w.resource), subjectId: w.id, fields: w.fields, occurredAt: at, actor: { kind: 'agent' } } };
|
|
1803
2082
|
},
|
|
1804
2083
|
root,
|
|
1805
2084
|
);
|
|
2085
|
+
if (result) observeStateWrite(m, result, before, root, stateChecks);
|
|
1806
2086
|
return value;
|
|
1807
2087
|
},
|
|
1808
2088
|
legal: (resource, field, operationId, current, to, id, actor) => {
|
|
1809
2089
|
const decl = m.resources[resource]?.state?.[field];
|
|
1810
2090
|
if (!decl) throw new Error(`semantics: ${resource}.${field} is not a declared state field`);
|
|
1811
2091
|
const { move, refusal } = transitionFor(field, decl, operationId, current, to, id ?? Object.values(call.params).at(-1), actor ?? 'api');
|
|
1812
|
-
if (move)
|
|
2092
|
+
if (move) {
|
|
2093
|
+
if (transitionObserver) {
|
|
2094
|
+
const key = stateCheckKey(storedType(m, resource), field);
|
|
2095
|
+
stateChecks.set(key, [...(stateChecks.get(key) ?? []), { transition: move, operation: operationId, from: stateOf(decl, current), to: move.to ?? stateOf(decl, current), actor: actor ?? 'api' }]);
|
|
2096
|
+
}
|
|
2097
|
+
return undefined;
|
|
2098
|
+
}
|
|
1813
2099
|
if (refusal) return refusal;
|
|
1814
2100
|
throw new Error(`semantics: ${operationId} moves ${resource}.${field} from ${String(current)}${to ? ` to ${to}` : ''}, which the machine does not declare`);
|
|
1815
2101
|
},
|
|
1816
2102
|
refuse: (e) => vendorError(m, e),
|
|
2103
|
+
gap: () => vendorError(m, m.gap ?? m.notFound),
|
|
1817
2104
|
notFound: (resource, id, param) => notFound(m, resource, id, param),
|
|
1818
2105
|
validate: () => validateBody(m, call.operation, body),
|
|
1819
2106
|
reply: (body, status = 200) => Response.json(body, { status }),
|
|
1820
2107
|
wrap: (body, extra = {}) => Response.json({ ...envelope(m, call.operation, body), ...extra }),
|
|
1821
2108
|
sse: (events) => sse(m, events, call.operation.id),
|
|
1822
|
-
expand: (resource, body, given) =>
|
|
2109
|
+
expand: (resource, body, given) => expand(m, resource, body, given ?? paths, root),
|
|
1823
2110
|
at: (when) => contextFor(m, { ...call, request: new Request(call.request.url) }, { ...scope, clock: () => when }),
|
|
1824
2111
|
crypto: handlerCrypto,
|
|
1825
2112
|
flag: (value) => flagOf(m, value),
|
|
1826
2113
|
fields: (body) => strictFields(m, call.operation, body),
|
|
1827
|
-
vendorFetch: (url, init) => vendorFetch(url, init),
|
|
2114
|
+
vendorFetch: (url, init) => vendorFetch(url, init, scope.application),
|
|
1828
2115
|
parts: async () => {
|
|
1829
2116
|
const boundary = multipartBoundary(call.request.headers.get('content-type'));
|
|
1830
2117
|
return boundary === null ? [] : multipartParts(new Uint8Array(await call.request.clone().arrayBuffer()), boundary);
|
|
1831
2118
|
},
|
|
1832
|
-
engine: scope.database === undefined ? unboundDatabase(m.service) : managedDatabase(scope.database, { readOnly: scope.readOnly === true || isReadOnlyRequest(call.request)
|
|
2119
|
+
engine: scope.database === undefined ? unboundDatabase(m.service) : managedDatabase(scope.database, { readOnly: scope.readOnly === true || isReadOnlyRequest(call.request) }),
|
|
1833
2120
|
redis: (commands, options) => execRedisRun(commands, {
|
|
1834
2121
|
root, occurredAt: at, service: m.service, readOnly: scope.readOnly === true || isReadOnlyRequest(call.request), dialect: options.dialect,
|
|
1835
2122
|
...(options.database === undefined ? {} : { database: options.database }),
|
|
@@ -1862,15 +2149,17 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
1862
2149
|
|
|
1863
2150
|
/** A pack's semantics handlers as the dispatch's handlers. */
|
|
1864
2151
|
export function bindSemantics(m: DerivedManifest, handlers: Record<string, Semantics>, scope: CoreScope = {}): Record<string, DerivedHandler> {
|
|
2152
|
+
prepareViewOmissions(m);
|
|
1865
2153
|
return Object.fromEntries(Object.entries(handlers).map(([id, h]) => [id, async (call: DerivedCall) => h(await contextFor(m, call, scope))]));
|
|
1866
2154
|
}
|
|
1867
2155
|
|
|
1868
2156
|
/** The derived core as the dispatch's core: it owns every operation on a resource the manifest declares. */
|
|
1869
2157
|
export function coreFor(m: DerivedManifest, scope: CoreScope = {}): { owns: (o: DerivedOperation) => boolean; serve: (call: DerivedCall) => Promise<DerivedCoreOutcome> } {
|
|
2158
|
+
prepareViewOmissions(m);
|
|
1870
2159
|
const crud = new Set(['create', 'retrieve', 'list', 'update', 'delete']);
|
|
1871
2160
|
const owns = (o: DerivedOperation): boolean => {
|
|
1872
2161
|
const decl = o.resource !== undefined ? m.resources[o.resource] : undefined;
|
|
1873
|
-
if (!decl || m.unmodeled?.includes(o.id)) return false;
|
|
2162
|
+
if (!decl || m.unmodeled?.includes(o.id) || (o.class === 'list' && !m.list)) return false;
|
|
1874
2163
|
if (crud.has(o.class)) return true;
|
|
1875
2164
|
return o.class === 'action' && Object.values(decl.state ?? {}).some((f) => f.transitions.some((t) => t.operation === o.id));
|
|
1876
2165
|
};
|
|
@@ -1892,13 +2181,17 @@ export const SCENARIO_DECISION_HEADER = 'x-volter-scenario-decision';
|
|
|
1892
2181
|
function scenarioDecisionOf(request: Request): ScenarioDecision | undefined {
|
|
1893
2182
|
const raw = request.headers.get(SCENARIO_DECISION_HEADER);
|
|
1894
2183
|
if (!raw) return undefined;
|
|
1895
|
-
try { return
|
|
2184
|
+
try { return parseExactJson(raw) as ScenarioDecision; } catch { return undefined; }
|
|
1896
2185
|
}
|
|
1897
2186
|
|
|
1898
2187
|
export function authRefusal(m: DerivedManifest, request: Request, root?: string): Response | undefined {
|
|
1899
2188
|
if (!m.auth) return undefined;
|
|
1900
2189
|
const url = new URL(request.url);
|
|
1901
2190
|
if (m.auth.paths !== undefined && !new RegExp(m.auth.paths).test(url.pathname)) return undefined;
|
|
2191
|
+
const wire = m.auth.wires?.find((w) => new RegExp(w.paths).test(url.pathname));
|
|
2192
|
+
const errorManifest = wire ? { ...m, error: wire.error } : m;
|
|
2193
|
+
const invalid = wire?.invalid ?? m.auth.invalid;
|
|
2194
|
+
const missing = wire?.missing ?? m.auth.missing;
|
|
1902
2195
|
const sources = [{ header: m.auth.header, scheme: m.auth.scheme }, ...(m.auth.also ?? [])];
|
|
1903
2196
|
const given = sources.map((s) => (s.query !== undefined ? url.searchParams.get(s.query) : s.header ? request.headers.get(s.header) : null)).some((v) => v !== null);
|
|
1904
2197
|
if (!given && !m.auth.gateWhenAbsent) return undefined;
|
|
@@ -1912,17 +2205,41 @@ export function authRefusal(m: DerivedManifest, request: Request, root?: string)
|
|
|
1912
2205
|
};
|
|
1913
2206
|
const key = sources.map(keyOf).find((k) => k !== '') ?? '';
|
|
1914
2207
|
// no credential sent is `missing`; one sent in a form the vendor does not read is a key it does not know
|
|
1915
|
-
if (!key) return vendorError(
|
|
1916
|
-
if (m.auth.invalidKeys.includes(key) || (m.auth.keyFormat && !new RegExp(m.auth.keyFormat).test(key))) return vendorError(
|
|
2208
|
+
if (!key) return vendorError(errorManifest, given ? invalid : missing);
|
|
2209
|
+
if (m.auth.invalidKeys.includes(key) || (m.auth.keyFormat && !new RegExp(m.auth.keyFormat).test(key))) return vendorError(errorManifest, invalid);
|
|
1917
2210
|
const held = m.auth.held;
|
|
1918
2211
|
if (held && !held.standing?.includes(key)) {
|
|
1919
2212
|
const hash = sha256(key);
|
|
1920
2213
|
const holds = resourcesOfType(m.service, held.storedAs, root).some((r) => r.deleted !== true && (r as Record<string, unknown>)[held.hashField] === hash && (held.unless === undefined || (r as Record<string, unknown>)[held.unless] !== true));
|
|
1921
|
-
if (!holds) return vendorError(
|
|
2214
|
+
if (!holds) return vendorError(errorManifest, invalid);
|
|
1922
2215
|
}
|
|
1923
2216
|
return undefined;
|
|
1924
2217
|
}
|
|
1925
2218
|
|
|
2219
|
+
/** The number of answers a World has given that carried a filled answer header (`answerHeaders`'s `{hex:N}`), kept in
|
|
2220
|
+
* the World's own directory beside its rate-budget ledger (bookkeeping, never a log entry): a fresh World counts from 1,
|
|
2221
|
+
* so two Worlds made alike answer the same values in the same order, and a resumed World counts on, never answering a
|
|
2222
|
+
* value the count holds again. A World with no root (an in-memory walk) counts in this process. */
|
|
2223
|
+
const answeredInMemory = new Map<string, number>();
|
|
2224
|
+
function nextAnswer(service: string, root: string | undefined): number {
|
|
2225
|
+
const key = `${service}:${root ?? ''}`;
|
|
2226
|
+
if (root === undefined) { const n = (answeredInMemory.get(key) ?? 0) + 1; answeredInMemory.set(key, n); return n; }
|
|
2227
|
+
const store = getActiveWorldStore();
|
|
2228
|
+
const path = `${worldPaths(service, root).dir}/answer-count`;
|
|
2229
|
+
// a read-only request writes nothing, bookkeeping and its lock included, and reads the count without the lock: it
|
|
2230
|
+
// counts on in this process past the kept count, so after a restart, or from another process serving the same World,
|
|
2231
|
+
// the answers given to read-only requests since the last write may be given again
|
|
2232
|
+
if (writesRefused()) { const kept = Number(store.read(path) ?? '0') || 0; const extra = (answeredInMemory.get(key) ?? 0) + 1; answeredInMemory.set(key, extra); return kept + extra; }
|
|
2233
|
+
// read and written under the count's lock, as the rate-budget ledger is: two writes from processes serving one World never
|
|
2234
|
+
// take one number
|
|
2235
|
+
return store.withLock(`${path}.lock`, () => {
|
|
2236
|
+
const n = (Number(store.read(path) ?? '0') || 0) + (answeredInMemory.get(key) ?? 0) + 1;
|
|
2237
|
+
answeredInMemory.delete(key);
|
|
2238
|
+
store.writeAtomic(path, String(n));
|
|
2239
|
+
return n;
|
|
2240
|
+
});
|
|
2241
|
+
}
|
|
2242
|
+
|
|
1926
2243
|
export function crossCutting(m: DerivedManifest, opts: CoreScope & { readOnly?: boolean } = {}): (call: DerivedCall, next: () => Promise<Response>) => Promise<Response> {
|
|
1927
2244
|
const finish = async (r: Response, request: Request): Promise<Response> => {
|
|
1928
2245
|
if (!m.origin || !(r.headers.get('content-type') ?? '').includes('json')) return withHeaders(r);
|
|
@@ -1932,7 +2249,14 @@ export function crossCutting(m: DerivedManifest, opts: CoreScope & { readOnly?:
|
|
|
1932
2249
|
const withHeaders = (r: Response): Response => {
|
|
1933
2250
|
if (!m.answerHeaders) return r;
|
|
1934
2251
|
const headers = new Headers(r.headers);
|
|
1935
|
-
|
|
2252
|
+
const world = `${m.service}:${opts.root ?? ''}`;
|
|
2253
|
+
let seed: string | undefined;
|
|
2254
|
+
const filled = (v: string): string => v.replace(/\{hex:(\d+)\}/g, (_, w: string) => {
|
|
2255
|
+
seed ??= `${world}:answer:${nextAnswer(m.service, opts.root)}`;
|
|
2256
|
+
let hex = ''; for (let i = 0; hex.length < Number(w); i++) hex += sha256(`${seed}:${i}`);
|
|
2257
|
+
return hex.slice(0, Number(w));
|
|
2258
|
+
});
|
|
2259
|
+
for (const [k, v] of Object.entries(m.answerHeaders)) if (!headers.has(k)) headers.set(k, filled(v));
|
|
1936
2260
|
return new Response(r.body, { status: r.status, statusText: r.statusText, headers });
|
|
1937
2261
|
};
|
|
1938
2262
|
return async (call, next) => finish(await guarded(call, next), call.request);
|
|
@@ -1950,7 +2274,7 @@ export function crossCutting(m: DerivedManifest, opts: CoreScope & { readOnly?:
|
|
|
1950
2274
|
// multipart or form: Cloudflare's asset and script uploads)
|
|
1951
2275
|
const declared = call.operation.bodyEncoding;
|
|
1952
2276
|
const labelled = request.headers.get('content-type') ?? '';
|
|
1953
|
-
if ((labelled.includes('json') && !labelled.includes('ndjson')) || (m.body.json === 'always' && declared !== 'multipart' && declared !== 'form')) {
|
|
2277
|
+
if (!bytesPayload(call.operation) && ((labelled.includes('json') && !labelled.includes('ndjson') && !labelled.includes('connect+')) || (m.body.json === 'always' && declared !== 'multipart' && declared !== 'form'))) {
|
|
1954
2278
|
const ok = request.method === 'GET' || request.method === 'HEAD' ? true : await checkJsonBody(request.clone()).catch(() => true);
|
|
1955
2279
|
if (!ok) return vendorError(m, m.malformedBody ?? { status: 400, message: 'The request body could not be parsed as JSON.' });
|
|
1956
2280
|
}
|
|
@@ -1986,8 +2310,8 @@ export function crossCutting(m: DerivedManifest, opts: CoreScope & { readOnly?:
|
|
|
1986
2310
|
* coming (another wire's mutation, a door, a screen) — and answered with the vendor's own read-only
|
|
1987
2311
|
* error; `GET /twin` advertises `requestScopes`. The pack's catch-up runs under `runAsVendorMove`
|
|
1988
2312
|
* (request-scope.ts), so time's moves still land when a read-only request is what arrives. */
|
|
1989
|
-
export function derivedRequestScopes<F extends (request: Request) => Promise<Response>>(m: DerivedManifest, fetch: F): F {
|
|
1990
|
-
return withRequestScopes(fetch, { refuse: () => vendorError(m, m.readOnly) });
|
|
2313
|
+
export function derivedRequestScopes<F extends (request: Request) => Promise<Response>>(m: DerivedManifest, fetch: F, readOnly = false): F {
|
|
2314
|
+
return withRequestScopes(fetch, { readOnly, refuse: () => vendorError(m, m.readOnly) });
|
|
1991
2315
|
}
|
|
1992
2316
|
|
|
1993
2317
|
/** A semantics context for a request no surface operation names: another wire's (GraphQL) resolvers
|