Gå til innholdet
ErmisAI

Database schema and runtime state

The durable relational schema, the platform_state key-value layer, and the rule for deciding which to use.

ErmisAI persistence is a hybrid. Twenty-five Drizzle-defined Postgres tables hold durable relational data. Two of those tables — platform_state and platform_state_hash — are generic key-value stores that hold everything mutable that never earned a schema. Redis holds no application state at all.

Both halves live in the same Postgres database, so "runtime state" here does not mean "somewhere else". It means "a jsonb blob under a string key instead of columns".

The rule for deciding which side

If the data has a stable shape, is queried by anything other than its own key, or must survive a schema review — give it a table. If it is a per-tenant or per-user blob that is only ever read whole by key, put it in platform_state.

In practice the split falls out like this:

SignalSide
Foreign keys, indexes, or cross-row aggregationRelational table
Filtered or sorted by a column (editorial_state, status, updated_at)Relational table
Money, identity, or audit dataRelational table
Read and written as one whole object per tenant or per userplatform_state
Needs atomic insert-if-absent on a field (idempotency receipts)platform_state_hash
TTL'd cache that must survive a cold startplatform_state with expires_at
Rate-limit counters, in-flight reservations, a cross-instance lockRedis

Durable relational: Postgres and Drizzle

src/lib/db/schema.ts is the single schema source of truth — 25 pgTable definitions and 22 pgEnum declarations (18 of them in the block at src/lib/db/schema.ts:98-133, the rest declared next to the tables that use them). One repository module per domain sits beside it in src/lib/db/. There are no parallel Supabase SQL migrations; Supabase is only ever a possible host.

Billing

TableLineHolds
billing_plansschema.ts:206Plan catalog mirror: price mode (fixed, base_plus_seat_addon, custom), monthly price, per-seat add-on, monitoring-rule count, included seats, cost envelope cents, polar_product_id
tenant_subscriptionsschema.ts:232One row per tenant scope (tenant_scope_id unique): plan FK (onDelete: 'restrict'), Polar customer/subscription ids, seat quantity, status, current_period_end, polar_modified_at, ai_overage_enabled, ai_monthly_hard_limit_cents, cost_envelope_per_month_cents_override
billing_invoicesschema.ts:266Invoice mirror
billing_portal_sessionsschema.ts:283Polar portal sessions, provider session id uniquely indexed
billing_checkout_sessionsschema.ts:300Polar checkout sessions, same uniqueness
billing_webhook_eventsschema.ts:318Idempotency ledger for Polar webhooks, unique on (provider, provider_event_id); carries the payload-archive columns

polar_modified_at is an event-ordering guard: a late webhook cannot resurrect a canceled subscription (schema.ts:246-248).

Identity

TableLineHolds
clerk_usersschema.ts:346Webhook-maintained mirror of Clerk users: email, name, image, role, app_role (default unassigned), persona, org id/name/slug/role, metadata, deleted_at
tenant_membershipsschema.ts:376Composite PK (tenant_scope_id, clerk_user_id), tenant_role enum (owner, admin, member), title, desk_slugs
clerk_webhook_eventsschema.ts:401Same idempotency plus payload-archive shape as billing

clerk_users.app_role is written but never used for authorization — the live JWT claim wins. See Tenant scoping and isolation.

Source catalog

TableLineHolds
source_catalog_entriesschema.ts:426The feed registry: feed URL (unique), type, tier (wire, tier1, tier2, tier3), region, language, categories, catalog_origin (default global), ingest_enabled, active, resolved icon URL, last-fetch telemetry
source_catalog_dataset_versionsschema.ts:462Version, checksum, and source count of each catalog dataset ingest

Editorial

