@happyvertical/smrt-core 0.37.11 → 0.38.1

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.
@@ -1,3 +1,4 @@
1
+ import { resolveDispatchTenantScope } from "../dispatch/tenant-resolver.js";
1
2
  import { createHash } from "node:crypto";
2
3
  //#region src/generators/conditional-get.ts
3
4
  /**
@@ -154,14 +155,153 @@ function conditionalJsonResponse(request, payload, cacheControl) {
154
155
  });
155
156
  }
156
157
  /**
157
- * Emit the conditional-GET helper inlined into generated SvelteKit route
158
- * files, following the generator's existing inline-helper convention
159
- * (auth guard, tenant context, writable policy). The Cache-Control policy is
160
- * resolved at generation time from the object's `@smrt({ api })` config plus
161
- * the model's tenant scoping, and baked in as a constant.
162
- *
163
- * Kept textually in lockstep with the runtime helpers above the `.spec`
164
- * suite drives both through the same HTTP semantics.
158
+ * Compute the strong ETag for a generated read from the table's change-feed
159
+ * version and the request representation.
160
+ *
161
+ * Keying the ETag on the representation as well as the version is what keeps
162
+ * two different reads of the SAME table from colliding: `?limit=10` and
163
+ * `?limit=20` share a table version but produce different ETags, so a client
164
+ * caching one can never be wrongly answered `304` for the other. Any write to
165
+ * the table advances its version (see {@link getTableVersion}) and therefore
166
+ * every representation's ETag.
167
+ *
168
+ * The `version:representation` join is injective because `version` is a
169
+ * non-negative integer with no `:` — the first colon unambiguously delimits it
170
+ * from the representation, so `(1, ':x')` and `(1, 'x')` never collide.
171
+ * Deterministic and carrying no per-process state, so it is replica-stable.
172
+ */
173
+ function computeTableVersionEtag(version, representation) {
174
+ return `"${createHash("sha256").update(`${version}:${representation}`).digest("base64url")}"`;
175
+ }
176
+ /**
177
+ * Build a canonical, order-independent representation string for a read
178
+ * request: the URL path plus its query parameters sorted by name, and an
179
+ * optional extra discriminator (e.g. the resolved tenant scope) folded in.
180
+ *
181
+ * Two requests that must return the same body produce the same string (so they
182
+ * share an ETag and revalidate cheaply); any difference that changes the body —
183
+ * a different path, a different filter/limit/offset, or a different tenant —
184
+ * produces a different string and therefore a different ETag.
185
+ *
186
+ * Sorting is by parameter NAME only (a stable sort, so repeated keys keep their
187
+ * original relative order). Sorting by value too would make `?limit=10&limit=20`
188
+ * and `?limit=20&limit=10` canonicalize identically, yet the generated handlers
189
+ * read `searchParams.get('limit')` (the FIRST value) — different reads that must
190
+ * not share an ETag. Name-only sorting keeps different orderings of the same
191
+ * keys distinct while still making `?a=1&b=2` and `?b=2&a=1` equivalent.
192
+ *
193
+ * Names, values, and the extra discriminator are percent-ENCODED before being
194
+ * joined — the `searchParams` entries arrive already decoded, so re-joining them
195
+ * raw with `&`/`=`/`|` would let a value containing those characters collide with
196
+ * a structurally different request (`?q=a%26b=c` vs `?q=a&b=c` both decode-then-
197
+ * rejoin to `q=a&b=c`), a false-304 vector. Encoding makes the string injective.
198
+ */
199
+ function canonicalReadRepresentation(request, extra) {
200
+ const url = new URL(request.url);
201
+ const search = [...url.searchParams.entries()].sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0).map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join("&");
202
+ return `${url.pathname}?${search}${extra ? `|${encodeURIComponent(extra)}` : ""}`;
203
+ }
204
+ /**
205
+ * The active tenant folded into a read's ETag representation, or `undefined`
206
+ * when tenancy is not being enforced.
207
+ *
208
+ * This closes a cross-tenant hole specific to per-table version ETags: the
209
+ * table version spans all tenants, so without a tenant component two tenants —
210
+ * or one client switching tenants — would compute the SAME ETag for the same
211
+ * URL. Since tenant-scoped reads are `private, no-cache` (never shared-cached
212
+ * but still browser-cached), a client that viewed tenant A and then switched to
213
+ * tenant B could revalidate B's request with A's cached validator and be
214
+ * wrongly served A's rows from its own cache. Keying the ETag on the active
215
+ * tenant makes A's and B's validators distinct, so the switch forces a fresh
216
+ * `200`. Mirrors the fail-closed dispatch rule: enforced with no context →
217
+ * `global`, so a missing context never collides with a real tenant.
218
+ */
219
+ function resolveTenantEtagDiscriminator() {
220
+ const scope = resolveDispatchTenantScope();
221
+ if (!scope.enforced) return void 0;
222
+ return `t:${scope.tenantId ?? "global"}`;
223
+ }
224
+ /**
225
+ * Whether an `If-None-Match` header carries a CONCRETE ETag match — a specific
226
+ * quoted tag equal to `etag` — as opposed to the wildcard `*`.
227
+ *
228
+ * The version fast-path uses this rather than {@link ifNoneMatchSatisfied}
229
+ * because `*` matches unconditionally: per RFC 9110 `*` is satisfied only when a
230
+ * current representation EXISTS, which the pre-query fast-path cannot know. A
231
+ * concrete match, by contrast, can only be held by a client that received it
232
+ * from a prior `200` — and any delete of that row advances the table version,
233
+ * so the concrete ETag would no longer match — making a `304` without the query
234
+ * safe. `*` is deferred until existence is confirmed (see
235
+ * {@link versionConditionalResponse}).
236
+ */
237
+ function ifNoneMatchHasConcreteMatch(header, etag) {
238
+ if (!header) return false;
239
+ return header.split(",").some((candidate) => {
240
+ const tag = candidate.trim();
241
+ if (tag === "*") return false;
242
+ return (tag.startsWith("W/") ? tag.slice(2) : tag) === etag;
243
+ });
244
+ }
245
+ /**
246
+ * Build a generated read response from a precomputed version ETag, skipping the
247
+ * query on a conditional hit (#1765).
248
+ *
249
+ * A CONCRETE `If-None-Match` match returns `304 Not Modified` with an empty body
250
+ * and **never invokes `buildPayload`** — the collection query does not run,
251
+ * which is the point of ETag v2. Otherwise `buildPayload` runs; if it succeeds
252
+ * (a current representation therefore exists) a wildcard `If-None-Match: *` is
253
+ * honored with a `304` — deferring `*` past the build is what stops a
254
+ * `304` from being returned for a row that no longer exists (a `buildPayload`
255
+ * that throws, e.g. a `404` for a missing item, propagates and is never a 304).
256
+ * Mirrors {@link conditionalJsonResponse}'s response shape and header policy.
257
+ */
258
+ async function versionConditionalResponse(request, etag, cacheControl, buildPayload) {
259
+ const notModified = () => new Response(null, {
260
+ status: 304,
261
+ headers: {
262
+ "Cache-Control": cacheControl,
263
+ ETag: etag
264
+ }
265
+ });
266
+ const ifNoneMatch = request.headers.get("if-none-match");
267
+ if (ifNoneMatchHasConcreteMatch(ifNoneMatch, etag)) return notModified();
268
+ const payload = await buildPayload();
269
+ if (ifNoneMatchSatisfied(ifNoneMatch, etag)) return notModified();
270
+ return new Response(JSON.stringify(payload), {
271
+ status: 200,
272
+ headers: {
273
+ "Cache-Control": cacheControl,
274
+ "Content-Type": "application/json",
275
+ ETag: etag
276
+ }
277
+ });
278
+ }
279
+ /**
280
+ * Emit the conditional-GET helper inlined into generated SvelteKit route files,
281
+ * following the generator's existing inline-helper convention (auth guard,
282
+ * tenant context, writable policy). The Cache-Control policy is resolved at
283
+ * generation time from the object's `@smrt({ api })` config plus tenant scoping
284
+ * and baked in as a constant.
285
+ *
286
+ * Two shapes, chosen per route by `useBodyHash`:
287
+ * - **v2 (default, #1765)** — `conditionalVersionedRead(request, db, tableName,
288
+ * buildPayload)` derives the ETag from the table's change-feed version
289
+ * ({@link getTableVersion}) keyed by the request representation, so a concrete
290
+ * `If-None-Match` returns a `304` and `buildPayload` — the collection query —
291
+ * never runs. Imports its primitives from `@happyvertical/smrt-core` (the
292
+ * version lookup is dialect-aware SQL that cannot be inlined portably),
293
+ * mirroring the generated `_changes` route. Correct only when the payload is a
294
+ * pure function of the base table — the `toPublicJSON` path.
295
+ * - **v1 (#1757, `useBodyHash`)** — the inlined body-hash `conditionalJson`,
296
+ * used where a custom serializer can pull in related tables the base-table
297
+ * version can't see (see {@link ConditionalGetRouteHelperOptions.useBodyHash}).
298
+ *
299
+ * For tenant-scoped models the v2 representation folds in the active tenant
300
+ * ({@link resolveTenantEtagDiscriminator}) so one tenant's cached validator
301
+ * never satisfies another's read of the same URL — the cross-tenant false-304
302
+ * guard. The v2 runtime behavior is exercised end to end (query observation, 304
303
+ * without a query, mutation bumps the version) by the REST `conditional-get.spec`
304
+ * over the SAME core primitives this route calls.
165
305
  */
