@volter/world-core 3.0.163 → 3.0.165
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/dist/generated/pack-facts.json +44 -3660
- package/dist/pack-facts.cjs +4 -4
- package/dist/src/browser-session.d.ts +7 -1
- package/dist/src/browser-session.js +21 -10
- package/dist/src/browser.d.ts +4 -0
- package/dist/src/browser.js +10 -0
- package/dist/src/derived-core.d.ts +46 -6
- package/dist/src/derived-core.js +60 -13
- package/dist/src/derived-real.js +159 -5
- package/dist/src/derived.d.ts +1 -1
- package/dist/src/events.d.ts +6 -3
- package/dist/src/events.js +52 -7
- package/dist/src/executor.d.ts +2 -2
- package/dist/src/executor.js +13 -0
- package/dist/src/file-response.d.ts +6 -2
- package/dist/src/file-response.js +29 -5
- package/dist/src/git/index.d.ts +1 -0
- package/dist/src/git/index.js +1 -0
- package/dist/src/git/smart-http-client.d.ts +28 -0
- package/dist/src/git/smart-http-client.js +95 -0
- package/dist/src/git/smart-http.d.ts +3 -0
- package/dist/src/git/smart-http.js +9 -0
- package/dist/src/head.d.ts +16 -1
- package/dist/src/head.js +52 -7
- package/dist/src/html.d.ts +29 -0
- package/dist/src/html.js +64 -0
- package/dist/src/index.d.ts +5 -2
- package/dist/src/index.js +5 -1
- package/dist/src/managed-database.d.ts +4 -2
- package/dist/src/managed-database.js +1 -0
- package/dist/src/media-header.d.ts +34 -0
- package/dist/src/media-header.js +129 -0
- package/dist/src/pack-fetch.d.ts +24 -3
- package/dist/src/pack-fetch.js +155 -96
- package/dist/src/pack-paths.d.ts +2 -3
- package/dist/src/pack-paths.js +3 -13
- package/dist/src/packRegistry.d.ts +17 -1
- package/dist/src/request-body.d.ts +2 -3
- package/dist/src/request-body.js +2 -3
- package/dist/src/runtime.d.ts +4 -1
- package/dist/src/runtime.js +5 -1
- package/dist/src/serve-http.d.ts +2 -2
- package/dist/src/serve-http.js +104 -19
- package/dist/src/smtp-server.d.ts +78 -0
- package/dist/src/smtp-server.js +160 -0
- package/dist/src/smtp.d.ts +29 -11
- package/dist/src/smtp.js +221 -52
- package/generated/pack-facts.json +44 -3660
- package/pack-facts.cjs +4 -4
- package/package.json +4 -2
- package/src/browser-session.ts +17 -5
- package/src/browser.ts +10 -0
- package/src/derived-core.ts +77 -19
- package/src/derived-real.ts +131 -4
- package/src/derived.ts +2 -2
- package/src/events.ts +54 -9
- package/src/executor.ts +13 -2
- package/src/file-response.ts +28 -6
- package/src/git/index.ts +1 -0
- package/src/git/smart-http-client.ts +99 -0
- package/src/git/smart-http.ts +7 -0
- package/src/head.ts +37 -6
- package/src/html.ts +72 -0
- package/src/index.ts +6 -2
- package/src/managed-database.ts +4 -3
- package/src/media-header.ts +108 -0
- package/src/pack-fetch.ts +142 -87
- package/src/pack-paths.ts +3 -11
- package/src/packRegistry.ts +9 -1
- package/src/request-body.ts +2 -3
- package/src/runtime.ts +5 -1
- package/src/serve-http.ts +81 -19
- package/src/smtp-server.ts +166 -0
- package/src/smtp.ts +199 -53
package/pack-facts.cjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// THE PACK FACTS a process routes by (docs/contributing/architecture.md, "The catalog: where twins come from"): the facts
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// the platform owns (generated/pack-facts.json: its server backings, scripts/pack-facts.ts), overlaid by the facts of the
|
|
3
|
+
// packs the World installs (ADR 0034, decision 2). Each pack carries its own (`generated/pack-facts.json`), and the
|
|
4
|
+
// World's instance file (VOLTER_WORLD_INSTANCE, which every World process already has) names them as `packFacts`; a
|
|
5
|
+
// pack the World has not installed is unknown to it. Plain CommonJS, no
|
|
6
6
|
// dependency: the injector preloads it. Read once per process.
|
|
7
7
|
'use strict';
|
|
8
8
|
const fs = require('fs');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-core",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.165",
|
|
4
4
|
"description": "The kernel of Volter World: one log per twin, branches as pointers, checkpoints, the fold that keeps a twin current, the head that performs a write against the vendor, references, and the git plane. A twin package builds on it; the runtime serves it.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
|
@@ -169,6 +169,7 @@
|
|
|
169
169
|
"zod": "^4.4.2"
|
|
170
170
|
},
|
|
171
171
|
"devDependencies": {
|
|
172
|
+
"@volter/twin-stripe": "3.0.6",
|
|
172
173
|
"@types/bun": "^1.2.20",
|
|
173
174
|
"@types/node": "^24.0.0",
|
|
174
175
|
"typescript": "^5.9.0",
|
|
@@ -181,7 +182,8 @@
|
|
|
181
182
|
"node": ">=22.3"
|
|
182
183
|
},
|
|
183
184
|
"optionalDependencies": {
|
|
184
|
-
"@xmldom/xmldom": "0.9.12"
|
|
185
|
+
"@xmldom/xmldom": "0.9.12",
|
|
186
|
+
"parse5": "7.3.0"
|
|
185
187
|
},
|
|
186
188
|
"packageManager": "pnpm@12.15.0",
|
|
187
189
|
"devEngines": {
|
package/src/browser-session.ts
CHANGED
|
@@ -1,19 +1,31 @@
|
|
|
1
1
|
// A vendor's browser session: the bookkeeping used by its own sign-in and sign-out pages.
|
|
2
2
|
import type { HandlerContext } from './derived-core.ts';
|
|
3
|
+
import { personOf, type Person } from './people.ts';
|
|
3
4
|
|
|
4
5
|
type Writer = Pick<HandlerContext, 'record' | 'secret' | 'occurredAt'>;
|
|
5
6
|
|
|
6
|
-
/** The
|
|
7
|
-
export function
|
|
7
|
+
/** The value of the cookie `name` the request carries, as a browser sends it back. */
|
|
8
|
+
export function cookieOf(request: Request, name: string): string | undefined {
|
|
8
9
|
for (const part of (request.headers.get('cookie') ?? '').split(';')) {
|
|
9
10
|
const at = part.indexOf('=');
|
|
10
|
-
if (at > 0 && part.slice(0, at).trim() === name)
|
|
11
|
-
try { return decodeURIComponent(part.slice(at + 1).trim()); } catch { return undefined; }
|
|
12
|
-
}
|
|
11
|
+
if (at > 0 && part.slice(0, at).trim() === name) return decodeURIComponent(part.slice(at + 1).trim());
|
|
13
12
|
}
|
|
14
13
|
return undefined;
|
|
15
14
|
}
|
|
16
15
|
|
|
16
|
+
/** The named session cookie; malformed encodings do not name a session. */
|
|
17
|
+
export function browserSessionToken(request: Request, name: string): string | undefined {
|
|
18
|
+
try { return cookieOf(request, name); } catch { return undefined; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The person the request's session cookie names, or undefined when nobody is signed in; with `scope`, only a session
|
|
22
|
+
* begun in it (a vendor's sandbox dashboard and its production one sign a person in apart). */
|
|
23
|
+
export function signedIn(ctx: HandlerContext, cookie: string, scope?: string): Person | undefined {
|
|
24
|
+
const token = cookieOf(ctx.call.request, cookie);
|
|
25
|
+
const session = token ? ctx.rowsRaw('_web_session').find((s) => s.token === token && (scope === undefined || s.scope === scope)) : undefined;
|
|
26
|
+
return session ? personOf(ctx, String(session.email)) : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
17
29
|
/** A new, independent sign-in, returning the cookie the vendor places on its response. */
|
|
18
30
|
export async function startBrowserSession(ctx: Writer, email: string, cookie: string, scope?: string): Promise<string> {
|
|
19
31
|
const issued = await ctx.record('_web_session_issue', { email, ...(scope !== undefined ? { scope } : {}) });
|
package/src/browser.ts
CHANGED
|
@@ -60,6 +60,16 @@ function vendorResponse(response: Response, vendor: URL, twin: URL, mount: strin
|
|
|
60
60
|
return changed ? new Response(response.body, { status: response.status, statusText: response.statusText, headers }) : response;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/** The one password of an address in this World (ADR 0033 §5): `worldSecret('_world', <data>/_world,
|
|
64
|
+
* 'password:<address>')`, retained World state, so a seed signs a person up with it and a twin keeps only its hash. A
|
|
65
|
+
* seed reads it beside `browser(name)`; it is never a literal in a committed file. */
|
|
66
|
+
export async function password(address: string): Promise<string> {
|
|
67
|
+
const data = process.env.VOLTER_WORLD_DATA;
|
|
68
|
+
if (!process.env.VOLTER_WORLD || !data) throw new Error('password(address) runs in a World seed, with its data directory');
|
|
69
|
+
const { worldSecret } = await import('./derived-core.ts');
|
|
70
|
+
return worldSecret('_world', nodeBuiltin<typeof import('node:path')>('path').join(data, '_world'), `password:${address}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
63
73
|
/** A seed's named browser. Every state write goes through the World's single HTTP loader. */
|
|
64
74
|
export async function browser(name: string): Promise<SeedBrowser> {
|
|
65
75
|
assertBrowserName(name);
|
package/src/derived-core.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { parseExactJson, compareExactNumbers, exactInteger, isExactIntegerValue
|
|
|
11
11
|
import { applyTwinWrite, applyTwinWriteAtomic, applyTwinWrites, twinResources, type TwinResource } from './serve.ts';
|
|
12
12
|
import { copyResource, foldLocalWrite, heldSubject, ownFields, parentStamp, subjectHistory, treeChangesSince, treeResources, treeStamp, type Subject } from './log.ts';
|
|
13
13
|
import { performsAtHeadNow } from './head.ts';
|
|
14
|
-
import { getActiveWorldStore } from './world-store.ts';
|
|
14
|
+
import { getActiveWorldStore, type WorldStore } from './world-store.ts';
|
|
15
15
|
import { getActiveBlobStore } from './blob-store.ts';
|
|
16
16
|
import { worldPaths } from './storage.ts';
|
|
17
17
|
import { GitObjectStore } from './git/objects.ts';
|
|
@@ -31,7 +31,7 @@ import { readResourceBlob, readResourceBlobRange, removeResourceBlob, resourceBl
|
|
|
31
31
|
import { managedDatabase, unboundDatabase, type ManagedDatabase } from './managed-database.ts';
|
|
32
32
|
import { redisServiceTimes } from './redis/service-times.ts';
|
|
33
33
|
import { execRedisRun, type RedisDialect, type RunItem } from './redis/engine.ts';
|
|
34
|
-
import { sendMail, type Mail, type
|
|
34
|
+
import { sendMail, type Mail, type MailReply } from './smtp.ts';
|
|
35
35
|
import { ownField, parseBracketForm } from './form.ts';
|
|
36
36
|
export { ownField, parseBracketForm } from './form.ts';
|
|
37
37
|
import { multipartBoundary, multipartParts, type MultipartPart } from './multipart.ts';
|
|
@@ -340,6 +340,45 @@ export type PerformHookArgs = {
|
|
|
340
340
|
};
|
|
341
341
|
export type PerformHook = (args: PerformHookArgs) => Promise<import('./head.ts').PushOutcome>;
|
|
342
342
|
|
|
343
|
+
export type GitRepositoryDecl = { resource: string; name: string; remote: string };
|
|
344
|
+
export type GitPushDecl = { resource: string; repository: string; remote: string; ref: string; before: string; after: string };
|
|
345
|
+
|
|
346
|
+
/** A template over a row's fields (`{field}`, a dotted path read through objects), every name filled or refused. */
|
|
347
|
+
export function fillTemplate(template: string, row: Record<string, unknown>): string {
|
|
348
|
+
return template.replace(/\{([^}]+)\}/g, (_, name: string) => {
|
|
349
|
+
const value = name.split('.').reduce<unknown>((at, k) => (at !== null && typeof at === 'object' ? (at as Record<string, unknown>)[k] : undefined), row);
|
|
350
|
+
if (value === undefined || value === null || typeof value === 'object') throw new Error(`template ${template}: no value for ${name}`);
|
|
351
|
+
return String(value);
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** A pack's git repository `name` in a World (the store and refs `ctx.git(name)` answers), under the twin's state and,
|
|
356
|
+
* for a namespaced account, that account's. */
|
|
357
|
+
export function gitRepositoryOf(m: DerivedManifest, name: string, root?: string): { store: GitObjectStore; refs: GitRefs } {
|
|
358
|
+
if (!/^[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.-]+)*$/.test(name) || name.split('/').some((seg) => seg === '.' || seg === '..')) throw new Error(`semantics: ${name} is not a git repository name`);
|
|
359
|
+
const account = accountScope(m);
|
|
360
|
+
const plane = `${worldPaths(m.service, root).dir}/git${account && account !== 'default' ? `/accounts/${encodeURIComponent(account)}` : ''}`;
|
|
361
|
+
return { store: new GitObjectStore(getActiveBlobStore(), `${plane}/repos/${name}`), refs: new GitRefs(getActiveWorldStore(), `${plane}/repos/${name}`) };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** The name a repository row's code is kept under: the manifest's `git.repository.name` (a template over `{id}` alone)
|
|
365
|
+
* filled with the row's first World id, the one an adoption rebound to the vendor's (walked back through the aliases),
|
|
366
|
+
* so the screen, a refresh, a deploy and a waiting push's protection name one repository before and after adoption. */
|
|
367
|
+
export function gitNameOf(m: DerivedManifest, id: string, root?: string): string {
|
|
368
|
+
const decl = m.git?.repository;
|
|
369
|
+
if (!decl) throw new Error(`${m.vendor}: the manifest declares no git repositories`);
|
|
370
|
+
if (decl.name.replace(/\{id\}/g, '').includes('{')) throw new Error(`${m.vendor}: git.repository.name is a template over {id} alone`);
|
|
371
|
+
const type = `${storedType(m, decl.resource)}:`;
|
|
372
|
+
const aliases = aliasesNow(m.service, root);
|
|
373
|
+
let first = id;
|
|
374
|
+
for (let hops = 0; hops < 8; hops++) {
|
|
375
|
+
const from = [...aliases].find(([local, vendor]) => vendor === first && local.startsWith(type))?.[0];
|
|
376
|
+
if (from === undefined) break;
|
|
377
|
+
first = from.slice(type.length);
|
|
378
|
+
}
|
|
379
|
+
return decl.name.replace(/\{id\}/g, first);
|
|
380
|
+
}
|
|
381
|
+
|
|
343
382
|
export type DerivedManifest = {
|
|
344
383
|
/** Vendor data-plane hosts mapped to an already-running customer guest. */
|
|
345
384
|
machineRoutes?: ReadonlyArray<{ host: string; resource: string; where: Record<string, string>; name: string; port: number | string }>;
|
|
@@ -409,6 +448,12 @@ export type DerivedManifest = {
|
|
|
409
448
|
ingest?: IngestDecl;
|
|
410
449
|
/** the vendor's documented limits, charged by the executor on every live call (D8) */
|
|
411
450
|
rateBudget?: RateBudgetDeclaration;
|
|
451
|
+
/** where the vendor's repositories' code lives, as templates over a row's fields (`{field}`) (architecture, "The
|
|
452
|
+
* real-system adapters"; ADR 0031): the resource whose rows are the vendor's repositories, the name each is kept under
|
|
453
|
+
* (over `{id}` alone: the row's first World id, `gitNameOf`) and its remote (the vendor's absolute git URL), which a
|
|
454
|
+
* refresh fetches; and the resource a push to a World repository records, one row per ref, with the World id of the
|
|
455
|
+
* repository row it pushed to, which a deploy pushes */
|
|
456
|
+
git?: { repository: GitRepositoryDecl; push: GitPushDecl };
|
|
412
457
|
/** a pack whose wire has no derived perform (a socket, a line protocol, a managed database): binding a root is refused
|
|
413
458
|
* with this reason */
|
|
414
459
|
vendorBacked?: { none: string };
|
|
@@ -937,7 +982,7 @@ export type CoreScope = {
|
|
|
937
982
|
root?: string; clock?: () => string; database?: string; readOnly?: boolean;
|
|
938
983
|
/** `semantics/tenant.ts`: the tenant the caller acts in (the manifest's `tenant`), or undefined for none */
|
|
939
984
|
tenant?: (ctx: HandlerContext) => string | undefined | Promise<string | undefined>;
|
|
940
|
-
/** the application a walk stands in for:
|
|
985
|
+
/** the application a walk stands in for: vendor requests and declared events are answered by it (events.ts) */
|
|
941
986
|
application?: ApplicationStandIn;
|
|
942
987
|
};
|
|
943
988
|
|
|
@@ -1138,9 +1183,9 @@ function mintId(m: DerivedManifest, resource: string, root: string | undefined,
|
|
|
1138
1183
|
* increment. One space over every snowflake resource of the vendor, as its one generator's: the instant's first, or one
|
|
1139
1184
|
* past the greatest the World holds when that is later (a frozen clock, or several ids in one millisecond), so ids
|
|
1140
1185
|
* rise with time and never repeat. */
|
|
1141
|
-
/** The last snowflake minted per vendor and World
|
|
1186
|
+
/** The last snowflake minted per store, vendor and World: two mints before the first is written (a message
|
|
1142
1187
|
* and its attachment) must not meet. */
|
|
1143
|
-
const lastSnowflake = new Map<string, bigint
|
|
1188
|
+
const lastSnowflake = new WeakMap<WorldStore, Map<string, bigint>>();
|
|
1144
1189
|
function mintSnowflake(m: DerivedManifest, epoch: bigint, root: string | undefined, at: string, snapshot?: readonly TwinResource[]): string {
|
|
1145
1190
|
const ms = BigInt(Date.parse(at) || 0);
|
|
1146
1191
|
const base = ms > epoch ? (ms - epoch) << 22n : 0n;
|
|
@@ -1149,11 +1194,13 @@ function mintSnowflake(m: DerivedManifest, epoch: bigint, root: string | undefin
|
|
|
1149
1194
|
if (!/^\{snowflake:\d+\}$/.test(decl.ids ?? m.ids.template)) continue;
|
|
1150
1195
|
for (const r of mintRows(m, name, root, snapshot)) if (/^\d+$/.test(r.id)) { const v = BigInt(r.id); if (v > max) max = v; }
|
|
1151
1196
|
}
|
|
1197
|
+
const store = getActiveWorldStore();
|
|
1198
|
+
const reservations = lastSnowflake.get(store) ?? lastSnowflake.set(store, new Map()).get(store)!;
|
|
1152
1199
|
const key = `${m.service}\0${root ?? ''}`;
|
|
1153
|
-
const last =
|
|
1200
|
+
const last = reservations.get(key) ?? -1n;
|
|
1154
1201
|
const floor = max > last ? max : last;
|
|
1155
1202
|
const id = base > floor ? base : floor + 1n;
|
|
1156
|
-
|
|
1203
|
+
reservations.set(key, id);
|
|
1157
1204
|
return String(id);
|
|
1158
1205
|
}
|
|
1159
1206
|
|
|
@@ -1397,6 +1444,17 @@ const scopeOfCall = new WeakMap<DerivedCall, CoreScope>();
|
|
|
1397
1444
|
/** The intact copy contextFor already keeps for core(), before a handler can consume its request. */
|
|
1398
1445
|
const requestOfCall = new WeakMap<DerivedCall, Request>();
|
|
1399
1446
|
|
|
1447
|
+
/** A row of the manifest's `git.push.resource` a screen writes (a client's push to a World repository) records the push
|
|
1448
|
+
* it is, which a deploy sends to the vendor as a git push (head.ts settlingEntries offers it; derived-real.ts performs
|
|
1449
|
+
* it). One an operation of the surface writes (an API commit, a ref the API creates) is that operation's, which a
|
|
1450
|
+
* deploy sends again. */
|
|
1451
|
+
function pushedInput(m: DerivedManifest, resource: string, fields: Record<string, unknown>, input: Record<string, unknown>, root: string | undefined): Record<string, unknown> {
|
|
1452
|
+
const push = m.git?.push;
|
|
1453
|
+
if (!push || push.resource !== resource || typeof input.door !== 'string') return input;
|
|
1454
|
+
const fill = (template: string): string => fillTemplate(template, fields);
|
|
1455
|
+
return { ...input, git: { name: gitNameOf(m, fill(push.repository), root), remote: fill(push.remote), ref: fill(push.ref), before: fill(push.before), after: fill(push.after) } };
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1400
1458
|
async function writeDetailed(m: DerivedManifest, call: DerivedCall, resource: string, id: string, fields: Record<string, unknown>, operation: string, params: Record<string, unknown>, root: string | undefined, occurredAt: string, checks?: StateChecks): Promise<{ body: Record<string, unknown>; id: string; vendorData?: unknown }> {
|
|
1401
1459
|
const before = stateBeforeWrite(m, resource, id, root);
|
|
1402
1460
|
const prior = counted(m, resource) ? stored(m, resource, root).find((r) => r.id === id) as Record<string, unknown> | undefined : undefined;
|
|
@@ -1406,7 +1464,7 @@ async function writeDetailed(m: DerivedManifest, call: DerivedCall, resource: st
|
|
|
1406
1464
|
// a vendor's own move (ctx.asVendor, the clock) is the World's, as the clock's writes are: no API call a head performs
|
|
1407
1465
|
inVendorMove()
|
|
1408
1466
|
? { operation, subjectType: storedType(m, resource), subjectId: id, fields, input: { clock: true }, occurredAt, actor: { kind: 'system' } }
|
|
1409
|
-
: { operation, subjectType: storedType(m, resource), subjectId: id, fields, input: await withFiles(recordedInput(m, call, params)), occurredAt, actor: { kind: 'agent' } },
|
|
1467
|
+
: { operation, subjectType: storedType(m, resource), subjectId: id, fields, input: pushedInput(m, resource, fields, await withFiles(recordedInput(m, call, params)), root), occurredAt, actor: { kind: 'agent' } },
|
|
1410
1468
|
root,
|
|
1411
1469
|
);
|
|
1412
1470
|
observeStateWrite(m, result, before, root, checks);
|
|
@@ -1452,7 +1510,7 @@ async function finishWrite(m: DerivedManifest, call: DerivedCall, resource: stri
|
|
|
1452
1510
|
const endpoint = (kind: import('./events.ts').EndpointsDecl): import('./events.ts').EndpointsDecl => ({ ...kind, storedAs: storedType(m, kind.storedAs), ...(kind.joins ? { joins: Object.fromEntries(Object.entries(kind.joins).map(([name, join]) => [name, { ...join, storedAs: storedType(m, join.storedAs) }])) } : {}) });
|
|
1453
1511
|
const events = { ...m.events, ...(m.events.store ? { store: storedType(m, m.events.store) } : {}), endpoints: 'storedAs' in m.events.endpoints ? endpoint(m.events.endpoints) : m.events.endpoints.map(endpoint) };
|
|
1454
1512
|
const write = { operation, storedType: m.resources[resource]?.storedAs ?? resource, body, ...(root !== undefined ? { root } : {}), occurredAt, request: request.clone() };
|
|
1455
|
-
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);
|
|
1513
|
+
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, scopeOfCall.get(call)?.application);
|
|
1456
1514
|
}
|
|
1457
1515
|
if (m.sockets?.length) socketWrite(m.service, root, { operation, storedType: m.resources[resource]?.storedAs ?? resource, body, ...(root !== undefined ? { root } : {}), occurredAt, request: request.clone() });
|
|
1458
1516
|
// a read held until the World writes (held-reads.ts) hears it
|
|
@@ -1507,7 +1565,7 @@ function decodeOffset(cursor: unknown): number {
|
|
|
1507
1565
|
|
|
1508
1566
|
/** What a coverage measurement sees of a pack's declared state logic: each move a request made and each refusal a
|
|
1509
1567
|
* guard gave, by the transition that decided it. Nothing is observed unless a measurement installs an observer
|
|
1510
|
-
* (
|
|
1568
|
+
* (`twin-standard coverage`); serving never depends on it. */
|
|
1511
1569
|
export type TransitionEntry = { action: TwinAction; field: string; actor: Actor };
|
|
1512
1570
|
export type TransitionObserver = (decl: StateField, transition: Transition, outcome: 'moved' | 'refused', from: string, entry?: TransitionEntry) => void;
|
|
1513
1571
|
let transitionObserver: TransitionObserver | undefined;
|
|
@@ -1979,15 +2037,18 @@ export type SemanticsContext = {
|
|
|
1979
2037
|
* World's repository-scoped content-addressed object store, its refs in the World's store. Served over smart HTTP
|
|
1980
2038
|
* (`git.serveSmartHttp`), read and written through the library's codecs. */
|
|
1981
2039
|
git(name: string): { store: GitObjectStore; refs: GitRefs };
|
|
2040
|
+
/** The git repository of a row of the manifest's `git.repository.resource`, under the one name it keeps through
|
|
2041
|
+
* adoption (`gitNameOf`). */
|
|
2042
|
+
gitRepository(row: Record<string, unknown>): { store: GitObjectStore; refs: GitRefs };
|
|
1982
2043
|
/** A value the World set for its application (world-env.ts): what the app was given and the vendor must agree with (a
|
|
1983
2044
|
* webhook signing secret the app verifies with), never the caller's own shell; undefined when the World sets none. */
|
|
1984
2045
|
worldEnv(name: string): string | undefined;
|
|
1985
2046
|
/** Runs `fn` as the vendor's own move, not the caller's: what time makes due (a renewal a read catches up) is
|
|
1986
2047
|
* recorded with no caller, wherever a handler reaches it. */
|
|
1987
2048
|
asVendor<T>(fn: () => Promise<T>): Promise<T>;
|
|
1988
|
-
/** A mail the vendor
|
|
1989
|
-
*
|
|
1990
|
-
mail(
|
|
2049
|
+
/** A mail the vendor hands on, over SMTP to the World's twin that receives each recipient's domain (smtp.ts, ADR 0032
|
|
2050
|
+
* §3): each recipient's reply, `250` delivered and `5xx` refused; a domain no twin receives is refused by the kernel. */
|
|
2051
|
+
mail(mail: Mail): Promise<MailReply[]>;
|
|
1991
2052
|
now(): unknown;
|
|
1992
2053
|
/** Render a stored row or vendor view using its resource declaration, retaining the stored subject id fallback. */
|
|
1993
2054
|
render(row: Record<string, unknown>, resource?: string): Record<string, unknown>;
|
|
@@ -2252,12 +2313,9 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
2252
2313
|
ask: (url, init, within) => askApplication(url, init, within, scope.application),
|
|
2253
2314
|
worldEnv: (name) => worldEnvValue(name),
|
|
2254
2315
|
asVendor: (fn) => runAsVendorMove(fn),
|
|
2255
|
-
git: (name) =>
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
return { store: new GitObjectStore(getActiveBlobStore(), `${plane}/repos/${name}`), refs: new GitRefs(getActiveWorldStore(), `${plane}/repos/${name}`) };
|
|
2259
|
-
},
|
|
2260
|
-
mail: (route, mail, headers) => sendMail(route, mail, headers),
|
|
2316
|
+
git: (name) => gitRepositoryOf(m, name, root),
|
|
2317
|
+
gitRepository: (row) => gitRepositoryOf(m, gitNameOf(m, String(row.id), root), root),
|
|
2318
|
+
mail: (mail) => sendMail(mail),
|
|
2261
2319
|
now: () => now(m, at),
|
|
2262
2320
|
render: (row, resource) => {
|
|
2263
2321
|
const raw = rawResources.has(row);
|
package/src/derived-real.ts
CHANGED
|
@@ -5,12 +5,13 @@ import { parseExactJson, exactInteger, compareExactNumbers } from './exact-json.
|
|
|
5
5
|
// reaches here, only its keyed fingerprint (`PerformContext.credential`, for the idempotency key).
|
|
6
6
|
import type { TwinAction } from './actions.ts';
|
|
7
7
|
import { aliasesFrom, parentEntries, readParentTreeMap, readTree, wholeLog } from './log.ts';
|
|
8
|
-
import { storedType, type DerivedManifest, type IngestDecl, type ResourceDecl, type VendorManifest } from './derived-core.ts';
|
|
8
|
+
import { fillTemplate, gitNameOf, gitRepositoryOf, storedType, type DerivedManifest, type IngestDecl, type ResourceDecl, type VendorManifest } from './derived-core.ts';
|
|
9
9
|
import type { DerivedOperation, DerivedSurface } from './derived.ts';
|
|
10
10
|
import { verifyEvent, type EventScheme } from './events.ts';
|
|
11
11
|
import { hashFieldValue } from './hash.ts';
|
|
12
12
|
import { readResourceBlob, writeResourceBlob } from './resource-blob.ts';
|
|
13
|
-
import { RefusedWriteError, type PerformAction, type PerformContext, type PushOutcome } from './head.ts';
|
|
13
|
+
import { RefusedWriteError, settlingEntries, type PerformAction, type PerformContext, type PushOutcome } from './head.ts';
|
|
14
|
+
import { ZERO_SHA, fetchPack, isAncestor, lsRemote, sendPack, type SmartHttpTransport } from './git/index.ts';
|
|
14
15
|
import { observeResources, type Completed, type ObservedResource } from './observe.ts';
|
|
15
16
|
import type { RemoteExecute, RemoteExecuteResponse } from './remote-execute.ts';
|
|
16
17
|
import { readRoot, type StateSystemAdapters } from './state-system.ts';
|
|
@@ -411,6 +412,7 @@ function performOf(units: Unit[]): PerformAction {
|
|
|
411
412
|
return async (execute: RemoteExecute, group: TwinAction[], ctx: PerformContext): Promise<PushOutcome> => {
|
|
412
413
|
await adoptAccount(units, execute, ctx);
|
|
413
414
|
const input = (group[0]!.input ?? {}) as Row;
|
|
415
|
+
if (object(input.git)) return pushOf(units, execute, group, ctx);
|
|
414
416
|
const unit = unitOf(units, input);
|
|
415
417
|
// a protocol one request cannot express is the pack's hook, which composes the derived perform ("Protocols a
|
|
416
418
|
// request cannot express")
|
|
@@ -426,6 +428,113 @@ function performOf(units: Unit[]): PerformAction {
|
|
|
426
428
|
};
|
|
427
429
|
}
|
|
428
430
|
|
|
431
|
+
// ── git: a root's repositories over git's own smart HTTP (architecture, "The real-system adapters"; ADR 0031) ──
|
|
432
|
+
|
|
433
|
+
type PushInput = { name: string; remote: string; ref: string; before: string; after: string };
|
|
434
|
+
const pushInputOf = (e: TwinAction): PushInput | undefined => object((e.input as Row | undefined)?.git) as PushInput | undefined;
|
|
435
|
+
|
|
436
|
+
/** A 4xx other than 408 and 429 from the vendor's git: a refusal, as a REST answer's is ("Perform", 3). */
|
|
437
|
+
class GitAnswer extends Error { constructor(readonly status: number, message: string) { super(message); } }
|
|
438
|
+
|
|
439
|
+
/** Git smart HTTP to a remote (the vendor's absolute git URL) over the root's executor, which applies the credential
|
|
440
|
+
* (HostRule.basic) and charges the vendor's budget. */
|
|
441
|
+
function gitTransport(execute: RemoteExecute, remote: string): SmartHttpTransport {
|
|
442
|
+
return async ({ service, body }) => {
|
|
443
|
+
const answer = await execute({
|
|
444
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
445
|
+
path: body === undefined ? `${remote}/info/refs?service=${service}` : `${remote}/${service}`,
|
|
446
|
+
headers: {
|
|
447
|
+
'user-agent': 'git/2.0 (volter-twin)',
|
|
448
|
+
accept: body === undefined ? `application/x-${service}-advertisement` : `application/x-${service}-result`,
|
|
449
|
+
...(body === undefined ? {} : { 'content-type': `application/x-${service}-request` }),
|
|
450
|
+
},
|
|
451
|
+
...(body === undefined ? {} : { body }),
|
|
452
|
+
responseType: 'bytes',
|
|
453
|
+
});
|
|
454
|
+
if (answer.status !== 200) throw new GitAnswer(answer.status, `${answer.status}: git ${service} at ${remote}${answer.body ? `: ${answer.body.slice(0, 300)}` : ''}`);
|
|
455
|
+
return answer.bodyBytes ?? new TextEncoder().encode(answer.body);
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** DEPLOY a receive-pack's group (its push rows, one per ref): each ref's latest target decided on its own, the rest
|
|
460
|
+
* sent in one push against the root's refs (atomic when the root offers it), never forced. Each entry settles as its
|
|
461
|
+
* ref did (`PushOutcome.refused`); every ref refused refuses the group. */
|
|
462
|
+
async function pushOf(units: Unit[], execute: RemoteExecute, group: TwinAction[], ctx: PerformContext): Promise<PushOutcome> {
|
|
463
|
+
const first = group[0]!;
|
|
464
|
+
const m = units.find((u) => u.manifest.git)?.manifest;
|
|
465
|
+
const pushes = group.map((e) => ({ entry: e, push: pushInputOf(e) }));
|
|
466
|
+
const refuseAll = (reason: string): never => { throw new RefusedWriteError('vendor', reason, first.id); };
|
|
467
|
+
if (!m || pushes.some((p) => p.push === undefined)) refuseAll('the push names no repository of the vendor\'s');
|
|
468
|
+
const { name, remote } = pushes[0]!.push!;
|
|
469
|
+
if (pushes.some((p) => p.push!.name !== name || p.push!.remote !== remote)) refuseAll('one push names one repository');
|
|
470
|
+
const { store } = gitRepositoryOf(m!, name, ctx.root);
|
|
471
|
+
const transport = gitTransport(execute, remote);
|
|
472
|
+
const answered = <T>(work: Promise<T>): Promise<T> => work.catch((error: unknown) => {
|
|
473
|
+
if (error instanceof GitAnswer && error.status >= 400 && error.status < 500 && error.status !== 408 && error.status !== 429) refuseAll(error.message);
|
|
474
|
+
throw error;
|
|
475
|
+
});
|
|
476
|
+
const root = await answered(lsRemote(transport, 'git-receive-pack'));
|
|
477
|
+
// each ref's latest target in the group, and why the root may not take it
|
|
478
|
+
const targets = new Map<string, string>();
|
|
479
|
+
for (const p of pushes) targets.set(p.push!.ref, p.push!.after);
|
|
480
|
+
const refused = new Map<string, string>();
|
|
481
|
+
const commands: Array<{ name: string; old: string; new: string }> = [];
|
|
482
|
+
for (const [ref, target] of targets) {
|
|
483
|
+
const old = root.refs.get(ref) ?? ZERO_SHA;
|
|
484
|
+
if (old === target) continue;
|
|
485
|
+
if (target !== ZERO_SHA && old !== ZERO_SHA) {
|
|
486
|
+
if (ref.startsWith('refs/tags/')) { refused.set(ref, 'the tag already exists on the root'); continue; }
|
|
487
|
+
if (!(await store.has(old))) { refused.set(ref, 'the root holds work this World has not fetched (fetch first)'); continue; }
|
|
488
|
+
if (!(await isAncestor(store, old, target))) { refused.set(ref, `not a fast-forward of the root's ${old.slice(0, 7)}`); continue; }
|
|
489
|
+
}
|
|
490
|
+
commands.push({ name: ref, old, new: target });
|
|
491
|
+
}
|
|
492
|
+
if (commands.length) {
|
|
493
|
+
const report = await answered(sendPack(transport, store, commands, [...new Set(root.refs.values())], root.caps.includes('atomic')));
|
|
494
|
+
for (const c of commands) { const reason = report.get(c.name); if (reason !== null) refused.set(c.name, `the root refused it: ${reason}`); }
|
|
495
|
+
}
|
|
496
|
+
const took = pushes.filter((p) => !refused.has(p.push!.ref));
|
|
497
|
+
if (!took.length) refuseAll([...refused].map(([ref, reason]) => `${ref}: ${reason}`).join('; '));
|
|
498
|
+
return {
|
|
499
|
+
externalId: '', actionId: took[0]!.entry.id,
|
|
500
|
+
...(refused.size ? { refused: pushes.filter((p) => refused.has(p.push!.ref)).map((p) => ({ actionId: p.entry.id, reason: `${p.push!.ref}: ${refused.get(p.push!.ref)}` })) } : {}),
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** A repository's refs a refresh will publish: the root's branches and tags, its HEAD, and the World's refs as they
|
|
505
|
+
* stood when the refresh read them. */
|
|
506
|
+
type StagedMirror = { m: DerivedManifest; name: string; theirs: Map<string, string>; head: string | null; held: Map<string, string>; heldHead: string };
|
|
507
|
+
|
|
508
|
+
/** REFRESH a repository's code, read: the root's advertisement and the objects of its branches and tags the World's
|
|
509
|
+
* repository lacks (immutable: kept as they arrive). Its refs are staged, published by `publishMirror` once every read of
|
|
510
|
+
* the refresh succeeded. */
|
|
511
|
+
async function readMirror(m: DerivedManifest, execute: RemoteExecute, root: string | undefined, name: string, remote: string): Promise<StagedMirror> {
|
|
512
|
+
const { store, refs } = gitRepositoryOf(m, name, root);
|
|
513
|
+
const transport = gitTransport(execute, remote);
|
|
514
|
+
const advertised = await lsRemote(transport, 'git-upload-pack');
|
|
515
|
+
const theirs = new Map([...advertised.refs].filter(([ref]) => mirrored(ref)));
|
|
516
|
+
const held = new Map(refs.list().map((r) => [r.name, r.sha]));
|
|
517
|
+
const wants: string[] = [];
|
|
518
|
+
for (const sha of new Set(theirs.values())) if (!(await store.has(sha))) wants.push(sha);
|
|
519
|
+
await fetchPack(transport, store, wants, [...new Set(held.values())]);
|
|
520
|
+
return { m, name, theirs, head: advertised.head, held, heldHead: refs.head() };
|
|
521
|
+
}
|
|
522
|
+
const mirrored = (ref: string): boolean => ref.startsWith('refs/heads/') || ref.startsWith('refs/tags/');
|
|
523
|
+
|
|
524
|
+
/** REFRESH a repository's code, published: by compare-and-set under the repository's ref lock, the lock receive-pack
|
|
525
|
+
* holds. A ref takes the root's value, or is deleted when the root holds it no more, only while it still holds what the
|
|
526
|
+
* refresh read and no offered push names it (read inside the lock); HEAD follows the root's only while unchanged. */
|
|
527
|
+
function publishMirror(service: string, root: string | undefined, staged: StagedMirror): void {
|
|
528
|
+
const { refs } = gitRepositoryOf(staged.m, staged.name, root);
|
|
529
|
+
refs.withLock(() => {
|
|
530
|
+
const waiting = new Set(settlingEntries(service, root).offered.map(pushInputOf).filter((p): p is PushInput => p?.name === staged.name).map((p) => p.ref));
|
|
531
|
+
const untouched = (ref: string): boolean => !waiting.has(ref) && (refs.get(ref) ?? undefined) === staged.held.get(ref);
|
|
532
|
+
for (const [ref, sha] of staged.theirs) if (untouched(ref)) refs.set(ref, sha);
|
|
533
|
+
for (const ref of staged.held.keys()) if (mirrored(ref) && !staged.theirs.has(ref) && untouched(ref)) refs.delete(ref);
|
|
534
|
+
if (staged.head?.startsWith('refs/heads/') && refs.head() === staged.heldHead) refs.setHead(staged.head);
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
429
538
|
/** Path parameters that name a subject by a field rather than its id, sent as the vendor's value of that field, read
|
|
430
539
|
* from the adopted landed copy ("Perform", step 1): a parent found by `parent.where` (a repository addressed as
|
|
431
540
|
* `{owner}/{repo}`), and a subject named by the number it counts per parent (`number`, an issue's `issue_number`).
|
|
@@ -583,8 +692,8 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
|
|
|
583
692
|
const sent = asked.length ? `${target}${target.includes('?') ? '&' : '?'}${new URLSearchParams(asked)}` : target;
|
|
584
693
|
const answer = await execute({ method: op.method.toUpperCase(), path: sentTo(m, sent, subject), ...onLane, headers: fixedHeaders(op) });
|
|
585
694
|
// a door's subject the vendor does not show the root (another bot's private chat) stays as the World holds it
|
|
586
|
-
if (!rooted.has(key) && answer.status >= 400 && answer.status !== 429) continue;
|
|
587
695
|
if (answer.status === 401) throw new Error(`401: the vendor refused the root's credential (${op.method.toUpperCase()} ${path}); nothing folded`);
|
|
696
|
+
if (!rooted.has(key) && answer.status >= 400 && answer.status !== 429) continue;
|
|
588
697
|
if (answer.status === 403) { unread.push({ type, path, status: 403 }); refused = true; break; }
|
|
589
698
|
if (answer.status === 429) throw new Error(`429: rate limited${answer.headers['retry-after'] ? `, retry after ${answer.headers['retry-after']}s` : ''}`);
|
|
590
699
|
read.push(String(subject.id));
|
|
@@ -592,7 +701,15 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
|
|
|
592
701
|
// the vendor's answer for a subject it no longer has: a 404, or the scope's `gone` (Telegram's 400 `chat not found`)
|
|
593
702
|
if (answer.status === 404 || (scope.gone && answer.status === scope.gone.status && refusalWords(m, parse(answer)).includes(scope.gone.says))) continue;
|
|
594
703
|
if (answer.status >= 400 || refusedIn(m, parse(answer))) throw new Error(`${m.vendor}: ${resource} refresh ${op.id} (${op.method.toUpperCase()} ${op.path}): ${answer.status}: ${refusalWords(m, parse(answer))}`);
|
|
595
|
-
const
|
|
704
|
+
const parsed = parse(answer);
|
|
705
|
+
let full: Row;
|
|
706
|
+
if (op.answers?.list) {
|
|
707
|
+
const items = itemsOf(op, parsed);
|
|
708
|
+
const matching = items.find((item) => (scopeIdOf(scope, item) ?? storedIdOf(decl, item)) === vendorId);
|
|
709
|
+
if (!matching && items.length) throw new Error(`${m.vendor}: ${resource} refresh ${op.id} omitted the requested subject; nothing folded`);
|
|
710
|
+
if (!matching) continue;
|
|
711
|
+
full = matching;
|
|
712
|
+
} else full = object(op.answers?.key ? at(parsed, op.answers.key) : parsed) ?? {};
|
|
596
713
|
const local = (m as { worldFields?: Record<string, Record<string, unknown>> }).worldFields?.[resource] ?? {};
|
|
597
714
|
const fields = Object.fromEntries(Object.entries(full).filter(([k]) => !(k in local)));
|
|
598
715
|
if (decl.parent) {
|
|
@@ -853,7 +970,17 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
|
|
|
853
970
|
}
|
|
854
971
|
}
|
|
855
972
|
}
|
|
973
|
+
// the code of each repository observed, read over git; its refs are published with the blobs, once every read succeeded
|
|
974
|
+
const mirrors: StagedMirror[] = [];
|
|
975
|
+
for (const { manifest: m } of units) {
|
|
976
|
+
if (!m.git) continue;
|
|
977
|
+
const { resource, remote } = m.git.repository;
|
|
978
|
+
for (const row of observed.filter((o) => o.type === storedType(m, resource) && o.deleted !== true)) {
|
|
979
|
+
mirrors.push(await readMirror(m, execute, opts.root, gitNameOf(m, row.id, opts.root), fillTemplate(remote, { ...row.fields, id: row.id })));
|
|
980
|
+
}
|
|
981
|
+
}
|
|
856
982
|
for (const download of downloads) await writeResourceBlob(service, download.key, download.bytes, opts.root);
|
|
983
|
+
for (const staged of mirrors) publishMirror(service, opts.root, staged);
|
|
857
984
|
adopt(service, opts.root, units, observed);
|
|
858
985
|
const report = observeResources(service, observed, { ...(opts.root !== undefined ? { root: opts.root } : {}), complete });
|
|
859
986
|
return unread.length ? { ...(report as object), unread } : report;
|
package/src/derived.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { parseExactJson } from './exact-json.ts';
|
|
2
2
|
// THE DERIVED DISPATCH — the generated wire's router (docs/contributing/architecture.md, "The derived
|
|
3
|
-
// pack"). A derived pack's surface (`src/generated/surface.gen.json`, written by
|
|
3
|
+
// pack"). A derived pack's surface (`src/generated/surface.gen.json`, written by `twin-standard derive`)
|
|
4
4
|
// names every operation the vendor publishes. This matches a request to one of them and hands it to its
|
|
5
5
|
// owner: the pack's semantics handler for that operationId when there is one, otherwise the pack's
|
|
6
6
|
// existing fetch, which keeps serving everything not yet moved (and every route outside the spec, such
|
|
@@ -24,7 +24,7 @@ export type DerivedOperation = {
|
|
|
24
24
|
headers?: Record<string, string | null>;
|
|
25
25
|
/** the status the vendor answers success with (201 for a create, 204 for an empty answer) */
|
|
26
26
|
successStatus?: number;
|
|
27
|
-
/** how the spec says the request body is encoded (
|
|
27
|
+
/** how the spec says the request body is encoded (the derivation: json, form, multipart, none) */
|
|
28
28
|
bodyEncoding?: string;
|
|
29
29
|
/** the success is documented with no content (a setting's update answered 200 and nothing else) */
|
|
30
30
|
emptySuccess?: boolean;
|
package/src/events.ts
CHANGED
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
// Imports stay free of side effects: the delivery's transport (Node's http, the World's egress policy) is loaded when a
|
|
8
8
|
// delivery is made, never when a browser client imports the kernel.
|
|
9
9
|
import { createHash, createHmac } from 'node:crypto';
|
|
10
|
+
import { resolve } from 'node:path';
|
|
10
11
|
import { applyTwinWrite, applyTwinWriteAtomic, twinResources } from './serve.ts';
|
|
11
12
|
import { equalSecrets as same, ksuidFrom } from './signing.ts';
|
|
12
13
|
import { deliveryTraceHeaders } from './trace-context.ts';
|
|
13
14
|
import { sendRecordedEvent } from './event-delivery.ts';
|
|
14
|
-
import { getActiveWorldStore, withWorldStore } from './world-store.ts';
|
|
15
|
+
import { getActiveWorldStore, withWorldStore, type WorldStore } from './world-store.ts';
|
|
15
16
|
import { worldPaths } from './storage.ts';
|
|
16
17
|
|
|
17
18
|
/** How the vendor signs a delivery. */
|
|
@@ -306,6 +307,26 @@ const worldTransport: EventTransport = async (url, body, headers) => {
|
|
|
306
307
|
try { return ((await appFetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body })) as { status: number }).status; } catch { return 0; }
|
|
307
308
|
};
|
|
308
309
|
|
|
310
|
+
const pendingDeliveries = new WeakMap<WorldStore, Map<string, Set<Promise<void>>>>();
|
|
311
|
+
|
|
312
|
+
function forgetDeliveries(store: WorldStore, root: string, deliveries: Iterable<Promise<void>>): void {
|
|
313
|
+
const roots = pendingDeliveries.get(store), pending = roots?.get(root);
|
|
314
|
+
if (!pending) return;
|
|
315
|
+
for (const delivery of deliveries) pending.delete(delivery);
|
|
316
|
+
if (!pending.size) roots!.delete(root);
|
|
317
|
+
if (!roots!.size) pendingDeliveries.delete(store);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Await already-scheduled first dispatches and consume their retained persistence failures. */
|
|
321
|
+
export async function settleEventDeliveries(root: string): Promise<void> {
|
|
322
|
+
const store = getActiveWorldStore(), path = resolve(root);
|
|
323
|
+
const deliveries = [...(pendingDeliveries.get(store)?.get(path) ?? [])];
|
|
324
|
+
const outcomes = await Promise.allSettled(deliveries);
|
|
325
|
+
forgetDeliveries(store, path, deliveries);
|
|
326
|
+
const failures = outcomes.flatMap((outcome) => outcome.status === 'rejected' ? [outcome.reason] : []);
|
|
327
|
+
if (failures.length) throw new AggregateError(failures, 'Declared-event dispatch failed');
|
|
328
|
+
}
|
|
329
|
+
|
|
309
330
|
/** Who watches signed packets before dispatch (a walk reading each as its receiver would). This is not evidence of
|
|
310
331
|
* transport or acceptance. Set and restored by the walk, as its World store is; unset, nothing watches. */
|
|
311
332
|
export type DeliveryObserver = (delivery: EventDelivery & { service: string }) => void;
|
|
@@ -321,13 +342,14 @@ export function setDeliveryObserver(observer: DeliveryObserver | undefined): Del
|
|
|
321
342
|
* (`unreachable`), or it did not answer in time (`timeout`). */
|
|
322
343
|
export type ApplicationAnswer = { status: number; headers: Record<string, string>; body: string; missed?: 'timeout' | 'unreachable' };
|
|
323
344
|
|
|
324
|
-
/** Who answers
|
|
345
|
+
/** Who answers a vendor's requests to the application before the World's application route does, given to a
|
|
325
346
|
* pack's fetch (`PackFetchOptions.application`) and belonging to that fetch alone: an answer it gives is the
|
|
326
347
|
* application's; `'unreachable'` is a question nobody answers (a walk's life, a sealed World, takes no other); undefined
|
|
327
348
|
* is a question it does not take, which goes on as any other does: the application route, the World's egress rule, the
|
|
328
349
|
* network. A walk's stand-in answers every question (its life's answers, else unreachable); a hosted World's router
|
|
329
350
|
* answers only what it can answer truthfully (WorldDoors.askInWorld) and leaves the rest. `bodyError` models a
|
|
330
|
-
* disconnect after the response headers; a caller needing the complete body receives no complete answer
|
|
351
|
+
* disconnect after the response headers; a caller needing the complete body receives no complete answer, while an
|
|
352
|
+
* event dispatch acknowledges the response status without reading its body. */
|
|
331
353
|
export type ApplicationStandIn = (url: string, init: RequestInit) => Promise<{ status: number; headers?: Record<string, string>; body: string; bodyError?: string } | 'unreachable' | undefined>;
|
|
332
354
|
|
|
333
355
|
/** A message the vendor sends the application's server and decides by its answer (Stripe's real-time authorization
|
|
@@ -367,10 +389,15 @@ export async function deliverEvents(
|
|
|
367
389
|
data: (type: string) => Promise<Record<string, unknown>>,
|
|
368
390
|
transport: EventTransport = worldTransport,
|
|
369
391
|
values?: (type: string) => Promise<Record<string, unknown>>,
|
|
392
|
+
application?: ApplicationStandIn,
|
|
370
393
|
): Promise<EventDelivery[]> {
|
|
371
394
|
if (write.storedType === decl.store) return [];
|
|
395
|
+
const dispatch: EventTransport = application ? async (url, body, headers) => {
|
|
396
|
+
const answer = await application(url, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body });
|
|
397
|
+
return answer === undefined ? transport(url, body, headers) : answer === 'unreachable' ? 0 : answer.status;
|
|
398
|
+
} : transport;
|
|
372
399
|
const sent: EventDelivery[] = [];
|
|
373
|
-
for (const type of eventTypesOf(decl, write.operation)) sent.push(...(await deliverEventType(service, decl, write, data,
|
|
400
|
+
for (const type of eventTypesOf(decl, write.operation)) sent.push(...(await deliverEventType(service, decl, write, data, dispatch, values, type)));
|
|
374
401
|
return sent;
|
|
375
402
|
}
|
|
376
403
|
|
|
@@ -466,10 +493,28 @@ async function deliverEventType(
|
|
|
466
493
|
// active before it runs. Transport completion writes to the same retained state.
|
|
467
494
|
const store = getActiveWorldStore(), deliveryRoot = worldPaths(service, write.root).root;
|
|
468
495
|
for (const d of sent) deliveryObserver?.({ ...d, service });
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
496
|
+
const roots = pendingDeliveries.get(store) ?? new Map<string, Set<Promise<void>>>();
|
|
497
|
+
pendingDeliveries.set(store, roots);
|
|
498
|
+
const pending = roots.get(deliveryRoot) ?? new Set<Promise<void>>();
|
|
499
|
+
roots.set(deliveryRoot, pending);
|
|
500
|
+
for (const packet of recorded) {
|
|
501
|
+
let finish!: () => void;
|
|
502
|
+
let fail!: (reason: unknown) => void;
|
|
503
|
+
const dispatched = new Promise<void>((done, reject) => { finish = done; fail = reject; });
|
|
504
|
+
// Live writes return before dispatch; an explicit settlement reports persistence failures.
|
|
505
|
+
void dispatched.catch(() => {});
|
|
506
|
+
pending.add(dispatched);
|
|
507
|
+
setTimeout(async () => {
|
|
508
|
+
try {
|
|
509
|
+
await withWorldStore(store, () => sendRecordedEvent(service, packet.type, packet.id, deliveryRoot, transport, trace));
|
|
510
|
+
finish();
|
|
511
|
+
forgetDeliveries(store, deliveryRoot, [dispatched]);
|
|
512
|
+
} catch (error) {
|
|
513
|
+
// Keep the rejection until explicit settlement consumes it. The queued/sending claim remains for
|
|
514
|
+
// native recovery; this must not fail the already-committed vendor write or cause a blind resend.
|
|
515
|
+
fail(error);
|
|
516
|
+
}
|
|
517
|
+
}, 0);
|
|
518
|
+
}
|
|
474
519
|
return sent;
|
|
475
520
|
}
|