TableLineHolds
story_chat_sessionsschema.ts:474PK (tenant_scope_id, clerk_user_id, story_id, chat_id), active_stream_id
story_chat_session_payloadsschema.ts:502Same PK, messages jsonb — split off so metadata reads do not drag the transcript
editorial_story_draftsschema.ts:530The editorial system of record. PK (tenant_scope_id, story_id)
editor_queue_itemsschema.ts:641Personal editor queue. PK (tenant_scope_id, user_id, story_id), pinned, status queued/submitted/removed — no physical deletes
tenant_newsroomsschema.ts:667Newsroom profile per tenant scope: publication and desk name, vertical, selected source ids and countries, tags, custom categories, ui_locale, content_locale, display_style, custom_sources, completed_at

Integrations

TableLineHolds
tenant_integration_api_keysschema.ts:690key_hash uniquely indexed, masked_value, revoked_at, last_used_at
tenant_wordpress_connectionsschema.ts:716Unique (tenant_scope_id, site_url) and unique key_id; plugin and WordPress versions
tenant_webhook_configsschema.ts:750One row per tenant: endpoint, secret, enabled
tenant_webhook_logsschema.ts:769Delivery status, response code, timestamp

AI accounting

TableLineHolds
ai_usage_ledgerschema.ts:785One row per AI invocation: invocation/request/generation ids, tenant and user, actor type, billing scope (tenant/platform), billable, surface, stage, operation, provider mode/id/model id, status, token counters, duration, actual/estimated/billable cost cents with cost_source
ai_usage_metering_outboxschema.ts:836Durable outbox for Polar overage metering; FK to the ledger with onDelete: 'cascade', unique external_event_id, status pending/processed/failed, attempt_count

Compliance

data_erasure_requests (schema.ts:911) drives GDPR erasure through a grace window. Subject type is tenant or user; status is pending, failed, purged, cancelled, or purging. notification_targets and member_user_ids are captured at request time, because Clerk hard-deletes the org membership snapshot immediately and a purge-time lookup two weeks later would find nothing (schema.ts:936-944). A CHECK constraint requires the matching subject id, and two partial unique indexes make "at most one unresolved request per subject" race-safe (schema.ts:950-970).

Stories, clusters and articles are not tables