166
306
  function generateConditionalGetRouteHelper(apiConfig, options = {}) {
167
307
  const cacheControl = resolveReadCacheControl(apiConfig, options);
@@ -169,10 +309,11 @@ function generateConditionalGetRouteHelper(apiConfig, options = {}) {
169
309
  warnIfSharedCacheNeutralized(options.modelName, apiConfig, options.tenantScoped === true);
170
310
  warnIfTenantScopedPublicRead(options.modelName, apiConfig, options.tenantScoped === true);
171
311
  }
172
- return `
173
- // Conditional GET (#1757): strong body-hash ETag + If-None-Match → 304 with an
174
- // empty body. Reads stay private unless the model is public AND opts into
175
- // shared caching via @smrt({ api: { cache: { sMaxage } } }).
312
+ if (options.useBodyHash) return `
313
+ // Conditional GET (#1757 v1): a strong body-hash ETag over the serialized
314
+ // response used where a custom serializer can render data from related tables
315
+ // that the per-table change-feed version cannot observe, so the ETag must cover
316
+ // the whole rendered body.
176
317
  import { createHash } from 'node:crypto';
177
318
 
178
319
  const READ_CACHE_CONTROL = '${cacheControl}';
@@ -209,9 +350,65 @@ function conditionalJson(request: Request, payload: unknown): Response {
209
350
  },
210
351
  });
211
352
  }
