@mesofact/runtime 0.8.29
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/README.md +19 -0
- package/dist/adapters/r2.d.ts +24 -0
- package/dist/adapters/r2.d.ts.map +1 -0
- package/dist/adapters/r2.js +136 -0
- package/dist/adapters/r2.js.map +1 -0
- package/dist/adapters/sqlite.d.ts +25 -0
- package/dist/adapters/sqlite.d.ts.map +1 -0
- package/dist/adapters/sqlite.js +131 -0
- package/dist/adapters/sqlite.js.map +1 -0
- package/dist/config.d.ts +29 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +139 -0
- package/dist/config.js.map +1 -0
- package/dist/contract.d.ts +40 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +5 -0
- package/dist/contract.js.map +1 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +40 -0
- package/dist/errors.js.map +1 -0
- package/dist/head.d.ts +32 -0
- package/dist/head.d.ts.map +1 -0
- package/dist/head.js +93 -0
- package/dist/head.js.map +1 -0
- package/dist/health.d.ts +14 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +85 -0
- package/dist/health.js.map +1 -0
- package/dist/hooks.d.ts +28 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +64 -0
- package/dist/hooks.js.map +1 -0
- package/dist/hydration.d.ts +6 -0
- package/dist/hydration.d.ts.map +1 -0
- package/dist/hydration.js +68 -0
- package/dist/hydration.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/manifest.d.ts +68 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +9 -0
- package/dist/manifest.js.map +1 -0
- package/dist/routes.d.ts +67 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +130 -0
- package/dist/routes.js.map +1 -0
- package/dist/source.d.ts +36 -0
- package/dist/source.d.ts.map +1 -0
- package/dist/source.js +52 -0
- package/dist/source.js.map +1 -0
- package/dist/track-ctx.d.ts +13 -0
- package/dist/track-ctx.d.ts.map +1 -0
- package/dist/track-ctx.js +15 -0
- package/dist/track-ctx.js.map +1 -0
- package/dist/validate.d.ts +20 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +333 -0
- package/dist/validate.js.map +1 -0
- package/package.json +40 -0
- package/src/adapters/r2.ts +163 -0
- package/src/adapters/sqlite.ts +182 -0
- package/src/config.ts +213 -0
- package/src/contract.ts +82 -0
- package/src/errors.ts +52 -0
- package/src/head.ts +130 -0
- package/src/health.ts +99 -0
- package/src/hooks.ts +72 -0
- package/src/hydration.ts +72 -0
- package/src/index.ts +113 -0
- package/src/manifest.ts +104 -0
- package/src/routes.ts +320 -0
- package/src/source.ts +91 -0
- package/src/track-ctx.ts +29 -0
- package/src/validate.ts +388 -0
package/src/routes.ts
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
//! @yah:relay(R015, "Render cube support — placement axis + SSR build path + lint (W173)")
|
|
2
|
+
//! @yah:at(2026-06-04T19:31:37Z)
|
|
3
|
+
//! @yah:status(open)
|
|
4
|
+
//! @yah:next("W173 lives in the yah parent camp at .yah/docs/working/W173-mesofact-render-cube.md (relative from mesofact root: ../../.yah/docs/working/W173-mesofact-render-cube.md). Read § 'v1 schema delta' and § 'SSR_PREFIXES derivation rule' before T1/T2.")
|
|
5
|
+
//! @yah:next("yah-side consumer relay is R434 in the parent camp — R434-F3 (mesofact-dev SSR subprocess), R434-F4 (pond reconciler ssr_runtime), R434-F5 (first SSR consumer route) all assume this relay ships first.")
|
|
6
|
+
//! @yah:next("Coordinate handoff via @mesofact/runtime version bump: yah-side consumes via packages/yah/workload-spec/index.ts and crates/yah/cloud/src/reconciler/mesofact_static.rs.")
|
|
7
|
+
//! @yah:next("Order: T1 (schema) → T2 (build path) → then T3 + T4 unblock once a real SSR consumer exists on the yah side (R434-F5).")
|
|
8
|
+
//!
|
|
9
|
+
//! @yah:ticket(R015-F1, "Add Placement axis + placement?: field to RouteEntry + defineRoutes validation")
|
|
10
|
+
//! @yah:assignee(agent:claude)
|
|
11
|
+
//! @yah:at(2026-06-04T19:32:10Z)
|
|
12
|
+
//! @yah:status(review)
|
|
13
|
+
//! @yah:phase(P1)
|
|
14
|
+
//! @yah:parent(R015)
|
|
15
|
+
//! @yah:next("Add `export type Placement = \"host\" | \"edge\" | \"auto\"` alongside the existing RouteMode at routes.ts:5.")
|
|
16
|
+
//! @yah:next("Add `placement?: Placement` to RouteEntry. ssr-only — reject (loud, at defineRoutes call site) on any non-\"ssr\" mode. Default \"auto\" → \"host\" today (auto-classifier deferred per W173).")
|
|
17
|
+
//! @yah:next("RouteMode \"ssr\" slot already exists in this file — do NOT regress it. Do not remove or rename existing fields (requires, source_reads, concurrency, prerender, cache_policy.negative_ttl/vary).")
|
|
18
|
+
//! @yah:next("Add a unit test that defineRoutes throws when placement is set on a static or spa route.")
|
|
19
|
+
//! @yah:next("Place where the build classifier eventually slots in: comment that `placement: \"auto\"` resolves to \"host\" until the auto-classifier (W173 § 'Future auto-classifier criteria') lands.")
|
|
20
|
+
//! @yah:verify("defineRoutes accepts every existing yah-side routes file (../../app/yah/web/marketing/mesofact.routes.ts and ../../app/yah/web/dashboard/mesofact.routes.ts) unchanged")
|
|
21
|
+
//! @yah:verify("defineRoutes throws on `mode:\"static\", placement:\"host\"` etc.")
|
|
22
|
+
//! @yah:verify("bun test passes for the new placement validation cases")
|
|
23
|
+
//! @yah:handoff("Placement axis shipped. Changes to packages/mesofact-runtime/src/routes.ts: added `export type Placement = \"host\" | \"edge\" | \"auto\"`; added `placement?: Placement` to RouteEntry; defineRoutes now throws when placement is set on a non-ssr route (loud, names the offending route, default left undefined — auto-resolution happens at build time per W173). Exported Placement from src/index.ts. New tests/routes.test.ts covers 8 cases (ssr+host/edge/auto/undefined accepted; static+placement and spa+placement rejected; error message names route; mixed-mode workload accepted). Verified: bun test → 57 pass across 7 files; tsc --noEmit clean for runtime + build + worker; existing yah-side route files (marketing 5, dashboard 7, yah-dev 3) still parse unchanged.")
|
|
24
|
+
//! @yah:verify("cd packages/mesofact-runtime && bun test — 57 pass")
|
|
25
|
+
//! @yah:verify("cd packages/mesofact-runtime && bun run typecheck — clean")
|
|
26
|
+
//! @yah:verify("cd packages/mesofact-build && bun run typecheck — clean")
|
|
27
|
+
//! @yah:verify("cd packages/mesofact-worker && bun run typecheck — clean")
|
|
28
|
+
|
|
29
|
+
// `mesofact.routes.ts` — user-authored route table. Build phase 2 reads this,
|
|
30
|
+
// phase 3 infers `source_reads`, phase 4 validates, phase 6 emits the manifest.
|
|
31
|
+
// See `.yah/docs/architecture/mesofact.md` §"Build pipeline".
|
|
32
|
+
|
|
33
|
+
import { HOOK_NAMES, HOOK_ROUTE_CLAIMS, type HooksConfig, isHookName } from "./hooks.js";
|
|
34
|
+
|
|
35
|
+
export type RouteMode = "static" | "ssr" | "spa";
|
|
36
|
+
|
|
37
|
+
// Where per-request SSR rendering runs. Only meaningful for `mode:"ssr"`;
|
|
38
|
+
// rejected at defineRoutes for static/spa. `"auto"` is the default — today
|
|
39
|
+
// it resolves to `"host"` at build time. A future auto-classifier may pick
|
|
40
|
+
// `"edge"` when criteria match (data-only sources, no host-only imports,
|
|
41
|
+
// cacheable). See W173 § "Future auto-classifier criteria".
|
|
42
|
+
export type Placement = "host" | "edge" | "auto";
|
|
43
|
+
|
|
44
|
+
export type Requires = "user" | "project" | "region";
|
|
45
|
+
|
|
46
|
+
export type CachePolicyConfig = {
|
|
47
|
+
ttl: number;
|
|
48
|
+
swr?: number;
|
|
49
|
+
negative_ttl?: number;
|
|
50
|
+
vary?: readonly string[];
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// ─── Resilience axis (W181) ────────────────────────────────────────────────
|
|
54
|
+
// Declarative retry / timeout applied at the always-up edge (CF Worker in
|
|
55
|
+
// prod, the mesofact-dev proxy in dev) around the SSR origin hop. Not a cube
|
|
56
|
+
// axis — it's the lifecycle wrapper around any `mode:"ssr"` cell. v1 ships
|
|
57
|
+
// retry + timeout only; `queue` is type-reserved but rejected at validation
|
|
58
|
+
// until v2 lands a real consumer (see W181 § "v1 scope").
|
|
59
|
+
|
|
60
|
+
export type RetryOn = "connection" | "5xx" | "any";
|
|
61
|
+
|
|
62
|
+
export type RetryPolicy = {
|
|
63
|
+
// Total attempts including the first; 1 = no retry.
|
|
64
|
+
attempts: number;
|
|
65
|
+
// Gap before attempt i+1; length must be `attempts - 1`.
|
|
66
|
+
backoff_ms: readonly number[];
|
|
67
|
+
// What failures trigger a retry. Default "connection" (ECONNREFUSED /
|
|
68
|
+
// fetch failure); "5xx" adds server errors; "any" adds everything non-2xx.
|
|
69
|
+
retry_on?: RetryOn;
|
|
70
|
+
// Total wall-clock cap across the request + all retries + backoffs. Must
|
|
71
|
+
// cover sum(backoff_ms) + attempts * per-attempt timeout when set.
|
|
72
|
+
budget_ms?: number;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Reserved for v2 — the schema slot exists so v1 routes don't break when the
|
|
76
|
+
// queue implementation lands, but `defineRoutes` rejects it today.
|
|
77
|
+
export type QueuePolicy = {
|
|
78
|
+
queue: string;
|
|
79
|
+
ack: "on_enqueue" | "on_origin_2xx";
|
|
80
|
+
max_delay_ms?: number;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export type ResiliencePolicy = {
|
|
84
|
+
retry?: RetryPolicy;
|
|
85
|
+
queue?: QueuePolicy;
|
|
86
|
+
// Per-attempt request timeout; default 30_000.
|
|
87
|
+
timeout_ms?: number;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Default per-attempt timeout used when `resilience.timeout_ms` is omitted.
|
|
91
|
+
export const DEFAULT_RESILIENCE_TIMEOUT_MS = 30_000;
|
|
92
|
+
|
|
93
|
+
// Literal param maps OR a source-derived query the publisher runs at build
|
|
94
|
+
// time. Mode 1 routes only; non-parametric Mode 1 routes omit it.
|
|
95
|
+
//
|
|
96
|
+
// Three shapes:
|
|
97
|
+
// - { params } literal list, used as-is
|
|
98
|
+
// - { from, query, param } registered source adapter (R2 BlobSource)
|
|
99
|
+
// walked at build time via async load
|
|
100
|
+
// - { from_data, items_key, param }
|
|
101
|
+
// local-JSON file already declared in the
|
|
102
|
+
// same route's `data_inputs`. Read
|
|
103
|
+
// synchronously, walked via `items_key` as
|
|
104
|
+
// a dotted/array path.
|
|
105
|
+
// - { deferred: true } params are minted after the build
|
|
106
|
+
// (publish time). The build emits the
|
|
107
|
+
// server bundle + manifest entry and
|
|
108
|
+
// prerenders nothing; instances are
|
|
109
|
+
// produced exclusively through the
|
|
110
|
+
// render-only entrypoint and served per
|
|
111
|
+
// instance (instance-addressed route).
|
|
112
|
+
// mode:"static" + parametric route only.
|
|
113
|
+
export type PrerenderConfig =
|
|
114
|
+
| { params: ReadonlyArray<Record<string, string>> }
|
|
115
|
+
| { from: string; query: string; param: string }
|
|
116
|
+
| { from_data: string; items_key: string; param: string }
|
|
117
|
+
| { deferred: true };
|
|
118
|
+
|
|
119
|
+
// ─── Adding a field here (R749-T1) ─────────────────────────────────────────
|
|
120
|
+
// Every field below that changes SERVING behaviour is checked at startup
|
|
121
|
+
// against what the serving tier says it implements
|
|
122
|
+
// (`crates/mesofact-core/src/policy.rs`). Declaring one a tier does not enforce
|
|
123
|
+
// refuses the start, naming the route and the field — never a warning, never a
|
|
124
|
+
// skip, because a policy nothing wired looks byte-identical to one that runs.
|
|
125
|
+
//
|
|
126
|
+
// So a new serving field costs two edits, not one: the type here, and either a
|
|
127
|
+
// `RoutePolicy` variant plus an enforcement point per tier, or a
|
|
128
|
+
// `STRUCTURAL_FIELDS` entry saying why ignoring it cannot lose behaviour. The
|
|
129
|
+
// completeness gate (`every_route_field_is_classified`) fails until one of the
|
|
130
|
+
// two exists, and until then the field refuses every manifest that carries it.
|
|
131
|
+
//
|
|
132
|
+
// A field with no consumer ANYWHERE follows `resilience.queue`: keep the type
|
|
133
|
+
// slot so future routes don't break, and reject it at `defineRoutes` today.
|
|
134
|
+
// Declaring something that does nothing is the defect, not the schema.
|
|
135
|
+
export type RouteEntry = {
|
|
136
|
+
route: string;
|
|
137
|
+
mode: RouteMode;
|
|
138
|
+
entrypoint: string;
|
|
139
|
+
// Mode 3 (spa) only — the browser hydration entry. Required for `spa`
|
|
140
|
+
// routes; the build bundles it (browser target, content-hashed, code-split)
|
|
141
|
+
// to `dist/hydrate/` and records the result in the manifest's `hydration`.
|
|
142
|
+
client_entrypoint?: string;
|
|
143
|
+
requires?: readonly Requires[];
|
|
144
|
+
// Usually inferred by the build's adapter-import analysis. Setting it here
|
|
145
|
+
// is an explicit override (e.g. third-party module re-exporting an adapter).
|
|
146
|
+
source_reads?: readonly string[];
|
|
147
|
+
// Paths (relative to project root) of JSON files read as build-time data.
|
|
148
|
+
// Parsed content is passed to render() as `req.data[path]`. Mode 1 only.
|
|
149
|
+
// When any listed file changes, the route should be rebuilt.
|
|
150
|
+
data_inputs?: readonly string[];
|
|
151
|
+
cache_policy: CachePolicyConfig;
|
|
152
|
+
concurrency?: number;
|
|
153
|
+
prerender?: PrerenderConfig;
|
|
154
|
+
// SSR-only: where per-request rendering runs. Default `"auto"` resolves to
|
|
155
|
+
// `"host"` until the W173 auto-classifier ships.
|
|
156
|
+
placement?: Placement;
|
|
157
|
+
// SSR-only: declarative retry/timeout applied at the always-up edge (W181).
|
|
158
|
+
// No block = exactly today's behavior (one attempt, 30s timeout, 502 on
|
|
159
|
+
// failure). Rejected on static/spa and on placement:"edge" (retry-the-
|
|
160
|
+
// Worker-from-the-Worker is circular; reserved until an edge consumer
|
|
161
|
+
// needs it — W181 OQ1).
|
|
162
|
+
resilience?: ResiliencePolicy;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
export type ErrorRoutes = {
|
|
166
|
+
"404"?: string;
|
|
167
|
+
"5xx"?: string;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
export type RoutesConfig = {
|
|
171
|
+
routes: readonly RouteEntry[];
|
|
172
|
+
error_routes?: ErrorRoutes;
|
|
173
|
+
// Mode 2 endpoint callbacks (W311 §2 / R756-F6). Hook name → entrypoint
|
|
174
|
+
// path. A hook is engine-addressed, not path-addressed: Rust decides when
|
|
175
|
+
// to call it and owns the HTTP around it, so it is declared here beside
|
|
176
|
+
// `routes` rather than inside one. See `hooks.ts` for the vocabulary and
|
|
177
|
+
// why this shape rather than a per-route `middleware` field.
|
|
178
|
+
hooks?: HooksConfig;
|
|
179
|
+
// Origin for the manifest-derived sitemap (e.g. "https://yah.dev"), no
|
|
180
|
+
// trailing path. When set, the build emits `dist/sitemap.xml` listing every
|
|
181
|
+
// enumerable static route instance; instance-addressed (deferred) routes and
|
|
182
|
+
// `noindex` renders are excluded (W270 §4 — unlisted-by-capability means no
|
|
183
|
+
// sitemap participation). Omit to skip sitemap emission entirely.
|
|
184
|
+
site_url?: string;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
export function defineRoutes(config: RoutesConfig): RoutesConfig {
|
|
188
|
+
if (config.site_url !== undefined && !/^https?:\/\/[^/]+/.test(config.site_url)) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`defineRoutes: site_url=${JSON.stringify(config.site_url)} must be an absolute origin like "https://yah.dev" (scheme + host, no trailing path) — the sitemap emitter joins it with each route's path`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
for (const r of config.routes) {
|
|
194
|
+
if (r.placement !== undefined && r.mode !== "ssr") {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`defineRoutes: route ${r.route} has placement=${JSON.stringify(r.placement)} but mode=${JSON.stringify(r.mode)}; placement is only valid on mode:"ssr"`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
if (r.prerender && "from_data" in r.prerender) {
|
|
200
|
+
const declared = r.data_inputs ?? [];
|
|
201
|
+
if (!declared.includes(r.prerender.from_data)) {
|
|
202
|
+
throw new Error(
|
|
203
|
+
`defineRoutes: route ${r.route} has prerender.from_data=${JSON.stringify(r.prerender.from_data)} but that path is not in data_inputs (${JSON.stringify(declared)}); declare the file in data_inputs first so the build reads it once`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (r.prerender && "deferred" in r.prerender) {
|
|
208
|
+
if (r.prerender.deferred !== true) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
`defineRoutes: route ${r.route} has prerender.deferred=${JSON.stringify(r.prerender.deferred)} — omit prerender (render once at build) or set deferred: true`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
if (r.mode !== "static") {
|
|
214
|
+
throw new Error(
|
|
215
|
+
`defineRoutes: route ${r.route} has prerender.deferred but mode=${JSON.stringify(r.mode)}; deferred (publish-time) params are only valid on mode:"static" — ssr renders per request, spa shells are not instance-addressed`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (!r.route.includes(":")) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`defineRoutes: route ${r.route} has prerender.deferred but no ":param" segment — a literal route has exactly one instance, rendered at build`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (r.resilience !== undefined) validateResilience(r);
|
|
225
|
+
}
|
|
226
|
+
if (config.hooks !== undefined) validateHooks(config);
|
|
227
|
+
return config;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Mode 2 hook declaration (R756-F6). Same fail-fast home as placement and
|
|
231
|
+
// resilience: throw at config import, before any bundling work.
|
|
232
|
+
function validateHooks(config: RoutesConfig): void {
|
|
233
|
+
const hooks = config.hooks!;
|
|
234
|
+
for (const [name, entrypoint] of Object.entries(hooks)) {
|
|
235
|
+
if (!isHookName(name)) {
|
|
236
|
+
throw new Error(
|
|
237
|
+
`defineRoutes: unknown hook ${JSON.stringify(name)} — known hooks are ${HOOK_NAMES.map(
|
|
238
|
+
(h) => JSON.stringify(h),
|
|
239
|
+
).join(", ")}. A hook name is engine-defined: only mesofact invokes hooks, so a name it ` +
|
|
240
|
+
`does not know would never be called.`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
if (typeof entrypoint !== "string" || entrypoint.trim() === "") {
|
|
244
|
+
throw new Error(
|
|
245
|
+
`defineRoutes: hooks.${name}=${JSON.stringify(entrypoint)} must be a non-empty entrypoint ` +
|
|
246
|
+
`path relative to the project root (e.g. "src/${name}.ts")`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
const claimed = HOOK_ROUTE_CLAIMS[name];
|
|
250
|
+
if (claimed !== undefined && config.routes.some((r) => r.route === claimed)) {
|
|
251
|
+
throw new Error(
|
|
252
|
+
`defineRoutes: hook ${JSON.stringify(name)} is declared twice — as hooks.${name} and by ` +
|
|
253
|
+
`claiming the route ${JSON.stringify(claimed)}. Both mean "this app contributes a ` +
|
|
254
|
+
`${name} verdict"; pick one. The hooks declaration is usually the one you want — the ` +
|
|
255
|
+
`module stays out of ssr_prefixes, so the edge never forwards ${claimed} to the SSR ` +
|
|
256
|
+
`origin and the Rust probe route never shadows it.`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const RETRY_ON = new Set<RetryOn>(["connection", "5xx", "any"]);
|
|
263
|
+
|
|
264
|
+
// W181 validation rules. Throws at defineRoutes time (fail fast at config
|
|
265
|
+
// import, before any bundling work — same home as placement rejection).
|
|
266
|
+
function validateResilience(r: RouteEntry): void {
|
|
267
|
+
const res = r.resilience!;
|
|
268
|
+
if (r.mode !== "ssr") {
|
|
269
|
+
throw new Error(
|
|
270
|
+
`defineRoutes: route ${r.route} declares resilience but mode=${JSON.stringify(r.mode)}; resilience is only valid on mode:"ssr" (the policy wraps the edge→origin proxy hop, which only exists for SSR routes)`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
if (r.placement === "edge") {
|
|
274
|
+
throw new Error(
|
|
275
|
+
`defineRoutes: route ${r.route} declares resilience on placement:"edge" — retrying the Worker from the Worker is circular (W181 OQ1); remove the block or use placement:"host"`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
if (res.queue !== undefined) {
|
|
279
|
+
throw new Error(
|
|
280
|
+
`defineRoutes: route ${r.route} declares resilience.queue — queue policy is reserved for v2 and not implemented yet (W181 § "v1 scope"); remove the block (the type slot exists so v1 routes won't break when v2 lands)`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
if (res.timeout_ms !== undefined && (!Number.isFinite(res.timeout_ms) || res.timeout_ms <= 0)) {
|
|
284
|
+
throw new Error(
|
|
285
|
+
`defineRoutes: route ${r.route} has resilience.timeout_ms=${String(res.timeout_ms)}; expected a positive number of milliseconds`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
const retry = res.retry;
|
|
289
|
+
if (retry === undefined) return;
|
|
290
|
+
if (!Number.isInteger(retry.attempts) || retry.attempts < 1) {
|
|
291
|
+
throw new Error(
|
|
292
|
+
`defineRoutes: route ${r.route} has resilience.retry.attempts=${String(retry.attempts)}; expected an integer >= 1 (1 = no retry)`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
if (!Array.isArray(retry.backoff_ms) || retry.backoff_ms.length !== retry.attempts - 1) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
`defineRoutes: route ${r.route} has resilience.retry.backoff_ms of length ${Array.isArray(retry.backoff_ms) ? retry.backoff_ms.length : "?"}; expected attempts - 1 = ${retry.attempts - 1} entries (one gap between each pair of attempts)`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
if (retry.backoff_ms.some((b) => !Number.isFinite(b) || b < 0)) {
|
|
301
|
+
throw new Error(
|
|
302
|
+
`defineRoutes: route ${r.route} has a negative or non-numeric resilience.retry.backoff_ms entry`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
if (retry.retry_on !== undefined && !RETRY_ON.has(retry.retry_on)) {
|
|
306
|
+
throw new Error(
|
|
307
|
+
`defineRoutes: route ${r.route} has resilience.retry.retry_on=${JSON.stringify(retry.retry_on)}; expected "connection" | "5xx" | "any"`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
if (retry.budget_ms !== undefined) {
|
|
311
|
+
const perAttempt = res.timeout_ms ?? DEFAULT_RESILIENCE_TIMEOUT_MS;
|
|
312
|
+
const backoffSum = retry.backoff_ms.reduce((a, b) => a + b, 0);
|
|
313
|
+
const floor = backoffSum + retry.attempts * perAttempt;
|
|
314
|
+
if (retry.budget_ms < floor) {
|
|
315
|
+
throw new Error(
|
|
316
|
+
`defineRoutes: route ${r.route} has resilience.retry.budget_ms=${retry.budget_ms} < ${floor} (sum(backoff_ms)=${backoffSum} + attempts=${retry.attempts} × per-attempt timeout=${perAttempt}); raise budget_ms or lower the attempt/timeout shape`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
package/src/source.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Adapter API surface. Read-only by design — mesofact has no write API.
|
|
2
|
+
// See `.yah/docs/architecture/mesofact.md` §"Adapter API surface".
|
|
3
|
+
//
|
|
4
|
+
// The design doc lists `get`/`query`/`fetch`/`list` on a single interface with
|
|
5
|
+
// per-backend annotations. We split them into BlobSource (r2) and
|
|
6
|
+
// KeyValueSource (sqlite/pg) so callers get type-safety on what they hold:
|
|
7
|
+
// `r2('assets').get(...)` is a type error, not a runtime trap.
|
|
8
|
+
|
|
9
|
+
import { currentTrackCtx } from "./track-ctx.js";
|
|
10
|
+
|
|
11
|
+
export type ListOpts = {
|
|
12
|
+
limit?: number;
|
|
13
|
+
cursor?: string;
|
|
14
|
+
delimiter?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type R2Object = {
|
|
18
|
+
key: string;
|
|
19
|
+
size: number;
|
|
20
|
+
last_modified: string;
|
|
21
|
+
etag?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Common shape every adapter exposes.
|
|
25
|
+
export interface Source {
|
|
26
|
+
readonly name: string;
|
|
27
|
+
|
|
28
|
+
// Skip read-set tracking for the next call (e.g. fast-changing flags that
|
|
29
|
+
// would over-purge Mode 1 HTML). The override resets after one read.
|
|
30
|
+
noTrack(): this;
|
|
31
|
+
|
|
32
|
+
// Override the next call's timeout. Defaults: sqlite 100ms, pg 500ms,
|
|
33
|
+
// r2 2000ms. The override resets after one read.
|
|
34
|
+
timeout(ms: number): this;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Blob backends (r2): byte payloads addressed by key or key prefix.
|
|
38
|
+
export interface BlobSource extends Source {
|
|
39
|
+
fetch(key: string): Promise<Uint8Array | null>;
|
|
40
|
+
list(prefix: string, opts?: ListOpts): Promise<R2Object[]>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Row backends (sqlite, pg): tabular reads by id or query.
|
|
44
|
+
export interface KeyValueSource extends Source {
|
|
45
|
+
get<T>(table: string, id: string): Promise<T | null>;
|
|
46
|
+
query<T>(sql: string, params?: unknown[]): Promise<T[]>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Shared impl for `.noTrack()` / `.timeout(ms)` and tag emission. Adapters
|
|
50
|
+
// extend this and implement the read methods their backend supports.
|
|
51
|
+
export abstract class BaseSource implements Source {
|
|
52
|
+
constructor(public readonly name: string) {}
|
|
53
|
+
|
|
54
|
+
noTrack(): this {
|
|
55
|
+
const ctx = currentTrackCtx();
|
|
56
|
+
if (ctx) ctx.next.track = false;
|
|
57
|
+
return this;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
timeout(ms: number): this {
|
|
61
|
+
const ctx = currentTrackCtx();
|
|
62
|
+
if (ctx) ctx.next.timeout_ms = ms;
|
|
63
|
+
return this;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Consume per-call overrides applied by the most recent `.noTrack()` /
|
|
67
|
+
// `.timeout(ms)` and return the effective settings for a single read. The
|
|
68
|
+
// override slot resets after this call, so two chained reads only carry the
|
|
69
|
+
// override on the first.
|
|
70
|
+
protected consumeOverrides(defaultTimeoutMs: number): {
|
|
71
|
+
track: boolean;
|
|
72
|
+
timeout_ms: number;
|
|
73
|
+
} {
|
|
74
|
+
const ctx = currentTrackCtx();
|
|
75
|
+
if (!ctx) return { track: true, timeout_ms: defaultTimeoutMs };
|
|
76
|
+
const effective = {
|
|
77
|
+
track: ctx.next.track,
|
|
78
|
+
timeout_ms: ctx.next.timeout_ms ?? defaultTimeoutMs,
|
|
79
|
+
};
|
|
80
|
+
ctx.next = { track: true };
|
|
81
|
+
return effective;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Add a read-set tag to the ambient trackCtx, unless tracking was disabled
|
|
85
|
+
// for this call or there is no ctx (e.g. test harness running render outside
|
|
86
|
+
// `runInTrackCtx`).
|
|
87
|
+
protected emitTag(tag: string, track: boolean): void {
|
|
88
|
+
if (!track) return;
|
|
89
|
+
currentTrackCtx()?.tags.add(tag);
|
|
90
|
+
}
|
|
91
|
+
}
|
package/src/track-ctx.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Per-render ambient context. Adapters in this package read it to register
|
|
2
|
+
// read-set tags and honor `.noTrack()` / `.timeout(ms)` overrides. The worker
|
|
3
|
+
// re-exports it for backward compatibility with R005's surface.
|
|
4
|
+
//
|
|
5
|
+
// See `.yah/docs/architecture/mesofact.md` §"Adapter read-set provenance".
|
|
6
|
+
|
|
7
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
8
|
+
|
|
9
|
+
export type TrackCtx = {
|
|
10
|
+
readonly tags: Set<string>;
|
|
11
|
+
// Per-call overrides toggled by `Source.noTrack()` / `.timeout(ms)`. The
|
|
12
|
+
// adapter consults these inside the same async chain and resets after the
|
|
13
|
+
// single read they apply to.
|
|
14
|
+
next: {
|
|
15
|
+
track: boolean;
|
|
16
|
+
timeout_ms?: number;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const storage = new AsyncLocalStorage<TrackCtx>();
|
|
21
|
+
|
|
22
|
+
export function runInTrackCtx<T>(fn: () => Promise<T>): Promise<{ value: T; ctx: TrackCtx }> {
|
|
23
|
+
const ctx: TrackCtx = { tags: new Set(), next: { track: true } };
|
|
24
|
+
return storage.run(ctx, async () => ({ value: await fn(), ctx }));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function currentTrackCtx(): TrackCtx | undefined {
|
|
28
|
+
return storage.getStore();
|
|
29
|
+
}
|