From a12a6bbc9f6dc6130861a6c12a8e6b715d8a7764 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Fri, 3 Apr 2026 06:59:59 +0000 Subject: [PATCH 1/9] docs: add brainstorm for language cache performance fix Co-Authored-By: Claude Opus 4.6 (1M context) --- ...language-cache-performance-requirements.md | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 docs/brainstorms/2026-04-03-manager-language-cache-performance-requirements.md diff --git a/docs/brainstorms/2026-04-03-manager-language-cache-performance-requirements.md b/docs/brainstorms/2026-04-03-manager-language-cache-performance-requirements.md new file mode 100644 index 00000000..11dbb608 --- /dev/null +++ b/docs/brainstorms/2026-04-03-manager-language-cache-performance-requirements.md @@ -0,0 +1,47 @@ +--- +date: 2026-04-03 +topic: manager-language-cache-performance +--- + +# Manager Language Cache Performance + +## Problem Frame + +The manager's language cache fetches continent, country, language, and country-language data from the CMS via 4 parallel GraphQL queries on every cache refresh (every 24h and on startup). The `countryLanguages_connection` query returns 6,598 rows with nested relations (`country { coreId, continent { coreId } }`, `language { coreId }`). Strapi v5 GraphQL has no DataLoader batching — each nested relation on each row fires a separate `findOne` DB query. This produces ~20K individual DB queries, exhausting the 25-connection pool and blocking all other CMS requests (including auth checks) for the duration. + +This is the same N+1 pattern that caused the video coverage performance issue (PR #637), but for the language/geo data path. + +## Evidence + +- CMS logs show `KnexTimeoutError: pool is probably full` during language cache warm +- GraphQL association resolver stack traces (`association.js:59 → entity-manager load → findOne`) +- `/api/users/me` took 167 seconds during a language cache refresh +- `/api/auth/local` took 73 seconds +- Tables: 6,598 country-languages, 2,280 languages, 240 countries, 6 continents + +## Current Implementation + +- `apps/manager/src/app/api/languages/route.ts` — 4 parallel GraphQL queries via Apollo +- `apps/manager/src/instrumentation.ts` — warms on startup alongside video and coverage caches +- SWR cache: 24h TTL, 48h maxStale + +## Requirements + +- R1. Language/geo data fetch must not exhaust the CMS connection pool +- R2. Language cache refresh must complete in under 5 seconds (currently can take 30s+ and block everything) +- R3. Auth checks (`/api/users/me`) must remain responsive during language cache refresh +- R4. Language picker must still show all ~4,560 languages grouped by continent and country with speaker counts + +## Suggested Approach + +Follow the same pattern as the video-coverage fix: create a custom CMS REST endpoint (`/api/language-geo`) that returns all the data in a single SQL query using raw knex, bypassing GraphQL entirely. The SQL would join `country_languages`, `languages`, `countries`, and `continents` in one query — milliseconds instead of 20K individual queries. + +## Scope Boundaries + +- Video coverage endpoint is already fixed (PR #637) — not in scope +- No changes to the language picker UI +- No changes to the country-language data model + +## Next Steps + +-> `/ce:plan` for structured implementation planning From d15f14a52c8bb753a35e5efe520fbd563da93da1 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Fri, 3 Apr 2026 07:07:24 +0000 Subject: [PATCH 2/9] fix(cms): add /api/language-geo endpoint bypassing GraphQL N+1 --- .../language-geo/controllers/language-geo.ts | 19 +++ .../api/language-geo/routes/language-geo.ts | 14 ++ .../api/language-geo/services/language-geo.ts | 152 ++++++++++++++++++ 3 files changed, 185 insertions(+) create mode 100644 apps/cms/src/api/language-geo/controllers/language-geo.ts create mode 100644 apps/cms/src/api/language-geo/routes/language-geo.ts create mode 100644 apps/cms/src/api/language-geo/services/language-geo.ts diff --git a/apps/cms/src/api/language-geo/controllers/language-geo.ts b/apps/cms/src/api/language-geo/controllers/language-geo.ts new file mode 100644 index 00000000..bc8fdac8 --- /dev/null +++ b/apps/cms/src/api/language-geo/controllers/language-geo.ts @@ -0,0 +1,19 @@ +import type { Core } from "@strapi/strapi" +import { queryLanguageGeo } from "../services/language-geo" + +type StrapiContext = { + status: number + body: unknown +} + +export default ({ strapi }: { strapi: Core.Strapi }) => ({ + async index(ctx: StrapiContext) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const knex = (strapi.db as any).connection + + const data = await queryLanguageGeo(knex) + + ctx.status = 200 + ctx.body = data + }, +}) diff --git a/apps/cms/src/api/language-geo/routes/language-geo.ts b/apps/cms/src/api/language-geo/routes/language-geo.ts new file mode 100644 index 00000000..746a5426 --- /dev/null +++ b/apps/cms/src/api/language-geo/routes/language-geo.ts @@ -0,0 +1,14 @@ +export default { + routes: [ + { + method: "GET", + path: "/language-geo", + handler: "language-geo.index", + config: { + auth: false, + policies: [], + middlewares: [], + }, + }, + ], +} diff --git a/apps/cms/src/api/language-geo/services/language-geo.ts b/apps/cms/src/api/language-geo/services/language-geo.ts new file mode 100644 index 00000000..478505bb --- /dev/null +++ b/apps/cms/src/api/language-geo/services/language-geo.ts @@ -0,0 +1,152 @@ +/** + * Language Geo Service + * + * Returns all language, country, and continent data in a single SQL query, + * bypassing GraphQL's N+1 problem (no DataLoader in Strapi v5). + * + * Critical: All queries filter `published_at IS NOT NULL` to avoid + * counting Strapi v5 draft rows. + */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type KnexInstance = any + +type RawRow = { + lang_core_id: string | null + lang_document_id: string + lang_name: string | null + speakers: number | null + country_core_id: string | null + country_document_id: string + country_name: string | null + continent_core_id: string | null + continent_document_id: string + continent_name: string | null +} + +type Continent = { + id: string + name: string +} + +type Country = { + id: string + name: string + continentId: string +} + +type Language = { + id: string + englishLabel: string + nativeLabel: string + countryIds: string[] + continentIds: string[] + countrySpeakers: Record +} + +type LanguageGeoResult = { + continents: Continent[] + countries: Country[] + languages: Language[] +} + +export async function queryLanguageGeo( + knex: KnexInstance, +): Promise { + const sql = ` + SELECT + l.core_id AS lang_core_id, + l.document_id AS lang_document_id, + l.name AS lang_name, + cl.speakers, + c.core_id AS country_core_id, + c.document_id AS country_document_id, + c.name AS country_name, + ct.core_id AS continent_core_id, + ct.document_id AS continent_document_id, + ct.name AS continent_name + FROM country_languages cl + JOIN country_languages_language_lnk cll ON cll.country_language_id = cl.id + JOIN country_languages_country_lnk clc ON clc.country_language_id = cl.id + JOIN languages l ON l.id = cll.language_id AND l.published_at IS NOT NULL + JOIN countries c ON c.id = clc.country_id AND c.published_at IS NOT NULL + JOIN countries_continent_lnk ccl ON ccl.country_id = c.id + JOIN continents ct ON ct.id = ccl.continent_id AND ct.published_at IS NOT NULL + WHERE cl.published_at IS NOT NULL + ` + + const result: { rows: RawRow[] } = await knex.raw(sql) + + // Aggregate raw rows into the denormalized shape the manager expects + const continentMap = new Map() + const countryMap = new Map() + const langCountryIds = new Map>() + const langContinentIds = new Map>() + const langCountrySpeakers = new Map>() + const langNames = new Map() + + for (const row of result.rows) { + const continentId = String( + row.continent_core_id ?? row.continent_document_id, + ) + const countryId = String(row.country_core_id ?? row.country_document_id) + const langId = String(row.lang_core_id ?? row.lang_document_id) + + // Collect unique continents + if (!continentMap.has(continentId)) { + continentMap.set(continentId, { + id: continentId, + name: String(row.continent_name ?? ""), + }) + } + + // Collect unique countries + if (!countryMap.has(countryId)) { + countryMap.set(countryId, { + id: countryId, + name: String(row.country_name ?? ""), + continentId, + }) + } + + // Track language name + if (!langNames.has(langId)) { + langNames.set(langId, String(row.lang_name ?? langId)) + } + + // Track country IDs per language + if (!langCountryIds.has(langId)) langCountryIds.set(langId, new Set()) + langCountryIds.get(langId)!.add(countryId) + + // Track continent IDs per language + if (!langContinentIds.has(langId)) langContinentIds.set(langId, new Set()) + langContinentIds.get(langId)!.add(continentId) + + // Aggregate speakers per language per country + const speakers = row.speakers ?? 0 + if (speakers > 0) { + if (!langCountrySpeakers.has(langId)) langCountrySpeakers.set(langId, {}) + const existing = langCountrySpeakers.get(langId)! + existing[countryId] = (existing[countryId] ?? 0) + speakers + } + } + + // Build languages array from all unique language IDs + const languages: Language[] = [] + for (const [langId, name] of langNames) { + languages.push({ + id: langId, + englishLabel: name, + nativeLabel: name, + countryIds: Array.from(langCountryIds.get(langId) ?? []), + continentIds: Array.from(langContinentIds.get(langId) ?? []), + countrySpeakers: langCountrySpeakers.get(langId) ?? {}, + }) + } + + return { + continents: Array.from(continentMap.values()), + countries: Array.from(countryMap.values()), + languages, + } +} From d7159b23b456fc9ef8303eb5d082f44344a46f73 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Fri, 3 Apr 2026 07:08:06 +0000 Subject: [PATCH 3/9] fix(manager): replace GraphQL N+1 with CMS REST endpoint for language cache --- apps/manager/src/app/api/languages/route.ts | 206 +++----------------- 1 file changed, 31 insertions(+), 175 deletions(-) diff --git a/apps/manager/src/app/api/languages/route.ts b/apps/manager/src/app/api/languages/route.ts index 485f7bf5..e3651ecb 100644 --- a/apps/manager/src/app/api/languages/route.ts +++ b/apps/manager/src/app/api/languages/route.ts @@ -1,196 +1,52 @@ import { NextResponse } from "next/server" -import { graphql } from "@forge/graphql" import { authenticateRequest } from "@/lib/auth" -import getClient from "@/cms/client" -import { - type PageInfo, - DEFAULT_PAGE_INFO, - fetchAllPages, -} from "@/lib/strapi-pagination" +import { env } from "@/config/env" import { createSwrCache } from "@/lib/swr-cache" // --------------------------------------------------------------------------- -// Typed queries +// Types from CMS /api/language-geo endpoint // --------------------------------------------------------------------------- -const GET_CONTINENTS = graphql(` - query GetContinentsApi { - continents { - documentId - coreId - name - } - } -`) - -const GET_COUNTRIES_CONNECTION = graphql(` - query GetCountriesApi($pagination: PaginationArg) { - countries_connection(pagination: $pagination) { - nodes { - documentId - coreId - name - continent { - coreId - } - } - pageInfo { - page - pageCount - pageSize - total - } - } - } -`) - -const GET_LANGUAGES_CONNECTION = graphql(` - query GetLanguagesApi($pagination: PaginationArg) { - languages_connection(pagination: $pagination) { - nodes { - documentId - coreId - name - } - pageInfo { - page - pageCount - pageSize - total - } - } - } -`) - -const GET_COUNTRY_LANGUAGES_CONNECTION = graphql(` - query GetCountryLanguagesApi($pagination: PaginationArg) { - countryLanguages_connection(pagination: $pagination) { - nodes { - documentId - coreId - speakers - language { - coreId - } - country { - coreId - continent { - coreId - } - } - } - pageInfo { - page - pageCount - pageSize - total - } - } - } -`) +type CmsLanguageGeo = { + continents: Array<{ id: string; name: string }> + countries: Array<{ id: string; name: string; continentId: string }> + languages: Array<{ + id: string + englishLabel: string + nativeLabel: string + countryIds: string[] + continentIds: string[] + countrySpeakers: Record + }> +} // --------------------------------------------------------------------------- -// SWR cache (geo data changes only on core sync) -// Caches pre-serialized JSON string for zero-cost response serving. +// Fetch from CMS language-geo endpoint // --------------------------------------------------------------------------- async function fetchLanguagePayload(): Promise { - const client = getClient() - - const [continentsResult, countryNodes, languageNodes, countryLanguageNodes] = - await Promise.all([ - client.query({ query: GET_CONTINENTS, fetchPolicy: "no-cache" }), - fetchAllPages(async (page) => { - const result = await client.query({ - query: GET_COUNTRIES_CONNECTION, - variables: { pagination: { page, pageSize: 5000 } }, - fetchPolicy: "no-cache", - }) - const conn = result.data?.countries_connection - return { - nodes: conn?.nodes ?? [], - pageInfo: (conn?.pageInfo ?? DEFAULT_PAGE_INFO) as PageInfo, - } - }), - fetchAllPages(async (page) => { - const result = await client.query({ - query: GET_LANGUAGES_CONNECTION, - variables: { pagination: { page, pageSize: 5000 } }, - fetchPolicy: "no-cache", - }) - const conn = result.data?.languages_connection - return { - nodes: conn?.nodes ?? [], - pageInfo: (conn?.pageInfo ?? DEFAULT_PAGE_INFO) as PageInfo, - } - }), - fetchAllPages(async (page) => { - const result = await client.query({ - query: GET_COUNTRY_LANGUAGES_CONNECTION, - variables: { pagination: { page, pageSize: 5000 } }, - fetchPolicy: "no-cache", - }) - const conn = result.data?.countryLanguages_connection - return { - nodes: conn?.nodes ?? [], - pageInfo: (conn?.pageInfo ?? DEFAULT_PAGE_INFO) as PageInfo, - } - }), - ]) - - const continents = (continentsResult.data?.continents ?? []) - .filter((c): c is NonNullable => c != null) - .map((c) => ({ - id: String(c.coreId ?? c.documentId), - name: String(c.name ?? ""), - })) - - const countries = countryNodes.map((c) => ({ - id: String(c.coreId ?? c.documentId), - name: String(c.name ?? ""), - continentId: String(c.continent?.coreId ?? ""), - })) + const url = `${env.STRAPI_URL}/api/language-geo` - const langCountryIds = new Map>() - const langContinentIds = new Map>() - const langCountrySpeakers = new Map>() - - for (const cl of countryLanguageNodes) { - const langId = String(cl.language?.coreId ?? "") - const countryId = String(cl.country?.coreId ?? "") - const continentId = String(cl.country?.continent?.coreId ?? "") - const speakers = cl.speakers ?? 0 - - if (!langId) continue - - if (!langCountryIds.has(langId)) langCountryIds.set(langId, new Set()) - if (countryId) langCountryIds.get(langId)!.add(countryId) - - if (!langContinentIds.has(langId)) langContinentIds.set(langId, new Set()) - if (continentId) langContinentIds.get(langId)!.add(continentId) + const response = await fetch(url, { + headers: { Authorization: `Bearer ${env.STRAPI_API_TOKEN}` }, + signal: AbortSignal.timeout(10_000), + }) - if (!langCountrySpeakers.has(langId)) langCountrySpeakers.set(langId, {}) - if (countryId && speakers > 0) { - const existing = langCountrySpeakers.get(langId)! - existing[countryId] = (existing[countryId] ?? 0) + speakers - } + if (!response.ok) { + throw new Error( + `CMS /api/language-geo returned ${response.status}: ${await response.text()}`, + ) } - const languages = languageNodes.map((l) => { - const id = String(l.coreId ?? l.documentId) - return { - id, - englishLabel: String(l.name ?? id), - nativeLabel: String(l.name ?? id), - countryIds: Array.from(langCountryIds.get(id) ?? []), - continentIds: Array.from(langContinentIds.get(id) ?? []), - countrySpeakers: langCountrySpeakers.get(id) ?? {}, - } - }) - - return JSON.stringify({ continents, countries, languages }) + const data = (await response.json()) as CmsLanguageGeo + return JSON.stringify(data) } +// --------------------------------------------------------------------------- +// SWR cache (geo data changes only on core sync) +// Caches pre-serialized JSON string for zero-cost response serving. +// --------------------------------------------------------------------------- + export const languageCache = createSwrCache({ fetcher: fetchLanguagePayload, ttlMs: 24 * 60 * 60_000, // 24 hours — geo data changes only on core sync From bcfd888db7eb54793ac1afbdf323296cb5b88d56 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Fri, 3 Apr 2026 07:08:20 +0000 Subject: [PATCH 4/9] docs: mark language cache performance plan complete --- ...manager-language-cache-performance-plan.md | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 docs/plans/2026-04-03-001-fix-manager-language-cache-performance-plan.md diff --git a/docs/plans/2026-04-03-001-fix-manager-language-cache-performance-plan.md b/docs/plans/2026-04-03-001-fix-manager-language-cache-performance-plan.md new file mode 100644 index 00000000..f552a3c6 --- /dev/null +++ b/docs/plans/2026-04-03-001-fix-manager-language-cache-performance-plan.md @@ -0,0 +1,182 @@ +--- +title: "fix: Bypass GraphQL N+1 for language/geo cache with custom CMS REST endpoint" +type: fix +status: completed +date: 2026-04-03 +origin: docs/brainstorms/2026-04-03-manager-language-cache-performance-requirements.md +--- + +# fix: Bypass GraphQL N+1 for language/geo cache with custom CMS REST endpoint + +## Overview + +Create a custom CMS REST endpoint (`/api/language-geo`) that returns all language, country, continent, and country-language data in a single SQL query using raw knex. Update the manager's language route to call this endpoint instead of 4 parallel GraphQL queries that produce ~20K individual DB queries via Strapi v5's unpatched N+1 problem. + +## Problem Frame + +The manager's language cache warm fires 4 parallel GraphQL queries on startup and every 24h. The `countryLanguages_connection` query returns 6,598 rows, each with nested `language`, `country`, and `country.continent` relations. Strapi v5 GraphQL has no DataLoader batching — every nested relation fires a separate `findOne` DB query. This produces ~20K queries, exhausts the 25-connection PostgreSQL pool, and blocks all other CMS requests (auth checks took 73-167 seconds during a language cache refresh). + +This is the same N+1 pattern fixed for video coverage in PR #637. (see origin: `docs/brainstorms/2026-04-03-manager-language-cache-performance-requirements.md`) + +## Requirements Trace + +- R1. Language/geo data fetch must not exhaust the CMS connection pool +- R2. Language cache refresh must complete in under 5 seconds (currently 30s+) +- R3. Auth checks (`/api/users/me`) must remain responsive during language cache refresh +- R4. Language picker must still show all ~4,560 languages grouped by continent and country with speaker counts + +## Scope Boundaries + +- Video coverage endpoint already fixed (PR #637) — not in scope +- No changes to the language picker UI +- No changes to the country-language data model +- No changes to the SWR cache utility (`src/lib/swr-cache.ts`) + +## Context & Research + +### Relevant Code and Patterns + +- **Video coverage endpoint (the template):** + - Route: `apps/cms/src/api/video-coverage/routes/video-coverage.ts` + - Controller: `apps/cms/src/api/video-coverage/controllers/video-coverage.ts` + - Service: `apps/cms/src/api/video-coverage/services/video-coverage.ts` +- **Current language route:** `apps/manager/src/app/api/languages/route.ts` — 4 parallel GraphQL queries, transforms into `{ continents, countries, languages }` shape +- **Manager video route (consumption pattern):** `apps/manager/src/app/api/videos/route.ts` — calls `fetch(${STRAPI_URL}/api/video-coverage)` with Bearer token +- **Cache warming:** `apps/manager/src/instrumentation.ts` +- **SWR cache:** `apps/manager/src/lib/swr-cache.ts` +- **Env config:** `apps/manager/src/config/env.ts` + +### Database Schema (Strapi v5 conventions) + +All tables use `published_at IS NOT NULL` to filter out draft rows. + +| Table | Key columns | Link tables | +| ------------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `continents` | `id`, `core_id`, `name`, `published_at` | — | +| `countries` | `id`, `core_id`, `name`, `published_at` | `countries_continent_lnk` (`country_id`, `continent_id`) | +| `languages` | `id`, `core_id`, `name`, `published_at` | — | +| `country_languages` | `id`, `core_id`, `speakers`, `published_at` | `country_languages_language_lnk` (`country_language_id`, `language_id`), `country_languages_country_lnk` (`country_language_id`, `country_id`) | + +### Institutional Learnings + +- **`docs/solutions/performance-issues/manager-video-coverage-sql-aggregation-20260402.md`**: Strapi v5 GraphQL has no DataLoader. Proven escape hatch: custom REST endpoint with raw knex. Always filter `published_at IS NOT NULL`. Result: 60ms with filter, 660ms global (down from 22-47s). +- **`docs/solutions/performance-issues/strapi-nested-relation-truncation-and-n-plus-one-manager-20260328.md`**: Strapi v5 GraphQL silently truncates nested relations to 10 items unless `pagination: { limit: -1 }` is passed. +- **`docs/solutions/performance-issues/swr-cache-failure-backoff-manager-20260331.md`**: SWR cache has failure backoff (circuit breaker) — no changes needed there. + +## Key Technical Decisions + +- **Single denormalized SQL query**: Join all 4 tables + 3 link tables in one query returning ~6,598 rows. Aggregate in the CMS service layer (not the manager) to return the exact `{ continents, countries, languages }` shape the manager expects. This minimizes data transfer and keeps aggregation close to the data. +- **Aggregate in CMS service, not SQL**: Use JS aggregation in the service layer rather than SQL `GROUP BY` / `json_agg`. The video-coverage endpoint uses this pattern, it's simpler to maintain, and the row count (6,598) is small enough that in-memory aggregation is fast. +- **`auth: false` on route**: Same as video-coverage. The manager authenticates via its API token at the application level, not through Strapi's built-in auth middleware. +- **Return `coreId` as primary identifier**: The manager uses `coreId` (falling back to `documentId`) as the canonical ID. The endpoint should return `coreId` to match the existing data contract. + +## Open Questions + +### Resolved During Planning + +- **Should aggregation happen in SQL or JS?** JS in the CMS service. Simpler, follows video-coverage pattern, ~6.6K rows is trivial to aggregate in memory. +- **Should the endpoint return raw rows or the aggregated shape?** Aggregated shape matching current contract. Keeps the manager route change minimal (swap fetch source, remove transform logic). + +### Deferred to Implementation + +- **Exact column aliasing in the SQL query**: Will be finalized when writing the service, verifying against actual DB column names. +- **Whether `document_id` fallback is still needed**: The current code falls back to `documentId` when `coreId` is absent. Will verify during implementation whether any rows lack `core_id`. + +## Implementation Units + +- [x] **Unit 1: Create CMS `/api/language-geo` endpoint** + + **Goal:** New custom REST endpoint that returns all language/geo data in a single SQL query, aggregated into the `{ continents, countries, languages }` shape. + + **Requirements:** R1, R2 + + **Dependencies:** None + + **Files:** + - Create: `apps/cms/src/api/language-geo/routes/language-geo.ts` + - Create: `apps/cms/src/api/language-geo/controllers/language-geo.ts` + - Create: `apps/cms/src/api/language-geo/services/language-geo.ts` + + **Approach:** + - Route: single GET at `/language-geo`, `auth: false`, handler `"language-geo.index"` + - Controller: get knex via `(strapi.db as any).connection`, delegate to service, set `ctx.body` + - Service: single SQL query joining `country_languages` → `languages`, `countries`, `continents` through their link tables. Filter `published_at IS NOT NULL` on all content tables. Return raw rows, then aggregate in JS to produce: + ``` + { + continents: [{ id, name }], + countries: [{ id, name, continentId }], + languages: [{ id, englishLabel, nativeLabel, countryIds, continentIds, countrySpeakers }] + } + ``` + - Use `core_id` as the `id` field, with `document_id` fallback + + **Patterns to follow:** + - `apps/cms/src/api/video-coverage/` — identical 3-file structure (route/controller/service) + - `(strapi.db as any).connection` for knex access with eslint-disable comment + - `type KnexInstance = any` pattern from video-coverage service + + **Test scenarios:** + - Returns all continents (6), countries (240), languages (~2,280) with correct structure + - `countrySpeakers` map correctly aggregates speaker counts per language per country + - `continentIds` and `countryIds` on each language reflect actual country-language junction data + - No draft rows included (only `published_at IS NOT NULL`) + - Query executes in single digit milliseconds against production data volume + + **Verification:** + - `curl http://localhost:1337/api/language-geo` returns valid JSON with correct counts + - CMS logs show a single SQL query (no N+1 traces from `association.js`) + - Response shape matches the existing `languageCache` data contract + +- [x] **Unit 2: Update manager language route to use new REST endpoint** + + **Goal:** Replace 4 parallel GraphQL queries with a single fetch to `/api/language-geo`. + + **Requirements:** R1, R2, R3, R4 + + **Dependencies:** Unit 1 + + **Files:** + - Modify: `apps/manager/src/app/api/languages/route.ts` + + **Approach:** + - Replace the `fetchLanguageData()` function internals: swap Apollo GraphQL queries for a single `fetch(\`${env.STRAPI_URL}/api/language-geo\`)` call with Bearer token header + - Remove the 4 GraphQL query definitions (`GET_CONTINENTS`, `GET_COUNTRIES_CONNECTION`, `GET_LANGUAGES_CONNECTION`, `GET_COUNTRY_LANGUAGES_CONNECTION`) + - Remove the in-memory aggregation/transform logic (now handled by CMS endpoint) + - Keep the SWR cache wrapper, TTL settings, and export unchanged + - Follow the pattern in `apps/manager/src/app/api/videos/route.ts` for REST consumption + + **Patterns to follow:** + - `apps/manager/src/app/api/videos/route.ts` — fetch from CMS REST endpoint with Bearer token, parse JSON response + - Use `env.STRAPI_URL` from `src/config/env.ts`, never hardcode + + **Test scenarios:** + - Language picker still shows all languages grouped by continent and country + - Speaker counts are accurate + - Cache warming succeeds on startup without pool exhaustion + - Stale-while-revalidate behavior unchanged + + **Verification:** + - Manager language API returns same data shape as before + - No GraphQL language queries appear in CMS logs during cache refresh + - Auth endpoints (`/api/users/me`) respond in normal time during language cache warm + - Language cache refresh completes in under 5 seconds + +## System-Wide Impact + +- **Connection pool relief:** Removing ~20K individual queries frees the PostgreSQL connection pool for concurrent requests. This directly unblocks auth checks (R3). +- **Error propagation:** The SWR cache already has failure backoff (30s circuit breaker). If the new endpoint fails, the cache serves stale data within `maxStaleMs` (48h). No changes needed. +- **Cache warming order:** `instrumentation.ts` warms video, coverage, and language caches. The language cache will now complete faster, reducing the startup window where the pool is under pressure. +- **API surface:** No other consumers use the language GraphQL queries being removed — they are defined locally in the manager's route file. + +## Risks & Dependencies + +- **Strapi v5 link table naming**: The plan assumes link table columns based on the core-sync service evidence. If column names differ, the SQL query will need adjustment — verify with a quick `\d` or `SELECT` during implementation. +- **CMS deployment ordering**: The CMS endpoint (Unit 1) must be deployed before the manager update (Unit 2). In a monorepo deploy, both ship together, but if Railway deploys them independently, the CMS must go first. + +## Sources & References + +- **Origin document:** [docs/brainstorms/2026-04-03-manager-language-cache-performance-requirements.md](docs/brainstorms/2026-04-03-manager-language-cache-performance-requirements.md) +- **Video coverage fix (pattern):** PR #637, `apps/cms/src/api/video-coverage/` +- **Learning:** `docs/solutions/performance-issues/manager-video-coverage-sql-aggregation-20260402.md` +- **Learning:** `docs/solutions/performance-issues/strapi-nested-relation-truncation-and-n-plus-one-manager-20260328.md` +- **Learning:** `docs/solutions/performance-issues/swr-cache-failure-backoff-manager-20260331.md` From 1543d77c84257ddd5e37a84d7ea6516a72b981b8 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Fri, 3 Apr 2026 07:14:53 +0000 Subject: [PATCH 5/9] =?UTF-8?q?fix:=20address=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20error=20logging,=20naming,=20cross-refs,=20CLAUDE.m?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/cms/CLAUDE.md | 3 ++- .../cms/src/api/language-geo/services/language-geo.ts | 1 + apps/manager/src/app/api/languages/route.ts | 11 ++++++++--- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/cms/CLAUDE.md b/apps/cms/CLAUDE.md index dd5ced37..3c6d9403 100644 --- a/apps/cms/CLAUDE.md +++ b/apps/cms/CLAUDE.md @@ -9,7 +9,8 @@ ## Conventions - Content types managed via Strapi admin UI. Avoid manual schema file edits. -- GraphQL plugin is the primary API. REST endpoints exist but apps should not use them. +- GraphQL plugin is the primary API for standard CRUD operations. +- **Performance escape hatch**: For bulk aggregate queries where GraphQL's N+1 problem causes connection pool exhaustion (no DataLoader in Strapi v5), custom REST endpoints using raw SQL via knex are permitted. These live in `src/api/{endpoint-name}/` following the standard route/controller/service structure. Current examples: `video-coverage`, `language-geo`. - API tokens seeded in bootstrap lifecycle using HMAC-SHA512 hashing. - Media uploads handled by Strapi's default provider (or configured cloud provider). diff --git a/apps/cms/src/api/language-geo/services/language-geo.ts b/apps/cms/src/api/language-geo/services/language-geo.ts index 478505bb..1b9145ef 100644 --- a/apps/cms/src/api/language-geo/services/language-geo.ts +++ b/apps/cms/src/api/language-geo/services/language-geo.ts @@ -44,6 +44,7 @@ type Language = { countrySpeakers: Record } +// Must match CmsLanguageGeo in apps/manager/src/app/api/languages/route.ts type LanguageGeoResult = { continents: Continent[] countries: Country[] diff --git a/apps/manager/src/app/api/languages/route.ts b/apps/manager/src/app/api/languages/route.ts index e3651ecb..5adff19c 100644 --- a/apps/manager/src/app/api/languages/route.ts +++ b/apps/manager/src/app/api/languages/route.ts @@ -7,6 +7,7 @@ import { createSwrCache } from "@/lib/swr-cache" // Types from CMS /api/language-geo endpoint // --------------------------------------------------------------------------- +// Must match LanguageGeoResult in apps/cms/src/api/language-geo/services/language-geo.ts type CmsLanguageGeo = { continents: Array<{ id: string; name: string }> countries: Array<{ id: string; name: string; continentId: string }> @@ -24,7 +25,7 @@ type CmsLanguageGeo = { // Fetch from CMS language-geo endpoint // --------------------------------------------------------------------------- -async function fetchLanguagePayload(): Promise { +async function fetchLanguageGeo(): Promise { const url = `${env.STRAPI_URL}/api/language-geo` const response = await fetch(url, { @@ -48,7 +49,7 @@ async function fetchLanguagePayload(): Promise { // --------------------------------------------------------------------------- export const languageCache = createSwrCache({ - fetcher: fetchLanguagePayload, + fetcher: fetchLanguageGeo, ttlMs: 24 * 60 * 60_000, // 24 hours — geo data changes only on core sync maxStaleMs: 48 * 60 * 60_000, // 48 hours — hard limit label: "language-cache", @@ -67,7 +68,11 @@ export async function GET(request: Request) { return new Response(payload, { headers: { "Content-Type": "application/json" }, }) - } catch { + } catch (error) { + console.error( + "[api/languages] Failed to fetch language data:", + error instanceof Error ? error.message : "Unknown error", + ) return NextResponse.json( { error: "Failed to fetch language data" }, { status: 502 }, From 098be692b39787d9c6cf6f030dff0c34d5a44a8b Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Fri, 3 Apr 2026 07:19:35 +0000 Subject: [PATCH 6/9] fix(cms): require API token auth on language-geo and video-coverage endpoints --- apps/cms/src/api/language-geo/routes/language-geo.ts | 1 - apps/cms/src/api/video-coverage/routes/video-coverage.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/apps/cms/src/api/language-geo/routes/language-geo.ts b/apps/cms/src/api/language-geo/routes/language-geo.ts index 746a5426..88f79228 100644 --- a/apps/cms/src/api/language-geo/routes/language-geo.ts +++ b/apps/cms/src/api/language-geo/routes/language-geo.ts @@ -5,7 +5,6 @@ export default { path: "/language-geo", handler: "language-geo.index", config: { - auth: false, policies: [], middlewares: [], }, diff --git a/apps/cms/src/api/video-coverage/routes/video-coverage.ts b/apps/cms/src/api/video-coverage/routes/video-coverage.ts index bfad9b0d..9a6d9c55 100644 --- a/apps/cms/src/api/video-coverage/routes/video-coverage.ts +++ b/apps/cms/src/api/video-coverage/routes/video-coverage.ts @@ -5,7 +5,6 @@ export default { path: "/video-coverage", handler: "video-coverage.index", config: { - auth: false, policies: [], middlewares: [], }, From 732dbdfc4f3c4152963809db86b1eac65003b616 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Fri, 3 Apr 2026 07:21:30 +0000 Subject: [PATCH 7/9] fix(cms): add FK indexes on Strapi v5 link tables for query performance --- ...4.03T00.00.00.add-link-table-fk-indexes.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 apps/cms/database/migrations/2026.04.03T00.00.00.add-link-table-fk-indexes.ts diff --git a/apps/cms/database/migrations/2026.04.03T00.00.00.add-link-table-fk-indexes.ts b/apps/cms/database/migrations/2026.04.03T00.00.00.add-link-table-fk-indexes.ts new file mode 100644 index 00000000..800bb353 --- /dev/null +++ b/apps/cms/database/migrations/2026.04.03T00.00.00.add-link-table-fk-indexes.ts @@ -0,0 +1,53 @@ +/** + * Add indexes to Strapi v5 link tables used by custom REST endpoints. + * + * Strapi v5 auto-generates `_lnk` junction tables for content type relations + * but does not create indexes on the foreign key columns. Without indexes, + * JOINs through these tables fall back to sequential scans. + * + * These link tables are joined by: + * - /api/language-geo (country_languages → languages, countries, continents) + * - /api/video-coverage (video_subtitles → videos, languages; video_variants → videos, languages) + */ + +const LINK_TABLE_INDEXES = [ + // language-geo endpoint + { table: "country_languages_language_lnk", column: "country_language_id" }, + { table: "country_languages_language_lnk", column: "language_id" }, + { table: "country_languages_country_lnk", column: "country_language_id" }, + { table: "country_languages_country_lnk", column: "country_id" }, + { table: "countries_continent_lnk", column: "country_id" }, + { table: "countries_continent_lnk", column: "continent_id" }, + // video-coverage endpoint + { table: "video_subtitles_video_lnk", column: "video_subtitle_id" }, + { table: "video_subtitles_video_lnk", column: "video_id" }, + { table: "video_subtitles_language_lnk", column: "video_subtitle_id" }, + { table: "video_subtitles_language_lnk", column: "language_id" }, + { table: "video_variants_video_lnk", column: "video_variant_id" }, + { table: "video_variants_video_lnk", column: "video_id" }, + { table: "video_variants_language_lnk", column: "video_variant_id" }, + { table: "video_variants_language_lnk", column: "language_id" }, + { table: "videos_children_lnk", column: "video_id" }, + { table: "videos_children_lnk", column: "inv_video_id" }, + { table: "video_images_video_lnk", column: "video_image_id" }, + { table: "video_images_video_lnk", column: "video_id" }, +] as const + +/* eslint-disable @typescript-eslint/no-explicit-any */ +export async function up(knex: any): Promise { + for (const { table, column } of LINK_TABLE_INDEXES) { + const exists = await knex.schema.hasTable(table) + if (!exists) continue + const indexName = `idx_${table}_${column}` + await knex.raw( + `CREATE INDEX IF NOT EXISTS ${indexName} ON "${table}" ("${column}")`, + ) + } +} + +export async function down(knex: any): Promise { + for (const { table, column } of LINK_TABLE_INDEXES) { + const indexName = `idx_${table}_${column}` + await knex.raw(`DROP INDEX IF EXISTS ${indexName}`) + } +} From 0e1de755e69e1869b44ea2ceda2bc18837eb1d04 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Fri, 3 Apr 2026 07:34:18 +0000 Subject: [PATCH 8/9] docs: compound learning for language cache N+1 fix (#646) --- ...che-raw-sql-bypass-cms-manager-20260403.md | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 docs/solutions/performance-issues/strapi-language-cache-raw-sql-bypass-cms-manager-20260403.md diff --git a/docs/solutions/performance-issues/strapi-language-cache-raw-sql-bypass-cms-manager-20260403.md b/docs/solutions/performance-issues/strapi-language-cache-raw-sql-bypass-cms-manager-20260403.md new file mode 100644 index 00000000..6df36a95 --- /dev/null +++ b/docs/solutions/performance-issues/strapi-language-cache-raw-sql-bypass-cms-manager-20260403.md @@ -0,0 +1,193 @@ +--- +title: "Language cache warm exhausts PostgreSQL connection pool via Strapi N+1" +date: "2026-04-03" +category: "performance-issues" +severity: "critical" +module: + - "manager" + - "cms" +tags: + - "strapi-v5" + - "graphql" + - "n-plus-1" + - "postgresql" + - "connection-pool" + - "knex" + - "raw-sql" + - "rest-endpoint" + - "fk-indexes" + - "api-token-auth" + - "language-cache" +related_prs: + - "#646" + - "#637" +symptoms: + - "KnexTimeoutError: pool is probably full during language cache refresh" + - "Auth checks (/api/users/me) took 167 seconds during cache warm" + - "/api/auth/local took 73 seconds" + - "4 parallel GraphQL queries produced ~20K individual DB queries" +root_cause: "Strapi v5 lacks DataLoader so GraphQL relation resolution causes N+1 queries; 6,598 country-language rows with 3 nested relations produced ~20K queries exhausting the 25-connection pool" +--- + +## Problem + +The manager application's language cache warm-up fired 4 parallel GraphQL queries against Strapi v5, producing approximately 20,000 individual database queries due to Strapi's N+1 problem. This exhausted the PostgreSQL connection pool (25 connections), blocking all CMS requests for 30-167 seconds and rendering the entire CMS unresponsive during cache refresh cycles. + +This is the second occurrence of this pattern. The first was the video-coverage query (PR #637). + +## Evidence + +- CMS logs during language cache warm: `KnexTimeoutError: Knex: Timeout acquiring a connection. The pool is probably full.` +- `/api/users/me` response time: **167 seconds** (normally <50ms) +- `/api/auth/local` response time: **73 seconds** +- Dataset sizes driving the explosion: + - `country_languages`: 6,598 rows + - `languages`: 2,280 rows + - `countries`: 240 rows + - `continents`: 6 rows +- The `countryLanguages_connection` GraphQL query with nested `language`, `country`, and `country.continent` relations was the primary offender: 6,598 rows x 3 nested relations = ~20K individual `findOne` queries. + +## Root Cause + +Strapi v5's GraphQL implementation lacks DataLoader-style batching. Every nested relation on every row in a collection query fires a separate `findOne` database call: + +``` +6,598 country_language rows + x 1 findOne for language + x 1 findOne for country + x 1 findOne for country.continent += ~19,794 additional DB queries (on top of the base queries) +``` + +Four such GraphQL queries ran in parallel during cache warm, instantly saturating the connection pool. Every other CMS request then queued behind the pool. + +## Solution + +### Part 1: Custom CMS REST endpoint (`/api/language-geo`) + +Created a custom Strapi v5 API following the route/controller/service pattern established by the video-coverage fix (PR #637). + +**File structure:** + +``` +apps/cms/src/api/language-geo/ + routes/language-geo.ts -- GET /language-geo + controllers/language-geo.ts -- knex access, delegates to service + services/language-geo.ts -- single SQL query + JS aggregation +``` + +**Single SQL query replacing ~20K:** + +```sql +SELECT + l.core_id AS lang_core_id, l.name AS lang_name, cl.speakers, + c.core_id AS country_core_id, c.name AS country_name, + ct.core_id AS continent_core_id, ct.name AS continent_name +FROM country_languages cl +JOIN country_languages_language_lnk cll ON cll.country_language_id = cl.id +JOIN country_languages_country_lnk clc ON clc.country_language_id = cl.id +JOIN languages l ON l.id = cll.language_id AND l.published_at IS NOT NULL +JOIN countries c ON c.id = clc.country_id AND c.published_at IS NOT NULL +JOIN countries_continent_lnk ccl ON ccl.country_id = c.id +JOIN continents ct ON ct.id = ccl.continent_id AND ct.published_at IS NOT NULL +WHERE cl.published_at IS NOT NULL +``` + +**Critical Strapi v5 details:** + +- Relations are stored in `_lnk` junction tables, not as FK columns on the content type table. You must join through `country_languages_language_lnk`, `country_languages_country_lnk`, etc. +- Naming convention: `{plural_content_type}_{relation_field}_lnk` with columns `{singular_content_type}_id` and `{target_singular}_id`. +- Always filter `published_at IS NOT NULL` on every content table -- Strapi v5 stores both draft and published rows. + +JS aggregation in the service transforms flat SQL rows into `{ continents, countries, languages }` using Maps keyed on `core_id`. + +### Part 2: Manager route update + +Replaced 4 GraphQL queries and ~175 lines of pagination/aggregation logic with a single `fetch()`: + +```typescript +const response = await fetch(`${env.STRAPI_URL}/api/language-geo`, { + headers: { Authorization: `Bearer ${env.STRAPI_API_TOKEN}` }, + signal: AbortSignal.timeout(10_000), +}) +const data = (await response.json()) as CmsLanguageGeo +``` + +SWR cache unchanged (24h TTL, 48h maxStale). Cache warming via `instrumentation.ts` unchanged. + +### Part 3: Link table FK indexes + +Strapi v5 auto-creates `_lnk` junction tables but does **not** create indexes on their FK columns. Added a migration (`2026.04.03T00.00.00.add-link-table-fk-indexes.ts`) covering all FK columns in link tables used by both `language-geo` and `video-coverage` endpoints. Uses `CREATE INDEX IF NOT EXISTS` and `hasTable` checks for safety. + +### Part 4: Auth fix + +Removed `auth: false` from both `language-geo` and `video-coverage` route configs. Strapi's default API token auth now validates the Bearer token the manager already sends. Previously both endpoints were unauthenticated -- anyone who could reach the CMS could query them. + +## Result + +| Metric | Before | After | +| --------------------------- | --------------------------------- | ---------------- | +| DB queries per cache warm | ~20,000 | 1 | +| CMS blocked during warm | 30-167s | 0s | +| `/api/users/me` during warm | 167s | <50ms | +| Language cache response | Multiple paginated GQL roundtrips | Single REST call | +| Connection pool errors | `KnexTimeoutError` on every warm | None | + +## Reusable Pattern: Strapi v5 Performance Escape Hatch + +This is now documented in `apps/cms/CLAUDE.md`. For any query touching large datasets with nested relations: + +1. **Identify:** GraphQL query returning >100 rows with nested relations. +2. **Calculate:** `rows x nested_relations = DB queries`. If >1,000, build a custom endpoint. +3. **Implement:** Route/controller/service in `apps/cms/src/api/{name}/` with raw SQL joining through `_lnk` tables. +4. **Index:** Add FK indexes on all `_lnk` tables involved. +5. **Filter:** Always include `published_at IS NOT NULL` on every content table. +6. **Auth:** Do not set `auth: false` -- let Strapi validate the API token. +7. **Consume:** Replace GraphQL calls in the consumer with a single `fetch()`. + +Current endpoints using this pattern: `video-coverage` (PR #637), `language-geo` (PR #646). + +## Prevention + +### When to Use GraphQL vs Custom REST + +| Use GraphQL | Use Custom REST with Raw SQL | +| ----------------------------------------- | -------------------------------- | +| Single-entity fetches by ID or slug | Queries returning >200 rows | +| Small collections with 1 level of nesting | Aggregation/reporting queries | +| Client-facing pages (small data) | Queries joining 3+ tables | +| CRUD from CMS admin UI | Dashboard or bulk data endpoints | + +**Hard rule:** If a query's result set scales with editorial content volume, it must not use Strapi's GraphQL resolver. + +### Code Review Red Flags + +- Any GraphQL query fetching a collection with nested relations and no pagination limit +- Queries nesting more than one level of relations +- New content types with link tables lacking explicit FK indexes +- Any query used in dashboard, reporting, or aggregation context + +### CMS Log Patterns Indicating N+1 + +- `KnexTimeoutError: pool is probably full` +- Duration spikes (>100ms per query) in bursts of hundreds within seconds +- `pg_stat_activity` showing dozens of identical `SELECT ... WHERE id = $1` queries + +### Checklist for New Custom REST Endpoints + +- [ ] No `auth: false` -- use Strapi's built-in API token auth +- [ ] Raw SQL via `(strapi.db as any).connection.raw()`, not `entityService` +- [ ] FK indexes on all `_lnk` table columns used in JOINs +- [ ] `published_at IS NOT NULL` on every content table in the query +- [ ] Cross-reference comment linking CMS return type and consumer type +- [ ] Error logging with labeled prefix (e.g., `[api/languages]`) +- [ ] `AbortSignal.timeout()` on the consumer's fetch call + +## Related Documentation + +- [`manager-video-coverage-sql-aggregation-20260402.md`](manager-video-coverage-sql-aggregation-20260402.md) -- First instance of this pattern (PR #637) +- [`strapi-nested-relation-truncation-and-n-plus-one-manager-20260328.md`](strapi-nested-relation-truncation-and-n-plus-one-manager-20260328.md) -- Original N+1 diagnosis +- [`swr-cache-failure-backoff-manager-20260331.md`](swr-cache-failure-backoff-manager-20260331.md) -- SWR cache circuit breaker (no changes needed) +- [`docs/solutions/cms/core-sync-bulk-update-temp-table-pattern.md`](../cms/core-sync-bulk-update-temp-table-pattern.md) -- Complementary raw knex patterns +- PR #646: Language cache performance fix +- PR #637: Video coverage performance fix From 39f103b9ab2e136699fe21c6d58739c50fc8f469 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Fri, 3 Apr 2026 07:49:57 +0000 Subject: [PATCH 9/9] docs: refresh related learnings with language-geo cross-refs and auth fix --- ...anager-video-coverage-sql-aggregation-20260402.md | 10 +++++++++- ...ion-truncation-and-n-plus-one-manager-20260328.md | 12 +++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/solutions/performance-issues/manager-video-coverage-sql-aggregation-20260402.md b/docs/solutions/performance-issues/manager-video-coverage-sql-aggregation-20260402.md index 0d847165..4f558f3f 100644 --- a/docs/solutions/performance-issues/manager-video-coverage-sql-aggregation-20260402.md +++ b/docs/solutions/performance-issues/manager-video-coverage-sql-aggregation-20260402.md @@ -31,8 +31,10 @@ Created a custom CMS REST endpoint (`/api/video-coverage`) that computes per-vid ## Key Patterns -- **Follow the `coverage-snapshot` service pattern** for raw SQL endpoints in Strapi v5: controller/routes/services structure, `(strapi.db as any).connection` for knex +- **Follow the route/controller/service pattern** for raw SQL endpoints in Strapi v5: `(strapi.db as any).connection` for knex access. Second endpoint using this pattern: `language-geo` (PR #646, see [`strapi-language-cache-raw-sql-bypass-cms-manager-20260403.md`](strapi-language-cache-raw-sql-bypass-cms-manager-20260403.md)) - **Always filter `published_at IS NOT NULL`** in Strapi v5 SQL queries — every published document has both a draft and published row +- **Do not use `auth: false` on custom REST routes** — Strapi's default API token auth validates the Bearer token consumers already send. Both `video-coverage` and `language-geo` had `auth: false` removed in PR #646 +- **Add FK indexes on `_lnk` junction tables** — Strapi v5 auto-creates these tables but does not add indexes on FK columns. Migration `2026.04.03T00.00.00.add-link-table-fk-indexes.ts` covers both endpoints - **`videos_children_lnk`**: `video_id` = parent, `inv_video_id` = child - **`video_images_video_lnk`**: joins images to videos; use `DISTINCT ON (v.document_id)` to get one image per video - **Language filtering via `l.core_id = ANY(?)`** with knex parameterized bindings works for variable-length language ID arrays @@ -53,5 +55,11 @@ Created a custom CMS REST endpoint (`/api/video-coverage`) that computes per-vid ## What NOT to Do - Don't set `maxLimit` on the Strapi GraphQL plugin config unless you intend to cap ALL paginated queries — it applies globally, not per-query +- Don't use `auth: false` on custom CMS REST endpoints — Strapi's default API token auth works with the Bearer token consumers already send - Don't use native `