initial commit on Federted servers
release gate / check (push) Failing after 8m35s

This commit is contained in:
Andrew S Erwin
2026-10-19 16:23:55 -05:00
commit b9721ff13f
217 changed files with 31534 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
#!/bin/sh
# schema.sql <-> migration-chain convergence check (review MIG-03).
#
# A fresh volume is built from db/schema.sql (initdb) and then runs every
# migration on top; an existing volume only runs migrations. Both paths must
# land on the same structure — i.e. schema.sql has to be kept current with
# the chain, so each migration is a structural no-op against it. This builds
# two scratch databases, compares columns/indexes/constraints, and fails on
# any drift. Runs inside the postgres image against the compose db; needs
# CREATEDB rights for DB_USER (the compose superuser has them).
#
# Environment: PGPASSWORD (required), DB_HOST, DB_USER.
set -eu
DB_HOST="${DB_HOST:-db}"
DB_USER="${DB_USER:-computer}"
[ -n "${PGPASSWORD:-}" ] || { echo "error: PGPASSWORD is required" >&2; exit 1; }
BASE_DB=computer_conv_schema
CHAIN_DB=computer_conv_chain
psql() { command psql -h "$DB_HOST" -U "$DB_USER" -v ON_ERROR_STOP=1 "$@"; }
drop() { psql -q -d postgres -c "DROP DATABASE IF EXISTS $1 WITH (FORCE)" >/dev/null 2>&1 || true; }
drop "$BASE_DB"; drop "$CHAIN_DB"
trap 'drop "$BASE_DB"; drop "$CHAIN_DB"' EXIT INT TERM
psql -q -d postgres -c "CREATE DATABASE $BASE_DB"
psql -q -d postgres -c "CREATE DATABASE $CHAIN_DB"
# Path 1: schema.sql alone. Path 2: schema.sql + the full ledger chain
# (exactly what initdb + migrate.sh do on a fresh install). schema.sql
# carries its own BEGIN/COMMIT, so no -1 wrapper here.
psql -q -d "$BASE_DB" -f /db/schema.sql
psql -q -d "$CHAIN_DB" -f /db/schema.sql
DB_NAME="$CHAIN_DB" sh /db/migrate.sh >/dev/null
# Structural catalog, minus the ledger itself (chain path only).
catalog() {
psql -Atq -d "$1" -c "
SELECT 'column '||table_name||'.'||column_name||' '||data_type||' '||is_nullable||' '||COALESCE(column_default,'-')
FROM information_schema.columns
WHERE table_schema='public' AND table_name<>'_migration'
UNION ALL
SELECT 'index '||indexname||': '||indexdef FROM pg_indexes
WHERE schemaname='public' AND tablename<>'_migration'
UNION ALL
SELECT 'constraint '||c.relname||' '||con.conname||': '||pg_get_constraintdef(con.oid)
FROM pg_constraint con JOIN pg_class c ON c.oid=con.conrelid
WHERE con.connamespace='public'::regnamespace AND c.relname<>'_migration'
UNION ALL
SELECT 'sequence '||sequence_name FROM information_schema.sequences
WHERE sequence_schema='public'
ORDER BY 1"
}
catalog "$BASE_DB" > /tmp/base.catalog
catalog "$CHAIN_DB" > /tmp/chain.catalog
fail() {
echo "error: db/schema.sql and db/migrations disagree (diff above)." >&2
echo " Update schema.sql (fresh installs) AND ship a new numbered migration (existing installs)." >&2
exit 1
}
# Pass 1: the chain is a structural no-op against the current schema.sql.
diff -u /tmp/base.catalog /tmp/chain.catalog || fail
# Pass 2 — upgraded-install simulation: pass 1 alone cannot see the DDL
# inside CREATE TABLE IF NOT EXISTS blocks (schema.sql always wins there).
# Drop every migration-created table, wipe the ledger, and re-run the chain
# so those definitions materialize for real, then compare again. This is what
# an existing install upgraded through the ledger ends up with. Caveat: if a
# schema.sql table ever gains an FK to a migration-created table, the CASCADE
# drop removes that constraint and this check fails conservatively.
tables=$(sed -n 's/.*CREATE TABLE IF NOT EXISTS "\([^"]*\)".*/\1/p' /db/migrations/*.sql | sort -u)
if [ -n "$tables" ]; then
for tb in $tables; do
psql -q -d "$CHAIN_DB" -c "DROP TABLE IF EXISTS \"$tb\" CASCADE"
done
psql -q -d "$CHAIN_DB" -c "DELETE FROM _migration"
DB_NAME="$CHAIN_DB" sh /db/migrate.sh >/dev/null
catalog "$CHAIN_DB" > /tmp/chain.catalog
diff -u /tmp/base.catalog /tmp/chain.catalog || fail
fi
echo "schema.sql and the migration chain converge (fresh and upgraded paths)"
+68
View File
@@ -0,0 +1,68 @@
#!/bin/sh
# Proves migrate.sh apply+record atomicity (review MIG-03). Runs inside the
# converge service (postgres image, /db mounted) against the compose db, on
# scratch databases it creates and drops; app data is never touched. Invoked
# by 'make db-converge' alongside the schema convergence check.
set -u
FAIL_DB=computer_conv_atomicity
PSQL="psql -h db -U computer -v ON_ERROR_STOP=1"
$PSQL -d postgres -qc "DROP DATABASE IF EXISTS $FAIL_DB WITH (FORCE)"
$PSQL -d postgres -qc "CREATE DATABASE $FAIL_DB"
mkdir -p /tmp/migs
echo "--- case 1: failing migration leaves no ledger row and no partial DDL"
printf 'CREATE TABLE conv_probe(id text);\nSELECT 1/0;\n' > /tmp/migs/999_probe.sql
MIGRATIONS=/tmp/migs DB_HOST=db DB_USER=computer DB_NAME=$FAIL_DB sh /db/migrate.sh
rc=$?
echo "migrate exit=$rc"
[ "$rc" -ne 0 ] || { echo "FAIL: broken migration exited 0"; exit 1; }
rows=$($PSQL -At -d $FAIL_DB -c "SELECT count(*) FROM _migration")
probe=$($PSQL -At -d $FAIL_DB -c "SELECT count(*) FROM pg_tables WHERE tablename='conv_probe'")
[ "$rows" = "0" ] || { echo "FAIL: ledger recorded a rolled-back migration ($rows)"; exit 1; }
[ "$probe" = "0" ] || { echo "FAIL: partial DDL survived the rollback"; exit 1; }
echo "case 1 OK"
echo "--- case 2: successful migration applies and records atomically"
printf 'CREATE TABLE conv_probe(id text);\n' > /tmp/migs/999_probe.sql
MIGRATIONS=/tmp/migs DB_HOST=db DB_USER=computer DB_NAME=$FAIL_DB sh /db/migrate.sh
rc=$?
[ "$rc" -eq 0 ] || { echo "FAIL: valid migration exited $rc"; exit 1; }
rows=$($PSQL -At -d $FAIL_DB -c "SELECT count(*) FROM _migration")
probe=$($PSQL -At -d $FAIL_DB -c "SELECT count(*) FROM pg_tables WHERE tablename='conv_probe'")
{ [ "$rows" = "1" ] && [ "$probe" = "1" ]; } || { echo "FAIL: apply/record mismatch rows=$rows probe=$probe"; exit 1; }
echo "case 2 OK"
echo "--- case 3: re-run is a no-op; tampered checksum aborts before applying"
MIGRATIONS=/tmp/migs DB_HOST=db DB_USER=computer DB_NAME=$FAIL_DB sh /db/migrate.sh || { echo "FAIL: idempotent re-run failed"; exit 1; }
printf 'CREATE TABLE conv_probe2(id text);\n' > /tmp/migs/999_probe.sql
MIGRATIONS=/tmp/migs DB_HOST=db DB_USER=computer DB_NAME=$FAIL_DB sh /db/migrate.sh
rc=$?
[ "$rc" -ne 0 ] || { echo "FAIL: tampered migration did not abort"; exit 1; }
probe2=$($PSQL -At -d $FAIL_DB -c "SELECT count(*) FROM pg_tables WHERE tablename='conv_probe2'")
[ "$probe2" = "0" ] || { echo "FAIL: tampered migration applied anyway"; exit 1; }
echo "case 3 OK"
echo "--- case 4: concurrent runners — exactly one applies, the loser aborts"
printf 'CREATE TABLE conv_race(id text);\n' > /tmp/migs/999_probe.sql
RACE_DB=computer_conv_race
$PSQL -d postgres -qc "DROP DATABASE IF EXISTS $RACE_DB WITH (FORCE)"
$PSQL -d postgres -qc "CREATE DATABASE $RACE_DB"
MIGRATIONS=/tmp/migs DB_HOST=db DB_USER=computer DB_NAME=$RACE_DB sh /db/migrate.sh >/tmp/race1.log 2>&1 &
p1=$!
MIGRATIONS=/tmp/migs DB_HOST=db DB_USER=computer DB_NAME=$RACE_DB sh /db/migrate.sh >/tmp/race2.log 2>&1 &
p2=$!
rc1=0; rc2=0
wait $p1 || rc1=$?
wait $p2 || rc2=$?
# The loser may abort loudly (primary-key violation after the lock handoff)
# or no-op via the ledger check; what must hold: at least one succeeds, the
# ledger has exactly one row, and the file applied exactly once.
{ [ $rc1 -eq 0 ] || [ $rc2 -eq 0 ]; } || { echo "FAIL: both runners failed"; cat /tmp/race1.log /tmp/race2.log; exit 1; }
rows=$($PSQL -At -d $RACE_DB -c "SELECT count(*) FROM _migration")
race=$($PSQL -At -d $RACE_DB -c "SELECT count(*) FROM pg_tables WHERE tablename='conv_race'")
{ [ "$rows" = "1" ] && [ "$race" = "1" ]; } || { echo "FAIL: race left rows=$rows table=$race"; cat /tmp/race1.log /tmp/race2.log; exit 1; }
echo "case 4 OK"
$PSQL -d postgres -qc "DROP DATABASE IF EXISTS $RACE_DB WITH (FORCE)"
$PSQL -d postgres -qc "DROP DATABASE IF EXISTS $FAIL_DB WITH (FORCE)"
echo "ATOMICITY TESTS PASSED"
Executable
+95
View File
@@ -0,0 +1,95 @@
#!/bin/sh
# Ordered, checksummed migration runner (review MIG-01).
#
# Applies every /db/migrations/*.sql file exactly once, in name order, and
# records it in the _migration ledger. Apply and record share ONE transaction
# under a fixed advisory lock (review MIG-03): a crash can no longer leave an
# applied file unrecorded (or a recorded file unapplied), and a concurrent
# runner blocks on the lock, then aborts on the ledger primary key instead of
# double-applying. A recorded file is immutable: if its checksum no longer
# matches, the runner aborts before applying anything else.
#
# Migration files must therefore be transaction-safe and must NOT carry
# their own BEGIN/COMMIT (the runner wraps each file): statements that cannot
# run inside a transaction block (CREATE INDEX CONCURRENTLY, VACUUM, ...) are
# NOT supported by this runner — handle those as explicit operator steps
# outside the ledger. Files stay idempotent by convention (defense in depth;
# migration 001 must remain so for first ledger adoption on databases already
# upgraded via the legacy upgrade.sql).
#
# Environment: PGPASSWORD (required), DB_HOST, DB_USER, DB_NAME, MIGRATIONS.
set -eu
DB_HOST="${DB_HOST:-db}"
DB_USER="${DB_USER:-computer}"
DB_NAME="${DB_NAME:-computer}"
MIGRATIONS="${MIGRATIONS:-/db/migrations}"
[ -n "${PGPASSWORD:-}" ] || { echo "error: PGPASSWORD is required" >&2; exit 1; }
psql() {
command psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 "$@"
}
psql -qc 'CREATE TABLE IF NOT EXISTS _migration(name text PRIMARY KEY, checksum text NOT NULL, "appliedAt" timestamptz NOT NULL DEFAULT now())'
# --- Preflight (review CR-12): validate the COMPLETE inventory/ledger pair
# BEFORE any schema change. The old single loop applied pending files as it
# walked, so a pending migration earlier in the chain could mutate the
# database before a later file's checksum mismatch was detected. Policy:
# - a recorded migration missing from disk is an integrity failure (the
# applied chain must stay complete — restore the file, do not edit it);
# - a recorded migration whose checksum changed is an integrity failure
# (applied files are immutable; ship a new numbered file);
# - late insertion of an EARLIER-numbered file is allowed by design: files
# apply in name order on the next run and the ledger is keyed by name,
# not by sequence position.
# A concurrent runner between preflight and apply is still safe: the apply
# loop re-checks the ledger per file, and apply+record share one transaction
# under the advisory lock with no ON CONFLICT, so a racer fails loudly on the
# primary key instead of double-applying.
# Colon-joined so shell word splitting yields one entry per ledger row
# (names contain no colons; checksums are hex).
for entry in $(psql -Atc "SELECT name || ':' || checksum FROM _migration"); do
name="${entry%%:*}"
sum="${entry#*:}"
f="$MIGRATIONS/$name"
if [ ! -e "$f" ]; then
echo "error: migration $name is recorded in the ledger but missing from $MIGRATIONS." >&2
echo " The applied chain must stay complete; restore the file (ledger checksum $sum)." >&2
exit 1
fi
actual="$(sha256sum "$f" | cut -d' ' -f1)"
if [ "$actual" != "$sum" ]; then
echo "error: migration $name changed after being applied (ledger $sum, file $actual)." >&2
echo " Applied migrations are immutable; ship the change as a new numbered file." >&2
exit 1
fi
done
for f in "$MIGRATIONS"/*.sql; do
[ -e "$f" ] || { echo "error: no migrations found in $MIGRATIONS" >&2; exit 1; }
name="$(basename "$f")"
sum="$(sha256sum "$f" | cut -d' ' -f1)"
recorded="$(psql -Atc "SELECT checksum FROM _migration WHERE name='$name'")"
if [ -n "$recorded" ]; then
# Preflight proved the checksum matches; re-check here so a file mutated
# mid-run is still refused before anything else applies.
if [ "$recorded" != "$sum" ]; then
echo "error: migration $name changed after being applied (ledger $recorded, file $sum)." >&2
echo " Applied migrations are immutable; ship the change as a new numbered file." >&2
exit 1
fi
continue
fi
echo "applying $name"
# One transaction: psql executes interleaved -c/-f options under -1 as a
# single transaction (PostgreSQL >= 12). Take the lock (arbitrary fixed
# key), record, then apply — any failure rolls back both. Deliberately no
# ON CONFLICT: a runner that lost the race must fail loudly on the primary
# key rather than silently re-execute an already-recorded file.
psql -1 -q \
-c 'DO $$ BEGIN PERFORM pg_advisory_xact_lock(742417); END $$' \
-c "INSERT INTO _migration(name,checksum) VALUES('$name','$sum')" \
-f "$f"
done
echo "migrations up to date"
+68
View File
@@ -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;
+78
View File
@@ -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 $$;
+7
View File
@@ -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;
+8
View File
@@ -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);
+10
View File
@@ -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);
+351
View File
@@ -0,0 +1,351 @@
-- 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;