dorfteich/docs/architecture/data-model.md
Claude Opus 4.8 621aa47244
Some checks failed
CI / Auth e2e pack (push) Waiting to run
CI / Import/export fidelity gate (push) Waiting to run
CI / Build container images (push) Waiting to run
CD / Build and push images (push) Failing after 1m33s
CD / Deploy to Test (push) Has been skipped
CD / Smoke tests against Test (push) Has been skipped
CD / Promote to Int (push) Has been skipped
CI / Lint, typecheck, test (push) Has been cancelled
Add plugin storage, install API, and directory watcher (#71)
Backend for installing plugin ZIPs (ADR 0008, plugin-architecture.md
§Lifecycle, security.md §Plugins). Consumes the #70 SDK for validation.

- Schema: `plugins` (id, name, version, apiVersion, kind, mode, manifest
  jsonb, removedAt soft-delete) + `pond_plugins` (per-pond activation) +
  `PluginInstanceMode` enum; migration 20260710130000_plugins.
- `PluginPackageService`: pure, stateless ZIP → validated package via
  fflate — structure check, manifest validation (SDK), apiVersion gate,
  kind/bundle/styles rules, CSS sanitation (no @import / external url() /
  expression()), zip-slip and unpacked-size guards. Each failure carries a
  stable PluginErrorCode; manifest issues travel as ApiError details.
- `PluginStorageService`: on-disk layout `<PLUGINS_DIR>/<id>/<version>/`;
  atomic writeVersion (staging dir + rename, no 404 window mid-update),
  removeVersion/removePlugin, traversal-safe asset resolution, dropzone +
  quarantine dirs.
- `PluginsService`: install/update (update only to a strictly higher
  version, preserving the admin's instance mode; files land before the
  metadata pointer flips) / uninstall (refused while required; soft-delete
  + files removed + pond activations dropped) / list / get.
- `POST/GET/DELETE /admin/plugins` (SiteAdminGuard, multer memory upload),
  error→HTTP-status mapping. Public version-pinned static serving at
  `GET /plugins/:id/:version/*rest` with immutable cache + nosniff, only for
  the installed current version.
- `PluginWatcherService`: watches `<PLUGINS_DIR>/_dropzone/`, runs the same
  validation, installs valid drops and quarantines invalid ones with the
  error logged; inert under NODE_ENV=test (tests drive processDropped).
- SDK: `compareVersions`/`isHigherVersion`. shared: `PluginView`,
  `PluginInstanceMode`, `PLUGIN_ERROR_CODES`, `PLUGINS_DIR` env, plugin
  error i18n (de+en). Compose: `plugins` volume + `PLUGINS_DIR`.
- Tests: package unit test (valid + each invalid class) and an e2e DB test
  (GUI install + immutable serving, non-admin 403, invalid-manifest details,
  dropzone install + quarantine, atomic higher-only update, required-guarded
  uninstall that removes files and tombstones metadata).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 16:55:26 +02:00

205 lines
9.5 KiB
Markdown

# Data model
Authoritative once implemented in `apps/api/prisma/schema.prisma`; this
document explains the entities and their intent. Naming below uses the
English terms (pond = Teich).
## Overview
```mermaid
erDiagram
users ||--o{ user_identities : "logs in via"
users ||--o{ sessions : has
users ||--o{ ponds : "created"
ponds ||--o{ pages : contains
ponds ||--o{ labels : defines
labels o|--o{ labels : "parent of"
pages }o--o{ labels : "tagged with"
pages ||--o{ page_versions : "has history"
pages ||--|| page_content_cache : "derived text"
pages ||--o{ page_links : "links to"
pages ||--o{ attachments : has
ponds ||--o{ attachments : owns
users ||--o{ role_grants : "subject of"
ponds ||--o{ role_grants : "scoped to"
plugins ||--o{ pond_plugins : "activated in"
ponds ||--o{ pond_plugins : activates
pages ||--o{ comments : has
users ||--o{ notifications : receives
users ||--o{ watches : sets
```
## Identity and access
### `users`
| Column | Notes |
| ----------------------------- | ----------------------------------------------------------- |
| `id` (uuid) | |
| `username` | unique, URL-safe |
| `email` | unique, stored verified/unverified with `email_verified_at` |
| `display_name` | shown at cursors, comments |
| `locale` | UI language (ADR 0012) |
| `is_site_admin` | boolean; Site Admin is a user flag, not a grant |
| `status` | `active` / `disabled` / `pending_verification` |
| `created_at`, `last_login_at` | |
### `user_identities` (ADR 0007)
`user_id`, `provider` (`password` now; `oidc:<issuer>` later), `subject`,
`credential` (Argon2id hash for `password`), unique on
(`provider`, `subject`).
### `sessions`
Opaque id (hashed), `user_id`, `created_at`, `expires_at`, `last_seen_at`,
user-agent summary (for "active sessions" UI).
### `auth_tokens`
Single-use tokens for e-mail verification and password reset: hashed token,
`purpose`, `expires_at`, `consumed_at`.
### `role_grants` — the permission table (see `permissions.md`)
| Column | Notes |
| -------------------------- | --------------------------------------------- |
| `id` | |
| `pond_id` | every grant belongs to exactly one pond |
| `subject_type` | `user` / `authenticated` / `public` |
| `subject_id` | user id when `subject_type = user`, else null |
| `role` | `pond_admin` / `editor` / `reader` |
| `scope_type` | `pond` / `label` / `page` |
| `scope_id` | label id or page id when scoped, else null |
| `effect` | `allow` / `deny` |
| `created_by`, `created_at` | audit |
Unique on (`pond_id`, `subject_type`, `subject_id`, `role`, `scope_type`,
`scope_id`). `pond_admin` grants are only valid with `scope_type = pond`
and `subject_type = user`.
## Content
### `ponds`
| Column | Notes |
| -------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `id`, `slug` (unique), `name`, `description` | |
| `type` | `personal` (one per user, from self-signup) / `shared` |
| `owner_id` | creator; personal ponds: the user it belongs to |
| `settings` (jsonb) | fonts (ADR 0016), sidebar sort mode (`alpha` / `created` / `manual`), default page permissions |
| `deleted_at`, `deleted_by` | pond-level trash (ADR 0013) |
### `pages`
| Column | Notes |
| ---------------------------------------- | ----------------------------------------------------------------------------------- |
| `id`, `pond_id` | |
| `title` | also indexed for search weight A |
| `slug` | unique per pond, for stable URLs and wikilink resolution |
| `ydoc_state` (bytea) | current merged Yjs state (ADR 0003) |
| `ydoc_updates` | append log table `page_updates(page_id, seq, update bytea)`, compacted periodically |
| `sort_key` | manual sidebar ordering (fractional indexing) |
| `created_by`, `created_at`, `updated_at` | |
| `deleted_at`, `deleted_by` | trash |
### `page_content_cache`
One row per page, refreshed on persistence: `plain_text`, `markdown`,
`html`, `outline` (headings JSON, for TOC plugins), generated `tsvector`
column with GIN index (ADR 0010).
### `page_versions` (ADR 0013)
`page_id`, `ydoc_snapshot` (bytea, self-contained), `trigger`
(`auto` / `manual` / `pre_restore`), `label`, `contributor_ids`,
`created_at`.
### `labels`
`pond_id`, `name`, `parent_id` (nullable — hierarchy), `color`, unique on
(`pond_id`, `parent_id`, `name`). Cycles are rejected at write time.
`page_labels(page_id, label_id)` is the assignment table.
### `page_links`
Wikilink index maintained on persistence: `from_page_id`, `to_page_id`
(nullable when target does not exist yet — "phantom" links), `target_slug`.
Backlinks = query by `to_page_id`. Creating a page with a phantom-linked
slug resolves those rows.
### `attachments` (ADR 0011)
`id`, `pond_id`, `page_id` (nullable — pond-level files), `file_name`,
`mime_type`, `size_bytes`, `storage_path`, `uploaded_by`, `created_at`,
`deleted_at`.
## Plugins (ADR 0008)
### `plugins`
`id` (manifest id, primary key), `name`, `version`, `api_version`,
`kind` (`code` / `section_style`), `mode`
(`DISABLED` / `OPTIONAL` / `REQUIRED`, the instance mode — issue #72 sets it),
`manifest` (jsonb — the full validated manifest, so serving/admin views never
re-read disk), `installed_at`, `updated_at`, `removed_at` (soft-delete
tombstone: set on uninstall, files removed, so existing `plugin_block` nodes can
still resolve the manifest fallback). Unpacked bundles live on the plugins
volume at `<PLUGINS_DIR>/<id>/<version>/`; the path is derived from id+version,
not stored (issue #71).
### `pond_plugins`
(`pond_id`, `plugin_id`, `enabled`) — only meaningful for `optional`
plugins; `required` plugins are active everywhere. Rows cascade-delete with
their pond or plugin, and are dropped when a plugin is uninstalled.
## Quotas and settings
### `instance_settings`
Key-value (typed JSON) singleton set: registration mode, SMTP config
(secrets referenced from env, not stored plaintext — see `security.md`),
default quotas, upload allowlist, legal pages content (imprint, privacy),
instance default locale.
### `quota_overrides`
`subject_type` (`user` / `pond`), `subject_id`, `quota_key`
(`editors_per_pond`, `readers_per_pond`, `additional_ponds`,
`storage_bytes`, `max_file_bytes`), `value`. Resolution: pond override →
user override → instance default (most specific wins, mirroring the
permission philosophy).
### `pond_usage`
Cached counters per pond: `storage_bytes_used`, `editor_count`,
`reader_count` — updated transactionally, reconciled nightly.
## Collaboration support
- `collab_tokens` are **not** stored — they are short-lived signed JWTs
(ADR 0007).
- `mail_outbox`: pending/sent e-mails with retry state (ADR 0002 — no
broker).
- `jobs`: conversion jobs for import/export (ADR 0009) and maintenance jobs
(compaction, trash purge, quota reconciliation) with status + timestamps.
## Comments & notifications (later milestone)
- `comments`: `page_id`, `author_id`, `body` (Markdown), `anchor`
(optional serialized position), `resolved_at`, `created_at`, thread via
`parent_id`.
- `watches`: (`user_id`, `target_type` `page`/`pond`, `target_id`).
- `notifications`: `user_id`, `type`, `payload` (jsonb), `created_at`,
`read_at`; delivered in-app, optionally by e-mail digest.
## Deliberate non-entities
- **No `organizations`/`teams`** — ponds + grants cover the vision; groups
can be added as a new `subject_type` in `role_grants` without migration
pain.
- **No content translations** (ADR 0012).
- **No per-keystroke authorship** — contributor granularity is the version
snapshot (ADR 0013).