There is no stories table, no clusters table, no articles table. The only editorial table is editorial_story_drafts, and it embeds the story it was composed from as a story_snapshot jsonb column. Aggregated stories live in the feed cache (platform:story-feed-cache:v1:<scopeKey>, TTL'd) and in process memory. Anything that needs to query across stories queries drafts or rebuilds the feed.

Schema-enforced editorial invariants

editorial_story_drafts does not trust application code for two things.

Optimistic concurrency. version integer NOT NULL DEFAULT 0 (schema.ts:544-547) is bumped on every persisted write, and the transition mutators pass the version they read as a compare-and-set guard. When a caller supplies expectedVersion — the version the client loaded into its edit session — the lock spans the whole session, so a save built on stale content is rejected even after minutes (src/lib/platform/drafts/tenant-draft-store.ts:481-491). A conflict is a precondition failure, not an infrastructure fault: it is re-thrown rather than masked by the fallback path (tenant-draft-store.ts:493-497) and surfaces to the editor as "Someone else updated this draft. The latest version was loaded - review and retry."

State/timestamp consistency. Three CHECK constraints (schema.ts:596-607):

editorial_state != 'in_review'  OR submitted_for_review_at IS NOT NULL
editorial_state != 'published'  OR published_at           IS NOT NULL
editorial_state != 'rejected'   OR reviewed_at            IS NOT NULL

A row that violates one is contradictory audit data, and the CMS and WordPress export gates read published_at. The migration that added them backfills legacy rows from updated_at first (drizzle/20260717002329_editorial-state-timestamp-checks/).

One deliberate oddity: editorial_story_drafts.content_locale and tenant_newsrooms.content_locale both default to the literal 'en', not to the env-derived platform default. A column DEFAULT baked from ERMIS_CONTENT_LOCALE would make the generated schema drift with whichever environment generated it (schema.ts:566-569). Application code always writes contentLocale explicitly.

The database client

src/lib/db/client.ts is plain postgres (postgres-js) plus drizzle-orm/postgres-js. No vendor transport.

  • Connection string resolution order is SUPABASE_DATABASE_URLDATABASE_URL (client.ts:66-69). Both must use the postgres: or postgresql: protocol. If the value contains REDIS_URL= or UPSTASH_REDIS_ the error adds "Check for a missing newline in your env file" (client.ts:82-89).
  • Pool size is POSTGRES_POOL_MAX, default 3 per instance, with connect_timeout: 10, idle_timeout: 20, prepare: false (client.ts:19, :135-149).
  • The client is memoized on globalThis.__ermisDbClient only outside production (client.ts:154-162).

Direct Postgres hosts are refused in production, fail-closed. In NODE_ENV=production, a URL that is a direct host throws at client construction instead of falling through to the next candidate env var (client.ts:97-104). Direct means Supabase db.<ref>.supabase.co on any port other than 6543, or CapyDB *.db.capydb.dev on any port other than 6432. The message is "<NAME> points at a direct Postgres host. Use the pooled/transaction connection string for the app runtime in production." A partial misconfiguration is itself a fault — using a direct host from serverless exhausts connections.

Statement timeout

POSTGRES_STATEMENT_TIMEOUT_MS defaults to 10000 ms and is sent as a wire startup parameter — but only for non-pooled URLs. isCapyDbPooledUrl (*.db.capydb.dev on port 6432) omits it entirely (client.ts:58-63, :146-148).

The reason is an incident. CapyDB's PgBouncer pooler hard-rejected statement_timeout with 08P01 unsupported startup parameter, which took down every production Postgres connection on 2026-07-22. CapyDB then added it to ignore_startup_parameters — so it is now silently dropped rather than rejected. Sending it through the pooler achieves nothing either way.

Pooled connections get their timeout from a role-level default instead, installed by drizzle/20260722020726_statement_timeout_role_default/:

ALTER ROLE <current_user> IN DATABASE <current_database> SET statement_timeout = '10s';

Tuning POSTGRES_STATEMENT_TIMEOUT_MS in production does nothing. The pooled runtime uses the 10 s role default. Changing the effective pooled timeout requires a new migration. Long-running DDL inherits the same 10 s cap, so any migration with slow DDL must start with SET statement_timeout = 0;.

Runtime state: platform_state and platform_state_hash

Two generic tables, both in schema.ts:

TableShapeLine
platform_statekey text PRIMARY KEYvalue jsonb, nullable expires_at, updated_atschema.ts:870
platform_state_hashPK (key, field)value jsonb, updated_atschema.ts:889

They replace an earlier Upstash blob keyspace. The doc comment states the tradeoff: these access patterns are low-frequency or fronted by an in-process cache, so Postgres latency matches Redis while gaining durability and removing a hard Redis dependency (schema.ts:861-868). The composite primary key on the hash table is what gives back the atomic insert-if-absent that Redis HSETNX provided (schema.ts:883-887).

The store API

The factory is createPlatformStateStore (src/lib/platform/shared/runtime-state.ts:60). Exactly one instance is built, in src/lib/platform/shared/state.ts:25-37, and it exports:

FunctionSemantics
readState(key, seedFactory, schema?)Select where key matches and (expires_at IS NULL or expires_at > now()); on miss, seed with INSERT … ON CONFLICT DO NOTHING, and if the insert lost the race re-read with the same expiry filter so an expired row cannot be resurrected
readStateIfPresent(key, schema?)Same read, no seeding; null on miss
writeState(key, value, schema?)INSERT … ON CONFLICT (key) DO UPDATE, always writing expires_at: null
mutateState(key, seed, mutator, schema?)Transaction with SELECT … FOR UPDATE on the row, then upsert
listStateKeysByPrefix(prefix)LIKE '<escaped>%' with \, % and _ escaped, expired rows filtered out
addHashRecordIfAbsent(key, field, record, schema)INSERT … ON CONFLICT DO NOTHING plus read-back, returning { record, created }
hasHashField(key, field)Existence check
listHashRecords(key, schema)All fields under a key

The Drizzle context is dynamically imported and lazily cached, so the in-memory path never instantiates a DB client and a missing DATABASE_URL only matters once the store is actually used (runtime-state.ts:42-58).

Unlike the Redis store it replaced, this store does not latch a disabled flag. Postgres is the system of record, so a transient error surfaces in production or falls back per call in dev (runtime-state.ts:70-73).

mutateState's mutator must be synchronous. It runs inside SELECT … FOR UPDATE. An async mutator would hold a row lock across arbitrary awaits and exhaust a 3-connection pool (runtime-state.ts:537-544). The signature enforces it — mutator: (current: T) => T — but a fire-and-forget promise inside the body would compile.

Key naming

buildScopedPlatformKey(domain, id) produces platform:<domain>:<normalized-id> (runtime-state.ts:18-20). Ids are trimmed and lowercased; null or undefined becomes 'anonymous' (runtime-state.ts:10-12). The id is almost always a tenant scope id — user:<clerkUserId> or org:<clerkOrgId> — which is also how the GDPR purge finds a tenant's KV slice.

A handful of keys use dedicated builders for composite ids: buildEditorQueueStateKey, buildStoryNotesStateKey, buildStoryChatSessionsKey, buildWordPressConnectSessionsStateKey, buildWordPressConnectionsStateKey (runtime-state.ts:22-39).

Validation

Every payload is zod-validated on read and on write. resolveLocalPlatformStateSchemaByKey (src/lib/platform/shared/state-schemas.ts:274-382) matches a key against an ordered list of exact-match and prefix matchers and returns the schema.

  • A failing read treats the value as absent and writes one line to stderr: [local-platform-data] invalid postgres state for <key>: … (runtime-state.ts:112-114).
  • A failing write or mutate throws [local-platform-data] write validation failed for <key> (runtime-state.ts:513); the hash variant throws [local-platform-data] hash write validation failed for <key> (runtime-state.ts:377).

Unmatched keys are unvalidated. The matcher list falls through to z.unknown() (state-schemas.ts:381), so a typo'd key family gets no schema enforcement at all and no warning. Adding a key family means adding a matcher.

Known key families

Key or prefixPayloadDefined in
platform:editorial:article-drafts (and :<tenant>)Article draft recordsshared/state.ts:11
platform:admin:delivery-logsGlobal recent-N delivery mirrorshared/state.ts:12
platform:admin:review-status-eventsReview status change eventsshared/state.ts:13
platform:admin:delivery-failed-eventsDelivery failure eventsshared/state.ts:14
platform:admin:feature-flagsGlobal flag snapshotfeature-flags.ts:16
platform:admin:feature-flag-overrides:<tenant>Per-tenant flag overridesfeature-flags.ts:17
platform:admin:ai-runtime-configAdmin-managed AI runtime configai-runtime-config.ts:63
platform:alerts-rules:<tenant>Alert rulesalerts/index.ts:313
platform:alerts-history:<tenant>Alert historyalerts/index.ts:427
platform:alerts-delivery-claims:<tenant>Atomic delivery claimsalerts/index.ts:444
platform:monitoring-sources:<tenant>Monitored sourcesalerts/index.ts:233
platform:notifications:<user>Notification inboxstate-schemas.ts:328
platform:team-members:<user>Team member snapshots (KV fallback)team/index.ts:83
platform:editor-queue:<tenant>:<user>Editor queue fallbackruntime-state.ts:22
platform:story-notes:<tenant>:<storyId>Story notesruntime-state.ts:26
platform:story-delivery-state:<tenant>Per-UTC-day released/delivered/queued story idslocal-platform-data.ts:1905
platform:story-chat-sessions:<tenant>:<story>:<user>Chat sessions fallbackruntime-state.ts:38
platform:integrations-api-keys:globalAPI key records — one global arrayintegrations/keys-and-wordpress.ts:65
platform:integrations-webhook-config:<tenant> / -logs:<tenant>Webhook config and logs fallbackstate-schemas.ts:344-350
platform:delivery-logs:<tenant> / platform:delivery-counters:<tenant>Per-tenant log and per-UTC-day countersshared/delivery-log.ts:66-72
platform:newsroom-profile:<scope>Newsroom profile fallback copynewsroom-preferences.ts:406
platform:story-feed-cache:v1:<scopeKey>Persisted feed cache — the only TTL'd keyrss-aggregation.ts:696
platform:email-suppressions:marketing / :bounces (hash)Unsubscribe and hard-bounce tokenssrc/lib/email/suppression.ts:8-9
platform:ai-envelope-threshold-firings:<tenant> (hash)At-most-once threshold firing receiptsai/usage-threshold-notifier.ts:33

The platform: prefix is not proof of a platform_state row. platform:cache:newsroom-profile:<scope> (30 s TTL) and platform:cache:newsroom-source-catalog (5 min TTL) live in Redis, not in the table (src/lib/platform/newsroom-preferences.ts:187-188, read at :360).

Expiry and cleanup

writeState and mutateState always write expires_at: null. Exactly one writer sets an expiry: the persisted feed cache, using ERMIS_FEED_CACHE_PERSIST_RETENTION_MS (src/lib/services/rss-aggregation.ts:794-802).

Reads filter expired rows, but nothing deleted them, so abandoned scope keys — old multi-KB per-source-set feed caches — accumulated indefinitely. src/app/api/admin/state/cleanup/route.ts runs daily at 15 3 * * * and deletes:

  1. platform_state rows with a non-null expires_at in the past;
  2. platform_state_hash rows under platform:ai-envelope-threshold-firings:% older than 90 days (THRESHOLD_FIRING_RETENTION_DAYS).

It returns { deleted, deletedHashRecords, processedAt }. GET requires CRON_SECRET; POST requires an operator session with queues admin access.

Email-suppression hash rows are deliberately never pruned — the route prunes only explicitly listed key families, never the whole hash store (cleanup/route.ts:18-25).

Array caps

KV arrays are bounded, which means they are not counting sources:

ConstantValueWhere
STORY_DELIVERY_STATE_LIMIT5000local-platform-data.ts:79
ARTICLE_DRAFT_STATE_LIMIT5000local-platform-data.ts:81
STORY_CHAT_SESSION_LIMIT24local-platform-data.ts:80
TENANT_STORY_NOTE_LIMIT80local-platform-data.ts:82
DELIVERY_LOG_LIMIT, TENANT_DELIVERY_LOG_LIMIT, DELIVERY_FAILED_EVENT_LIMIT, REVIEW_STATUS_EVENT_LIMIT500 eachshared/delivery-log.ts:35-38
TENANT_DELIVERY_COUNTER_RETENTION_DAYS35shared/delivery-log.ts:40

The delivery-log module documents the failure this caused. Delivery records used to live in one global 500-entry array written read-then-write: concurrent appends lost entries, and once platform-wide volume passed 500 entries per month the tenant usage page silently undercounted. The fix was to separate the three concerns — a bounded per-tenant raw log for dedup and inspection, exact per-UTC-day counters as the usage page's source of truth, and a bounded global mirror for the admin dashboard — and to route every write through mutateState (src/lib/platform/shared/delivery-log.ts:1-18).

Read-then-writeState on a shared array is a lost-update bug. Use mutateState. If you need an exact count, add a counter — do not count a bounded array.

Hybrid domains: which side is authoritative

Several domains have a table and a KV key, selected at runtime by a repository-availability gate. The table is primary; the KV key is a mirror or fallback.

DomainPrimaryFallback keySelector
Editorial draftseditorial_story_draftsplatform:editorial:article-drafts:<tenant>src/lib/platform/drafts/tenant-draft-store.ts
Story chatstory_chat_sessions (+ payloads)platform:story-chat-sessions:…local-platform-data.ts:135, used from :2203
Editor queueeditor_queue_itemsplatform:editor-queue:*local-platform-data.ts:168, used from :1404
Newsroom profiletenant_newsroomsplatform:newsroom-profile:<scope>newsroom-preferences.ts:1004-1008
Integrations (keys, WordPress, webhooks)tenant_integration_api_keys, tenant_wordpress_connections, tenant_webhook_configs, tenant_webhook_logsplatform:integrations-*shared/integrations-gateway.ts:62-81

Two behaviours are worth knowing before you touch these:

  • Successful DB writes are still mirrored to KV. The draft upsert writes the DB under a compare-and-set, then reflects the post-write version into the KV copy so the cache and the DB do not disagree by one (tenant-draft-store.ts:506-522). The newsroom profile does the same.
  • The integrations gateway returns { ok: false } rather than throwing. runWithIntegrationsRepository hands back a discriminated result and the caller uses the KV records (integrations-gateway.ts:62-81).

Availability is not permanent. createRepositoryAvailability (src/lib/runtime/repository-availability.ts:20-67) disables a repository for a 60 second cooldown (DEFAULT_REPOSITORY_DISABLE_COOLDOWN_MS = 60_000), logs the first disable, and re-logs once per cooldown window so an ongoing outage stays visible instead of being swallowed.

Production has no in-memory fallback

src/lib/platform/shared/persistence.ts decides this in two functions:

export function isInMemoryStoreForced() {
  if (process.env.VERCEL_ENV) {
    return false
  }

  return process.env.ERMIS_FORCE_INMEMORY_STORE === 'true'
}

export function isLocalStateFallbackAllowed() {
  if (isInMemoryStoreForced()) {
    return true
  }

  if (process.env.VERCEL_ENV) {
    return false
  }

  return process.env.NODE_ENV !== 'production'
}

When fallback is not allowed, a failure throws [local-platform-data] <surface> is required in production after <stage>: <error> (persistence.ts:36-46).

An unreachable or unmigrated Postgres 503s every authenticated surface. This is deliberate, not a gap. There is no degraded read-only mode for tenant state. GET /api/health reports checks.database.migrated: false with the detail "platform_state unreachable (run db:migrate before serving traffic): …" and returns HTTP 503 (src/app/api/health/route.ts:53-77, :144). Redis degradation is reported but never fails readiness.

ERMIS_FORCE_INMEMORY_STORE=true is a local-only switch. The presence of VERCEL_ENV — production or preview — wins over the flag, so a stray value copied into a deployment environment can never route tenant state to memory.

The Redis boundary

src/lib/redis/client.ts:17-39 returns a cached singleton, or null when UPSTASH_REDIS_REST_URL/UPSTASH_REDIS_REST_TOKEN are absent or construction fails (logged [redis] Upstash client disabled: …). Every consumer must tolerate null.

That is the complete list of consumers:

ConsumerKeysFailure mode
Edge rate limiting (src/lib/api/rate-limit.ts)ermis:rl:<scope>:<key> sliding windowsFails open
AI usage guardrail windows (src/lib/ai/usage-guardrails.ts:29-105)ermis:ai:tenant:rpm, ermis:ai:user:rp5hFalls back to per-process counters; the per-tenant spend guardrail stays fail-closed
AI ledger retry queue (src/lib/ai/ledger-retry-queue.ts)Lists ermis:ai:ledger:retry:queue, …:dlq, …:processingDeliberately not in Postgres — the failure it covers is "Postgres unavailable"
Compose-stage response cache (src/lib/ai/cache-middleware.ts)ermis:ai-cache:<stage>:…, 6 h default TTL, 128 KB max payload, wrapGenerate onlyIn-memory LRU of 256 entries; fails open
Feed build lock (src/lib/services/rss-aggregation.ts:853-920)ermis:feed:build-lock:<scopeKey>, SET NX PX, 7 min TTLFails open with a bypass token so builds always proceed
Newsroom profile and source-catalog caches (src/lib/platform/newsroom-preferences.ts:360)platform:cache:*, 30 s and 5 min TTLCache miss
Health probe (src/app/api/health/route.ts:80-96)PINGReported, non-fatal

The ledger retry queue claims work with LMOVE into a processing list rather than RPOP, so a crash strands at most one entry, and a reclaim loop drains the processing list at the start of each redrive (ledger-retry-queue.ts:166-189). Redrive is safe to race because ledger persistence is idempotent — a stable client-supplied row id makes re-insert a PK no-op. MAX_ATTEMPTS is 10.

The feed build lock TTL is 7 minutes because a 4-minute TTL expired while minute-five builds were still running and a concurrent trigger started a duplicate whole-catalog AI build. It must exceed the 300 s consumer maxDuration.

REDIS_URL / KV_URL are unrelated to Upstash here — they are required only when NEXT_PUBLIC_ERMIS_CHAT_STREAM_MODE=resume for resumable chat streams.

Object storage

src/lib/storage/object-storage.ts supports two providers: 'supabase' and 'vercel-blob'. resolvePreferredProvider() (object-storage.ts:101-117) takes an explicit OBJECT_STORAGE_PROVIDER first; otherwise Supabase if SUPABASE_URL/NEXT_PUBLIC_SUPABASE_URL plus SUPABASE_SERVICE_ROLE_KEY are set; otherwise Vercel Blob if BLOB_READ_WRITE_TOKEN or BLOB_STORE_ID (the OIDC path) is set; otherwise null.

Path normalization is security-relevant. Leading slashes are stripped and empty, . and .. segments dropped, so a caller-supplied path cannot traverse out of its prefix in a shared public bucket; an empty result throws "Invalid object storage path" (object-storage.ts:67-83).

Public objects

uploadPublicObject defaults to cache-control: 31536000. Two callers:

  • src/app/api/files/upload/route.ts:68 — key uploads/<userId>/<uuid>.<png|jpg>, derived server-side and never from the client filename (route.ts:59-63).
  • src/lib/sources/source-icon-resolver.ts:451 — key source-icons/<host>.

getPublicObjectUrl(path) builds <url>/storage/v1/object/public/<bucket>/<path> on Supabase. Vercel Blob has no path-to-URL helper, so it needs BLOB_PUBLIC_BASE_URL and returns null without it (object-storage.ts:251-279).

The private archive split

Webhook payload archives carry PII — Clerk and Polar webhook bodies contain emails, names and billing ids — so they use a true access: 'private' ACL.

Vercel Blob store access is fixed at store creation. The shared public store rejects private puts outright, which is what broke the Clerk and Polar webhook archive path on 2026-07-22. Private archives therefore live on a separate private store, reached through BLOB_ARCHIVE_READ_WRITE_TOKEN. An in-place put({ access: 'private' }) against the public store fails; converting requires creating a new private store and moving the objects with scripts/storage/fix-archive-blob-access.ts.

uploadPrivateArchiveObject (object-storage.ts:281-319) prefixes the path with archive/, sets addRandomSuffix: true, stamps bucket with the marker 'private-archive', and nulls the returned url so no fetchable location is ever persisted. Deletion works from the pathname alone.

Without the archive token the archive is not written at all and the function returns null; the caller then inlines the payload in its DB row (object-storage.ts:301-305). That is still non-public, but see the retention trap below.

deletePrivateArchiveObject(locator) (object-storage.ts:337-381) routes by the recorded provider and bucket, not the currently configured one, so post-cutover deletes still find pre-cutover objects. bucket === 'private-archive' means the private store and requires the archive token — it throws a descriptive error if the token is missing. bucket === null means a pre-split row on the shared public store.

removePublicObjectsByPrefix(prefix) (object-storage.ts:470-492) is what GDPR erasure uses to purge uploads/<userId>/. The Supabase path walks the folder tree with 1000-item pagination and removes in batches; the Blob path pages list() and deletes each page before fetching the next cursor.

Payload archive and retention

src/lib/storage/payload-archive.ts computes a SHA-256 checksum, a preview capped at 2048 characters, and a path archives/<domain>/<YYYY>/<MM>/<DD>/<uuid>.json. If the upload returns null, the record keeps payload inline and leaves every storage column null (payload-archive.ts:53-63). Callers are src/lib/db/clerk-user-repository.ts:781 (domain clerk-webhooks) and src/lib/db/billing-repository.ts:926 (domain billing-webhooks).

Both repositories prune webhook rows older than 30 days (WEBHOOK_EVENT_RETENTION_DAYS) at most every 6 hours (WEBHOOK_EVENT_PRUNE_INTERVAL_MS) and delete the corresponding blobs.

Rows with inline payloads are never pruned. Both prune queries require payload_storage_path IS NOT NULL (clerk-user-repository.ts:79-88, billing-repository.ts:200-208). So when BLOB_ARCHIVE_READ_WRITE_TOKEN is unset and payloads inline into the payload column, those PII-bearing rows grow without bound and no retention job can reach them. BLOB_ARCHIVE_READ_WRITE_TOKEN is tracked as a launch-critical variable for exactly this reason (src/lib/platform/launch-readiness.ts:31-41), reported under config in GET /api/health.

Where a tenant's data actually lives

Useful when answering "what is stored for this workspace". The GDPR purge is the authoritative enumeration — one transaction deletes, in order: ai_usage_metering_outbox, ai_usage_ledger, editor_queue_items, editorial_story_drafts, story_chat_session_payloads, story_chat_sessions, tenant_webhook_logs, tenant_webhook_configs, tenant_wordpress_connections, tenant_integration_api_keys, tenant_newsrooms, billing_checkout_sessions, billing_portal_sessions, billing_invoices, tenant_subscriptions, tenant_memberships, then the tenant's slice of platform_state and platform_state_hash matched by LIKE '%<escaped scope id>%' (purgeTenantData, src/lib/db/data-erasure-repository.ts:103). After commit it purges uploads/<userId>/ and drains the Redis ledger retry queue for the scope. Per-table counts land in data_erasure_requests.purge_report.

That LIKE '%<scope>%' match is only possible because every KV key embeds the scope id. A key that does not carry the scope id is invisible to erasure. The function refuses a blank scope id outright, because a blank one would turn the scoped delete into LIKE '%%' and wipe every tenant's state (data-erasure-repository.ts:115-120).

Secrets at rest

src/lib/security/secret-encryption.ts wraps tenant outbound-webhook secrets in an AES-256-GCM envelope, enc:v1:<iv>:<authTag>:<ciphertext>, keyed by WEBHOOK_SECRET_ENCRYPTION_KEY (32 bytes, base64 or hex).

When the key is unset, encryptSecret is a no-op returning plaintext and decryptSecret passes through anything without the enc:v1: prefix. That makes the feature switchable on later with no migration — and means an unset key stores webhook secrets in the clear, silently.

Migration layout

Migrations use the drizzle-kit v1 rc layout: one directory per migration containing migration.sql and snapshot.json. There is deliberately no drizzle/meta/ directory and no _journal.json; applied state lives in the drizzle.__drizzle_migrations table in the database. Nineteen migration directories exist, from 20260507070419_fearless_praxagora (initial schema) to 20260722020726_statement_timeout_role_default.

Full rules — the production migrate-then-build gate, enum ordering, and the direct-versus-pooled URL split — are in Migrations and schema changes.

På denne siden