This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
-- 001_legacy_additive.sql — additive upgrades historically shipped as
|
||||
-- db/upgrade.sql, adopted into the ordered ledger (review MIG-01).
|
||||
-- Idempotent: every statement is guarded by an existence check, so first
|
||||
-- ledger adoption on an already-upgraded database is a no-op. From ledger
|
||||
-- adoption onward recorded migrations are immutable; new changes go into
|
||||
-- new numbered files. The runner (db/migrate.sh) applies each file in its
|
||||
-- own transaction with ON_ERROR_STOP.
|
||||
|
||||
-- Review MIG-02: report orphaned bookmark->contact references with an
|
||||
-- actionable error before adding the foreign key, instead of failing with a
|
||||
-- bare constraint violation. Never erase relationships silently — repair is
|
||||
-- a deliberate operator decision.
|
||||
DO $$
|
||||
DECLARE orphans bigint;
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='Bookmark_contactId_fkey' AND conrelid='"Bookmark"'::regclass) THEN
|
||||
SELECT count(*) INTO orphans FROM "Bookmark" b
|
||||
WHERE b."contactId" IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM "Contact" c WHERE c.id=b."contactId");
|
||||
IF orphans > 0 THEN
|
||||
RAISE EXCEPTION 'migration 001: % bookmark(s) reference missing Contact rows; repair them deliberately (e.g. UPDATE "Bookmark" SET "contactId"=NULL WHERE "contactId" IS NOT NULL AND NOT EXISTS (SELECT 1 FROM "Contact" c WHERE c.id="Bookmark"."contactId")) and re-run', orphans;
|
||||
END IF;
|
||||
ALTER TABLE "Bookmark" ADD CONSTRAINT "Bookmark_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "Contact"(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
-- Bring pre-port mailbox switches into the background signal scheduler.
|
||||
-- Existing sources (including paused watches) and modern watchers are preserved.
|
||||
INSERT INTO "SignalSource"(id,type,config,enabled,"bookmarkId","ownerId")
|
||||
SELECT 'legacy-'||b.id,'email',json_build_object('subject',COALESCE(b."imapQuery",''),'legacy',true),true,b.id,b."ownerId"
|
||||
FROM "Bookmark" b
|
||||
WHERE b."imapWatchEnabled" AND NOT EXISTS (
|
||||
SELECT 1 FROM "SignalSource" s WHERE s."bookmarkId"=b.id AND s."ownerId"=b."ownerId" AND s.config->>'legacy'='true'
|
||||
)
|
||||
ON CONFLICT(id) DO NOTHING;
|
||||
CREATE INDEX IF NOT EXISTS "Bookmark_ownerId_updatedAt_id_idx" ON "Bookmark"("ownerId","updatedAt" DESC,id DESC);
|
||||
-- Revocable sessions: cookie JWTs embed this version; signout bumps it.
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "tokenVersion" integer NOT NULL DEFAULT 1;
|
||||
-- Query-pattern indexes: shared-date visibility join and the alerts poll.
|
||||
CREATE INDEX IF NOT EXISTS "SharedDate_creatorId_recipientId_date_idx" ON "SharedDate"("creatorId","recipientId",date);
|
||||
CREATE INDEX IF NOT EXISTS "Bookmark_ownerId_alertAt_unsent_idx" ON "Bookmark"("ownerId","alertAt") WHERE NOT "alertSent";
|
||||
-- Prisma's Json maps to jsonb; enables GIN indexing and dedups keys.
|
||||
-- Re-running on an already-jsonb column is a validated no-op.
|
||||
ALTER TABLE "SignalSource" ALTER COLUMN config TYPE jsonb USING config::jsonb;
|
||||
-- Optimistic concurrency for page saves (review DATA-01): content saves bump
|
||||
-- this revision and a stale base version in PATCH is rejected with 409.
|
||||
ALTER TABLE "Page" ADD COLUMN IF NOT EXISTS version integer NOT NULL DEFAULT 1;
|
||||
-- Durable per-source mailbox ingestion checkpoints (review SYNC-02): polls
|
||||
-- process only UIDs newer than lastUid and advance only after durable inserts.
|
||||
CREATE TABLE IF NOT EXISTS "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()
|
||||
);
|
||||
-- One-shot administrator bootstrap state (review AUTH-06): once recorded,
|
||||
-- signup never auto-promotes again; recovery is operator-driven.
|
||||
CREATE TABLE IF NOT EXISTS "AppSetting" (
|
||||
key text PRIMARY KEY,
|
||||
value jsonb NOT NULL DEFAULT '{}',
|
||||
"updatedAt" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
-- Installations that already have a qualifying administrator completed
|
||||
-- bootstrap before this ledger existed; record it so demoting that
|
||||
-- administrator cannot re-open first-signup promotion.
|
||||
INSERT INTO "AppSetting"(key,value)
|
||||
SELECT 'adminBootstrap',jsonb_build_object('completed',true)
|
||||
WHERE EXISTS (SELECT 1 FROM "User" WHERE role='ADMIN' AND email NOT LIKE '%@example.com')
|
||||
ON CONFLICT(key) DO NOTHING;
|
||||
@@ -0,0 +1,78 @@
|
||||
-- 002_collections.sql — collections, typed properties, memberships and saved
|
||||
-- views (Notion-like upgrades phase 2: N-01/N-02/N-03 data layer).
|
||||
--
|
||||
-- Design decisions (computer_notion_upgrades.md §4, locked before shipping):
|
||||
-- * Membership is contextual: the same bookmark can belong to many
|
||||
-- collections with independent property values; removing a membership or a
|
||||
-- whole collection never touches the bookmark (N-01).
|
||||
-- * Membership never broadens access: item reads re-check BookmarkVisible at
|
||||
-- query time, and creation validates visibility (decision #4, N-08).
|
||||
-- * Views are queries, not copies: filters/sorts/grouping live on the view
|
||||
-- and never duplicate record data (decision #3).
|
||||
-- * Deviation from the proposal draft, per its own API rule ("PATCH must
|
||||
-- require the expected version", the DATA-05 lesson): CollectionProperty,
|
||||
-- CollectionItem and CollectionView carry a version column bumped by every
|
||||
-- mutating PATCH; a stale base is a 409. Property rows also gained
|
||||
-- updatedAt for the same reason.
|
||||
-- * Property types are a CHECK constraint, not a Postgres enum (easier to
|
||||
-- evolve). RELATION is reserved in the constraint but the API rejects it
|
||||
-- until cross-collection access checks are designed (draft §6 open issue).
|
||||
-- * Date semantics (decision #7): DATE properties declare config.mode
|
||||
-- 'date' (YYYY-MM-DD, calendar-day stable across timezones) or 'datetime'
|
||||
-- (RFC3339, normalized to UTC on write). Values live in CollectionItem.
|
||||
-- propertyValues (JSONB keyed by property id) and are validated server-side
|
||||
-- against CollectionProperty on every write.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "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 IF NOT EXISTS "Collection_ownerId_idx" ON "Collection"("ownerId");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "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 IF NOT EXISTS "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 IF NOT EXISTS "CollectionItem_bookmarkId_idx" ON "CollectionItem"("bookmarkId");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "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 IF NOT EXISTS "CollectionView_collectionId_idx" ON "CollectionView"("collectionId");
|
||||
@@ -0,0 +1,19 @@
|
||||
-- 003_collections_propertyvalues_case.sql — repair a quoting mistake in 002.
|
||||
-- 002 created CollectionItem.propertyValues unquoted, so PostgreSQL folded it
|
||||
-- to lowercase "propertyvalues" on databases where the migration's own DDL
|
||||
-- materialized the table (existing installs upgraded through the ledger).
|
||||
-- schema.sql and the API contract use the quoted camelCase name. 002 is
|
||||
-- recorded and immutable, so the repair ships as this guarded rename; on
|
||||
-- databases built from the corrected schema.sql the column already has the
|
||||
-- right name and this is a no-op.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_attribute
|
||||
WHERE attrelid='"CollectionItem"'::regclass
|
||||
AND attname='propertyvalues'
|
||||
AND NOT attisdropped
|
||||
) THEN
|
||||
ALTER TABLE "CollectionItem" RENAME COLUMN propertyvalues TO "propertyValues";
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- 004_bookmark_version.sql — optimistic concurrency for bookmark updates
|
||||
-- (review CR-04). Owner-driven PUT /api/bookmarks/{id} (fields and tag
|
||||
-- replacement) now requires the expected version and bumps it; the
|
||||
-- column-deletion move bumps it too, so a client holding a stale snapshot
|
||||
-- gets a 409 instead of silently overwriting a concurrent edit. Sharing and
|
||||
-- server-driven flags (alertSent) stay outside the contract.
|
||||
ALTER TABLE "Bookmark" ADD COLUMN IF NOT EXISTS version integer NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- 005_contact_source.sql — CardDAV provenance for deletion reconciliation
|
||||
-- (review CR-07). Sync mirrors the configured address book, scoped by this
|
||||
-- column: only contacts previously imported from the SAME source URL are
|
||||
-- candidates for remote-deletion removal; manually created contacts (NULL)
|
||||
-- and other address books' imports are never touched. Existing rows keep
|
||||
-- NULL and are adopted (stamped with the source) the next time the server
|
||||
-- returns them.
|
||||
ALTER TABLE "Contact" ADD COLUMN IF NOT EXISTS source text;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- 006_bookmark_kanban_position.sql — persisted card ordering on kanban
|
||||
-- boards. Every card move normalizes the target column's positions so a
|
||||
-- drag inside a column survives reloads and concurrent moves stay
|
||||
-- deterministic. Legacy rows default to 0 and keep their existing
|
||||
-- updatedAt-descending order until the column is first arranged.
|
||||
ALTER TABLE "Bookmark" ADD COLUMN IF NOT EXISTS "kanbanPosition" integer NOT NULL DEFAULT 0;
|
||||
CREATE INDEX IF NOT EXISTS "Bookmark_ownerId_kanbanColumnId_kanbanPosition_idx"
|
||||
ON "Bookmark"("ownerId","kanbanColumnId","kanbanPosition",id);
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 007_page_search_index.sql — make page search index-backed and literal.
|
||||
--
|
||||
-- The page list searched title/content with an unescaped ILIKE pattern: "%"
|
||||
-- and "_" behaved as wildcards and every request scanned the owner's pages.
|
||||
-- The handler now escapes those characters; these trigram indexes let
|
||||
-- Postgres serve the substring predicate without a sequential scan.
|
||||
-- pg_trgm is a trusted extension, so the database owner can enable it.
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
CREATE INDEX IF NOT EXISTS "Page_title_trgm_idx" ON "Page" USING gin (title gin_trgm_ops);
|
||||
CREATE INDEX IF NOT EXISTS "Page_content_trgm_idx" ON "Page" USING gin (content gin_trgm_ops);
|
||||
Reference in New Issue
Block a user