353
+ `;
354
+ const tenantScoped = options.tenantScoped === true;
355
+ return `
356
+ // Conditional GET (#1765): the ETag is the table's change-feed version keyed by
357
+ // the request representation, so a CONCRETE If-None-Match returns 304 BEFORE the
358
+ // collection query runs. A wildcard \`*\` is honored only after the payload builds
359
+ // (existence confirmed), so a 304 is never returned for a missing row. Reads stay
360
+ // private unless the model is public AND opts into shared caching via
361
+ // @smrt({ api: { cache: { sMaxage } } }).
362
+ import {
363
+ ${[
364
+ "canonicalReadRepresentation",
365
+ "computeTableVersionEtag",
366
+ "getTableVersion",
367
+ "ifNoneMatchHasConcreteMatch",
368
+ "ifNoneMatchSatisfied",
369
+ ...tenantScoped ? ["resolveTenantEtagDiscriminator"] : []
370
+ ].join(",\n ")},
371
+ } from '@happyvertical/smrt-core';
372
+
373
+ const READ_CACHE_CONTROL = '${cacheControl}';
374
+
375
+ async function conditionalVersionedRead(
376
+ request: Request,
377
+ db: Parameters<typeof getTableVersion>[0],
378
+ tableName: string,
379
+ buildPayload: () => Promise<unknown>,
380
+ ): Promise<Response> {
381
+ const version = await getTableVersion(db, tableName);
382
+ const etag = computeTableVersionEtag(
383
+ version,
384
+ canonicalReadRepresentation(request, ${tenantScoped ? "resolveTenantEtagDiscriminator()" : "undefined"}),
385
+ );
386
+ const ifNoneMatch = request.headers.get('if-none-match');
387
+ const notModified = () =>
388
+ new Response(null, {
389
+ status: 304,
390
+ headers: { 'cache-control': READ_CACHE_CONTROL, etag },
391
+ });
392
+ if (ifNoneMatchHasConcreteMatch(ifNoneMatch, etag)) {
393
+ return notModified();
394
+ }
395
+ const payload = await buildPayload();
396
+ // Existence confirmed by a successful build → honor a wildcard \`*\` now.
397
+ if (ifNoneMatchSatisfied(ifNoneMatch, etag)) {
398
+ return notModified();
399
+ }
400
+ return new Response(JSON.stringify(payload), {
401
+ status: 200,
402
+ headers: {
403
+ 'cache-control': READ_CACHE_CONTROL,
404
+ 'content-type': 'application/json',
405
+ etag,
406
+ },
407
+ });
408
+ }
212
409
  `;
213
410
  }
214
411
  //#endregion
215
- export { PRIVATE_READ_CACHE_CONTROL, computeBodyEtag, conditionalJsonResponse, generateConditionalGetRouteHelper, ifNoneMatchSatisfied, resolveReadCacheControl, warnIfSharedCacheNeutralized, warnIfTenantScopedPublicRead };
412
+ export { PRIVATE_READ_CACHE_CONTROL, canonicalReadRepresentation, computeBodyEtag, computeTableVersionEtag, conditionalJsonResponse, generateConditionalGetRouteHelper, ifNoneMatchHasConcreteMatch, ifNoneMatchSatisfied, resolveReadCacheControl, resolveTenantEtagDiscriminator, versionConditionalResponse, warnIfSharedCacheNeutralized, warnIfTenantScopedPublicRead };
216
413
 
217
414
  //# sourceMappingURL=conditional-get.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"conditional-get.js","names":[],"sources":["../../src/generators/conditional-get.ts"],"sourcesContent":["/**\n * Conditional GET v1 for generated read routes (#1757).\n *\n * Generated `list`/`get` responses carry a strong ETag computed from the\n * serialized JSON body, and a matching `If-None-Match` answers\n * `304 Not Modified` with an empty body. v1 deliberately still runs the query\n * — the win is transfer, parse, and re-render, not the database round trip\n * (a later slice upgrades the ETag source to the change-feed table version).\n *\n * Cache-Control policy (fail-private, mirroring the #1540 posture):\n * - Default reads: `private, no-cache` — responses may be stored by the\n * browser but MUST be revalidated before reuse, and shared caches never\n * store them.\n * - `@smrt({ api: { public: true | 'read', cache: { sMaxage } } })` reads:\n * `public, max-age=0, s-maxage=<n>` — CDNs/shared caches may serve the\n * response for `n` seconds while browsers still revalidate (cheap 304s).\n * Models without the public flag NEVER emit shared-cache headers, even when\n * `cache.sMaxage` is configured.\n * - Tenant-scoped models (`@smrt({ tenantScoped })` / `@TenantScoped()`, any\n * mode) NEVER emit shared-cache headers: their bodies vary with the tenant\n * context, which URL-keyed shared caches cannot see. `sMaxage` is ignored\n * with a one-time warning.\n *\n * Consumed by both the runtime REST generator (`./rest.ts`) and — as an\n * emitted code snippet — the SvelteKit route generator\n * (`../vite-plugin/sveltekit-generator.ts`). Keeping every piece here keeps\n * the two generators' diffs minimal and the policy in one place.\n */\n\nimport { createHash } from 'node:crypto';\n\n/** Default Cache-Control for generated reads: private conditional revalidation. */\nexport const PRIVATE_READ_CACHE_CONTROL = 'private, no-cache';\n\n/**\n * Compute the strong ETag for a serialized response body.\n *\n * SHA-256 of the exact JSON text, base64url-encoded and quoted per RFC 9110.\n * Deterministic for a given body, so any change to the underlying data (which\n * changes the serialized JSON) changes the ETag.\n */\nexport function computeBodyEtag(body: string): string {\n return `\"${createHash('sha256').update(body).digest('base64url')}\"`;\n}\n\n/**\n * Whether an `If-None-Match` request header matches the response ETag.\n *\n * Implements RFC 9110 §13.1.2 weak comparison: `*` matches anything, the\n * header may carry a comma-separated list, and a `W/` prefix is ignored.\n */\nexport function ifNoneMatchSatisfied(\n header: string | null | undefined,\n etag: string,\n): boolean {\n if (!header) return false;\n if (header.trim() === '*') return true;\n return header.split(',').some((candidate) => {\n const tag = candidate.trim();\n const opaque = tag.startsWith('W/') ? tag.slice(2) : tag;\n return opaque === etag;\n });\n}\n\ninterface ApiCacheShape {\n cache?: { sMaxage?: unknown };\n public?: unknown;\n}\n\n/** Model-level context that constrains the cache policy beyond `api` config. */\nexport interface ReadCacheControlOptions {\n /**\n * Whether the model is tenant-scoped (`@smrt({ tenantScoped })` or the\n * `@TenantScoped()` decorator, ANY mode including `'optional'`). Tenant\n * scoping keys the response body on request identity (session cookie), which\n * shared caches cannot see — they key on the URL alone — so honoring\n * `sMaxage` would serve one tenant's rows to other tenants or to anonymous\n * visitors. Fail-closed: tenant-scoped models NEVER emit shared-cache\n * headers (#1757 review finding).\n */\n tenantScoped?: boolean;\n}\n\n/**\n * The shared Cache-Control string the `api` config asks for, or null when the\n * config does not (validly) opt into shared caching. Config-only — the\n * tenant-scoped restriction is applied by `resolveReadCacheControl`.\n */\nfunction requestedSharedCacheControl(apiConfig: unknown): string | null {\n if (!apiConfig || typeof apiConfig !== 'object') {\n return null;\n }\n\n const config = apiConfig as ApiCacheShape;\n const publicRead = config.public === true || config.public === 'read';\n const sMaxage = config.cache?.sMaxage;\n\n if (\n publicRead &&\n typeof sMaxage === 'number' &&\n Number.isFinite(sMaxage) &&\n sMaxage > 0\n ) {\n // Shared caches serve for sMaxage seconds; browsers (max-age=0) always\n // revalidate, so end users see edits immediately via cheap 304s.\n return `public, max-age=0, s-maxage=${Math.floor(sMaxage)}`;\n }\n\n return null;\n}\n\n/**\n * Resolve the Cache-Control header for a generated read response from a\n * model's `@smrt({ api })` config (defensively typed — the config arrives as\n * `unknown` from the registry at runtime and from the manifest at build time).\n *\n * Only models that opted out of auth via `public: true` (or `'read'`, which\n * makes reads public) may emit shared-cache headers, and only when they also\n * configure a positive `cache.sMaxage`. Everything else — including a\n * non-public model that configures `sMaxage` — stays `private, no-cache`.\n *\n * Tenant-scoped models are ALWAYS `private, no-cache` regardless of config:\n * their response bodies vary with the tenant context (resolved from session\n * cookies, invisible to URL-keyed shared caches), so shared caching would\n * leak one tenant's rows to other tenants or anonymous visitors.\n */\nexport function resolveReadCacheControl(\n apiConfig: unknown,\n options: ReadCacheControlOptions = {},\n): string {\n if (options.tenantScoped) {\n return PRIVATE_READ_CACHE_CONTROL;\n }\n\n return requestedSharedCacheControl(apiConfig) ?? PRIVATE_READ_CACHE_CONTROL;\n}\n\n/** Whether an `api` config opts reads out of auth (`public: true | 'read'`). */\nfunction isPublicRead(apiConfig: unknown): boolean {\n if (!apiConfig || typeof apiConfig !== 'object') {\n return false;\n }\n const value = (apiConfig as ApiCacheShape).public;\n return value === true || value === 'read';\n}\n\n// One warning per model — both transports resolve the same model repeatedly\n// (per route template at generation time, per request at runtime).\nconst sharedCacheNeutralizedWarned = new Set<string>();\n\n// One warning per model for the tenant-scoped + public-read combination (#1782).\nconst tenantScopedPublicReadWarned = new Set<string>();\n\n/**\n * Warn (once per model) when a tenant-scoped model is also marked publicly\n * readable (`@smrt({ api: { public: true | 'read' } })`).\n *\n * Anonymous / no-tenant-context reads on such a model fail closed to NULL-tenant\n * (global) rows only (#1782): they never expose any tenant's rows. That is the\n * intended, safe behavior, but silently it reads as \"the public endpoint returns\n * nothing\" — so surface the combination and its consequence at generation /\n * serve time. Called from both the REST runtime and the SvelteKit route\n * generator so the message appears wherever the model is exposed.\n */\nexport function warnIfTenantScopedPublicRead(\n modelName: string,\n apiConfig: unknown,\n tenantScoped: boolean,\n): void {\n if (!tenantScoped) return;\n if (!isPublicRead(apiConfig)) return;\n if (tenantScopedPublicReadWarned.has(modelName)) return;\n tenantScopedPublicReadWarned.add(modelName);\n console.warn(\n `[smrt] tenant-scoped model ${modelName} is marked api.public — ` +\n 'anonymous reads with no tenant context return NULL-tenant (global) ' +\n 'rows ONLY, never any tenant’s rows (fail-closed, #1782). Resolve a ' +\n 'tenant from the request (host/subdomain/session) if per-tenant public ' +\n 'reads are intended.',\n );\n}\n\n/**\n * Warn (once per model) when a tenant-scoped model configures\n * `api.cache.sMaxage`: the knob is deliberately neutralized to private\n * caching, and silently ignoring it would leave developers wondering why no\n * CDN caching happens. Called from both the REST runtime and the SvelteKit\n * route generator so the message surfaces wherever the model is served.\n */\nexport function warnIfSharedCacheNeutralized(\n modelName: string,\n apiConfig: unknown,\n tenantScoped: boolean,\n): void {\n if (!tenantScoped) return;\n if (requestedSharedCacheControl(apiConfig) === null) return;\n if (sharedCacheNeutralizedWarned.has(modelName)) return;\n sharedCacheNeutralizedWarned.add(modelName);\n console.warn(\n `[smrt] api.cache.sMaxage ignored for tenant-scoped model ${modelName}: ` +\n 'shared caches cannot key on tenant context — serving ' +\n `'${PRIVATE_READ_CACHE_CONTROL}' instead (#1757).`,\n );\n}\n\n/**\n * Build the JSON response for a generated read, honoring `If-None-Match`.\n *\n * Returns `304 Not Modified` with an EMPTY body when the request's\n * `If-None-Match` matches the body ETag; otherwise a 200 with the serialized\n * payload. Both carry the ETag and the resolved Cache-Control so clients can\n * revalidate the representation they hold.\n */\nexport function conditionalJsonResponse(\n request: Request,\n payload: unknown,\n cacheControl: string,\n): Response {\n const body = JSON.stringify(payload);\n const etag = computeBodyEtag(body);\n\n if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) {\n return new Response(null, {\n status: 304,\n headers: {\n 'Cache-Control': cacheControl,\n ETag: etag,\n },\n });\n }\n\n return new Response(body, {\n status: 200,\n headers: {\n 'Cache-Control': cacheControl,\n 'Content-Type': 'application/json',\n ETag: etag,\n },\n });\n}\n\n/** Generation-time context for the emitted SvelteKit route helper. */\nexport interface ConditionalGetRouteHelperOptions\n extends ReadCacheControlOptions {\n /** Model name used for the one-time sMaxage-neutralized warning. */\n modelName?: string;\n}\n\n/**\n * Emit the conditional-GET helper inlined into generated SvelteKit route\n * files, following the generator's existing inline-helper convention\n * (auth guard, tenant context, writable policy). The Cache-Control policy is\n * resolved at generation time from the object's `@smrt({ api })` config plus\n * the model's tenant scoping, and baked in as a constant.\n *\n * Kept textually in lockstep with the runtime helpers above — the `.spec`\n * suite drives both through the same HTTP semantics.\n */\nexport function generateConditionalGetRouteHelper(\n apiConfig: unknown,\n options: ConditionalGetRouteHelperOptions = {},\n): string {\n // All branches of resolveReadCacheControl return fixed framework-owned\n // strings (no user text), so interpolating into a single-quoted literal is\n // safe and matches the generated-code quoting style.\n const cacheControl = resolveReadCacheControl(apiConfig, options);\n if (options.modelName) {\n warnIfSharedCacheNeutralized(\n options.modelName,\n apiConfig,\n options.tenantScoped === true,\n );\n warnIfTenantScopedPublicRead(\n options.modelName,\n apiConfig,\n options.tenantScoped === true,\n );\n }\n\n return `\n// Conditional GET (#1757): strong body-hash ETag + If-None-Match → 304 with an\n// empty body. Reads stay private unless the model is public AND opts into\n// shared caching via @smrt({ api: { cache: { sMaxage } } }).\nimport { createHash } from 'node:crypto';\n\nconst READ_CACHE_CONTROL = '${cacheControl}';\n\nfunction bodyEtag(body: string): string {\n return \\`\"\\${createHash('sha256').update(body).digest('base64url')}\"\\`;\n}\n\nfunction ifNoneMatchSatisfied(header: string | null, etag: string): boolean {\n if (!header) return false;\n if (header.trim() === '*') return true;\n return header.split(',').some((candidate) => {\n const tag = candidate.trim();\n const opaque = tag.startsWith('W/') ? tag.slice(2) : tag;\n return opaque === etag;\n });\n}\n\nfunction conditionalJson(request: Request, payload: unknown): Response {\n const body = JSON.stringify(payload);\n const etag = bodyEtag(body);\n if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) {\n return new Response(null, {\n status: 304,\n headers: { 'cache-control': READ_CACHE_CONTROL, etag },\n });\n }\n return new Response(body, {\n status: 200,\n headers: {\n 'cache-control': READ_CACHE_CONTROL,\n 'content-type': 'application/json',\n etag,\n },\n });\n}\n`;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgCA,IAAa,6BAA6B;;;;;;;;AAS1C,SAAgB,gBAAgB,MAAsB;CACpD,OAAO,IAAI,WAAW,QAAQ,CAAC,CAAC,OAAO,IAAI,CAAC,CAAC,OAAO,WAAW,EAAE;AACnE;;;;;;;AAQA,SAAgB,qBACd,QACA,MACS;CACT,IAAI,CAAC,QAAQ,OAAO;CACpB,IAAI,OAAO,KAAK,MAAM,KAAK,OAAO;CAClC,OAAO,OAAO,MAAM,GAAG,CAAC,CAAC,MAAM,cAAc;EAC3C,MAAM,MAAM,UAAU,KAAK;EAE3B,QADe,IAAI,WAAW,IAAI,IAAI,IAAI,MAAM,CAAC,IAAI,SACnC;CACpB,CAAC;AACH;;;;;;AA0BA,SAAS,4BAA4B,WAAmC;CACtE,IAAI,CAAC,aAAa,OAAO,cAAc,UACrC,OAAO;CAGT,MAAM,SAAS;CACf,MAAM,aAAa,OAAO,WAAW,QAAQ,OAAO,WAAW;CAC/D,MAAM,UAAU,OAAO,OAAO;CAE9B,IACE,cACA,OAAO,YAAY,YACnB,OAAO,SAAS,OAAO,KACvB,UAAU,GAIV,OAAO,+BAA+B,KAAK,MAAM,OAAO;CAG1D,OAAO;AACT;;;;;;;;;;;;;;;;AAiBA,SAAgB,wBACd,WACA,UAAmC,CAAC,GAC5B;CACR,IAAI,QAAQ,cACV,OAAO;CAGT,OAAO,4BAA4B,SAAS,KAAA;AAC9C;;AAGA,SAAS,aAAa,WAA6B;CACjD,IAAI,CAAC,aAAa,OAAO,cAAc,UACrC,OAAO;CAET,MAAM,QAAS,UAA4B;CAC3C,OAAO,UAAU,QAAQ,UAAU;AACrC;AAIA,IAAM,+CAA+B,IAAI,IAAY;AAGrD,IAAM,+CAA+B,IAAI,IAAY;;;;;;;;;;;;AAarD,SAAgB,6BACd,WACA,WACA,cACM;CACN,IAAI,CAAC,cAAc;CACnB,IAAI,CAAC,aAAa,SAAS,GAAG;CAC9B,IAAI,6BAA6B,IAAI,SAAS,GAAG;CACjD,6BAA6B,IAAI,SAAS;CAC1C,QAAQ,KACN,8BAA8B,UAAU,wPAK1C;AACF;;;;;;;;AASA,SAAgB,6BACd,WACA,WACA,cACM;CACN,IAAI,CAAC,cAAc;CACnB,IAAI,4BAA4B,SAAS,MAAM,MAAM;CACrD,IAAI,6BAA6B,IAAI,SAAS,GAAG;CACjD,6BAA6B,IAAI,SAAS;CAC1C,QAAQ,KACN,4DAA4D,UAAU,0DAEhE,2BAA2B,mBACnC;AACF;;;;;;;;;AAUA,SAAgB,wBACd,SACA,SACA,cACU;CACV,MAAM,OAAO,KAAK,UAAU,OAAO;CACnC,MAAM,OAAO,gBAAgB,IAAI;CAEjC,IAAI,qBAAqB,QAAQ,QAAQ,IAAI,eAAe,GAAG,IAAI,GACjE,OAAO,IAAI,SAAS,MAAM;EACxB,QAAQ;EACR,SAAS;GACP,iBAAiB;GACjB,MAAM;EACR;CACF,CAAC;CAGH,OAAO,IAAI,SAAS,MAAM;EACxB,QAAQ;EACR,SAAS;GACP,iBAAiB;GACjB,gBAAgB;GAChB,MAAM;EACR;CACF,CAAC;AACH;;;;;;;;;;;AAmBA,SAAgB,kCACd,WACA,UAA4C,CAAC,GACrC;CAIR,MAAM,eAAe,wBAAwB,WAAW,OAAO;CAC/D,IAAI,QAAQ,WAAW;EACrB,6BACE,QAAQ,WACR,WACA,QAAQ,iBAAiB,IAC3B;EACA,6BACE,QAAQ,WACR,WACA,QAAQ,iBAAiB,IAC3B;CACF;CAEA,OAAO;;;;;;8BAMqB,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmC3C"}
