@happyvertical/smrt-core 0.38.6 → 0.38.8

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 (34) hide show
  1. package/AGENTS.md +4 -1
  2. package/dist/generators/conditional-get.d.ts +23 -1
  3. package/dist/generators/conditional-get.d.ts.map +1 -1
  4. package/dist/generators/conditional-get.js +27 -12
  5. package/dist/generators/conditional-get.js.map +1 -1
  6. package/dist/generators/rest.d.ts +14 -1
  7. package/dist/generators/rest.d.ts.map +1 -1
  8. package/dist/generators/rest.js +4 -2
  9. package/dist/generators/rest.js.map +1 -1
  10. package/dist/generators/tool-schema.d.ts +83 -0
  11. package/dist/generators/tool-schema.d.ts.map +1 -0
  12. package/dist/generators/tool-schema.js +175 -0
  13. package/dist/generators/tool-schema.js.map +1 -0
  14. package/dist/manifest/static-manifest.js +2 -2
  15. package/dist/manifest/static-manifest.js.map +1 -1
  16. package/dist/manifest/store.js +1 -1
  17. package/dist/manifest/test-manifest-stub.js +2 -2
  18. package/dist/manifest/test-manifest-stub.js.map +1 -1
  19. package/dist/manifest.json +2 -2
  20. package/dist/prebuild/index.d.ts.map +1 -1
  21. package/dist/prebuild/index.js +19 -0
  22. package/dist/prebuild/index.js.map +1 -1
  23. package/dist/smrt-knowledge.json +6 -6
  24. package/dist/vite-plugin/index.d.ts.map +1 -1
  25. package/dist/vite-plugin/index.js +34 -13
  26. package/dist/vite-plugin/index.js.map +1 -1
  27. package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
  28. package/dist/vite-plugin/sveltekit-generator.js +7 -2
  29. package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
  30. package/dist/vite-plugin/web-collections.d.ts +68 -3
  31. package/dist/vite-plugin/web-collections.d.ts.map +1 -1
  32. package/dist/vite-plugin/web-collections.js +158 -9
  33. package/dist/vite-plugin/web-collections.js.map +1 -1
  34. package/package.json +4 -4
