@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/dist/src/derived-core.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseExactJson, compareExactNumbers, exactInteger, isExactIntegerValue } from "./exact-json.js";
|
|
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,
|
|
@@ -14,20 +15,23 @@ import { GitRefs } from "./git/refs.js";
|
|
|
14
15
|
import { worldNow } from "./world-clock.js";
|
|
15
16
|
import { worldEnvValue } from "./world-env.js";
|
|
16
17
|
import { openSessions, socketWrite } from "./sockets.js";
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
18
|
+
import { awaitWrite, awaitCondition, heldReadsWrite } from "./held-reads.js";
|
|
19
|
+
import { MACHINE_POOL, poolFor, guardedMachinePool } from "./machines.js";
|
|
20
|
+
import { askApplication, deliverEvents } from "./events.js";
|
|
21
|
+
import { handlerCrypto, hmac, ksuidFrom, lettersFrom, sha256, uuidFrom } from "./signing.js";
|
|
20
22
|
import { hashFieldValue } from "./hash.js";
|
|
21
23
|
import { readResourceBlob, removeResourceBlob, writeResourceBlob } from "./resource-blob.js";
|
|
22
24
|
import { managedDatabase, unboundDatabase } from "./managed-database.js";
|
|
23
25
|
import { execRedisRun } from "./redis/engine.js";
|
|
24
26
|
import { sendMail } from "./smtp.js";
|
|
27
|
+
import { ownField, parseBracketForm } from "./form.js";
|
|
28
|
+
export { ownField, parseBracketForm } from "./form.js";
|
|
25
29
|
import { multipartBoundary, multipartParts } from "./multipart.js";
|
|
26
|
-
import { projectOwnerResources, resolveSubjectId, subjectAliases } from "./actions.js";
|
|
30
|
+
import { listActions, projectOwnerResources, resolveSubjectId, subjectAliases } from "./actions.js";
|
|
27
31
|
import { vendorFetch } from "./vendor-call.js";
|
|
28
32
|
import { packReferences } from "./references.js";
|
|
29
33
|
import { twinPublicBase, withRequestScopes } from "./twin-fetch.js";
|
|
30
|
-
import { inVendorMove, isReadOnlyRequest, runAsVendorMove } from "./request-scope.js";
|
|
34
|
+
import { inVendorMove, isReadOnlyRequest, runAsVendorMove, writesRefused } from "./request-scope.js";
|
|
31
35
|
import { bodyTextOf, checkJsonBody, takeParsedBody } from "./derived.js";
|
|
32
36
|
/** What a move to `to` stores in the field: a boolean field's value is a boolean, and a derived field's
|
|
33
37
|
* value is written by the move's effects (a state name is not a timestamp), so it stores nothing itself. */
|
|
@@ -44,7 +48,7 @@ export function stateOf(decl, value) {
|
|
|
44
48
|
return d.state;
|
|
45
49
|
if (w.equals !== undefined && value === w.equals)
|
|
46
50
|
return d.state;
|
|
47
|
-
if (w.gt !== undefined && typeof value === 'number' && value
|
|
51
|
+
if (w.gt !== undefined && (typeof value === 'number' || isExactIntegerValue(value)) && compareExactNumbers(value, w.gt) > 0)
|
|
48
52
|
return d.state;
|
|
49
53
|
if (w.truthy !== undefined && Boolean(value) === w.truthy)
|
|
50
54
|
return d.state;
|
|
@@ -68,13 +72,13 @@ export function strictFields(m, operation, body) {
|
|
|
68
72
|
const { type } = field;
|
|
69
73
|
const fits = value === null
|
|
70
74
|
|| (type === 'boolean' && typeof value === 'boolean')
|
|
71
|
-
|| (type === 'integer' &&
|
|
72
|
-
|| (type === 'number' && typeof value === 'number')
|
|
75
|
+
|| (type === 'integer' && isExactIntegerValue(value))
|
|
76
|
+
|| (type === 'number' && (typeof value === 'number' || isExactIntegerValue(value)))
|
|
73
77
|
|| (type === 'string' && typeof value === 'string')
|
|
74
78
|
|| !['boolean', 'integer', 'number', 'string'].includes(type);
|
|
75
79
|
if (!fits)
|
|
76
80
|
return refuse(strict.type, { name, type: type === 'integer' ? 'an integer' : `a ${type}` });
|
|
77
|
-
if (typeof value === 'number' && ((field.minimum !== undefined && value
|
|
81
|
+
if ((typeof value === 'number' || isExactIntegerValue(value)) && ((field.minimum !== undefined && compareExactNumbers(value, field.minimum) < 0) || (field.maximum !== undefined && compareExactNumbers(value, field.maximum) > 0))) {
|
|
78
82
|
return refuse(strict.range, { name, min: field.minimum ?? null, max: field.maximum ?? null });
|
|
79
83
|
}
|
|
80
84
|
fields[name] = value;
|
|
@@ -98,10 +102,10 @@ function validateBody(m, operation, body) {
|
|
|
98
102
|
continue;
|
|
99
103
|
}
|
|
100
104
|
const types = field.type.split('|');
|
|
101
|
-
const fits = types.some((type) => type === 'null' ? value === null : type === 'integer' ?
|
|
105
|
+
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);
|
|
102
106
|
if (!fits)
|
|
103
107
|
return refuse(rule.type, { name: field.name, type: field.type, input: value });
|
|
104
|
-
if (typeof value === 'number' && ((field.minimum !== undefined && value
|
|
108
|
+
if ((typeof value === 'number' || isExactIntegerValue(value)) && ((field.minimum !== undefined && compareExactNumbers(value, field.minimum) < 0) || (field.maximum !== undefined && compareExactNumbers(value, field.maximum) > 0)))
|
|
105
109
|
return refuse(rule.range, { name: field.name, min: field.minimum ?? null, max: field.maximum ?? null, input: value });
|
|
106
110
|
}
|
|
107
111
|
return undefined;
|
|
@@ -120,73 +124,6 @@ export function flagOf(m, value) {
|
|
|
120
124
|
return false;
|
|
121
125
|
return undefined;
|
|
122
126
|
}
|
|
123
|
-
/** A form body's bracket notation (`a[b][0][c]=v`, `expand[]=x`) as nested objects and arrays. A form carries only
|
|
124
|
-
* text: a field the operation's spec types as a number or a boolean (`scalars`, by bracket path, written by
|
|
125
|
-
* world-tooling's formScalarsOf) is read as one when its text is that literal; every other field stays the text sent
|
|
126
|
-
* (`metadata[order]=007`, `name=2024`). */
|
|
127
|
-
/** A field set as the object's own, whatever its name (`__proto__`, `constructor` and `prototype` are data a caller
|
|
128
|
-
* may name: Stripe's metadata keys are the caller's), never through what the object inherits. */
|
|
129
|
-
export function ownField(node, key, value) {
|
|
130
|
-
Object.defineProperty(node, key, { value, writable: true, enumerable: true, configurable: true });
|
|
131
|
-
}
|
|
132
|
-
/** A field the object holds as its own, never one it inherits (`constructor` is every object's). */
|
|
133
|
-
const ownValue = (node, key) => (Object.hasOwn(node, key) ? node[key] : undefined);
|
|
134
|
-
export function parseBracketForm(text, scalars) {
|
|
135
|
-
const out = {};
|
|
136
|
-
for (const [rawKey, raw] of new URLSearchParams(text)) {
|
|
137
|
-
const parts = rawKey.replace(/\]/g, '').split('[');
|
|
138
|
-
const kind = scalars && scalarAt(scalars, parts);
|
|
139
|
-
const value = kind === 'boolean' ? (raw === 'true' ? true : raw === 'false' ? false : raw)
|
|
140
|
-
: kind === 'integer' ? (/^-?\d+$/.test(raw) ? Number(raw) : raw)
|
|
141
|
-
: kind === 'number' ? (/^-?\d+(\.\d+)?$/.test(raw) ? Number(raw) : raw)
|
|
142
|
-
: raw;
|
|
143
|
-
// every field is the node's own, read and written as such: a key naming an object's machinery (`__proto__[x]`,
|
|
144
|
-
// `constructor[prototype][x]`) is a field of that name, never a walk into what every object inherits
|
|
145
|
-
let node = out;
|
|
146
|
-
for (const [i, key] of parts.entries()) {
|
|
147
|
-
// an array holds its items by index (`a[]`, `a[0]`): a named key under one (`a[]=1&a[length]=-1`) has nowhere to
|
|
148
|
-
// go, as a key under text has none (`a=1&a[b]=2`): the first shape stands
|
|
149
|
-
if (Array.isArray(node) && key !== '' && !/^\d+$/.test(key))
|
|
150
|
-
break;
|
|
151
|
-
if (i === parts.length - 1) {
|
|
152
|
-
if (key === '') {
|
|
153
|
-
if (Array.isArray(node))
|
|
154
|
-
node.push(value);
|
|
155
|
-
}
|
|
156
|
-
else
|
|
157
|
-
ownField(node, key, value);
|
|
158
|
-
break;
|
|
159
|
-
}
|
|
160
|
-
const next = parts[i + 1];
|
|
161
|
-
if (key === '')
|
|
162
|
-
break;
|
|
163
|
-
let child = ownValue(node, key);
|
|
164
|
-
if (child === undefined) {
|
|
165
|
-
child = next === '' || /^\d+$/.test(next) ? [] : {};
|
|
166
|
-
ownField(node, key, child);
|
|
167
|
-
}
|
|
168
|
-
if (child === null || typeof child !== 'object')
|
|
169
|
-
break;
|
|
170
|
-
node = child;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
return out;
|
|
174
|
-
}
|
|
175
|
-
/** The type `scalars` gives a form key's parts: a segment is a property name, `[]` an array's item (an index or empty),
|
|
176
|
-
* `*` a map's key. */
|
|
177
|
-
function scalarAt(scalars, parts) {
|
|
178
|
-
let paths = [''];
|
|
179
|
-
for (const [i, part] of parts.entries()) {
|
|
180
|
-
const segs = part === '' || /^\d+$/.test(part) ? ['[]', '*'] : [part, '*'];
|
|
181
|
-
paths = paths.flatMap((p) => segs.map((seg) => (i === 0 ? seg : `${p}.${seg}`)));
|
|
182
|
-
if (paths.length > 64)
|
|
183
|
-
paths = paths.filter((p) => Object.keys(scalars).some((k) => k === p || k.startsWith(`${p}.`)));
|
|
184
|
-
}
|
|
185
|
-
for (const p of paths)
|
|
186
|
-
if (scalars[p])
|
|
187
|
-
return scalars[p];
|
|
188
|
-
return undefined;
|
|
189
|
-
}
|
|
190
127
|
/** A request's parameters, its query's and its body's. With the manifest's `body.form.coerce`, a form's fields the
|
|
191
128
|
* operation's spec types as numbers or booleans are read as them (parseBracketForm); without an operation (a twin door)
|
|
192
129
|
* every form value is its text. */
|
|
@@ -194,7 +131,7 @@ export async function readParams(manifest, request, operation) {
|
|
|
194
131
|
const url = new URL(request.url);
|
|
195
132
|
const scalars = manifest.body.form?.coerce ? (operation?.scalars ?? {}) : undefined;
|
|
196
133
|
const query = parseBracketForm(url.search.replace(/^\?/, ''), scalars);
|
|
197
|
-
if (request.method === 'GET' || request.method === 'HEAD')
|
|
134
|
+
if (request.method === 'GET' || request.method === 'HEAD' || (operation && bytesPayload(operation)))
|
|
198
135
|
return query;
|
|
199
136
|
const type = request.headers.get('content-type') ?? '';
|
|
200
137
|
if (type.includes('multipart/form-data')) {
|
|
@@ -219,7 +156,7 @@ export async function readParams(manifest, request, operation) {
|
|
|
219
156
|
}
|
|
220
157
|
// NDJSON (`application/x-ndjson`) is lines of JSON, not a JSON document: the handler reads its text
|
|
221
158
|
const media = (type.split(';')[0] ?? '').trim().toLowerCase();
|
|
222
|
-
const isJson = media.includes('json') && !media.includes('ndjson');
|
|
159
|
+
const isJson = media.includes('json') && !media.includes('ndjson') && !media.includes('connect+');
|
|
223
160
|
// a form is a body labelled one (application/x-www-form-urlencoded); a body of any other type (an image, a video, an
|
|
224
161
|
// octet-stream PUT to an upload URL, a text/plain SQL statement, an NDJSON batch) is no form: its parameters are the
|
|
225
162
|
// query's, and the handler reads its text or bytes itself
|
|
@@ -235,7 +172,7 @@ export async function readParams(manifest, request, operation) {
|
|
|
235
172
|
let body;
|
|
236
173
|
if (asJson) {
|
|
237
174
|
try {
|
|
238
|
-
body =
|
|
175
|
+
body = parseExactJson(text);
|
|
239
176
|
}
|
|
240
177
|
catch {
|
|
241
178
|
if (isJson)
|
|
@@ -353,27 +290,102 @@ function parentValue(parent, params) {
|
|
|
353
290
|
return String(fill(parent.value ?? parent.params.map((p) => `{${p}}`).join('/'), params));
|
|
354
291
|
return parent.param !== undefined ? params[parent.param] : undefined;
|
|
355
292
|
}
|
|
293
|
+
function parentField(row, path) {
|
|
294
|
+
if (row && typeof row === 'object' && Object.hasOwn(row, path))
|
|
295
|
+
return row[path];
|
|
296
|
+
let value = row;
|
|
297
|
+
for (const key of path.split('.'))
|
|
298
|
+
value = value && typeof value === 'object' ? value[key] : undefined;
|
|
299
|
+
return value;
|
|
300
|
+
}
|
|
356
301
|
function missingParent(m, resource, call, root) {
|
|
357
302
|
const parent = m.resources[resource]?.parent;
|
|
358
303
|
if (!parent)
|
|
359
304
|
return undefined;
|
|
360
305
|
const id = parentValue(parent, call.params);
|
|
361
306
|
const where = Object.entries(parent.where ?? {}).map(([field, template]) => [field, String(fill(template, call.params))]);
|
|
362
|
-
const found = (r) => (where.length ? where.every(([field, v]) => String(r
|
|
307
|
+
const found = (r) => (where.length ? where.every(([field, v]) => String(parentField(r, field)) === v) : r.id === id);
|
|
363
308
|
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));
|
|
364
309
|
}
|
|
365
310
|
/** The vendor's view of a stored subject: its own fields, bookkeeping (`_`) left out. */
|
|
366
311
|
/** A stored subject as the vendor's object: its id first (the subject's, unless the pack kept an id of its own), then
|
|
367
312
|
* the fields the pack wrote, bookkeeping (`_`) and the kernel's updatedAt left out. */
|
|
368
|
-
export function render(r) {
|
|
369
|
-
return
|
|
313
|
+
export function render(r, vendorUnderscored = []) {
|
|
314
|
+
return renderOwn({ id: r.id, ...ownFields(r) }, vendorUnderscored);
|
|
315
|
+
}
|
|
316
|
+
/** Filter an own-field view through the same declared vendor fields as a stored row. */
|
|
317
|
+
function renderOwn(own, vendorUnderscored = []) {
|
|
318
|
+
return Object.fromEntries(Object.entries(own).filter(([k]) => !k.startsWith('_') || vendorUnderscored.includes(k)));
|
|
319
|
+
}
|
|
320
|
+
const viewOmissions = new WeakMap();
|
|
321
|
+
// Precompute once at manifest binding: omitted fields, whether any exist, and their transitive containers through embeds.
|
|
322
|
+
function prepareViewOmissions(m) {
|
|
323
|
+
const held = viewOmissions.get(m);
|
|
324
|
+
if (held)
|
|
325
|
+
return held;
|
|
326
|
+
const fields = new Map();
|
|
327
|
+
for (const [resource, decl] of Object.entries(m.resources)) {
|
|
328
|
+
if (decl.viewOmit?.length)
|
|
329
|
+
fields.set(resource, new Set(decl.viewOmit));
|
|
330
|
+
}
|
|
331
|
+
const affected = new Set(fields.keys());
|
|
332
|
+
const embeds = new Map();
|
|
333
|
+
if (fields.size) {
|
|
334
|
+
let changed = true;
|
|
335
|
+
while (changed) {
|
|
336
|
+
changed = false;
|
|
337
|
+
for (const [resource, decl] of Object.entries(m.resources)) {
|
|
338
|
+
if (!affected.has(resource) && Object.values(decl.embeds ?? {}).some((target) => affected.has(target))) {
|
|
339
|
+
affected.add(resource);
|
|
340
|
+
changed = true;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
for (const resource of affected) {
|
|
345
|
+
embeds.set(resource, Object.entries(m.resources[resource]?.embeds ?? {}).filter(([, target]) => affected.has(target)));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const plan = { fields, affected, embeds };
|
|
349
|
+
viewOmissions.set(m, plan);
|
|
350
|
+
return plan;
|
|
351
|
+
}
|
|
352
|
+
/** The one response-omission mechanism, shared by stored, constructed and expanded resource views. */
|
|
353
|
+
function omitView(m, resource, body, seen) {
|
|
354
|
+
const plan = viewOmissions.get(m) ?? prepareViewOmissions(m);
|
|
355
|
+
if (!resource || !plan.affected.has(resource))
|
|
356
|
+
return body;
|
|
357
|
+
const held = seen?.get(resource)?.get(body);
|
|
358
|
+
if (held)
|
|
359
|
+
return held;
|
|
360
|
+
const out = { ...body };
|
|
361
|
+
seen ??= new Map();
|
|
362
|
+
let visited = seen.get(resource);
|
|
363
|
+
if (!visited) {
|
|
364
|
+
visited = new WeakMap();
|
|
365
|
+
seen.set(resource, visited);
|
|
366
|
+
}
|
|
367
|
+
visited.set(body, out);
|
|
368
|
+
for (const field of plan.fields.get(resource) ?? [])
|
|
369
|
+
delete out[field];
|
|
370
|
+
for (const [field, target] of plan.embeds.get(resource) ?? []) {
|
|
371
|
+
const present = out[field];
|
|
372
|
+
if (Array.isArray(present)) {
|
|
373
|
+
out[field] = present.map((child) => child && typeof child === 'object' && !Array.isArray(child) ? omitView(m, target, child, seen) : child);
|
|
374
|
+
}
|
|
375
|
+
else if (present && typeof present === 'object') {
|
|
376
|
+
out[field] = omitView(m, target, present, seen);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return out;
|
|
370
380
|
}
|
|
371
381
|
/** A stored subject as the vendor answers it: its own fields, its id under the name the vendor gives it. */
|
|
372
382
|
function view(m, resource, r) {
|
|
373
383
|
const idAs = m.resources[resource]?.idAs;
|
|
384
|
+
const keep = m.resources[resource]?.vendorUnderscored ?? [];
|
|
385
|
+
const shown = omitView(m, resource, render(r, keep));
|
|
374
386
|
if (!idAs)
|
|
375
|
-
return
|
|
376
|
-
const { id, ...rest } =
|
|
387
|
+
return shown;
|
|
388
|
+
const { id, ...rest } = shown;
|
|
377
389
|
return { [idAs]: id, ...rest };
|
|
378
390
|
}
|
|
379
391
|
function now(m, at) {
|
|
@@ -447,33 +459,58 @@ function worldSigningKey(service, root, at, label, alg) {
|
|
|
447
459
|
keying.set(key, made);
|
|
448
460
|
return made;
|
|
449
461
|
}
|
|
450
|
-
function mintId(m, resource, root, at) {
|
|
462
|
+
function mintId(m, resource, root, at, snapshot) {
|
|
451
463
|
if (!m.resources[resource])
|
|
452
464
|
throw new Error(`mint: ${resource} is not a resource ${m.vendor}'s manifest declares`);
|
|
453
465
|
const prefix = m.resources[resource].idPrefix;
|
|
454
466
|
const template = m.resources[resource].ids ?? m.ids.template;
|
|
455
467
|
if (template === '{uuid}')
|
|
456
|
-
return mintUuid(m, resource, root);
|
|
468
|
+
return mintUuid(m, resource, root, snapshot);
|
|
457
469
|
const snowflake = /^\{snowflake:(\d+)\}$/.exec(template);
|
|
458
470
|
if (snowflake)
|
|
459
|
-
return mintSnowflake(m, BigInt(snowflake[1]), root, at);
|
|
460
|
-
// `{letters:N}` and `{hex:N}`, alone
|
|
461
|
-
|
|
471
|
+
return mintSnowflake(m, BigInt(snowflake[1]), root, at, snapshot);
|
|
472
|
+
// `{letters:N}` and `{hex:N}`, alone, after the resource's prefix (`{prefix}{letters:16}`: Fly's `vol_…`), or after it
|
|
473
|
+
// and a separator (`{prefix}_{hex:48}`: OpenAI's `resp_67cc…`)
|
|
474
|
+
const letters = /^(?:\{prefix\}([_-]?))?\{letters:(\d+)\}$/.exec(template);
|
|
462
475
|
if (letters)
|
|
463
|
-
return mintLetters(m, resource, Number(letters[2]), root, letters[1] ? prefix : '');
|
|
464
|
-
const hex = /^(
|
|
476
|
+
return mintLetters(m, resource, Number(letters[2]), root, letters[1] !== undefined ? `${prefix}${letters[1]}` : '', snapshot);
|
|
477
|
+
const hex = /^(?:\{prefix\}([_-]?))?\{hex:(\d+)\}$/.exec(template);
|
|
465
478
|
if (hex)
|
|
466
|
-
return mintHex(m, resource, Number(hex[2]), root, hex[1] ? prefix : '');
|
|
479
|
+
return mintHex(m, resource, Number(hex[2]), root, hex[1] !== undefined ? `${prefix}${hex[1]}` : '', snapshot);
|
|
480
|
+
const digits = /^\{digits:(\d+)\}$/.exec(template);
|
|
481
|
+
if (digits) {
|
|
482
|
+
const width = Number(digits[1]);
|
|
483
|
+
if (!Number.isInteger(width) || width < 1 || width > 77)
|
|
484
|
+
throw new Error('mint: decimal width must be between 1 and 77');
|
|
485
|
+
const held = new Set((snapshot ? snapshot.filter((r) => r.type === storedType(m, resource)) : resourcesOfType(m.service, storedType(m, resource), root)).map((r) => r.id));
|
|
486
|
+
const floor = 10n ** BigInt(width - 1);
|
|
487
|
+
for (let n = held.size + 1;; n++) {
|
|
488
|
+
const id = String(floor + BigInt('0x' + sha256(`${m.service}:${resource}:${n}`)) % (9n * floor));
|
|
489
|
+
if (!held.has(id))
|
|
490
|
+
return id;
|
|
491
|
+
if (n - held.size > 9 * Number(floor))
|
|
492
|
+
throw new Error('mint: decimal id space exhausted');
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
// `{ksuid}` alone, after the prefix, or after it and a separator (`{prefix}_{ksuid}`: Clerk's `user_2…`)
|
|
496
|
+
// `{ksuid:N}` gives the id N characters (a vendor's own length: OpenAI's `proj_` 24, `chatcmpl-` 29), its order kept
|
|
497
|
+
const ksuid = /^(?:\{prefix\}([_-]?))?\{ksuid(?::(\d+))?\}$/.exec(template);
|
|
498
|
+
// narrower than 11 characters a KSUID no longer holds its instant and mint count, so neither order nor uniqueness
|
|
499
|
+
// within a second: such an id is minted otherwise ({letters:N}, {hex:N})
|
|
500
|
+
if (ksuid && ksuid[2] && Number(ksuid[2]) < 11)
|
|
501
|
+
throw new Error(`${m.service}: ${resource}'s id template ${template} is narrower than a KSUID's 11 ordered characters`);
|
|
502
|
+
if (ksuid)
|
|
503
|
+
return mintKsuid(m, resource, at, root, ksuid[1] !== undefined ? `${prefix}${ksuid[1]}` : '', snapshot, ksuid[2] ? Number(ksuid[2]) : 27);
|
|
467
504
|
const [head, tail = ''] = template.split('{n}');
|
|
468
505
|
const pattern = new RegExp(`^${escapeRe(String(fill(head, { prefix })))}(\\d+)${escapeRe(String(fill(tail, { prefix })))}$`);
|
|
469
|
-
let max =
|
|
506
|
+
let max = 0n;
|
|
470
507
|
// the one type, from the index that follows writes (a scan of the whole World per create grew with it)
|
|
471
|
-
for (const r of resourcesOfType(m.service, storedType(m, resource), root)) {
|
|
508
|
+
for (const r of (snapshot ? snapshot.filter((r) => r.type === storedType(m, resource)) : resourcesOfType(m.service, storedType(m, resource), root))) {
|
|
472
509
|
const hit = pattern.exec(r.id);
|
|
473
|
-
if (hit)
|
|
474
|
-
max =
|
|
510
|
+
if (hit && BigInt(hit[1]) > max)
|
|
511
|
+
max = BigInt(hit[1]);
|
|
475
512
|
}
|
|
476
|
-
return String(fill(template, { prefix, n: max +
|
|
513
|
+
return String(fill(template, { prefix, n: String(max + 1n) }));
|
|
477
514
|
}
|
|
478
515
|
/** The next snowflake (Discord's, X's): the milliseconds since the vendor's epoch shifted left 22 bits, the low bits an
|
|
479
516
|
* increment. One space over every snowflake resource of the vendor, as its one generator's: the instant's first, or one
|
|
@@ -482,14 +519,14 @@ function mintId(m, resource, root, at) {
|
|
|
482
519
|
/** The last snowflake minted per vendor and World in this process: two mints before the first is written (a message
|
|
483
520
|
* and its attachment) must not meet. */
|
|
484
521
|
const lastSnowflake = new Map();
|
|
485
|
-
function mintSnowflake(m, epoch, root, at) {
|
|
522
|
+
function mintSnowflake(m, epoch, root, at, snapshot) {
|
|
486
523
|
const ms = BigInt(Date.parse(at ?? '') || 0);
|
|
487
524
|
const base = ms > epoch ? (ms - epoch) << 22n : 0n;
|
|
488
525
|
let max = -1n;
|
|
489
526
|
for (const [name, decl] of Object.entries(m.resources)) {
|
|
490
527
|
if (!/^\{snowflake:\d+\}$/.test(decl.ids ?? m.ids.template))
|
|
491
528
|
continue;
|
|
492
|
-
for (const r of resourcesOfType(m.service, storedType(m, name), root))
|
|
529
|
+
for (const r of (snapshot ? snapshot.filter((r) => r.type === storedType(m, name)) : resourcesOfType(m.service, storedType(m, name), root)))
|
|
493
530
|
if (/^\d+$/.test(r.id)) {
|
|
494
531
|
const v = BigInt(r.id);
|
|
495
532
|
if (v > max)
|
|
@@ -503,10 +540,21 @@ function mintSnowflake(m, epoch, root, at) {
|
|
|
503
540
|
lastSnowflake.set(key, id);
|
|
504
541
|
return String(id);
|
|
505
542
|
}
|
|
543
|
+
/** The next KSUID for a resource (Clerk's and Svix's ids, `ksuidFrom`): the World's instant, then the type's mint
|
|
544
|
+
* count, so a frozen clock still lists them as made; past any the World already holds. */
|
|
545
|
+
function mintKsuid(m, resource, at, root, prefix = '', snapshot, width = 27) {
|
|
546
|
+
const held = new Set((snapshot ? snapshot.filter((r) => r.type === storedType(m, resource)) : resourcesOfType(m.service, storedType(m, resource), root)).map((r) => r.id));
|
|
547
|
+
const seconds = Math.floor((Date.parse(at ?? '') || 0) / 1000);
|
|
548
|
+
for (let n = held.size + 1;; n++) {
|
|
549
|
+
const id = `${prefix}${ksuidFrom(seconds, n, `${m.service}:${resource}:${n}`, width)}`;
|
|
550
|
+
if (!held.has(id))
|
|
551
|
+
return id;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
506
554
|
/** The next id of `count` lowercase letters for a resource, derived from the resource and the count before it, past
|
|
507
555
|
* any the World already holds. */
|
|
508
|
-
function mintLetters(m, resource, count, root, prefix = '') {
|
|
509
|
-
const held = new Set(resourcesOfType(m.service, storedType(m, resource), root).map((r) => r.id));
|
|
556
|
+
function mintLetters(m, resource, count, root, prefix = '', snapshot) {
|
|
557
|
+
const held = new Set((snapshot ? snapshot.filter((r) => r.type === storedType(m, resource)) : resourcesOfType(m.service, storedType(m, resource), root)).map((r) => r.id));
|
|
510
558
|
for (let n = held.size + 1;; n++) {
|
|
511
559
|
const id = `${prefix}${lettersFrom(`${m.service}:${resource}:${n}`, count)}`;
|
|
512
560
|
if (!held.has(id))
|
|
@@ -515,8 +563,8 @@ function mintLetters(m, resource, count, root, prefix = '') {
|
|
|
515
563
|
}
|
|
516
564
|
/** The next hex id for a resource: N hex digits derived from the resource and the count before it, past any the World
|
|
517
565
|
* already holds (a Cloudflare id, 32). */
|
|
518
|
-
function mintHex(m, resource, count, root, prefix = '') {
|
|
519
|
-
const held = new Set(resourcesOfType(m.service, storedType(m, resource), root).map((r) => r.id));
|
|
566
|
+
function mintHex(m, resource, count, root, prefix = '', snapshot) {
|
|
567
|
+
const held = new Set((snapshot ? snapshot.filter((r) => r.type === storedType(m, resource)) : resourcesOfType(m.service, storedType(m, resource), root)).map((r) => r.id));
|
|
520
568
|
for (let n = held.size + 1;; n++) {
|
|
521
569
|
const id = `${prefix}${sha256(`${m.service}:${resource}:${n}`).slice(0, count)}`;
|
|
522
570
|
if (!held.has(id))
|
|
@@ -525,19 +573,35 @@ function mintHex(m, resource, count, root, prefix = '') {
|
|
|
525
573
|
}
|
|
526
574
|
/** The next UUID for a resource: a version-4-shaped UUID derived from the resource and the count before it, past any
|
|
527
575
|
* the World already holds. */
|
|
528
|
-
function mintUuid(m, resource, root) {
|
|
576
|
+
function mintUuid(m, resource, root, snapshot) {
|
|
529
577
|
const type = storedType(m, resource);
|
|
530
|
-
const held = new Set(resourcesOfType(m.service, type, root).map((r) => r.id));
|
|
578
|
+
const held = new Set((snapshot ? snapshot.filter((r) => r.type === type) : resourcesOfType(m.service, type, root)).map((r) => r.id));
|
|
531
579
|
for (let n = held.size + 1;; n++) {
|
|
532
580
|
const id = uuidFrom(`${m.service}:${resource}:${n}`);
|
|
533
581
|
if (!held.has(id))
|
|
534
582
|
return id;
|
|
535
583
|
}
|
|
536
584
|
}
|
|
537
|
-
function expand(m, resource, body, paths, root) {
|
|
585
|
+
function expand(m, resource, body, paths, root, within = new Set()) {
|
|
586
|
+
body = omitView(m, resource, body);
|
|
538
587
|
const embeds = m.resources[resource]?.embeds ?? {};
|
|
588
|
+
// the fields the vendor always embeds join the caller's paths; a resource already being embedded above is not embedded
|
|
589
|
+
// again inside itself (a cycle answers the id)
|
|
590
|
+
const always = within.has(resource) ? [] : (m.resources[resource]?.alwaysEmbedded ?? []);
|
|
591
|
+
const inner = new Set([...within, resource]);
|
|
539
592
|
const out = { ...body };
|
|
540
|
-
|
|
593
|
+
const idKey = m.resources[resource]?.idAs ?? 'id';
|
|
594
|
+
const ownId = typeof body[idKey] === 'string' ? body[idKey] : undefined;
|
|
595
|
+
for (const [field, c] of within.has(resource) ? [] : Object.entries(m.resources[resource]?.collections ?? {})) {
|
|
596
|
+
if (!ownId)
|
|
597
|
+
continue;
|
|
598
|
+
const childPaths = paths.filter((p) => p.startsWith(`${field}.data.`)).map((p) => p.slice(field.length + 6));
|
|
599
|
+
const data = stored(m, c.resource, root).filter((r) => r[c.by] === ownId)
|
|
600
|
+
.map((r) => expand(m, c.resource, view(m, c.resource, r), childPaths, root, inner));
|
|
601
|
+
const fill = (v) => v === '{count}' ? data.length : typeof v === 'string' ? v.replaceAll('{id}', ownId) : v;
|
|
602
|
+
out[field] = { ...Object.fromEntries(Object.entries(c.list ?? {}).map(([k, v]) => [k, fill(v)])), data };
|
|
603
|
+
}
|
|
604
|
+
for (const path of [...new Set([...always, ...paths])]) {
|
|
541
605
|
const [head, ...rest] = path.split('.');
|
|
542
606
|
const target = embeds[head];
|
|
543
607
|
const id = out[head];
|
|
@@ -545,15 +609,14 @@ function expand(m, resource, body, paths, root) {
|
|
|
545
609
|
continue;
|
|
546
610
|
// an object already embedded (an earlier path's) is walked further, not fetched again
|
|
547
611
|
if (id && typeof id === 'object' && !Array.isArray(id)) {
|
|
548
|
-
|
|
549
|
-
out[head] = expand(m, target, id, [rest.join('.')], root);
|
|
612
|
+
out[head] = expand(m, target, id, rest.length ? [rest.join('.')] : [], root, inner);
|
|
550
613
|
continue;
|
|
551
614
|
}
|
|
552
615
|
if (typeof id !== 'string')
|
|
553
616
|
continue;
|
|
554
617
|
const hit = stored(m, target, root).find((r) => r.id === id);
|
|
555
618
|
if (hit)
|
|
556
|
-
out[head] =
|
|
619
|
+
out[head] = expand(m, target, view(m, target, hit), rest.length ? [rest.join('.')] : [], root, inner);
|
|
557
620
|
}
|
|
558
621
|
return out;
|
|
559
622
|
}
|
|
@@ -561,16 +624,17 @@ function expandPaths(m, params) {
|
|
|
561
624
|
const raw = m.expandParam ? params[m.expandParam] : undefined;
|
|
562
625
|
return Array.isArray(raw) ? raw.map(String) : typeof raw === 'string' ? [raw] : [];
|
|
563
626
|
}
|
|
564
|
-
async function write(m, call, resource, id, fields, operation, params, root, occurredAt) {
|
|
565
|
-
return (await writeDetailed(m, call, resource, id, fields, operation, params, root, occurredAt)).body;
|
|
627
|
+
async function write(m, call, resource, id, fields, operation, params, root, occurredAt, checks) {
|
|
628
|
+
return (await writeDetailed(m, call, resource, id, fields, operation, params, root, occurredAt, checks)).body;
|
|
566
629
|
}
|
|
567
630
|
const sentOf = new WeakMap();
|
|
568
631
|
/** The lane a vendor's router sent a request to (pack-fetch's laneRouter sets it). */
|
|
569
632
|
export const LANE_HEADER = 'x-volter-lane';
|
|
570
633
|
function sentRequest(m, call, body) {
|
|
571
634
|
const url = new URL(call.request.url);
|
|
572
|
-
const named = [...(m.headers ?? []), ...(m.version ? [m.version.header] : [])];
|
|
573
|
-
const
|
|
635
|
+
const named = [...(m.headers ?? []), ...(m.version ? [m.version.header] : [])].map((h) => h.toLowerCase());
|
|
636
|
+
const recorded = (h) => named.some((n) => (n.endsWith('*') ? h.startsWith(n.slice(0, -1)) : h === n));
|
|
637
|
+
const headers = Object.fromEntries([...call.request.headers].filter(([h]) => recorded(h.toLowerCase())).map(([h, v]) => [h.toLowerCase(), v]));
|
|
574
638
|
const lane = call.request.headers.get(LANE_HEADER) ?? undefined;
|
|
575
639
|
const requestId = call.request.headers.get('x-twins-request-id') ?? undefined;
|
|
576
640
|
const declared = new Set((call.operation.query ?? []).map((q) => q.name));
|
|
@@ -588,6 +652,9 @@ function sentRequest(m, call, body) {
|
|
|
588
652
|
}
|
|
589
653
|
/** What a write records of the call that made it: the surface operation and the request as it came, or which of the
|
|
590
654
|
* World's own doors made it (a door or a screen, the gap, the clock), which a perform never sends. */
|
|
655
|
+
/** An operation whose body is its payload's bytes, one `blob` field: recorded as it came and never read as JSON
|
|
656
|
+
* ("What the derived core records with a write"). */
|
|
657
|
+
const bytesPayload = (op) => op.body?.length === 1 && op.body[0].type === 'blob';
|
|
591
658
|
/** A body as it came: `{ $text }` when its bytes are UTF-8, else `{ $base64 }`. */
|
|
592
659
|
async function rawBody(request) {
|
|
593
660
|
const bytes = new Uint8Array(await request.clone().arrayBuffer());
|
|
@@ -624,13 +691,14 @@ function recordedInput(m, call, merged) {
|
|
|
624
691
|
return { clock: true };
|
|
625
692
|
if (id === 'unmatched')
|
|
626
693
|
return { gap: true, ...asSent };
|
|
627
|
-
if ((m.doors ?? []).some((d) => d.id === id) || (m.screens ?? []).some((sc) => sc.id === id) || id === 'machine-pool' || id.startsWith('socket:'))
|
|
694
|
+
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:'))
|
|
628
695
|
return { door: id, ...asSent };
|
|
629
696
|
return { operationId: id, ...asSent };
|
|
630
697
|
}
|
|
631
698
|
/** The scope each call is served in (serveCore, contextFor), for the contexts a write opens for its hooks. */
|
|
632
699
|
const scopeOfCall = new WeakMap();
|
|
633
|
-
async function writeDetailed(m, call, resource, id, fields, operation, params, root, occurredAt) {
|
|
700
|
+
async function writeDetailed(m, call, resource, id, fields, operation, params, root, occurredAt, checks) {
|
|
701
|
+
const before = stateBeforeWrite(m, resource, id, root);
|
|
634
702
|
const withFiles = async (input) => { const files = input.operationId ? await keptFiles(m, call, root) : undefined; return files ? { ...input, files } : input; };
|
|
635
703
|
const { resource: row, result } = await applyTwinWrite(m.service,
|
|
636
704
|
// who made it: the login the pack's identity reads off the request, when it declares one, so a subject's
|
|
@@ -639,6 +707,31 @@ async function writeDetailed(m, call, resource, id, fields, operation, params, r
|
|
|
639
707
|
inVendorMove()
|
|
640
708
|
? { operation, subjectType: storedType(m, resource), subjectId: id, fields, input: { clock: true }, occurredAt, actor: { kind: 'system' } }
|
|
641
709
|
: { 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) } : {}) } }, root);
|
|
710
|
+
observeStateWrite(m, result, before, root, checks);
|
|
711
|
+
return finishWrite(m, call, resource, row, result, operation, params, root, occurredAt);
|
|
712
|
+
}
|
|
713
|
+
async function atomicWrite(m, call, resource, decide, operation, params, root, occurredAt, unchangedId, checks) {
|
|
714
|
+
const input = recordedInput(m, call, params);
|
|
715
|
+
const files = input.operationId ? await keptFiles(m, call, root) : undefined;
|
|
716
|
+
let before;
|
|
717
|
+
const committed = await applyTwinWriteAtomic(m.service, (snapshot) => {
|
|
718
|
+
const selected = decide(snapshot);
|
|
719
|
+
if (selected && transitionObserver)
|
|
720
|
+
before = snapshot.find((r) => r.type === storedType(m, resource) && r.id === selected.id);
|
|
721
|
+
return selected ? { kind: 'write', value: selected.id, write: {
|
|
722
|
+
operation, subjectType: storedType(m, resource), subjectId: selected.id, fields: selected.fields,
|
|
723
|
+
input: files ? { ...input, files } : input, occurredAt, actor: { kind: 'agent', ...(m.identity ? { id: m.identity(call.request.headers.get('authorization'), root) } : {}) },
|
|
724
|
+
} } : { kind: 'skip', value: undefined };
|
|
725
|
+
}, root);
|
|
726
|
+
if (!committed.result || !committed.value) {
|
|
727
|
+
const row = unchangedId ? stored(m, resource, root).find((r) => r.id === unchangedId) : undefined;
|
|
728
|
+
return row ? view(m, resource, row) : undefined;
|
|
729
|
+
}
|
|
730
|
+
observeStateWrite(m, committed.result, before, root, checks);
|
|
731
|
+
const row = resourcesOfType(m.service, storedType(m, resource), root).find((r) => r.id === resolveSubjectId(m.service, storedType(m, resource), committed.value, root));
|
|
732
|
+
return (await finishWrite(m, call, resource, row, committed.result, operation, params, root, occurredAt)).body;
|
|
733
|
+
}
|
|
734
|
+
async function finishWrite(m, call, resource, row, result, operation, params, root, occurredAt) {
|
|
642
735
|
await keepCounts(m, call, resource, row, params, root, occurredAt);
|
|
643
736
|
const body = view(m, resource, row);
|
|
644
737
|
// the write's own context for its hooks, in the scope the call was served in (a walk's application answers, a tenant,
|
|
@@ -649,10 +742,12 @@ async function writeDetailed(m, call, resource, id, fields, operation, params, r
|
|
|
649
742
|
if (m.events) {
|
|
650
743
|
const events = m.events;
|
|
651
744
|
const write = { operation, storedType: storedType(m, resource), body, ...(root !== undefined ? { root } : {}), occurredAt, request: call.request };
|
|
652
|
-
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
|
|
745
|
+
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);
|
|
653
746
|
}
|
|
654
747
|
if (m.sockets?.length)
|
|
655
748
|
socketWrite(m.service, root, { operation, storedType: storedType(m, resource), body, ...(root !== undefined ? { root } : {}), occurredAt, request: call.request });
|
|
749
|
+
// a read held until the World writes (held-reads.ts) hears it
|
|
750
|
+
heldReadsWrite(m.service, root, storedType(m, resource));
|
|
656
751
|
// the subject's id as it stands after the write: the vendor's, when a live head minted one
|
|
657
752
|
return { body, id: result.externalId ?? row.id, ...(result.vendorData !== undefined ? { vendorData: result.vendorData } : {}) };
|
|
658
753
|
}
|
|
@@ -664,15 +759,15 @@ async function keepCounts(m, call, resource, row, params, root, occurredAt) {
|
|
|
664
759
|
if (storedType(m, c.of) !== storedType(m, resource) || row[c.by] === undefined || row[c.by] === null)
|
|
665
760
|
continue;
|
|
666
761
|
const pid = String(row[c.by]);
|
|
667
|
-
const held = stored(m, parent, root).find((r) => r.id === pid);
|
|
762
|
+
const held = stored(m, parent, root).find((r) => String(c.parentKey ? ownFields(r)[c.parentKey] : r.id) === pid);
|
|
668
763
|
if (!held)
|
|
669
764
|
continue;
|
|
670
765
|
const n = stored(m, c.of, root).filter((r) => {
|
|
671
766
|
const x = r;
|
|
672
|
-
return String(x[c.by]) === pid && (c.present === undefined || (x[c.present] !== undefined && x[c.present] !== null));
|
|
767
|
+
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);
|
|
673
768
|
}).length + (c.plus ?? 0);
|
|
674
769
|
if (held[field] !== n)
|
|
675
|
-
await writeDetailed(m, call, parent,
|
|
770
|
+
await writeDetailed(m, call, parent, String(held.id), { [field]: n }, `${storedType(m, parent)}.counted`, params, root, occurredAt);
|
|
676
771
|
}
|
|
677
772
|
}
|
|
678
773
|
}
|
|
@@ -685,7 +780,7 @@ function decodeOffset(cursor) {
|
|
|
685
780
|
if (typeof cursor !== 'string' || !cursor)
|
|
686
781
|
return 0;
|
|
687
782
|
try {
|
|
688
|
-
const o =
|
|
783
|
+
const o = parseExactJson(atob(cursor)).o;
|
|
689
784
|
return typeof o === 'number' && o >= 0 ? o : 0;
|
|
690
785
|
}
|
|
691
786
|
catch {
|
|
@@ -696,61 +791,68 @@ let transitionObserver;
|
|
|
696
791
|
export function observeTransitions(observer) {
|
|
697
792
|
transitionObserver = observer;
|
|
698
793
|
}
|
|
794
|
+
const stateCheckKey = (type, field) => JSON.stringify([type, field]);
|
|
795
|
+
const stateValue = (row, field) => field.split('.').reduce((value, part) => value && typeof value === 'object' ? value[part] : undefined, row);
|
|
796
|
+
function stateBeforeWrite(m, resource, id, root) {
|
|
797
|
+
if (!transitionObserver)
|
|
798
|
+
return undefined;
|
|
799
|
+
const type = storedType(m, resource);
|
|
800
|
+
const key = resolveSubjectId(m.service, type, id, root);
|
|
801
|
+
return resourcesOfType(m.service, type, root).find((row) => row.id === key);
|
|
802
|
+
}
|
|
803
|
+
/** Observe the committed entry, not a legality probe's display id. Only a move the machine checks
|
|
804
|
+
* for this entry's actual states, operation and actor is reported; no serving depends on measurement. */
|
|
805
|
+
function observeStateWrite(m, result, before, root, checks) {
|
|
806
|
+
if (!transitionObserver)
|
|
807
|
+
return;
|
|
808
|
+
const action = listActions(m.service, root).find((a) => a.id === result.actionId);
|
|
809
|
+
if (!action?.fields)
|
|
810
|
+
return;
|
|
811
|
+
const prior = before ? ownFields(before) : undefined;
|
|
812
|
+
const next = { ...prior, ...action.fields };
|
|
813
|
+
const decisions = new Map();
|
|
814
|
+
for (const [resource, decl] of Object.entries(m.resources)) {
|
|
815
|
+
if ((decl.storedAs ?? resource) !== action.subject.type)
|
|
816
|
+
continue;
|
|
817
|
+
for (const [field, machine] of Object.entries(decl.state ?? {})) {
|
|
818
|
+
if (!(field.split('.')[0] in action.fields))
|
|
819
|
+
continue;
|
|
820
|
+
const key = stateCheckKey(action.subject.type, field);
|
|
821
|
+
// Aliases of one stored field use the same consumed evidence on this one entry.
|
|
822
|
+
if (!decisions.has(key)) {
|
|
823
|
+
decisions.set(key, checks?.get(key) ?? []);
|
|
824
|
+
checks?.delete(key);
|
|
825
|
+
}
|
|
826
|
+
if (result.status === 'replayed')
|
|
827
|
+
continue;
|
|
828
|
+
const current = prior ? stateValue(prior, field) : undefined, target = stateOf(machine, stateValue(next, field));
|
|
829
|
+
const from = stateOf(machine, current);
|
|
830
|
+
const proof = decisions.get(key).find((c) => machine.transitions.includes(c.transition) && c.from === from && c.to === target);
|
|
831
|
+
// Initial-state and same-state moves still need an explicit check and a committed field write.
|
|
832
|
+
if ((!prior || from === target) && !proof)
|
|
833
|
+
continue;
|
|
834
|
+
const actor = proof?.actor ?? (action.actor?.kind === 'system' || action.input?.clock === true ? 'vendor' : 'api');
|
|
835
|
+
const operation = typeof action.input?.operationId === 'string' ? action.input.operationId : proof?.operation ?? (action.input?.clock === true ? 'clock' : action.operation ?? '');
|
|
836
|
+
transitionFor(field, machine, operation, current, target, action.subject.id, actor, { action, field, actor });
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
699
840
|
/** The transition a request asks for on one state field, or the vendor's refusal when none applies. */
|
|
700
841
|
/** What a declared machine says to one move: the transition that allows it, or the refusal it gives (and
|
|
701
842
|
* nothing when it declares neither). The derived core and `legal` ask it; so does an engine that is not
|
|
702
843
|
* HTTP-shaped (a line protocol's session), so one machine rules every wire. */
|
|
703
|
-
|
|
704
|
-
* a success resets the failure count and writes its fields; a failure counts, and a run of them makes the declared
|
|
705
|
-
* move, asked of the endpoint's machine as the vendor's (a move the machine does not allow is not made). Read fresh
|
|
706
|
-
* and written in one move, since the answer arrives after the write's own and answers may arrive together. */
|
|
707
|
-
async function deliveryOutcome(m, answer, root) {
|
|
708
|
-
const o = answer.kind.outcome;
|
|
709
|
-
const type = answer.kind.storedAs;
|
|
710
|
-
const ok = answer.status >= 200 && answer.status < 300;
|
|
711
|
-
// the read and the count are one move under the action lock, so two answers at once count twice
|
|
712
|
-
await runAsVendorMove(() => applyTwinWriteAtomic(m.service, (resources) => {
|
|
713
|
-
const row = resources.find((r) => r.type === type && String(r.id) === String(answer.row.id));
|
|
714
|
-
if (!row || row.deleted === true)
|
|
715
|
-
return { kind: 'skip', value: undefined };
|
|
716
|
-
const fields = {};
|
|
717
|
-
let operation = `${type}.delivered`;
|
|
718
|
-
if (ok) {
|
|
719
|
-
fields[o.failures] = 0;
|
|
720
|
-
const seconds = Math.floor((Date.parse(answer.occurredAt) || 0) / 1000);
|
|
721
|
-
for (const [k, v] of Object.entries(o.success ?? {}))
|
|
722
|
-
fields[k] = v === '$time.iso' ? answer.occurredAt : v === '$time.s' ? seconds : v;
|
|
723
|
-
}
|
|
724
|
-
else {
|
|
725
|
-
const failures = (Number(row[o.failures]) || 0) + 1;
|
|
726
|
-
fields[o.failures] = failures;
|
|
727
|
-
operation = `${type}.delivery-failed`;
|
|
728
|
-
const exempt = o.unless !== undefined && row[o.unless.field] === o.unless.equals;
|
|
729
|
-
if (o.move && o.after !== undefined && failures >= o.after && !exempt) {
|
|
730
|
-
const decl = Object.values(m.resources).find((r) => (r.storedAs ?? '') === type) ?? m.resources[type];
|
|
731
|
-
const machine = decl?.state?.[o.move.field];
|
|
732
|
-
const { move } = machine ? transitionFor(o.move.field, machine, o.move.operation, row[o.move.field], o.move.to, String(row.id), 'vendor') : {};
|
|
733
|
-
if (move) {
|
|
734
|
-
fields[o.move.field] = o.move.to;
|
|
735
|
-
operation = o.move.operation;
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
return { kind: 'write', value: undefined, write: { operation, subjectType: type, subjectId: String(row.id), fields, occurredAt: answer.occurredAt, actor: { kind: 'system' } } };
|
|
740
|
-
}, root));
|
|
741
|
-
}
|
|
742
|
-
export function transitionFor(field, decl, operationId, current, requested, id, actor = 'api') {
|
|
844
|
+
export function transitionFor(field, decl, operationId, current, requested, id, actor = 'api', entry) {
|
|
743
845
|
const now = stateOf(decl, current);
|
|
744
|
-
const candidates = decl.transitions.filter((t) => (t.actor ?? 'api') === actor && (t.operation ? t.operation === operationId : true) && (requested === undefined || (t.to ?? now) === String(requested)));
|
|
745
|
-
const move = candidates.find((t) => t.from === '*' || t.from.includes(now));
|
|
846
|
+
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)));
|
|
847
|
+
const move = candidates.find((t) => !(t.to === undefined && t.refusal !== undefined) && (t.from === '*' || t.from.includes(now)));
|
|
746
848
|
if (move) {
|
|
747
|
-
transitionObserver?.(decl, move, 'moved', now);
|
|
849
|
+
transitionObserver?.(decl, move, 'moved', now, entry);
|
|
748
850
|
return { move };
|
|
749
851
|
}
|
|
750
|
-
const refused = candidates.find((t) => t.refusals?.[now]) ?? candidates.find((t) => t.refusal);
|
|
852
|
+
const refused = candidates.find((t) => t.refusals?.[now]) ?? candidates.find((t) => t.refusal && (t.to !== undefined || t.from === '*' || t.from.includes(now)));
|
|
751
853
|
const r = refused?.refusals?.[now] ?? refused?.refusal;
|
|
752
854
|
if (refused && r)
|
|
753
|
-
transitionObserver?.(decl, refused, 'refused', now);
|
|
855
|
+
transitionObserver?.(decl, refused, 'refused', now, entry);
|
|
754
856
|
if (r)
|
|
755
857
|
return { refusal: { status: r.status, message: String(fill(r.message, { from: current, to: refused.to ?? now, field, id: id ?? '' })), ...(r.code ? { code: r.code } : {}) } };
|
|
756
858
|
return {};
|
|
@@ -891,47 +993,52 @@ function arrangeRows(m, resource, params, input) {
|
|
|
891
993
|
indexed.sort((a, b) => {
|
|
892
994
|
const x = a.row[order.field];
|
|
893
995
|
const y = b.row[order.field];
|
|
894
|
-
const byValue = !byText || typeof x === 'number' || typeof y === 'number' ?
|
|
895
|
-
// ties by
|
|
896
|
-
|
|
996
|
+
const byValue = !byText || typeof x === 'number' || typeof y === 'number' ? compareExactNumbers(x ?? 0, y ?? 0) : String(x ?? '').localeCompare(String(y ?? ''));
|
|
997
|
+
// ties (two made in one instant) by the order they were written: the rows come in the tree's order, which keeps each
|
|
998
|
+
// subject where it was first written, so this is the creation order whatever the id template (an ordered KSUID or
|
|
999
|
+
// count, or a hash-derived uuid, hex or letters id whose text says nothing of when it was made)
|
|
1000
|
+
const d = byValue || a.i - b.i;
|
|
897
1001
|
return order.direction === 'desc' ? -d : d;
|
|
898
1002
|
});
|
|
899
1003
|
return indexed.map((x) => x.row);
|
|
900
1004
|
}
|
|
901
1005
|
/** A page of arranged rows, answered in the manifest's list envelope with its paging (cursors, pages, offsets). */
|
|
902
1006
|
function pageRows(m, op, call, params, rows, resource, root, paths) {
|
|
903
|
-
const
|
|
904
|
-
|
|
1007
|
+
const list = m.list;
|
|
1008
|
+
if (!list)
|
|
1009
|
+
return vendorError(m, m.gap ?? m.notFound);
|
|
1010
|
+
const size = list.limits?.[op.id] ?? list.limit;
|
|
1011
|
+
const asked = params[list.limit.param];
|
|
905
1012
|
const n = asked === undefined || asked === '' ? size.default : Number(asked);
|
|
906
1013
|
const limit = Number.isFinite(n) ? Math.min(size.max, Math.max(1, Math.trunc(n))) : size.default;
|
|
907
|
-
const after =
|
|
908
|
-
const before =
|
|
1014
|
+
const after = list.after ? params[list.after] : undefined;
|
|
1015
|
+
const before = list.before ? params[list.before] : undefined;
|
|
909
1016
|
let from = 0;
|
|
910
1017
|
let to;
|
|
911
1018
|
if (typeof before === 'string') {
|
|
912
1019
|
const at = rows.findIndex((r) => r.id === before);
|
|
913
|
-
to = at === -1 ? (
|
|
1020
|
+
to = at === -1 ? (list.unknownCursor === 'end' ? 0 : rows.length) : at;
|
|
914
1021
|
from = Math.max(0, to - limit);
|
|
915
1022
|
}
|
|
916
1023
|
else {
|
|
917
1024
|
if (typeof after === 'string') {
|
|
918
1025
|
const at = rows.findIndex((r) => r.id === after);
|
|
919
|
-
from = at === -1 ? (
|
|
1026
|
+
from = at === -1 ? (list.unknownCursor === 'end' ? rows.length : 0) : at + 1;
|
|
920
1027
|
}
|
|
921
1028
|
to = from + limit;
|
|
922
1029
|
}
|
|
923
|
-
const page = rows.slice(from, to).map((r) =>
|
|
1030
|
+
const page = rows.slice(from, to).map((r) => expand(m, resource, r, paths.filter((p) => p.startsWith('data.')).map((p) => p.slice(5)), root));
|
|
924
1031
|
const hasMore = typeof before === 'string' ? from > 0 : to < rows.length;
|
|
925
1032
|
const url = op.path.replace(/\{[^}]+\}/g, (p) => call.params[p.slice(1, -1)] ?? p);
|
|
926
|
-
if (
|
|
927
|
-
const n = Math.max(1, Math.floor(Number(params[
|
|
928
|
-
const slice = rows.slice((n - 1) * limit, n * limit);
|
|
1033
|
+
if (list.page) {
|
|
1034
|
+
const n = Math.max(1, Math.floor(Number(params[list.page.param]) || 1));
|
|
1035
|
+
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));
|
|
929
1036
|
const last = Math.max(1, Math.ceil(rows.length / limit));
|
|
930
1037
|
const headers = new Headers();
|
|
931
|
-
if (
|
|
1038
|
+
if (list.page.link && rows.length > limit) {
|
|
932
1039
|
const at = (k) => {
|
|
933
1040
|
const u = new URL(call.request.url);
|
|
934
|
-
u.searchParams.set(
|
|
1041
|
+
u.searchParams.set(list.page.param, String(k));
|
|
935
1042
|
return u.toString();
|
|
936
1043
|
};
|
|
937
1044
|
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"`] : [])];
|
|
@@ -939,31 +1046,63 @@ function pageRows(m, op, call, params, rows, resource, root, paths) {
|
|
|
939
1046
|
headers.set('link', rels.join(', '));
|
|
940
1047
|
}
|
|
941
1048
|
// `{total_count}` the count before paging, `{max_page}` the number of pages (none for an empty list: Polar's pagination)
|
|
942
|
-
return Response.json(fill(
|
|
1049
|
+
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 });
|
|
943
1050
|
}
|
|
944
|
-
if (
|
|
945
|
-
const skip = Math.max(0, Math.trunc(Number(params[
|
|
1051
|
+
if (list.offset) {
|
|
1052
|
+
const skip = Math.max(0, Math.trunc(Number(params[list.offset.param]) || 0));
|
|
946
1053
|
const slice = rows.slice(skip, skip + limit);
|
|
947
1054
|
const key = op.answers?.key ?? 'data';
|
|
948
|
-
return Response.json({ ...(m.success ?? {}), [key]: slice, ...fill(
|
|
1055
|
+
return Response.json({ ...(m.success ?? {}), [key]: slice, ...fill(list.envelopes?.[op.id] ?? list.envelope, { data: slice, total_count: rows.length, key }) });
|
|
949
1056
|
}
|
|
950
|
-
if (
|
|
951
|
-
|
|
1057
|
+
if (list.cursor) {
|
|
1058
|
+
if (list.cursor.encoding === 'offset-template') {
|
|
1059
|
+
const cursor = list.cursor;
|
|
1060
|
+
const names = [];
|
|
1061
|
+
const pattern = '^' + cursor.template.split(/(\{offset\}|\{reverse\})/).map((part) => {
|
|
1062
|
+
if (part === '{offset}' || part === '{reverse}') {
|
|
1063
|
+
names.push(part.slice(1, -1));
|
|
1064
|
+
return '(\\d+)';
|
|
1065
|
+
}
|
|
1066
|
+
return escapeRe(part);
|
|
1067
|
+
}).join('') + '$';
|
|
1068
|
+
if (names.filter((name) => name === 'offset').length !== 1 || names.filter((name) => name === 'reverse').length !== 1)
|
|
1069
|
+
throw new Error('list: offset-template must name offset and reverse once');
|
|
1070
|
+
const value = params[cursor.param];
|
|
1071
|
+
const hit = typeof value === 'string' ? new RegExp(pattern).exec(value) : undefined;
|
|
1072
|
+
if (value !== undefined && !hit)
|
|
1073
|
+
return vendorError(m, { status: 400, message: 'Invalid cursor' });
|
|
1074
|
+
const decoded = Object.fromEntries(names.map((name, i) => [name, Number(hit?.[i + 1] ?? 0)]));
|
|
1075
|
+
if (!Number.isSafeInteger(decoded.offset) || ![0, 1].includes(decoded.reverse))
|
|
1076
|
+
return vendorError(m, { status: 400, message: 'Invalid cursor' });
|
|
1077
|
+
const start = decoded.reverse ? Math.max(0, decoded.offset - limit) : decoded.offset;
|
|
1078
|
+
const slice = rows.slice(start, start + limit);
|
|
1079
|
+
const link = (offset, reverse, rel, available) => {
|
|
1080
|
+
const marker = cursor.template.replace('{offset}', String(offset)).replace('{reverse}', String(reverse));
|
|
1081
|
+
const target = new URL(call.request.url);
|
|
1082
|
+
target.searchParams.set(cursor.param, marker);
|
|
1083
|
+
return `<${target.href}>; rel="${rel}"; ${cursor.link.results}="${available}"; ${cursor.link.cursor}="${marker}"`;
|
|
1084
|
+
};
|
|
1085
|
+
return Response.json(fill(list.envelope, { data: slice, key: op.answers?.key ?? 'data' }), { headers: {
|
|
1086
|
+
link: [link(start, 1, cursor.link.previous, start > 0), link(start + limit, 0, cursor.link.next, start + limit < rows.length)].join(', '),
|
|
1087
|
+
} });
|
|
1088
|
+
}
|
|
1089
|
+
const start = decodeOffset(params[list.cursor.param]);
|
|
952
1090
|
const slice = rows.slice(start, start + limit);
|
|
953
1091
|
const next = start + limit < rows.length ? encodeOffset(start + limit) : '';
|
|
954
|
-
return Response.json({ ...(m.success ?? {}), ...fill(
|
|
1092
|
+
return Response.json({ ...(m.success ?? {}), ...fill(list.envelope, { data: slice, next_cursor: next, key: op.answers?.key ?? 'data' }), ...(op.answers?.key ? { [op.answers.key]: slice } : {}) });
|
|
955
1093
|
}
|
|
956
|
-
return Response.json(fill(
|
|
1094
|
+
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 }));
|
|
957
1095
|
}
|
|
958
1096
|
/** Serve one operation from the manifest, or say why the core cannot. */
|
|
959
|
-
/** A setting's fields as the vendor answers them: its own, without the kernel's bookkeeping
|
|
960
|
-
*
|
|
961
|
-
|
|
962
|
-
|
|
1097
|
+
/** A setting's fields as the vendor answers them: its own, without the kernel's bookkeeping (a `_` field the resource
|
|
1098
|
+
* does not declare the vendor's own, as view() and render() keep it) or the parent it is kept under (a setting answers
|
|
1099
|
+
* its values, not an identity). */
|
|
1100
|
+
function settingFields(own, parentField, vendorUnderscored = []) {
|
|
1101
|
+
return Object.fromEntries(Object.entries(own).filter(([k]) => (!k.startsWith('_') || vendorUnderscored.includes(k)) && k !== 'deleted' && k !== parentField));
|
|
963
1102
|
}
|
|
964
1103
|
function settingView(m, resource, hit) {
|
|
965
1104
|
// ownFields: the kernel's id, type and updatedAt gone, a vendor's own of those names restored
|
|
966
|
-
return settingFields(ownFields(hit), m.resources[resource]?.parent?.field);
|
|
1105
|
+
return settingFields(ownFields(hit), m.resources[resource]?.parent?.field, m.resources[resource]?.vendorUnderscored);
|
|
967
1106
|
}
|
|
968
1107
|
export async function serveCore(m, call, scope = {}) {
|
|
969
1108
|
const root = scope.root;
|
|
@@ -994,9 +1133,9 @@ export async function serveCore(m, call, scope = {}) {
|
|
|
994
1133
|
// the vendor's success status from its spec: 201 for most creates, 204 with no body where it answers nothing
|
|
995
1134
|
const status = op.successStatus ?? 200;
|
|
996
1135
|
const answer = (body) => ({
|
|
997
|
-
served: status === 204 ? new Response(null, { status }) : Response.json(envelope(m, op,
|
|
1136
|
+
served: status === 204 ? new Response(null, { status }) : Response.json(envelope(m, op, expand(m, resource, body, paths, root)), { status }),
|
|
998
1137
|
});
|
|
999
|
-
const control = new Set([m.expandParam, m.list
|
|
1138
|
+
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));
|
|
1000
1139
|
const data = Object.fromEntries(Object.entries(params).filter(([k]) => !control.has(k) && k !== 'id'));
|
|
1001
1140
|
// a strict vendor refuses a body it does not declare before the core writes it (body.strict, as ctx.fields)
|
|
1002
1141
|
if (m.body.strict && (op.class === 'create' || op.class === 'update')) {
|
|
@@ -1019,7 +1158,8 @@ export async function serveCore(m, call, scope = {}) {
|
|
|
1019
1158
|
const under = { ...(parent ? { [parent.field]: parentId } : {}), ...(tenantField ? { [tenantField]: tenant } : {}) };
|
|
1020
1159
|
if (decl.number) {
|
|
1021
1160
|
const siblings = stored(m, resource, root, { withDeleted: true }).filter(underParent);
|
|
1022
|
-
|
|
1161
|
+
const next = siblings.reduce((n, r) => { const value = String(r[decl.number.field] ?? '0'); const held = /^-?\d+$/.test(value) ? BigInt(value) : 0n; return held > n ? held : n; }, 0n) + 1n;
|
|
1162
|
+
under[decl.number.field] = exactInteger(String(next));
|
|
1023
1163
|
}
|
|
1024
1164
|
const made = { ...assigned, ...initial, ...under, ...data };
|
|
1025
1165
|
const clash = conflictOf(m, resource, made, undefined, root);
|
|
@@ -1035,6 +1175,8 @@ export async function serveCore(m, call, scope = {}) {
|
|
|
1035
1175
|
return hit ? answer(view(m, resource, hit)) : { served: notFound(m, resource, String(idParam), Object.keys(call.params).at(-1)) };
|
|
1036
1176
|
}
|
|
1037
1177
|
case 'list': {
|
|
1178
|
+
if (!m.list)
|
|
1179
|
+
return { unmodeled: 'no manifest list configuration' };
|
|
1038
1180
|
const rows = stored(m, resource, root).filter(underParent).map((r) => view(m, resource, r));
|
|
1039
1181
|
const arranged = arrangeRows(m, resource, params, rows);
|
|
1040
1182
|
if (!Array.isArray(arranged))
|
|
@@ -1063,6 +1205,7 @@ export async function serveCore(m, call, scope = {}) {
|
|
|
1063
1205
|
const current = view(m, resource, hit);
|
|
1064
1206
|
const changes = { ...(op.class === 'update' ? data : {}) };
|
|
1065
1207
|
let moved = false;
|
|
1208
|
+
const stateChecks = new Map();
|
|
1066
1209
|
for (const [field, sdecl] of Object.entries(decl.state ?? {})) {
|
|
1067
1210
|
const requested = op.class === 'update' ? data[field] : undefined;
|
|
1068
1211
|
if (op.class === 'update' && requested === undefined)
|
|
@@ -1075,6 +1218,8 @@ export async function serveCore(m, call, scope = {}) {
|
|
|
1075
1218
|
return { unmodeled: `no declared transition of ${field} to ${String(requested)} by ${op.id}` };
|
|
1076
1219
|
continue;
|
|
1077
1220
|
}
|
|
1221
|
+
if (transitionObserver)
|
|
1222
|
+
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' }]);
|
|
1078
1223
|
const stored = storedState(sdecl, move.to);
|
|
1079
1224
|
if (stored !== undefined)
|
|
1080
1225
|
changes[field] = stored;
|
|
@@ -1090,7 +1235,7 @@ export async function serveCore(m, call, scope = {}) {
|
|
|
1090
1235
|
const clash = conflictOf(m, resource, { ...current, ...merged }, hit.id, root);
|
|
1091
1236
|
if (clash)
|
|
1092
1237
|
return { served: vendorError(m, clash) };
|
|
1093
|
-
return answer(await write(m, call, resource, hit.id, merged, `${storedType(m, resource)}.${op.class === 'update' ? 'update' : op.id}`, params, root, at));
|
|
1238
|
+
return answer(await write(m, call, resource, hit.id, merged, `${storedType(m, resource)}.${op.class === 'update' ? 'update' : op.id}`, params, root, at, stateChecks));
|
|
1094
1239
|
}
|
|
1095
1240
|
case 'delete': {
|
|
1096
1241
|
const hit = idParam ? stored(m, resource, root).find((r) => namedBy(decl, r, idParam) && underParent(r)) : undefined;
|
|
@@ -1132,20 +1277,26 @@ export async function contextFor(m, call, scope) {
|
|
|
1132
1277
|
scopeOfCall.set(call, scope);
|
|
1133
1278
|
const at = (scope.clock ?? worldNow)();
|
|
1134
1279
|
// the guard's parse when it made one (taken once), else this context's own
|
|
1135
|
-
|
|
1136
|
-
const
|
|
1280
|
+
// a body that is its payload's bytes (S3's PutObject) is never read as JSON, whatever its label
|
|
1281
|
+
const payload = bytesPayload(call.operation);
|
|
1282
|
+
const parsed = payload ? undefined : takeParsedBody(call.request);
|
|
1283
|
+
const text = await bodyTextOf(call.request.clone()).catch(() => '');
|
|
1137
1284
|
let body = parsed?.value;
|
|
1138
1285
|
if (!parsed) {
|
|
1139
|
-
|
|
1140
|
-
body = text
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1286
|
+
if (payload)
|
|
1287
|
+
body = text || undefined;
|
|
1288
|
+
else {
|
|
1289
|
+
try {
|
|
1290
|
+
body = text ? parseExactJson(text) : undefined;
|
|
1291
|
+
}
|
|
1292
|
+
catch {
|
|
1293
|
+
body = text;
|
|
1294
|
+
}
|
|
1144
1295
|
}
|
|
1145
1296
|
}
|
|
1146
1297
|
// the request as it came, before the core's parameter merge and alias adoption: what a write records, so a perform
|
|
1147
1298
|
// can send it again (the real-system adapters, "What the derived core records with a write")
|
|
1148
|
-
const boundary = multipartBoundary(call.request.headers.get('content-type'));
|
|
1299
|
+
const boundary = payload ? null : multipartBoundary(call.request.headers.get('content-type'));
|
|
1149
1300
|
const parts = boundary === null ? undefined : multipartParts(new Uint8Array(await call.request.clone().arrayBuffer()), boundary);
|
|
1150
1301
|
// 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
|
|
1151
1302
|
// compressed itself, PostHog's `compression=gzip-js`) as base64
|
|
@@ -1157,8 +1308,21 @@ export async function contextFor(m, call, scope) {
|
|
|
1157
1308
|
// every read of the body is of a copy, so the handler's own `call.request` and the core's answer (`core()`) each
|
|
1158
1309
|
// still find it unread, whichever of them reads it first
|
|
1159
1310
|
const pristine = call.request.clone();
|
|
1160
|
-
const params = await boundaryParams(m, { ...call, request: call.request.clone() }, root)
|
|
1311
|
+
const params = await boundaryParams(m, { ...call, request: call.request.clone() }, root).catch((error) => {
|
|
1312
|
+
// An around context opens before the credential/JSON guard. Keep non-body parameters available to it without
|
|
1313
|
+
// turning the guard's declared malformed-body refusal into a context-construction exception.
|
|
1314
|
+
if (!(error instanceof SyntaxError) || error.message !== 'malformed JSON body')
|
|
1315
|
+
throw error;
|
|
1316
|
+
return boundaryParams(m, { ...call, request: new Request(call.request.url, { headers: call.request.headers }) }, root);
|
|
1317
|
+
});
|
|
1161
1318
|
const paths = expandPaths(m, params);
|
|
1319
|
+
const stateChecks = new Map();
|
|
1320
|
+
const rawResources = new WeakMap();
|
|
1321
|
+
const rawRow = (resource, row) => {
|
|
1322
|
+
const name = m.resources[resource] ? resource : Object.keys(m.resources).find((name) => storedType(m, name) === row.type);
|
|
1323
|
+
rawResources.set(row, name ?? resource);
|
|
1324
|
+
return row;
|
|
1325
|
+
};
|
|
1162
1326
|
return {
|
|
1163
1327
|
call,
|
|
1164
1328
|
params,
|
|
@@ -1168,7 +1332,6 @@ export async function contextFor(m, call, scope) {
|
|
|
1168
1332
|
root,
|
|
1169
1333
|
publicBase: twinPublicBase(call.request),
|
|
1170
1334
|
scenario: scenarioDecisionOf(call.request),
|
|
1171
|
-
deliver: (url, init) => deliverToApplication(url, init),
|
|
1172
1335
|
ask: (url, init, within) => askApplication(url, init, within, scope.application),
|
|
1173
1336
|
worldEnv: (name) => worldEnvValue(name),
|
|
1174
1337
|
asVendor: (fn) => runAsVendorMove(fn),
|
|
@@ -1176,11 +1339,19 @@ export async function contextFor(m, call, scope) {
|
|
|
1176
1339
|
if (!/^[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.-]+)*$/.test(name) || name.split('/').some((seg) => seg === '.' || seg === '..'))
|
|
1177
1340
|
throw new Error(`semantics: ${name} is not a git repository name`);
|
|
1178
1341
|
const plane = `${worldPaths(m.service, root).dir}/git`;
|
|
1179
|
-
return { store: new GitObjectStore(getActiveBlobStore(), `${plane}/
|
|
1342
|
+
return { store: new GitObjectStore(getActiveBlobStore(), `${plane}/repos/${name}`), refs: new GitRefs(getActiveWorldStore(), `${plane}/repos/${name}`) };
|
|
1180
1343
|
},
|
|
1181
1344
|
mail: (route, mail, headers) => sendMail(route, mail, headers),
|
|
1182
1345
|
actor: m.identity ? m.identity(call.request.headers.get('authorization'), root) : undefined,
|
|
1183
1346
|
now: () => now(m, at),
|
|
1347
|
+
render: (row, resource) => {
|
|
1348
|
+
const raw = rawResources.has(row);
|
|
1349
|
+
const name = resource ?? rawResources.get(row);
|
|
1350
|
+
const declared = name && (m.resources[name] ? name : Object.keys(m.resources).find((r) => storedType(m, r) === name));
|
|
1351
|
+
const keep = declared ? m.resources[declared]?.vendorUnderscored ?? [] : [];
|
|
1352
|
+
const shown = raw ? render(row, keep) : renderOwn(row, keep);
|
|
1353
|
+
return omitView(m, declared || undefined, shown);
|
|
1354
|
+
},
|
|
1184
1355
|
get: (resource, id) => {
|
|
1185
1356
|
const hit = stored(m, resource, root).find((r) => r.id === id);
|
|
1186
1357
|
return hit ? view(m, resource, hit) : undefined;
|
|
@@ -1194,49 +1365,86 @@ export async function contextFor(m, call, scope) {
|
|
|
1194
1365
|
rows: (resource) => stored(m, resource, root).map((r) => view(m, resource, r)),
|
|
1195
1366
|
mint: (resource) => mintId(m, resource, root, at),
|
|
1196
1367
|
sockets: (id) => openSessions(m.service, root, id),
|
|
1368
|
+
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)),
|
|
1369
|
+
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),
|
|
1197
1370
|
issue: async (resource) => {
|
|
1198
|
-
const id = mintId(m, resource, root, at);
|
|
1199
1371
|
if (!resource.startsWith('_'))
|
|
1200
1372
|
throw new Error(`semantics: ${resource} is not a bookkeeping type (an issued id's type starts with _)`);
|
|
1201
|
-
if (scope.readOnly
|
|
1202
|
-
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1373
|
+
if (scope.readOnly === true || isReadOnlyRequest(call.request))
|
|
1374
|
+
return mintId(m, resource, root, at);
|
|
1375
|
+
const { value } = await applyTwinWriteAtomic(m.service, (snapshot) => {
|
|
1376
|
+
const id = mintId(m, resource, root, at, snapshot);
|
|
1377
|
+
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' } } };
|
|
1378
|
+
}, root);
|
|
1379
|
+
return value;
|
|
1205
1380
|
},
|
|
1206
1381
|
occurredAt: at,
|
|
1207
1382
|
secret: async (label) => hmac(await worldSeed(m.service, root, at), label, 'base64url'),
|
|
1208
|
-
record: async (type, fields, id) => {
|
|
1383
|
+
record: async (type, fields, id, operation) => {
|
|
1209
1384
|
if (!type.startsWith('_'))
|
|
1210
1385
|
throw new Error(`semantics: ${type} is not a bookkeeping type (bookkeeping types start with _)`);
|
|
1211
1386
|
if (id !== undefined) {
|
|
1212
|
-
|
|
1387
|
+
const before = operation ? stateBeforeWrite(m, type, id, root) : undefined;
|
|
1388
|
+
const { result } = await applyTwinWrite(m.service, { operation: operation ?? `${type.slice(1)}.record`, subjectType: type, subjectId: id, fields, occurredAt: at, actor: { kind: 'system' } }, root);
|
|
1389
|
+
if (operation)
|
|
1390
|
+
observeStateWrite(m, result, before, root, stateChecks);
|
|
1213
1391
|
return id;
|
|
1214
1392
|
}
|
|
1215
|
-
const { value } = await applyTwinWriteAtomic(m.service, (resources) => {
|
|
1393
|
+
const { value, result } = await applyTwinWriteAtomic(m.service, (resources) => {
|
|
1216
1394
|
// Allocate and append under the same state lock: concurrent sign-ins must never share an issue id.
|
|
1217
1395
|
const subject = (() => {
|
|
1218
1396
|
const prefix = `${type.slice(1)}_`;
|
|
1219
|
-
let max =
|
|
1397
|
+
let max = 0n;
|
|
1220
1398
|
for (const r of resources) {
|
|
1221
1399
|
if (r.type !== type)
|
|
1222
1400
|
continue;
|
|
1223
|
-
const
|
|
1224
|
-
if (
|
|
1225
|
-
max =
|
|
1401
|
+
const digits = r.id.startsWith(prefix) ? r.id.slice(prefix.length) : '';
|
|
1402
|
+
if (/^\d+$/.test(digits) && BigInt(digits) > max)
|
|
1403
|
+
max = BigInt(digits);
|
|
1226
1404
|
}
|
|
1227
|
-
return `${prefix}${max +
|
|
1405
|
+
return `${prefix}${max + 1n}`;
|
|
1228
1406
|
})();
|
|
1229
|
-
return { kind: 'write', value: subject, write: { operation: `${type.slice(1)}.record`, subjectType: type, subjectId: subject, fields, occurredAt: at, actor: { kind: 'system' } } };
|
|
1407
|
+
return { kind: 'write', value: subject, write: { operation: operation ?? `${type.slice(1)}.record`, subjectType: type, subjectId: subject, fields, occurredAt: at, actor: { kind: 'system' } } };
|
|
1230
1408
|
}, root, () => resourcesOfType(m.service, type, root));
|
|
1409
|
+
if (operation && result)
|
|
1410
|
+
observeStateWrite(m, result, undefined, root, stateChecks);
|
|
1231
1411
|
return value;
|
|
1232
1412
|
},
|
|
1233
1413
|
get machines() {
|
|
1234
1414
|
const enrolled = resourcesOfType(m.service, MACHINE_POOL, root).find((r) => r.id === 'pool');
|
|
1235
|
-
return poolFor(m.service, root, enrolled?.kind ?? 'none');
|
|
1415
|
+
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));
|
|
1236
1416
|
},
|
|
1237
1417
|
signingKey: (label, alg) => worldSigningKey(m.service, root, at, label, alg ?? 'RS256'),
|
|
1238
1418
|
heldSigningKey: (label, alg) => heldWorldSigningKey(m.service, root, label, alg ?? 'RS256'),
|
|
1239
|
-
|
|
1419
|
+
create: (resource, fields, operation) => atomicWrite(m, call, resource, (snapshot) => {
|
|
1420
|
+
const id = mintId(m, resource, root, at, snapshot);
|
|
1421
|
+
return { id, fields: typeof fields === 'function' ? fields(id) : fields };
|
|
1422
|
+
}, operation, params, root, at, undefined, stateChecks).then((row) => row),
|
|
1423
|
+
createUnique: async (resource, fields, operation) => {
|
|
1424
|
+
const rules = m.resources[resource]?.unique;
|
|
1425
|
+
if (!rules?.length)
|
|
1426
|
+
throw new Error('createUnique requires declared unique keys');
|
|
1427
|
+
let existing;
|
|
1428
|
+
const row = await atomicWrite(m, call, resource, (snapshot) => {
|
|
1429
|
+
const id = mintId(m, resource, root, at, snapshot);
|
|
1430
|
+
const selected = typeof fields === 'function' ? fields(id) : fields;
|
|
1431
|
+
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])));
|
|
1432
|
+
if (hit) {
|
|
1433
|
+
existing = view(m, resource, hit);
|
|
1434
|
+
return undefined;
|
|
1435
|
+
}
|
|
1436
|
+
return { id, fields: selected };
|
|
1437
|
+
}, operation, params, root, at, undefined, stateChecks);
|
|
1438
|
+
return existing ? { created: false, row: existing } : { created: true, row: row };
|
|
1439
|
+
},
|
|
1440
|
+
change: (resource, id, decide, operation) => atomicWrite(m, call, resource, (snapshot) => {
|
|
1441
|
+
const row = snapshot.find((r) => r.type === storedType(m, resource) && r.id === id && r.deleted !== true);
|
|
1442
|
+
if (!row)
|
|
1443
|
+
return undefined;
|
|
1444
|
+
const fields = decide(row);
|
|
1445
|
+
return fields === undefined ? undefined : { id, fields };
|
|
1446
|
+
}, operation, params, root, at, id, stateChecks),
|
|
1447
|
+
write: (resource, id, fields, operation) => write(m, call, resource, id, fields, operation, params, root, at, stateChecks),
|
|
1240
1448
|
accumulate: async (resource, id, deltas, operation) => {
|
|
1241
1449
|
if (!resource.startsWith('_') || !m.resources[resource])
|
|
1242
1450
|
throw new Error('accumulate requires a declared bookkeeping resource');
|
|
@@ -1254,24 +1462,29 @@ export async function contextFor(m, call, scope) {
|
|
|
1254
1462
|
return { kind: 'write', value: undefined, write: { operation, subjectType: storedType(m, resource), subjectId: id, fields, occurredAt: at, actor: { kind: 'agent' } } };
|
|
1255
1463
|
}, root);
|
|
1256
1464
|
},
|
|
1257
|
-
writeDetailed: (resource, id, fields, operation) => writeDetailed(m, call, resource, id, fields, operation, params, root, at),
|
|
1465
|
+
writeDetailed: (resource, id, fields, operation) => writeDetailed(m, call, resource, id, fields, operation, params, root, at, stateChecks),
|
|
1258
1466
|
resolve: (resource, id) => resolveSubjectId(m.service, storedType(m, resource), id, root),
|
|
1259
1467
|
storedType: (resource) => storedType(m, resource),
|
|
1260
1468
|
ok: (fields) => Response.json({ ...(m.success ?? {}), ...fields }),
|
|
1261
|
-
row: (resource, id, opts) => stored(m, resource, root, opts).find((r) => r.id === id),
|
|
1262
|
-
rowsRaw: (resource, opts) => stored(m, resource, root, opts),
|
|
1469
|
+
row: (resource, id, opts) => { const row = stored(m, resource, root, opts).find((r) => r.id === id); return row ? rawRow(resource, row) : undefined; },
|
|
1470
|
+
rowsRaw: (resource, opts) => stored(m, resource, root, opts).map((row) => rawRow(resource, row)),
|
|
1263
1471
|
tree: () => twinResources(m.service, root),
|
|
1264
1472
|
history: (resource, id) => subjectHistory(m.service, { type: storedType(m, resource), id }, root).map((e) => ({ ...(e.operation ? { operation: e.operation } : {}), ...(e.fields ? { fields: e.fields } : {}), ...(e.occurredAt ? { occurredAt: e.occurredAt } : {}) })),
|
|
1265
1473
|
raw: (body, init = {}) => new Response(body, { status: init.status ?? 200, headers: init.headers ?? {} }),
|
|
1266
1474
|
atomically: async (decide) => {
|
|
1267
|
-
|
|
1475
|
+
let before;
|
|
1476
|
+
const { value, result } = await applyTwinWriteAtomic(m.service, (resources) => {
|
|
1268
1477
|
const rows = (resource) => resources.filter((r) => r.type === storedType(m, resource) && r.deleted !== true);
|
|
1269
1478
|
const d = decide(rows);
|
|
1270
1479
|
if (!d.write)
|
|
1271
1480
|
return { kind: 'skip', value: d.value };
|
|
1272
1481
|
const w = d.write;
|
|
1482
|
+
if (transitionObserver)
|
|
1483
|
+
before = resources.find((r) => r.type === storedType(m, w.resource) && r.id === w.id);
|
|
1273
1484
|
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' } } };
|
|
1274
1485
|
}, root);
|
|
1486
|
+
if (result)
|
|
1487
|
+
observeStateWrite(m, result, before, root, stateChecks);
|
|
1275
1488
|
return value;
|
|
1276
1489
|
},
|
|
1277
1490
|
legal: (resource, field, operationId, current, to, id, actor) => {
|
|
@@ -1279,29 +1492,35 @@ export async function contextFor(m, call, scope) {
|
|
|
1279
1492
|
if (!decl)
|
|
1280
1493
|
throw new Error(`semantics: ${resource}.${field} is not a declared state field`);
|
|
1281
1494
|
const { move, refusal } = transitionFor(field, decl, operationId, current, to, id ?? Object.values(call.params).at(-1), actor ?? 'api');
|
|
1282
|
-
if (move)
|
|
1495
|
+
if (move) {
|
|
1496
|
+
if (transitionObserver) {
|
|
1497
|
+
const key = stateCheckKey(storedType(m, resource), field);
|
|
1498
|
+
stateChecks.set(key, [...(stateChecks.get(key) ?? []), { transition: move, operation: operationId, from: stateOf(decl, current), to: move.to ?? stateOf(decl, current), actor: actor ?? 'api' }]);
|
|
1499
|
+
}
|
|
1283
1500
|
return undefined;
|
|
1501
|
+
}
|
|
1284
1502
|
if (refusal)
|
|
1285
1503
|
return refusal;
|
|
1286
1504
|
throw new Error(`semantics: ${operationId} moves ${resource}.${field} from ${String(current)}${to ? ` to ${to}` : ''}, which the machine does not declare`);
|
|
1287
1505
|
},
|
|
1288
1506
|
refuse: (e) => vendorError(m, e),
|
|
1507
|
+
gap: () => vendorError(m, m.gap ?? m.notFound),
|
|
1289
1508
|
notFound: (resource, id, param) => notFound(m, resource, id, param),
|
|
1290
1509
|
validate: () => validateBody(m, call.operation, body),
|
|
1291
1510
|
reply: (body, status = 200) => Response.json(body, { status }),
|
|
1292
1511
|
wrap: (body, extra = {}) => Response.json({ ...envelope(m, call.operation, body), ...extra }),
|
|
1293
1512
|
sse: (events) => sse(m, events, call.operation.id),
|
|
1294
|
-
expand: (resource, body, given) =>
|
|
1513
|
+
expand: (resource, body, given) => expand(m, resource, body, given ?? paths, root),
|
|
1295
1514
|
at: (when) => contextFor(m, { ...call, request: new Request(call.request.url) }, { ...scope, clock: () => when }),
|
|
1296
1515
|
crypto: handlerCrypto,
|
|
1297
1516
|
flag: (value) => flagOf(m, value),
|
|
1298
1517
|
fields: (body) => strictFields(m, call.operation, body),
|
|
1299
|
-
vendorFetch: (url, init) => vendorFetch(url, init),
|
|
1518
|
+
vendorFetch: (url, init) => vendorFetch(url, init, scope.application),
|
|
1300
1519
|
parts: async () => {
|
|
1301
1520
|
const boundary = multipartBoundary(call.request.headers.get('content-type'));
|
|
1302
1521
|
return boundary === null ? [] : multipartParts(new Uint8Array(await call.request.clone().arrayBuffer()), boundary);
|
|
1303
1522
|
},
|
|
1304
|
-
engine: scope.database === undefined ? unboundDatabase(m.service) : managedDatabase(scope.database, { readOnly: scope.readOnly === true || isReadOnlyRequest(call.request)
|
|
1523
|
+
engine: scope.database === undefined ? unboundDatabase(m.service) : managedDatabase(scope.database, { readOnly: scope.readOnly === true || isReadOnlyRequest(call.request) }),
|
|
1305
1524
|
redis: (commands, options) => execRedisRun(commands, {
|
|
1306
1525
|
root, occurredAt: at, service: m.service, readOnly: scope.readOnly === true || isReadOnlyRequest(call.request), dialect: options.dialect,
|
|
1307
1526
|
...(options.database === undefined ? {} : { database: options.database }),
|
|
@@ -1333,14 +1552,16 @@ export async function contextFor(m, call, scope) {
|
|
|
1333
1552
|
}
|
|
1334
1553
|
/** A pack's semantics handlers as the dispatch's handlers. */
|
|
1335
1554
|
export function bindSemantics(m, handlers, scope = {}) {
|
|
1555
|
+
prepareViewOmissions(m);
|
|
1336
1556
|
return Object.fromEntries(Object.entries(handlers).map(([id, h]) => [id, async (call) => h(await contextFor(m, call, scope))]));
|
|
1337
1557
|
}
|
|
1338
1558
|
/** The derived core as the dispatch's core: it owns every operation on a resource the manifest declares. */
|
|
1339
1559
|
export function coreFor(m, scope = {}) {
|
|
1560
|
+
prepareViewOmissions(m);
|
|
1340
1561
|
const crud = new Set(['create', 'retrieve', 'list', 'update', 'delete']);
|
|
1341
1562
|
const owns = (o) => {
|
|
1342
1563
|
const decl = o.resource !== undefined ? m.resources[o.resource] : undefined;
|
|
1343
|
-
if (!decl || m.unmodeled?.includes(o.id))
|
|
1564
|
+
if (!decl || m.unmodeled?.includes(o.id) || (o.class === 'list' && !m.list))
|
|
1344
1565
|
return false;
|
|
1345
1566
|
if (crud.has(o.class))
|
|
1346
1567
|
return true;
|
|
@@ -1363,7 +1584,7 @@ function scenarioDecisionOf(request) {
|
|
|
1363
1584
|
if (!raw)
|
|
1364
1585
|
return undefined;
|
|
1365
1586
|
try {
|
|
1366
|
-
return
|
|
1587
|
+
return parseExactJson(raw);
|
|
1367
1588
|
}
|
|
1368
1589
|
catch {
|
|
1369
1590
|
return undefined;
|
|
@@ -1375,6 +1596,10 @@ export function authRefusal(m, request, root) {
|
|
|
1375
1596
|
const url = new URL(request.url);
|
|
1376
1597
|
if (m.auth.paths !== undefined && !new RegExp(m.auth.paths).test(url.pathname))
|
|
1377
1598
|
return undefined;
|
|
1599
|
+
const wire = m.auth.wires?.find((w) => new RegExp(w.paths).test(url.pathname));
|
|
1600
|
+
const errorManifest = wire ? { ...m, error: wire.error } : m;
|
|
1601
|
+
const invalid = wire?.invalid ?? m.auth.invalid;
|
|
1602
|
+
const missing = wire?.missing ?? m.auth.missing;
|
|
1378
1603
|
const sources = [{ header: m.auth.header, scheme: m.auth.scheme }, ...(m.auth.also ?? [])];
|
|
1379
1604
|
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);
|
|
1380
1605
|
if (!given && !m.auth.gateWhenAbsent)
|
|
@@ -1393,18 +1618,50 @@ export function authRefusal(m, request, root) {
|
|
|
1393
1618
|
const key = sources.map(keyOf).find((k) => k !== '') ?? '';
|
|
1394
1619
|
// no credential sent is `missing`; one sent in a form the vendor does not read is a key it does not know
|
|
1395
1620
|
if (!key)
|
|
1396
|
-
return vendorError(
|
|
1621
|
+
return vendorError(errorManifest, given ? invalid : missing);
|
|
1397
1622
|
if (m.auth.invalidKeys.includes(key) || (m.auth.keyFormat && !new RegExp(m.auth.keyFormat).test(key)))
|
|
1398
|
-
return vendorError(
|
|
1623
|
+
return vendorError(errorManifest, invalid);
|
|
1399
1624
|
const held = m.auth.held;
|
|
1400
1625
|
if (held && !held.standing?.includes(key)) {
|
|
1401
1626
|
const hash = sha256(key);
|
|
1402
1627
|
const holds = resourcesOfType(m.service, held.storedAs, root).some((r) => r.deleted !== true && r[held.hashField] === hash && (held.unless === undefined || r[held.unless] !== true));
|
|
1403
1628
|
if (!holds)
|
|
1404
|
-
return vendorError(
|
|
1629
|
+
return vendorError(errorManifest, invalid);
|
|
1405
1630
|
}
|
|
1406
1631
|
return undefined;
|
|
1407
1632
|
}
|
|
1633
|
+
/** The number of answers a World has given that carried a filled answer header (`answerHeaders`'s `{hex:N}`), kept in
|
|
1634
|
+
* the World's own directory beside its rate-budget ledger (bookkeeping, never a log entry): a fresh World counts from 1,
|
|
1635
|
+
* so two Worlds made alike answer the same values in the same order, and a resumed World counts on, never answering a
|
|
1636
|
+
* value the count holds again. A World with no root (an in-memory walk) counts in this process. */
|
|
1637
|
+
const answeredInMemory = new Map();
|
|
1638
|
+
function nextAnswer(service, root) {
|
|
1639
|
+
const key = `${service}:${root ?? ''}`;
|
|
1640
|
+
if (root === undefined) {
|
|
1641
|
+
const n = (answeredInMemory.get(key) ?? 0) + 1;
|
|
1642
|
+
answeredInMemory.set(key, n);
|
|
1643
|
+
return n;
|
|
1644
|
+
}
|
|
1645
|
+
const store = getActiveWorldStore();
|
|
1646
|
+
const path = `${worldPaths(service, root).dir}/answer-count`;
|
|
1647
|
+
// a read-only request writes nothing, bookkeeping and its lock included, and reads the count without the lock: it
|
|
1648
|
+
// counts on in this process past the kept count, so after a restart, or from another process serving the same World,
|
|
1649
|
+
// the answers given to read-only requests since the last write may be given again
|
|
1650
|
+
if (writesRefused()) {
|
|
1651
|
+
const kept = Number(store.read(path) ?? '0') || 0;
|
|
1652
|
+
const extra = (answeredInMemory.get(key) ?? 0) + 1;
|
|
1653
|
+
answeredInMemory.set(key, extra);
|
|
1654
|
+
return kept + extra;
|
|
1655
|
+
}
|
|
1656
|
+
// read and written under the count's lock, as the rate-budget ledger is: two writes from processes serving one World never
|
|
1657
|
+
// take one number
|
|
1658
|
+
return store.withLock(`${path}.lock`, () => {
|
|
1659
|
+
const n = (Number(store.read(path) ?? '0') || 0) + (answeredInMemory.get(key) ?? 0) + 1;
|
|
1660
|
+
answeredInMemory.delete(key);
|
|
1661
|
+
store.writeAtomic(path, String(n));
|
|
1662
|
+
return n;
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1408
1665
|
export function crossCutting(m, opts = {}) {
|
|
1409
1666
|
const finish = async (r, request) => {
|
|
1410
1667
|
if (!m.origin || !(r.headers.get('content-type') ?? '').includes('json'))
|
|
@@ -1416,9 +1673,18 @@ export function crossCutting(m, opts = {}) {
|
|
|
1416
1673
|
if (!m.answerHeaders)
|
|
1417
1674
|
return r;
|
|
1418
1675
|
const headers = new Headers(r.headers);
|
|
1676
|
+
const world = `${m.service}:${opts.root ?? ''}`;
|
|
1677
|
+
let seed;
|
|
1678
|
+
const filled = (v) => v.replace(/\{hex:(\d+)\}/g, (_, w) => {
|
|
1679
|
+
seed ??= `${world}:answer:${nextAnswer(m.service, opts.root)}`;
|
|
1680
|
+
let hex = '';
|
|
1681
|
+
for (let i = 0; hex.length < Number(w); i++)
|
|
1682
|
+
hex += sha256(`${seed}:${i}`);
|
|
1683
|
+
return hex.slice(0, Number(w));
|
|
1684
|
+
});
|
|
1419
1685
|
for (const [k, v] of Object.entries(m.answerHeaders))
|
|
1420
1686
|
if (!headers.has(k))
|
|
1421
|
-
headers.set(k, v);
|
|
1687
|
+
headers.set(k, filled(v));
|
|
1422
1688
|
return new Response(r.body, { status: r.status, statusText: r.statusText, headers });
|
|
1423
1689
|
};
|
|
1424
1690
|
return async (call, next) => finish(await guarded(call, next), call.request);
|
|
@@ -1438,7 +1704,7 @@ export function crossCutting(m, opts = {}) {
|
|
|
1438
1704
|
// multipart or form: Cloudflare's asset and script uploads)
|
|
1439
1705
|
const declared = call.operation.bodyEncoding;
|
|
1440
1706
|
const labelled = request.headers.get('content-type') ?? '';
|
|
1441
|
-
if ((labelled.includes('json') && !labelled.includes('ndjson')) || (m.body.json === 'always' && declared !== 'multipart' && declared !== 'form')) {
|
|
1707
|
+
if (!bytesPayload(call.operation) && ((labelled.includes('json') && !labelled.includes('ndjson') && !labelled.includes('connect+')) || (m.body.json === 'always' && declared !== 'multipart' && declared !== 'form'))) {
|
|
1442
1708
|
const ok = request.method === 'GET' || request.method === 'HEAD' ? true : await checkJsonBody(request.clone()).catch(() => true);
|
|
1443
1709
|
if (!ok)
|
|
1444
1710
|
return vendorError(m, m.malformedBody ?? { status: 400, message: 'The request body could not be parsed as JSON.' });
|
|
@@ -1479,8 +1745,8 @@ export function crossCutting(m, opts = {}) {
|
|
|
1479
1745
|
* coming (another wire's mutation, a door, a screen) — and answered with the vendor's own read-only
|
|
1480
1746
|
* error; `GET /twin` advertises `requestScopes`. The pack's catch-up runs under `runAsVendorMove`
|
|
1481
1747
|
* (request-scope.ts), so time's moves still land when a read-only request is what arrives. */
|
|
1482
|
-
export function derivedRequestScopes(m, fetch) {
|
|
1483
|
-
return withRequestScopes(fetch, { refuse: () => vendorError(m, m.readOnly) });
|
|
1748
|
+
export function derivedRequestScopes(m, fetch, readOnly = false) {
|
|
1749
|
+
return withRequestScopes(fetch, { readOnly, refuse: () => vendorError(m, m.readOnly) });
|
|
1484
1750
|
}
|
|
1485
1751
|
/** A semantics context for a request no surface operation names: another wire's (GraphQL) resolvers
|
|
1486
1752
|
* get the same interface as a handler, named by the operation id the wire gives. */
|