This commit is contained in:
Executable
+87
@@ -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)"
|
||||
Executable
+68
@@ -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
@@ -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"
|
||||
@@ -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);
|
||||
+351
@@ -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;
|
||||
Reference in New Issue
Block a user