@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.
Files changed (77) hide show
  1. package/README.md +19 -0
  2. package/dist/adapters/r2.d.ts +24 -0
  3. package/dist/adapters/r2.d.ts.map +1 -0
  4. package/dist/adapters/r2.js +136 -0
  5. package/dist/adapters/r2.js.map +1 -0
  6. package/dist/adapters/sqlite.d.ts +25 -0
  7. package/dist/adapters/sqlite.d.ts.map +1 -0
  8. package/dist/adapters/sqlite.js +131 -0
  9. package/dist/adapters/sqlite.js.map +1 -0
  10. package/dist/config.d.ts +29 -0
  11. package/dist/config.d.ts.map +1 -0
  12. package/dist/config.js +139 -0
  13. package/dist/config.js.map +1 -0
  14. package/dist/contract.d.ts +40 -0
  15. package/dist/contract.d.ts.map +1 -0
  16. package/dist/contract.js +5 -0
  17. package/dist/contract.js.map +1 -0
  18. package/dist/errors.d.ts +29 -0
  19. package/dist/errors.d.ts.map +1 -0
  20. package/dist/errors.js +40 -0
  21. package/dist/errors.js.map +1 -0
  22. package/dist/head.d.ts +32 -0
  23. package/dist/head.d.ts.map +1 -0
  24. package/dist/head.js +93 -0
  25. package/dist/head.js.map +1 -0
  26. package/dist/health.d.ts +14 -0
  27. package/dist/health.d.ts.map +1 -0
  28. package/dist/health.js +85 -0
  29. package/dist/health.js.map +1 -0
  30. package/dist/hooks.d.ts +28 -0
  31. package/dist/hooks.d.ts.map +1 -0
  32. package/dist/hooks.js +64 -0
  33. package/dist/hooks.js.map +1 -0
  34. package/dist/hydration.d.ts +6 -0
  35. package/dist/hydration.d.ts.map +1 -0
  36. package/dist/hydration.js +68 -0
  37. package/dist/hydration.js.map +1 -0
  38. package/dist/index.d.ts +26 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +16 -0
  41. package/dist/index.js.map +1 -0
  42. package/dist/manifest.d.ts +68 -0
  43. package/dist/manifest.d.ts.map +1 -0
  44. package/dist/manifest.js +9 -0
  45. package/dist/manifest.js.map +1 -0
  46. package/dist/routes.d.ts +67 -0
  47. package/dist/routes.d.ts.map +1 -0
  48. package/dist/routes.js +130 -0
  49. package/dist/routes.js.map +1 -0
  50. package/dist/source.d.ts +36 -0
  51. package/dist/source.d.ts.map +1 -0
  52. package/dist/source.js +52 -0
  53. package/dist/source.js.map +1 -0
  54. package/dist/track-ctx.d.ts +13 -0
  55. package/dist/track-ctx.d.ts.map +1 -0
  56. package/dist/track-ctx.js +15 -0
  57. package/dist/track-ctx.js.map +1 -0
  58. package/dist/validate.d.ts +20 -0
  59. package/dist/validate.d.ts.map +1 -0
  60. package/dist/validate.js +333 -0
  61. package/dist/validate.js.map +1 -0
  62. package/package.json +40 -0
  63. package/src/adapters/r2.ts +163 -0
  64. package/src/adapters/sqlite.ts +182 -0
  65. package/src/config.ts +213 -0
  66. package/src/contract.ts +82 -0
  67. package/src/errors.ts +52 -0
  68. package/src/head.ts +130 -0
  69. package/src/health.ts +99 -0
  70. package/src/hooks.ts +72 -0
  71. package/src/hydration.ts +72 -0
  72. package/src/index.ts +113 -0
  73. package/src/manifest.ts +104 -0
  74. package/src/routes.ts +320 -0
  75. package/src/source.ts +91 -0
  76. package/src/track-ctx.ts +29 -0
  77. 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
+ }
@@ -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
+ }