Data model
The two-world model, the users→workspaces→websites→pages/sections ownership chain, and the ~56 Convex tables grouped by concern.
The full Convex table reference is grounded in convex/schema.ts (~56 tables).
The published-snapshot shape lives in convex/model/snapshot.ts; the section
content union in convex/model/sections.ts.
The load-bearing idea: the two-world model
You edit normalized draft tables; publishing writes an immutable
snapshot (siteVersions.snapshot) that the public site reads. Draft edits
never change the live site until republish. This separation is the single most
important property of the data model — see
Publishing.
Ownership chain
users → workspaces → websites → pages/sectionsEvery content table denormalizes websiteId so one indexed read both
fetches a row and authorizes it. Authz helpers (convex/lib/authz.ts) verify
membership before any client-supplied id is used — see
Auth & permissions.
Conventions:
- Index names are
by_<field>. - Sections, pages, and members are their own rows, never arrays.
- High-churn data (analytics, presence, history) is isolated in its own tables.
- Every new field is optional so existing rows stay valid — no destructive migrations.
Identity & access
| Table | Purpose | Key fields |
|---|---|---|
users | Account identity | tokenIdentifier, clerkUserId, email, locale |
workspaces | Owner's workspace = billing + credit container | ownerUserId, plan, creditsBalance, Stripe ids |
workspaceMembers | Access to all sites in a workspace | workspaceId, userId, role (owner/editor/viewer) |
websiteMembers | Access to one site | websiteId, userId, role (editor/viewer) |
invites | Pending shares (email-bound, single-use, expiring) | scope, email, role, token, status |
previewLinks | Shareable /p/<token> preview (one row / site) | token, mode (public/password), passwordHash, expiresAt |
previewGrants | 24h password-unlock session for a preview link | linkId, grantToken, expiresAt |
Site content (draft world)
| Table | Purpose | Key fields |
|---|---|---|
websites | Site aggregate root: brand, theme, contact, locales, publishedVersionId, dirty timestamps | workspaceId, slug, vertical, goal, theme, status, homePageId, draftUpdatedAt |
pages | A page or a post | websiteId, slug, title, pageType, order, folderId, showInNav, seo |
pageFolders | Editor-only nav folders (stripped at publish) | websiteId, name, order, parentFolderId, collapsed |
sections | One content block per row | websiteId, pageId, type, variant, tone, order (LexoRank), content (union), hidden, rev (OCC) |
services | Phase S canonical offering (book/quote/call/…) | websiteId, name, price fields, bookable, primaryAction, availability, rev |
themes | Global theme presets | key, label, tokens, recommendedVerticals |
assets | Uploaded / AI images, logo, favicon | workspaceId, websiteId?, storageId, kind, category, status, source, parentAssetId/rootAssetId |
imageCollections | Workspace-scoped asset folders (Image Studio) | workspaceId, name, order |
fonts | Custom typefaces (upload/Google/Adobe) | workspaceId, websiteId, source, family |
Posts are pages
A news article is not a new table — it is a pages row with
pageType: "post", reusing the whole page machinery (section canvas, publish →
snapshot, sitemap, translation). Post-only fields on pages:
| Field | Purpose |
|---|---|
pageType: "page" | "post" | absent / "page" = normal page; "post" = article |
excerpt? | list/article summary + default meta/OG description |
featuredImage? | lead image for the /news card + article header |
firstPublishedAt? | stable article date (set once on first publish, then editable) — distinct from publishedAt (bumped every publish for dirty-tracking) |
contentType? | AI-detected kind (news/offer/guide/…); owner-overridable |
aiReview? | draft-only AI suggestions — never snapshotted or exported |
plannedFor? / plannedReminderAt? | content-calendar target date (planning only; never auto-publishes) |
Posts are created showInNav: false, filtered out of the Pages tree, and served
under /news/<slug>. Unpublishing a post marks excludeFromPublish and
drops it from the snapshot entirely (unlike a held normal page, which stays
frozen-live).
Publishing & history
| Table | Purpose | Key fields |
|---|---|---|
siteVersions | Immutable published snapshots (+ per-locale) | websiteId, versionNumber, snapshot, localizedSnapshots? |
restorePoints | Version-history timeline (normalized draft copies) | websiteId, kind (auto/manual/publish/pre_restore/pre_ai), draft, publishedVersionId? |
changeHistory | Undo / redo ops | websiteId, actorToken, kind, inverse, undone, redoInverse |
activityEvents | Company-home activity feed | websiteId, type, title?, createdAt |
siteVersions.snapshot is the denormalized public delivery copy;
restorePoints.draft is the normalized copy a restore writes back. They are
different shapes on purpose.
AI / generation
| Table | Purpose | Key fields |
|---|---|---|
generationJobs | Whole-site AI builds | websiteId, kind, status, usedLlm |
imageJobs | Per-image AI request (generate/edit/variations) | workspaceId, kind, status, prompt, resultAssetIds, credits |
chatThreads | Editor AI-chat conversations | websiteId, title, summary/summarizedUpTo |
chatMessages | AI-chat messages | websiteId, threadId?, role, content, edits[], sources? |
chatSteps | Ephemeral live progress for the in-flight chat turn; deleted on finish | websiteId, threadId, turnId, kind, status |
websites.vertical (the business type, a.k.a. "starter kit") is an enum of 11
keys (dentist, clinic, salon, cleaning, restaurant, fitness,
handyman, consultant, coach, freelancer, generic). It is the only
persisted artifact of the starter-kit system — a code-side registry drives the
first-draft recipe. Adding a business type only adds an enum literal; existing
rows stay valid. See the AI system.
Billing & credits
| Table | Purpose | Key fields |
|---|---|---|
creditLedger | Append-only AI credit movements | workspaceId, delta, balanceAfter, type, reason, jobId? |
billingEvents | Stripe webhook idempotency | stripeEventId, type, processedAt |
dfyOrders | "Done-for-you" add-on fulfilment | workspaceId, tier, status |
Run-the-business modules
| Table | Purpose | Key fields |
|---|---|---|
leadSubmissions | Public form captures + inbox state | websiteId, sectionType, fields, status, handledAt? |
contacts | Lightweight CRM (dedupe by email) | websiteId, email, counts, status |
contactEvents | Contact interaction timeline | contactId, kind (lead/booking/note/quote/invoice/review/call) |
bookings | Native appointments (runtime; never snapshotted) | websiteId, serviceId, date/startMin, customer, status |
offers | Quotes ("offert") — own number series | websiteId, number/seq, lineItems, vatRate, status, token |
invoices | Invoices — Swedish gap-free numbering | websiteId, number/seq, lineItems, vatRate, status, token |
payments | Stripe Checkout attempts (direct charge on connected acct) | invoiceId, sessionId, paymentIntentId?, status |
recurringInvoices | Auto-invoice templates (cron) | websiteId, intervalMonths, nextRunAt, active |
replyTemplates | Canned inbox replies | websiteId, title, body, order |
analyticsEvents | Day-bucketed, no-PII events | websiteId, type, pageSlug?, serviceSlug?, day |
maintenanceFindings | In-app QA nudges | websiteId, severity, code, message{sv,en}, resolved |
onboardingDrafts | Onboarding working state (pre-site) | workspaceId, business fields, uploadAssetIds, step |
Domains
| Table | Purpose | Key fields |
|---|---|---|
domains | Connected website addresses | websiteId, hostname, status (lifecycle), isPrimary, verificationToken, Vercel ids |
domainPurchaseIntents | Buy-a-domain attempts (payment before register) | websiteId, domain, status, stripeSessionId?, domainId? |
domainEvents | Domain audit trail (debugging) | websiteId, type, data |
Secrets are never stored on
domains; host routing keys offstatus === "active"/ LIVE.
Infra
| Table | Purpose | Key fields |
|---|---|---|
presence | Live multiplayer editor presence (ephemeral; GC cron) | websiteId, sessionId, pageId, cursor, lastSeen |
rateLimits | Fixed-window abuse counters for public writes | key, windowStart, count |
Deliberate absences
The model is comprehensive; a few omissions are by design:
- No separate "published pages/sections" tables — publishing denormalizes
into one
siteVersions.snapshotblob, not parallel tables. - No
customerActionstable — customer actions (book/quote/call/message/ pay/review) are typed CTAs +contactEvents, not a pipeline. - No email-hosting tables — Sajt is never a mail host.
Back-compat & durability
Every new field is optional, so pre-feature snapshots still validate. Post fields
and contentType round-trip through restore points
(convex/model/draftSnapshot.ts) and export/import (convex/model/portable.ts).
The draft-only aiReview / plannedReminderAt are intentionally not
snapshotted or exported.