-- computer database schema -- Derived from computer_porting_guide.md section 2 and prisma/schema.prisma. -- IDs are text (CUID/UUID). CASCADE / SetNull per the guide. -- Run against an EMPTY database. Never drops or recreates a database. BEGIN; -- Trigram indexes back the page search substring predicate (review PERF-2). -- pg_trgm is a trusted extension, so the database owner can enable it. CREATE EXTENSION IF NOT EXISTS pg_trgm; -- 2.1 legacy enum CREATE TYPE "KanbanStatus" AS ENUM ('INBOX','TODO','IN_PROGRESS','DONE','ARCHIVED'); -- 2.2 User CREATE TABLE "User" ( id text PRIMARY KEY, name text, email text UNIQUE, "emailVerified" timestamptz, image text, password text, role text NOT NULL DEFAULT 'USER' CHECK (role IN ('USER','ADMIN')), "tokenVersion" integer NOT NULL DEFAULT 1, "createdAt" timestamptz NOT NULL DEFAULT now(), "updatedAt" timestamptz NOT NULL DEFAULT now() ); -- 2.3 Account, Session, VerificationToken CREATE TABLE "Account" ( id text PRIMARY KEY, "userId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, type text NOT NULL, provider text NOT NULL, "providerAccountId" text NOT NULL, "refresh_token" text, "access_token" text, "expires_at" int, "token_type" text, scope text, "id_token" text, "session_state" text, UNIQUE(provider, "providerAccountId") ); CREATE INDEX "Account_userId_idx" ON "Account"("userId"); CREATE TABLE "Session" ( id text PRIMARY KEY, "sessionToken" text NOT NULL UNIQUE, "userId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, expires timestamptz NOT NULL ); CREATE INDEX "Session_userId_idx" ON "Session"("userId"); CREATE TABLE "VerificationToken" ( identifier text NOT NULL, token text NOT NULL UNIQUE, expires timestamptz NOT NULL, UNIQUE(identifier, token) ); -- 2.4 Bookmark CREATE TABLE "Bookmark" ( id text PRIMARY KEY, url text NOT NULL, title text, description text, favicon text, "ogImage" text, "ogTitle" text, "ogDescription" text, notes text, "dueDate" timestamptz, "alertAt" timestamptz, "alertSent" boolean NOT NULL DEFAULT false, "kanbanStatus" "KanbanStatus" NOT NULL DEFAULT 'INBOX', "kanbanColumnId" text, -- Board ordering within a column. Server-normalized on every card move; -- legacy rows default to 0 and fall back to updatedAt-desc ordering. "kanbanPosition" integer NOT NULL DEFAULT 0, "imapWatchEnabled" boolean NOT NULL DEFAULT false, "imapQuery" text, "isPublic" boolean NOT NULL DEFAULT false, "ownerId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, "contactId" text, -- Optimistic concurrency revision (review CR-04): every owner-driven PUT -- (fields and tag replacement) requires the expected version and bumps it; -- the column-deletion move bumps it too. Sharing and server-driven flags -- (alertSent) are outside the contract — see API.md. version integer NOT NULL DEFAULT 0, "createdAt" timestamptz NOT NULL DEFAULT now(), "updatedAt" timestamptz NOT NULL DEFAULT now() ); CREATE INDEX "Bookmark_ownerId_idx" ON "Bookmark"("ownerId"); CREATE INDEX "Bookmark_ownerId_updatedAt_id_idx" ON "Bookmark"("ownerId","updatedAt" DESC,id DESC); CREATE INDEX "Bookmark_contactId_idx" ON "Bookmark"("contactId"); CREATE INDEX "Bookmark_kanbanStatus_idx" ON "Bookmark"("kanbanStatus"); CREATE INDEX "Bookmark_kanbanColumnId_idx" ON "Bookmark"("kanbanColumnId"); CREATE INDEX "Bookmark_ownerId_kanbanColumnId_kanbanPosition_idx" ON "Bookmark"("ownerId","kanbanColumnId","kanbanPosition",id); CREATE INDEX "Bookmark_dueDate_idx" ON "Bookmark"("dueDate"); CREATE INDEX "Bookmark_alertAt_idx" ON "Bookmark"("alertAt"); -- 2.5 Tag CREATE TABLE "Tag" ( id text PRIMARY KEY, name text NOT NULL, color text NOT NULL DEFAULT '#6366f1', "ownerId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, "createdAt" timestamptz NOT NULL DEFAULT now(), "updatedAt" timestamptz NOT NULL DEFAULT now(), UNIQUE(name, "ownerId") ); CREATE INDEX "Tag_ownerId_idx" ON "Tag"("ownerId"); -- 2.6 BookmarkTag CREATE TABLE "BookmarkTag" ( "bookmarkId" text NOT NULL REFERENCES "Bookmark"(id) ON DELETE CASCADE, "tagId" text NOT NULL REFERENCES "Tag"(id) ON DELETE CASCADE, PRIMARY KEY ("bookmarkId", "tagId") ); CREATE INDEX "BookmarkTag_tagId_idx" ON "BookmarkTag"("tagId"); -- 2.7 BookmarkShare CREATE TABLE "BookmarkShare" ( "bookmarkId" text NOT NULL REFERENCES "Bookmark"(id) ON DELETE CASCADE, "userId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, "createdAt" timestamptz NOT NULL DEFAULT now(), PRIMARY KEY ("bookmarkId", "userId") ); CREATE INDEX "BookmarkShare_userId_idx" ON "BookmarkShare"("userId"); -- 2.8 TagShare CREATE TABLE "TagShare" ( "tagId" text NOT NULL REFERENCES "Tag"(id) ON DELETE CASCADE, "userId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, "createdAt" timestamptz NOT NULL DEFAULT now(), PRIMARY KEY ("tagId", "userId") ); CREATE INDEX "TagShare_userId_idx" ON "TagShare"("userId"); -- 2.9 Contact CREATE TABLE "Contact" ( id text PRIMARY KEY, uid text NOT NULL, "displayName" text NOT NULL, email text, phone text, "cardDavUrl" text, -- CardDAV provenance (review CR-07): the address-book URL this contact was -- imported from. Deletion reconciliation is scoped to it, so manually -- created contacts (NULL) and other address books' imports are never -- touched by a sync. source text, "userId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, "createdAt" timestamptz NOT NULL DEFAULT now(), "updatedAt" timestamptz NOT NULL DEFAULT now(), UNIQUE(uid, "userId") ); CREATE INDEX "Contact_userId_idx" ON "Contact"("userId"); ALTER TABLE "Bookmark" ADD CONSTRAINT "Bookmark_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "Contact"(id) ON DELETE SET NULL; -- 2.10 ImapConfig CREATE TABLE "ImapConfig" ( id text PRIMARY KEY, host text NOT NULL, port int NOT NULL DEFAULT 993, tls boolean NOT NULL DEFAULT true, username text NOT NULL, password text NOT NULL, folder text NOT NULL DEFAULT 'INBOX', "userId" text UNIQUE NOT NULL REFERENCES "User"(id) ON DELETE CASCADE ); -- 2.11 KanbanColumn CREATE TABLE "KanbanColumn" ( id text PRIMARY KEY, label text NOT NULL, color text NOT NULL DEFAULT '#64748b', position int NOT NULL DEFAULT 0, "key" text, "userId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, "createdAt" timestamptz NOT NULL DEFAULT now() ); CREATE INDEX "KanbanColumn_userId_position_idx" ON "KanbanColumn"("userId", position); -- Bookmark.kanbanColumnId -> KanbanColumn (SetNull) ALTER TABLE "Bookmark" ADD CONSTRAINT "Bookmark_kanbanColumnId_fkey" FOREIGN KEY ("kanbanColumnId") REFERENCES "KanbanColumn"(id) ON DELETE SET NULL; -- 2.12 Page CREATE TABLE "Page" ( id text PRIMARY KEY, title text NOT NULL, content text NOT NULL DEFAULT '', icon text, pinned boolean NOT NULL DEFAULT false, position int NOT NULL DEFAULT 0, -- Connected pages (phase 3): a page may live under another page. Deleting -- the parent promotes its children to the top level instead of deleting -- them; cycle rejection is enforced by the API. "parentId" text REFERENCES "Page"(id) ON DELETE SET NULL, -- Optimistic concurrency revision (review DATA-01): content saves bump it; -- a stale base version in PATCH is rejected with 409. version integer NOT NULL DEFAULT 1, "ownerId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, "createdAt" timestamptz NOT NULL DEFAULT now(), "updatedAt" timestamptz NOT NULL DEFAULT now() ); CREATE INDEX "Page_ownerId_position_idx" ON "Page"("ownerId", position); CREATE INDEX "Page_parentId_idx" ON "Page"("parentId"); -- Index-backed substring search over the two searchable fields. CREATE INDEX "Page_title_trgm_idx" ON "Page" USING gin (title gin_trgm_ops); CREATE INDEX "Page_content_trgm_idx" ON "Page" USING gin (content gin_trgm_ops); -- 2.13 PageBookmark CREATE TABLE "PageBookmark" ( "pageId" text NOT NULL REFERENCES "Page"(id) ON DELETE CASCADE, "bookmarkId" text NOT NULL REFERENCES "Bookmark"(id) ON DELETE CASCADE, position int NOT NULL DEFAULT 0, "createdAt" timestamptz NOT NULL DEFAULT now(), PRIMARY KEY ("pageId", "bookmarkId") ); CREATE INDEX "PageBookmark_bookmarkId_idx" ON "PageBookmark"("bookmarkId"); -- 2.13b PageLink: resolved internal page links, rebuilt from page content on -- every content save. Targets are only stored when the author owns the target -- page, so a backlink can never reveal an inaccessible title. Self-links are -- ignored by the writer and refused here as defense in depth. CREATE TABLE "PageLink" ( "sourcePageId" text NOT NULL REFERENCES "Page"(id) ON DELETE CASCADE, "targetPageId" text NOT NULL REFERENCES "Page"(id) ON DELETE CASCADE, "createdAt" timestamptz NOT NULL DEFAULT now(), PRIMARY KEY ("sourcePageId", "targetPageId"), CONSTRAINT "PageLink_no_self" CHECK ("sourcePageId" <> "targetPageId") ); CREATE INDEX "PageLink_targetPageId_idx" ON "PageLink"("targetPageId"); -- 2.14 SignalSource CREATE TABLE "SignalSource" ( id text PRIMARY KEY, type text NOT NULL, config jsonb NOT NULL DEFAULT '{}', enabled boolean NOT NULL DEFAULT true, "lastCheckedAt" timestamptz, "lastStatus" text, "bookmarkId" text NOT NULL REFERENCES "Bookmark"(id) ON DELETE CASCADE, "ownerId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, "createdAt" timestamptz NOT NULL DEFAULT now(), "updatedAt" timestamptz NOT NULL DEFAULT now() ); CREATE INDEX "SignalSource_bookmarkId_idx" ON "SignalSource"("bookmarkId"); CREATE INDEX "SignalSource_ownerId_idx" ON "SignalSource"("ownerId"); -- 2.14b SignalCheckpoint (review SYNC-02): durable per-source mailbox -- ingestion state. A poll processes only UIDs newer than lastUid while -- uidValidity matches; the checkpoint advances only after the batch's -- signals are durably inserted. A uidValidity change resets ingestion. CREATE TABLE "SignalCheckpoint" ( "sourceId" text PRIMARY KEY REFERENCES "SignalSource"(id) ON DELETE CASCADE, "uidValidity" bigint NOT NULL, "lastUid" bigint NOT NULL, "updatedAt" timestamptz NOT NULL DEFAULT now() ); -- 2.15 Signal CREATE TABLE "Signal" ( id text PRIMARY KEY, "sourceType" text NOT NULL, title text NOT NULL, summary text, url text, "externalId" text, "occurredAt" timestamptz NOT NULL DEFAULT now(), read boolean NOT NULL DEFAULT false, "sourceId" text, "bookmarkId" text NOT NULL REFERENCES "Bookmark"(id) ON DELETE CASCADE, "ownerId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, "createdAt" timestamptz NOT NULL DEFAULT now(), UNIQUE("sourceId", "externalId") ); CREATE INDEX "Signal_bookmarkId_occurredAt_idx" ON "Signal"("bookmarkId", "occurredAt"); CREATE INDEX "Signal_ownerId_occurredAt_idx" ON "Signal"("ownerId", "occurredAt"); ALTER TABLE "Signal" ADD CONSTRAINT "Signal_sourceId_fkey" FOREIGN KEY ("sourceId") REFERENCES "SignalSource"(id) ON DELETE SET NULL; -- 2.16 AppSetting: server-owned singleton settings rows. 'adminBootstrap' -- records that initial provisioning completed (review AUTH-06), so the -- absence of a qualifying administrator never re-opens first-signup -- promotion after a demotion or removal. CREATE TABLE "AppSetting" ( key text PRIMARY KEY, value jsonb NOT NULL DEFAULT '{}', "updatedAt" timestamptz NOT NULL DEFAULT now() ); -- 2.16 SharedDate CREATE TABLE "SharedDate" ( id text PRIMARY KEY, date date NOT NULL, note text, "creatorId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, "recipientId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, "createdAt" timestamptz NOT NULL DEFAULT now() ); CREATE INDEX "SharedDate_recipientId_idx" ON "SharedDate"("recipientId"); CREATE INDEX "SharedDate_creatorId_idx" ON "SharedDate"("creatorId"); CREATE INDEX "SharedDate_date_idx" ON "SharedDate"(date); CREATE INDEX "SharedDate_creatorId_recipientId_date_idx" ON "SharedDate"("creatorId","recipientId",date); CREATE INDEX "Bookmark_ownerId_alertAt_unsent_idx" ON "Bookmark"("ownerId","alertAt") WHERE NOT "alertSent"; -- 2.17 Collections (Notion-like upgrades phase 2; see 002_collections.sql for -- the design decisions). Contextual workflow state: the same bookmark can -- live in many collections with independent property values; membership -- never broadens access and never owns the bookmark. CREATE TABLE "Collection" ( id text PRIMARY KEY, name text NOT NULL, description text, icon text, "ownerId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, version integer NOT NULL DEFAULT 1, "createdAt" timestamptz NOT NULL DEFAULT now(), "updatedAt" timestamptz NOT NULL DEFAULT now() ); CREATE INDEX "Collection_ownerId_idx" ON "Collection"("ownerId"); CREATE TABLE "CollectionProperty" ( id text PRIMARY KEY, "collectionId" text NOT NULL REFERENCES "Collection"(id) ON DELETE CASCADE, name text NOT NULL, type text NOT NULL CHECK (type IN ('TEXT','NUMBER','SELECT','MULTI_SELECT','DATE','CHECKBOX','URL','RELATION')), position integer NOT NULL DEFAULT 0, config jsonb NOT NULL DEFAULT '{}', version integer NOT NULL DEFAULT 1, "createdAt" timestamptz NOT NULL DEFAULT now(), "updatedAt" timestamptz NOT NULL DEFAULT now(), UNIQUE ("collectionId", name) ); CREATE TABLE "CollectionItem" ( id text PRIMARY KEY, "collectionId" text NOT NULL REFERENCES "Collection"(id) ON DELETE CASCADE, "bookmarkId" text NOT NULL REFERENCES "Bookmark"(id) ON DELETE CASCADE, "propertyValues" jsonb NOT NULL DEFAULT '{}', position integer NOT NULL DEFAULT 0, version integer NOT NULL DEFAULT 1, "addedAt" timestamptz NOT NULL DEFAULT now(), "updatedAt" timestamptz NOT NULL DEFAULT now(), UNIQUE ("collectionId", "bookmarkId") ); CREATE INDEX "CollectionItem_bookmarkId_idx" ON "CollectionItem"("bookmarkId"); CREATE TABLE "CollectionView" ( id text PRIMARY KEY, "collectionId" text NOT NULL REFERENCES "Collection"(id) ON DELETE CASCADE, name text NOT NULL, type text NOT NULL DEFAULT 'TABLE' CHECK (type IN ('TABLE','BOARD','CALENDAR','LIST')), position integer NOT NULL DEFAULT 0, filters jsonb NOT NULL DEFAULT '{"operator":"AND","clauses":[]}', sorts jsonb NOT NULL DEFAULT '[]', "groupByPropertyId" text REFERENCES "CollectionProperty"(id) ON DELETE SET NULL, "displaySettings" jsonb NOT NULL DEFAULT '{}', version integer NOT NULL DEFAULT 1, "createdAt" timestamptz NOT NULL DEFAULT now(), "updatedAt" timestamptz NOT NULL DEFAULT now() ); CREATE INDEX "CollectionView_collectionId_idx" ON "CollectionView"("collectionId"); -- 2.18 PageEmbed (Notion-like upgrades phase 3): a live collection view -- embedded in a page. The reference is resolved from the page's Markdown -- (`{{collection:|view=}}`) on every content save. Only collections -- owned by the page's author are stored; embedding displays data and never -- publishes it or widens bookmark access. Deleting the collection or the -- chosen view removes/falls back rather than leaving a dangling embed. CREATE TABLE "PageEmbed" ( "pageId" text NOT NULL REFERENCES "Page"(id) ON DELETE CASCADE, "collectionId" text NOT NULL REFERENCES "Collection"(id) ON DELETE CASCADE, "viewId" text REFERENCES "CollectionView"(id) ON DELETE SET NULL, position integer NOT NULL DEFAULT 0, "createdAt" timestamptz NOT NULL DEFAULT now(), PRIMARY KEY ("pageId", "collectionId") ); CREATE INDEX "PageEmbed_collectionId_idx" ON "PageEmbed"("collectionId"); CREATE INDEX "PageEmbed_viewId_idx" ON "PageEmbed"("viewId"); COMMIT;