@happyvertical/smrt-core 0.38.0 → 0.38.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -1
- package/dist/change-feed.d.ts +40 -0
- package/dist/change-feed.d.ts.map +1 -1
- package/dist/change-feed.js +49 -1
- package/dist/change-feed.js.map +1 -1
- package/dist/generators/conditional-get.d.ts +122 -8
- package/dist/generators/conditional-get.d.ts.map +1 -1
- package/dist/generators/conditional-get.js +210 -13
- package/dist/generators/conditional-get.js.map +1 -1
- package/dist/generators/index.d.ts +1 -1
- package/dist/generators/index.d.ts.map +1 -1
- package/dist/generators/index.js +2 -2
- package/dist/generators/rest.d.ts +22 -8
- package/dist/generators/rest.d.ts.map +1 -1
- package/dist/generators/rest.js +84 -42
- package/dist/generators/rest.js.map +1 -1
- package/dist/generators.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/manifest/static-manifest.js +2 -2
- package/dist/manifest/static-manifest.js.map +1 -1
- package/dist/manifest/store.js +1 -1
- package/dist/manifest/test-manifest-stub.d.ts.map +1 -1
- package/dist/manifest/test-manifest-stub.js +240 -2
- package/dist/manifest/test-manifest-stub.js.map +1 -1
- package/dist/manifest.json +2 -2
- package/dist/smrt-knowledge.json +6 -6
- package/dist/vite-plugin/sveltekit-generator.js +26 -14
- package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
- package/package.json +4 -4
|
@@ -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
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
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
|
|
174
|
-
//
|
|
175
|
-
//
|
|
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;
|
|
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"}
|
package/dist/generators/index.js
CHANGED
|
@@ -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
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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":"AAAA;;;;GAIG;AAEH,OAAO,IAAI,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"rest.d.ts","sourceRoot":"","sources":["../../src/generators/rest.ts"],"names":[],"mappings":"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"}
|
package/dist/generators/rest.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
*
|
|
520
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|