Last Updated: 2026-09-05 · Status: Active
Own Neon Postgres (
vlozi-engagement), separate from core. Schema insrc/db/schema.ts, migrations indrizzle/.
The tables
| Table | Primary key | Holds |
|---|---|---|
eng_sites |
id |
one embeddable surface per tenant: origins, Turnstile key, status |
eng_settings |
tenant_id |
every tenant-level policy — one row, or none |
eng_targets |
(tenant_id, target_key) |
what's being engaged with, plus a verified title/slug snapshot |
eng_target_stats |
(tenant_id, target_key) |
all-time counters |
eng_target_daily |
(tenant_id, target_key, day) |
per-day counters, for series and windows |
eng_reactions |
id |
one row per (visitor, target, kind) |
eng_comments |
id |
the comments themselves, all statuses |
eng_bans |
id |
one tenant's banned emails and IPs |
eng_flags |
id |
visitor reports |
eng_members |
id |
verified readers |
eng_webhook_deliveries |
id |
one row per delivery attempt |
tenant_engagement_usage |
tenant_id |
the credit accumulator |
eng_platform_blocklist |
id |
no tenant_id — cross-tenant abuse blocking |
Every table carries tenant_id except eng_platform_blocklist, and that absence is the whole point of it: a spammer hitting fifty customers shouldn't need fifty bans.
Why the counters are split three ways
eng_target_stats (all-time), eng_target_daily (per-day) and the Durable Object's own SQLite (the last minute) look redundant. They aren't:
- The DO absorbs the write rate. A post on the front page of Hacker News is thousands of views a minute; a Postgres upsert per view would melt.
eng_target_statsanswers "how many views does this post have?" in one indexed read. Summingeng_target_dailyfor an all-time count would scan every day ever.eng_target_dailyanswers "the last 30 days" and every chart.eng_target_statscannot, because it's a single number with no history.
Writing both on each flush costs one extra upsert per target per minute, which is nothing next to what either alternative costs on read.
Where each number actually comes from
| Number | Source | Exact? |
|---|---|---|
| Views, reads, uniques | DO → eng_target_stats / eng_target_daily |
exact, up to a minute stale |
| Rating average, histogram | eng_target_stats, written synchronously by the reaction route |
exact, immediate |
| Comment count | eng_target_stats.comments_count, adjusted on publish/unpublish |
exact, immediate |
| Reading now | the DO's active table, five-minute window |
live, never persisted |
| Referrer / country / device / hour | Analytics Engine | sampled estimate |
The last row is the one to be careful with. AE samples above a write-rate threshold and reports a multiplier; every count is scaled back up by SUM(_sample_interval), which makes the figures comparable with each other but not with the Postgres rollups. Never reconcile billing against them.
Notable columns
eng_comments.author_email_hash — sha256 of the lowercased address. It powers the "has this person been approved before?" lookup without a second plaintext copy sitting in an index.
eng_comments.published_at — never cleared, even on an unpublish. It doubles as the "has this comment ever been billed?" marker, which is what stops a published → edited → re-approved comment from charging twice.
eng_comments.parent_id — a self-reference with no foreign key and no cascade. Deleting a parent leaves replies in place; parent_id is only used for display grouping, and cascading would delete other people's comments because one person's was removed.
eng_members.session_version — the only revocation lever. Sessions are stateless signed tokens, so bumping this integer is what makes "log out everywhere" and a ban take effect immediately.
eng_members.email_hash — unique per (tenant_id, email_hash). GDPR erasure changes it along with the address, or the original would stay confirmable by guessing.
eng_settings.notify.lastDigestAt / lastWeeklyAt — JSONB bookkeeping that makes the digest crons safe to over-call.
eng_settings defaults
getSettings returns a fully-populated object when no row exists, so a tenant who never opened Settings reads a complete object without forcing a write.
| Column | Default |
|---|---|
comments_mode |
"guest" |
moderation_mode |
"first_time" |
hold_link_threshold |
2 |
close_after_days |
null |
ai_moderation |
true |
reactions_enabled |
["like"] |
ratings_enabled |
false |
views_public |
true |
display |
{"hideViewsBelow":10,"hideRatingBelow":3} |
notify |
{"mode":"digest_daily","to":[]} |
banned_words, blocked_email_domains, webhook_events |
[] |
webhook_url, webhook_secret, newsletter_form_id |
null |
moderation_mode: "first_time" and ai_moderation: true together mean a brand-new tenant gets held first comments and AI screening without configuring anything. That is deliberate — the safe default for a surface facing the open internet — and it does surprise people writing their first test.
Privacy
| Kept | Not kept |
|---|---|
author_email on comments (the tenant needs to reply) |
raw IP on a view or read |
source_ip + user_agent on a comment, raw, cleared after 30 days by /cron/purge-comment-ips |
any cookie, ever |
email_hash for lookups |
the daily visitor_hash — computed, used, discarded |
visitor_id for reactions (client-generated) |
any cross-site identifier |
A reader who only reads has nothing written to their browser and nothing identifying written to our database.
Migrations
drizzle-kit generate into drizzle/, journal version 7.
| 0001–0003 | Phases 1, 5, 6, 8 |
| 0004 | eng_members |
| 0005 | eng_platform_blocklist |
ENGAGEMENT_DATABASE_URL="postgres://…" npx drizzle-kit generate # from apps/engagement-service
pnpm --filter engagement-service migrateTests run the real migrations against PGlite (test/setup.ts), so a migration that doesn't apply cleanly fails the suite rather than the deploy.