Data and storage#
What is stored, where, in which form, how the schema evolves and what is kept forever. Read together with encryption (what is encrypted), backup, restore and recovery and data protection.
1. Two stores#
| Store | Holds | Access path |
|---|---|---|
| PostgreSQL 16 | every record of the CMS: accounts, content, configuration, asset records, gateway definitions, logs of mail and workflow | core-api, asset-processor and gateway connect directly (same schema, same credentials); nothing else should |
| S3-compatible object store | the bytes: asset originals, generated thumbnails, form-upload staging copies, rotated application log files | asset-processor and core-api with the operator's S3 credentials; browsers get bytes only through /dam-asset/* on asset-processor, never an S3 URL |
There is no other persistent state. Containers are stateless apart from two volumes in the default compose file
(pgdata, garagedata) and the log directory (LOG_DIR, default ./logs, inside the container unless mounted).
2. Database content by area#
The schema is defined in packages/core/src/db/*-schema.ts and created by 53 SQL migrations in
packages/core/drizzle/ (journal meta/_journal.json). 42 tables:
| Area | Tables |
|---|---|
| Accounts and access | users, sessions, roles, permissions, role_permissions, user_roles |
| Websites and pages | websites, website_templates, website_paths (the menus; renamed from menus in migration 0017), whoobe_pages, whoobe_pages_staging, whoobe_one_templates, themes, content_versions |
| Reusable authoring | whoobe_one_snippets (with a locked flag), whoobe_one_kits, cms_snippets, cms_documents, data_components |
| Structured data | data_classes, data_objects, data_object_preview_tokens, class_workflow_definitions, workflow_transition_log, search_documents |
| Assets | assets, folders |
| Forms and mail | form_uploads, mail_settings, mail_templates, mail_log, pwa_subscriptions |
| Gateway | gateway_endpoints, gateway_routes, gateway_schemas, gateway_bindings |
| Settings and integrations | ai_settings, ai_image_settings, ai_translate_settings, figma_settings, migration_sources, whoobe_ai_generated |
Per-website settings (themes catalog, upload limits settings.uploads, documentation-site settings, domains) are JSON in
websites.settings.
What is stored in clear and what is not#
| Data | Form |
|---|---|
| Passwords | argon2id hash (argon2, library defaults) |
| Session identifier | random 32 bytes as hex in the cookie; sessions.id holds its SHA-256, so a database read or backup does not yield usable cookies (F-056) |
| Password-reset tokens | hashed |
| TOTP secrets | AES-256-GCM (TOTP_ENCRYPTION_KEY) |
| Gateway upstream credentials, migration source credentials | AES-256-GCM (GATEWAY_AUTH_ENCRYPTION_KEY, MIGRATION_SOURCE_ENCRYPTION_KEY) |
| SMTP password, mail-provider API keys | AES-256-GCM (MAIL_SETTINGS_ENCRYPTION_KEY) |
| Per-website gateway API keys | hashed with a pepper (SITE_API_KEY_PEPPER) |
AI provider API keys (ai_settings, ai_image_settings, ai_translate_settings, one row per user), Figma access token (figma_settings), push-notification provider API key (OneSignal, inside the website's pwa configuration) |
AES-256-GCM, enc:v1: (INTEGRATION_SECRETS_ENCRYPTION_KEY, F-055); clear text on an installation that has not set the key. The API never returns them (the push key used to be returned, F-059: records now carry only onesignalApiKeySet) |
| Everything else (content, e-mail addresses of users and of form submitters, IP and user agent of sessions, mail log bodies) | clear text in the database |
3. Object storage layout#
Keys are the asset's path (formatS3ObjectKey(pimcorePath, keyPrefix)), optionally behind S3_KEY_PREFIX.
| Key prefix | Content | Visibility |
|---|---|---|
anything under public/ |
public assets (built-in rule /public/**; more rules in the DAM settings) |
readable without a session |
| other paths | assets recorded as not public | session required (DAM_ENFORCE_ASSET_VISIBILITY, on by default) |
private/… |
the system area, always session-only | session required |
private/logs/<app>/<yyyy-mm>/ |
rotated application logs uploaded by the logger | session required; also readable by core-api with the service token |
private/_uploads/<websiteId>/<uploadId>.<ext> |
form uploads waiting for their form to be submitted; no asset record; deleted when attached or after 2 hours | session required |
private/<website upload folder>/<uuid>.<ext> |
form uploads after the form was submitted (default private/forms) |
session required |
.thumbnails/… |
generated thumbnails | follows the original |
Bucket policy: when the store supports policies, the provider writes a public-read policy for .thumbnails/* and for the
folders of the public visibility rules; everything else is reachable only through the asset-processor proxy, which
enforces the visibility rules. With an external S3, check that the bucket itself is not public.
4. Schema evolution#
- Migrations are plain SQL files applied in journal order by core-api at start (
bootstrap.ts, drizzlemigrate). Several migrations are hand-written becausedrizzle-kit generatecannot load every schema file. - Policy in use (see the comments of the recent migrations): expand only — new tables, new nullable or defaulted
columns,
IF NOT EXISTS. A column that stops being used is left in place and dropped by a later migration once every environment has been through the backfill (example: the old plaintextmail_settings.password). - Consequences: an old version of the code keeps working against a newer schema for one step (useful for a rollback of the application), but a database restore is the only way back from a migration.
- There is no migration lock: if two core-api processes start together against an empty or older database, both try to migrate. Start one instance first (the compose file does this: asset-processor and gateway wait for core-api to be healthy).
- Data backfills run at start and are idempotent (for example the encryption of old TOTP secrets and SMTP passwords).
5. Lifecycle and retention#
| Data | Kept | Removed by |
|---|---|---|
| Sessions | 7 days from login (fixed, not extended by activity); revoked on logout and on password change or reset | expired and revoked rows are deleted a week later (core-api, at start and every six hours) |
mail_log |
forever | an administrator, from Settings > Mail > Log (by filter) |
workflow_transition_log, content_versions |
forever | with the object or page |
| Form submissions | as data objects (unpublished drafts) until an editor deletes them | an editor |
| Form-upload rows | form_uploads rows stay (status attached, rejected, expired) |
nothing (no purge) |
| Form-upload staging bytes | until the form is submitted, at most about 2 hours | asset-processor timer every 30 minutes |
| Application log files | rotated daily or at 10 MB (pino-roll) |
not removed; uploaded to the DAM log folder; no retention limit is enforced |
| Page and snippet history | content_versions, no purge job |
not removed automatically |
| Deleted website | pages must be deleted first (F-044: deleting a website that has pages can answer 500) |
Retention is the operator's decision today; see data protection for what that means.
6. Size drivers#
Assets dominate (up to ASSET_MAX_FILE_BYTES, default 50 MB per file; ASSET_MAX_COUNT optional); the database stays
small by comparison (JSON page trees, a few kilobytes to a few hundred kilobytes per page). Rows that grow without bound
unless someone trims them: mail_log, form_uploads, content_versions (sessions is purged automatically).
Last updated