1
+ {"version":3,"file":"conditional-get.js","names":[],"sources":["../../src/generators/conditional-get.ts"],"sourcesContent":["/**\n * Conditional GET v1 for generated read routes (#1757).\n *\n * Generated `list`/`get` responses carry a strong ETag computed from the\n * serialized JSON body, and a matching `If-None-Match` answers\n * `304 Not Modified` with an empty body. v1 deliberately still runs the query\n * — the win is transfer, parse, and re-render, not the database round trip\n * (a later slice upgrades the ETag source to the change-feed table version).\n *\n * Cache-Control policy (fail-private, mirroring the #1540 posture):\n * - Default reads: `private, no-cache` — responses may be stored by the\n * browser but MUST be revalidated before reuse, and shared caches never\n * store them.\n * - `@smrt({ api: { public: true | 'read', cache: { sMaxage } } })` reads:\n * `public, max-age=0, s-maxage=<n>` — CDNs/shared caches may serve the\n * response for `n` seconds while browsers still revalidate (cheap 304s).\n * Models without the public flag NEVER emit shared-cache headers, even when\n * `cache.sMaxage` is configured.\n * - Tenant-scoped models (`@smrt({ tenantScoped })` / `@TenantScoped()`, any\n * mode) NEVER emit shared-cache headers: their bodies vary with the tenant\n * context, which URL-keyed shared caches cannot see. `sMaxage` is ignored\n * with a one-time warning.\n *\n * Consumed by both the runtime REST generator (`./rest.ts`) and — as an\n * emitted code snippet — the SvelteKit route generator\n * (`../vite-plugin/sveltekit-generator.ts`). Keeping every piece here keeps\n * the two generators' diffs minimal and the policy in one place.\n */\n\nimport { createHash } from 'node:crypto';\nimport { resolveDispatchTenantScope } from '../dispatch/tenant-resolver.js';\n\n/** Default Cache-Control for generated reads: private conditional revalidation. */\nexport const PRIVATE_READ_CACHE_CONTROL = 'private, no-cache';\n\n/**\n * Compute the strong ETag for a serialized response body.\n *\n * SHA-256 of the exact JSON text, base64url-encoded and quoted per RFC 9110.\n * Deterministic for a given body, so any change to the underlying data (which\n * changes the serialized JSON) changes the ETag.\n */\nexport function computeBodyEtag(body: string): string {\n return `\"${createHash('sha256').update(body).digest('base64url')}\"`;\n}\n\n/**\n * Whether an `If-None-Match` request header matches the response ETag.\n *\n * Implements RFC 9110 §13.1.2 weak comparison: `*` matches anything, the\n * header may carry a comma-separated list, and a `W/` prefix is ignored.\n */\nexport function ifNoneMatchSatisfied(\n header: string | null | undefined,\n etag: string,\n): boolean {\n if (!header) return false;\n if (header.trim() === '*') return true;\n return header.split(',').some((candidate) => {\n const tag = candidate.trim();\n const opaque = tag.startsWith('W/') ? tag.slice(2) : tag;\n return opaque === etag;\n });\n}\n\ninterface ApiCacheShape {\n cache?: { sMaxage?: unknown };\n public?: unknown;\n}\n\n/** Model-level context that constrains the cache policy beyond `api` config. */\nexport interface ReadCacheControlOptions {\n /**\n * Whether the model is tenant-scoped (`@smrt({ tenantScoped })` or the\n * `@TenantScoped()` decorator, ANY mode including `'optional'`). Tenant\n * scoping keys the response body on request identity (session cookie), which\n * shared caches cannot see — they key on the URL alone — so honoring\n * `sMaxage` would serve one tenant's rows to other tenants or to anonymous\n * visitors. Fail-closed: tenant-scoped models NEVER emit shared-cache\n * headers (#1757 review finding).\n */\n tenantScoped?: boolean;\n}\n\n/**\n * The shared Cache-Control string the `api` config asks for, or null when the\n * config does not (validly) opt into shared caching. Config-only — the\n * tenant-scoped restriction is applied by `resolveReadCacheControl`.\n */\nfunction requestedSharedCacheControl(apiConfig: unknown): string | null {\n if (!apiConfig || typeof apiConfig !== 'object') {\n return null;\n }\n\n const config = apiConfig as ApiCacheShape;\n const publicRead = config.public === true || config.public === 'read';\n const sMaxage = config.cache?.sMaxage;\n\n if (\n publicRead &&\n typeof sMaxage === 'number' &&\n Number.isFinite(sMaxage) &&\n sMaxage > 0\n ) {\n // Shared caches serve for sMaxage seconds; browsers (max-age=0) always\n // revalidate, so end users see edits immediately via cheap 304s.\n return `public, max-age=0, s-maxage=${Math.floor(sMaxage)}`;\n }\n\n return null;\n}\n\n/**\n * Resolve the Cache-Control header for a generated read response from a\n * model's `@smrt({ api })` config (defensively typed — the config arrives as\n * `unknown` from the registry at runtime and from the manifest at build time).\n *\n * Only models that opted out of auth via `public: true` (or `'read'`, which\n * makes reads public) may emit shared-cache headers, and only when they also\n * configure a positive `cache.sMaxage`. Everything else — including a\n * non-public model that configures `sMaxage` — stays `private, no-cache`.\n *\n * Tenant-scoped models are ALWAYS `private, no-cache` regardless of config:\n * their response bodies vary with the tenant context (resolved from session\n * cookies, invisible to URL-keyed shared caches), so shared caching would\n * leak one tenant's rows to other tenants or anonymous visitors.\n */\nexport function resolveReadCacheControl(\n apiConfig: unknown,\n options: ReadCacheControlOptions = {},\n): string {\n if (options.tenantScoped) {\n return PRIVATE_READ_CACHE_CONTROL;\n }\n\n return requestedSharedCacheControl(apiConfig) ?? PRIVATE_READ_CACHE_CONTROL;\n}\n\n/** Whether an `api` config opts reads out of auth (`public: true | 'read'`). */\nfunction isPublicRead(apiConfig: unknown): boolean {\n if (!apiConfig || typeof apiConfig !== 'object') {\n return false;\n }\n const value = (apiConfig as ApiCacheShape).public;\n return value === true || value === 'read';\n}\n\n// One warning per model — both transports resolve the same model repeatedly\n// (per route template at generation time, per request at runtime).\nconst sharedCacheNeutralizedWarned = new Set<string>();\n\n// One warning per model for the tenant-scoped + public-read combination (#1782).\nconst tenantScopedPublicReadWarned = new Set<string>();\n\n/**\n * Warn (once per model) when a tenant-scoped model is also marked publicly\n * readable (`@smrt({ api: { public: true | 'read' } })`).\n *\n * Anonymous / no-tenant-context reads on such a model fail closed to NULL-tenant\n * (global) rows only (#1782): they never expose any tenant's rows. That is the\n * intended, safe behavior, but silently it reads as \"the public endpoint returns\n * nothing\" — so surface the combination and its consequence at generation /\n * serve time. Called from both the REST runtime and the SvelteKit route\n * generator so the message appears wherever the model is exposed.\n */\nexport function warnIfTenantScopedPublicRead(\n modelName: string,\n apiConfig: unknown,\n tenantScoped: boolean,\n): void {\n if (!tenantScoped) return;\n if (!isPublicRead(apiConfig)) return;\n if (tenantScopedPublicReadWarned.has(modelName)) return;\n tenantScopedPublicReadWarned.add(modelName);\n console.warn(\n `[smrt] tenant-scoped model ${modelName} is marked api.public — ` +\n 'anonymous reads with no tenant context return NULL-tenant (global) ' +\n 'rows ONLY, never any tenant’s rows (fail-closed, #1782). Resolve a ' +\n 'tenant from the request (host/subdomain/session) if per-tenant public ' +\n 'reads are intended.',\n );\n}\n\n/**\n * Warn (once per model) when a tenant-scoped model configures\n * `api.cache.sMaxage`: the knob is deliberately neutralized to private\n * caching, and silently ignoring it would leave developers wondering why no\n * CDN caching happens. Called from both the REST runtime and the SvelteKit\n * route generator so the message surfaces wherever the model is served.\n */\nexport function warnIfSharedCacheNeutralized(\n modelName: string,\n apiConfig: unknown,\n tenantScoped: boolean,\n): void {\n if (!tenantScoped) return;\n if (requestedSharedCacheControl(apiConfig) === null) return;\n if (sharedCacheNeutralizedWarned.has(modelName)) return;\n sharedCacheNeutralizedWarned.add(modelName);\n console.warn(\n `[smrt] api.cache.sMaxage ignored for tenant-scoped model ${modelName}: ` +\n 'shared caches cannot key on tenant context — serving ' +\n `'${PRIVATE_READ_CACHE_CONTROL}' instead (#1757).`,\n );\n}\n\n/**\n * Build the JSON response for a generated read, honoring `If-None-Match`.\n *\n * Returns `304 Not Modified` with an EMPTY body when the request's\n * `If-None-Match` matches the body ETag; otherwise a 200 with the serialized\n * payload. Both carry the ETag and the resolved Cache-Control so clients can\n * revalidate the representation they hold.\n */\nexport function conditionalJsonResponse(\n request: Request,\n payload: unknown,\n cacheControl: string,\n): Response {\n const body = JSON.stringify(payload);\n const etag = computeBodyEtag(body);\n\n if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) {\n return new Response(null, {\n status: 304,\n headers: {\n 'Cache-Control': cacheControl,\n ETag: etag,\n },\n });\n }\n\n return new Response(body, {\n status: 200,\n headers: {\n 'Cache-Control': cacheControl,\n 'Content-Type': 'application/json',\n ETag: etag,\n },\n });\n}\n\n// ===========================================================================\n// ETag v2: per-table change-feed version source (#1765)\n//\n// v1 (above) hashes the serialized response body, so a 304 still runs the\n// query — the win is transfer, not the database round trip. v2 derives the\n// ETag from the change feed's per-table version (getTableVersion in\n// ../change-feed) plus the request representation, so a matching If-None-Match\n// short-circuits into a 304 BEFORE the collection query runs. The Cache-Control\n// policy, If-None-Match matching, and 304/200 response shape are all preserved\n// verbatim from v1 — only the ETag SOURCE changes.\n//\n// ## Consistency model (the deliberate cost of zero-query revalidation)\n//\n// Revalidating against a version PROXY instead of the response body — the whole\n// point of \"zero database work\" — means v2 is weakly, not strongly, consistent.\n// Two bounded windows follow, both acceptable for the sites-track read cache\n// this serves; a route that needs strong consistency keeps the v1 body-hash\n// path (which reads the data), as serializer routes do:\n//\n// 1. Write→feed gap. On the autocommit save()/delete() path the data row\n// commits and THEN the afterSave/afterDelete interceptor appends the feed\n// row (a separate statement — see change-feed.ts). A revalidation landing\n// in that sub-statement window reads the pre-write version and can return a\n// stale 304; it self-heals on the next revalidation once the feed advances.\n// (Wrapping save() + append in one transaction would close it, but that is\n// a change-feed write-path concern, not this consumer's.)\n// 2. Shape change without a table write. The ETag reflects the table version\n// and request, NOT the serialization shape. A deploy that changes fields /\n// toPublicJSON / transformJSON / sensitive markings without any table write\n// leaves ETags unchanged, so clients keep the old shape until the table\n// next changes. Deploy-time invalidation — salting the ETag with the\n// manifest/build hash — is version-awareness (#1764) territory; shared-cache\n// operators should purge on a shape-changing deploy in the meantime.\n// ===========================================================================\n\n/**\n * Compute the strong ETag for a generated read from the table's change-feed\n * version and the request representation.\n *\n * Keying the ETag on the representation as well as the version is what keeps\n * two different reads of the SAME table from colliding: `?limit=10` and\n * `?limit=20` share a table version but produce different ETags, so a client\n * caching one can never be wrongly answered `304` for the other. Any write to\n * the table advances its version (see {@link getTableVersion}) and therefore\n * every representation's ETag.\n *\n * The `version:representation` join is injective because `version` is a\n * non-negative integer with no `:` — the first colon unambiguously delimits it\n * from the representation, so `(1, ':x')` and `(1, 'x')` never collide.\n * Deterministic and carrying no per-process state, so it is replica-stable.\n */\nexport function computeTableVersionEtag(\n version: number,\n representation: string,\n): string {\n return `\"${createHash('sha256')\n .update(`${version}:${representation}`)\n .digest('base64url')}\"`;\n}\n\n/**\n * Build a canonical, order-independent representation string for a read\n * request: the URL path plus its query parameters sorted by name, and an\n * optional extra discriminator (e.g. the resolved tenant scope) folded in.\n *\n * Two requests that must return the same body produce the same string (so they\n * share an ETag and revalidate cheaply); any difference that changes the body —\n * a different path, a different filter/limit/offset, or a different tenant —\n * produces a different string and therefore a different ETag.\n *\n * Sorting is by parameter NAME only (a stable sort, so repeated keys keep their\n * original relative order). Sorting by value too would make `?limit=10&limit=20`\n * and `?limit=20&limit=10` canonicalize identically, yet the generated handlers\n * read `searchParams.get('limit')` (the FIRST value) — different reads that must\n * not share an ETag. Name-only sorting keeps different orderings of the same\n * keys distinct while still making `?a=1&b=2` and `?b=2&a=1` equivalent.\n *\n * Names, values, and the extra discriminator are percent-ENCODED before being\n * joined — the `searchParams` entries arrive already decoded, so re-joining them\n * raw with `&`/`=`/`|` would let a value containing those characters collide with\n * a structurally different request (`?q=a%26b=c` vs `?q=a&b=c` both decode-then-\n * rejoin to `q=a&b=c`), a false-304 vector. Encoding makes the string injective.\n */\nexport function canonicalReadRepresentation(\n request: Request,\n extra?: string,\n): string {\n const url = new URL(request.url);\n const params = [...url.searchParams.entries()].sort((a, b) =>\n a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0,\n );\n const search = params\n .map(\n ([key, value]) =>\n `${encodeURIComponent(key)}=${encodeURIComponent(value)}`,\n )\n .join('&');\n return `${url.pathname}?${search}${extra ? `|${encodeURIComponent(extra)}` : ''}`;\n}\n\n/**\n * The active tenant folded into a read's ETag representation, or `undefined`\n * when tenancy is not being enforced.\n *\n * This closes a cross-tenant hole specific to per-table version ETags: the\n * table version spans all tenants, so without a tenant component two tenants —\n * or one client switching tenants — would compute the SAME ETag for the same\n * URL. Since tenant-scoped reads are `private, no-cache` (never shared-cached\n * but still browser-cached), a client that viewed tenant A and then switched to\n * tenant B could revalidate B's request with A's cached validator and be\n * wrongly served A's rows from its own cache. Keying the ETag on the active\n * tenant makes A's and B's validators distinct, so the switch forces a fresh\n * `200`. Mirrors the fail-closed dispatch rule: enforced with no context →\n * `global`, so a missing context never collides with a real tenant.\n */\nexport function resolveTenantEtagDiscriminator(): string | undefined {\n const scope = resolveDispatchTenantScope();\n if (!scope.enforced) return undefined;\n return `t:${scope.tenantId ?? 'global'}`;\n}\n\n/**\n * Whether an `If-None-Match` header carries a CONCRETE ETag match — a specific\n * quoted tag equal to `etag` — as opposed to the wildcard `*`.\n *\n * The version fast-path uses this rather than {@link ifNoneMatchSatisfied}\n * because `*` matches unconditionally: per RFC 9110 `*` is satisfied only when a\n * current representation EXISTS, which the pre-query fast-path cannot know. A\n * concrete match, by contrast, can only be held by a client that received it\n * from a prior `200` — and any delete of that row advances the table version,\n * so the concrete ETag would no longer match — making a `304` without the query\n * safe. `*` is deferred until existence is confirmed (see\n * {@link versionConditionalResponse}).\n */\nexport function ifNoneMatchHasConcreteMatch(\n header: string | null | undefined,\n etag: string,\n): boolean {\n if (!header) return false;\n return header.split(',').some((candidate) => {\n const tag = candidate.trim();\n if (tag === '*') return false;\n const opaque = tag.startsWith('W/') ? tag.slice(2) : tag;\n return opaque === etag;\n });\n}\n\n/**\n * Build a generated read response from a precomputed version ETag, skipping the\n * query on a conditional hit (#1765).\n *\n * A CONCRETE `If-None-Match` match returns `304 Not Modified` with an empty body\n * and **never invokes `buildPayload`** — the collection query does not run,\n * which is the point of ETag v2. Otherwise `buildPayload` runs; if it succeeds\n * (a current representation therefore exists) a wildcard `If-None-Match: *` is\n * honored with a `304` — deferring `*` past the build is what stops a\n * `304` from being returned for a row that no longer exists (a `buildPayload`\n * that throws, e.g. a `404` for a missing item, propagates and is never a 304).\n * Mirrors {@link conditionalJsonResponse}'s response shape and header policy.\n */\nexport async function versionConditionalResponse(\n request: Request,\n etag: string,\n cacheControl: string,\n buildPayload: () => unknown | Promise<unknown>,\n): Promise<Response> {\n const notModified = () =>\n new Response(null, {\n status: 304,\n headers: {\n 'Cache-Control': cacheControl,\n ETag: etag,\n },\n });\n\n const ifNoneMatch = request.headers.get('if-none-match');\n if (ifNoneMatchHasConcreteMatch(ifNoneMatch, etag)) {\n return notModified();\n }\n\n const payload = await buildPayload();\n // Existence confirmed by a successful build → honor a wildcard `*` now.\n if (ifNoneMatchSatisfied(ifNoneMatch, etag)) {\n return notModified();\n }\n return new Response(JSON.stringify(payload), {\n status: 200,\n headers: {\n 'Cache-Control': cacheControl,\n 'Content-Type': 'application/json',\n ETag: etag,\n },\n });\n}\n\n/** Generation-time context for the emitted SvelteKit route helper. */\nexport interface ConditionalGetRouteHelperOptions\n extends ReadCacheControlOptions {\n /** Model name used for the one-time sMaxage-neutralized warning. */\n modelName?: string;\n /**\n * Emit the v1 body-hash helper (`conditionalJson`, query-first) instead of the\n * v2 version-first `conditionalVersionedRead`. Set when the route's GET handler\n * renders via a CUSTOM serializer whose output can depend on RELATED tables\n * (e.g. content's `serializeContent` loads assets/references): the per-base-\n * table version cannot observe those changes, so a version-derived `304` would\n * serve stale serialized fields. The body hash covers the whole rendered\n * payload, so it stays correct — at the cost of running the query (a\n * transfer-saving 304, not zero-query). The default `toPublicJSON` payload IS a\n * pure function of the base table, so it uses v2.\n */\n useBodyHash?: boolean;\n}\n\n/**\n * Emit the conditional-GET helper inlined into generated SvelteKit route files,\n * following the generator's existing inline-helper convention (auth guard,\n * tenant context, writable policy). The Cache-Control policy is resolved at\n * generation time from the object's `@smrt({ api })` config plus tenant scoping\n * and baked in as a constant.\n *\n * Two shapes, chosen per route by `useBodyHash`:\n * - **v2 (default, #1765)** — `conditionalVersionedRead(request, db, tableName,\n * buildPayload)` derives the ETag from the table's change-feed version\n * ({@link getTableVersion}) keyed by the request representation, so a concrete\n * `If-None-Match` returns a `304` and `buildPayload` — the collection query —\n * never runs. Imports its primitives from `@happyvertical/smrt-core` (the\n * version lookup is dialect-aware SQL that cannot be inlined portably),\n * mirroring the generated `_changes` route. Correct only when the payload is a\n * pure function of the base table — the `toPublicJSON` path.\n * - **v1 (#1757, `useBodyHash`)** — the inlined body-hash `conditionalJson`,\n * used where a custom serializer can pull in related tables the base-table\n * version can't see (see {@link ConditionalGetRouteHelperOptions.useBodyHash}).\n *\n * For tenant-scoped models the v2 representation folds in the active tenant\n * ({@link resolveTenantEtagDiscriminator}) so one tenant's cached validator\n * never satisfies another's read of the same URL — the cross-tenant false-304\n * guard. The v2 runtime behavior is exercised end to end (query observation, 304\n * without a query, mutation bumps the version) by the REST `conditional-get.spec`\n * over the SAME core primitives this route calls.\n */\nexport function generateConditionalGetRouteHelper(\n apiConfig: unknown,\n options: ConditionalGetRouteHelperOptions = {},\n): string {\n // All branches of resolveReadCacheControl return fixed framework-owned\n // strings (no user text), so interpolating into a single-quoted literal is\n // safe and matches the generated-code quoting style.\n const cacheControl = resolveReadCacheControl(apiConfig, options);\n if (options.modelName) {\n warnIfSharedCacheNeutralized(\n options.modelName,\n apiConfig,\n options.tenantScoped === true,\n );\n warnIfTenantScopedPublicRead(\n options.modelName,\n apiConfig,\n options.tenantScoped === true,\n );\n }\n\n // Serializer-backed routes: the body can depend on related tables the base-\n // table version can't see, so keep the v1 body-hash ETag (query-first but\n // correct). See useBodyHash.\n if (options.useBodyHash) {\n return `\n// Conditional GET (#1757 v1): a strong body-hash ETag over the serialized\n// response — used where a custom serializer can render data from related tables\n// that the per-table change-feed version cannot observe, so the ETag must cover\n// the whole rendered body.\nimport { createHash } from 'node:crypto';\n\nconst READ_CACHE_CONTROL = '${cacheControl}';\n\nfunction bodyEtag(body: string): string {\n return \\`\"\\${createHash('sha256').update(body).digest('base64url')}\"\\`;\n}\n\nfunction ifNoneMatchSatisfied(header: string | null, etag: string): boolean {\n if (!header) return false;\n if (header.trim() === '*') return true;\n return header.split(',').some((candidate) => {\n const tag = candidate.trim();\n const opaque = tag.startsWith('W/') ? tag.slice(2) : tag;\n return opaque === etag;\n });\n}\n\nfunction conditionalJson(request: Request, payload: unknown): Response {\n const body = JSON.stringify(payload);\n const etag = bodyEtag(body);\n if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) {\n return new Response(null, {\n status: 304,\n headers: { 'cache-control': READ_CACHE_CONTROL, etag },\n });\n }\n return new Response(body, {\n status: 200,\n headers: {\n 'cache-control': READ_CACHE_CONTROL,\n 'content-type': 'application/json',\n etag,\n },\n });\n}\n`;\n }\n\n // Tenant-scoped models key the ETag on the active tenant; non-tenant models\n // omit the discriminator (their bodies do not vary by tenant), so the import\n // and the representation argument are conditional on tenant scoping.\n const tenantScoped = options.tenantScoped === true;\n const coreImports = [\n 'canonicalReadRepresentation',\n 'computeTableVersionEtag',\n 'getTableVersion',\n 'ifNoneMatchHasConcreteMatch',\n 'ifNoneMatchSatisfied',\n ...(tenantScoped ? ['resolveTenantEtagDiscriminator'] : []),\n ].join(',\\n ');\n const representationExtra = tenantScoped\n ? 'resolveTenantEtagDiscriminator()'\n : 'undefined';\n\n return `\n// Conditional GET (#1765): the ETag is the table's change-feed version keyed by\n// the request representation, so a CONCRETE If-None-Match returns 304 BEFORE the\n// collection query runs. A wildcard \\`*\\` is honored only after the payload builds\n// (existence confirmed), so a 304 is never returned for a missing row. Reads stay\n// private unless the model is public AND opts into shared caching via\n// @smrt({ api: { cache: { sMaxage } } }).\nimport {\n ${coreImports},\n} from '@happyvertical/smrt-core';\n\nconst READ_CACHE_CONTROL = '${cacheControl}';\n\nasync function conditionalVersionedRead(\n request: Request,\n db: Parameters<typeof getTableVersion>[0],\n tableName: string,\n buildPayload: () => Promise<unknown>,\n): Promise<Response> {\n const version = await getTableVersion(db, tableName);\n const etag = computeTableVersionEtag(\n version,\n canonicalReadRepresentation(request, ${representationExtra}),\n );\n const ifNoneMatch = request.headers.get('if-none-match');\n const notModified = () =>\n new Response(null, {\n status: 304,\n headers: { 'cache-control': READ_CACHE_CONTROL, etag },\n });\n if (ifNoneMatchHasConcreteMatch(ifNoneMatch, etag)) {\n return notModified();\n }\n const payload = await buildPayload();\n // Existence confirmed by a successful build → honor a wildcard \\`*\\` now.\n if (ifNoneMatchSatisfied(ifNoneMatch, etag)) {\n return notModified();\n }\n return new Response(JSON.stringify(payload), {\n status: 200,\n headers: {\n 'cache-control': READ_CACHE_CONTROL,\n 'content-type': 'application/json',\n etag,\n },\n });\n}\n`;\n}\n"],"mappings":"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"}
@@ -3,7 +3,7 @@
3
3
  */
