352 lines
13 KiB
PL/PgSQL
352 lines
13 KiB
PL/PgSQL
-- 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,
|
|
-- 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);
|
|
-- 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.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");
|
|
COMMIT;
|