@volter/world-core 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +29 -0
- package/app-route.cjs +154 -0
- package/app-route.d.cts +7 -0
- package/attach.cjs +80 -0
- package/dist/app-route.cjs +154 -0
- package/dist/app-route.d.cts +7 -0
- package/dist/attach.cjs +80 -0
- package/dist/generated/pack-facts.json +4306 -0
- package/dist/inject.cjs +1097 -0
- package/dist/network-policy.cjs +92 -0
- package/dist/network-policy.d.cts +10 -0
- package/dist/src/actions.d.ts +276 -0
- package/dist/src/actions.js +436 -0
- package/dist/src/ancestry.d.ts +22 -0
- package/dist/src/ancestry.js +238 -0
- package/dist/src/args.d.ts +3 -0
- package/dist/src/args.js +12 -0
- package/dist/src/blob-store.d.ts +55 -0
- package/dist/src/blob-store.js +186 -0
- package/dist/src/brand-tokens.d.ts +2 -0
- package/dist/src/brand-tokens.js +17 -0
- package/dist/src/changeset.d.ts +431 -0
- package/dist/src/changeset.js +0 -0
- package/dist/src/client-bundle.d.ts +1 -0
- package/dist/src/client-bundle.js +28 -0
- package/dist/src/credential.d.ts +38 -0
- package/dist/src/credential.js +114 -0
- package/dist/src/derived-core.d.ts +452 -0
- package/dist/src/derived-core.js +782 -0
- package/dist/src/derived.d.ts +84 -0
- package/dist/src/derived.js +122 -0
- package/dist/src/emit.d.ts +106 -0
- package/dist/src/emit.js +157 -0
- package/dist/src/executor.d.ts +120 -0
- package/dist/src/executor.js +387 -0
- package/dist/src/file-response.d.ts +3 -0
- package/dist/src/file-response.js +22 -0
- package/dist/src/fork.d.ts +26 -0
- package/dist/src/fork.js +68 -0
- package/dist/src/git/history.d.ts +36 -0
- package/dist/src/git/history.js +298 -0
- package/dist/src/git/index.d.ts +6 -0
- package/dist/src/git/index.js +6 -0
- package/dist/src/git/inflate.d.ts +11 -0
- package/dist/src/git/inflate.js +194 -0
- package/dist/src/git/objects.d.ts +64 -0
- package/dist/src/git/objects.js +161 -0
- package/dist/src/git/pack.d.ts +14 -0
- package/dist/src/git/pack.js +199 -0
- package/dist/src/git/refs.d.ts +19 -0
- package/dist/src/git/refs.js +35 -0
- package/dist/src/git/smart-http.d.ts +45 -0
- package/dist/src/git/smart-http.js +223 -0
- package/dist/src/hash.d.ts +38 -0
- package/dist/src/hash.js +48 -0
- package/dist/src/head.d.ts +140 -0
- package/dist/src/head.js +313 -0
- package/dist/src/history.d.ts +76 -0
- package/dist/src/history.js +322 -0
- package/dist/src/index.d.ts +73 -0
- package/dist/src/index.js +98 -0
- package/dist/src/lifecycle.d.ts +1 -0
- package/dist/src/lifecycle.js +8 -0
- package/dist/src/log.d.ts +254 -0
- package/dist/src/log.js +801 -0
- package/dist/src/mirror-shell.d.ts +2 -0
- package/dist/src/mirror-shell.js +13 -0
- package/dist/src/observe.d.ts +49 -0
- package/dist/src/observe.js +148 -0
- package/dist/src/pack-assets.d.ts +30 -0
- package/dist/src/pack-assets.js +88 -0
- package/dist/src/packRegistry.d.ts +374 -0
- package/dist/src/packRegistry.js +142 -0
- package/dist/src/placeholder-remote.d.ts +22 -0
- package/dist/src/placeholder-remote.js +86 -0
- package/dist/src/proxy.d.ts +25 -0
- package/dist/src/proxy.js +155 -0
- package/dist/src/rateBudget.d.ts +367 -0
- package/dist/src/rateBudget.js +925 -0
- package/dist/src/references.d.ts +18 -0
- package/dist/src/references.js +27 -0
- package/dist/src/remote-execute.d.ts +22 -0
- package/dist/src/remote-execute.js +1 -0
- package/dist/src/resource-blob.d.ts +10 -0
- package/dist/src/resource-blob.js +56 -0
- package/dist/src/scenario.d.ts +197 -0
- package/dist/src/scenario.js +425 -0
- package/dist/src/schemas.d.ts +78 -0
- package/dist/src/schemas.js +50 -0
- package/dist/src/serve-http.d.ts +48 -0
- package/dist/src/serve-http.js +340 -0
- package/dist/src/serve.d.ts +147 -0
- package/dist/src/serve.js +507 -0
- package/dist/src/shared-blob-index.d.ts +4 -0
- package/dist/src/shared-blob-index.js +126 -0
- package/dist/src/state-system.d.ts +70 -0
- package/dist/src/state-system.js +90 -0
- package/dist/src/storage.d.ts +101 -0
- package/dist/src/storage.js +337 -0
- package/dist/src/twin-fetch.d.ts +64 -0
- package/dist/src/twin-fetch.js +91 -0
- package/dist/src/types.d.ts +40 -0
- package/dist/src/types.js +1 -0
- package/dist/src/v1-removed.d.ts +159 -0
- package/dist/src/v1-removed.js +124 -0
- package/dist/src/volter-home.d.ts +5 -0
- package/dist/src/volter-home.js +10 -0
- package/dist/src/world-clock.d.ts +4 -0
- package/dist/src/world-clock.js +32 -0
- package/dist/src/world-env.d.ts +3 -0
- package/dist/src/world-env.js +22 -0
- package/dist/src/world-store-sql.d.ts +27 -0
- package/dist/src/world-store-sql.js +86 -0
- package/dist/src/world-store.d.ts +168 -0
- package/dist/src/world-store.js +475 -0
- package/dist/src/worldConfig.d.ts +9 -0
- package/dist/src/worldConfig.js +17 -0
- package/dist/stream-bridge.cjs +80 -0
- package/dist/vendor-hosts.cjs +200 -0
- package/generated/pack-facts.json +4306 -0
- package/inject.cjs +1097 -0
- package/network-policy.cjs +92 -0
- package/network-policy.d.cts +10 -0
- package/package.json +103 -0
- package/src/actions.ts +564 -0
- package/src/ancestry.ts +213 -0
- package/src/args.ts +14 -0
- package/src/blob-store.ts +185 -0
- package/src/brand-tokens.ts +17 -0
- package/src/changeset.ts +1032 -0
- package/src/client-bundle.ts +29 -0
- package/src/credential.ts +140 -0
- package/src/derived-core.ts +1004 -0
- package/src/derived.ts +176 -0
- package/src/emit.ts +242 -0
- package/src/executor.ts +431 -0
- package/src/file-response.ts +22 -0
- package/src/fork.ts +89 -0
- package/src/git/history.ts +177 -0
- package/src/git/index.ts +6 -0
- package/src/git/inflate.ts +125 -0
- package/src/git/objects.ts +110 -0
- package/src/git/pack.ts +105 -0
- package/src/git/refs.ts +25 -0
- package/src/git/smart-http.ts +149 -0
- package/src/hash.ts +66 -0
- package/src/head.ts +318 -0
- package/src/history.ts +246 -0
- package/src/index.ts +323 -0
- package/src/lifecycle.ts +8 -0
- package/src/log.ts +793 -0
- package/src/mirror-shell.ts +15 -0
- package/src/observe.ts +130 -0
- package/src/pack-assets.ts +81 -0
- package/src/packRegistry.ts +408 -0
- package/src/placeholder-remote.ts +81 -0
- package/src/proxy.ts +183 -0
- package/src/rateBudget.ts +1115 -0
- package/src/references.ts +46 -0
- package/src/remote-execute.ts +26 -0
- package/src/resource-blob.ts +57 -0
- package/src/scenario.ts +479 -0
- package/src/schemas.ts +56 -0
- package/src/serve-http.ts +299 -0
- package/src/serve.ts +618 -0
- package/src/shared-blob-index.ts +108 -0
- package/src/state-system.ts +115 -0
- package/src/storage.ts +407 -0
- package/src/twin-fetch.ts +147 -0
- package/src/types.ts +50 -0
- package/src/v1-removed.ts +172 -0
- package/src/volter-home.ts +11 -0
- package/src/world-clock.ts +33 -0
- package/src/world-env.ts +18 -0
- package/src/world-store-sql.ts +118 -0
- package/src/world-store.ts +572 -0
- package/src/worldConfig.ts +27 -0
- package/stream-bridge.cjs +80 -0
- package/vendor-hosts.cjs +200 -0
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
// THE DERIVED CORE — what a derived pack does for an operation it declares no handler for
|
|
2
|
+
// (docs/contributing/architecture.md, "Protocol 3"). The generated surface says what the
|
|
3
|
+
// vendor's operations are; the pack's manifest says the vendor facts no spec carries (how ids look,
|
|
4
|
+
// how lists page, how errors read, which fields are state and how they move). From those two this
|
|
5
|
+
// serves CRUD generically and applies declared transitions, over the kernel's tree and write path.
|
|
6
|
+
// Nothing here knows a vendor: every vendor difference is a manifest value.
|
|
7
|
+
import { applyTwinWrite, applyTwinWriteAtomic, twinResources } from "./serve.js";
|
|
8
|
+
import { copyResource, ownFields, parentStamp, subjectHistory, treeChangesSince, treeStamp } from "./log.js";
|
|
9
|
+
import { getActiveWorldStore } from "./world-store.js";
|
|
10
|
+
import { worldNow } from "./world-clock.js";
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import { hashFieldValue } from "./hash.js";
|
|
13
|
+
import { resolveSubjectId, subjectAliases } from "./actions.js";
|
|
14
|
+
import { packReferences } from "./references.js";
|
|
15
|
+
import { twinPublicBase } from "./twin-fetch.js";
|
|
16
|
+
/** What a move to `to` stores in the field: a boolean field's value is a boolean, and a derived field's
|
|
17
|
+
* value is written by the move's effects (a state name is not a timestamp), so it stores nothing itself. */
|
|
18
|
+
export function storedState(decl, to) {
|
|
19
|
+
if (to === undefined || decl.derive?.length)
|
|
20
|
+
return undefined;
|
|
21
|
+
return typeof decl.initial === 'boolean' ? to === 'true' : to;
|
|
22
|
+
}
|
|
23
|
+
/** The state a stored value is in, by the field's machine. */
|
|
24
|
+
export function stateOf(decl, value) {
|
|
25
|
+
for (const d of decl.derive ?? []) {
|
|
26
|
+
const w = d.when;
|
|
27
|
+
if (w.absent !== undefined && (value === undefined || value === null) === w.absent)
|
|
28
|
+
return d.state;
|
|
29
|
+
if (w.equals !== undefined && value === w.equals)
|
|
30
|
+
return d.state;
|
|
31
|
+
if (w.gt !== undefined && typeof value === 'number' && value > w.gt)
|
|
32
|
+
return d.state;
|
|
33
|
+
if (w.truthy !== undefined && Boolean(value) === w.truthy)
|
|
34
|
+
return d.state;
|
|
35
|
+
}
|
|
36
|
+
return value === undefined || value === null ? String(decl.initial) : String(value);
|
|
37
|
+
}
|
|
38
|
+
// ── request parsing ──────────────────────────────────────────────────────────────────────────
|
|
39
|
+
/** A form body's bracket notation (`a[b][0][c]=v`, `expand[]=x`) as nested objects and arrays. A form carries only
|
|
40
|
+
* text: a field the operation's spec types as a number or a boolean (`scalars`, by bracket path, written by
|
|
41
|
+
* world-tooling's formScalarsOf) is read as one when its text is that literal; every other field stays the text sent
|
|
42
|
+
* (`metadata[order]=007`, `name=2024`). */
|
|
43
|
+
export function parseBracketForm(text, scalars) {
|
|
44
|
+
const out = {};
|
|
45
|
+
for (const [rawKey, raw] of new URLSearchParams(text)) {
|
|
46
|
+
const parts = rawKey.replace(/\]/g, '').split('[');
|
|
47
|
+
const kind = scalars && scalarAt(scalars, parts);
|
|
48
|
+
const value = kind === 'boolean' ? (raw === 'true' ? true : raw === 'false' ? false : raw)
|
|
49
|
+
: kind === 'integer' ? (/^-?\d+$/.test(raw) ? Number(raw) : raw)
|
|
50
|
+
: kind === 'number' ? (/^-?\d+(\.\d+)?$/.test(raw) ? Number(raw) : raw)
|
|
51
|
+
: raw;
|
|
52
|
+
let node = out;
|
|
53
|
+
parts.forEach((key, i) => {
|
|
54
|
+
if (i === parts.length - 1) {
|
|
55
|
+
if (key === '') {
|
|
56
|
+
if (Array.isArray(node))
|
|
57
|
+
node.push(value);
|
|
58
|
+
}
|
|
59
|
+
else
|
|
60
|
+
node[key] = value;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const next = parts[i + 1];
|
|
64
|
+
if (node[key] === undefined)
|
|
65
|
+
node[key] = next === '' || /^\d+$/.test(next) ? [] : {};
|
|
66
|
+
node = node[key];
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
/** The type `scalars` gives a form key's parts: a segment is a property name, `[]` an array's item (an index or empty),
|
|
72
|
+
* `*` a map's key. */
|
|
73
|
+
function scalarAt(scalars, parts) {
|
|
74
|
+
let paths = [''];
|
|
75
|
+
for (const [i, part] of parts.entries()) {
|
|
76
|
+
const segs = part === '' || /^\d+$/.test(part) ? ['[]', '*'] : [part, '*'];
|
|
77
|
+
paths = paths.flatMap((p) => segs.map((seg) => (i === 0 ? seg : `${p}.${seg}`)));
|
|
78
|
+
if (paths.length > 64)
|
|
79
|
+
paths = paths.filter((p) => Object.keys(scalars).some((k) => k === p || k.startsWith(`${p}.`)));
|
|
80
|
+
}
|
|
81
|
+
for (const p of paths)
|
|
82
|
+
if (scalars[p])
|
|
83
|
+
return scalars[p];
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
/** A request's parameters, its query's and its body's. With the manifest's `body.form.coerce`, a form's fields the
|
|
87
|
+
* operation's spec types as numbers or booleans are read as them (parseBracketForm); without an operation (a twin door)
|
|
88
|
+
* every form value is its text. */
|
|
89
|
+
export async function readParams(manifest, request, operation) {
|
|
90
|
+
const url = new URL(request.url);
|
|
91
|
+
const scalars = manifest.body.form?.coerce ? (operation?.scalars ?? {}) : undefined;
|
|
92
|
+
const query = parseBracketForm(url.search.replace(/^\?/, ''), scalars);
|
|
93
|
+
if (request.method === 'GET' || request.method === 'HEAD')
|
|
94
|
+
return query;
|
|
95
|
+
const type = request.headers.get('content-type') ?? '';
|
|
96
|
+
if (type.includes('multipart/form-data')) {
|
|
97
|
+
// an upload: text fields as given, each file as its name, media type, size and content (its text), with its raw
|
|
98
|
+
// `bytes` beside them for a pack that must tell an image or audio file from anything else; the bytes are not
|
|
99
|
+
// enumerated, so a write's record of the request keeps the text alone
|
|
100
|
+
const form = await request.formData();
|
|
101
|
+
const out = { ...query };
|
|
102
|
+
for (const [key, value] of form.entries()) {
|
|
103
|
+
if (typeof value === 'string') {
|
|
104
|
+
out[key] = value;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const file = value;
|
|
108
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
109
|
+
const parsed = { name: file.name, type: file.type, size: file.size, content: new TextDecoder().decode(bytes) };
|
|
110
|
+
out[key] = Object.defineProperty(parsed, 'bytes', { value: bytes, enumerable: false });
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
const text = await request.text();
|
|
115
|
+
if (!text)
|
|
116
|
+
return query;
|
|
117
|
+
const asJson = type.includes('json') || manifest.body.json === 'always';
|
|
118
|
+
const body = asJson ? JSON.parse(text) : parseBracketForm(text, scalars);
|
|
119
|
+
// a body that is not an object (GitHub's set-labels takes a bare array) is the body, not fields
|
|
120
|
+
return body && typeof body === 'object' && !Array.isArray(body) ? { ...query, ...body } : { ...query };
|
|
121
|
+
}
|
|
122
|
+
// ── answers ──────────────────────────────────────────────────────────────────────────────────
|
|
123
|
+
const ABSENT = Symbol('absent');
|
|
124
|
+
function fill(template, values, omitAbsent = false) {
|
|
125
|
+
if (typeof template === 'string') {
|
|
126
|
+
const whole = /^\{(\w+)\}$/.exec(template);
|
|
127
|
+
if (whole)
|
|
128
|
+
return values[whole[1]] ?? (omitAbsent ? ABSENT : null);
|
|
129
|
+
return template.replace(/\{(\w+)\}/g, (_, k) => String(values[k] ?? ''));
|
|
130
|
+
}
|
|
131
|
+
if (Array.isArray(template))
|
|
132
|
+
return template.map((t) => fill(t, values, omitAbsent)).filter((v) => v !== ABSENT);
|
|
133
|
+
if (template && typeof template === 'object') {
|
|
134
|
+
return Object.fromEntries(Object.entries(template).map(([k, v]) => [k, fill(v, values, omitAbsent)]).filter(([, v]) => v !== ABSENT));
|
|
135
|
+
}
|
|
136
|
+
return template;
|
|
137
|
+
}
|
|
138
|
+
export function vendorError(manifest, e) {
|
|
139
|
+
// the status as the answer's number, and as text for a vendor whose body spells it as a string (GitHub's "404")
|
|
140
|
+
const values = { message: e.message, code: e.code ?? null, param: e.param ?? null, kind: e.kind ?? manifest.defaultKind ?? null, status: e.status, statusText: String(e.status) };
|
|
141
|
+
// a vendor that answers its errors as a document, not JSON (S3's XML `<Error><Code>…`): the template is that text,
|
|
142
|
+
// each value escaped into it
|
|
143
|
+
if (typeof manifest.error === 'string') {
|
|
144
|
+
const escape = (v) => String(v ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
145
|
+
const body = manifest.error.replace(/\{(\w+)\}/g, (_, k) => escape(values[k]));
|
|
146
|
+
return new Response(body, { status: e.status, headers: { 'content-type': body.trimStart().startsWith('<') ? 'application/xml' : 'text/plain; charset=utf-8' } });
|
|
147
|
+
}
|
|
148
|
+
return Response.json(fill(manifest.error, values, manifest.errorOmitsAbsent ?? false), { status: e.status });
|
|
149
|
+
}
|
|
150
|
+
function notFound(manifest, object, id, param) {
|
|
151
|
+
const nf = manifest.notFound;
|
|
152
|
+
if (!nf.withParam)
|
|
153
|
+
param = undefined;
|
|
154
|
+
const own = manifest.resources[object]?.notFound;
|
|
155
|
+
const message = typeof own === 'string' ? own : (own?.message ?? nf.message);
|
|
156
|
+
const code = typeof own === 'object' ? own.code : nf.code;
|
|
157
|
+
return vendorError(manifest, { status: nf.status, message: String(fill(message, { object, id })), ...(code ? { code } : {}), ...(nf.kind ? { kind: nf.kind } : {}), ...(param ? { param } : {}) });
|
|
158
|
+
}
|
|
159
|
+
// ── the tree ─────────────────────────────────────────────────────────────────────────────────
|
|
160
|
+
const storedType = (m, resource) => m.resources[resource]?.storedAs ?? resource;
|
|
161
|
+
// A service's resources by type, folded once per World state (the tree's stamp; any write moves it), not on every read:
|
|
162
|
+
// a read of one resource type projected the service's whole tree, so a pack whose tree also holds many subjects of
|
|
163
|
+
// other types (PlanetScale's SQL rows beside its management records) paid for all of them on each read. A read gets its
|
|
164
|
+
// own copy of each resource, as twinResources hands out, with the vendor's own id/type/updatedAt a spread would drop.
|
|
165
|
+
const typeIndexes = new WeakMap();
|
|
166
|
+
/** A service's resources of one type, each the reader's own copy: `twinResources` filtered by type, from an index
|
|
167
|
+
* that follows each write rather than projecting the whole tree per call. */
|
|
168
|
+
export function resourcesOfType(service, type, root) {
|
|
169
|
+
const store = getActiveWorldStore();
|
|
170
|
+
const indexes = typeIndexes.get(store) ?? typeIndexes.set(store, new Map()).get(store);
|
|
171
|
+
const key = `${service}\u0000${root ?? ''}`;
|
|
172
|
+
let held = indexes.get(key);
|
|
173
|
+
// a write moves the stamp; the index follows it by the subjects the write touched, and refolds only when
|
|
174
|
+
// the kernel cannot say which (a projection of the whole tree after every write grew with the World)
|
|
175
|
+
const delta = held ? treeChangesSince(service, root, held.stamp) : undefined;
|
|
176
|
+
if (held && delta) {
|
|
177
|
+
// the index keeps the tree's order: a subject updated where it stands stays there, one new to the tree (or made
|
|
178
|
+
// again after a delete) goes to the end in the order the tree holds it
|
|
179
|
+
for (const k of [...delta.removed, ...delta.appended])
|
|
180
|
+
held.byType.get(k.slice(0, k.indexOf(':')))?.delete(k);
|
|
181
|
+
const byKey = new Map(delta.changed.map((r) => [`${r.type}:${r.id}`, r]));
|
|
182
|
+
for (const [k, r] of byKey)
|
|
183
|
+
if (!delta.appended.includes(k))
|
|
184
|
+
(held.byType.get(r.type) ?? held.byType.set(r.type, new Map()).get(r.type)).set(k, r);
|
|
185
|
+
for (const k of delta.appended) {
|
|
186
|
+
const r = byKey.get(k);
|
|
187
|
+
(held.byType.get(r.type) ?? held.byType.set(r.type, new Map()).get(r.type)).set(k, r);
|
|
188
|
+
}
|
|
189
|
+
held.stamp = delta.stamp;
|
|
190
|
+
}
|
|
191
|
+
else if (!held || held.stamp !== treeStamp(service, root)) {
|
|
192
|
+
// the stamp is read before the fold, so a write between them can only leave a newer fold under an older stamp
|
|
193
|
+
const stamp = treeStamp(service, root);
|
|
194
|
+
const byType = new Map();
|
|
195
|
+
for (const r of twinResources(service, root))
|
|
196
|
+
(byType.get(r.type) ?? byType.set(r.type, new Map()).get(r.type)).set(`${r.type}:${r.id}`, r);
|
|
197
|
+
held = { stamp, byType };
|
|
198
|
+
indexes.set(key, held);
|
|
199
|
+
}
|
|
200
|
+
return [...(held.byType.get(type)?.values() ?? [])].map(copyResource);
|
|
201
|
+
}
|
|
202
|
+
function stored(m, resource, root, opts = {}) {
|
|
203
|
+
const type = storedType(m, resource);
|
|
204
|
+
const when = Object.entries(m.resources[resource]?.readableWhen ?? {});
|
|
205
|
+
return resourcesOfType(m.service, type, root).filter((r) => (opts.withDeleted || r.deleted !== true) && when.every(([k, v]) => r[k] === v));
|
|
206
|
+
}
|
|
207
|
+
/** The subject an item operation names: the manifest's key over the path parameters, or the last one. */
|
|
208
|
+
function subjectOf(m, resource, call) {
|
|
209
|
+
const key = m.resources[resource]?.key;
|
|
210
|
+
return key ? String(fill(key, call.params)) : Object.values(call.params).at(-1);
|
|
211
|
+
}
|
|
212
|
+
/** A missing parent's not-found, when the resource lives under one. */
|
|
213
|
+
/** The parent value a call names: one path parameter, or several joined by the parent's template. */
|
|
214
|
+
function parentValue(parent, params) {
|
|
215
|
+
if (parent.params)
|
|
216
|
+
return String(fill(parent.value ?? parent.params.map((p) => `{${p}}`).join('/'), params));
|
|
217
|
+
return parent.param !== undefined ? params[parent.param] : undefined;
|
|
218
|
+
}
|
|
219
|
+
function missingParent(m, resource, call, root) {
|
|
220
|
+
const parent = m.resources[resource]?.parent;
|
|
221
|
+
if (!parent)
|
|
222
|
+
return undefined;
|
|
223
|
+
const id = parentValue(parent, call.params);
|
|
224
|
+
const where = Object.entries(parent.where ?? {}).map(([field, template]) => [field, String(fill(template, call.params))]);
|
|
225
|
+
const found = (r) => (where.length ? where.every(([field, v]) => String(r[field]) === v) : r.id === id);
|
|
226
|
+
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));
|
|
227
|
+
}
|
|
228
|
+
/** The vendor's view of a stored subject: its own fields, bookkeeping (`_`) left out. */
|
|
229
|
+
/** A stored subject as the vendor's object: its id first (the subject's, unless the pack kept an id of its own), then
|
|
230
|
+
* the fields the pack wrote, bookkeeping (`_`) and the kernel's updatedAt left out. */
|
|
231
|
+
export function render(r) {
|
|
232
|
+
return Object.fromEntries(Object.entries({ id: r.id, ...ownFields(r) }).filter(([k]) => !k.startsWith('_') && k !== 'updatedAt'));
|
|
233
|
+
}
|
|
234
|
+
function view(m, resource, r) {
|
|
235
|
+
const own = m.resources[resource]?.view;
|
|
236
|
+
if (own)
|
|
237
|
+
return own(render(r), { id: r.id, type: r.type });
|
|
238
|
+
if (!m.view)
|
|
239
|
+
return render(r);
|
|
240
|
+
// a pack's view sees the stored row whole, and bookkeeping (`_`) never reaches the wire whatever it returns
|
|
241
|
+
return Object.fromEntries(Object.entries(m.view(storedType(m, resource), r)).filter(([k]) => !k.startsWith('_')));
|
|
242
|
+
}
|
|
243
|
+
function now(m, at) {
|
|
244
|
+
return m.time === 'unix' ? Math.floor(Date.parse(at) / 1000) : at;
|
|
245
|
+
}
|
|
246
|
+
function applyRule(m, rule, id, values, at) {
|
|
247
|
+
if ('now' in rule)
|
|
248
|
+
return now(m, at);
|
|
249
|
+
if ('id' in rule)
|
|
250
|
+
return id;
|
|
251
|
+
if ('value' in rule)
|
|
252
|
+
return rule.value;
|
|
253
|
+
return fill(rule.template, { ...values, id });
|
|
254
|
+
}
|
|
255
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
256
|
+
/** The next id the manifest's template gives this resource: one past the highest it already minted. */
|
|
257
|
+
function mintId(m, resource, root) {
|
|
258
|
+
const prefix = m.resources[resource].idPrefix;
|
|
259
|
+
if (m.ids.template === '{uuid}')
|
|
260
|
+
return mintUuid(m, resource, root);
|
|
261
|
+
const [head, tail = ''] = m.ids.template.split('{n}');
|
|
262
|
+
const pattern = new RegExp(`^${escapeRe(String(fill(head, { prefix })))}(\\d+)${escapeRe(String(fill(tail, { prefix })))}$`);
|
|
263
|
+
let max = 0;
|
|
264
|
+
// the one type, from the index that follows writes (a scan of the whole World per create grew with it)
|
|
265
|
+
for (const r of resourcesOfType(m.service, storedType(m, resource), root)) {
|
|
266
|
+
const hit = pattern.exec(r.id);
|
|
267
|
+
if (hit)
|
|
268
|
+
max = Math.max(max, Number(hit[1]));
|
|
269
|
+
}
|
|
270
|
+
return String(fill(m.ids.template, { prefix, n: max + 1 }));
|
|
271
|
+
}
|
|
272
|
+
/** The next UUID for a resource: a version-4-shaped UUID derived from the resource and the count before it, past any
|
|
273
|
+
* the World already holds. */
|
|
274
|
+
function mintUuid(m, resource, root) {
|
|
275
|
+
const type = storedType(m, resource);
|
|
276
|
+
const held = new Set(resourcesOfType(m.service, type, root).map((r) => r.id));
|
|
277
|
+
for (let n = held.size + 1;; n++) {
|
|
278
|
+
const h = createHash('sha256').update(`${m.service}:${resource}:${n}`).digest('hex');
|
|
279
|
+
const id = `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-${'89ab'[parseInt(h[16], 16) % 4]}${h.slice(17, 20)}-${h.slice(20, 32)}`;
|
|
280
|
+
if (!held.has(id))
|
|
281
|
+
return id;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
function expand(m, resource, body, paths, root) {
|
|
285
|
+
const embeds = m.resources[resource]?.embeds ?? {};
|
|
286
|
+
const out = { ...body };
|
|
287
|
+
for (const path of paths) {
|
|
288
|
+
const [head, ...rest] = path.split('.');
|
|
289
|
+
const target = embeds[head];
|
|
290
|
+
const id = out[head];
|
|
291
|
+
if (!target || typeof id !== 'string')
|
|
292
|
+
continue;
|
|
293
|
+
const hit = stored(m, target, root).find((r) => r.id === id);
|
|
294
|
+
if (hit)
|
|
295
|
+
out[head] = rest.length ? expand(m, target, view(m, target, hit), [rest.join('.')], root) : view(m, target, hit);
|
|
296
|
+
}
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
299
|
+
function expandPaths(m, params) {
|
|
300
|
+
const raw = m.expandParam ? params[m.expandParam] : undefined;
|
|
301
|
+
return Array.isArray(raw) ? raw.map(String) : typeof raw === 'string' ? [raw] : [];
|
|
302
|
+
}
|
|
303
|
+
async function write(m, call, resource, id, fields, operation, params, root, occurredAt) {
|
|
304
|
+
return (await writeDetailed(m, call, resource, id, fields, operation, params, root, occurredAt)).body;
|
|
305
|
+
}
|
|
306
|
+
async function writeDetailed(m, call, resource, id, fields, operation, params, root, occurredAt) {
|
|
307
|
+
const { resource: row, result } = await applyTwinWrite(m.service,
|
|
308
|
+
// who made it: the login the pack's identity reads off the request, when it declares one, so a subject's
|
|
309
|
+
// history can say who did each thing
|
|
310
|
+
{ operation, subjectType: storedType(m, resource), subjectId: id, fields, input: { operationId: call.operation.id, params: call.params, body: params }, occurredAt, actor: { kind: 'agent', ...(m.identity ? { id: m.identity(call.request.headers.get('authorization'), root) } : {}) } }, root);
|
|
311
|
+
const body = view(m, resource, row);
|
|
312
|
+
if (m.onWrite)
|
|
313
|
+
await m.onWrite({ operation, storedType: storedType(m, resource), body, ...(root !== undefined ? { root } : {}), occurredAt, request: call.request });
|
|
314
|
+
// the subject's id as it stands after the write: the vendor's, when a live head minted one
|
|
315
|
+
return { body, id: result.externalId ?? row.id, ...(result.vendorData !== undefined ? { vendorData: result.vendorData } : {}) };
|
|
316
|
+
}
|
|
317
|
+
/** An answer in the vendor's success envelope, under the operation's key, when the vendor has one. */
|
|
318
|
+
function envelope(m, op, body) {
|
|
319
|
+
return m.success && op.answers?.key ? { ...m.success, [op.answers.key]: body } : body;
|
|
320
|
+
}
|
|
321
|
+
const encodeOffset = (o) => btoa(JSON.stringify({ o }));
|
|
322
|
+
function decodeOffset(cursor) {
|
|
323
|
+
if (typeof cursor !== 'string' || !cursor)
|
|
324
|
+
return 0;
|
|
325
|
+
try {
|
|
326
|
+
const o = JSON.parse(atob(cursor)).o;
|
|
327
|
+
return typeof o === 'number' && o >= 0 ? o : 0;
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
return 0;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
let transitionObserver;
|
|
334
|
+
export function observeTransitions(observer) {
|
|
335
|
+
transitionObserver = observer;
|
|
336
|
+
}
|
|
337
|
+
/** The transition a request asks for on one state field, or the vendor's refusal when none applies. */
|
|
338
|
+
/** What a declared machine says to one move: the transition that allows it, or the refusal it gives (and
|
|
339
|
+
* nothing when it declares neither). The derived core and `legal` ask it; so does an engine that is not
|
|
340
|
+
* HTTP-shaped (a line protocol's session), so one machine rules every wire. */
|
|
341
|
+
export function transitionFor(field, decl, operationId, current, requested, id, actor = 'api') {
|
|
342
|
+
const now = stateOf(decl, current);
|
|
343
|
+
const candidates = decl.transitions.filter((t) => (t.actor ?? 'api') === actor && (t.operation ? t.operation === operationId : true) && (requested === undefined || (t.to ?? now) === String(requested)));
|
|
344
|
+
const move = candidates.find((t) => t.from === '*' || t.from.includes(now));
|
|
345
|
+
if (move) {
|
|
346
|
+
transitionObserver?.(decl, move, 'moved', now);
|
|
347
|
+
return { move };
|
|
348
|
+
}
|
|
349
|
+
const refused = candidates.find((t) => t.refusals?.[now]) ?? candidates.find((t) => t.refusal);
|
|
350
|
+
const r = refused?.refusals?.[now] ?? refused?.refusal;
|
|
351
|
+
if (refused && r)
|
|
352
|
+
transitionObserver?.(decl, refused, 'refused', now);
|
|
353
|
+
if (r)
|
|
354
|
+
return { refusal: { status: r.status, message: String(fill(r.message, { from: current, to: refused.to ?? now, field, id: id ?? '' })), ...(r.code ? { code: r.code } : {}) } };
|
|
355
|
+
return {};
|
|
356
|
+
}
|
|
357
|
+
// ALIAS-AWARE LOOKUP AT THE REQUEST BOUNDARY (architecture.md): after an adoption a caller may still name a subject by
|
|
358
|
+
// its local id; a declared reference in the call's params is resolved through the alias map once, here, where the
|
|
359
|
+
// params are parsed, so a handler's lookup (`ctx.row`) and the write both see the adopted id. The map is read only
|
|
360
|
+
// for a pack that declares references, and once per state of the parent side. An idempotency key's request hash is
|
|
361
|
+
// taken over the bytes as sent, so the same key sent once with the local id and once with the adopted one is two
|
|
362
|
+
// requests.
|
|
363
|
+
const aliasMemos = new WeakMap();
|
|
364
|
+
function aliasesNow(service, root) {
|
|
365
|
+
const store = getActiveWorldStore();
|
|
366
|
+
const memos = aliasMemos.get(store) ?? aliasMemos.set(store, new Map()).get(store);
|
|
367
|
+
const key = `${service}\u0000${root ?? ''}`;
|
|
368
|
+
// aliases come from the parent side alone: a local write (the branch log) leaves them as they were
|
|
369
|
+
const stamp = parentStamp(service, root);
|
|
370
|
+
let held = memos.get(key);
|
|
371
|
+
if (!held || held.stamp !== stamp) {
|
|
372
|
+
held = { stamp, aliases: subjectAliases(service, root) };
|
|
373
|
+
memos.set(key, held);
|
|
374
|
+
}
|
|
375
|
+
return held.aliases;
|
|
376
|
+
}
|
|
377
|
+
/** The call with a path parameter naming the operation's subject, or its parent, by a local id moved to the adopted
|
|
378
|
+
* id (`GET /segments/<local>` after the segment was adopted). Only the operation's own type and its parent's are
|
|
379
|
+
* tried, so no other parameter is touched. A vendor naming its subject in the body (Slack's `ts`) declares it as a
|
|
380
|
+
* reference, which adoptedInCall resolves. */
|
|
381
|
+
function adoptedPath(m, call, root) {
|
|
382
|
+
const resource = call.operation.resource;
|
|
383
|
+
const decl = resource ? m.resources[resource] : undefined;
|
|
384
|
+
if (!decl || Object.keys(call.params).length === 0)
|
|
385
|
+
return call;
|
|
386
|
+
const aliases = aliasesNow(m.service, root);
|
|
387
|
+
if (aliases.size === 0)
|
|
388
|
+
return call;
|
|
389
|
+
const types = [storedType(m, resource), ...(decl.parent ? [storedType(m, decl.parent.resource)] : [])];
|
|
390
|
+
let params = call.params;
|
|
391
|
+
for (const [name, value] of Object.entries(call.params)) {
|
|
392
|
+
const adopted = types.map((type) => aliases.get(`${type}:${value}`)).find((id) => id !== undefined);
|
|
393
|
+
if (adopted !== undefined && adopted !== value)
|
|
394
|
+
params = { ...params, [name]: adopted };
|
|
395
|
+
}
|
|
396
|
+
return params === call.params ? call : { ...call, params };
|
|
397
|
+
}
|
|
398
|
+
/** A call's fields with each declared reference that names an adopted subject by its local id moved to the adopted
|
|
399
|
+
* id. A call (a handler's operation) need not say which subject type it writes, so every reference the pack declares
|
|
400
|
+
* is tried: a field is rewritten only when its value is a local id the alias map holds for the reference's target. */
|
|
401
|
+
function adoptedInCall(service, fields, root) {
|
|
402
|
+
const refs = packReferences(service);
|
|
403
|
+
if (refs.length === 0)
|
|
404
|
+
return fields;
|
|
405
|
+
const aliases = aliasesNow(service, root);
|
|
406
|
+
if (aliases.size === 0)
|
|
407
|
+
return fields;
|
|
408
|
+
let out = fields;
|
|
409
|
+
for (const ref of refs) {
|
|
410
|
+
const local = ref.key(out);
|
|
411
|
+
const adopted = local === undefined ? undefined : aliases.get(`${ref.to}:${local}`);
|
|
412
|
+
if (adopted !== undefined && adopted !== local)
|
|
413
|
+
out = { ...out, ...ref.adopt(out, adopted) };
|
|
414
|
+
}
|
|
415
|
+
return out;
|
|
416
|
+
}
|
|
417
|
+
async function boundaryParams(m, call, root) {
|
|
418
|
+
return adoptedInCall(m.service, await readParams(m, call.request, call.operation), root);
|
|
419
|
+
}
|
|
420
|
+
/** Serve one operation from the manifest, or say why the core cannot. */
|
|
421
|
+
export async function serveCore(m, call, scope = {}) {
|
|
422
|
+
const root = scope.root;
|
|
423
|
+
const at = (scope.clock ?? worldNow)();
|
|
424
|
+
const op = call.operation;
|
|
425
|
+
const resource = op.resource;
|
|
426
|
+
const decl = resource ? m.resources[resource] : undefined;
|
|
427
|
+
if (!resource || !decl)
|
|
428
|
+
return { unmodeled: `no manifest entry for resource ${resource ?? '(none)'}` };
|
|
429
|
+
call = adoptedPath(m, call, root);
|
|
430
|
+
const params = await boundaryParams(m, call, root);
|
|
431
|
+
const idParam = subjectOf(m, resource, call);
|
|
432
|
+
const orphan = missingParent(m, resource, call, root);
|
|
433
|
+
if (orphan)
|
|
434
|
+
return { served: orphan };
|
|
435
|
+
const parent = decl.parent;
|
|
436
|
+
const parentId = parent ? parentValue(parent, call.params) : undefined;
|
|
437
|
+
const underParent = (r) => !parent || r[parent.field] === parentId;
|
|
438
|
+
const paths = expandPaths(m, params);
|
|
439
|
+
// the vendor's success status from its spec: 201 for most creates, 204 with no body where it answers nothing
|
|
440
|
+
const status = op.successStatus ?? 200;
|
|
441
|
+
const answer = (body) => ({
|
|
442
|
+
served: status === 204 ? new Response(null, { status }) : Response.json(envelope(m, op, paths.length ? expand(m, resource, body, paths, root) : body), { status }),
|
|
443
|
+
});
|
|
444
|
+
const control = new Set([m.expandParam, m.list.limit.param, m.list.after, m.list.before, m.list.offset?.param].filter(Boolean));
|
|
445
|
+
const data = Object.fromEntries(Object.entries(params).filter(([k]) => !control.has(k) && k !== 'id'));
|
|
446
|
+
switch (op.class) {
|
|
447
|
+
case 'create': {
|
|
448
|
+
for (const field of Object.keys(decl.state ?? {}))
|
|
449
|
+
if (field in data)
|
|
450
|
+
return { unmodeled: `create sets state field ${field}` };
|
|
451
|
+
if (decl.key)
|
|
452
|
+
return { unmodeled: 'a create under a composite key is a handler' };
|
|
453
|
+
const provided = m.ids.acceptProvided && typeof params.id === 'string' && params.id ? params.id : undefined;
|
|
454
|
+
const id = provided ?? mintId(m, resource, root);
|
|
455
|
+
const assigned = Object.fromEntries(Object.entries(decl.assigned ?? {}).map(([k, rule]) => [k, applyRule(m, rule, id, data, at)]));
|
|
456
|
+
const initial = Object.fromEntries(Object.entries(decl.state ?? {}).map(([k, s]) => [k, s.initial]));
|
|
457
|
+
const under = parent ? { [parent.field]: parentId } : {};
|
|
458
|
+
if (decl.number) {
|
|
459
|
+
const siblings = stored(m, resource, root, { withDeleted: true }).filter((r) => !parent || r[parent.field] === parentId);
|
|
460
|
+
under[decl.number.field] = 1 + siblings.reduce((n, r) => Math.max(n, Number(r[decl.number.field]) || 0), 0);
|
|
461
|
+
}
|
|
462
|
+
return answer(await write(m, call, resource, id, { ...assigned, ...initial, ...under, ...data }, `${storedType(m, resource)}.create`, params, root, at));
|
|
463
|
+
}
|
|
464
|
+
case 'retrieve': {
|
|
465
|
+
const hit = idParam ? stored(m, resource, root).find((r) => r.id === idParam && underParent(r)) : undefined;
|
|
466
|
+
return hit ? answer(view(m, resource, hit)) : { served: notFound(m, resource, String(idParam), Object.keys(call.params).at(-1)) };
|
|
467
|
+
}
|
|
468
|
+
case 'list': {
|
|
469
|
+
let rows = stored(m, resource, root).filter(underParent).map((r) => view(m, resource, r));
|
|
470
|
+
for (const f of decl.filters ?? [])
|
|
471
|
+
if (params[f] !== undefined)
|
|
472
|
+
rows = rows.filter((r) => String(r[f]) === String(params[f]));
|
|
473
|
+
const order = decl.order ?? { field: 'created', direction: 'desc' };
|
|
474
|
+
rows.sort((a, b) => {
|
|
475
|
+
// ties by mint order (`file-twin-10` after `file-twin-9`), not by the ids' lexical order
|
|
476
|
+
const d = Number(a[order.field] ?? 0) - Number(b[order.field] ?? 0) || String(a.id).localeCompare(String(b.id), undefined, { numeric: true });
|
|
477
|
+
return order.direction === 'desc' ? -d : d;
|
|
478
|
+
});
|
|
479
|
+
const size = m.list.limits?.[op.id] ?? m.list.limit;
|
|
480
|
+
const asked = params[m.list.limit.param];
|
|
481
|
+
const n = asked === undefined || asked === '' ? size.default : Number(asked);
|
|
482
|
+
const limit = Number.isFinite(n) ? Math.min(size.max, Math.max(1, Math.trunc(n))) : size.default;
|
|
483
|
+
const after = m.list.after ? params[m.list.after] : undefined;
|
|
484
|
+
const before = m.list.before ? params[m.list.before] : undefined;
|
|
485
|
+
let from = 0;
|
|
486
|
+
let to;
|
|
487
|
+
if (typeof before === 'string') {
|
|
488
|
+
const at = rows.findIndex((r) => r.id === before);
|
|
489
|
+
to = at === -1 ? (m.list.unknownCursor === 'end' ? 0 : rows.length) : at;
|
|
490
|
+
from = Math.max(0, to - limit);
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
if (typeof after === 'string') {
|
|
494
|
+
const at = rows.findIndex((r) => r.id === after);
|
|
495
|
+
from = at === -1 ? (m.list.unknownCursor === 'end' ? rows.length : 0) : at + 1;
|
|
496
|
+
}
|
|
497
|
+
to = from + limit;
|
|
498
|
+
}
|
|
499
|
+
const page = rows.slice(from, to).map((r) => (paths.length ? expand(m, resource, r, paths.filter((p) => p.startsWith('data.')).map((p) => p.slice(5)), root) : r));
|
|
500
|
+
const hasMore = typeof before === 'string' ? from > 0 : to < rows.length;
|
|
501
|
+
const url = op.path.replace(/\{[^}]+\}/g, (p) => call.params[p.slice(1, -1)] ?? p);
|
|
502
|
+
if (m.list.page) {
|
|
503
|
+
const n = Math.max(1, Math.floor(Number(params[m.list.page.param]) || 1));
|
|
504
|
+
const slice = rows.slice((n - 1) * limit, n * limit);
|
|
505
|
+
const last = Math.max(1, Math.ceil(rows.length / limit));
|
|
506
|
+
const headers = new Headers();
|
|
507
|
+
if (m.list.page.link && rows.length > limit) {
|
|
508
|
+
const at = (k) => {
|
|
509
|
+
const u = new URL(call.request.url);
|
|
510
|
+
u.searchParams.set(m.list.page.param, String(k));
|
|
511
|
+
return u.toString();
|
|
512
|
+
};
|
|
513
|
+
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"`] : [])];
|
|
514
|
+
if (rels.length)
|
|
515
|
+
headers.set('link', rels.join(', '));
|
|
516
|
+
}
|
|
517
|
+
return { served: Response.json(fill(m.list.envelope, { data: slice, next_cursor: null, key: op.answers?.key ?? 'data' }), { headers }) };
|
|
518
|
+
}
|
|
519
|
+
if (m.list.offset) {
|
|
520
|
+
const skip = Math.max(0, Math.trunc(Number(params[m.list.offset.param]) || 0));
|
|
521
|
+
const slice = rows.slice(skip, skip + limit);
|
|
522
|
+
const key = op.answers?.key ?? 'data';
|
|
523
|
+
return { served: Response.json({ ...(m.success ?? {}), [key]: slice, ...fill(m.list.envelopes?.[op.id] ?? m.list.envelope, { data: slice, total_count: rows.length, key }) }) };
|
|
524
|
+
}
|
|
525
|
+
if (m.list.cursor) {
|
|
526
|
+
const start = decodeOffset(params[m.list.cursor.param]);
|
|
527
|
+
const slice = rows.slice(start, start + limit);
|
|
528
|
+
const next = start + limit < rows.length ? encodeOffset(start + limit) : '';
|
|
529
|
+
return { served: Response.json({ ...(m.success ?? {}), ...fill(m.list.envelope, { data: slice, next_cursor: next, key: op.answers?.key ?? 'data' }), ...(op.answers?.key ? { [op.answers.key]: slice } : {}) }) };
|
|
530
|
+
}
|
|
531
|
+
return { served: Response.json(fill(m.list.envelope, { data: page, has_more: hasMore, url, first_id: page[0]?.id ?? null, last_id: page.at(-1)?.id ?? null })) };
|
|
532
|
+
}
|
|
533
|
+
case 'update':
|
|
534
|
+
case 'action': {
|
|
535
|
+
const hit = idParam ? stored(m, resource, root).find((r) => r.id === idParam && underParent(r)) : undefined;
|
|
536
|
+
if (!hit)
|
|
537
|
+
return { served: notFound(m, resource, String(idParam), Object.keys(call.params).at(-1)) };
|
|
538
|
+
const current = view(m, resource, hit);
|
|
539
|
+
const changes = { ...(op.class === 'update' ? data : {}) };
|
|
540
|
+
let moved = false;
|
|
541
|
+
for (const [field, sdecl] of Object.entries(decl.state ?? {})) {
|
|
542
|
+
const requested = op.class === 'update' ? data[field] : undefined;
|
|
543
|
+
if (op.class === 'update' && requested === undefined)
|
|
544
|
+
continue;
|
|
545
|
+
const { move, refusal } = transitionFor(field, sdecl, op.id, current[field], requested, hit.id);
|
|
546
|
+
if (refusal)
|
|
547
|
+
return { served: vendorError(m, refusal) };
|
|
548
|
+
if (!move) {
|
|
549
|
+
if (op.class === 'update')
|
|
550
|
+
return { unmodeled: `no declared transition of ${field} to ${String(requested)} by ${op.id}` };
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
const stored = storedState(sdecl, move.to);
|
|
554
|
+
if (stored !== undefined)
|
|
555
|
+
changes[field] = stored;
|
|
556
|
+
for (const [k, rule] of Object.entries(move.effects ?? {}))
|
|
557
|
+
changes[k] = applyRule(m, rule, hit.id, { ...current, ...changes }, at);
|
|
558
|
+
moved = true;
|
|
559
|
+
}
|
|
560
|
+
if (op.class === 'action' && !moved)
|
|
561
|
+
return { unmodeled: `action ${op.id} declares no transition` };
|
|
562
|
+
const merged = {};
|
|
563
|
+
for (const [k, v] of Object.entries(changes)) {
|
|
564
|
+
const prior = current[k];
|
|
565
|
+
merged[k] = decl.update !== 'replace' && v && typeof v === 'object' && !Array.isArray(v) && prior && typeof prior === 'object' && !Array.isArray(prior) ? Object.fromEntries(Object.entries({ ...prior, ...v }).filter(([, value]) => value !== '')) : v;
|
|
566
|
+
}
|
|
567
|
+
return answer(await write(m, call, resource, hit.id, merged, `${storedType(m, resource)}.${op.class === 'update' ? 'update' : op.id}`, params, root, at));
|
|
568
|
+
}
|
|
569
|
+
case 'delete': {
|
|
570
|
+
const hit = idParam ? stored(m, resource, root).find((r) => r.id === idParam && underParent(r)) : undefined;
|
|
571
|
+
if (!hit)
|
|
572
|
+
return { served: notFound(m, resource, String(idParam), Object.keys(call.params).at(-1)) };
|
|
573
|
+
await write(m, call, resource, hit.id, { deleted: true }, `${storedType(m, resource)}.delete`, params, root, at);
|
|
574
|
+
const shape = decl.deleted !== undefined ? decl.deleted : m.deleted;
|
|
575
|
+
return { served: status === 204 || shape === null ? new Response(null, { status: 204 }) : Response.json(fill(shape, { id: hit.id, object: current(hit) }), { status }) };
|
|
576
|
+
}
|
|
577
|
+
default:
|
|
578
|
+
return { unmodeled: `class ${op.class} is not served by the core` };
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
function current(r) {
|
|
582
|
+
return render(r).object ?? r.type;
|
|
583
|
+
}
|
|
584
|
+
/** Server-sent events in the manifest's framing. The events are known when the answer starts: the
|
|
585
|
+
* twin decides deterministically, so the stream carries a decided answer, never a model's. */
|
|
586
|
+
export function sse(m, events, operationId) {
|
|
587
|
+
const enc = new TextEncoder();
|
|
588
|
+
const framing = (operationId && m.streamFor?.[operationId]) || m.sse;
|
|
589
|
+
const frame = (e) => `${framing?.named && e.event ? `event: ${e.event}\n` : ''}data: ${JSON.stringify(e.data)}\n\n`;
|
|
590
|
+
const body = new ReadableStream({
|
|
591
|
+
start(controller) {
|
|
592
|
+
for (const e of events)
|
|
593
|
+
controller.enqueue(enc.encode(frame(e)));
|
|
594
|
+
if (framing?.done)
|
|
595
|
+
controller.enqueue(enc.encode(`data: ${framing.done}\n\n`));
|
|
596
|
+
controller.close();
|
|
597
|
+
},
|
|
598
|
+
});
|
|
599
|
+
return new Response(body, { status: 200, headers: { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache' } });
|
|
600
|
+
}
|
|
601
|
+
async function contextFor(m, call, scope) {
|
|
602
|
+
const root = scope.root;
|
|
603
|
+
call = adoptedPath(m, call, root);
|
|
604
|
+
const at = (scope.clock ?? worldNow)();
|
|
605
|
+
const text = await call.request.clone().text().catch(() => '');
|
|
606
|
+
let body = undefined;
|
|
607
|
+
try {
|
|
608
|
+
body = text ? JSON.parse(text) : undefined;
|
|
609
|
+
}
|
|
610
|
+
catch {
|
|
611
|
+
body = text;
|
|
612
|
+
}
|
|
613
|
+
// a handler reading the body itself sees the adopted id too (the boundary's one resolution, below)
|
|
614
|
+
if (body && typeof body === 'object' && !Array.isArray(body))
|
|
615
|
+
body = adoptedInCall(m.service, body, root);
|
|
616
|
+
const params = await boundaryParams(m, call, root);
|
|
617
|
+
const paths = expandPaths(m, params);
|
|
618
|
+
return {
|
|
619
|
+
call,
|
|
620
|
+
params,
|
|
621
|
+
id: Object.values(call.params).at(-1),
|
|
622
|
+
body,
|
|
623
|
+
text,
|
|
624
|
+
root,
|
|
625
|
+
occurredAt: at,
|
|
626
|
+
actor: m.identity ? m.identity(call.request.headers.get('authorization'), root) : undefined,
|
|
627
|
+
now: () => now(m, at),
|
|
628
|
+
get: (resource, id) => {
|
|
629
|
+
const hit = stored(m, resource, root).find((r) => r.id === id);
|
|
630
|
+
return hit ? view(m, resource, hit) : undefined;
|
|
631
|
+
},
|
|
632
|
+
rows: (resource) => stored(m, resource, root).map((r) => view(m, resource, r)),
|
|
633
|
+
mint: (resource) => mintId(m, resource, root),
|
|
634
|
+
write: (resource, id, fields, operation) => write(m, call, resource, id, fields, operation, params, root, at),
|
|
635
|
+
writeDetailed: (resource, id, fields, operation) => writeDetailed(m, call, resource, id, fields, operation, params, root, at),
|
|
636
|
+
resolve: (resource, id) => resolveSubjectId(m.service, storedType(m, resource), id, root),
|
|
637
|
+
ok: (fields) => Response.json({ ...(m.success ?? {}), ...fields }),
|
|
638
|
+
row: (resource, id, opts) => stored(m, resource, root, opts).find((r) => r.id === id),
|
|
639
|
+
rowsRaw: (resource, opts) => stored(m, resource, root, opts),
|
|
640
|
+
tree: () => twinResources(m.service, root),
|
|
641
|
+
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 } : {}) })),
|
|
642
|
+
record: async (type, fields, id) => {
|
|
643
|
+
if (!type.startsWith('_'))
|
|
644
|
+
throw new Error(`semantics: ${type} is not a bookkeeping type (bookkeeping types start with _)`);
|
|
645
|
+
const subject = id ?? `${type.slice(1)}_${twinResources(m.service, root).filter((r) => r.type === type).length + 1}`;
|
|
646
|
+
await applyTwinWrite(m.service, { operation: `${type.slice(1)}.record`, subjectType: type, subjectId: subject, fields, occurredAt: at, actor: { kind: 'system' } }, root);
|
|
647
|
+
return subject;
|
|
648
|
+
},
|
|
649
|
+
raw: (body, init = {}) => new Response(body, { status: init.status ?? 200, headers: init.headers ?? {} }),
|
|
650
|
+
atomically: async (decide) => {
|
|
651
|
+
const { value } = await applyTwinWriteAtomic(m.service, (resources) => {
|
|
652
|
+
const rows = (resource) => resources.filter((r) => r.type === storedType(m, resource) && r.deleted !== true);
|
|
653
|
+
const d = decide(rows);
|
|
654
|
+
if (!d.write)
|
|
655
|
+
return { kind: 'skip', value: d.value };
|
|
656
|
+
const w = d.write;
|
|
657
|
+
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' } } };
|
|
658
|
+
}, root);
|
|
659
|
+
return value;
|
|
660
|
+
},
|
|
661
|
+
legal: (resource, field, operationId, current, to, id, actor) => {
|
|
662
|
+
const decl = m.resources[resource]?.state?.[field];
|
|
663
|
+
if (!decl)
|
|
664
|
+
throw new Error(`semantics: ${resource}.${field} is not a declared state field`);
|
|
665
|
+
const { move, refusal } = transitionFor(field, decl, operationId, current, to, id ?? Object.values(call.params).at(-1), actor ?? 'api');
|
|
666
|
+
if (move)
|
|
667
|
+
return undefined;
|
|
668
|
+
if (refusal)
|
|
669
|
+
return refusal;
|
|
670
|
+
throw new Error(`semantics: ${operationId} moves ${resource}.${field} from ${String(current)}${to ? ` to ${to}` : ''}, which the machine does not declare`);
|
|
671
|
+
},
|
|
672
|
+
refuse: (e) => vendorError(m, e),
|
|
673
|
+
notFound: (resource, id, param) => notFound(m, resource, id, param),
|
|
674
|
+
reply: (body, status = 200) => Response.json(body, { status }),
|
|
675
|
+
wrap: (body, extra = {}) => Response.json({ ...envelope(m, call.operation, body), ...extra }),
|
|
676
|
+
sse: (events) => sse(m, events, call.operation.id),
|
|
677
|
+
expand: (resource, body) => (paths.length ? expand(m, resource, body, paths, root) : body),
|
|
678
|
+
at: (when) => contextFor(m, { ...call, request: new Request(call.request.url) }, { ...scope, clock: () => when }),
|
|
679
|
+
core: async () => { const out = await serveCore(m, call, { ...scope, clock: () => at }); return 'served' in out ? out.served : undefined; },
|
|
680
|
+
own: (row) => ownFields(row),
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
/** A pack's semantics handlers as the dispatch's handlers. */
|
|
684
|
+
export function bindSemantics(m, handlers, scope = {}) {
|
|
685
|
+
return Object.fromEntries(Object.entries(handlers).map(([id, h]) => [id, async (call) => h(await contextFor(m, call, scope))]));
|
|
686
|
+
}
|
|
687
|
+
/** The derived core as the dispatch's core: it owns every operation on a resource the manifest declares. */
|
|
688
|
+
export function coreFor(m, scope = {}) {
|
|
689
|
+
const crud = new Set(['create', 'retrieve', 'list', 'update', 'delete']);
|
|
690
|
+
const owns = (o) => {
|
|
691
|
+
const decl = o.resource !== undefined ? m.resources[o.resource] : undefined;
|
|
692
|
+
if (!decl || m.unmodeled?.includes(o.id))
|
|
693
|
+
return false;
|
|
694
|
+
if (crud.has(o.class))
|
|
695
|
+
return true;
|
|
696
|
+
return o.class === 'action' && Object.values(decl.state ?? {}).some((f) => f.transitions.some((t) => t.operation === o.id));
|
|
697
|
+
};
|
|
698
|
+
return { owns, serve: (call) => serveCore(m, call, scope) };
|
|
699
|
+
}
|
|
700
|
+
// ── what every moved operation gets ──────────────────────────────────────────────────────────
|
|
701
|
+
const hex = (s) => Array.from(new TextEncoder().encode(s), (b) => b.toString(16).padStart(2, '0')).join('');
|
|
702
|
+
/** Read-only refusal, API-version validation and idempotent replay, applied once around every
|
|
703
|
+
* operation a handler or the core serves. */
|
|
704
|
+
export function crossCutting(m, opts = {}) {
|
|
705
|
+
const finish = async (r, request) => {
|
|
706
|
+
if (!m.origin || !(r.headers.get('content-type') ?? '').includes('json'))
|
|
707
|
+
return withHeaders(r);
|
|
708
|
+
const text = (await r.text()).replaceAll(m.origin.placeholder, twinPublicBase(request));
|
|
709
|
+
return withHeaders(new Response(text, { status: r.status, statusText: r.statusText, headers: r.headers }));
|
|
710
|
+
};
|
|
711
|
+
const withHeaders = (r) => {
|
|
712
|
+
if (!m.answerHeaders)
|
|
713
|
+
return r;
|
|
714
|
+
const headers = new Headers(r.headers);
|
|
715
|
+
for (const [k, v] of Object.entries(m.answerHeaders))
|
|
716
|
+
if (!headers.has(k))
|
|
717
|
+
headers.set(k, v);
|
|
718
|
+
return new Response(r.body, { status: r.status, statusText: r.statusText, headers });
|
|
719
|
+
};
|
|
720
|
+
return async (call, next) => finish(await guarded(call, next), call.request);
|
|
721
|
+
async function guarded(call, next) {
|
|
722
|
+
const { request } = call;
|
|
723
|
+
if (m.auth) {
|
|
724
|
+
const raw = request.headers.get(m.auth.header);
|
|
725
|
+
if (raw !== null || m.auth.gateWhenAbsent) {
|
|
726
|
+
const scheme = `${m.auth.scheme.toLowerCase()} `;
|
|
727
|
+
const key = raw && raw.toLowerCase().startsWith(scheme) ? raw.slice(scheme.length).trim() : '';
|
|
728
|
+
if (!key)
|
|
729
|
+
return vendorError(m, m.auth.missing);
|
|
730
|
+
if (m.auth.invalidKeys.includes(key) || (m.auth.keyFormat && !new RegExp(m.auth.keyFormat).test(key)))
|
|
731
|
+
return vendorError(m, m.auth.invalid);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
// a read-only twin refuses writes: what the operation does, not the HTTP verb it came by (an RPC
|
|
735
|
+
// wire POSTs its reads)
|
|
736
|
+
if (opts.readOnly && !['retrieve', 'list', 'computed'].includes(call.operation.class) && !m.reads?.includes(call.operation.id))
|
|
737
|
+
return vendorError(m, m.readOnly);
|
|
738
|
+
// a body labelled JSON that does not parse is the vendor's refusal, never a crash of the twin
|
|
739
|
+
if ((request.headers.get('content-type') ?? '').includes('json') || m.body.json === 'always') {
|
|
740
|
+
const text = request.method === 'GET' || request.method === 'HEAD' ? '' : await request.clone().text().catch(() => '');
|
|
741
|
+
if (text) {
|
|
742
|
+
try {
|
|
743
|
+
JSON.parse(text);
|
|
744
|
+
}
|
|
745
|
+
catch {
|
|
746
|
+
return vendorError(m, m.malformedBody ?? { status: 400, message: 'The request body could not be parsed as JSON.' });
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
if (m.version) {
|
|
751
|
+
const value = request.headers.get(m.version.header);
|
|
752
|
+
if (value && !new RegExp(m.version.pattern).test(value))
|
|
753
|
+
return vendorError(m, { ...m.version.error, message: String(fill(m.version.error.message, { value })) });
|
|
754
|
+
}
|
|
755
|
+
const key = m.idempotency ? request.headers.get(m.idempotency.header) : null;
|
|
756
|
+
if (!m.idempotency || !key || !(m.idempotency.methods ?? ['POST']).includes(request.method.toUpperCase()))
|
|
757
|
+
return next();
|
|
758
|
+
const url = new URL(request.url);
|
|
759
|
+
const signature = hashFieldValue({ path: url.pathname + url.search, body: await request.clone().text() });
|
|
760
|
+
const id = `idem_${hex(key)}`;
|
|
761
|
+
const prior = twinResources(m.service, opts.root).find((r) => r.type === m.idempotency.storedAs && r.id === id);
|
|
762
|
+
if (prior) {
|
|
763
|
+
if (m.idempotency.conflict && prior.requestHash !== undefined && prior.requestHash !== signature)
|
|
764
|
+
return vendorError(m, m.idempotency.conflict);
|
|
765
|
+
const answer = prior.response;
|
|
766
|
+
// a streamed answer replays as the stream it was; a record kept before text was kept replays its JSON
|
|
767
|
+
return answer.text !== undefined ? new Response(answer.text, { status: answer.status, headers: { 'content-type': answer.contentType ?? 'application/json' } }) : Response.json(answer.body, { status: answer.status });
|
|
768
|
+
}
|
|
769
|
+
const response = await next();
|
|
770
|
+
if (m.idempotency.onlySuccess && (response.status < 200 || response.status >= 300))
|
|
771
|
+
return response;
|
|
772
|
+
const contentType = response.headers.get('content-type') ?? 'application/json';
|
|
773
|
+
const text = await response.clone().text();
|
|
774
|
+
await applyTwinWrite(m.service, { operation: 'idempotency.record', subjectType: m.idempotency.storedAs, subjectId: id, fields: { response: { status: response.status, text, contentType }, requestHash: signature }, occurredAt: (opts.clock ?? worldNow)(), actor: { kind: 'system' } }, opts.root);
|
|
775
|
+
return response;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
/** A semantics context for a request no surface operation names: another wire's (GraphQL) resolvers
|
|
779
|
+
* get the same interface as a handler, named by the operation id the wire gives. */
|
|
780
|
+
export function semanticsContext(m, request, operation, scope = {}) {
|
|
781
|
+
return contextFor(m, { request, operation, params: {} }, scope);
|
|
782
|
+
}
|