From ee7a15fbd6c8f715c0767056c8ad1781af6d5688 Mon Sep 17 00:00:00 2001 From: NGPixel Date: Sat, 25 Jul 2026 14:09:48 +0000 Subject: [PATCH] refactor: migrate backend to Typescript + other modernization --- .devcontainer/Dockerfile | 28 +- .devcontainer/app-init.sh | 2 +- .devcontainer/devcontainer-lock.json | 24 + .devcontainer/devcontainer.json | 7 +- .devcontainer/docker-compose.yml | 8 +- .oxfmtrc.json | 10 +- .vscode/settings.json | 5 + CLAUDE.md | 274 ++++++++++ README.md | 2 +- .../{authentication.js => authentication.ts} | 30 +- backend/api/groups.ts | 467 ++++++++++++++++++ backend/api/index.js | 18 - backend/api/index.ts | 22 + backend/api/locales.js | 24 - backend/api/locales.ts | 34 ++ backend/api/{pages.js => pages.ts} | 19 +- backend/api/schemas/group.ts | 136 +++++ backend/api/schemas/{site.js => site.ts} | 4 +- backend/api/schemas/{user.js => user.ts} | 11 +- backend/api/{sites.js => sites.ts} | 32 +- backend/api/{system.js => system.ts} | 27 +- backend/api/{users.js => users.ts} | 22 +- backend/controllers/site.js | 61 --- backend/controllers/site.ts | 65 +++ backend/core/{config.js => config.ts} | 41 +- backend/core/{db.js => db.ts} | 74 +-- backend/core/{logger.js => logger.ts} | 32 +- backend/core/{scheduler.js => scheduler.ts} | 149 +++--- backend/db/{relations.js => relations.ts} | 2 +- backend/db/{schema.js => schema.ts} | 6 +- backend/helpers/common.js | 138 ------ backend/helpers/common.ts | 183 +++++++ backend/helpers/config.js | 21 - backend/helpers/config.ts | 24 + backend/{index.js => index.ts} | 89 ++-- backend/locales/metadata.d.ts | 26 + .../{authentication.js => authentication.ts} | 41 +- backend/models/groups.js | 59 --- backend/models/groups.ts | 302 +++++++++++ backend/models/index.js | 19 - backend/models/index.ts | 19 + backend/models/{jobs.js => jobs.ts} | 32 +- backend/models/{locales.js => locales.ts} | 25 +- backend/models/{sessions.js => sessions.ts} | 31 +- backend/models/{settings.js => settings.ts} | 19 +- backend/models/{sites.js => sites.ts} | 34 +- backend/models/types.ts | 15 + backend/models/{users.js => users.ts} | 131 ++++- .../{authentication.js => authentication.ts} | 11 +- backend/package-lock.json | 467 +++++++++++++++++- backend/package.json | 73 +-- .../{check-version.js => check-version.ts} | 10 +- ...an-job-history.js => clean-job-history.ts} | 4 +- .../{update-locales.js => update-locales.ts} | 16 +- .../{purge-uploads.js => purge-uploads.ts} | 11 +- backend/tsconfig.json | 37 ++ backend/types/fastify.d.ts | 38 ++ backend/types/global.d.ts | 79 +++ backend/{worker.js => worker.ts} | 14 +- dev/build/Dockerfile | 4 +- frontend/package.json | 3 +- 61 files changed, 2851 insertions(+), 760 deletions(-) create mode 100644 .devcontainer/devcontainer-lock.json create mode 100644 CLAUDE.md rename backend/api/{authentication.js => authentication.ts} (84%) create mode 100644 backend/api/groups.ts delete mode 100644 backend/api/index.js create mode 100644 backend/api/index.ts delete mode 100644 backend/api/locales.js create mode 100644 backend/api/locales.ts rename backend/api/{pages.js => pages.ts} (83%) create mode 100644 backend/api/schemas/group.ts rename backend/api/schemas/{site.js => site.ts} (97%) rename backend/api/schemas/{user.js => user.ts} (72%) rename backend/api/{sites.js => sites.ts} (87%) rename backend/api/{system.js => system.ts} (87%) rename backend/api/{users.js => users.ts} (87%) delete mode 100644 backend/controllers/site.js create mode 100644 backend/controllers/site.ts rename backend/core/{config.js => config.ts} (75%) rename backend/core/{db.js => db.ts} (74%) rename backend/core/{logger.js => logger.ts} (56%) rename backend/core/{scheduler.js => scheduler.ts} (67%) rename backend/db/{relations.js => relations.ts} (91%) rename backend/db/{schema.js => schema.ts} (97%) delete mode 100644 backend/helpers/common.js create mode 100644 backend/helpers/common.ts delete mode 100644 backend/helpers/config.js create mode 100644 backend/helpers/config.ts rename backend/{index.js => index.ts} (81%) create mode 100644 backend/locales/metadata.d.ts rename backend/models/{authentication.js => authentication.ts} (72%) delete mode 100644 backend/models/groups.js create mode 100644 backend/models/groups.ts delete mode 100644 backend/models/index.js create mode 100644 backend/models/index.ts rename backend/models/{jobs.js => jobs.ts} (53%) rename backend/models/{locales.js => locales.ts} (84%) rename backend/models/{sessions.js => sessions.ts} (68%) rename backend/models/{settings.js => settings.ts} (88%) rename backend/models/{sites.js => sites.ts} (87%) create mode 100644 backend/models/types.ts rename backend/models/{users.js => users.ts} (65%) rename backend/modules/authentication/local/{authentication.js => authentication.ts} (70%) rename backend/tasks/simple/{check-version.js => check-version.ts} (67%) rename backend/tasks/simple/{clean-job-history.js => clean-job-history.ts} (81%) rename backend/tasks/simple/{update-locales.js => update-locales.ts} (82%) rename backend/tasks/workers/{purge-uploads.js => purge-uploads.ts} (61%) create mode 100644 backend/tsconfig.json create mode 100644 backend/types/fastify.d.ts create mode 100644 backend/types/global.d.ts rename backend/{worker.js => worker.ts} (83%) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 835dea99..30acb08a 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,36 +1,12 @@ # Based of https://github.com/devcontainers/images/blob/main/src/javascript-node/.devcontainer/Dockerfile -ARG VARIANT=24-bookworm +ARG VARIANT=26 FROM node:${VARIANT} ARG USERNAME=node -ARG NPM_GLOBAL=/usr/local/share/npm-global ENV DEBIAN_FRONTEND=noninteractive -# Add NPM global to PATH. -ENV PATH=${NPM_GLOBAL}/bin:${PATH} - -RUN \ - # Configure global npm install location, use group to adapt to UID/GID changes - if ! cat /etc/group | grep -e "^npm:" > /dev/null 2>&1; then groupadd -r npm; fi \ - && usermod -a -G npm ${USERNAME} \ - && umask 0002 \ - && mkdir -p ${NPM_GLOBAL} \ - && touch /usr/local/etc/npmrc \ - && chown ${USERNAME}:npm ${NPM_GLOBAL} /usr/local/etc/npmrc \ - && chmod g+s ${NPM_GLOBAL} \ - && npm config -g set prefix ${NPM_GLOBAL} \ - && su ${USERNAME} -c "npm config -g set prefix ${NPM_GLOBAL}" \ - # Install eslint - && su ${USERNAME} -c "umask 0002 && npm install -g eslint" \ - && npm cache clean --force > /dev/null 2>&1 - -# Enable PNPM -ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 -RUN corepack enable \ - && corepack prepare pnpm@latest --activate - EXPOSE 3000 # Install the packages we need @@ -50,8 +26,6 @@ RUN apt-get update && apt-get install -qy \ # avoid million NPM install messages ENV npm_config_loglevel=warn -# allow installing when the main user is root -ENV npm_config_unsafe_perm=true # disable NPM funding messages ENV npm_config_fund=false diff --git a/.devcontainer/app-init.sh b/.devcontainer/app-init.sh index 004cf786..9959ad8f 100644 --- a/.devcontainer/app-init.sh +++ b/.devcontainer/app-init.sh @@ -17,7 +17,7 @@ cd ../frontend npm install cd ../blocks npm install -npm build +npm run build cd .. echo "Ready!" diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 00000000..8d9c2cd2 --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,24 @@ +{ + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "version": "2.5.9", + "resolved": "ghcr.io/devcontainers/features/common-utils@sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a", + "integrity": "sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a" + }, + "ghcr.io/devcontainers/features/git:1": { + "version": "1.3.8", + "resolved": "ghcr.io/devcontainers/features/git@sha256:fd75977de13a9979000e0e78baf949adb0ca71d2398995fa22e0a36d7e7e7fe2", + "integrity": "sha256:fd75977de13a9979000e0e78baf949adb0ca71d2398995fa22e0a36d7e7e7fe2" + }, + "ghcr.io/devcontainers/features/node:1": { + "version": "1.7.1", + "resolved": "ghcr.io/devcontainers/features/node@sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6", + "integrity": "sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6" + }, + "ghcr.io/joedmck/devcontainer-features/cloudflared:1": { + "version": "1.0.2", + "resolved": "ghcr.io/joedmck/devcontainer-features/cloudflared@sha256:128d55c58d58b2b78dcb3e60557b8ce0ffb56312b0c02bebfbffbafa122839b1", + "integrity": "sha256:128d55c58d58b2b78dcb3e60557b8ce0ffb56312b0c02bebfbffbafa122839b1" + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index d49ac03b..f0804dc2 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -15,7 +15,6 @@ }, "extensions": [ "arcanis.vscode-zipfs", - "dbaeumer.vscode-eslint", "eamodio.gitlens", "Vue.volar", "oxc.oxc-vscode", @@ -66,7 +65,11 @@ "upgradePackages": "true" }, "ghcr.io/devcontainers/features/node:1": { - "version": "24" + "nodeGypDependencies": true, + "version": "26", + "npmVersion": "none", + "pnpmVersion": "none", + "nvmVersion": "latest" }, "ghcr.io/devcontainers/features/git:1": {}, "ghcr.io/joedmck/devcontainer-features/cloudflared:1": {} diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index c81b6a68..028a007e 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -7,7 +7,7 @@ services: # Update 'VARIANT' to pick an LTS version of Node.js: 18, 16, 14, 12. # Append -bullseye or -buster to pin to an OS version. # Use -bullseye variants on local arm64/Apple Silicon. - VARIANT: 24-bookworm + VARIANT: 26 volumes: - ..:/workspace @@ -26,10 +26,12 @@ services: # (Adding the "ports" property to this file will not forward from a Codespace.) db: - image: postgres:17 + image: postgres:18 restart: unless-stopped volumes: - - postgres-data:/var/lib/postgresql/data + # Postgres 18+ stores data in a major-version subdirectory and expects the + # volume mounted at /var/lib/postgresql (not /var/lib/postgresql/data). + - postgres-data:/var/lib/postgresql environment: POSTGRES_PASSWORD: postgres POSTGRES_USER: postgres diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 3e147920..232e4651 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -5,5 +5,13 @@ "trailingComma": "none", "bracketSameLine": true, "endOfLine": "lf", - "insertFinalNewline": true + "insertFinalNewline": true, + "ignorePatterns": [ + "**/node_modules/**", + "backend/db/migrations/**", + "backend/locales/**", + "blocks/compiled/**", + "assets/**", + "frontend/dist/**" + ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index de154234..6dcbc20e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,5 +10,10 @@ "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true, "editor.formatOnSaveMode": "file" + }, + "[typescript]": { + "editor.defaultFormatter": "oxc.oxc-vscode", + "editor.formatOnSave": true, + "editor.formatOnSaveMode": "file" } } diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..1f71bd73 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,274 @@ +# Wiki.js 3.x + +Next-generation open source wiki. This is the **3.x development branch** — incomplete, unstable, and +with no upgrade path from 2.x. AGPL-3.0. + +Three independently-installed workspaces (each has its own `package.json` / `node_modules`, there is +no root package or monorepo tooling): + +| Path | What it is | +| ----------- | ------------------------------------------------------------- | +| `backend/` | Fastify REST API server + job scheduler, Drizzle on PostgreSQL | +| `frontend/` | Vue 3 / Vite / Quasar SPA | +| `blocks/` | Lit web components users embed into wiki pages | + +Requires Node.js **26+** and PostgreSQL **16+**. All three workspaces are ESM (`"type": "module"`). + +The backend is **TypeScript 7**; `frontend/` and `blocks/` are JavaScript. See +[TypeScript (backend)](#typescript-backend). + +## Layout + +### Root + +- `config.yml` — instance config (copy of `config.sample.yml`). Read by the backend at boot *and* by + `frontend/vite.config.js` in dev mode to learn the proxy target port. +- `assets/` — **build output** of the frontend (`vite build` writes here), plus static assets under + `assets/_assets/`. Served by the backend. Don't hand-edit. +- `dev/` — deployment/packaging artifacts: `dev/build/Dockerfile` (production image), `dev/helm/`, + `dev/packer/`, `dev/noto-emoji-build/`. +- `.devcontainer/` — VS Code dev container (app + postgres + pgAdmin via docker-compose). +- `localazy.json` — translation sync config; locale strings live in `backend/locales/`. + +### `backend/` + +Entry point is `backend/index.ts`, and it must be run **from the repo root** (`node backend`), not +from inside `backend/`. It boots in three phases: `preBoot()` (config → db → models → cache → +scheduler → event emitters), `initHTTPServer()` (Fastify plugins, auth, routes), `postBoot()` +(refresh locales/strategies/sites from disk & db, start scheduler). + +- `api/` — REST route plugins, one file per resource (`sites.ts`, `users.ts`, `pages.ts`, + `system.ts`, `locales.ts`, `authentication.ts`), registered by `api/index.ts` under the `/_api` + prefix. + - `api/schemas/` — shared JSON Schemas registered via `app.addSchema()` and referenced from route + schemas as `{ $ref: 'Site#' }`. Register new shared schemas in `api/index.ts` *before* the routes. +- `controllers/` — non-API HTTP routes. `site.ts` serves per-site resources (logo, favicon, login + background) under `/_site`. +- `core/` — long-lived singletons: `config.ts` (yml + db-backed settings), `db.ts` (pg pool, Drizzle + instance, migrations, LISTEN/NOTIFY pubsub), `logger.ts`, `scheduler.ts` (poolifier thread pool + + postgres-backed job queue). +- `db/` — `schema.ts` (all Drizzle table definitions), `relations.ts`, `migrations/` (generated). +- `models/` — data-access classes over Drizzle, aggregated by `models/index.ts` and exposed as + `WIKI.models.*`. Business logic belongs here, not in route handlers. `types.ts` holds the shared + `SystemIds` passed to each model's `init()` during first-run seeding. +- `modules/` — pluggable extensions, discovered from disk. Each module is a directory with a + `definition.yml` (key, title, props/config schema) plus its implementation — e.g. + `modules/authentication/local/`. +- `tasks/simple/` — jobs run in-process by the scheduler; each exports `task()`. File name is + kebab-case, the task key is its camelCase form. +- `tasks/workers/` — CPU-bound jobs run in a worker thread via `worker.ts`, which boots a minimal + `WIKI` global (config + logger + lazy `ensureDb()`) and dynamically imports the task. +- `base.yml` — system defaults for every config key. Do not edit as a user-facing config; it defines + the shape merged with `config.yml` and the db `settings` table. +- `helpers/` — small pure utilities (`common.ts`, `config.ts`). +- `types/` — ambient declarations: `global.d.ts` (the `WIKI` global) and `fastify.d.ts` (session + + route-permission augmentations). +- `locales/` — `en.json` source strings (Localazy-managed) + `metadata.js` language table (the one + remaining JavaScript file; typed by its sibling `metadata.d.ts`). + +### `frontend/` + +Quasar app on plain Vite (not Quasar CLI). `src/main.js` wires it up manually: router → pinia store +→ `boot/*` initializers → Quasar plugins → mount. + +- `src/boot/` — one-time app initializers: `api.js` (creates the `ky` client with JWT refresh, exposed + as the `API_CLIENT` global), `components.js` (global components), `eventbus.js` (`EVENT_BUS` global, + mitt), `externals.js`, `i18n.js`, `monaco.js`. +- `src/router/` — `index.js` (router factory) and `routes.js` (the full route table; page components + are lazily imported). +- `src/layouts/` — `MainLayout`, `AdminLayout`, `AuthLayout`, `ProfileLayout`. +- `src/pages/` — route-level views. `Admin*.vue` are the admin area, `Profile*.vue` the user profile. +- `src/components/` — everything else: dialogs (`*Dialog.vue`), full-screen overlays + (`*Overlay.vue`), editors (`Editor*.vue`), nav/tree components. +- `src/stores/` — Pinia stores (`site`, `user`, `page`, `editor`, `admin`, `common`, `flags`). + `stores/index.js` creates the pinia instance and injects `router` into every store. +- `src/renderers/` — page content rendering pipeline: `markdown.js` plus `modules/` (katex, kroki, + plantuml, markdown-it plugins). +- `src/css/` — SCSS. `_theme.scss` holds the Quasar sass variables (wired in `vite.config.js`). +- `src/helpers/`, `src/assets/`, `public/`, `index.html`. + +Path alias `@` → `frontend/src` (defined in `vite.config.js`; `jsconfig.json` mirrors it for the IDE). + +Dev server runs on **3001** and proxies `/_api`, `/_blocks`, `/_site`, `/_thumb`, `/_user` to the +backend on **3000**, so the backend must be running too. + +### `blocks/` + +Self-contained Lit components. Each lives in `blocks/block-/component.js` — the glob in +`rollup.config.mjs` picks up any directory matching `block-*` automatically, so a new block needs no +config change. Output goes to `blocks/compiled/`, which the backend serves statically under +`/_blocks/`. Blocks are loaded dynamically at runtime, which is why `_blocks/**` is excluded from +Vite's `dynamicImportVarsOptions`. + +Blocks style themselves with `:host` / `:host-context(body.body--dark)` for dark mode and read Quasar +theme colors via CSS custom properties (`var(--q-primary)`). + +## Commands + +Run backend commands from `backend/`, frontend from `frontend/`, blocks from `blocks/`. + +```sh +# backend +npm run dev # nodemon, restarts on any backend file change +npm run start # plain node +npm run typecheck # tsc — type check only, never emits +npm run typecheck:watch +npm run db-generate # drizzle-kit generate — after editing db/schema.ts +npm run db-up # drizzle-kit up + +# frontend +npm run dev # vite dev server on :3001 (needs backend running on :3000) +npm run build # builds into ../assets — required before the backend can serve the UI + +# blocks +npm run build # rollup → blocks/compiled/ +``` + +`npx ncu -i` (`npm run ncu`) for interactive dependency updates. + +The API is browsable via Swagger UI at `http://localhost:3000/_api` in a running instance. Default +admin login is `admin@example.com` / `12345678`. + +## TypeScript (backend) + +The backend is entirely **TypeScript 7** (the native Go compiler — `tsc` is a platform binary, not a +JS bundle). The only remaining `.js` is `locales/metadata.js`, which is Localazy-generated output and +is typed by a sibling `locales/metadata.d.ts`. + +**There is no build step.** Node 26 runs `.ts` files directly by stripping types at load time, so +`node backend` and nodemon keep working unchanged as files are converted. `tsc` is used purely as a +type checker (`noEmit`) — never to produce output. Do not add a build/dist step. + +Consequences of type stripping, all enforced by `backend/tsconfig.json`: + +- **Relative imports must carry the real extension.** A `.ts` file importing a converted module writes + `./core/config.ts`, not `./core/config.js` and not extensionless — Node resolves the literal path. + This means converting a file requires updating the specifier in every file that imports it. + (`allowImportingTsExtensions`) +- **Only erasable syntax is allowed** — no `enum`, no `namespace`, no constructor parameter + properties, no `experimentalDecorators`. Use union types or `as const` objects instead of enums. + (`erasableSyntaxOnly`) +- **Type-only imports must say `import type`**, otherwise the import survives erasure and Node tries + to load a value that doesn't exist. (`verbatimModuleSyntax`) + +`allowJs` is **off** — the backend is fully TypeScript, so a stray `.js` file would silently escape +type checking rather than be quietly tolerated. `locales/metadata.js` is the sole exception and is +resolved through its sibling `metadata.d.ts`. + +`backend/types/global.d.ts` declares the ambient `WIKI` global as the `WikiGlobal` interface, wired +to the real module types (`WIKI.db` is the Drizzle instance, `WIKI.models` is `models/index.ts`, and +so on). Only `config` and `data` stay `any` — both are assembled at runtime from YAML plus a JSONB +settings table, so they have no static shape. `index.ts` and `worker.ts` build their own local `WIKI` +literal and assert it to `WikiGlobal`, since each populates the object progressively. + +`backend/types/fastify.d.ts` augments Fastify: session fields (`authenticated`, `user`, +`permissions`) and the per-route `config.permissions` used by the `preHandler` permission hook. + +**Three dynamic paths are extension-sensitive** and invisible to the type checker — they must be +updated by hand if the files they point at are ever renamed: + +- `core/scheduler.ts` → `path.join(WIKI.SERVERPATH, 'worker.ts')` (the poolifier pool entry) +- `worker.ts` → `import('./tasks/workers/${kebabCase(job.task)}.ts')` +- `models/authentication.ts` → `import('../modules/authentication/${stg.module}/authentication.ts')` + +`scheduler.ts` reads `tasks/simple/` filenames with `/\.[jt]s$/`, so task files are extension-agnostic. + +`worker.ts` builds its own minimal `WIKI` (config + logger + lazy `ensureDb()`), but the shared +declaration types it as the full object — so worker-only code can reference members that do not +actually exist in a worker thread. Be deliberate about what you touch there. + +Conventions established during the conversion, worth following in new code: + +- **`catch (err: any)`** at each site rather than globally disabling `useUnknownInCatchVariables`. + Strict mode types a caught error as `unknown`, and this codebase reads `err.message` everywhere; + annotating per-site keeps the looseness visible instead of hiding it in tsconfig. +- **Per-route Fastify generics** for request shapes: `app.get<{ Params: { siteId: string } }>(...)`. + The JSON Schema stays as-is for validation and OpenAPI; the generic is what types `req.params`, + `req.body` and `req.query`. +- **Pre-existing bugs are preserved, not fixed.** Where the type checker exposed already-broken code, + it was left behaving identically behind a narrow cast plus a `FIXME:` comment explaining the real + fix. A migration should not silently change runtime behavior. Search `FIXME:` under `backend/` for + the list — they are genuine open bugs, not type-checker noise. + +## Conventions + +### Style, linting, formatting + +**oxlint** for linting, **oxfmt** for formatting — not ESLint or Prettier (ESLint is explicitly +disabled in `.vscode/settings.json`). Both are devDependencies of `backend/` and `frontend/`. + +```sh +npx oxlint # from backend/ or frontend/ — uses that dir's .oxlintrc.json +npx oxfmt # config is the repo-root .oxfmtrc.json +``` + +Format settings (root `.oxfmtrc.json`): no semicolons, single quotes, no trailing commas, +`bracketSameLine`, LF, final newline. 2-space indent, per `.editorconfig`. + +Otherwise follow **standard JS** rules. Note that much of `frontend/` predates oxfmt and still uses +the standard-style space before parens (`function initializeRouter ()`); new and touched code should +be oxfmt-formatted, but don't reformat untouched files as drive-by changes. + +Each workspace has its own `.oxlintrc.json` — the backend declares the `WIKI` global and node env; +the frontend adds the `vue` plugin and the `API_CLIENT` / `EVENT_BUS` globals. Only the `correctness` +category is an error. + +Both tools handle `.ts` with no extra configuration, and the backend's oxlint config already enables +the `typescript` plugin. oxlint does not type-check — run `npm run typecheck` for that. + +### Backend patterns + +- **The `WIKI` global.** Set up in `index.ts`, typed in `types/global.d.ts`, available everywhere + without importing: + `WIKI.db` (Drizzle), `WIKI.models.*`, `WIKI.config`, `WIKI.logger`, `WIKI.cache`, `WIKI.scheduler`, + `WIKI.events.{inbound,outbound}` (Emittery), `WIKI.sites` / `WIKI.sitesMappings` (cached site + configs), `WIKI.ROOTPATH`, `WIKI.SERVERPATH`, `WIKI.INSTANCE_ID`. +- **Routes** are Fastify plugins: `async function routes(app) { ... }` with a default export. +- **Permissions** are declared per-route in `config.permissions`, and enforced by a single + `preHandler` hook in `index.ts`. The array is OR-ed; a nested array is AND-ed + (`permissions: ['read:sites', ['manage:pages', 'write:pages']]`). `manage:system` bypasses every + check. `@fastify/swagger`'s `transform` folds these into the OpenAPI description automatically — + so declaring them is also how they get documented. +- **Every route needs a `schema`** with `summary`, `tags`, and response schemas. `hideUntagged` is on, + so an untagged route is invisible in the API docs. Reuse `$ref` schemas from `api/schemas/`. +- **Errors** via `@fastify/sensible` helpers (`reply.notFound()`, `reply.badRequest()`, + `reply.unauthorized()`, `reply.forbidden()`). The `setErrorHandler` in `index.ts` shapes `/_api/` + failures into `{ ok, error, statusCode, message }` JSON. +- **Schema changes**: edit `db/schema.ts`, then `npm run db-generate` and commit the generated + migration. Never hand-edit an existing migration. +- Prefer **es-toolkit** over lodash on the backend. +- **Dates use the native `Temporal` API**, not luxon (which is no longer a backend dependency — + `frontend/` still uses it). `Temporal` is a global in Node 26 and is typed by the TS 7 lib, so it + needs no import. Three things to know: + - `Temporal.Instant` accepts **exact time units only** — `add({ days: 1 })` throws. Since these are + all UTC instants, use `{ hours: 24 }`. + - Temporal types have no `valueOf`, so `a < b` **throws**. Compare with + `Temporal.Instant.compare(a, b)`. + - `Instant.toString()` defaults to nanosecond precision; pass + `{ smallestUnit: 'millisecond' }` for values written to postgres or compared as strings, which is + what the rest of the codebase emits. + - Converting: `date.toTemporalInstant()` from a `Date` (what drizzle returns for `timestamp` + columns), `Temporal.Instant.from(str)` for postgres-format strings (what raw `db.execute()` + returns), and `new Date(instant.epochMilliseconds)` going back the other way. + +### Frontend patterns + +- **Vue 3 with pug templates** (`