feat(ssr-server): enhance lookup and aggregate handling for SSR cache invalidation

- Improved parsing of `lookup` and `aggregate` options to support JSON strings and arrays.
- Added support for object format in `lookup` and `aggregate` to specify collections.
- Simplified dependency tracking for SSR cache invalidation based on new formats.
This commit is contained in:
2026-05-17 15:16:32 +00:00
parent bd8d413850
commit 349fb9b2da
4 changed files with 2136 additions and 1278 deletions
+10 -4
View File
@@ -78,14 +78,17 @@ When content changes, `clear_cache.js` only invalidates SSR entries that depend
- `col:id` OR `col:*` on `PUT`/`DELETE` - `col:id` OR `col:*` on `PUT`/`DELETE`
- everything on manual clear (`POST /ssr?clear=1` with no collection context) - everything on manual clear (`POST /ssr?clear=1` with no collection context)
## Limit: 1 ensures precise dependencies ## Limit: 1 ensures precise dependencies
By default, an API query for a collection (like `/api/v1/_/content?filter=...`) sets a list dependency `collection:*`. This means *any* change to ANY entry in that collection will clear the SSR cache for this page. By default, an API query for a collection (like `/api/v1/_/content?filter=...`) sets a list dependency `collection:*`. This means _any_ change to ANY entry in that collection will clear the SSR cache for this page.
If you are querying a single document (like a page or article based on its path or slug), you should ALWAYS append `limit: 1` to your API call (or pass `limit=1` to `getDBEntries`). If you are querying a single document (like a page or article based on its path or slug), you should ALWAYS append `limit: 1` to your API call (or pass `{ filter: {...}, limit: 1 }` to `getDBEntries`).
When `api/hooks/lib/ssr-server.js` intercepts a request with `limit === 1` and exactly one result is returned, it will register a precise `collection:id` dependency instead of a wildcard `collection:*`. This optimizes the cache drastically, because edits to *other* pages won't invalidate this page. When `api/hooks/lib/ssr-server.js` intercepts a request with `limit === 1` and exactly one result is returned, it will register a precise `collection:id` dependency instead of a wildcard `collection:*`. This optimizes the cache drastically, because edits to _other_ pages won't invalidate this page.
### Automatic dependency tracking via `lookup` and `aggregate`
When options like `lookup` (e.g. `lookup: "image:medialib"`) or `aggregate` (e.g. `aggregate: "comments:contentId:count"`) are provided to an API call, `ssr-server.js` automatically parses these values and adds wildcard cache dependencies (`medialib:*` or `comments:*`) to the page. This guarantees that if a referenced image or child comment changes, the parent's SSR HTML is correctly flushed.
## How SSR data loading is supposed to work ## How SSR data loading is supposed to work
@@ -181,6 +184,7 @@ if (typeof window === "undefined") {
``` ```
**Why this works:** **Why this works:**
- The `tibi-types` package declares `var context: HookContext` as a global (available because goja provides it during SSR). - The `tibi-types` package declares `var context: HookContext` as a global (available because goja provides it during SSR).
- During SSR, `loadContent()` runs synchronously (goja transforms `async`/`await`). - During SSR, `loadContent()` runs synchronously (goja transforms `async`/`await`).
- By the time `render(App)` returns in `ssr.ts`, `context.is404` is already `true`. - By the time `render(App)` returns in `ssr.ts`, `context.is404` is already `true`.
@@ -254,6 +258,7 @@ $effect(() => { loadData() })
``` ```
**Warum das funktioniert:** **Warum das funktioniert:**
- `loadData()` läuft während der Component-Initialisierung (vor Template-Auswertung) - `loadData()` läuft während der Component-Initialisierung (vor Template-Auswertung)
- `getCachedEntries``apiRequest` → SSR-Pfad → `context.ssrRequest()` → blockierender HTTP-Fetch in goja - `getCachedEntries``apiRequest` → SSR-Pfad → `context.ssrRequest()` → blockierender HTTP-Fetch in goja
- goja's `await` auf einem bereits aufgelösten Promise läuft synchron weiter (kein Microtask-Hickhack) - goja's `await` auf einem bereits aufgelösten Promise läuft synchron weiter (kein Microtask-Hickhack)
@@ -275,6 +280,7 @@ pathRewrite: function (path, req) {
Ohne `noCache` wird die erste SSR-Antwort gecached und bei Code-Änderungen nicht invalidiert. Der Entwickler sieht immer den alten Stand. **Immer zuerst den Cache-Bypass prüfen, bevor SSR-Fehler gesucht werden.** Ohne `noCache` wird die erste SSR-Antwort gecached und bei Code-Änderungen nicht invalidiert. Der Entwickler sieht immer den alten Stand. **Immer zuerst den Cache-Bypass prüfen, bevor SSR-Fehler gesucht werden.**
**Erkennungsmerkmale für veralteten SSR-Cache:** **Erkennungsmerkmale für veralteten SSR-Cache:**
- `X-SSR-Cache: true` im Response-Header - `X-SSR-Cache: true` im Response-Header
- `<!--COMMENT--><!--SSR.ERROR-->` im HTML - `<!--COMMENT--><!--SSR.ERROR-->` im HTML
- `__SSR_CACHE__` enthält nicht die erwarteten Daten - `__SSR_CACHE__` enthält nicht die erwarteten Daten
+1 -1
View File
@@ -1,2 +1,2 @@
ADMIN_TOKEN=5bdfjc78hdxn338cuhSJ ADMIN_TOKEN=5bdfjc78hdxn338cuhSJ
ADMIN_ASSET_VERSION=db968ab-dirty-1779028656102 ADMIN_ASSET_VERSION=db968ab-dirty-1779030587180
+2064 -1244
View File
File diff suppressed because it is too large Load Diff
+61 -29
View File
@@ -93,42 +93,74 @@ function ssrRequest(cacheKey, endpoint, query, options) {
// Both `lookup` and `aggregate` parameters can inject data from other collections. // Both `lookup` and `aggregate` parameters can inject data from other collections.
// We must invalidate the SSR cache if any of those referenced collections change. // We must invalidate the SSR cache if any of those referenced collections change.
if (options && options.lookup) { if (options && options.lookup) {
const lookups = typeof options.lookup === "string" ? options.lookup.split(",") : []; /** @type {any[]} */
let lookups = []
if (typeof options.lookup === "string") {
const trimmed = options.lookup.trim()
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
try {
const parsed = JSON.parse(trimmed)
lookups = Array.isArray(parsed) ? parsed : [parsed]
} catch (e) {
lookups = options.lookup.split(",")
}
} else {
lookups = options.lookup.split(",")
}
} else if (Array.isArray(options.lookup)) {
lookups = options.lookup
} else if (typeof options.lookup === "object" && options.lookup !== null) {
lookups = [options.lookup]
}
for (const l of lookups) { for (const l of lookups) {
// format: "fieldPath:collectionName" if (typeof l === "object" && l !== null && l.collection) {
const parts = l.split(":");
if (parts.length > 1) {
const targetCollection = parts[parts.length - 1];
// @ts-ignore // @ts-ignore
context.ssrDeps[targetCollection + ":*"] = true; context.ssrDeps[l.collection + ":*"] = true
} else if (typeof l === "string") {
// format: "fieldPath:collectionName"
const parts = l.split(":")
if (parts.length > 1) {
const targetCollection = parts[parts.length - 1]
// @ts-ignore
context.ssrDeps[targetCollection + ":*"] = true
}
} }
} }
} }
const rawAggregate = (options && options.aggregate) || (options && options.params && options.params.aggregate); const rawAggregate = (options && options.aggregate) || (options && options.params && options.params.aggregate)
if (rawAggregate) { if (rawAggregate) {
const aggregates = typeof rawAggregate === "string" /** @type {any[]} */
? rawAggregate.split(",") let aggregates = []
: []; if (typeof rawAggregate === "string") {
for (const a of aggregates) { const trimmed = rawAggregate.trim()
// simple format: "collectionName:foreignField:..." if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
// json format: '{"collection":"comments",...}' try {
try { const parsed = JSON.parse(trimmed)
if (a.startsWith("{")) { aggregates = Array.isArray(parsed) ? parsed : [parsed]
const parsed = JSON.parse(a); } catch (e) {
if (parsed && parsed.collection) { aggregates = rawAggregate.split(",")
// @ts-ignore }
context.ssrDeps[parsed.collection + ":*"] = true; } else {
} aggregates = rawAggregate.split(",")
} else { }
const parts = a.split(":"); } else if (Array.isArray(rawAggregate)) {
if (parts.length > 0) { aggregates = rawAggregate
const targetCollection = parts[0]; } else if (typeof rawAggregate === "object" && rawAggregate !== null) {
// @ts-ignore aggregates = [rawAggregate]
context.ssrDeps[targetCollection + ":*"] = true; }
}
for (const a of aggregates) {
if (typeof a === "object" && a !== null && a.collection) {
// @ts-ignore
context.ssrDeps[a.collection + ":*"] = true
} else if (typeof a === "string") {
const parts = a.split(":")
if (parts.length > 0) {
const targetCollection = parts[0]
// @ts-ignore
context.ssrDeps[targetCollection + ":*"] = true
} }
} catch (e) {
// silently ignore parse errors here
} }
} }
} }