4
4
  export type { CLIConfig, CLIContext } from './cli';
5
5
  export { CLIGenerator, getCLIHandler, setupCLI } from './cli';
6
- export { computeBodyEtag, conditionalJsonResponse, ifNoneMatchSatisfied, PRIVATE_READ_CACHE_CONTROL, type ReadCacheControlOptions, resolveReadCacheControl, warnIfSharedCacheNeutralized, } from './conditional-get';
6
+ export { canonicalReadRepresentation, computeBodyEtag, computeTableVersionEtag, conditionalJsonResponse, ifNoneMatchHasConcreteMatch, ifNoneMatchSatisfied, PRIVATE_READ_CACHE_CONTROL, type ReadCacheControlOptions, resolveReadCacheControl, resolveTenantEtagDiscriminator, versionConditionalResponse, warnIfSharedCacheNeutralized, } from './conditional-get';
7
7
  export type { MCPConfig, MCPContext, MCPRequest, MCPResponse, MCPTool, } from './mcp';
8
8
  export { MCPGenerator } from './mcp';
9
9
  export type { APIConfig, APIContext, RestServerConfig } from './rest';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/generators/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,YAAY,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,OAAO,CAAC;AAEnD,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAE9D,OAAO,EACL,eAAe,EACf,uBAAuB,EACvB,oBAAoB,EACpB,0BAA0B,EAC1B,KAAK,uBAAuB,EAC5B,uBAAuB,EACvB,4BAA4B,GAC7B,MAAM,mBAAmB,CAAC;AAC3B,YAAY,EACV,SAAS,EACT,UAAU,EACV,UAAU,EACV,WAAW,EACX,OAAO,GACR,MAAM,OAAO,CAAC;AAEf,OAAO,EAAE,YAAY,EAAE,MAAM,OAAO,CAAC;AACrC,YAAY,EAAE,SAAS,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,QAAQ,CAAC;AAEtE,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,QAAQ,CAAC;AACzE,YAAY,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAE/C,OAAO,EACL,mBAAmB,EACnB,cAAc,GACf,MAAM,WAAW,CAAC;AAEnB,OAAO,EACL,iBAAiB,EACjB,yBAAyB,EACzB,KAAK,sBAAsB,EAC3B,KAAK,iBAAiB,GACvB,MAAM,eAAe,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/generators/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,YAAY,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,OAAO,CAAC;AAEnD,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAG9D,OAAO,EACL,2BAA2B,EAC3B,eAAe,EACf,uBAAuB,EACvB,uBAAuB,EACvB,2BAA2B,EAC3B,oBAAoB,EACpB,0BAA0B,EAC1B,KAAK,uBAAuB,EAC5B,uBAAuB,EACvB,8BAA8B,EAC9B,0BAA0B,EAC1B,4BAA4B,GAC7B,MAAM,mBAAmB,CAAC;AAC3B,YAAY,EACV,SAAS,EACT,UAAU,EACV,UAAU,EACV,WAAW,EACX,OAAO,GACR,MAAM,OAAO,CAAC;AAEf,OAAO,EAAE,YAAY,EAAE,MAAM,OAAO,CAAC;AACrC,YAAY,EAAE,SAAS,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,QAAQ,CAAC;AAEtE,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,QAAQ,CAAC;AACzE,YAAY,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAE/C,OAAO,EACL,mBAAmB,EACnB,cAAc,GACf,MAAM,WAAW,CAAC;AAEnB,OAAO,EACL,iBAAiB,EACjB,yBAAyB,EACzB,KAAK,sBAAsB,EAC3B,KAAK,iBAAiB,GACvB,MAAM,eAAe,CAAC"}
