✨ 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:
@@ -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
@@ -1,2 +1,2 @@
|
|||||||
ADMIN_TOKEN=5bdfjc78hdxn338cuhSJ
|
ADMIN_TOKEN=5bdfjc78hdxn338cuhSJ
|
||||||
ADMIN_ASSET_VERSION=db968ab-dirty-1779028656102
|
ADMIN_ASSET_VERSION=db968ab-dirty-1779030587180
|
||||||
|
|||||||
+2064
-1244
File diff suppressed because it is too large
Load Diff
+61
-29
@@ -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[]} */
|
||||||
for (const l of lookups) {
|
let lookups = []
|
||||||
// format: "fieldPath:collectionName"
|
if (typeof options.lookup === "string") {
|
||||||
const parts = l.split(":");
|
const trimmed = options.lookup.trim()
|
||||||
if (parts.length > 1) {
|
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
|
||||||
const targetCollection = parts[parts.length - 1];
|
|
||||||
// @ts-ignore
|
|
||||||
context.ssrDeps[targetCollection + ":*"] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const rawAggregate = (options && options.aggregate) || (options && options.params && options.params.aggregate);
|
|
||||||
if (rawAggregate) {
|
|
||||||
const aggregates = typeof rawAggregate === "string"
|
|
||||||
? rawAggregate.split(",")
|
|
||||||
: [];
|
|
||||||
for (const a of aggregates) {
|
|
||||||
// simple format: "collectionName:foreignField:..."
|
|
||||||
// json format: '{"collection":"comments",...}'
|
|
||||||
try {
|
try {
|
||||||
if (a.startsWith("{")) {
|
const parsed = JSON.parse(trimmed)
|
||||||
const parsed = JSON.parse(a);
|
lookups = Array.isArray(parsed) ? parsed : [parsed]
|
||||||
if (parsed && parsed.collection) {
|
} catch (e) {
|
||||||
// @ts-ignore
|
lookups = options.lookup.split(",")
|
||||||
context.ssrDeps[parsed.collection + ":*"] = true;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const parts = a.split(":");
|
lookups = options.lookup.split(",")
|
||||||
if (parts.length > 0) {
|
}
|
||||||
const targetCollection = parts[0];
|
} 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) {
|
||||||
|
if (typeof l === "object" && l !== null && l.collection) {
|
||||||
// @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)
|
||||||
|
if (rawAggregate) {
|
||||||
|
/** @type {any[]} */
|
||||||
|
let aggregates = []
|
||||||
|
if (typeof rawAggregate === "string") {
|
||||||
|
const trimmed = rawAggregate.trim()
|
||||||
|
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed)
|
||||||
|
aggregates = Array.isArray(parsed) ? parsed : [parsed]
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// silently ignore parse errors here
|
aggregates = rawAggregate.split(",")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
aggregates = rawAggregate.split(",")
|
||||||
|
}
|
||||||
|
} else if (Array.isArray(rawAggregate)) {
|
||||||
|
aggregates = rawAggregate
|
||||||
|
} else if (typeof rawAggregate === "object" && rawAggregate !== null) {
|
||||||
|
aggregates = [rawAggregate]
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user