package/AGENTS.md CHANGED
@@ -108,8 +108,11 @@ The push companion to the change feed (`src/change-signals.ts` + the generated `
108
108
  | REST API | `src/generators/rest.ts` | OpenAPI-compliant CRUD endpoints |
109
109
  | CLI | `src/generators/cli.ts` | `objectname:action` admin commands — writable allowlist, exhaustive-include, `--from-file`, fail-closed tenant context |
110
110
  | MCP Server | `src/generators/mcp.ts` | Model Context Protocol tools |
111
+ | Web collections | `src/vite-plugin/web-collections.ts` (selectors) + `generateWebModule` | `@happyvertical/smrt-virt-web` — one typed collection definition per API-exposed REST collection (#1761), consumed by `@happyvertical/smrt-web` |
111
112
 
112
- Generated reads (`list`/`get`) on the REST and SvelteKit generators support conditional GET (helpers in `src/generators/conditional-get.ts`). ETag v2 (#1765): the validator is the table's change-feed version (`getTableVersion`) keyed by the request representation, so a **concrete** `If-None-Match` short-circuits into a 304 with an empty body **before** the collection query runs — an unchanged table revalidates with zero table scan. A wildcard `If-None-Match: *` is deferred until the payload builds (existence confirmed), so a missing item still returns 404, not a false 304. Tenant-scoped reads fold the active tenant into the representation (`resolveTenantEtagDiscriminator`) so one tenant's cached validator never satisfies another's read of the same URL. Routes whose GET renders via a **custom serializer** (which can load related tables the base-table version can't observe) keep the v1 body-hash ETag (`#1757`, query-first but correct); the default `toPublicJSON` path all REST reads and non-serializer SvelteKit reads — uses v2. v2 is weakly consistent by design (the cost of not reading the data): a revalidation in the sub-statement window between a committed write and its feed append can return a stale 304 that self-heals on the next revalidation, and a deploy that changes the response shape without a table write leaves ETags unchanged until the next write (deploy-time ETag invalidation via the manifest hash is #1764's domain). Strong consistency requires the v1 body-hash path. Cache-Control policy (unchanged from #1757): `private, no-cache` by default; public models may opt into shared caching via `@smrt({ api: { public: true | 'read', cache: { sMaxage } } })` → `public, max-age=0, s-maxage=<n>`; non-public models never emit shared-cache headers. Tenant-scoped models (any mode) never emit them either — bodies vary with session-cookie tenant context that URL-keyed shared caches cannot see; `sMaxage` is neutralized to `private, no-cache` with a one-time warning.
113
+ The web module also emits a build-time **`manifestHash`** constant (#1764): `computeWebManifestHash(manifest)` is a deterministic, replica-stable digest of the emitted web-collection SHAPE (name/className/endpoint/idField/actions/fields/relationships), canonicalized (recursive key sort) before `sha256 base64url`, truncated to 16 chars so the same schema always hashes the same, and a field add/remove/type-change/edge-change changes it. A change means old persisted client rows may mis-hydrate, so smrt-web keys its durable persistence namespace on it and its `updateAvailable` contract signal compares against it. Three emission sites must not drift: the runtime value (`generateWebModule`), the `@happyvertical/smrt-virt-web` ambient d.ts (`vite-plugin/index.ts`), and the physical `@smrt/web` d.ts (`prebuild/index.ts`).
114
+
115
+ Generated reads (`list`/`get`) on the REST and SvelteKit generators support conditional GET (helpers in `src/generators/conditional-get.ts`). ETag v2 (#1765): the validator is the table's change-feed version (`getTableVersion`) keyed by the request representation, so a **concrete** `If-None-Match` short-circuits into a 304 with an empty body **before** the collection query runs — an unchanged table revalidates with zero table scan. A wildcard `If-None-Match: *` is deferred until the payload builds (existence confirmed), so a missing item still returns 404, not a false 304. Tenant-scoped reads fold the active tenant into the representation (`resolveTenantEtagDiscriminator`) so one tenant's cached validator never satisfies another's read of the same URL. Routes whose GET renders via a **custom serializer** (which can load related tables the base-table version can't observe) keep the v1 body-hash ETag (`#1757`, query-first but correct); the default `toPublicJSON` path — all REST reads and non-serializer SvelteKit reads — uses v2. v2 is weakly consistent by design (the cost of not reading the data): a revalidation in the sub-statement window between a committed write and its feed append can return a stale 304 that self-heals on the next revalidation. The other v2 window — a deploy that changes the response shape WITHOUT a table write — is closed by the **#1764 ETag salt**: `computeTableVersionEtag(version, representation, manifestHash?)` folds the build's web-collection shape digest into the digest, so a shape-only redeploy busts every read validator (`undefined` reproduces the pre-#1764 unsalted value byte-for-byte, so existing callers/tests are unaffected). The generated SvelteKit route bakes the digest in as a `MANIFEST_HASH` constant (via `generateConditionalGetRouteHelper`'s `manifestHash` option, sourced from `computeWebManifestHash(manifest)`) — automatic for the SvelteKit transport. The runtime `APIGenerator` reads `APIConfig.manifestHash`, but it is **NOT auto-populated**: a non-SvelteKit runtime-REST deployment that wants the shape-only-deploy guard must pass `manifestHash` (imported from `@happyvertical/smrt-virt-web`) into its `APIConfig` — a deliberate consumer responsibility; left unset, that path's read ETags stay unsalted (equivalent to pre-#1764). The digest scope is get-OR-list (`selectWebEtagSaltEntries`), so **get-only** routes are salted too. Strong consistency still requires the v1 body-hash path. Cache-Control policy (unchanged from #1757): `private, no-cache` by default; public models may opt into shared caching via `@smrt({ api: { public: true | 'read', cache: { sMaxage } } })` → `public, max-age=0, s-maxage=<n>`; non-public models never emit shared-cache headers. Tenant-scoped models (any mode) never emit them either — bodies vary with session-cookie tenant context that URL-keyed shared caches cannot see; `sMaxage` is neutralized to `private, no-cache` with a one-time warning.
113
116
 
114
117
  ## Child Accessors (R10)
115
118
 
@@ -126,8 +126,20 @@ export declare function conditionalJsonResponse(request: Request, payload: unkno
126
126
  * non-negative integer with no `:` — the first colon unambiguously delimits it
127
127
  * from the representation, so `(1, ':x')` and `(1, 'x')` never collide.
128
128
  * Deterministic and carrying no per-process state, so it is replica-stable.
129
+ *
130
+ * The optional `manifestHash` (#1764) salts the digest with the build's
131
+ * web-collection SHAPE digest, closing the documented "shape change without a
132
+ * table write" staleness gap (see the v2 consistency note above): a deploy that
133
+ * changes fields / toPublicJSON / sensitive markings WITHOUT any table write
134
+ * leaves the table version unchanged, so without the salt every read ETag stays
135
+ * identical and clients keep the stale shape until the table next changes.
136
+ * Folding the build-time manifest hash in makes a shape-only redeploy bust every
137
+ * read validator. Backward-compatible: `undefined` reproduces the pre-#1764
138
+ * digest input EXACTLY (`${version}:${representation}`, no trailing separator),
139
+ * so existing callers and their tests are byte-for-byte unaffected; only a
140
+ * generated route that threads the constant appends the `:${manifestHash}` salt.
129
141
  */
130
- export declare function computeTableVersionEtag(version: number, representation: string): string;
142
+ export declare function computeTableVersionEtag(version: number, representation: string, manifestHash?: string): string;
131
143
  /**
132
144
  * Build a canonical, order-independent representation string for a read
133
145
  * request: the URL path plus its query parameters sorted by name, and an
@@ -212,6 +224,16 @@ export interface ConditionalGetRouteHelperOptions extends ReadCacheControlOption
212
224
  * pure function of the base table, so it uses v2.
213
225
  */
214
226
  useBodyHash?: boolean;
227
+ /**
228
+ * The build's web-collection SHAPE digest (#1764), baked into the emitted v2
229
+ * helper as a `MANIFEST_HASH` constant and folded into every read ETag via
230
+ * {@link computeTableVersionEtag}. This makes a shape-only deploy (no table
231
+ * write) bust every read validator, closing the documented v2 staleness gap.
232
+ * Omitted (the default) reproduces the pre-#1764 emit exactly — no constant,
233
+ * no third argument — so callers that do not thread a hash are unaffected.
234
+ * Ignored on the v1 body-hash path, whose ETag already covers the whole body.
235
+ */
236
+ manifestHash?: string;
215
237
  }
216
238
  /**
217
239
  * Emit the conditional-GET helper inlined into generated SvelteKit route files,
@@ -1 +1 @@
1
- {"version":3,"file":"conditional-get.d.ts","sourceRoot":"","sources":["../../src/generators/conditional-get.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAKH,mFAAmF;AACnF,eAAO,MAAM,0BAA0B,sBAAsB,CAAC;AAE9D;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACjC,IAAI,EAAE,MAAM,GACX,OAAO,CAQT;AAOD,gFAAgF;AAChF,MAAM,WAAW,uBAAuB;IACtC;;;;;;;;OAQG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AA8BD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,uBAAuB,CACrC,SAAS,EAAE,OAAO,EAClB,OAAO,GAAE,uBAA4B,GACpC,MAAM,CAMR;AAkBD;;;;;;;;;;GAUG;AACH,wBAAgB,4BAA4B,CAC1C,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,OAAO,EAClB,YAAY,EAAE,OAAO,GACpB,IAAI,CAYN;AAED;;;;;;GAMG;AACH,wBAAgB,4BAA4B,CAC1C,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,OAAO,EAClB,YAAY,EAAE,OAAO,EACrB,gBAAgB,UAAQ,GACvB,IAAI,CAwBN;AAED;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,OAAO,EAChB,YAAY,EAAE,MAAM,GACnB,QAAQ,CAsBV;AAqCD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,MAAM,EACf,cAAc,EAAE,MAAM,GACrB,MAAM,CAIR;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,2BAA2B,CACzC,OAAO,EAAE,OAAO,EAChB,KAAK,CAAC,EAAE,MAAM,GACb,MAAM,CAYR;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,8BAA8B,IAAI,MAAM,GAAG,SAAS,CAInE;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,2BAA2B,CACzC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACjC,IAAI,EAAE,MAAM,GACX,OAAO,CAQT;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,0BAA0B,CAC9C,OAAO,EAAE,OAAO,EAChB,IAAI,EAAE,MAAM,EACZ,YAAY,EAAE,MAAM,EACpB,YAAY,EAAE,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,GAC7C,OAAO,CAAC,QAAQ,CAAC,CA4BnB;AAED,sEAAsE;AACtE,MAAM,WAAW,gCACf,SAAQ,uBAAuB;IAC/B,oEAAoE;IACpE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;;;;;OAUG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,iCAAiC,CAC/C,SAAS,EAAE,OAAO,EAClB,OAAO,GAAE,gCAAqC,GAC7C,MAAM,CAoIR"}
1
+ {"version":3,"file":"conditional-get.d.ts","sourceRoot":"","sources":["../../src/generators/conditional-get.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAKH,mFAAmF;AACnF,eAAO,MAAM,0BAA0B,sBAAsB,CAAC;AAE9D;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACjC,IAAI,EAAE,MAAM,GACX,OAAO,CAQT;AAOD,gFAAgF;AAChF,MAAM,WAAW,uBAAuB;IACtC;;;;;;;;OAQG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AA8BD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,uBAAuB,CACrC,SAAS,EAAE,OAAO,EAClB,OAAO,GAAE,uBAA4B,GACpC,MAAM,CAMR;AAkBD;;;;;;;;;;GAUG;AACH,wBAAgB,4BAA4B,CAC1C,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,OAAO,EAClB,YAAY,EAAE,OAAO,GACpB,IAAI,CAYN;AAED;;;;;;GAMG;AACH,wBAAgB,4BAA4B,CAC1C,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,OAAO,EAClB,YAAY,EAAE,OAAO,EACrB,gBAAgB,UAAQ,GACvB,IAAI,CAwBN;AAED;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,OAAO,EAChB,YAAY,EAAE,MAAM,GACnB,QAAQ,CAsBV;AAqCD;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,MAAM,EACf,cAAc,EAAE,MAAM,EACtB,YAAY,CAAC,EAAE,MAAM,GACpB,MAAM,CAMR;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,2BAA2B,CACzC,OAAO,EAAE,OAAO,EAChB,KAAK,CAAC,EAAE,MAAM,GACb,MAAM,CAYR;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,8BAA8B,IAAI,MAAM,GAAG,SAAS,CAInE;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,2BAA2B,CACzC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACjC,IAAI,EAAE,MAAM,GACX,OAAO,CAQT;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,0BAA0B,CAC9C,OAAO,EAAE,OAAO,EAChB,IAAI,EAAE,MAAM,EACZ,YAAY,EAAE,MAAM,EACpB,YAAY,EAAE,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,GAC7C,OAAO,CAAC,QAAQ,CAAC,CA4BnB;AAED,sEAAsE;AACtE,MAAM,WAAW,gCACf,SAAQ,uBAAuB;IAC/B,oEAAoE;IACpE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;;;;;OAUG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;;;;;;OAQG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,iCAAiC,CAC/C,SAAS,EAAE,OAAO,EAClB,OAAO,GAAE,gCAAqC,GAC7C,MAAM,CAkJR"}
@@ -174,9 +174,22 @@ function conditionalJsonResponse(request, payload, cacheControl) {
174
174
  * non-negative integer with no `:` — the first colon unambiguously delimits it
175
175
  * from the representation, so `(1, ':x')` and `(1, 'x')` never collide.
176
176
  * Deterministic and carrying no per-process state, so it is replica-stable.
177
+ *
178
+ * The optional `manifestHash` (#1764) salts the digest with the build's
179
+ * web-collection SHAPE digest, closing the documented "shape change without a
180
+ * table write" staleness gap (see the v2 consistency note above): a deploy that
181
+ * changes fields / toPublicJSON / sensitive markings WITHOUT any table write
182
+ * leaves the table version unchanged, so without the salt every read ETag stays
183
+ * identical and clients keep the stale shape until the table next changes.
184
+ * Folding the build-time manifest hash in makes a shape-only redeploy bust every
185
+ * read validator. Backward-compatible: `undefined` reproduces the pre-#1764
186
+ * digest input EXACTLY (`${version}:${representation}`, no trailing separator),
187
+ * so existing callers and their tests are byte-for-byte unaffected; only a
188
+ * generated route that threads the constant appends the `:${manifestHash}` salt.
177
189
  */
178
- function computeTableVersionEtag(version, representation) {
179
- return `"${createHash("sha256").update(`${version}:${representation}`).digest("base64url")}"`;
190
+ function computeTableVersionEtag(version, representation, manifestHash) {
191
+ const input = manifestHash === void 0 ? `${version}:${representation}` : `${version}:${representation}:${manifestHash}`;
192
+ return `"${createHash("sha256").update(input).digest("base64url")}"`;
180
193
  }
181
194
  /**
182
195
  * Build a canonical, order-independent representation string for a read
@@ -358,6 +371,15 @@ function conditionalJson(request: Request, payload: unknown): Response {
358
371
  }
359
372
  `;
360
373
  const tenantScoped = options.tenantScoped === true;
374
+ const coreImports = [
375
+ "canonicalReadRepresentation",
376
+ "computeTableVersionEtag",
377
+ "getTableVersion",
378
+ "ifNoneMatchHasConcreteMatch",
379
+ "ifNoneMatchSatisfied",
380
+ ...tenantScoped ? ["resolveTenantEtagDiscriminator"] : []
381
+ ].join(",\n ");
382
+ const representationExtra = tenantScoped ? "resolveTenantEtagDiscriminator()" : "undefined";
361
383
  return `
362
384
  // Conditional GET (#1765): the ETag is the table's change-feed version keyed by
363
385
  // the request representation, so a CONCRETE If-None-Match returns 304 BEFORE the
@@ -366,18 +388,11 @@ function conditionalJson(request: Request, payload: unknown): Response {
366
388
  // private unless the model is public AND opts into shared caching via
367
389
  // @smrt({ api: { cache: { sMaxage } } }).
368
390
  import {
369
- ${[
370
- "canonicalReadRepresentation",
371
- "computeTableVersionEtag",
372
- "getTableVersion",
373
- "ifNoneMatchHasConcreteMatch",
374
- "ifNoneMatchSatisfied",
375
- ...tenantScoped ? ["resolveTenantEtagDiscriminator"] : []
376
- ].join(",\n ")},
391
+ ${coreImports},
377
392
  } from '@happyvertical/smrt-core';
378
393
 
379
394
  const READ_CACHE_CONTROL = '${cacheControl}';
380
-
395
+ ${options.manifestHash === void 0 ? "" : `\nconst MANIFEST_HASH = '${options.manifestHash}';\n`}
381
396
  async function conditionalVersionedRead(
382
397
  request: Request,
383
398
  db: Parameters<typeof getTableVersion>[0],
@@ -387,7 +402,7 @@ async function conditionalVersionedRead(
387
402
  const version = await getTableVersion(db, tableName);
388
403
  const etag = computeTableVersionEtag(
389
404
  version,
390
- canonicalReadRepresentation(request, ${tenantScoped ? "resolveTenantEtagDiscriminator()" : "undefined"}),
405
+ canonicalReadRepresentation(request, ${representationExtra})${options.manifestHash === void 0 ? "" : ",\n MANIFEST_HASH"},
391
406
  );
392
407
  const ifNoneMatch = request.headers.get('if-none-match');
393
408
  const notModified = () =>
@@ -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 * - Field-level read-permission models NEVER emit shared-cache headers: their\n * bodies vary with the caller's resolved permission set, which shared caches\n * cannot see. These routes use the v1 body-hash ETag so the validator covers\n * the redacted payload actually returned to that caller.\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 * Whether the response body varies with caller permissions because at least\n * one field has `@field({ readPermission })`. Shared caches key on URL, not\n * user permission sets, so this also fails private regardless of `sMaxage`.\n */\n permissionScoped?: 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 and permission-scoped models are ALWAYS `private, no-cache`\n * regardless of config: their response bodies vary with request identity\n * (tenant or permissions, invisible to URL-keyed shared caches), so shared\n * caching would leak one caller's representation to another caller.\n */\nexport function resolveReadCacheControl(\n apiConfig: unknown,\n options: ReadCacheControlOptions = {},\n): string {\n if (options.tenantScoped || options.permissionScoped) {\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 permissionScoped = false,\n): void {\n if (!tenantScoped && !permissionScoped) return;\n if (requestedSharedCacheControl(apiConfig) === null) return;\n const reasonKey = `${tenantScoped ? 'tenant' : ''}:${permissionScoped ? 'permission' : ''}`;\n const warningKey = `${modelName}:${reasonKey}`;\n if (sharedCacheNeutralizedWarned.has(warningKey)) return;\n sharedCacheNeutralizedWarned.add(warningKey);\n const scopeDescription =\n tenantScoped && permissionScoped\n ? 'tenant/read-permission context'\n : tenantScoped\n ? 'tenant context'\n : 'caller permissions';\n const modelDescription =\n tenantScoped && permissionScoped\n ? 'tenant-scoped/read-permission model'\n : tenantScoped\n ? 'tenant-scoped model'\n : 'read-permission model';\n console.warn(\n `[smrt] api.cache.sMaxage ignored for ${modelDescription} ${modelName}: ` +\n `shared caches cannot key on ${scopeDescription} — 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, or where `@field({ readPermission })` means the body\n * differs by caller permissions.\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 options.permissionScoped === true,\n );\n warnIfTenantScopedPublicRead(\n options.modelName,\n apiConfig,\n options.tenantScoped === true,\n );\n }\n\n // Serializer-backed or permission-scoped routes: the body can depend on data\n // the base-table version representation cannot see, so keep the v1 body-hash\n // ETag (query-first but correct). See useBodyHash.\n const useBodyHash = options.useBodyHash || options.permissionScoped === true;\n if (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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"}
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 * - Field-level read-permission models NEVER emit shared-cache headers: their\n * bodies vary with the caller's resolved permission set, which shared caches\n * cannot see. These routes use the v1 body-hash ETag so the validator covers\n * the redacted payload actually returned to that caller.\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 * Whether the response body varies with caller permissions because at least\n * one field has `@field({ readPermission })`. Shared caches key on URL, not\n * user permission sets, so this also fails private regardless of `sMaxage`.\n */\n permissionScoped?: 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 and permission-scoped models are ALWAYS `private, no-cache`\n * regardless of config: their response bodies vary with request identity\n * (tenant or permissions, invisible to URL-keyed shared caches), so shared\n * caching would leak one caller's representation to another caller.\n */\nexport function resolveReadCacheControl(\n apiConfig: unknown,\n options: ReadCacheControlOptions = {},\n): string {\n if (options.tenantScoped || options.permissionScoped) {\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 permissionScoped = false,\n): void {\n if (!tenantScoped && !permissionScoped) return;\n if (requestedSharedCacheControl(apiConfig) === null) return;\n const reasonKey = `${tenantScoped ? 'tenant' : ''}:${permissionScoped ? 'permission' : ''}`;\n const warningKey = `${modelName}:${reasonKey}`;\n if (sharedCacheNeutralizedWarned.has(warningKey)) return;\n sharedCacheNeutralizedWarned.add(warningKey);\n const scopeDescription =\n tenantScoped && permissionScoped\n ? 'tenant/read-permission context'\n : tenantScoped\n ? 'tenant context'\n : 'caller permissions';\n const modelDescription =\n tenantScoped && permissionScoped\n ? 'tenant-scoped/read-permission model'\n : tenantScoped\n ? 'tenant-scoped model'\n : 'read-permission model';\n console.warn(\n `[smrt] api.cache.sMaxage ignored for ${modelDescription} ${modelName}: ` +\n `shared caches cannot key on ${scopeDescription} — 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 *\n * The optional `manifestHash` (#1764) salts the digest with the build's\n * web-collection SHAPE digest, closing the documented \"shape change without a\n * table write\" staleness gap (see the v2 consistency note above): a deploy that\n * changes fields / toPublicJSON / sensitive markings WITHOUT any table write\n * leaves the table version unchanged, so without the salt every read ETag stays\n * identical and clients keep the stale shape until the table next changes.\n * Folding the build-time manifest hash in makes a shape-only redeploy bust every\n * read validator. Backward-compatible: `undefined` reproduces the pre-#1764\n * digest input EXACTLY (`${version}:${representation}`, no trailing separator),\n * so existing callers and their tests are byte-for-byte unaffected; only a\n * generated route that threads the constant appends the `:${manifestHash}` salt.\n */\nexport function computeTableVersionEtag(\n version: number,\n representation: string,\n manifestHash?: string,\n): string {\n const input =\n manifestHash === undefined\n ? `${version}:${representation}`\n : `${version}:${representation}:${manifestHash}`;\n return `\"${createHash('sha256').update(input).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 * The build's web-collection SHAPE digest (#1764), baked into the emitted v2\n * helper as a `MANIFEST_HASH` constant and folded into every read ETag via\n * {@link computeTableVersionEtag}. This makes a shape-only deploy (no table\n * write) bust every read validator, closing the documented v2 staleness gap.\n * Omitted (the default) reproduces the pre-#1764 emit exactly — no constant,\n * no third argument — so callers that do not thread a hash are unaffected.\n * Ignored on the v1 body-hash path, whose ETag already covers the whole body.\n */\n manifestHash?: string;\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, or where `@field({ readPermission })` means the body\n * differs by caller permissions.\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 options.permissionScoped === true,\n );\n warnIfTenantScopedPublicRead(\n options.modelName,\n apiConfig,\n options.tenantScoped === true,\n );\n }\n\n // Serializer-backed or permission-scoped routes: the body can depend on data\n // the base-table version representation cannot see, so keep the v1 body-hash\n // ETag (query-first but correct). See useBodyHash.\n const useBodyHash = options.useBodyHash || options.permissionScoped === true;\n if (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 // The build-time web-collection shape digest (#1764) salts the ETag so a\n // shape-only deploy (no table write) busts every read validator — the\n // documented v2 staleness gap. Baked in as a constant and passed as\n // computeTableVersionEtag's third argument. When absent, the emit is the\n // pre-#1764 shape exactly: no constant, no third argument (undefined\n // reproduces today's digest input byte-for-byte). The value is a base64url\n // digest (only [A-Za-z0-9_-]), so single-quote interpolation is safe.\n const manifestHashConstant =\n options.manifestHash === undefined\n ? ''\n : `\\nconst MANIFEST_HASH = '${options.manifestHash}';\\n`;\n const manifestHashArg =\n options.manifestHash === undefined ? '' : ',\\n MANIFEST_HASH';\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${manifestHashConstant}\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})${manifestHashArg},\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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"}
@@ -14,6 +14,17 @@ export interface APIConfig {
14
14
  authMiddleware?: (objectName: string, action: string) => (req: Request) => Promise<Request | Response>;
15
15
  port?: number;
16
16
  hostname?: string;
17
+ /**
18
+ * The build's web-collection SHAPE digest (#1764) — the same value the
19
+ * generated `@happyvertical/smrt-virt-web` module exports. When supplied it is
20
+ * folded into every read ETag via {@link computeTableVersionEtag}, so a
21
+ * shape-only deploy (no table write) busts every read validator, closing the
22
+ * documented v2 staleness gap. Optional and defaulting to undefined:
23
+ * non-generated / test callers behave exactly as before (no salt). The
24
+ * generated SvelteKit routes bake the same constant in directly rather than
25
+ * going through this runtime server.
26
+ */
27
+ manifestHash?: string;
17
28
  }
18
29
  export interface APIContext {
19
30
  db?: unknown;
@@ -197,7 +208,9 @@ export declare class APIGenerator {
197
208
  * version ({@link getTableVersion}) keyed by the request representation, so a
198
209
  * matching `If-None-Match` can short-circuit into a 304 BEFORE the collection
199
210
  * query runs. The representation folds in the active tenant for tenant-scoped
200
- * models so one tenant's cached ETag never satisfies another's read.
211
+ * models so one tenant's cached ETag never satisfies another's read. When an
212
+ * `APIConfig.manifestHash` is configured it is folded into the digest (#1764),
213
+ * so a shape-only deploy busts every read validator.
201
214
  */
202
215
  private computeReadEtag;
203
216
  /**
@@ -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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"}
@@ -620,10 +620,12 @@ var APIGenerator = class {
620
620
  * version ({@link getTableVersion}) keyed by the request representation, so a
621
621
  * matching `If-None-Match` can short-circuit into a 304 BEFORE the collection
622
622
  * query runs. The representation folds in the active tenant for tenant-scoped
623
- * models so one tenant's cached ETag never satisfies another's read.
623
+ * models so one tenant's cached ETag never satisfies another's read. When an
624
+ * `APIConfig.manifestHash` is configured it is folded into the digest (#1764),
625
+ * so a shape-only deploy busts every read validator.
624
626
  */
625
627
  async computeReadEtag(collection, req, objectName) {
626
- return computeTableVersionEtag(await getTableVersion(collection.db, collection.tableName), canonicalReadRepresentation(req, this.readTenantDiscriminator(objectName)));
628
+ return computeTableVersionEtag(await getTableVersion(collection.db, collection.tableName), canonicalReadRepresentation(req, this.readTenantDiscriminator(objectName)), this.config.manifestHash);
627
629
  }
628
630
  /**
629
631
  * A per-tenant ETag discriminator for tenant-scoped models: the active tenant