diff --git a/docs/README.md b/docs/README.md index 2b1606817..14fa4c18a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,6 +23,7 @@ docs/ │ ├── plugin-system.md ← plugin SDK, sandbox, lifecycle, permissions │ ├── publisher.md ← page tree → static HTML/CSS pipeline │ ├── visual-components.md ← VCs, slots, params, instantiation +│ ├── localization.md ← languages, translations and per-language publication │ ├── content-storage.md ← data_tables + data_rows (the universal store) │ ├── content-workspace.md ← Content workspace: collections, entries, body editor │ ├── auth-and-access.md ← sessions, MFA, capabilities, roles @@ -140,6 +141,7 @@ Three categories, three voices: | [features/publisher.md](features/publisher.md) | The page-tree-to-HTML/CSS renderer + server-side publishing wrappers | | [features/visual-components.md](features/visual-components.md) | VCs, slots, params, instantiation, recursion guard | | [features/content-storage.md](features/content-storage.md) | `data_tables` + `data_rows` — the universal content store | +| [features/localization.md](features/localization.md) | Languages, inheritance, translation review and independent publication | | [features/content-workspace.md](features/content-workspace.md) | Content workspace UI: collections, entries, body editor, settings panel | | [features/data-workspace.md](features/data-workspace.md) | Data workspace UI: DataInspector, field management, DataGrid | | [features/auth-and-access.md](features/auth-and-access.md) | Sessions, MFA, step-up, lockout, CSRF, capabilities | diff --git a/docs/architecture.md b/docs/architecture.md index 036acc8a4..1176e82fa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -418,6 +418,10 @@ bun run test:e2e # run specs in tests/e2e/*.e2e.ts --- +## Localization + +Logical content identities share structure while per-language records own draft values, availability, publication history and schedules. Site and Content authoring, collaboration, public routes and integrations carry an explicit locale. See [`localization.md`](features/localization.md) for the storage model and lifecycle. + ## Related - `CLAUDE.md` — the agent rule book (start there before changing code) diff --git a/docs/editor.md b/docs/editor.md index 2de45ee41..a4d72aca5 100644 --- a/docs/editor.md +++ b/docs/editor.md @@ -603,13 +603,14 @@ The sidebar shell expands/collapses by animating `--*-panel-width`. The panel sl `src/admin/modals/Settings/SettingsModal.tsx`. Shares the visual language of the Spotlight palette and Module Inserter: a direct-token panel shell, `--bg-surface-2` rail with categorical accent icon chips, accent-bar section header, card-group rows (`--bg-surface-2` fills, `--panel-radius` corners, 1px gaps showing the darker panel surface through) for section content, and an Esc keycap affordance. Backdrop click and Esc both close — there is no dedicated close button. -**Sections** (rail nav, four entries): +**Sections** (rail nav): | Section | What it contains | |---------------|------------------------------------------------------------------------------| -| General | Site name, meta title, meta description, language, favicon | +| General | Site name, meta title, meta description, favicon | +| Languages | Source and translation languages, URL prefixes, direction and availability | | Shortcuts | Auto-rendered keyboard shortcut reference from the keybindings registry | -| Publishing | Self-hosted runtime info + framework CSS tree-shaking toggle | +| Publishing | Public website URL, self-hosted runtime info and framework CSS tree-shaking toggle | | Preferences | Catalog-driven editor preferences (auto-rendered from `PREFERENCE_CATALOG`) | Site-specific controls that were previously sections of this modal (Pages roster, Breakpoints/Viewports, Conditions) now live in their dedicated surfaces: the Site Explorer panel and `CanvasContextSelector` (unified condition axis). @@ -721,6 +722,10 @@ See [docs/features/plugin-system.md](features/plugin-system.md) for the plugin S --- +## Localization + +Logical content identities share structure while per-language records own draft values, availability, publication history and schedules. Site and Content authoring, collaboration, public routes and integrations carry an explicit locale. See [`localization.md`](features/localization.md) for the storage model and lifecycle. + ## Related - [docs/architecture.md](architecture.md) — system overview diff --git a/docs/features/content-storage.md b/docs/features/content-storage.md index 2a22ef1db..e60257413 100644 --- a/docs/features/content-storage.md +++ b/docs/features/content-storage.md @@ -341,6 +341,10 @@ Events are emitted from `server/publish/contentEvents.ts`, which also exports `a --- +## Localization + +Logical content identities share structure while per-language records own draft values, availability, publication history and schedules. Site and Content authoring, collaboration, public routes and integrations carry an explicit locale. See [`localization.md`](localization.md) for the storage model and lifecycle. + ## Related - [docs/architecture.md](../architecture.md) — system overview ("All content lives in `data_tables` + `data_rows`") diff --git a/docs/features/dashboard.md b/docs/features/dashboard.md index 029e3bf3f..9d6fe24ea 100644 --- a/docs/features/dashboard.md +++ b/docs/features/dashboard.md @@ -222,12 +222,12 @@ The dashboard fans out into **per-domain** endpoints under `/admin/api/cms/dashb | Endpoint | Hook | Capability gate | Response shape (summary) | |-----------------------------|--------------------------|-----------------|--------------------------| -| `/dashboard/pages` | `usePagesStats` | authenticated user | `{ total, published, drafts, scheduled, deltaPublishedThisWeek }` | -| `/dashboard/posts` | `usePostsStats` | authenticated user | `{ total, categories, scheduled, daily28 }` | +| `/dashboard/pages` | `usePagesStats` | authenticated user | `{ total, variants, published, drafts, offline, scheduled, deltaPublishedThisWeek }` | +| `/dashboard/posts` | `usePostsStats` | authenticated user | `{ total, variants, categories, scheduled, daily28 }` | | `/dashboard/media` | `useMediaStats` | `media.read` | `{ count, totalBytes, latestThumbs[] }` | | `/dashboard/plugins` | `usePluginsStats` | `plugins.read` | `{ total, active, disabled, errored, rows[] }` | | `/dashboard/storage` | `useStorageStats` | authenticated user | `{ imageBytes, videoBytes, documentBytes, pluginBytes, databaseBytes, totalBytes, dialect }` | -| `/dashboard/publish-lineup` | `usePublishLineupStats` | authenticated user | `{ rows: [{ id, path, status, at }] }` | +| `/dashboard/publish-lineup` | `usePublishLineupStats` | authenticated user | `{ rows: [{ id, localeId, localeCode, localeEnabled, title, path, status, at }] }` | | `/dashboard/activity` | `useRecentActivityStats` | `audit.read` | `{ rows: [{ id, action, actor, targetCode, targetText, createdAt }] }` | Non-CMS first-party widgets: @@ -238,6 +238,20 @@ Non-CMS first-party widgets: | `domain` | Local component rows | Shows the current placeholder primary-domain / HTTPS rows. | | `status` | Local component rows | Shows the current placeholder site/build/backup/plugin status rows. | +### Language-aware content statistics + +The Pages and Posts headline totals count logical content identities. `variants` counts authored language variants; published, draft, offline, and schedule counters describe those variants. A published variant can also have a frozen scheduled update. Disabled languages contribute no publicly online variants, while their paused schedules remain visible. The weekly delta and daily histogram count immutable locale publication versions, including versions superseded by later releases. + +The lineup carries row identity plus locale identity. Published paths come from the active version; scheduled paths come from the frozen scheduled revision. Draft paths use localized collection routes and the language prefix. A page named `index` displays the language homepage, while unfinished draft paths display their title. Templates and generic data rows are excluded from the route lineup. Activity resolves content against the event's locale, using the source language for older events without locale metadata. + +The shared TypeBox contracts live in `src/core/dashboard/contentStatsSchemas.ts`; server readers and client hooks consume the same definitions. `server/handlers/cms/dashboard/__tests__/localizedDashboard.test.ts` covers logical-versus-variant counts, disabled languages, concurrent live/scheduled states, frozen URLs, and locale activity titles. + +### Publishing from the dashboard + +The **Publish pages…** action opens the shared `SitePublishDialog` (`src/admin/shared/SitePublishDialog/`). Authors explicitly select page-language variants; disabled languages cannot be selected. `DashboardPublishButton` gates the action on `pages.publish`, runs the same step-up flow as the Site toolbar, and submits the selected `{rowId, localeId}` pairs. A successful publication emits `CMS_PUBLICATION_CHANGED_EVENT`; dashboard hooks refresh independently on that event and on `CMS_SITE_RELOAD_EVENT`. Failures keep the selection open and surface through the global toast bus. The Site toolbar keeps its additional collaboration-sync and runtime-validation checks. + +The lineup displays the server-provided title, language, status, and frozen address. A live version and its scheduled update have separate entries keyed by row, language, and status. `src/admin/pages/dashboard/components/DashboardPublishButton.test.tsx` covers explicit selection, disabled languages, capabilities, step-up cancellation, and failure handling; `src/admin/pages/dashboard/widgets/localizedWidgets.test.tsx` covers variant labels, frozen paths, and event-driven refresh. + ### Timezone-aware day bucketing Every dashboard stats request includes a `?tz=` query parameter (`Intl.DateTimeFormat().resolvedOptions().timeZone` from the viewer's browser). The server reads it in `handleDashboardRoutes` via `resolveTimeZone` (`server/time.ts`) and threads the resolved zone into `DashboardRequestContext.timeZone`. Readers that bin timestamps per calendar day — currently the Posts histogram — use `localDayKeyFactory(ctx.timeZone)` to map each `published_at` to a local day key rather than the UTC date. A post published at 23:30 local time lands on the correct bar instead of rolling into the next UTC day. diff --git a/docs/features/localization.md b/docs/features/localization.md new file mode 100644 index 000000000..8a8576c41 --- /dev/null +++ b/docs/features/localization.md @@ -0,0 +1,81 @@ +# Localization + +Languages, translated content and independent publication of pages and CMS entries. + +A logical content row has one identity and optional language drafts; a missing language draft inherits its source during authoring. The editor shares structure and design while resolving sparse content overrides. Publishing freezes a selected language version; editing and source-language inheritance cannot silently change an existing public release. + +--- + +## TL;DR + +- Configure languages in **Settings → Languages**. The source language uses the root URL; additional languages have an explicit prefix, direction and online switch. New languages start offline. +- Choose the active language in the Site or Content toolbar. Content inherits from the source until overridden. Shared structure and styles are edited in the source language. +- **Publish pages…** selects page-language pairs explicitly. **Languages and publication** controls an individual page or entry in every language. Unselected and offline variants retain their state. +- Source-language withdrawal does not withdraw other languages. Disabling a whole language hides all its public variants without deleting their drafts or versions. +- Scheduling freezes the current content, URL and design revision. Later edits remain drafts; an existing live version remains live until the scheduled release. Schedules in a disabled language pause until that language is enabled. +- Published URLs, links, lists, switchers, fragments and SEO resolve through the same live inventory. Missing/offline variants never produce a guessed public URL. +- Source of truth: `src/core/localization-schema/`, `src/core/localization/`, `src/core/localization-routing/`, `server/repositories/localization/`. + +## Content model + +Migration `027` in both `server/db/migrations-pg.ts` and `server/db/migrations-sqlite.ts` adds localization storage and backfills the source language. Existing rows, version IDs, version cells, runtime assets and site snapshot references are retained. Older page versions may contain only title and slug; their published tree still comes from the original `site_snapshots` join. Migration does not replace those historical bodies with the current draft. + +| Storage | Responsibility | +|---|---| +| `site_locales` | Stable language ID, BCP 47 code, name, path prefix, direction, source flag and availability | +| `data_rows` | Logical identity, shared fields, ownership and shared tree structure | +| `data_row_localizations` | Sparse draft cells, slug, availability, active version, frozen schedule, translation review metadata and sync sequence | +| `data_row_versions` | Immutable resolved content, language, public path and site snapshot reference | +| `data_table_localizations` | Per-language collection route base | + +The source language uses the same localization record shape as translations. Public page and post-type URL slugs are always managed per language; their field editor cannot promise a shared routing value. The dedicated variant `slug` is canonical: writers normalize any existing slug cell to it, sparse absence remains absent, and projected slug controls, lookups and filters show that same value. Once a variant exists, its routing slug is independent of later source slug changes. Component and layout identifiers remain shared. `getDataRow` and `listDataRows` in `server/repositories/data/rows/` return the selected projection together with shared cells and localization metadata. Omitted language means the configured source; an explicitly unknown language is an error. + +`DataField.localization` is `shared` or `localized`. Field-mode changes and moves between collections with different field modes use `server/repositories/data/tableFieldLocalization.ts` to preserve source values and dormant translations. Structural tree fields and internal component schemas cannot be made independently structural per language. + +## Inheritance and review + +`src/core/localization/` resolves shared cells → source overrides → selected overrides. Property absence means inheritance; explicit empty strings and null values remain intentional content. Tree overlays contain node content properties and visibility, never a second tree hierarchy. Deleted shared nodes cannot be resurrected by an old override. + +Visual Component parameter definitions remain shared. Localizable content defaults and instance values use the parameter policy in `src/core/visualComponents/parameterLocalization.ts`; style and structural parameters stay shared. + +`ContentLanguagesDialog` shows missing, inherited, needs-review and reviewed fields. **Use source** removes the override. **Mark reviewed** records the current source fingerprint; a later source change returns that field to needs-review without replacing the translation. Editing a translation also marks that field for review while retaining the reviews of untouched fields. The translation API also supports resetting one tree node property; review is field-wide. + +## Authoring and collaboration + +- `GET /admin/api/cms/site-document?localeId=…` assembles the projected document and sync metadata in one transaction. `getDraftSiteDocumentInTx` is the assembly entrypoint for existing transactions; never nest the transactional wrapper. +- `src/admin/pages/site/store/slices/site/` holds the selected locale and projects localized documents. `src/core/collab/docIds.ts` distinguishes shared structural documents from locale overlays; undo belongs to the active document. +- `server/collab/localizationGuard.ts` validates localized updates before persistence. An editor cannot use a translated overlay to change shared structure or styles. +- `src/admin/pages/content/hooks/useContentWorkspace.ts` reads and writes an explicit language. Content previews, loop previews and binding pickers carry that same selection. +- Collection settings expose translated route bases. Draft slug or base changes take effect only when the affected variants are published. +- Moving a logical entry between collections withdraws all its language versions and cancels schedules because its schema/template context changes. The UI explains this before the move. + +## Publication and routes + +`server/publish/publishedRoutes.ts` builds the live inventory from enabled locales and online variants pointing at immutable versions. `server/publish/publicRouter.ts` checks it before static artefacts, cached rendering and dynamic routes. Entry lists and route-aware fragments apply the same availability rules. + +`server/publish/publishSite.ts` publishes explicit page-language pairs. `server/publish/publishRow.ts` publishes one selected row language. Publishing resolves inherited content and pins its shared site dependencies. Retraction and whole-language availability changes rebuild dependent live pages, switchers, alternates and the sitemap from those pinned releases, so updated menus/lists do not publish unrelated drafts. + +CMS entries use the latest published site/template snapshot of their language. Publish a matching template before publishing an entry with a public route. **Republish entry** in the Content publishing menu adopts the current published template without withdrawing the entry first. An entry schedule pins that published template; a page schedule captures its current draft design. Technical templates have no direct public URL. + +The `base.language-switcher` module links only to live equivalents of the current logical content. `server/publish/localizedSeo.ts` emits HTML language/direction, canonical and reciprocal alternates; sitemap entries come from the same inventory. Configure **Public website URL** for absolute canonical URLs and sitemap hosts. Each locale can use its own published 404 projection. Changing a language code, direction, URL prefix or collection route base remains a draft routing/content change until the affected variant is published again; availability changes apply immediately. + +Public forms carry the originating logical content ID, language, version and public path. `server/forms/handler.ts` resolves that exact current published route before accepting a challenge/submission. A withdrawn or replaced release cannot continue submitting through an old page token. + +## Integrations and permissions + +`server/handlers/cms/localeContext.ts` validates HTTP language context. Creating/editing language configuration requires `site.structure.edit`; changing availability additionally requires `pages.publish`. Generic collections (`kind=data`) remain internal data without the page/post-type public release lifecycle. Their values can be localized and inherit the source during reads. Row access retains existing ownership and collection permissions; publish and schedule actions retain step-up authentication. + +Plugin content calls expose locale selection through the existing permission-gated SDK. AI and MCP workspaces expose their selected language; mutation tools reject a stale or conflicting locale instead of applying the operation elsewhere. See `docs/features/plugin-system.md` and `docs/features/mcp-connectors.md`. + +Bundles include locale identities, sparse drafts and the exact immutable dependencies needed by published/scheduled versions. Import validates identity conflicts and applies the bundle transactionally. See `docs/features/site-transfer.md`. + +## Related + +- `docs/features/publisher.md` — release snapshots, route inventory and dependency rebaking. +- `docs/features/site-shell.md` — editor synchronization and collaborative document ownership. +- `docs/features/content-storage.md` — logical rows and field storage. +- `docs/features/site-transfer.md` — bundle lifecycle and permissions. +- `src/core/localization/__tests__/` — inheritance, source review and sparse tree policies. +- `src/__tests__/server/localizedPublication.test.ts` and `localizedRouteInventory.test.ts` — independent availability, pinned releases and route collisions. +- `src/__tests__/server/siteDocumentSave.test.ts` — coherent reads and transactional writes. +- `src/__tests__/collab/localization.test.ts` and `src/__tests__/server/localizedCollabGuard.test.ts` — locale documents, undo and server guards. diff --git a/docs/features/mcp-connectors.md b/docs/features/mcp-connectors.md index 30febc736..8aa9e3580 100644 --- a/docs/features/mcp-connectors.md +++ b/docs/features/mcp-connectors.md @@ -159,6 +159,14 @@ There is intentionally no headless page-tree mutation path. The open editor stor Writes remain drafts. Clients should finish and verify an edit sequence, then call `site_publish` once only when deployment was requested. +### Language context + +`get_context` returns configured `locales`, the resolved `localeId`, and each live workspace's selected language (`editor.siteLocaleId` and `editor.contentLocaleId`). Headless document reads accept `localeId`; omission resolves the source language. Chat reads inherit the language in the validated workspace snapshot. Source: `server/ai/mcp/tools/contextTool.ts`, `server/ai/mcp/tools/documentTools.ts`, and `src/core/ai/localeContext.ts`. + +Browser tools accept an optional `localeId` as a precondition. The bridge pins each request to its workspace language and rejects a request if the user switched languages before execution. `site_select_locale({ localeId })` and `content_select_locale({ localeId })` change the visible workspace through its normal editor action. Node and document IDs remain the same across languages. Translation mode edits localized content; shared structure, design, and code are authored in the source language. The Site snapshot contains the visible materialized content and excludes other languages' sparse drafts. Source: `src/admin/ai/useMcpWorkspaceBridge.ts`, `src/admin/pages/site/agent/executor.ts`, and `src/admin/pages/content/agent/contentBridge.ts`. + +`site_publish({ variants: [{ rowId, localeId }] })` explicitly publishes the selected language variants. An omitted `variants` selection rebuilds only variants already online; it never takes offline content online. Publication still uses the canonical atomic pipeline and capability checks. Source: `server/ai/mcp/tools/publishTool.ts`; regression tests: `server/ai/mcp/publishTool.test.ts`, `src/__tests__/collab/localization.test.ts`, and `src/__tests__/agent/mcpWorkspaceReadiness.test.tsx`. + ## Data model `ai_mcp_connectors` remains the persistent owner/capability grant (migrations `018` and `019`): diff --git a/docs/features/plugin-system.md b/docs/features/plugin-system.md index 62f161280..63e8d4039 100644 --- a/docs/features/plugin-system.md +++ b/docs/features/plugin-system.md @@ -745,7 +745,7 @@ await bodyTree.mutate([ await bodyTree.replace(currentTree) // Cross-table -await api.cms.content.search('hello world', 25) +await api.cms.content.search('hello world', { limit: 25 }) const snap = await api.cms.content.getPublishedSnapshot(entryId) const { count } = await api.cms.content.republishAll() ``` @@ -756,7 +756,26 @@ const { count } = await api.cms.content.republishAll() Tree mutation and replacement payloads are validated against the canonical `@core/page-tree` TypeBox schemas before host dispatch. `insertNode.node` must be a complete `PageNode`, and `replace(tree)` must receive a complete `NodeTree` with a valid `rootNodeId`, matching node-map keys, resolvable child IDs, and no reachable cycles. -The host protocol names the per-table entry calls as `cms.content.entries.list`, `cms.content.entries.get`, `cms.content.entries.getBySlug`, `cms.content.entries.create`, `cms.content.entries.update`, `cms.content.entries.delete`, `cms.content.entries.publish`, `cms.content.entries.moveTable`, `cms.content.entries.createMany`, `cms.content.entries.updateMany`, and `cms.content.entries.deleteMany`. Tree calls dispatch as `cms.content.tree.read`, `cms.content.tree.mutate`, and `cms.content.tree.replace`; `getPublishedSnapshot(...)` dispatches as `cms.content.snapshot`. +Content reads and writes accept a `localeId`. Omitting it selects the configured primary language. `api.cms.content.locales.list()` returns the configured languages (including disabled languages available for authoring). Entries expose `localeId`, their sparse `localization` draft, and the frozen `publicPath` of their active version. Collection fields expose their resolved `localization: 'shared' | 'localized'` policy. + +```ts +const locales = await api.cms.content.locales.list() +const german = locales.find((locale) => locale.code === 'de') +if (german) { + const posts = api.cms.content.table('posts') + const entry = await posts.get(entryId, { localeId: german.id }) + await posts.update(entryId, { localeId: german.id, cells: { title: 'Hallo' } }) + await posts.publish(entryId, { localeId: german.id }) + await posts.unpublish(entryId, { localeId: german.id }) + await api.cms.content.tree(pageId, 'body', { localeId: german.id }).read() +} +``` + +Sparse target edits inherit untouched source fields. A missing translation stays offline; a plugin must explicitly publish that variant. `publish` and `unpublish` require the granted `cms.content.publish` permission and the table's `publish` access mode. `delete` removes the logical item in every language. Scheduled publication accepts `{ localeId, scheduledFor }` and freezes both content and design dependencies; later draft changes do not alter the planned release. Tree edits in secondary languages support localized properties and visibility while structural changes remain shared. + +`content.entry.created`/`updated` and the `content.entry.cells` filter context carry `localeId`; a global table move or delete uses `localeId: null`. Filters must retain that scope when issuing follow-up writes. Published snapshots resolve only an online variant in the requested enabled language, with its frozen path and cells. Search applies language and table access filters before its limit. + +The host protocol names the per-table entry calls as `cms.content.entries.list`, `cms.content.entries.get`, `cms.content.entries.getBySlug`, `cms.content.entries.create`, `cms.content.entries.update`, `cms.content.entries.delete`, `cms.content.entries.publish`, `cms.content.entries.unpublish`, `cms.content.entries.moveTable`, `cms.content.entries.createMany`, `cms.content.entries.updateMany`, and `cms.content.entries.deleteMany`. Tree calls dispatch as `cms.content.tree.read`, `cms.content.tree.mutate`, and `cms.content.tree.replace`; `getPublishedSnapshot(...)` dispatches as `cms.content.snapshot`. #### Content events diff --git a/docs/features/publisher.md b/docs/features/publisher.md index f7efd7b6a..65c2808ef 100644 --- a/docs/features/publisher.md +++ b/docs/features/publisher.md @@ -9,7 +9,7 @@ The published output has **no framework runtime**, **no client-side hydration of ## TL;DR - Entry point: `publishPage(page, site, registry, options?)` in `src/core/publisher/render.ts`. Returns `{ filename, html, jsModuleIds }`, where `html` is the full document string and `jsModuleIds` are per-page module-JS candidates for the server injection pass. -- Recursion: `renderNode(nodeId, config, acc)` in `renderNode.ts`. Bottom-up walk. Two specialized renderers hook in for `base.visual-component-ref` and `base.loop`. +- Recursion: `renderNode(nodeId, config, acc)` in `renderNode.ts`. Bottom-up walk. Specialized renderers handle Visual Components, loops, outlets and the language switcher. - Hidden nodes (`node.hidden`) are pruned at the top of `renderNode`, before unknown-module comments, dynamic holes, specialized renderers, standard rendering, or CSS collection. - Per-node flow: render children → resolve effective + dynamic props → `escapeProps` → call `module.render(props, renderedChildren)` → collect deduped CSS → inject author class names. - CSS is deduped by `moduleId` via `CssCollector` (~60–80% size reduction on typical pages). @@ -44,7 +44,7 @@ src/core/publisher/ └── utils.ts — escapeHtml, isSafeUrl, safeUrl (re-exported from @core/html-sanitize); sanitiseCssValue (from @core/css-sanitize) server/publish/ -├── publicRouter.ts — gateway: Layer A disk fast-path → Layer B LRU → live resolver +├── publicRouter.ts — gateway: live language inventory → Layer A disk → Layer B LRU → frozen context ├── staticArtefact.ts — two-slot pointer-file swap + read/write/purge artefacts (Layer A); all URL-derived paths are validated by `resolveArtefactPath` (URL-decode + `..`-rejection + containment check after `path.join`) ├── renderCache.ts — in-memory LRU (Layer B); reads publishVersion from publishState ├── publishState.ts — publishVersion (bump/get) + withPublishLock + createVersionedSingleFlight @@ -57,7 +57,13 @@ server/publish/ ├── renderTreeWalk.ts — walkRenderTree: visits every node that contributes to a rendered page (page nodes + VC definition trees, cycle-guarded); single source of truth for loop-prefetch and media-prefetch ├── mediaPrefetch.ts, loopPrefetch.ts — pre-warm caches needed by the renderer ├── republish.ts — bulk re-publish on site-level changes -├── publishScheduler.ts — scheduled publish jobs +├── publishScheduler.ts — scheduled language publications +├── schedulePublication.ts — capture content, public path and design dependencies for a schedule +├── publishedRoutes.ts — authoritative live route inventory, cached per DB + publish version +├── publishedRouteContext.ts — hydrate pinned releases and project live navigation +├── rebakePublishedRoutes.ts — rebuild every live route after publication or retraction +├── localizedSeo.ts — canonical, reciprocal hreflang and live sitemap +├── publishedCssFallback.ts — recreate CSS from exact composed live release contexts ├── runtime/ — per-site bun install workspace serving └── loopRuntime.ts — loop runtime asset ``` @@ -250,9 +256,10 @@ published-snapshot renderer uses `buildPublishedSiteCssBundle`, which memoises the three page-invariant files by `publishVersion` + site object. The all-pages walk then runs **once per published snapshot object** instead of once per render, so a Layer-B cache miss or a background republish no longer repays it per page. -The site-object guard matters during a full publish: HTML is baked before -`bumpPublishVersion()`, so a new snapshot at the still-current version must not -reuse CSS from the previous published site. `userStyles` is still rebuilt per +Different languages and independently published content may retain different +immutable releases at the same process publish version. The route-context memo +shares a projected site object per release; the CSS cache keeps their class and +framework output separate. `userStyles` is still rebuilt per call (page-scoped). `bumpPublishVersion()` invalidates the memo, so a content change can never serve stale framework/style CSS. Callers that pass draft or arbitrary sites at the live version (preview, AI render, the CSS-route fallback) @@ -290,28 +297,31 @@ Four bundles per page (each hashed independently): `reset`, `framework`, ### Static publishing — everything baked to disk -A full publish (`publishDraftSite`) bakes **every page** plus all of its assets -into the publish slot: - -- **HTML** — fully-static pages bake to a complete document; pages with dynamic - nodes bake their static **shell** with `` placeholders (the hole - runtime hydrates each fragment from `/_instatic/hole/`). Either way the HTML is on - disk. A page that fails to render (e.g. a VC ref cycle) is skipped and falls - through to the live renderer. -- **CSS bundles** — `/_instatic/css/-.css`, for every page. -- **Runtime JS** — `/_instatic/assets//…`, for every page. - -The visitor router serves all of these straight off disk (`readArtefact` / -`readStaticAsset`) — no DB round-trip, no per-request rebuild. The slot is a -self-contained static export: **a published page never hits the server to -generate its HTML, CSS, or JS. The only request that touches the DB is the -`/_instatic/hole/` fragment fetch** for a page's dynamic islands. - -Hole shells are stamped with the *next* publish version (`getPublishVersion() + -1`) at bake time, because `bumpPublishVersion()` runs as the synchronous -statement right after the slot swap — so a baked `` -always matches what the hole endpoint expects (a mismatch would make the -endpoint refuse to hydrate). +Every publication rebuilds all currently live routes into the inactive slot. +Each route uses its own immutable content version and pinned site release. +Offlining a page, item or language invalidates the complete previous generation; +rebaking updates lists, navigation, language-switcher links and SEO on the +remaining pages as well as removing the retracted route. + +- **HTML** is either a complete document or a static shell with dynamic holes. +- **CSS bundles** include every linked, content-addressed stylesheet. +- **Runtime JS** includes the rendering page/template's manifest and its chunks. +- **404 documents** have internal language-specific storage keys, so an authored + `/404` page cannot replace an unrelated missing-page response. Conventional + locale `/404.html` files are also written when no actual route owns that URL. +- **Sitemap** contains only live page and CMS item variants with public routes. + +The request resolves the cached live inventory **before** reading HTML from +disk. The first request at a publish version loads the inventory; subsequent +validated disk hits require no snapshot hydration or additional SQL. A version +bump disables the old complete slot immediately. Only a successful slot swap +marks the replacement current. If the bake fails, live rendering remains +available and old navigation cannot leak retracted content. Startup rebakes +under the publication lock before trusting an existing slot again. + +HTML and hole placeholders use the committed process publish version. The +version is bumped immediately after the DB commit, before the rebake starts; +there is no interval in which newly committed content can reuse the old cache. The exclusive namespaces `/_instatic/css/*` (`serveSiteCss`) and `/_instatic/assets/*` (`tryServeRuntimeAsset`) are served **disk-first**, falling back to a rebuild @@ -371,7 +381,7 @@ Because `serializeCsp` sorts, the same plugins + adapters always emit a **byte-i ## Module JS channel -`render()` may return `js` next to `html`/`css` (`RenderOutput`, `src/core/module-engine/types.ts`). The walker dedupes it per moduleId into `RenderAccumulators.jsMap`; `publishPage` reports per-page candidates (`jsModuleIds` = render-emitted ids ∪ every moduleId inside the page's hole subtrees via `collectHoleSubtreeModuleIds`); the server intersects candidates with the site-wide map (`buildPublishedSiteModuleJsMap`) and injects one external `` escaping anywhere. Pages with no module JS ship zero script tags and keep `script-src 'none'`. The CMS form runtime is the first consumer: `base.form` emits it when `mode === 'cms'` (`src/modules/base/forms/formRuntimeJs.ts`); token stamping stays server-side (`stampFormPageTokens`, applied to baked pages and hole fragments). +`render()` may return `js` next to `html`/`css` (`RenderOutput`, `src/core/module-engine/types.ts`). The walker dedupes it per moduleId into `RenderAccumulators.jsMap`; `publishPage` reports per-page candidates (`jsModuleIds` = render-emitted ids ∪ every moduleId inside the page's hole subtrees via `collectHoleSubtreeModuleIds`); the server intersects candidates with the site-wide map (`buildPublishedSiteModuleJsMap`) and injects one external `` escaping anywhere. Pages with no module JS ship zero script tags and keep `script-src 'none'`. The CMS form runtime is the first consumer: `base.form` emits it when `mode === 'cms'` (`src/modules/base/forms/formRuntimeJs.ts`); token stamping stays server-side (`stampFormPageTokens`, applied to baked pages, hole fragments and infinite-loop fragments). --- @@ -381,7 +391,7 @@ Because `serializeCsp` sorts, the same plugins + adapters always emit a **byte-i | File | Role | |-------------------------------------------------|---------------------------------------------------------------------| -| `server/publish/publicRouter.ts` | Gateway: Layer A disk fast-path → Layer B LRU → live `resolvePublicRoute` + `renderPublicResolution`. | +| `server/publish/publicRouter.ts` | Gateway: authoritative language inventory → current disk generation → LRU → immutable route context. | | `server/publish/staticArtefact.ts` | Two-slot pointer-file swap (`swapSlot`), per-file atomic writes (`writeArtefact`, `updateArtefactInPlace`), and reads (`readArtefact`). Layer A. | | `server/publish/renderCache.ts` | In-memory LRU keyed by `(urlPath, canonicalQuery)`, entries versioned. `getOrRender` (single-flight). Reads the version from `publishState`; version captured at render start — a publish landing mid-render discards the result rather than caching stale HTML. Layer B. | | `server/publish/publishState.ts` | Publish-time process state: `publishVersion` (`bumpPublishVersion`/`getPublishVersion`), `withPublishLock` (ISS-038 publish serializer), and `createVersionedSingleFlight` — the generalized version-keyed single-flight memo the hole endpoint reuses. Repositories import the version + lock from here (not from the cache). | @@ -402,7 +412,7 @@ Because `serializeCsp` sorts, the same plugins + adapters always emit a **byte-i | `server/publish/runtime/packageServer.ts` | Serve per-site `bun install` workspace under `/_instatic/runtime/cache/`. | | `server/publish/loopRuntime.ts` | The loop runtime asset (small JS shim used by certain loop variants).| | `server/handlers/cms/hole.ts` | `GET /_instatic/hole-runtime.js` (serves `HOLE_RUNTIME_JS`) and `GET /_instatic/hole/?v=&u=` (renders a node subtree at request time for Layer C islands). | -| `server/handlers/cms/moduleJs.ts` | `GET /_instatic/module-js/.js?v=` — serves a module's render-emitted JS from the memoised site map; validates the untrusted moduleId segment; 404 unknown; `text/javascript`; `cache-control: public, max-age=3600`. | +| `server/handlers/cms/moduleJs.ts` | `GET /_instatic/module-js/.js?v=&u=` — serves a module's render-emitted JS from the memoised site map; validates the untrusted moduleId segment; 404 unknown; `text/javascript`; `cache-control: public, max-age=3600`. | | `server/richtextSanitizer.ts` | Installs the server's jsdom-backed DOMPurify runtime without global DOM objects. | ### `publishedHtmlPipeline.ts` — the plugin filter point @@ -418,7 +428,7 @@ applyPublishedHtmlPipeline(renderedOutput, db) ├─→ Emit `publish.before` hook (plugins can prepare state) ├─→ Splice in declarative tags from plugin manifests' `frontend.assets[]` ├─→ Stamp form page tokens onto CMS-native
tags (`stampFormPageTokens`) - ├─→ Inject per-module published JS: one ``, + ``, ) .join('\n') const withScripts = html.includes('') diff --git a/server/publish/publicRenderer.ts b/server/publish/publicRenderer.ts index c8f41a48a..c376e0f03 100644 --- a/server/publish/publicRenderer.ts +++ b/server/publish/publicRenderer.ts @@ -11,11 +11,15 @@ import { prefetchLoopData, publishedDataRowToLoopItem } from './loopPrefetch' import { prefetchMediaAssets } from './mediaPrefetch' import { getPublishVersion } from './publishState' import type { Page } from '@core/page-tree' +import type { PublicFormRouteIdentity } from '@core/forms' import type { DocumentMetaOverride, SiteCssBundle } from '@core/publisher' import type { PublishedDataRow } from '@core/data/schemas' import { readEntrySeoOverride } from '@core/data/cells' import type { DbClient } from '../db/client' import type { PublishedPageSnapshot } from '../repositories/publish' +import { publishedLanguageAlternatives, type PublishedRoute, type PublishedRouteInventory } from '@core/localization-routing' +import { buildLocalizedSeo, renderLocalizedSeoLinks } from './localizedSeo' +import type { PublishedRouteContext } from './publishedRouteContext' /** * URL prefix where the Bun server exposes the per-site CSS bundle. Mirrors @@ -62,6 +66,8 @@ export interface RendererOutput { * whose page-scoped `userStyles` hash can differ from any raw page's). */ cssBundle: SiteCssBundle + publicPath?: string + formIdentity?: PublicFormRouteIdentity } interface RenderPublishedSnapshotContext { @@ -71,11 +77,13 @@ interface RenderPublishedSnapshotContext { /** * Publish version to stamp into `` placeholders. * Defaults to the live `getPublishVersion()`. The full/incremental publish - * bakes shells BEFORE bumping the version, so it passes the next version - * (`getPublishVersion() + 1`) here — otherwise every baked hole would carry + * bakes shells after committing and bumping, so it passes that exact version + * here — otherwise every baked hole would carry * a stale version and the hole endpoint would refuse to hydrate it. */ publishVersion?: number + route?: PublishedRoute + inventory?: PublishedRouteInventory } /** @@ -111,6 +119,7 @@ async function renderMergedTemplate( cssAssetBaseUrl: CSS_ASSET_BASE_URL, loopData, mediaAssets, + languageAlternatives: ctx.route && ctx.inventory ? publishedLanguageAlternatives(ctx.inventory, ctx.route) : [], loopEndpointBaseUrl: LOOP_ENDPOINT_BASE_URL, publishVersion, }) @@ -118,7 +127,26 @@ async function renderMergedTemplate( // subtrees) ∩ the site module-JS map — over-inclusive candidates from // unbaked holes are filtered down to modules that actually ship JS. const jsModuleIds = published.jsModuleIds.filter((id) => moduleJsMap.has(id)) - return { html: published.html, jsModuleIds, publishVersion, cssBundle } + let html = published.html + if (ctx.route && ctx.inventory && snapshot.site.settings.publicOrigin) { + const seo = buildLocalizedSeo(ctx.inventory, ctx.route, snapshot.site.settings.publicOrigin) + if (seo) html = html.replace('', `${renderLocalizedSeoLinks(seo)}\n`) + } + return { html, jsModuleIds, publishVersion, cssBundle } +} + +export async function renderResolvedPublishedRoute( + context: PublishedRouteContext, + db: DbClient, + url: URL, + inventory: PublishedRouteInventory, + publishVersion?: number, +): Promise { + const ctx = { db, url, inventory, route: context.route, publishVersion } + const rendered = await (context.row + ? renderPublishedDataRowTemplate(context.snapshot, context.row, ctx) + : renderPublishedSnapshot(context.snapshot, ctx)) + return rendered ? { ...rendered, formIdentity: { pageId: context.route.contentId, localeId: context.route.localeId, publishedVersionId: context.route.publishedVersionId, pagePath: context.route.path } } : null } export async function renderPublishedSnapshot( @@ -142,7 +170,7 @@ export async function renderPublishedSnapshot( : undefined const rendered = await renderMergedTemplate(merged, snapshot, templateContext, ctx) - return { ...rendered, pageId: snapshot.pageRowId, slug: page.slug, siteId: snapshot.site.id } + return { ...rendered, pageId: snapshot.pageRowId, slug: page.slug, siteId: snapshot.site.id, publicPath: ctx.url?.pathname } } /** @@ -167,7 +195,7 @@ export async function renderPublishedNotFound( : undefined const rendered = await renderMergedTemplate(merged, snapshot, templateContext, ctx) - return { ...rendered, pageId: page.id, slug: page.slug, siteId: snapshot.site.id } + return { ...rendered, pageId: page.id, slug: page.slug, siteId: snapshot.site.id, publicPath: ctx.url?.pathname } } export async function renderPublishedDataRowTemplate( @@ -186,6 +214,7 @@ export async function renderPublishedDataRowTemplate( // binding as well as ``, so the SEO override travels separately // through `documentMeta` and only reaches the `<head>`. if (typeof row.cells.title === 'string') merged.title = row.cells.title + if (row.publicPath) merged.publicPath = row.publicPath // Seed the entry stack with the published row + route frame from the request // URL. Loop interceptors push/pop iteration items on top of this stack; @@ -203,5 +232,5 @@ export async function renderPublishedDataRowTemplate( ctx, readEntrySeoOverride(row.cells), ) - return { ...rendered, pageId: merged.id, slug: merged.slug, siteId: snapshot.site.id } + return { ...rendered, pageId: merged.id, slug: merged.slug, siteId: snapshot.site.id, publicPath: ctx.url?.pathname } } diff --git a/server/publish/publicRouter.ts b/server/publish/publicRouter.ts index de02b0339..18287f072 100644 --- a/server/publish/publicRouter.ts +++ b/server/publish/publicRouter.ts @@ -1,341 +1,90 @@ -/** - * Public-site routing entrypoint. - * - * Every visitor request for an HTML page — whether the URL maps to a - * stand-alone published page (`/about`) or to a content row rendered - * through its postType's entry template (`/posts/hello-world`) — flows - * through this module. There used to be two parallel router branches: - * - * - `tryServePublishedPage` → page lookup by slug → render - * - `tryServeContentRoute` → row lookup by route → template render - * - * Both branches produced the same `RendererOutput` shape and both fed - * the same `applyPublishedHtmlPipeline`. The split predates the - * pages→data_rows migration: pages used to be their own table. After - * the migration, pages, posts, and components are all `data_rows` — - * the difference between them is just the lookup strategy, not the - * publishing model. - * - * This module consolidates the public-route surface: - * - * 1. `resolvePublicRoute(db, url)` walks the lookup order (page slug - * → data-row route → row redirect) and returns a - * `PublicRouteResolution`. - * 2. `renderPublicResolution(db, url, uploadsDir?)` handles the full - * request. Layer A: when `uploadsDir` is set and the URL has no - * real loop-pagination query params, it first tries `readArtefact` from the active - * publish slot. On a hit the pre-rendered HTML is returned - * immediately (no DB, no render). On a miss, it falls through to - * `resolvePublicRoute` + Layer B. - * - * Layer B: a warm cache entry (only ever a 200 render at the current - * publish version) is served BEFORE route resolution, so cache hits do - * zero DB work. On a miss, redirects and not-founds resolve before the - * factory so they are never stored. The render factory is invoked at - * most once per concurrent key burst (single-flight) and its result is - * stored in the LRU keyed by (urlPath, canonicalQuery, publishVersion). - * The cache is invalidated by `bumpPublishVersion`, which fires on - * every mutation that changes what a published URL serves (publish, - * unpublish, soft-delete, table move). - * - * The `publicSlugFromPath` helper is exported because the loop runtime - * (`server/handlers/cms/loop.ts`) needs the same path → slug - * normalisation as the resolver does. Keeping the helper in one place - * stops "the loop endpoint thinks `/about/` is a different slug than - * `/about`" drift. - * - * Layer A disk-artefact contract: - * - Static artefacts are written at publish time by `publishDraftSite` - * (full publish) and `publishDataRow` (incremental publish). - * - `applyPublishedHtmlPipeline` fires at publish time for static - * routes — plugin frontend injections and filters are baked into - * the artefact. The disk path never calls the pipeline per request. - * - For non-static routes (loops, request-dependent bindings), the - * live-render fallback runs once per (urlPath, canonicalQuery, publishVersion) - * burst and the result is stored in the Layer B LRU. - * - Only requests whose canonical query is empty hit the disk path. Junk - * params collapse to empty; real `loop_<nodeId>_page` pagination always - * falls through so Layer A never serves stale pagination output. - */ - -import type { DbClient } from '../db/client' -import type { PublishedPageSnapshot } from '../repositories/publish' -import type { PublishedDataRow } from '@core/data/schemas' -import { isTemplatePage, resolveNotFoundTemplate } from '@core/templates' +/** One public inventory gates HTML, cache hits, redirects and localized SEO. */ import { - getDataRowRedirectByRoute, - getPublishedDataRowByRoute, -} from '../repositories/data/publish' -import { getPublishedPageBySlug } from '../repositories/publish' -import { applyPublishedHtmlPipeline } from './publishedHtmlPipeline' -import { - renderPublishedDataRowTemplate, - renderPublishedNotFound, - renderPublishedSnapshot, -} from './publicRenderer' -import { NOT_FOUND_ARTEFACT_URL_PATH, readArtefact } from './staticArtefact' + buildLocalizedPath, + localeForPublishedPath, + LocalizedRouteError, + normalizePublishedPath, + resolvePublishedRoute, +} from '@core/localization-routing' +import type { DbClient } from '../db/client' +import { getPublishedRedirectByPath } from '../repositories/data' +import { getLatestPublishedSiteSnapshot } from '../repositories/publish' import { getOrRender, peek } from './renderCache' -import { getLatestSnapshotForVersion } from './publishedSnapshotCache' -import { snapshotForEntryRoute, snapshotForNotFoundRoute } from './entryTemplateSnapshot' -import { getPublishVersion } from './publishState' +import { arePublishedArtefactsCurrent, getPublishVersion } from './publishState' import { canonicalRenderQuery } from './loopPrefetch' - -// --------------------------------------------------------------------------- -// Path helpers -// --------------------------------------------------------------------------- - -/** - * Normalise an inbound URL pathname to the slug used by the published-page - * lookup. The empty path (`/`) maps to the canonical `index` slug. - * - * Shared with the loop runtime so per-page slug resolution stays consistent. - */ -function publicSlugFromPath(pathname: string): string { - const trimmed = pathname.replace(/^\/+|\/+$/g, '') - return trimmed === '' ? 'index' : trimmed -} - -/** - * Split a `/<table-route>/<row-slug>` pathname into its components, ready - * for `getPublishedDataRowByRoute`. Returns `null` for paths that don't - * have at least two segments — the caller should treat those as - * "not a content-row URL" and move on. - */ -function contentRouteFromPath(pathname: string): { tableRouteBase: string; rowSlug: string } | null { - const parts = pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean) - if (parts.length < 2) return null - return { - tableRouteBase: `/${parts.slice(0, -1).map((part) => decodeURIComponent(part)).join('/')}`, - rowSlug: decodeURIComponent(parts[parts.length - 1]), - } -} - -// --------------------------------------------------------------------------- -// Route resolution -// --------------------------------------------------------------------------- - -/** - * Discriminated result of `resolvePublicRoute`. `not-found` means the - * URL doesn't map to any published content; callers continue dispatch - * to the next handler (e.g. the setup-wizard redirect). `redirect` is - * an old row-slug → new path mapping; the caller emits a 301. - */ -type PublicRouteResolution = - | { kind: 'page'; snapshot: PublishedPageSnapshot } - | { kind: 'row'; snapshot: PublishedPageSnapshot; row: PublishedDataRow } - | { kind: 'redirect'; location: string } - | { kind: 'not-found' } - -/** - * Walk the lookup order for a public URL: - * - * 1. Page snapshot at the full slug (`/about` → page row with slug - * `about`). - * 2. Data row at `<route-base>/<row-slug>` (`/posts/hello` → row - * `hello` under postType `posts`). - * 3. Redirect from a previous slug (the row was renamed; old URL → - * new path). - * - * Page lookup wins over row lookup when both shapes are possible — a - * page with slug `posts/hello` shadows a row at the same URL. That - * matches the pre-unification routing order (`tryServePublishedPage` - * ran before `tryServeContentRoute` in the dispatcher). - * - * The row path also needs the site snapshot to find explicitly authored entry - * templates; when there isn't one, we return `not-found` rather than inventing - * a fallback document. - */ -async function resolvePublicRoute( - db: DbClient, - url: URL, -): Promise<PublicRouteResolution> { - // Page at the full slug. - const pageSlug = publicSlugFromPath(url.pathname) - const pageSnapshot = await getPublishedPageBySlug(db, pageSlug) - if (pageSnapshot) { - const page = pageSnapshot.site.pages.find((p) => p.id === pageSnapshot.pageRowId) - if (page && !isTemplatePage(page)) { - return { kind: 'page', snapshot: pageSnapshot } - } - // Template page (a layout/entry template): never directly routable — it - // only ever wraps other content. Fall through to row/redirect/not-found. - } - - // Data-row routes need at least `/table/slug` shape. - const route = contentRouteFromPath(url.pathname) - if (!route) return { kind: 'not-found' } - - const row = await getPublishedDataRowByRoute(db, route.tableRouteBase, route.rowSlug) - if (row) { - // Row routes render through explicitly authored entry templates. A missing - // site snapshot means there is no published template surface to consult, so - // surface that as not-found rather than inventing a fallback document. The - // snapshot is memoised per publish version, so warm row requests skip the - // full-site parse. - const siteSnapshot = await getLatestSnapshotForVersion(db, getPublishVersion()) - if (!siteSnapshot) return { kind: 'not-found' } - // That snapshot carries no runtime manifest — an entry route takes the one - // belonging to the template that actually renders it. - const snapshot = await snapshotForEntryRoute(db, siteSnapshot, row.tableSlug) - return { kind: 'row', snapshot, row } - } - - const redirect = await getDataRowRedirectByRoute(db, route.tableRouteBase, route.rowSlug) - if (redirect) { - return { kind: 'redirect', location: `${redirect.targetPath}${url.search}` } +import { getPublishedRouteInventoryForVersion } from './publishedRoutes' +import { projectPublishedSite, readPublishedRouteContext } from './publishedRouteContext' +import { notFoundArtefactPath, readArtefact } from './staticArtefact' +import { renderPublishedNotFound, renderResolvedPublishedRoute } from './publicRenderer' +import { applyPublishedHtmlPipeline } from './publishedHtmlPipeline' +import { buildLocalizedSitemap } from './localizedSeo' +import { snapshotForNotFoundRoute } from './entryTemplateSnapshot' + +const htmlHeaders = { 'content-type': 'text/html; charset=utf-8' } + +export async function renderPublicResolution(db: DbClient, url: URL, uploadsDir?: string): Promise<Response | null> { + const version = getPublishVersion() + const inventory = await getPublishedRouteInventoryForVersion(db, version) + if (url.pathname === '/sitemap.xml') { + const carrier = inventory.routes[0] ?? inventory.dependencies[0] + if (!carrier) return null + const snapshot = await getLatestPublishedSiteSnapshot(db, carrier.localeId, carrier.siteSnapshotId) + const origin = snapshot?.site.settings.publicOrigin + return origin ? new Response(buildLocalizedSitemap(inventory, origin), { headers: { 'content-type': 'application/xml; charset=utf-8' } }) : null } - - return { kind: 'not-found' } -} - -// --------------------------------------------------------------------------- -// Resolution → Response -// --------------------------------------------------------------------------- - -/** - * Materialise a public URL into the `Response` the visitor sees. - * - * Returns `null` for `not-found` so the router can fall through to its - * next handler (e.g. the setup-wizard redirect). - * - * Layer A fast-path: when `uploadsDir` is provided AND the request URL's - * canonical render query is empty, `readArtefact` is called first. On a hit - * the pre-rendered HTML is returned immediately — no DB lookup, no render. - * On a miss the resolution path below runs normally. - * - * Redirect and not-found resolutions are returned immediately without - * consulting the cache — they are cheap to recompute and must not - * poison the LRU. - * - * Layer B: the render + pipeline result for a 200 response is stored in - * an in-memory LRU keyed by (urlPath, canonicalQuery). Entries become stale - * when `bumpPublishVersion()` is called after any publish. Concurrent - * requests for the same key share one in-flight factory (single-flight). - * - * A `row` resolution can still yield `null` here when the postType's - * entry template selection misses (no matching template at all). That's - * the same "render → 404" behaviour the pre-unification router had. - */ -export async function renderPublicResolution( - db: DbClient, - url: URL, - uploadsDir?: string, -): Promise<Response | null> { - // Canonicalise the query to the loop-pagination params the renderer actually - // consumes. Junk params collapse to '' (so they never mint cache slots), and - // real pagination keeps a bounded, canonical key (ISS-032). - const canonicalQuery = canonicalRenderQuery(url.searchParams) - - // ── Layer A: disk artefact fast-path ───────────────────────────────────── - // Only for requests whose canonical query is empty. Real loop pagination - // (e.g. `?loop_x_page=2`) falls through to Layer B so it is never served the - // canonical URL's baked HTML; junk query strings still hit the disk artefact. - if (uploadsDir && canonicalQuery === '') { - const html = await readArtefact(uploadsDir, url.pathname) - if (html !== null) { - return new Response(html, { - headers: { 'content-type': 'text/html; charset=utf-8' }, - }) + const route = resolvePublishedRoute(inventory, url.pathname) + if (!route) { + let path: string + try { + path = normalizePublishedPath(url.pathname) + } catch (error) { + if (error instanceof LocalizedRouteError) return null + throw error } + const redirect = await getPublishedRedirectByPath(db, path) + if (!redirect || !resolvePublishedRoute(inventory, redirect.targetPath)) return null + return new Response(null, { status: 301, headers: { location: `${redirect.targetPath}${url.search}` } }) } - - // ── Layer B fast-path: serve a warm cached render before resolving ─────── - // Only 200 renders are ever stored, so a version-matched hit can be served - // without touching the DB. Route retractions (unpublish, soft-delete, table - // move) bump the publish version, which turns every cached entry into a - // miss — a deleted route can never be served from a stale entry. - const cacheKey = { urlPath: url.pathname, queryString: canonicalQuery } - const warm = peek(cacheKey) - if (warm) { - return new Response(warm.body, { headers: warm.headers, status: warm.status }) - } - - // Resolve outside the cache factory so redirects and not-founds are never - // stored in the LRU. - const resolution = await resolvePublicRoute(db, url) - if (resolution.kind === 'not-found') return null - if (resolution.kind === 'redirect') { - return new Response(null, { - status: 301, - headers: { location: resolution.location }, - }) + // The authoritative visibility check is before disk and memory fast paths. + // A version bump disables old artefacts, including baked lists of retracted + // items, until the replacement slot is complete. + const queryString = canonicalRenderQuery(url.searchParams) + if (uploadsDir && queryString === '' && arePublishedArtefactsCurrent()) { + const html = await readArtefact(uploadsDir, route.path) + if (html !== null) return new Response(html, { headers: htmlHeaders }) } - - // ── Layer B: in-memory LRU cache for the expensive render path ─────────── - const cached = await getOrRender( - cacheKey, - async () => { - const rendered = resolution.kind === 'page' - ? await renderPublishedSnapshot(resolution.snapshot, { db, url }) - : await renderPublishedDataRowTemplate(resolution.snapshot, resolution.row, { db, url }) - if (!rendered) return null - const html = await applyPublishedHtmlPipeline(rendered, db) - return { body: html, headers: { 'content-type': 'text/html; charset=utf-8' }, status: 200 } - }, - ) - if (!cached) return null - return new Response(cached.body, { headers: cached.headers, status: cached.status }) + const key = { urlPath: route.path, queryString } + const warm = peek(key) + if (warm) return new Response(warm.body, { status: warm.status, headers: warm.headers }) + const rendered = await getOrRender(key, async () => { + const context = await readPublishedRouteContext(db, route, inventory) + if (!context) return null + const output = await renderResolvedPublishedRoute(context, db, url, inventory, version) + if (!output) return null + return { body: await applyPublishedHtmlPipeline(output, db), status: 200, headers: htmlHeaders } + }) + return rendered ? new Response(rendered.body, { status: rendered.status, headers: rendered.headers }) : null } -// --------------------------------------------------------------------------- -// 404 page -// --------------------------------------------------------------------------- - -/** - * Materialise the site's 404 page for a GET that fell through every route. - * - * Serving order mirrors `renderPublicResolution`: - * - * - Layer A: the `404.html` artefact baked by the full publish — one disk - * read, no DB. This is the path bot probes and crawler noise hit. - * - Layer B fallback (no uploadsDir / bake failed): live render through the - * LRU under the reserved `/404` key, so a burst of misses renders once. - * The entry is stored as a 200 body (the cache only holds 200 renders); - * status 404 is stamped on the Response here. A direct GET of `/404` - * served through `renderPublicResolution` returns the same body with - * status 200 — identical to how static hosts treat `404.html`. - * - * Returns `null` when the published site has no notFound template (or nothing - * is published at all) — the dispatcher then falls back to its bare JSON 404. - * - * The render is seeded with a synthetic `/404` URL (not the requested one) so - * the cached body — like the baked artefact — is identical for every missed - * path; request-dependent nodes are holes and hydrate per request anyway. - */ -export async function renderNotFoundResponse( - db: DbClient, - url: URL, - uploadsDir?: string, -): Promise<Response | null> { - const htmlHeaders = { 'content-type': 'text/html; charset=utf-8' } - - // ── Layer A: baked 404 artefact ─────────────────────────────────────────── - if (uploadsDir) { - const html = await readArtefact(uploadsDir, NOT_FOUND_ARTEFACT_URL_PATH) - if (html !== null) { - return new Response(html, { status: 404, headers: htmlHeaders }) - } +export async function renderNotFoundResponse(db: DbClient, url: URL, uploadsDir?: string): Promise<Response | null> { + const version = getPublishVersion() + const inventory = await getPublishedRouteInventoryForVersion(db, version) + const locale = localeForPublishedPath(inventory.locales, url.pathname) + if (!locale) return null + const path = buildLocalizedPath(locale, '404') + if (uploadsDir && arePublishedArtefactsCurrent()) { + const html = await readArtefact(uploadsDir, notFoundArtefactPath(locale.id)) + if (html !== null) return new Response(html, { status: 404, headers: htmlHeaders }) } - - // ── Layer B: live render through the LRU under the reserved /404 key ───── - const cacheKey = { urlPath: NOT_FOUND_ARTEFACT_URL_PATH, queryString: '' } - const warm = peek(cacheKey) - if (warm) { - return new Response(warm.body, { headers: warm.headers, status: 404 }) - } - - const siteSnapshot = await getLatestSnapshotForVersion(db, getPublishVersion()) - if (!siteSnapshot || !resolveNotFoundTemplate(siteSnapshot.site)) return null - // Same as entry routes: the 404 template supplies its own runtime manifest. - const snapshot = await snapshotForNotFoundRoute(db, siteSnapshot) - - const syntheticUrl = new URL(NOT_FOUND_ARTEFACT_URL_PATH, url.origin) - const cached = await getOrRender(cacheKey, async () => { - const rendered = await renderPublishedNotFound(snapshot, { db, url: syntheticUrl }) - if (!rendered) return null - const html = await applyPublishedHtmlPipeline(rendered, db) - return { body: html, headers: htmlHeaders, status: 200 } + const key = { urlPath: `${path}:not-found`, queryString: '' } + const rendered = await getOrRender(key, async () => { + const published = await getLatestPublishedSiteSnapshot(db, locale.id) + if (!published) return null + const site = projectPublishedSite(published.site, inventory, locale.id) + const snapshot = await snapshotForNotFoundRoute(db, { ...published, site }) + const output = await renderPublishedNotFound(snapshot, { db, url: new URL(path, url.origin), publishVersion: version }) + if (!output) return null + return { body: await applyPublishedHtmlPipeline(output, db), status: 200, headers: htmlHeaders } }) - if (!cached) return null - return new Response(cached.body, { headers: cached.headers, status: 404 }) + return rendered ? new Response(rendered.body, { status: 404, headers: rendered.headers }) : null } diff --git a/server/publish/publishRow.ts b/server/publish/publishRow.ts index 8e7e4155f..2433522fa 100644 --- a/server/publish/publishRow.ts +++ b/server/publish/publishRow.ts @@ -1,168 +1,78 @@ -/** - * Incremental (per-row) publish orchestrator. - * - * Drives one data row through the publish pipeline: - * - * 1. `persistDataRowPublish` — one short DB transaction (the data - * repository owns all SQL). - * 2. Layer A — update the row's baked artefact in the ACTIVE slot in - * place (and prune the old path when the slug changed). - * 3. Layer B — bump the publish version so the render cache refreshes. - * - * Data access lives in `server/repositories/data/publish.ts`; this module - * owns the sequencing, rendering, and disk artefacts. The dependency - * direction is one-way: publish → repositories, never back. - */ +/** Publishes one locale variant and rebuilds surfaces that depend on visibility. */ import type { DbClient } from '../db/client' import type { DataRow, DataRowVersion } from '@core/data/schemas' +import type { ScheduledLocalizationRevision } from '@core/localization-schema' +import { buildLocalizedPath, createPublishedRouteInventory, readSnapshotLanguage, LocalizedRouteError } from '@core/localization-routing' import { resolveTemplateChain } from '@core/templates' -import { - getPublishedDataRowByRoute, - getRowTableRouteBase, - getRowTableRouteInfo, - persistDataRowPublish, - previousRouteChanged, - publicDataPath, - type PreviousPublishedRoute, -} from '../repositories/data/publish' +import { getDataRow, getDataTable, getPublishedDataRowById } from '../repositories/data' +import { persistDataRowPublish, readPreviousPublishedRoute } from '../repositories/data/publish' +import { getDefaultLocale, getLocale, getTableLocalization, listLocales } from '../repositories/localization' +import { listPublishedRouteCandidates } from '../repositories/localizationRoutes' import { getLatestPublishedSiteSnapshot } from '../repositories/publish' -import { snapshotForEntryRoute } from './entryTemplateSnapshot' -import { renderPublishedDataRowTemplate } from './publicRenderer' -import { applyPublishedHtmlPipeline } from './publishedHtmlPipeline' -import { removeArtefactInPlace, updateArtefactInPlace } from './staticArtefact' -import { bumpPublishVersion, getPublishVersion, withPublishLock } from './publishState' +import { removeArtefactInPlace } from './staticArtefact' +import { bumpPublishVersion, withPublishLock } from './publishState' import { runPublishFlush } from './publishFlush' +import { publishDraftSite } from './publishSite' +import { rebakePublishedRoutes } from './rebakePublishedRoutes' -export interface PublishDataRowResult { - row: DataRow - version: DataRowVersion -} +export interface PublishDataRowResult { row: DataRow; version: DataRowVersion } +export interface PublishDataRowOptions { localeId?: string; revision?: ScheduledLocalizationRevision } export async function publishDataRow( - db: DbClient, - rowId: string, - /** - * The user attributed as the publisher. `null` is allowed for system - * actors that have no user context — e.g. the scheduled-publish tick - * (`server/publish/publishScheduler.ts`). - */ - publisherUserId: string | null, - uploadsDir?: string, + db: DbClient, rowId: string, publisherUserId: string | null, + uploadsDir?: string, options: PublishDataRowOptions = {}, ): Promise<PublishDataRowResult> { - // Flush the collab relay before reading the row — a page/component/row doc - // edited live may still hold un-persisted changes inside the debounce - // window, and per-row publish must bake exactly what the admins see. await runPublishFlush() - // Serialize against every other publish so the version read→bake→bump window - // can't interleave and mis-stamp baked hole shells (ISS-038). - return withPublishLock(() => publishDataRowLocked(db, rowId, publisherUserId, uploadsDir)) -} - -async function publishDataRowLocked( - db: DbClient, - rowId: string, - publisherUserId: string | null, - uploadsDir?: string, -): Promise<PublishDataRowResult> { - const { row, version, previousRoute } = await persistDataRowPublish(db, rowId, publisherUserId) - - // Layer A: incremental artefact update outside the transaction. - // Disk artefacts are derived state — errors are logged but do not fail - // the publish. The next full publish (publishDraftSite) will rebuild. - if (uploadsDir) { - // Bake with the NEXT publish version — `bumpPublishVersion()` below is the - // synchronous statement right after this await resolves, so a hole-shell - // baked here carries the version that becomes current with no gap. - const nextPublishVersion = getPublishVersion() + 1 - await writeDataRowArtefact(db, uploadsDir, row, previousRoute, nextPublishVersion).catch((err) => { - console.error('[publish:row] static artefact write failed (live renderer remains active):', err) + const initialRow = await getDataRow(db, rowId, options.localeId) + if (!initialRow) throw new Error('Content no longer exists') + if (initialRow.tableId === 'pages') { + await publishDraftSite(db, publisherUserId, uploadsDir, { + variants: [{ rowId, localeId: initialRow.localeId }], + ...(options.revision ? { revision: options.revision } : {}), }) + const [publishedRow, version] = await Promise.all([ + getDataRow(db, rowId, initialRow.localeId), getPublishedDataRowById(db, rowId, initialRow.localeId), + ]) + if (!publishedRow || !version) throw new Error('Published page version is missing') + return { row: publishedRow, version } } - - // Layer B: invalidate the in-memory render cache so the next visitor request - // re-renders against the freshly committed row version. - bumpPublishVersion() - - return { row, version } -} - -/** - * After a successful `persistDataRowPublish` transaction, write (or remove) - * the disk artefact for the row's entry-template page. - * - * The artefact is baked whether or not the template is fully static: a static - * template bakes a complete document; a template with dynamic nodes bakes its - * static SHELL with `<instatic-hole>` placeholders (the hole runtime hydrates each - * fragment from `/_instatic/hole/`). Either way HTML + CSS + JS come from disk. - * - * Steps: - * 1. Remove the old artefact if the slug changed (old URL no longer valid). - * 2. Look up the table route info and site snapshot. - * 3. Render through the template (stamping `publishVersion`) and write the - * artefact into the active slot. - */ -async function writeDataRowArtefact( - db: DbClient, - uploadsDir: string, - publishedRow: DataRow, - previousRoute: PreviousPublishedRoute | null, - publishVersion: number, -): Promise<void> { - const tableInfo = await getRowTableRouteInfo(db, publishedRow.id) - if (!tableInfo) return - - // Remove old artefact when the slug changed (old URL is now stale). - if (previousRoute && previousRouteChanged(previousRoute, publishedRow.slug)) { - const oldPath = publicDataPath(previousRoute.routeBase, previousRoute.slug) - await removeArtefactInPlace(uploadsDir, oldPath).catch((err) => { - console.error('[publish:row] failed to remove stale artefact at', oldPath, err) + return withPublishLock(async () => { + const row = await getDataRow(db, rowId, initialRow.localeId) + if (!row) throw new Error('Content no longer exists') + const [table, locale, locales, live] = await Promise.all([ + getDataTable(db, row.tableId), getLocale(db, row.localeId), listLocales(db), listPublishedRouteCandidates(db), + ]) + if (!table || !locale) throw new Error('Content collection or language no longer exists') + if (!locale.enabled) throw new LocalizedRouteError(locale.id, 'Enable this language before publishing content.') + const primary = await getDefaultLocale(db) + const routeConfig = await getTableLocalization(db, table.id, locale.id) + ?? await getTableLocalization(db, table.id, primary.id) + const snapshot = options.revision && !options.revision.siteSnapshotId ? null + : await getLatestPublishedSiteSnapshot(db, locale.id, options.revision?.siteSnapshotId) + const routed = table.kind === 'postType' && snapshot + && resolveTemplateChain(snapshot.site, { kind: 'entry', tableSlug: table.slug }).length > 0 + const path = options.revision && options.revision.publicPath !== undefined ? options.revision.publicPath + : routed ? buildLocalizedPath(locale, options.revision?.slug ?? row.slug, routeConfig?.routeBase ?? table.routeBase) : null + createPublishedRouteInventory(locales, [ + ...live.filter((entry) => entry.contentId !== rowId || entry.localeId !== locale.id), + ...(path ? [{ contentId: rowId, localeId: locale.id, publishedVersionId: 'planned', + languageCode: snapshot ? readSnapshotLanguage(locale.id, snapshot.site.locales, snapshot.site.settings.language).code : locale.code, + tableId: table.id, tableSlug: table.slug, availability: 'online' as const, kind: 'row' as const, path }] : []), + ]) + const result = await persistDataRowPublish(db, rowId, publisherUserId, { + localeId: locale.id, publicPath: path, siteSnapshotId: snapshot?.siteSnapshotId ?? null, + revision: options.revision, }) - } - - // Resolve the full template chain for this row's table (everywhere layout + - // entry template). No chain → no entry route to bake. - const siteSnapshot = await getLatestPublishedSiteSnapshot(db) - if (!siteSnapshot) return - - const chain = resolveTemplateChain(siteSnapshot.site, { kind: 'entry', tableSlug: tableInfo.tableSlug }) - if (chain.length === 0) return - - // Fetch the full PublishedDataRow (needed for templateContext + media path). - const publishedDataRow = await getPublishedDataRowByRoute(db, tableInfo.tableRouteBase, publishedRow.slug) - if (!publishedDataRow) return - - const newPath = publicDataPath(tableInfo.tableRouteBase, publishedRow.slug) - const syntheticUrl = new URL(`http://localhost${newPath}`) - // Runtime assets come from this table's entry template, not from the - // arbitrary page the site-wide snapshot happens to name. - const snapshot = await snapshotForEntryRoute(db, siteSnapshot, tableInfo.tableSlug) - const rendered = await renderPublishedDataRowTemplate(snapshot, publishedDataRow, { - db, - url: syntheticUrl, - publishVersion, + const version = bumpPublishVersion() + if (uploadsDir) await rebakePublishedRoutes(db, uploadsDir, version) + return { row: result.row, version: result.version } }) - if (!rendered) return - - const html = await applyPublishedHtmlPipeline(rendered, db) - await updateArtefactInPlace(uploadsDir, newPath, html) } -/** - * Remove a data row's baked Layer-A artefact from the active slot. Called when - * a row leaves public visibility (unpublish, revert-to-draft, soft-delete) so - * the static file stops being served — Layer A reads the disk slot with no - * publishVersion awareness, so without this a retracted row stays public - * (ISS-039). The route is resolved WITHOUT the `deleted_at is null` filter so - * it still works after a soft delete. Best-effort: unresolved route or missing - * file is a no-op (removeArtefactInPlace never throws on a missing file). - */ +/** The previous version retains its public path even after retraction or deletion. */ export async function removeDataRowArtefact( - db: DbClient, - uploadsDir: string, - rowId: string, - slug: string, + db: DbClient, uploadsDir: string, rowId: string, options: { localeId?: string } = {}, ): Promise<void> { - const routeBase = await getRowTableRouteBase(db, rowId) - if (routeBase === null) return - await removeArtefactInPlace(uploadsDir, publicDataPath(routeBase, slug)) + const previous = await readPreviousPublishedRoute(db, rowId, options.localeId) + if (previous) await removeArtefactInPlace(uploadsDir, previous.path) } diff --git a/server/publish/publishScheduler.ts b/server/publish/publishScheduler.ts index f1998a7b0..10aa7b62f 100644 --- a/server/publish/publishScheduler.ts +++ b/server/publish/publishScheduler.ts @@ -1,34 +1,4 @@ -/** - * Scheduled publish tick — polls the `data_rows` table for rows where - * `status = 'scheduled' AND scheduled_publish_at <= now()` and fires the - * regular publish path on each. - * - * Modeled on `server/plugins/scheduler.ts`. Differences: - * - * • One-shot, not recurring. Each scheduled row fires AT MOST ONCE per - * `scheduled_publish_at` timestamp. After firing the row's status - * transitions to `'published'` (success) or `'draft'` (failure) — - * either way it's no longer selected by subsequent ticks. - * - * • No per-schedule cadence math. The target time IS the cadence. - * - * • No run-history table. Failures go to `console.error`. The audit - * log captures the publish event itself via the existing - * `auditEvent('row.publish.scheduled')` we record next to the call. - * - * HA-safety: leader election via the shared `withSchedulerLeaderLock` - * (`server/db/advisoryLock.ts`), same as the plugin scheduler. Only ONE host - * instance ticks at a time; SQLite is single-process so the lock is a no-op - * sentinel. - * - * Failure policy: when `publishDataRow` throws (e.g. validation fails, - * the row got deleted between selection and publish), the row is - * reverted to `'draft'` via `cancelScheduledPublish` and the error is - * logged. This is the "revert to draft + log error" choice from the - * scheduling-design discussion — no retry counters, no 'failed' status, - * the operator sees their row back in the drafts list and retries - * manually. - */ +/** Scheduled language publications keep their existing live version on failure. */ import type { DbClient } from '../db/client' import { withSchedulerLeaderLock } from '../db/advisoryLock' import { publishDataRow } from './publishRow' @@ -94,34 +64,22 @@ export async function tickPublishScheduler(db: DbClient, uploadsDir?: string): P await withSchedulerLeaderLock(db, ADVISORY_LOCK_KEY, '[publish-scheduler]', async () => { const due = await listDuePublishSchedules(db, new Date().toISOString(), TICK_BATCH_LIMIT) for (const entry of due) { - await fireOne(db, entry.rowId, uploadsDir) + await fireOne(db, entry, uploadsDir) } }) } -/** - * Fire one scheduled publish. Publishes via `publishDataRow` and on - * failure reverts the row to draft + logs the error. The fired row's - * own `status='scheduled'` → `'published'` transition (inside - * `publishDataRow`) is what guarantees idempotency: even if two ticks - * race past the leader lock, the second one's `update ... where - * status = 'scheduled'` is a no-op because the first already flipped - * it to `'published'`. (See `publishDataRow`'s transaction.) - */ -async function fireOne(db: DbClient, rowId: string, uploadsDir?: string): Promise<void> { +async function fireOne( + db: DbClient, entry: Awaited<ReturnType<typeof listDuePublishSchedules>>[number], uploadsDir?: string, +): Promise<void> { try { - // `publisherUserId: null` is the "system actor" path — the publish - // wasn't initiated by a logged-in user, it was the scheduler tick. - // The `published_by_user_id` column lands as null which downstream - // UI renders as "Scheduled publish" instead of a user attribution. - await publishDataRow(db, rowId, null, uploadsDir) - await emitContentEntryUpdated(db, rowId, ['status'], { kind: 'system' }) + await publishDataRow(db, entry.rowId, null, uploadsDir, { localeId: entry.localeId, revision: entry.scheduledRevision }) + await emitContentEntryUpdated(db, entry.rowId, ['status'], { kind: 'system' }, entry.localeId) } catch (err) { - console.error(`[publish-scheduler] failed to publish row ${rowId}:`, err) - // Revert to draft so the row stops being selected on subsequent - // ticks. Operator sees it back in drafts and retries manually. - await cancelScheduledPublish(db, rowId, null).catch((cancelErr) => { - console.error(`[publish-scheduler] failed to revert row ${rowId} after publish error:`, cancelErr) + console.error(`[publish-scheduler] failed to publish ${entry.rowId}/${entry.localeId}:`, err) + // Cancel only this pending schedule; an older online version stays online. + await cancelScheduledPublish(db, entry.rowId, null, entry.localeId).catch((cancelErr) => { + console.error(`[publish-scheduler] failed to cancel ${entry.rowId}/${entry.localeId}:`, cancelErr) }) } } diff --git a/server/publish/publishSite.ts b/server/publish/publishSite.ts index cf4901610..4119b6486 100644 --- a/server/publish/publishSite.ts +++ b/server/publish/publishSite.ts @@ -1,312 +1,140 @@ -/** - * Full-site publish orchestrator. - * - * Drives the whole publish pipeline for the current draft site: - * - * Phase 1 — read the draft + run every expensive non-DB build (runtime - * script bundling, dependency cache, package importmap). - * Phase 2 — one short DB transaction via `persistSitePublish` (the - * publish repository owns all SQL). - * Layer A — bake static artefacts (HTML + CSS + runtime JS) into the - * inactive slot and swap it live (`staticArtefact.ts`). - * Layer B — bump the publish version so the in-memory render cache and - * the version-keyed snapshot memos refresh. - * - * Data access lives in `server/repositories/publish.ts`; this module owns - * the sequencing, rendering, and disk artefacts. The dependency direction is - * one-way: publish → repositories, never back. - */ +/** Publishes explicit language variants and their frozen design dependencies. */ import { nanoid } from 'nanoid' +import type { ScheduledLocalizationRevision } from '@core/localization-schema' import type { SiteDocument } from '@core/page-tree' -import type { PublishedPageRuntimeAssets } from '@core/site-runtime' -import type { PublishedRuntimePackageImportmap, SiteCssBundle } from '@core/publisher' import { normalizeSiteRuntimeConfig } from '@core/site-runtime' -import { registry } from '@core/module-engine' -import { isTemplatePage, resolveNotFoundTemplate } from '@core/templates' +import { isTemplatePage } from '@core/templates' +import { pageToCells } from '@core/data/pageFromRow' +import { buildLocalizedPath, createPublishedRouteInventory, readSnapshotLanguage, LocalizedRouteError, type PublishedRouteCandidate } from '@core/localization-routing' import type { DbClient } from '../db/client' import { nextDataRowVersionNumber } from '../repositories/data' +import { getLocale, listLocales } from '../repositories/localization' +import { listPublishedRouteCandidates } from '../repositories/localizationRoutes' import { getDraftSiteDocument, + getStoredSiteDocument, + getPublishedPageSnapshotById, persistSitePublish, - type PublishedPageSnapshot, + type PersistSitePublishInput, type PublishedPageVersionWrite, } from '../repositories/publish' import { buildSiteRuntimeScripts } from './runtime/bundleScripts' import { RuntimeScriptBuildError } from './runtime/buildError' import { ensureRuntimeDependencyCache } from './runtime/dependencyCache' -import { - buildRuntimePackageImportmap, - serializeImportmapForCsp, -} from './runtime/packageImportmap' -import { renderPublishedNotFound, renderPublishedSnapshot } from './publicRenderer' -import { prefetchMediaAssets } from './mediaPrefetch' -import { applyPublishedHtmlPipeline } from './publishedHtmlPipeline' -import { - NOT_FOUND_ARTEFACT_URL_PATH, - prepareInactiveSlot, - swapSlot, - writeArtefact, - writeStaticAsset, -} from './staticArtefact' -import { buildPublishedSiteCssBundle } from './siteCssBundle' -import { bakePublishedDataRowArtefacts } from './bakeDataRows' -import { bumpPublishVersion, getPublishVersion, withPublishLock } from './publishState' +import { buildRuntimePackageImportmap, serializeImportmapForCsp } from './runtime/packageImportmap' +import { bumpPublishVersion, withPublishLock } from './publishState' import { runPublishFlush } from './publishFlush' +import { rebakePublishedRoutes } from './rebakePublishedRoutes' -interface PublishResult { - publishedPages: number -} - -/** - * Assemble the in-memory snapshot for one page. The `site` object is SHARED - * across every snapshot of a publish (it is frozen content — nothing mutates - * it after creation), so building N snapshots costs N small objects, not N - * deep clones of the whole site. - */ -function createSnapshot( - site: SiteDocument, - pageRowId: string, - runtimeAssets?: PublishedPageRuntimeAssets, - runtimePackageImportmap?: PublishedRuntimePackageImportmap, -): PublishedPageSnapshot { - return { - cmsSnapshotVersion: 1, - pageRowId, - site, - ...(runtimeAssets && runtimeAssets.scripts.length > 0 ? { runtimeAssets } : {}), - ...(runtimePackageImportmap ? { runtimePackageImportmap } : {}), - } +export interface PublishSiteOptions { + /** Omitted means refresh currently online variants; offline stays offline. */ + variants?: { rowId: string; localeId: string }[] + /** Internal scheduler input; never accepted from the site-publish HTTP body. */ + revision?: ScheduledLocalizationRevision } export async function publishDraftSite( db: DbClient, - adminUserId: string, + adminUserId: string | null, uploadsDir?: string, -): Promise<PublishResult> { - // Flush the collab relay so the published snapshot includes edits still - // inside the debounce window (publish bakes exactly what the admins see). - // Intrinsic to publishing now, not bolted onto the HTTP route. + options: PublishSiteOptions = {}, +): Promise<{ publishedPages: number }> { await runPublishFlush() - // Serialize against every other publish so the version read→bake→bump window - // can't interleave and mis-stamp baked hole shells (ISS-038). - return withPublishLock(() => publishDraftSiteLocked(db, adminUserId, uploadsDir)) + return withPublishLock(async () => { + const [live, locales] = await Promise.all([listPublishedRouteCandidates(db), listLocales(db)]) + const selection = options.variants ?? live.filter((entry) => entry.kind !== 'row').map((entry) => ({ + rowId: entry.contentId, localeId: entry.localeId, + })) + const localeSelections = new Map<string, Set<string>>() + for (const variant of selection) { + const ids = localeSelections.get(variant.localeId) ?? new Set<string>() + ids.add(variant.rowId) + localeSelections.set(variant.localeId, ids) + } + const inputs: PersistSitePublishInput[] = [] + // Build every requested locale before committing any of them. A runtime + // compilation error in one language cannot publish the other languages. + for (const [localeId, selectedIds] of localeSelections) { + inputs.push(await prepareLocalePublish(db, adminUserId, localeId, selectedIds, options.revision)) + } + const allocatedVersions = new Map<string, number>() + for (const input of inputs) for (const page of input.pages) { + page.versionNumber = Math.max(page.versionNumber, (allocatedVersions.get(page.pageId) ?? 0) + 1) + allocatedVersions.set(page.pageId, page.versionNumber) + } + const variantKey = (localeId: string, rowId: string): string => JSON.stringify([localeId, rowId]) + const replaced = new Set(inputs.flatMap((input) => input.pages.filter((page) => page.activate).map((page) => variantKey(page.localeId, page.pageId)))) + const planned: PublishedRouteCandidate[] = inputs.flatMap((input) => input.pages.filter((page) => page.activate).map((page) => ({ + contentId: page.pageId, localeId: page.localeId, publishedVersionId: page.versionId, + siteSnapshotId: input.siteSnapshotId, tableId: 'pages', tableSlug: 'pages', availability: 'online', + languageCode: readSnapshotLanguage(page.localeId, input.site.locales, input.site.settings.language).code, + kind: page.publicPath === null ? 'template' : 'page', + ...(page.publicPath !== null ? { path: page.publicPath } : {}), + }))) + // Collision checks include existing CMS URLs and other languages. They + // happen before the short transaction, never after exposing a new route. + createPublishedRouteInventory(locales, [ + ...live.filter((entry) => !replaced.has(variantKey(entry.localeId, entry.contentId))), ...planned, + ]) + if (inputs.length > 0) await persistSitePublish(db, inputs) + const version = bumpPublishVersion() + if (uploadsDir) await rebakePublishedRoutes(db, uploadsDir, version) + return { publishedPages: inputs.reduce((count, input) => count + input.pages.filter((page) => page.publicPath !== null).length, 0) } + }) } -async function publishDraftSiteLocked( +async function prepareLocalePublish( db: DbClient, - adminUserId: string, - uploadsDir?: string, -): Promise<PublishResult> { - // ── Phase 1: read inputs + run every expensive non-DB build ────────────── - // Dependency installs (`bun install` on a cold cache) and per-page esbuild - // runs take seconds; the SQLite adapter serializes ALL transactions through - // one chain, so doing this inside the transaction stalled every concurrent - // write (autosaves, row publishes) behind it. `withPublishLock` already - // serializes publishes, and version numbers are only allocated by publish - // paths under that same lock, so reading outside the transaction is stable. - const site = await getDraftSiteDocument(db) - if (!site) throw new Error('draft site not found') - + publisherId: string | null, + localeId: string, + selectedIds: ReadonlySet<string>, + revision?: ScheduledLocalizationRevision, +): Promise<PersistSitePublishInput> { + const [draft, locale] = await Promise.all([ + revision?.siteSnapshotId ? getStoredSiteDocument(db, revision.siteSnapshotId) : getDraftSiteDocument(db, { localeId }), getLocale(db, localeId), + ]) + if (!draft || !locale) throw new LocalizedRouteError(localeId, 'The site or language does not exist.') + if (!locale.enabled) throw new LocalizedRouteError(locale.id, 'Enable this language before publishing content.') + const frozenLocale = draft.locales?.find((entry) => entry.id === localeId) ?? locale + for (const selectedId of selectedIds) { + if (!draft.pages.some((page) => page.id === selectedId)) { + throw new LocalizedRouteError(selectedId, 'A selected page no longer exists.') + } + } + const pages: SiteDocument['pages'] = [] + for (const page of draft.pages) { + if (isTemplatePage(page) || selectedIds.has(page.id)) { + pages.push(page) + } else { + const published = await getPublishedPageSnapshotById(db, page.id, localeId) + const frozen = published?.site.pages.find((candidate) => candidate.id === page.id) + if (frozen) pages.push(frozen) + } + } + const { localization: _draftVariants, ...withoutDraftVariants } = draft + const site: SiteDocument = { ...withoutDraftVariants, pages, layouts: [] } const runtime = normalizeSiteRuntimeConfig(site.runtime) const dependencyCache = Object.keys(runtime.dependencyLock.packages).length > 0 - ? await ensureRuntimeDependencyCache(runtime.dependencyLock) - : undefined - // Build the package importmap once per publish — the JSON is identical - // for every page sharing the same lock, so its SHA-256 stays stable - // across snapshots. Module plugins use bare imports (`import "three"`) - // and the browser resolves them through this map at page load. + ? await ensureRuntimeDependencyCache(runtime.dependencyLock) : undefined const packageImportmap = dependencyCache - ? await buildRuntimePackageImportmap(runtime.dependencyLock, dependencyCache) - : null - const serializedImportmap = packageImportmap - ? await serializeImportmapForCsp(packageImportmap.importmap) - : null - const runtimePackageImportmap: PublishedRuntimePackageImportmap | undefined = serializedImportmap - ? { body: serializedImportmap.body, sha256: serializedImportmap.sha256 } - : undefined - - const publishedSite: SiteDocument = { - ...site, - pages: site.pages.map((page) => ({ - ...page, - updatedByUserId: adminUserId, - })), - } - - const siteSnapshotId = nanoid() - const snapshots: PublishedPageSnapshot[] = [] - // Runtime JS bytes for every page, collected for the Layer A disk write so - // published pages serve their scripts straight off disk (not the DB). - const runtimeAssetFiles: Array<{ publicPath: string; bytes: Uint8Array }> = [] + ? await buildRuntimePackageImportmap(runtime.dependencyLock, dependencyCache) : null + const serializedImportmap = packageImportmap ? await serializeImportmapForCsp(packageImportmap.importmap) : null const pageWrites: PublishedPageVersionWrite[] = [] - for (const page of publishedSite.pages) { - const versionNumber = await nextDataRowVersionNumber(db, page.id) + for (const page of site.pages) { + if (!selectedIds.has(page.id) && !isTemplatePage(page)) continue const versionId = nanoid() - const runtimeBuild = await buildSiteRuntimeScripts({ - site: publishedSite, - page, - target: 'publish', - assetBasePath: `/_instatic/assets/${versionId}/`, - dependencyCache, + const built = await buildSiteRuntimeScripts({ + site, page, target: 'publish', assetBasePath: `/_instatic/assets/${versionId}/`, dependencyCache, }) - const runtimeErrors = runtimeBuild.diagnostics.filter((d) => d.severity === 'error') - if (runtimeErrors.length > 0) { - throw new RuntimeScriptBuildError(page, runtimeErrors) - } - - const snapshot = createSnapshot( - publishedSite, - page.id, - runtimeBuild.runtimeAssets, - runtimePackageImportmap, - ) - snapshots.push(snapshot) + const errors = built.diagnostics.filter((diagnostic) => diagnostic.severity === 'error') + if (errors.length > 0) throw new RuntimeScriptBuildError(page, errors) pageWrites.push({ - pageId: page.id, - title: page.title, - slug: page.slug, - versionId, - versionNumber, - runtimeAssets: snapshot.runtimeAssets ?? null, - runtimeFiles: runtimeBuild.files, + pageId: page.id, title: page.title, slug: page.slug, cells: pageToCells(page), localeId, + activate: selectedIds.has(page.id), + publicPath: isTemplatePage(page) ? null : revision?.publicPath ?? buildLocalizedPath(frozenLocale, page.slug), + versionId, versionNumber: await nextDataRowVersionNumber(db, page.id), + runtimeAssets: built.runtimeAssets, runtimeFiles: built.files, }) - for (const file of runtimeBuild.files) { - runtimeAssetFiles.push({ publicPath: file.publicPath, bytes: file.bytes }) - } } - - // ── Phase 2: short transaction — DB writes only ─────────────────────────── - await persistSitePublish(db, { - siteSnapshotId, - site: publishedSite, - serializedImportmap: serializedImportmap - ? { body: serializedImportmap.body, sha256: serializedImportmap.sha256 } - : null, - pages: pageWrites, - publishedByUserId: adminUserId, - }) - - const publishedPages = publishedSite.pages.length - - // Layer A: write static artefacts outside the transaction. Disk artefacts - // are derived state — a write failure is logged but does not roll back the - // DB publish. Visitors fall through to the live renderer until the next - // full publish rebuilds the slot. - // - // Complete static publishing: alongside each page's HTML we bake the CSS - // bundles and runtime JS into the same slot under their public paths - // (`/_instatic/css/...`, `/_instatic/assets/...`). The visitor router serves these off - // disk, so a published page never hits the server to (re)generate its CSS - // or JS — the slot is a self-contained static export. - // - // EVERY page is baked: fully-static pages bake to a complete document; pages - // with dynamic nodes bake their static SHELL with `<instatic-hole>` placeholders - // (the hole runtime lazy-fetches each fragment from `/_instatic/hole/`). Either way - // the HTML + CSS + JS are served from disk — only the hole fragment touches - // the server. The shells are stamped with `nextPublishVersion` (the version - // that becomes current the instant `bumpPublishVersion()` runs after the - // swap) so their `<instatic-hole data-instatic-version>` matches what the hole endpoint - // expects; otherwise every baked hole would be rejected as stale. - const nextPublishVersion = getPublishVersion() + 1 - if (uploadsDir) { - try { - const { slot, slotDir } = await prepareInactiveSlot(uploadsDir) - - // Every distinct static asset referenced by ANY baked artefact. - // Content-hashed filenames dedupe identical bytes across pages to a - // single write. The page-invariant CSS trio (reset/framework/style) is - // computed ONCE per publish via the version-keyed memo — the all-pages - // walk no longer repeats per page. Only `userStyles` is page-scoped. - const assetsByPath = new Map<string, Uint8Array>() - const encoder = new TextEncoder() - const collectCssFiles = (cssBundle: SiteCssBundle): void => { - for (const file of [cssBundle.reset, cssBundle.framework, cssBundle.style, cssBundle.userStyles]) { - if (file.content.length === 0) continue - const publicPath = `/_instatic/css/${file.filename}` - if (!assetsByPath.has(publicPath)) assetsByPath.set(publicPath, encoder.encode(file.content)) - } - } - for (const snapshot of snapshots) { - const page = snapshot.site.pages.find((p) => p.id === snapshot.pageRowId) - if (!page || isTemplatePage(page)) continue // template pages only ever wrap; never baked at their own slug - const mediaAssets = await prefetchMediaAssets(page, snapshot.site, registry, db) - collectCssFiles(buildPublishedSiteCssBundle(snapshot.site, registry, page, nextPublishVersion, { mediaAssets })) - } - for (const asset of runtimeAssetFiles) { - if (!assetsByPath.has(asset.publicPath)) assetsByPath.set(asset.publicPath, asset.bytes) - } - - // The 404 page: bake the notFound template (wrapped in its everywhere - // layout chain) to `404.html`. Baked FIRST so a literal page with slug - // `404` — if anyone creates one — overwrites it below and stays - // authoritative for both `/404` and the static-export error page. - const notFoundPage = resolveNotFoundTemplate(publishedSite) - const notFoundSnapshot = notFoundPage - ? snapshots.find((s) => s.pageRowId === notFoundPage.id) - : undefined - if (notFoundSnapshot) { - try { - const rendered = await renderPublishedNotFound(notFoundSnapshot, { - db, - url: new URL(`http://localhost${NOT_FOUND_ARTEFACT_URL_PATH}`), - publishVersion: nextPublishVersion, - }) - if (rendered) { - const html = await applyPublishedHtmlPipeline(rendered, db) - await writeArtefact(slotDir, NOT_FOUND_ARTEFACT_URL_PATH, html) - collectCssFiles(rendered.cssBundle) - } - } catch (err) { - console.error('[publish:site] failed to bake the 404 artefact (falls through to live renderer):', err) - } - } - - // HTML artefacts (or hole shells) for every page. A page that fails to - // render (e.g. a VC ref cycle) is skipped and falls through to the live - // renderer at request time — one bad page never aborts the whole bake. - for (const snapshot of snapshots) { - const page = snapshot.site.pages.find((p) => p.id === snapshot.pageRowId) - if (!page || isTemplatePage(page)) continue // template pages only ever wrap; never baked at their own slug - const urlPath = page.slug === 'index' ? '/' : `/${page.slug}` - try { - const syntheticUrl = new URL(`http://localhost${urlPath}`) - const rendered = await renderPublishedSnapshot(snapshot, { - db, - url: syntheticUrl, - publishVersion: nextPublishVersion, - }) - const html = await applyPublishedHtmlPipeline(rendered, db) - await writeArtefact(slotDir, urlPath, html) - // The render's own bundle covers template-composed hashes the raw - // page bundle above cannot (the merged page's userStyles). - collectCssFiles(rendered.cssBundle) - } catch (err) { - console.error('[publish:site] failed to bake artefact for', urlPath, '(falls through to live renderer):', err) - } - } - - // Data-row artefacts: every published row whose table has an entry - // template bakes into the same slot. Without this the slot swap would - // strand every previously-baked row artefact in the inactive slot and - // ALL row routes would fall to the live renderer after a full publish. - const rowBake = await bakePublishedDataRowArtefacts(db, slotDir, nextPublishVersion) - for (const cssBundle of rowBake.cssBundles) collectCssFiles(cssBundle) - - for (const [publicPath, bytes] of assetsByPath) { - await writeStaticAsset(slotDir, publicPath, bytes) - } - await swapSlot(uploadsDir, slot) - } catch (err) { - console.error('[publish:site] static artefact write failed (live renderer remains active):', err) - } - } - - // Layer B: invalidate the in-memory render cache so the next visitor request - // re-renders against the freshly committed snapshot. This is the SYNCHRONOUS - // statement right after the swap — no `await` between them — so there is no - // window where the freshly-swapped shells (stamped nextPublishVersion) are - // live while the version counter still reads the old value. - bumpPublishVersion() - - return { publishedPages } + return { siteSnapshotId: nanoid(), site, serializedImportmap, pages: pageWrites, publishedByUserId: publisherId } } diff --git a/server/publish/publishState.ts b/server/publish/publishState.ts index 343586016..a55d07b64 100644 --- a/server/publish/publishState.ts +++ b/server/publish/publishState.ts @@ -27,6 +27,17 @@ // --------------------------------------------------------------------------- let publishVersion = 0 +let artefactVersion = -1 + +/** A version bump invalidates every baked list and navigation dependency. */ +export function arePublishedArtefactsCurrent(): boolean { + return artefactVersion === publishVersion +} + +/** Call only after the complete matching slot has been written and swapped. */ +export function markPublishedArtefactsCurrent(version: number): void { + artefactVersion = version +} /** * Increment the publish version. All version-keyed caches (the render-cache @@ -177,6 +188,7 @@ export function createVersionedSingleFlight<T>(): VersionedSingleFlight<T> { */ export function resetPublishStateForTests(): void { publishVersion = 0 + artefactVersion = -1 publishChain = Promise.resolve() for (const reset of versionedCacheResets) reset() } diff --git a/server/publish/publishedCssFallback.ts b/server/publish/publishedCssFallback.ts new file mode 100644 index 000000000..75588c571 --- /dev/null +++ b/server/publish/publishedCssFallback.ts @@ -0,0 +1,44 @@ +/** Recreates exactly the CSS linked by live, localized route releases. */ +import { registry } from '@core/module-engine' +import type { Page, SiteDocument } from '@core/page-tree' +import type { SiteCssBundleId } from '@core/publisher' +import { buildRouteFrame } from '@core/templates/contextFrames' +import { composeTemplateChain, resolveNotFoundTemplate, resolveTemplateChain } from '@core/templates' +import type { TemplateRenderDataContext } from '@core/templates/dynamicBindings' +import type { DbClient } from '../db/client' +import { getPublishedRouteInventoryForVersion } from './publishedRoutes' +import { projectPublishedSite, readPublishedRouteContext } from './publishedRouteContext' +import { getPublishedSnapshotsForVersion } from './publishedSnapshotCache' +import { prefetchMediaAssets } from './mediaPrefetch' +import { prefetchLoopData, publishedDataRowToLoopItem } from './loopPrefetch' +import { buildPublishedSiteCssBundle } from './siteCssBundle' + +export async function rebuildPublishedCss( + db: DbClient, bundleId: SiteCssBundleId, requestedHash: string, version: number, +): Promise<string | null> { + async function match(page: Page, site: SiteDocument, path: string, entryStack: TemplateRenderDataContext['entryStack'] = []) { + const url = new URL(path, site.settings.publicOrigin ?? 'http://localhost') + const templateContext = { entryStack, route: buildRouteFrame(url.toString()) } + const loopData = await prefetchLoopData(page, site, db, url) + const mediaAssets = await prefetchMediaAssets(page, site, registry, db, { templateContext, loopData }) + const file = buildPublishedSiteCssBundle(site, registry, page, version, { mediaAssets })[bundleId] + return file.hash === requestedHash ? file.content : null + } + const inventory = await getPublishedRouteInventoryForVersion(db, version) + for (const route of inventory.routes) { + const context = await readPublishedRouteContext(db, route, inventory) + if (!context) continue + const body = await match(context.page, context.snapshot.site, route.path, + context.row ? [publishedDataRowToLoopItem(context.row)] : []) + if (body !== null) return body + } + for (const snapshot of await getPublishedSnapshotsForVersion(db, version)) { + const site = projectPublishedSite(snapshot.site, inventory, snapshot.localeId ?? snapshot.site.localeId ?? '') + const template = resolveNotFoundTemplate(site) + if (!template) continue + const page = composeTemplateChain(resolveTemplateChain(site, { kind: 'page' }), { kind: 'page', page: template }) + const body = await match(page, site, '/404') + if (body !== null) return body + } + return null +} diff --git a/server/publish/publishedHtmlPipeline.ts b/server/publish/publishedHtmlPipeline.ts index 96686a955..de1270682 100644 --- a/server/publish/publishedHtmlPipeline.ts +++ b/server/publish/publishedHtmlPipeline.ts @@ -50,13 +50,14 @@ export async function applyPublishedHtmlPipeline( const withInjections = injectFrontendAssets(rendered.html, injections) // Token stamping is an HTML mutation (needs the server signing secret) — // its own step, independent of JS injection. - const withFormTokens = stampFormPageTokens(withInjections, rendered.pageId) + const withFormTokens = rendered.formIdentity ? stampFormPageTokens(withInjections, rendered.formIdentity) : withInjections // Module-JS channel: one external <script defer> per moduleId the page // needs; relaxes CSP script-src to 'self' iff at least one tag landed. const withModuleScripts = injectModuleScripts( withFormTokens, rendered.jsModuleIds, rendered.publishVersion, + rendered.publicPath, ) const filtered = await hookBus.applyFilter('publish.html', withModuleScripts, { siteId: rendered.siteId, diff --git a/server/publish/publishedRouteContext.ts b/server/publish/publishedRouteContext.ts new file mode 100644 index 000000000..b9b0dda83 --- /dev/null +++ b/server/publish/publishedRouteContext.ts @@ -0,0 +1,122 @@ +import { reindexNodeParents, selectVisualComponentById, type Page, type PageNode, type SiteDocument } from '@core/page-tree' +import { instantiateVCAtRef, resolveSlotName, safePropOverrides } from '@core/visualComponents' +import type { PublishedDataRow } from '@core/data/schemas' +import { readSnapshotLanguage, type PublishedRoute, type PublishedRouteInventory } from '@core/localization-routing' +import { composeTemplateChain, isTemplatePage, resolveTemplateChain } from '@core/templates' +import { getPublishedDataRowById } from '../repositories/data' +import { getLatestPublishedSiteSnapshot, getPublishedPageSnapshotById, type PublishedPageSnapshot } from '../repositories/publish' +import type { DbClient } from '../db/client' +import { getPublishVersion, registerVersionedCacheReset } from './publishState' +import { snapshotForEntryRoute } from './entryTemplateSnapshot' + +export interface PublishedRouteContext { + route: PublishedRoute + snapshot: PublishedPageSnapshot + page: Page + row?: PublishedDataRow +} + +/** Follow the actual rendered tree, including localized component instances. */ +export function findPublishedFragmentTarget(page: Page, site: SiteDocument, nodeId: string): { page: Page; node: PageNode } | null { + function visit(tree: Page, id: string, seen: ReadonlySet<string>, components: ReadonlySet<string>): { page: Page; node: PageNode } | null { + if (seen.has(id)) return null + const node = tree.nodes[id] + if (!node || node.hidden) return null + if (id === nodeId) return { page: tree, node } + const nextSeen = new Set(seen).add(id) + if (node.moduleId === 'base.visual-component-ref') { + const componentId = typeof node.props.componentId === 'string' ? node.props.componentId : '' + const component = selectVisualComponentById(site, componentId) + if (!component || components.has(componentId)) return null + const slots: Record<string, string[]> = {} + for (const childId of node.children) { + const child = tree.nodes[childId] + if (child?.moduleId === 'base.slot-instance') slots[resolveSlotName(child.props)] = child.children + } + const instantiated = instantiateVCAtRef(component, safePropOverrides(node.props), slots, tree.nodes, node.id) + const nodes: Record<string, PageNode> = Object.fromEntries(Object.entries(instantiated.nodes).map(([key, value]) => [key, { ...value }])) + reindexNodeParents(nodes) + return visit({ ...page, nodes, rootNodeId: instantiated.rootNodeId }, instantiated.rootNodeId, new Set(), new Set(components).add(componentId)) + } + for (const childId of node.children) { + const result = visit(tree, childId, nextSeen, components) + if (result) return result + } + return null + } + return visit(page, page.rootNodeId, new Set(), new Set()) +} + +/** Filter every navigation and internal reference through the live inventory. */ +export function projectPublishedSite(site: SiteDocument, inventory: PublishedRouteInventory, localeId: string): SiteDocument { + const locale = site.locales?.find((entry) => entry.id === localeId) ?? inventory.locales.find((entry) => entry.id === localeId) + const language = readSnapshotLanguage(localeId, site.locales, site.settings.language) + return { + ...site, + localeId, + locales: site.locales ?? (locale ? [{ ...locale, ...language }] : []), + settings: { ...site.settings, language: language.code }, + pages: [ + ...site.pages.filter(isTemplatePage), + ...inventory.routes.filter((route) => route.kind === 'page' && route.localeId === localeId).map((route) => { + const frozen = site.pages.find((page) => page.id === route.contentId) + return { ...(frozen ?? { id: route.contentId, nodes: {}, rootNodeId: '' }), + title: route.title ?? frozen?.title ?? '', slug: route.slug ?? frozen?.slug ?? '', publicPath: route.path } + }), + ], + } +} + +let contextCaches = new WeakMap<DbClient, { version: number; entries: Map<string, Promise<PublishedRouteContext | null>>; sites: Map<string, SiteDocument> }>() +registerVersionedCacheReset(() => { contextCaches = new WeakMap() }) + +/** The same locale and frozen template release is used for pages and fragments. */ +export async function readPublishedRouteContext( + db: DbClient, route: PublishedRoute, inventory: PublishedRouteInventory, +): Promise<PublishedRouteContext | null> { + const version = getPublishVersion() + let cache = contextCaches.get(db) + if (!cache || cache.version !== version) { + cache = { version, entries: new Map(), sites: new Map() } + contextCaches.set(db, cache) + } + const key = JSON.stringify([route.localeId, route.contentId, route.publishedVersionId, route.path]) + let pending = cache.entries.get(key) + if (!pending) { + pending = loadPublishedRouteContext(db, route, inventory, cache.sites).catch((err) => { cache.entries.delete(key); throw err }) + cache.entries.set(key, pending) + } + return pending +} + +async function loadPublishedRouteContext( + db: DbClient, route: PublishedRoute, inventory: PublishedRouteInventory, sites: Map<string, SiteDocument>, +): Promise<PublishedRouteContext | null> { + function project(snapshot: PublishedPageSnapshot): SiteDocument { + const key = JSON.stringify([route.localeId, snapshot.siteSnapshotId ?? snapshot.versionId]) + let site = sites.get(key) + if (!site) { site = projectPublishedSite(snapshot.site, inventory, route.localeId); sites.set(key, site) } + return site + } + if (route.kind === 'page') { + const snapshot = await getPublishedPageSnapshotById(db, route.contentId, route.localeId, route.siteSnapshotId) + if (!snapshot || snapshot.versionId !== route.publishedVersionId) return null + const site = project(snapshot) + const source = site.pages.find((page) => page.id === route.contentId) + if (!source || isTemplatePage(source)) return null + const page = composeTemplateChain(resolveTemplateChain(site, { kind: 'page' }), { kind: 'page', page: source }) + return { route, snapshot: { ...snapshot, site }, page } + } + const row = await getPublishedDataRowById(db, route.contentId, route.localeId) + if (!row || row.id !== route.publishedVersionId) return null + const siteSnapshot = await getLatestPublishedSiteSnapshot(db, route.localeId, row.siteSnapshotId ?? undefined) + if (!siteSnapshot) return null + const site = project(siteSnapshot) + const chain = resolveTemplateChain(site, { kind: 'entry', tableSlug: row.tableSlug }) + if (chain.length === 0) return null + const snapshot = await snapshotForEntryRoute(db, { ...siteSnapshot, site }, row.tableSlug) + const page = composeTemplateChain(chain, { kind: 'entry' }) + page.publicPath = route.path + if (typeof row.cells.title === 'string') page.title = row.cells.title + return { route, snapshot, page, row } +} diff --git a/server/publish/publishedRoutes.ts b/server/publish/publishedRoutes.ts new file mode 100644 index 000000000..cb4189475 --- /dev/null +++ b/server/publish/publishedRoutes.ts @@ -0,0 +1,28 @@ +import { createPublishedRouteInventory, type PublishedRouteInventory } from '@core/localization-routing' +import type { DbClient } from '../db/client' +import { listLocales } from '../repositories/localization' +import { listPublishedRouteCandidates } from '../repositories/localizationRoutes' +import { createVersionedSingleFlight, registerVersionedCacheReset } from './publishState' + +let inventories = new WeakMap<DbClient, ReturnType<typeof createVersionedSingleFlight<PublishedRouteInventory>>>() +registerVersionedCacheReset(() => { inventories = new WeakMap() }) + +export async function getPublishedRouteInventoryForVersion(db: DbClient, version: number): Promise<PublishedRouteInventory> { + let memo = inventories.get(db) + if (!memo) { + memo = createVersionedSingleFlight<PublishedRouteInventory>() + inventories.set(db, memo) + } + const inventory = await memo.get(version, () => loadPublishedRouteInventory(db)) + if (!inventory) throw new Error('Published inventory could not be loaded') + return inventory +} + +/** Callers own publish locking and version caching; also accepts their transaction. */ +export async function loadPublishedRouteInventory(db: DbClient): Promise<PublishedRouteInventory> { + const [locales, candidates] = await Promise.all([ + listLocales(db), + listPublishedRouteCandidates(db), + ]) + return createPublishedRouteInventory(locales, candidates) +} diff --git a/server/publish/publishedSnapshotCache.ts b/server/publish/publishedSnapshotCache.ts index 7037f1fda..1b7757b3b 100644 --- a/server/publish/publishedSnapshotCache.ts +++ b/server/publish/publishedSnapshotCache.ts @@ -1,114 +1,27 @@ -/** - * Version-keyed caches over the latest published site snapshot. - * - * The published snapshot (the entire SiteDocument) changes only when the - * publish version moves, yet three request-time consumers used to load and - * JSON-parse it from the DB per request — the per-request cost flagged in the - * architecture review: - * - * - the public router's row-route resolution (`publicRouter.ts`) - * - the Layer C hole endpoint (`handlers/cms/hole.ts`) - * - the infinite-loop load-more endpoint (`handlers/cms/loop.ts`) - * - * This module owns ONE snapshot memo plus the two derived per-version indexes - * (nodeId → page for holes, loopId → page+node for loops), all built on - * `createVersionedSingleFlight` from `publishState.ts`: one concurrent loader - * per version, cached until the version changes, reset together with the rest - * of the publish state in tests. A publish bump simply makes the next read - * miss and reload against the fresh snapshot. - * - * The publish-time bake may pass `nextPublishVersion` (the version that - * becomes current the instant `bumpPublishVersion()` runs after the slot - * swap) — that pre-warms the memo for the version visitors are about to hit. - */ - +/** Active frozen snapshots used by content-addressed CSS and module assets. */ import type { DbClient } from '../db/client' -import type { Page, PageNode, SiteDocument } from '@core/page-tree' import type { PublishedPageSnapshot } from '../repositories/publish' import { getLatestPublishedSiteSnapshot } from '../repositories/publish' -import { collectLoopNodes } from './loopPrefetch' -import { createVersionedSingleFlight } from './publishState' - -// --------------------------------------------------------------------------- -// Latest snapshot -// --------------------------------------------------------------------------- - -const snapshotMemo = createVersionedSingleFlight<PublishedPageSnapshot>() - -/** - * The latest published site snapshot for `version`. Loads (and JSON-parses) - * it from the DB once per publish version; warm calls do zero I/O. - */ -export function getLatestSnapshotForVersion( - db: DbClient, - version: number, -): Promise<PublishedPageSnapshot | null> { - return snapshotMemo.get(version, () => getLatestPublishedSiteSnapshot(db)) -} - -// --------------------------------------------------------------------------- -// nodeId → page index (Layer C holes) -// --------------------------------------------------------------------------- - -interface PublishedNodeIndex { - site: SiteDocument - /** First page wins on the (extremely unlikely) duplicate node id. */ - nodeIndex: Map<string, Page> -} - -const nodeIndexMemo = createVersionedSingleFlight<PublishedNodeIndex>() - -/** - * The published site plus a `nodeId → page` index for `version`, so the hole - * endpoint locates a fragment's page in O(1) instead of scanning all pages. - */ -export function getPublishedNodeIndexForVersion( - db: DbClient, - version: number, -): Promise<PublishedNodeIndex | null> { - return nodeIndexMemo.get(version, async () => { - const snapshot = await getLatestSnapshotForVersion(db, version) - if (!snapshot) return null - const nodeIndex = new Map<string, Page>() - for (const page of snapshot.site.pages) { - for (const nodeId of Object.keys(page.nodes)) { - if (!nodeIndex.has(nodeId)) nodeIndex.set(nodeId, page) - } - } - return { site: snapshot.site, nodeIndex } - }) -} - -// --------------------------------------------------------------------------- -// loopId → page + node index (infinite-loop load-more) -// --------------------------------------------------------------------------- - -interface PublishedLoopIndex { - site: SiteDocument - /** First page wins on a duplicate loop id, matching the old scan order. */ - loops: Map<string, { page: Page; node: PageNode }> -} - -const loopIndexMemo = createVersionedSingleFlight<PublishedLoopIndex>() - -/** - * The published site plus a `loopId → { page, node }` index for `version`. - * Built once per publish version by walking each page's render tree (the same - * `collectLoopNodes` walk the loop endpoint used to repeat per request). - */ -export function getPublishedLoopIndexForVersion( - db: DbClient, - version: number, -): Promise<PublishedLoopIndex | null> { - return loopIndexMemo.get(version, async () => { - const snapshot = await getLatestSnapshotForVersion(db, version) - if (!snapshot) return null - const loops = new Map<string, { page: Page; node: PageNode }>() - for (const page of snapshot.site.pages) { - for (const node of collectLoopNodes(page, snapshot.site)) { - if (!loops.has(node.id)) loops.set(node.id, { page, node }) - } +import { getPublishedRouteInventoryForVersion } from './publishedRoutes' +import { createVersionedSingleFlight, registerVersionedCacheReset } from './publishState' + +let caches = new WeakMap<DbClient, ReturnType<typeof createVersionedSingleFlight<PublishedPageSnapshot[]>>>() +registerVersionedCacheReset(() => { caches = new WeakMap() }) + +export async function getPublishedSnapshotsForVersion(db: DbClient, version: number): Promise<PublishedPageSnapshot[]> { + let cache = caches.get(db) + if (!cache) { cache = createVersionedSingleFlight<PublishedPageSnapshot[]>(); caches.set(db, cache) } + return await cache.get(version, async () => { + const inventory = await getPublishedRouteInventoryForVersion(db, version) + const snapshots: PublishedPageSnapshot[] = [] + const seen = new Set<string>() + for (const entry of [...inventory.routes, ...inventory.dependencies]) { + const key = JSON.stringify([entry.localeId, entry.siteSnapshotId]) + if (seen.has(key)) continue + seen.add(key) + const snapshot = await getLatestPublishedSiteSnapshot(db, entry.localeId, entry.siteSnapshotId) + if (snapshot) snapshots.push(snapshot) } - return { site: snapshot.site, loops } - }) + return snapshots + }) ?? [] } diff --git a/server/publish/rebakePublishedRoutes.ts b/server/publish/rebakePublishedRoutes.ts new file mode 100644 index 000000000..b127abb18 --- /dev/null +++ b/server/publish/rebakePublishedRoutes.ts @@ -0,0 +1,71 @@ +/** Rebuilds every live dependency surface after a publication or retraction. */ +import { buildLocalizedPath } from '@core/localization-routing' +import type { DbClient } from '../db/client' +import { getLatestPublishedSiteSnapshot } from '../repositories/publish' +import { listPublishedRuntimeAssetsForVersion } from '../repositories/runtimeAsset' +import type { PublishedPageSnapshot } from '../repositories/publish' +import type { RendererOutput } from './publicRenderer' +import { renderPublishedNotFound, renderResolvedPublishedRoute } from './publicRenderer' +import { loadPublishedRouteInventory } from './publishedRoutes' +import { projectPublishedSite, readPublishedRouteContext } from './publishedRouteContext' +import { applyPublishedHtmlPipeline } from './publishedHtmlPipeline' +import { buildLocalizedSitemap } from './localizedSeo' +import { getPublishVersion, markPublishedArtefactsCurrent, withPublishLock } from './publishState' +import { notFoundArtefactPath, prepareInactiveSlot, swapSlot, writeArtefact, writeStaticAsset } from './staticArtefact' +import { snapshotForNotFoundRoute } from './entryTemplateSnapshot' + +/** After a committed visibility change, refresh every affected public surface. */ +export async function refreshPublishedRoutes(db: DbClient, uploadsDir: string): Promise<void> { + await withPublishLock(() => rebakePublishedRoutes(db, uploadsDir, getPublishVersion())) +} + +export async function rebakePublishedRoutes(db: DbClient, uploadsDir: string, version: number): Promise<void> { + const inventory = await loadPublishedRouteInventory(db) + const { slot, slotDir } = await prepareInactiveSlot(uploadsDir) + const assets = new Set<string>() + const encoder = new TextEncoder() + async function writeAssets(rendered: RendererOutput, snapshot: PublishedPageSnapshot): Promise<void> { + for (const file of Object.values(rendered.cssBundle)) { + const path = `/_instatic/css/${file.filename}` + if (assets.has(path) || file.content.length === 0) continue + await writeStaticAsset(slotDir, path, encoder.encode(file.content)) + assets.add(path) + } + if (snapshot.versionId) for (const asset of await listPublishedRuntimeAssetsForVersion(db, snapshot.versionId)) { + if (assets.has(asset.publicPath)) continue + await writeStaticAsset(slotDir, asset.publicPath, asset.bytes) + assets.add(asset.publicPath) + } + } + let publicOrigin: string | undefined + for (const locale of inventory.locales.filter((entry) => entry.enabled)) { + const published = await getLatestPublishedSiteSnapshot(db, locale.id) + if (!published) continue + const site = projectPublishedSite(published.site, inventory, locale.id) + publicOrigin ??= site.settings.publicOrigin + const snapshot = await snapshotForNotFoundRoute(db, { ...published, site }) + const path = buildLocalizedPath(locale, '404') + const rendered = await renderPublishedNotFound(snapshot, { db, url: new URL(path, publicOrigin ?? 'http://localhost'), publishVersion: version }) + if (rendered) { + const html = await applyPublishedHtmlPipeline(rendered, db) + await writeArtefact(slotDir, notFoundArtefactPath(locale.id), html) + // Conventional static-export fallback remains available unless an actual + // published content route already owns this URL. + if (!inventory.byPath.has(path)) await writeArtefact(slotDir, path, html) + await writeAssets(rendered, snapshot) + } + } + for (const route of inventory.routes) { + const context = await readPublishedRouteContext(db, route, inventory) + if (!context) continue + publicOrigin ??= context.snapshot.site.settings.publicOrigin + const url = new URL(route.path, publicOrigin ?? 'http://localhost') + const rendered = await renderResolvedPublishedRoute(context, db, url, inventory, version) + if (!rendered) continue + await writeArtefact(slotDir, route.path, await applyPublishedHtmlPipeline(rendered, db)) + await writeAssets(rendered, context.snapshot) + } + if (publicOrigin) await writeStaticAsset(slotDir, '/sitemap.xml', encoder.encode(buildLocalizedSitemap(inventory, publicOrigin))) + await swapSlot(uploadsDir, slot) + markPublishedArtefactsCurrent(version) +} diff --git a/server/publish/republish.ts b/server/publish/republish.ts index 93262d76e..727fb2e98 100644 --- a/server/publish/republish.ts +++ b/server/publish/republish.ts @@ -1,97 +1,27 @@ -/** - * Background republish primitives. - * - * Called from the plugin API surface `api.cms.pages.republish(pageId)` and - * `api.cms.pages.republishAll()`. These drive the full publish pipeline - * (publish.before → publish.html filter → publish.after) for already- - * published pages, without writing a new snapshot. The side-effects — hook - * listeners and filter handlers firing — are the whole point. - * - * Note on the synthetic URL: `renderPublishedSnapshot` accepts an optional - * `url` on its context for per-loop pagination and `{route.*}` binding - * resolution. For background republish (not driven by an inbound HTTP - * request), we pass a synthetic localhost URL so the renderer has a valid - * URL object to work with. The URL is not user-visible and its exact value - * is irrelevant beyond being parseable. - */ - +/** Replays publish hooks for the immutable releases of every live page language. */ import type { DbClient } from '../db/client' -import { getPublishedPageSnapshotById } from '../repositories/publish' -import { renderPublishedSnapshot } from './publicRenderer' +import { getPublishVersion } from './publishState' +import { getPublishedRouteInventoryForVersion } from './publishedRoutes' +import { readPublishedRouteContext } from './publishedRouteContext' +import { renderResolvedPublishedRoute } from './publicRenderer' import { applyPublishedHtmlPipeline } from './publishedHtmlPipeline' -// --------------------------------------------------------------------------- -// Typed error — callers can distinguish "page not found / not published" from -// transient failures. -// --------------------------------------------------------------------------- - -class PageNotPublishedError extends Error { - readonly pageId: string - constructor(pageId: string) { - super(`Page "${pageId}" is not currently published`) - this.name = 'PageNotPublishedError' - this.pageId = pageId - } -} - -// --------------------------------------------------------------------------- -// Republish helpers -// --------------------------------------------------------------------------- - -/** - * Re-run the full publish pipeline for a single page that is already in the - * `published` state. Discards the rendered HTML — the sole purpose is to - * fire plugin hook listeners and filters so their side-effects are applied to - * a page that was published before the plugin was activated. - * - * Throws `PageNotPublishedError` if the page is not found or is not - * currently published. - */ -async function republishSinglePage(db: DbClient, pageId: string): Promise<void> { - // Typed read through the publish repository — the snapshot column is parsed - // by the DbClient (`*_json` auto-parse) and typed as `PublishedPageSnapshot`, - // so there is no boundary cast here. - const snapshot = await getPublishedPageSnapshotById(db, pageId) - if (!snapshot) { - throw new PageNotPublishedError(pageId) - } - - // Synthetic URL for background republish. The URL object is used by - // renderPublishedSnapshot for pagination helpers and {route.*} bindings. - // Its exact value is irrelevant for background re-renders. - const syntheticUrl = new URL('http://localhost/__republish') - - // Drive the full pipeline (publish.before → frontend.assets injection → - // publish.html filter → publish.after). The returned HTML is discarded — - // the side-effects are what the caller actually needs (lets plugins - // catch up on pages published before they were activated). - const rendered = await renderPublishedSnapshot(snapshot, { db, url: syntheticUrl }) - await applyPublishedHtmlPipeline(rendered, db) -} - -/** - * Republish every currently-published page. Iterates all published pages and - * calls `republishSinglePage` for each. Returns the total count published. - * - * Errors for individual pages are logged and do not abort the batch — the - * count reflects pages that completed without error. - */ +/** Only live variants participate; replaying hooks never publishes a draft. */ export async function republishAllPages(db: DbClient): Promise<number> { - const { rows } = await db<{ id: string }>` - select id - from data_rows - where table_id = 'pages' - and status = 'published' - and deleted_at is null - order by created_at asc - ` - const results = await Promise.allSettled(rows.map(row => republishSinglePage(db, row.id))) + const version = getPublishVersion() + const inventory = await getPublishedRouteInventoryForVersion(db, version) let count = 0 - for (const [i, result] of results.entries()) { - if (result.status === 'fulfilled') { + for (const route of inventory.routes.filter((entry) => entry.kind === 'page')) { + try { + const context = await readPublishedRouteContext(db, route, inventory) + if (!context) continue + const url = new URL(route.path, context.snapshot.site.settings.publicOrigin ?? 'http://localhost') + const rendered = await renderResolvedPublishedRoute(context, db, url, inventory, version) + if (!rendered) continue + await applyPublishedHtmlPipeline(rendered, db) count++ - } else { - console.error(`[publish:republish] republishSinglePage("${rows[i].id}") threw:`, result.reason) + } catch (err) { + console.error(`[publish:republish] ${route.contentId}/${route.localeId} failed:`, err) } } return count diff --git a/server/publish/schedulePublication.ts b/server/publish/schedulePublication.ts new file mode 100644 index 000000000..88a7a11e2 --- /dev/null +++ b/server/publish/schedulePublication.ts @@ -0,0 +1,43 @@ +/** Captures content, route and design dependencies at the time a schedule is set. */ +import type { DataRow } from '@core/data/schemas' +import type { ScheduledLocalizationRevision } from '@core/localization-schema' +import { buildLocalizedPath } from '@core/localization-routing' +import { isTemplatePage, resolveTemplateChain } from '@core/templates' +import type { DbClient } from '../db/client' +import { getDataRow, getDataTable } from '../repositories/data' +import { getDraftSiteDocument, getLatestPublishedSiteSnapshot, saveSiteDocumentSnapshot } from '../repositories/publish' +import { getDefaultLocale, getLocale, getTableLocalization, saveContentLocalizationDraft, scheduleContentLocalizationPublish } from '../repositories/localization' +import { runPublishFlush } from './publishFlush' +import { withPublishLock } from './publishState' + +export async function scheduleLocalizedDataRowPublish( + db: DbClient, rowId: string, whenIso: string, actorUserId: string | null = null, localeId?: string, +): Promise<DataRow | null> { + await runPublishFlush() + return withPublishLock(async () => { + const row = await getDataRow(db, rowId, localeId) + if (!row) return null + const [locale, table] = await Promise.all([getLocale(db, row.localeId), getDataTable(db, row.tableId)]) + if (!locale || !table) return null + const revision: ScheduledLocalizationRevision = { cells: row.cells, slug: row.slug } + if (row.tableId === 'pages') { + const site = await getDraftSiteDocument(db, { localeId: locale.id }) + const page = site?.pages.find((entry) => entry.id === rowId) + if (!site || !page) return null + revision.siteSnapshotId = await saveSiteDocumentSnapshot(db, site) + revision.publicPath = isTemplatePage(page) ? null : buildLocalizedPath(locale, row.slug) + } else { + const snapshot = await getLatestPublishedSiteSnapshot(db, locale.id) + const primary = await getDefaultLocale(db) + const config = await getTableLocalization(db, row.tableId, locale.id) + ?? await getTableLocalization(db, row.tableId, primary.id) + if (snapshot?.siteSnapshotId) revision.siteSnapshotId = snapshot.siteSnapshotId + revision.publicPath = table.kind === 'postType' && snapshot + && resolveTemplateChain(snapshot.site, { kind: 'entry', tableSlug: table.slug }).length > 0 + ? buildLocalizedPath(locale, row.slug, config?.routeBase ?? table.routeBase) : null + } + if (!row.localization) await saveContentLocalizationDraft(db, rowId, locale.id, { cells: {}, slug: row.slug }, actorUserId) + await scheduleContentLocalizationPublish(db, rowId, locale.id, whenIso, revision, actorUserId) + return getDataRow(db, rowId, locale.id) + }) +} diff --git a/server/publish/siteCssBundle.ts b/server/publish/siteCssBundle.ts index daa7346dc..c74bdd215 100644 --- a/server/publish/siteCssBundle.ts +++ b/server/publish/siteCssBundle.ts @@ -19,13 +19,10 @@ * publish version (preview, AI render, the CSS-route fallback) use this: * memoising across them would cross-contaminate unpublished content. * - * - `buildPublishedSiteCssBundle` is the hot path for the published-snapshot - * renderer (`publicRenderer.ts`). There the site content is fixed for a - * given publish version, so the three page-invariant files (reset / - * framework / style) are memoised by `publishVersion` and reused across - * every render at that version — the expensive all-pages walk runs once per - * publish, not once per request. Only `userStyles` (page-scoped) is rebuilt - * per call. The memo is invalidated automatically by `bumpPublishVersion()`. + * - `buildPublishedSiteCssBundle` shares page-invariant files for each immutable + * projected release document at a publish version. Different languages and + * releases have independent cache entries. Only page-scoped userStyles are + * rebuilt per call. */ import { createHash } from 'node:crypto' @@ -81,22 +78,9 @@ export function buildSiteCssBundle( } /** - * Published-render variant of `buildSiteCssBundle`. Memoises the three - * page-invariant files (reset / framework / style) by `publishVersion`, so - * the O(all-pages) module-CSS walk runs once per publish version instead of - * once per render. Only `userStyles` is rebuilt per call (it is page-scoped). - * - * Memo key = publish version ALONE. The published site content is fixed for a - * given version: `publishDraftSite` is the only snapshot writer and it bumps - * the version right after committing, and incremental row publishes never - * write the site document (they bump too, which just re-primes the memo with - * identical content). The publish-time bake renders the NEXT version's content - * before the bump, so it passes `nextPublishVersion` explicitly — its entries - * can never collide with pre-publish renders at the old version. - * - * Safe ONLY for published-snapshot content. Callers that pass draft / - * arbitrary sites (preview, AI render) must use `buildSiteCssBundle` — - * sharing a render-path cache across them would serve stale CSS. + * Reuse page-invariant CSS for a stable projected release document. The route + * context cache preserves document identity across requests; separate locales + * and frozen releases never reuse each other's framework or class CSS. */ export function buildPublishedSiteCssBundle( site: SiteDocument, @@ -124,16 +108,11 @@ function computePageInvariantBundles( } } -// Page-invariant bundle memo, keyed by publish version. A bump invalidates it -// (the next read sees a new version → recompute), so a content change can never -// serve stale framework/style CSS. Registered with the shared test-reset hook. -// -// Deliberately NOT keyed on the site object: every consumer loads the snapshot -// fresh (DB JSON parse per query), so an identity key would never hit — that -// was exactly the bug that made every Layer B miss re-walk the whole site. -let pageInvariantCache: { version: number; mediaSignature: string; bundles: PageInvariantBundles } | null = null +// Immutable document identity separates simultaneous locale releases. Version +// and media signatures invalidate dependent publication and asset changes. +let pageInvariantCache = new WeakMap<SiteDocument, { version: number; mediaSignature: string; bundles: PageInvariantBundles }>() registerVersionedCacheReset(() => { - pageInvariantCache = null + pageInvariantCache = new WeakMap() }) /** @@ -147,11 +126,12 @@ function memoizedPageInvariantBundles( options: ResponsiveCssOptions, ): PageInvariantBundles { const mediaSignature = styleMediaSignature(site, options) - if (pageInvariantCache && pageInvariantCache.version === version && pageInvariantCache.mediaSignature === mediaSignature) { - return pageInvariantCache.bundles + const cached = pageInvariantCache.get(site) + if (cached && cached.version === version && cached.mediaSignature === mediaSignature) { + return cached.bundles } const bundles = computePageInvariantBundles(site, registry, options) - pageInvariantCache = { version, mediaSignature, bundles } + pageInvariantCache.set(site, { version, mediaSignature, bundles }) return bundles } diff --git a/server/publish/staticArtefact.ts b/server/publish/staticArtefact.ts index 57c229f7e..1d0640dc9 100644 --- a/server/publish/staticArtefact.ts +++ b/server/publish/staticArtefact.ts @@ -59,15 +59,10 @@ import { type Slot = 'a' | 'b' -/** - * Artefact URL path the site's `notFound` template bakes to. Maps to - * `404.html` in the slot — deliberately the static-hosting convention - * (Netlify / GitHub Pages serve `404.html` for unmatched routes), so a - * published slot keeps working as a self-contained static export. The - * dispatcher's fall-through 404 handler reads this artefact and serves it - * with status 404. - */ -export const NOT_FOUND_ARTEFACT_URL_PATH = '/404' +/** Internal language-specific 404 key, separate from an authored page /404. */ +export function notFoundArtefactPath(localeId: string): string { + return `/_instatic/not-found/${Buffer.from(localeId).toString('hex')}` +} // --------------------------------------------------------------------------- // Private path helpers diff --git a/server/repositories/__tests__/bundlePublication.test.ts b/server/repositories/__tests__/bundlePublication.test.ts new file mode 100644 index 000000000..54c211c92 --- /dev/null +++ b/server/repositories/__tests__/bundlePublication.test.ts @@ -0,0 +1,150 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import type { SiteBundle } from '@core/data/bundleSchema' +import { filterSiteBundleForImportSelection } from '@core/data/bundleSelection' +import { parseSiteBundleArchive } from '@core/persistence/cmsTransfer' +import { createSqliteClient } from '../../db/sqlite' +import { runMigrations } from '../../db/runMigrations' +import { sqliteMigrations } from '../../db/migrations-sqlite' +import type { DbClient } from '../../db/client' +import { createDataRow, getDataRow, listDataRows, listDataTables } from '../data' +import { replaceDataRow } from '../data/rows' +import { getDraftSite } from '../site' +import { getDraftSiteDocument } from '../publish' +import { + createLocale, getContentLocalization, listLocales, saveContentLocalizationDraft, + saveTableLocalization, scheduleContentLocalizationPublish, setContentLocalizationPublishedVersion, +} from '../localization' +import { exportBundlePublication, restoreBundleLocales, restoreBundlePublication } from '../bundlePublication' +import { createCapabilityTestHarness } from '../../../src/__tests__/helpers/capabilityHarness' +import { handleExportRoute } from '../../handlers/cms/export' +import { handleImportRoute } from '../../handlers/cms/import' + +const cleanups: (() => Promise<void>)[] = [] +afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup() }) + +async function database() { + const db = createSqliteClient(':memory:') + cleanups.push(() => db.close()) + await runMigrations(db, sqliteMigrations) + await db`insert into site (id, name, settings_json) values ('default', 'Portable', ${{}})` + return db +} + +async function source(db = undefined as DbClient | undefined) { + db ??= await database() + const locale = await createLocale(db, { code: 'de', name: 'Deutsch', pathPrefix: 'de', enabled: true, direction: 'ltr' }) + const tree = { rootNodeId: 'root', nodes: { root: { id: 'root', moduleId: 'base.body', props: {}, children: [], classIds: [], breakpointOverrides: {} } } } + await createDataRow(db, { id: 'portable-page', tableId: 'pages', cells: { title: 'Source', slug: 'portable', body: tree, seoTitle: 'English SEO' }, slug: 'portable' }) + await createDataRow(db, { id: 'unselected-post', tableId: 'posts', cells: { title: 'Other' }, slug: 'other' }) + await saveContentLocalizationDraft(db, 'portable-page', locale.id, { + cells: { title: 'Entwurf', seoTitle: 'Deutsches SEO', body: { nodes: { root: { hidden: true } } } }, + slug: 'entwurf', translationMeta: { title: { sourceFingerprint: 'fingerprint', reviewState: 'reviewed' } }, + }) + await saveTableLocalization(db, 'posts', locale.id, '/artikel') + const snapshot = await getDraftSiteDocument(db, { localeId: locale.id }) + await db`insert into site_snapshots (id, site_json, content_hash, importmap_body, importmap_sha256) + values ('portable-snapshot', ${snapshot}, 'frozen-hash', '{ "imports": {} }', 'exact-importmap-hash')` + await db`insert into data_row_versions (id, row_id, locale_id, version_number, cells_json, slug, public_path, site_snapshot_id, runtime_assets_json) + values ('portable-version', 'portable-page', ${locale.id}, 1, ${{ title: 'Live title', slug: 'live' }}, 'live', '/de/live', 'portable-snapshot', ${{ scripts: [] }})` + const bytes = Buffer.from('globalThis.portable = "Grüße";\n') + await db`insert into published_runtime_assets (id, data_row_version_id, asset_path, public_path, content_type, content_bytes) + values ('portable-js', 'portable-version', 'entry.js', '/_instatic/assets/portable-version/entry.js', 'text/javascript', ${bytes})` + await setContentLocalizationPublishedVersion(db, 'portable-page', locale.id, 'portable-version') + await scheduleContentLocalizationPublish(db, 'portable-page', locale.id, '2026-12-01T12:00:00.000Z', { + cells: { title: 'Frozen planned update' }, slug: 'scheduled', siteSnapshotId: 'portable-snapshot', publicPath: '/de/scheduled', + }) + const tables = await listDataTables(db) + const rows = (await Promise.all(tables.map((table) => listDataRows(db!, table.id)))).flat() + const publication = await exportBundlePublication(db, rows.map((row) => row.id), tables.map((table) => table.id)) + const bundle: SiteBundle = { schemaVersion: 1, exportedAt: '2026-09-07T12:00:00.000Z', site: (await getDraftSite(db))!, tables, rows, ...publication } + return { db, locale, bundle, bytes } +} + +async function restore(db: DbClient, bundle: SiteBundle) { + await db.transaction(async (tx) => { + await tx`delete from data_rows` + await tx`delete from data_table_localizations` + await tx`delete from site_locales` + await restoreBundleLocales(tx, bundle, 'replace') + for (const row of bundle.rows) await replaceDataRow(tx, row) + await restoreBundlePublication(tx, bundle, new Set(bundle.rows.map((row) => row.id)), new Set(bundle.tables.map((table) => table.id)), 'replace') + }) +} + +describe('portable localized releases', () => { + it('round-trips sparse drafts, offline source, live target, history joins, scheduled revisions and exact runtime bytes', async () => { + const original = await source() + const target = await database() + await restore(target, original.bundle) + expect(await listLocales(target)).toEqual(original.bundle.locales) + const row = await getDataRow(target, 'portable-page', original.locale.id) + expect(row?.status).toBe('published') + expect(row?.publicPath).toBe('/de/live') + expect(row?.cells.title).toBe('Entwurf') + expect(row?.cells.seoTitle).toBe('Deutsches SEO') + expect((await getDataRow(target, 'portable-page'))?.status).toBe('draft') + const exported = await exportBundlePublication(target, original.bundle.rows.map((entry) => entry.id), original.bundle.tables.map((entry) => entry.id)) + expect(exported).toEqual({ locales: original.bundle.locales, localizations: original.bundle.localizations, tableLocalizations: original.bundle.tableLocalizations, versions: original.bundle.versions, siteSnapshots: original.bundle.siteSnapshots, runtimeAssets: original.bundle.runtimeAssets }) + expect(exported.versions?.[0].cells).toEqual({ title: 'Live title', slug: 'live' }) + expect(Buffer.from(exported.runtimeAssets![0].bytesBase64, 'base64')).toEqual(original.bytes) + expect((await getContentLocalization(target, 'portable-page', original.locale.id))?.scheduledRevision?.siteSnapshotId).toBe('portable-snapshot') + }) + + it('rejects missing release references atomically and never activates inherited content', async () => { + const { bundle } = await source() + const target = await database() + const broken = structuredClone(bundle) + broken.versions = [] + await expect(restore(target, broken)).rejects.toThrow('missing or mismatched') + expect(await getDataRow(target, 'portable-page')).toBeNull() + expect(await listLocales(target)).toHaveLength(1) + }) + + it('keeps immutable history immutable and refuses language identity collisions on merge', async () => { + const { bundle } = await source() + const target = await database() + await restore(target, bundle) + const conflict = structuredClone(bundle) + conflict.versions![0].cells.title = 'Changed old version' + await expect(target.transaction((tx) => restoreBundlePublication(tx, conflict, new Set(['portable-page']), new Set(['pages']), 'merge-overwrite'))).rejects.toThrow('different immutable content') + expect((await getDataRow(target, 'portable-page', bundle.versions![0].localeId))?.status).toBe('published') + const languageCollision = structuredClone(bundle) + languageCollision.locales![0].code = 'fr' + await expect(restoreBundleLocales(target, languageCollision, 'merge-overwrite')).rejects.toThrow('different local language') + }) + + it('prunes unselected release dependencies and does not export snapshots through an own-row permission scope', async () => { + const { db, bundle } = await source() + const selected = filterSiteBundleForImportSelection(bundle, { + includeSite: false, tables: [{ tableId: 'posts' }], includeMedia: false, includeMediaFolders: false, includeRedirects: false, + }) + expect(selected.localizations?.every((variant) => variant.rowId === 'unselected-post')).toBe(true) + expect(selected.versions).toEqual([]) + expect(selected.siteSnapshots).toEqual([]) + expect(selected.runtimeAssets).toEqual([]) + const restricted = await exportBundlePublication(db, ['portable-page'], ['pages'], false) + expect(restricted.siteSnapshots).toEqual([]) + expect(restricted.localizations?.every((variant) => variant.availability === 'offline' && variant.activeVersionId === null && variant.scheduledPublishAt === null)).toBe(true) + }) + + it('transports locale state through real ZIP export and the authorized HTTP replace handler', async () => { + const from = await createCapabilityTestHarness() + cleanups.push(() => from.cleanup()) + const cookie = await from.setupOwner() + const original = await source(from.db) + const exportRequest = new Request('http://localhost/admin/api/cms/export') + exportRequest.headers.set('cookie', cookie) + const exported = await handleExportRoute(exportRequest, from.db) + expect(exported?.status).toBe(200) + const bundle = parseSiteBundleArchive(new Uint8Array(await exported!.arrayBuffer()))! + const into = await createCapabilityTestHarness() + cleanups.push(() => into.cleanup()) + const targetCookie = await into.stepUp(await into.setupOwner()) + const importRequest = new Request('http://localhost/admin/api/cms/import?strategy=replace', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(bundle) }) + importRequest.headers.set('cookie', targetCookie) + const imported = await handleImportRoute(importRequest, into.db) + expect(imported?.status).toBe(200) + expect((await getDataRow(into.db, 'portable-page', original.locale.id))?.publicPath).toBe('/de/live') + expect((await exportBundlePublication(into.db, ['portable-page'], ['pages'])).runtimeAssets).toEqual(original.bundle.runtimeAssets) + }) +}) diff --git a/server/repositories/audit.ts b/server/repositories/audit.ts index f56a9a4b6..e65a7eead 100644 --- a/server/repositories/audit.ts +++ b/server/repositories/audit.ts @@ -32,6 +32,10 @@ const AuditActionSchema = Type.Union([ Type.Literal('data.row.move'), Type.Literal('data.author.assign'), Type.Literal('publish'), + Type.Literal('locale.create'), + Type.Literal('locale.update'), + Type.Literal('translation.reset'), + Type.Literal('translation.review'), Type.Literal('plugin.install'), Type.Literal('plugin.update'), Type.Literal('plugin.enable'), diff --git a/server/repositories/bundlePublication.ts b/server/repositories/bundlePublication.ts new file mode 100644 index 000000000..a4b848a62 --- /dev/null +++ b/server/repositories/bundlePublication.ts @@ -0,0 +1,199 @@ +/** Portable localization state and immutable publication dependencies. */ +import { + BundleRuntimeAssetSchema, + BundleSiteSnapshotSchema, + BundleVersionSchema, + type ImportStrategy, + type SiteBundle, +} from '@core/data/bundleSchema' +import { parseValue, Type } from '@core/utils/typeboxHelpers' +import { isoDate } from '@core/utils/isoDate' +import { deepEqual } from '@core/utils/deepEqual' +import { createPublishedRouteInventory } from '@core/localization-routing' +import { placeholder, type DbClient } from '../db/client' +import { + importLocale, + listContentLocalizations, + listLocales, + listTableLocalizations, + LocalizationError, + saveContentLocalizationDraft, + saveTableLocalization, +} from './localization' +import { listPublishedRouteCandidates } from './localizationRoutes' + +export type BundlePublication = Pick<SiteBundle, + 'locales' | 'localizations' | 'tableLocalizations' | 'versions' | 'siteSnapshots' | 'runtimeAssets'> + +export async function exportBundlePublication( + db: DbClient, + rowIds: readonly string[], + tableIds: readonly string[], + includeReleases = true, +): Promise<BundlePublication> { + const [locales, localizations, tableLocalizations] = await Promise.all([ + listLocales(db), + listContentLocalizations(db, { rowIds }), + listTableLocalizations(db), + ]) + const result: BundlePublication = { + locales, + localizations: includeReleases ? localizations : localizations.map((variant) => ({ + ...variant, availability: 'offline', activeVersionId: null, + scheduledPublishAt: null, scheduledRevision: null, + publishedAt: null, publishedByUserId: null, + })), + tableLocalizations: tableLocalizations.filter((entry) => tableIds.includes(entry.tableId)), + versions: [], siteSnapshots: [], runtimeAssets: [], + } + if (!includeReleases || rowIds.length === 0) return result + const slots = rowIds.map((_, index) => placeholder(db.dialect, index + 1)).join(', ') + const { rows: versions } = await db.unsafe<{ + id: string; row_id: string; locale_id: string; version_number: number; + cells_json: unknown; slug: string; public_path: string | null; site_snapshot_id: string | null; + runtime_assets_json: unknown; published_by_user_id: string | null; + published_at: string | Date; created_at: string | Date; + }>(`select * from data_row_versions where row_id in (${slots}) order by row_id, version_number`, [...rowIds]) + result.versions = versions.map((version) => parseValue(BundleVersionSchema, { + id: version.id, rowId: version.row_id, localeId: version.locale_id, + versionNumber: Number(version.version_number), cells: version.cells_json, + slug: version.slug, publicPath: version.public_path, siteSnapshotId: version.site_snapshot_id, + runtimeAssets: version.runtime_assets_json, + publishedByUserId: version.published_by_user_id, + publishedAt: isoDate(version.published_at), createdAt: isoDate(version.created_at), + })) + const snapshotIds = [...new Set([ + ...versions.flatMap((version) => version.site_snapshot_id ? [version.site_snapshot_id] : []), + ...localizations.flatMap((variant) => variant.scheduledRevision?.siteSnapshotId ? [variant.scheduledRevision.siteSnapshotId] : []), + ])] + if (snapshotIds.length > 0) { + const { rows } = await db.unsafe<{ id: string; site_json: unknown; content_hash: string; importmap_body: string | null; importmap_sha256: string | null; created_at: string | Date }>( + `select * from site_snapshots where id in (${snapshotIds.map((_, index) => placeholder(db.dialect, index + 1)).join(', ')}) order by id`, snapshotIds, + ) + result.siteSnapshots = rows.map((row) => parseValue(BundleSiteSnapshotSchema, { + id: row.id, site: row.site_json, contentHash: row.content_hash, + importmapBody: row.importmap_body, importmapSha256: row.importmap_sha256, createdAt: isoDate(row.created_at), + })) + } + const { rows: assets } = await db.unsafe<{ id: string; data_row_version_id: string; asset_path: string; public_path: string; content_type: string; content_bytes: Uint8Array; created_at: string | Date }>(` + select assets.* from published_runtime_assets assets + join data_row_versions versions on versions.id = assets.data_row_version_id + where versions.row_id in (${slots}) order by assets.id`, [...rowIds]) + result.runtimeAssets = assets.map((asset) => parseValue(BundleRuntimeAssetSchema, { + id: asset.id, dataRowVersionId: asset.data_row_version_id, assetPath: asset.asset_path, + publicPath: asset.public_path, contentType: asset.content_type, + bytesBase64: Buffer.from(asset.content_bytes).toString('base64'), createdAt: isoDate(asset.created_at), + })) + return result +} + +/** Keep logical locale identities stable; merges never silently relabel existing content. */ +export async function restoreBundleLocales(db: DbClient, bundle: SiteBundle, strategy: ImportStrategy): Promise<void> { + if (!bundle.locales) return + if (bundle.locales.filter((locale) => locale.isDefault).length !== 1 || !bundle.locales.some((locale) => locale.id === 'default' && locale.isDefault)) { + throw new LocalizationError('A bundle must contain exactly one default language with identity "default"', 'locales') + } + for (const locale of bundle.locales) await importLocale(db, locale, strategy) +} + +/** Called in the same transaction as the imported logical rows. */ +export async function restoreBundlePublication( + db: DbClient, + bundle: SiteBundle, + importedRowIds: ReadonlySet<string>, + importedTableIds: ReadonlySet<string>, + strategy: ImportStrategy, +): Promise<void> { + for (const entry of bundle.tableLocalizations ?? []) { + if (!importedTableIds.has(entry.tableId)) continue + if (strategy === 'merge-add' && (await listTableLocalizations(db, entry.tableId)).some((stored) => stored.localeId === entry.localeId)) continue + await saveTableLocalization(db, entry.tableId, entry.localeId, entry.routeBase) + } + if (bundle.localizations !== undefined) { + for (const rowId of importedRowIds) await db`delete from data_row_localizations where row_id = ${rowId}` + } + const versions = (bundle.versions ?? []).filter((version) => importedRowIds.has(version.rowId)) + const variants = (bundle.localizations ?? []).filter((variant) => importedRowIds.has(variant.rowId)) + const snapshotIds = new Set([ + ...versions.flatMap((version) => version.siteSnapshotId ? [version.siteSnapshotId] : []), + ...variants.flatMap((variant) => variant.scheduledRevision?.siteSnapshotId ? [variant.scheduledRevision.siteSnapshotId] : []), + ]) + const snapshots = (bundle.siteSnapshots ?? []).filter((snapshot) => snapshotIds.has(snapshot.id)) + const incomingSnapshots = new Map(snapshots.map((snapshot) => [snapshot.id, snapshot])) + for (const version of versions) { + if (version.siteSnapshotId && !incomingSnapshots.has(version.siteSnapshotId)) { + throw new LocalizationError('A published version is missing its immutable site snapshot', 'versions') + } + } + for (const snapshot of snapshots) { + const { rows } = await db<{ site_json: unknown; content_hash: string; importmap_body: string | null; importmap_sha256: string | null }>`select site_json, content_hash, importmap_body, importmap_sha256 from site_snapshots where id = ${snapshot.id}` + if (rows[0]) { + const stored = rows[0] + if (!deepEqual(stored.site_json, snapshot.site) || stored.content_hash !== snapshot.contentHash || stored.importmap_body !== snapshot.importmapBody || stored.importmap_sha256 !== snapshot.importmapSha256) { + throw new LocalizationError('A site snapshot identity already belongs to different immutable content', 'siteSnapshots') + } + } else { + await db`insert into site_snapshots (id, site_json, content_hash, importmap_body, importmap_sha256, created_at) + values (${snapshot.id}, ${snapshot.site}, ${snapshot.contentHash}, ${snapshot.importmapBody}, ${snapshot.importmapSha256}, ${snapshot.createdAt})` + } + } + const incomingVersions = new Map(versions.map((version) => [version.id, version])) + for (const version of versions) { + const { rows } = await db<{ row_id: string; locale_id: string; version_number: number; cells_json: unknown; slug: string; public_path: string | null; site_snapshot_id: string | null; runtime_assets_json: unknown }>`select * from data_row_versions where id = ${version.id}` + if (rows[0]) { + const stored = rows[0] + if (stored.row_id !== version.rowId || stored.locale_id !== version.localeId || Number(stored.version_number) !== version.versionNumber || !deepEqual(stored.cells_json, version.cells) || stored.slug !== version.slug || stored.public_path !== version.publicPath || stored.site_snapshot_id !== version.siteSnapshotId || !deepEqual(stored.runtime_assets_json, version.runtimeAssets)) { + throw new LocalizationError('A version identity already belongs to different immutable content', 'versions') + } + continue + } + const { rows: collisions } = await db`select id from data_row_versions where row_id = ${version.rowId} and version_number = ${version.versionNumber}` + if (collisions[0]) throw new LocalizationError('Version history conflicts with local history; use replace to restore this bundle', 'versions') + await db`insert into data_row_versions (id, row_id, locale_id, version_number, cells_json, slug, public_path, site_snapshot_id, runtime_assets_json, published_at, created_at) + values (${version.id}, ${version.rowId}, ${version.localeId}, ${version.versionNumber}, ${version.cells}, ${version.slug}, ${version.publicPath}, ${version.siteSnapshotId}, ${version.runtimeAssets}, ${version.publishedAt}, ${version.createdAt})` + } + for (const asset of bundle.runtimeAssets ?? []) { + if (!incomingVersions.has(asset.dataRowVersionId)) continue + const { rows } = await db<{ id: string; data_row_version_id: string; asset_path: string; public_path: string; content_type: string; content_bytes: Uint8Array }>`select * from published_runtime_assets where id = ${asset.id} or public_path = ${asset.publicPath}` + const bytes = Buffer.from(asset.bytesBase64, 'base64') + if (rows[0]) { + const stored = rows[0] + if (stored.id !== asset.id || stored.data_row_version_id !== asset.dataRowVersionId || stored.asset_path !== asset.assetPath || stored.public_path !== asset.publicPath || stored.content_type !== asset.contentType || !Buffer.from(stored.content_bytes).equals(bytes)) { + throw new LocalizationError('A runtime asset identity already belongs to different immutable bytes', 'runtimeAssets') + } + } else { + await db`insert into published_runtime_assets (id, data_row_version_id, asset_path, public_path, content_type, content_bytes, created_at) + values (${asset.id}, ${asset.dataRowVersionId}, ${asset.assetPath}, ${asset.publicPath}, ${asset.contentType}, ${bytes}, ${asset.createdAt})` + } + } + for (const variant of variants) { + const active = variant.activeVersionId ? incomingVersions.get(variant.activeVersionId) : null + if (variant.activeVersionId && (!active || active.rowId !== variant.rowId || active.localeId !== variant.localeId)) { + throw new LocalizationError('A language variant references a missing or mismatched published version', 'localizations') + } + const row = bundle.rows.find((entry) => entry.id === variant.rowId) + const kind = bundle.tables.find((entry) => entry.id === row?.tableId)?.kind + if (variant.availability === 'online' && (!active || (kind === 'page' && !active.siteSnapshotId))) { + throw new LocalizationError('An online page requires its complete immutable release', 'localizations') + } + if (active?.siteSnapshotId && kind === 'page') { + const snapshot = incomingSnapshots.get(active.siteSnapshotId)! + const pages = parseValue(Type.Array(Type.Object({ id: Type.String() })), snapshot.site.pages) + if (!pages.some((page) => page.id === variant.rowId) || (snapshot.site.localeId !== undefined && snapshot.site.localeId !== variant.localeId) || (snapshot.site.localeId === undefined && variant.localeId !== 'default')) { + throw new LocalizationError('A published page points to a snapshot for another page or language', 'siteSnapshots') + } + } + if (variant.scheduledRevision?.siteSnapshotId && !incomingSnapshots.has(variant.scheduledRevision.siteSnapshotId)) { + throw new LocalizationError('A scheduled publication is missing its frozen site snapshot', 'localizations') + } + await saveContentLocalizationDraft(db, variant.rowId, variant.localeId, variant) + await db`update data_row_localizations + set availability = ${variant.availability}, active_version_id = ${variant.activeVersionId}, + scheduled_publish_at = ${variant.scheduledPublishAt}, scheduled_revision_json = ${variant.scheduledRevision}, + seq = ${variant.seq}, created_at = ${variant.createdAt}, updated_at = ${variant.updatedAt}, published_at = ${variant.publishedAt}, + created_by_user_id = null, updated_by_user_id = null, published_by_user_id = null + where row_id = ${variant.rowId} and locale_id = ${variant.localeId}` + } + // Frozen public paths remain unique across imported and retained live content. + createPublishedRouteInventory(await listLocales(db), await listPublishedRouteCandidates(db)) +} diff --git a/server/repositories/collabDocuments.ts b/server/repositories/collabDocuments.ts index 804d97e99..c0c59c66e 100644 --- a/server/repositories/collabDocuments.ts +++ b/server/repositories/collabDocuments.ts @@ -62,3 +62,9 @@ export async function deleteCollabDocuments( [...docIds], ) } + +/** IDs only: resets must also invalidate dormant stored translation lineages. */ +export async function listCollabDocumentIds(db: DbClient): Promise<string[]> { + const { rows } = await db<{ doc_id: string }>`select doc_id from collab_documents` + return rows.map((row) => row.doc_id) +} diff --git a/server/repositories/data/__tests__/publish-row-join.test.ts b/server/repositories/data/__tests__/publish-row-join.test.ts index 153c109cf..3ec967d03 100644 --- a/server/repositories/data/__tests__/publish-row-join.test.ts +++ b/server/repositories/data/__tests__/publish-row-join.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, beforeEach } from 'bun:test' +import { describe, expect, it, beforeEach, afterEach } from 'bun:test' import { nanoid } from 'nanoid' import { createSqliteClient } from '../../../db/sqlite' import { sqliteMigrations } from '../../../db/migrations-sqlite' @@ -20,6 +20,8 @@ describe('getPublishedDataRowByRoute — author/publisher join parity', () => { db = await freshDb() }) + afterEach(async () => { await db.close() }) + it('resolves author + publisher fields from the shared user-ref joins', async () => { const author = await createUser(db, { email: 'author@example.com', @@ -37,9 +39,8 @@ describe('getPublishedDataRowByRoute — author/publisher join parity', () => { const rowId = nanoid() const versionId = nanoid() - // data_rows.active_version_id and data_row_versions.row_id form a circular - // FK, so insert the row first (no active version), then the version, then - // point the row at it. The publisher join targets + // Insert logical identity, immutable localized version and active variant. + // The publisher join targets // data_row_versions.published_by_user_id, so we deliberately set a DIFFERENT // user on data_rows.published_by_user_id to prove the read pulls the // per-version publisher, not the row-level one. @@ -49,10 +50,11 @@ describe('getPublishedDataRowByRoute — author/publisher join parity', () => { values (${rowId}, ${'posts'}, ${'{}'}, ${'hello-world'}, ${'published'}, ${author.id}, ${author.id}) ` await db` - insert into data_row_versions (id, row_id, version_number, cells_json, slug, published_by_user_id) - values (${versionId}, ${rowId}, ${1}, ${'{}'}, ${'hello-world'}, ${publisher.id}) + insert into data_row_versions (id, row_id, version_number, cells_json, slug, published_by_user_id, locale_id, public_path) + values (${versionId}, ${rowId}, ${1}, ${'{}'}, ${'hello-world'}, ${publisher.id}, ${'default'}, ${'/posts/hello-world'}) ` - await db`update data_rows set active_version_id = ${versionId} where id = ${rowId}` + await db`insert into data_row_localizations (row_id, locale_id, active_version_id, availability) + values (${rowId}, 'default', ${versionId}, 'online')` const published = await getPublishedDataRowByRoute(db, '/posts', 'hello-world') @@ -84,10 +86,11 @@ describe('getPublishedDataRowByRoute — author/publisher join parity', () => { values (${rowId}, ${'posts'}, ${'{}'}, ${'anon'}, ${'published'}, ${null}, ${null}) ` await db` - insert into data_row_versions (id, row_id, version_number, cells_json, slug, published_by_user_id) - values (${versionId}, ${rowId}, ${1}, ${'{}'}, ${'anon'}, ${null}) + insert into data_row_versions (id, row_id, version_number, cells_json, slug, published_by_user_id, locale_id, public_path) + values (${versionId}, ${rowId}, ${1}, ${'{}'}, ${'anon'}, ${null}, ${'default'}, ${'/posts/anon'}) ` - await db`update data_rows set active_version_id = ${versionId} where id = ${rowId}` + await db`insert into data_row_localizations (row_id, locale_id, active_version_id, availability) + values (${rowId}, 'default', ${versionId}, 'online')` const published = await getPublishedDataRowByRoute(db, '/posts', 'anon') diff --git a/server/repositories/data/index.ts b/server/repositories/data/index.ts index 3e55ba96c..3c40f5b45 100644 --- a/server/repositories/data/index.ts +++ b/server/repositories/data/index.ts @@ -25,6 +25,7 @@ export { getDataTableBySlug, createDataTable, updateDataTable, + updateDataTableInTx, softDeleteDataTable, } from './tables' @@ -44,6 +45,7 @@ export { createDataRowMany, saveDataRowDraft, upsertDataRowDraft, + upsertSharedDataRowDraft, updateDataRowDraftCells, saveDataRowDraftMany, softDeleteDataRow, @@ -62,6 +64,8 @@ export type { ApplyDataRowChangesInput, DataRowWrite } from './rows' export { getPublishedDataRowByRoute, + getPublishedDataRowById, + getPublishedRedirectByPath, getDataRowRedirectByRoute, } from './publish' diff --git a/server/repositories/data/publish.ts b/server/repositories/data/publish.ts index 9575e9566..faf87afef 100644 --- a/server/repositories/data/publish.ts +++ b/server/repositories/data/publish.ts @@ -23,6 +23,7 @@ * artefact writes, cache bump) lives in `server/publish/publishRow.ts` and * calls down into this repository. */ +import type { BundleRedirect } from '@core/data/bundleSchema' import { nanoid } from 'nanoid' import { placeholder, type DbClient } from '../../db/client' import { userRefColumns, userRefJoin } from './shared' @@ -30,6 +31,8 @@ import type { DataRow, DataRowVersion, DataRowRedirect, PublishedDataRow } from import { normalizeRouteBase } from '@core/templates/templateMatching' import { readFeaturedMediaCell } from '@core/data/cells' import { getDataRow } from './rows' +import { getDefaultLocale, setContentLocalizationPublishedVersion } from '../localization' +import type { ScheduledLocalizationRevision } from '@core/localization-schema' import { nextDataRowVersionNumber } from './versions' import { isoDate } from '@core/utils/isoDate' @@ -44,6 +47,9 @@ interface PublishedDataRowQueryRow { table_slug: string table_kind: string table_route_base: string + locale_id: string + public_path: string | null + site_snapshot_id: string | null version_number: number cells_json: Record<string, unknown> slug: string @@ -59,17 +65,11 @@ interface PublishedDataRowQueryRow { created_at: string | Date } -interface PreviousPublishedRouteRow { - previous_slug: string - previous_route_base: string -} - interface DataRowRedirectRow { id: string from_route_base: string from_slug: string - target_route_base: string - target_slug: string + target_path: string } interface MediaAssetRow { @@ -84,6 +84,8 @@ interface MediaAssetRow { export interface PreviousPublishedRoute { slug: string routeBase: string + path: string + localeId: string } export interface PersistDataRowPublishResult { @@ -97,11 +99,6 @@ export interface PersistDataRowPublishResult { previousRoute: PreviousPublishedRoute | null } -export interface RowTableRouteInfo { - tableRouteBase: string - tableSlug: string -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -112,15 +109,6 @@ export function publicDataPath(routeBase: string, slug: string): string { return `${normalizedBase === '/' ? '' : normalizedBase}/${slug}` } -/** True when the previously-published route differs from the current slug's. */ -export function previousRouteChanged(previous: PreviousPublishedRoute, currentSlug: string): boolean { - return ( - previous.slug.length > 0 && - publicDataPath(previous.routeBase, previous.slug) !== - publicDataPath(previous.routeBase, currentSlug) - ) -} - // --------------------------------------------------------------------------- // Publish persistence // --------------------------------------------------------------------------- @@ -129,109 +117,93 @@ export function previousRouteChanged(previous: PreviousPublishedRoute, currentSl * Transactional write of one row publish. DB writes only — the publish lock, * artefact bake, and cache bump are owned by `server/publish/publishRow.ts`. */ +export interface PersistDataRowPublishOptions { + localeId: string + publicPath: string | null + siteSnapshotId: string | null + revision?: ScheduledLocalizationRevision +} + export async function persistDataRowPublish( db: DbClient, rowId: string, - /** - * The user attributed as the publisher. `null` is allowed for system - * actors that have no user context — e.g. the scheduled-publish tick - * (`server/publish/publishScheduler.ts`) which fires once - * `scheduled_publish_at` is in the past. The `published_by_user_id` - * column on `data_rows` is nullable (`on delete set null`), so a - * null publisher round-trips cleanly through the schema. - */ publisherUserId: string | null, + options: PersistDataRowPublishOptions, ): Promise<PersistDataRowPublishResult> { return db.transaction(async (tx) => { - const row = await getDataRow(tx, rowId) - if (!row) throw new Error('data row not found') - - const previousRoute = await readPreviousPublishedRoute(tx, rowId) + const draft = await getDataRow(tx, rowId, options.localeId) + if (!draft) throw new Error('Content item not found') + const cells = options.revision?.cells ?? draft.cells + const slug = options.revision?.slug ?? draft.slug + const previousRoute = await readPreviousPublishedRoute(tx, rowId, options.localeId) const versionNumber = await nextDataRowVersionNumber(tx, rowId) const versionId = nanoid() - await tx` insert into data_row_versions - (id, row_id, version_number, cells_json, slug, published_by_user_id) - values ( - ${versionId}, - ${row.id}, - ${versionNumber}, - ${row.cells}, - ${row.slug}, - ${publisherUserId} - ) + (id, row_id, locale_id, version_number, cells_json, slug, public_path, site_snapshot_id, published_by_user_id) + values (${versionId}, ${rowId}, ${options.localeId}, ${versionNumber}, ${cells}, ${slug}, + ${options.publicPath}, ${options.siteSnapshotId}, ${publisherUserId}) ` - - const { rows: updateRows } = await tx<{ id: string }>` - update data_rows - set status = 'published', - active_version_id = ${versionId}, - published_by_user_id = ${publisherUserId}, - published_at = current_timestamp, - updated_by_user_id = ${publisherUserId}, - updated_at = current_timestamp - where id = ${row.id} - and deleted_at is null - returning id + await tx` + insert into data_row_localizations (row_id, locale_id, slug) + values (${rowId}, ${options.localeId}, ${slug}) + on conflict (row_id, locale_id) do nothing ` - if (!updateRows[0]) throw new Error('data row publish update failed') - - if (previousRoute && previousRouteChanged(previousRoute, row.slug)) { - await tx` - insert into data_row_redirects (id, table_id, from_route_base, from_slug, target_row_id) - values ( - ${nanoid()}, - ${row.tableId}, - ${normalizeRouteBase(previousRoute.routeBase)}, - ${previousRoute.slug}, - ${row.id} - ) - on conflict (from_route_base, from_slug) do update - set table_id = excluded.table_id, - target_row_id = excluded.target_row_id - ` + const localization = await setContentLocalizationPublishedVersion(tx, rowId, options.localeId, versionId, publisherUserId) + if (!localization) throw new Error('Content item disappeared during publication') + if (previousRoute && options.publicPath !== previousRoute.path) { + await savePublishedRedirect(tx, rowId, draft.tableId, options.localeId, previousRoute.path) } - - const publishedRow = await getDataRow(tx, row.id) - if (!publishedRow) throw new Error('data row could not be re-read after publish') - - const publishedAt = publishedRow.publishedAt ?? new Date().toISOString() + const row = await getDataRow(tx, rowId, options.localeId) + if (!row) throw new Error('Published content item could not be read') + const publishedAt = localization.publishedAt ?? new Date().toISOString() return { - row: publishedRow, + row, version: { - id: versionId, - rowId: publishedRow.id, - versionNumber, - cells: publishedRow.cells, - slug: publishedRow.slug, - publishedByUserId: publisherUserId, - publishedAt, - createdAt: publishedAt, + id: versionId, rowId, localeId: options.localeId, publicPath: options.publicPath, + siteSnapshotId: options.siteSnapshotId, versionNumber, cells, slug, + publishedByUserId: publisherUserId, publishedAt, createdAt: publishedAt, }, previousRoute, } }) } -async function readPreviousPublishedRoute( +/** Reads the frozen route even after unpublishing or soft deletion. */ +export async function readPreviousPublishedRoute( db: DbClient, rowId: string, + localeId?: string, ): Promise<PreviousPublishedRoute | null> { - const { rows } = await db<PreviousPublishedRouteRow>` - select data_row_versions.slug as previous_slug, - data_tables.route_base as previous_route_base - from data_rows - join data_tables on data_tables.id = data_rows.table_id - join data_row_versions on data_row_versions.id = data_rows.active_version_id - where data_rows.id = ${rowId} - and data_rows.deleted_at is null - and data_tables.deleted_at is null + const targetLocaleId = localeId ?? (await getDefaultLocale(db)).id + const { rows } = await db<{ slug: string; public_path: string | null }>` + select versions.slug, versions.public_path + from data_row_localizations variants + join data_row_versions versions on versions.id = variants.active_version_id + and versions.row_id = variants.row_id and versions.locale_id = variants.locale_id + where variants.row_id = ${rowId} and variants.locale_id = ${targetLocaleId} limit 1 ` - return rows[0] - ? { slug: rows[0].previous_slug, routeBase: rows[0].previous_route_base } - : null + const row = rows[0] + if (!row?.public_path) return null + const slash = row.public_path.lastIndexOf('/') + return { slug: row.slug, routeBase: row.public_path.slice(0, slash) || '/', path: row.public_path, localeId: targetLocaleId } +} + +export async function savePublishedRedirect( + db: DbClient, + rowId: string, + tableId: string, + localeId: string, + previousPath: string, +): Promise<void> { + const slash = previousPath.lastIndexOf('/') + await db` + insert into data_row_redirects (id, table_id, locale_id, from_route_base, from_slug, target_row_id) + values (${nanoid()}, ${tableId}, ${localeId}, ${previousPath.slice(0, slash) || '/'}, ${previousPath.slice(slash + 1)}, ${rowId}) + on conflict (from_route_base, from_slug) do update + set table_id = excluded.table_id, locale_id = excluded.locale_id, target_row_id = excluded.target_row_id + ` } // --------------------------------------------------------------------------- @@ -244,145 +216,52 @@ async function readPreviousPublishedRoute( * `server/publish/publishRow.ts` to resolve the public URL path without * joining the table into every other query. */ -export async function getRowTableRouteInfo( +export async function getPublishedDataRowByRoute( db: DbClient, - rowId: string, -): Promise<RowTableRouteInfo | null> { - const { rows } = await db<{ route_base: string; table_slug: string }>` - select data_tables.route_base, - data_tables.slug as table_slug - from data_rows - join data_tables on data_tables.id = data_rows.table_id - where data_rows.id = ${rowId} - and data_rows.deleted_at is null - and data_tables.deleted_at is null - limit 1 - ` - if (!rows[0]) return null - return { - tableRouteBase: normalizeRouteBase(rows[0].route_base), - tableSlug: rows[0].table_slug, - } + tableRouteBase: string, + rowSlug: string, + localeId?: string, +): Promise<PublishedDataRow | null> { + return readPublishedDataRow(db, { localeId, publicPath: publicDataPath(tableRouteBase, rowSlug) }) } -/** - * The owning table's raw `route_base` for a row, resolved WITHOUT the - * `deleted_at is null` filters — artefact removal must still resolve the - * route after a soft delete (ISS-039). - */ -export async function getRowTableRouteBase( +export async function getPublishedDataRowById( db: DbClient, rowId: string, -): Promise<string | null> { - const { rows } = await db<{ route_base: string }>` - select data_tables.route_base - from data_rows - join data_tables on data_tables.id = data_rows.table_id - where data_rows.id = ${rowId} - limit 1 - ` - return rows[0]?.route_base ?? null -} - -// --------------------------------------------------------------------------- -// Public-route lookups -// --------------------------------------------------------------------------- - -interface PublishedRowRoute { - rowId: string - /** Slug of the row's ACTIVE published version (what the public URL uses). */ - rowSlug: string - tableSlug: string - tableRouteBase: string -} - -/** - * Every published, non-deleted data row (excluding the `pages` table) with - * its active version's slug and its table's route info. The full publish uses - * this to bake a Layer A artefact for each row route into the fresh slot — - * without it, the slot swap would strand every row artefact written by - * incremental publishes. - */ -export async function listPublishedRowRoutes(db: DbClient): Promise<PublishedRowRoute[]> { - const { rows } = await db<{ - row_id: string - row_slug: string - table_slug: string - table_route_base: string - }>` - select data_rows.id as row_id, - data_row_versions.slug as row_slug, - data_tables.slug as table_slug, - data_tables.route_base as table_route_base - from data_rows - join data_tables on data_tables.id = data_rows.table_id - join data_row_versions on data_row_versions.id = data_rows.active_version_id - where data_rows.table_id <> 'pages' - and data_rows.status = 'published' - and data_rows.deleted_at is null - and data_tables.deleted_at is null - order by data_rows.created_at asc - ` - return rows.map((row) => ({ - rowId: row.row_id, - rowSlug: row.row_slug, - tableSlug: row.table_slug, - tableRouteBase: normalizeRouteBase(row.table_route_base), - })) + localeId?: string, +): Promise<PublishedDataRow | null> { + return readPublishedDataRow(db, { localeId, rowId }) } -/** - * Resolve a public URL (tableRouteBase + rowSlug) to the active published - * version of a data row. - * - * `featuredMediaPath` is resolved in app code: first we read - * `cells.featuredMedia` (via `readFeaturedMediaCell`) from the version's - * `cells_json`, then — only when a media id is present — we do a second - * query against `media_assets` for the `public_path`. This keeps the primary - * query dialect-naive (no JSON-extract functions, no PG-specific operators). - */ -export async function getPublishedDataRowByRoute( +async function readPublishedDataRow( db: DbClient, - tableRouteBase: string, - rowSlug: string, + options: { localeId?: string; rowId?: string; publicPath?: string }, ): Promise<PublishedDataRow | null> { - const normalizedBase = normalizeRouteBase(tableRouteBase) - - // The author/publisher user-ref joins reuse the shared `userRefColumns` / - // `userRefJoin` fragments (the single source, also spliced by the hydrated - // data-row SELECT in `rows/mapper.ts`). The publisher join targets - // `data_row_versions.published_by_user_id` — the per-version publisher — not - // `data_rows.published_by_user_id`. SQL stays dialect-naive (ANSI joins, - // positional `placeholder()` binds). + const localeId = options.localeId ?? (await getDefaultLocale(db)).id const p = (n: number) => placeholder(db.dialect, n) + const where = options.rowId !== undefined ? `data_rows.id = ${p(2)}` : `data_row_versions.public_path = ${p(2)}` const { rows } = await db.unsafe<PublishedDataRowQueryRow>( - `select data_row_versions.id, - data_row_versions.row_id, - data_rows.table_id, - data_tables.slug as table_slug, - data_tables.kind as table_kind, + `select data_row_versions.id, data_row_versions.row_id, data_row_versions.locale_id, + data_row_versions.public_path, data_row_versions.site_snapshot_id, + data_rows.table_id, data_tables.slug as table_slug, data_tables.kind as table_kind, data_tables.route_base as table_route_base, - data_row_versions.version_number, - data_row_versions.cells_json, - data_row_versions.slug, - data_rows.author_user_id, - ${userRefColumns('author')}, - data_row_versions.published_by_user_id, - ${userRefColumns('published_by')}, - data_row_versions.published_at, - data_row_versions.created_at + data_row_versions.version_number, data_row_versions.cells_json, data_row_versions.slug, + data_rows.author_user_id, ${userRefColumns('author')}, + data_row_versions.published_by_user_id, ${userRefColumns('published_by')}, + data_row_versions.published_at, data_row_versions.created_at from data_rows join data_tables on data_tables.id = data_rows.table_id - join data_row_versions on data_row_versions.id = data_rows.active_version_id + join data_row_localizations variants on variants.row_id = data_rows.id + join data_row_versions on data_row_versions.id = variants.active_version_id + and data_row_versions.row_id = variants.row_id and data_row_versions.locale_id = variants.locale_id + join site_locales locales on locales.id = variants.locale_id ${userRefJoin('author', 'data_rows.author_user_id')} ${userRefJoin('published_by', 'data_row_versions.published_by_user_id')} - where data_tables.route_base = ${p(1)} - and data_row_versions.slug = ${p(2)} - and data_rows.status = 'published' - and data_rows.deleted_at is null - and data_tables.deleted_at is null + where variants.locale_id = ${p(1)} and ${where} + and variants.availability = 'online' and locales.enabled = ${p(3)} + and data_rows.deleted_at is null and data_tables.deleted_at is null limit 1`, - [normalizedBase, rowSlug], + [localeId, options.rowId ?? options.publicPath, true], ) if (!rows[0]) return null @@ -408,6 +287,9 @@ export async function getPublishedDataRowByRoute( return { id: queryRow.id, rowId: queryRow.row_id, + localeId: queryRow.locale_id, + publicPath: queryRow.public_path, + siteSnapshotId: queryRow.site_snapshot_id, tableId: queryRow.table_id, tableSlug: queryRow.table_slug, tableKind: queryRow.table_kind as PublishedDataRow['tableKind'], @@ -435,34 +317,32 @@ export async function getDataRowRedirectByRoute( tableRouteBase: string, rowSlug: string, ): Promise<DataRowRedirect | null> { - const normalizedBase = normalizeRouteBase(tableRouteBase) + return getPublishedRedirectByPath(db, publicDataPath(tableRouteBase, rowSlug)) +} +export async function getPublishedRedirectByPath(db: DbClient, publicPath: string): Promise<DataRowRedirect | null> { + const slash = publicPath.lastIndexOf('/') + const base = publicPath.slice(0, slash) || '/' + const slug = publicPath.slice(slash + 1) const { rows } = await db<DataRowRedirectRow>` - select data_row_redirects.id, - data_row_redirects.from_route_base, - data_row_redirects.from_slug, - data_tables.route_base as target_route_base, - data_row_versions.slug as target_slug - from data_row_redirects - join data_rows target_rows on target_rows.id = data_row_redirects.target_row_id + select redirects.id, redirects.from_route_base, redirects.from_slug, + versions.public_path as target_path + from data_row_redirects redirects + join data_rows target_rows on target_rows.id = redirects.target_row_id join data_tables on data_tables.id = target_rows.table_id - join data_row_versions on data_row_versions.id = target_rows.active_version_id - where data_row_redirects.from_route_base = ${normalizedBase} - and data_row_redirects.from_slug = ${rowSlug} - and target_rows.status = 'published' - and target_rows.deleted_at is null - and data_tables.deleted_at is null + join data_row_localizations variants on variants.row_id = target_rows.id and variants.locale_id = redirects.locale_id + join data_row_versions versions on versions.id = variants.active_version_id + and versions.row_id = variants.row_id and versions.locale_id = variants.locale_id + join site_locales locales on locales.id = variants.locale_id + where redirects.from_route_base = ${base} and redirects.from_slug = ${slug} + and variants.availability = 'online' and locales.enabled = ${true} + and target_rows.deleted_at is null and data_tables.deleted_at is null + and versions.public_path is not null limit 1 ` - - if (!rows[0]) return null - - const queryRow = rows[0] - const fromPath = publicDataPath(queryRow.from_route_base, queryRow.from_slug) - const targetPath = publicDataPath(queryRow.target_route_base, queryRow.target_slug) - if (fromPath === targetPath) return null - - return { id: queryRow.id, fromPath, targetPath } + const row = rows[0] + if (!row || row.target_path === publicPath) return null + return { id: row.id, fromPath: publicPath, targetPath: row.target_path } } // --------------------------------------------------------------------------- @@ -474,15 +354,10 @@ export async function getDataRowRedirectByRoute( * Shape-compatible with `BundleRedirect` in `@core/data/bundleSchema` so the * export handler can pass these straight through. */ -export interface ExportableRedirect { - id: string - tableId: string - fromRouteBase: string - fromSlug: string - targetRowId: string -} +export type ExportableRedirect = BundleRedirect interface ExportableRedirectRow { + locale_id: string id: string table_id: string from_route_base: string @@ -493,12 +368,13 @@ interface ExportableRedirectRow { /** Every redirect, raw, for a full-site export. */ export async function listExportableRedirects(db: DbClient): Promise<ExportableRedirect[]> { const { rows } = await db<ExportableRedirectRow>` - select id, table_id, from_route_base, from_slug, target_row_id + select id, table_id, locale_id, from_route_base, from_slug, target_row_id from data_row_redirects order by from_route_base asc, from_slug asc ` return rows.map((row) => ({ id: row.id, + localeId: row.locale_id, tableId: row.table_id, fromRouteBase: row.from_route_base, fromSlug: row.from_slug, @@ -517,16 +393,18 @@ export async function deleteAllDataRowRedirects(db: DbClient): Promise<void> { */ export async function importDataRowRedirect(db: DbClient, input: ExportableRedirect): Promise<void> { await db` - insert into data_row_redirects (id, table_id, from_route_base, from_slug, target_row_id) + insert into data_row_redirects (id, table_id, locale_id, from_route_base, from_slug, target_row_id) values ( ${input.id}, ${input.tableId}, + ${input.localeId}, ${input.fromRouteBase}, ${input.fromSlug}, ${input.targetRowId} ) on conflict (from_route_base, from_slug) do update set table_id = excluded.table_id, + locale_id = excluded.locale_id, target_row_id = excluded.target_row_id ` } diff --git a/server/repositories/data/rows/__tests__/apply.test.ts b/server/repositories/data/rows/__tests__/apply.test.ts index a7dfc328f..536fd4d63 100644 --- a/server/repositories/data/rows/__tests__/apply.test.ts +++ b/server/repositories/data/rows/__tests__/apply.test.ts @@ -5,6 +5,8 @@ import { runMigrations } from '../../../../db/runMigrations' import type { DbClient } from '../../../../db/client' import { applyDataRowChanges } from '../apply' import { allocateSiteSeq } from '../../../syncSequence' +import { seedLocalizedVariant } from './fixtures' +import type { DataRowStatus } from '@core/data/schemas' const USER_ID = 'user-owner' @@ -18,17 +20,19 @@ async function freshDb(): Promise<DbClient> { return db } -async function seedRow(db: DbClient, id: string, slug: string, status = 'draft'): Promise<void> { +async function seedRow(db: DbClient, id: string, slug: string, status: DataRowStatus = 'draft'): Promise<void> { await db` insert into data_rows (id, table_id, cells_json, slug, status, author_user_id, created_by_user_id, updated_by_user_id) values (${id}, ${'components'}, ${{ name: id }}, ${slug}, ${status}, ${USER_ID}, ${USER_ID}, ${USER_ID}) ` + await seedLocalizedVariant(db, { rowId: id, cells: {}, slug, status }) } async function activeSlugs(db: DbClient): Promise<Map<string, string>> { const { rows } = await db<{ id: string; slug: string }>` - select id, slug from data_rows - where table_id = ${'components'} and deleted_at is null + select data_rows.id, localized.slug from data_rows + join data_row_localizations localized on localized.row_id = data_rows.id and localized.locale_id = 'default' + where data_rows.table_id = ${'components'} and data_rows.deleted_at is null ` return new Map(rows.map((r) => [r.id, r.slug])) } diff --git a/server/repositories/data/rows/__tests__/filter.test.ts b/server/repositories/data/rows/__tests__/filter.test.ts index f5b418c75..1a8037f9c 100644 --- a/server/repositories/data/rows/__tests__/filter.test.ts +++ b/server/repositories/data/rows/__tests__/filter.test.ts @@ -4,6 +4,7 @@ import { sqliteMigrations } from '../../../../db/migrations-sqlite' import { runMigrations } from '../../../../db/runMigrations' import type { DbClient } from '../../../../db/client' import { listDataRowsWithFilter } from '../filter' +import { seedLocalizedVariant } from './fixtures' /** * Wrap a DbClient so every `db.unsafe()` call is counted. The hydrated SELECT @@ -54,6 +55,7 @@ async function seedRow(db: DbClient, row: SeedRow): Promise<void> { ${row.deleted ? '2024-12-31T00:00:00.000Z' : null} ) ` + await seedLocalizedVariant(db, { rowId: row.id, cells: { title: row.title, slug: row.id }, slug: row.id, status: row.status, updatedAt: row.updatedAt }) } async function freshDb(): Promise<DbClient> { @@ -141,10 +143,10 @@ describe('listDataRowsWithFilter', () => { const bigResult = await listDataRowsWithFilter(big.db, 'posts', { limit: 500 }) expect(bigResult.rows).toHaveLength(50) - // Two queries total: one hydrated data page + one count. Crucially the - // count is identical for 4 rows and 50 rows — no per-row hydration. - expect(small.counts.unsafe).toBe(2) - expect(big.counts.unsafe).toBe(2) + // Hydration, locale variants, live URL snapshots and count are batched; + // the number of queries must stay bounded as the result grows. + expect(small.counts.unsafe).toBeLessThanOrEqual(4) + expect(big.counts.unsafe).toBeLessThanOrEqual(4) expect(big.counts.unsafe).toBe(small.counts.unsafe) }) }) diff --git a/server/repositories/data/rows/__tests__/fixtures.ts b/server/repositories/data/rows/__tests__/fixtures.ts new file mode 100644 index 000000000..08c916ba3 --- /dev/null +++ b/server/repositories/data/rows/__tests__/fixtures.ts @@ -0,0 +1,23 @@ +import type { DbClient } from '../../../../db/client' +import type { DataRowCells, DataRowStatus } from '@core/data/schemas' + +/** SQL fixture for the canonical draft and published-variant storage, independent of mutation helpers. */ +export async function seedLocalizedVariant( + db: DbClient, + input: { rowId: string; cells: DataRowCells; slug: string; status?: DataRowStatus; localeId?: string; updatedAt?: string }, +): Promise<void> { + const localeId = input.localeId ?? 'default' + const hasVersion = input.status === 'published' || input.status === 'unpublished' + const versionId = hasVersion ? `${input.rowId}-${localeId}-version` : null + if (versionId) { + await db` + insert into data_row_versions (id, row_id, locale_id, version_number, cells_json, slug, public_path) + values (${versionId}, ${input.rowId}, ${localeId}, 1, ${input.cells}, ${input.slug}, ${`/posts/${input.slug}`}) + ` + } + await db` + insert into data_row_localizations (row_id, locale_id, cells_json, slug, availability, active_version_id, created_at, updated_at) + values (${input.rowId}, ${localeId}, ${input.cells}, ${input.slug}, ${input.status === 'published' ? 'online' : 'offline'}, + ${versionId}, ${input.updatedAt ?? '2024-01-01T00:00:00.000Z'}, ${input.updatedAt ?? '2024-01-01T00:00:00.000Z'}) + ` +} diff --git a/server/repositories/data/rows/__tests__/localizedRows.test.ts b/server/repositories/data/rows/__tests__/localizedRows.test.ts new file mode 100644 index 000000000..9a9fd7d70 --- /dev/null +++ b/server/repositories/data/rows/__tests__/localizedRows.test.ts @@ -0,0 +1,227 @@ +import { createDataTable, getDataTable, updateDataTable } from '../../tables' +import { withPublishLock } from '../../../../publish/publishState' +import { afterEach, describe, expect, it } from 'bun:test' +import { createSqliteClient } from '../../../../db/sqlite' +import { sqliteMigrations } from '../../../../db/migrations-sqlite' +import { runMigrations } from '../../../../db/runMigrations' +import type { DbClient } from '../../../../db/client' +import { createDataRow, saveDataRowDraft, softDeleteDataRow, updateDataRowStatus, updateDataRowTable, updateDataRowAuthor, upsertSharedDataRowDraft } from '../mutations' +import { getDataRow, getDataRowBySlug, getDataRowMany, listDataRows } from '../read' +import { searchDataRows } from '../search' +import { createTranslationFieldMetadata, getTranslationFieldState } from '@core/localization' +import { listDataRowsWithFilter } from '../filter' +import { scheduleDataRowPublish, cancelScheduledPublish, listDuePublishSchedules } from '../schedule' +import { createLocale, getContentLocalization, LocalizationError, saveContentLocalizationDraft, setContentLocalizationPublishedVersion } from '../../../localization' +import { insertDataRowIfAbsent, upsertDataRow } from '../import' + +const clients: DbClient[] = [] +afterEach(async () => { await Promise.all(clients.splice(0).map((db) => db.close())) }) + +async function fixture() { + const db = createSqliteClient(':memory:') + clients.push(db) + await runMigrations(db, sqliteMigrations) + const de = await createLocale(db, { code: 'de', name: 'Deutsch', pathPrefix: 'de', enabled: true, direction: 'ltr' }) + const first = await createDataRow(db, { tableId: 'posts', cells: { title: 'Alpha', body: 'Original', slug: 'alpha' }, slug: 'alpha' }) + const second = await createDataRow(db, { tableId: 'posts', cells: { title: 'Bravo', body: 'Second', slug: 'bravo' }, slug: 'bravo' }) + return { db, de, first, second } +} + +describe('localized row integration', () => { + it('uses one draft slug in sparse writes, projections, filters and lookups', async () => { + const { db, de, first } = await fixture() + await saveContentLocalizationDraft(db, first.id, de.id, { cells: { title: 'Deutsch' }, slug: 'deutsch' }) + const sparse = await getDataRow(db, first.id, de.id) + expect(sparse?.slug).toBe('deutsch') + expect(sparse?.cells.slug).toBe('deutsch') + expect(Object.hasOwn(sparse!.localization!.cells, 'slug')).toBe(false) + expect((await listDataRowsWithFilter(db, 'posts', { localeId: de.id, filter: { slug: 'deutsch' } })).rows.map((row) => row.id)).toEqual([first.id]) + expect((await getDataRowBySlug(db, 'posts', 'deutsch', de.id))?.cells.slug).toBe('deutsch') + await saveContentLocalizationDraft(db, first.id, de.id, { cells: { title: 'Deutsch', slug: 'conflicting-cell' }, slug: 'canonical' }) + expect((await getContentLocalization(db, first.id, de.id))?.cells.slug).toBe('canonical') + const saved = await saveDataRowDraft(db, first.id, { cells: { ...sparse!.cells, slug: 'stale-cell' }, slug: 'next-canonical', localeId: de.id }) + expect(saved?.cells.slug).toBe('next-canonical') + expect(saved?.localization?.cells.slug).toBe('next-canonical') + expect((await getDataRow(db, first.id))?.cells.slug).toBe('alpha') + }) + + it('keeps public routing slugs localized while structural identifiers stay shared', async () => { + const { db } = await fixture() + const posts = await getDataTable(db, 'posts') + expect(posts?.fields.find((field) => field.id === 'slug')?.localization).toBe('localized') + await expect(updateDataTable(db, 'posts', { fields: posts!.fields.map((field) => field.id === 'slug' ? { ...field, localization: 'shared' } : field) })).rejects.toBeInstanceOf(LocalizationError) + await expect(createDataTable(db, { name: 'Invalid', slug: 'invalid', kind: 'postType', singularLabel: 'Invalid', pluralLabel: 'Invalid', fields: [{ id: 'slug', label: 'Slug', type: 'text', localization: 'shared' }] })).rejects.toBeInstanceOf(LocalizationError) + for (const tableId of ['components', 'layouts']) expect((await getDataTable(db, tableId))?.fields.find((field) => field.id === 'slug')?.localization).toBe('shared') + }) + + it('preserves a locale slug when a draft update only supplies cells', async () => { + const { db, de, first } = await fixture() + await saveDataRowDraft(db, first.id, { cells: { ...first.cells, slug: 'hallo' }, localeId: de.id }) + const updated = await saveDataRowDraft(db, first.id, { cells: { title: 'Hallo' }, localeId: de.id }) + expect(updated?.slug).toBe('hallo') + expect((await getDataRow(db, first.id))?.slug).toBe('alpha') + }) + + it('rejects explicit unknown selections even with no matching rows and before author writes', async () => { + const { db, first } = await fixture() + for (const localeId of ['', 'missing']) { + await expect(getDataRow(db, 'absent', localeId)).rejects.toBeInstanceOf(LocalizationError) + await expect(getDataRowMany(db, [], localeId)).rejects.toBeInstanceOf(LocalizationError) + await expect(getDataRowBySlug(db, 'posts', 'absent', localeId)).rejects.toBeInstanceOf(LocalizationError) + await expect(listDataRows(db, 'empty-table', { localeId })).rejects.toBeInstanceOf(LocalizationError) + await expect(listDataRowsWithFilter(db, 'posts', { localeId })).rejects.toBeInstanceOf(LocalizationError) + await expect(searchDataRows(db, 'absent', 1, { localeId })).rejects.toBeInstanceOf(LocalizationError) + await expect(updateDataRowAuthor(db, first.id, 'unvalidated-author', null, localeId)).rejects.toBeInstanceOf(LocalizationError) + } + expect((await getDataRow(db, first.id))?.authorUserId).toBeNull() + expect(await getContentLocalization(db, first.id, '')).toBeNull() + }) + + it('invalidates review of edited translations and preserves untouched field reviews', async () => { + const { db, first, de } = await fixture() + const titleMeta = createTranslationFieldMetadata(first.cells.title, 'reviewed') + const bodyMeta = createTranslationFieldMetadata(first.cells.body, 'reviewed') + await saveContentLocalizationDraft(db, first.id, de.id, { + cells: { title: 'Deutsch', body: 'Original übersetzt' }, slug: 'deutsch', + translationMeta: { title: titleMeta, body: bodyMeta }, + }) + const previous = await getDataRow(db, first.id, de.id) + const saved = await saveDataRowDraft(db, first.id, { cells: { ...previous?.cells, title: 'Neue Übersetzung' }, localeId: de.id }) + expect(saved?.localization?.translationMeta.title.reviewState).toBe('needs_review') + expect(saved?.localization?.translationMeta.body).toEqual(bodyMeta) + expect(getTranslationFieldState(first.cells, saved!.localization!.cells, 'title', saved?.localization?.translationMeta.title)).toBe('needs_review') + }) + + it('preserves source values and dormant translations when collection field modes differ', async () => { + const { db, de } = await fixture() + for (const [id, shared] of [['from-data', false], ['to-data', true]] as const) { + await createDataTable(db, { id, name: id, slug: id, kind: 'data', singularLabel: 'Row', pluralLabel: 'Rows', fields: [ + { id: 'title', label: 'Title', type: 'text', localization: shared ? 'shared' : 'localized' }, + { id: 'note', label: 'Note', type: 'text', localization: shared ? 'localized' : 'shared' }, + ] }) + } + const row = await createDataRow(db, { tableId: 'from-data', cells: { title: 'Source', note: 'Shared note' }, slug: '' }) + await saveDataRowDraft(db, row.id, { cells: { title: 'Deutsch', note: 'Shared note' }, slug: '', localeId: de.id }) + const moved = await updateDataRowTable(db, row.id, 'to-data', null, { localeId: de.id }) + expect(moved.ok && moved.row.cells).toMatchObject({ title: 'Source', note: 'Shared note' }) + expect((await getDataRow(db, row.id))?.cells).toMatchObject({ title: 'Source', note: 'Shared note' }) + expect((await getContentLocalization(db, row.id, de.id))?.cells.title).toBe('Deutsch') + const restored = await updateDataRowTable(db, row.id, 'from-data', null, { localeId: de.id }) + expect(restored.ok && restored.row.cells).toMatchObject({ title: 'Deutsch', note: 'Shared note' }) + }) + + it('does not move content into shared structural tables', async () => { + const { db, first, de } = await fixture() + for (const tableId of ['pages', 'components', 'layouts']) { + expect(await updateDataRowTable(db, first.id, tableId, null, { localeId: de.id })).toEqual({ ok: false, reason: 'unsupported_table' }) + } + expect((await getDataRow(db, first.id))?.tableId).toBe('posts') + expect((await getDataRow(db, first.id))?.cells.title).toBe('Alpha') + }) + + it('waits for an in-flight publication before retracting a language', async () => { + const { db, de, first } = await fixture() + await saveDataRowDraft(db, first.id, { cells: { title: 'Deutsch' }, slug: 'deutsch', localeId: de.id }) + await db`insert into data_row_versions (id, row_id, locale_id, version_number, cells_json, slug) values ('race-version', ${first.id}, ${de.id}, 1, ${{ title: 'Live' }}, 'live')` + await setContentLocalizationPublishedVersion(db, first.id, de.id, 'race-version') + let release!: () => void + let entered!: () => void + const started = new Promise<void>((resolve) => { entered = resolve }) + const gate = new Promise<void>((resolve) => { release = resolve }) + const publishing = withPublishLock(async () => { entered(); await gate }) + await started + const retracting = updateDataRowStatus(db, first.id, 'unpublished', null, de.id) + try { + await Bun.sleep(5) + expect((await getContentLocalization(db, first.id, de.id))?.availability).toBe('online') + } finally { + release() + await publishing + } + expect((await retracting)?.status).toBe('unpublished') + expect((await getContentLocalization(db, first.id, de.id))?.availability).toBe('offline') + }) + + it('retracts every language and cancels frozen schedules when content moves collections', async () => { + const { db, de, first } = await fixture() + await createDataTable(db, { id: 'articles', name: 'Articles', slug: 'articles', kind: 'postType', singularLabel: 'Article', pluralLabel: 'Articles' }) + await saveDataRowDraft(db, first.id, { cells: { title: 'Deutsch' }, slug: 'deutsch', localeId: de.id }) + await db`insert into data_row_versions (id, row_id, locale_id, version_number, cells_json, slug) values ('move-source', ${first.id}, 'default', 1, ${{ title: 'Source' }}, 'source')` + await db`insert into data_row_versions (id, row_id, locale_id, version_number, cells_json, slug) values ('move-de', ${first.id}, ${de.id}, 2, ${{ title: 'Deutsch' }}, 'deutsch')` + await setContentLocalizationPublishedVersion(db, first.id, 'default', 'move-source') + await setContentLocalizationPublishedVersion(db, first.id, de.id, 'move-de') + await scheduleDataRowPublish(db, first.id, '2026-12-01T00:00:00.000Z', null, de.id) + const moved = await updateDataRowTable(db, first.id, 'articles', null, { localeId: de.id }) + expect(moved.ok).toBe(true) + for (const localeId of ['default', de.id]) { + const variant = await getContentLocalization(db, first.id, localeId) + expect(variant?.availability).toBe('offline') + expect(variant?.scheduledPublishAt).toBeNull() + expect(variant?.activeVersionId).not.toBeNull() + } + }) + + it('stores sparse translations, keeps inherited fields linked, and resolves slugs by language', async () => { + const { db, de, first } = await fixture() + const inherited = await getDataRow(db, first.id, de.id) + expect(inherited?.cells.title).toBe('Alpha') + expect(inherited?.localization).toBeNull() + await saveDataRowDraft(db, first.id, { cells: { ...inherited?.cells, title: 'Deutsch', slug: 'deutsch' }, slug: 'deutsch', localeId: de.id }) + expect((await getContentLocalization(db, first.id, de.id))?.cells).toEqual({ title: 'Deutsch', slug: 'deutsch' }) + await saveDataRowDraft(db, first.id, { cells: { ...first.cells, body: 'Changed source' }, slug: first.slug }) + const translated = await getDataRow(db, first.id, de.id) + expect(translated?.cells).toMatchObject({ title: 'Deutsch', body: 'Changed source' }) + expect((await getDataRowBySlug(db, 'posts', 'deutsch', de.id))?.id).toBe(first.id) + expect(await getDataRowBySlug(db, 'posts', 'deutsch', 'default')).toBeNull() + expect((await getDataRow(db, first.id))?.cells.title).toBe('Alpha') + }) + + it('filters resolved values before pagination and honors null overrides over source values', async () => { + const { db, de, first, second } = await fixture() + await saveDataRowDraft(db, first.id, { cells: { ...first.cells, title: null }, slug: first.slug, localeId: de.id }) + await saveDataRowDraft(db, second.id, { cells: { ...second.cells, title: 'Aardvark' }, slug: second.slug, localeId: de.id }) + const filtered = await listDataRowsWithFilter(db, 'posts', { localeId: de.id, filter: { title: { like: 'A%' } }, orderBy: { title: 'asc' }, limit: 1 }) + expect(filtered.totalCount).toBe(1) + expect(filtered.rows[0].id).toBe(second.id) + expect((await listDataRowsWithFilter(db, 'posts', { localeId: de.id, filter: { title: null } })).rows.map((row) => row.id)).toEqual([first.id]) + expect((await listDataRowsWithFilter(db, 'posts', { filter: { title: 'Alpha' } })).totalCount).toBe(1) + expect((await listDataRowsWithFilter(db, 'posts', { localeId: de.id, filter: { title: { eq: null } } })).rows.map((row) => row.id)).toEqual([first.id]) + expect((await listDataRowsWithFilter(db, 'posts', { localeId: de.id, filter: { title: { ne: null } } })).rows.map((row) => row.id)).toEqual([second.id]) + }) + + it('keeps a published translation online while scheduling a frozen update and editing later drafts', async () => { + const { db, de, first } = await fixture() + await saveDataRowDraft(db, first.id, { cells: { ...first.cells, title: 'Deutsch' }, slug: 'deutsch', localeId: de.id }) + await db`insert into data_row_versions (id, row_id, locale_id, version_number, cells_json, slug, public_path) values ('de-version', ${first.id}, ${de.id}, 1, ${{ title: 'Live' }}, 'live', '/de/posts/live')` + await setContentLocalizationPublishedVersion(db, first.id, de.id, 'de-version') + const scheduled = await scheduleDataRowPublish(db, first.id, '2026-12-01T12:00:00.000Z', null, de.id) + expect(scheduled?.status).toBe('published') + expect(scheduled?.publicPath).toBe('/de/posts/live') + await saveDataRowDraft(db, first.id, { cells: { ...scheduled?.cells, title: 'Later draft' }, slug: 'later', localeId: de.id }) + const due = await listDuePublishSchedules(db, '2026-12-02T00:00:00.000Z', 10) + expect(due).toHaveLength(1) + expect(due[0].localeId).toBe(de.id) + expect(due[0].scheduledRevision.cells.title).toBe('Deutsch') + expect((await cancelScheduledPublish(db, first.id, null, de.id))?.status).toBe('published') + expect((await getDataRow(db, first.id))?.status).toBe('draft') + expect((await softDeleteDataRow(db, first.id))?.status).toBe('published') + }) + + it('keeps logical shared writes separate from locale values and rejects cross-table identity reuse', async () => { + const { db, first } = await fixture() + await upsertSharedDataRowDraft(db, { id: first.id, tableId: 'posts', cells: { structural: 'new' } }) + expect((await getDataRow(db, first.id))?.cells).toMatchObject({ title: 'Alpha', structural: 'new' }) + await expect(upsertSharedDataRowDraft(db, { id: first.id, tableId: 'pages', cells: {} })).rejects.toThrow('another collection') + expect((await getDataRow(db, first.id))?.tableId).toBe('posts') + }) + + it('restores imports as drafts unless a matching published snapshot exists', async () => { + const { db, first } = await fixture() + const input = { id: 'imported', tableId: 'posts', cells: { title: 'Imported' }, slug: 'imported', status: 'published' as const, publishedAt: null, createdAt: null, updatedAt: null } + await upsertDataRow(db, input) + expect((await getDataRow(db, input.id))?.status).toBe('draft') + expect((await getDataRow(db, input.id))?.cells.title).toBe('Imported') + expect(await insertDataRowIfAbsent(db, { ...input, id: 'duplicate', slug: first.slug })).toBe(false) + expect(await getDataRow(db, 'duplicate')).toBeNull() + }) +}) diff --git a/server/repositories/data/rows/__tests__/mutations.test.ts b/server/repositories/data/rows/__tests__/mutations.test.ts index 756560983..d36bb4d10 100644 --- a/server/repositories/data/rows/__tests__/mutations.test.ts +++ b/server/repositories/data/rows/__tests__/mutations.test.ts @@ -5,6 +5,7 @@ import { runMigrations } from '../../../../db/runMigrations' import type { DbClient } from '../../../../db/client' import { softDeleteDataRow, upsertDataRowDraft } from '../mutations' import { getDataRow } from '../read' +import { seedLocalizedVariant } from './fixtures' const USER_ID = 'user-author' @@ -27,6 +28,7 @@ async function seedRow(db: DbClient, id: string): Promise<void> { ${'2024-01-01T00:00:00.000Z'}, ${'2024-01-01T00:00:00.000Z'} ) ` + await seedLocalizedVariant(db, { rowId: id, cells: { title: id, slug: id }, slug: id }) } describe('softDeleteDataRow', () => { diff --git a/server/repositories/data/rows/__tests__/read.test.ts b/server/repositories/data/rows/__tests__/read.test.ts index 67b11b125..2b47d2e93 100644 --- a/server/repositories/data/rows/__tests__/read.test.ts +++ b/server/repositories/data/rows/__tests__/read.test.ts @@ -6,6 +6,7 @@ import type { DbClient } from '../../../../db/client' import { countDataRows, getDataRow, getDataRowMany } from '../read' import { softDeleteDataRow } from '../mutations' import { getDataTableBySlug } from '../../tables' +import { seedLocalizedVariant } from './fixtures' async function freshDb(): Promise<DbClient> { const db = createSqliteClient(':memory:') @@ -21,6 +22,7 @@ async function seedRow(db: DbClient, id: string): Promise<void> { ${'2024-01-01T00:00:00.000Z'}, ${'2024-01-01T00:00:00.000Z'} ) ` + await seedLocalizedVariant(db, { rowId: id, cells: { title: id, slug: id }, slug: id }) } describe('getDataRowMany', () => { diff --git a/server/repositories/data/rows/apply.ts b/server/repositories/data/rows/apply.ts index 31fd29aa5..cf29cd24f 100644 --- a/server/repositories/data/rows/apply.ts +++ b/server/repositories/data/rows/apply.ts @@ -49,16 +49,19 @@ import { softDeleteDataRow, } from './mutations' import { listDataRowIdSlugs, listSoftDeletedDataRowIds } from './read' +import { getDefaultLocale } from '../../localization' import { notifyRowWrite, serializeCollabAwareWrite } from '../../rowWriteEvents' export interface DataRowWrite { id: string + localeId?: string cells: Record<string, unknown> slug: string } export interface ApplyDataRowChangesInput { tableId: string + localeId?: string /** Rows to create/update, with their final slugs. */ writes: DataRowWrite[] /** Row ids to soft-delete. Unknown / already-deleted ids are no-ops. */ @@ -89,11 +92,11 @@ async function stampDataRowSeq(db: DbClient, rowId: string, seq: number): Promis */ export async function applyDataRowChangesInTx( tx: DbClient, - { tableId, writes, deleteIds, actorUserId, seq }: ApplyDataRowChangesInput, + { tableId, localeId, writes, deleteIds, actorUserId, seq }: ApplyDataRowChangesInput, ): Promise<ApplyDataRowChangesResult> { let deletedPublished = false - const existing = await listDataRowIdSlugs(tx, tableId) + const existing = await listDataRowIdSlugs(tx, tableId, localeId) const existingSlugById = new Map(existing.map((r) => [r.id, r.slug])) const softDeletedIds = new Set(await listSoftDeletedDataRowIds(tx, tableId)) @@ -120,9 +123,9 @@ export async function applyDataRowChangesInTx( const storedSlug = existingSlugById.get(write.id) if (storedSlug === undefined) continue // created or revived below if (storedSlug === write.slug) { - await updateDataRowDraftCells(tx, write.id, { cells: write.cells, slug: write.slug }, actorUserId) + await updateDataRowDraftCells(tx, write.id, { cells: write.cells, slug: write.slug, localeId: write.localeId ?? localeId }, actorUserId) } else { - await updateDataRowDraftCells(tx, write.id, { cells: write.cells, slug: '' }, actorUserId) + await updateDataRowDraftCells(tx, write.id, { cells: write.cells, slug: '', localeId: write.localeId ?? localeId }, actorUserId) parked.push(write) } await stampDataRowSeq(tx, write.id, seq) @@ -130,12 +133,12 @@ export async function applyDataRowChangesInTx( for (const write of writes) { if (existingSlugById.has(write.id)) continue if (softDeletedIds.has(write.id)) { - await resurrectDataRow(tx, write.id, { cells: write.cells, slug: '' }, actorUserId) + await resurrectDataRow(tx, write.id, { cells: write.cells, slug: '', localeId: write.localeId ?? localeId }, actorUserId) parked.push(write) } else { await createDataRow( tx, - { id: write.id, tableId, cells: write.cells, slug: write.slug }, + { id: write.id, tableId, cells: write.cells, slug: write.slug, localeId: write.localeId ?? localeId }, actorUserId, null, // In-transaction: the caller notifies row-write listeners post-commit @@ -148,7 +151,7 @@ export async function applyDataRowChangesInTx( // 3. Final slugs for the parked rows — every old slug is free by now. for (const write of parked) { - await updateDataRowSlug(tx, write.id, write.slug) + await updateDataRowSlug(tx, write.id, write.slug, write.localeId ?? localeId) } return { deletedPublished } @@ -169,10 +172,13 @@ export async function applyDataRowChanges( result = await applyDataRowChangesInTx(tx, input) }) if (input.writes.length > 0) { - notifyRowWrite({ + const defaultLocale = await getDefaultLocale(db) + for (const write of input.writes) notifyRowWrite({ tableId: input.tableId, - rowIds: input.writes.map((write) => write.id), + rowIds: [write.id], kind: 'update', + localeId: write.localeId ?? input.localeId ?? defaultLocale.id, + sharedChanged: true, }) } if (input.deleteIds.size > 0) { diff --git a/server/repositories/data/rows/bulk.ts b/server/repositories/data/rows/bulk.ts index e8022207f..0ad54e245 100644 --- a/server/repositories/data/rows/bulk.ts +++ b/server/repositories/data/rows/bulk.ts @@ -1,3 +1,4 @@ +import { bumpPublishVersion, withPublishLock } from '../../../publish/publishState' /** * Transactional batch operations for data rows. Each helper wraps the * matching single-row mutation in one transaction so a failure aborts the @@ -11,6 +12,8 @@ import type { DbClient } from '../../../db/client' import type { DataRow } from '@core/data/schemas' import type { InsertDataRowInput, UpdateDataRowDraftInput } from './mapper' import { createDataRow, saveDataRowDraft, softDeleteDataRow } from './mutations' +import { getDataRowMany } from './read' +import { deepEqual } from '@core/utils/deepEqual' import { notifyRowWrite, serializeCollabAwareWrite } from '../../rowWriteEvents' /** @@ -40,7 +43,7 @@ export async function createDataRowMany( return rows }) for (const row of created) { - notifyRowWrite({ tableId: row.tableId, rowIds: [row.id], kind: 'create' }) + notifyRowWrite({ tableId: row.tableId, rowIds: [row.id], kind: 'create', localeId: row.localeId, sharedChanged: true }) } return created }) @@ -58,6 +61,7 @@ export async function saveDataRowDraftMany( pluginActorId: string | null = null, ): Promise<DataRow[]> { return serializeCollabAwareWrite(async () => { + const before = new Map((await getDataRowMany(db, updates.map((update) => update.id))).map((row) => [row.id, row.sharedCells])) const updated = await db.transaction(async (tx) => { const rows: DataRow[] = [] for (const { id, input } of updates) { @@ -74,7 +78,7 @@ export async function saveDataRowDraftMany( return rows }) for (const row of updated) { - notifyRowWrite({ tableId: row.tableId, rowIds: [row.id], kind: 'update' }) + notifyRowWrite({ tableId: row.tableId, rowIds: [row.id], kind: 'update', localeId: row.localeId, sharedChanged: !deepEqual(before.get(row.id), row.sharedCells) }) } return updated }) @@ -93,7 +97,7 @@ export async function softDeleteDataRowMany( rowIds: ReadonlyArray<string>, actorUserId: string | null = null, ): Promise<{ deleted: number; publishedDeleted: number }> { - return serializeCollabAwareWrite(async () => { + return serializeCollabAwareWrite(() => withPublishLock(async () => { const deletedRows = await db.transaction(async (tx) => { const rows: NonNullable<Awaited<ReturnType<typeof softDeleteDataRow>>>[] = [] for (const id of rowIds) { @@ -110,9 +114,10 @@ export async function softDeleteDataRowMany( for (const row of deletedRows) { notifyRowWrite({ tableId: row.tableId, rowIds: [row.id], kind: 'delete' }) } + if (deletedRows.some((row) => row.status === 'published')) bumpPublishVersion() return { deleted: deletedRows.length, publishedDeleted: deletedRows.filter((row) => row.status === 'published').length, } - }) + })) } diff --git a/server/repositories/data/rows/filter.ts b/server/repositories/data/rows/filter.ts index b998be7d1..3228217fb 100644 --- a/server/repositories/data/rows/filter.ts +++ b/server/repositories/data/rows/filter.ts @@ -8,9 +8,12 @@ * for cells_json paths) — `db-postgres-isms.test.ts` gates against drift. */ import type { DbClient } from '../../../db/client' -import type { DataRow } from '@core/data/schemas' +import type { DataRow, DataRowStatus } from '@core/data/schemas' import type { StorageFilterOperator, StorageFilterValue } from '@core/plugin-sdk/storageSchemas' -import { jsonField } from '../../../db/jsonExtract' +import { jsonField, jsonFieldExists } from '../../../db/jsonExtract' +import { resolveDataFieldLocalization } from '@core/localization' +import { getDefaultLocale, resolveContentLocale } from '../../localization' +import { getDataTable } from '../tables' import { placeholder, selectHydratedDataRows } from './mapper' /** @@ -27,9 +30,10 @@ import { placeholder, selectHydratedDataRows } from './mapper' * so the SQL stays dialect-naive). */ interface ListDataRowsFilterOptions { + localeId?: string filter?: Record<string, StorageFilterValue> orderBy?: Record<string, 'asc' | 'desc'> - status?: 'any' | 'draft' | 'published' | 'scheduled' + status?: 'any' | DataRowStatus limit?: number offset?: number } @@ -67,18 +71,45 @@ export async function listDataRowsWithFilter( ): Promise<ListDataRowsWithFilterResult> { const { filter, orderBy, status = 'any', limit = 100, offset = 0 } = options - const params: unknown[] = [tableId] - let paramIdx = 1 + const sourceLocale = await getDefaultLocale(db) + const locale = options.localeId === undefined ? sourceLocale : await resolveContentLocale(db, options.localeId) + const table = await getDataTable(db, tableId) + if (!table) return { rows: [], totalCount: 0 } + const fields = new Map(table.fields.map((field) => [field.id, field])) + const params: unknown[] = [locale.id, sourceLocale.id, tableId] + let paramIdx = 3 function addParam(value: unknown): string { params.push(value) paramIdx++ return placeholder(db.dialect, paramIdx) } - let whereSql = `data_rows.table_id = ${placeholder(db.dialect, 1)} and data_rows.deleted_at is null` + const localizedCte = `localized_rows as ( + select data_rows.id, data_rows.cells_json as shared_cells_json, + localized.cells_json as locale_cells_json, source.cells_json as source_cells_json, + coalesce(localized.slug, source.slug, '') as slug, + case when localized.availability = 'online' and localized.active_version_id is not null then 'published' + when localized.scheduled_publish_at is not null then 'scheduled' + when localized.active_version_id is not null then 'unpublished' else 'draft' end as status, + data_rows.created_at, data_rows.updated_at, localized.published_at + from data_rows + join data_tables on data_tables.id = data_rows.table_id + left join data_row_localizations localized on localized.row_id = data_rows.id and localized.locale_id = ${placeholder(db.dialect, 1)} + left join data_row_localizations source on source.row_id = data_rows.id and source.locale_id = ${placeholder(db.dialect, 2)} + where data_rows.table_id = ${placeholder(db.dialect, 3)} and data_rows.deleted_at is null and data_tables.deleted_at is null + )` + function cellExpression(key: string): string { + const field = fields.get(key) + if (key === 'slug' && field && resolveDataFieldLocalization(field) === 'localized') return 'slug' + if (!field || resolveDataFieldLocalization(field) === 'shared') return jsonField('shared_cells_json', key, db.dialect).sql + const present = jsonFieldExists('locale_cells_json', key, db.dialect).sql + return `(case when ${present} then ${jsonField('locale_cells_json', key, db.dialect).sql} + else ${jsonField('source_cells_json', key, db.dialect).sql} end)` + } + let whereSql = '1 = 1' if (status !== 'any') { - whereSql += ` and data_rows.status = ${addParam(status)}` + whereSql += ` and status = ${addParam(status)}` } if (filter) { @@ -86,14 +117,14 @@ export async function listDataRowsWithFilter( if (!FIELD_KEY_RE.test(key)) { throw new Error(`[content] invalid filter field name: ${JSON.stringify(key)}`) } - const fragment = jsonField('cells_json', key, db.dialect).sql + const fragment = cellExpression(key) if (value === null || typeof value !== 'object') { - whereSql += ` and ${fragment} = ${addParam(value)}` + whereSql += value === null ? ` and ${fragment} is null` : ` and ${fragment} = ${addParam(value)}` } else { const op = value as StorageFilterOperator - if (op.eq !== undefined) whereSql += ` and ${fragment} = ${addParam(op.eq)}` - if (op.ne !== undefined) whereSql += ` and ${fragment} != ${addParam(op.ne)}` + if (op.eq !== undefined) whereSql += op.eq === null ? ` and ${fragment} is null` : ` and ${fragment} = ${addParam(op.eq)}` + if (op.ne !== undefined) whereSql += op.ne === null ? ` and ${fragment} is not null` : ` and ${fragment} != ${addParam(op.ne)}` if (op.gt !== undefined) whereSql += ` and ${fragment} > ${addParam(op.gt)}` if (op.gte !== undefined) whereSql += ` and ${fragment} >= ${addParam(op.gte)}` if (op.lt !== undefined) whereSql += ` and ${fragment} < ${addParam(op.lt)}` @@ -115,22 +146,22 @@ export async function listDataRowsWithFilter( const countParamCount = params.length - let orderBySql = 'data_rows.updated_at desc, data_rows.created_at desc' + let orderBySql = 'updated_at desc, created_at desc, id asc' if (orderBy && Object.keys(orderBy).length > 0) { const parts: string[] = [] for (const [key, dir] of Object.entries(orderBy)) { const normalizedDir = dir === 'desc' ? 'desc' : 'asc' if (ROW_LEVEL_ORDER_KEYS.has(key)) { - parts.push(`data_rows.${key} ${normalizedDir}`) + parts.push(`${key} ${normalizedDir}`) continue } if (!FIELD_KEY_RE.test(key)) { throw new Error(`[content] invalid orderBy field name: ${JSON.stringify(key)}`) } - const fragment = jsonField('cells_json', key, db.dialect).sql + const fragment = cellExpression(key) parts.push(`${fragment} ${normalizedDir}`) } - orderBySql = parts.join(', ') + orderBySql = [...parts, 'id asc'].join(', ') } const limitPlaceholder = addParam(Math.max(1, Math.min(500, limit))) @@ -140,17 +171,18 @@ export async function listDataRowsWithFilter( // hydrated SELECT joins it back to data_rows + user refs in one round-trip. // The outer `order by` is re-applied because a JOIN does not preserve the // CTE's row order. - const cte = `filtered_ids as ( - select data_rows.id - from data_rows + const cte = `${localizedCte}, filtered_ids as ( + select id, row_number() over (order by ${orderBySql}) as position + from localized_rows where ${whereSql} order by ${orderBySql} limit ${limitPlaceholder} offset ${offsetPlaceholder} )` const countSql = ` + with ${localizedCte} select count(*) as total - from data_rows + from localized_rows where ${whereSql} ` @@ -158,9 +190,10 @@ export async function listDataRowsWithFilter( const [rows, countResult] = await Promise.all([ selectHydratedDataRows(db, { + localeId: locale.id, cte, join: 'join filtered_ids on filtered_ids.id = data_rows.id', - tail: `order by ${orderBySql}`, + tail: 'order by filtered_ids.position', params, }), db.unsafe<{ total: number | bigint | string }>(countSql, countParams), diff --git a/server/repositories/data/rows/import.ts b/server/repositories/data/rows/import.ts index 3a2850f9b..2b959b1cd 100644 --- a/server/repositories/data/rows/import.ts +++ b/server/repositories/data/rows/import.ts @@ -1,21 +1,19 @@ -/** - * Bundle-import upserts for data rows. These bypass the normal CRUD path to - * preserve the source instance's original id, status, and timestamps. - * - * upsertDataRow — id-preserving upsert (merge-overwrite / replace) - * insertDataRowIfAbsent — insert only if id absent (merge-add) - * replaceDataRow — plain insert after wipe (replace strategy) - * - * User reference columns (author, createdBy, etc.) are intentionally dropped - * on import: the user ids from the source instance will not exist in the target. - */ +/** Bundle restoration writes logical identity and locale drafts; live pointers must reference real snapshots. */ import type { DbClient } from '../../../db/client' +import type { ContentLocalizationDraftInput } from '@core/localization-schema' import type { DataRowCells, DataRowStatus } from '@core/data/schemas' +import { resolveDataFieldLocalization } from '@core/localization' +import { getDataTable } from '../tables' +import { getDefaultLocale, getLocale, LocalizationError, saveContentLocalizationDraft, setContentLocalizationAvailability, setContentLocalizationPublishedVersion } from '../../localization' export interface DataRowImportInput { id: string tableId: string cells: DataRowCells + sharedCells?: DataRowCells + localeId?: string + localization?: ContentLocalizationDraftInput | null + activeVersionId?: string | null slug: string status: DataRowStatus publishedAt: string | null @@ -23,84 +21,71 @@ export interface DataRowImportInput { updatedAt: string | null } -/** - * Upsert a row preserving its original id, status, and timestamps. Used by - * the `merge-overwrite` and `replace` import strategies. - */ -export async function upsertDataRow( - db: DbClient, - input: DataRowImportInput, -): Promise<void> { - const createdAt = input.createdAt ?? new Date().toISOString() - const updatedAt = input.updatedAt ?? new Date().toISOString() +async function importProjection(db: DbClient, input: DataRowImportInput) { + const locale = input.localeId ? await getLocale(db, input.localeId) : await getDefaultLocale(db) + if (!locale) throw new LocalizationError('Unknown imported content language', 'localeId') + const table = await getDataTable(db, input.tableId) + if (!table) throw new Error('Imported content table is missing') + const sharedCells = structuredClone(input.sharedCells ?? input.cells) + const cells: DataRowCells = {} + for (const field of table.fields) { + if (resolveDataFieldLocalization(field) === 'shared' || field.type === 'pageTree') continue + if (Object.hasOwn(input.cells, field.id)) cells[field.id] = structuredClone(input.cells[field.id]) + delete sharedCells[field.id] + } + return { locale, sharedCells, cells } +} + +async function restoreDraft(db: DbClient, input: DataRowImportInput, localeId: string, cells: DataRowCells): Promise<void> { + if (input.localization === null) return + await saveContentLocalizationDraft(db, input.id, localeId, input.localization ?? { cells, slug: input.slug }) + await setContentLocalizationAvailability(db, input.id, localeId, 'offline') + if (input.status === 'published' && input.activeVersionId) { + await setContentLocalizationPublishedVersion(db, input.id, localeId, input.activeVersionId) + } + // The complete bundle restores every locale and its history after logical rows exist. await db` - insert into data_rows ( - id, table_id, cells_json, slug, status, - published_at, created_at, updated_at - ) - values ( - ${input.id}, ${input.tableId}, ${input.cells}, ${input.slug}, ${input.status}, - ${input.publishedAt}, ${createdAt}, ${updatedAt} - ) - on conflict (id) do update - set table_id = excluded.table_id, - cells_json = excluded.cells_json, - slug = excluded.slug, - status = excluded.status, - published_at = excluded.published_at, - updated_at = excluded.updated_at + update data_row_localizations + set created_at = ${input.createdAt ?? new Date().toISOString()}, + updated_at = ${input.updatedAt ?? new Date().toISOString()} + where row_id = ${input.id} and locale_id = ${localeId} ` } -/** - * Insert a row only when no uniqueness constraint is hit. Returns `true` when - * the row was inserted, `false` when it was skipped (id conflict, or an active - * row in the same table already owns the imported slug). Used by the - * `merge-add` import strategy. - * - * RETURNING id is supported by both Postgres and SQLite, making this dialect- - * neutral while still reporting whether an insert actually happened. - */ -export async function insertDataRowIfAbsent( - db: DbClient, - input: DataRowImportInput, -): Promise<boolean> { - const createdAt = input.createdAt ?? new Date().toISOString() - const updatedAt = input.updatedAt ?? new Date().toISOString() +export async function upsertDataRow(db: DbClient, input: DataRowImportInput): Promise<void> { + const projection = await importProjection(db, input) + await db` + insert into data_rows (id, table_id, cells_json, slug, created_at, updated_at) + values (${input.id}, ${input.tableId}, ${projection.sharedCells}, '', ${input.createdAt ?? new Date().toISOString()}, ${input.updatedAt ?? new Date().toISOString()}) + on conflict (id) do update set table_id = excluded.table_id, cells_json = excluded.cells_json, + slug = '', updated_at = excluded.updated_at + ` + await restoreDraft(db, input, projection.locale.id, projection.cells) +} + +/** Skip existing identities or an authored slug already claimed in the selected language. */ +export async function insertDataRowIfAbsent(db: DbClient, input: DataRowImportInput): Promise<boolean> { + const projection = await importProjection(db, input) const { rows } = await db<{ id: string }>` - insert into data_rows ( - id, table_id, cells_json, slug, status, - published_at, created_at, updated_at - ) - values ( - ${input.id}, ${input.tableId}, ${input.cells}, ${input.slug}, ${input.status}, - ${input.publishedAt}, ${createdAt}, ${updatedAt} + insert into data_rows (id, table_id, cells_json, slug, created_at, updated_at) + select ${input.id}, ${input.tableId}, ${projection.sharedCells}, '', ${input.createdAt ?? new Date().toISOString()}, ${input.updatedAt ?? new Date().toISOString()} + where not exists ( + select 1 from data_row_localizations localized join data_rows on data_rows.id = localized.row_id + where data_rows.table_id = ${input.tableId} and data_rows.deleted_at is null + and localized.locale_id = ${projection.locale.id} and localized.slug = ${input.slug} and localized.slug <> '' ) - on conflict do nothing - returning id + on conflict do nothing returning id ` - return rows.length > 0 + if (!rows[0]) return false + await restoreDraft(db, input, projection.locale.id, projection.cells) + return true } -/** - * Plain INSERT with no conflict handling. Assumes the caller has already wiped - * the table (as the `replace` strategy does). Returns void — the caller does - * not need the inserted row shape. - */ -export async function replaceDataRow( - db: DbClient, - input: DataRowImportInput, -): Promise<void> { - const createdAt = input.createdAt ?? new Date().toISOString() - const updatedAt = input.updatedAt ?? new Date().toISOString() +export async function replaceDataRow(db: DbClient, input: DataRowImportInput): Promise<void> { + const projection = await importProjection(db, input) await db` - insert into data_rows ( - id, table_id, cells_json, slug, status, - published_at, created_at, updated_at - ) - values ( - ${input.id}, ${input.tableId}, ${input.cells}, ${input.slug}, ${input.status}, - ${input.publishedAt}, ${createdAt}, ${updatedAt} - ) + insert into data_rows (id, table_id, cells_json, slug, created_at, updated_at) + values (${input.id}, ${input.tableId}, ${projection.sharedCells}, '', ${input.createdAt ?? new Date().toISOString()}, ${input.updatedAt ?? new Date().toISOString()}) ` + await restoreDraft(db, input, projection.locale.id, projection.cells) } diff --git a/server/repositories/data/rows/index.ts b/server/repositories/data/rows/index.ts index 0d452f16a..1699aed9f 100644 --- a/server/repositories/data/rows/index.ts +++ b/server/repositories/data/rows/index.ts @@ -38,6 +38,7 @@ export { createDataRow, saveDataRowDraft, upsertDataRowDraft, + upsertSharedDataRowDraft, updateDataRowDraftCells, softDeleteDataRow, updateDataRowTable, diff --git a/server/repositories/data/rows/mapper.ts b/server/repositories/data/rows/mapper.ts index 910dcf71e..dedbb2143 100644 --- a/server/repositories/data/rows/mapper.ts +++ b/server/repositories/data/rows/mapper.ts @@ -17,8 +17,14 @@ */ import { placeholder, type DbClient } from '../../../db/client' import type { DataRow, DataRowCells, DataRowStatus } from '@core/data/schemas' +import { DataUserReferenceSchema } from '@core/data/schemas' +import { parseValue } from '@core/utils/typeboxHelpers' import { userRefAt, userRefColumns, userRefJoin, type UserJoinColumns } from '../shared' import { isoDate, isoDateOrNull } from '@core/utils/isoDate' +import { normalizeDataTableFields } from '@core/data/fields' +import { materializeLocalizedCells, resolveDataFieldLocalization } from '@core/localization' +import type { ContentLocalization } from '@core/localization-schema' +import { getDefaultLocale, resolveContentLocale, listContentLocalizations } from '../../localization' // Re-exported so the sibling rows/ query modules (filter, read) keep one // local entry point for the dialect-aware placeholder; the single definition @@ -39,11 +45,13 @@ export interface InsertDataRowInput { * tables that have no slug field. */ slug: string + localeId?: string } export interface UpdateDataRowDraftInput { cells: DataRowCells - slug: string + slug?: string + localeId?: string } // --------------------------------------------------------------------------- @@ -54,6 +62,7 @@ interface DataRowRow extends UserJoinColumns { id: string table_id: string cells_json: Record<string, unknown> + fields_json: unknown slug: string status: DataRowStatus seq: number @@ -72,26 +81,48 @@ interface DataRowRow extends UserJoinColumns { // Mapper // --------------------------------------------------------------------------- -function mapRow(row: DataRowRow): DataRow { +function mapRow( + row: DataRowRow, + localeId: string, + localization: ContentLocalization | null, + source: ContentLocalization | null, + publishedBy: DataRow['publishedBy'], + publicPath: string | null, +): DataRow { + const fields = normalizeDataTableFields(row.fields_json) + let slug = localization?.slug ?? source?.slug ?? '' + const cells = materializeLocalizedCells(fields, row.cells_json, source?.cells ?? {}, localization?.cells ?? {}) + const slugField = fields.find((field) => field.id === 'slug') + if (slugField && resolveDataFieldLocalization(slugField) === 'shared') slug = typeof cells.slug === 'string' ? cells.slug : '' + else if (slugField) cells.slug = slug + const status: DataRowStatus = localization?.availability === 'online' && localization.activeVersionId + ? 'published' + : localization?.scheduledPublishAt + ? 'scheduled' + : localization?.activeVersionId ? 'unpublished' : 'draft' return { id: row.id, tableId: row.table_id, - cells: row.cells_json, - slug: row.slug, - status: row.status, + localeId, + sharedCells: row.cells_json, + localization, + cells, + slug, + publicPath, + status, seq: Number(row.seq), authorUserId: row.author_user_id ?? null, createdByUserId: row.created_by_user_id ?? null, updatedByUserId: row.updated_by_user_id ?? null, - publishedByUserId: row.published_by_user_id ?? null, + publishedByUserId: localization?.publishedByUserId ?? null, author: userRefAt(row, 'author'), createdBy: userRefAt(row, 'created_by'), updatedBy: userRefAt(row, 'updated_by'), - publishedBy: userRefAt(row, 'published_by'), + publishedBy, createdAt: isoDate(row.created_at), updatedAt: isoDate(row.updated_at), - publishedAt: isoDateOrNull(row.published_at), - scheduledPublishAt: isoDateOrNull(row.scheduled_publish_at), + publishedAt: localization?.publishedAt ?? null, + scheduledPublishAt: localization?.scheduledPublishAt ?? null, deletedAt: isoDateOrNull(row.deleted_at), } } @@ -114,6 +145,7 @@ export function isOwnedByUser(row: DataRow, ownerUserId: string): boolean { const DATA_ROW_COLUMNS = `data_rows.id, data_rows.table_id, data_rows.cells_json, + data_tables.fields_json, data_rows.slug, data_rows.status, data_rows.seq, @@ -133,6 +165,7 @@ const DATA_ROW_COLUMNS = `data_rows.id, /** The `from data_rows` clause with the four user-ref left joins. */ const DATA_ROW_JOINS = `from data_rows + join data_tables on data_tables.id = data_rows.table_id ${userRefJoin('author', 'data_rows.author_user_id')} ${userRefJoin('created_by', 'data_rows.created_by_user_id')} ${userRefJoin('updated_by', 'data_rows.updated_by_user_id')} @@ -146,6 +179,8 @@ const DATA_ROW_JOINS = `from data_rows * (ANSI joins + CTE, no Postgres-isms). */ interface HydratedDataRowsQuery { + /** Omitted means the configured source locale, on the same localization path. */ + localeId?: string /** * Optional CTE body spliced as `with <cte> select …`. Provide the full * `name as ( … )` clause. Lets callers inline a filtered/paginated id set so @@ -173,6 +208,8 @@ export async function selectHydratedDataRows( db: DbClient, query: HydratedDataRowsQuery, ): Promise<DataRow[]> { + const sourceLocale = await getDefaultLocale(db) + const localeId = query.localeId === undefined ? sourceLocale.id : (await resolveContentLocale(db, query.localeId)).id const sql = ` ${query.cte ? `with ${query.cte}` : ''} select ${DATA_ROW_COLUMNS} @@ -182,5 +219,46 @@ export async function selectHydratedDataRows( ${query.tail ?? ''} ` const { rows } = await db.unsafe<DataRowRow>(sql, query.params) - return rows.map(mapRow) + if (rows.length === 0) return [] + const localizations = await listContentLocalizations(db, { rowIds: rows.map((row) => row.id) }) + const byRow = new Map<string, Map<string, ContentLocalization>>() + for (const localization of localizations) { + let locales = byRow.get(localization.rowId) + if (!locales) { + locales = new Map() + byRow.set(localization.rowId, locales) + } + locales.set(localization.localeId, localization) + } + const publisherIds = [...new Set(localizations.map((localization) => localization.publishedByUserId).filter((id): id is string => id !== null))] + const publishers = new Map<string, NonNullable<DataRow['publishedBy']>>() + const publicPaths = new Map<string, string | null>() + const versionIds = [...new Set(localizations.map((localization) => localization.activeVersionId).filter((id): id is string => id !== null))] + if (versionIds.length > 0) { + const { rows: versions } = await db.unsafe<{ id: string; public_path: string | null }>( + `select id, public_path from data_row_versions where id in (${versionIds.map((_, i) => placeholder(db.dialect, i + 1)).join(', ')})`, versionIds, + ) + for (const version of versions) publicPaths.set(version.id, version.public_path) + } + if (publisherIds.length > 0) { + const { rows: users } = await db.unsafe<{ + id: string; email: string; display_name: string; role_slug: string | null; role_name: string | null + }>(`select users.id, users.email, users.display_name, roles.slug as role_slug, roles.name as role_name + from users left join roles on roles.id = users.role_id + where users.id in (${publisherIds.map((_, i) => placeholder(db.dialect, i + 1)).join(', ')})`, publisherIds) + for (const user of users) { + publishers.set(user.id, parseValue(DataUserReferenceSchema, { + id: user.id, email: user.email, displayName: user.display_name || user.email, + roleSlug: user.role_slug, roleName: user.role_name, + })) + } + } + return rows.map((row) => mapRow( + row, + localeId, + byRow.get(row.id)?.get(localeId) ?? null, + byRow.get(row.id)?.get(sourceLocale.id) ?? null, + publishers.get(byRow.get(row.id)?.get(localeId)?.publishedByUserId ?? '') ?? null, + publicPaths.get(byRow.get(row.id)?.get(localeId)?.activeVersionId ?? '') ?? null, + )) } diff --git a/server/repositories/data/rows/mutations.ts b/server/repositories/data/rows/mutations.ts index e0117dd15..a407c1d80 100644 --- a/server/repositories/data/rows/mutations.ts +++ b/server/repositories/data/rows/mutations.ts @@ -18,16 +18,53 @@ */ import { nanoid } from 'nanoid' import type { DbClient } from '../../../db/client' -import type { DataRow, DataRowStatus, DeletedRowSummary } from '@core/data/schemas' -import { bumpPublishVersionSerialized } from '../../../publish/publishState' +import type { DataRow, DataRowCells, DeletedRowSummary } from '@core/data/schemas' +import { getLocalizablePropertyKeys, splitLocalizedCells, updateTranslationMetadata } from '@core/localization' +import { registry } from '@core/module-engine' +import { bumpPublishVersion, withPublishLock } from '../../../publish/publishState' import { type InsertDataRowInput, type UpdateDataRowDraftInput } from './mapper' import { isoDateOrNull } from '@core/utils/isoDate' -import { getDataRow } from './read' +import { deepEqual } from '@core/utils/deepEqual' +import { getDataRow, listDataRows } from './read' +import { localizableComponentParameterIds } from '@core/visualComponents' +import { visualComponentFromRow } from '@core/data/componentFromRow' import { notifyRowWrite, serializeCollabAwareWrite } from '../../rowWriteEvents' +import { getDataTable } from '../tables' +import { changeRowFieldLocalizationInTx } from '../tableFieldLocalization' +import { getDefaultLocale, resolveContentLocale, listContentLocalizations, LocalizationError, saveContentLocalizationDraft, setContentLocalizationAvailability } from '../../localization' + + +async function localizationPropertyPolicy(db: DbClient) { + const components = new Map((await listDataRows(db, 'components')).flatMap((row) => { + const component = visualComponentFromRow({ ...row, cells: row.sharedCells }) + return component ? [[component.id, component] as const] : [] + })) + return (node: { moduleId: string; props: Record<string, unknown> }, key: string): boolean | ReadonlySet<string> => { + if (node.moduleId === 'base.visual-component-ref' && key === 'propOverrides') { + const component = components.get(String(node.props.componentId ?? '')) + return component ? localizableComponentParameterIds(component) : false + } + const definition = registry.get(node.moduleId) + return definition ? getLocalizablePropertyKeys(definition.schema).has(key) : false + } +} + +async function assertDraftSlugAvailable(db: DbClient, tableId: string, rowId: string, localeId: string, slug: string): Promise<void> { + if (!slug) return + const { rows } = await db<{ row_id: string }>` + select localizations.row_id from data_row_localizations localizations + join data_rows on data_rows.id = localizations.row_id + where data_rows.table_id = ${tableId} and data_rows.deleted_at is null + and localizations.locale_id = ${localeId} and localizations.slug = ${slug} + and localizations.row_id <> ${rowId} + limit 1 + ` + if (rows[0]) throw new LocalizationError('This URL slug is already used in this language', 'slug') +} type UpdateDataRowTableResult = | { ok: true; row: DataRow } - | { ok: false; reason: 'row_not_found' | 'table_not_found' | 'slug_conflict' } + | { ok: false; reason: 'row_not_found' | 'table_not_found' | 'slug_conflict' | 'unsupported_table' } export async function createDataRow( db: DbClient, @@ -38,43 +75,50 @@ export async function createDataRow( ): Promise<DataRow> { if (!opts.collabInternal) { return serializeCollabAwareWrite(async () => { - const created = await createDataRow( - db, + const created = await db.transaction((tx) => createDataRow( + tx, input, actorUserId, pluginActorId, { collabInternal: true }, - ) - notifyRowWrite({ tableId: created.tableId, rowIds: [created.id], kind: 'create' }) + )) + notifyRowWrite({ tableId: created.tableId, rowIds: [created.id], kind: 'create', localeId: created.localeId, sharedChanged: true }) return created }) } - const { rows } = await db<{ id: string }>` + const locale = await resolveContentLocale(db, input.localeId) + const table = await getDataTable(db, input.tableId) + if (!table) throw new Error('Data table not found') + const id = input.id ?? nanoid() + await assertDraftSlugAvailable(db, input.tableId, id, locale.id, input.slug) + const split = splitLocalizedCells(table.fields, {}, {}, input.cells, {}, { + allowStructureChanges: locale.isDefault, + canLocalizeProperty: await localizationPropertyPolicy(db), + }) + await db` insert into data_rows ( id, table_id, cells_json, slug, - status, author_user_id, created_by_user_id, updated_by_user_id, plugin_actor_id ) values ( - ${input.id ?? nanoid()}, + ${id}, ${input.tableId}, - ${input.cells}, - ${input.slug}, - ${'draft'}, + ${split.sharedCells}, + ${''}, ${actorUserId}, ${actorUserId}, ${actorUserId}, ${pluginActorId} ) - returning id ` - const created = await getDataRow(db, rows[0].id) + await saveContentLocalizationDraft(db, id, locale.id, { cells: split.localeCells, slug: input.slug }, actorUserId) + const created = await getDataRow(db, id, locale.id) if (!created) throw new Error('data row was created but could not be re-read') return created } @@ -89,20 +133,21 @@ export async function saveDataRowDraft( ): Promise<DataRow | null> { if (!opts.collabInternal) { return serializeCollabAwareWrite(async () => { - const row = await saveDataRowDraft( - db, + const before = await getDataRow(db, rowId, input.localeId) + const row = await db.transaction((tx) => saveDataRowDraft( + tx, rowId, input, actorUserId, pluginActorId, { collabInternal: true }, - ) - if (row) notifyRowWrite({ tableId: row.tableId, rowIds: [row.id], kind: 'update' }) + )) + if (row) notifyRowWrite({ tableId: row.tableId, rowIds: [row.id], kind: 'update', localeId: row.localeId, sharedChanged: !deepEqual(before?.sharedCells, row.sharedCells) }) return row }) } const updated = await updateDataRowDraftCells(db, rowId, input, actorUserId, pluginActorId) - return updated ? getDataRow(db, rowId) : null + return updated ? getDataRow(db, rowId, input.localeId) : null } /** @@ -118,10 +163,27 @@ export async function updateDataRowDraftCells( actorUserId: string | null = null, pluginActorId: string | null = null, ): Promise<boolean> { + const locale = await resolveContentLocale(db, input.localeId) + const previous = await getDataRow(db, rowId, locale.id) + if (!previous) return false + const table = await getDataTable(db, previous.tableId) + if (!table) return false + const slug = input.slug ?? (typeof input.cells.slug === 'string' ? input.cells.slug : previous.slug) + await assertDraftSlugAvailable(db, previous.tableId, rowId, locale.id, slug) + const inputCells = Object.hasOwn(input.cells, 'slug') ? { ...input.cells, slug } : input.cells + const split = splitLocalizedCells(table.fields, previous.sharedCells, previous.cells, inputCells, previous.localization?.cells ?? {}, { + allowStructureChanges: locale.isDefault, + canLocalizeProperty: await localizationPropertyPolicy(db), + }) + const source = locale.isDefault ? previous : await getDataRow(db, rowId) + const translationMeta = locale.isDefault ? {} : updateTranslationMetadata( + source?.cells ?? {}, previous.localization?.cells ?? {}, split.localeCells, + previous.localization?.translationMeta ?? {}, + ) const { rows } = await db<{ id: string }>` update data_rows - set cells_json = ${input.cells}, - slug = ${input.slug}, + set cells_json = ${split.sharedCells}, + slug = '', updated_by_user_id = ${actorUserId}, plugin_actor_id = ${pluginActorId}, updated_at = current_timestamp @@ -129,6 +191,9 @@ export async function updateDataRowDraftCells( and deleted_at is null returning id ` + if (rows.length > 0) { + await saveContentLocalizationDraft(db, rowId, locale.id, { cells: split.localeCells, slug, translationMeta }, actorUserId) + } return rows.length > 0 } @@ -147,13 +212,12 @@ export async function resurrectDataRow( await db` update data_rows set deleted_at = null, - cells_json = ${input.cells}, - slug = ${input.slug}, updated_by_user_id = ${actorUserId}, updated_at = current_timestamp where id = ${rowId} and deleted_at is not null ` + await updateDataRowDraftCells(db, rowId, input, actorUserId) } /** @@ -171,11 +235,15 @@ export async function upsertDataRowDraft( ): Promise<void> { if (!opts.collabInternal) { return serializeCollabAwareWrite(async () => { - await upsertDataRowDraft(db, input, actorUserId, { collabInternal: true }) - notifyRowWrite({ tableId: input.tableId, rowIds: [input.id], kind: 'update' }) + const before = await getDataRow(db, input.id, input.localeId) + await db.transaction((tx) => upsertDataRowDraft(tx, input, actorUserId, { collabInternal: true })) + const after = await getDataRow(db, input.id, input.localeId) + notifyRowWrite({ tableId: input.tableId, rowIds: [input.id], kind: 'update', localeId: after?.localeId, sharedChanged: !deepEqual(before?.sharedCells, after?.sharedCells) }) }) } - const draft = { cells: input.cells, slug: input.slug } + const draft = { cells: input.cells, slug: input.slug, localeId: input.localeId } + const { rows: identities } = await db<{ table_id: string }>`select table_id from data_rows where id = ${input.id}` + if (identities[0] && identities[0].table_id !== input.tableId) throw new LocalizationError('This content identity belongs to another collection', 'tableId') const updated = await updateDataRowDraftCells(db, input.id, draft, actorUserId) if (updated) return const { rows } = await db<{ id: string }>` @@ -198,15 +266,43 @@ export async function updateDataRowSlug( db: DbClient, rowId: string, slug: string, + localeId?: string, ): Promise<void> { + const locale = await resolveContentLocale(db, localeId) + const row = await getDataRow(db, rowId, locale.id) + if (!row) return + await assertDraftSlugAvailable(db, row.tableId, rowId, locale.id, slug) await db` - update data_rows + update data_row_localizations set slug = ${slug} - where id = ${rowId} - and deleted_at is null + where row_id = ${rowId} and locale_id = ${locale.id} ` } +/** CRDT shared documents persist only logical structure and shared values. */ +export async function upsertSharedDataRowDraft( + db: DbClient, + input: { id: string; tableId: string; cells: DataRowCells }, + actorUserId: string | null = null, + opts: { collabInternal?: boolean } = {}, +): Promise<void> { + if (!opts.collabInternal) { + return serializeCollabAwareWrite(async () => { + await upsertSharedDataRowDraft(db, input, actorUserId, { collabInternal: true }) + notifyRowWrite({ tableId: input.tableId, rowIds: [input.id], kind: 'update' }) + }) + } + const { rows } = await db<{ id: string }>` + insert into data_rows (id, table_id, cells_json, slug, author_user_id, created_by_user_id, updated_by_user_id) + values (${input.id}, ${input.tableId}, ${input.cells}, '', ${actorUserId}, ${actorUserId}, ${actorUserId}) + on conflict (id) do update set cells_json = excluded.cells_json, slug = '', + deleted_at = null, updated_by_user_id = excluded.updated_by_user_id, updated_at = current_timestamp + where data_rows.table_id = excluded.table_id + returning id + ` + if (!rows[0]) throw new LocalizationError('This content identity belongs to another collection', 'tableId') +} + /** * Soft-delete is the one mutation that returns the row directly from * RETURNING rather than re-reading via `getDataRow`: the row now has @@ -223,17 +319,19 @@ export async function softDeleteDataRow( opts: { collabInternal?: boolean } = {}, ): Promise<DeletedRowSummary | null> { if (!opts.collabInternal) { - return serializeCollabAwareWrite(async () => { - const row = await softDeleteDataRow(db, rowId, actorUserId, { collabInternal: true }) + return serializeCollabAwareWrite(() => withPublishLock(async () => { + const row = await db.transaction((tx) => softDeleteDataRow(tx, rowId, actorUserId, { collabInternal: true })) if (row) notifyRowWrite({ tableId: row.tableId, rowIds: [row.id], kind: 'delete' }) + if (row?.status === 'published') bumpPublishVersion() return row - }) + })) } + const localizations = await listContentLocalizations(db, { rowIds: [rowId] }) + const defaultLocale = await getDefaultLocale(db) + const source = localizations.find((localization) => localization.localeId === defaultLocale.id) const { rows } = await db<{ id: string table_id: string - slug: string - status: DataRowStatus deleted_at: string | Date | null }>` update data_rows @@ -242,15 +340,16 @@ export async function softDeleteDataRow( updated_at = current_timestamp where id = ${rowId} and deleted_at is null - returning id, table_id, slug, status, deleted_at + returning id, table_id, deleted_at ` const row = rows[0] if (!row) return null return { id: row.id, tableId: row.table_id, - slug: row.slug, - status: row.status, + slug: source?.slug ?? '', + status: localizations.some((localization) => localization.availability === 'online' && localization.activeVersionId) + ? 'published' : source?.activeVersionId ? 'unpublished' : 'draft', deletedAt: isoDateOrNull(row.deleted_at), } } @@ -266,60 +365,53 @@ export async function updateDataRowTable( rowId: string, tableId: string, actorUserId: string | null = null, - opts: { collabInternal?: boolean } = {}, + opts: { collabInternal?: boolean; localeId?: string } = {}, ): Promise<UpdateDataRowTableResult> { if (!opts.collabInternal) { - const moved = await serializeCollabAwareWrite(async () => { - const before = await getDataRow(db, rowId) - const result = await updateDataRowTable( - db, - rowId, - tableId, - actorUserId, - { collabInternal: true }, - ) - let bumpPublishVersion = false + // Lock order: collab write lane → publication → DB transaction. Publishers + // flush collaboration before entering their publication critical section. + return serializeCollabAwareWrite(() => withPublishLock(async () => { + const before = await getDataRow(db, rowId, opts.localeId) + const hadLiveVariant = (await listContentLocalizations(db, { rowIds: [rowId] })).some((variant) => variant.availability === 'online') + const result = await db.transaction((tx) => updateDataRowTable( + tx, rowId, tableId, actorUserId, { collabInternal: true, localeId: opts.localeId }, + )) if (before && result.ok && before.tableId !== result.row.tableId) { - // A table move changes both collection rosters. Emit the pair while - // still holding the collab-aware write lane so a dirty old row doc - // cannot land between the move and its synchronous invalidation. notifyRowWrite({ tableId: before.tableId, rowIds: [rowId], kind: 'delete' }) notifyRowWrite({ tableId: result.row.tableId, rowIds: [rowId], kind: 'create' }) - bumpPublishVersion = before.status === 'published' + if (hadLiveVariant) bumpPublishVersion() } - return { result, bumpPublishVersion } - }) - // The publish lock may itself wait on persistence work. Never hold the - // non-reentrant collab-aware lane while awaiting that independent lock. - if (moved.bumpPublishVersion) await bumpPublishVersionSerialized() - return moved.result + return result + })) } - const row = await getDataRow(db, rowId) + const row = await getDataRow(db, rowId, opts.localeId) if (!row) return { ok: false, reason: 'row_not_found' } if (row.tableId === tableId) return { ok: true, row } - const { rows: tableRows } = await db<{ id: string }>` - select id from data_tables - where id = ${tableId} - and deleted_at is null - limit 1 - ` - if (!tableRows[0]) return { ok: false, reason: 'table_not_found' } + const targetTable = await getDataTable(db, tableId) + const sourceTable = await getDataTable(db, row.tableId) + if (!targetTable || !sourceTable) return { ok: false, reason: 'table_not_found' } + if (targetTable.kind !== 'data' && targetTable.kind !== 'postType') { + return { ok: false, reason: 'unsupported_table' } + } - // Only check for slug conflicts when the row has a non-empty slug. - if (row.slug) { + const variants = await listContentLocalizations(db, { rowIds: [rowId] }) + for (const variant of variants) { + if (!variant.slug) continue const { rows: conflictRows } = await db<{ id: string }>` - select id from data_rows - where table_id = ${tableId} - and slug = ${row.slug} - and id <> ${rowId} - and deleted_at is null + select data_rows.id from data_rows + join data_row_localizations localized on localized.row_id = data_rows.id + where data_rows.table_id = ${tableId} + and localized.locale_id = ${variant.localeId} and localized.slug = ${variant.slug} + and data_rows.id <> ${rowId} and data_rows.deleted_at is null limit 1 ` if (conflictRows[0]) return { ok: false, reason: 'slug_conflict' } } + await changeRowFieldLocalizationInTx(db, { id: row.id, cells_json: row.sharedCells }, sourceTable.fields, targetTable.fields, actorUserId) + const { rows } = await db<{ id: string }>` update data_rows set table_id = ${tableId}, @@ -330,7 +422,11 @@ export async function updateDataRowTable( returning id ` if (!rows[0]) return { ok: false, reason: 'row_not_found' } - const updated = await getDataRow(db, rows[0].id) + // A different collection changes route and template context for every language. + await db`update data_row_localizations set availability = 'offline', scheduled_publish_at = null, + scheduled_revision_json = null, seq = seq + 1, updated_at = current_timestamp, updated_by_user_id = ${actorUserId} + where row_id = ${rowId}` + const updated = await getDataRow(db, rows[0].id, opts.localeId) if (!updated) return { ok: false, reason: 'row_not_found' } return { ok: true, row: updated } } @@ -338,31 +434,33 @@ export async function updateDataRowTable( /** * Flip a row between `draft` and `unpublished` (the only states reachable * from this endpoint — `published` goes through the dedicated publish flow). - * Always clears publish and schedule metadata since neither remains meaningful - * in the retracted state. + * Retracts one language atomically with publication. Unpublished keeps its + * historical version pointer; both states cancel the language schedule. */ export async function updateDataRowStatus( db: DbClient, rowId: string, status: 'draft' | 'unpublished', actorUserId: string | null = null, + localeId?: string, ): Promise<DataRow | null> { - const { rows } = await db<{ id: string }>` - update data_rows - set status = ${status}, - published_at = null, - published_by_user_id = null, - scheduled_publish_at = null, - updated_by_user_id = ${actorUserId}, - updated_at = current_timestamp - where id = ${rowId} - and deleted_at is null - returning id - ` - if (!rows[0]) return null - // Invalidate the render cache — the route's published state changed. - await bumpPublishVersionSerialized() - return getDataRow(db, rows[0].id) + return withPublishLock(async () => { + const result = await db.transaction(async (tx) => { + const locale = await resolveContentLocale(tx, localeId) + const current = await getDataRow(tx, rowId, locale.id) + if (!current) return null + if (!current.localization) await saveContentLocalizationDraft(tx, rowId, locale.id, { cells: {}, slug: current.slug }, actorUserId) + const updated = await setContentLocalizationAvailability(tx, rowId, locale.id, 'offline', actorUserId) + if (!updated) return null + // Both editor actions mean offline; historical publication remains in the version history. + if (status === 'draft') { + await tx`update data_row_localizations set active_version_id = null, published_at = null, published_by_user_id = null where row_id = ${rowId} and locale_id = ${locale.id}` + } + return getDataRow(tx, rowId, locale.id) + }) + if (result) bumpPublishVersion() + return result + }) } export async function updateDataRowAuthor( @@ -370,7 +468,9 @@ export async function updateDataRowAuthor( rowId: string, authorUserId: string, actorUserId: string | null = null, + localeId?: string, ): Promise<DataRow | null> { + const locale = await resolveContentLocale(db, localeId) const { rows } = await db<{ id: string }>` update data_rows set author_user_id = ${authorUserId}, @@ -380,5 +480,5 @@ export async function updateDataRowAuthor( and deleted_at is null returning id ` - return rows[0] ? getDataRow(db, rows[0].id) : null + return rows[0] ? getDataRow(db, rows[0].id, locale.id) : null } diff --git a/server/repositories/data/rows/read.ts b/server/repositories/data/rows/read.ts index d0a9cb407..a6d994a8f 100644 --- a/server/repositories/data/rows/read.ts +++ b/server/repositories/data/rows/read.ts @@ -13,8 +13,10 @@ import type { DbClient } from '../../../db/client' import type { DataRow } from '@core/data/schemas' import { selectHydratedDataRows, isOwnedByUser, placeholder } from './mapper' +import { getDefaultLocale, resolveContentLocale } from '../../localization' interface ListDataRowsVisibility { + localeId?: string /** * When set, only rows whose effective owner is this user id are returned. * Ownership: author overrides; when no author is assigned the creator is @@ -37,6 +39,7 @@ export async function listDataRows( visibility: ListDataRowsVisibility = {}, ): Promise<DataRow[]> { const dataRows = await selectHydratedDataRows(db, { + localeId: visibility.localeId, where: `data_rows.table_id = ${placeholder(db.dialect, 1)} and data_rows.deleted_at is null`, params: [tableId], tail: 'order by data_rows.updated_at desc, data_rows.created_at desc', @@ -62,11 +65,16 @@ interface DataRowIdSlug { export async function listDataRowIdSlugs( db: DbClient, tableId: string, + localeId?: string, ): Promise<DataRowIdSlug[]> { + const sourceLocale = await getDefaultLocale(db) + const selectedLocale = localeId === undefined ? sourceLocale : await resolveContentLocale(db, localeId) const { rows } = await db<DataRowIdSlug>` - select id, slug from data_rows - where table_id = ${tableId} - and deleted_at is null + select data_rows.id, coalesce(localized.slug, source.slug, '') as slug from data_rows + left join data_row_localizations localized on localized.row_id = data_rows.id and localized.locale_id = ${selectedLocale.id} + left join data_row_localizations source on source.row_id = data_rows.id and source.locale_id = ${sourceLocale.id} + where data_rows.table_id = ${tableId} + and data_rows.deleted_at is null ` return rows } @@ -154,8 +162,10 @@ export async function listChangedDataRowRefsSince( export async function getDataRow( db: DbClient, rowId: string, + localeId?: string, ): Promise<DataRow | null> { const rows = await selectHydratedDataRows(db, { + localeId, where: `data_rows.id = ${placeholder(db.dialect, 1)} and data_rows.deleted_at is null`, params: [rowId], tail: 'limit 1', @@ -172,34 +182,42 @@ export async function getDataRow( export async function getDataRowMany( db: DbClient, rowIds: ReadonlyArray<string>, + localeId?: string, ): Promise<DataRow[]> { - if (rowIds.length === 0) return [] + if (rowIds.length === 0) { + if (localeId !== undefined) await resolveContentLocale(db, localeId) + return [] + } const placeholders = rowIds.map((_, i) => placeholder(db.dialect, i + 1)).join(', ') return selectHydratedDataRows(db, { + localeId, where: `data_rows.id in (${placeholders}) and data_rows.deleted_at is null`, params: [...rowIds], }) } /** - * Read a non-deleted row in a table by its denormalized slug. Plain ANSI - * SQL — the `data_rows_table_slug_active_idx` index covers this query - * (the `where slug <> ''` partial guard does not exclude the lookup here - * because we pass an explicit slug). + * Read a non-deleted row by the selected language's draft slug. A missing + * variant inherits its source slug through the same join as row projection. */ export async function getDataRowBySlug( db: DbClient, tableId: string, slug: string, + localeId?: string, ): Promise<DataRow | null> { + const sourceLocale = await getDefaultLocale(db) + const selectedLocale = localeId === undefined ? sourceLocale : await resolveContentLocale(db, localeId) const { rows } = await db<{ id: string }>` - select id from data_rows - where table_id = ${tableId} - and slug = ${slug} - and deleted_at is null + select data_rows.id from data_rows + left join data_row_localizations localized on localized.row_id = data_rows.id and localized.locale_id = ${selectedLocale.id} + left join data_row_localizations source on source.row_id = data_rows.id and source.locale_id = ${sourceLocale.id} + where data_rows.table_id = ${tableId} + and coalesce(localized.slug, source.slug, '') = ${slug} + and data_rows.deleted_at is null limit 1 ` - return rows[0] ? getDataRow(db, rows[0].id) : null + return rows[0] ? getDataRow(db, rows[0].id, localeId) : null } /** Count non-deleted rows in a table — one indexed COUNT. */ diff --git a/server/repositories/data/rows/schedule.ts b/server/repositories/data/rows/schedule.ts index 084230a45..a13f9dac4 100644 --- a/server/repositories/data/rows/schedule.ts +++ b/server/repositories/data/rows/schedule.ts @@ -1,128 +1,60 @@ -/** - * Scheduled-publish lifecycle for data rows. - * - * scheduleDataRowPublish — mark a row `scheduled` for a future publish - * cancelScheduledPublish — revert a pending scheduled row to a draft - * listDuePublishSchedules — read scheduled rows whose target time has passed - * - * The publish-scheduler tick (`server/publish/publishScheduler.ts`) polls - * `listDuePublishSchedules` and calls the regular publish path on each result. - */ import type { DbClient } from '../../../db/client' import type { DataRow } from '@core/data/schemas' +import { ScheduledLocalizationRevisionSchema, type ScheduledLocalizationRevision } from '@core/localization-schema' +import { parseValue } from '@core/utils/typeboxHelpers' import { isoDate } from '@core/utils/isoDate' import { getDataRow } from './read' +import { cancelContentLocalizationSchedule, saveContentLocalizationDraft, scheduleContentLocalizationPublish } from '../../localization' -/** - * Mark a row as `scheduled` for future publication. The publish-scheduler - * tick (`server/publish/publishScheduler.ts`) polls for rows where - * `status='scheduled' AND scheduled_publish_at <= now()` and calls the - * regular publish path on each. - * - * • `whenIso` MUST be in the future — the caller (HTTP handler) - * validates this before invoking us. We don't re-validate here so a - * direct repo caller (tests, fixtures) can plant rows at any time. - * - * • `published_at` / `published_by_user_id` are cleared because the - * row is no longer in the published state — they get repopulated - * when the tick actually publishes the row. - * - * • `actorUserId` is recorded as the updater. We don't track "who - * scheduled this" separately — the audit log captures intent if - * a scheduling audit is ever needed. - */ -export async function scheduleDataRowPublish( - db: DbClient, - rowId: string, - whenIso: string, - actorUserId: string | null = null, -): Promise<DataRow | null> { - const { rows } = await db<{ id: string }>` - update data_rows - set status = 'scheduled', - scheduled_publish_at = ${whenIso}, - published_at = null, - published_by_user_id = null, - updated_by_user_id = ${actorUserId}, - updated_at = current_timestamp - where id = ${rowId} - and deleted_at is null - returning id - ` - return rows[0] ? getDataRow(db, rows[0].id) : null +/** Scheduling captures resolved content and keeps any current public version active. */ +export async function scheduleDataRowPublish(db: DbClient, rowId: string, whenIso: string, actorUserId: string | null = null, localeId?: string): Promise<DataRow | null> { + const row = await getDataRow(db, rowId, localeId) + if (!row) return null + if (!row.localization) await saveContentLocalizationDraft(db, rowId, row.localeId, { cells: {}, slug: row.slug }, actorUserId) + await scheduleContentLocalizationPublish(db, rowId, row.localeId, whenIso, { cells: row.cells, slug: row.slug }, actorUserId) + return getDataRow(db, rowId, row.localeId) } -/** - * Cancel a pending scheduled publication and revert the row to a draft. - * Used by the "Cancel schedule" UI action and by the publish-scheduler - * tick's failure handler (when a publish attempt fails the row falls - * back to draft per CLAUDE.md "Revert to draft + log error" choice). - */ -export async function cancelScheduledPublish( - db: DbClient, - rowId: string, - actorUserId: string | null = null, -): Promise<DataRow | null> { - const { rows } = await db<{ id: string }>` - update data_rows - set status = 'draft', - scheduled_publish_at = null, - updated_by_user_id = ${actorUserId}, - updated_at = current_timestamp - where id = ${rowId} - and deleted_at is null - and status = 'scheduled' - returning id - ` - return rows[0] ? getDataRow(db, rows[0].id) : null +export async function cancelScheduledPublish(db: DbClient, rowId: string, actorUserId: string | null = null, localeId?: string): Promise<DataRow | null> { + const row = await getDataRow(db, rowId, localeId) + if (!row?.scheduledPublishAt) return null + await cancelContentLocalizationSchedule(db, rowId, row.localeId, actorUserId) + return getDataRow(db, rowId, row.localeId) } -/** - * Lightweight read shape for the publish-scheduler tick — just the - * identity columns it needs to dispatch a publish, no joined user refs - * (the tick doesn't render any UI). One small ANSI-SQL query, the same - * filter the partial index `data_rows_scheduled_publish_idx` covers. - */ interface DueScheduledRow { rowId: string tableId: string + localeId: string scheduledPublishAt: string + scheduledRevision: ScheduledLocalizationRevision } -/** - * List scheduled rows whose target time has passed and that aren't - * already deleted. Returns up to `limit` rows ordered by their target - * time (oldest first — back-pressure favours the rows that have been - * waiting longest). The scheduler tick calls this, then calls - * `publishDataRow(...)` on each result. - * - * NOT atomic — two concurrent leader instances could read the same - * batch. The publish-scheduler tick relies on the host-level leader - * lock (`pg_try_advisory_lock` in PG, single-process for SQLite) to - * ensure only one instance ticks at a time. - */ -export async function listDuePublishSchedules( - db: DbClient, - nowIso: string, - limit: number, -): Promise<DueScheduledRow[]> { +export async function listDuePublishSchedules(db: DbClient, nowIso: string, limit: number): Promise<DueScheduledRow[]> { const { rows } = await db<{ - id: string + row_id: string table_id: string + locale_id: string scheduled_publish_at: string | Date + scheduled_revision_json: unknown }>` - select id, table_id, scheduled_publish_at - from data_rows - where status = 'scheduled' - and deleted_at is null - and scheduled_publish_at is not null - and scheduled_publish_at <= ${nowIso} - order by scheduled_publish_at asc - limit ${limit} + select localizations.row_id, data_rows.table_id, localizations.locale_id, + localizations.scheduled_publish_at, localizations.scheduled_revision_json + from data_row_localizations localizations + join data_rows on data_rows.id = localizations.row_id + join data_tables on data_tables.id = data_rows.table_id + join site_locales on site_locales.id = localizations.locale_id + where localizations.scheduled_publish_at <= ${nowIso} + and data_rows.deleted_at is null and data_tables.deleted_at is null + and site_locales.enabled = ${true} + order by localizations.scheduled_publish_at, localizations.row_id, localizations.locale_id + limit ${Math.max(1, limit)} ` return rows.map((row) => ({ - rowId: row.id, + rowId: row.row_id, tableId: row.table_id, + localeId: row.locale_id, scheduledPublishAt: isoDate(row.scheduled_publish_at), + scheduledRevision: parseValue(ScheduledLocalizationRevisionSchema, row.scheduled_revision_json), })) } diff --git a/server/repositories/data/rows/search.ts b/server/repositories/data/rows/search.ts index 412d6a5a9..ea6b8d53b 100644 --- a/server/repositories/data/rows/search.ts +++ b/server/repositories/data/rows/search.ts @@ -4,9 +4,10 @@ * searchDataRows — search non-deleted rows across all non-deleted data * tables by slug, returning a lightweight summary */ -import type { DbClient } from '../../../db/client' +import { placeholder, type DbClient } from '../../../db/client' import type { DataRowStatus } from '@core/data/schemas' import { isoDate } from '@core/utils/isoDate' +import { getDefaultLocale, resolveContentLocale } from '../../localization' /** * A lightweight row summary returned by spotlight content search. @@ -38,6 +39,8 @@ interface DataRowSearchRow { } interface SearchDataRowsVisibility { + tableSlugs?: readonly string[] + localeId?: string /** * When set, only rows whose effective owner matches this user id are * returned. Ownership follows the same rule used by `listDataRows`: @@ -67,12 +70,24 @@ export async function searchDataRows( limit: number, visibility: SearchDataRowsVisibility = {}, ): Promise<DataRowSearchResult[]> { - const likePattern = `%${query.toLowerCase()}%` - const { rows } = await db<DataRowSearchRow>` + const sourceLocale = await getDefaultLocale(db) + const locale = visibility.localeId === undefined ? sourceLocale : await resolveContentLocale(db, visibility.localeId) + if (visibility.tableSlugs?.length === 0) return [] + const params: unknown[] = [] + const bind = (value: unknown) => { params.push(value); return placeholder(db.dialect, params.length) } + const selectedLocale = bind(locale.id) + const source = bind(sourceLocale.id) + const search = bind(`%${query.toLowerCase()}%`) + const ownerFilter = visibility.ownerUserId ? `and coalesce(data_rows.author_user_id, data_rows.created_by_user_id) = ${bind(visibility.ownerUserId)}` : '' + const tableFilter = visibility.tableSlugs ? `and data_tables.slug in (${visibility.tableSlugs.map(bind).join(', ')})` : '' + const boundedLimit = bind(limit) + const { rows } = await db.unsafe<DataRowSearchRow>(` select data_rows.id, data_rows.table_id, - data_rows.slug, - data_rows.status, + coalesce(localized.slug, source.slug, '') as slug, + case when localized.availability = 'online' and localized.active_version_id is not null then 'published' + when localized.scheduled_publish_at is not null then 'scheduled' + when localized.active_version_id is not null then 'unpublished' else 'draft' end as status, data_rows.author_user_id, data_rows.created_by_user_id, data_rows.updated_at, @@ -81,12 +96,16 @@ export async function searchDataRows( data_tables.system as table_system from data_rows join data_tables on data_tables.id = data_rows.table_id + left join data_row_localizations localized on localized.row_id = data_rows.id and localized.locale_id = ${selectedLocale} + left join data_row_localizations source on source.row_id = data_rows.id and source.locale_id = ${source} where data_rows.deleted_at is null and data_tables.deleted_at is null - and lower(data_rows.slug) like ${likePattern} + and lower(coalesce(localized.slug, source.slug, '')) like ${search} + ${ownerFilter} + ${tableFilter} order by data_rows.updated_at desc - limit ${limit} - ` + limit ${boundedLimit} + `, params) const results = rows.map((r) => ({ row: r, result: { @@ -100,15 +119,5 @@ export async function searchDataRows( tableSystem: Boolean(r.table_system), }, })) - if (visibility.ownerUserId) { - const ownerUserId = visibility.ownerUserId - return results - .filter(({ row }) => { - if (row.author_user_id === ownerUserId) return true - if (row.author_user_id === null) return row.created_by_user_id === ownerUserId - return false - }) - .map(({ result }) => result) - } return results.map(({ result }) => result) } diff --git a/server/repositories/data/tableFieldLocalization.ts b/server/repositories/data/tableFieldLocalization.ts new file mode 100644 index 000000000..7d6bea3ad --- /dev/null +++ b/server/repositories/data/tableFieldLocalization.ts @@ -0,0 +1,57 @@ +import type { DataField, DataRowCells, DataTable } from '@core/data/schemas' +import { materializeLocalizedCells, resolveDataFieldLocalization } from '@core/localization' +import type { DbClient } from '../../db/client' +import { getContentLocalization, getDefaultLocale, saveContentLocalizationDraft } from '../localization' + +/** Preserve the source value when a field switches between shared and translated authoring. */ +export async function changeTableFieldLocalizationInTx( + db: DbClient, + table: DataTable, + nextFields: readonly DataField[], + actorUserId: string | null, +): Promise<void> { + const changed = nextFields.filter((field) => { + const before = table.fields.find((candidate) => candidate.id === field.id) + return before && resolveDataFieldLocalization(before) !== resolveDataFieldLocalization(field) + }) + if (changed.length === 0) return + const { rows } = await db<{ id: string; cells_json: DataRowCells }>` + select id, cells_json from data_rows where table_id = ${table.id} + ` + for (const row of rows) await changeRowFieldLocalizationInTx(db, row, table.fields, nextFields, actorUserId) +} + +/** A collection move uses the same value-preserving transfer as schema editing. */ +export async function changeRowFieldLocalizationInTx( + db: DbClient, + row: { id: string; cells_json: DataRowCells }, + previousFields: readonly DataField[], + nextFields: readonly DataField[], + actorUserId: string | null, +): Promise<void> { + const changed = nextFields.filter((field) => { + const before = previousFields.find((candidate) => candidate.id === field.id) + return before && resolveDataFieldLocalization(before) !== resolveDataFieldLocalization(field) + }) + if (changed.length === 0) return + const source = await getDefaultLocale(db) + const localization = await getContentLocalization(db, row.id, source.id) + const shared = structuredClone(row.cells_json) + const cells = structuredClone(localization?.cells ?? {}) + const projection = materializeLocalizedCells(previousFields, shared, cells, cells) + for (const field of changed) { + const destination = resolveDataFieldLocalization(field) === 'shared' ? shared : cells + if (field.type === 'pageTree' && resolveDataFieldLocalization(field) === 'localized') { + // The shared tree is already the source baseline; locale values are sparse overlays. + delete cells[field.id] + } else if (Object.hasOwn(projection, field.id)) { + Object.defineProperty(destination, field.id, { value: projection[field.id], writable: true, configurable: true, enumerable: true }) + } else { + delete destination[field.id] + } + } + await db`update data_rows set cells_json = ${shared}, updated_at = current_timestamp where id = ${row.id}` + await saveContentLocalizationDraft(db, row.id, source.id, { + cells, slug: localization?.slug ?? '', translationMeta: localization?.translationMeta ?? {}, + }, actorUserId) +} diff --git a/server/repositories/data/tables.ts b/server/repositories/data/tables.ts index 89177dbbc..36a1d5494 100644 --- a/server/repositories/data/tables.ts +++ b/server/repositories/data/tables.ts @@ -1,3 +1,6 @@ +import { changeTableFieldLocalizationInTx } from './tableFieldLocalization' +import { getDefaultLocale, saveTableLocalization, LocalizationError } from '../localization' +import { serializeCollabAwareWrite, notifyRowWrite } from '../rowWriteEvents' /** * CRUD for data tables. * @@ -233,6 +236,16 @@ function withPostTypeBuiltIns(kind: DataTableKind | undefined, fields: DataField return held.length === 0 ? fields : [...held, ...fields] } +/** Public URL slugs belong to each locale; component/layout identifiers remain shared. */ +function enforceRoutingFieldPolicy(kind: DataTableKind | undefined, fields: DataField[]): DataField[] { + if (kind !== 'page' && kind !== 'postType') return fields + return fields.map((field) => { + if (field.id !== 'slug') return field + if (field.localization === 'shared') throw new LocalizationError('Public URL slugs are managed separately for each language', 'fields.slug.localization') + return { ...field, builtIn: true, localization: 'localized' } + }) +} + /** * The same invariant, held across a PATCH. * @@ -282,7 +295,7 @@ export async function createDataTable( db: DbClient, input: CreateDataTableInput, ): Promise<DataTable> { - const fields = withPostTypeBuiltIns(input.kind, normalizeDataTableFields(input.fields ?? [])) + const fields = enforceRoutingFieldPolicy(input.kind, withPostTypeBuiltIns(input.kind, normalizeDataTableFields(input.fields ?? []))) const { rows } = await db<DataTableRow>` insert into data_tables ( id, @@ -323,12 +336,28 @@ export async function updateDataTable( db: DbClient, tableId: string, input: UpdateDataTableInput, +): Promise<DataTable | null> { + return serializeCollabAwareWrite(async () => { + const updated = await db.transaction((tx) => updateDataTableInTx(tx, tableId, input)) + if (updated && input.fields) { + const { rows } = await db<{ id: string }>`select id from data_rows where table_id = ${tableId} and deleted_at is null` + notifyRowWrite({ tableId, rowIds: rows.map((row) => row.id), kind: 'update', sharedChanged: true }) + } + return updated + }) +} + +export async function updateDataTableInTx( + db: DbClient, + tableId: string, + input: UpdateDataTableInput, ): Promise<DataTable | null> { let fields: DataField[] | null = null if (input.fields !== undefined) { const existing = await getDataTable(db, tableId) if (!existing) return null - fields = keepPostTypeBuiltIns(existing, normalizeDataTableFields(input.fields)) + fields = enforceRoutingFieldPolicy(existing.kind, keepPostTypeBuiltIns(existing, normalizeDataTableFields(input.fields))) + await changeTableFieldLocalizationInTx(db, existing, fields, input.updatedByUserId ?? null) } const routeBase = input.routeBase === undefined ? null : normalizeExplicitRouteBase(input.routeBase) const { rows } = await db<DataTableRow>` @@ -348,7 +377,12 @@ export async function updateDataTable( primary_field_id, fields_json, system, created_by_user_id, updated_by_user_id, created_at, updated_at ` - return rows[0] ? mapTable(rows[0]) : null + if (!rows[0]) return null + if (routeBase !== null) { + const source = await getDefaultLocale(db) + await saveTableLocalization(db, tableId, source.id, routeBase) + } + return mapTable(rows[0]) } /** @@ -364,7 +398,7 @@ export async function insertDataTableIfAbsent( ): Promise<boolean> { // Same seeding as createDataTable: `merge-add` / `merge-overwrite` is an // import, and an imported post type has to be routable too. - const fields = withPostTypeBuiltIns(input.kind, normalizeDataTableFields(input.fields ?? [])) + const fields = enforceRoutingFieldPolicy(input.kind, withPostTypeBuiltIns(input.kind, normalizeDataTableFields(input.fields ?? []))) const { rows } = await db<{ id: string }>` insert into data_tables ( id, diff --git a/server/repositories/localization/__tests__/localization.test.ts b/server/repositories/localization/__tests__/localization.test.ts new file mode 100644 index 000000000..efdf2f4ee --- /dev/null +++ b/server/repositories/localization/__tests__/localization.test.ts @@ -0,0 +1,197 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import { createSqliteClient } from '../../../db/sqlite' +import { sqliteMigrations } from '../../../db/migrations-sqlite' +import { runMigrations } from '../../../db/runMigrations' +import type { DbClient } from '../../../db/client' +import { makePage, makeSite } from '../../../../src/__tests__/publisher/helpers' +import { getPublishedPageSnapshotById } from '../../publish' +import { loadPublishedRouteInventory } from '../../../publish/publishedRoutes' +import { parseSiteDocument } from '@core/page-tree' +import { + cancelContentLocalizationSchedule, + createLocale, + getContentLocalization, + getDefaultLocale, + getTableLocalization, + listContentLocalizations, + listDueContentLocalizationSchedules, + listLocales, + LocalizationError, + saveContentLocalizationDraft, + saveTableLocalization, + scheduleContentLocalizationPublish, + setContentLocalizationAvailability, + setContentLocalizationPublishedVersion, + updateLocale, +} from '..' + +const clients: DbClient[] = [] +afterEach(async () => { + await Promise.all(clients.splice(0).map((db) => db.close())) +}) + +async function database(beforeLocalization = false): Promise<DbClient> { + const db = createSqliteClient(':memory:') + clients.push(db) + await runMigrations(db, beforeLocalization ? sqliteMigrations.filter((m) => m.id !== '027_content_localization') : sqliteMigrations) + return db +} + +async function seedRow(db: DbClient, id: string, cells: Record<string, unknown> = { title: 'Draft', slug: id }, tableId = 'posts'): Promise<void> { + await db`insert into data_rows (id, table_id, cells_json, slug) values (${id}, ${tableId}, ${cells}, ${typeof cells.slug === 'string' ? cells.slug : id})` +} + +async function seedVersion(db: DbClient, rowId: string, id: string, localeId: string, versionNumber: number): Promise<void> { + await db` + insert into data_row_versions (id, row_id, locale_id, version_number, cells_json, slug) + values (${id}, ${rowId}, ${localeId}, ${versionNumber}, ${{ title: 'Live snapshot', slug: 'live' }}, ${'live'}) + ` +} + +async function secondLocale(db: DbClient) { + return createLocale(db, { code: 'de-DE', name: 'Deutsch', pathPrefix: 'de', enabled: true, direction: 'ltr' }) +} + +describe('localization migration', () => { + it('preserves the primary draft, live history, snapshot joins and scheduled revisions', async () => { + const db = await database(true) + await db`insert into site (id, name, settings_json) values ('default', 'Example', ${{ site: { settings: { language: 'ar' } } }})` + const tree = { rootNodeId: 'root', nodes: { root: { id: 'root', props: { text: 'Draft body' } } } } + await seedRow(db, 'page', { title: 'Draft title', slug: 'draft-page', body: tree }, 'pages') + await seedRow(db, 'post', { title: 'Scheduled title', body: '# Keep Markdown' }) + await seedRow(db, 'template', { title: 'Shell', body: tree, templateEnabled: true }, 'pages') + const siteInput = makeSite({ layouts: [], pages: [ + { ...makePage({ root: { moduleId: 'base.text', props: { text: 'Published body' } } }), id: 'page', slug: 'index' }, + { ...makePage({ root: { moduleId: 'base.container' } }), id: 'template', template: { enabled: true, target: { kind: 'everywhere' }, priority: 0 } }, + ] }) + const site = { ...siteInput, ...parseSiteDocument(siteInput) } + await db`insert into site_snapshots (id, site_json, content_hash) values ('snapshot', ${site}, 'hash')` + await db` + insert into data_row_versions (id, row_id, version_number, cells_json, slug, site_snapshot_id, runtime_assets_json) + values ('v1', 'page', 1, ${{ title: 'Old title', slug: 'old' }}, 'old', 'snapshot', ${{ scripts: ['old.js'] }}) + ` + await db` + insert into data_row_versions (id, row_id, version_number, cells_json, slug, site_snapshot_id) + values ('v2', 'page', 2, ${{ title: 'Live title', slug: 'index' }}, 'index', 'snapshot') + ` + await db` + insert into data_row_versions (id, row_id, version_number, cells_json, slug, site_snapshot_id) + values ('template-v1', 'template', 1, ${{ title: 'Shell', slug: 'shell' }}, 'shell', 'snapshot') + ` + await db`update data_rows set status = 'published', active_version_id = 'v2', published_at = '2026-01-01T00:00:00.000Z' where id = 'page'` + await db`update data_rows set status = 'scheduled', scheduled_publish_at = '2026-12-01T10:00:00.000Z' where id = 'post'` + + await runMigrations(db, sqliteMigrations) + expect(await getDefaultLocale(db)).toEqual({ id: 'default', code: 'ar', name: 'ar', pathPrefix: '', isDefault: true, enabled: true, direction: 'rtl' }) + const page = await getContentLocalization(db, 'page', 'default') + expect(page?.cells).toEqual({ title: 'Draft title', slug: 'draft-page' }) + expect(page?.availability).toBe('online') + expect(page?.activeVersionId).toBe('v2') + const { rows: originalRows } = await db<{ cells_json: Record<string, unknown> }>`select cells_json from data_rows where id = 'page'` + expect(originalRows[0].cells_json.body).toEqual(tree) + const { rows: versions } = await db<{ id: string; locale_id: string; cells_json: unknown; public_path: string; site_snapshot_id: string; runtime_assets_json: unknown }>`select * from data_row_versions where row_id = 'page' order by version_number` + expect(versions.map((v) => [v.id, v.locale_id, v.public_path])).toEqual([['v1', 'default', '/old'], ['v2', 'default', '/']]) + expect(versions[0].cells_json).toEqual({ title: 'Old title', slug: 'old' }) + expect(versions[0].runtime_assets_json).toEqual({ scripts: ['old.js'] }) + expect(versions[1].site_snapshot_id).toBe('snapshot') + const { rows: snapshots } = await db<{ site_json: unknown }>`select site_json from site_snapshots where id = 'snapshot'` + expect(snapshots[0].site_json).toEqual(site) + const published = await getPublishedPageSnapshotById(db, 'page') + expect(published?.versionId).toBe('v2') + expect(published?.site.pages.find((entry) => entry.id === 'page')?.nodes.root.props.text).toBe('Published body') + expect((await loadPublishedRouteInventory(db)).routes.map((route) => [route.contentId, route.localeId, route.path])).toEqual([['page', 'default', '/']]) + const { rows: templates } = await db<{ public_path: string | null }>`select public_path from data_row_versions where id = 'template-v1'` + expect(templates[0].public_path).toBeNull() + const post = await getContentLocalization(db, 'post', 'default') + expect(post?.availability).toBe('offline') + expect(post?.scheduledRevision).toEqual({ cells: { title: 'Scheduled title', body: '# Keep Markdown' }, slug: 'post' }) + expect(await getTableLocalization(db, 'posts', 'default')).toEqual({ tableId: 'posts', localeId: 'default', routeBase: '/posts' }) + const { rows: tables } = await db<{ fields_json: Array<{ id: string; localization?: string }> }>`select fields_json from data_tables where id = 'pages'` + expect(tables[0].fields_json.find((field) => field.id === 'templateTarget')?.localization).toBe('shared') + expect(tables[0].fields_json.find((field) => field.id === 'title')?.localization).toBeUndefined() + await runMigrations(db, sqliteMigrations) + expect((await listLocales(db)).length).toBe(1) + }) +}) + +describe('locale registry', () => { + it('canonicalizes languages and rejects duplicate or reserved route prefixes', async () => { + const db = await database() + expect((await getDefaultLocale(db)).code).toBe('en') + const locale = await createLocale(db, { code: 'de-de', name: ' Deutsch ', pathPrefix: 'DE', enabled: false, direction: 'ltr' }) + expect(locale).toMatchObject({ code: 'de-DE', name: 'Deutsch', pathPrefix: 'de', enabled: false }) + expect(await listContentLocalizations(db, { localeId: locale.id })).toEqual([]) + await expect(createLocale(db, { ...locale, code: 'de-DE', pathPrefix: 'german' })).rejects.toBeInstanceOf(LocalizationError) + await expect(createLocale(db, { ...locale, code: 'fr', pathPrefix: 'de' })).rejects.toBeInstanceOf(LocalizationError) + await expect(createLocale(db, { ...locale, code: 'fr', pathPrefix: 'admin' })).rejects.toBeInstanceOf(LocalizationError) + await expect(createLocale(db, { ...locale, code: 'not_a_locale', pathPrefix: 'bad' })).rejects.toBeInstanceOf(LocalizationError) + await expect(updateLocale(db, 'default', { pathPrefix: 'en' })).rejects.toBeInstanceOf(LocalizationError) + expect((await updateLocale(db, locale.id, { enabled: true }))?.enabled).toBe(true) + }) +}) + +describe('independent content variants', () => { + it('keeps primary and secondary drafts and live snapshots independent, including scheduling', async () => { + const db = await database() + await seedRow(db, 'post') + const de = await secondLocale(db) + await saveContentLocalizationDraft(db, 'post', 'default', { cells: { title: 'English' }, slug: 'english' }) + await saveContentLocalizationDraft(db, 'post', de.id, { cells: { title: 'Deutsch' }, slug: 'deutsch' }) + expect((await getContentLocalization(db, 'post', de.id))?.availability).toBe('offline') + expect(await setContentLocalizationAvailability(db, 'post', de.id, 'online')).toBeNull() + await seedVersion(db, 'post', 'en-v1', 'default', 1) + await seedVersion(db, 'post', 'de-v2', de.id, 2) + expect(await setContentLocalizationPublishedVersion(db, 'post', de.id, 'en-v1')).toBeNull() + await setContentLocalizationPublishedVersion(db, 'post', de.id, 'de-v2') + expect((await getContentLocalization(db, 'post', 'default'))?.availability).toBe('offline') + const when = '2026-12-01T10:00:00.000Z' + await scheduleContentLocalizationPublish(db, 'post', de.id, when, { cells: { title: 'Frozen scheduled update' }, slug: 'scheduled' }) + await saveContentLocalizationDraft(db, 'post', de.id, { cells: { title: 'Later draft' }, slug: 'later' }) + const scheduled = await getContentLocalization(db, 'post', de.id) + expect(scheduled?.availability).toBe('online') + expect(scheduled?.activeVersionId).toBe('de-v2') + expect(scheduled?.scheduledRevision?.cells).toEqual({ title: 'Frozen scheduled update' }) + expect(await listDueContentLocalizationSchedules(db, '2026-11-01T00:00:00.000Z')).toEqual([]) + expect((await listDueContentLocalizationSchedules(db, when)).map((variant) => variant.localeId)).toEqual([de.id]) + await cancelContentLocalizationSchedule(db, 'post', de.id) + expect((await getContentLocalization(db, 'post', de.id))?.availability).toBe('online') + await setContentLocalizationAvailability(db, 'post', de.id, 'offline') + expect((await getContentLocalization(db, 'post', de.id))?.activeVersionId).toBe('de-v2') + expect((await getContentLocalization(db, 'post', de.id))?.availability).toBe('offline') + const { rows } = await db<{ cells_json: unknown }>`select cells_json from data_row_versions where id = 'de-v2'` + expect(rows[0].cells_json).toEqual({ title: 'Live snapshot', slug: 'live' }) + }) + + it('preserves explicit empty overrides and metadata, filters logical deletion, and supports localized collection routes', async () => { + const db = await database() + await seedRow(db, 'first') + await seedRow(db, 'second') + const de = await secondLocale(db) + const metadata = { title: { sourceFingerprint: 'source-v1', reviewState: 'reviewed' as const } } + await saveContentLocalizationDraft(db, 'first', de.id, { cells: { title: '', featuredMedia: null }, slug: 'first', translationMeta: metadata }) + await saveContentLocalizationDraft(db, 'first', de.id, { cells: { title: '' }, slug: 'first' }) + await saveContentLocalizationDraft(db, 'second', de.id, { cells: {}, slug: 'second' }) + expect((await getContentLocalization(db, 'first', de.id))?.translationMeta).toEqual(metadata) + expect((await getContentLocalization(db, 'first', de.id))?.cells).toEqual({ title: '' }) + expect((await listContentLocalizations(db, { rowIds: ['first'], localeId: de.id, tableId: 'posts' })).length).toBe(1) + expect(await saveContentLocalizationDraft(db, 'missing', de.id, { cells: {}, slug: 'missing' })).toBeNull() + expect(await saveContentLocalizationDraft(db, 'first', 'missing', { cells: {}, slug: 'missing' })).toBeNull() + await db`update data_rows set deleted_at = current_timestamp where id = 'first'` + expect(await getContentLocalization(db, 'first', de.id)).toBeNull() + expect((await listContentLocalizations(db, { tableId: 'posts', localeId: de.id })).map((variant) => variant.rowId)).toEqual(['second']) + expect(await getTableLocalization(db, 'posts', de.id)).toBeNull() + expect(await saveTableLocalization(db, 'posts', de.id, '/artikel/')).toEqual({ tableId: 'posts', localeId: de.id, routeBase: '/artikel' }) + expect((await getTableLocalization(db, 'posts', 'default'))?.routeBase).toBe('/posts') + }) + + it('allows deleting a logical row with published history without dangling variant references', async () => { + const db = await database() + await seedRow(db, 'post') + await saveContentLocalizationDraft(db, 'post', 'default', { cells: {}, slug: 'post' }) + await seedVersion(db, 'post', 'v1', 'default', 1) + await setContentLocalizationPublishedVersion(db, 'post', 'default', 'v1') + await db`delete from data_rows where id = 'post'` + const { rows } = await db`pragma foreign_key_check` + expect(rows).toEqual([]) + }) +}) diff --git a/server/repositories/localization/content.ts b/server/repositories/localization/content.ts new file mode 100644 index 000000000..f852ad202 --- /dev/null +++ b/server/repositories/localization/content.ts @@ -0,0 +1,227 @@ +import { + ContentLocalizationSchema, + type ContentLocalization, + type ContentLocalizationDraftInput, + type LocalizationAvailability, + type ScheduledLocalizationRevision, +} from '@core/localization-schema' +import { isoDate, isoDateOrNull } from '@core/utils/isoDate' +import { parseValue } from '@core/utils/typeboxHelpers' +import { placeholder, type DbClient } from '../../db/client' + +interface LocalizationRow { + row_id: string + locale_id: string + cells_json: unknown + slug: string + availability: string + active_version_id: string | null + scheduled_publish_at: string | Date | null + scheduled_revision_json: unknown + translation_meta_json: unknown + seq: number | string | bigint + created_by_user_id: string | null + updated_by_user_id: string | null + published_by_user_id: string | null + created_at: string | Date + updated_at: string | Date + published_at: string | Date | null +} + +function mapLocalization(row: LocalizationRow): ContentLocalization { + return parseValue(ContentLocalizationSchema, { + rowId: row.row_id, + localeId: row.locale_id, + cells: row.cells_json, + slug: row.slug, + availability: row.availability, + activeVersionId: row.active_version_id, + scheduledPublishAt: isoDateOrNull(row.scheduled_publish_at), + scheduledRevision: row.scheduled_revision_json, + translationMeta: row.translation_meta_json, + seq: Number(row.seq), + createdByUserId: row.created_by_user_id, + updatedByUserId: row.updated_by_user_id, + publishedByUserId: row.published_by_user_id, + createdAt: isoDate(row.created_at), + updatedAt: isoDate(row.updated_at), + publishedAt: isoDateOrNull(row.published_at), + }) +} + +export interface ListContentLocalizationsOptions { + rowIds?: readonly string[] + tableId?: string + localeId?: string +} + +export async function listContentLocalizations( + db: DbClient, + options: ListContentLocalizationsOptions = {}, +): Promise<ContentLocalization[]> { + if (options.rowIds?.length === 0) return [] + const params: unknown[] = [] + const conditions = ['data_rows.deleted_at is null', 'data_tables.deleted_at is null'] + function bind(value: unknown): string { + params.push(value) + return placeholder(db.dialect, params.length) + } + if (options.rowIds) { + conditions.push(`localizations.row_id in (${options.rowIds.map(bind).join(', ')})`) + } + if (options.tableId) conditions.push(`data_rows.table_id = ${bind(options.tableId)}`) + if (options.localeId !== undefined) conditions.push(`localizations.locale_id = ${bind(options.localeId)}`) + const { rows } = await db.unsafe<LocalizationRow>(` + select localizations.* + from data_row_localizations localizations + join data_rows on data_rows.id = localizations.row_id + join data_tables on data_tables.id = data_rows.table_id + where ${conditions.join(' and ')} + order by localizations.row_id, localizations.locale_id + `, params) + return rows.map(mapLocalization) +} + +export async function getContentLocalization( + db: DbClient, + rowId: string, + localeId: string, +): Promise<ContentLocalization | null> { + return (await listContentLocalizations(db, { rowIds: [rowId], localeId }))[0] ?? null +} + +/** Writes only the editable variant; an existing public snapshot stays active. */ +export async function saveContentLocalizationDraft( + db: DbClient, + rowId: string, + localeId: string, + input: ContentLocalizationDraftInput, + actorUserId: string | null = null, +): Promise<ContentLocalization | null> { + // The dedicated slug is the routing value. Keep an existing editable slug + // override consistent with it without inventing an absent sparse override. + const cells = Object.hasOwn(input.cells, 'slug') ? { ...input.cells, slug: input.slug } : input.cells + const { rows } = await db<{ row_id: string }>` + insert into data_row_localizations + (row_id, locale_id, cells_json, slug, translation_meta_json, seq, created_by_user_id, updated_by_user_id) + select data_rows.id, ${localeId}, ${cells}, ${input.slug}, ${input.translationMeta ?? {}}, 1, + ${actorUserId}, ${actorUserId} + from data_rows + join data_tables on data_tables.id = data_rows.table_id + where data_rows.id = ${rowId} and data_rows.deleted_at is null and data_tables.deleted_at is null + and exists (select 1 from site_locales where id = ${localeId}) + on conflict (row_id, locale_id) do update + set cells_json = excluded.cells_json, + slug = excluded.slug, + translation_meta_json = case when ${input.translationMeta != null} + then excluded.translation_meta_json else data_row_localizations.translation_meta_json end, + seq = data_row_localizations.seq + 1, + updated_by_user_id = excluded.updated_by_user_id, + updated_at = current_timestamp + returning row_id + ` + return rows[0] ? getContentLocalization(db, rowId, localeId) : null +} + +/** Retracting a variant also cancels its scheduled publication; history is retained. */ +export async function setContentLocalizationAvailability( + db: DbClient, + rowId: string, + localeId: string, + availability: LocalizationAvailability, + actorUserId: string | null = null, +): Promise<ContentLocalization | null> { + const { rows } = await db<{ row_id: string }>` + update data_row_localizations + set availability = ${availability}, + scheduled_publish_at = case when ${availability} = 'offline' then null else scheduled_publish_at end, + scheduled_revision_json = case when ${availability} = 'offline' then null else scheduled_revision_json end, + seq = seq + 1, updated_by_user_id = ${actorUserId}, updated_at = current_timestamp + where row_id = ${rowId} and locale_id = ${localeId} + and exists (select 1 from data_rows where id = ${rowId} and deleted_at is null) + and (${availability} = 'offline' or exists ( + select 1 from data_row_versions + where id = data_row_localizations.active_version_id + and row_id = ${rowId} and locale_id = ${localeId} + )) + returning row_id + ` + return rows[0] ? getContentLocalization(db, rowId, localeId) : null +} + +/** Publish orchestration owns the snapshot insert, artefacts, and cache invalidation. */ +export async function setContentLocalizationPublishedVersion( + db: DbClient, + rowId: string, + localeId: string, + versionId: string, + publisherUserId: string | null = null, +): Promise<ContentLocalization | null> { + const { rows } = await db<{ row_id: string }>` + update data_row_localizations + set availability = 'online', active_version_id = ${versionId}, + published_by_user_id = ${publisherUserId}, published_at = current_timestamp, + scheduled_publish_at = null, scheduled_revision_json = null, + seq = seq + 1, updated_by_user_id = ${publisherUserId}, updated_at = current_timestamp + where row_id = ${rowId} and locale_id = ${localeId} + and exists (select 1 from data_rows where id = ${rowId} and deleted_at is null) + and exists (select 1 from data_row_versions + where id = ${versionId} and row_id = ${rowId} and locale_id = ${localeId}) + returning row_id + ` + return rows[0] ? getContentLocalization(db, rowId, localeId) : null +} + +/** Scheduling freezes resolved cells while leaving the current live version untouched. */ +export async function scheduleContentLocalizationPublish( + db: DbClient, + rowId: string, + localeId: string, + whenIso: string, + revision: ScheduledLocalizationRevision, + actorUserId: string | null = null, +): Promise<ContentLocalization | null> { + const { rows } = await db<{ row_id: string }>` + update data_row_localizations + set scheduled_publish_at = ${whenIso}, scheduled_revision_json = ${revision}, + seq = seq + 1, updated_by_user_id = ${actorUserId}, updated_at = current_timestamp + where row_id = ${rowId} and locale_id = ${localeId} + and exists (select 1 from data_rows where id = ${rowId} and deleted_at is null) + returning row_id + ` + return rows[0] ? getContentLocalization(db, rowId, localeId) : null +} + +export async function cancelContentLocalizationSchedule( + db: DbClient, + rowId: string, + localeId: string, + actorUserId: string | null = null, +): Promise<ContentLocalization | null> { + const { rows } = await db<{ row_id: string }>` + update data_row_localizations + set scheduled_publish_at = null, scheduled_revision_json = null, + seq = seq + 1, updated_by_user_id = ${actorUserId}, updated_at = current_timestamp + where row_id = ${rowId} and locale_id = ${localeId} + and exists (select 1 from data_rows where id = ${rowId} and deleted_at is null) + returning row_id + ` + return rows[0] ? getContentLocalization(db, rowId, localeId) : null +} + +export async function listDueContentLocalizationSchedules( + db: DbClient, + nowIso: string, +): Promise<ContentLocalization[]> { + const { rows } = await db<LocalizationRow>` + select localizations.* from data_row_localizations localizations + join data_rows on data_rows.id = localizations.row_id + join data_tables on data_tables.id = data_rows.table_id + join site_locales on site_locales.id = localizations.locale_id + where localizations.scheduled_publish_at <= ${nowIso} + and data_rows.deleted_at is null and data_tables.deleted_at is null + and site_locales.enabled = ${true} + order by localizations.scheduled_publish_at, localizations.row_id, localizations.locale_id + ` + return rows.map(mapLocalization) +} diff --git a/server/repositories/localization/errors.ts b/server/repositories/localization/errors.ts new file mode 100644 index 000000000..9d053f39a --- /dev/null +++ b/server/repositories/localization/errors.ts @@ -0,0 +1,9 @@ +export class LocalizationError extends Error { + readonly path: string + + constructor(message: string, path: string, options?: ErrorOptions) { + super(message, options) + this.name = 'LocalizationError' + this.path = path + } +} diff --git a/server/repositories/localization/index.ts b/server/repositories/localization/index.ts new file mode 100644 index 000000000..269141070 --- /dev/null +++ b/server/repositories/localization/index.ts @@ -0,0 +1,16 @@ +export { LocalizationError } from './errors' +export { listLocales, getLocale, getDefaultLocale, resolveContentLocale, createLocale, updateLocale } from './locales' +export { + listContentLocalizations, + getContentLocalization, + saveContentLocalizationDraft, + setContentLocalizationAvailability, + setContentLocalizationPublishedVersion, + scheduleContentLocalizationPublish, + cancelContentLocalizationSchedule, + listDueContentLocalizationSchedules, +} from './content' +export type { ListContentLocalizationsOptions } from './content' +export { listTableLocalizations, getTableLocalization, saveTableLocalization } from './tables' + +export { importLocale } from './locales' diff --git a/server/repositories/localization/locales.ts b/server/repositories/localization/locales.ts new file mode 100644 index 000000000..5d718aae5 --- /dev/null +++ b/server/repositories/localization/locales.ts @@ -0,0 +1,140 @@ +import { nanoid } from 'nanoid' +import { LocaleSchema, type Locale, type LocaleInput, type LocaleUpdateInput } from '@core/localization-schema' +import { LocalizedRouteError, normalizeLocalePathPrefix } from '@core/localization-routing' +import { parseValue } from '@core/utils/typeboxHelpers' +import type { DbClient } from '../../db/client' +import { LocalizationError } from './errors' + +interface LocaleRow { + id: string + code: string + name: string + path_prefix: string + is_default: boolean | number + enabled: boolean | number + direction: string +} + +function mapLocale(row: LocaleRow): Locale { + return parseValue(LocaleSchema, { + id: row.id, + code: row.code, + name: row.name, + pathPrefix: row.path_prefix, + isDefault: Boolean(row.is_default), + enabled: Boolean(row.enabled), + direction: row.direction, + }) +} + +export async function listLocales(db: DbClient): Promise<Locale[]> { + const { rows } = await db<LocaleRow>` + select id, code, name, path_prefix, is_default, enabled, direction + from site_locales order by is_default desc, created_at asc, id asc + ` + return rows.map(mapLocale) +} + +export async function getLocale(db: DbClient, localeId: string): Promise<Locale | null> { + const { rows } = await db<LocaleRow>` + select id, code, name, path_prefix, is_default, enabled, direction + from site_locales where id = ${localeId} + ` + return rows[0] ? mapLocale(rows[0]) : null +} + +export async function getDefaultLocale(db: DbClient): Promise<Locale> { + const { rows } = await db<LocaleRow>` + select id, code, name, path_prefix, is_default, enabled, direction + from site_locales where is_default = ${true} + ` + if (!rows[0]) throw new Error('Default locale is missing') + return mapLocale(rows[0]) +} + +/** An omitted selection means source; an explicit unknown or empty ID is invalid. */ +export async function resolveContentLocale(db: DbClient, localeId?: string): Promise<Locale> { + const locale = localeId === undefined ? await getDefaultLocale(db) : await getLocale(db, localeId) + if (!locale) throw new LocalizationError('Unknown content language', 'localeId') + return locale +} + +function normalizeLocale(input: LocaleInput, isDefault: boolean): LocaleInput { + let code: string + try { + const codes = Intl.getCanonicalLocales(input.code.trim()) + if (!codes[0]) throw new RangeError('Empty locale') + code = codes[0] + } catch (err) { + // Intl rejects malformed BCP 47 tags; expose a field-local validation error. + throw new LocalizationError('Use a valid language code, such as de or en-GB', 'code', { cause: err }) + } + const name = input.name.trim() + if (!name) throw new LocalizationError('A language name is required', 'name') + let pathPrefix: string + try { + pathPrefix = normalizeLocalePathPrefix(input.pathPrefix.trim().toLowerCase()) + } catch (err) { + if (err instanceof LocalizedRouteError) throw new LocalizationError(err.message, 'pathPrefix', { cause: err }) + throw err + } + if (isDefault ? pathPrefix !== '' : pathPrefix === '') { + throw new LocalizationError(isDefault + ? 'The default language uses the root URL' + : 'A language URL prefix is required', 'pathPrefix') + } + return { ...input, code, name, pathPrefix } +} + +async function assertUniqueLocale(db: DbClient, input: LocaleInput, excludeId: string): Promise<void> { + const { rows } = await db<{ code: string; path_prefix: string }>` + select code, path_prefix from site_locales + where id <> ${excludeId} and (lower(code) = ${input.code.toLowerCase()} or path_prefix = ${input.pathPrefix}) + ` + if (!rows[0]) return + if (rows.some((row) => row.code.toLowerCase() === input.code.toLowerCase())) { + throw new LocalizationError('This language already exists', 'code') + } + throw new LocalizationError('This URL prefix is already used by another language', 'pathPrefix') +} + +export async function createLocale(db: DbClient, input: LocaleInput): Promise<Locale> { + const normalized = normalizeLocale(input, false) + const id = nanoid() + await assertUniqueLocale(db, normalized, id) + await db` + insert into site_locales (id, code, name, path_prefix, is_default, enabled, direction) + values (${id}, ${normalized.code}, ${normalized.name}, ${normalized.pathPrefix}, ${false}, ${normalized.enabled}, ${normalized.direction}) + ` + return { ...normalized, id, isDefault: false } +} + +export async function updateLocale(db: DbClient, localeId: string, input: LocaleUpdateInput): Promise<Locale | null> { + const current = await getLocale(db, localeId) + if (!current) return null + const normalized = normalizeLocale({ ...current, ...input }, current.isDefault) + await assertUniqueLocale(db, normalized, localeId) + await db` + update site_locales + set code = ${normalized.code}, name = ${normalized.name}, path_prefix = ${normalized.pathPrefix}, + enabled = ${normalized.enabled}, direction = ${normalized.direction}, updated_at = current_timestamp + where id = ${localeId} + ` + return { ...normalized, id: localeId, isDefault: current.isDefault } +} + +/** Bundle restore preserves identities; a merge must not relabel existing rows. */ +export async function importLocale(db: DbClient, input: Locale, strategy: 'replace' | 'merge-add' | 'merge-overwrite'): Promise<void> { + const normalized = normalizeLocale(input, input.isDefault) + const current = await getLocale(db, input.id) + if (current && strategy !== 'replace' && (current.code !== normalized.code || current.isDefault !== input.isDefault)) { + throw new LocalizationError('The imported language identity refers to a different local language; use replace to restore this bundle', 'locales') + } + if (current && strategy === 'merge-add') return + await assertUniqueLocale(db, normalized, input.id) + await db`insert into site_locales (id, code, name, path_prefix, is_default, enabled, direction) + values (${input.id}, ${normalized.code}, ${normalized.name}, ${normalized.pathPrefix}, ${input.isDefault}, ${normalized.enabled}, ${normalized.direction}) + on conflict (id) do update set code = excluded.code, name = excluded.name, + path_prefix = excluded.path_prefix, enabled = excluded.enabled, direction = excluded.direction, + updated_at = current_timestamp` +} diff --git a/server/repositories/localization/tables.ts b/server/repositories/localization/tables.ts new file mode 100644 index 000000000..d0c009d19 --- /dev/null +++ b/server/repositories/localization/tables.ts @@ -0,0 +1,52 @@ +import { TableLocalizationSchema, type TableLocalization } from '@core/localization-schema' +import { LocalizedRouteError, normalizePublishedPath } from '@core/localization-routing' +import { parseValue } from '@core/utils/typeboxHelpers' +import type { DbClient } from '../../db/client' +import { LocalizationError } from './errors' + +interface TableLocalizationRow { + table_id: string + locale_id: string + route_base: string +} + +function mapTableLocalization(row: TableLocalizationRow): TableLocalization { + return parseValue(TableLocalizationSchema, { tableId: row.table_id, localeId: row.locale_id, routeBase: row.route_base }) +} + +export async function listTableLocalizations(db: DbClient, tableId?: string): Promise<TableLocalization[]> { + const { rows } = tableId === undefined + ? await db<TableLocalizationRow>`select table_id, locale_id, route_base from data_table_localizations order by table_id, locale_id` + : await db<TableLocalizationRow>`select table_id, locale_id, route_base from data_table_localizations where table_id = ${tableId} order by locale_id` + return rows.map(mapTableLocalization) +} + +/** Returns the stored override; absent secondary routes inherit the default at resolution time. */ +export async function getTableLocalization(db: DbClient, tableId: string, localeId: string): Promise<TableLocalization | null> { + const { rows } = await db<TableLocalizationRow>` + select table_id, locale_id, route_base from data_table_localizations + where table_id = ${tableId} and locale_id = ${localeId} + ` + return rows[0] ? mapTableLocalization(rows[0]) : null +} + +export async function saveTableLocalization(db: DbClient, tableId: string, localeId: string, routeBase: string): Promise<TableLocalization | null> { + const raw = routeBase.trim() + if (raw !== '' && !raw.startsWith('/')) throw new LocalizationError('Use an absolute route path', 'routeBase') + let value: string + try { + value = raw === '' ? '' : normalizePublishedPath(raw) + } catch (err) { + if (err instanceof LocalizedRouteError) throw new LocalizationError(err.message, 'routeBase', { cause: err }) + throw err + } + const { rows } = await db<TableLocalizationRow>` + insert into data_table_localizations (table_id, locale_id, route_base) + select id, ${localeId}, ${value} from data_tables + where id = ${tableId} and deleted_at is null + and exists (select 1 from site_locales where id = ${localeId}) + on conflict (table_id, locale_id) do update set route_base = excluded.route_base + returning table_id, locale_id, route_base + ` + return rows[0] ? mapTableLocalization(rows[0]) : null +} diff --git a/server/repositories/localizationRoutes.ts b/server/repositories/localizationRoutes.ts new file mode 100644 index 000000000..68fed9cd6 --- /dev/null +++ b/server/repositories/localizationRoutes.ts @@ -0,0 +1,101 @@ +import { PublishedRouteCandidateSchema, readSnapshotLanguage, type PublishedRouteCandidate } from '@core/localization-routing' +import { isoDate } from '@core/utils/isoDate' +import { parseValue } from '@core/utils/typeboxHelpers' +import { placeholder, type DbClient } from '../db/client' +import { jsonField } from '../db/jsonExtract' +import { LocaleSchema } from '@core/localization-schema' +import { Type } from '@core/utils/typeboxHelpers' +import { safeParseJson } from '@core/utils/jsonValidate' + +interface PublishedRouteRow { + row_id: string + locale_id: string + version_id: string + site_snapshot_id: string | null + table_id: string + table_slug: string + table_kind: string + public_path: string | null + published_at: string | Date + slug: string + title: string | null + locales_json: unknown + settings_json: unknown +} + +const SnapshotSettingsSchema = Type.Object({ language: Type.Optional(Type.String()) }) + +/** + * Public routing reads the selected locale version, never draft slugs/cells or + * the row-wide status. The three-column version join prevents a localization + * from exposing a version belonging to another row or language. + * + * A page version without a public path is a published template dependency. + * Collection items without a route remain usable content, not guessed URLs. + */ +export async function listPublishedRouteCandidates(db: DbClient): Promise<PublishedRouteCandidate[]> { + const titleExpr = jsonField('cells_json', 'title', db.dialect) + const localesExpr = jsonField('site_json', 'locales', db.dialect) + const settingsExpr = jsonField('site_json', 'settings', db.dialect) + const { rows } = await db.unsafe<PublishedRouteRow>(` + select localizations.row_id, + localizations.locale_id, + versions.id as version_id, + versions.site_snapshot_id, + tables.id as table_id, + tables.slug as table_slug, + tables.kind as table_kind, + versions.public_path, + versions.published_at, versions.slug, + (select ${titleExpr.sql} from data_row_versions where id = versions.id) as title, + (select ${localesExpr.sql} from site_snapshots where id = versions.site_snapshot_id) as locales_json, + (select ${settingsExpr.sql} from site_snapshots where id = versions.site_snapshot_id) as settings_json + from data_row_localizations localizations + join data_row_versions versions + on versions.id = localizations.active_version_id + and versions.row_id = localizations.row_id + and versions.locale_id = localizations.locale_id + join data_rows rows on rows.id = localizations.row_id + join data_tables tables on tables.id = rows.table_id + join site_locales locales on locales.id = localizations.locale_id + where localizations.availability = 'online' + and locales.enabled = ${placeholder(db.dialect, 1)} + and rows.deleted_at is null + and tables.deleted_at is null + and tables.kind in ('page', 'postType') + and (tables.kind = 'page' or versions.public_path is not null) + order by rows.created_at asc, rows.id asc, locales.created_at asc, locales.id asc + `, [true]) + return rows.map((row) => { + let rawLocales = row.locales_json + if (typeof rawLocales === 'string') { + const parsed = safeParseJson(rawLocales, Type.Array(LocaleSchema)) + if (!parsed.ok) throw parsed.error + rawLocales = parsed.value + } + const frozenLocales = rawLocales == null ? [] : parseValue(Type.Array(LocaleSchema), rawLocales) + let rawSettings = row.settings_json + if (typeof rawSettings === 'string') { + const parsed = safeParseJson(rawSettings, SnapshotSettingsSchema) + if (!parsed.ok) throw parsed.error + rawSettings = parsed.value + } + const settings = parseValue(SnapshotSettingsSchema, rawSettings ?? {}) + const language = readSnapshotLanguage(row.locale_id, frozenLocales, settings.language) + return parseValue(PublishedRouteCandidateSchema, { + contentId: row.row_id, + localeId: row.locale_id, + publishedVersionId: row.version_id, + ...(row.site_snapshot_id ? { siteSnapshotId: row.site_snapshot_id } : {}), + tableId: row.table_id, + tableSlug: row.table_slug, + kind: row.table_kind === 'page' ? (row.public_path === null ? 'template' : 'page') : 'row', + ...(row.public_path !== null ? { path: row.public_path } : {}), + availability: 'online', + publishedAt: isoDate(row.published_at), + slug: row.slug, + ...(row.title !== null ? { title: row.title } : {}), + languageCode: language.code, direction: language.direction, + }) + }) +} diff --git a/server/repositories/publish.ts b/server/repositories/publish.ts index b3de3e6d1..48167f990 100644 --- a/server/repositories/publish.ts +++ b/server/repositories/publish.ts @@ -23,17 +23,28 @@ */ import { createHash } from 'node:crypto' import type { DataRow } from '@core/data/schemas' -import type { SiteDocument } from '@core/page-tree' -import type { PublishedPageRuntimeAssets } from '@core/site-runtime' +import { PageSchema, SiteShellSchema, type SiteDocument } from '@core/page-tree' +import { VisualComponentSchema } from '@core/visual-components-schema' +import { SavedLayoutSchema } from '@core/layouts-schema' +import { LocaleSchema } from '@core/localization-schema' +import { Type, parseValue } from '@core/utils/typeboxHelpers' +import { nanoid } from 'nanoid' +import { isTemplatePage } from '@core/templates' +import { readPreviousPublishedRoute, savePublishedRedirect } from './data/publish' +import { PublishedPageRuntimeAssetsSchema, type PublishedPageRuntimeAssets } from '@core/site-runtime' import type { PublishedRuntimePackageImportmap } from '@core/publisher' -import type { DbClient } from '../db/client' +import { placeholder, type DbClient } from '../db/client' import type { BuiltRuntimeAssetFile } from '../publish/runtime/bundleScripts' import { getDraftSite } from './site' -import { listDataRows } from './data' +import { getDataTable, listDataRows } from './data' import { pageFromRow } from '../../src/core/data/pageFromRow' import { visualComponentFromRow } from '../../src/core/data/componentFromRow' import { validateVisualComponents } from '../../src/core/persistence/validate' import { savePublishedRuntimeAssets } from './runtimeAsset' +import { getDefaultLocale, getLocale, listLocales, listContentLocalizations, setContentLocalizationPublishedVersion } from './localization' +import type { SiteLocalizationContext } from '@core/localization-schema' +import { resolveDataFieldLocalization } from '@core/localization' +import { savedLayoutFromRow } from '@core/data/layoutFromRow' // --------------------------------------------------------------------------- // Types @@ -44,6 +55,10 @@ export interface PublishedPageSnapshot { /** id of the `data_rows` row for this page (was `pageId` in the old schema). */ pageRowId: string site: SiteDocument + localeId?: string + versionId?: string + siteSnapshotId?: string + publicPath?: string | null runtimeAssets?: PublishedPageRuntimeAssets /** * Pre-serialised importmap mapping bare specifiers like `three` to URLs @@ -75,6 +90,10 @@ interface SnapshotQueryRow { runtime_assets_json: PublishedPageRuntimeAssets | null importmap_body: string | null importmap_sha256: string | null + locale_id: string + version_id: string + site_snapshot_id: string + public_path: string | null } /** One page's version write within `persistSitePublish`. */ @@ -86,6 +105,11 @@ export interface PublishedPageVersionWrite { versionNumber: number runtimeAssets: PublishedPageRuntimeAssets | null runtimeFiles: BuiltRuntimeAssetFile[] + localeId: string + publicPath: string | null + cells: DataRow['cells'] + /** Dependency snapshots do not change a template's selected live release. */ + activate: boolean } export interface PersistSitePublishInput { @@ -94,7 +118,29 @@ export interface PersistSitePublishInput { site: SiteDocument serializedImportmap: { body: string; sha256: string } | null pages: PublishedPageVersionWrite[] - publishedByUserId: string + publishedByUserId: string | null +} + +const PublishedSiteDocumentSchema = Type.Intersect([SiteShellSchema, Type.Object({ + pages: Type.Array(PageSchema), + visualComponents: Type.Array(VisualComponentSchema), + layouts: Type.Array(SavedLayoutSchema), + localeId: Type.Optional(Type.String()), + locales: Type.Optional(Type.Array(LocaleSchema)), +})]) + +/** Frozen schedule payloads and publication versions use the same snapshot store. */ +export async function saveSiteDocumentSnapshot(db: DbClient, site: SiteDocument): Promise<string> { + const id = nanoid() + const { localization: _draftContext, ...withoutDrafts } = site + const frozen = { ...withoutDrafts, layouts: [] } + await db`insert into site_snapshots (id, site_json, content_hash) values (${id}, ${frozen}, ${siteContentHash(frozen)})` + return id +} + +export async function getStoredSiteDocument(db: DbClient, id: string): Promise<SiteDocument | null> { + const { rows } = await db<{ site_json: unknown }>`select site_json from site_snapshots where id = ${id}` + return rows[0] ? parseValue(PublishedSiteDocumentSchema, rows[0].site_json) : null } // --------------------------------------------------------------------------- @@ -138,12 +184,17 @@ function orderSiteDocumentRows(rows: readonly DataRow[]): DataRow[] { /** Reassemble the `PublishedPageSnapshot` shape from the getter join. */ function snapshotFromQueryRow(row: SnapshotQueryRow): PublishedPageSnapshot { + const runtimeAssets = row.runtime_assets_json ? parseValue(PublishedPageRuntimeAssetsSchema, row.runtime_assets_json) : null return { cmsSnapshotVersion: 1, pageRowId: row.row_id, - site: row.site_json, - ...(row.runtime_assets_json && row.runtime_assets_json.scripts.length > 0 - ? { runtimeAssets: row.runtime_assets_json } + site: parseValue(PublishedSiteDocumentSchema, row.site_json), + localeId: row.locale_id, + versionId: row.version_id, + siteSnapshotId: row.site_snapshot_id, + publicPath: row.public_path, + ...(runtimeAssets && runtimeAssets.scripts.length > 0 + ? { runtimeAssets } : {}), ...(row.importmap_body && row.importmap_sha256 ? { runtimePackageImportmap: { body: row.importmap_body, sha256: row.importmap_sha256 } } @@ -160,72 +211,80 @@ function snapshotFromQueryRow(row: SnapshotQueryRow): PublishedPageSnapshot { * `pages` and `components` data rows. Returns `null` when no draft site * exists yet. Saved layouts are editor-only; publishing ignores them. */ -export async function getDraftSiteDocument(db: DbClient): Promise<SiteDocument | null> { +export async function getDraftSiteDocument(db: DbClient, options: { localeId?: string } = {}): Promise<SiteDocument | null> { + return db.transaction((tx) => getDraftSiteDocumentInTx(tx, options)) +} + +/** Assemble inside an existing transaction, including callers reading sync metadata. */ +export async function getDraftSiteDocumentInTx(db: DbClient, options: { localeId?: string }): Promise<SiteDocument | null> { const shell = await getDraftSite(db) if (!shell) return null - const [pageRows, vcRows] = await Promise.all([ - listDataRows(db, 'pages'), - listDataRows(db, 'components'), + const locale = options.localeId ? await getLocale(db, options.localeId) : await getDefaultLocale(db) + if (!locale) throw new Error('Language not found') + const [pageRows, vcRows, layoutRows, locales] = await Promise.all([ + listDataRows(db, 'pages', { localeId: locale.id }), + listDataRows(db, 'components', { localeId: locale.id }), + listDataRows(db, 'layouts', { localeId: locale.id }), + listLocales(db), ]) const visualComponents = validateVisualComponents( orderSiteDocumentRows(vcRows) .flatMap((r) => { const vc = visualComponentFromRow(r); return vc ? [vc] : [] }) ) + const contentRows = [...pageRows, ...vcRows, ...layoutRows] + const tables = await Promise.all(['pages', 'components', 'layouts'].map((tableId) => getDataTable(db, tableId))) + const fieldLocalizations = new Map(tables.filter((table) => table !== null).map((table) => [table.id, + Object.fromEntries(table.fields.map((field) => [field.id, resolveDataFieldLocalization(field)])), + ])) + const variants = await listContentLocalizations(db, { rowIds: contentRows.map((row) => row.id) }) + const localization: SiteLocalizationContext = { + fieldLocalizations: Object.fromEntries(fieldLocalizations), + rows: Object.fromEntries(contentRows.map((row) => [row.id, { + tableId: row.tableId === 'pages' ? 'pages' : row.tableId === 'components' ? 'components' : 'layouts', + sharedCells: row.sharedCells, + localizations: Object.fromEntries(variants.filter((variant) => variant.rowId === row.id).map((variant) => [variant.localeId, { + cells: variant.cells, slug: variant.slug, translationMeta: variant.translationMeta, + }])), + }])), + } return { ...shell, + locales, + localeId: locale.id, + localization, pages: orderSiteDocumentRows(pageRows).map(pageFromRow), visualComponents, - layouts: [], + layouts: orderSiteDocumentRows(layoutRows).flatMap((row) => { + const layout = savedLayoutFromRow(row) + return layout ? [layout] : [] + }), } } -export async function getDraftPublishStatus(db: DbClient): Promise<DraftPublishStatus> { - const draftSite = await getDraftSiteDocument(db) - if (!draftSite) { - return { - hasPublishedVersion: false, - draftMatchesPublished: false, - draftPages: 0, - publishedPages: 0, - } - } - - // Only the per-publish content hash is fetched — never the stored site - // document. Comparing the draft's hash against each row's stamped hash is - // observationally identical to comparing canonical JSON strings, but costs - // one draft serialisation instead of one per published page. - const { rows: publishedRows } = await db<PublishStatusRow>` - select data_rows.id as row_id, - site_snapshots.content_hash, - data_row_versions.published_at - from data_rows - join data_row_versions on data_row_versions.id = data_rows.active_version_id - join site_snapshots on site_snapshots.id = data_row_versions.site_snapshot_id - where data_rows.table_id = 'pages' - and data_rows.status = 'published' - and data_rows.deleted_at is null - order by data_rows.created_at asc +export async function getDraftPublishStatus(db: DbClient, options: { localeId?: string } = {}): Promise<DraftPublishStatus> { + const draft = await getDraftSiteDocument(db, options) + if (!draft) return { hasPublishedVersion: false, draftMatchesPublished: false, draftPages: 0, publishedPages: 0 } + const pages = draft.pages.filter((page) => !isTemplatePage(page)) + const { rows } = await db<PublishStatusRow>` + select content_rows.id as row_id, snapshots.content_hash, versions.published_at + from data_rows content_rows + join data_row_localizations variants on variants.row_id = content_rows.id + join data_row_versions versions on versions.id = variants.active_version_id + and versions.row_id = variants.row_id and versions.locale_id = variants.locale_id + join site_snapshots snapshots on snapshots.id = versions.site_snapshot_id + join site_locales locales on locales.id = variants.locale_id + where content_rows.table_id = 'pages' and content_rows.deleted_at is null + and variants.locale_id = ${draft.localeId} and variants.availability = 'online' + and locales.enabled = ${true} and versions.public_path is not null ` - - const draftSiteHash = siteContentHash(draftSite) - const draftPageIds = new Set(draftSite.pages.map((page) => page.id)) - const draftMatchesPublished = - publishedRows.length === draftSite.pages.length && - publishedRows.every((row) => - draftPageIds.has(row.row_id) && - row.content_hash === draftSiteHash - ) - const lastPublishedAt = publishedRows - .map((row) => new Date(row.published_at).getTime()) - .filter(Number.isFinite) - .sort((a, b) => b - a)[0] - + const { localization: _context, ...withoutContext } = draft + const hash = siteContentHash({ ...withoutContext, layouts: [] }) + const lastPublishedAt = rows.map((row) => new Date(row.published_at).getTime()).filter(Number.isFinite).sort((a, b) => b - a)[0] return { - hasPublishedVersion: publishedRows.length > 0, - draftMatchesPublished, - draftPages: draftSite.pages.length, - publishedPages: publishedRows.length, + hasPublishedVersion: rows.length > 0, + draftMatchesPublished: rows.length === pages.length && rows.every((row) => row.content_hash === hash), + draftPages: pages.length, publishedPages: rows.length, ...(lastPublishedAt ? { lastPublishedAt: new Date(lastPublishedAt).toISOString() } : {}), } } @@ -240,9 +299,10 @@ export async function getDraftPublishStatus(db: DbClient): Promise<DraftPublishS */ export async function persistSitePublish( db: DbClient, - input: PersistSitePublishInput, + inputs: PersistSitePublishInput[], ): Promise<void> { await db.transaction(async (tx) => { + for (const input of inputs) { // The site document is stored ONCE per publish; every page version row // references it. The content hash powers the publish-status check without // ever re-fetching the document. @@ -258,83 +318,55 @@ export async function persistSitePublish( ` for (const page of input.pages) { + const previous = page.activate ? await readPreviousPublishedRoute(tx, page.pageId, page.localeId) : null await tx` insert into data_row_versions - (id, row_id, version_number, cells_json, slug, site_snapshot_id, runtime_assets_json, published_by_user_id) + (id, row_id, locale_id, version_number, cells_json, slug, public_path, site_snapshot_id, runtime_assets_json, published_by_user_id) values ( ${page.versionId}, ${page.pageId}, + ${page.localeId}, ${page.versionNumber}, - ${{ title: page.title, slug: page.slug }}, + ${page.cells}, ${page.slug}, + ${page.publicPath}, ${input.siteSnapshotId}, ${page.runtimeAssets}, ${input.publishedByUserId} ) ` await savePublishedRuntimeAssets(tx, page.versionId, page.runtimeFiles) - const { rowCount } = await tx` - update data_rows - set active_version_id = ${page.versionId}, - status = 'published', - published_by_user_id = ${input.publishedByUserId}, - published_at = current_timestamp, - updated_by_user_id = ${input.publishedByUserId}, - updated_at = current_timestamp - where id = ${page.pageId} - and deleted_at is null + if (!page.activate) continue + await tx` + insert into data_row_localizations (row_id, locale_id, slug) + values (${page.pageId}, ${page.localeId}, ${page.slug}) + on conflict (row_id, locale_id) do nothing ` - // The page was read before the transaction opened; if a concurrent save - // reaped it in between, don't leave an orphan version pointing at it. - if (rowCount === 0) { - await tx`delete from data_row_versions where id = ${page.versionId}` + const localization = await setContentLocalizationPublishedVersion(tx, page.pageId, page.localeId, page.versionId, input.publishedByUserId) + if (!localization) throw new Error(`Page "${page.pageId}" disappeared during publication`) + if (previous && previous.path !== page.publicPath) { + await savePublishedRedirect(tx, page.pageId, 'pages', page.localeId, previous.path) } } + } }) } export async function getPublishedPageBySlug( db: DbClient, slug: string, + localeId?: string, ): Promise<PublishedPageSnapshot | null> { - const { rows } = await db<SnapshotQueryRow>` - select data_rows.id as row_id, - site_snapshots.site_json, - data_row_versions.runtime_assets_json, - site_snapshots.importmap_body, - site_snapshots.importmap_sha256 - from data_rows - join data_row_versions on data_row_versions.id = data_rows.active_version_id - join site_snapshots on site_snapshots.id = data_row_versions.site_snapshot_id - where data_rows.table_id = 'pages' - and data_rows.slug = ${slug} - and data_rows.status = 'published' - and data_rows.deleted_at is null - limit 1 - ` - return rows[0] ? snapshotFromQueryRow(rows[0]) : null + return readPageSnapshot(db, { localeId, slug }) } export async function getPublishedPageSnapshotById( db: DbClient, pageId: string, + localeId?: string, + siteSnapshotId?: string, ): Promise<PublishedPageSnapshot | null> { - const { rows } = await db<SnapshotQueryRow>` - select data_rows.id as row_id, - site_snapshots.site_json, - data_row_versions.runtime_assets_json, - site_snapshots.importmap_body, - site_snapshots.importmap_sha256 - from data_rows - join data_row_versions on data_row_versions.id = data_rows.active_version_id - join site_snapshots on site_snapshots.id = data_row_versions.site_snapshot_id - where data_rows.id = ${pageId} - and data_rows.table_id = 'pages' - and data_rows.status = 'published' - and data_rows.deleted_at is null - limit 1 - ` - return rows[0] ? snapshotFromQueryRow(rows[0]) : null + return readPageSnapshot(db, { localeId, pageId, siteSnapshotId }) } /** @@ -352,21 +384,53 @@ export async function getPublishedPageSnapshotById( */ export async function getLatestPublishedSiteSnapshot( db: DbClient, + localeId?: string, + siteSnapshotId?: string, +): Promise<PublishedPageSnapshot | null> { + const snapshot = await readPageSnapshot(db, { localeId, siteSnapshotId }) + if (!snapshot) return null + const { runtimeAssets: _runtimeAssets, ...withoutRuntime } = snapshot + return withoutRuntime +} + +async function readPageSnapshot( + db: DbClient, + options: { localeId?: string; slug?: string; pageId?: string; siteSnapshotId?: string }, ): Promise<PublishedPageSnapshot | null> { - const { rows } = await db<SnapshotQueryRow>` - select data_rows.id as row_id, - site_snapshots.site_json, - site_snapshots.importmap_body, - site_snapshots.importmap_sha256 - from data_rows - join data_row_versions on data_row_versions.id = data_rows.active_version_id - join site_snapshots on site_snapshots.id = data_row_versions.site_snapshot_id - where data_rows.table_id = 'pages' - and data_rows.status = 'published' - and data_rows.deleted_at is null - order by data_rows.created_at asc + const localeId = options.localeId ?? (await getDefaultLocale(db)).id + const values: unknown[] = [localeId, true] + const conditions = [ + `versions.locale_id = ${placeholder(db.dialect, 1)}`, + `locales.enabled = ${placeholder(db.dialect, 2)}`, + "content_rows.table_id = 'pages'", + ...(!options.siteSnapshotId ? ['content_rows.deleted_at is null'] : []), + ] + for (const [column, value] of [ + ['versions.slug', options.slug], ['content_rows.id', options.pageId], ['versions.site_snapshot_id', options.siteSnapshotId], + ] as const) { + if (value !== undefined) { + values.push(value) + conditions.push(`${column} = ${placeholder(db.dialect, values.length)}`) + } + } + // A content version pins the template release it was published with. Such + // dependencies may be historical; direct public reads use only active ones. + const activeJoin = options.siteSnapshotId ? '' : ` + join data_row_localizations variants on variants.row_id = versions.row_id + and variants.locale_id = versions.locale_id and variants.active_version_id = versions.id + and variants.availability = 'online'` + const { rows } = await db.unsafe<SnapshotQueryRow>(` + select content_rows.id as row_id, versions.id as version_id, versions.locale_id, + versions.site_snapshot_id, versions.public_path, versions.runtime_assets_json, + site_snapshots.site_json, site_snapshots.importmap_body, site_snapshots.importmap_sha256 + from data_row_versions versions + join data_rows content_rows on content_rows.id = versions.row_id + join site_snapshots on site_snapshots.id = versions.site_snapshot_id + join site_locales locales on locales.id = versions.locale_id + ${activeJoin} + where ${conditions.join(' and ')} + order by versions.published_at desc, versions.version_number desc limit 1 - ` - const row = rows[0] - return row ? snapshotFromQueryRow({ ...row, runtime_assets_json: null }) : null + `, values) + return rows[0] ? snapshotFromQueryRow(rows[0]) : null } diff --git a/server/repositories/rowWriteEvents.ts b/server/repositories/rowWriteEvents.ts index 31177cd6c..57989c105 100644 --- a/server/repositories/rowWriteEvents.ts +++ b/server/repositories/rowWriteEvents.ts @@ -16,6 +16,10 @@ export interface RowWriteEvent { tableId: string rowIds: readonly string[] kind: RowWriteKind + /** An exact translation write; absent means all translations may have changed. */ + localeId?: string + /** Whether the logical shared structure also changed. */ + sharedChanged?: boolean } type RowWriteListener = (event: RowWriteEvent) => void diff --git a/server/repositories/runtimeAsset.ts b/server/repositories/runtimeAsset.ts index ffb8c5d85..4f1bcd16b 100644 --- a/server/repositories/runtimeAsset.ts +++ b/server/repositories/runtimeAsset.ts @@ -47,3 +47,12 @@ export async function getPublishedRuntimeAsset( bytes: row.content_bytes, } } + +/** Includes imported chunks, source maps and files referenced by entry scripts. */ +export async function listPublishedRuntimeAssetsForVersion(db: DbClient, versionId: string): Promise<PublishedRuntimeAssetRecord[]> { + const { rows } = await db<RuntimeAssetRow>` + select public_path, content_type, content_bytes from published_runtime_assets + where data_row_version_id = ${versionId} + ` + return rows.map((row) => ({ publicPath: row.public_path, contentType: row.content_type, bytes: row.content_bytes })) +} diff --git a/server/router.ts b/server/router.ts index f2866e123..9b78d1419 100644 --- a/server/router.ts +++ b/server/router.ts @@ -5,9 +5,8 @@ import { handleCmsRequest } from './handlers/cms' import type { DbClient } from './db/client' import { renderNotFoundResponse, renderPublicResolution } from './publish/publicRouter' import { readStaticAsset } from './publish/staticArtefact' -import { getLatestSnapshotForVersion } from './publish/publishedSnapshotCache' +import { rebuildPublishedCss } from './publish/publishedCssFallback' import { getPublishVersion, registerVersionedCacheReset } from './publish/publishState' -import { prefetchMediaAssets } from './publish/mediaPrefetch' import { getSetupStatusCached } from './repositories/setup' import { getPublishedRuntimeAsset } from './repositories/runtimeAsset' import { handleLoopRequest, isLoopRuntimeAssetPath, serveLoopRuntimeAsset } from './handlers/cms/loop' @@ -18,9 +17,7 @@ import { isRuntimePackagePath, tryServeRuntimePackage } from './publish/runtime/ import { jsonResponse } from './http' import { binaryResponse, toArrayBuffer } from './binary' import { hardenUploadResponse, serveAdminApp, serveStaticFile } from './static' -import { registry } from '@core/module-engine' -import type { CssBundleFile, SiteCssBundleId } from '@core/publisher' -import { buildPublishedSiteCssBundle } from './publish/siteCssBundle' +import type { SiteCssBundleId } from '@core/publisher' import { mediaStorageRegistry } from '@core/plugins/mediaStorageRegistry' const VITE_DEV_URL = 'http://localhost:5173' @@ -634,7 +631,7 @@ async function serveSiteCss(db: DbClient, pathname: string, uploadsDir?: string) const inflight = cssFallbackInFlight.get(cacheKey) const promise = inflight ?? (async (): Promise<string | null> => { try { - const content = await rebuildSiteCssFromSnapshot(db, bundleId, requestedHash, version) + const content = await rebuildPublishedCss(db, bundleId, requestedHash, version) if (cssFallbackCache.size >= CSS_FALLBACK_CACHE_MAX) cssFallbackCache.clear() cssFallbackCache.set(cacheKey, content) return content @@ -648,44 +645,6 @@ async function serveSiteCss(db: DbClient, pathname: string, uploadsDir?: string) return content === null ? new Response('Not found', { status: 404 }) : cssResponse(content, requestedHash) } -/** - * Rebuild the requested CSS bundle file from the latest published snapshot. - * Returns the file body, or `null` when no page (nor the page-agnostic view) - * produces the requested hash. The page-invariant trio comes from the - * version-keyed memo, so only `userStyles` does per-page work here. - */ -async function rebuildSiteCssFromSnapshot( - db: DbClient, - bundleId: SiteCssBundleId, - requestedHash: string, - version: number, -): Promise<string | null> { - const snapshot = await getLatestSnapshotForVersion(db, version) - if (!snapshot) return null - - const pages = bundleId === 'userStyles' ? snapshot.site.pages : snapshot.site.pages.slice(0, 1) - for (const page of pages) { - const mediaAssets = await prefetchMediaAssets(page, snapshot.site, registry, db) - const file: CssBundleFile = buildPublishedSiteCssBundle(snapshot.site, registry, page, version, { mediaAssets })[bundleId] - if (file.hash === requestedHash) return file.content - } - // Page-agnostic view (every enabled stylesheet) — covers a hash that - // predates a scope change but is still referenced somewhere. - const fallbackMediaAssets = snapshot.site.pages[0] - ? await prefetchMediaAssets(snapshot.site.pages[0], snapshot.site, registry, db) - : undefined - const fallback: CssBundleFile = buildPublishedSiteCssBundle( - snapshot.site, - registry, - undefined, - version, - { mediaAssets: fallbackMediaAssets }, - )[bundleId] - if (fallback.hash === requestedHash) return fallback.content - - return null -} - function cssResponse(body: BodyInit, hash: string): Response { return new Response(body, { headers: { diff --git a/src/__tests__/admin/capabilityAwareAdmin.test.tsx b/src/__tests__/admin/capabilityAwareAdmin.test.tsx index 38f6bf00d..ad92f421e 100644 --- a/src/__tests__/admin/capabilityAwareAdmin.test.tsx +++ b/src/__tests__/admin/capabilityAwareAdmin.test.tsx @@ -1,3 +1,4 @@ +import { SOURCE_LOCALE, makeContentLocalization } from '../fixtures/localization' import { afterEach, describe, expect, it } from 'bun:test' import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { MemoryRouter, Route, Routes } from '@admin/lib/routing' @@ -99,6 +100,11 @@ function makeRow( return { id, tableId, + localeId: SOURCE_LOCALE.id, + sharedCells: {}, + seq: 0, + localization: makeContentLocalization(id, { cells: mergedCells, slug: String(mergedCells.slug), ...(overrides.status === 'published' ? { availability: 'online', activeVersionId: 'version-1' } : {}) }), + publicPath: overrides.status === 'published' ? `/${tableId}/${mergedCells.slug}` : null, cells: mergedCells, slug: typeof mergedCells.slug === 'string' ? mergedCells.slug : 'untitled', status: 'draft', @@ -197,7 +203,8 @@ describe('capability-aware admin UI', () => { setupEditorState() const calls: Array<{ url: string; method: string }> = [] globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input) + const url = String(input).split('?')[0] + if (url === '/admin/api/cms/locales') return json({ locales: [SOURCE_LOCALE] }) const method = init?.method ?? 'GET' calls.push({ url, method }) if (url === '/admin/api/cms/data/tables') { @@ -251,7 +258,8 @@ describe('capability-aware admin UI', () => { setupEditorState() const calls: Array<{ url: string; method: string }> = [] globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input) + const url = String(input).split('?')[0] + if (url === '/admin/api/cms/locales') return json({ locales: [SOURCE_LOCALE] }) const method = init?.method ?? 'GET' calls.push({ url, method }) diff --git a/src/__tests__/admin/content/localizedPublicationControls.test.tsx b/src/__tests__/admin/content/localizedPublicationControls.test.tsx new file mode 100644 index 000000000..10a2bd410 --- /dev/null +++ b/src/__tests__/admin/content/localizedPublicationControls.test.tsx @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { DateTimePicker } from '@ui/components/DateTimePicker' +import { Button } from '@ui/components/Button' +import { SchedulePublishDialog } from '@admin/modals/SchedulePublishDialog' +import { StepUpProvider } from '@admin/shared/StepUp' +import { AdminSessionProvider } from '@admin/session' +import { ConfirmDeleteProvider } from '@admin/shared/dialogs/ConfirmDeleteDialog' +import { useContentMoveConfirmation } from '@admin/pages/content/hooks/useContentMoveConfirmation' +import { makeContentLocalization, SOURCE_LOCALE } from '../../fixtures/localization' +import { PublishButton } from '@site/toolbar/PublishButton' +import { useEditorStore } from '@site/store/store' +import { makeSite } from '../../fixtures' +import type { DataRow } from '@core/data/schemas' + +const originalFetch = globalThis.fetch +afterEach(() => { cleanup(); globalThis.fetch = originalFetch; useEditorStore.setState({ site: null, activePageId: null, activeLocaleId: null }) }) +const future = '2027-12-01T14:30:00.000Z' +const row: DataRow = { + id: 'entry', tableId: 'posts', localeId: 'de', cells: { title: 'Eintrag' }, sharedCells: {}, + localization: makeContentLocalization('entry', { localeId: 'de', cells: { title: 'Eintrag' }, slug: 'eintrag' }), + slug: 'eintrag', publicPath: null, status: 'scheduled', scheduledPublishAt: future, + createdAt: future, updatedAt: future, publishedAt: null, deletedAt: null, + authorUserId: null, createdByUserId: null, updatedByUserId: null, publishedByUserId: null, + author: null, createdBy: null, updatedBy: null, publishedBy: null, +} +function MoveControl({ commit }: { commit: () => Promise<void> }) { + const confirmMove = useContentMoveConfirmation() + return <ConfirmDeleteProvider><Button onClick={() => { void confirmMove(row, 'News', commit) }}>Move collection</Button></ConfirmDeleteProvider> +} + +describe('language publication controls', () => { + it('disables keyboard confirmation and every picker control while saving', () => { + let confirms = 0 + render(<DateTimePicker value={new Date(future)} busy onConfirm={() => { confirms++ }} onCancel={() => {}} />) + for (const button of screen.getAllByRole('button')) expect((button as HTMLButtonElement).disabled).toBe(true) + expect((screen.getByLabelText('Hours') as HTMLInputElement).disabled).toBe(true) + expect((screen.getByLabelText('Minutes') as HTMLInputElement).disabled).toBe(true) + fireEvent.keyDown(screen.getByRole('grid'), { key: 'Enter' }) + expect(confirms).toBe(0) + }) + it('submits one locale schedule despite two immediate confirmations and keeps the existing date', async () => { + const requests: string[] = [] + let finish!: (response: Response) => void + globalThis.fetch = async (input) => { requests.push(String(input)); return new Promise<Response>((resolve) => { finish = resolve }) } + const scheduled: DataRow[] = [] + let closed = 0 + render(<AdminSessionProvider user={null}><StepUpProvider><SchedulePublishDialog open rowId="entry" localeId="de" currentScheduledAt={future} entityLabel="post" onClose={() => { closed++ }} onScheduled={(value) => scheduled.push(value)} /></StepUpProvider></AdminSessionProvider>) + expect(screen.getByText('Reschedule this post')).toBeTruthy() + expect(screen.getByRole('grid').getAttribute('aria-label')).toBe('December 2027 days') + const confirm = screen.getByRole('button', { name: 'Confirm' }) + act(() => { fireEvent.click(confirm); fireEvent.click(confirm) }) + await waitFor(() => expect(requests).toEqual(['/admin/api/cms/data/rows/entry/schedule?localeId=de'])) + expect((screen.getByRole('button', { name: 'Saving…' }) as HTMLButtonElement).disabled).toBe(true) + expect((screen.getByRole('button', { name: 'Cancel current schedule' }) as HTMLButtonElement).disabled).toBe(true) + await act(async () => { finish(new Response(JSON.stringify({ row }), { headers: { 'content-type': 'application/json' } })) }) + await waitFor(() => expect(scheduled).toHaveLength(1)) + expect(closed).toBe(1) + }) + it('loads the selected page language schedule before opening the calendar', async () => { + const site = makeSite() + site.localeId = 'de' + site.locales = [SOURCE_LOCALE, { ...SOURCE_LOCALE, id: 'de', code: 'de', name: 'Deutsch', pathPrefix: 'de', isDefault: false }] + const pageId = site.pages[0].id + useEditorStore.setState({ site, activePageId: pageId, activeLocaleId: 'de' }) + const requested: string[] = [] + globalThis.fetch = async (input) => { + const url = String(input); requested.push(url) + return new Response(JSON.stringify(url.includes('/publish/status') + ? { hasPublishedVersion: false, draftMatchesPublished: false, draftPages: 1, publishedPages: 0 } + : { row: { ...row, id: pageId, tableId: 'pages' } }), { headers: { 'content-type': 'application/json' } }) + } + render(<AdminSessionProvider user={null}><StepUpProvider><PublishButton /></StepUpProvider></AdminSessionProvider>) + fireEvent.click(screen.getByTestId('toolbar-publish-actions-trigger')) + fireEvent.click(screen.getByTestId('toolbar-schedule-publish-action')) + expect(await screen.findByText('Reschedule this page')).toBeTruthy() + expect(requested).toContain(`/admin/api/cms/data/rows/${pageId}?localeId=de`) + expect(screen.getByRole('grid').getAttribute('aria-label')).toBe('December 2027 days') + }) + + it('does not open a schedule response after the editor switched languages', async () => { + const site = makeSite() + site.localeId = 'de'; site.locales = [SOURCE_LOCALE, { ...SOURCE_LOCALE, id: 'de', code: 'de', name: 'Deutsch', pathPrefix: 'de', isDefault: false }] + useEditorStore.setState({ site, activePageId: site.pages[0].id, activeLocaleId: 'de' }) + let finish!: (response: Response) => void + globalThis.fetch = async (input) => String(input).includes('/publish/status') + ? new Response(JSON.stringify({ hasPublishedVersion: false, draftMatchesPublished: false, draftPages: 1, publishedPages: 0 }), { headers: { 'content-type': 'application/json' } }) + : new Promise<Response>((resolve) => { finish = resolve }) + render(<AdminSessionProvider user={null}><StepUpProvider><PublishButton /></StepUpProvider></AdminSessionProvider>) + fireEvent.click(screen.getByTestId('toolbar-publish-actions-trigger')) + fireEvent.click(screen.getByTestId('toolbar-schedule-publish-action')) + await waitFor(() => expect(finish).toBeDefined()) + act(() => useEditorStore.setState({ activeLocaleId: 'default' })) + await act(async () => finish(new Response(JSON.stringify({ row }), { headers: { 'content-type': 'application/json' } }))) + expect(screen.queryByText('Reschedule this page')).toBeNull() + }) + + it('uses one confirmation host and explains that collection moves retract every language', async () => { + let moves = 0 + render(<ConfirmDeleteProvider><MoveControl commit={async () => { moves++ }} /></ConfirmDeleteProvider>) + fireEvent.click(screen.getByRole('button', { name: 'Move collection' })) + expect(moves).toBe(0) + expect(screen.getAllByRole('alertdialog')).toHaveLength(1) + expect(screen.getByText(/All language versions will go offline/)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(moves).toBe(0) + fireEvent.click(screen.getByRole('button', { name: 'Move collection' })) + fireEvent.click(screen.getByRole('button', { name: 'Move entry' })) + await waitFor(() => expect(moves).toBe(1)) + }) +}) diff --git a/src/__tests__/admin/data/contentCollectionStepUp.test.tsx b/src/__tests__/admin/data/contentCollectionStepUp.test.tsx index 1c7c922c3..93a8e95a4 100644 --- a/src/__tests__/admin/data/contentCollectionStepUp.test.tsx +++ b/src/__tests__/admin/data/contentCollectionStepUp.test.tsx @@ -1,3 +1,4 @@ +import { SOURCE_LOCALE } from '../../fixtures/localization' import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test' import React, { type ReactNode } from 'react' import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' @@ -118,9 +119,11 @@ describe('Content collection step-up flow', () => { const stepUpRequests: Array<Record<string, unknown>> = [] globalThis.fetch = mock(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input) + const url = String(input).split('?')[0] + if (url === '/admin/api/cms/locales') return json({ locales: [SOURCE_LOCALE] }) - if (url === '/admin/api/ai/editor-bridge?scope=content') { + if (url === '/admin/api/ai/editor-bridge' && new URL(String(input), 'http://localhost').searchParams.get('scope') === 'content') { + expect(new URL(String(input), 'http://localhost').searchParams.get('localeId')).toBe('default') contentBridgeRequests += 1 return json({ error: 'Unauthorized' }, 401) } @@ -172,6 +175,7 @@ describe('Content collection step-up flow', () => { if (url.endsWith('/admin/api/cms/plugins')) return json({ plugins: [], adminPages: [] }) if (url.endsWith('/admin/api/cms/site')) return json({ site: null }, 404) + if (url.endsWith('/admin/api/cms/site-document')) return json({ error: 'Not found' }, 404) if (url.endsWith('/admin/api/cms/publish/status')) return json({ ok: false }, 404) if (url.endsWith('/admin/api/cms/media/folders')) return json({ folders: [] }) diff --git a/src/__tests__/admin/data/exportDialog.test.tsx b/src/__tests__/admin/data/exportDialog.test.tsx index 0781185e1..b5d0d8878 100644 --- a/src/__tests__/admin/data/exportDialog.test.tsx +++ b/src/__tests__/admin/data/exportDialog.test.tsx @@ -15,6 +15,7 @@ * 10. An empty category (count 0 from the summary) is disabled */ +import { makeContentLocalization } from '../../fixtures/localization' import { afterEach, beforeEach, describe, expect, it } from 'bun:test' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import type { DataTableListItem } from '@core/data/schemas' @@ -311,8 +312,8 @@ describe('ExportDialog', () => { if (url.includes('/data/tables/posts/rows')) { return jsonResponse({ rows: [ - { id: 'p1', tableId: 'posts', cells: { title: 'First' }, slug: 'first', status: 'published', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', publishedAt: null, scheduledPublishAt: null, deletedAt: null, authorUserId: null, createdByUserId: null, updatedByUserId: null, publishedByUserId: null, author: null, createdBy: null, updatedBy: null, publishedBy: null }, - { id: 'p2', tableId: 'posts', cells: { title: 'Second' }, slug: 'second', status: 'draft', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', publishedAt: null, scheduledPublishAt: null, deletedAt: null, authorUserId: null, createdByUserId: null, updatedByUserId: null, publishedByUserId: null, author: null, createdBy: null, updatedBy: null, publishedBy: null }, + { id: 'p1', tableId: 'posts', localeId: 'default', sharedCells: {}, localization: makeContentLocalization('p1', { slug: 'first' }), publicPath: null, cells: { title: 'First' }, slug: 'first', status: 'published', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', publishedAt: null, scheduledPublishAt: null, deletedAt: null, authorUserId: null, createdByUserId: null, updatedByUserId: null, publishedByUserId: null, author: null, createdBy: null, updatedBy: null, publishedBy: null }, + { id: 'p2', tableId: 'posts', localeId: 'default', sharedCells: {}, localization: makeContentLocalization('p2', { slug: 'second' }), publicPath: null, cells: { title: 'Second' }, slug: 'second', status: 'draft', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', publishedAt: null, scheduledPublishAt: null, deletedAt: null, authorUserId: null, createdByUserId: null, updatedByUserId: null, publishedByUserId: null, author: null, createdBy: null, updatedBy: null, publishedBy: null }, ], }) } diff --git a/src/__tests__/admin/siteImport/SiteImportModal.test.tsx b/src/__tests__/admin/siteImport/SiteImportModal.test.tsx index d2e6eb825..1a525ba4b 100644 --- a/src/__tests__/admin/siteImport/SiteImportModal.test.tsx +++ b/src/__tests__/admin/siteImport/SiteImportModal.test.tsx @@ -34,7 +34,6 @@ import { AnalyzeStep } from '@admin/modals/SiteImport/steps/AnalyzeStep' import { SiteImportModal } from '@admin/modals/SiteImport' import type { ImportSelection } from '@admin/modals/SiteImport' import { commitImportPlan } from '@core/siteImport' -import { pageToCells } from '@core/data/pageFromRow' import { BUNDLE_ARCHIVE_MANIFEST_PATH } from '@core/data/bundleArchive' import { CORE_CAPABILITIES } from '@core/capabilities' // Static-site import maps HTML into base modules during plan analysis. @@ -52,6 +51,7 @@ import type { CmsCurrentUser } from '@core/persistence' import type { SiteBundle } from '@core/data/bundleSchema' import type { Page, SiteDocument } from '@core/page-tree' import { makeSite } from '../../fixtures' +import { makeContentLocalization, SOURCE_LOCALE } from '../../fixtures/localization' // --------------------------------------------------------------------------- // Helpers @@ -166,6 +166,10 @@ const CMS_BUNDLE_ROW: DataRow = { id: 'cms-row-1', tableId: 'posts', cells: { title: 'Imported post', slug: 'imported-post' }, + sharedCells: {}, + localeId: 'default', + localization: makeContentLocalization('cms-row-1', { cells: { title: 'Imported post', slug: 'imported-post' }, slug: 'imported-post' }), + publicPath: null, slug: 'imported-post', status: 'published', authorUserId: null, @@ -188,6 +192,7 @@ const CMS_BUNDLE_PAGE_ROW: DataRow = { id: 'cms-page-1', tableId: 'pages', cells: { title: 'Imported page', slug: 'imported-page' }, + localization: makeContentLocalization('cms-page-1', { cells: { title: 'Imported page', slug: 'imported-page' }, slug: 'imported-page' }), slug: 'imported-page', } @@ -230,51 +235,15 @@ function jsonResponse(body: unknown, status = 200): Response { }) } -function siteShell(site: SiteDocument): Omit<SiteDocument, 'pages' | 'visualComponents'> { - const { pages: _pages, visualComponents: _visualComponents, ...shell } = site - return shell -} - -function pageRow(page: Page): DataRow { - return { - id: page.id, - tableId: 'pages', - cells: pageToCells(page), - slug: page.slug, - status: 'draft', - authorUserId: null, - createdByUserId: null, - updatedByUserId: null, - publishedByUserId: null, - author: null, - createdBy: null, - updatedBy: null, - publishedBy: null, - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - publishedAt: null, - scheduledPublishAt: null, - deletedAt: null, - } -} - function mockDraftSiteLoad(site: SiteDocument): string[] { const requested: string[] = [] globalThis.fetch = async (input: RequestInfo | URL) => { const url = String(input) requested.push(url) - if (url === '/admin/api/cms/site') { - return jsonResponse({ site: siteShell(site) }) - } - if (url === '/admin/api/cms/pages') { - return jsonResponse({ rows: site.pages.map(pageRow) }) - } - if (url === '/admin/api/cms/components') { - return jsonResponse({ rows: [] }) - } - if (url === '/admin/api/cms/layouts') { - return jsonResponse({ rows: [] }) - } + if (url === '/admin/api/cms/site-document') return jsonResponse({ + site: { ...site, localeId: SOURCE_LOCALE.id, locales: [SOURCE_LOCALE], localization: { fieldLocalizations: {}, rows: {} } }, + rowSeqs: {}, shellSeq: 0, + }) return jsonResponse({ error: `Unexpected request: ${url}` }, 500) } return requested @@ -1022,10 +991,7 @@ describe('SiteImportModal — global static import', () => { expect(screen.queryByText(/editor has no site loaded/i)).toBeNull() expect(useEditorStore.getState().site?.name).toBe('Global Draft Site') expect(requested).toEqual([ - '/admin/api/cms/site', - '/admin/api/cms/pages', - '/admin/api/cms/components', - '/admin/api/cms/layouts', + '/admin/api/cms/site-document', ]) }) }) diff --git a/src/__tests__/agent/contentBridge.test.ts b/src/__tests__/agent/contentBridge.test.ts index 54beef6ba..cfc93c3b1 100644 --- a/src/__tests__/agent/contentBridge.test.ts +++ b/src/__tests__/agent/contentBridge.test.ts @@ -28,6 +28,9 @@ function registerHandle(overrides: Partial<ContentBridgeHandle> = {}) { calls.push('selectDocument') return true }, + async selectLocale() { + calls.push('selectLocale') + }, async selectCollection() { calls.push('selectCollection') return true @@ -62,6 +65,20 @@ afterEach(() => { }) describe('executeContentTool', () => { + it('rejects a command addressed to another locale before mutating the workspace', async () => { + const { calls } = registerHandle() + const result = await executeContentTool('content_create_document', { tableId: 'posts', localeId: 'de', fields: { title: 'Hallo' } }) + expect(result.ok).toBe(false) + expect(result.error).toContain('language') + expect(calls).toEqual([]) + }) + + it('selects the requested locale through the live workspace', async () => { + const { calls } = registerHandle() + const result = await executeContentTool('content_select_locale', { localeId: 'de' }) + expect(result).toEqual({ ok: true, data: { localeId: 'de' } }) + expect(calls).toEqual(['selectLocale']) + }) it('returns the new document id in canonical tool data', async () => { let createArgs: Parameters<ContentBridgeHandle['createDocument']>[0] | null = null let createCalls = 0 diff --git a/src/__tests__/agent/mcpWorkspaceReadiness.test.tsx b/src/__tests__/agent/mcpWorkspaceReadiness.test.tsx index f125ba045..fa1ad78ee 100644 --- a/src/__tests__/agent/mcpWorkspaceReadiness.test.tsx +++ b/src/__tests__/agent/mcpWorkspaceReadiness.test.tsx @@ -9,6 +9,25 @@ afterEach(() => { }) describe('MCP workspace bridge readiness', () => { + it('reconnects with the selected locale when the user changes language', async () => { + const realFetch = globalThis.fetch + const paths: string[] = [] + globalThis.fetch = (async (input) => { + paths.push(String(input)) + return new Response(null, { status: 401 }) + }) as typeof fetch + const dispatch = async () => ({ ok: true as const }) + try { + const view = renderHook(({ localeId }) => useMcpWorkspaceBridge('site', dispatch, undefined, true, localeId), { initialProps: { localeId: 'en' } }) + await waitFor(() => expect(paths).toHaveLength(1)) + view.rerender({ localeId: 'de:at' }) + await waitFor(() => expect(paths).toHaveLength(2)) + expect(paths.map((path) => new URL(path, 'http://localhost').searchParams.get('localeId'))).toEqual(['en', 'de:at']) + } finally { + cleanup() + globalThis.fetch = realFetch + } + }) it('does not register a Site bridge before the editor store is hydrated', async () => { const realFetch = globalThis.fetch let bridgeRequests = 0 @@ -49,7 +68,7 @@ describe('MCP workspace bridge readiness', () => { 'const siteHydrated = useEditorStore((state) => state.site !== null)', ) expect(source).toContain( - "useMcpWorkspaceBridge('site', executeAgentTool, undefined, siteHydrated)", + "useMcpWorkspaceBridge('site', executeAgentTool, undefined, siteHydrated, activeLocaleId)", ) }) }) diff --git a/src/__tests__/agent/siteAgentSnapshot.test.ts b/src/__tests__/agent/siteAgentSnapshot.test.ts index 83c135631..a7db32ed7 100644 --- a/src/__tests__/agent/siteAgentSnapshot.test.ts +++ b/src/__tests__/agent/siteAgentSnapshot.test.ts @@ -28,6 +28,16 @@ function fixture(): { site: SiteDocument; active: Page } { } describe('buildSiteAgentSnapshot', () => { + it('keeps the selected language but excludes sparse drafts for other languages', () => { + const { site, active } = fixture() + site.localeId = 'de' + site.localization = { fieldLocalizations: {}, rows: {} } + const snap = buildSiteAgentSnapshot(active, site, { + selectedNodeId: null, activeBreakpointId: 'desktop', currentDocument: { type: 'page', id: active.id }, + }) + expect(snap.site.localeId).toBe('de') + expect(Object.hasOwn(snap.site, 'localization')).toBe(false) + }) it('posts the active page with full nodes', () => { const { site, active } = fixture() const snap = buildSiteAgentSnapshot(active, site, { diff --git a/src/__tests__/ai/mcpContextTool.test.ts b/src/__tests__/ai/mcpContextTool.test.ts index 74d726d27..088c2acc3 100644 --- a/src/__tests__/ai/mcpContextTool.test.ts +++ b/src/__tests__/ai/mcpContextTool.test.ts @@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test' import { createCapabilityTestHarness, type CapabilityTestHarness } from '../helpers/capabilityHarness' import { contextMcpTools } from '../../../server/ai/mcp/tools/contextTool' import { createEditorBridgeStream } from '../../../server/ai/mcp/editorBridge' +import { createDataRow } from '../../../server/repositories/data' +import { getDefaultLocale, createLocale } from '../../../server/repositories/localization' import type { ToolContext } from '../../../server/ai/runtime/types' function ctxFor(harness: CapabilityTestHarness): ToolContext { @@ -27,7 +29,10 @@ describe('get_context', () => { harness = await createCapabilityTestHarness() await harness.setupOwner() }) - afterEach(() => { console.error = originalError }) + afterEach(async () => { + console.error = originalError + await harness.cleanup() + }) it('reports editor disconnected when no bridge is open and lists templates', async () => { const out = (await getContext.handler!({}, ctxFor(harness))) as { @@ -42,18 +47,16 @@ describe('get_context', () => { }) it('surfaces an everywhere template as wrapping a page', async () => { - const cells = JSON.stringify({ + const cells = { title: 'Shell', slug: 'shell', body: { rootNodeId: 'r', nodes: { r: { id: 'r', moduleId: 'base.body', props: {}, breakpointOverrides: {}, classIds: [], children: [] } } }, templateEnabled: true, templateTarget: { kind: 'everywhere' }, templatePriority: 10, - }) - await harness.db`insert into data_rows (id, table_id, cells_json, slug, status) - values ('tpl1', 'pages', ${cells}, 'shell', 'draft')` - const pageCells = JSON.stringify({ title: 'Home', slug: 'home', body: { rootNodeId: 'r', nodes: { r: { id: 'r', moduleId: 'base.body', props: {}, breakpointOverrides: {}, classIds: [], children: [] } } } }) - await harness.db`insert into data_rows (id, table_id, cells_json, slug, status) - values ('home1', 'pages', ${pageCells}, 'home', 'draft')` + } + await createDataRow(harness.db, { id: 'tpl1', tableId: 'pages', slug: 'shell', cells }) + const pageCells = { title: 'Home', slug: 'home', body: { rootNodeId: 'r', nodes: { r: { id: 'r', moduleId: 'base.body', props: {}, breakpointOverrides: {}, classIds: [], children: [] } } } } + await createDataRow(harness.db, { id: 'home1', tableId: 'pages', slug: 'home', cells: pageCells }) const out = (await getContext.handler!({ entryId: 'home1' }, ctxFor(harness))) as { templates: Array<{ target: string; title: string }> @@ -67,7 +70,9 @@ describe('get_context', () => { it('reports Site and Content workspace connections independently', async () => { const siteCtrl = new AbortController() const contentCtrl = new AbortController() - createEditorBridgeStream('no-editor-user', 'site', siteCtrl.signal) + const source = await getDefaultLocale(harness.db) + const german = await createLocale(harness.db, { code: 'de', name: 'Deutsch', pathPrefix: 'de', direction: 'ltr', enabled: true }) + createEditorBridgeStream('no-editor-user', 'site', siteCtrl.signal, undefined, source.id) try { const siteOnly = (await getContext.handler!({}, ctxFor(harness))) as { @@ -76,15 +81,19 @@ describe('get_context', () => { expect(siteOnly.editor).toEqual({ siteConnected: true, contentConnected: false, + siteLocaleId: source.id, + contentLocaleId: null, }) - createEditorBridgeStream('no-editor-user', 'content', contentCtrl.signal) + createEditorBridgeStream('no-editor-user', 'content', contentCtrl.signal, undefined, german.id) const both = (await getContext.handler!({}, ctxFor(harness))) as { editor: { siteConnected: boolean; contentConnected: boolean } } expect(both.editor).toEqual({ siteConnected: true, contentConnected: true, + siteLocaleId: source.id, + contentLocaleId: german.id, }) } finally { siteCtrl.abort() diff --git a/src/__tests__/architecture/binding-compatibility-coverage.test.ts b/src/__tests__/architecture/binding-compatibility-coverage.test.ts index ef12b5330..983dd4503 100644 --- a/src/__tests__/architecture/binding-compatibility-coverage.test.ts +++ b/src/__tests__/architecture/binding-compatibility-coverage.test.ts @@ -14,11 +14,10 @@ * Every type listed as compatible must be a real `DataFieldType` member. * Catches typos and stale entries. * - * 3. Every DataFieldType appears in at least one control's compatibility list. - * A field type invisible to the picker cannot be bound by page authors. - * If a new field type is added to `DataFieldSchema`, it must also be wired - * into BINDING_COMPATIBILITY. The covered-types set must equal the full - * `DATA_FIELD_TYPES` set — neither a subset nor a superset. + * 3. Every scalar DataFieldType appears in at least one compatibility list. + * Whole-document and parameter-schema storage cannot be bound to a node + * property. Explicitly enumerate those exceptions rather than claiming + * they are compatible with a group control. * * @see src/core/data/schemas.ts — DATA_FIELD_TYPES / DataFieldType * @see src/admin/shared/DataBindingPicker/bindingCompatibility.ts @@ -63,9 +62,11 @@ describe('BINDING_COMPATIBILITY — architecture coverage', () => { expect(invalid).toHaveLength(0) }) - test('every DataFieldType is bindable to at least one PropertyControl', () => { + test('every scalar DataFieldType is bindable to at least one PropertyControl', () => { + const documentTypes = new Set(['repeater', 'pageTree', 'fieldSchema', 'parameterValues']) + const bindableTypes = DATA_FIELD_TYPES.filter((type) => !documentTypes.has(type)) const coveredTypes = new Set(Object.values(BINDING_COMPATIBILITY).flat()) - const uncovered = DATA_FIELD_TYPES.filter((t) => !coveredTypes.has(t)) + const uncovered = bindableTypes.filter((t) => !coveredTypes.has(t)) if (uncovered.length > 0) { throw new Error( @@ -80,6 +81,6 @@ describe('BINDING_COMPATIBILITY — architecture coverage', () => { ) } - expect(coveredTypes).toEqual(new Set(DATA_FIELD_TYPES)) + expect(coveredTypes).toEqual(new Set(bindableTypes)) }) }) diff --git a/src/__tests__/architecture/bundle-size-budgets.test.ts b/src/__tests__/architecture/bundle-size-budgets.test.ts index 194aed9cf..84107cf66 100644 --- a/src/__tests__/architecture/bundle-size-budgets.test.ts +++ b/src/__tests__/architecture/bundle-size-budgets.test.ts @@ -123,9 +123,11 @@ const BUDGETS: ChunkBudget[] = [ // can paint the existing toolbar/chrome before the editor body downloads. { prefix: 'SitePage-', - maxBytes: 30_000, + // Locale selection, source permissions and publication/schedule state add + // shell behavior; the canvas and module graph still load independently. + maxBytes: 32_000, rationale: - 'site route shell (current ~22 KB raw / ~9 KB gzipped). Must not ' + + 'site route shell with language/publication controls (current ~30 KB raw). Must not ' + 'pull the visual editor body, DnD, canvas, first-party modules, or ' + 'PropertiesPanel back into the active route chunk.', }, diff --git a/src/__tests__/architecture/cms-handlers-capability-gated.test.ts b/src/__tests__/architecture/cms-handlers-capability-gated.test.ts index 91c88f2d8..f3889626f 100644 --- a/src/__tests__/architecture/cms-handlers-capability-gated.test.ts +++ b/src/__tests__/architecture/cms-handlers-capability-gated.test.ts @@ -50,6 +50,7 @@ const ALLOWLIST: ReadonlyMap<string, string> = new Map([ // Shared utilities — body parsers, audit context helpers, schema // exports. No request handlers live here. ['shared.ts', 'Shared request helpers; no handlers.'], + ['localeContext.ts', 'Locale selection validator called after authentication by resource handlers; no independent route.'], ['session.ts', 'Session lookup helper; called from auth.ts which gates.'], // Media upload helpers — `acceptUploadedMedia`, `readUploadForm`, // file-magic sniffing. Always called by an already-gated parent diff --git a/src/__tests__/architecture/cmsTransferExport.test.ts b/src/__tests__/architecture/cmsTransferExport.test.ts index d72523272..72254459b 100644 --- a/src/__tests__/architecture/cmsTransferExport.test.ts +++ b/src/__tests__/architecture/cmsTransferExport.test.ts @@ -185,7 +185,7 @@ beforeAll(async () => { // Seed 1 row in pages const pg = await createDataRow(db, { tableId: 'pages', - cells: { title: 'Home Page', slug: 'home', body: { nodes: {}, rootNodeId: 'root' } }, + cells: { title: 'Home Page', slug: 'home', body: { nodes: { root: { id: 'root', moduleId: 'base.container', props: {}, breakpointOverrides: {}, children: [], classIds: [] } }, rootNodeId: 'root' } }, slug: 'home', }) pageId = pg.id diff --git a/src/__tests__/architecture/cmsTransferImport.test.ts b/src/__tests__/architecture/cmsTransferImport.test.ts index 8299459c8..e6c526e66 100644 --- a/src/__tests__/architecture/cmsTransferImport.test.ts +++ b/src/__tests__/architecture/cmsTransferImport.test.ts @@ -15,6 +15,7 @@ * @see docs/plans/2026-05-19-site-transfer-ux.md */ +import { makeContentLocalization } from '../fixtures/localization' import { describe, test, expect, beforeAll } from 'bun:test' import { createSqliteClient } from '../../../server/db/sqlite' import { runMigrations } from '../../../server/db/runMigrations' @@ -131,6 +132,10 @@ function bundleRowEntry( id, tableId, cells: { slug, ...cellOverrides }, + sharedCells: { slug, ...cellOverrides }, + localeId: 'default', + localization: makeContentLocalization(id, { cells: { slug, ...cellOverrides }, slug }), + publicPath: null, slug, status: 'draft', authorUserId: null, diff --git a/src/__tests__/architecture/cmsTransferPreview.test.ts b/src/__tests__/architecture/cmsTransferPreview.test.ts index d3b5a341b..fdd693d27 100644 --- a/src/__tests__/architecture/cmsTransferPreview.test.ts +++ b/src/__tests__/architecture/cmsTransferPreview.test.ts @@ -17,6 +17,7 @@ * @see docs/plans/2026-05-19-site-transfer-ux.md */ +import { makeContentLocalization } from '../fixtures/localization' import { describe, test, expect } from 'bun:test' import { createSqliteClient } from '../../../server/db/sqlite' import { runMigrations } from '../../../server/db/runMigrations' @@ -113,6 +114,10 @@ function bundleRowEntry(id: string, tableId: string, slug: string = ''): DataRow id, tableId, cells: { slug }, + sharedCells: { slug }, + localeId: 'default', + localization: makeContentLocalization(id, { cells: { slug }, slug }), + publicPath: null, slug, status: 'draft', authorUserId: null, diff --git a/src/__tests__/architecture/codemirror-lazy-only.test.ts b/src/__tests__/architecture/codemirror-lazy-only.test.ts index 0cf515aad..3b0457e17 100644 --- a/src/__tests__/architecture/codemirror-lazy-only.test.ts +++ b/src/__tests__/architecture/codemirror-lazy-only.test.ts @@ -59,14 +59,16 @@ function collectFiles(dir: string, exts = ['.ts', '.tsx', '.js', '.jsx', '.mts', } // Scan production source under src/. We deliberately skip `src/__tests__/` -// — test files may contain the package names as literal patterns (this file -// is one of them) and would self-match. +// and collocated test/spec files: their runtime helpers do not enter the +// production bundle. const PROD_DIRS = ['admin', 'core', 'modules', 'ui', 'editor', 'app', 'lib'].map((d) => join(SRC_ROOT, d) ) function collectProdFiles(): string[] { - return PROD_DIRS.flatMap((dir) => collectFiles(dir)) + return PROD_DIRS.flatMap((dir) => collectFiles(dir)).filter((file) => + !/[/\\]__tests__[/\\]|\.(?:test|spec)\.[cm]?[jt]sx?$/.test(file), + ) } // --------------------------------------------------------------------------- diff --git a/src/__tests__/architecture/import-export-roundtrip.test.ts b/src/__tests__/architecture/import-export-roundtrip.test.ts index c2475fef3..c304d877f 100644 --- a/src/__tests__/architecture/import-export-roundtrip.test.ts +++ b/src/__tests__/architecture/import-export-roundtrip.test.ts @@ -25,6 +25,7 @@ * @see src/core/data/bundleSchema.ts */ +import { makeContentLocalization } from '../fixtures/localization' import { describe, test, expect, beforeAll, afterAll } from 'bun:test' import { createSqliteClient } from '../../../server/db/sqlite' import { runMigrations } from '../../../server/db/runMigrations' @@ -81,7 +82,7 @@ beforeAll(async () => { title: 'Home', slug: 'home', templateEnabled: false, - body: { nodes: {}, rootNodeId: 'root' }, + body: { nodes: { root: { id: 'root', moduleId: 'base.container', props: {}, breakpointOverrides: {}, children: [], classIds: [] } }, rootNodeId: 'root' }, }, slug: 'home', }) @@ -94,7 +95,7 @@ beforeAll(async () => { templateEnabled: true, templateTarget: { kind: 'postTypes', tableSlugs: ['posts'] }, templatePriority: 100, - body: { nodes: {}, rootNodeId: 'root' }, + body: { nodes: { root: { id: 'root', moduleId: 'base.container', props: {}, breakpointOverrides: {}, children: [], classIds: [] } }, rootNodeId: 'root' }, }, slug: 'blog-template', }) @@ -356,7 +357,7 @@ describe('with strategies — handler-level roundtrip', () => { }) await createDataRow(sourceDb, { tableId: 'pages', - cells: { title: 'Home', slug: 'home', body: { nodes: {}, rootNodeId: 'root' } }, + cells: { title: 'Home', slug: 'home', body: { nodes: { root: { id: 'root', moduleId: 'base.container', props: {}, breakpointOverrides: {}, children: [], classIds: [] } }, rootNodeId: 'root' } }, slug: 'home', }) // A saved layout — rides the same generic table/row pipeline; the @@ -675,6 +676,7 @@ describe('full-site round-trip — folders, membership, redirects', () => { await importDataRowRedirect(sourceDb, { id: 'redirect-1', tableId: 'posts', + localeId: 'default', fromRouteBase: '/posts', fromSlug: 'old-slug', targetRowId: targetRow.id, @@ -872,6 +874,10 @@ describe('archive import validation', () => { id: 'bundle-conflicting-row', tableId: 'posts', cells: { title: 'Bundle row', slug: 'shared-slug' }, + sharedCells: {}, + localeId: 'default', + localization: makeContentLocalization('bundle-conflicting-row', { cells: { title: 'Bundle row', slug: 'shared-slug' }, slug: 'shared-slug' }), + publicPath: null, slug: 'shared-slug', status: 'draft', authorUserId: null, diff --git a/src/__tests__/architecture/no-vc-in-site-shell.test.ts b/src/__tests__/architecture/no-vc-in-site-shell.test.ts index 1cd4544e2..5f1422117 100644 --- a/src/__tests__/architecture/no-vc-in-site-shell.test.ts +++ b/src/__tests__/architecture/no-vc-in-site-shell.test.ts @@ -78,10 +78,10 @@ describe('Gate SH-3 — site repository does not read/write visualComponents', ( // 4 — CMS adapter fetches/saves VCs separately from the shell // --------------------------------------------------------------------------- -describe('Gate SH-4 — CMS adapter uses /components endpoint for VCs', () => { - it('cms.ts fetches /components endpoint (not embedded in /site GET)', () => { +describe('Gate SH-4 — CMS adapter loads a consistent assembled document', () => { + it('cms.ts loads /site-document and excludes components from stored shell writes', () => { const source = readFileSync(CMS_ADAPTER, 'utf-8') - expect(source).toMatch(/\/components/) + expect(source).toMatch(/site-document/) }) it('cms.ts calls validateVisualComponents', () => { @@ -89,9 +89,10 @@ describe('Gate SH-4 — CMS adapter uses /components endpoint for VCs', () => { expect(source).toMatch(/validateVisualComponents/) }) - it('cms.ts calls visualComponentFromRow', () => { + it('the assembled document loader validates components at its wire boundary', () => { const source = readFileSync(CMS_ADAPTER, 'utf-8') - expect(source).toMatch(/visualComponentFromRow/) + expect(source).toMatch(/validateVisualComponents/) + expect(source).toMatch(/site-document/) }) }) @@ -124,7 +125,7 @@ describe('Gate SH-6 — /admin/api/cms/components handler exists', () => { it('components handler matches /admin/api/cms/components path', () => { const source = readFileSync(COMPONENTS_HANDLER, 'utf-8') - expect(source).toMatch(/\/components/) + expect(source).toMatch(/site-document/) }) it('components handler serves GET; component writes live in the site-document save', () => { diff --git a/src/__tests__/architecture/plugin-cms-content-surface.test.ts b/src/__tests__/architecture/plugin-cms-content-surface.test.ts index ff9631081..6c3263c50 100644 --- a/src/__tests__/architecture/plugin-cms-content-surface.test.ts +++ b/src/__tests__/architecture/plugin-cms-content-surface.test.ts @@ -27,7 +27,9 @@ describe('cms.content plugin API surface', () => { expect(source).toContain('content: {') expect(source).toContain('tables: {') expect(source).toContain('table: (slug: string)') - expect(source).toContain('tree: (entryId: string, fieldId: string)') + expect(source).toContain('tree: (entryId: string, fieldId: string, options?: ContentLocaleOptions)') + expect(source).toContain('locales: { list:') + expect(source).toContain('unpublish:') expect(source).toContain('search:') expect(source).toContain('getPublishedSnapshot:') expect(source).toContain('republishAll:') diff --git a/src/__tests__/architecture/plugin-content-access-enforced.test.ts b/src/__tests__/architecture/plugin-content-access-enforced.test.ts index 307f0a3c0..31596fa94 100644 --- a/src/__tests__/architecture/plugin-content-access-enforced.test.ts +++ b/src/__tests__/architecture/plugin-content-access-enforced.test.ts @@ -71,6 +71,7 @@ describe('plugin content handlers — access enforced', () => { // Cross-table handlers (List on tables, search, republishAll) are // intentionally allowlisted — their authorization model differs. const crossTableAllowlist = new Set([ + 'handleContentLocalesList', // registry metadata; requires cms.content.read 'handleContentTablesList', // intersects with allowlist itself 'handleContentTablesCreate', // gated by cms.content.tables.manage 'handleContentRepublishAll', // operates on all published pages diff --git a/src/__tests__/architecture/plugin-rpc-target-registry.test.ts b/src/__tests__/architecture/plugin-rpc-target-registry.test.ts index 24a09921f..ba1189bc9 100644 --- a/src/__tests__/architecture/plugin-rpc-target-registry.test.ts +++ b/src/__tests__/architecture/plugin-rpc-target-registry.test.ts @@ -66,6 +66,7 @@ const EXPECTED_TARGET_PERMISSIONS: Record<string, string> = { 'cms.media.registerStorageAdapter': 'media.storage.adapter', 'cms.media.registerUrlTransformer': 'media.url.transform', 'cms.media.registerVariantDelegate': 'media.variant.delegate', + 'cms.content.locales.list': 'cms.content.read', 'cms.content.tables.list': 'cms.content.read', 'cms.content.tables.get': 'cms.content.read', 'cms.content.tables.create': 'cms.content.tables.manage', @@ -76,6 +77,7 @@ const EXPECTED_TARGET_PERMISSIONS: Record<string, string> = { 'cms.content.entries.update': 'cms.content.write', 'cms.content.entries.delete': 'cms.content.delete', 'cms.content.entries.publish': 'cms.content.publish', + 'cms.content.entries.unpublish': 'cms.content.publish', 'cms.content.entries.moveTable': 'cms.content.write', 'cms.content.entries.createMany': 'cms.content.write', 'cms.content.entries.updateMany': 'cms.content.write', diff --git a/src/__tests__/architecture/plugin-sandbox-invariants.test.ts b/src/__tests__/architecture/plugin-sandbox-invariants.test.ts index 796d0c799..9244cf81f 100644 --- a/src/__tests__/architecture/plugin-sandbox-invariants.test.ts +++ b/src/__tests__/architecture/plugin-sandbox-invariants.test.ts @@ -180,8 +180,10 @@ describe('plugin sandbox invariants', () => { 'cms.content.entries.list', 'cms.content.entries.moveTable', 'cms.content.entries.publish', + 'cms.content.entries.unpublish', 'cms.content.entries.update', 'cms.content.entries.updateMany', + 'cms.content.locales.list', 'cms.content.republishAll', 'cms.content.search', 'cms.content.snapshot', diff --git a/src/__tests__/architecture/static-artefact-served-before-render.test.ts b/src/__tests__/architecture/static-artefact-served-before-render.test.ts index 8d18d7c18..8f380292c 100644 --- a/src/__tests__/architecture/static-artefact-served-before-render.test.ts +++ b/src/__tests__/architecture/static-artefact-served-before-render.test.ts @@ -1,16 +1,4 @@ -/** - * Architecture gate: the Layer A disk fast-path (`readArtefact`) is invoked - * BEFORE the live resolver (`resolvePublicRoute`) in `publicRouter.ts`. - * - * The central safety property of Layer A is that pre-rendered HTML is served - * at ≤ 5 ms TTFB with no DB hit — only if `readArtefact` runs before - * `resolvePublicRoute` can the resolver's DB query be avoided on a cache hit. - * - * A simple source-position check is sufficient here; the functional behaviour - * is covered by `publishStaticArtefact.test.ts`. The regex approach avoids an - * AST parser dependency while still being robust enough to catch a source move - * vs. a comment. - */ +/** The live manifest gates disk before expensive snapshot hydration/rendering. */ import { describe, expect, it } from 'bun:test' import { readFile } from 'node:fs/promises' @@ -24,21 +12,16 @@ async function read(relative: string): Promise<string> { } describe('static-artefact-served-before-render', () => { - it('readArtefact is called before resolvePublicRoute in publicRouter.ts', async () => { + it('visibility is resolved before disk and disk is read before snapshot hydration', async () => { const source = await read('server/publish/publicRouter.ts') - // Both must be present — search for the *call* sites, not declarations. - // `resolvePublicRoute` is declared in the same file so a bare - // `indexOf('resolvePublicRoute(')` would match the declaration first; - // `await resolvePublicRoute(` is guaranteed to be the call site. - const artefactIdx = source.indexOf('readArtefact(') - const resolverIdx = source.indexOf('await resolvePublicRoute(') - - expect(artefactIdx).toBeGreaterThan(-1) - expect(resolverIdx).toBeGreaterThan(-1) - - // Artefact lookup must precede the resolver call - expect(artefactIdx).toBeLessThan(resolverIdx) + const visibility = source.indexOf('const route = resolvePublishedRoute(') + const artefact = source.indexOf('await readArtefact(') + const hydration = source.indexOf('await readPublishedRouteContext(') + expect(visibility).toBeGreaterThan(-1) + expect(artefact).toBeGreaterThan(visibility) + expect(hydration).toBeGreaterThan(artefact) + expect(source).toContain('arePublishedArtefactsCurrent()') }) it('readArtefact is imported in publicRouter.ts from staticArtefact', async () => { @@ -53,20 +36,14 @@ describe('static-artefact-served-before-render', () => { // '' and serve the artefact; only render-affecting (loop pagination) params // fall through to the live renderer (ISS-032). expect(source).toContain('canonicalRenderQuery(url.searchParams)') - expect(source).toContain("canonicalQuery === ''") + expect(source).toContain("queryString === ''") }) it('the disk path does not call applyPublishedHtmlPipeline at request time', async () => { const source = await read('server/publish/publicRouter.ts') - // The pipeline call must only appear AFTER resolvePublicRoute, not in the - // disk fast-path branch. Verify by checking that applyPublishedHtmlPipeline - // does not appear before the resolver call position. const artefactReturn = source.indexOf('return new Response(html,') - const resolverIdx = source.indexOf('resolvePublicRoute(') - const pipelineIdx = source.indexOf('applyPublishedHtmlPipeline(') - - // The disk path's early return does not include applyPublishedHtmlPipeline - // (the pipeline call only exists in the live-render branch below the resolver) - expect(pipelineIdx).toBeGreaterThan(resolverIdx) + const pipeline = source.indexOf('applyPublishedHtmlPipeline(') + expect(artefactReturn).toBeGreaterThan(-1) + expect(pipeline).toBeGreaterThan(artefactReturn) }) }) diff --git a/src/__tests__/collab/inlineEditRemoteMerge.test.tsx b/src/__tests__/collab/inlineEditRemoteMerge.test.tsx index 5c2ae07fd..90a9f509c 100644 --- a/src/__tests__/collab/inlineEditRemoteMerge.test.tsx +++ b/src/__tests__/collab/inlineEditRemoteMerge.test.tsx @@ -19,7 +19,7 @@ import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react import * as Y from 'yjs' import { CanvasTransformLayer } from '@site/canvas/CanvasTransformLayer' import { useEditorStore } from '@site/store/store' -import { encodeCollabDocId, LOCAL_ORIGIN, seedPageDoc, treeMap } from '@core/collab' +import { applyLocalizationDraftToDoc, encodeCollabDocId, LOCAL_ORIGIN, projectLocalizationDoc, seedLocalizationDoc, seedPageDoc, treeMap } from '@core/collab' import { collabDocFor } from '@site/store/slices/site/collabBinding' import { attachInlineEditRemoteMerge, @@ -32,6 +32,27 @@ import '@modules/base' const originalFetch = globalThis.fetch +it('merges inherited source text during editing, then follows an explicit locale override and its reset', () => { + const source = new Y.Doc() + const locale = new Y.Doc() + seedLocalizationDoc(source, { cells: { body: { nodes: { text: { props: { text: 'Hello' } } } } }, slug: 'index' }) + seedLocalizationDoc(locale, { cells: {}, slug: 'index' }) + const el = document.createElement('div') + seedInlineEditableContent(el, 'Hello') + const detach = attachInlineEditRemoteMerge({ el, doc: locale, fallbackDocs: [source], nodeId: 'text', prop: 'text' }) + applyLocalizationDraftToDoc(source, projectLocalizationDoc(source), { cells: { body: { nodes: { text: { props: { text: 'Hello world' } } } } }, slug: 'index' }, 'remote') + expect(el.textContent).toBe('Hello world') + applyLocalizationDraftToDoc(locale, projectLocalizationDoc(locale), { cells: { body: { nodes: { text: { props: { text: 'Hallo' } } } } }, slug: 'index' }, 'remote') + expect(el.textContent).toBe('Hallo') + applyLocalizationDraftToDoc(source, projectLocalizationDoc(source), { cells: { body: { nodes: { text: { props: { text: 'New source' } } } } }, slug: 'index' }, 'remote') + expect(el.textContent).toBe('Hallo') + applyLocalizationDraftToDoc(locale, projectLocalizationDoc(locale), { cells: {}, slug: 'index' }, 'remote') + expect(el.textContent).toBe('New source') + detach() + source.destroy() + locale.destroy() +}) + function yTextOf(doc: Y.Doc, nodeId: string): Y.Text { const nodes = treeMap(doc).get('nodes') as Y.Map<unknown> const node = nodes.get(nodeId) as Y.Map<unknown> diff --git a/src/__tests__/collab/localization.test.ts b/src/__tests__/collab/localization.test.ts new file mode 100644 index 000000000..8476d1fb5 --- /dev/null +++ b/src/__tests__/collab/localization.test.ts @@ -0,0 +1,238 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import * as Y from 'yjs' +import '@modules/base' +import { captureSiteLocalization, projectSiteLocale } from '@core/localization' +import { applyLocalizationDraftToDoc, encodeCollabDocId, metaMap, parseCollabDocId, projectLocalizationDoc, projectPageDoc, seedLocalizationDoc, treeMap, LOCAL_ORIGIN } from '@core/collab' +import type { SiteDocument } from '@core/page-tree' +import type { ContentLocalizationDraftInput } from '@core/localization-schema' +import { useEditorStore } from '@site/store/store' +import { collabDocFor } from '@site/store/slices/site/collabBinding' +import { executeAgentTool } from '@site/agent' +import { toolLocaleId } from '@core/ai' +import { makeNode, makePage, makeSite, makeVC } from '../fixtures' + +function localizedSite(): SiteDocument { + const page = makePage({ id: 'p1', title: 'Hello', slug: 'index', nodes: { + root: makeNode({ id: 'root', moduleId: 'base.body', children: ['text'] }), + text: makeNode({ id: 'text', moduleId: 'base.text', props: { text: 'Hello', tag: 'p' } }), + } }) + const site = makeSite({ pages: [page] }) + site.localeId = 'en' + site.locales = [ + { id: 'en', code: 'en', name: 'English', isDefault: true, pathPrefix: '', enabled: true, direction: 'ltr' }, + { id: 'de', code: 'de', name: 'Deutsch', isDefault: false, pathPrefix: 'de', enabled: true, direction: 'ltr' }, + ] + site.localization = { + fieldLocalizations: { pages: { title: 'localized', slug: 'localized', body: 'localized', templateEnabled: 'shared', templateTarget: 'shared', templatePriority: 'shared' }, components: { name: 'shared', slug: 'shared', body: 'localized', params: 'shared', classIds: 'shared', parameterDefaults: 'localized' }, layouts: { name: 'localized', slug: 'localized', body: 'localized', classes: 'shared' } }, + rows: { p1: { tableId: 'pages', sharedCells: { body: { rootNodeId: 'root', nodes: page.nodes }, templateEnabled: false }, localizations: { en: { cells: { title: 'Hello', slug: 'index' }, slug: 'index' } } } }, + } + return projectSiteLocale(site, 'en') +} + +afterEach(() => useEditorStore.getState().clearSite()) + +describe('localized collaboration', () => { + it('pins AI commands to a language and reports rejected structural changes', async () => { + useEditorStore.getState().loadSite(localizedSite()) + expect(toolLocaleId({}, { site: { localeId: 'en' } })).toBe('en') + expect(toolLocaleId({ localeId: 'de' }, { localeId: 'en' })).toBe('de') + const mismatch = await executeAgentTool('site_update_node_props', { localeId: 'de', nodeId: 'text', patch: { text: 'Falsch' } }) + expect(mismatch.ok).toBe(false) + expect(useEditorStore.getState().site!.pages[0].nodes.text.props.text).toBe('Hello') + expect((await executeAgentTool('site_select_locale', { localeId: 'de' })).ok).toBe(true) + expect((await executeAgentTool('site_update_node_props', { localeId: 'de', nodeId: 'text', patch: { text: 'Hallo' } })).ok).toBe(true) + expect((await executeAgentTool('site_delete_node', { localeId: 'de', nodeId: 'text' })).ok).toBe(false) + expect((await executeAgentTool('site_update_node_props', { localeId: 'de', nodeId: 'text', patch: { unknownDesignProperty: 'large' } })).ok).toBe(false) + expect(useEditorStore.getState().site!.pages[0].nodes.text.props).toMatchObject({ text: 'Hallo', tag: 'p' }) + }) + it('keeps source template conversion visible after shared and locale projections', async () => { + useEditorStore.getState().loadSite(localizedSite()) + const template = { enabled: true, target: { kind: 'postTypes' as const, tableSlugs: ['posts'] }, priority: 100 } + useEditorStore.getState().convertPageToTemplate('p1', template) + expect(useEditorStore.getState().site!.pages[0].template).toEqual(template) + const shared = collabDocFor('page:p1')! + expect(projectPageDoc(shared, 'p1').template).toEqual(template) + // A peer's unrelated shared update re-projects the canonical row. + shared.transact(() => metaMap(shared).set('ownerUserId', 'peer'), 'remote') + await Promise.resolve() + expect(useEditorStore.getState().site!.pages[0].template).toEqual(template) + const source = collabDocFor(encodeCollabDocId({ kind: 'page', rowId: 'p1', localeId: 'en' }))! + const before = projectLocalizationDoc(source) + applyLocalizationDraftToDoc(source, before, { ...before, cells: { ...before.cells, title: 'Localized template' } }, 'remote') + await Promise.resolve() + expect(useEditorStore.getState().site!.pages[0].template).toEqual(template) + useEditorStore.getState().convertTemplateToPage('p1') + expect(useEditorStore.getState().site!.pages[0].template).toBeUndefined() + shared.transact(() => metaMap(shared).set('ownerUserId', 'another-peer'), 'remote') + await Promise.resolve() + expect(useEditorStore.getState().site!.pages[0].template).toBeUndefined() + }) + + it('encodes locale identity without ambiguous row ids', () => { + const id = encodeCollabDocId({ kind: 'page', rowId: 'page:a', localeId: 'de:at' }) + expect(parseCollabDocId(id)).toEqual({ kind: 'page', rowId: 'page:a', localeId: 'de:at' }) + expect(parseCollabDocId('localization:page:p:de:extra')).toBeNull() + }) + + it('keeps locale text and undo separate while retaining shared layout', () => { + const store = useEditorStore + store.getState().loadSite(localizedSite()) + store.getState().updateNodeProps('text', { text: 'Hello source' }) + expect(projectPageDoc(collabDocFor('page:p1')!, 'p1').nodes.text.props.text).toBe('Hello') + store.getState().setActiveLocaleId('de') + expect(store.getState().site!.pages[0].nodes.text.props.text).toBe('Hello source') + expect(store.getState().canUndo).toBe(false) + store.getState().updateNodeProps('text', { text: 'Hallo' }) + const deDoc = collabDocFor(encodeCollabDocId({ kind: 'page', rowId: 'p1', localeId: 'de' }))! + expect(projectLocalizationDoc(deDoc).cells.body).toEqual({ nodes: { text: { props: { text: 'Hallo' } } } }) + store.getState().setActiveLocaleId('en') + expect(store.getState().site!.pages[0].nodes.text.props.text).toBe('Hello source') + store.getState().undo() + expect(store.getState().site!.pages[0].nodes.text.props.text).toBe('Hello') + store.getState().setActiveLocaleId('de') + expect(store.getState().site!.pages[0].nodes.text.props.text).toBe('Hallo') + store.getState().undo() + expect(store.getState().site!.pages[0].nodes.text.props.text).toBe('Hello') + store.getState().redo() + expect(store.getState().site!.pages[0].nodes.text.props.text).toBe('Hallo') + }) + + it('applies remote source edits through inheritance without freezing target cells', async () => { + useEditorStore.getState().loadSite(localizedSite()) + useEditorStore.getState().setActiveLocaleId('de') + const enDoc = collabDocFor(encodeCollabDocId({ kind: 'page', rowId: 'p1', localeId: 'en' }))! + const before = projectLocalizationDoc(enDoc) + applyLocalizationDraftToDoc(enDoc, before, { ...before, cells: { ...before.cells, body: { nodes: { text: { props: { text: 'Changed remotely' } } } } } }, 'remote') + await Promise.resolve() + expect(useEditorStore.getState().site!.pages[0].nodes.text.props.text).toBe('Changed remotely') + expect(useEditorStore.getState().site!.localization!.rows.p1.localizations.de?.cells.body).toBeUndefined() + }) + + it('rejects shared structural changes in target projections and accepts new source nodes', () => { + const source = localizedSite() + const target = projectSiteLocale(source, 'de') + const invalid = structuredClone(target) + invalid.pages[0].nodes.text.classIds = ['other-class'] + expect(() => captureSiteLocalization(target, invalid)).toThrow() + const globalDesign = structuredClone(target) + globalDesign.name = 'Shared rename' + expect(() => captureSiteLocalization(target, globalDesign)).toThrow('source language') + useEditorStore.getState().loadSite(source) + const id = useEditorStore.getState().insertNode('base.text', { text: 'New source', tag: 'p' }, 'root') + useEditorStore.getState().setActiveLocaleId('de') + const tree = useEditorStore.getState().site!.pages[0] + expect(tree.nodes[id].props.text).toBe('New source') + expect(tree.nodes.root.children).toContain(id) + expect(projectPageDoc(collabDocFor('page:p1')!, 'p1').nodes[id].props.text).toBeUndefined() + }) + + it('translates component defaults and instance parameters individually while preserving shared design', () => { + const site = localizedSite() + const vc = makeVC({ id: 'card', name: 'Card', params: [ + { id: 'heading', name: 'Heading', type: 'string', defaultValue: 'Default heading', required: false }, + { id: 'summary', name: 'Summary', type: 'richText', defaultValue: 'Default summary', required: false }, + { id: 'color', name: 'Color', type: 'color', defaultValue: 'red', required: false }, + ] }) + site.visualComponents = [vc] + site.localization!.rows.card = { tableId: 'components', sharedCells: { + name: vc.name, slug: 'card', body: vc.tree, params: vc.params, classIds: [], + }, localizations: {} } + site.pages[0].nodes.ref = makeNode({ id: 'ref', moduleId: 'base.visual-component-ref', props: { + componentId: 'card', propOverrides: { heading: 'Source heading', summary: 'Source summary', color: 'blue' }, + } }) + site.pages[0].nodes.root.children.push('ref') + site.localization!.rows.p1.sharedCells.body = { rootNodeId: 'root', nodes: site.pages[0].nodes } + useEditorStore.getState().loadSite(site) + useEditorStore.getState().setActiveLocaleId('de') + useEditorStore.getState().updateParamDefaultValue('card', 'heading', 'Standardüberschrift') + expect(useEditorStore.getState().site!.visualComponents[0].params[0].defaultValue).toBe('Standardüberschrift') + expect(useEditorStore.getState().site!.localization!.rows.card.localizations.de.cells.parameterDefaults).toEqual({ heading: 'Standardüberschrift' }) + useEditorStore.getState().updateNodeProps('ref', { propOverrides: { heading: 'Überschrift', summary: 'Source summary', color: 'blue' } }) + expect(projectLocalizationDoc(collabDocFor(encodeCollabDocId({ kind: 'page', rowId: 'p1', localeId: 'de' }))!).cells.body) + .toEqual({ nodes: { ref: { props: { propOverrides: { heading: 'Überschrift' } } } } }) + useEditorStore.getState().setActiveLocaleId('en') + useEditorStore.getState().updateParamDefaultValue('card', 'summary', 'New default summary') + useEditorStore.getState().updateNodeProps('ref', { propOverrides: { heading: 'Source heading', summary: 'New source summary', color: 'green' } }) + useEditorStore.getState().setActiveLocaleId('de') + expect(useEditorStore.getState().site!.visualComponents[0].params.map((parameter) => parameter.defaultValue)) + .toEqual(['Standardüberschrift', 'New default summary', 'red']) + expect(useEditorStore.getState().site!.pages[0].nodes.ref.props.propOverrides) + .toEqual({ heading: 'Überschrift', summary: 'New source summary', color: 'green' }) + const before = useEditorStore.getState().site! + const invalid = structuredClone(before) + invalid.pages[0].nodes.ref.props.propOverrides = { color: 'black' } + expect(() => captureSiteLocalization(before, invalid)).toThrow() + invalid.visualComponents[0].params[2].defaultValue = 'black' + expect(() => captureSiteLocalization(before, invalid)).toThrow() + }) + + it('merges concurrent text changes inside the same locale and isolates another locale', () => { + const seed: ContentLocalizationDraftInput = { cells: { body: { nodes: { text: { props: { text: 'hello' } } } } }, slug: 'index' } + const first = new Y.Doc(); seedLocalizationDoc(first, seed) + const second = new Y.Doc(); Y.applyUpdate(second, Y.encodeStateAsUpdate(first)) + const other = new Y.Doc(); seedLocalizationDoc(other, { cells: {}, slug: 'index' }) + applyLocalizationDraftToDoc(first, seed, { ...seed, cells: { body: { nodes: { text: { props: { text: 'hello A' } } } } } }, LOCAL_ORIGIN) + applyLocalizationDraftToDoc(second, seed, { ...seed, cells: { body: { nodes: { text: { props: { text: 'B hello' } } } } } }, LOCAL_ORIGIN) + Y.applyUpdate(first, Y.encodeStateAsUpdate(second)); Y.applyUpdate(second, Y.encodeStateAsUpdate(first)) + expect(projectLocalizationDoc(first)).toEqual(projectLocalizationDoc(second)) + expect(projectLocalizationDoc(first).cells.body).toEqual({ nodes: { text: { props: { text: 'B hello A' } } } }) + expect(projectLocalizationDoc(other).cells).toEqual({}) + first.destroy(); second.destroy(); other.destroy() + }) + + it('preserves concurrent first translations of different nodes in an inherited locale', () => { + const seed: ContentLocalizationDraftInput = { cells: {}, slug: 'index' } + const first = new Y.Doc(); seedLocalizationDoc(first, seed) + const second = new Y.Doc(); Y.applyUpdate(second, Y.encodeStateAsUpdate(first)) + applyLocalizationDraftToDoc(first, seed, { ...seed, cells: { body: { nodes: { heading: { props: { text: 'Überschrift' } } } } } }, LOCAL_ORIGIN) + applyLocalizationDraftToDoc(second, seed, { ...seed, cells: { body: { nodes: { summary: { props: { text: 'Zusammenfassung' } } } } } }, LOCAL_ORIGIN) + Y.applyUpdate(first, Y.encodeStateAsUpdate(second)); Y.applyUpdate(second, Y.encodeStateAsUpdate(first)) + expect(projectLocalizationDoc(first)).toEqual(projectLocalizationDoc(second)) + expect(projectLocalizationDoc(first).cells.body).toEqual({ nodes: { + heading: { props: { text: 'Überschrift' } }, summary: { props: { text: 'Zusammenfassung' } }, + } }) + applyLocalizationDraftToDoc(first, projectLocalizationDoc(first), seed, LOCAL_ORIGIN) + expect(projectLocalizationDoc(first).cells.body).toBeUndefined() + expect(treeMap(first).get('nodes')).toBeInstanceOf(Y.Map) + first.destroy(); second.destroy() + }) + it('merges first translations of separate properties and visibility on one inherited node', () => { + const seed: ContentLocalizationDraftInput = { cells: {}, slug: 'index' } + const first = new Y.Doc(); seedLocalizationDoc(first, seed) + const second = new Y.Doc(); Y.applyUpdate(second, Y.encodeStateAsUpdate(first)) + applyLocalizationDraftToDoc(first, seed, { ...seed, cells: { body: { nodes: { link: { props: { text: 'Lesen' } } } } } }, LOCAL_ORIGIN) + applyLocalizationDraftToDoc(second, seed, { ...seed, cells: { body: { nodes: { link: { props: { href: '/de/article' }, hidden: true } } } } }, LOCAL_ORIGIN) + Y.applyUpdate(first, Y.encodeStateAsUpdate(second)); Y.applyUpdate(second, Y.encodeStateAsUpdate(first)) + expect(projectLocalizationDoc(first)).toEqual(projectLocalizationDoc(second)) + expect(projectLocalizationDoc(first).cells.body).toEqual({ nodes: { + link: { props: { text: 'Lesen', href: '/de/article' }, hidden: true }, + } }) + const previous = projectLocalizationDoc(first) + applyLocalizationDraftToDoc(first, previous, seed, LOCAL_ORIGIN) + applyLocalizationDraftToDoc(second, previous, { ...previous, cells: { body: { nodes: { link: { props: { text: 'Lesen', href: '/de/article', title: 'Mehr erfahren' }, hidden: true } } } } }, LOCAL_ORIGIN) + Y.applyUpdate(first, Y.encodeStateAsUpdate(second)); Y.applyUpdate(second, Y.encodeStateAsUpdate(first)) + expect(projectLocalizationDoc(first).cells.body).toEqual({ nodes: { link: { props: { title: 'Mehr erfahren' } } } }) + expect(projectLocalizationDoc(first)).toEqual(projectLocalizationDoc(second)) + first.destroy(); second.destroy() + }) + + it('merges first translations of independent component defaults and instance parameters', () => { + const seed: ContentLocalizationDraftInput = { cells: {}, slug: 'index' } + const first = new Y.Doc(); seedLocalizationDoc(first, seed) + const second = new Y.Doc(); Y.applyUpdate(second, Y.encodeStateAsUpdate(first)) + const variant = (parameter: string, value: string): ContentLocalizationDraftInput => ({ ...seed, cells: { + parameterDefaults: { [parameter]: value }, + body: { nodes: { component: { props: { propOverrides: { [parameter]: value } } } } }, + } }) + applyLocalizationDraftToDoc(first, seed, variant('title', 'Titel'), LOCAL_ORIGIN) + applyLocalizationDraftToDoc(second, seed, variant('label', 'Ansehen'), LOCAL_ORIGIN) + Y.applyUpdate(first, Y.encodeStateAsUpdate(second)); Y.applyUpdate(second, Y.encodeStateAsUpdate(first)) + expect(projectLocalizationDoc(first)).toEqual(projectLocalizationDoc(second)) + expect(projectLocalizationDoc(first).cells).toEqual({ + parameterDefaults: { title: 'Titel', label: 'Ansehen' }, + body: { nodes: { component: { props: { propOverrides: { title: 'Titel', label: 'Ansehen' } } } } }, + }) + first.destroy(); second.destroy() + }) + +}) diff --git a/src/__tests__/collab/provider.test.ts b/src/__tests__/collab/provider.test.ts index 5fb763ab0..861c47223 100644 --- a/src/__tests__/collab/provider.test.ts +++ b/src/__tests__/collab/provider.test.ts @@ -112,7 +112,7 @@ describe('collab provider', () => { const resets: string[] = [] provider.onReset((docId) => resets.push(docId)) - socket.emit(encodeCollabFrame('page:p1', 'gen-1', FRAME_RESET, new Uint8Array())) + socket.emit(encodeCollabFrame('page:p1', '', FRAME_RESET, new Uint8Array())) expect(resets).toEqual(['page:p1']) // A rebind gets a FRESH doc (the old one was destroyed). const rebound = provider.bind('page:p1') @@ -132,7 +132,7 @@ describe('collab provider', () => { }) // Reset (or any unbind) before the first sync must resolve the promise, // not leave every chained continuation pending forever. - socket.emit(encodeCollabFrame('page:p1', 'gen-1', FRAME_RESET, new Uint8Array())) + socket.emit(encodeCollabFrame('page:p1', '', FRAME_RESET, new Uint8Array())) await binding.whenSynced // resolves instead of hanging the test await Promise.resolve() expect(settled).toBe(true) @@ -140,6 +140,37 @@ describe('collab provider', () => { }) // ── Liveness ────────────────────────────────────────────────────────────── + it('ignores a delayed rejection from the previous generation after rebinding', async () => { + const socket = new FakeSocket() + const provider = createCollabProvider({ createSocket: () => socket }) + socket.open() + const binding = provider.bind('page:p1') + function syncGeneration(generation: string, title: string) { + const server = new Y.Doc() + server.getMap('meta').set('title', title) + const encoder = encoding.createEncoder() + syncProtocol.writeSyncStep2(encoder, server, Y.encodeStateVector(new Y.Doc())) + socket.emit(encodeCollabFrame('page:p1', generation, FRAME_SYNC, encoding.toUint8Array(encoder))) + server.destroy() + } + syncGeneration('old-generation', 'Old') + await binding.whenSynced + socket.emit(encodeCollabFrame('page:p1', '', FRAME_RESET, new Uint8Array())) + const rebound = provider.bind('page:p1') + socket.emit(encodeCollabFrame('page:p1', 'old-generation', FRAME_RESET, new Uint8Array())) + expect(provider.bind('page:p1').doc).toBe(rebound.doc) + expect(rebound.synced).toBe(false) + syncGeneration('new-generation', 'Rewritten') + await rebound.whenSynced + socket.emit(encodeCollabFrame('page:p1', 'old-generation', FRAME_RESET, new Uint8Array())) + expect(provider.bind('page:p1').doc).toBe(rebound.doc) + expect(rebound.doc.getMap('meta').get('title')).toBe('Rewritten') + // A rejection of this live generation still performs the required reset. + socket.emit(encodeCollabFrame('page:p1', 'new-generation', FRAME_RESET, new Uint8Array())) + expect(provider.bind('page:p1').doc).not.toBe(rebound.doc) + provider.destroy() + }) + // `readyState` cannot distinguish a live socket from a black-holed one, and // the write gate refuses edits it cannot deliver — so "connected" has to // mean "answered a ping recently", not "the browser has not noticed yet". diff --git a/src/__tests__/data/contentAdmin.test.tsx b/src/__tests__/data/contentAdmin.test.tsx index f1ee06ef3..d6a05ff90 100644 --- a/src/__tests__/data/contentAdmin.test.tsx +++ b/src/__tests__/data/contentAdmin.test.tsx @@ -1,3 +1,4 @@ +import { SOURCE_LOCALE, makeContentLocalization } from '../fixtures/localization' import { afterEach, beforeEach, describe, expect, it } from 'bun:test' import { readFileSync } from 'node:fs' import { join } from 'node:path' @@ -132,6 +133,11 @@ function makeRow( return { id, tableId, + localeId: SOURCE_LOCALE.id, + sharedCells: {}, + seq: 0, + localization: makeContentLocalization(id, { cells: mergedCells, slug: String(mergedCells.slug), ...(overrides.status === 'published' ? { availability: 'online', activeVersionId: 'version-1' } : {}) }), + publicPath: overrides.status === 'published' ? `/${tableId}/${mergedCells.slug}` : null, cells: mergedCells, slug: typeof mergedCells.slug === 'string' ? mergedCells.slug : 'untitled', status: 'draft', @@ -170,9 +176,13 @@ function json(body: unknown, status = 200) { * `undefined` for non-ambient URLs so per-test handlers stay authoritative. */ function ambientFetchFallback(url: string): Response | undefined { + if (url === '/admin/api/cms/locales') return json({ locales: [SOURCE_LOCALE] }) if (url.endsWith('/admin/api/cms/plugins')) { return json({ plugins: [], adminPages: [] }) } + if (url.endsWith('/admin/api/cms/site-document')) { + return json({ site: { ...makeSite({ name: 'Content Shell Site' }), localeId: 'default', locales: [SOURCE_LOCALE], localization: { rows: {}, fieldLocalizations: {} } }, shellSeq: 0, rowSeqs: {} }) + } if (url.endsWith('/admin/api/cms/site')) { return json({ site: makeSite({ name: 'Content Shell Site' }) }) } @@ -351,7 +361,7 @@ beforeEach(() => { globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { calls.push({ input, init }) - const url = String(input) + const url = String(input).split('?')[0] if (url === '/admin/api/cms/data/tables') { return json({ @@ -423,8 +433,10 @@ beforeEach(() => { if (url === '/admin/api/cms/data/rows/entry_1/publish' && init?.method === 'POST') { return json({ row: putRow({ - ...makeRow('entry_1', 'posts', { title: 'My first post', slug: 'untitled', body: '## Intro', featuredMedia: null, seoTitle: '', seoDescription: '' }), + ...(postsRows.find((row) => row.id === 'entry_1') ?? makeRow('entry_1', 'posts')), status: 'published', + publicPath: '/posts/untitled', + localization: makeContentLocalization('entry_1', { availability: 'online', activeVersionId: 'version-1' }), updatedAt: '2026-05-01T10:02:00.000Z', publishedAt: '2026-05-01T10:02:00.000Z', }), @@ -631,7 +643,7 @@ describe('ContentPage', () => { }) globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input) + const url = String(input).split('?')[0] if (url === '/admin/api/cms/data/tables') { return json({ tables: [makeTable('posts', 'Posts', 'posts', '/posts', 'Post', 'Posts')] }) @@ -713,7 +725,7 @@ describe('ContentPage', () => { it('suppresses the token tooltip while open and inserts populated media and repeater fields', async () => { const defaultFetch = globalThis.fetch globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input) + const url = String(input).split('?')[0] if (url === '/admin/api/cms/data/_meta') { return json({ meta: { @@ -909,7 +921,7 @@ describe('ContentPage', () => { const calls = (globalThis as typeof globalThis & { __contentFetchCalls?: FetchCall[] }).__contentFetchCalls ?? [] await waitFor(() => { expect(calls.some((call) => - String(call.input) === '/admin/api/cms/data/rows/entry_1' && + String(call.input).split('?')[0] === '/admin/api/cms/data/rows/entry_1' && call.init?.method === 'DELETE' )).toBe(true) }) @@ -968,7 +980,7 @@ describe('ContentPage', () => { ;(globalThis as typeof globalThis & { __contentFetchCalls?: FetchCall[] }).__contentFetchCalls = calls globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { calls.push({ input, init }) - const url = String(input) + const url = String(input).split('?')[0] if (url === '/admin/api/cms/data/tables') { return json({ tables: [makeTable('posts', 'Posts', 'posts', '/posts', 'Post', 'Posts')] }) @@ -1039,7 +1051,7 @@ describe('ContentPage', () => { await waitFor(() => { expect(calls.some((call) => - String(call.input) === '/admin/api/cms/data/rows/entry_1/author' && + String(call.input).split('?')[0] === '/admin/api/cms/data/rows/entry_1/author' && call.init?.method === 'PATCH' && call.init?.body === JSON.stringify({ authorUserId: adminAuthor.id }) )).toBe(true) @@ -1066,7 +1078,8 @@ describe('ContentPage', () => { globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { calls.push({ input, init }) - const url = String(input) + const url = String(input).split('?')[0] + if (url === '/admin/api/cms/locales') return json({ locales: [SOURCE_LOCALE] }) const method = init?.method ?? 'GET' if (url === '/admin/api/cms/data/tables' && method === 'GET') { @@ -1132,7 +1145,7 @@ describe('ContentPage', () => { expect(activationResult?.ok).toBe(true) expect(writeResult?.ok).toBe(true) const patchCall = calls.find((call) => - String(call.input) === '/admin/api/cms/data/rows/article_2' && + String(call.input).split('?')[0] === '/admin/api/cms/data/rows/article_2' && call.init?.method === 'PATCH' ) expect(JSON.parse(String(patchCall?.init?.body))).toMatchObject({ @@ -1169,7 +1182,8 @@ describe('ContentPage', () => { const postB = makeRow('post_b', 'posts', { title: 'Other post', slug: 'other-post', seoTitle: '' }) globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input) + const url = String(input).split('?')[0] + if (url === '/admin/api/cms/locales') return json({ locales: [SOURCE_LOCALE] }) const method = init?.method ?? 'GET' if (url === '/admin/api/cms/data/tables' && method === 'GET') { @@ -1317,8 +1331,9 @@ describe('ContentPage', () => { expect(publishedButton.getAttribute('aria-disabled')).toBe('true') const calls = (globalThis as typeof globalThis & { __contentFetchCalls?: FetchCall[] }).__contentFetchCalls ?? [] - const saveCall = calls.find((call) => String(call.input) === '/admin/api/cms/data/rows/entry_1' && call.init?.method === 'PATCH') + const saveCall = calls.find((call) => String(call.input).split('?')[0] === '/admin/api/cms/data/rows/entry_1' && call.init?.method === 'PATCH') expect(saveCall?.init?.body).toBe(JSON.stringify({ + localeId: 'default', cells: { title: 'My first post', slug: 'untitled', @@ -1329,7 +1344,7 @@ describe('ContentPage', () => { }, })) expect(calls.some((call) => - String(call.input) === '/admin/api/cms/data/rows/entry_1/publish' && + String(call.input).split('?')[0] === '/admin/api/cms/data/rows/entry_1/publish' && call.init?.method === 'POST' )).toBe(true) }) @@ -1367,7 +1382,7 @@ describe('ContentPage', () => { ;(globalThis as typeof globalThis & { __contentFetchCalls?: FetchCall[] }).__contentFetchCalls = calls globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { calls.push({ input, init }) - const url = String(input) + const url = String(input).split('?')[0] if (url === '/admin/api/cms/data/tables' && init?.method === 'GET') { return json({ tables: [makeTable('posts', 'Posts', 'posts', '/posts', 'Post', 'Posts')] }) @@ -1426,7 +1441,7 @@ describe('ContentPage', () => { expect(await screen.findByLabelText('Title')).toBeDefined() const createCollectionCall = calls.find((call) => - String(call.input) === '/admin/api/cms/data/tables' && + String(call.input).split('?')[0] === '/admin/api/cms/data/tables' && call.init?.method === 'POST' ) expect(createCollectionCall?.init?.body).toBe(JSON.stringify({ @@ -1447,7 +1462,7 @@ describe('ContentPage', () => { ], })) expect(calls.some((call) => - String(call.input) === '/admin/api/cms/data/tables/products/rows' && + String(call.input).split('?')[0] === '/admin/api/cms/data/tables/products/rows' && call.init?.method === 'POST' )).toBe(true) }) @@ -1457,7 +1472,7 @@ describe('ContentPage', () => { ;(globalThis as typeof globalThis & { __contentFetchCalls?: FetchCall[] }).__contentFetchCalls = calls globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { calls.push({ input, init }) - const url = String(input) + const url = String(input).split('?')[0] if (url === '/admin/api/cms/data/tables') { return json({ @@ -1528,10 +1543,13 @@ describe('ContentPage', () => { fireEvent.click(screen.getByLabelText('Collection')) fireEvent.click(await screen.findByRole('option', { name: 'Products' })) + expect(await screen.findByText(/All language versions will go offline/)).toBeDefined() + expect(calls.some((call) => String(call.input).split('?')[0] === '/admin/api/cms/data/rows/entry_1/table' && call.init?.method === 'PATCH')).toBe(false) + fireEvent.click(screen.getByRole('button', { name: 'Move entry' })) await waitFor(() => { expect(calls.some((call) => - String(call.input) === '/admin/api/cms/data/rows/entry_1/table' && + String(call.input).split('?')[0] === '/admin/api/cms/data/rows/entry_1/table' && call.init?.method === 'PATCH' && call.init?.body === JSON.stringify({ tableId: 'products' }) )).toBe(true) @@ -1547,7 +1565,7 @@ describe('ContentPage', () => { ;(globalThis as typeof globalThis & { __contentFetchCalls?: FetchCall[] }).__contentFetchCalls = calls globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { calls.push({ input, init }) - const url = String(input) + const url = String(input).split('?')[0] if (url === '/admin/api/cms/data/tables' && init?.method === 'GET') { return json({ @@ -1644,7 +1662,7 @@ describe('ContentPage', () => { fireEvent.click(within(menu).getByRole('menuitem', { name: /convert to draft/i })) await waitFor(() => { expect(calls.some((call) => - String(call.input) === '/admin/api/cms/data/rows/entry_2/status' && + String(call.input).split('?')[0] === '/admin/api/cms/data/rows/entry_2/status' && call.init?.method === 'PATCH' && call.init?.body === JSON.stringify({ status: 'draft' }) )).toBe(true) @@ -1661,7 +1679,7 @@ describe('ContentPage', () => { fireEvent.click(within(menu).getByRole('menuitem', { name: /^publish$/i })) await waitFor(() => { expect(calls.some((call) => - String(call.input) === '/admin/api/cms/data/rows/entry_1/publish' && + String(call.input).split('?')[0] === '/admin/api/cms/data/rows/entry_1/publish' && call.init?.method === 'POST' )).toBe(true) }) @@ -1677,9 +1695,10 @@ describe('ContentPage', () => { expect(await within(postsRegion).findByText('Winter sale')).toBeDefined() expect(calls.some((call) => - String(call.input) === '/admin/api/cms/data/rows/entry_1' && + String(call.input).split('?')[0] === '/admin/api/cms/data/rows/entry_1' && call.init?.method === 'PATCH' && call.init?.body === JSON.stringify({ + localeId: 'default', cells: { title: 'Winter sale', slug: 'winter-sale', @@ -1710,7 +1729,7 @@ describe('ContentPage', () => { expect(await within(collectionsRegion).findByText('Catalog')).toBeDefined() expect(calls.some((call) => - String(call.input) === '/admin/api/cms/data/tables/products' && + String(call.input).split('?')[0] === '/admin/api/cms/data/tables/products' && call.init?.method === 'PATCH' && call.init?.body === JSON.stringify({ name: 'Catalog', @@ -1730,7 +1749,7 @@ describe('ContentPage', () => { await waitFor(() => { expect(calls.some((call) => - String(call.input) === '/admin/api/cms/data/rows/entry_1' && + String(call.input).split('?')[0] === '/admin/api/cms/data/rows/entry_1' && call.init?.method === 'DELETE' )).toBe(true) }) @@ -1745,7 +1764,7 @@ describe('ContentPage', () => { await waitFor(() => { expect(calls.some((call) => - String(call.input) === '/admin/api/cms/data/tables/products' && + String(call.input).split('?')[0] === '/admin/api/cms/data/tables/products' && call.init?.method === 'DELETE' )).toBe(true) }) @@ -1773,6 +1792,10 @@ describe('ContentPage', () => { .getByRole('button', { name: /new post/i }), ) + await screen.findByLabelText('Title') + await waitFor(() => expect(screen.getByTestId('toolbar-publish-btn').hasAttribute('disabled')).toBe(false)) + clickToolbarPublish() + await screen.findByRole('button', { name: /^published$/i }) await waitFor(() => { expect(screen.getByRole('button', { name: /more publishing actions/i }).hasAttribute('disabled')).toBe(false) }) @@ -1830,9 +1853,14 @@ describe('ContentPage', () => { // The notch "Media" button opens the workspace media picker. Pick an // image, commit, and confirm the editor surfaces a media node and the // saved draft body cell holds the markdown image line. - fireEvent.click(screen.getByRole('button', { name: /add media/i })) - fireEvent.click(await screen.findByRole('button', { name: /hero\.png/i })) - fireEvent.click(screen.getByRole('button', { name: /use selected/i })) + // Flush the lazy picker mount and its media requests before querying + // the asset grid; the first opening also loads the workspace module. + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /add media/i })) + }) + const mediaPicker = within(await screen.findByRole('dialog', { name: 'Select media' })) + fireEvent.click(await mediaPicker.findByRole('button', { name: /hero\.png/i })) + fireEvent.click(mediaPicker.getByRole('button', { name: /use selected/i })) expect(await screen.findByRole('img', { name: 'hero.png' })).toBeDefined() @@ -1840,8 +1868,9 @@ describe('ContentPage', () => { await screen.findByText('Draft saved') const calls = (globalThis as typeof globalThis & { __contentFetchCalls?: FetchCall[] }).__contentFetchCalls ?? [] - const saveCalls = calls.filter((call) => String(call.input) === '/admin/api/cms/data/rows/entry_1' && call.init?.method === 'PATCH') + const saveCalls = calls.filter((call) => String(call.input).split('?')[0] === '/admin/api/cms/data/rows/entry_1' && call.init?.method === 'PATCH') expect(saveCalls.at(-1)?.init?.body).toBe(JSON.stringify({ + localeId: 'default', cells: { title: 'Untitled', slug: 'untitled', @@ -1881,10 +1910,13 @@ describe('ContentPage', () => { expect(slugInput.disabled).toBe(false) fireEvent.change(slugInput, { target: { value: 'updated slug' } }) - fireEvent.click(screen.getByRole('button', { name: /choose featured media/i })) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /choose featured media/i })) + }) // Workspace-style MediaPickerModal: pick + commit via "Use selected". - fireEvent.click(await screen.findByRole('button', { name: /hero\.png/i })) - fireEvent.click(screen.getByRole('button', { name: /use selected/i })) + const mediaPicker = within(await screen.findByRole('dialog', { name: 'Select media' })) + fireEvent.click(await mediaPicker.findByRole('button', { name: /hero\.png/i })) + fireEvent.click(mediaPicker.getByRole('button', { name: /use selected/i })) clickToolbarSaveDraft() await screen.findByText('Draft saved') @@ -1895,8 +1927,9 @@ describe('ContentPage', () => { await screen.findByText('Unpublished') const calls = (globalThis as typeof globalThis & { __contentFetchCalls?: FetchCall[] }).__contentFetchCalls ?? [] - const saveCalls = calls.filter((call) => String(call.input) === '/admin/api/cms/data/rows/entry_1' && call.init?.method === 'PATCH') + const saveCalls = calls.filter((call) => String(call.input).split('?')[0] === '/admin/api/cms/data/rows/entry_1' && call.init?.method === 'PATCH') expect(saveCalls.at(-1)?.init?.body).toBe(JSON.stringify({ + localeId: 'default', cells: { title: 'My first post', slug: 'updated-slug', @@ -1907,7 +1940,7 @@ describe('ContentPage', () => { }, })) expect(calls.some((call) => - String(call.input) === '/admin/api/cms/data/rows/entry_1/status' && + String(call.input).split('?')[0] === '/admin/api/cms/data/rows/entry_1/status' && call.init?.method === 'PATCH' && call.init?.body === JSON.stringify({ status: 'unpublished' }) )).toBe(true) @@ -1916,7 +1949,7 @@ describe('ContentPage', () => { it('hydrates saved featured media metadata when reopening the content page', async () => { const baseFetch = globalThis.fetch globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input) + const url = String(input).split('?')[0] if (url === '/admin/api/cms/data/tables/posts/rows' && init?.method === 'GET') { return json({ rows: [makeRow('entry_1', 'posts', { @@ -1983,7 +2016,7 @@ describe('ContentPage', () => { }) it('uses the shared data-binding picker instead of inserting a fixed token', () => { - const src = readFileSync(join(process.cwd(), 'src/admin/pages/content/ContentPage.tsx'), 'utf8') + const src = readFileSync(join(process.cwd(), 'src/admin/pages/content/components/ContentTokenPicker/ContentTokenPicker.tsx'), 'utf8') expect(src).toContain("from '@admin/shared/DataBindingPicker'") expect(src).toContain('<DataBindingPicker') diff --git a/src/__tests__/data/newTableSchemaComposer.test.tsx b/src/__tests__/data/newTableSchemaComposer.test.tsx index 8e8a03a3e..5ed664c9c 100644 --- a/src/__tests__/data/newTableSchemaComposer.test.tsx +++ b/src/__tests__/data/newTableSchemaComposer.test.tsx @@ -242,7 +242,7 @@ describe('NewTableDialog schema composer', () => { await user.click(screen.getByRole('button', { name: 'Add field' })) const fieldDialog = screen.getAllByRole('dialog').at(-1)! - await user.click(within(fieldDialog).getByRole('combobox')) + await user.click(within(fieldDialog).getByRole('combobox', { name: 'Type' })) await user.click(screen.getByRole('option', { name: 'Repeater' })) await user.type(within(fieldDialog).getByLabelText(/^Label/), 'Gallery') expect((within(fieldDialog).getByLabelText(/^ID/) as HTMLInputElement).value).toBe('gallery') @@ -272,7 +272,8 @@ describe('NewTableDialog schema composer', () => { type: 'repeater', id: 'gallery', label: 'Gallery', - fields: [{ type: 'text', id: 'caption', label: 'Caption' }], + localization: 'localized', + fields: [{ type: 'text', id: 'caption', label: 'Caption', localization: 'localized' }], }) }) diff --git a/src/__tests__/fixtures/index.ts b/src/__tests__/fixtures/index.ts index 0f9e86512..71a525453 100644 --- a/src/__tests__/fixtures/index.ts +++ b/src/__tests__/fixtures/index.ts @@ -145,6 +145,9 @@ export function makeSite(overrides: Partial<SiteDocument> = {}): SiteDocument { return { id: overrides.id ?? 'site-1', name: overrides.name ?? 'Test SiteDocument', + ...(overrides.localeId !== undefined ? { localeId: overrides.localeId } : {}), + ...(overrides.locales !== undefined ? { locales: overrides.locales } : {}), + ...(overrides.localization !== undefined ? { localization: overrides.localization } : {}), pages: overrides.pages ?? [makePage()], breakpoints: overrides.breakpoints ?? DEFAULT_BREAKPOINTS, settings: overrides.settings ?? structuredClone(DEFAULT_SITE_SETTINGS), diff --git a/src/__tests__/fixtures/localization.ts b/src/__tests__/fixtures/localization.ts new file mode 100644 index 000000000..a8c3e6e92 --- /dev/null +++ b/src/__tests__/fixtures/localization.ts @@ -0,0 +1,15 @@ +import type { ContentLocalization, Locale } from '@core/localization-schema' + +export const SOURCE_LOCALE: Locale = { + id: 'default', code: 'en', name: 'English', pathPrefix: '', isDefault: true, enabled: true, direction: 'ltr', +} + +export function makeContentLocalization(rowId: string, overrides: Partial<ContentLocalization> = {}): ContentLocalization { + return { + rowId, localeId: SOURCE_LOCALE.id, cells: {}, slug: '', availability: 'offline', activeVersionId: null, + scheduledPublishAt: null, scheduledRevision: null, translationMeta: {}, seq: 0, + createdByUserId: null, updatedByUserId: null, publishedByUserId: null, + createdAt: '2026-05-01T10:00:00.000Z', updatedAt: '2026-05-01T10:00:00.000Z', publishedAt: null, + ...overrides, + } +} diff --git a/src/__tests__/helpers/publishingTestDb.ts b/src/__tests__/helpers/publishingTestDb.ts new file mode 100644 index 000000000..777dc7679 --- /dev/null +++ b/src/__tests__/helpers/publishingTestDb.ts @@ -0,0 +1,41 @@ +/** Real repositories and migrations for publication tests; no SQL-text emulator. */ +import { parsePage, type Page, type SiteDocument } from '@core/page-tree' +import { pageToCells } from '@core/data/pageFromRow' +import { visualComponentToCells } from '@core/data/componentFromRow' +import { createDataRow, getDataRow, saveDataRowDraft } from '../../../server/repositories/data' +import { saveDraftSite } from '../../../server/repositories/site' +import { publishDraftSite } from '../../../server/publish/publishSite' +import { createTestDb, type TestDb } from './createTestDb' +import { makeSite } from '../publisher/helpers' + +const cleanups: Array<() => Promise<void>> = [] +export async function cleanupPublishingTestDbs(): Promise<void> { + while (cleanups.length) await cleanups.pop()!() +} + +export async function seedPublishingPage(db: TestDb['db'], page: Page): Promise<void> { + const cells = pageToCells(parsePage(page)) + if (await getDataRow(db, page.id)) await saveDataRowDraft(db, page.id, { cells, slug: page.slug }) + else await createDataRow(db, { id: page.id, tableId: 'pages', cells, slug: page.slug }) +} + +export async function seedPublishingSite(db: TestDb['db'], site: SiteDocument): Promise<void> { + await saveDraftSite(db, site) + for (const page of site.pages) await seedPublishingPage(db, page) + for (const component of site.visualComponents ?? []) { + const cells = visualComponentToCells(component) + await createDataRow(db, { id: component.id, tableId: 'components', cells, slug: component.name }) + } +} + +export async function createPublishingTestDb(site?: SiteDocument | null, publish = true): Promise<TestDb['db']> { + const { db, cleanup } = await createTestDb() + cleanups.push(cleanup) + if (site) { + await seedPublishingSite(db, { ...makeSite({ layouts: [] }), ...site }) + if (publish) await publishDraftSite(db, null, undefined, { + variants: site.pages.map((page) => ({ rowId: page.id, localeId: 'default' })), + }) + } + return db +} diff --git a/src/__tests__/layout/editorLayoutPersistence.test.tsx b/src/__tests__/layout/editorLayoutPersistence.test.tsx index b22f10219..b2baf2bf4 100644 --- a/src/__tests__/layout/editorLayoutPersistence.test.tsx +++ b/src/__tests__/layout/editorLayoutPersistence.test.tsx @@ -1,3 +1,4 @@ +import { SOURCE_LOCALE } from '../fixtures/localization' /** * Editor layout persistence + rail integration tests. * @@ -18,7 +19,6 @@ import { StepUpProvider } from '@admin/shared/StepUp' import { useEditorStore } from '@site/store/store' import { makeNode, makePage, makeSite } from '../fixtures' import type { CmsCurrentUser } from '@core/persistence' -import { pageToCells } from '@core/data/pageFromRow' import '@modules/base/index' const LAYOUT_STORAGE_KEY = 'instatic-editor-layout-v2' @@ -239,42 +239,12 @@ beforeEach(() => { describe('AdminCanvasLayout — CMS site hydration gate', () => { it('keeps the editor shell mounted while the CMS site hydrates', async () => { - const loaded = makeSite({ name: 'Hydrated Site' }) + const loaded = { ...makeSite({ name: 'Hydrated Site' }), localeId: SOURCE_LOCALE.id, locales: [SOURCE_LOCALE], localization: { fieldLocalizations: {}, rows: {} } } const originalFetch = globalThis.fetch globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input) - const { pages, ...shell } = loaded - if (url.includes('/admin/api/cms/pages')) { - const rows = pages.map((page) => ({ - id: page.id, - tableId: 'pages', - cells: pageToCells(page), - slug: page.slug, - status: 'draft', - authorUserId: null, - createdByUserId: null, - updatedByUserId: null, - publishedByUserId: null, - author: null, - createdBy: null, - updatedBy: null, - publishedBy: null, - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - publishedAt: null, - scheduledPublishAt: null, - deletedAt: null, - })) - return new Response(JSON.stringify({ rows }), { status: 200 }) - } - if (url.includes('/admin/api/cms/components')) { - return new Response(JSON.stringify({ rows: [] }), { status: 200 }) - } - if (url.includes('/admin/api/cms/layouts')) { - return new Response(JSON.stringify({ rows: [] }), { status: 200 }) - } - if (url.includes('/admin/api/cms/site')) { - return new Response(JSON.stringify({ site: shell }), { status: 200 }) + if (url.includes('/admin/api/cms/site-document')) { + return new Response(JSON.stringify({ site: loaded, rowSeqs: {}, shellSeq: 0 }), { status: 200 }) } return originalFetch(input, init) }) as typeof fetch @@ -756,3 +726,21 @@ describe('AdminCanvasLayout — permanent panel rail', () => { expect(sidebar.getAttribute('data-active-panel')).toBe('explorer') }) }) + +describe('AdminCanvasLayout — language authoring', () => { + it('retains history and viewport inspection while disabling shared insertion tools', async () => { + loadSiteWithSelectedHeading() + const site = useEditorStore.getState().site! + useEditorStore.setState({ site: { ...site, localeId: 'de', locales: [SOURCE_LOCALE, + { id: 'de', code: 'de', name: 'Deutsch', isDefault: false, enabled: true, pathPrefix: 'de', direction: 'ltr' }, + ] }, activeLocaleId: 'de' }) + renderEditorLayout() + expect(await screen.findByRole('group', { name: 'Undo and redo' })).toBeDefined() + expect(screen.getByRole('button', { name: 'Undo', exact: true })).toBeDefined() + expect(screen.getByRole('button', { name: 'Redo', exact: true })).toBeDefined() + expect(screen.queryByTestId('canvas-notch-add-btn')).toBeNull() + expect(screen.queryByTestId('canvas-notch-container-btn')).toBeNull() + expect(screen.getAllByRole('button', { name: /Switch to .* breakpoint/ }).length).toBeGreaterThan(0) + expect(screen.queryByRole('button', { name: 'Rename Text', exact: true })).toBeNull() + }) +}) diff --git a/src/__tests__/loops/dataRowsCellFilter.test.ts b/src/__tests__/loops/dataRowsCellFilter.test.ts index e7e514007..9d352bbdf 100644 --- a/src/__tests__/loops/dataRowsCellFilter.test.ts +++ b/src/__tests__/loops/dataRowsCellFilter.test.ts @@ -37,7 +37,9 @@ async function seedPost( insert into data_row_versions (id, row_id, version_number, cells_json, slug, published_at, created_at) values (${`${rowId}-v1`}, ${rowId}, ${1}, ${cells}, ${slug}, ${publishedAt}, ${publishedAt}) ` - await db`update data_rows set active_version_id = ${`${rowId}-v1`} where id = ${rowId}` + await db`update data_row_versions set locale_id = 'default', public_path = ${`/posts/${slug}`} where id = ${`${rowId}-v1`}` + await db`insert into data_row_localizations (row_id, locale_id, cells_json, slug, availability, active_version_id) + values (${rowId}, 'default', ${cells}, ${slug}, 'online', ${`${rowId}-v1`})` } async function seedDataRow( @@ -50,6 +52,8 @@ async function seedDataRow( insert into data_rows (id, table_id, cells_json, slug, status, created_at, updated_at) values (${rowId}, ${tableId}, ${cells}, ${slug}, ${'draft'}, ${'2024-01-01T00:00:00Z'}, ${'2024-01-01T00:00:00Z'}) ` + await db`insert into data_row_localizations (row_id, locale_id, cells_json, slug) + values (${rowId}, 'default', ${cells}, ${slug})` } async function slugsWith(tableId: string, cellFilter: CellFilter | null): Promise<string[]> { diff --git a/src/__tests__/loops/dataRowsFetch.test.ts b/src/__tests__/loops/dataRowsFetch.test.ts index 02eb651ae..e55ae911d 100644 --- a/src/__tests__/loops/dataRowsFetch.test.ts +++ b/src/__tests__/loops/dataRowsFetch.test.ts @@ -63,7 +63,9 @@ async function seedPost(db: Db, seed: PostSeed): Promise<void> { (${versionId}, ${seed.rowId}, ${1}, ${JSON.stringify(seed.cells)}, ${seed.slug}, ${seed.publishedByUserId ?? null}, ${seed.versionPublishedAt}, ${seed.versionCreatedAt}) ` - await db`update data_rows set active_version_id = ${versionId} where id = ${seed.rowId}` + await db`update data_row_versions set locale_id = 'default', public_path = ${`/posts/${seed.slug}`} where id = ${versionId}` + await db`insert into data_row_localizations (row_id, locale_id, cells_json, slug, availability, active_version_id) + values (${seed.rowId}, 'default', ${seed.cells}, ${seed.slug}, ${seed.status === 'draft' ? 'offline' : 'online'}, ${versionId})` } interface DataRowSeed { @@ -84,6 +86,8 @@ async function seedDataRow(db: Db, tableId: string, seed: DataRowSeed): Promise< (${seed.rowId}, ${tableId}, ${JSON.stringify(seed.cells)}, ${seed.slug}, ${'draft'}, ${seed.createdAt}, ${seed.updatedAt}, ${seed.deletedAt ?? null}) ` + await db`insert into data_row_localizations (row_id, locale_id, cells_json, slug) + values (${seed.rowId}, 'default', ${seed.cells}, ${seed.slug})` } async function fetchSlugs( @@ -248,9 +252,9 @@ describe('fetchPublishedDataRowItems — post-type ordering', () => { expect(await fetchSlugs(db, 'posts', 'createdAt', 'desc')).toEqual(['bravo', 'charlie', 'alpha']) }) - it('orders by updatedAt in both directions (row updated_at)', async () => { - expect(await fetchSlugs(db, 'posts', 'updatedAt', 'asc')).toEqual(['bravo', 'alpha', 'charlie']) - expect(await fetchSlugs(db, 'posts', 'updatedAt', 'desc')).toEqual(['charlie', 'alpha', 'bravo']) + it('orders by updatedAt using the frozen version timestamp', async () => { + expect(await fetchSlugs(db, 'posts', 'updatedAt', 'asc')).toEqual(['alpha', 'charlie', 'bravo']) + expect(await fetchSlugs(db, 'posts', 'updatedAt', 'desc')).toEqual(['bravo', 'charlie', 'alpha']) }) it('orders by slug in both directions (version slug)', async () => { @@ -341,7 +345,7 @@ describe('fetchPublishedDataRowItems — post-type ordering', () => { expect(f['slug']).toBe('alpha') expect(f['publishedAt']).toBe('2026-01-03T00:00:00.000Z') expect(f['createdAt']).toBe('2026-01-01T00:00:00.000Z') - expect(f['updatedAt']).toBe('2026-01-02T00:00:00.000Z') + expect(f['updatedAt']).toBe('2026-01-01T00:00:00.000Z') expect(f['permalink']).toBe('/posts/alpha') }) }) @@ -493,7 +497,9 @@ describe('fetchPublishedDataRowItems — custom media field resolution', () => { values ('vendor-a-v1', 'vendor-a', 1, ${vendorCells}, 'vendere', '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:00.000Z') ` - await db`update data_rows set active_version_id = 'vendor-a-v1' where id = 'vendor-a'` + await db`update data_row_versions set locale_id = 'default', public_path = '/vendors/vendere' where id = 'vendor-a-v1'` + await db`insert into data_row_localizations (row_id, locale_id, cells_json, slug, availability, active_version_id) + values ('vendor-a', 'default', ${vendorCells}, 'vendere', 'online', 'vendor-a-v1')` }) async function clientFields(rowId: string): Promise<Record<string, unknown>> { diff --git a/src/__tests__/loops/dataRowsLocalization.test.ts b/src/__tests__/loops/dataRowsLocalization.test.ts new file mode 100644 index 000000000..08cf2e823 --- /dev/null +++ b/src/__tests__/loops/dataRowsLocalization.test.ts @@ -0,0 +1,66 @@ +import { expect, test } from 'bun:test' +import { createTestDb } from '../helpers/createTestDb' +import { createDataRow, createDataTable, saveDataRowDraft, updateDataTable, getDataRow } from '../../../server/repositories/data' +import { createLocale, saveContentLocalizationDraft, setContentLocalizationPublishedVersion, setContentLocalizationAvailability } from '../../../server/repositories/localization' +import { fetchPublishedDataRowItems } from '@core/loops/sources/dataRows' + +test('a published translation stays in its list after the source is withdrawn and draft paths change', async () => { + const { db, cleanup } = await createTestDb() + try { + const locale = await createLocale(db, { code: 'de', name: 'Deutsch', pathPrefix: 'de', enabled: true, direction: 'ltr' }) + const row = await createDataRow(db, { tableId: 'posts', cells: { title: 'English', slug: 'english' }, slug: 'english' }) + await saveContentLocalizationDraft(db, row.id, locale.id, { cells: { title: 'Deutsch', slug: 'deutsch' }, slug: 'deutsch' }) + await db`insert into data_row_versions (id, row_id, locale_id, public_path, version_number, cells_json, slug) + values ('german-release', ${row.id}, ${locale.id}, '/de/beitraege/deutsch', 1, ${{ title: 'Veröffentlicht' }}, 'deutsch')` + await setContentLocalizationPublishedVersion(db, row.id, locale.id, 'german-release') + await setContentLocalizationAvailability(db, row.id, 'default', 'offline') + await saveDataRowDraft(db, row.id, { localeId: locale.id, cells: { title: 'Unpublished draft', slug: 'new-path' }, slug: 'new-path' }) + const result = await fetchPublishedDataRowItems(db, { localeId: locale.id, tableId: 'posts', orderBy: 'slug', direction: 'asc', limit: 1, offset: 0 }) + expect(result.totalItems).toBe(1) + expect(result.items[0]?.fields.title).toBe('Veröffentlicht') + expect(result.items[0]?.fields.permalink).toBe('/de/beitraege/deutsch') + const source = await fetchPublishedDataRowItems(db, { localeId: 'default', tableId: 'posts', orderBy: 'slug', direction: 'asc', limit: 1, offset: 0 }) + expect(source).toEqual({ items: [], totalItems: 0 }) + await setContentLocalizationAvailability(db, row.id, locale.id, 'offline') + const withdrawn = await fetchPublishedDataRowItems(db, { localeId: locale.id, tableId: 'posts', orderBy: 'slug', direction: 'asc', limit: 1, offset: 0 }) + expect(withdrawn).toEqual({ items: [], totalItems: 0 }) + } finally { await cleanup() } +}) + +test('localized data fields resolve explicit null before filtering, counting and pagination', async () => { + const { db, cleanup } = await createTestDb() + try { + const locale = await createLocale(db, { code: 'de', name: 'Deutsch', pathPrefix: 'de', enabled: true, direction: 'ltr' }) + const table = await createDataTable(db, { name: 'Logos', slug: 'logos', kind: 'data', singularLabel: 'Logo', pluralLabel: 'Logos', fields: [ + { id: 'title', label: 'Title', type: 'text', localization: 'localized' }, + { id: 'rank', label: 'Rank', type: 'number', localization: 'shared' }, + ] }) + const row = await createDataRow(db, { tableId: table.id, cells: { title: 'Source', rank: 3 }, slug: 'logo' }) + await saveDataRowDraft(db, row.id, { localeId: locale.id, cells: { title: null, rank: 3 }, slug: 'logo' }) + const result = await fetchPublishedDataRowItems(db, { + localeId: locale.id, tableId: table.id, orderBy: 'cell:rank', direction: 'asc', limit: 1, offset: 0, + cellFilter: { field: 'title', operator: 'isEmpty', value: '' }, + }) + expect(result.totalItems).toBe(1) + expect(result.items[0]?.fields.title).toBeNull() + expect(result.items[0]?.fields.rank).toBe(3) + } finally { await cleanup() } +}) + +test('changing a field between shared and translated preserves the source and existing translations', async () => { + const { db, cleanup } = await createTestDb() + try { + const locale = await createLocale(db, { code: 'de', name: 'Deutsch', pathPrefix: 'de', enabled: true, direction: 'ltr' }) + const table = await createDataTable(db, { name: 'Details', slug: 'details', kind: 'data', singularLabel: 'Detail', pluralLabel: 'Details', fields: [ + { id: 'name', label: 'Name', type: 'text', localization: 'shared' }, + ] }) + const row = await createDataRow(db, { tableId: table.id, cells: { name: 'Source name' }, slug: '' }) + await updateDataTable(db, table.id, { fields: [{ id: 'name', label: 'Name', type: 'text', localization: 'localized' }] }) + expect((await getDataRow(db, row.id, locale.id))?.cells.name).toBe('Source name') + await saveDataRowDraft(db, row.id, { localeId: locale.id, cells: { name: 'Übersetzt' }, slug: '' }) + await updateDataTable(db, table.id, { fields: [{ id: 'name', label: 'Name', type: 'text', localization: 'shared' }] }) + expect((await getDataRow(db, row.id, locale.id))?.cells.name).toBe('Source name') + await updateDataTable(db, table.id, { fields: [{ id: 'name', label: 'Name', type: 'text', localization: 'localized' }] }) + expect((await getDataRow(db, row.id, locale.id))?.cells.name).toBe('Übersetzt') + } finally { await cleanup() } +}) diff --git a/src/__tests__/loops/sitePagesLoopItemParity.test.ts b/src/__tests__/loops/sitePagesLoopItemParity.test.ts index 74e2b2518..cb54c5811 100644 --- a/src/__tests__/loops/sitePagesLoopItemParity.test.ts +++ b/src/__tests__/loops/sitePagesLoopItemParity.test.ts @@ -67,7 +67,7 @@ describe('site.pages loop-item parity (canvas preview ↔ engine source)', () => expect(canvasPath(PAGES, filters, limit)).toEqual(enginePreview) }) - it('matches the engine fetch() projection (definition order)', async () => { + it('matches the engine public fetch projection while excluding technical templates)', async () => { const filters = {} const engineFetch = await SitePagesSource.fetch({ site: site(PAGES), @@ -77,7 +77,8 @@ describe('site.pages loop-item parity (canvas preview ↔ engine source)', () => offset: 0, limit: 10, }) - expect(canvasPath(PAGES, filters, 10)).toEqual(engineFetch.items) + expect(canvasPath(PAGES, { excludeTemplates: true }, 10)).toEqual(engineFetch.items) + expect(engineFetch.items.map((item) => item.id)).toEqual(['a', 'b', 'c']) }) it('agrees on permalink normalization (index → /, bare slug → /slug)', () => { diff --git a/src/__tests__/persistence/cmsAdapter.test.ts b/src/__tests__/persistence/cmsAdapter.test.ts index a5450e452..d48dceb26 100644 --- a/src/__tests__/persistence/cmsAdapter.test.ts +++ b/src/__tests__/persistence/cmsAdapter.test.ts @@ -1,3 +1,4 @@ +import { SOURCE_LOCALE } from '../fixtures/localization' import { describe, expect, it } from 'bun:test' import type { Page, SiteDocument } from '@core/page-tree' import type { VisualComponent } from '@core/visualComponents' @@ -68,6 +69,7 @@ function makeLayout(id: string, name: string): SavedLayout { function site(): SiteDocument { return { + localeId: 'default', locales: [SOURCE_LOCALE], localization: { rows: {}, fieldLocalizations: {} }, id: 'project_1', name: 'CMS Site', pages: [makePage('page_home', 'index')], @@ -92,14 +94,14 @@ describe('CmsAdapter', () => { const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [] const adapter = new CmsAdapter(async (input, init) => { calls.push({ input, init }) - return new Response(JSON.stringify({ site: site() }), { status: 200 }) + return new Response(JSON.stringify({ site: site(), rowSeqs: {}, shellSeq: 0 }), { status: 200 }) }) const loaded = await adapter.loadSite('ignored-in-single-site-mode') expect(loaded?.site.id).toBe('project_1') expect(calls[0]).toMatchObject({ - input: '/admin/api/cms/site', + input: '/admin/api/cms/site-document', init: { method: 'GET', credentials: 'include' }, }) }) @@ -360,23 +362,8 @@ describe('CmsAdapter conflict protocol', () => { it('loadSite returns per-row seqs and the shell seq alongside the document', async () => { const adapter = new CmsAdapter(async (input) => { - const url = String(input) - if (url.endsWith('/site')) { - return new Response(JSON.stringify({ site: site(), seq: 3 }), { status: 200 }) - } - if (url.endsWith('/pages')) { - return new Response(JSON.stringify({ - rows: [{ - id: 'page_home', tableId: 'pages', slug: 'index', status: 'draft', seq: 2, - cells: { title: 'index', slug: 'index', body: { rootNodeId: 'root', nodes: { root: { id: 'root', moduleId: 'base.body', props: {}, breakpointOverrides: {}, children: [] } } } }, - authorUserId: null, createdByUserId: null, updatedByUserId: null, publishedByUserId: null, - author: null, createdBy: null, updatedBy: null, publishedBy: null, - createdAt: '2026-01-01', updatedAt: '2026-01-01', publishedAt: null, - scheduledPublishAt: null, deletedAt: null, - }], - }), { status: 200 }) - } - return new Response(JSON.stringify({ rows: [] }), { status: 200 }) + expect(String(input)).toBe('/admin/api/cms/site-document') + return new Response(JSON.stringify({ site: site(), shellSeq: 3, rowSeqs: { page_home: 2 } }), { status: 200 }) }) const loaded = await adapter.loadSite('default') diff --git a/src/__tests__/persistence/cmsDataClient.test.ts b/src/__tests__/persistence/cmsDataClient.test.ts index 757aa020e..23f236969 100644 --- a/src/__tests__/persistence/cmsDataClient.test.ts +++ b/src/__tests__/persistence/cmsDataClient.test.ts @@ -43,6 +43,7 @@ function rowFixture(overrides: Record<string, unknown> = {}) { return { id: 'row_1', tableId: 'posts', + localeId: 'default', sharedCells: {}, localization: null, publicPath: null, seq: 0, cells: { title: 'Hello', slug: 'hello', body: '', featuredMedia: null, seoTitle: '', seoDescription: '' }, slug: 'hello', status: 'draft', diff --git a/src/__tests__/persistence/cmsPublishClient.test.ts b/src/__tests__/persistence/cmsPublishClient.test.ts index 60bda9725..f016aa126 100644 --- a/src/__tests__/persistence/cmsPublishClient.test.ts +++ b/src/__tests__/persistence/cmsPublishClient.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'bun:test' -import { getCmsPublishStatus, publishCmsDraft } from '@core/persistence/cmsPublish' +import { getCmsPublishStatus, publishCmsDraft } from '@core/persistence' describe('publishCmsDraft', () => { it('posts to the CMS publish endpoint with session credentials', async () => { diff --git a/src/__tests__/publisher/entryRouteRuntimeScope.test.ts b/src/__tests__/publisher/entryRouteRuntimeScope.test.ts index 582ba0d6f..27d52f0fb 100644 --- a/src/__tests__/publisher/entryRouteRuntimeScope.test.ts +++ b/src/__tests__/publisher/entryRouteRuntimeScope.test.ts @@ -15,8 +15,9 @@ * easily: if the oldest page carries no scripts, a scoped script reaches * nothing. */ -import { describe, expect, it } from 'bun:test' -import type { DbClient } from '../../../server/db' +import { afterEach, describe, expect, it } from 'bun:test' +import { cleanupPublishingTestDbs, createPublishingTestDb } from '../helpers/publishingTestDb' +afterEach(cleanupPublishingTestDbs) import type { PublishedPageSnapshot } from '../../../server/repositories/publish' import { snapshotForEntryRoute } from '../../../server/publish/entryTemplateSnapshot' import { makeSite } from '../fixtures' @@ -25,7 +26,7 @@ const postsTarget = { kind: 'postTypes' as const, tableSlugs: ['posts'] } function runtimeAssets(name: string) { return { - scripts: [{ publicPath: `/_instatic/assets/${name}/classic/001-${name}.js`, placement: 'body-end' as const }], + scripts: [{ fileId: name, src: `/_instatic/assets/${name}/classic/001-${name}.js`, placement: 'body-end' as const, timing: 'immediate' as const, priority: 0 }], } } @@ -42,26 +43,23 @@ function siteSnapshot(): PublishedPageSnapshot { return { cmsSnapshotVersion: 1, pageRowId: 'oldest-page', site } } -/** Stands in for the DB: only `getPublishedPageSnapshotById` is reached. */ -function dbReturning(byPageId: Record<string, unknown>): DbClient { - const fake = (async (_strings: TemplateStringsArray, ...params: unknown[]) => { - const pageId = String(params[0]) - const assets = byPageId[pageId] - return assets - ? { rows: [{ row_id: pageId, site_json: makeSite(), runtime_assets_json: assets }], rowCount: 1 } - : { rows: [], rowCount: 0 } - }) as unknown as DbClient - return fake +async function dbReturning(byPageId: Record<string, ReturnType<typeof runtimeAssets>>) { + const snapshot = siteSnapshot() + const db = await createPublishingTestDb(snapshot.site) + for (const [pageId, assets] of Object.entries(byPageId)) { + await db`update data_row_versions set runtime_assets_json = ${assets} where row_id = ${pageId} and locale_id = ${'default'}` + } + return db } describe('entry-route runtime manifest', () => { it('uses the entry template\'s own manifest, not the site snapshot\'s', async () => { const snapshot = siteSnapshot() - const db = dbReturning({ 'post-template': runtimeAssets('template') }) + const db = await dbReturning({ 'post-template': runtimeAssets('template') }) const resolved = await snapshotForEntryRoute(db, snapshot, 'posts') - expect(resolved.runtimeAssets?.scripts[0]?.publicPath).toContain('001-template.js') + expect(resolved.runtimeAssets?.scripts[0]?.src).toContain('001-template.js') }) it('serves no scripts when the template has none, rather than another page\'s', async () => { @@ -70,25 +68,25 @@ describe('entry-route runtime manifest', () => { // passed that straight through, so every entry route on the site served // `001-oldest.js`. const snapshot = { ...siteSnapshot(), runtimeAssets: runtimeAssets('oldest') } - const db = dbReturning({}) + const db = await dbReturning({}) const resolved = await snapshotForEntryRoute(db, snapshot, 'posts') - expect(resolved.runtimeAssets?.scripts[0]?.publicPath ?? '').not.toContain('001-oldest.js') + expect(resolved.runtimeAssets?.scripts[0]?.src ?? '').not.toContain('001-oldest.js') }) it('overrides a stale manifest on the site snapshot with the template\'s', async () => { const snapshot = { ...siteSnapshot(), runtimeAssets: runtimeAssets('oldest') } - const db = dbReturning({ 'post-template': runtimeAssets('template') }) + const db = await dbReturning({ 'post-template': runtimeAssets('template') }) const resolved = await snapshotForEntryRoute(db, snapshot, 'posts') - expect(resolved.runtimeAssets?.scripts[0]?.publicPath).toContain('001-template.js') + expect(resolved.runtimeAssets?.scripts[0]?.src).toContain('001-template.js') }) it('leaves the site document untouched so the resolved chain still applies', async () => { const snapshot = siteSnapshot() - const db = dbReturning({ 'post-template': runtimeAssets('template') }) + const db = await dbReturning({ 'post-template': runtimeAssets('template') }) const resolved = await snapshotForEntryRoute(db, snapshot, 'posts') @@ -97,7 +95,7 @@ describe('entry-route runtime manifest', () => { it('falls back to the site snapshot when the table has no entry template', async () => { const snapshot = siteSnapshot() - const db = dbReturning({ 'post-template': runtimeAssets('template') }) + const db = await dbReturning({ 'post-template': runtimeAssets('template') }) const resolved = await snapshotForEntryRoute(db, snapshot, 'no-such-table') diff --git a/src/__tests__/publisher/formRuntime.test.ts b/src/__tests__/publisher/formRuntime.test.ts index 96d7d82e0..02d3ca4a3 100644 --- a/src/__tests__/publisher/formRuntime.test.ts +++ b/src/__tests__/publisher/formRuntime.test.ts @@ -5,6 +5,8 @@ import { pathToFileURL } from 'node:url' import { stampFormPageTokens } from '../../../server/forms/formRuntime' import { FORM_RUNTIME_JS } from '../../modules/base/forms/formRuntimeJs' +const identity = { pageId: 'page-home', localeId: 'fr', publishedVersionId: 'version-fr', pagePath: '/fr/contact' } + const PAGE_WITH_CMS_FORM = `<!doctype html> <html> <head> @@ -17,7 +19,7 @@ const PAGE_WITH_CMS_FORM = `<!doctype html> describe('stampFormPageTokens', () => { it('stamps a page token and page id onto every CMS-native form tag', () => { - const html = stampFormPageTokens(PAGE_WITH_CMS_FORM, 'page-home') + const html = stampFormPageTokens(PAGE_WITH_CMS_FORM, identity) expect(html).toContain('data-instatic-page-token=') expect(html).toContain('data-instatic-page-id="page-home"') }) @@ -25,15 +27,15 @@ describe('stampFormPageTokens', () => { it('leaves non-CMS forms untouched', () => { const html = stampFormPageTokens( PAGE_WITH_CMS_FORM.replace('data-instatic-form-mode="cms"', 'data-instatic-form-mode="custom"'), - 'page-home', + identity, ) expect(html).not.toContain('data-instatic-page-token=') expect(html).not.toContain('data-instatic-page-id=') }) it('is idempotent', () => { - const once = stampFormPageTokens(PAGE_WITH_CMS_FORM, 'page-home') - const twice = stampFormPageTokens(once, 'page-home') + const once = stampFormPageTokens(PAGE_WITH_CMS_FORM, identity) + const twice = stampFormPageTokens(once, identity) expect(twice).toBe(once) expect(twice.match(/data-instatic-page-token=/g)?.length).toBe(1) }) @@ -42,7 +44,7 @@ describe('stampFormPageTokens', () => { describe('form runtime browser behaviour', () => { it('prefetches the submit challenge on attach and submits via document-level delegation', async () => { document.body.innerHTML = ` - <form data-instatic-form-mode="cms" data-instatic-form-id="contact" data-instatic-page-id="page-home" data-instatic-page-token="page-token"> + <form data-instatic-form-mode="cms" data-instatic-form-id="contact" data-instatic-page-id="page-home" data-instatic-locale-id="fr" data-instatic-published-version-id="version-fr" data-instatic-page-path="/fr/contact" data-instatic-page-token="page-token"> <input name="email" value="ai@example.com"> <button type="submit">Send</button> <p data-instatic-form-message="status"></p> @@ -83,7 +85,7 @@ describe('form runtime browser behaviour', () => { await flushRuntime() expect(calls.map((call) => call.path)).toEqual(['/_instatic/form/challenge']) - expect(calls[0].payload.pageId).toBe('page-home') + expect(calls[0].payload).toMatchObject(identity) const form = document.querySelector('form') expect(form).not.toBeNull() diff --git a/src/__tests__/publisher/localizedRoutes.test.ts b/src/__tests__/publisher/localizedRoutes.test.ts new file mode 100644 index 000000000..e709e8c97 --- /dev/null +++ b/src/__tests__/publisher/localizedRoutes.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'bun:test' +import type { Locale } from '@core/localization-schema' +import { + assertLocalePathPrefixAvailable, + buildLocalizedPath, + createPublishedRouteInventory, + findPublishedContentRoute, + inventoryFromPublishedManifest, + localeForPublishedPath, + LocalizedRouteError, + normalizePublishedPath, + publishedRouteAlternatives, + resolvePublishedRoute, + type PublishedRouteCandidate, +} from '@core/localization-routing' + +const de: Locale = { id: 'de', code: 'de', name: 'Deutsch', pathPrefix: '', isDefault: true, enabled: true, direction: 'ltr' } +const en: Locale = { id: 'en', code: 'en', name: 'English', pathPrefix: 'en', isDefault: false, enabled: true, direction: 'ltr' } +const ar: Locale = { id: 'ar', code: 'ar', name: 'العربية', pathPrefix: 'ar', isDefault: false, enabled: false, direction: 'rtl' } + +function candidate(overrides: Partial<PublishedRouteCandidate> = {}): PublishedRouteCandidate { + return { + contentId: 'about', localeId: de.id, publishedVersionId: 'version-de', + siteSnapshotId: 'snapshot-1', tableId: 'pages', tableSlug: 'pages', + kind: 'page', path: '/ueber-uns', availability: 'online', ...overrides, + } +} + +describe('localized public paths', () => { + it('keeps the original default homepage and creates locale homepages', () => { + expect(buildLocalizedPath(de, 'index')).toBe('/') + expect(buildLocalizedPath(en, 'index')).toBe('/en') + expect(buildLocalizedPath(en, 'company/about')).toBe('/en/company/about') + }) + + it('translates collection route bases while preserving an item named index', () => { + expect(buildLocalizedPath(en, 'launch', '/news')).toBe('/en/news/launch') + expect(buildLocalizedPath(de, 'start', '/neuigkeiten')).toBe('/neuigkeiten/start') + expect(buildLocalizedPath(en, 'index', '/')).toBe('/en/index') + }) + + it('normalizes Unicode and percent-encoded equivalents to one collision key', () => { + expect(normalizePublishedPath('/u\u0308ber-uns/')).toBe('/%C3%BCber-uns') + expect(normalizePublishedPath('/%c3%bcber-uns')).toBe('/%C3%BCber-uns') + }) + + it.each(['/../secret', '/%2e%2e/secret', '/en%2fadmin', '/en%5cadmin', '/bad%zz', '/bad%00', '/x?locale=en', '/x#en', '/en//x'])( + 'rejects ambiguous or unsafe path %s', (path) => { + expect(() => normalizePublishedPath(path)).toThrow(LocalizedRouteError) + }, + ) + + it('rejects locale prefixes and default paths owned by the server', () => { + for (const prefix of ['admin', '_instatic', 'uploads', 'health', 'sitemap.xml']) { + expect(() => buildLocalizedPath({ pathPrefix: prefix }, 'index')).toThrow(LocalizedRouteError) + expect(() => buildLocalizedPath(de, `${prefix}/child`)).toThrow(LocalizedRouteError) + } + }) + + it('rejects adding a locale that claims a currently live default-language path', () => { + expect(() => assertLocalePathPrefixAvailable(en, [{ localeId: de.id, path: '/en/existing' }])).toThrow(LocalizedRouteError) + expect(() => assertLocalePathPrefixAvailable(en, [{ localeId: de.id, path: '/english' }])).not.toThrow() + expect(() => assertLocalePathPrefixAvailable(en, [{ localeId: en.id, path: '/en/about' }])).not.toThrow() + }) + + it('does not route a disabled locale namespace to the default-language 404', () => { + expect(localeForPublishedPath([de, en, ar], '/ar/missing')).toBeNull() + expect(localeForPublishedPath([de, en, ar], '/en/missing')?.id).toBe('en') + expect(localeForPublishedPath([de, en, ar], '/missing')?.id).toBe('de') + }) +}) + +describe('published locale inventory', () => { + it('only exposes independently published online variants', () => { + const inventory = createPublishedRouteInventory([de, en, ar], [ + candidate(), + candidate({ localeId: en.id, availability: 'offline', path: '/en/about', publishedVersionId: 'version-en' }), + candidate({ localeId: ar.id, path: '/ar/about', publishedVersionId: 'version-ar' }), + candidate({ contentId: 'new', path: '/new', publishedVersionId: null }), + ]) + expect(inventory.routes.map((route) => route.path)).toEqual(['/ueber-uns']) + expect(resolvePublishedRoute(inventory, '/en/about')).toBeNull() + expect(findPublishedContentRoute(inventory, 'about', en.id)).toBeNull() + expect(publishedRouteAlternatives(inventory, 'about').map((route) => route.localeId)).toEqual(['de']) + }) + + it('does not require the default variant to be online', () => { + const inventory = createPublishedRouteInventory([de, en], [ + candidate({ availability: 'offline' }), + candidate({ localeId: en.id, path: '/en/about', publishedVersionId: 'version-en' }), + ]) + expect(resolvePublishedRoute(inventory, '/ueber-uns')).toBeNull() + expect(resolvePublishedRoute(inventory, '/en/about')?.localeId).toBe('en') + }) + + it('keeps the immutable published path when the draft prefix has changed', () => { + const inventory = createPublishedRouteInventory([de, { ...en, pathPrefix: 'english' }], [ + candidate({ localeId: en.id, path: '/en/about', publishedVersionId: 'version-en' }), + ]) + expect(resolvePublishedRoute(inventory, '/en/about/')?.publishedVersionId).toBe('version-en') + expect(resolvePublishedRoute(inventory, '/english/about')).toBeNull() + }) + + it('keeps published templates as dependencies without creating their own URL', () => { + const inventory = createPublishedRouteInventory([de], [candidate({ kind: 'template', path: undefined })]) + expect(inventory.routes).toEqual([]) + expect(inventory.dependencies[0].contentId).toBe('about') + expect(resolvePublishedRoute(inventory, '/ueber-uns')).toBeNull() + expect(publishedRouteAlternatives(inventory, 'about')).toEqual([]) + }) + + it('reserves configured language prefixes even while that language is disabled', () => { + for (const path of ['/en', '/en/about', '/ar/private']) { + expect(() => createPublishedRouteInventory([de, en, ar], [candidate({ path })])).toThrow(LocalizedRouteError) + } + }) + + it('rejects a page and a CMS item competing for the same canonical URL', () => { + expect(() => createPublishedRouteInventory([de], [ + candidate({ path: '/news/launch' }), + candidate({ contentId: 'launch', kind: 'row', tableId: 'posts', tableSlug: 'posts', path: '/news/launch/' }), + ])).toThrow(LocalizedRouteError) + }) + + it('rejects conflicting percent-encoded URLs and duplicate live variants', () => { + expect(() => createPublishedRouteInventory([de], [ + candidate({ path: '/über-uns' }), candidate({ contentId: 'other', path: '/%C3%BCber-uns' }), + ])).toThrow(LocalizedRouteError) + expect(() => createPublishedRouteInventory([de], [candidate(), candidate({ path: '/other' })])).toThrow(LocalizedRouteError) + }) + + it('rejects duplicate frozen language codes for the same content after a language is renamed', () => { + const renamed = { ...en, code: 'en-GB' } + const replacement = { ...ar, code: 'en', enabled: true } + const original = candidate({ localeId: en.id, path: '/en/about', languageCode: 'EN' }) + const translated = candidate({ localeId: ar.id, path: '/ar/about', languageCode: 'en' }) + expect(() => createPublishedRouteInventory([de, renamed, replacement], [original, translated])).toThrow(LocalizedRouteError) + expect(() => createPublishedRouteInventory([de, renamed, replacement], [ + original, { ...translated, availability: 'offline' }, + ])).not.toThrow() + expect(() => createPublishedRouteInventory([de, renamed, replacement], [ + original, { ...translated, contentId: 'different' }, + ])).not.toThrow() + }) + + it('restores a validated manifest with the same visibility and indexes', () => { + const original = createPublishedRouteInventory([de, en], [ + candidate(), candidate({ contentId: 'layout', kind: 'template', path: undefined }), + ]) + const restored = inventoryFromPublishedManifest({ locales: original.locales, routes: original.routes, dependencies: original.dependencies }) + expect(resolvePublishedRoute(restored, '/ueber-uns')).toEqual(resolvePublishedRoute(original, '/ueber-uns')) + expect(restored.dependencies).toEqual(original.dependencies) + }) + + it('returns no route for malformed public requests', () => { + const inventory = createPublishedRouteInventory([de], [candidate()]) + expect(resolvePublishedRoute(inventory, '/bad%zz')).toBeNull() + }) +}) diff --git a/src/__tests__/publisher/localizedSeo.test.ts b/src/__tests__/publisher/localizedSeo.test.ts new file mode 100644 index 000000000..f05b598f0 --- /dev/null +++ b/src/__tests__/publisher/localizedSeo.test.ts @@ -0,0 +1,87 @@ +import { normalizePublicOrigin } from '@core/localization-routing' +import { describe, expect, it } from 'bun:test' +import type { Locale } from '@core/localization-schema' +import { createPublishedRouteInventory, type PublishedRouteCandidate } from '@core/localization-routing' +import { + buildLocalizedSeo, + buildLocalizedSitemap, + renderLocalizedSeoLinks, +} from '../../../server/publish/localizedSeo' + +const de: Locale = { id: 'de', code: 'de', name: 'Deutsch', pathPrefix: '', isDefault: true, enabled: true, direction: 'ltr' } +const en: Locale = { id: 'en', code: 'en-GB', name: 'English', pathPrefix: 'en', isDefault: false, enabled: true, direction: 'ltr' } +const ar: Locale = { id: 'ar', code: 'ar', name: 'العربية', pathPrefix: 'ar', isDefault: false, enabled: true, direction: 'rtl' } + +function candidate(locale: Locale, path: string, availability: 'online' | 'offline' = 'online'): PublishedRouteCandidate { + return { + contentId: 'about', localeId: locale.id, publishedVersionId: `v-${locale.id}`, + tableId: 'pages', tableSlug: 'pages', kind: 'page', path, availability, + publishedAt: '2026-09-07T12:00:00.000Z', + } +} + +describe('published localization SEO', () => { + it('emits self-canonicals and identical reciprocal hreflang sets for all online variants', () => { + const inventory = createPublishedRouteInventory([de, en, ar], [ + candidate(de, '/ueber-uns'), candidate(en, '/en/about'), candidate(ar, '/ar/about', 'offline'), + ]) + const german = buildLocalizedSeo(inventory, inventory.routes[0], 'https://example.test')! + const english = buildLocalizedSeo(inventory, inventory.routes[1], 'https://example.test')! + expect(german.canonicalUrl).toBe('https://example.test/ueber-uns') + expect(english.canonicalUrl).toBe('https://example.test/en/about') + expect(german.alternates).toEqual(english.alternates) + expect(english.alternates).toEqual([ + { hrefLang: 'de', url: 'https://example.test/ueber-uns' }, + { hrefLang: 'en-GB', url: 'https://example.test/en/about' }, + { hrefLang: 'x-default', url: 'https://example.test/ueber-uns' }, + ]) + expect(renderLocalizedSeoLinks(english)).toContain('rel="canonical" href="https://example.test/en/about"') + expect(renderLocalizedSeoLinks(english)).not.toContain('/ar/') + }) + + it('does not invent x-default when the primary version is offline', () => { + const inventory = createPublishedRouteInventory([de, ar], [candidate(de, '/ueber-uns', 'offline'), candidate(ar, '/ar/about')]) + const seo = buildLocalizedSeo(inventory, inventory.routes[0], 'https://example.test')! + expect(seo.language).toBe('ar') + expect(seo.direction).toBe('rtl') + expect(seo.alternates).toEqual([{ hrefLang: 'ar', url: 'https://example.test/ar/about' }]) + }) + + it('does not emit SEO for a stale route after that variant was retracted', () => { + const original = createPublishedRouteInventory([de, en], [candidate(de, '/ueber-uns'), candidate(en, '/en/about')]) + const retracted = createPublishedRouteInventory([de, en], [candidate(de, '/ueber-uns'), candidate(en, '/en/about', 'offline')]) + expect(buildLocalizedSeo(retracted, original.routes[1], 'https://example.test')).toBeNull() + }) + + it('uses only online public URLs in the sitemap with each reciprocal alternate set', () => { + const inventory = createPublishedRouteInventory([de, en, ar], [ + candidate(de, '/ueber-uns'), candidate(en, '/en/about'), candidate(ar, '/ar/about', 'offline'), + { ...candidate(de, '/internal-template'), contentId: 'template', kind: 'template', path: undefined }, + ]) + const sitemap = buildLocalizedSitemap(inventory, 'https://example.test') + expect(sitemap.match(/<url>/g)).toHaveLength(2) + expect(sitemap.match(/hreflang="de"/g)).toHaveLength(2) + expect(sitemap.match(/hreflang="en-GB"/g)).toHaveLength(2) + expect(sitemap).toContain('xmlns:xhtml="http://www.w3.org/1999/xhtml"') + expect(sitemap).toContain('<lastmod>2026-09-07T12:00:00.000Z</lastmod>') + expect(sitemap).not.toContain('/ar/') + expect(sitemap).not.toContain('internal-template') + }) + + it('escapes XML attributes and locations without changing the live URL', () => { + const inventory = createPublishedRouteInventory([de], [candidate(de, '/research&development')]) + const sitemap = buildLocalizedSitemap(inventory, 'https://example.test') + expect(sitemap).toContain('/research%26development</loc>') + const links = renderLocalizedSeoLinks({ language: 'de', direction: 'ltr', canonicalUrl: 'https://example.test/?a=1&b="x"', alternates: [] }) + expect(links).toContain('&') + expect(links).toContain('"') + }) + + it('accepts configured HTTP origins and rejects request-like or executable URLs', () => { + expect(normalizePublicOrigin('https://example.test/')).toBe('https://example.test') + expect(normalizePublicOrigin('http://localhost:3000')).toBe('http://localhost:3000') + for (const invalid of ['javascript:alert(1)', '//example.test', 'https://user:secret@example.test', 'https://example.test/subdir', 'https://example.test/?q=x']) { + expect(() => normalizePublicOrigin(invalid)).toThrow() + } + }) +}) diff --git a/src/__tests__/publisher/render.test.ts b/src/__tests__/publisher/render.test.ts index 7f7fddf3d..c5eec1c84 100644 --- a/src/__tests__/publisher/render.test.ts +++ b/src/__tests__/publisher/render.test.ts @@ -1009,11 +1009,12 @@ describe('publishPage', () => { expect(html).not.toContain('zustand') }) - it('uses site metaTitle for <title> when set', () => { + it('uses site metaTitle when the page has no title', () => { const proj = makeSite({ settings: { ...makeSite().settings, metaTitle: 'My Site — Home' }, }) const page = makePage({ root: { moduleId: 'base.text', props: { text: 'Hi' } } }) + page.title = '' const { html } = publishPage(page, proj, registry) expect(html).toContain('<title>My Site — Home') }) @@ -1026,6 +1027,7 @@ describe('publishPage', () => { }, }) const page = makePage({ root: { moduleId: 'base.text', props: { text: 'Hi' } } }) + page.title = '' const { html } = publishPage(page, proj, registry) expect(html).not.toContain('"' } }], diff --git a/src/__tests__/server/authStepUp.test.ts b/src/__tests__/server/authStepUp.test.ts index 3c3e5d449..eb5c57dda 100644 --- a/src/__tests__/server/authStepUp.test.ts +++ b/src/__tests__/server/authStepUp.test.ts @@ -214,9 +214,12 @@ describe('Step-up auth', () => { const { db } = testDb let cookie = await login(db) - for (let i = 0; i < 35; i += 1) { - cookie = await completeStepUp(db, cookie, VALID_LOGIN_PHRASE) - } + // Reach the last allowed attempt without repeating expensive password + // verification 35 times. Without the success reset, the second request + // below exceeds the limit and returns 429. + for (let i = 0; i < 29; i += 1) expect(loginPerIpRateLimit.consume(IP).ok).toBe(true) + cookie = await completeStepUp(db, cookie, VALID_LOGIN_PHRASE) + await completeStepUp(db, cookie, VALID_LOGIN_PHRASE) }) it('POST /step-up for an MFA-enabled account requires a second-factor code', async () => { diff --git a/src/__tests__/server/cmsDataAuthorization.test.ts b/src/__tests__/server/cmsDataAuthorization.test.ts index 61f8434b1..09d96df7c 100644 --- a/src/__tests__/server/cmsDataAuthorization.test.ts +++ b/src/__tests__/server/cmsDataAuthorization.test.ts @@ -523,8 +523,9 @@ describe('CMS data ownership authorization', () => { row: { id: rowId, status: 'unpublished', - publishedAt: null, - publishedByUserId: null, + publishedAt: expect.any(String), + publishedByUserId: expect.any(String), + localization: { availability: 'offline', activeVersionId: expect.any(String) }, }, }) }) diff --git a/src/__tests__/server/cmsHandlers.test.ts b/src/__tests__/server/cmsHandlers.test.ts index 9208b3e6e..fdb63875c 100644 --- a/src/__tests__/server/cmsHandlers.test.ts +++ b/src/__tests__/server/cmsHandlers.test.ts @@ -1,6 +1,7 @@ +import { createTestDb } from '../helpers/createTestDb' import { afterEach, describe, expect, it } from 'bun:test' import { handleCmsRequest } from '../../../server/handlers/cms' -import type { DbClient, DbResult } from '../../../server/db' +import type { DbClient } from '../../../server/db' import { SESSION_COOKIE_NAME } from '../../../server/auth/tokens' import { loginRateLimit } from '../../../server/auth/rateLimit' import { configurePublicOrigins, resetPublicOrigins, stampSocketIp } from '../../../server/auth/security' @@ -9,411 +10,20 @@ afterEach(() => { resetPublicOrigins() }) -function makeFakeDb() { - const site: Record[] = [] - const users: Record[] = [] - const roles: Record[] = [ - { - id: 'owner', - slug: 'owner', - name: 'Owner', - description: '', - is_system: true, - capabilities_json: [ - 'site.read', - 'site.structure.edit', - 'site.content.edit', - 'site.style.edit', - 'pages.edit', - 'pages.publish', - 'content.create', - 'content.edit.own', - 'content.edit.any', - 'content.publish.own', - 'content.publish.any', - 'content.manage', - 'media.read', - 'media.write', - 'media.replace', - 'media.delete', - 'runtime.dependencies', - 'storage.elect', - 'storage.migrate', - 'plugins.read', - 'plugins.configure', - 'plugins.install', - 'plugins.lifecycle', - 'users.manage', - 'roles.manage', - 'audit.read', - ], - }, - { - id: 'admin', - slug: 'admin', - name: 'Admin', - description: '', - is_system: true, - capabilities_json: [ - 'site.read', - 'site.structure.edit', - 'site.content.edit', - 'site.style.edit', - 'pages.edit', - 'pages.publish', - 'content.create', - 'content.edit.own', - 'content.edit.any', - 'content.publish.own', - 'content.publish.any', - 'content.manage', - 'media.read', - 'media.write', - 'media.replace', - 'media.delete', - 'runtime.dependencies', - 'storage.elect', - 'storage.migrate', - 'plugins.read', - 'plugins.configure', - 'plugins.install', - 'plugins.lifecycle', - 'users.manage', - 'audit.read', - ], - }, - { - id: 'member', - slug: 'member', - name: 'Member', - description: '', - is_system: true, - capabilities_json: [], - }, - ] - const sessions: Record[] = [] - const pages: Record[] = [] - const auditEvents: Record[] = [] - const loginAttempts: Record[] = [] - - function joinedUser(user: Record) { - const role = roles.find((candidate) => candidate.id === user.role_id) ?? roles[0] - return { - ...user, - role_slug: role.slug, - role_name: role.name, - role_description: role.description, - role_is_system: role.is_system, - role_capabilities_json: role.capabilities_json, - } - } - - const handle = async >( - strings: TemplateStringsArray, - ...values: unknown[] - ): Promise> => { - // Reconstruct a parameterized SQL string for pattern matching. - const sql = strings.reduce((acc, str, i) => (i === 0 ? str : `${acc}$${i}${str}`), '') - const normalized = sql.replace(/\s+/g, ' ').trim().toLowerCase() - - // getSetupStatus — no values - if (normalized.includes('count(*) as count from site')) { - return { rows: [{ count: site.length } as Row], rowCount: 1 } - } - if (normalized.includes('count(*) as count') && normalized.includes('from users') && normalized.includes('role_id')) { - const count = users.filter((user) => - user.role_id === 'owner' && - user.status === 'active' && - user.deleted_at == null - ).length - return { rows: [{ count } as Row], rowCount: 1 } - } - if (normalized.includes('from roles') && normalized.includes('where id =')) { - const role = roles.find((candidate) => candidate.id === values[0]) - const row = role - ? { - ...role, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - } - : null - return { rows: row ? [row as Row] : [], rowCount: row ? 1 : 0 } - } - // createSite (repositories.ts) — values[0]=name, values[1]=settings - // saveDraftSite (siteRepository.ts) — values[0]=name, values[1]=siteShell (via transaction) - if (normalized.includes('insert into site')) { - const row = { id: 'default', name: values[0], settings_json: values[1] } - const index = site.findIndex((s) => s.id === 'default') - if (index >= 0) site[index] = row - else site.push(row) - return { rows: [], rowCount: 1 } - } - if (normalized.includes('insert into users')) { - const row = { - id: values[0], - email: values[1], - email_normalized: values[2], - display_name: values[3], - password_hash: values[4], - status: values[5], - role_id: values[6], - last_login_at: null, - failed_login_count: 0, - locked_until: null, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - deleted_at: null, - } - users.push(row) - return { rows: [row as Row], rowCount: 1 } - } - // recordFailedLoginAttempt — increments counter and sets locked_until. - // Bind shape: values[0]=lockedUntil (Date|null), values[1]=userId. - if (normalized.includes('update users') && normalized.includes('failed_login_count = failed_login_count + 1')) { - const lockedUntil = values[0] as Date | null - const userId = values[1] - const user = users.find((candidate) => candidate.id === userId && candidate.deleted_at == null) - if (!user) return { rows: [], rowCount: 0 } - user.failed_login_count = Number(user.failed_login_count ?? 0) + 1 - user.locked_until = lockedUntil ? lockedUntil.toISOString() : null - user.updated_at = new Date().toISOString() - return { - rows: [{ failed_login_count: user.failed_login_count, locked_until: user.locked_until } as Row], - rowCount: 1, - } - } - // recordLoginAttempt — append-only audit. Bind shape: - // values[0]=id, values[1]=emailNorm, values[2]=ip, values[3]=userAgent, - // values[4]=userId, values[5]=result. - if (normalized.includes('insert into login_attempts')) { - loginAttempts.push({ - id: values[0], - email_norm: values[1], - ip_address: values[2], - user_agent: values[3], - user_id: values[4], - result: values[5], - attempted_at: new Date().toISOString(), - }) - return { rows: [], rowCount: 1 } - } - // setup.ts seeds the homepage via createDataRow (insert into data_rows) - if (normalized.includes('insert into data_rows')) { - const row = { - id: values[0], - table_id: values[1], - cells_json: values[2], - slug: values[3], - status: values[4], - author_user_id: values[5], - created_by_user_id: values[6], - updated_by_user_id: values[7], - } - const index = pages.findIndex((p) => p.id === row.id) - if (index >= 0) pages[index] = row - else pages.push(row) - return { rows: [{ id: row.id } as Row], rowCount: 1 } - } - // listDataRows for pages — select from data_rows where table_id = 'pages' - if (normalized.includes('from data_rows') && normalized.includes('left join users')) { - return { - rows: pages.map((p) => ({ - ...p, - author_email: null, - author_display_name: null, - author_role_slug: null, - author_role_name: null, - creator_users: null, - created_by_email: null, - created_by_display_name: null, - created_by_role_slug: null, - created_by_role_name: null, - updated_by_email: null, - updated_by_display_name: null, - updated_by_role_slug: null, - updated_by_role_name: null, - publisher_users: null, - published_by_email: null, - published_by_display_name: null, - published_by_role_slug: null, - published_by_role_name: null, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - published_at: null, - deleted_at: null, - })) as Row[], - rowCount: pages.length, - } - } - if (normalized.includes('from users') && normalized.includes('join roles') && normalized.includes('where users.email_normalized')) { - const rows = users - .filter((user) => String(user.email_normalized) === String(values[0]) && user.deleted_at == null) - .map(joinedUser) - return { rows: rows as Row[], rowCount: rows.length } - } - if (normalized.includes('from users') && normalized.includes('join roles') && normalized.includes('where users.id')) { - const userId = values[0] ?? users[users.length - 1]?.id - const rows = users - .filter((user) => String(user.id) === String(userId) && user.deleted_at == null) - .map(joinedUser) - return { rows: rows as Row[], rowCount: rows.length } - } - if (normalized.includes('insert into sessions')) { - const hasStepUpColumn = normalized.includes('step_up_expires_at') - sessions.push({ - id_hash: values[0], - user_id: values[1], - expires_at: values[2], - ip_address: values[3], - user_agent: values[4], - device_label: values[5] ?? '', - mfa_passed_at: values[6] ?? null, - created_at: new Date().toISOString(), - last_seen_at: new Date().toISOString(), - revoked_at: null, - step_up_expires_at: hasStepUpColumn ? values[7] ?? null : null, - }) - return { rows: [], rowCount: 1 } - } - if (normalized.includes('select user_id') && normalized.includes('from sessions')) { - const session = sessions.find((candidate) => - candidate.id_hash === values[0] && candidate.revoked_at == null, - ) - return { - rows: session ? [session as Row] : [], - rowCount: session ? 1 : 0, - } - } - // getSessionStepUpExpiresAt — `select step_up_expires_at from sessions - // where id_hash = $1 and revoked_at is null`. Bind: values[0] = idHash. - if (normalized.includes('select step_up_expires_at') && normalized.includes('from sessions')) { - const session = sessions.find((candidate) => - candidate.id_hash === values[0] && candidate.revoked_at == null, - ) - const row = session ? { step_up_expires_at: session.step_up_expires_at ?? null } : null - return { rows: row ? [row as Row] : [], rowCount: row ? 1 : 0 } - } - // markSessionStepUpFresh — `update sessions set step_up_expires_at = $1 - // where id_hash = $2 and revoked_at is null`. Bind: values[0] = expiresAt, - // values[1] = idHash. - if (normalized.includes('update sessions') && normalized.includes('step_up_expires_at')) { - const session = sessions.find((candidate) => - candidate.id_hash === values[1] && candidate.revoked_at == null, - ) - if (!session) return { rows: [], rowCount: 0 } - session.step_up_expires_at = values[0] instanceof Date - ? values[0].toISOString() - : (values[0] as string | null) - return { rows: [], rowCount: 1 } - } - if (normalized.includes('from sessions') && normalized.includes('join users')) { - const session = sessions.find((candidate) => candidate.id_hash === values[0] && candidate.revoked_at == null) - const user = session ? users.find((candidate) => candidate.id === session.user_id && candidate.status === 'active') : null - const rows = user ? [{ - ...joinedUser(user), - session_mfa_passed_at: session.mfa_passed_at ?? null, - avatar_public_path: null, - }] : [] - return { rows: rows as Row[], rowCount: rows.length } - } - if (normalized.includes('update sessions') && normalized.includes('last_seen_at')) { - return { rows: [], rowCount: 1 } - } - if (normalized.includes('update sessions') && normalized.includes('revoked_at')) { - const now = new Date().toISOString() - if (normalized.includes('where user_id = $1') && normalized.includes('id_hash != $2')) { - let count = 0 - for (const session of sessions) { - if (session.user_id === values[0] && session.id_hash !== values[1] && session.revoked_at == null) { - session.revoked_at = now - count += 1 - } - } - return { rows: [], rowCount: count } - } - if (normalized.includes('where user_id = $1')) { - let count = 0 - for (const session of sessions) { - if (session.user_id === values[0] && session.revoked_at == null) { - session.revoked_at = now - count += 1 - } - } - return { rows: [], rowCount: count } - } - const session = sessions.find((candidate) => candidate.id_hash === values[0]) - if (session) session.revoked_at = now - return { rows: [], rowCount: session ? 1 : 0 } - } - // The full updateUser path (`set email, ..., role_id, updated_at`) must - // be matched BEFORE the `last_login_at` matcher because the RETURNING - // clause of this SQL also mentions `last_login_at`. - if (normalized.includes('update users') && normalized.includes('set email =')) { - const userId = values[7] - const user = users.find((candidate) => candidate.id === userId && candidate.deleted_at == null) - if (!user) return { rows: [], rowCount: 0 } - Object.assign(user, { - email: values[0], - email_normalized: values[1], - display_name: values[2], - password_hash: values[3], - password_updated_at: values[4], - status: values[5], - role_id: values[6], - updated_at: new Date().toISOString(), - }) - return { rows: [user as Row], rowCount: 1 } - } - if (normalized.includes('update users') && normalized.includes('set deleted_at')) { - const user = users.find((candidate) => candidate.id === values[0] && candidate.deleted_at == null) - if (!user) return { rows: [], rowCount: 0 } - user.deleted_at = new Date().toISOString() - return { rows: [], rowCount: 1 } - } - if (normalized.includes('update users') && normalized.includes('last_login_at')) { - // Bind shape now: values[0]=null (for locked_until clear), values[1]=userId. - // Match by trying each value as a candidate user id; production code passes - // userId last but tests should not depend on which slot it occupies. - const user = users.find((candidate) => - values.some((v) => v === candidate.id), - ) - if (user) { - user.last_login_at = new Date().toISOString() - user.failed_login_count = 0 - user.locked_until = null - } - return { rows: [], rowCount: user ? 1 : 0 } - } - if (normalized.includes('insert into audit_events')) { - auditEvents.push({ - id: values[0], - actor_user_id: values[1], - action: values[2], - target_type: values[3], - target_id: values[4], - metadata_json: values[5], - ip_address: values[6], - user_agent: values[7], - }) - return { rows: [], rowCount: 1 } - } - throw new Error(`Unhandled SQL: ${sql}`) - } - - // Repositories that splice shared column lists (users + sessions) issue their - // SELECTs through db.unsafe(rawSql, params). Re-dispatch those through the - // same tagged-template matcher by splitting the raw SQL on its positional - // placeholders ($1.. or ?) so `values` lines up with `params`. - handle.unsafe = async >( - sql: string, - params: unknown[] = [], - ): Promise> => - handle(sql.split(/\$\d+|\?/) as unknown as TemplateStringsArray, ...params) - - handle.transaction = async (cb: (tx: DbClient) => Promise): Promise => - cb(handle as unknown as DbClient) - - return Object.assign(handle as DbClient, { site, users, roles, sessions, pages, auditEvents, loginAttempts }) +const cleanups: Array<() => Promise> = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0)) await cleanup() +}) + +async function makeDb(): Promise { + const test = await createTestDb() + cleanups.push(test.cleanup) + return test.db +} + +async function records(db: DbClient, table: 'site' | 'users' | 'sessions' | 'audit_events') { + const { rows } = await db.unsafe>(`select * from ${table}`) + return rows } async function json(res: Response) { @@ -440,17 +50,14 @@ async function completeStepUp( describe('CMS handlers', () => { it('reports setup status', async () => { - const db = makeFakeDb() + const db = await makeDb() const res = await handleCmsRequest(new Request('http://localhost/admin/api/cms/setup/status'), db) expect(res.status).toBe(200) expect(await json(res)).toEqual({ hasSite: false, hasAdmin: false, hasOwner: false, needsSetup: true }) }) it('creates the first site and owner account', async () => { - // Step 2 of unified-content-storage: the legacy pages table is gone; the - // home page seed will be added back in Step 3 as a data_row in the - // seeded 'pages' data table. For now setup creates the site + owner only. - const db = makeFakeDb() + const db = await makeDb() const res = await handleCmsRequest(new Request('http://localhost/admin/api/cms/setup', { method: 'POST', body: JSON.stringify({ siteName: 'Example', email: 'owner@example.com', password: 'long-enough-password' }), @@ -458,28 +65,18 @@ describe('CMS handlers', () => { }), db) expect(res.status).toBe(201) expect(await json(res)).toMatchObject({ ok: true }) - expect(db.site).toHaveLength(1) - expect(db.users).toHaveLength(1) - expect(db.users[0]).toMatchObject({ email_normalized: 'owner@example.com', role_id: 'owner', status: 'active' }) - expect(db.auditEvents[0]?.ip_address).toBeNull() + expect((await records(db, 'site'))).toHaveLength(1) + expect((await records(db, 'users'))).toHaveLength(1) + expect((await records(db, 'users'))[0]).toMatchObject({ email_normalized: 'owner@example.com', role_id: 'owner', status: 'active' }) + expect((await records(db, 'audit_events'))[0]?.ip_address).toBeNull() }) it('refuses setup after an owner exists', async () => { - const db = makeFakeDb() - db.site.push({ id: 'default', name: 'Existing' }) - db.users.push({ - id: 'owner_1', - email: 'owner@example.com', - email_normalized: 'owner@example.com', - display_name: 'Owner', - password_hash: 'hash', - status: 'active', - role_id: 'owner', - last_login_at: null, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - deleted_at: null, - }) + const db = await makeDb() + await handleCmsRequest(new Request('http://localhost/admin/api/cms/setup', { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ siteName: 'Existing', email: 'owner@example.com', password: 'long-enough-password' }), + }), db) const res = await handleCmsRequest(new Request('http://localhost/admin/api/cms/setup', { method: 'POST', body: JSON.stringify({ siteName: 'Example', email: 'new@example.com', password: 'long-enough-password' }), @@ -489,7 +86,7 @@ describe('CMS handlers', () => { }) it('logs in and sets an HttpOnly session cookie', async () => { - const db = makeFakeDb() + const db = await makeDb() await handleCmsRequest(new Request('http://localhost/admin/api/cms/setup', { method: 'POST', body: JSON.stringify({ siteName: 'Example', email: 'owner@example.com', password: 'long-enough-password' }), @@ -511,13 +108,13 @@ describe('CMS handlers', () => { // Plain HTTP request → cookie must NOT carry the Secure flag, otherwise // browsers reject it. expect(cookie).not.toContain('Secure') - expect(db.sessions).toHaveLength(1) - expect(db.sessions[0]?.ip_address).toBe('203.0.113.77') - expect(db.auditEvents.at(-1)?.ip_address).toBe('203.0.113.77') + expect((await records(db, 'sessions'))).toHaveLength(1) + expect((await records(db, 'sessions'))[0]?.ip_address).toBe('203.0.113.77') + expect((await records(db, 'audit_events')).at(-1)?.ip_address).toBe('203.0.113.77') }) it('returns the current user with role capabilities', async () => { - const db = makeFakeDb() + const db = await makeDb() const email = 'me-owner@example.com' loginRateLimit.reset(`unknown|${email}`) await handleCmsRequest(new Request('http://localhost/admin/api/cms/setup', { @@ -551,7 +148,7 @@ describe('CMS handlers', () => { }) it('keeps owner setup-only when managing users', async () => { - const db = makeFakeDb() + const db = await makeDb() await handleCmsRequest(new Request('http://localhost/admin/api/cms/setup', { method: 'POST', body: JSON.stringify({ siteName: 'Example', email: 'owner-only@example.com', password: 'long-enough-password' }), @@ -579,11 +176,11 @@ describe('CMS handlers', () => { expect(createRes.status).toBe(400) expect(await json(createRes)).toEqual({ error: 'Owner role is setup-only' }) - expect(db.users.filter((user) => user.role_id === 'owner')).toHaveLength(1) + expect((await records(db, 'users')).filter((user) => user.role_id === 'owner')).toHaveLength(1) }) it('prevents assigning the owner role after setup and prevents owner self-demotion', async () => { - const db = makeFakeDb() + const db = await makeDb() await handleCmsRequest(new Request('http://localhost/admin/api/cms/setup', { method: 'POST', body: JSON.stringify({ siteName: 'Example', email: 'owner-role@example.com', password: 'long-enough-password' }), @@ -621,7 +218,7 @@ describe('CMS handlers', () => { expect(assignOwnerRes.status).toBe(400) expect(await json(assignOwnerRes)).toEqual({ error: 'Owner role is setup-only' }) - const ownerId = String(db.users.find((user) => user.role_id === 'owner')?.id) + const ownerId = String((await records(db, 'users')).find((user) => user.role_id === 'owner')?.id) const selfDemoteReq = new Request(`http://localhost/admin/api/cms/users/${ownerId}`, { method: 'PATCH', body: JSON.stringify({ roleId: 'admin' }), @@ -638,7 +235,7 @@ describe('CMS handlers', () => { // must NOT be able to PATCH the Owner row's password (Owner-takeover // primitive) or DELETE it. Only the Owner themself may mutate the Owner // row. - const db = makeFakeDb() + const db = await makeDb() await handleCmsRequest(new Request('http://localhost/admin/api/cms/setup', { method: 'POST', body: JSON.stringify({ siteName: 'Example', email: 'real-owner@example.com', password: 'long-enough-password' }), @@ -677,8 +274,8 @@ describe('CMS handlers', () => { 'rogue-admin-phrase', ) - const ownerId = String(db.users.find((user) => user.role_id === 'owner')?.id) - const ownerHashBefore = db.users.find((user) => user.role_id === 'owner')?.password_hash + const ownerId = String((await records(db, 'users')).find((user) => user.role_id === 'owner')?.id) + const ownerHashBefore = (await records(db, 'users')).find((user) => user.role_id === 'owner')?.password_hash // Admin tries to overwrite the Owner's password — must be rejected with 403. const passwordPatchReq = new Request(`http://localhost/admin/api/cms/users/${ownerId}`, { @@ -692,7 +289,7 @@ describe('CMS handlers', () => { expect(await json(passwordPatchRes)).toEqual({ error: 'Only the owner can modify the owner account' }) // Owner's password_hash must not have been touched. - expect(db.users.find((user) => user.role_id === 'owner')?.password_hash).toBe(ownerHashBefore) + expect((await records(db, 'users')).find((user) => user.role_id === 'owner')?.password_hash).toBe(ownerHashBefore) // Admin tries to rewrite the Owner's email — also rejected. const emailPatchReq = new Request(`http://localhost/admin/api/cms/users/${ownerId}`, { @@ -703,7 +300,7 @@ describe('CMS handlers', () => { emailPatchReq.headers.set('cookie', adminCookie) const emailPatchRes = await handleCmsRequest(emailPatchReq, db) expect(emailPatchRes.status).toBe(403) - expect(db.users.find((user) => user.role_id === 'owner')?.email).toBe('real-owner@example.com') + expect((await records(db, 'users')).find((user) => user.role_id === 'owner')?.email).toBe('real-owner@example.com') // Admin tries to delete the Owner — rejected with 403, NOT the // "last active owner" 409 (we want the row-level guard to fire first @@ -715,7 +312,7 @@ describe('CMS handlers', () => { const deleteRes = await handleCmsRequest(deleteReq, db) expect(deleteRes.status).toBe(403) expect(await json(deleteRes)).toEqual({ error: 'Only the owner can delete the owner account' }) - expect(db.users.find((user) => user.role_id === 'owner')?.deleted_at).toBeNull() + expect((await records(db, 'users')).find((user) => user.role_id === 'owner')?.deleted_at).toBeNull() // The Owner themself may still update their own row (e.g. rotate // password) — sanity check we didn't over-rotate. @@ -738,7 +335,7 @@ describe('CMS handlers', () => { // Secure cookie (which browsers would reject). describe('session cookie Secure flag', () => { async function loginThen(): Promise { - const db = makeFakeDb() + const db = await makeDb() await handleCmsRequest(new Request('http://localhost/admin/api/cms/setup', { method: 'POST', body: JSON.stringify({ siteName: 'Example', email: 'o@example.com', password: 'long-enough-password' }), @@ -773,7 +370,7 @@ describe('CMS handlers', () => { }) it('ignores a spoofed X-Forwarded-Proto: https when no https public origin is configured', async () => { - const db = makeFakeDb() + const db = await makeDb() await handleCmsRequest(new Request('http://localhost/admin/api/cms/setup', { method: 'POST', body: JSON.stringify({ siteName: 'Example', email: 'o@example.com', password: 'long-enough-password' }), @@ -790,7 +387,7 @@ describe('CMS handlers', () => { it('logout cookie also gets Secure when an https public origin is configured', async () => { configurePublicOrigins(['https://cms.example.com']) - const db = makeFakeDb() + const db = await makeDb() await handleCmsRequest(new Request('http://localhost/admin/api/cms/setup', { method: 'POST', body: JSON.stringify({ siteName: 'Example', email: 'o@example.com', password: 'long-enough-password' }), @@ -845,7 +442,7 @@ describe('CMS handlers', () => { } async function makeDbWithAdmin() { - const db = makeFakeDb() + const db = await makeDb() await handleCmsRequest(new Request('http://localhost/admin/api/cms/setup', { method: 'POST', body: JSON.stringify({ siteName: 'X', email: 'owner@example.com', password: 'long-enough-password' }), diff --git a/src/__tests__/server/cmsPublish.test.ts b/src/__tests__/server/cmsPublish.test.ts index 1aa4e6b45..e987e186c 100644 --- a/src/__tests__/server/cmsPublish.test.ts +++ b/src/__tests__/server/cmsPublish.test.ts @@ -1,7 +1,6 @@ -import { describe, expect, it } from 'bun:test' -import type { SiteDocument, SiteShell } from '@core/page-tree' +import { afterEach, describe, expect, it } from 'bun:test' +import type { SiteShell } from '@core/page-tree' import { normalizeSiteRuntimeConfig } from '@core/site-runtime' -import type { DbResult } from '../../../server/db' import { saveDraftSite } from '../../../server/repositories/site' import { getDraftPublishStatus, @@ -10,213 +9,9 @@ import { import { publishDraftSite } from '../../../server/publish/publishSite' import { createDataRow, saveDataRowDraft } from '../../../server/repositories/data' import { pageToCells } from '../../../src/core/data/pageFromRow' -import { createFakeDb } from './dbTestFake' - -function createPublishFakeDb() { - const state = { - site: null as Record | null, - dataRows: [] as Record[], - dataRowVersions: [] as Record[], - siteSnapshots: [] as Record[], - runtimeAssets: [] as Record[], - } - - const db = createFakeDb(async (rawSql, params): Promise => { - const sql = rawSql.replace(/\s+/g, ' ').trim().toLowerCase() - - // saveDraftSite — insert or update site row (NOT site_snapshots) - if (sql.startsWith('insert into site (')) { - state.site = { - id: 'default', - name: params[0], - settings_json: params[1], - created_at: new Date('2026-01-01').toISOString(), - updated_at: new Date('2026-01-02').toISOString(), - } - return { rows: [], rowCount: 1 } - } - // getDraftSite — select site row - if (sql.startsWith('select id, name, settings_json')) { - return { rows: state.site ? [state.site] : [], rowCount: state.site ? 1 : 0 } - } - // createDataRow — insert into data_rows returning id - if (sql.startsWith('insert into data_rows')) { - const row = { - id: params[0], - table_id: params[1], - cells_json: params[2], - slug: params[3], - status: params[4], - author_user_id: params[5], - created_by_user_id: params[6], - updated_by_user_id: params[7], - active_version_id: null, - published_by_user_id: null, - published_at: null, - created_at: new Date('2026-01-01').toISOString(), - updated_at: new Date('2026-01-01').toISOString(), - deleted_at: null, - } - const idx = state.dataRows.findIndex((r) => r.id === row.id) - if (idx >= 0) state.dataRows[idx] = row - else state.dataRows.push(row) - return { rows: [{ id: row.id }], rowCount: 1 } - } - // saveDataRowDraft — update data_rows set cells_json, slug, updated_by_user_id, plugin_actor_id - // params: [0]=cells_json, [1]=slug, [2]=updated_by_user_id, [3]=plugin_actor_id, [4]=rowId - if (sql.startsWith('update data_rows set cells_json')) { - const row = state.dataRows.find((r) => r.id === params[4]) - if (row) { - row.cells_json = params[0] - row.slug = params[1] - row.updated_by_user_id = params[2] - } - return { rows: [], rowCount: row ? 1 : 0 } - } - // listDataRows for pages — select from data_rows where table_id = 'pages' - if (sql.includes('from data_rows') && sql.includes('left join users')) { - const matchingRows = state.dataRows.filter((r) => { - if (r.deleted_at != null) return false - if (sql.includes('data_rows.table_id =')) return r.table_id === params[0] - if (sql.includes('data_rows.id =')) return r.id === params[0] - return true - }) - const rows = (sql.includes('order by data_rows.updated_at desc') - ? matchingRows.sort((a, b) => - String(b.updated_at).localeCompare(String(a.updated_at)) || - String(b.created_at).localeCompare(String(a.created_at)) - ) - : matchingRows - ) - .map((r) => ({ - ...r, - author_email: null, - author_display_name: null, - author_role_slug: null, - author_role_name: null, - created_by_email: null, - created_by_display_name: null, - created_by_role_slug: null, - created_by_role_name: null, - updated_by_email: null, - updated_by_display_name: null, - updated_by_role_slug: null, - updated_by_role_name: null, - published_by_email: null, - published_by_display_name: null, - published_by_role_slug: null, - published_by_role_name: null, - })) - return { rows, rowCount: rows.length } - } - // nextVersionNumber — max(version_number) + 1 - if (sql.startsWith('select coalesce(max(version_number)')) { - const rowId = params[0] as string - const rowVersions = state.dataRowVersions.filter((v) => v.row_id === rowId) - const nextVersion = Math.max(0, ...rowVersions.map((v) => Number(v.version_number))) + 1 - return { rows: [{ next_version: nextVersion }], rowCount: 1 } - } - // insert into site_snapshots — one per publish, referenced by version rows - if (sql.startsWith('insert into site_snapshots')) { - state.siteSnapshots.push({ - id: params[0], - site_json: params[1], - content_hash: params[2], - importmap_body: params[3], - importmap_sha256: params[4], - }) - return { rows: [], rowCount: 1 } - } - // insert into data_row_versions - // columns: (id, row_id, version_number, cells_json, slug, site_snapshot_id, - // runtime_assets_json, published_by_user_id) - if (sql.startsWith('insert into data_row_versions')) { - state.dataRowVersions.push({ - id: params[0], - row_id: params[1], - version_number: params[2], - cells_json: params[3], - slug: params[4], - site_snapshot_id: params[5], - runtime_assets_json: params[6], - published_by_user_id: params[7], - published_at: new Date('2026-01-03').toISOString(), - }) - return { rows: [], rowCount: 1 } - } - // savePublishedRuntimeAssets — insert into published_runtime_assets - if (sql.startsWith('insert into published_runtime_assets')) { - state.runtimeAssets.push({ - id: params[0], - page_version_id: params[1], - asset_path: params[2], - public_path: params[3], - content_type: params[4], - content_bytes: params[5], - }) - return { rows: [], rowCount: 1 } - } - // update data_rows set active_version_id = $1 ... (after publish) - // SQL params: $1=versionId, $2=publishedByUserId, $3=updatedByUserId, $4=rowId - if (sql.startsWith('update data_rows') && sql.includes('active_version_id')) { - const versionId = params[0] as string - const publishedBy = params[1] as string - const rowId = params[3] as string - const row = state.dataRows.find((r) => r.id === rowId) - if (row) { - row.active_version_id = versionId - row.status = 'published' - row.published_by_user_id = publishedBy - row.published_at = new Date('2026-01-03').toISOString() - row.updated_by_user_id = publishedBy - row.updated_at = new Date('2026-01-03').toISOString() - } - return { rows: [], rowCount: row ? 1 : 0 } - } - // getPublishedPageBySlug — join data_rows + data_row_versions + site_snapshots - if (sql.includes('site_snapshots.site_json') && sql.includes('data_rows.slug')) { - const slug = params[0] as string - const row = state.dataRows.find((r) => r.slug === slug && r.status === 'published') - const version = row ? state.dataRowVersions.find((v) => v.id === row.active_version_id) : null - const snap = version - ? state.siteSnapshots.find((s) => s.id === version.site_snapshot_id) - : null - return { - rows: version && snap - ? [{ - row_id: version.row_id, - site_json: snap.site_json, - runtime_assets_json: version.runtime_assets_json, - importmap_body: snap.importmap_body, - importmap_sha256: snap.importmap_sha256, - }] - : [], - rowCount: version && snap ? 1 : 0, - } - } - // getDraftPublishStatus — published rows join (selects the per-publish content hash) - if (sql.includes('site_snapshots.content_hash') && sql.includes('created_at asc')) { - const rows = state.dataRows - .filter((r) => r.status === 'published' && r.active_version_id && !r.deleted_at) - .map((r) => { - const ver = state.dataRowVersions.find((v) => v.id === r.active_version_id) - const snap = ver - ? state.siteSnapshots.find((s) => s.id === ver.site_snapshot_id) - : null - return ver && snap ? { - row_id: r.id, - content_hash: snap.content_hash, - published_at: ver.published_at, - } : null - }) - .filter(Boolean) - return { rows, rowCount: rows.length } - } - throw new Error(`Unhandled SQL: ${rawSql}`) - }) - - return { state, db } -} +import { cleanupPublishingTestDbs, createPublishingTestDb } from '../helpers/publishingTestDb' +import type { DbClient } from '../../../server/db' +afterEach(cleanupPublishingTestDbs) function makeSiteShell(overrides: Partial = {}): SiteShell { return { @@ -263,7 +58,7 @@ function makeHomePage(text: string) { } async function seedSiteAndPage( - db: ReturnType['db'], + db: DbClient, text: string, ) { const shell = makeSiteShell() @@ -274,41 +69,41 @@ async function seedSiteAndPage( tableId: 'pages', cells: pageToCells(page), slug: page.slug, - }, 'admin_1') + }, null) } describe('CMS publishing', () => { it('publishes draft pages as immutable active snapshots', async () => { - const { state, db } = createPublishFakeDb() + const db = await createPublishingTestDb() await seedSiteAndPage(db, 'Published headline') - const result = await publishDraftSite(db, 'admin_1') + const result = await publishDraftSite(db, null, undefined, { variants: [{ rowId: 'page_home', localeId: 'default' }] }) const published = await getPublishedPageBySlug(db, 'index') expect(result).toMatchObject({ publishedPages: 1 }) - expect(state.dataRowVersions).toHaveLength(1) + expect((await db`select id from data_row_versions`).rows).toHaveLength(1) expect(published?.site.pages[0].nodes.text_1.props.text).toBe('Published headline') }) it('does not expose later draft changes until another publish occurs', async () => { - const { db } = createPublishFakeDb() + const db = await createPublishingTestDb() await seedSiteAndPage(db, 'Public version') - await publishDraftSite(db, 'admin_1') + await publishDraftSite(db, null, undefined, { variants: [{ rowId: 'page_home', localeId: 'default' }] }) // Update the draft page text await saveDataRowDraft(db, 'page_home', { cells: pageToCells({ ...makeHomePage('Draft only') }), slug: 'index', - }, 'admin_1') + }, null) const published = await getPublishedPageBySlug(db, 'index') expect(published?.site.pages[0].nodes.text_1.props.text).toBe('Public version') }) it('reports that the current draft matches the active published snapshots after publishing', async () => { - const { db } = createPublishFakeDb() + const db = await createPublishingTestDb() await seedSiteAndPage(db, 'Public version') - await publishDraftSite(db, 'admin_1') + await publishDraftSite(db, null, undefined, { variants: [{ rowId: 'page_home', localeId: 'default' }] }) const status = await getDraftPublishStatus(db) @@ -322,7 +117,7 @@ describe('CMS publishing', () => { }) it('keeps publish status matched when publishing changes the rows recency order', async () => { - const { state, db } = createPublishFakeDb() + const db = await createPublishingTestDb() const shell = makeSiteShell() await saveDraftSite(db, shell) @@ -344,38 +139,33 @@ describe('CMS publishing', () => { tableId: 'pages', cells: pageToCells(page), slug: page.slug, - }, 'admin_1') + }, null) } - const homeRow = state.dataRows.find((row) => row.id === home.id) - const layoutRow = state.dataRows.find((row) => row.id === layout.id) - if (!homeRow || !layoutRow) throw new Error('test pages were not seeded') - homeRow.created_at = new Date('2026-01-01').toISOString() - homeRow.updated_at = new Date('2026-01-02').toISOString() - layoutRow.created_at = new Date('2026-01-02').toISOString() - layoutRow.updated_at = new Date('2026-01-01').toISOString() + await db`update data_rows set created_at = ${'2026-01-01T00:00:00Z'}, updated_at = ${'2026-01-02T00:00:00Z'} where id = ${home.id}` + await db`update data_rows set created_at = ${'2026-01-02T00:00:00Z'}, updated_at = ${'2026-01-01T00:00:00Z'} where id = ${layout.id}` - await publishDraftSite(db, 'admin_1') + await publishDraftSite(db, null, undefined, { variants: [{ rowId: 'page_home', localeId: 'default' }] }) const status = await getDraftPublishStatus(db) expect(status).toMatchObject({ hasPublishedVersion: true, draftMatchesPublished: true, - draftPages: 2, - publishedPages: 2, + draftPages: 1, + publishedPages: 1, }) }) it('reports that the current draft no longer matches after a later draft save', async () => { - const { db } = createPublishFakeDb() + const db = await createPublishingTestDb() await seedSiteAndPage(db, 'Public version') - await publishDraftSite(db, 'admin_1') + await publishDraftSite(db, null, undefined, { variants: [{ rowId: 'page_home', localeId: 'default' }] }) // Update the draft to create mismatch await saveDataRowDraft(db, 'page_home', { cells: pageToCells({ ...makeHomePage('Draft only') }), slug: 'index', - }, 'admin_1') + }, null) const status = await getDraftPublishStatus(db) @@ -388,7 +178,7 @@ describe('CMS publishing', () => { }) it('stores built runtime assets with the published page version', async () => { - const { state, db } = createPublishFakeDb() + const db = await createPublishingTestDb() const shell = makeSiteShell({ files: [ { @@ -416,19 +206,20 @@ describe('CMS publishing', () => { tableId: 'pages', cells: pageToCells(page), slug: page.slug, - }, 'admin_1') + }, null) - await publishDraftSite(db, 'admin_1') + await publishDraftSite(db, null, undefined, { variants: [{ rowId: 'page_home', localeId: 'default' }] }) const published = await getPublishedPageBySlug(db, 'index') - expect(state.runtimeAssets.length).toBeGreaterThan(0) - expect(String(state.runtimeAssets[0].public_path)).toContain('/_instatic/assets/') + const { rows: runtimeAssets } = await db<{ public_path: string }>`select public_path from published_runtime_assets` + expect(runtimeAssets.length).toBeGreaterThan(0) + expect(String(runtimeAssets[0].public_path)).toContain('/_instatic/assets/') expect(published?.runtimeAssets?.scripts).toHaveLength(1) - expect(published?.runtimeAssets?.scripts[0].src).toBe(state.runtimeAssets[0].public_path) + expect(published?.runtimeAssets?.scripts[0].src).toBe(runtimeAssets[0].public_path) }) it('rejects invalid authored runtime scripts with their file and location before writing a publish', async () => { - const { state, db } = createPublishFakeDb() + const db = await createPublishingTestDb() const shell = makeSiteShell({ files: [ { @@ -456,12 +247,12 @@ describe('CMS publishing', () => { tableId: 'pages', cells: pageToCells(page), slug: page.slug, - }, 'admin_1') + }, null) - await expect(publishDraftSite(db, 'admin_1')).rejects.toThrow( + await expect(publishDraftSite(db, null, undefined, { variants: [{ rowId: 'page_home', localeId: 'default' }] })).rejects.toThrow( 'Runtime script build failed for page "Home": src/scripts/forgotten-test.ts:1:', ) - expect(state.siteSnapshots).toEqual([]) - expect(state.dataRowVersions).toEqual([]) + expect((await db`select id from site_snapshots`).rows).toEqual([]) + expect((await db`select id from data_row_versions`).rows).toEqual([]) }) }) diff --git a/src/__tests__/server/cmsSiteHandlers.test.ts b/src/__tests__/server/cmsSiteHandlers.test.ts index 75e9a3f8c..bff298724 100644 --- a/src/__tests__/server/cmsSiteHandlers.test.ts +++ b/src/__tests__/server/cmsSiteHandlers.test.ts @@ -23,6 +23,7 @@ function makeFakeDb() { // Reconstruct a parameterized SQL string for pattern matching. const sql = strings.reduce((acc, str, i) => (i === 0 ? str : `${acc}$${i}${str}`), '') const normalized = sql.replace(/\s+/g, ' ').trim().toLowerCase() + if (normalized.includes('from site_locales')) return { rows: [{ id: 'default', code: 'en', name: 'English', path_prefix: '', is_default: true, enabled: true, direction: 'ltr' } as unknown as Row], rowCount: 1 } if (normalized.includes('from sessions') && normalized.includes('join users')) { const session = sessions.find((s) => String(s.id_hash) === String(values[0])) diff --git a/src/__tests__/server/cmsTemplateRoutes.test.ts b/src/__tests__/server/cmsTemplateRoutes.test.ts index e76e9eef7..61f0794dd 100644 --- a/src/__tests__/server/cmsTemplateRoutes.test.ts +++ b/src/__tests__/server/cmsTemplateRoutes.test.ts @@ -1,317 +1,94 @@ -import { beforeEach, describe, expect, it } from 'bun:test' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import type { DbResult } from '../../../server/db' import { handleServerRequest } from '../../../server/router' import { resetForTests } from '../../../server/publish/renderCache' -import type { PublishedPageSnapshot } from '../../../server/repositories/publish' +import { getPublishVersion, markPublishedArtefactsCurrent } from '../../../server/publish/publishState' +import { getPublishedRouteInventoryForVersion } from '../../../server/publish/publishedRoutes' +import { publishDataRow } from '../../../server/publish/publishRow' +import { createDataRow, saveDataRowDraft } from '../../../server/repositories/data' import { makePage, makeSite } from '../publisher/helpers' +import { cleanupPublishingTestDbs, createPublishingTestDb } from '../helpers/publishingTestDb' import { createFakeDb } from './dbTestFake' -import { - prepareInactiveSlot, - writeArtefact, - swapSlot, -} from '../../../server/publish/staticArtefact' +import { prepareInactiveSlot, writeArtefact, swapSlot } from '../../../server/publish/staticArtefact' -type QueryHandler = (sql: string, params: unknown[]) => DbResult | undefined +beforeEach(resetForTests) +afterEach(cleanupPublishingTestDbs) -function makeTemplateRouteFakeDb(handlers: QueryHandler[]) { - return createFakeDb(async (rawSql, params): Promise => { - const sql = rawSql.replace(/\s+/g, ' ').trim().toLowerCase() - for (const handler of handlers) { - const result = handler(sql, params) - if (result) return result - } - throw new Error(`Unhandled SQL: ${rawSql}`) +async function fixture(title = 'Dynamic Post', slug = 'dynamic-post') { + const page = makePage({ + root: { moduleId: 'base.body', children: ['title'] }, + title: { moduleId: 'base.text', props: { text: 'Static title', tag: 'h1' }, + dynamicBindings: { text: { source: 'currentEntry', field: 'title' } } }, }) -} - -function rowDate(value: string) { - return new Date(value) + page.id = 'post-template' + page.slug = 'post-template' + page.template = { enabled: true, target: { kind: 'postTypes', tableSlugs: ['posts'] }, priority: 100 } + const db = await createPublishingTestDb(makeSite({ pages: [page] })) + const row = await createDataRow(db, { tableId: 'posts', slug, cells: { title, body: 'Body' } }) + await publishDataRow(db, row.id, null) + return { db, row } } describe('CMS dynamic template routes', () => { - // Each test serves a different snapshot fixture at the same publish version. - // Reset the render cache + version-keyed snapshot memos so one test's - // published site can't leak into the next (or in from another test file). - beforeEach(() => { - resetForTests() - }) - - it('renders a published data row through the highest priority page template', async () => { - const page = makePage({ - root: { moduleId: 'base.body', props: {}, children: ['title'] }, - title: { - moduleId: 'base.text', - props: { text: 'Static title', tag: 'h1' }, - dynamicBindings: { text: { source: 'currentEntry', field: 'title' } }, - }, - }) - page.id = 'post-template' - page.title = 'Post Template' - page.slug = 'post-template' - page.template = { - enabled: true, - target: { kind: 'postTypes', tableSlugs: ['posts'] }, - priority: 100, - } - const snapshot: PublishedPageSnapshot = { - cmsSnapshotVersion: 1, - pageRowId: page.id, - site: makeSite({ pages: [page] }), - } - - const db = makeTemplateRouteFakeDb([ - (sql) => { - if (sql.startsWith('select id, name, version, enabled, lifecycle_status')) { - return { rows: [], rowCount: 0 } - } - return undefined - }, - (sql) => { - // `collectFrontendInjections` reads elected media storage adapters so - // their declared CSP origins extend the page CSP. No adapter is - // elected in these tests, so the empty result lands the renderer on - // the local-disk defaults. - if (sql.includes('from active_media_storage_adapter')) { - return { rows: [], rowCount: 0 } - } - return undefined - }, - (sql, params) => { - if (!sql.includes('site_snapshots.site_json')) return undefined - - // getPublishedPageBySlug — has data_rows.slug parameter; return empty - // (no published page at 'posts/dynamic-post') - if (sql.includes('data_rows.slug =')) { - expect(params).toEqual(['posts/dynamic-post']) - return { rows: [], rowCount: 0 } - } - - // getLatestPublishedSiteSnapshot — return the snapshot so the template - // renderer can find the matching template page - return { - rows: [{ - row_id: snapshot.pageRowId, - site_json: snapshot.site, - runtime_assets_json: null, - importmap_body: null, - importmap_sha256: null, - }], - rowCount: 1, - } - }, - (sql, params) => { - if (!sql.startsWith('select data_row_versions.id')) return undefined - expect(params).toEqual(['/posts', 'dynamic-post']) - return { - rows: [{ - id: 'version_1', - row_id: 'row_1', - table_id: 'posts', - table_slug: 'posts', - table_kind: 'postType', - table_route_base: '/posts', - version_number: 1, - cells_json: { - title: 'Dynamic Post', - slug: 'dynamic-post', - body: 'Body', - featuredMedia: null, - seoTitle: '', - seoDescription: '', - }, - slug: 'dynamic-post', - published_at: rowDate('2026-05-01T10:00:00Z'), - created_at: rowDate('2026-05-01T10:00:00Z'), - }], - rowCount: 1, - } - }, - ]) - - const res = await handleServerRequest(new Request('http://localhost/posts/dynamic-post'), { db }) - const html = await res.text() - - expect(res.status).toBe(200) - expect(res.headers.get('content-type')).toContain('text/html') + it('renders a published data row through its matching published template', async () => { + const { db } = await fixture() + const response = await handleServerRequest(new Request('http://localhost/posts/dynamic-post'), { db }) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toContain('text/html') + const html = await response.text() expect(html).toContain('

Dynamic Post

') expect(html).not.toContain('Static title') }) - it('serves a template route from disk when a baked artefact exists', async () => { + it('serves a validated template route from disk without snapshot hydration', async () => { + const { db: real } = await fixture() + let deny = false + const db = createFakeDb(async (sql, params) => { + if (deny) throw new Error('Unexpected SQL after inventory warmup') + return real.unsafe(sql, params) + }) + await getPublishedRouteInventoryForVersion(db, getPublishVersion()) + deny = true const uploadsDir = await mkdtemp(join(tmpdir(), 'template-disk-test-')) - try { - // Bake a pre-rendered artefact for the template route const { slot, slotDir } = await prepareInactiveSlot(uploadsDir) - await writeArtefact(slotDir, '/posts/dynamic-post', '

Baked template post

') + await writeArtefact(slotDir, '/posts/dynamic-post', '

Baked template post

') await swapSlot(uploadsDir, slot) - - // DB that would error if the snapshot path were consulted - const db = createFakeDb(async (sql: string): Promise => { - const s = sql.toLowerCase() - if (s.includes('site_snapshots')) { - throw new Error('Snapshot queried despite disk artefact hit') - } - if (s.includes('count(*) as count from site')) return { rows: [{ count: 1 }], rowCount: 1 } - if (s.includes('from users') && s.includes('role_id')) return { rows: [{ count: 1 }], rowCount: 1 } - return { rows: [], rowCount: 0 } - }) - - const res = await handleServerRequest( - new Request('http://localhost/posts/dynamic-post'), - { db, uploadsDir }, - ) - - expect(res.status).toBe(200) - expect(res.headers.get('content-type')).toContain('text/html') - expect(await res.text()).toContain('Baked template post') + markPublishedArtefactsCurrent(getPublishVersion()) + const response = await handleServerRequest(new Request('http://localhost/posts/dynamic-post'), { db, uploadsDir }) + expect(response.status).toBe(200) + expect(await response.text()).toContain('Baked template post') } finally { await rm(uploadsDir, { recursive: true, force: true }) } }) - it('falls through to the live renderer for a template route with a render-affecting (loop pagination) query', async () => { - const uploadsDir = await mkdtemp(join(tmpdir(), 'template-qs-test-')) - + it('bypasses a template artefact for a render-affecting loop-pagination query', async () => { + const { db } = await fixture('QS Post') + const uploadsDir = await mkdtemp(join(tmpdir(), 'template-query-test-')) try { - // Bake an artefact — but the loop-pagination query affects the render so - // it must be bypassed (junk queries instead serve the artefact — ISS-032) const { slot, slotDir } = await prepareInactiveSlot(uploadsDir) await writeArtefact(slotDir, '/posts/dynamic-post', 'baked') await swapSlot(uploadsDir, slot) - - const page = makePage({ - root: { moduleId: 'base.body', props: {}, children: ['title'] }, - title: { - moduleId: 'base.text', - props: { text: 'Static title', tag: 'h1' }, - dynamicBindings: { text: { source: 'currentEntry', field: 'title' } }, - }, - }) - page.id = 'post-template-qs' - page.title = 'Post Template QS' - page.slug = 'post-template-qs' - page.template = { - enabled: true, - target: { kind: 'postTypes', tableSlugs: ['posts'] }, - priority: 100, - } - const snapshot: PublishedPageSnapshot = { - cmsSnapshotVersion: 1, - pageRowId: page.id, - site: makeSite({ pages: [page] }), - } - - const db = makeTemplateRouteFakeDb([ - (sql) => { - if (sql.startsWith('select id, name, version, enabled, lifecycle_status')) { - return { rows: [], rowCount: 0 } - } - return undefined - }, - (sql) => { - if (sql.includes('from active_media_storage_adapter')) { - return { rows: [], rowCount: 0 } - } - return undefined - }, - (sql) => { - if (!sql.includes('site_snapshots.site_json')) return undefined - if (sql.includes('data_rows.slug =')) { - return { rows: [], rowCount: 0 } - } - return { - rows: [{ - row_id: snapshot.pageRowId, - site_json: snapshot.site, - runtime_assets_json: null, - importmap_body: null, - importmap_sha256: null, - }], - rowCount: 1, - } - }, - (sql, params) => { - if (!sql.startsWith('select data_row_versions.id')) return undefined - return { - rows: [{ - id: 'version_qs', - row_id: 'row_qs', - table_id: 'posts', - table_slug: 'posts', - table_kind: 'postType', - table_route_base: '/posts', - version_number: 1, - cells_json: { - title: 'QS Post', - slug: 'dynamic-post', - body: 'Body', - featuredMedia: null, - seoTitle: '', - seoDescription: '', - }, - slug: 'dynamic-post', - published_at: rowDate('2026-05-01T10:00:00Z'), - created_at: rowDate('2026-05-01T10:00:00Z'), - }], - rowCount: 1, - } - }, - ]) - - const res = await handleServerRequest( - new Request('http://localhost/posts/dynamic-post?loop_x_page=2'), - { db, uploadsDir }, - ) - - // The live renderer was called (not the baked artefact) and rendered from DB - expect(res.status).toBe(200) - expect(res.headers.get('content-type')).toContain('text/html') - // Must NOT return the baked content (which just has "baked") - const body = await res.text() - expect(body).not.toContain('>baked<') + markPublishedArtefactsCurrent(getPublishVersion()) + const response = await handleServerRequest(new Request('http://localhost/posts/dynamic-post?loop_x_page=2'), { db, uploadsDir }) + expect(response.status).toBe(200) + const html = await response.text() + expect(html).toContain('QS Post') + expect(html).not.toContain('>baked<') } finally { await rm(uploadsDir, { recursive: true, force: true }) } }) - it('redirects an old published data row slug to the active published slug', async () => { - const db = makeTemplateRouteFakeDb([ - (sql, params) => { - if (!sql.includes('site_snapshots.site_json')) return undefined - // getPublishedPageBySlug — return empty (no published page at this slug) - if (sql.includes('data_rows.slug =')) { - expect(params).toEqual(['posts/untitled']) - } - return { rows: [], rowCount: 0 } - }, - (sql, params) => { - if (!sql.startsWith('select data_row_versions.id')) return undefined - expect(params).toEqual(['/posts', 'untitled']) - return { rows: [], rowCount: 0 } - }, - (sql, params) => { - if (!sql.startsWith('select data_row_redirects.id')) return undefined - expect(params).toEqual(['/posts', 'untitled']) - return { - rows: [{ - id: 'redirect_1', - from_route_base: '/posts', - from_slug: 'untitled', - target_route_base: '/posts', - target_slug: 'post', - }], - rowCount: 1, - } - }, - ]) - - const res = await handleServerRequest(new Request('http://localhost/posts/untitled'), { db }) - - expect(res.status).toBe(301) - expect(res.headers.get('location')).toBe('/posts/post') + it('redirects an old published data row slug to the active language version', async () => { + const { db, row } = await fixture('Post', 'untitled') + await saveDataRowDraft(db, row.id, { slug: 'post', cells: row.cells }) + await publishDataRow(db, row.id, null) + const response = await handleServerRequest(new Request('http://localhost/posts/untitled'), { db }) + expect(response.status).toBe(301) + expect(response.headers.get('location')).toBe('/posts/post') }) }) diff --git a/src/__tests__/server/collabRelay.test.ts b/src/__tests__/server/collabRelay.test.ts index f6514a8bd..107ab8986 100644 --- a/src/__tests__/server/collabRelay.test.ts +++ b/src/__tests__/server/collabRelay.test.ts @@ -8,6 +8,9 @@ import { afterEach, describe, expect, it, spyOn } from 'bun:test' import * as Y from 'yjs' import { LOCAL_ORIGIN, + encodeCollabDocId, + projectLocalizationDoc, + applyLocalizationDraftToDoc, projectPageDoc, rostersMap, shellMap, @@ -19,11 +22,13 @@ import type { DbClient } from '../../../server/db' import { getCollabDocumentState } from '../../../server/repositories/collabDocuments' import { createDataRow, + getDataRow, createDataTable, listDataRows, saveDataRowDraft, updateDataRowTable, } from '../../../server/repositories/data' +import { getDefaultLocale } from '../../../server/repositories/localization' import { getDraftSite, saveDraftSite } from '../../../server/repositories/site' import { notifyRowWrite, @@ -113,8 +118,8 @@ function gateDerivedRowWrites(db: DbClient, targetRowId: string): { if ( armed && !gated && - sql.includes('update data_rows') && - sql.includes('set cells_json') && + sql.includes('insert into data_rows') && + sql.includes('do update set cells_json') && values.includes(targetRowId) ) { gated = true @@ -152,7 +157,7 @@ function gateRosterSweep(db: DbClient): { if ( armed && !gated && - sql.includes('select id, slug from data_rows') && + sql.includes('as slug from data_rows') && values.includes('pages') ) { gated = true @@ -314,7 +319,7 @@ function observeRosterSweep(db: DbClient): { if ( armed && !announced && - strings.join('?').includes('select id, slug from data_rows') && + strings.join('?').includes('as slug from data_rows') && values.includes('layouts') ) { announced = true @@ -350,6 +355,7 @@ function populateFreshPage(doc: Y.Doc, title: string, slug: string): void { root.set('props', new Y.Map()) root.set('breakpointOverrides', new Y.Map()) root.set('children', new Y.Array()) + root.set('classIds', new Y.Array()) nodes.set('root', root) const meta = doc.getMap('meta') meta.set('title', title) @@ -362,7 +368,10 @@ describe('collab relay', () => { const { harness, relay, homeId } = await setup() const { doc: doc } = await relay.openDoc(`page:${homeId}`) const projected = projectPageDoc(doc, homeId) - expect(projected.slug).toBe('index') + expect(projected.slug).toBe('') + const sourceLocale = await getDefaultLocale(harness.db) + const localized = await relay.openDoc(encodeCollabDocId({ kind: 'page', rowId: homeId, localeId: sourceLocale.id })) + expect(projectLocalizationDoc(localized.doc).slug).toBe('index') expect(projected.rootNodeId).not.toBe('') // A second relay (fresh registry, no blob persisted yet? force reset) — @@ -479,10 +488,10 @@ describe('collab relay', () => { relay.onReset((id) => resets.push(id)) // Simulate a pack install / data-workspace edit. - const { rows } = await harness.db<{ cells_json: Record; slug: string }>` - select cells_json, slug from data_rows where id = ${homeId} - ` - await saveDataRowDraft(harness.db, homeId, { cells: rows[0].cells_json, slug: rows[0].slug }) + const row = (await getDataRow(harness.db, homeId))! + const body = row.cells.body as { rootNodeId: string; nodes: Record } + body.nodes[body.rootNodeId].label = 'Externally renamed section' + await saveDataRowDraft(harness.db, homeId, { cells: row.cells, slug: row.slug }) await new Promise((resolve) => setTimeout(resolve, 20)) expect(resets).toContain(docId) @@ -507,6 +516,7 @@ describe('collab relay', () => { root.set('props', new Y.Map()) root.set('breakpointOverrides', new Y.Map()) root.set('children', new Y.Array()) + root.set('classIds', new Y.Array()) nodes.set('root', root) const meta = doc.getMap('meta') meta.set('title', 'Fresh') @@ -519,7 +529,14 @@ describe('collab relay', () => { const { rows } = await harness.db<{ id: string; slug: string }>` select id, slug from data_rows where id = ${'fresh-row-id'} ` - expect(rows[0]?.slug).toBe('fresh') + expect(rows[0]?.slug).toBe('') + const { doc: siteDoc } = await relay.openDoc(SITE_DOC_ID) + ;(rostersMap(siteDoc).get('pages') as Y.Map).set('fresh-row-id', true) + const locale = await getDefaultLocale(harness.db) + const localized = await relay.openDoc(encodeCollabDocId({ kind: 'page', rowId: 'fresh-row-id', localeId: locale.id })) + applyLocalizationDraftToDoc(localized.doc, projectLocalizationDoc(localized.doc), { cells: { title: 'Fresh', slug: 'fresh' }, slug: 'fresh' }, LOCAL_ORIGIN) + await relay.flushAll() + expect((await getDataRow(harness.db, 'fresh-row-id', locale.id))?.slug).toBe('fresh') }) it('does not resurrect a dirty deleted page ahead of its same-slug replacement', async () => { @@ -542,7 +559,7 @@ describe('collab relay', () => { const { rows } = await harness.db<{ id: string; deleted_at: string | null }>` select id, deleted_at from data_rows - where table_id = ${'pages'} and slug = ${'index'} + where table_id = ${'pages'} order by id asc ` expect(rows.find((row) => row.id === homeId)?.deleted_at).not.toBeNull() @@ -995,6 +1012,7 @@ describe('collab relay', () => { ...page, tableId: targetTable.id, cells: importedCells, + sharedCells: importedCells, slug: 'imported-custom-row', }], }, @@ -1084,7 +1102,7 @@ describe('collab relay', () => { const { rows } = await harness.db<{ id: string; deleted_at: string | null }>` select id, deleted_at from data_rows - where table_id = ${'pages'} and slug = ${'index'} + where table_id = ${'pages'} order by id asc ` expect(rows.find((row) => row.id === homeId)?.deleted_at).not.toBeNull() @@ -1553,6 +1571,7 @@ describe('collab relay', () => { root.set('props', new Y.Map()) root.set('breakpointOverrides', new Y.Map()) root.set('children', new Y.Array()) + root.set('classIds', new Y.Array()) nodes.set('root', root) tree.set('nodes', nodes) const meta = pageDoc.getMap('meta') diff --git a/src/__tests__/server/collabRelayIntegration.test.ts b/src/__tests__/server/collabRelayIntegration.test.ts index b01e750e0..ccb122981 100644 --- a/src/__tests__/server/collabRelayIntegration.test.ts +++ b/src/__tests__/server/collabRelayIntegration.test.ts @@ -27,11 +27,17 @@ import { FRAME_SYNC, PRESENCE_DOC_ID, LOCAL_ORIGIN, + encodeCollabDocId, + projectLocalizationDoc, projectPageDoc, SITE_SOCKET_PATH, treeMap, } from '@core/collab' import { pageFromRow } from '@core/data/pageFromRow' +import { useEditorStore } from '@site/store/store' +import { connectCollabProvider, disconnectCollabProvider } from '@site/store/slices/site/collabBinding' +import { whenCollabWritable } from '@site/store/slices/site/collabWriteGate' +import { getDraftSiteDocument } from '../../../server/repositories/publish' import { createCollabProvider, type CollabProvider, @@ -43,6 +49,7 @@ import { handleCollabSocketUpgrade, } from '../../../server/collab/socket' import { getCollabDocumentState } from '../../../server/repositories/collabDocuments' +import { getDefaultLocale } from '../../../server/repositories/localization' import { runPublishFlush } from '../../../server/publish/publishFlush' import { getDataRow, saveDataRowDraft } from '../../../server/repositories/data' import { findUserByEmail } from '../../../server/repositories/users' @@ -154,6 +161,7 @@ function insertChildNode(doc: Y.Doc, nodeId: string, moduleId: string): void { const nodes = tree.get('nodes') as Y.Map const rootId = tree.get('rootNodeId') as string const node = new Y.Map() + node.set('id', nodeId) node.set('moduleId', moduleId) node.set('props', new Y.Map()) node.set('breakpointOverrides', new Y.Map()) @@ -166,6 +174,29 @@ function insertChildNode(doc: Y.Doc, nodeId: string, moduleId: string): void { } describe('collab relay integration (real server, real sockets)', () => { + it('creates a localized page and converts it to a template without resetting its new lineage', async () => { + const stack = await startStack() + const site = (await getDraftSiteDocument(stack.harness.db))! + useEditorStore.getState().loadSite(site) + const client = connectClient(stack) + const resets: string[] = [] + client.onReset((id, reason) => resets.push(`${id}:${reason}`)) + connectCollabProvider(client) + cleanups.push(() => { disconnectCollabProvider(); useEditorStore.getState().clearSite() }) + expect(await whenCollabWritable()).toBe(true) + const created = useEditorStore.getState().addPage('New post template', 'new-post-template') + expect(await whenCollabWritable()).toBe(true) + await waitFor(async () => (await getDataRow(stack.harness.db, created.id))?.cells.title === 'New post template') + expect(resets).toEqual([]) + const template = { enabled: true, target: { kind: 'postTypes' as const, tableSlugs: ['posts'] }, priority: 100 } + useEditorStore.getState().renamePage(created.id, 'Post template', 'post-template') + useEditorStore.getState().convertPageToTemplate(created.id, template) + await waitFor(async () => (await getDataRow(stack.harness.db, created.id))?.sharedCells.templateEnabled === true) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(useEditorStore.getState().site!.pages.find((page) => page.id === created.id)?.template).toEqual(template) + expect(resets).toEqual([]) + }) + it('two clients edit concurrently, converge, and the relay persists blob + derived JSON', async () => { const stack = await startStack() const docId = `page:${stack.homeId}` @@ -342,7 +373,8 @@ describe('collab relay integration (real server, real sockets)', () => { it('resets a doc when the row is written outside the relay', async () => { const stack = await startStack() - const docId = `page:${stack.homeId}` + const locale = await getDefaultLocale(stack.harness.db) + const docId = encodeCollabDocId({ kind: 'page', rowId: stack.homeId, localeId: locale.id }) const client = connectClient(stack) const bound = client.bind(docId) @@ -365,8 +397,8 @@ describe('collab relay integration (real server, real sockets)', () => { // Rebinding gets a FRESH server seed carrying the out-of-relay write. const rebound = client.bind(docId) await rebound.whenSynced - const projected = projectPageDoc(rebound.doc, stack.homeId) - expect(projected.title).toBe('Rewritten outside the relay') + const projected = projectLocalizationDoc(rebound.doc) + expect(projected.cells.title).toBe('Rewritten outside the relay') }) it('a reconnecting client catches up on edits it missed while offline', async () => { diff --git a/src/__tests__/server/dataCms.test.ts b/src/__tests__/server/dataCms.test.ts index 830f7f82d..dea2e3b75 100644 --- a/src/__tests__/server/dataCms.test.ts +++ b/src/__tests__/server/dataCms.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'bun:test' +import { afterEach, describe, expect, it } from 'bun:test' import type { DbResult } from '../../../server/db' import { pgMigrations } from '../../../server/db/migrations-pg' import { @@ -7,8 +7,6 @@ import { updateDataTable, createDataRow, listDataAuthorOptions, - updateDataRowAuthor, - saveDataRowDraft, getPublishedDataRowByRoute, getDataRowRedirectByRoute, } from '../../../server/repositories/data' @@ -16,6 +14,21 @@ import { handleServerRequest } from '../../../server/router' import { resetForTests } from '../../../server/publish/renderCache' import { createFakeDb } from './dbTestFake' +import { createSqliteClient } from '../../../server/db/sqlite' +import { runMigrations } from '../../../server/db/runMigrations' +import { sqliteMigrations } from '../../../server/db/migrations-sqlite' +import type { DbClient } from '../../../server/db/client' +import { publishDataRow } from '../../../server/publish/publishRow' +import { createUser } from '../../../server/repositories/users' + +const clients: DbClient[] = [] +afterEach(async () => { for (const db of clients.splice(0)) await db.close() }) +async function realDb() { + const db = createSqliteClient(':memory:'); clients.push(db) + await runMigrations(db, sqliteMigrations) + return db +} + type QueryHandler = (sql: string, params: unknown[]) => DbResult | undefined function makeDataFakeDb(handlers: QueryHandler[]) { @@ -151,70 +164,12 @@ describe('data CMS repository', () => { }) }) - it('updates table identity, route, labels, and field settings', async () => { + it('updates table identity, route, labels, and field settings without losing locale paths', async () => { + const db = await realDb() + const table = await createDataTable(db, { id: 'products', name: 'Products', slug: 'products', kind: 'postType', routeBase: '/products', singularLabel: 'Product', pluralLabel: 'Products', fields: defaultFields }) const nextFields = defaultFields.slice(0, 2) - const db = makeDataFakeDb([ - // updateDataTable reads the table first, so a patch cannot drop the - // mandatory `title`/`slug` of a post type by omitting them. - (sql) => { - if (!sql.startsWith('select id, name, slug, kind')) return undefined - return { - rows: [{ - id: 'products', - name: 'Products', - slug: 'products', - kind: 'postType', - route_base: '/products', - singular_label: 'Product', - plural_label: 'Products', - primary_field_id: 'title', - fields_json: defaultFields, - created_by_user_id: null, - updated_by_user_id: null, - created_at: rowDate('2026-05-01T10:00:00Z'), - updated_at: rowDate('2026-05-01T10:00:00Z'), - }], - rowCount: 1, - } - }, - (sql, params) => { - if (!sql.startsWith('update data_tables')) return undefined - expect(params).toContain('Catalog') - expect(params).toContain('catalog') - return { - rows: [{ - id: 'products', - name: 'Catalog', - slug: 'catalog', - kind: 'postType', - route_base: '/catalog', - singular_label: 'Product', - plural_label: 'Catalog', - primary_field_id: 'title', - fields_json: nextFields, - created_by_user_id: null, - updated_by_user_id: null, - created_at: rowDate('2026-05-01T10:00:00Z'), - updated_at: rowDate('2026-05-01T10:05:00Z'), - }], - rowCount: 1, - } - }, - ]) - - await expect(updateDataTable(db, 'products', { - name: 'Catalog', - slug: 'catalog', - routeBase: '/catalog', - singularLabel: 'Product', - pluralLabel: 'Catalog', - fields: nextFields, - }, null)).resolves.toMatchObject({ - id: 'products', - name: 'Catalog', - slug: 'catalog', - routeBase: '/catalog', - fields: nextFields, + expect(await updateDataTable(db, table.id, { name: 'Catalog', slug: 'catalog', routeBase: '/catalog', singularLabel: 'Product', pluralLabel: 'Catalog', fields: nextFields }, null)).toMatchObject({ + id: 'products', name: 'Catalog', slug: 'catalog', routeBase: '/catalog', fields: nextFields, }) }) @@ -261,12 +216,16 @@ describe('data CMS repository', () => { const db = makeDataFakeDb([ (sql, params) => { if (!sql.startsWith('select data_row_versions.id')) return undefined - expect(sql).toContain('data_row_versions.id = data_rows.active_version_id') - expect(params).toEqual(['/posts', 'hello']) + expect(sql).toContain('data_row_versions.id = variants.active_version_id') + expect(sql).toContain('data_row_versions.locale_id = variants.locale_id') + expect(params).toEqual(['default', '/posts/hello', true]) return { rows: [{ id: 'version_1', row_id: 'row_1', + locale_id: 'default', + public_path: '/posts/hello', + site_snapshot_id: null, table_id: 'posts', table_slug: 'posts', table_kind: 'postType', @@ -297,7 +256,7 @@ describe('data CMS repository', () => { }, ]) - await expect(getPublishedDataRowByRoute(db, '/posts', 'hello')).resolves.toMatchObject({ + await expect(getPublishedDataRowByRoute(db, '/posts', 'hello', 'default')).resolves.toMatchObject({ id: 'version_1', rowId: 'row_1', tableSlug: 'posts', @@ -315,26 +274,25 @@ describe('data CMS repository', () => { const db = makeDataFakeDb([ (sql, params) => { if (!sql.startsWith('select data_row_versions.id')) return undefined - expect(params).toEqual(['/posts', 'untitled']) + expect(params).toEqual(['default', '/posts/untitled', true]) return { rows: [], rowCount: 0 } }, ]) - await expect(getPublishedDataRowByRoute(db, '/posts', 'untitled')).resolves.toBeNull() + await expect(getPublishedDataRowByRoute(db, '/posts', 'untitled', 'default')).resolves.toBeNull() }) it('resolves old published slugs as redirects to the active published slug', async () => { const db = makeDataFakeDb([ (sql, params) => { - if (!sql.startsWith('select data_row_redirects.id')) return undefined - expect(params).toEqual(['/posts', 'untitled']) + if (!sql.startsWith('select redirects.id')) return undefined + expect(params).toEqual(['/posts', 'untitled', true]) return { rows: [{ id: 'redirect_1', from_route_base: '/posts', from_slug: 'untitled', - target_route_base: '/posts', - target_slug: 'post', + target_path: '/posts/post', }], rowCount: 1, } @@ -350,75 +308,17 @@ describe('data CMS repository', () => { }) describe('data CMS public routes', () => { - it('returns 404 when a postType row has no matching entry template', async () => { - // The row route consults the version-keyed published-snapshot memo; reset - // publish state so a snapshot cached by another test file can't leak in. + it('returns 404 when a published postType variant has no matching entry template', async () => { resetForTests() - // A postType table only gets a public row route when the site has an - // explicitly authored entry template. Without one, the dispatcher surfaces - // the request as a 404 rather than inventing a half-styled fallback - // document. - const db = makeDataFakeDb([ - (sql) => { - if (sql.startsWith('select id, name, version, enabled, lifecycle_status')) { - return { rows: [], rowCount: 0 } - } - return undefined - }, - (sql) => { - // getPublishedPageBySlug / getLatestPublishedSiteSnapshot — no - // published snapshot at all (both run the same site_snapshots join). - if (sql.includes('site_snapshots.site_json')) { - return { rows: [], rowCount: 0 } - } - return undefined - }, - (sql) => { - if (!sql.startsWith('select data_row_versions.id')) return undefined - return { - rows: [{ - id: 'version_1', - row_id: 'row_1', - table_id: 'products', - table_slug: 'products', - table_kind: 'postType', - table_route_base: '/products', - version_number: 1, - cells_json: { - title: 'Some product', - slug: 'some-product', - body: 'A product body.', - featuredMedia: null, - seoTitle: '', - seoDescription: '', - }, - slug: 'some-product', - published_at: rowDate('2026-05-01T10:00:00Z'), - created_at: rowDate('2026-05-01T10:00:00Z'), - }], - rowCount: 1, - } - }, - (sql) => { - // No redirect for this slug either. - if (sql.startsWith('select id, table_id, from_route_base')) { - return { rows: [], rowCount: 0 } - } - return undefined - }, - (sql) => { - if (sql.startsWith('select count(*) as count from site')) { - return { rows: [{ count: 1 }], rowCount: 1 } - } - if (sql.startsWith('select count(*) as count') && sql.includes('from users')) { - return { rows: [{ count: 1 }], rowCount: 1 } - } - return undefined - }, - ]) - + const db = await realDb() + await db`insert into site (id, name, settings_json) values ('default', 'Test', ${{}})` + await createUser(db, { id: 'owner', email: 'owner@example.test', displayName: 'Owner', passwordHash: 'fixture', roleId: 'owner', allowOwnerRole: true }) + await createDataTable(db, { id: 'products', name: 'Products', slug: 'products', kind: 'postType', routeBase: '/products', singularLabel: 'Product', pluralLabel: 'Products' }) + const row = await createDataRow(db, { tableId: 'products', cells: { title: 'Some product', slug: 'some-product', body: 'Product body' }, slug: 'some-product' }) + const result = await publishDataRow(db, row.id, null) + expect(result.row.status).toBe('published') + expect(result.version.publicPath).toBeNull() const res = await handleServerRequest(new Request('http://localhost/products/some-product'), { db }) - expect(res.status).toBe(404) }) }) diff --git a/src/__tests__/server/dynamicIslandsPlugin.test.ts b/src/__tests__/server/dynamicIslandsPlugin.test.ts index befe8b099..65589d599 100644 --- a/src/__tests__/server/dynamicIslandsPlugin.test.ts +++ b/src/__tests__/server/dynamicIslandsPlugin.test.ts @@ -20,7 +20,7 @@ import { beforeEach, afterEach, describe, expect, it } from 'bun:test' import { Value } from '@sinclair/typebox/value' -import type { DbClient, DbResult } from '../../../server/db' +import type { DbClient } from '../../../server/db' import { handleHoleRequest } from '../../../server/handlers/cms/hole' import { resetForTests } from '../../../server/publish/renderCache' import { getPublishVersion } from '../../../server/publish/publishState' @@ -31,6 +31,10 @@ import { loopSourceRegistry } from '../../core/loops/registry' import { findDynamicNodeIds } from '../../core/publisher/dynamicDetection' import type { LoopEntitySource, SourceFetchContext } from '../../core/loops/types' import { makeModule, makePage, makeSite } from '../publisher/helpers' +import { cleanupPublishingTestDbs, createPublishingTestDb } from '../helpers/publishingTestDb' +import { createFakeDb } from './dbTestFake' + +afterEach(cleanupPublishingTestDbs) const LIVE_SOURCE_ID = 'acme.di.live' const VISITOR_SOURCE_ID = 'acme.di.visitor' @@ -171,35 +175,16 @@ function makeReq(cookie?: string): Request { } as unknown as Request } -function makeFakeDb( +async function makePublishedDb( snapshot: ReturnType | null, counters?: { snapshotLoads: number }, -): DbClient { - const handle = async = Record>( - strings: TemplateStringsArray, - ..._values: unknown[] - ): Promise> => { - const sql = strings.join(' ').replace(/\s+/g, ' ').trim().toLowerCase() - if (sql.includes('site_snapshots.site_json')) { - if (counters) counters.snapshotLoads++ - return { - rows: snapshot - ? [{ - row_id: snapshot.pageRowId, - site_json: snapshot.site, - runtime_assets_json: null, - importmap_body: null, - importmap_sha256: null, - } as unknown as Row] - : [], - rowCount: snapshot ? 1 : 0, - } - } - return { rows: [], rowCount: 0 } - } - handle.transaction = async (cb: (tx: DbClient) => Promise): Promise => - cb(handle as unknown as DbClient) - return handle as DbClient +): Promise { + const db = await createPublishingTestDb(snapshot?.site ?? null) + if (!counters) return db + return createFakeDb(async (sql, params) => { + if (sql.includes('site_snapshots.site_json')) counters.snapshotLoads++ + return db.unsafe(sql, params) + }) } beforeEach(() => { @@ -286,7 +271,7 @@ describe('findDynamicNodeIds — plugin loop sources', () => { describe('hole endpoint — shared (requestDependent) hole', () => { it('renders request-time data from route.query and does NOT expose cookies', async () => { const snap = makeSnapshotWithLoop('hole-loop', LIVE_SOURCE_ID) - const db = makeFakeDb(snap) + const db = await makePublishedDb(snap) const v = getPublishVersion() const u = encodeURIComponent('/search?q=shoes') const url = new URL(`http://localhost/_instatic/hole/hole-loop?v=${v}&u=${u}`) @@ -301,7 +286,7 @@ describe('hole endpoint — shared (requestDependent) hole', () => { it('caches per query: same query reuses the render, different query re-fetches', async () => { const snap = makeSnapshotWithLoop('hole-loop', LIVE_SOURCE_ID) - const db = makeFakeDb(snap) + const db = await makePublishedDb(snap) const v = getPublishVersion() const hit = async (q: string) => { @@ -327,9 +312,9 @@ describe('hole endpoint — shared (requestDependent) hole', () => { describe('hole endpoint — per-visitor hole', () => { it('reads cookies, bypasses the cache (no-store), and re-renders every request', async () => { const snap = makeSnapshotWithLoop('hole-loop', VISITOR_SOURCE_ID) - const db = makeFakeDb(snap) + const db = await makePublishedDb(snap) const v = getPublishVersion() - const url = new URL(`http://localhost/_instatic/hole/hole-loop?v=${v}&u=${encodeURIComponent('/')}`) + const url = new URL(`http://localhost/_instatic/hole/hole-loop?v=${v}&u=${encodeURIComponent('/search')}`) const res1 = await handleHoleRequest(makeReq('sid=alice'), url, { db }) expect(res1.headers.get('cache-control')).toBe('no-store') @@ -355,11 +340,11 @@ describe('hole endpoint — versioned snapshot cache', () => { it('loads the published snapshot from the DB once per publish version', async () => { const snap = makeSnapshotWithLoop('hole-loop', LIVE_SOURCE_ID) const counters = { snapshotLoads: 0 } - const db = makeFakeDb(snap, counters) + const db = await makePublishedDb(snap, counters) const v = getPublishVersion() for (const q of ['a', 'b', 'c']) { - const url = new URL(`http://localhost/_instatic/hole/hole-loop?v=${v}&u=${encodeURIComponent(`/s?q=${q}`)}`) + const url = new URL(`http://localhost/_instatic/hole/hole-loop?v=${v}&u=${encodeURIComponent(`/search?q=${q}`)}`) await handleHoleRequest(makeReq(), url, { db }) } // Three distinct requests, one snapshot DB read. diff --git a/src/__tests__/server/formChallengeSecret.test.ts b/src/__tests__/server/formChallengeSecret.test.ts index 97854816e..610acbbf6 100644 --- a/src/__tests__/server/formChallengeSecret.test.ts +++ b/src/__tests__/server/formChallengeSecret.test.ts @@ -45,17 +45,17 @@ describe('public form challenge signing secret configuration', () => { }) const pageToken = issuer.issuePublicFormPageToken({ - pageId: 'page-home', + pageId: 'page-home', localeId: 'fr', publishedVersionId: 'version-fr', pagePath: '/fr/newsletter', formId: 'newsletter', }) expect(sameFormSecret.verifyPublicFormPageToken({ - pageId: 'page-home', + pageId: 'page-home', localeId: 'fr', publishedVersionId: 'version-fr', pagePath: '/fr/newsletter', formId: 'newsletter', pageToken, })).toBe(true) expect(changedFormSecret.verifyPublicFormPageToken({ - pageId: 'page-home', + pageId: 'page-home', localeId: 'fr', publishedVersionId: 'version-fr', pagePath: '/fr/newsletter', formId: 'newsletter', pageToken, })).toBe(false) @@ -67,17 +67,17 @@ describe('public form challenge signing secret configuration', () => { const rotatedMasterKey = await importChallengeWithEnv({ secretKey: 'rotated-master-key' }) const pageToken = issuer.issuePublicFormPageToken({ - pageId: 'page-home', + pageId: 'page-home', localeId: 'fr', publishedVersionId: 'version-fr', pagePath: '/fr/newsletter', formId: 'newsletter', }) expect(verifier.verifyPublicFormPageToken({ - pageId: 'page-home', + pageId: 'page-home', localeId: 'fr', publishedVersionId: 'version-fr', pagePath: '/fr/newsletter', formId: 'newsletter', pageToken, })).toBe(true) expect(rotatedMasterKey.verifyPublicFormPageToken({ - pageId: 'page-home', + pageId: 'page-home', localeId: 'fr', publishedVersionId: 'version-fr', pagePath: '/fr/newsletter', formId: 'newsletter', pageToken, })).toBe(false) @@ -88,17 +88,17 @@ describe('public form challenge signing secret configuration', () => { const secondProcessSecret = await importChallengeWithEnv({}) const pageToken = issuer.issuePublicFormPageToken({ - pageId: 'page-home', + pageId: 'page-home', localeId: 'fr', publishedVersionId: 'version-fr', pagePath: '/fr/newsletter', formId: 'newsletter', }) expect(issuer.verifyPublicFormPageToken({ - pageId: 'page-home', + pageId: 'page-home', localeId: 'fr', publishedVersionId: 'version-fr', pagePath: '/fr/newsletter', formId: 'newsletter', pageToken, })).toBe(true) expect(secondProcessSecret.verifyPublicFormPageToken({ - pageId: 'page-home', + pageId: 'page-home', localeId: 'fr', publishedVersionId: 'version-fr', pagePath: '/fr/newsletter', formId: 'newsletter', pageToken, })).toBe(false) diff --git a/src/__tests__/server/holeRouteHandler.test.ts b/src/__tests__/server/holeRouteHandler.test.ts index 803144dd5..c8cfc0a60 100644 --- a/src/__tests__/server/holeRouteHandler.test.ts +++ b/src/__tests__/server/holeRouteHandler.test.ts @@ -1,8 +1,7 @@ /** * Tests for the `/_instatic/hole/` and `/_instatic/hole-runtime.js` endpoints. * - * Uses a minimal fake DbClient that intercepts `getLatestPublishedSiteSnapshot` - * queries (the same pattern as publicRouterCache.test.ts). + * Uses real SQLite migrations, localized publications and route visibility. * * Covers: * - Correct version (matches current publishVersion) → 200 + HTML fragment @@ -13,7 +12,7 @@ * - Runtime asset endpoint serves HOLE_RUNTIME_JS with correct headers */ -import { beforeEach, describe, expect, it } from 'bun:test' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' import type { DbClient, DbResult } from '../../../server/db' import { handleHoleRequest, @@ -26,6 +25,10 @@ import { HOLE_RUNTIME_JS } from '../../../server/publish/holeRuntime' import { handleServerRequest } from '../../../server/router' import { makeModule } from '../publisher/helpers' import { registry } from '../../core/module-engine/registry' +import { cleanupPublishingTestDbs, createPublishingTestDb } from '../helpers/publishingTestDb' +import { createFakeDb } from './dbTestFake' + +afterEach(cleanupPublishingTestDbs) // --------------------------------------------------------------------------- // Snapshot fixture @@ -84,74 +87,25 @@ function makeSnapshot(text = 'Hello from hole') { // Fake DB // --------------------------------------------------------------------------- -function makeFakeDb(snapshot: ReturnType | null): DbClient { - const handle = async = Record>( - strings: TemplateStringsArray, - ..._values: unknown[] - ): Promise> => { - const sql = strings.reduce((acc, str, i) => (i === 0 ? str : `${acc}$${i}${str}`), '') - const normalized = sql.replace(/\s+/g, ' ').trim().toLowerCase() - - if (normalized.includes('site_snapshots.site_json')) { - return { - rows: snapshot - ? [{ - row_id: snapshot.pageRowId, - site_json: snapshot.site, - runtime_assets_json: null, - importmap_body: null, - importmap_sha256: null, - } as unknown as Row] - : [], - rowCount: snapshot ? 1 : 0, - } - } - - return { rows: [], rowCount: 0 } - } - - handle.transaction = async (cb: (tx: DbClient) => Promise): Promise => - cb(handle as unknown as DbClient) - - return handle as DbClient +async function makePublishedDb(snapshot: ReturnType | null): Promise { + return createPublishingTestDb(snapshot?.site ?? null) } -/** - * Fake DB that counts snapshot loads and yields a microtask before resolving, - * so concurrent hole requests overlap and exercise the version-keyed - * single-flight in `publishState`. `count()` reports how many times the - * published-snapshot query actually hit the DB. - */ -function makeCountingDb(snapshot: ReturnType): { +/** Count actual immutable-snapshot hydration while retaining real SQL behavior. */ +async function makeCountingDb(snapshot: ReturnType): Promise<{ db: DbClient count: () => number -} { +}> { + const realDb = await makePublishedDb(snapshot) let loads = 0 - const handle = async = Record>( - strings: TemplateStringsArray, - ..._values: unknown[] - ): Promise> => { - const sql = strings.reduce((acc, str, i) => (i === 0 ? str : `${acc}$${i}${str}`), '') - const normalized = sql.replace(/\s+/g, ' ').trim().toLowerCase() - if (normalized.includes('site_snapshots.site_json')) { + const db = createFakeDb(async (sql, params) => { + if (sql.includes('site_snapshots.site_json')) { loads++ - await Promise.resolve() // let other in-flight callers join before resolving - return { - rows: [{ - row_id: snapshot.pageRowId, - site_json: snapshot.site, - runtime_assets_json: null, - importmap_body: null, - importmap_sha256: null, - } as unknown as Row], - rowCount: 1, - } + await Promise.resolve() } - return { rows: [], rowCount: 0 } - } - handle.transaction = async (cb: (tx: DbClient) => Promise): Promise => - cb(handle as unknown as DbClient) - return { db: handle as DbClient, count: () => loads } + return realDb.unsafe(sql, params) + }) + return { db, count: () => loads } } function makeThrowingDb(): { db: DbClient; wasQueried: () => boolean } { @@ -250,11 +204,11 @@ describe('server router — hole namespace ownership', () => { it('routes hole fragments through the router before public-page fallthrough', async () => { const snapshot = makeSnapshot() - const db = makeFakeDb(snapshot) + const db = await makePublishedDb(snapshot) const version = getPublishVersion() const res = await handleServerRequest( - new Request(`http://localhost/_instatic/hole/text-node?v=${version}`), + new Request(`http://localhost/_instatic/hole/text-node?v=${version}&u=/test`), { db }, ) @@ -267,7 +221,7 @@ describe('server router — hole namespace ownership', () => { const { db, wasQueried } = makeThrowingDb() const res = await handleServerRequest( - new Request('http://localhost/_instatic/hole/?v=0'), + new Request('http://localhost/_instatic/hole/?v=0&u=/test'), { db }, ) @@ -283,10 +237,10 @@ describe('server router — hole namespace ownership', () => { describe('handleHoleRequest — method guard', () => { it('returns 405 for non-GET methods', async () => { - const db = makeFakeDb(null) + const db = await makePublishedDb(null) for (const method of ['POST', 'PUT', 'DELETE', 'PATCH']) { - const url = new URL('http://localhost/_instatic/hole/text-node?v=0') + const url = new URL('http://localhost/_instatic/hole/text-node?v=0&u=/test') const req = new Request(url, { method }) const res = await handleHoleRequest(req, url, { db }) expect(res.status).toBe(405) @@ -300,9 +254,9 @@ describe('handleHoleRequest — method guard', () => { describe('handleHoleRequest — site not published', () => { it('returns 404 when no published snapshot exists', async () => { - const db = makeFakeDb(null) + const db = await makePublishedDb(null) const currentVersion = getPublishVersion() - const url = new URL(`http://localhost/_instatic/hole/text-node?v=${currentVersion}`) + const url = new URL(`http://localhost/_instatic/hole/text-node?v=${currentVersion}&u=/test`) const req = new Request(url) const res = await handleHoleRequest(req, url, { db }) expect(res.status).toBe(404) @@ -316,14 +270,14 @@ describe('handleHoleRequest — site not published', () => { describe('handleHoleRequest — stale version', () => { it('returns stale sentinel when ?v= does not match current publishVersion', async () => { const snapshot = makeSnapshot() - const db = makeFakeDb(snapshot) + const db = await makePublishedDb(snapshot) // Bump the publish version so v=0 becomes stale bumpPublishVersion() const currentVersion = getPublishVersion() // = 1 // Request with the old version (0, now stale) - const url = new URL(`http://localhost/_instatic/hole/text-node?v=0`) + const url = new URL(`http://localhost/_instatic/hole/text-node?v=0&u=/test`) const req = new Request(url) const res = await handleHoleRequest(req, url, { db }) @@ -341,12 +295,12 @@ describe('handleHoleRequest — stale version', () => { it('returns stale sentinel even when a snapshot exists — version mismatch wins', async () => { const snapshot = makeSnapshot() - const db = makeFakeDb(snapshot) + const db = await makePublishedDb(snapshot) bumpPublishVersion() // Old version - const url = new URL(`http://localhost/_instatic/hole/text-node?v=0`) + const url = new URL(`http://localhost/_instatic/hole/text-node?v=0&u=/test`) const req = new Request(url) const res = await handleHoleRequest(req, url, { db }) @@ -361,10 +315,10 @@ describe('handleHoleRequest — stale version', () => { describe('handleHoleRequest — node not found', () => { it('returns 404 when nodeId is not present in any page', async () => { const snapshot = makeSnapshot() - const db = makeFakeDb(snapshot) + const db = await makePublishedDb(snapshot) const currentVersion = getPublishVersion() - const url = new URL(`http://localhost/_instatic/hole/no-such-node?v=${currentVersion}`) + const url = new URL(`http://localhost/_instatic/hole/no-such-node?v=${currentVersion}&u=/test`) const req = new Request(url) const res = await handleHoleRequest(req, url, { db }) expect(res.status).toBe(404) @@ -378,10 +332,10 @@ describe('handleHoleRequest — node not found', () => { describe('handleHoleRequest — successful render', () => { it('returns 200 with rendered HTML fragment for a matching node + version', async () => { const snapshot = makeSnapshot() - const db = makeFakeDb(snapshot) + const db = await makePublishedDb(snapshot) const currentVersion = getPublishVersion() - const url = new URL(`http://localhost/_instatic/hole/text-node?v=${currentVersion}`) + const url = new URL(`http://localhost/_instatic/hole/text-node?v=${currentVersion}&u=/test`) const req = new Request(url) const res = await handleHoleRequest(req, url, { db }) @@ -395,10 +349,10 @@ describe('handleHoleRequest — successful render', () => { it('second request for the same node+version hits the Layer B cache (same body)', async () => { const snapshot = makeSnapshot() - const db = makeFakeDb(snapshot) + const db = await makePublishedDb(snapshot) const currentVersion = getPublishVersion() - const url = new URL(`http://localhost/_instatic/hole/text-node?v=${currentVersion}`) + const url = new URL(`http://localhost/_instatic/hole/text-node?v=${currentVersion}&u=/test`) const res1 = await handleHoleRequest(new Request(url), url, { db }) const body1 = await res1.text() @@ -413,30 +367,33 @@ describe('handleHoleRequest — successful render', () => { it('keys the fragment cache on the page path, so a poisoned u= cannot leak across paths (GHSA-f29g)', async () => { // The fragment's content depends on the originating page path (route.path). const snapshot = makeSnapshot('viewing:{route.path}') - const db = makeFakeDb(snapshot) + const db = await makePublishedDb(snapshot) const version = getPublishVersion() // Attacker renders the fragment under an arbitrary originating path. const evilUrl = new URL(`http://localhost/_instatic/hole/text-node?v=${version}&u=${encodeURIComponent('/evil-path')}`) const evil = await handleHoleRequest(new Request(evilUrl), evilUrl, { db }) - expect(await evil.text()).toContain('/evil-path') + expect(evil.status).toBe(404) // A normal visitor of a different path (same query) must render its own // path, not be served the attacker's fragment from a shared cache slot. - const realUrl = new URL(`http://localhost/_instatic/hole/text-node?v=${version}&u=${encodeURIComponent('/')}`) + const realUrl = new URL(`http://localhost/_instatic/hole/text-node?v=${version}&u=${encodeURIComponent('/test')}`) const real = await handleHoleRequest(new Request(realUrl), realUrl, { db }) - expect(await real.text()).not.toContain('/evil-path') + expect(real.status).toBe(200) + const html = await real.text() + expect(html).toContain('/test') + expect(html).not.toContain('/evil-path') }) it('becomes stale for old ?v= after bumpPublishVersion()', async () => { const snapshot = makeSnapshot() - const db = makeFakeDb(snapshot) + const db = await makePublishedDb(snapshot) const oldVersion = getPublishVersion() // = 0 bumpPublishVersion() // now = 1 // Old version is now stale - const url = new URL(`http://localhost/_instatic/hole/text-node?v=${oldVersion}`) + const url = new URL(`http://localhost/_instatic/hole/text-node?v=${oldVersion}&u=/test`) const req = new Request(url) const res = await handleHoleRequest(req, url, { db }) @@ -451,9 +408,9 @@ describe('handleHoleRequest — successful render', () => { describe('handleHoleRequest — snapshot single-flight', () => { it('loads the published snapshot once for concurrent requests at the same version', async () => { - const { db, count } = makeCountingDb(makeSnapshot()) + const { db, count } = await makeCountingDb(makeSnapshot()) const currentVersion = getPublishVersion() - const url = new URL(`http://localhost/_instatic/hole/text-node?v=${currentVersion}`) + const url = new URL(`http://localhost/_instatic/hole/text-node?v=${currentVersion}&u=/test`) // Fire several concurrent requests for the same (nodeId, version). The // version-keyed single-flight memo must collapse them into one DB load. @@ -469,16 +426,16 @@ describe('handleHoleRequest — snapshot single-flight', () => { }) it('reloads after a version bump (memo is version-keyed)', async () => { - const { db, count } = makeCountingDb(makeSnapshot()) + const { db, count } = await makeCountingDb(makeSnapshot()) const v0 = getPublishVersion() - const url0 = new URL(`http://localhost/_instatic/hole/text-node?v=${v0}`) + const url0 = new URL(`http://localhost/_instatic/hole/text-node?v=${v0}&u=/test`) await handleHoleRequest(new Request(url0), url0, { db }) expect(count()).toBe(1) bumpPublishVersion() const v1 = getPublishVersion() - const url1 = new URL(`http://localhost/_instatic/hole/text-node?v=${v1}`) + const url1 = new URL(`http://localhost/_instatic/hole/text-node?v=${v1}&u=/test`) await handleHoleRequest(new Request(url1), url1, { db }) expect(count()).toBe(2) }) @@ -500,9 +457,10 @@ describe('hole fragments and CMS forms', () => { const snapshot = makeSnapshot() snapshot.site.pages[0].nodes['text-node'].moduleId = 'test.cmsform' + const db = await makePublishedDb(snapshot) const version = getPublishVersion() - const url = new URL(`http://localhost/_instatic/hole/text-node?v=${version}`) - const res = await handleHoleRequest(new Request(url), url, { db: makeFakeDb(snapshot) }) + const url = new URL(`http://localhost/_instatic/hole/text-node?v=${version}&u=/test`) + const res = await handleHoleRequest(new Request(url), url, { db }) expect(res.status).toBe(200) const html = await res.text() diff --git a/src/__tests__/server/localizedCollabGuard.test.ts b/src/__tests__/server/localizedCollabGuard.test.ts new file mode 100644 index 000000000..409c619da --- /dev/null +++ b/src/__tests__/server/localizedCollabGuard.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'bun:test' +import * as Y from 'yjs' +import '@modules/base' +import { applyLocalizationDraftToDoc, dataMap, encodeCollabDocId, metaMap, projectLocalizationDoc, seedLocalizationDoc } from '@core/collab' +import type { DataField } from '@core/data/schemas' +import type { CoreCapability } from '@core/capabilities' +import { validateGuardedUpdate } from '../../../server/collab/updateGuard' +import { makeNode, makeVC } from '../fixtures' + +const docId = encodeCollabDocId({ kind: 'page', rowId: 'p1', localeId: 'de' }) +const fields: DataField[] = [ + { id: 'title', label: 'Title', type: 'text', localization: 'localized' }, + { id: 'body', label: 'Body', type: 'pageTree', localization: 'localized' }, + { id: 'templateEnabled', label: 'Template', type: 'boolean', localization: 'shared' }, +] +const context = { fields, sharedCells: { body: { rootNodeId: 'root', nodes: { + root: makeNode({ id: 'root', moduleId: 'base.body', children: ['text'] }), + text: makeNode({ id: 'text', moduleId: 'base.text', props: { text: 'Source' } }), +} } } } +const full: CoreCapability[] = ['site.content.edit', 'site.structure.edit', 'site.style.edit'] + +function verdict(mutate: (fork: Y.Doc) => void, capabilities: CoreCapability[] = full) { + const doc = new Y.Doc(); seedLocalizationDoc(doc, { cells: {}, slug: 'index' }) + const fork = new Y.Doc(); Y.applyUpdate(fork, Y.encodeStateAsUpdate(doc)) + const vector = Y.encodeStateVector(fork) + mutate(fork) + const result = validateGuardedUpdate(docId, doc, Y.encodeStateAsUpdate(fork, vector), capabilities, context) + expect(projectLocalizationDoc(doc)).toEqual({ cells: {}, slug: 'index', translationMeta: {} }) + fork.destroy(); doc.destroy() + return result +} + +describe('localized collab update guard', () => { + it('allows copy editors to translate metadata and node content without shared structural permission', () => { + expect(verdict((doc) => applyLocalizationDraftToDoc(doc, projectLocalizationDoc(doc), { + slug: 'start', cells: { title: 'Startseite', body: { nodes: { text: { props: { text: 'Hallo' }, hidden: false } } } }, + }, 'peer'), ['site.content.edit'])).toEqual({ ok: true }) + }) + + it('rejects writes from a viewer while allowing its empty handshake', () => { + expect(verdict(() => {}, []).ok).toBe(true) + expect(verdict((doc) => metaMap(doc).set('slug', 'new'), []).ok).toBe(false) + }) + + it('rejects shared field overrides even from a full site writer', () => { + expect(verdict((doc) => applyLocalizationDraftToDoc(doc, projectLocalizationDoc(doc), { slug: 'index', cells: { templateEnabled: true } }, 'peer')).ok).toBe(false) + }) + + it('rejects non-content props and fabricated nodes before the authoritative doc changes', () => { + expect(verdict((doc) => applyLocalizationDraftToDoc(doc, projectLocalizationDoc(doc), { + slug: 'index', cells: { body: { nodes: { text: { props: { componentId: 'other' } } } } }, + }, 'peer')).ok).toBe(false) + expect(verdict((doc) => applyLocalizationDraftToDoc(doc, projectLocalizationDoc(doc), { + slug: 'index', cells: { body: { nodes: { fabricated: { props: { text: 'Oops' } } } } }, + }, 'peer')).ok).toBe(false) + }) + + it('enforces component parameter type and binding policy for defaults and instance overrides', () => { + const component = makeVC({ id: 'card', name: 'Card', params: [ + { id: 'copy', name: 'Copy', type: 'string', defaultValue: 'Hi', required: false }, + { id: 'paint', name: 'Paint', type: 'color', defaultValue: 'red', required: false }, + ] }) + const shared = { fields: [...fields, { id: 'parameterDefaults', label: 'Defaults', type: 'parameterValues' as const, localization: 'localized' as const }], + sharedCells: { body: component.tree, params: component.params }, components: [component] } + const doc = new Y.Doc(); seedLocalizationDoc(doc, { cells: {}, slug: 'index' }) + const incoming = (values: Record) => { + const fork = new Y.Doc(); Y.applyUpdate(fork, Y.encodeStateAsUpdate(doc)) + applyLocalizationDraftToDoc(fork, projectLocalizationDoc(fork), { cells: { parameterDefaults: values }, slug: 'index' }, 'peer') + const result = validateGuardedUpdate(docId, doc, Y.encodeStateAsUpdate(fork), full, shared) + fork.destroy() + return result.ok + } + expect(incoming({ copy: 'Hallo' })).toBe(true) + expect(incoming({ paint: 'blue' })).toBe(false) + expect(incoming({ copy: { injected: true } })).toBe(false) + expect(incoming({ invented: 'Hi' })).toBe(false) + doc.destroy() + }) + + it('rejects injecting a full body tree through generic cells', () => { + expect(verdict((doc) => { + const cells = dataMap(doc).get('cells') as Y.Map + cells.set('body', context.sharedCells.body) + }).ok).toBe(false) + }) +}) diff --git a/src/__tests__/server/localizedPublicForms.test.ts b/src/__tests__/server/localizedPublicForms.test.ts new file mode 100644 index 000000000..ec16bf10d --- /dev/null +++ b/src/__tests__/server/localizedPublicForms.test.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import type { PublicFormIdentity } from '@core/forms' +import type { Page } from '@core/page-tree' +import { makePage, makeSite } from '../publisher/helpers' +import { makeVC } from '../fixtures' +import { cleanupPublishingTestDbs, createPublishingTestDb } from '../helpers/publishingTestDb' +import { createLocale } from '../../../server/repositories/localization' +import { createDataRow, createDataTable, getDataRow, updateDataRowStatus } from '../../../server/repositories/data' +import { publishDraftSite } from '../../../server/publish/publishSite' +import { publishDataRow } from '../../../server/publish/publishRow' +import { loadPublishedRouteInventory } from '../../../server/publish/publishedRoutes' +import { findPublishedFormSnapshot } from '../../../server/forms/publishedFormSnapshot' +import { issuePublicFormPageToken, resetPublicFormChallenges, verifyPublicFormPageToken } from '../../../server/forms/challenge' +import { handlePublicFormRequest } from '../../../server/forms/handler' +import { resetForTests } from '../../../server/publish/renderCache' +import { renderPublicResolution } from '../../../server/publish/publicRouter' +import { resetPublicOrigins } from '../../../server/auth/security' +import * as rateLimits from '../../../server/forms/rateLimit' + +beforeEach(() => { + resetForTests() + resetPublicOrigins() + resetPublicFormChallenges() + for (const limit of Object.values(rateLimits)) limit.reset() +}) +afterEach(cleanupPublishingTestDbs) + +async function fixture(kind: 'page' | 'component' | 'entry' = 'page') { + const form = makePage({ + form: { moduleId: 'base.form', props: { mode: 'cms', formId: 'contact', targetTableId: 'submissions', minSubmitSeconds: 0 }, children: ['email', 'hidden'] }, + email: { moduleId: 'base.input', props: { fieldId: 'email', name: 'email', inputType: 'email', required: true } }, + hidden: { moduleId: 'base.input', props: { fieldId: 'private', name: 'private', required: true }, hidden: true }, + }) + form.rootNodeId = 'form' + form.id = 'contact' + form.slug = 'contact' + const page: Page = kind === 'component' ? { ...makePage({ root: { moduleId: 'base.visual-component-ref', props: { componentId: 'contact-component' } } }), id: 'contact', slug: 'contact' } : form + if (kind === 'entry') page.template = { enabled: true, target: { kind: 'postTypes', tableSlugs: ['posts'] }, priority: 0 } + const site = makeSite({ pages: [page], visualComponents: kind === 'component' ? [makeVC({ id: 'contact-component', name: 'Contact', tree: { nodes: form.nodes, rootNodeId: 'form' } })] : [] }) + const db = await createPublishingTestDb(site, false) + await createDataTable(db, { id: 'submissions', name: 'Submissions', slug: 'submissions', kind: 'data', singularLabel: 'Submission', pluralLabel: 'Submissions', fields: [{ id: 'email', label: 'Email', type: 'email', required: true }] }) + const locale = await createLocale(db, { code: 'fr', name: 'Français', pathPrefix: 'fr', enabled: true, direction: 'ltr' }) + await publishDraftSite(db, null, undefined, { variants: [{ rowId: 'contact', localeId: locale.id }] }) + if (kind === 'entry') { + await createDataRow(db, { id: 'article', tableId: 'posts', slug: 'article', cells: { title: 'Article', body: 'Article body' } }) + await publishDataRow(db, 'article', null, undefined, { localeId: locale.id }) + } + const route = (await loadPublishedRouteInventory(db)).routes[0] + const identity: PublicFormIdentity = { pageId: route.contentId, localeId: route.localeId, publishedVersionId: route.publishedVersionId, pagePath: route.path, formId: 'contact' } + return { db, identity } +} + +async function request(db: Awaited>['db'], kind: 'challenge' | 'submit', payload: Record) { + const url = new URL(`http://forms.test/_instatic/form/${kind}`) + const req = new Request(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) }) + req.headers.set('origin', url.origin) + req.headers.set('sec-fetch-site', 'same-origin') + return (await handlePublicFormRequest(req, db, url))! +} + +describe('published form language identity', () => { + it('accepts an independently online translated form and stores its language', async () => { + const { db, identity } = await fixture() + const htmlResponse = await renderPublicResolution(db, new URL(`http://forms.test${identity.pagePath}`)) + const html = await htmlResponse!.text() + expect(html).toContain(`data-instatic-locale-id="${identity.localeId}"`) + expect(html).toContain(`data-instatic-published-version-id="${identity.publishedVersionId}"`) + const snapshot = await findPublishedFormSnapshot(db, identity) + expect(snapshot?.controls.map((control) => control.fieldId)).toEqual(['email']) + const challengeResponse = await request(db, 'challenge', { ...identity, pageToken: issuePublicFormPageToken(identity) }) + expect(challengeResponse.status).toBe(200) + const challenge = await challengeResponse.json() + const submitted = await request(db, 'submit', { ...identity, ...challenge, values: { email: 'reader@example.test' } }) + expect(submitted.status).toBe(200) + const result = await submitted.json() + expect((await getDataRow(db, result.rowId, identity.localeId))?.localeId).toBe(identity.localeId) + expect(await findPublishedFormSnapshot(db, { ...identity, localeId: 'default', pagePath: '/contact' })).toBeNull() + }) + + it('resolves a visible localized component form and revokes it on retraction', async () => { + const { db, identity } = await fixture('component') + expect((await findPublishedFormSnapshot(db, identity))?.controls).toHaveLength(1) + const challengeResponse = await request(db, 'challenge', { ...identity, pageToken: issuePublicFormPageToken(identity) }) + const challenge = await challengeResponse.json() + await updateDataRowStatus(db, identity.pageId, 'unpublished', null, identity.localeId) + expect(await findPublishedFormSnapshot(db, identity)).toBeNull() + const submitted = await request(db, 'submit', { ...identity, ...challenge, values: { email: 'reader@example.test' } }) + expect(submitted.status).toBe(404) + }) + + it('binds a CMS form to its live item while retaining the frozen template dependency', async () => { + const { db, identity } = await fixture('entry') + expect(identity.pageId).toBe('article') + expect(identity.pagePath).toBe('/fr/posts/article') + await updateDataRowStatus(db, 'contact', 'unpublished', null, identity.localeId) + const response = await renderPublicResolution(db, new URL(`http://forms.test${identity.pagePath}`)) + expect(await response!.text()).toContain('data-instatic-page-id="article"') + const challengeResponse = await request(db, 'challenge', { ...identity, pageToken: issuePublicFormPageToken(identity) }) + expect(challengeResponse.status).toBe(200) + const challenge = await challengeResponse.json() + expect((await request(db, 'submit', { ...identity, ...challenge, values: { email: 'reader@example.test' } })).status).toBe(200) + expect(await findPublishedFormSnapshot(db, { ...identity, pageId: 'contact' })).toBeNull() + await updateDataRowStatus(db, identity.pageId, 'unpublished', null, identity.localeId) + expect(await findPublishedFormSnapshot(db, identity)).toBeNull() + }) + + it('binds form tokens to the exact language, path, logical content and release', async () => { + const { db, identity } = await fixture() + const pageToken = issuePublicFormPageToken(identity) + for (const patch of [{ localeId: 'default' }, { pagePath: '/contact' }, { pageId: 'other' }, { publishedVersionId: 'other' }]) { + expect(verifyPublicFormPageToken({ ...identity, ...patch, pageToken })).toBe(false) + } + await publishDraftSite(db, null, undefined, { variants: [{ rowId: identity.pageId, localeId: identity.localeId }] }) + expect(await findPublishedFormSnapshot(db, identity)).toBeNull() + expect((await request(db, 'challenge', { ...identity, pageToken })).status).toBe(404) + }) +}) diff --git a/src/__tests__/server/localizedPublication.test.ts b/src/__tests__/server/localizedPublication.test.ts new file mode 100644 index 000000000..8005eff05 --- /dev/null +++ b/src/__tests__/server/localizedPublication.test.ts @@ -0,0 +1,336 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { mkdtemp, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { makePage, makeSite } from '../publisher/helpers' +import { createTestDb, type TestDb } from '../helpers/createTestDb' +import { pageToCells } from '@core/data/pageFromRow' +import { makePageRef } from '@core/page-tree' +import { saveDraftSite, getDraftSite } from '../../../server/repositories/site' +import { createDataRow, getDataRow, updateDataRowStatus } from '../../../server/repositories/data' +import { createLocale, saveContentLocalizationDraft, saveTableLocalization, updateLocale } from '../../../server/repositories/localization' +import { publishDraftSite } from '../../../server/publish/publishSite' +import { publishDataRow } from '../../../server/publish/publishRow' +import { getPublishVersion, bumpPublishVersionSerialized, arePublishedArtefactsCurrent } from '../../../server/publish/publishState' +import { loadPublishedRouteInventory } from '../../../server/publish/publishedRoutes' +import { renderNotFoundResponse, renderPublicResolution } from '../../../server/publish/publicRouter' +import { handleHoleRequest } from '../../../server/handlers/cms/hole' +import { handleLoopRequest } from '../../../server/handlers/cms/loop' +import { readArtefact } from '../../../server/publish/staticArtefact' +import { scheduleLocalizedDataRowPublish } from '../../../server/publish/schedulePublication' +import { tickPublishScheduler } from '../../../server/publish/publishScheduler' +import { getPublishedDataRowById } from '../../../server/repositories/data' +import { handleServerRequest } from '../../../server/router' +import { resetForTests } from '../../../server/publish/renderCache' +import { getLatestPublishedSiteSnapshot } from '../../../server/repositories/publish' + +let testDb: TestDb +let uploadsDir: string +let fr: string +const publicUrl = (path: string) => new URL(path, 'https://site.test') + +beforeEach(async () => { + resetForTests() + testDb = await createTestDb() + uploadsDir = await mkdtemp(join(tmpdir(), 'localized-publish-')) + const site = makeSite({ layouts: [] }) + site.settings.publicOrigin = 'https://site.test' + await saveDraftSite(testDb.db, site) + fr = (await createLocale(testDb.db, { code: 'fr', name: 'Français', pathPrefix: 'fr', enabled: true, direction: 'ltr' })).id +}) + +afterEach(async () => { + await testDb.cleanup() + await rm(uploadsDir, { recursive: true, force: true }) + resetForTests() +}) + +async function page(id: string, slug: string, text: string, extraNodes: Parameters[0] = {}) { + const value = { ...makePage({ root: { moduleId: 'base.container', children: ['copy', ...Object.keys(extraNodes).filter((id) => !Object.values(extraNodes).some((node) => node.children?.includes(id)))] }, + copy: { moduleId: 'base.text', props: { text, tag: 'p', htmlAttributes: {} } }, ...extraNodes }), id, slug, title: text } + return createDataRow(testDb.db, { id, tableId: 'pages', slug, cells: pageToCells(value) }) +} + +async function translate(rowId: string, slug: string, text: string) { + await saveContentLocalizationDraft(testDb.db, rowId, fr, { + slug, cells: { title: text, seoTitle: `${text} SEO`, seoDescription: `${text} description`, body: { nodes: { copy: { props: { text } } } } }, + }) +} + +async function html(path: string, disk = true): Promise { + const response = await renderPublicResolution(testDb.db, publicUrl(path), disk ? uploadsDir : undefined) + expect(response?.status).toBe(200) + return response!.text() +} + +async function hole(path: string, nodeId = 'copy'): Promise { + const url = publicUrl(`/_instatic/hole/${nodeId}?v=${getPublishVersion()}&u=${encodeURIComponent(path)}`) + return handleHoleRequest(new Request(url), url, { db: testDb.db }) +} + +describe('locale publication end to end', () => { + it('recreates each live release CSS after independent language publications without disk artefacts', async () => { + await page('about', 'about', 'About') + await translate('about', 'a-propos', 'À propos') + const shell = await getDraftSite(testDb.db) + expect(shell).not.toBeNull() + const stylesheet = { id: 'theme', path: 'theme.css', type: 'style' as const, content: 'body{color:maroon}', createdAt: 1, updatedAt: 1 } + await saveDraftSite(testDb.db, { ...shell!, files: [stylesheet] }) + await publishDraftSite(testDb.db, null, undefined, { variants: [{ rowId: 'about', localeId: 'default' }] }) + await saveDraftSite(testDb.db, { ...shell!, files: [{ ...stylesheet, content: 'body{color:navy}' }] }) + await publishDraftSite(testDb.db, null, undefined, { variants: [{ rowId: 'about', localeId: fr }] }) + for (const [path, color] of [['/about', 'maroon'], ['/fr/a-propos', 'navy']]) { + const body = await html(path, false) + const cssPath = body.match(/href="(\/_instatic\/css\/userStyles-[^"]+\.css)"/)?.[1] + expect(cssPath).toBeString() + const response = await handleServerRequest(new Request(publicUrl(cssPath!)), { db: testDb.db }) + expect(response.status).toBe(200) + expect(await response.text()).toContain(color) + } + }) + + it('publishes only selected variants with frozen independent URLs, metadata and fragments', async () => { + await page('about', 'about', 'About source') + await page('private', 'private', 'Private source') + await translate('about', 'a-propos', 'À propos') + await publishDraftSite(testDb.db, null, uploadsDir, { variants: [{ rowId: 'about', localeId: fr }] }) + expect((await loadPublishedRouteInventory(testDb.db)).routes.map((route) => route.path)).toEqual(['/fr/a-propos']) + expect(await renderPublicResolution(testDb.db, publicUrl('/about'), uploadsDir)).toBeNull() + const french = await html('/fr/a-propos') + expect(french).toContain('') + expect(french).toContain('À propos SEO') + expect(french).toContain('href="https://site.test/fr/a-propos"') + expect(french).not.toContain('x-default') + expect(await (await hole('/fr/a-propos')).text()).toContain('À propos') + expect((await hole('/about')).status).toBe(404) + expect((await hole('/private')).status).toBe(404) + await publishDraftSite(testDb.db, null, uploadsDir) + expect((await loadPublishedRouteInventory(testDb.db)).routes).toHaveLength(1) + }) + + it('allocates distinct versions across locales and keeps secondary online after source retraction', async () => { + await page('about', 'about', 'Source title') + await translate('about', 'a-propos', 'Titre français') + await publishDraftSite(testDb.db, null, uploadsDir, { variants: [{ rowId: 'about', localeId: 'default' }, { rowId: 'about', localeId: fr }] }) + const english = await html('/about') + expect(english).toContain('hreflang="fr"') + expect(english).toContain('hreflang="x-default"') + await updateDataRowStatus(testDb.db, 'about', 'unpublished', null, 'default') + expect(arePublishedArtefactsCurrent()).toBe(false) + expect(await readArtefact(uploadsDir, '/about')).not.toBeNull() + expect(await renderPublicResolution(testDb.db, publicUrl('/about'), uploadsDir)).toBeNull() + expect((await hole('/about')).status).toBe(404) + const french = await html('/fr/a-propos') + expect(french).toContain('Titre français') + expect(french).not.toContain('hreflang="x-default"') + const sitemap = await renderPublicResolution(testDb.db, publicUrl('/sitemap.xml')) + expect(await sitemap!.text()).not.toContain('https://site.test/about') + }) + + it('refreshes links and site-page lists when a previously absent translation comes online or goes offline', async () => { + await page('nav', 'index', 'Navigation', { + link: { moduleId: 'base.button', props: { text: 'Go', href: makePageRef('destination') } }, + loop: { moduleId: 'base.loop', props: { sourceId: 'site.pages', filters: {}, pagination: 'infinite', pageSize: 20 }, children: ['item'] }, + item: { moduleId: 'base.text', props: { text: '{currentEntry.title}', tag: 'p', htmlAttributes: {} } }, + }) + await translate('nav', 'index', 'Navigation française') + await publishDraftSite(testDb.db, null, uploadsDir, { variants: [{ rowId: 'nav', localeId: fr }] }) + await page('destination', 'destination', 'Hidden source title') + await translate('destination', 'destination-fr', 'Nouvelle destination') + await publishDataRow(testDb.db, 'destination', null, uploadsDir, { localeId: fr }) + const published = await html('/fr') + expect(published).toContain('/fr/destination-fr') + expect(published).toContain('Nouvelle destination') + const loopUrl = publicUrl(`/_instatic/loop/loop?page=1&pagePath=${encodeURIComponent('/fr')}`) + const loopResult = await handleLoopRequest(new Request(loopUrl), loopUrl, { db: testDb.db }) + expect(loopResult.status).toBe(200) + expect(await loopResult.text()).toContain('Nouvelle destination') + await updateDataRowStatus(testDb.db, 'destination', 'unpublished', null, fr) + const retracted = await html('/fr') + expect(retracted).not.toContain('/fr/destination-fr') + expect(retracted).not.toContain('Nouvelle destination') + expect(retracted).not.toContain('Hidden source title') + await updateDataRowStatus(testDb.db, 'nav', 'unpublished', null, fr) + expect((await handleLoopRequest(new Request(loopUrl), loopUrl, { db: testDb.db })).status).toBe(404) + }) + + it('does not rename published URLs or hreflang after draft language configuration changes', async () => { + await page('about', 'about', 'Source') + await translate('about', 'a-propos', 'Version française') + await publishDataRow(testDb.db, 'about', null, uploadsDir, { localeId: fr }) + await translate('about', 'nouveau', 'New draft') + await updateLocale(testDb.db, fr, { code: 'fr-CA', pathPrefix: 'canada', direction: 'rtl' }) + await bumpPublishVersionSerialized() + const frozen = await html('/fr/a-propos') + expect(frozen).toContain('') + expect(frozen).toContain('hreflang="fr"') + expect(frozen).not.toContain('New draft') + expect(await renderPublicResolution(testDb.db, publicUrl('/canada/nouveau'))).toBeNull() + await publishDataRow(testDb.db, 'about', null, uploadsDir, { localeId: fr }) + const redirect = await renderPublicResolution(testDb.db, publicUrl('/fr/a-propos?campaign=1'), uploadsDir) + expect(redirect?.status).toBe(301) + expect(redirect?.headers.get('location')).toBe('/canada/nouveau?campaign=1') + expect(await html('/canada/nouveau')).toContain('') + }) + + it('keeps the stored language of releases created before localization when the source language is renamed', async () => { + await page('about', 'about', 'Published before localization') + await updateLocale(testDb.db, 'default', { code: 'ar', direction: 'rtl' }) + await publishDataRow(testDb.db, 'about', null) + const snapshot = (await getLatestPublishedSiteSnapshot(testDb.db, 'default'))! + const { locales: _locales, localeId: _localeId, ...withoutLocales } = snapshot.site + const storedSite = { ...withoutLocales, settings: { ...withoutLocales.settings, language: 'ar' } } + // Model the persisted pre-localization JSON shape without rewriting it on reads. + await testDb.db`update site_snapshots set site_json = ${storedSite} where id = ${snapshot.siteSnapshotId}` + await updateLocale(testDb.db, 'default', { code: 'de', direction: 'ltr' }) + await bumpPublishVersionSerialized() + const route = (await loadPublishedRouteInventory(testDb.db)).routes[0] + expect(route.languageCode).toBe('ar') + expect(route.direction).toBe('rtl') + const published = await html('/about', false) + expect(published).toContain('') + expect(published).toContain('hreflang="ar"') + expect(published).not.toContain('hreflang="de"') + expect((await testDb.db`select site_json from site_snapshots where id = ${snapshot.siteSnapshotId}`).rows[0].site_json).toEqual(storedSite) + await publishDataRow(testDb.db, 'about', null) + expect((await loadPublishedRouteInventory(testDb.db)).routes[0].languageCode).toBe('de') + }) + + it('rejects a conflicting frozen language code before activating any selected release', async () => { + await page('about', 'about', 'Source') + await page('other', 'other', 'Still private') + await translate('about', 'a-propos', 'Version française') + await publishDataRow(testDb.db, 'about', null, undefined, { localeId: fr }) + const original = (await loadPublishedRouteInventory(testDb.db)).routes[0] + const version = getPublishVersion() + const versions = await testDb.db`select id from data_row_versions` + const snapshots = await testDb.db`select id from site_snapshots` + await updateLocale(testDb.db, fr, { code: 'fr-CA' }) + const replacement = await createLocale(testDb.db, { code: 'fr', name: 'Français de France', pathPrefix: 'france', enabled: true, direction: 'ltr' }) + await expect(publishDraftSite(testDb.db, null, undefined, { variants: [ + { rowId: 'other', localeId: 'default' }, { rowId: 'about', localeId: replacement.id }, + ] })).rejects.toThrow('already published for this content') + expect((await loadPublishedRouteInventory(testDb.db)).routes).toEqual([original]) + expect((await testDb.db`select id from data_row_versions`).rows).toEqual(versions.rows) + expect((await testDb.db`select id from site_snapshots`).rows).toEqual(snapshots.rows) + expect(getPublishVersion()).toBe(version) + await publishDraftSite(testDb.db, null, undefined, { variants: [ + { rowId: 'about', localeId: fr }, { rowId: 'about', localeId: replacement.id }, + ] }) + expect((await loadPublishedRouteInventory(testDb.db)).routes.map((route) => route.languageCode).toSorted()).toEqual(['fr', 'fr-CA']) + }) + + it('publishes CMS variants through a frozen same-language template and translated collection base', async () => { + const template = { ...makePage({ root: { moduleId: 'base.text', props: { text: '{currentEntry.title}', tag: 'p', htmlAttributes: {} } } }), + id: 'entry-template', slug: 'entry-template', template: { enabled: true, target: { kind: 'postTypes', tableSlugs: ['posts'] }, priority: 0 } } + await createDataRow(testDb.db, { id: template.id, tableId: 'pages', slug: template.slug, cells: pageToCells(template) }) + await publishDraftSite(testDb.db, null, uploadsDir, { variants: [{ rowId: template.id, localeId: fr }] }) + await createDataRow(testDb.db, { id: 'post', tableId: 'posts', slug: 'post', cells: { title: 'Source article', body: 'Source body' } }) + await saveContentLocalizationDraft(testDb.db, 'post', fr, { slug: 'article', cells: { title: 'Article français', body: 'Texte français' } }) + await saveTableLocalization(testDb.db, 'posts', fr, '/actualites') + await publishDataRow(testDb.db, 'post', null, uploadsDir, { localeId: fr }) + expect(await html('/fr/actualites/article')).toContain('Article français') + expect(await renderPublicResolution(testDb.db, publicUrl('/fr/entry-template'))).toBeNull() + expect(await renderPublicResolution(testDb.db, publicUrl('/posts/post'))).toBeNull() + await updateDataRowStatus(testDb.db, 'post', 'unpublished', null, fr) + expect(await renderPublicResolution(testDb.db, publicUrl('/fr/actualites/article'), uploadsDir)).toBeNull() + }) + + it('publishes a template and then a CMS route while the source homepage stays offline', async () => { + await page('home', 'index', 'Private homepage') + await createDataRow(testDb.db, { id: 'post', tableId: 'posts', slug: 'post', cells: { title: 'Public article', body: 'Article body' } }) + await publishDataRow(testDb.db, 'post', null) + expect((await loadPublishedRouteInventory(testDb.db)).routes).toEqual([]) + const template = { ...makePage({ root: { moduleId: 'base.text', props: { text: '{currentEntry.title}', tag: 'p', htmlAttributes: {} } } }), + id: 'post-template', slug: 'post-template', template: { enabled: true, target: { kind: 'postTypes', tableSlugs: ['posts'] }, priority: 0 } } + await createDataRow(testDb.db, { id: template.id, tableId: 'pages', slug: template.slug, cells: pageToCells(template) }) + await publishDraftSite(testDb.db, null, uploadsDir, { variants: [{ rowId: template.id, localeId: 'default' }] }) + await publishDataRow(testDb.db, 'post', null, uploadsDir) + expect(await html('/posts/post')).toContain('Public article') + expect(await renderPublicResolution(testDb.db, publicUrl('/'), uploadsDir)).toBeNull() + expect(await renderPublicResolution(testDb.db, publicUrl('/post-template'), uploadsDir)).toBeNull() + expect((await getDataRow(testDb.db, 'home'))?.localization?.availability).toBe('offline') + }) + + it('freezes template dependencies without activating unselected template variants', async () => { + await page('home', 'index', 'Homepage') + for (const id of ['active-template', 'offline-template']) { + const template = { ...makePage({ root: { moduleId: 'base.text', props: { text: id, tag: 'p', htmlAttributes: {} } } }), + id, slug: id, template: { enabled: true, target: { kind: 'postTypes', tableSlugs: ['posts'] }, priority: 0 } } + await createDataRow(testDb.db, { id, tableId: 'pages', slug: id, cells: pageToCells(template) }) + } + await publishDraftSite(testDb.db, null, undefined, { variants: [{ rowId: 'active-template', localeId: 'default' }] }) + const active = (await getDataRow(testDb.db, 'active-template'))!.localization! + const offline = (await getDataRow(testDb.db, 'offline-template'))!.localization! + expect(offline.availability).toBe('offline') + expect(offline.activeVersionId).toBeNull() + await publishDraftSite(testDb.db, null, undefined, { variants: [{ rowId: 'home', localeId: 'default' }] }) + expect((await getDataRow(testDb.db, 'active-template'))!.localization).toEqual(active) + expect((await getDataRow(testDb.db, 'offline-template'))!.localization).toEqual(offline) + const snapshot = (await getLatestPublishedSiteSnapshot(testDb.db, 'default'))! + expect(snapshot.site.pages.filter((value) => value.template?.enabled).map((value) => value.id).toSorted()) + .toEqual(['active-template', 'offline-template']) + expect((await loadPublishedRouteInventory(testDb.db)).dependencies.map((value) => value.contentId)).toEqual(['active-template']) + }) + + it('freezes scheduled page content, routes and shared design dependencies while preserving later drafts', async () => { + await page('scheduled', 'scheduled', 'Before schedule') + await translate('scheduled', 'planifie', 'Texte planifié') + await scheduleLocalizedDataRowPublish(testDb.db, 'scheduled', '2000-01-01T00:00:00.000Z', null, fr) + await translate('scheduled', 'new-draft-path', 'Unpublished later edit') + const shell = (await getDraftSite(testDb.db))! + await saveDraftSite(testDb.db, { ...shell, name: 'Later shared site change' }) + await tickPublishScheduler(testDb.db, uploadsDir) + const result = await html('/fr/planifie') + expect(result).toContain('Texte planifié') + expect(result).not.toContain('Unpublished later edit') + expect(result).not.toContain('Later shared site change') + expect((await getDataRow(testDb.db, 'scheduled', fr))?.cells.title).toBe('Unpublished later edit') + expect((await getPublishedDataRowById(testDb.db, 'scheduled', fr))?.slug).toBe('planifie') + }) +}) + + +describe('published locale navigation', () => { + it('switches the same logical page only to online alternatives and removes links after retraction', async () => { + await page('about', 'about', 'About', { switcher: { moduleId: 'base.language-switcher', props: { label: 'Select language', hideIfSingle: true, showCurrent: true, display: 'name' } } }) + await translate('about', 'a-propos', 'À propos') + await createLocale(testDb.db, { code: 'de', name: 'Deutsch', pathPrefix: 'de', enabled: true, direction: 'ltr' }) + await publishDraftSite(testDb.db, null, uploadsDir, { variants: [{ rowId: 'about', localeId: 'default' }, { rowId: 'about', localeId: fr }] }) + const source = await html('/about') + const switcher = source.match(/]*data-instatic-language-switcher[^>]*>.*?<\/nav>/)?.[0] + expect(switcher).toContain('href="/fr/a-propos"') + expect(switcher).toContain('Français') + expect(switcher).toContain('aria-current="page"') + expect(switcher).not.toContain('Deutsch') + expect(await (await hole('/about', 'switcher')).text()).toContain('/fr/a-propos') + await updateDataRowStatus(testDb.db, 'about', 'unpublished', null, fr) + expect(await html('/about')).not.toContain('data-instatic-language-switcher') + expect(await (await hole('/about', 'switcher')).text()).toBe('') + }) + + it('renders a localized not-found template and never falls back through a disabled prefix', async () => { + const value = { ...makePage({ root: { moduleId: 'base.text', props: { text: 'Not found source', tag: 'p', htmlAttributes: {} } } }), + id: 'not-found', slug: 'not-found', template: { enabled: true, target: { kind: 'notFound' as const }, priority: 0 } } + await createDataRow(testDb.db, { id: value.id, tableId: 'pages', slug: value.slug, cells: pageToCells(value) }) + await saveContentLocalizationDraft(testDb.db, value.id, fr, { slug: value.slug, cells: { body: { nodes: { root: { props: { text: 'Page introuvable' } } } } } }) + await publishDraftSite(testDb.db, null, uploadsDir, { variants: [{ rowId: value.id, localeId: 'default' }, { rowId: value.id, localeId: fr }] }) + const response = await renderNotFoundResponse(testDb.db, publicUrl('/fr/missing'), uploadsDir) + expect(response?.status).toBe(404) + expect(await response!.text()).toContain('Page introuvable') + expect(await renderPublicResolution(testDb.db, publicUrl('/fr/not-found'))).toBeNull() + await page('error-article', '404', 'An article about HTTP errors') + await translate('error-article', '404', 'Article sur les erreurs HTTP') + await publishDraftSite(testDb.db, null, uploadsDir, { variants: [{ rowId: 'error-article', localeId: fr }] }) + expect(await html('/fr/404')).toContain('Article sur les erreurs HTTP') + const stillMissing = await renderNotFoundResponse(testDb.db, publicUrl('/fr/missing'), uploadsDir) + expect(stillMissing?.status).toBe(404) + expect(await stillMissing!.text()).toContain('Page introuvable') + await updateLocale(testDb.db, fr, { enabled: false }) + await bumpPublishVersionSerialized() + expect(await renderNotFoundResponse(testDb.db, publicUrl('/fr/missing'), uploadsDir)).toBeNull() + const primary = await renderNotFoundResponse(testDb.db, publicUrl('/missing'), uploadsDir) + expect(primary?.status).toBe(404) + expect(await primary!.text()).toContain('Not found source') + }) +}) diff --git a/src/__tests__/server/localizedRouteInventory.test.ts b/src/__tests__/server/localizedRouteInventory.test.ts new file mode 100644 index 000000000..7065e92be --- /dev/null +++ b/src/__tests__/server/localizedRouteInventory.test.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import type { DbClient } from '../../../server/db/client' +import { createTestDb } from '../helpers/createTestDb' +import { createLocale } from '../../../server/repositories/localization' +import { loadPublishedRouteInventory } from '../../../server/publish/publishedRoutes' +import { findPublishedContentRoute, resolvePublishedRoute } from '@core/localization-routing' + +let db: DbClient +let cleanup: () => Promise +let secondaryLocaleId: string + +beforeEach(async () => { + ({ db, cleanup } = await createTestDb()) + const locale = await createLocale(db, { code: 'fr', name: 'Français', pathPrefix: 'fr', enabled: true, direction: 'ltr' }) + secondaryLocaleId = locale.id +}) + +afterEach(async () => { + await cleanup() +}) + +async function publishFixture(options: { + rowId: string + localeId: string + path: string | null + tableId?: string + availability?: 'online' | 'offline' +}): Promise { + const versionId = crypto.randomUUID() + const tableId = options.tableId ?? 'pages' + await db` + insert into data_rows (id, table_id, slug, cells_json) + values (${options.rowId}, ${tableId}, ${options.rowId}, ${{ title: 'Draft title' }}) + on conflict (id) do nothing + ` + const { rows } = await db<{ next: number }>` + select coalesce(max(version_number), 0) + 1 as next from data_row_versions where row_id = ${options.rowId} + ` + await db` + insert into data_row_versions (id, row_id, locale_id, version_number, public_path, slug, cells_json) + values (${versionId}, ${options.rowId}, ${options.localeId}, ${Number(rows[0].next)}, ${options.path}, ${'live-slug'}, ${{ title: 'Live title' }}) + ` + await db` + insert into data_row_localizations (row_id, locale_id, slug, availability, active_version_id) + values (${options.rowId}, ${options.localeId}, ${'draft-slug'}, ${options.availability ?? 'online'}, ${versionId}) + on conflict (row_id, locale_id) do update + set active_version_id = excluded.active_version_id, availability = excluded.availability + ` + return versionId +} + +describe('DB-backed published locale inventory', () => { + it('independently exposes the secondary version of an otherwise draft logical row', async () => { + await publishFixture({ rowId: 'about', localeId: 'default', path: '/about', availability: 'offline' }) + const versionId = await publishFixture({ rowId: 'about', localeId: secondaryLocaleId, path: '/fr/a-propos' }) + const inventory = await loadPublishedRouteInventory(db) + expect(resolvePublishedRoute(inventory, '/about')).toBeNull() + expect(resolvePublishedRoute(inventory, '/fr/a-propos')?.publishedVersionId).toBe(versionId) + expect(findPublishedContentRoute(inventory, 'about', 'default')).toBeNull() + }) + + it('uses frozen version paths after draft slug, language prefix and collection base edits', async () => { + await publishFixture({ rowId: 'story', tableId: 'posts', localeId: secondaryLocaleId, path: '/fr/posts/original' }) + await db`update site_locales set path_prefix = ${'francais'} where id = ${secondaryLocaleId}` + await db`update data_row_localizations set slug = ${'nouveau'} where row_id = ${'story'} and locale_id = ${secondaryLocaleId}` + await db` + insert into data_table_localizations (table_id, locale_id, route_base) + values (${'posts'}, ${secondaryLocaleId}, ${'/actualites'}) + ` + const inventory = await loadPublishedRouteInventory(db) + expect(inventory.routes.map((route) => route.path)).toEqual(['/fr/posts/original']) + expect(resolvePublishedRoute(inventory, '/francais/actualites/nouveau')).toBeNull() + }) + + it('removes a disabled locale and keeps other locales of the same content live', async () => { + await publishFixture({ rowId: 'about', localeId: 'default', path: '/about' }) + await publishFixture({ rowId: 'about', localeId: secondaryLocaleId, path: '/fr/a-propos' }) + await db`update site_locales set enabled = ${false} where id = ${secondaryLocaleId}` + const inventory = await loadPublishedRouteInventory(db) + expect(inventory.routes.map((route) => route.path)).toEqual(['/about']) + }) + + it('excludes deleted logical rows and deleted collections', async () => { + await publishFixture({ rowId: 'deleted-page', localeId: 'default', path: '/deleted-page' }) + await publishFixture({ rowId: 'deleted-collection-item', tableId: 'posts', localeId: 'default', path: '/posts/item' }) + await db`update data_rows set deleted_at = current_timestamp where id = ${'deleted-page'}` + await db`update data_tables set deleted_at = current_timestamp where id = ${'posts'}` + expect((await loadPublishedRouteInventory(db)).routes).toEqual([]) + }) + + it('does not expose a selected version from another language', async () => { + const defaultVersion = await publishFixture({ rowId: 'about', localeId: 'default', path: '/about' }) + await publishFixture({ rowId: 'about', localeId: secondaryLocaleId, path: '/fr/a-propos' }) + await db` + update data_row_localizations set active_version_id = ${defaultVersion} + where row_id = ${'about'} and locale_id = ${secondaryLocaleId} + ` + const inventory = await loadPublishedRouteInventory(db) + expect(inventory.routes.map((route) => route.path)).toEqual(['/about']) + }) + + it('does not expose a selected version from another logical content item', async () => { + const firstVersion = await publishFixture({ rowId: 'first', localeId: 'default', path: '/first' }) + await publishFixture({ rowId: 'second', localeId: 'default', path: '/second' }) + await db`update data_row_localizations set active_version_id = ${firstVersion} where row_id = ${'second'}` + const inventory = await loadPublishedRouteInventory(db) + expect(inventory.routes.map((route) => route.path)).toEqual(['/first']) + }) + + it('separates template dependencies from publicly addressable page and item routes', async () => { + await publishFixture({ rowId: 'layout', localeId: 'default', path: null }) + await publishFixture({ rowId: 'unrouted-item', tableId: 'posts', localeId: 'default', path: null }) + const inventory = await loadPublishedRouteInventory(db) + expect(inventory.routes).toEqual([]) + expect(inventory.dependencies.map((dependency) => dependency.contentId)).toEqual(['layout']) + }) +}) diff --git a/src/__tests__/server/moduleJsBundle.test.ts b/src/__tests__/server/moduleJsBundle.test.ts index b1a9882aa..f614e66a4 100644 --- a/src/__tests__/server/moduleJsBundle.test.ts +++ b/src/__tests__/server/moduleJsBundle.test.ts @@ -67,7 +67,7 @@ describe('injectModuleScripts', () => { const zIdx = html.indexOf('data-instatic-module-js="z.widget"') expect(aIdx).toBeGreaterThan(-1) expect(zIdx).toBeGreaterThan(aIdx) - expect(html).toContain('') + expect(html).toContain('') expect(zIdx).toBeLessThan(html.indexOf('')) expect(html).toContain("script-src 'self';") expect(html).not.toContain("script-src 'none';") diff --git a/src/__tests__/server/moduleJsRoute.test.ts b/src/__tests__/server/moduleJsRoute.test.ts index 69d57dc29..f329142e7 100644 --- a/src/__tests__/server/moduleJsRoute.test.ts +++ b/src/__tests__/server/moduleJsRoute.test.ts @@ -3,8 +3,9 @@ * Fake DbClient intercepts the published-snapshot query — same pattern as * holeRouteHandler.test.ts. */ -import { beforeEach, describe, expect, it } from 'bun:test' -import type { DbClient, DbResult } from '../../../server/db' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { cleanupPublishingTestDbs, createPublishingTestDb } from '../helpers/publishingTestDb' +afterEach(cleanupPublishingTestDbs) import { handleModuleJsAssetRequest, isModuleJsAssetPath, @@ -62,38 +63,9 @@ function makeSnapshot() { } } -function makeFakeDb(snapshot: ReturnType | null): DbClient { - const handle = async = Record>( - strings: TemplateStringsArray, - ..._values: unknown[] - ): Promise> => { - const sql = strings.reduce((acc, str, i) => (i === 0 ? str : `${acc}$${i}${str}`), '') - const normalized = sql.replace(/\s+/g, ' ').trim().toLowerCase() - // The snapshot getters join site_snapshots and reassemble the - // PublishedPageSnapshot shape from this row (see repositories/publish.ts). - if (normalized.includes('site_snapshots.site_json')) { - return { - rows: snapshot - ? [{ - row_id: snapshot.pageRowId, - site_json: snapshot.site, - runtime_assets_json: null, - importmap_body: null, - importmap_sha256: null, - } as unknown as Row] - : [], - rowCount: snapshot ? 1 : 0, - } - } - return { rows: [], rowCount: 0 } - } - handle.transaction = async (cb: (tx: DbClient) => Promise): Promise => - cb(handle as unknown as DbClient) - return handle as DbClient -} - function moduleJsRequest(path: string, method = 'GET'): [Request, URL] { const url = new URL(`http://localhost${path}`) + url.searchParams.set('u', '/test') return [new Request(url, { method }), url] } @@ -124,7 +96,7 @@ describe('isModuleJsAssetPath', () => { describe('handleModuleJsAssetRequest', () => { it('serves a known module with text/javascript and a 1h public cache', async () => { const [req, url] = moduleJsRequest('/_instatic/module-js/test.jsy.js?v=0') - const res = await handleModuleJsAssetRequest(req, url, { db: makeFakeDb(makeSnapshot()) }) + const res = await handleModuleJsAssetRequest(req, url, { db: await createPublishingTestDb(makeSnapshot().site) }) expect(res.status).toBe(200) expect(res.headers.get('content-type')).toBe('text/javascript; charset=utf-8') expect(res.headers.get('cache-control')).toBe('public, max-age=3600') @@ -133,7 +105,7 @@ describe('handleModuleJsAssetRequest', () => { it('404s for a moduleId with no published js', async () => { const [req, url] = moduleJsRequest('/_instatic/module-js/test.body.js') - const res = await handleModuleJsAssetRequest(req, url, { db: makeFakeDb(makeSnapshot()) }) + const res = await handleModuleJsAssetRequest(req, url, { db: await createPublishingTestDb(makeSnapshot().site) }) expect(res.status).toBe(404) }) @@ -146,20 +118,20 @@ describe('handleModuleJsAssetRequest', () => { '/_instatic/module-js/', ]) { const [req, url] = moduleJsRequest(path) - const res = await handleModuleJsAssetRequest(req, url, { db: makeFakeDb(makeSnapshot()) }) + const res = await handleModuleJsAssetRequest(req, url, { db: await createPublishingTestDb(makeSnapshot().site) }) expect(res.status).toBe(404) } }) it('404s when the site has never been published', async () => { const [req, url] = moduleJsRequest('/_instatic/module-js/test.jsy.js') - const res = await handleModuleJsAssetRequest(req, url, { db: makeFakeDb(null) }) + const res = await handleModuleJsAssetRequest(req, url, { db: await createPublishingTestDb(null) }) expect(res.status).toBe(404) }) it('405s non-GET methods', async () => { const [req, url] = moduleJsRequest('/_instatic/module-js/test.jsy.js', 'POST') - const res = await handleModuleJsAssetRequest(req, url, { db: makeFakeDb(makeSnapshot()) }) + const res = await handleModuleJsAssetRequest(req, url, { db: await createPublishingTestDb(makeSnapshot().site) }) expect(res.status).toBe(405) }) }) diff --git a/src/__tests__/server/notFoundResponse.test.ts b/src/__tests__/server/notFoundResponse.test.ts index 6f72cec78..aa662229a 100644 --- a/src/__tests__/server/notFoundResponse.test.ts +++ b/src/__tests__/server/notFoundResponse.test.ts @@ -12,13 +12,20 @@ * bare JSON 404 takes over), and nothing is cached. */ import { afterEach, beforeEach, describe, expect, it } from 'bun:test' -import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import type { DbClient, DbResult } from '../../../server/db' +import type { DbClient } from '../../../server/db' import type { PublishedPageSnapshot } from '../../../server/repositories/publish' import { renderNotFoundResponse } from '../../../server/publish/publicRouter' import { getStats, resetForTests } from '../../../server/publish/renderCache' +import { createPublishingTestDb, cleanupPublishingTestDbs } from '../helpers/publishingTestDb' +import { createFakeDb } from './dbTestFake' +import { getPublishedRouteInventoryForVersion } from '../../../server/publish/publishedRoutes' +import { getPublishVersion, markPublishedArtefactsCurrent } from '../../../server/publish/publishState' +import { notFoundArtefactPath, swapSlot, writeArtefact, removeArtefactInPlace } from '../../../server/publish/staticArtefact' + +afterEach(cleanupPublishingTestDbs) // --------------------------------------------------------------------------- // Fixtures @@ -78,41 +85,8 @@ function makeSnapshot(withNotFound: boolean): PublishedPageSnapshot { } as unknown as PublishedPageSnapshot } -/** - * Minimal DbClient for the live-render path: `getLatestPublishedSiteSnapshot` - * (distinguished by its `order by data_rows.created_at` clause) returns the - * fixture snapshot; everything else is empty. - */ -function makeFakeDb(snapshot: PublishedPageSnapshot | null): DbClient { - const handle = async = Record>( - strings: TemplateStringsArray, - ...values: unknown[] - ): Promise> => { - void values - const sql = strings.join(' ').replace(/\s+/g, ' ').trim().toLowerCase() - if (sql.includes('site_snapshots.site_json') && sql.includes('order by data_rows.created_at')) { - return { - rows: snapshot - ? [{ - row_id: snapshot.pageRowId, - site_json: snapshot.site, - runtime_assets_json: snapshot.runtimeAssets ?? null, - importmap_body: null, - importmap_sha256: null, - } as unknown as Row] - : [], - rowCount: snapshot ? 1 : 0, - } - } - return { rows: [], rowCount: 0 } - } - handle.unsafe = async = Record>( - _sql: string, - _params?: unknown[], - ): Promise> => ({ rows: [], rowCount: 0 }) - handle.transaction = async (cb: (tx: DbClient) => Promise): Promise => - cb(handle as unknown as DbClient) - return handle as DbClient +async function makePublishedDb(snapshot: PublishedPageSnapshot | null): Promise { + return createPublishingTestDb(snapshot?.site ?? null) } // --------------------------------------------------------------------------- @@ -125,7 +99,7 @@ beforeEach(() => { describe('renderNotFoundResponse — Layer B live render', () => { it('renders the notFound template with status 404 and caches it', async () => { - const db = makeFakeDb(makeSnapshot(true)) + const db = await makePublishedDb(makeSnapshot(true)) const res1 = await renderNotFoundResponse(db, new URL('http://localhost/nowhere')) expect(res1?.status).toBe(404) @@ -141,13 +115,13 @@ describe('renderNotFoundResponse — Layer B live render', () => { }) it('returns null — and caches nothing — when the site has no notFound template', async () => { - const db = makeFakeDb(makeSnapshot(false)) + const db = await makePublishedDb(makeSnapshot(false)) expect(await renderNotFoundResponse(db, new URL('http://localhost/nowhere'))).toBeNull() - expect(getStats()).toMatchObject({ hits: 0, misses: 0, size: 0 }) + expect(getStats()).toMatchObject({ hits: 0, misses: 1, size: 0 }) }) it('returns null when nothing is published at all', async () => { - const db = makeFakeDb(null) + const db = await makePublishedDb(null) expect(await renderNotFoundResponse(db, new URL('http://localhost/nowhere'))).toBeNull() }) }) @@ -159,19 +133,24 @@ describe('renderNotFoundResponse — Layer A baked artefact', () => { uploadsDir = await mkdtemp(join(tmpdir(), 'instatic-404-')) const slotDir = join(uploadsDir, 'published', 'a') await mkdir(slotDir, { recursive: true }) - await writeFile(join(slotDir, '404.html'), '

baked 404

', 'utf-8') - await symlink('a', join(uploadsDir, 'published', 'current')) + await writeArtefact(slotDir, notFoundArtefactPath('default'), '

baked 404

') + await swapSlot(uploadsDir, 'a') }) afterEach(async () => { await rm(uploadsDir, { recursive: true, force: true }) }) - it('serves the baked 404.html with status 404 without touching the DB', async () => { - // A DB that throws on ANY query proves the artefact path is DB-free. - const explodingDb = (async () => { - throw new Error('DB must not be queried on the Layer A path') - }) as unknown as DbClient + it('serves the baked 404 after warming the locale inventory without further SQL', async () => { + const db = await makePublishedDb(makeSnapshot(true)) + let deny = false + const explodingDb = createFakeDb(async (sql, params) => { + if (deny) throw new Error('DB must not be queried after the inventory is warm') + return db.unsafe(sql, params) + }) + await getPublishedRouteInventoryForVersion(explodingDb, getPublishVersion()) + markPublishedArtefactsCurrent(getPublishVersion()) + deny = true const res = await renderNotFoundResponse(explodingDb, new URL('http://localhost/nope'), uploadsDir) expect(res?.status).toBe(404) @@ -180,8 +159,8 @@ describe('renderNotFoundResponse — Layer A baked artefact', () => { }) it('falls through to the live render when the artefact is missing', async () => { - await rm(join(uploadsDir, 'published', 'a', '404.html')) - const db = makeFakeDb(makeSnapshot(true)) + await removeArtefactInPlace(uploadsDir, notFoundArtefactPath('default')) + const db = await makePublishedDb(makeSnapshot(true)) const res = await renderNotFoundResponse(db, new URL('http://localhost/nope'), uploadsDir) expect(res?.status).toBe(404) expect(await res!.text()).toContain('This page is missing') diff --git a/src/__tests__/server/publicForms.test.ts b/src/__tests__/server/publicForms.test.ts index b1e8fdc81..2eab9df17 100644 --- a/src/__tests__/server/publicForms.test.ts +++ b/src/__tests__/server/publicForms.test.ts @@ -11,8 +11,11 @@ import { import { publicFormPerFormRateLimit, publicFormPerIpRateLimit } from '../../../server/forms/rateLimit' import { configurePublicOrigins, resetPublicOrigins, stampSocketIp } from '../../../server/auth/security' import { hookBus } from '@core/plugins/hookBus' -import { createFakeDb } from './dbTestFake' -import type { PublishedPageSnapshot } from '../../../server/repositories/publish' +import type { PublicFormIdentity } from '@core/forms' +import { createDataTable } from '../../../server/repositories/data' +import { loadPublishedRouteInventory } from '../../../server/publish/publishedRoutes' +import { createPublishingTestDb, cleanupPublishingTestDbs } from '../helpers/publishingTestDb' +import { makePage, makeSite } from '../publisher/helpers' function makeRequest( path: string, @@ -44,161 +47,41 @@ function node(id: string, moduleId: string, props: Record, chil } } -function makeSnapshot(targetTableId = 'newsletter_submissions'): PublishedPageSnapshot { - return { - cmsSnapshotVersion: 1, - pageRowId: 'page-home', - site: { - id: 'site', - name: 'Site', - settings: {}, - pages: [{ - id: 'page-home', - slug: 'index', - title: 'Home', - rootNodeId: 'body', - nodes: { - body: node('body', 'base.body', {}, ['form']), - form: node('form', 'base.form', { - mode: 'cms', - formId: 'newsletter', - targetTableId, - honeypotName: 'company', - minSubmitSeconds: 0, - }, ['input']), - input: node('input', 'base.input', { - fieldId: 'email', - name: 'email', - id: 'email-input', - inputType: 'email', - required: true, - }), - }, - }], - visualComponents: [], - classes: [], - breakpoints: [], - settingsVersion: 1, - }, - } as PublishedPageSnapshot -} - -interface FakeTableRow { - id: string - name: string - slug: string - kind: string - route_base: string - singular_label: string - plural_label: string - primary_field_id: string - fields_json: unknown[] - system: number -} - -const newsletterTableRow: FakeTableRow = { - id: 'newsletter_submissions', - name: 'Newsletter submissions', - slug: 'newsletter-submissions', - kind: 'data', - route_base: '', - singular_label: 'Submission', - plural_label: 'Submissions', - primary_field_id: 'email', - fields_json: [{ id: 'email', label: 'Email', type: 'email', required: true }], - system: 0, -} - -function makeDb(options: { - snapshot?: PublishedPageSnapshot - tableRows?: Record -} = {}) { - const createdRows: Record[] = [] - const snapshot = options.snapshot ?? makeSnapshot() - const tableRows = options.tableRows ?? { newsletter_submissions: newsletterTableRow } - const db = createFakeDb(async (rawSql, params): Promise => { - const sql = rawSql.replace(/\s+/g, ' ').trim().toLowerCase() - // getPublishedPageSnapshotById — joins data_row_versions to site_snapshots. - // Must be matched before the generic `select data_rows.id` branch below - // (the snapshot getter's SELECT also starts with `select data_rows.id`). - if (sql.includes('site_snapshots.site_json')) { - return { - rows: [{ - row_id: snapshot.pageRowId, - site_json: snapshot.site, - runtime_assets_json: snapshot.runtimeAssets ?? null, - importmap_body: snapshot.runtimePackageImportmap?.body ?? null, - importmap_sha256: snapshot.runtimePackageImportmap?.sha256 ?? null, - }], - rowCount: 1, - } - } - if (sql.startsWith('select id, name, slug, kind, route_base')) { - const row = tableRows[String(params[0])] - if (!row) return { rows: [], rowCount: 0 } - return { - rows: [{ - ...row, - created_by_user_id: null, - updated_by_user_id: null, - created_at: new Date('2026-06-01T00:00:00Z'), - updated_at: new Date('2026-06-01T00:00:00Z'), - }], - rowCount: 1, - } - } - if (sql.startsWith('insert into data_rows')) { - createdRows.push({ - id: params[0], - table_id: params[1], - cells_json: params[2], - slug: params[3], - status: params[4], - author_user_id: params[5], - created_by_user_id: params[6], - updated_by_user_id: params[7], - }) - return { rows: [{ id: params[0] }], rowCount: 1 } - } - if (sql.startsWith('select data_tables.slug')) { - const row = createdRows.find((candidate) => candidate.id === params[0]) - const table = row ? tableRows[String(row.table_id)] : undefined - return table ? { rows: [{ slug: table.slug }], rowCount: 1 } : { rows: [], rowCount: 0 } - } - if (sql.startsWith('select data_rows.id') && sql.includes('from data_rows')) { - const row = createdRows.find((candidate) => candidate.id === params[0]) - if (!row) return { rows: [], rowCount: 0 } - return { - rows: [{ - ...row, - author_email: null, - author_display_name: null, - author_role_slug: null, - author_role_name: null, - created_by_email: null, - created_by_display_name: null, - created_by_role_slug: null, - created_by_role_name: null, - updated_by_email: null, - updated_by_display_name: null, - updated_by_role_slug: null, - updated_by_role_name: null, - published_by_email: null, - published_by_display_name: null, - published_by_role_slug: null, - published_by_role_name: null, - published_at: null, - scheduled_publish_at: null, - deleted_at: null, - created_at: new Date('2026-06-01T00:00:00Z'), - updated_at: new Date('2026-06-01T00:00:00Z'), - }], - rowCount: 1, - } - } - throw new Error(`Unhandled SQL: ${rawSql}`) +async function makeDb(options: { targetTableId?: string; system?: boolean } = {}) { + const targetTableId = options.targetTableId ?? 'newsletter_submissions' + const page = makePage({ + body: node('body', 'base.body', {}, ['form']), + form: node('form', 'base.form', { + mode: 'cms', formId: 'newsletter', targetTableId, + honeypotName: 'company', minSubmitSeconds: 0, + }, ['input']), + input: node('input', 'base.input', { + fieldId: 'email', name: 'email', id: 'email-input', inputType: 'email', required: true, + }), + }, 'body') + page.id = 'page-home' + const db = await createPublishingTestDb(makeSite({ pages: [page] })) + await createDataTable(db, { + id: targetTableId, + name: 'Newsletter submissions', slug: 'newsletter-submissions', kind: 'data', + routeBase: '', singularLabel: 'Submission', pluralLabel: 'Submissions', + primaryFieldId: 'email', + fields: [{ id: 'email', label: 'Email', type: 'email', required: true }], }) - return { db, createdRows } + if (options.system) await db`update data_tables set system = ${true} where id = ${targetTableId}` + const route = (await loadPublishedRouteInventory(db)).routes.find((entry) => entry.contentId === page.id) + if (!route) throw new Error('Form fixture did not publish its originating route') + const identity: PublicFormIdentity = { + pageId: route.contentId, localeId: route.localeId, + publishedVersionId: route.publishedVersionId, pagePath: route.path, formId: 'newsletter', + } + return { + db, identity, + pageToken: () => issuePublicFormPageToken(identity), + createdRows: async () => (await db<{ id: string; table_id: string; cells_json: Record }>` + select id, table_id, cells_json from data_rows where table_id = ${targetTableId} + `).rows, + } } function makeThrowingDb(): { db: DbClient; wasQueried: () => boolean } { @@ -219,24 +102,21 @@ async function readJson(response: Response): Promise> { return await response.json() as Record } -function pageToken(): string { - return issuePublicFormPageToken({ pageId: 'page-home', formId: 'newsletter' }) -} - describe('public CMS-native form endpoint', () => { - afterEach(() => { + afterEach(async () => { + await cleanupPublishingTestDbs() resetPublicOrigins() hookBus.reset() }) it('router owns public form challenge URLs before public-route/setup fallthrough', async () => { resetPublicFormChallenges() - const { db } = makeDb() + const { db, identity, pageToken } = await makeDb() const response = await handleServerRequest( makeRequest( '/_instatic/form/challenge', - { formId: 'newsletter', pageId: 'page-home', pageToken: pageToken() }, + { ...identity, pageToken: pageToken() }, 'http://cms.test', '203.0.113.40', ), @@ -280,9 +160,9 @@ describe('public CMS-native form endpoint', () => { }) it('rejects challenge requests from foreign origins', async () => { - const { db } = makeDb() + const { db, identity, pageToken } = await makeDb() const response = await handlePublicFormRequest( - makeRequest('/_instatic/form/challenge', { formId: 'newsletter', pageId: 'page-home', pageToken: pageToken() }, 'https://evil.test'), + makeRequest('/_instatic/form/challenge', { ...identity, pageToken: pageToken() }, 'https://evil.test'), db, new URL('http://cms.test/_instatic/form/challenge'), ) @@ -298,11 +178,11 @@ describe('public CMS-native form endpoint', () => { // — the old inline duplicate only compared against expectedOrigin() and // would have rejected this. configurePublicOrigins(['https://app.up.railway.app', 'https://forms.example.com']) - const { db } = makeDb() + const { db, identity, pageToken } = await makeDb() const response = await handlePublicFormRequest( makeRequest( '/_instatic/form/challenge', - { formId: 'newsletter', pageId: 'page-home', pageToken: pageToken() }, + { ...identity, pageToken: pageToken() }, 'https://forms.example.com', ), db, @@ -314,9 +194,9 @@ describe('public CMS-native form endpoint', () => { it('issues a same-origin challenge and rejects submits without it', async () => { resetPublicFormChallenges() - const { db } = makeDb() + const { db, identity, pageToken } = await makeDb() const challenge = await handlePublicFormRequest( - makeRequest('/_instatic/form/challenge', { formId: 'newsletter', pageId: 'page-home', pageToken: pageToken() }), + makeRequest('/_instatic/form/challenge', { ...identity, pageToken: pageToken() }), db, new URL('http://cms.test/_instatic/form/challenge'), ) @@ -327,8 +207,7 @@ describe('public CMS-native form endpoint', () => { const submit = await handlePublicFormRequest( makeRequest('/_instatic/form/submit', { - formId: 'newsletter', - pageId: 'page-home', + ...identity, token: 'missing', challenge: 'missing', values: { email: 'ai@example.com' }, @@ -341,11 +220,10 @@ describe('public CMS-native form endpoint', () => { it('rejects challenge requests without the published page token', async () => { resetPublicFormChallenges() - const { db } = makeDb() + const { db, identity } = await makeDb() const response = await handlePublicFormRequest( makeRequest('/_instatic/form/challenge', { - formId: 'newsletter', - pageId: 'page-home', + ...identity, pageToken: 'forged', }), db, @@ -357,11 +235,10 @@ describe('public CMS-native form endpoint', () => { it('rejects oversized challenge payloads before accepting the request', async () => { resetPublicFormChallenges() - const { db } = makeDb() + const { db, identity, pageToken } = await makeDb() const response = await handlePublicFormRequest( makeRequest('/_instatic/form/challenge', { - formId: 'newsletter', - pageId: 'page-home', + ...identity, pageToken: pageToken(), padding: 'x'.repeat(9 * 1024), }, 'http://cms.test', '203.0.113.20'), @@ -374,13 +251,12 @@ describe('public CMS-native form endpoint', () => { it('rate-limits challenge issuance per client', async () => { resetPublicFormChallenges() - const { db } = makeDb() + const { db, identity, pageToken } = await makeDb() const ip = '203.0.113.21' for (let i = 0; i < 60; i++) { const response = await handlePublicFormRequest( makeRequest('/_instatic/form/challenge', { - formId: 'newsletter', - pageId: 'page-home', + ...identity, pageToken: pageToken(), }, 'http://cms.test', ip), db, @@ -391,8 +267,7 @@ describe('public CMS-native form endpoint', () => { const limited = await handlePublicFormRequest( makeRequest('/_instatic/form/challenge', { - formId: 'newsletter', - pageId: 'page-home', + ...identity, pageToken: pageToken(), }, 'http://cms.test', ip), db, @@ -403,14 +278,14 @@ describe('public CMS-native form endpoint', () => { it('keeps the in-memory challenge store bounded by evicting oldest entries', () => { resetPublicFormChallenges() - const first = issuePublicFormChallenge({ pageId: 'page-home', formId: 'newsletter' }) + const identity: PublicFormIdentity = { pageId: 'page-home', localeId: 'default', publishedVersionId: 'version-home', pagePath: '/', formId: 'newsletter' } + const first = issuePublicFormChallenge(identity) for (let i = 0; i < 2_000; i++) { - issuePublicFormChallenge({ pageId: 'page-home', formId: `newsletter-${i}` }) + issuePublicFormChallenge({ ...identity, formId: `newsletter-${i}` }) } expect(verifyAndConsumePublicFormChallenge({ - pageId: 'page-home', - formId: 'newsletter', + ...identity, challenge: first.challenge, token: first.token, })).toBeNull() @@ -420,13 +295,13 @@ describe('public CMS-native form endpoint', () => { resetPublicFormChallenges() publicFormPerIpRateLimit.reset('unknown') publicFormPerFormRateLimit.reset('unknown|newsletter') - const { db, createdRows } = makeDb() + const { db, identity, pageToken, createdRows } = await makeDb() const createdEvents: unknown[] = [] hookBus.on('test.notifications', 'content.entry.created', (payload) => { createdEvents.push(payload) }) const challengeResponse = await handlePublicFormRequest( - makeRequest('/_instatic/form/challenge', { formId: 'newsletter', pageId: 'page-home', pageToken: pageToken() }), + makeRequest('/_instatic/form/challenge', { ...identity, pageToken: pageToken() }), db, new URL('http://cms.test/_instatic/form/challenge'), ) @@ -434,8 +309,7 @@ describe('public CMS-native form endpoint', () => { const submit = await handlePublicFormRequest( makeRequest('/_instatic/form/submit', { - formId: 'newsletter', - pageId: 'page-home', + ...identity, token: challenge.token, challenge: challenge.challenge, values: { email: 'ai@example.com', company: '' }, @@ -445,12 +319,14 @@ describe('public CMS-native form endpoint', () => { ) expect(submit?.status).toBe(200) - expect(createdRows).toHaveLength(1) - expect(createdRows[0].table_id).toBe('newsletter_submissions') - expect(createdRows[0].cells_json).toEqual({ email: 'ai@example.com' }) + const rows = await createdRows() + expect(rows).toHaveLength(1) + expect(rows[0].table_id).toBe('newsletter_submissions') + expect(rows[0].cells_json).toEqual({ email: 'ai@example.com' }) expect(createdEvents).toEqual([{ tableSlug: 'newsletter-submissions', - entryId: createdRows[0].id, + entryId: rows[0].id, + localeId: 'default', actor: { kind: 'system' }, }]) }) @@ -459,12 +335,11 @@ describe('public CMS-native form endpoint', () => { resetPublicFormChallenges() publicFormPerIpRateLimit.reset('203.0.113.22') publicFormPerFormRateLimit.reset('203.0.113.22|newsletter') - const { db } = makeDb() + const { db, identity } = await makeDb() const response = await handlePublicFormRequest( makeRequest('/_instatic/form/submit', { - formId: 'newsletter', - pageId: 'page-home', + ...identity, token: 'missing', challenge: 'missing', values: { email: `${'a'.repeat(1024 * 1024)}@example.com` }, @@ -484,20 +359,9 @@ describe('public CMS-native form endpoint', () => { resetPublicFormChallenges() publicFormPerIpRateLimit.reset('unknown') publicFormPerFormRateLimit.reset('unknown|newsletter') - const { db, createdRows } = makeDb({ - snapshot: makeSnapshot('system_submissions'), - tableRows: { - system_submissions: { - ...newsletterTableRow, - id: 'system_submissions', - name: 'System submissions', - slug: 'system-submissions', - system: 1, - }, - }, - }) + const { db, identity, pageToken, createdRows } = await makeDb({ targetTableId: 'system_submissions', system: true }) const challengeResponse = await handlePublicFormRequest( - makeRequest('/_instatic/form/challenge', { formId: 'newsletter', pageId: 'page-home', pageToken: pageToken() }), + makeRequest('/_instatic/form/challenge', { ...identity, pageToken: pageToken() }), db, new URL('http://cms.test/_instatic/form/challenge'), ) @@ -505,8 +369,7 @@ describe('public CMS-native form endpoint', () => { const submit = await handlePublicFormRequest( makeRequest('/_instatic/form/submit', { - formId: 'newsletter', - pageId: 'page-home', + ...identity, token: challenge.token, challenge: challenge.challenge, values: { email: 'ai@example.com', company: '' }, @@ -516,6 +379,6 @@ describe('public CMS-native form endpoint', () => { ) expect(submit?.status).toBe(404) - expect(createdRows).toHaveLength(0) + expect(await createdRows()).toHaveLength(0) }) }) diff --git a/src/__tests__/server/publicRendering.test.ts b/src/__tests__/server/publicRendering.test.ts index 5486d493a..be4dc9ff6 100644 --- a/src/__tests__/server/publicRendering.test.ts +++ b/src/__tests__/server/publicRendering.test.ts @@ -1,5 +1,5 @@ -import { beforeEach, describe, expect, it } from 'bun:test' -import type { DbClient, DbResult } from '../../../server/db' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import type { DbClient } from '../../../server/db' import { resetForTests } from '../../../server/publish/renderCache' import type { PublishedPageSnapshot } from '../../../server/repositories/publish' import { @@ -8,6 +8,13 @@ import { } from '../../../server/publish/publicRenderer' import type { PublishedDataRow } from '@core/data/schemas' import { handleServerRequest } from '../../../server/router' +import { cleanupPublishingTestDbs, createPublishingTestDb } from '../helpers/publishingTestDb' +import { createFakeDb } from './dbTestFake' +import { createUser } from '../../../server/repositories/users' +import { saveDraftSite } from '../../../server/repositories/site' +import { makeSite } from '../publisher/helpers' + +afterEach(cleanupPublishingTestDbs) function snapshot(text: string): PublishedPageSnapshot { return { @@ -56,53 +63,21 @@ function snapshot(text: string): PublishedPageSnapshot { } } -function makeFakeDb( +async function makePublishedDb( activeSnapshot: PublishedPageSnapshot | null, runtimeAssets: Record[] = [], -): DbClient { - const handle = async = Record>( - strings: TemplateStringsArray, - ...values: unknown[] - ): Promise> => { - // Reconstruct a parameterized SQL string for pattern matching. - const sql = strings.reduce((acc, str, i) => (i === 0 ? str : `${acc}$${i}${str}`), '') - const normalized = sql.replace(/\s+/g, ' ').trim().toLowerCase() - - // getPublishedRuntimeAsset — values[0]=publicPath - if (normalized.includes('select public_path, content_type, content_bytes')) { - const row = runtimeAssets.find((asset) => asset.public_path === values[0]) - return { rows: row ? [row as Row] : [], rowCount: row ? 1 : 0 } - } - // getPublishedPageBySlug / getLatestPublishedSiteSnapshot — joins - // data_row_versions to site_snapshots - if (normalized.includes('site_snapshots.site_json')) { - return { - rows: activeSnapshot - ? [{ - row_id: activeSnapshot.pageRowId, - site_json: activeSnapshot.site, - runtime_assets_json: activeSnapshot.runtimeAssets ?? null, - importmap_body: activeSnapshot.runtimePackageImportmap?.body ?? null, - importmap_sha256: activeSnapshot.runtimePackageImportmap?.sha256 ?? null, - } as unknown as Row] - : [], - rowCount: activeSnapshot ? 1 : 0, - } - } - // getSetupStatus — public-rendering tests assume CMS is already set up - if (normalized.includes('count(*) as count from site')) { - return { rows: [{ count: 1 } as unknown as Row], rowCount: 1 } +): Promise { + const db = await createPublishingTestDb(activeSnapshot?.site ?? null) + if (!activeSnapshot) await saveDraftSite(db, makeSite()) + await createUser(db, { email: 'public-test@local.test', displayName: 'Local owner', passwordHash: 'unused-test-hash', roleId: 'owner', allowOwnerRole: true }) + if (!runtimeAssets.length) return db + return createFakeDb(async (sql, params) => { + if (sql.includes('select public_path, content_type, content_bytes')) { + const row = runtimeAssets.find((asset) => asset.public_path === params[0]) + return { rows: row ? [row] : [], rowCount: row ? 1 : 0 } } - if (normalized.includes('count(*) as count') && normalized.includes('from users')) { - return { rows: [{ count: 1 } as unknown as Row], rowCount: 1 } - } - return { rows: [], rowCount: 0 } - } - - handle.transaction = async (cb: (tx: DbClient) => Promise): Promise => - cb(handle as unknown as DbClient) - - return handle as DbClient + return db.unsafe(sql, params) + }) } describe('public rendering', () => { @@ -115,11 +90,11 @@ describe('public rendering', () => { it('renders complete HTML from a published snapshot', async () => { const snap = snapshot('Visible to public') - const { html } = await renderPublishedSnapshot(snap, { db: makeFakeDb(snap) }) + const { html } = await renderPublishedSnapshot(snap, { db: await makePublishedDb(snap) }) expect(html).toContain('') expect(html).toContain('Visible to public') - expect(html).toContain('Public Site') + expect(html).toContain('Home') }) // Guards the page-wrapper's identity reporting after the shared @@ -127,7 +102,7 @@ describe('public rendering', () => { // not the merged tree. it('reports pageId and slug from the page row for the snapshot path', async () => { const snap = snapshot('Identity') - const out = await renderPublishedSnapshot(snap, { db: makeFakeDb(snap) }) + const out = await renderPublishedSnapshot(snap, { db: await makePublishedDb(snap) }) expect(out.pageId).toBe('page_home') expect(out.slug).toBe('index') expect(out.siteId).toBe('project_1') @@ -161,7 +136,7 @@ describe('public rendering', () => { publishedAt: '2024-01-01T00:00:00.000Z', createdAt: '2024-01-01T00:00:00.000Z', } - const result = await renderPublishedDataRowTemplate(snap, row, { db: makeFakeDb(snap) }) + const result = await renderPublishedDataRowTemplate(snap, row, { db: await makePublishedDb(snap) }) expect(result).toBeNull() }) @@ -259,7 +234,7 @@ describe('public rendering', () => { seoDescription: 'SEO override description', }, }, - { db: makeFakeDb(snap) }, + { db: await makePublishedDb(snap) }, ) expect(withSeo?.html).toContain('SEO Override Title') expect(withSeo?.html).toContain( @@ -276,7 +251,7 @@ describe('public rendering', () => { const withoutSeo = await renderPublishedDataRowTemplate( snap, { ...entryBaseRow, cells: { title: 'Plain H1 Title' } }, - { db: makeFakeDb(snap) }, + { db: await makePublishedDb(snap) }, ) expect(withoutSeo?.html).toContain('Plain H1 Title') expect(withoutSeo?.html).not.toContain(' { const blankSeo = await renderPublishedDataRowTemplate( snap, { ...entryBaseRow, cells: { title: 'Plain H1 Title', seoTitle: ' ', seoDescription: '' } }, - { db: makeFakeDb(snap) }, + { db: await makePublishedDb(snap) }, ) expect(blankSeo?.html).toContain('Plain H1 Title') expect(blankSeo?.html).not.toContain(' { seoDescription: 'SEO override description', }, }, - { db: makeFakeDb(snap) }, + { db: await makePublishedDb(snap) }, ) expect(withSeo?.html).toContain('SEO Override Title') expect(withSeo?.html).toContain( @@ -322,14 +297,14 @@ describe('public rendering', () => { resetForTests() - // Without an entry override the site-level settings still win over the - // entry title, unchanged from before. + // Without an SEO title override the localized entry title is preferred; + // the site description remains the fallback. const withoutSeo = await renderPublishedDataRowTemplate( snap, { ...entryBaseRow, cells: { title: 'Plain H1 Title' } }, - { db: makeFakeDb(snap) }, + { db: await makePublishedDb(snap) }, ) - expect(withoutSeo?.html).toContain('Site Wide Meta Title') + expect(withoutSeo?.html).toContain('Plain H1 Title') expect(withoutSeo?.html).toContain( '', ) @@ -349,7 +324,7 @@ describe('public rendering', () => { ], } - const { html } = await renderPublishedSnapshot(published, { db: makeFakeDb(published) }) + const { html } = await renderPublishedSnapshot(published, { db: await makePublishedDb(published) }) expect(html).toContain("script-src 'self'") expect(html).toContain('/_instatic/assets/version_1/entries/entry.js') @@ -357,7 +332,7 @@ describe('public rendering', () => { it('serves / from the active published index snapshot', async () => { const res = await handleServerRequest(new Request('http://localhost/'), { - db: makeFakeDb(snapshot('Homepage')), + db: await makePublishedDb(snapshot('Homepage')), }) expect(res.status).toBe(200) @@ -367,7 +342,7 @@ describe('public rendering', () => { it('serves immutable published runtime assets by public path', async () => { const res = await handleServerRequest(new Request('http://localhost/_instatic/assets/version_1/entries/entry.js'), { - db: makeFakeDb(null, [ + db: await makePublishedDb(null, [ { public_path: '/_instatic/assets/version_1/entries/entry.js', content_type: 'text/javascript; charset=utf-8', @@ -390,7 +365,7 @@ describe('public rendering', () => { // refuses everything else, even a file that is present. it('refuses to serve an SVG from the runtime-asset namespace', async () => { const res = await handleServerRequest(new Request('http://localhost/_instatic/assets/version_1/poc.svg'), { - db: makeFakeDb(null, [ + db: await makePublishedDb(null, [ { public_path: '/_instatic/assets/version_1/poc.svg', content_type: 'image/svg+xml', @@ -404,7 +379,7 @@ describe('public rendering', () => { it('returns 404 when there is no active published snapshot', async () => { const res = await handleServerRequest(new Request('http://localhost/'), { - db: makeFakeDb(null), + db: await makePublishedDb(null), }) expect(res.status).toBe(404) @@ -412,7 +387,7 @@ describe('public rendering', () => { it('emits external CSS tags pointing at the per-site bundle', async () => { const snap = snapshot('Hello') - const { html } = await renderPublishedSnapshot(snap, { db: makeFakeDb(snap) }) + const { html } = await renderPublishedSnapshot(snap, { db: await makePublishedDb(snap) }) expect(html).toMatch(//) // No inline reset block — site-wide CSS lives in the external bundle. expect(html).not.toContain(':where(*, *::before, *::after)') @@ -422,7 +397,7 @@ describe('public rendering', () => { const published = snapshot('Hello') // First request the page to discover the current bundle filenames. const pageRes = await handleServerRequest(new Request('http://localhost/'), { - db: makeFakeDb(published), + db: await makePublishedDb(published), }) const pageHtml = await pageRes.text() const resetMatch = pageHtml.match(/href="(\/_instatic\/css\/reset-[a-f0-9]{12}\.css)"/) @@ -431,7 +406,7 @@ describe('public rendering', () => { // Now fetch the bundle. const cssRes = await handleServerRequest( new Request(`http://localhost${resetMatch![1]}`), - { db: makeFakeDb(published) }, + { db: await makePublishedDb(published) }, ) expect(cssRes.status).toBe(200) expect(cssRes.headers.get('content-type')).toContain('text/css') @@ -444,7 +419,7 @@ describe('public rendering', () => { it('returns 404 for stale CSS hashes so cached HTML refetches the page', async () => { const cssRes = await handleServerRequest( new Request('http://localhost/_instatic/css/reset-deadbeefdead.css'), - { db: makeFakeDb(snapshot('Hello')) }, + { db: await makePublishedDb(snapshot('Hello')) }, ) expect(cssRes.status).toBe(404) }) @@ -452,7 +427,7 @@ describe('public rendering', () => { it('returns 404 for malformed CSS bundle paths', async () => { const cssRes = await handleServerRequest( new Request('http://localhost/_instatic/css/whatever.css'), - { db: makeFakeDb(snapshot('Hello')) }, + { db: await makePublishedDb(snapshot('Hello')) }, ) expect(cssRes.status).toBe(404) }) diff --git a/src/__tests__/server/publicRouterCache.test.ts b/src/__tests__/server/publicRouterCache.test.ts index 512a38e9d..f354513e9 100644 --- a/src/__tests__/server/publicRouterCache.test.ts +++ b/src/__tests__/server/publicRouterCache.test.ts @@ -11,8 +11,11 @@ * Uses `getStats()` from renderCache to observe hit/miss counts without * requiring module-level spying on the renderer. */ -import { beforeEach, describe, expect, it } from 'bun:test' -import type { DbClient, DbResult } from '../../../server/db' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { cleanupPublishingTestDbs, createPublishingTestDb, seedPublishingPage } from '../helpers/publishingTestDb' +import { publishDraftSite } from '../../../server/publish/publishSite' +import { savePublishedRedirect } from '../../../server/repositories/data/publish' +afterEach(cleanupPublishingTestDbs) import type { PublishedPageSnapshot } from '../../../server/repositories/publish' import { renderPublicResolution } from '../../../server/publish/publicRouter' import { getStats, resetForTests } from '../../../server/publish/renderCache' @@ -57,98 +60,16 @@ function makeSnapshot(): PublishedPageSnapshot { } } -// --------------------------------------------------------------------------- -// Fake DB -// --------------------------------------------------------------------------- - -/** - * Minimal DbClient that handles queries made by resolvePublicRoute, - * renderPublishedSnapshot, and applyPublishedHtmlPipeline. - * - * When `snapshot` is provided, the slug lookup returns it. - * Everything else returns empty results (no plugins, no media, etc.). - */ -function makeFakeDb(snapshot: PublishedPageSnapshot | null): DbClient { - const handle = async = Record>( - strings: TemplateStringsArray, - ...values: unknown[] - ): Promise> => { - const sql = strings.reduce((acc, str, i) => (i === 0 ? str : `${acc}$${i}${str}`), '') - const normalized = sql.replace(/\s+/g, ' ').trim().toLowerCase() - - // getPublishedPageBySlug — joins data_row_versions to site_snapshots - if (normalized.includes('site_snapshots.site_json')) { - return { - rows: snapshot - ? [{ - row_id: snapshot.pageRowId, - site_json: snapshot.site, - runtime_assets_json: snapshot.runtimeAssets ?? null, - importmap_body: snapshot.runtimePackageImportmap?.body ?? null, - importmap_sha256: snapshot.runtimePackageImportmap?.sha256 ?? null, - } as unknown as Row] - : [], - rowCount: snapshot ? 1 : 0, - } - } - - // Anything else (plugins, media, loop data, etc.) → empty - return { rows: [], rowCount: 0 } - } - - // getPublishedDataRowByRoute runs through db.unsafe (it splices the shared - // user-ref join fragments) — no content row in this fixture. - handle.unsafe = async = Record>( - _sql: string, - _params?: unknown[], - ): Promise> => ({ rows: [], rowCount: 0 }) - - handle.transaction = async (cb: (tx: DbClient) => Promise): Promise => - cb(handle as unknown as DbClient) - - return handle as DbClient +async function makePublishedDb(snapshot: PublishedPageSnapshot | null) { + return createPublishingTestDb(snapshot?.site ?? null) } -/** Fake DB that returns a redirect for any slug lookup. */ -function makeRedirectDb(): DbClient { - const handle = async = Record>( - strings: TemplateStringsArray, - ...values: unknown[] - ): Promise> => { - const sql = strings.reduce((acc, str, i) => (i === 0 ? str : `${acc}$${i}${str}`), '') - const normalized = sql.replace(/\s+/g, ' ').trim().toLowerCase() - - // getPublishedPageBySlug → not found - if (normalized.includes('site_snapshots.site_json')) { - return { rows: [], rowCount: 0 } - } - // getDataRowRedirectByRoute → return a redirect - if (normalized.includes('from data_row_redirects')) { - return { - rows: [ - { - id: 'redirect_1', - from_route_base: '/posts', - from_slug: 'old-post', - target_route_base: '/posts', - target_slug: 'new-post', - } as unknown as Row, - ], - rowCount: 1, - } - } - return { rows: [], rowCount: 0 } - } - // getPublishedDataRowByRoute → not found (no content row). It runs through - // db.unsafe (shared user-ref join fragments), so the not-found branch lives - // here, letting resolvePublicRoute fall through to the redirect lookup. - handle.unsafe = async = Record>( - _sql: string, - _params?: unknown[], - ): Promise> => ({ rows: [], rowCount: 0 }) - handle.transaction = async (cb: (tx: DbClient) => Promise): Promise => - cb(handle as unknown as DbClient) - return handle as DbClient +async function makeRedirectDb() { + const site = makeSnapshot().site + site.pages[0].slug = 'posts/new-post' + const db = await createPublishingTestDb(site) + await savePublishedRedirect(db, site.pages[0].id, 'pages', 'default', '/posts/old-post') + return db } // --------------------------------------------------------------------------- @@ -162,7 +83,7 @@ beforeEach(() => { describe('Layer B render cache integration', () => { it('first request is a miss; second identical request is a cache hit', async () => { const snap = makeSnapshot() - const db = makeFakeDb(snap) + const db = await makePublishedDb(snap) const url = new URL('http://localhost/test') const res1 = await renderPublicResolution(db, url) @@ -176,7 +97,7 @@ describe('Layer B render cache integration', () => { it('responses from cache and from renderer have the same body', async () => { const snap = makeSnapshot() - const db = makeFakeDb(snap) + const db = await makePublishedDb(snap) const url = new URL('http://localhost/test') const res1 = await renderPublicResolution(db, url) @@ -191,7 +112,7 @@ describe('Layer B render cache integration', () => { it('bumpPublishVersion causes the next request to re-render', async () => { const snap = makeSnapshot() - const db = makeFakeDb(snap) + const db = await makePublishedDb(snap) const url = new URL('http://localhost/test') await renderPublicResolution(db, url) @@ -205,7 +126,7 @@ describe('Layer B render cache integration', () => { it('after re-render following a bump, the subsequent request is a hit', async () => { const snap = makeSnapshot() - const db = makeFakeDb(snap) + const db = await makePublishedDb(snap) const url = new URL('http://localhost/test') await renderPublicResolution(db, url) @@ -217,8 +138,10 @@ describe('Layer B render cache integration', () => { it('different URL paths are distinct cache entries', async () => { const snap = makeSnapshot() - const db = makeFakeDb(snap) + const db = await makePublishedDb(snap) + await seedPublishingPage(db, { ...snap.site.pages[0], id: 'other-page', slug: 'other' }) + await publishDraftSite(db, null, undefined, { variants: [{ rowId: 'other-page', localeId: 'default' }] }) await renderPublicResolution(db, new URL('http://localhost/test')) await renderPublicResolution(db, new URL('http://localhost/other')) expect(getStats().size).toBe(2) @@ -227,7 +150,7 @@ describe('Layer B render cache integration', () => { it('same path with different render-affecting (loop pagination) queries are distinct entries', async () => { const snap = makeSnapshot() - const db = makeFakeDb(snap) + const db = await makePublishedDb(snap) // Only loop-pagination params survive query canonicalisation, so they are // the only thing that produces distinct cache keys (ISS-032). Junk params @@ -240,7 +163,7 @@ describe('Layer B render cache integration', () => { it('different junk query strings collapse onto a single cache entry', async () => { const snap = makeSnapshot() - const db = makeFakeDb(snap) + const db = await makePublishedDb(snap) await renderPublicResolution(db, new URL('http://localhost/test?utm=a')) await renderPublicResolution(db, new URL('http://localhost/test?utm=b')) @@ -249,7 +172,7 @@ describe('Layer B render cache integration', () => { }) it('redirect resolutions are NOT cached', async () => { - const db = makeRedirectDb() + const db = await makeRedirectDb() // Redirect URL: /posts/old-post → resolved by getDataRowRedirectByRoute const url = new URL('http://localhost/posts/old-post') @@ -264,7 +187,7 @@ describe('Layer B render cache integration', () => { }) it('not-found resolutions are NOT cached', async () => { - const db = makeFakeDb(null) // no snapshot → not-found + const db = await makePublishedDb(null) // no snapshot → not-found const url = new URL('http://localhost/nowhere') const res1 = await renderPublicResolution(db, url) diff --git a/src/__tests__/server/publishRebakeTemplate.test.ts b/src/__tests__/server/publishRebakeTemplate.test.ts index 11d6e182f..df50ce34b 100644 --- a/src/__tests__/server/publishRebakeTemplate.test.ts +++ b/src/__tests__/server/publishRebakeTemplate.test.ts @@ -13,89 +13,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import type { DbResult } from '../../../server/db' +import { cleanupPublishingTestDbs, createPublishingTestDb } from '../helpers/publishingTestDb' +import { makeSite } from '../publisher/helpers' +afterEach(cleanupPublishingTestDbs) import { readArtefact } from '../../../server/publish/staticArtefact' -import { createFakeDb } from './dbTestFake' import { makePage } from '../publisher/helpers' -import type { Page } from '../../../src/core/page-tree' - -function rowDate(value: string) { - return new Date(value) -} - -function pageRow(page: Page, extraCells: Record = {}) { - return { - id: page.id, - table_id: 'pages', - slug: page.slug, - status: 'draft', - cells_json: { - title: page.title, - slug: page.slug, - body: { nodes: page.nodes, rootNodeId: page.rootNodeId }, - ...extraCells, - }, - author_user_id: null, author_email: null, author_display_name: null, - author_role_slug: null, author_role_name: null, - created_by_user_id: null, created_by_email: null, created_by_display_name: null, - created_by_role_slug: null, created_by_role_name: null, - updated_by_user_id: null, updated_by_email: null, updated_by_display_name: null, - updated_by_role_slug: null, updated_by_role_name: null, - published_by_user_id: null, published_by_email: null, published_by_display_name: null, - published_by_role_slug: null, published_by_role_name: null, - created_at: rowDate('2026-01-01'), updated_at: rowDate('2026-01-01'), - published_at: null, scheduled_publish_at: null, deleted_at: null, - } -} - -function buildFakeDb(layout: Page, about: Page) { - return createFakeDb(async (sql: string, params: unknown[]): Promise => { - const s = sql.replace(/\s+/g, ' ').trim().toLowerCase() - - if (s.startsWith('select id, name, version, enabled, lifecycle_status')) return { rows: [], rowCount: 0 } - - if (s.includes('from site') && s.includes('select id')) { - return { - rows: [{ - id: 'proj-1', name: 'Test Site', - settings_json: { metaTitle: 'Test Site', shortcuts: {} }, - files_json: [], classes_json: {}, - breakpoints_json: [{ id: 'desktop', label: 'Desktop', width: 1440, icon: 'monitor' }], - runtime_json: { dependencyLock: { version: 1, packages: {}, updatedAt: 0 }, scripts: {} }, - version: 1, created_at: rowDate('2026-01-01'), updated_at: rowDate('2026-01-01'), - }], - rowCount: 1, - } - } - - if (s.includes('select data_rows.id') && s.includes('from data_rows') && s.includes('order by')) { - if (params[0] === 'pages') { - return { - rows: [ - pageRow(layout, { - templateEnabled: true, - templateTarget: { kind: 'everywhere' }, - templatePriority: 0, - }), - pageRow(about), - ], - rowCount: 2, - } - } - return { rows: [], rowCount: 0 } - } - - if (s.includes('coalesce(max(version_number), 0) + 1')) return { rows: [{ next_version: 1 }], rowCount: 1 } - if (s.includes('insert into data_row_versions')) return { rows: [], rowCount: 1 } - if (s.includes('insert into runtime_assets')) return { rows: [], rowCount: 0 } - if (s.includes('select count') && s.includes('from runtime_assets')) return { rows: [{ count: 0 }], rowCount: 1 } - if (s.includes('update data_rows') && s.includes("status = 'published'")) return { rows: [], rowCount: 1 } - if (s.includes('from active_media_storage_adapter')) return { rows: [], rowCount: 0 } - if (s.includes('count(*) as count from site')) return { rows: [{ count: 1 }], rowCount: 1 } - - return { rows: [], rowCount: 0 } - }) -} describe('publishDraftSite — template re-bake', () => { let uploadsDir: string @@ -126,9 +48,9 @@ describe('publishDraftSite — template re-bake', () => { about.slug = 'about' about.title = 'About' - const db = buildFakeDb(layout, about) + const db = await createPublishingTestDb(makeSite({ pages: [layout, about], layouts: [] }), false) const { publishDraftSite } = await import('../../../server/publish/publishSite') - await publishDraftSite(db, 'user-1', uploadsDir) + await publishDraftSite(db, null, uploadsDir, { variants: [{ rowId: about.id, localeId: 'default' }] }) // /about is baked AND wrapped in the layout (MASTHEAD present + own body). const aboutHtml = await readArtefact(uploadsDir, '/about') diff --git a/src/__tests__/server/publishRuntimeErrorResponse.test.ts b/src/__tests__/server/publishRuntimeErrorResponse.test.ts index 65cc023a9..3451f7581 100644 --- a/src/__tests__/server/publishRuntimeErrorResponse.test.ts +++ b/src/__tests__/server/publishRuntimeErrorResponse.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'bun:test' import type { SiteShell } from '@core/page-tree' import { normalizeSiteRuntimeConfig } from '@core/site-runtime' import { saveDraftSite } from '../../../server/repositories/site' +import { listDataRows } from '../../../server/repositories/data' import { createCapabilityTestHarness, readJson, @@ -93,9 +94,11 @@ describe('publish runtime validation response', () => { }), }) + const rows = await listDataRows(harness.db, 'pages') const response = await harness.cms('/admin/api/cms/publish', { method: 'POST', cookie, + json: { variants: rows.map((row) => ({ rowId: row.id, localeId: 'default' })) }, }) expect(response.status).toBe(422) const body = await readJson<{ error: string }>(response) diff --git a/src/__tests__/server/publishScheduler.test.ts b/src/__tests__/server/publishScheduler.test.ts index 97b9aa956..5f888492d 100644 --- a/src/__tests__/server/publishScheduler.test.ts +++ b/src/__tests__/server/publishScheduler.test.ts @@ -3,22 +3,21 @@ import type { DbClient } from '../../../server/db' import { tickPublishScheduler } from '../../../server/publish/publishScheduler' import { getDataRow, listDuePublishSchedules } from '../../../server/repositories/data/rows' import { createTestDb, type TestDb } from '../helpers/createTestDb' +import { seedPublishingSite } from '../helpers/publishingTestDb' +import { makeSite, makePage } from '../publisher/helpers' +import { scheduleLocalizedDataRowPublish } from '../../../server/publish/schedulePublication' async function seedScheduledPageRow( db: DbClient, input: { rowId: string; slug: string; scheduledAt: string }, ): Promise { - await db` - insert into data_rows (id, table_id, cells_json, slug, status, scheduled_publish_at) - values ( - ${input.rowId}, - ${'pages'}, - ${{ title: 'Scheduled page', page: { id: input.rowId } }}, - ${input.slug}, - ${'scheduled'}, - ${input.scheduledAt} - ) - ` + const page = makePage({ root: { moduleId: 'base.container' } }) + page.id = input.rowId + page.slug = input.slug + page.title = 'Scheduled page' + await seedPublishingSite(db, makeSite({ pages: [page] })) + await scheduleLocalizedDataRowPublish(db, input.rowId, input.scheduledAt) + } describe('publish scheduler', () => { @@ -56,15 +55,15 @@ describe('publish scheduler', () => { status: 'published', publishedByUserId: null, updatedByUserId: null, - scheduledPublishAt: scheduledAt, + scheduledPublishAt: null, }) expect(row?.publishedAt).toBeString() await expect(listDuePublishSchedules(db, new Date().toISOString(), 25)).resolves.toHaveLength(0) const { rows: dataRows } = await db<{ active_version_id: string | null }>` select active_version_id - from data_rows - where id = ${rowId} + from data_row_localizations + where row_id = ${rowId} and locale_id = 'default' ` expect(dataRows[0]?.active_version_id).toBeString() diff --git a/src/__tests__/server/publishStaticArtefact.test.ts b/src/__tests__/server/publishStaticArtefact.test.ts index 0043753ff..acdf89837 100644 --- a/src/__tests__/server/publishStaticArtefact.test.ts +++ b/src/__tests__/server/publishStaticArtefact.test.ts @@ -1,7 +1,7 @@ /** * Integration test for the Layer A static-artefact publish protocol. * - * Uses a minimal fake DB and a real tmpdir to exercise: + * Uses real SQLite publications and a real tmpdir to exercise: * * 1. `publishDraftSite` with a mixed fixture site (some fully-static pages, * one page with a request-dependent loop source) → only static pages get @@ -22,11 +22,15 @@ */ import { afterEach, beforeEach, describe, expect, it } from 'bun:test' -import { mkdtemp, readFile, rm, readlink } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { DbResult } from '../../../server/db' -import type { PublishedPageSnapshot } from '../../../server/repositories/publish' +import { cleanupPublishingTestDbs, createPublishingTestDb } from '../helpers/publishingTestDb' +import { getPublishedRouteInventoryForVersion } from '../../../server/publish/publishedRoutes' +import { getPublishVersion, markPublishedArtefactsCurrent } from '../../../server/publish/publishState' + +afterEach(cleanupPublishingTestDbs) import { handleServerRequest } from '../../../server/router' import { getActiveSlot, @@ -42,233 +46,33 @@ import { loopSourceRegistry } from '../../../src/core/loops/registry' // Helpers // --------------------------------------------------------------------------- -function rowDate(value: string) { - return new Date(value) +async function buildPublishedDb(...pages: Array>) { + return createPublishingTestDb(makeSite({ pages })) } -/** Make a minimal PublishedPageSnapshot with the given page as the only content. */ -function makeSnapshot(page: ReturnType): PublishedPageSnapshot { - return { - cmsSnapshotVersion: 1, - pageRowId: page.id, - site: makeSite({ pages: [page] }), - } +async function makeRouteDb(slug = 'about') { + const page = makePage({ root: { moduleId: 'base.text', props: { text: 'Live page' } } }) + page.slug = slug + return buildPublishedDb(page) } -/** - * Build a fake DbClient that answers the queries `publishDraftSite` and - * the live-render fallback path need. Snapshots are built on-the-fly from - * the provided page fixtures. - */ -function buildFakeDb( - staticPage: ReturnType, - dynamicPage: ReturnType, -) { - const staticSnapshot = makeSnapshot(staticPage) - const dynamicSnapshot = makeSnapshot(dynamicPage) - - /** Row shape the snapshot getters' 3-way join returns. */ - const toSnapshotRow = (snapshot: PublishedPageSnapshot) => ({ - row_id: snapshot.pageRowId, - site_json: snapshot.site, - runtime_assets_json: snapshot.runtimeAssets ?? null, - importmap_body: snapshot.runtimePackageImportmap?.body ?? null, - importmap_sha256: snapshot.runtimePackageImportmap?.sha256 ?? null, - }) - - let insertCallCount = 0 - - return createFakeDb(async (sql: string, params: unknown[]): Promise => { - const s = sql.replace(/\s+/g, ' ').trim().toLowerCase() - - // ── snapshot getters (live render fallback / row bake) ──────────────── - // MUST precede the listDataRows branch below: getLatestPublishedSiteSnapshot - // also contains `select data_rows.id` + `order by`. - if (s.includes('site_snapshots.site_json')) { - // getPublishedPageBySlug — parameterised on data_rows.slug. - if (s.includes('data_rows.slug =')) { - const slug = typeof params[0] === 'string' ? params[0] : '' - if (slug === staticPage.slug || slug === 'index') { - return { rows: [toSnapshotRow(staticSnapshot)], rowCount: 1 } - } - if (slug === dynamicPage.slug) { - return { rows: [toSnapshotRow(dynamicSnapshot)], rowCount: 1 } - } - return { rows: [], rowCount: 0 } - } - // getLatestPublishedSiteSnapshot - return { rows: [toSnapshotRow(staticSnapshot)], rowCount: 1 } - } - - // ── getDraftSite ─────────────────────────────────────────────────────── - if (s.startsWith('select id, name, version, enabled, lifecycle_status')) { - // Plugin listing for hook bus - return { rows: [], rowCount: 0 } - } - - if (s.includes('from site') && s.includes('select id')) { - return { - rows: [{ - id: 'proj-1', - name: 'Test Site', - settings_json: { - metaTitle: 'Test Site', - shortcuts: {}, - }, - files_json: [], - classes_json: {}, - breakpoints_json: [ - { id: 'desktop', label: 'Desktop', width: 1440, icon: 'monitor' }, - ], - runtime_json: { - dependencyLock: { version: 1, packages: {}, updatedAt: 0 }, - scripts: {}, - }, - version: 1, - created_at: rowDate('2026-01-01'), - updated_at: rowDate('2026-01-01'), - }], - rowCount: 1, - } - } - - // ── listDataRows (pages + components) ───────────────────────────────── - // `listDataRows` parameterizes the table_id ($1), so we check params. - if (s.includes('select data_rows.id') && s.includes('from data_rows') && s.includes('order by')) { - if (params[0] === 'pages') { - return { - rows: [ - { - id: staticPage.id, - table_id: 'pages', - slug: staticPage.slug, - status: 'draft', - cells_json: { - title: staticPage.title, - slug: staticPage.slug, - body: { nodes: staticPage.nodes, rootNodeId: staticPage.rootNodeId }, - }, - author_user_id: null, - author_email: null, - author_display_name: null, - author_role_slug: null, - author_role_name: null, - created_by_user_id: null, - created_by_email: null, - created_by_display_name: null, - created_by_role_slug: null, - created_by_role_name: null, - updated_by_user_id: null, - updated_by_email: null, - updated_by_display_name: null, - updated_by_role_slug: null, - updated_by_role_name: null, - published_by_user_id: null, - published_by_email: null, - published_by_display_name: null, - published_by_role_slug: null, - published_by_role_name: null, - created_at: rowDate('2026-01-01'), - updated_at: rowDate('2026-01-01'), - published_at: null, - scheduled_publish_at: null, - deleted_at: null, - }, - { - id: dynamicPage.id, - table_id: 'pages', - slug: dynamicPage.slug, - status: 'draft', - cells_json: { - title: dynamicPage.title, - slug: dynamicPage.slug, - body: { nodes: dynamicPage.nodes, rootNodeId: dynamicPage.rootNodeId }, - }, - author_user_id: null, - author_email: null, - author_display_name: null, - author_role_slug: null, - author_role_name: null, - created_by_user_id: null, - created_by_email: null, - created_by_display_name: null, - created_by_role_slug: null, - created_by_role_name: null, - updated_by_user_id: null, - updated_by_email: null, - updated_by_display_name: null, - updated_by_role_slug: null, - updated_by_role_name: null, - published_by_user_id: null, - published_by_email: null, - published_by_display_name: null, - published_by_role_slug: null, - published_by_role_name: null, - created_at: rowDate('2026-01-01'), - updated_at: rowDate('2026-01-01'), - published_at: null, - scheduled_publish_at: null, - deleted_at: null, - }, - ], - rowCount: 2, - } - } - // components or any other table - return { rows: [], rowCount: 0 } - } - - // ── nextVersionNumber ───────────────────────────────────────────────── - if (s.includes('coalesce(max(version_number), 0) + 1')) { - return { rows: [{ next_version: 1 }], rowCount: 1 } - } - - // ── insert into site_snapshots (one per publish) ────────────────────── - if (s.includes('insert into site_snapshots')) { - return { rows: [], rowCount: 1 } - } - - // ── insert into data_row_versions ───────────────────────────────────── - if (s.includes('insert into data_row_versions')) { - insertCallCount++ - return { rows: [], rowCount: 1 } - } - - // ── savePublishedRuntimeAssets ──────────────────────────────────────── - if (s.includes('insert into runtime_assets')) { - return { rows: [], rowCount: 0 } - } - if (s.includes('select count') && s.includes('from runtime_assets')) { - return { rows: [{ count: 0 }], rowCount: 1 } - } - - // ── update data_rows (status=published) ─────────────────────────────── - if (s.includes('update data_rows') && s.includes("status = 'published'")) { - return { rows: [], rowCount: 1 } - } - - // ── collectFrontendInjections: active_media_storage_adapter ────────── - if (s.includes('from active_media_storage_adapter')) { - return { rows: [], rowCount: 0 } - } - - // ── getSetupStatus ──────────────────────────────────────────────────── - if (s.includes('count(*) as count from site')) { - return { rows: [{ count: 1 }], rowCount: 1 } +/** Warm the authoritative route inventory, then enforce zero further SQL. */ +async function makeWarmInventoryDb(slug = 'about') { + const real = await makeRouteDb(slug) + let deny = false + let queried = false + const db = createFakeDb(async (sql, params) => { + if (deny) { + queried = true + throw new Error(`unexpected DB query after inventory warmup: ${sql.slice(0, 80)}`) } - if (s.includes('from users') && s.includes('role_id')) { - return { rows: [{ count: 1 }], rowCount: 1 } - } - - // ── fallthrough ─────────────────────────────────────────────────────── - return { rows: [], rowCount: 0 } + return real.unsafe(sql, params) }) + await getPublishedRouteInventoryForVersion(db, getPublishVersion()) + deny = true + return { db, wasQueried: () => queried } } -// --------------------------------------------------------------------------- -// Fixtures -// --------------------------------------------------------------------------- - const REQUEST_DEPENDENT_SOURCE_ID = 'test.requestDependent' const requestDependentSource: LoopEntitySource = { @@ -320,10 +124,10 @@ describe('publishDraftSite — Layer A static artefacts', () => { dynamicPage.slug = 'news' dynamicPage.title = 'News' - const db = buildFakeDb(staticPage, dynamicPage) + const db = await buildPublishedDb(staticPage, dynamicPage) const { publishDraftSite } = await import('../../../server/publish/publishSite') - const result = await publishDraftSite(db, 'user-1', uploadsDir) + const result = await publishDraftSite(db, null, uploadsDir) expect(result.publishedPages).toBe(2) @@ -388,9 +192,9 @@ describe('publishDraftSite — Layer A static artefacts', () => { dynamicPage.slug = 'empty' dynamicPage.title = 'Empty' - const db = buildFakeDb(page, dynamicPage) + const db = await buildPublishedDb(page, dynamicPage) const { publishDraftSite } = await import('../../../server/publish/publishSite') - const result = await publishDraftSite(db, 'user-1') // no uploadsDir + const result = await publishDraftSite(db, null) // no uploadsDir expect(result.publishedPages).toBe(2) // No symlink should exist @@ -414,15 +218,14 @@ describe('publishDraftSite — Layer A static artefacts', () => { page2.slug = 'flip2' page2.title = 'Flip2' - const db = buildFakeDb(page, page2) + const db = await buildPublishedDb(page, page2) const { publishDraftSite } = await import('../../../server/publish/publishSite') // First publish: writes to inactive slot (b), flips current → b - await publishDraftSite(db, 'user-1', uploadsDir) + await publishDraftSite(db, null, uploadsDir) const slotAfterFirst = await getActiveSlot(uploadsDir) // The other slot directory should still exist on disk (not wiped until next publish) - const otherSlot = slotAfterFirst === 'a' ? 'b' : 'a' // The inactive slot from the perspective of "before the first publish" is // the one the publish just wrote into — the OLD slot is what was active before. // On a brand-new uploadsDir there's no old slot, so just verify the active one has content. @@ -430,7 +233,7 @@ describe('publishDraftSite — Layer A static artefacts', () => { expect(html).toContain('First publish') // Second publish: writes to inactive slot (the other one), flips current - await publishDraftSite(db, 'user-1', uploadsDir) + await publishDraftSite(db, null, uploadsDir) const slotAfterSecond = await getActiveSlot(uploadsDir) // Slots must have rotated @@ -453,28 +256,15 @@ describe('publicRouter — Layer A disk fast-path', () => { await rm(uploadsDir, { recursive: true, force: true }) }) - it('serves a baked artefact without DB snapshot lookup when URL has no query string', async () => { + it('serves a baked artefact after route validation without snapshot hydration', async () => { + const { db, wasQueried } = await makeWarmInventoryDb() // Pre-bake an artefact const { prepareInactiveSlot, writeArtefact, swapSlot } = await import('../../../server/publish/staticArtefact') const { slot, slotDir } = await prepareInactiveSlot(uploadsDir) await writeArtefact(slotDir, '/about', '

Baked about page

') await swapSlot(uploadsDir, slot) + markPublishedArtefactsCurrent(getPublishVersion()) - // Fake DB that throws on any snapshot lookup — proves we never hit it - let snapshotLookupCalled = false - const db = createFakeDb(async (sql: string): Promise => { - const s = sql.toLowerCase() - if (s.includes('site_snapshots')) { - snapshotLookupCalled = true - } - if (s.includes('count(*) as count from site')) { - return { rows: [{ count: 1 }], rowCount: 1 } - } - if (s.includes('from users') && s.includes('role_id')) { - return { rows: [{ count: 1 }], rowCount: 1 } - } - return { rows: [], rowCount: 0 } - }) const res = await handleServerRequest( new Request('http://localhost/about'), @@ -484,10 +274,11 @@ describe('publicRouter — Layer A disk fast-path', () => { expect(res.status).toBe(200) expect(res.headers.get('content-type')).toContain('text/html') expect(await res.text()).toContain('Baked about page') - expect(snapshotLookupCalled).toBe(false) + expect(wasQueried()).toBe(false) }) - it('serves a static page (HTML + CSS + JS) entirely from disk with ZERO database queries', async () => { + it('serves static HTML, CSS and JS with zero queries after inventory warmup', async () => { + const { db: throwingDb, wasQueried } = await makeWarmInventoryDb() // Pre-bake a full static page: HTML that links a CSS bundle and a JS chunk, // plus those two assets baked into the slot — exactly what a full publish // produces for a fully-static page. @@ -508,14 +299,7 @@ describe('publicRouter — Layer A disk fast-path', () => { await writeStaticAsset(slotDir, cssPath, enc.encode('body{margin:0}')) await writeStaticAsset(slotDir, jsPath, enc.encode('console.log("hi")')) await swapSlot(uploadsDir, slot) - - // A DB that throws on ANY query — the only way the three requests below can - // succeed is if NOTHING touches the database. - let dbQueried = false - const throwingDb = createFakeDb(async (sql: string): Promise => { - dbQueried = true - throw new Error(`unexpected DB query during static serve: ${sql.slice(0, 80)}`) - }) + markPublishedArtefactsCurrent(getPublishVersion()) // No staticDir → the admin static handler is a no-op; the public/asset // handlers own these paths. @@ -535,10 +319,11 @@ describe('publicRouter — Layer A disk fast-path', () => { expect(await jsRes.text()).toBe('console.log("hi")') // The hard guarantee: not a single DB query was issued for any of the three. - expect(dbQueried).toBe(false) + expect(wasQueried()).toBe(false) }) - it('serves a hole-page SHELL (HTML + CSS) from disk with ZERO DB — only the /_instatic/hole fragment is dynamic', async () => { + it('serves a hole shell and CSS with zero queries after inventory warmup', async () => { + const { db: throwingDb, wasQueried } = await makeWarmInventoryDb('blog') // A page with a hole bakes a static shell: real HTML + a // placeholder + the hole runtime. The shell and its CSS are on disk; only // the hole fragment fetch (/_instatic/hole/) touches the server at runtime. @@ -557,12 +342,7 @@ describe('publicRouter — Layer A disk fast-path', () => { await writeArtefact(slotDir, '/blog', shell) await writeStaticAsset(slotDir, cssPath, new TextEncoder().encode('h1{color:#000}')) await swapSlot(uploadsDir, slot) - - let dbQueried = false - const throwingDb = createFakeDb(async (sql: string): Promise => { - dbQueried = true - throw new Error(`unexpected DB query serving hole-shell: ${sql.slice(0, 80)}`) - }) + markPublishedArtefactsCurrent(getPublishVersion()) const htmlRes = await handleServerRequest(new Request('http://localhost/blog'), { db: throwingDb, uploadsDir }) expect(htmlRes.status).toBe(200) @@ -578,36 +358,29 @@ describe('publicRouter — Layer A disk fast-path', () => { // The shell + CSS were served entirely from disk — zero DB. (The hole // fragment endpoint, exercised in holeRouteHandler.test.ts, is the only // request that reads the DB.) - expect(dbQueried).toBe(false) + expect(wasQueried()).toBe(false) }) it('falls through to the live renderer when URL has a render-affecting (loop pagination) query', async () => { + const db = await makeRouteDb() // Pre-bake an artefact for /about const { prepareInactiveSlot, writeArtefact, swapSlot } = await import('../../../server/publish/staticArtefact') const { slot, slotDir } = await prepareInactiveSlot(uploadsDir) await writeArtefact(slotDir, '/about', '

Baked about page

') await swapSlot(uploadsDir, slot) + markPublishedArtefactsCurrent(getPublishVersion()) // A loop-pagination query affects the render, so it must bypass the disk // path (junk queries instead serve the baked artefact — ISS-032) - const db = createFakeDb(async (sql: string): Promise => { - const s = sql.toLowerCase() - if (s.includes('count(*) as count from site')) { - return { rows: [{ count: 1 }], rowCount: 1 } - } - if (s.includes('from users') && s.includes('role_id')) { - return { rows: [{ count: 1 }], rowCount: 1 } - } - return { rows: [], rowCount: 0 } - }) const res = await handleServerRequest( new Request('http://localhost/about?loop_x_page=2'), { db, uploadsDir }, ) - // No snapshot for this URL → falls through to not-found (404) - // The baked artefact must NOT have been served - expect(res.status).toBe(404) + expect(res.status).toBe(200) + const html = await res.text() + expect(html).toContain('Live page') + expect(html).not.toContain('Baked about page') }) }) diff --git a/src/__tests__/server/router.test.ts b/src/__tests__/server/router.test.ts index 6263f3263..905cf5f41 100644 --- a/src/__tests__/server/router.test.ts +++ b/src/__tests__/server/router.test.ts @@ -2,6 +2,13 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { createPublishingTestDb, cleanupPublishingTestDbs, seedPublishingPage } from '../helpers/publishingTestDb' +import { createUser } from '../../../server/repositories/users' +import { makeSite, makePage } from '../publisher/helpers' +import { publishDraftSite } from '../../../server/publish/publishSite' +import { markPublishedArtefactsCurrent, getPublishVersion } from '../../../server/publish/publishState' +import { getPublishedRouteInventoryForVersion } from '../../../server/publish/publishedRoutes' +import { resetForTests } from '../../../server/publish/renderCache' import { handleServerRequest } from '../../../server/router' import type { DbClient, DbResult } from '../../../server/db' import { @@ -15,38 +22,30 @@ interface FakeDbCounts { owners: number } -function makeFakeDb(counts: FakeDbCounts = { site: 0, owners: 0 }): DbClient { - const handle = async >( - strings: TemplateStringsArray, - ...values: unknown[] - ): Promise> => { - const sql = strings.reduce((acc, str, i) => (i === 0 ? str : `${acc}$${i}${str}`), '') - const normalized = sql.toLowerCase() - if (normalized.includes('count(*) as count from site')) { - return { rows: [{ count: counts.site } as Row], rowCount: 1 } - } - if (normalized.includes('from users') && normalized.includes('role_id')) { - return { rows: [{ count: counts.owners } as Row], rowCount: 1 } - } - // Catch-all: unknown queries (e.g. publishRepository.getPublishedPageBySlug) return empty - return { rows: [], rowCount: 0 } - } - - handle.transaction = async (cb: (tx: DbClient) => Promise): Promise => - cb(handle as unknown as DbClient) - - return handle as DbClient +async function makeDb(counts: FakeDbCounts = { site: 0, owners: 0 }): Promise { + const db = await createPublishingTestDb(counts.site ? makeSite() : null, false) + if (counts.owners) await createUser(db, { email: 'router@local.test', displayName: 'Owner', passwordHash: 'unused-test-hash', roleId: 'owner', allowOwnerRole: true }) + return db } +async function publishRoute(db: DbClient, slug: string): Promise { + await seedPublishingPage(db, { ...makePage({ root: { moduleId: 'base.body' } }), id: slug, slug }) + await publishDraftSite(db, null, undefined, { variants: [{ rowId: slug, localeId: 'default' }] }) + markPublishedArtefactsCurrent(getPublishVersion()) +} + +beforeEach(() => resetForTests()) +afterEach(cleanupPublishingTestDbs) + describe('server router', () => { it('serves health checks', async () => { - const res = await handleServerRequest(new Request('http://localhost/health'), { db: makeFakeDb() }) + const res = await handleServerRequest(new Request('http://localhost/health'), { db: await makeDb() }) expect(res.status).toBe(200) expect(await res.json()).toMatchObject({ status: 'ok' }) }) it('routes cms setup status', async () => { - const res = await handleServerRequest(new Request('http://localhost/admin/api/cms/setup/status'), { db: makeFakeDb() }) + const res = await handleServerRequest(new Request('http://localhost/admin/api/cms/setup/status'), { db: await makeDb() }) expect(res.status).toBe(200) expect(await res.json()).toMatchObject({ needsSetup: true }) }) @@ -54,7 +53,7 @@ describe('server router', () => { it('redirects unmatched public routes to /admin on a fresh install', async () => { const res = await handleServerRequest( new Request('http://localhost/'), - { db: makeFakeDb({ site: 0, owners: 0 }) }, + { db: await makeDb({ site: 0, owners: 0 }) }, ) expect(res.status).toBe(302) expect(res.headers.get('location')).toBe('/admin') @@ -63,7 +62,7 @@ describe('server router', () => { it('returns 404 for unknown routes once setup is complete', async () => { const res = await handleServerRequest( new Request('http://localhost/nope'), - { db: makeFakeDb({ site: 1, owners: 1 }) }, + { db: await makeDb({ site: 1, owners: 1 }) }, ) expect(res.status).toBe(404) }) @@ -71,7 +70,7 @@ describe('server router', () => { it('explains where the admin UI lives when /admin is hit on the cms port without a build', async () => { const res = await handleServerRequest( new Request('http://localhost/admin'), - { db: makeFakeDb() }, + { db: await makeDb() }, ) expect(res.status).toBe(404) expect(res.headers.get('content-type')).toContain('text/html') @@ -99,7 +98,9 @@ describe('server router — Layer A disk artefact fast-path', () => { // DB that tracks snapshot lookups — should never be called for a disk hit let snapshotQueried = false - const db = makeFakeDb({ site: 1, owners: 1 }) + const db = await makeDb({ site: 1, owners: 1 }) + await publishRoute(db, 'about') + await getPublishedRouteInventoryForVersion(db, getPublishVersion()) const originalHandle = db as unknown as (strings: TemplateStringsArray, ...values: unknown[]) => Promise const trackingDb = Object.assign( async (strings: TemplateStringsArray, ...values: unknown[]): Promise => { @@ -107,7 +108,7 @@ describe('server router — Layer A disk artefact fast-path', () => { if (sql.toLowerCase().includes('site_snapshots')) snapshotQueried = true return originalHandle(strings, ...values) }, - { transaction: db.transaction, unsafe: db.unsafe }, + { transaction: db.transaction, unsafe: db.unsafe, dialect: db.dialect }, ) as DbClient const res = await handleServerRequest( @@ -131,7 +132,7 @@ describe('server router — Layer A disk artefact fast-path', () => { // (junk queries instead serve the baked artefact — ISS-032). const res = await handleServerRequest( new Request('http://localhost/about?loop_x_page=2'), - { db: makeFakeDb({ site: 1, owners: 1 }), uploadsDir }, + { db: await makeDb({ site: 1, owners: 1 }), uploadsDir }, ) // The DB has no snapshot → resolvePublicRoute returns not-found → 404 @@ -147,7 +148,7 @@ describe('server router — Layer A disk artefact fast-path', () => { const res = await handleServerRequest( new Request('http://localhost/contact'), - { db: makeFakeDb({ site: 1, owners: 1 }), uploadsDir }, + { db: await makeDb({ site: 1, owners: 1 }), uploadsDir }, ) // No DB snapshot → 404 @@ -178,7 +179,9 @@ describe('server router — HEAD is GET minus the body', () => { await writeArtefact(slotDir, '/kontakt', 'Kontakt') await swapSlot(uploadsDir, slot) - const runtime = { db: makeFakeDb({ site: 1, owners: 1 }), uploadsDir } + const db = await makeDb({ site: 1, owners: 1 }) + await publishRoute(db, 'kontakt') + const runtime = { db, uploadsDir } const get = await handleServerRequest(new Request('http://localhost/kontakt'), runtime) const head = await handleServerRequest( new Request('http://localhost/kontakt', { method: 'HEAD' }), @@ -193,7 +196,7 @@ describe('server router — HEAD is GET minus the body', () => { it('answers HEAD with the setup redirect on a fresh install, like GET', async () => { const res = await handleServerRequest( new Request('http://localhost/', { method: 'HEAD' }), - { db: makeFakeDb({ site: 0, owners: 0 }) }, + { db: await makeDb({ site: 0, owners: 0 }) }, ) expect(res.status).toBe(302) @@ -203,7 +206,7 @@ describe('server router — HEAD is GET minus the body', () => { it('answers HEAD on a JSON API route like GET instead of 405', async () => { const res = await handleServerRequest( new Request('http://localhost/admin/api/cms/setup/status', { method: 'HEAD' }), - { db: makeFakeDb() }, + { db: await makeDb() }, ) expect(res.status).toBe(200) @@ -212,7 +215,7 @@ describe('server router — HEAD is GET minus the body', () => { it('still rejects a method that GET-only routes genuinely do not support', async () => { const res = await handleServerRequest( new Request('http://localhost/admin/api/cms/setup/status', { method: 'DELETE' }), - { db: makeFakeDb() }, + { db: await makeDb() }, ) expect(res.status).not.toBe(200) diff --git a/src/__tests__/server/siteCssBundleMemo.test.ts b/src/__tests__/server/siteCssBundleMemo.test.ts index bf7e34080..5a8010942 100644 --- a/src/__tests__/server/siteCssBundleMemo.test.ts +++ b/src/__tests__/server/siteCssBundleMemo.test.ts @@ -103,12 +103,9 @@ describe('buildPublishedSiteCssBundle — page-invariant memo', () => { expect(after.style).not.toBe(before.style) }) - it('memo key is the publish version ALONE — a different site object at the same version reuses it', () => { - // Every consumer loads the published snapshot fresh from the DB (a new - // JSON-parsed object per query), so a site-identity key would never hit. - // Published content is fixed per version (every snapshot writer bumps), so - // the version alone is a sound key — and two distinct site objects at the - // same version must share one walk. + it('different frozen locale snapshots have independent CSS at the same publish version', () => { + // Independent locale releases coexist at one process publication version. + // Identity separates their styles; a same-site call still reuses its memo. const firstSite = makeMultiPageSite() const secondSite = makeMultiPageSite() @@ -117,9 +114,9 @@ describe('buildPublishedSiteCssBundle — page-invariant memo', () => { const second = buildPublishedSiteCssBundle(secondSite, registry, secondSite.pages[0]) - expect(renderCalls).toBe(callsAfterFirstSite) - expect(second.framework).toBe(first.framework) - expect(second.style).toBe(first.style) + expect(renderCalls).toBeGreaterThan(callsAfterFirstSite) + expect(second.framework).not.toBe(first.framework) + expect(second.style).not.toBe(first.style) }) it('an explicit publishVersion argument (publish-time bake) gets its own memo slot', () => { diff --git a/src/__tests__/server/siteDocumentSave.test.ts b/src/__tests__/server/siteDocumentSave.test.ts index 75cc27624..7625cb8b1 100644 --- a/src/__tests__/server/siteDocumentSave.test.ts +++ b/src/__tests__/server/siteDocumentSave.test.ts @@ -1,3 +1,5 @@ +import { materializeLocalizedCells } from '@core/localization' +import { getDataTable } from '../../../server/repositories/data' /** * PUT /admin/api/cms/site-document — the transactional whole-document save. * @@ -135,6 +137,8 @@ interface StoredRow { id: string slug: string cells_json: { title?: string } & Record + shared_cells_json: Record + localized_cells_json: Record | null seq: number updated_at: string deleted_at: string | null @@ -142,11 +146,17 @@ interface StoredRow { async function storedRows(harness: CapabilityTestHarness, tableId: string): Promise> { const { rows } = await harness.db` - select id, slug, cells_json, seq, updated_at, deleted_at + select data_rows.id, coalesce(data_row_localizations.slug, '') as slug, + data_rows.cells_json as shared_cells_json, data_row_localizations.cells_json as localized_cells_json, + data_rows.seq, data_rows.updated_at, data_rows.deleted_at from data_rows - where table_id = ${tableId} + left join data_row_localizations on data_row_localizations.row_id = data_rows.id and data_row_localizations.locale_id = 'default' + where data_rows.table_id = ${tableId} ` - return new Map(rows.map((row) => [row.id, row])) + const table = await getDataTable(harness.db, tableId) + return new Map(rows.map((row) => [row.id, { ...row, + cells_json: materializeLocalizedCells(table!.fields, row.shared_cells_json, row.localized_cells_json ?? {}, row.localized_cells_json ?? {}), + }])) } async function backdateRows(harness: CapabilityTestHarness, tableId: string): Promise { @@ -268,6 +278,29 @@ async function expectOk(res: Response): Promise { // --------------------------------------------------------------------------- describe('site-document save — pages', () => { + it('loads a coherent localized document and releases the transaction for subsequent language writes', async () => { + const ctx = await setupHarness() + try { + const response = await ctx.harness.cms('/admin/api/cms/site-document', { cookie: ctx.cookie }) + expect(response.status).toBe(200) + const document = await readJson<{ site: { localeId: string; pages: { id: string }[] }; rowSeqs: Record }>(response) + expect(document.site.localeId).toBe('default') + expect(document.site.pages.map((page) => page.id)).toContain(ctx.homeId) + expect(document.rowSeqs[ctx.homeId]).toBeNumber() + const created = await ctx.harness.cms('/admin/api/cms/locales', { + method: 'POST', cookie: ctx.cookie, + json: { code: 'de', name: 'Deutsch', pathPrefix: 'de', direction: 'ltr', enabled: false }, + }) + expect(created.status).toBe(201) + const { locale } = await readJson<{ locale: { id: string } }>(created) + const translated = await ctx.harness.cms(`/admin/api/cms/site-document?localeId=${locale.id}`, { cookie: ctx.cookie }) + expect(translated.status).toBe(200) + expect((await readJson<{ site: { localeId: string } }>(translated)).site.localeId).toBe(locale.id) + } finally { + await ctx.harness.cleanup() + } + }) + it('writes ONLY the changed page among N stored rows; unmentioned rows are byte-untouched', async () => { const ctx = await setupHarness() try { @@ -291,6 +324,8 @@ describe('site-document save — pages', () => { for (const id of [ctx.homeId, 'page-b']) { expect(after.get(id)!.updated_at).toBe(BACKDATED) expect(after.get(id)!.cells_json).toEqual(before.get(id)!.cells_json) + expect(after.get(id)!.shared_cells_json).toEqual(before.get(id)!.shared_cells_json) + expect(after.get(id)!.localized_cells_json).toEqual(before.get(id)!.localized_cells_json) expect(after.get(id)!.deleted_at).toBeNull() } } finally { diff --git a/src/__tests__/server/unpublishArtefactRemoval.test.ts b/src/__tests__/server/unpublishArtefactRemoval.test.ts index 2b1d8cda1..f71b7df3b 100644 --- a/src/__tests__/server/unpublishArtefactRemoval.test.ts +++ b/src/__tests__/server/unpublishArtefactRemoval.test.ts @@ -34,6 +34,11 @@ describe('removeDataRowArtefact', () => { await db` insert into data_rows (id, table_id, slug, status, cells_json) values (${rowId}, ${'pages'}, ${slug}, ${status}, ${{ title: 'Ghost', slug }})` + const versionId = crypto.randomUUID() + await db`insert into data_row_versions (id, row_id, locale_id, version_number, cells_json, slug, public_path) + values (${versionId}, ${rowId}, ${'default'}, ${1}, ${{ title: 'Ghost' }}, ${slug}, ${`/${slug}`})` + await db`insert into data_row_localizations (row_id, locale_id, slug, active_version_id, availability) + values (${rowId}, ${'default'}, ${'new-draft-slug'}, ${versionId}, ${status === 'published' ? 'online' : 'offline'})` const artefactPath = `/${slug}` await updateArtefactInPlace(uploadsDir, artefactPath, 'ghost content') // Establish the `current` symlink so readArtefact (Layer A) can see it. @@ -55,7 +60,7 @@ describe('removeDataRowArtefact', () => { const ctx = await withRow('unpublished') try { expect(await readArtefact(ctx.uploadsDir, ctx.artefactPath)).toContain('ghost content') - await removeDataRowArtefact(ctx.db, ctx.uploadsDir, ctx.rowId, ctx.slug) + await removeDataRowArtefact(ctx.db, ctx.uploadsDir, ctx.rowId, { localeId: 'default' }) expect(await readArtefact(ctx.uploadsDir, ctx.artefactPath)).toBeNull() } finally { await ctx.cleanup() @@ -67,7 +72,7 @@ describe('removeDataRowArtefact', () => { try { await ctx.db`update data_rows set deleted_at = current_timestamp where id = ${ctx.rowId}` expect(await readArtefact(ctx.uploadsDir, ctx.artefactPath)).toContain('ghost content') - await removeDataRowArtefact(ctx.db, ctx.uploadsDir, ctx.rowId, ctx.slug) + await removeDataRowArtefact(ctx.db, ctx.uploadsDir, ctx.rowId, { localeId: 'default' }) expect(await readArtefact(ctx.uploadsDir, ctx.artefactPath)).toBeNull() } finally { await ctx.cleanup() diff --git a/src/__tests__/settings/settingsModal.test.tsx b/src/__tests__/settings/settingsModal.test.tsx index 8c4edd2d9..83194d026 100644 --- a/src/__tests__/settings/settingsModal.test.tsx +++ b/src/__tests__/settings/settingsModal.test.tsx @@ -193,12 +193,12 @@ describe('SettingsModal — backdrop', () => { // --------------------------------------------------------------------------- describe('SettingsModal — section navigation', () => { - it('renders exactly 4 nav items (general, shortcuts, publishing, preferences)', () => { + it('renders five nav items including language settings', () => { openModal() render() const nav = screen.getByRole('navigation', { name: /settings sections/i }) const navBtns = Array.from(nav.querySelectorAll('button')) - expect(navBtns.length).toBe(4) + expect(navBtns.length).toBe(5) }) it('renders nav items with the current section labels', () => { diff --git a/src/__tests__/site-explorer/siteExplorerLivePath.test.tsx b/src/__tests__/site-explorer/siteExplorerLivePath.test.tsx new file mode 100644 index 000000000..4307154ab --- /dev/null +++ b/src/__tests__/site-explorer/siteExplorerLivePath.test.tsx @@ -0,0 +1,67 @@ +import { afterEach, expect, it, mock, spyOn } from 'bun:test' +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { Value } from '@core/utils/typeboxHelpers' +import { DataRowSchema, type DataRow } from '@core/data/schemas' +import { ContentLocalizationSchema } from '@core/localization-schema' +import { useEditorStore } from '@site/store/store' +import { useSiteExplorerLivePath } from '@site/panels/SiteExplorerPanel/useSiteExplorerLivePath' +import { CMS_PUBLICATION_CHANGED_EVENT } from '@admin/state/adminEvents' +import { makePage, makeSite } from '../fixtures' + +afterEach(() => { cleanup(); mock.restore(); useEditorStore.getState().clearSite() }) + +function prepare() { + const page = makePage({ id: 'page', slug: 'new-draft-path' }) + const site = makeSite({ pages: [page], localeId: 'de', locales: [ + { id: 'en', code: 'en', name: 'English', enabled: true, isDefault: true, direction: 'ltr', pathPrefix: '' }, + { id: 'de', code: 'de', name: 'Deutsch', enabled: true, isDefault: false, direction: 'ltr', pathPrefix: 'de' }, + ] }) + useEditorStore.getState().loadSite(site) + return { page, site } +} + +function liveRow(localeId: string): DataRow { + return { ...Value.Create(DataRowSchema), id: 'page', tableId: 'pages', localeId, + publicPath: localeId === 'de' ? '/de/frozen-path' : '/frozen-path', + localization: { ...Value.Create(ContentLocalizationSchema), rowId: 'page', localeId, availability: 'online' }, + } +} + +it('uses a frozen locale path and removes it after unpublishing or disabling the language', async () => { + const { page, site } = prepare() + let row = liveRow('de') + const requested: string[] = [] + spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + requested.push(String(input)) + return new Response(JSON.stringify({ row }), { headers: { 'Content-Type': 'application/json' } }) + }) + const { result, rerender } = renderHook(({ selected }) => useSiteExplorerLivePath(selected), { initialProps: { selected: page } }) + await waitFor(() => expect(result.current).toBe('/de/frozen-path')) + expect(requested[0]).toContain('localeId=de') + row = { ...row, localization: { ...row.localization!, availability: 'offline' } } + act(() => window.dispatchEvent(new Event(CMS_PUBLICATION_CHANGED_EVENT))) + await waitFor(() => expect(result.current).toBeNull()) + row = liveRow('de') + act(() => window.dispatchEvent(new Event(CMS_PUBLICATION_CHANGED_EVENT))) + await waitFor(() => expect(result.current).toBe('/de/frozen-path')) + rerender({ selected: { ...page, template: { enabled: true, target: { kind: 'everywhere' }, priority: 100 } } }) + expect(result.current).toBeNull() + rerender({ selected: page }) + act(() => useEditorStore.setState({ site: { ...site, locales: site.locales!.map((locale) => ({ ...locale, enabled: false })) } })) + expect(result.current).toBeNull() +}) + +it('never shows a previous language while the next language request is pending', async () => { + const { page, site } = prepare() + let resolveSource!: (value: Response) => void + spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + if (String(input).includes('localeId=en')) return new Promise((resolve) => { resolveSource = resolve }) + return Response.json({ row: liveRow('de') }) + }) + const { result } = renderHook(() => useSiteExplorerLivePath(page)) + await waitFor(() => expect(result.current).toBe('/de/frozen-path')) + act(() => useEditorStore.setState({ site: { ...site, localeId: 'en' } })) + expect(result.current).toBeNull() + await act(async () => resolveSource(Response.json({ row: liveRow('en') }))) + await waitFor(() => expect(result.current).toBe('/frozen-path')) +}) diff --git a/src/__tests__/site-explorer/siteExplorerPanel.test.tsx b/src/__tests__/site-explorer/siteExplorerPanel.test.tsx index 237c0cdef..6de982b1f 100644 --- a/src/__tests__/site-explorer/siteExplorerPanel.test.tsx +++ b/src/__tests__/site-explorer/siteExplorerPanel.test.tsx @@ -9,6 +9,9 @@ import { normalizeCmsMediaAsset } from '@core/persistence/cmsMedia' import { useEditorStore } from '@site/store/store' import { makeNode, makePage, makeSite } from '../fixtures' import type { VisualComponent } from '@core/visualComponents' +import { Value } from '@core/utils/typeboxHelpers' +import { DataRowSchema } from '@core/data/schemas' +import { ContentLocalizationSchema } from '@core/localization-schema' import '@modules/base/index' afterEach(cleanup) @@ -927,9 +930,15 @@ describe('SiteExplorerPanel', () => { }) }) - it('opens page routes in a new browser tab from the page context menu', () => { + it('opens the frozen locale route in a new browser tab from the page context menu', async () => { loadSite() + useEditorStore.setState({ site: { ...useEditorStore.getState().site!, localeId: 'de' } }) const originalOpen = window.open + const originalFetch = globalThis.fetch + globalThis.fetch = async () => Response.json({ row: { + ...Value.Create(DataRowSchema), id: 'page-pricing', tableId: 'pages', localeId: 'de', publicPath: '/de/published-pricing', + localization: { ...Value.Create(ContentLocalizationSchema), rowId: 'page-pricing', localeId: 'de', availability: 'online' }, + } }) const openCalls: unknown[] = [] window.open = ((...args: unknown[]) => { openCalls.push(args) @@ -943,11 +952,12 @@ describe('SiteExplorerPanel', () => { clientX: 120, clientY: 140, }) - fireEvent.click(screen.getByRole('menuitem', { name: /open in new tab/i })) + fireEvent.click(await screen.findByRole('menuitem', { name: /open in new tab/i })) - expect(openCalls).toEqual([['/pricing', '_blank', 'noopener,noreferrer']]) + expect(openCalls).toEqual([['/de/published-pricing', '_blank', 'noopener,noreferrer']]) } finally { window.open = originalOpen + globalThis.fetch = originalFetch } }) diff --git a/src/__tests__/toolbar/resolveLivePath.test.ts b/src/__tests__/toolbar/resolveLivePath.test.ts index 0b0d37e5a..c5aa4132d 100644 --- a/src/__tests__/toolbar/resolveLivePath.test.ts +++ b/src/__tests__/toolbar/resolveLivePath.test.ts @@ -30,14 +30,18 @@ describe('resolveLivePath', () => { it('maps a regular page to its public path', () => { expect(resolveLivePath({ - activePage: page('p', 'about'), isTemplate: false, targetKind: null, + activePage: page('p', 'about-draft'), isTemplate: false, targetKind: null, pageLivePath: '/de/ueber-uns', selection: null, sitePages: null, rows: [], - })).toBe('/about') + })).toBe('/de/ueber-uns') + }) + + it('keeps an offline page without a live link even when its draft has a slug', () => { + expect(resolveLivePath({ activePage: page('offline', 'about'), isTemplate: false, targetKind: null, selection: null, sitePages: null, rows: [] })).toBeNull() }) it('maps the home page (slug "index") to "/"', () => { expect(resolveLivePath({ - activePage: page('home', 'index'), isTemplate: false, targetKind: null, + activePage: page('home', 'index'), isTemplate: false, targetKind: null, pageLivePath: '/', selection: null, sitePages: null, rows: [], })).toBe('/') }) @@ -49,12 +53,12 @@ describe('resolveLivePath', () => { // No explicit selection → defaults to the first non-template page (home). expect(resolveLivePath({ activePage: tpl, isTemplate: true, targetKind: 'everywhere', - selection: null, sitePages: [tpl, home, about], rows: [], + selection: null, sitePages: [tpl, home, about], rows: [], pageLivePath: '/', })).toBe('/') // Explicit selection wins. expect(resolveLivePath({ activePage: tpl, isTemplate: true, targetKind: 'everywhere', - selection: 'about', sitePages: [tpl, home, about], rows: [], + selection: 'about', sitePages: [tpl, home, about], rows: [], pageLivePath: '/about', })).toBe('/about') }) diff --git a/src/__tests__/toolbar/toolbar.test.ts b/src/__tests__/toolbar/toolbar.test.ts index cececd737..c1078ca2f 100644 --- a/src/__tests__/toolbar/toolbar.test.ts +++ b/src/__tests__/toolbar/toolbar.test.ts @@ -346,7 +346,8 @@ describe('PublishButton — publish state machine', () => { // Live co-editing streams every edit to the server as it happens; the // publish ENDPOINT flushes the relay's debounced persist. The button must // not carry a client-side save path anymore. - expect(src).toContain('publishCmsDraft()') + expect(src).toContain('publishCmsDraft(undefined, undefined, selection)') + expect(src).toContain('onPublish={() => setPublicationDialogOpen(true)}') expect(src).not.toContain('onSave') }) @@ -562,7 +563,7 @@ describe('Toolbar — structural requirements', () => { ) expect(src).toContain('Draft synced') expect(src).toContain('Offline — reconnecting') - expect(src).toContain('publishDisabled={disabled || state === \'published\'}') + expect(src).toContain('publishDisabled={disabled}') expect(src).not.toContain('Save draft') }) it('PublishActionGroup keeps the status pill and delegates its split control to the shared SplitButton', () => { diff --git a/src/admin/ai/useMcpWorkspaceBridge.ts b/src/admin/ai/useMcpWorkspaceBridge.ts index 182b5c58f..da7c213e4 100644 --- a/src/admin/ai/useMcpWorkspaceBridge.ts +++ b/src/admin/ai/useMcpWorkspaceBridge.ts @@ -76,13 +76,14 @@ export async function runMcpWorkspaceBridgeConnection( dispatchTool: McpToolDispatcher, afterSuccessfulTool: McpAfterSuccessfulTool | undefined, lifecycleSignal: AbortSignal, + localeId?: string | null, ): Promise { const connectionController = new AbortController() const signal = AbortSignal.any([lifecycleSignal, connectionController.signal]) let bridgeId = '' try { - const res = await fetch(`${MCP_BRIDGE_PATH}?scope=${scope}`, { + const res = await fetch(`${MCP_BRIDGE_PATH}?scope=${scope}${localeId ? `&localeId=${encodeURIComponent(localeId)}` : ''}`, { method: 'GET', credentials: 'same-origin', // The bridge body stays newline-delimited JSON, but the event-stream @@ -120,6 +121,7 @@ export function useMcpWorkspaceBridge( dispatchTool: McpToolDispatcher, afterSuccessfulTool?: McpAfterSuccessfulTool, enabled = true, + localeId?: string | null, ): void { useEffect(() => { // A mounted route is not necessarily a usable workspace yet. In @@ -143,6 +145,7 @@ export function useMcpWorkspaceBridge( dispatchTool, afterSuccessfulTool, lifecycleController.signal, + localeId, ) } @@ -197,5 +200,5 @@ export function useMcpWorkspaceBridge( if (reconnectTimer) clearTimeout(reconnectTimer) lifecycleController.abort() } - }, [scope, dispatchTool, afterSuccessfulTool, enabled]) + }, [scope, dispatchTool, afterSuccessfulTool, enabled, localeId]) } diff --git a/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx b/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx index 5627be739..9fdf3f23e 100644 --- a/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx +++ b/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx @@ -1,3 +1,4 @@ +import { useEditorLocale } from '@site/localization' /** * AdminCanvasLayout — the Site editor admin shell. * @@ -147,9 +148,10 @@ export function AdminCanvasLayout() { // three holds full editor rights; a user with only `canEditContent` is the // "Client / copy editor" persona: read everything, change copy on existing // nodes, no DnD, no style edits, no structural changes. - const canEditStructureFlag = accessCanEditStructure(currentUser) + const { isTranslation } = useEditorLocale() + const canEditStructureFlag = accessCanEditStructure(currentUser) && !isTranslation const canEditContentFlag = accessCanEditContent(currentUser) - const canEditStyleFlag = accessCanEditStyle(currentUser) + const canEditStyleFlag = accessCanEditStyle(currentUser) && !isTranslation const canSaveSite = canSaveDraftSite(currentUser) const canUseAgent = canUseAiChat(currentUser) // Legacy "anything-editable" flag — true when the caller can drag/drop and diff --git a/src/admin/modals/SchedulePublishDialog/SchedulePublishDialog.module.css b/src/admin/modals/SchedulePublishDialog/SchedulePublishDialog.module.css index 225ae27ae..e2b8ba299 100644 --- a/src/admin/modals/SchedulePublishDialog/SchedulePublishDialog.module.css +++ b/src/admin/modals/SchedulePublishDialog/SchedulePublishDialog.module.css @@ -9,3 +9,5 @@ color: var(--danger); font-size: var(--text-s); } + +.description { margin: 0 0 var(--space-m); color: var(--text-muted); font-size: var(--text-s); line-height: 1.5; } diff --git a/src/admin/modals/SchedulePublishDialog/SchedulePublishDialog.tsx b/src/admin/modals/SchedulePublishDialog/SchedulePublishDialog.tsx index 941caba55..1be885de8 100644 --- a/src/admin/modals/SchedulePublishDialog/SchedulePublishDialog.tsx +++ b/src/admin/modals/SchedulePublishDialog/SchedulePublishDialog.tsx @@ -12,9 +12,9 @@ * * No retry / failure UI: the picker rejects past timestamps client-side * before hitting the network, and server-side errors surface as a brief - * inline message + the dialog stays open so the user can retry. + * toast and the dialog stays open so the user can retry. */ -import { useState } from 'react' +import { useRef, useState } from 'react' import { scheduleCmsDataRowPublish, cancelCmsDataRowSchedule, @@ -24,12 +24,15 @@ import { Dialog } from '@ui/components/Dialog' import { Button } from '@ui/components/Button' import { DateTimePicker } from '@ui/components/DateTimePicker' import { getErrorMessage } from '@core/utils/errorMessage' +import { pushToast } from '@ui/components/Toast' +import { StepUpCancelledMessage, useStepUp } from '@admin/shared/StepUp' import styles from './SchedulePublishDialog.module.css' interface SchedulePublishDialogProps { open: boolean onClose: () => void rowId: string + localeId?: string /** * Existing scheduled time (ISO datetime) if the row is already * `'scheduled'`. Pre-fills the picker so re-opening the dialog shows @@ -55,17 +58,20 @@ async function schedulePublish( setError: (msg: string | null) => void, onScheduled: (row: DataRow) => void, onClose: () => void, + localeId: string | undefined, + runStepUp: (operation: () => Promise) => Promise, ): Promise { setBusy(true) setError(null) try { - const row = await scheduleCmsDataRowPublish(rowId, next.toISOString()) + const row = await runStepUp(() => scheduleCmsDataRowPublish(rowId, next.toISOString(), undefined, undefined, localeId)) onScheduled(row) onClose() } catch (err) { + if (err instanceof Error && err.message === StepUpCancelledMessage) return console.error('[schedule-dialog] Schedule failed:', err) const message = getErrorMessage(err, 'Failed to schedule publish') - setError(message) + pushToast({ kind: 'error', title: 'Could not schedule publication', body: message }) } finally { setBusy(false) } @@ -77,17 +83,20 @@ async function cancelSchedule( setError: (msg: string | null) => void, onScheduled: (row: DataRow) => void, onClose: () => void, + localeId: string | undefined, + runStepUp: (operation: () => Promise) => Promise, ): Promise { setBusy(true) setError(null) try { - const row = await cancelCmsDataRowSchedule(rowId) + const row = await runStepUp(() => cancelCmsDataRowSchedule(rowId, undefined, undefined, localeId)) onScheduled(row) onClose() } catch (err) { + if (err instanceof Error && err.message === StepUpCancelledMessage) return console.error('[schedule-dialog] Cancel schedule failed:', err) const message = getErrorMessage(err, 'Failed to cancel schedule') - setError(message) + pushToast({ kind: 'error', title: 'Could not cancel publication', body: message }) } finally { setBusy(false) } @@ -97,33 +106,43 @@ export function SchedulePublishDialog({ open, onClose, rowId, + localeId, currentScheduledAt, entityLabel, onScheduled, }: SchedulePublishDialogProps) { + const { runStepUp } = useStepUp() const initialValue = currentScheduledAt ? new Date(currentScheduledAt) : null const [error, setError] = useState(null) const [busy, setBusy] = useState(false) + const pendingRef = useRef(false) + function setPending(value: boolean) { pendingRef.current = value; setBusy(value) } + function requestClose() { if (!pendingRef.current) onClose() } const isAlreadyScheduled = currentScheduledAt !== null async function handleConfirm(next: Date) { + if (pendingRef.current) return if (next.getTime() <= Date.now()) { setError('Scheduled time must be in the future.') return } - await schedulePublish(rowId, next, setBusy, setError, onScheduled, onClose) + await schedulePublish(rowId, next, setPending, setError, onScheduled, onClose, localeId, runStepUp) } async function handleCancelSchedule() { - await cancelSchedule(rowId, setBusy, setError, onScheduled, onClose) + if (pendingRef.current) return + await cancelSchedule(rowId, setPending, setError, onScheduled, onClose, localeId, runStepUp) } return ( +

This schedules the current draft in the selected language. Later edits remain drafts. An existing published version stays online until the scheduled version replaces it.

{activeSection === 'general' && } + {activeSection === 'languages' && } {activeSection === 'shortcuts' && } {activeSection === 'publishing' && } {activeSection === 'preferences' && } diff --git a/src/admin/modals/Settings/sections/GeneralSection.tsx b/src/admin/modals/Settings/sections/GeneralSection.tsx index ed551d5ec..833412eb7 100644 --- a/src/admin/modals/Settings/sections/GeneralSection.tsx +++ b/src/admin/modals/Settings/sections/GeneralSection.tsx @@ -1,7 +1,8 @@ +import { SourceLocaleNotice, useEditorLocale } from '@site/localization' /** * GeneralSection — site-level metadata. * - * Fields: site name, meta title, meta description, language, favicon (picked + * Fields: site name, meta title, meta description, favicon (picked * from the CMS media library — the same modal Content / Site property * controls use). All changes are persisted immediately to the Zustand store * and ultimately to the CMS draft via the autosave pipeline. @@ -39,6 +40,9 @@ const MediaPickerModal = lazy(() => export function GeneralSection() { const { site, error, updateSiteName, updateSiteSettings } = useSiteSettingsController() + const { isTranslation } = useEditorLocale() + if (isTranslation) return + if (error) { return

{error}

} @@ -105,23 +109,6 @@ export function GeneralSection() { /> - {/* ── Language ──────────────────────────────────────────────────────── */} -
- - - updateSiteSettings({ language: e.target.value.trim() || 'en' }) - } - onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()} - /> -
- {/* ── Favicon ───────────────────────────────────────────────────────── */} +

+ Share your site structure across languages. Translate content, URLs and metadata, + then publish each page or entry independently. New languages start offline. +

+ {loading && !locales &&

Loading languages…

} + {error &&

{error}

} + {locales?.map((locale) => ( + + ))} + {adding ? { setAdding(false); changed() }} onCancel={() => setAdding(false)} /> + : } + + ) +} + +function LanguageForm({ locale, onSaved, onCancel }: { + locale?: Locale + onSaved: () => void + onCancel?: () => void +}) { + const [input, setInput] = useState(() => locale ? { + code: locale.code, name: locale.name, pathPrefix: locale.pathPrefix, + enabled: locale.enabled, direction: locale.direction, + } : { + code: '', name: '', pathPrefix: '', enabled: false, direction: 'ltr', + }) + const [busy, setBusy] = useState(false) + const formId = locale?.id ?? 'new' + + async function save() { + setBusy(true) + try { + if (locale) await updateCmsLocale(locale.id, input) + else await createCmsLocale(input) + onSaved() + pushToast({ kind: 'success', title: locale ? 'Language updated' : 'Language added' }) + } catch (err) { + console.error('[LanguagesSection] failed to save language:', err) + pushToast({ kind: 'error', title: 'Could not save language', body: getErrorMessage(err, 'Unknown language error') }) + } finally { + setBusy(false) + } + } + + return ( + { event.preventDefault(); void save() }}> +

{locale ? `${locale.name}${locale.isDefault ? ' · Source language' : ''}` : 'New language'}

+
+ + + +
+

Runtime diff --git a/src/admin/pages/content/ContentPage.tsx b/src/admin/pages/content/ContentPage.tsx index 95ef66183..76818e029 100644 --- a/src/admin/pages/content/ContentPage.tsx +++ b/src/admin/pages/content/ContentPage.tsx @@ -1,8 +1,7 @@ +import { ConfirmDeleteProvider } from '@admin/shared/dialogs/ConfirmDeleteDialog' +import { useContentPanel } from './hooks/useContentPanel' +import { ContentLanguagesDialog } from '@admin/shared/ContentLanguagesDialog' import { Suspense, lazy, useEffect, useId, useRef, useState } from 'react' -import { - readWorkspaceLayout, - writeWorkspaceLayout, -} from '@admin/state/workspaceLayoutStorage' import { useAdminUi } from '@admin/state/adminUi' import { readTitleCell } from '@core/data/cells' import type { @@ -16,8 +15,7 @@ import { ImagesSolidIcon } from 'pixel-art-icons/icons/images-solid' import { TextPlusIcon } from 'pixel-art-icons/icons/text-plus' import { BracesIcon } from 'pixel-art-icons/icons/braces' import { AdminWorkspaceCanvasLayout } from '@admin/layouts/AdminWorkspaceCanvasLayout' -import { DataBindingPicker } from '@admin/shared/DataBindingPicker' -import { bindingToToken } from '@core/templates/tokenInterpolation' +import { ContentTokenPicker } from './components/ContentTokenPicker/ContentTokenPicker' import { MediaExplorerPanel } from '@site/panels/MediaExplorerPanel' import type { CanvasNotchAction } from '@site/canvas/CanvasNotch' import { ContentDocumentCanvas } from './components/ContentDocumentCanvas/ContentDocumentCanvas' @@ -25,7 +23,7 @@ import { NewTableDialog } from '@admin/pages/data/components/NewTableDialog/NewT import { ContentExplorerPanel } from './components/ContentExplorerPanel/ContentExplorerPanel' import { ContentSettingsPanel } from './components/ContentSettingsPanel/ContentSettingsPanel' import { MediaViewerWindow } from '@admin/pages/media/components/MediaViewerWindow/MediaViewerWindow' -import { ContentSidebar, type ContentPanelId } from './components/ContentSidebar/ContentSidebar' +import { ContentSidebar } from './components/ContentSidebar/ContentSidebar' import { ContentToolbar } from './components/ContentToolbar/ContentToolbar' import type { TiptapBodyEditorHandle } from './TiptapBodyEditor' // Lazy-load the WordPress-style fullscreen media picker. Pulls in the full @@ -38,13 +36,15 @@ const MediaPickerModal = lazy(() => ), ) import { runEntryOp, type EntryOpDeps, type EntryOpOptions } from './utils/entryOp' +import { useContentMoveConfirmation } from './hooks/useContentMoveConfirmation' import { useContentEntryDraft } from './hooks/useContentEntryDraft' import { useContentMediaPicker } from './hooks/useContentMediaPicker' import { useContentWorkspace } from './hooks/useContentWorkspace' -import { publicContentPath } from './utils/contentEntryUtils' import { useAuthenticatedAdminUser } from '@admin/sessionContext' import { StepUpCancelledMessage, useStepUp } from '@admin/shared/StepUp' import { getErrorMessage } from '@core/utils/errorMessage' +import { Select } from '@ui/components/Select' +import { pushToast } from '@ui/components/Toast' import { ContentAgentMount } from './agent/ContentAgentMount' import { useContentToolBridge } from './agent/useContentToolBridge' import { @@ -57,37 +57,19 @@ import { canUseAiChat, } from '@admin/access' -const CONTENT_PANEL_IDS: ReadonlySet = new Set(['content', 'media', 'agent']) - -function readPersistedContentPanel(): ContentPanelId | null { - const stored = readWorkspaceLayout('content').activeLeftPanel - if (stored === null) return null - if (typeof stored === 'string' && CONTENT_PANEL_IDS.has(stored as ContentPanelId)) { - return stored as ContentPanelId - } - return 'content' +export function ContentPage() { + return } -export function ContentPage() { - // Initial value pulls from the per-workspace stored layout so the rail - // remembers the last panel the user had open in the Content workspace. - // First-time visitors fall back to the 'content' panel; an explicit `null` - // (user closed the rail) is preserved. - const [activeContentPanel, setActiveContentPanel] = useState( - readPersistedContentPanel, - ) - // Persist any rail change so the next visit to /admin/content reopens the - // same panel. Effect runs on mount too — that's fine; the value is - // identical to what we just read. - useEffect(() => { - writeWorkspaceLayout('content', { activeLeftPanel: activeContentPanel }) - }, [activeContentPanel]) +function ContentPageBody() { + const [activeContentPanel, setActiveContentPanel] = useContentPanel() const [collectionDialogOpen, setCollectionDialogOpen] = useState(false) // These are monotonic counters used purely as "do the action now" pings: // bumping them re-runs the focus effect inside the canvas / body editor. const [focusTitleSignal, setFocusTitleSignal] = useState(0) const [focusBodySignal, setFocusBodySignal] = useState(0) const [tokenPickerOpen, setTokenPickerOpen] = useState(false) + const [languagesOpen, setLanguagesOpen] = useState(false) // Canvas display mode: 'write' is the bare editor surface, 'live' is // the entry rendered inside its template (real site styles, inline // editing). Switching is purely client-side — the body markdown is the @@ -119,6 +101,7 @@ export function ContentPage() { ? null : activeContentPanel const workspace = useContentWorkspace({ loadAuthors: canReassignAuthor }) + const confirmContentMove = useContentMoveConfirmation() // Collection schema mutations (create/update/delete) are step-up gated on // the server — they change the public route surface — so they must run // through `runStepUp`, which transparently opens the password re-entry @@ -148,9 +131,9 @@ export function ContentPage() { entries: workspace.entries, }) - const publicPath = workspace.selectedCollection && draft.slug - ? publicContentPath(workspace.selectedCollection.routeBase, draft.slug) - : '' + const publicPath = workspace.selectedEntry?.localization?.availability === 'online' + && workspace.locales.some((locale) => locale.id === workspace.activeLocaleId && locale.enabled) + ? workspace.selectedEntry.publicPath ?? '' : '' const canEditSelectedEntry = canEditContentEntry(permissionUser, workspace.selectedEntry) const canMoveRows = canMoveDataRow(permissionUser) const canMoveSelectedEntry = canEditSelectedEntry && canMoveRows @@ -192,6 +175,26 @@ export function ContentPage() { } const SAVE_PHASE = { pending: 'saving', done: 'saved' } as const + async function openLanguages() { + try { + if (draft.isDirty) await draft.saveDraft() + setLanguagesOpen(true) + } catch (err) { + console.error('[ContentPage] failed to save before opening translations:', err) + pushToast({ kind: 'error', title: 'Could not open translations', body: getErrorMessage(err, 'Could not save current draft') }) + } + } + + async function handleLocaleChange(localeId: string) { + try { + if (draft.isDirty) await draft.saveDraft() + workspace.selectLocale(localeId) + } catch (err) { + console.error('[ContentPage] failed to switch language:', err) + pushToast({ kind: 'error', title: 'Could not switch language', body: getErrorMessage(err, 'Could not save the current draft') }) + } + } + function handleCreateEntry() { return withEntryOp(() => workspace.createUntitledEntry(), { permitted: canCreateEntries, @@ -209,13 +212,14 @@ export function ContentPage() { } function handleMoveEntryCollection(tableId: string) { - return withEntryOp(() => workspace.moveSelectedEntryToCollection(tableId), { + if (workspace.selectedEntry?.tableId === tableId) return Promise.resolve() + return confirmContentMove(workspace.selectedEntry, workspace.collections.find((table) => table.id === tableId)?.name ?? 'this collection', () => withEntryOp(() => workspace.moveSelectedEntryToCollection(tableId), { permitted: canMoveSelectedEntry, permMsg: 'Your role cannot move this entry', fallback: 'Could not move entry', phase: SAVE_PHASE, apply: (entry) => { if (entry) draft.applySelectedEntry(entry) }, - }) + })) } function handleUpdateEntryAuthor(authorUserId: string) { @@ -344,17 +348,16 @@ export function ContentPage() { function handleMoveEntryToCollection(entry: DataRow, tableId: string) { if (entry.tableId === tableId) return Promise.resolve() - return withEntryOp(() => workspace.moveEntryToCollection(entry, tableId), { + return confirmContentMove(entry, workspace.collections.find((table) => table.id === tableId)?.name ?? 'this collection', () => withEntryOp(() => workspace.moveEntryToCollection(entry, tableId), { permitted: canMoveRows && canEditContentEntry(permissionUser, entry), permMsg: 'Your role cannot move this entry', fallback: 'Could not move entry', - rethrow: true, apply: (updatedEntry) => { if (workspace.selectedEntry?.id === entry.id) { draft.applySelectedEntry(updatedEntry) } }, - }) + })) } async function handlePublishEntry(entry: DataRow) { @@ -456,9 +459,24 @@ export function ContentPage() { return ( <> + {languagesOpen && workspace.selectedEntry && setLanguagesOpen(false)} onEditLanguage={(id) => void handleLocaleChange(id)} + onUpdated={(row) => { + if (row.localeId === workspace.activeLocaleId) { + workspace.updateSelectedEntry(row) + draft.applySelectedEntry(row) + } + }} />} + setPath(event.target.value)} /> + + + +} + +export function CollectionLanguagesSection({ tableId, routeBase }: { tableId: string; routeBase: string }) { + const resource = useAsyncResource(() => getCmsTableLocalizations(tableId), [tableId]) + function pathFor(locale: Locale, localizations: TableLocalization[]) { + return localizations.find((item) => item.localeId === locale.id)?.routeBase ?? routeBase + } + return
+

Translated URL paths

+

The language prefix is added automatically. Published entries keep their current address until you publish them again.

+ {resource.error &&

{resource.error}

} + {resource.data?.locales.filter((locale) => !locale.isDefault).map((locale) => )} + {resource.data?.locales.length === 1 &&

Add languages in Settings to translate collection addresses.

} +
+} diff --git a/src/admin/pages/content/components/ContentCollectionSettingsDialog/ContentCollectionSettingsDialog.tsx b/src/admin/pages/content/components/ContentCollectionSettingsDialog/ContentCollectionSettingsDialog.tsx index cb88cdc0b..bbcf96d73 100644 --- a/src/admin/pages/content/components/ContentCollectionSettingsDialog/ContentCollectionSettingsDialog.tsx +++ b/src/admin/pages/content/components/ContentCollectionSettingsDialog/ContentCollectionSettingsDialog.tsx @@ -17,6 +17,7 @@ import styles from '../../ContentPage.module.css' import { slugFromTitle } from '@core/utils/slug' import { getErrorMessage } from '@core/utils/errorMessage' import { StepUpCancelledMessage } from '@admin/shared/StepUp' +import { CollectionLanguagesSection } from './CollectionLanguagesSection' interface ContentCollectionSettingsDialogProps { collection: DataTable @@ -235,6 +236,7 @@ export function ContentCollectionSettingsDialog({

)} +

) } diff --git a/src/admin/pages/content/components/ContentExplorerPanel/ContentExplorerPanel.tsx b/src/admin/pages/content/components/ContentExplorerPanel/ContentExplorerPanel.tsx index cb88e4146..44d162ccc 100644 --- a/src/admin/pages/content/components/ContentExplorerPanel/ContentExplorerPanel.tsx +++ b/src/admin/pages/content/components/ContentExplorerPanel/ContentExplorerPanel.tsx @@ -26,7 +26,8 @@ import { type ContentItemRenamePayload, } from '@content/components/ContentItemRenameDialog/ContentItemRenameDialog' import styles from '../../ContentPage.module.css' -import { publicContentPath } from '@content/utils/contentEntryUtils' +import { pushToast } from '@ui/components/Toast' +import { getErrorMessage } from '@core/utils/errorMessage' type ContentExplorerContextTarget = | { kind: 'collection'; collection: DataTable } @@ -39,6 +40,7 @@ interface ContextMenuState { } interface ContentExplorerPanelProps { + languageOnline: boolean loading: boolean error: string | null collections: DataTable[] @@ -99,6 +101,7 @@ function entryAuthorLabel(entry: DataRow): string { } export function ContentExplorerPanel({ + languageOnline, loading, error, collections, @@ -155,13 +158,13 @@ export function ContentExplorerPanel({ } async function copyEntryUrl(entry: DataRow) { - const collection = collectionForEntry(entry) - if (!collection) return - const url = `${window.location.origin}${publicContentPath(collection.routeBase, entry.slug)}` + if (!entry.publicPath) return + const url = `${window.location.origin}${entry.publicPath}` try { await navigator.clipboard.writeText(url) } catch (err) { console.error('[ContentExplorerPanel] copy entry URL error:', err) + pushToast({ kind: 'error', title: 'Could not copy URL', body: getErrorMessage(err, 'Clipboard unavailable') }) } } @@ -211,15 +214,13 @@ export function ContentExplorerPanel({ setContextMenu(null) }, }) - } else { + } + if (languageOnline && target.entry.localization?.availability === 'online' && target.entry.publicPath) { items.push({ label: 'Open in new tab', icon: , action: () => { - const collection = collectionForEntry(target.entry) - if (collection) { - window.open(publicContentPath(collection.routeBase, target.entry.slug), '_blank', 'noopener,noreferrer') - } + if (target.entry.publicPath) window.open(target.entry.publicPath, '_blank', 'noopener,noreferrer') setContextMenu(null) }, }) diff --git a/src/admin/pages/content/components/ContentSettingsPanel/ContentSettingsPanel.tsx b/src/admin/pages/content/components/ContentSettingsPanel/ContentSettingsPanel.tsx index bf89524b6..26caf7a62 100644 --- a/src/admin/pages/content/components/ContentSettingsPanel/ContentSettingsPanel.tsx +++ b/src/admin/pages/content/components/ContentSettingsPanel/ContentSettingsPanel.tsx @@ -244,6 +244,9 @@ export function ContentSettingsPanel({
Public URL {publicPath || 'Not available'} + {selectedEntry?.localization?.availability === 'online' && !selectedEntry.publicPath && ( + This release has no public route. Publish a matching template in this language, then republish the entry. + )}
{selectedEntry && (
diff --git a/src/admin/pages/content/components/ContentTokenPicker/ContentTokenPicker.tsx b/src/admin/pages/content/components/ContentTokenPicker/ContentTokenPicker.tsx new file mode 100644 index 000000000..9e2adb7a6 --- /dev/null +++ b/src/admin/pages/content/components/ContentTokenPicker/ContentTokenPicker.tsx @@ -0,0 +1,39 @@ +import type { RefObject } from 'react' +import type { DataRow } from '@core/data/schemas' +import { DataBindingPicker } from '@admin/shared/DataBindingPicker' +import { bindingToToken } from '@core/templates/tokenInterpolation' +import type { useContentEntryDraft } from '../../hooks/useContentEntryDraft' + +export function ContentTokenPicker({ localeId, tableId, entry, draft, triggerRef, onClose, onInsert }: { + localeId?: string; tableId: string; entry: DataRow | null + draft: ReturnType; triggerRef: RefObject + onClose: () => void; onInsert: (text: string) => void +}) { + return ( + { + onInsert(bindingToToken(binding.source, binding.field)) + }} + /> + ) +} diff --git a/src/admin/pages/content/components/ContentToolbar/ContentToolbar.tsx b/src/admin/pages/content/components/ContentToolbar/ContentToolbar.tsx index b3c15452b..864b8f37b 100644 --- a/src/admin/pages/content/components/ContentToolbar/ContentToolbar.tsx +++ b/src/admin/pages/content/components/ContentToolbar/ContentToolbar.tsx @@ -27,6 +27,7 @@ interface ContentToolbarProps { canPublish: boolean onSaveDraft: () => void onPublish: () => void + onTranslations: () => void onSchedule: (entry: DataRow) => void } @@ -163,6 +164,7 @@ export function ContentToolbar({ onSaveDraft, onPublish, onSchedule, + onTranslations, }: ContentToolbarProps) { const entryLabel = (selectedCollection?.singularLabel ?? 'entry').toLowerCase() // Destructure the derived view state so the JSX below keeps reading like @@ -177,6 +179,15 @@ export function ContentToolbar({ const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false) const menuItems: PublishActionMenuItem[] = [ + ...(isCleanPublished ? [{ + id: 'republish', + label: `Republish ${entryLabel}`, + icon: SendSolidIcon, + disabled: !canPublish || isSaving || isPublishing, + onSelect: onPublish, + }] : []), + { id: 'translations', label: 'Languages and publication…', icon: ExternalLinkSolidIcon, + disabled: !selectedEntry || isSaving || isPublishing, onSelect: onTranslations }, { id: 'save-draft', label: 'Save draft', @@ -229,6 +240,7 @@ export function ContentToolbar({ open={scheduleDialogOpen} onClose={() => setScheduleDialogOpen(false)} rowId={selectedEntry.id} + localeId={selectedEntry.localeId} currentScheduledAt={selectedEntry.scheduledPublishAt} entityLabel={entryLabel} onScheduled={onSchedule} diff --git a/src/admin/pages/content/components/LiveCanvas/LiveCanvas.tsx b/src/admin/pages/content/components/LiveCanvas/LiveCanvas.tsx index 94c48049b..7f26ca317 100644 --- a/src/admin/pages/content/components/LiveCanvas/LiveCanvas.tsx +++ b/src/admin/pages/content/components/LiveCanvas/LiveCanvas.tsx @@ -325,7 +325,7 @@ export function LiveCanvas({ fetchAbortRef.current = controller const cells = { ...entry.cells, title, body: bodyRef.current } - previewCmsDataRow(entry.id, { cells, signal: controller.signal }) + previewCmsDataRow(entry.id, { localeId: entry.localeId, cells, signal: controller.signal }) .then((html) => { if (controller.signal.aborted) return setPreview({ status: 'ready', html, error: null }) @@ -342,7 +342,7 @@ export function LiveCanvas({ window.clearTimeout(fetchDebounceRef.current) } } - }, [entry.id, entry.cells, title]) + }, [entry.id, entry.localeId, entry.cells, title]) // Mount the editor into the iframe once it loads. The handler runs // every time `preview.html` changes (i.e., every iframe reload). We diff --git a/src/admin/pages/content/hooks/__tests__/useContentEntryDraft.test.ts b/src/admin/pages/content/hooks/__tests__/useContentEntryDraft.test.ts index d38fdedc9..0503cefae 100644 --- a/src/admin/pages/content/hooks/__tests__/useContentEntryDraft.test.ts +++ b/src/admin/pages/content/hooks/__tests__/useContentEntryDraft.test.ts @@ -21,6 +21,7 @@ function fakeRow(cells: DataRow['cells']): DataRow { return { id: 'row_1', tableId: 'tbl_posts', + localeId: 'default', sharedCells: {}, localization: null, publicPath: null, seq: 0, cells, slug: typeof cells.slug === 'string' ? cells.slug : '', status: 'draft', @@ -90,6 +91,7 @@ describe('useContentEntryDraft custom cells', () => { await waitFor(() => expect(result.current.saveMessage).toBe('saved')) expect(patchBody).toEqual({ + localeId: 'default', cells: { title: 'Hello', slug: 'hello', @@ -103,3 +105,40 @@ describe('useContentEntryDraft custom cells', () => { expect(updateSelectedEntry).toHaveBeenCalledTimes(1) }) }) + +describe('useContentEntryDraft pending language saves', () => { + it('keeps a newly selected language untouched when an earlier save resolves', async () => { + const source = fakeRow({ title: 'English', slug: 'english', body: 'Source', note: 'English note' }) + const german = { ...fakeRow({ title: 'Deutsch', slug: 'deutsch', body: 'Übersetzung', note: 'Deutsche Notiz' }), localeId: 'de' } + let resolveSave!: (response: Response) => void + spyOn(globalThis, 'fetch').mockImplementation(() => new Promise((resolve) => { resolveSave = resolve })) + const updateSelectedEntry = mock(() => {}) + const setError = mock(() => {}) + const { result, rerender } = renderHook(({ entry }) => useContentEntryDraft({ selectedEntry: entry, updateSelectedEntry, setError }), { initialProps: { entry: source } }) + let saving!: Promise + act(() => { saving = result.current.handleSaveDraft() }) + rerender({ entry: german }) + await act(async () => { resolveSave(new Response(JSON.stringify({ row: source }))); await saving }) + expect(result.current.title).toBe('Deutsch') + expect(result.current.customCells).toEqual({ note: 'Deutsche Notiz' }) + expect(result.current.saveMessage).toBe('idle') + expect(updateSelectedEntry).not.toHaveBeenCalled() + }) + + it('preserves newer typed values while updating the saved baseline for the same language', async () => { + const source = fakeRow({ title: 'Original', slug: 'original', body: 'Body' }) + let resolveSave!: (response: Response) => void + spyOn(globalThis, 'fetch').mockImplementation(() => new Promise((resolve) => { resolveSave = resolve })) + const { result, updateSelectedEntry } = renderDraft(source) + act(() => result.current.setTitle('Sent title')) + let saving!: Promise + act(() => { saving = result.current.handleSaveDraft() }) + act(() => result.current.setTitle('Newer draft title')) + const saved = { ...source, cells: { ...source.cells, title: 'Sent title' } } + await act(async () => { resolveSave(new Response(JSON.stringify({ row: saved }))); await saving }) + expect(result.current.title).toBe('Newer draft title') + expect(result.current.saveMessage).toBe('idle') + expect(result.current.isDirty).toBe(true) + expect(updateSelectedEntry).toHaveBeenCalledWith(saved) + }) +}) diff --git a/src/admin/pages/content/hooks/__tests__/useContentWorkspace.test.ts b/src/admin/pages/content/hooks/__tests__/useContentWorkspace.test.ts index 096044898..30a8a0c3d 100644 --- a/src/admin/pages/content/hooks/__tests__/useContentWorkspace.test.ts +++ b/src/admin/pages/content/hooks/__tests__/useContentWorkspace.test.ts @@ -1,3 +1,4 @@ +import { SOURCE_LOCALE } from '../../../../../__tests__/fixtures/localization' import { afterEach, beforeEach, describe, expect, it } from 'bun:test' import { act, cleanup, renderHook, waitFor } from '@testing-library/react' import type { DataRow } from '@core/data/schemas' @@ -46,6 +47,7 @@ function row(id: string, tableId: string, title: string): DataRow { return { id, tableId, + localeId: SOURCE_LOCALE.id, sharedCells: {}, localization: null, publicPath: null, seq: 0, cells: { title, slug: id }, slug: id, status: 'draft', @@ -70,7 +72,8 @@ describe('useContentWorkspace document navigation', () => { const post = row('post-1', 'posts', 'Post') window.history.replaceState({}, '', '/admin/content?table=posts&row=post-1') globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input) + const url = String(input).split('?')[0] + if (url === '/admin/api/cms/locales') return json({ locales: [SOURCE_LOCALE] }) const method = init?.method ?? 'GET' if (url === '/admin/api/cms/data/tables' && method === 'GET') { return json({ tables: [table('posts', 'Posts')] }) @@ -97,7 +100,8 @@ describe('useContentWorkspace document navigation', () => { }) globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input) + const url = String(input).split('?')[0] + if (url === '/admin/api/cms/locales') return json({ locales: [SOURCE_LOCALE] }) const method = init?.method ?? 'GET' if (url === '/admin/api/cms/data/tables' && method === 'GET') { return json({ tables: [table('posts', 'Posts'), table('articles', 'Articles')] }) diff --git a/src/admin/pages/content/hooks/useContentEntryDraft.ts b/src/admin/pages/content/hooks/useContentEntryDraft.ts index c5ecf5232..180ce0d1e 100644 --- a/src/admin/pages/content/hooks/useContentEntryDraft.ts +++ b/src/admin/pages/content/hooks/useContentEntryDraft.ts @@ -1,4 +1,4 @@ -import { useCallback, useLayoutEffect, useState } from 'react' +import { useCallback, useLayoutEffect, useRef, useState } from 'react' import { publishCmsDataRow, saveCmsDataRowDraft, @@ -50,6 +50,20 @@ export function useContentEntryDraft({ // the same draft lifecycle as the built-ins above. const [customCells, setCustomCells] = useState({}) const [saveMessage, setSaveMessage] = useState('idle') + const latestDraft = useRef({ selectedEntry, title, slug, seoTitle, seoDescription, featuredMediaId, body, customCells }) + useLayoutEffect(() => { + latestDraft.current = { selectedEntry, title, slug, seoTitle, seoDescription, featuredMediaId, body, customCells } + }, [selectedEntry, title, slug, seoTitle, seoDescription, featuredMediaId, body, customCells]) + + const isCurrentSelection = () => latestDraft.current.selectedEntry?.id === selectedEntry?.id && + latestDraft.current.selectedEntry?.localeId === selectedEntry?.localeId + const hasUnchangedFields = () => { + const current = latestDraft.current + return current.title === title && current.slug === slug && current.seoTitle === seoTitle && + current.seoDescription === seoDescription && current.featuredMediaId === featuredMediaId && + current.body === body && current.customCells === customCells + } + // Exception #1: referenced in the useLayoutEffect dep array below, so it // needs a stable identity that react-hooks/exhaustive-deps can see. @@ -71,7 +85,7 @@ export function useContentEntryDraft({ /* eslint-disable react-hooks/set-state-in-effect, react-hooks/exhaustive-deps */ useLayoutEffect(() => { applySelectedEntry(selectedEntry) - }, [applySelectedEntry, selectedEntry?.id]) + }, [applySelectedEntry, selectedEntry?.id, selectedEntry?.localeId]) /* eslint-enable react-hooks/set-state-in-effect, react-hooks/exhaustive-deps */ const applyEntryFields = (entry: DataRow) => { @@ -101,6 +115,7 @@ export function useContentEntryDraft({ const nextTitle = title.trim() || 'Untitled' const nextSlug = slugFromTitle(slug || nextTitle) const row = await saveCmsDataRowDraft(selectedEntry.id, { + localeId: selectedEntry.localeId, cells: { ...selectedEntry.cells, ...customCells, @@ -112,8 +127,10 @@ export function useContentEntryDraft({ seoDescription: seoDescription.trim(), }, }) - updateSelectedEntry(row) - applyEntryFields(row) + if (isCurrentSelection()) { + updateSelectedEntry(row) + if (hasUnchangedFields()) applyEntryFields(row) + } return row } @@ -122,8 +139,9 @@ export function useContentEntryDraft({ setError(null) try { await saveDraft() - setSaveMessage('saved') + if (isCurrentSelection()) setSaveMessage(hasUnchangedFields() ? 'saved' : 'idle') } catch (err) { + if (!isCurrentSelection()) return setSaveMessage('error') setError(getErrorMessage(err, 'Could not save draft')) } @@ -136,16 +154,12 @@ export function useContentEntryDraft({ try { const savedRow = await saveDraft() if (!savedRow) return - const publishedRow = await publishCmsDataRow(savedRow.id) - updateSelectedEntry({ - ...savedRow, - status: publishedRow.status, - updatedAt: publishedRow.updatedAt, - publishedAt: publishedRow.publishedAt, - deletedAt: publishedRow.deletedAt, - }) + const publishedRow = await publishCmsDataRow(savedRow.id, undefined, undefined, savedRow.localeId) + if (!isCurrentSelection()) return + updateSelectedEntry(publishedRow) setSaveMessage('published') } catch (err) { + if (!isCurrentSelection()) return setSaveMessage('error') setError(getErrorMessage(err, 'Could not publish entry')) } @@ -172,11 +186,13 @@ export function useContentEntryDraft({ try { const savedRow = await saveDraft() if (!savedRow) return - const updatedRow = await updateCmsDataRowStatus(savedRow.id, nextStatus) + const updatedRow = await updateCmsDataRowStatus(savedRow.id, nextStatus, undefined, undefined, savedRow.localeId) + if (!isCurrentSelection()) return updateSelectedEntry(updatedRow) - applyEntryFields(updatedRow) + if (hasUnchangedFields()) applyEntryFields(updatedRow) setSaveMessage('idle') } catch (err) { + if (!isCurrentSelection()) return setSaveMessage('error') setError(getErrorMessage(err, 'Could not update entry status')) } @@ -200,6 +216,7 @@ export function useContentEntryDraft({ setBody, setCustomCell, setSaveMessage, + saveDraft, handleSaveDraft, handlePublish, handleStatusChange, diff --git a/src/admin/pages/content/hooks/useContentMoveConfirmation.ts b/src/admin/pages/content/hooks/useContentMoveConfirmation.ts new file mode 100644 index 000000000..c97326bbc --- /dev/null +++ b/src/admin/pages/content/hooks/useContentMoveConfirmation.ts @@ -0,0 +1,26 @@ +import { useConfirmDelete } from '@admin/shared/dialogs/ConfirmDeleteDialog' +import type { DataRow } from '@core/data/schemas' +import { readTitleCell } from '@core/data/cells' +import { getErrorMessage } from '@core/utils/errorMessage' +import { pushToast } from '@ui/components/Toast' + +/** Collection identity is shared: moving content retracts every language and cancels every schedule. */ +export function useContentMoveConfirmation() { + const confirmMoveAction = useConfirmDelete() + return (entry: DataRow | null, collectionName: string, commit: () => Promise): Promise => { + if (!entry) return Promise.resolve() + confirmMoveAction({ + title: `Move “${readTitleCell(entry.cells) || entry.slug || 'Untitled'}” to ${collectionName}?`, + description: 'This moves the entry in every language. All language versions will go offline and every scheduled publication will be cancelled. You can publish each language again in the new collection.', + confirmLabel: 'Move entry', + alwaysConfirm: true, + commit: () => { + void commit().catch((error) => { + console.error('[content] Could not move entry:', error) + pushToast({ kind: 'error', title: 'Could not move entry', body: getErrorMessage(error, 'Move failed') }) + }) + }, + }) + return Promise.resolve() + } +} diff --git a/src/admin/pages/content/hooks/useContentPanel.ts b/src/admin/pages/content/hooks/useContentPanel.ts new file mode 100644 index 000000000..fa256ccb3 --- /dev/null +++ b/src/admin/pages/content/hooks/useContentPanel.ts @@ -0,0 +1,16 @@ +import { useEffect, useState } from 'react' +import { readWorkspaceLayout, writeWorkspaceLayout } from '@admin/state/workspaceLayoutStorage' +import type { ContentPanelId } from '../components/ContentSidebar/ContentSidebar' + +const PANEL_IDS: ReadonlySet = new Set(['content', 'media', 'agent']) +function initialPanel(): ContentPanelId | null { + const stored = readWorkspaceLayout('content').activeLeftPanel + if (stored === null) return null + return typeof stored === 'string' && PANEL_IDS.has(stored as ContentPanelId) ? stored as ContentPanelId : 'content' +} + +export function useContentPanel() { + const [panel, setPanel] = useState(initialPanel) + useEffect(() => { writeWorkspaceLayout('content', { activeLeftPanel: panel }) }, [panel]) + return [panel, setPanel] as const +} diff --git a/src/admin/pages/content/hooks/useContentWorkspace.ts b/src/admin/pages/content/hooks/useContentWorkspace.ts index 8dc0f8ccc..7c3cda76e 100644 --- a/src/admin/pages/content/hooks/useContentWorkspace.ts +++ b/src/admin/pages/content/hooks/useContentWorkspace.ts @@ -32,6 +32,8 @@ import { buildDuplicateRowCells } from '@core/data/duplicateRow' import { updateRowList } from '@content/utils/contentEntryUtils' import { useInitialQueryParams, useUrlQuerySync } from '@admin/lib/urlState' import { getErrorMessage } from '@core/utils/errorMessage' +import { listCmsLocales } from '@core/persistence' +import { useAsyncResource } from '@admin/lib/useAsyncResource' interface UseContentWorkspaceOptions { loadAuthors?: boolean @@ -65,6 +67,9 @@ export function useContentWorkspace({ // one-shot deep-link reads. Held in refs so the deep-link effects read // imperative values rather than reactive state. const initialParams = useInitialQueryParams() + const [requestedLocaleId, setRequestedLocaleId] = useState(() => initialParams.get('localeId')) + const { data: locales, loading: localesLoading, error: localesError } = useAsyncResource(listCmsLocales, []) + const activeLocaleId = requestedLocaleId ?? locales?.find((locale) => locale.isDefault)?.id ?? null const initialTableSlugRef = useRef(initialParams.get('table')) const initialRowIdRef = useRef(initialParams.get('row')) // Prevent the one-shot deep-link from firing more than once per mount. @@ -76,7 +81,7 @@ export function useContentWorkspace({ const entriesLoadEpochRef = useRef(0) const selectedCollection = collections.find((collection) => collection.id === selectedCollectionId) ?? null - const contentLoading = loading || entriesLoading + const contentLoading = loading || entriesLoading || localesLoading // The selection usually holds the stored row, but `createUntitledEntry` // deliberately selects an editor-local *view* of it whose title is blank so @@ -136,6 +141,7 @@ export function useContentWorkspace({ * whatever the author had open and unsaved. */ const applyEntryUpdate = (entry: DataRow) => { + if (selectedEntryRef.current?.localeId !== entry.localeId) return if (selectedEntryRef.current?.id === entry.id) { updateSelectedEntry(entry) return @@ -201,7 +207,7 @@ export function useContentWorkspace({ useEffect(() => { const loadEpoch = ++entriesLoadEpochRef.current - if (!selectedCollectionId) { + if (!selectedCollectionId || !activeLocaleId) { let cancelled = false queueMicrotask(() => { if (!cancelled && loadEpoch === entriesLoadEpochRef.current) { @@ -218,14 +224,14 @@ export function useContentWorkspace({ setEntriesLoading(true) setError(null) try { - const nextEntries = await listCmsDataRows(tableId) + const nextEntries = await listCmsDataRows(tableId, undefined, undefined, activeLocaleId ?? undefined) if (cancelled || loadEpoch !== entriesLoadEpochRef.current) return // A row changed after this request began (for example by an MCP save) // wins over the older list snapshot. Otherwise the response is // authoritative, including when it omits a concurrently deleted row. const current = selectedEntryRef.current const currentIsInTable = current?.tableId === tableId - const currentChangedDuringLoad = currentIsInTable && current !== selectedAtLoadStart + const currentChangedDuringLoad = currentIsInTable && current.localeId === activeLocaleId && current !== selectedAtLoadStart const serverSelected = currentIsInTable ? nextEntries.find((entry) => entry.id === current.id) ?? null : null @@ -254,7 +260,7 @@ export function useContentWorkspace({ void loadEntries() return () => { cancelled = true } - }, [selectedCollectionId]) + }, [selectedCollectionId, activeLocaleId]) // Deep-link effect A: once collections finish loading, resolve ?table= in the // original URL and override the default collection selection if a slug match @@ -312,10 +318,18 @@ export function useContentWorkspace({ { table: selectedCollection?.slug ?? null, row: selectedEntry?.id ?? null, + localeId: activeLocaleId, }, { enabled: !loading }, ) + const selectLocale = (localeId: string) => { + if (localeId === activeLocaleId || !locales?.some((locale) => locale.id === localeId)) return + entriesLoadEpochRef.current += 1 + setEntriesLoading(true) + setRequestedLocaleId(localeId) + } + const selectCollection = (tableId: string) => { if (tableId === selectedCollectionIdRef.current) return entriesLoadEpochRef.current += 1 @@ -326,6 +340,7 @@ export function useContentWorkspace({ const openEntry = (entry: DataRow): boolean => { if (!collections.some((collection) => collection.id === entry.tableId)) return false + if (entry.localeId !== activeLocaleId) selectLocale(entry.localeId) if (entry.tableId !== selectedCollectionIdRef.current) { // Invalidate the previous collection's in-flight list immediately. The @@ -350,6 +365,7 @@ export function useContentWorkspace({ if (!selectedCollection) return null const nextSlug = entries.length === 0 ? 'untitled' : `untitled-${entries.length + 1}` const row = await createCmsDataRow(selectedCollection.id, { + localeId: activeLocaleId ?? undefined, cells: { title: 'Untitled', slug: nextSlug, @@ -373,6 +389,7 @@ export function useContentWorkspace({ const collection = collections.find((candidate) => candidate.id === entry.tableId) if (!collection) throw new Error('Collection not found') const duplicated = await createCmsDataRow(entry.tableId, { + localeId: entry.localeId, cells: buildDuplicateRowCells(collection, entry, entries), }) setEntries((current) => updateRowList(current, duplicated)) @@ -437,6 +454,7 @@ export function useContentWorkspace({ ) => { setError(null) const updatedRow = await saveCmsDataRowDraft(row.id, { + localeId: row.localeId, cells: { ...row.cells, title: input.title, @@ -447,8 +465,7 @@ export function useContentWorkspace({ seoDescription: readSeoDescriptionCell(row.cells), }, }) - setEntries((current) => updateRowList(current, updatedRow)) - if (selectedEntry?.id === row.id) selectEntry(updatedRow) + applyEntryUpdate(updatedRow) return updatedRow } @@ -470,9 +487,8 @@ export function useContentWorkspace({ const publishEntry = async (entry: DataRow) => { setError(null) - const updatedRow = await publishCmsDataRow(entry.id) - setEntries((current) => updateRowList(current, updatedRow)) - if (selectedEntry?.id === entry.id) selectEntry(updatedRow) + const updatedRow = await publishCmsDataRow(entry.id, undefined, undefined, entry.localeId) + applyEntryUpdate(updatedRow) return updatedRow } @@ -484,9 +500,8 @@ export function useContentWorkspace({ status: 'draft' | 'unpublished', ) => { setError(null) - const updatedRow = await updateCmsDataRowStatus(entry.id, status) - setEntries((current) => updateRowList(current, updatedRow)) - if (selectedEntry?.id === entry.id) selectEntry(updatedRow) + const updatedRow = await updateCmsDataRowStatus(entry.id, status, undefined, undefined, entry.localeId) + applyEntryUpdate(updatedRow) return updatedRow } @@ -496,9 +511,8 @@ export function useContentWorkspace({ ) => { if (entry.authorUserId === authorUserId) return entry setError(null) - const updatedRow = await updateCmsDataRowAuthor(entry.id, authorUserId) - setEntries((current) => updateRowList(current, updatedRow)) - if (selectedEntry?.id === entry.id) selectEntry(updatedRow) + const updatedRow = await updateCmsDataRowAuthor(entry.id, authorUserId, undefined, undefined, entry.localeId) + applyEntryUpdate(updatedRow) return updatedRow } @@ -508,7 +522,7 @@ export function useContentWorkspace({ ) => { if (entry.tableId === tableId) return entry setError(null) - const updatedRow = await updateCmsDataRowTable(entry.id, tableId) + const updatedRow = await updateCmsDataRowTable(entry.id, tableId, undefined, undefined, entry.localeId) // Active collection view: the moved entry no longer belongs here. if (entry.tableId === selectedCollectionId) { setEntries((current) => current.filter((candidate) => candidate.id !== entry.id)) @@ -526,7 +540,7 @@ export function useContentWorkspace({ if (!selectedEntry || selectedEntry.tableId === tableId) return selectedEntry setError(null) setEntriesLoading(true) - const entry = await updateCmsDataRowTable(selectedEntry.id, tableId) + const entry = await updateCmsDataRowTable(selectedEntry.id, tableId, undefined, undefined, selectedEntry.localeId) entriesLoadEpochRef.current += 1 selectedCollectionIdRef.current = tableId setSelectedCollectionId(tableId) @@ -536,6 +550,9 @@ export function useContentWorkspace({ } return { + locales: locales ?? [], + activeLocaleId, + selectLocale, tables, collections, refreshCollections, @@ -546,7 +563,7 @@ export function useContentWorkspace({ selectedCollectionId, selectedEntry, contentLoading, - error, + error: error ?? localesError, setError, selectCollection, openEntry, diff --git a/src/admin/pages/content/utils/contentEntryUtils.ts b/src/admin/pages/content/utils/contentEntryUtils.ts index 2937c6fc4..710264909 100644 --- a/src/admin/pages/content/utils/contentEntryUtils.ts +++ b/src/admin/pages/content/utils/contentEntryUtils.ts @@ -12,10 +12,3 @@ export function updateRowList(rows: DataRow[], row: DataRow): DataRow[] { export function mediaTypeFromAsset(asset: CmsMediaAsset): 'image' | 'video' { return asset.mimeType.startsWith('video/') ? 'video' : 'image' } - -export function publicContentPath(routeBase: string, rowSlug: string): string { - const trimmedBase = routeBase.trim() - const withLeadingSlash = trimmedBase.startsWith('/') ? trimmedBase : `/${trimmedBase}` - const normalizedBase = withLeadingSlash.replace(/\/+$/g, '') || '/' - return `${normalizedBase === '/' ? '' : normalizedBase}/${rowSlug}` -} diff --git a/src/admin/pages/dashboard/DashboardPage.tsx b/src/admin/pages/dashboard/DashboardPage.tsx index 3b0a4dae0..625ee3510 100644 --- a/src/admin/pages/dashboard/DashboardPage.tsx +++ b/src/admin/pages/dashboard/DashboardPage.tsx @@ -49,7 +49,6 @@ import { } from '@dnd-kit/core' import { PlusIcon } from 'pixel-art-icons/icons/plus' import { LayoutSolidIcon } from 'pixel-art-icons/icons/layout-solid' -import { ZapSolidIcon } from 'pixel-art-icons/icons/zap-solid' import { ChevronRightIcon } from 'pixel-art-icons/icons/chevron-right' import { AdminPageLayout } from '@admin/layouts/AdminPageLayout' import { useAuthenticatedAdminUser } from '@admin/sessionContext' @@ -71,6 +70,7 @@ import { import { useDashboardWidgets } from './hooks/useDashboardWidgets' import { useOnboardingState } from './hooks/useOnboardingState' import { registerFirstPartyDashboardWidgets } from './widgets' +import { DashboardPublishButton } from './components/DashboardPublishButton' import { OnboardingPanel } from './components/OnboardingPanel' import { BlockLibrary, @@ -502,9 +502,7 @@ export function DashboardPage() { description="Your site at a glance — content, activity, storage and plugins. Configure the grid to surface exactly what you watch." actions={( <> - + diff --git a/src/admin/pages/dashboard/components/DashboardPublishButton.test.tsx b/src/admin/pages/dashboard/components/DashboardPublishButton.test.tsx new file mode 100644 index 000000000..89c9eebd3 --- /dev/null +++ b/src/admin/pages/dashboard/components/DashboardPublishButton.test.tsx @@ -0,0 +1,132 @@ +import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { AdminSessionContext } from '@admin/sessionContext' +import { StepUpContext, type StepUpContextValue, StepUpCancelledMessage } from '@admin/shared/StepUp/StepUpContext' +import { CMS_PUBLICATION_CHANGED_EVENT } from '@admin/state/adminEvents' +import type { CmsCurrentUser } from '@core/persistence' +import type { PublicationOverview } from '@core/localization-schema' +import * as toasts from '@ui/components/Toast' +import { DashboardPublishButton } from './DashboardPublishButton' + +const realFetch = globalThis.fetch +const overview: PublicationOverview = { + locales: [ + { id: 'en', code: 'en', name: 'English', pathPrefix: '', enabled: true, isDefault: true, direction: 'ltr' }, + { id: 'de', code: 'de', name: 'Deutsch', pathPrefix: 'de', enabled: true, isDefault: false, direction: 'ltr' }, + { id: 'fr', code: 'fr', name: 'Français', pathPrefix: 'fr', enabled: false, isDefault: false, direction: 'ltr' }, + ], + variants: [ + { rowId: 'home', localeId: 'en', title: 'Home', slug: 'index', isTemplate: false, availability: 'offline', scheduledPublishAt: null, publicPath: null }, + { rowId: 'home', localeId: 'de', title: 'Startseite', slug: 'index', isTemplate: false, availability: 'offline', scheduledPublishAt: null, publicPath: null }, + { rowId: 'home', localeId: 'fr', title: 'Accueil', slug: 'index', isTemplate: false, availability: 'offline', scheduledPublishAt: null, publicPath: null }, + ], +} + +function renderButton(canPublish = true, runStepUp: StepUpContextValue['runStepUp'] = (action) => action()) { + const user = { capabilities: canPublish ? ['pages.publish'] : [] } as CmsCurrentUser + return render( {} }}> + + ) +} + +afterEach(() => { + cleanup() + globalThis.fetch = realFetch +}) + +describe('Dashboard language publication', () => { + it('offers publication only to a user with the publication capability', () => { + renderButton(false) + expect(screen.queryByRole('button', { name: /publish/i })).toBeNull() + }) + + it('publishes only the explicitly selected language through step-up and refreshes dashboard data', async () => { + const bodies: unknown[] = [] + globalThis.fetch = (async (_url, init) => { + if (init?.method === 'POST') { + bodies.push(JSON.parse(String(init.body))) + return Response.json({ publishedPages: 1 }) + } + return Response.json(overview) + }) as typeof fetch + const stepUp = mock(async (action: () => Promise) => action()) + const changed = mock(() => {}) + const toast = spyOn(toasts, 'pushToast').mockReturnValue('test-toast') + window.addEventListener(CMS_PUBLICATION_CHANGED_EVENT, changed) + try { + renderButton(true, stepUp) + fireEvent.click(screen.getByRole('button', { name: 'Publish pages…' })) + const target = await screen.findByRole('checkbox', { name: /Startseite/ }) + expect((screen.getByRole('checkbox', { name: /Accueil/ }) as HTMLInputElement).disabled).toBe(true) + expect(bodies).toEqual([]) + fireEvent.click(target) + await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Publish 1 version' }))) + await waitFor(() => expect(bodies).toEqual([{ variants: [{ rowId: 'home', localeId: 'de' }] }])) + expect(stepUp).toHaveBeenCalledTimes(1) + expect(changed).toHaveBeenCalledTimes(1) + expect(toast).toHaveBeenCalledWith({ kind: 'success', title: 'Selected page versions published' }) + expect(screen.queryByRole('dialog')).toBeNull() + } finally { + window.removeEventListener(CMS_PUBLICATION_CHANGED_EVENT, changed) + toast.mockRestore() + } + }) + + it('keeps the selection open and reports a failed publication', async () => { + globalThis.fetch = (async (_url, init) => init?.method === 'POST' + ? Response.json({ error: 'Language route conflicts with another page.' }, { status: 409 }) + : Response.json(overview)) as typeof fetch + const toast = spyOn(toasts, 'pushToast').mockReturnValue('test-toast') + const errorLog = spyOn(console, 'error').mockImplementation(() => {}) + try { + renderButton() + fireEvent.click(screen.getByRole('button', { name: 'Publish pages…' })) + fireEvent.click(await screen.findByRole('checkbox', { name: /Startseite/ })) + await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Publish 1 version' }))) + await waitFor(() => expect(toast).toHaveBeenCalledWith({ kind: 'error', title: 'Publish failed', body: 'Language route conflicts with another page.' })) + expect(screen.getByRole('dialog')).toBeDefined() + } finally { + toast.mockRestore() + errorLog.mockRestore() + } + }) + + it('lets a template publish independently and identifies that it has no direct URL', async () => { + const bodies: unknown[] = [] + globalThis.fetch = (async (_url, init) => { + if (init?.method === 'POST') { + bodies.push(JSON.parse(String(init.body))) + return Response.json({ publishedPages: 0 }) + } + return Response.json({ ...overview, variants: [...overview.variants, + { rowId: 'post-template', localeId: 'en', title: 'Post template', slug: 'post-template', isTemplate: true, availability: 'offline', scheduledPublishAt: null, publicPath: null }, + ] }) + }) as typeof fetch + renderButton() + fireEvent.click(screen.getByRole('button', { name: 'Publish pages…' })) + const template = await screen.findByRole('checkbox', { name: /Post template/ }) + expect(screen.getByText('Template · no direct URL')).toBeDefined() + expect(screen.queryByText('/post-template')).toBeNull() + fireEvent.click(template) + await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Publish 1 version' }))) + await waitFor(() => expect(bodies).toEqual([{ variants: [{ rowId: 'post-template', localeId: 'en' }] }])) + }) + + it('retains the selection when password confirmation is cancelled without publishing', async () => { + const writes = mock(() => {}) + globalThis.fetch = (async (_url, init) => { + if (init?.method === 'POST') writes() + return Response.json(overview) + }) as typeof fetch + const toast = spyOn(toasts, 'pushToast').mockReturnValue('test-toast') + try { + renderButton(true, async () => { throw new Error(StepUpCancelledMessage) }) + fireEvent.click(screen.getByRole('button', { name: 'Publish pages…' })) + fireEvent.click(await screen.findByRole('checkbox', { name: /Startseite/ })) + await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Publish 1 version' }))) + expect(writes).not.toHaveBeenCalled() + expect(toast).not.toHaveBeenCalled() + expect(screen.getByRole('dialog')).toBeDefined() + } finally { toast.mockRestore() } + }) +}) diff --git a/src/admin/pages/dashboard/components/DashboardPublishButton.tsx b/src/admin/pages/dashboard/components/DashboardPublishButton.tsx new file mode 100644 index 000000000..9b560b250 --- /dev/null +++ b/src/admin/pages/dashboard/components/DashboardPublishButton.tsx @@ -0,0 +1,46 @@ +import { useState } from 'react' +import { hasCapability } from '@admin/access' +import { useCurrentAdminUser } from '@admin/sessionContext' +import { notifyCmsPublicationChanged } from '@admin/state/adminEvents' +import { SitePublishDialog } from '@admin/shared/SitePublishDialog' +import { StepUpCancelledMessage, useStepUp } from '@admin/shared/StepUp' +import type { PublishVariantSelection } from '@core/localization-schema' +import { publishCmsDraft } from '@core/persistence' +import { getErrorMessage } from '@core/utils/errorMessage' +import { Button } from '@ui/components/Button' +import { pushToast } from '@ui/components/Toast' +import { CloudUploadSolidIcon } from 'pixel-art-icons/icons/cloud-upload-solid' + +export function DashboardPublishButton() { + const user = useCurrentAdminUser() + const { runStepUp } = useStepUp() + const [open, setOpen] = useState(false) + const [busy, setBusy] = useState(false) + const canPublish = hasCapability(user, 'pages.publish') + + async function publish(selection: PublishVariantSelection): Promise { + if (!canPublish || busy) return false + setBusy(true) + try { + await runStepUp(() => publishCmsDraft(undefined, undefined, selection)) + notifyCmsPublicationChanged() + pushToast({ kind: 'success', title: 'Selected page versions published' }) + return true + } catch (err) { + if (err instanceof Error && err.message === StepUpCancelledMessage) return false + console.error('[DashboardPublishButton] Publication failed:', err) + pushToast({ kind: 'error', title: 'Publish failed', body: getErrorMessage(err, 'Unable to publish the selected page versions.') }) + return false + } finally { + setBusy(false) + } + } + + if (!canPublish) return null + return <> + + {open && setOpen(false)} onPublish={publish} />} + +} diff --git a/src/admin/pages/dashboard/hooks/useDashboardStats.ts b/src/admin/pages/dashboard/hooks/useDashboardStats.ts index 93b30732b..3d46fd440 100644 --- a/src/admin/pages/dashboard/hooks/useDashboardStats.ts +++ b/src/admin/pages/dashboard/hooks/useDashboardStats.ts @@ -37,6 +37,9 @@ * mounts, do it module-level so multiple sibling widgets that share a * domain (none today) reuse one fetch. */ +import { useEffect } from 'react' +import { DashboardPagesStatsSchema, DashboardPostsStatsSchema, DashboardPublishLineupStatsSchema, type DashboardPagesStats, type DashboardPostsStats, type DashboardPublishLineupStats } from '@core/dashboard' +import { CMS_PUBLICATION_CHANGED_EVENT, CMS_SITE_RELOAD_EVENT } from '@admin/state/adminEvents' import type { TSchema, TProperties } from '@sinclair/typebox' import { Type, type Static } from '@core/utils/typeboxHelpers' import { apiRequest } from '@core/http' @@ -83,22 +86,6 @@ const DashboardPluginRowSchema = looseObject({ }) export type DashboardPluginRow = Static -const DashboardPublishLineupRowSchema = looseObject({ - id: Type.String(), - /** Public path (`/blog/sandbox-deep-dive`). */ - path: Type.String(), - status: Type.Union([Type.Literal('scheduled'), Type.Literal('published'), Type.Literal('draft')]), - /** - * ISO datetime relevant to the status: - * - scheduled → future scheduled_publish_at - * - published → past published_at - * - draft → null - * The widget renders this as a relative-time label client-side. - */ - at: Type.Union([Type.String(), Type.Null()]), -}) -export type DashboardPublishLineupRow = Static - const DashboardActivityActorSchema = looseObject({ displayName: Type.String(), email: Type.String(), @@ -116,23 +103,6 @@ const DashboardActivityEntrySchema = looseObject({ }) export type DashboardActivityEntry = Static -const DashboardPagesStatsSchema = looseObject({ - total: Type.Number(), - published: Type.Number(), - drafts: Type.Number(), - scheduled: Type.Number(), - deltaPublishedThisWeek: Type.Number(), -}) -type DashboardPagesStats = Static - -const DashboardPostsStatsSchema = looseObject({ - total: Type.Number(), - categories: Type.Number(), - scheduled: Type.Number(), - daily28: Type.Array(Type.Number()), -}) -type DashboardPostsStats = Static - const DashboardMediaStatsSchema = looseObject({ count: Type.Number(), totalBytes: Type.Number(), @@ -149,11 +119,6 @@ const DashboardPluginsStatsSchema = looseObject({ }) type DashboardPluginsStats = Static -const DashboardPublishLineupStatsSchema = looseObject({ - rows: Type.Array(DashboardPublishLineupRowSchema), -}) -type DashboardPublishLineupStats = Static - /** * Storage widget payload. Mirrors `StorageStats` on the server (see * `server/handlers/cms/dashboard.ts`). All byte counts are raw integers; @@ -197,7 +162,7 @@ type DashboardActivityStats = Static */ function useDashboardEndpoint(endpoint: string, schema: S): Static | null { const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone - return useAsyncResource( + const { data, refresh } = useAsyncResource( (signal) => apiRequest(`/admin/api/cms/dashboard/${endpoint}`, { schema, @@ -206,7 +171,16 @@ function useDashboardEndpoint(endpoint: string, schema: S): S }), [endpoint, schema, timeZone], { swallowErrors: true }, - ).data + ) + useEffect(() => { + window.addEventListener(CMS_PUBLICATION_CHANGED_EVENT, refresh) + window.addEventListener(CMS_SITE_RELOAD_EVENT, refresh) + return () => { + window.removeEventListener(CMS_PUBLICATION_CHANGED_EVENT, refresh) + window.removeEventListener(CMS_SITE_RELOAD_EVENT, refresh) + } + }, [refresh]) + return data } // --------------------------------------------------------------------------- diff --git a/src/admin/pages/dashboard/widgets/PagesWidget.tsx b/src/admin/pages/dashboard/widgets/PagesWidget.tsx index e08777d77..aa54a315e 100644 --- a/src/admin/pages/dashboard/widgets/PagesWidget.tsx +++ b/src/admin/pages/dashboard/widgets/PagesWidget.tsx @@ -34,7 +34,7 @@ export function PagesWidget({ span, editing }: DashboardWidgetRendererProps) { value={stats.published.toLocaleString()} sub={( <> - Published + Online language versions {stats.deltaPublishedThisWeek > 0 && ( +{stats.deltaPublishedThisWeek} this week )} @@ -42,7 +42,10 @@ export function PagesWidget({ span, editing }: DashboardWidgetRendererProps) { )} />
- {stats.drafts} draft{stats.drafts === 1 ? '' : 's'} + {stats.total} page{stats.total === 1 ? '' : 's'} · {stats.variants} language version{stats.variants === 1 ? '' : 's'} +
+
+ {stats.drafts} draft{stats.drafts === 1 ? '' : 's'} · {stats.offline} offline {stats.scheduled} scheduled
diff --git a/src/admin/pages/dashboard/widgets/PostsWidget.tsx b/src/admin/pages/dashboard/widgets/PostsWidget.tsx index 0a2d5ee7c..86df2b81e 100644 --- a/src/admin/pages/dashboard/widgets/PostsWidget.tsx +++ b/src/admin/pages/dashboard/widgets/PostsWidget.tsx @@ -12,6 +12,7 @@ import { Bars, StatValue } from '@ui/components/charts' import type { DashboardWidgetRendererProps } from '@core/dashboard' import { Widget } from '@ui/components/Widget' import { usePostsStats } from '../hooks/useDashboardStats' +import styles from './widgets.module.css' // Last 6 days of the histogram are highlighted as the "current week". const ACCENT_INDEXES = [22, 23, 24, 25, 26, 27] @@ -39,6 +40,10 @@ export function PostsWidget({ span, editing }: DashboardWidgetRendererProps) { )} /> +
+ {stats.variants} language version{stats.variants === 1 ? '' : 's'} + {stats.scheduled} scheduled +
)} diff --git a/src/admin/pages/dashboard/widgets/PublishQueueWidget.tsx b/src/admin/pages/dashboard/widgets/PublishQueueWidget.tsx index 686e99f22..37d0230cf 100644 --- a/src/admin/pages/dashboard/widgets/PublishQueueWidget.tsx +++ b/src/admin/pages/dashboard/widgets/PublishQueueWidget.tsx @@ -11,13 +11,10 @@ * orders and limits the list so the widget just renders. */ import { CloudUploadSolidIcon } from 'pixel-art-icons/icons/cloud-upload-solid' -import type { DashboardWidgetRendererProps } from '@core/dashboard' +import type { DashboardWidgetRendererProps, DashboardPublishLineupRow } from '@core/dashboard' import { Widget } from '@ui/components/Widget' import { cn } from '@ui/cn' -import { - usePublishLineupStats, - type DashboardPublishLineupRow, -} from '../hooks/useDashboardStats' +import { usePublishLineupStats } from '../hooks/useDashboardStats' import styles from './widgets.module.css' function badgeClass(status: DashboardPublishLineupRow['status']): string { @@ -28,8 +25,8 @@ function badgeClass(status: DashboardPublishLineupRow['status']): string { function badgeLabel(status: DashboardPublishLineupRow['status']): string { if (status === 'scheduled') return 'scheduled' - if (status === 'published') return 'published' - return 'draft' + if (status === 'published') return 'online' + return status } /** @@ -97,12 +94,15 @@ export function PublishQueueWidget({ span, editing }: DashboardWidgetRendererPro {!isLoading && !isEmpty && (
    {rows.map((r) => ( -
  • - - {r.path} +
  • + + {r.title || 'Untitled'} + {r.path && {r.path}} - {badgeLabel(r.status)} + {r.localeCode} + {badgeLabel(r.status)} + {!r.localeEnabled && Language offline} {formatRelative(r.at)}
  • diff --git a/src/admin/pages/dashboard/widgets/localizedWidgets.test.tsx b/src/admin/pages/dashboard/widgets/localizedWidgets.test.tsx new file mode 100644 index 000000000..c788e0eba --- /dev/null +++ b/src/admin/pages/dashboard/widgets/localizedWidgets.test.tsx @@ -0,0 +1,35 @@ +import { afterEach, expect, it } from 'bun:test' +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { notifyCmsPublicationChanged } from '@admin/state/adminEvents' +import { PagesWidget } from './PagesWidget' +import { PostsWidget } from './PostsWidget' +import { PublishQueueWidget } from './PublishQueueWidget' + +const realFetch = globalThis.fetch +afterEach(() => { cleanup(); globalThis.fetch = realFetch }) + +it('distinguishes logical totals and language versions, shows frozen addresses, and refreshes after publication', async () => { + let loads = 0 + globalThis.fetch = (async (input) => { + loads++ + const path = new URL(String(input), 'http://localhost').pathname + if (path.endsWith('/pages')) return Response.json({ total: 2, variants: 4, published: 1, drafts: 2, offline: 1, scheduled: 1, deltaPublishedThisWeek: 1 }) + if (path.endsWith('/posts')) return Response.json({ total: 3, variants: 5, categories: 1, scheduled: 2, daily28: Array(28).fill(0) }) + return Response.json({ rows: [ + { id: 'home', localeId: 'en', localeCode: 'en', localeEnabled: true, title: 'Home', path: '/released-home', status: 'published', at: null }, + { id: 'home', localeId: 'de', localeCode: 'de', localeEnabled: false, title: 'Startseite', path: '/de/geplant', status: 'scheduled', at: null }, + { id: 'home', localeId: 'de', localeCode: 'de', localeEnabled: false, title: 'Startseite', path: null, status: 'offline', at: null }, + ] }) + }) as typeof fetch + render(<>) + await screen.findByText('/released-home') + expect(screen.getByText('/de/geplant')).toBeDefined() + expect(screen.getByText('2 pages · 4 language versions')).toBeDefined() + expect(screen.getByText('Online language versions')).toBeDefined() + expect(screen.getByText('5 language versions')).toBeDefined() + expect(screen.getAllByText('Language offline')).toHaveLength(2) + expect(screen.getAllByRole('listitem')).toHaveLength(3) + await waitFor(() => expect(loads).toBe(3)) + await act(async () => notifyCmsPublicationChanged()) + await waitFor(() => expect(loads).toBe(6)) +}) diff --git a/src/admin/pages/dashboard/widgets/widgets.module.css b/src/admin/pages/dashboard/widgets/widgets.module.css index a912af426..c9dd4503c 100644 --- a/src/admin/pages/dashboard/widgets/widgets.module.css +++ b/src/admin/pages/dashboard/widgets/widgets.module.css @@ -64,6 +64,19 @@ color: var(--text-disabled); } +.lineupTitle { + display: flex; + flex-direction: column; + gap: var(--space-3xs); + overflow: hidden; +} + +.lineupTitle > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .deltaSpacing { margin-left: var(--space-s); } diff --git a/src/admin/pages/data/components/DataGrid/cells/CellDisplayRenderer.tsx b/src/admin/pages/data/components/DataGrid/cells/CellDisplayRenderer.tsx index 9f6c06615..cc2f56935 100644 --- a/src/admin/pages/data/components/DataGrid/cells/CellDisplayRenderer.tsx +++ b/src/admin/pages/data/components/DataGrid/cells/CellDisplayRenderer.tsx @@ -440,6 +440,8 @@ export function CellDisplayRenderer({ if (!tree) return return Page tree } + case 'parameterValues': + return Component defaults case 'fieldSchema': { const params = readFieldSchemaCell(cells, field.id) if (params.length === 0) return diff --git a/src/admin/pages/data/components/DataGrid/cells/CellEditorRenderer.tsx b/src/admin/pages/data/components/DataGrid/cells/CellEditorRenderer.tsx index 387feba29..bafcf9368 100644 --- a/src/admin/pages/data/components/DataGrid/cells/CellEditorRenderer.tsx +++ b/src/admin/pages/data/components/DataGrid/cells/CellEditorRenderer.tsx @@ -100,6 +100,8 @@ export function CellEditorRenderer({ case 'fieldSchema': return + case 'parameterValues': + return Edit component defaults in the Site editor default: { // Exhaustive check: TypeScript will error here if a new field type diff --git a/src/admin/pages/data/components/DataInspector/fieldGuards.ts b/src/admin/pages/data/components/DataInspector/fieldGuards.ts index c5d33f979..c71c9c4ce 100644 --- a/src/admin/pages/data/components/DataInspector/fieldGuards.ts +++ b/src/admin/pages/data/components/DataInspector/fieldGuards.ts @@ -33,6 +33,7 @@ export const FIELD_TYPE_LABELS: Record = { repeater: 'Repeater', pageTree: 'Page tree', fieldSchema: 'Field schema', + parameterValues: 'Component defaults', } export function isMandatoryField(fieldId: string): boolean { diff --git a/src/admin/pages/data/components/NewFieldDialog/FieldAuthoringSettings.tsx b/src/admin/pages/data/components/NewFieldDialog/FieldAuthoringSettings.tsx new file mode 100644 index 000000000..b5430e10b --- /dev/null +++ b/src/admin/pages/data/components/NewFieldDialog/FieldAuthoringSettings.tsx @@ -0,0 +1,59 @@ +import { useId } from 'react' +import type { FieldLocalization } from '@core/localization-schema' +import { Textarea } from '@ui/components/Input' +import { Select } from '@ui/components/Select' +import { Switch } from '@ui/components/Switch' +import styles from './NewFieldDialog.module.css' + +export function FieldAuthoringSettings({ description, required, localization, localizationLocked, onDescriptionChange, onRequiredChange, onLocalizationChange }: { + description: string; required: boolean; localization: FieldLocalization; localizationLocked: boolean + onDescriptionChange: (value: string) => void; onRequiredChange: (value: boolean) => void + onLocalizationChange: (value: FieldLocalization) => void +}) { + const formId = useId() + const descriptionInputId = useId() + return ( +
    +
    +

    Authoring

    +

    Set the expectations and guidance shown when someone edits a record.

    +
    + +
    + +