@@ -1,7 +1,7 @@
1
1
  import { runWithTenantGate, setTenantEntryPointRunner } from "./tenant-gate.js";
2
2
  import { CLIGenerator, getCLIHandler, setupCLI } from "./cli.js";
3
- import { PRIVATE_READ_CACHE_CONTROL, computeBodyEtag, conditionalJsonResponse, ifNoneMatchSatisfied, resolveReadCacheControl, warnIfSharedCacheNeutralized } from "./conditional-get.js";
3
+ import { PRIVATE_READ_CACHE_CONTROL, canonicalReadRepresentation, computeBodyEtag, computeTableVersionEtag, conditionalJsonResponse, ifNoneMatchHasConcreteMatch, ifNoneMatchSatisfied, resolveReadCacheControl, resolveTenantEtagDiscriminator, versionConditionalResponse, warnIfSharedCacheNeutralized } from "./conditional-get.js";
4
4
  import { MCPGenerator } from "./mcp.js";
5
5
  import { APIGenerator, createRestServer, startRestServer } from "./rest.js";
6
6
  import { generateOpenAPISpec, setupSwaggerUI } from "./swagger.js";
7
- export { APIGenerator, CLIGenerator, MCPGenerator, PRIVATE_READ_CACHE_CONTROL, computeBodyEtag, conditionalJsonResponse, createRestServer, generateOpenAPISpec, getCLIHandler, ifNoneMatchSatisfied, resolveReadCacheControl, runWithTenantGate, setTenantEntryPointRunner, setupCLI, setupSwaggerUI, startRestServer, warnIfSharedCacheNeutralized };
7
+ export { APIGenerator, CLIGenerator, MCPGenerator, PRIVATE_READ_CACHE_CONTROL, canonicalReadRepresentation, computeBodyEtag, computeTableVersionEtag, conditionalJsonResponse, createRestServer, generateOpenAPISpec, getCLIHandler, ifNoneMatchHasConcreteMatch, ifNoneMatchSatisfied, resolveReadCacheControl, resolveTenantEtagDiscriminator, runWithTenantGate, setTenantEntryPointRunner, setupCLI, setupSwaggerUI, startRestServer, versionConditionalResponse, warnIfSharedCacheNeutralized };
@@ -179,14 +179,28 @@ export declare class APIGenerator {
179
179
  */
180
180
  private toPublicData;
181
181
  /**
182
- * Create a JSON read response with conditional-GET support (#1757): a strong
183
- * body-hash ETag, `If-None-Match` 304 with an empty body, and the
184
- * Cache-Control policy resolved from the object's `@smrt({ api })` config
185
- * (private + revalidatable by default; shared `s-maxage` only for public
186
- * models that opt in). Tenant-scoped models are always private: their bodies
187
- * vary with tenant context, which URL-keyed shared caches cannot see.
188
- */
189
- private createReadResponse;
182
+ * Resolve the Cache-Control policy for a generated read and fire the
183
+ * one-time policy warnings (#1757): private + revalidatable by default;
184
+ * shared `s-maxage` only for public models that opt in; always private for
185
+ * tenant-scoped models, whose bodies vary with tenant context that URL-keyed
186
+ * shared caches cannot see.
187
+ */
188
+ private resolveReadCachePolicy;
189
+ /**
190
+ * Compute the ETag v2 (#1765) for a generated read: the table's change-feed
191
+ * version ({@link getTableVersion}) keyed by the request representation, so a
192
+ * matching `If-None-Match` can short-circuit into a 304 BEFORE the collection
193
+ * query runs. The representation folds in the active tenant for tenant-scoped
194
+ * models so one tenant's cached ETag never satisfies another's read.
195
+ */
196
+ private computeReadEtag;
197
+ /**
198
+ * A per-tenant ETag discriminator for tenant-scoped models: the active tenant
199
+ * id (or `global`) so tenant A's cached ETag never satisfies tenant B's read
200
+ * of the same table and params. `undefined` for non-tenant-scoped models,
201
+ * whose bodies do not vary by tenant.
202
+ */
203
+ private readTenantDiscriminator;
190
204
  /**
191
205
  * Create JSON response with proper headers
192
206
  */
@@ -1 +1 @@
1
- {"version":3,"file":"rest.d.ts","sourceRoot":"","sources":["../../src/generators/rest.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"rest.d.ts","sourceRoot":"","sources":["../../src/generators/rest.ts"],"names":[],"mappings":"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"}
@@ -1,7 +1,8 @@
1
1
  import { isTenantScopedClassResolved, resolveDispatchTenantScope } from "../dispatch/tenant-resolver.js";
2
+ import { getTableVersion } from "../change-feed.js";
2
3
  import { ObjectRegistry } from "../registry.js";
3
4
  import "../dispatch/index.js";
4
- import { conditionalJsonResponse, resolveReadCacheControl, warnIfSharedCacheNeutralized, warnIfTenantScopedPublicRead } from "./conditional-get.js";
5
+ import { canonicalReadRepresentation, computeTableVersionEtag, ifNoneMatchHasConcreteMatch, ifNoneMatchSatisfied, resolveReadCacheControl, resolveTenantEtagDiscriminator, versionConditionalResponse, warnIfSharedCacheNeutralized, warnIfTenantScopedPublicRead } from "./conditional-get.js";
5
6
  import { SYNC_APPLY_ROUTE_SEGMENTS, processSyncApplyBatch } from "../sync/apply.js";
6
7
  import { handleChangesRoute } from "./changes-route.js";
7
8
  import http from "node:http";
@@ -291,51 +292,72 @@ var APIGenerator = class {
291
292
  * Handle GET /objects/:id
292
293
  */
293
294
  async handleGet(collection, id, req, objectName) {
295
+ const cacheControl = this.resolveReadCachePolicy(objectName);
296
+ const etag = await this.computeReadEtag(collection, req, objectName);
297
+ const ifNoneMatch = req.headers.get("if-none-match");
298
+ const notModified = () => new Response(null, {
299
+ status: 304,
300
+ headers: {
301
+ "Cache-Control": cacheControl,
302
+ ETag: etag
303
+ }
304
+ });
305
+ if (ifNoneMatchHasConcreteMatch(ifNoneMatch, etag)) return notModified();
294
306
  const scope = this.resolveTenantReadScope(objectName);
295
307
  const object = scope ? await collection.get({
296
308
  id,
297
309
  ...scope
298
310
  }) : await collection.get(id);
299
311
  if (!object) return this.createErrorResponse(404, "Object not found");
300
- return this.createReadResponse(req, objectName, this.toPublicData(object));
312
+ if (ifNoneMatchSatisfied(ifNoneMatch, etag)) return notModified();
313
+ return new Response(JSON.stringify(this.toPublicData(object)), {
314
+ status: 200,
315
+ headers: {
316
+ "Cache-Control": cacheControl,
317
+ "Content-Type": "application/json",
318
+ ETag: etag
319
+ }
320
+ });
301
321
  }
302
322
  /**
303
323
  * Handle GET /objects (list with query params)
304
324
  */
305
325
  async handleList(collection, params, req, objectName) {
306
- const limit = Number.parseInt(params.get("limit") || "50", 10);
307
- const offset = Number.parseInt(params.get("offset") || "0", 10);
308
- const orderBy = params.get("orderBy") || "created_at DESC";
309
- const where = {};
310
- for (const [key, value] of params.entries()) if (![
311
- "limit",
312
- "offset",
313
- "orderBy"
314
- ].includes(key)) {
315
- const match = key.match(/^(.+)\[(.+)\]$/);
316
- if (match) {
317
- const field = match[1];
318
- const operator = match[2];
319
- const sqlKey = `${field} ${{
320
- gt: ">",
321
- gte: ">=",
322
- lt: "<",
323
- lte: "<=",
324
- ne: "!=",
325
- in: "in",
326
- like: "like"
327
- }[operator] || operator}`;
328
- where[sqlKey] = operator === "in" ? value.split(",") : value;
329
- } else where[key] = value;
330
- }
331
- const scopedWhere = this.applyTenantReadScope(objectName, where);
332
- const objects = await collection.list({
333
- where: scopedWhere && Object.keys(scopedWhere).length > 0 ? scopedWhere : void 0,
334
- limit,
335
- offset,
336
- orderBy
326
+ const cacheControl = this.resolveReadCachePolicy(objectName);
327
+ return versionConditionalResponse(req, await this.computeReadEtag(collection, req, objectName), cacheControl, async () => {
328
+ const limit = Number.parseInt(params.get("limit") || "50", 10);
329
+ const offset = Number.parseInt(params.get("offset") || "0", 10);
330
+ const orderBy = params.get("orderBy") || "created_at DESC";
331
+ const where = {};
332
+ for (const [key, value] of params.entries()) if (![
333
+ "limit",
334
+ "offset",
335
+ "orderBy"
336
+ ].includes(key)) {
337
+ const match = key.match(/^(.+)\[(.+)\]$/);
338
+ if (match) {
339
+ const field = match[1];
340
+ const operator = match[2];
341
+ const sqlKey = `${field} ${{
342
+ gt: ">",
343
+ gte: ">=",
344
+ lt: "<",
345
+ lte: "<=",
346
+ ne: "!=",
347
+ in: "in",
348
+ like: "like"
349
+ }[operator] || operator}`;
350
+ where[sqlKey] = operator === "in" ? value.split(",") : value;
351
+ } else where[key] = value;
352
+ }
353
+ const scopedWhere = this.applyTenantReadScope(objectName, where);
354
+ return (await collection.list({
355
+ where: scopedWhere && Object.keys(scopedWhere).length > 0 ? scopedWhere : void 0,
356
+ limit,
357
+ offset,
358
+ orderBy
359
+ })).map((object) => this.toPublicData(object));
337
360
  });
338
- return this.createReadResponse(req, objectName, objects.map((object) => this.toPublicData(object)));
339
361
  }
340
362
  /**
341
363
  * Handle GET /objects/count
@@ -513,14 +535,13 @@ var APIGenerator = class {
513
535
  return typeof serializable?.toPublicJSON === "function" ? serializable.toPublicJSON() : object;
514
536
  }
515
537
  /**
516
- * Create a JSON read response with conditional-GET support (#1757): a strong
517
- * body-hash ETag, `If-None-Match` 304 with an empty body, and the
518
- * Cache-Control policy resolved from the object's `@smrt({ api })` config
519
- * (private + revalidatable by default; shared `s-maxage` only for public
520
- * models that opt in). Tenant-scoped models are always private: their bodies
521
- * vary with tenant context, which URL-keyed shared caches cannot see.
538
+ * Resolve the Cache-Control policy for a generated read and fire the
539
+ * one-time policy warnings (#1757): private + revalidatable by default;
540
+ * shared `s-maxage` only for public models that opt in; always private for
541
+ * tenant-scoped models, whose bodies vary with tenant context that URL-keyed
542
+ * shared caches cannot see.
522
543
  */
523
- createReadResponse(req, objectName, payload) {
544
+ resolveReadCachePolicy(objectName) {
524
545
  const config = objectName ? ObjectRegistry.getConfig(objectName) : void 0;
525
546
  const apiConfig = config?.api;
526
547
  const tenantScoped = objectName ? ObjectRegistry.isTenantScoped(objectName) || !!config?.tenantScoped : false;
@@ -528,7 +549,28 @@ var APIGenerator = class {
528
549
  warnIfSharedCacheNeutralized(objectName, apiConfig, tenantScoped);
529
550
  warnIfTenantScopedPublicRead(objectName, apiConfig, tenantScoped);
530
551
  }
531
- return conditionalJsonResponse(req, payload, resolveReadCacheControl(apiConfig, { tenantScoped }));
552
+ return resolveReadCacheControl(apiConfig, { tenantScoped });
553
+ }
554
+ /**
555
+ * Compute the ETag v2 (#1765) for a generated read: the table's change-feed
556
+ * version ({@link getTableVersion}) keyed by the request representation, so a
557
+ * matching `If-None-Match` can short-circuit into a 304 BEFORE the collection
558
+ * query runs. The representation folds in the active tenant for tenant-scoped
559
+ * models so one tenant's cached ETag never satisfies another's read.
560
+ */
561
+ async computeReadEtag(collection, req, objectName) {
562
+ return computeTableVersionEtag(await getTableVersion(collection.db, collection.tableName), canonicalReadRepresentation(req, this.readTenantDiscriminator(objectName)));
563
+ }
564
+ /**
565
+ * A per-tenant ETag discriminator for tenant-scoped models: the active tenant
566
+ * id (or `global`) so tenant A's cached ETag never satisfies tenant B's read
567
+ * of the same table and params. `undefined` for non-tenant-scoped models,
568
+ * whose bodies do not vary by tenant.
569
+ */
570
+ readTenantDiscriminator(objectName) {
571
+ if (!objectName) return void 0;
572
+ if (!(ObjectRegistry.isTenantScoped(objectName) || !!ObjectRegistry.getConfig(objectName)?.tenantScoped || isTenantScopedClassResolved(objectName))) return void 0;
573
+ return resolveTenantEtagDiscriminator();
532
574
  }
533
575
  /**
534
576
  * Create JSON response with proper headers