Sajt · Docs

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/sections

Every 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

TablePurposeKey fields
usersAccount identitytokenIdentifier, clerkUserId, email, locale
workspacesOwner's workspace = billing + credit containerownerUserId, plan, creditsBalance, Stripe ids
workspaceMembersAccess to all sites in a workspaceworkspaceId, userId, role (owner/editor/viewer)
websiteMembersAccess to one sitewebsiteId, userId, role (editor/viewer)
invitesPending shares (email-bound, single-use, expiring)scope, email, role, token, status
previewLinksShareable /p/<token> preview (one row / site)token, mode (public/password), passwordHash, expiresAt
previewGrants24h password-unlock session for a preview linklinkId, grantToken, expiresAt

Site content (draft world)

TablePurposeKey fields
websitesSite aggregate root: brand, theme, contact, locales, publishedVersionId, dirty timestampsworkspaceId, slug, vertical, goal, theme, status, homePageId, draftUpdatedAt
pagesA page or a postwebsiteId, slug, title, pageType, order, folderId, showInNav, seo
pageFoldersEditor-only nav folders (stripped at publish)websiteId, name, order, parentFolderId, collapsed
sectionsOne content block per rowwebsiteId, pageId, type, variant, tone, order (LexoRank), content (union), hidden, rev (OCC)
servicesPhase S canonical offering (book/quote/call/…)websiteId, name, price fields, bookable, primaryAction, availability, rev
themesGlobal theme presetskey, label, tokens, recommendedVerticals
assetsUploaded / AI images, logo, faviconworkspaceId, websiteId?, storageId, kind, category, status, source, parentAssetId/rootAssetId
imageCollectionsWorkspace-scoped asset folders (Image Studio)workspaceId, name, order
fontsCustom 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:

FieldPurpose
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

TablePurposeKey fields
siteVersionsImmutable published snapshots (+ per-locale)websiteId, versionNumber, snapshot, localizedSnapshots?
restorePointsVersion-history timeline (normalized draft copies)websiteId, kind (auto/manual/publish/pre_restore/pre_ai), draft, publishedVersionId?
changeHistoryUndo / redo opswebsiteId, actorToken, kind, inverse, undone, redoInverse
activityEventsCompany-home activity feedwebsiteId, 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

TablePurposeKey fields
generationJobsWhole-site AI buildswebsiteId, kind, status, usedLlm
imageJobsPer-image AI request (generate/edit/variations)workspaceId, kind, status, prompt, resultAssetIds, credits
chatThreadsEditor AI-chat conversationswebsiteId, title, summary/summarizedUpTo
chatMessagesAI-chat messageswebsiteId, threadId?, role, content, edits[], sources?
chatStepsEphemeral live progress for the in-flight chat turn; deleted on finishwebsiteId, 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

TablePurposeKey fields
creditLedgerAppend-only AI credit movementsworkspaceId, delta, balanceAfter, type, reason, jobId?
billingEventsStripe webhook idempotencystripeEventId, type, processedAt
dfyOrders"Done-for-you" add-on fulfilmentworkspaceId, tier, status

Run-the-business modules

TablePurposeKey fields
leadSubmissionsPublic form captures + inbox statewebsiteId, sectionType, fields, status, handledAt?
contactsLightweight CRM (dedupe by email)websiteId, email, counts, status
contactEventsContact interaction timelinecontactId, kind (lead/booking/note/quote/invoice/review/call)
bookingsNative appointments (runtime; never snapshotted)websiteId, serviceId, date/startMin, customer, status
offersQuotes ("offert") — own number serieswebsiteId, number/seq, lineItems, vatRate, status, token
invoicesInvoices — Swedish gap-free numberingwebsiteId, number/seq, lineItems, vatRate, status, token
paymentsStripe Checkout attempts (direct charge on connected acct)invoiceId, sessionId, paymentIntentId?, status
recurringInvoicesAuto-invoice templates (cron)websiteId, intervalMonths, nextRunAt, active
replyTemplatesCanned inbox replieswebsiteId, title, body, order
analyticsEventsDay-bucketed, no-PII eventswebsiteId, type, pageSlug?, serviceSlug?, day
maintenanceFindingsIn-app QA nudgeswebsiteId, severity, code, message{sv,en}, resolved
onboardingDraftsOnboarding working state (pre-site)workspaceId, business fields, uploadAssetIds, step

Domains

TablePurposeKey fields
domainsConnected website addresseswebsiteId, hostname, status (lifecycle), isPrimary, verificationToken, Vercel ids
domainPurchaseIntentsBuy-a-domain attempts (payment before register)websiteId, domain, status, stripeSessionId?, domainId?
domainEventsDomain audit trail (debugging)websiteId, type, data

Secrets are never stored on domains; host routing keys off status === "active" / LIVE.

Infra

TablePurposeKey fields
presenceLive multiplayer editor presence (ephemeral; GC cron)websiteId, sessionId, pageId, cursor, lastSeen
rateLimitsFixed-window abuse counters for public writeskey, 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.snapshot blob, not parallel tables.
  • No customerActions table — 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.

On this page