mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-22 10:33:27 +00:00
63d5cb7b73
P1: Wrap store helper queries (uniqueSlug, uniqueSlugExcluding, backfillItemNumbers) with s.q() for placeholder rebinding. P1: Replace boolToInt() with s.dialect.BoolToInt() so pgx receives native booleans instead of 0/1 integers. P1: Change boolean scan variables from int to bool to match PostgreSQL's native boolean type. P1: Fix FTS table aliases in PostgreSQL search branches. P1: Make api_tokens.workspace_id nullable for user-scoped tokens. P2: Move eventBus.Close() before srv.Shutdown() so SSE handlers drain before the HTTP server shutdown deadline.
444 lines
17 KiB
PL/PgSQL
444 lines
17 KiB
PL/PgSQL
-- Pad PostgreSQL schema (consolidated from SQLite migrations 001-021)
|
|
-- This is the initial schema for PostgreSQL deployments.
|
|
|
|
-- Enable UUID generation
|
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
|
|
|
-- ========== Core tables ==========
|
|
|
|
CREATE TABLE IF NOT EXISTS workspaces (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
slug TEXT NOT NULL UNIQUE,
|
|
description TEXT NOT NULL DEFAULT '',
|
|
settings JSONB NOT NULL DEFAULT '{}',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
deleted_at TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS documents (
|
|
id TEXT PRIMARY KEY,
|
|
workspace_id TEXT NOT NULL REFERENCES workspaces(id),
|
|
title TEXT NOT NULL,
|
|
slug TEXT NOT NULL,
|
|
content TEXT NOT NULL DEFAULT '',
|
|
doc_type TEXT NOT NULL DEFAULT 'notes'
|
|
CHECK (doc_type IN ('roadmap','phase-plan','architecture','ideation',
|
|
'feature-spec','notes','prompt-library','reference')),
|
|
status TEXT NOT NULL DEFAULT 'draft'
|
|
CHECK (status IN ('draft','active','completed','archived')),
|
|
tags JSONB NOT NULL DEFAULT '[]',
|
|
pinned BOOLEAN NOT NULL DEFAULT FALSE,
|
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
created_by TEXT NOT NULL DEFAULT 'user'
|
|
CHECK (created_by IN ('user','agent')),
|
|
last_modified_by TEXT NOT NULL DEFAULT 'user'
|
|
CHECK (last_modified_by IN ('user','agent')),
|
|
source TEXT NOT NULL DEFAULT 'web'
|
|
CHECK (source IN ('cli','web','skill')),
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
deleted_at TEXT,
|
|
|
|
-- Full-text search vector (auto-updated via trigger)
|
|
search_vector TSVECTOR,
|
|
|
|
UNIQUE(workspace_id, slug),
|
|
UNIQUE(workspace_id, title)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_documents_workspace ON documents(workspace_id);
|
|
CREATE INDEX IF NOT EXISTS idx_documents_type ON documents(workspace_id, doc_type);
|
|
CREATE INDEX IF NOT EXISTS idx_documents_status ON documents(workspace_id, status);
|
|
CREATE INDEX IF NOT EXISTS idx_documents_updated ON documents(workspace_id, updated_at);
|
|
CREATE INDEX IF NOT EXISTS idx_documents_fts ON documents USING GIN(search_vector);
|
|
|
|
-- Trigger to maintain document search vector
|
|
CREATE OR REPLACE FUNCTION documents_search_vector_update() RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
NEW.search_vector :=
|
|
setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') ||
|
|
setweight(to_tsvector('english', COALESCE(NEW.content, '')), 'B') ||
|
|
setweight(to_tsvector('english', COALESCE(NEW.tags::text, '')), 'C');
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
CREATE TRIGGER documents_search_vector_trigger
|
|
BEFORE INSERT OR UPDATE OF title, content, tags ON documents
|
|
FOR EACH ROW EXECUTE FUNCTION documents_search_vector_update();
|
|
|
|
CREATE TABLE IF NOT EXISTS versions (
|
|
id TEXT PRIMARY KEY,
|
|
document_id TEXT NOT NULL REFERENCES documents(id),
|
|
content TEXT NOT NULL,
|
|
change_summary TEXT NOT NULL DEFAULT '',
|
|
is_diff BOOLEAN NOT NULL DEFAULT FALSE,
|
|
created_by TEXT NOT NULL DEFAULT 'user'
|
|
CHECK (created_by IN ('user','agent')),
|
|
source TEXT NOT NULL DEFAULT 'web'
|
|
CHECK (source IN ('cli','web','skill')),
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_versions_document ON versions(document_id, created_at);
|
|
|
|
CREATE TABLE IF NOT EXISTS activities (
|
|
id TEXT PRIMARY KEY,
|
|
workspace_id TEXT NOT NULL REFERENCES workspaces(id),
|
|
document_id TEXT,
|
|
action TEXT NOT NULL
|
|
CHECK (action IN ('created','updated','archived',
|
|
'restored','read','searched')),
|
|
actor TEXT NOT NULL CHECK (actor IN ('user','agent')),
|
|
source TEXT NOT NULL CHECK (source IN ('cli','web','skill')),
|
|
metadata JSONB NOT NULL DEFAULT '{}',
|
|
user_id TEXT,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_activities_workspace ON activities(workspace_id, created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_activities_document ON activities(document_id, created_at);
|
|
|
|
-- ========== Collections & Items ==========
|
|
|
|
CREATE TABLE IF NOT EXISTS collections (
|
|
id TEXT PRIMARY KEY,
|
|
workspace_id TEXT NOT NULL REFERENCES workspaces(id),
|
|
name TEXT NOT NULL,
|
|
slug TEXT NOT NULL,
|
|
icon TEXT DEFAULT '',
|
|
description TEXT DEFAULT '',
|
|
prefix TEXT NOT NULL DEFAULT '',
|
|
schema JSONB NOT NULL DEFAULT '{"fields":[]}',
|
|
settings JSONB DEFAULT '{}',
|
|
sort_order INTEGER DEFAULT 0,
|
|
is_default BOOLEAN DEFAULT FALSE,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
deleted_at TEXT,
|
|
UNIQUE(workspace_id, slug)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS items (
|
|
id TEXT PRIMARY KEY,
|
|
workspace_id TEXT NOT NULL REFERENCES workspaces(id),
|
|
collection_id TEXT NOT NULL REFERENCES collections(id),
|
|
title TEXT NOT NULL,
|
|
slug TEXT NOT NULL,
|
|
content TEXT DEFAULT '',
|
|
fields JSONB DEFAULT '{}',
|
|
tags JSONB DEFAULT '[]',
|
|
pinned BOOLEAN DEFAULT FALSE,
|
|
sort_order INTEGER DEFAULT 0,
|
|
item_number INTEGER,
|
|
parent_id TEXT REFERENCES items(id),
|
|
created_by TEXT DEFAULT 'user',
|
|
last_modified_by TEXT DEFAULT 'user',
|
|
source TEXT DEFAULT 'web',
|
|
created_by_user_id TEXT,
|
|
last_modified_by_user_id TEXT,
|
|
assigned_user_id TEXT,
|
|
agent_role_id TEXT,
|
|
role_sort_order INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
deleted_at TEXT,
|
|
|
|
-- Full-text search vector
|
|
search_vector TSVECTOR,
|
|
|
|
UNIQUE(workspace_id, slug)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_items_collection ON items(collection_id) WHERE deleted_at IS NULL;
|
|
CREATE INDEX IF NOT EXISTS idx_items_workspace ON items(workspace_id) WHERE deleted_at IS NULL;
|
|
CREATE INDEX IF NOT EXISTS idx_items_parent ON items(parent_id) WHERE deleted_at IS NULL;
|
|
CREATE INDEX IF NOT EXISTS idx_items_updated ON items(updated_at) WHERE deleted_at IS NULL;
|
|
CREATE INDEX IF NOT EXISTS idx_items_assigned_user ON items(assigned_user_id) WHERE deleted_at IS NULL;
|
|
CREATE INDEX IF NOT EXISTS idx_items_agent_role ON items(agent_role_id) WHERE deleted_at IS NULL;
|
|
CREATE INDEX IF NOT EXISTS idx_items_fts ON items USING GIN(search_vector);
|
|
|
|
-- Trigger to maintain item search vector
|
|
CREATE OR REPLACE FUNCTION items_search_vector_update() RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
NEW.search_vector :=
|
|
setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') ||
|
|
setweight(to_tsvector('english', COALESCE(NEW.content, '')), 'B') ||
|
|
setweight(to_tsvector('english', COALESCE(NEW.tags::text, '')), 'C');
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
CREATE TRIGGER items_search_vector_trigger
|
|
BEFORE INSERT OR UPDATE OF title, content, tags ON items
|
|
FOR EACH ROW EXECUTE FUNCTION items_search_vector_update();
|
|
|
|
CREATE TABLE IF NOT EXISTS item_links (
|
|
id TEXT PRIMARY KEY,
|
|
workspace_id TEXT NOT NULL,
|
|
source_id TEXT NOT NULL REFERENCES items(id),
|
|
target_id TEXT NOT NULL REFERENCES items(id),
|
|
link_type TEXT DEFAULT 'related',
|
|
created_by TEXT DEFAULT 'user',
|
|
user_id TEXT,
|
|
created_at TEXT NOT NULL,
|
|
UNIQUE(source_id, target_id, link_type)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_links_source ON item_links(source_id);
|
|
CREATE INDEX IF NOT EXISTS idx_links_target ON item_links(target_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS views (
|
|
id TEXT PRIMARY KEY,
|
|
workspace_id TEXT NOT NULL REFERENCES workspaces(id),
|
|
collection_id TEXT REFERENCES collections(id),
|
|
name TEXT NOT NULL,
|
|
slug TEXT NOT NULL,
|
|
view_type TEXT NOT NULL,
|
|
config JSONB DEFAULT '{}',
|
|
sort_order INTEGER DEFAULT 0,
|
|
is_default BOOLEAN DEFAULT FALSE,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
UNIQUE(workspace_id, slug)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS item_versions (
|
|
id TEXT PRIMARY KEY,
|
|
item_id TEXT NOT NULL REFERENCES items(id),
|
|
content TEXT NOT NULL,
|
|
change_summary TEXT NOT NULL DEFAULT '',
|
|
created_by TEXT NOT NULL DEFAULT 'user',
|
|
source TEXT NOT NULL DEFAULT 'web',
|
|
is_diff BOOLEAN NOT NULL DEFAULT FALSE,
|
|
user_id TEXT,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_item_versions_item ON item_versions(item_id, created_at);
|
|
|
|
-- ========== Comments & Reactions ==========
|
|
|
|
CREATE TABLE IF NOT EXISTS comments (
|
|
id TEXT PRIMARY KEY,
|
|
item_id TEXT NOT NULL REFERENCES items(id),
|
|
workspace_id TEXT NOT NULL REFERENCES workspaces(id),
|
|
author TEXT NOT NULL DEFAULT '',
|
|
body TEXT NOT NULL,
|
|
user_id TEXT,
|
|
activity_id TEXT,
|
|
parent_id TEXT REFERENCES comments(id),
|
|
created_by TEXT NOT NULL DEFAULT 'user'
|
|
CHECK (created_by IN ('user', 'agent')),
|
|
source TEXT NOT NULL DEFAULT 'web'
|
|
CHECK (source IN ('cli', 'web', 'skill')),
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
|
|
-- Full-text search vector
|
|
search_vector TSVECTOR
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_comments_item ON comments(item_id, created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_comments_workspace ON comments(workspace_id, created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id);
|
|
CREATE INDEX IF NOT EXISTS idx_comments_activity ON comments(activity_id);
|
|
CREATE INDEX IF NOT EXISTS idx_comments_fts ON comments USING GIN(search_vector);
|
|
|
|
-- Trigger to maintain comment search vector
|
|
CREATE OR REPLACE FUNCTION comments_search_vector_update() RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
NEW.search_vector :=
|
|
to_tsvector('english', COALESCE(NEW.body, ''));
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
CREATE TRIGGER comments_search_vector_trigger
|
|
BEFORE INSERT OR UPDATE OF body ON comments
|
|
FOR EACH ROW EXECUTE FUNCTION comments_search_vector_update();
|
|
|
|
CREATE TABLE IF NOT EXISTS comment_reactions (
|
|
id TEXT PRIMARY KEY,
|
|
comment_id TEXT NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
|
|
user_id TEXT,
|
|
actor TEXT NOT NULL DEFAULT 'user',
|
|
emoji TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
UNIQUE(comment_id, user_id, emoji)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_comment_reactions_comment ON comment_reactions(comment_id);
|
|
|
|
-- ========== Users & Auth ==========
|
|
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id TEXT PRIMARY KEY,
|
|
email TEXT NOT NULL UNIQUE,
|
|
name TEXT NOT NULL,
|
|
password_hash TEXT NOT NULL,
|
|
role TEXT NOT NULL DEFAULT 'member'
|
|
CHECK (role IN ('admin', 'member')),
|
|
avatar_url TEXT DEFAULT '',
|
|
created_at TEXT NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')::TEXT,
|
|
updated_at TEXT NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')::TEXT
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
|
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL REFERENCES users(id),
|
|
token_hash TEXT NOT NULL,
|
|
device_info TEXT DEFAULT '',
|
|
expires_at TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')::TEXT
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash);
|
|
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
|
|
|
CREATE TABLE IF NOT EXISTS workspace_members (
|
|
workspace_id TEXT NOT NULL REFERENCES workspaces(id),
|
|
user_id TEXT NOT NULL REFERENCES users(id),
|
|
role TEXT NOT NULL DEFAULT 'editor'
|
|
CHECK (role IN ('owner', 'editor', 'viewer')),
|
|
created_at TEXT NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')::TEXT,
|
|
PRIMARY KEY (workspace_id, user_id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_workspace_members_user ON workspace_members(user_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS workspace_invitations (
|
|
id TEXT PRIMARY KEY,
|
|
workspace_id TEXT NOT NULL REFERENCES workspaces(id),
|
|
email TEXT NOT NULL,
|
|
role TEXT NOT NULL DEFAULT 'editor'
|
|
CHECK (role IN ('owner', 'editor', 'viewer')),
|
|
invited_by TEXT NOT NULL REFERENCES users(id),
|
|
code TEXT NOT NULL UNIQUE,
|
|
accepted_at TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')::TEXT
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_invitations_workspace ON workspace_invitations(workspace_id);
|
|
CREATE INDEX IF NOT EXISTS idx_invitations_code ON workspace_invitations(code);
|
|
CREATE INDEX IF NOT EXISTS idx_invitations_email ON workspace_invitations(email);
|
|
|
|
-- ========== API Tokens ==========
|
|
|
|
CREATE TABLE IF NOT EXISTS api_tokens (
|
|
id TEXT PRIMARY KEY,
|
|
workspace_id TEXT REFERENCES workspaces(id),
|
|
user_id TEXT REFERENCES users(id),
|
|
name TEXT NOT NULL,
|
|
token_hash TEXT NOT NULL,
|
|
prefix TEXT NOT NULL,
|
|
scopes JSONB NOT NULL DEFAULT '["*"]',
|
|
expires_at TEXT,
|
|
last_used_at TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')::TEXT
|
|
);
|
|
|
|
-- ========== Webhooks ==========
|
|
|
|
CREATE TABLE IF NOT EXISTS webhooks (
|
|
id TEXT PRIMARY KEY,
|
|
workspace_id TEXT NOT NULL REFERENCES workspaces(id),
|
|
url TEXT NOT NULL,
|
|
secret TEXT DEFAULT '',
|
|
events JSONB NOT NULL DEFAULT '["*"]',
|
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
created_at TEXT NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')::TEXT,
|
|
updated_at TEXT NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')::TEXT,
|
|
last_triggered_at TEXT,
|
|
failure_count INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
-- ========== Agent Roles ==========
|
|
|
|
CREATE TABLE IF NOT EXISTS agent_roles (
|
|
id TEXT PRIMARY KEY,
|
|
workspace_id TEXT NOT NULL REFERENCES workspaces(id),
|
|
slug TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
description TEXT NOT NULL DEFAULT '',
|
|
icon TEXT NOT NULL DEFAULT '',
|
|
tools TEXT NOT NULL DEFAULT '',
|
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
UNIQUE(workspace_id, slug)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_agent_roles_workspace ON agent_roles(workspace_id);
|
|
|
|
-- Foreign keys for items that reference users/agent_roles (added after tables exist)
|
|
ALTER TABLE items ADD CONSTRAINT fk_items_created_by_user FOREIGN KEY (created_by_user_id) REFERENCES users(id);
|
|
ALTER TABLE items ADD CONSTRAINT fk_items_modified_by_user FOREIGN KEY (last_modified_by_user_id) REFERENCES users(id);
|
|
ALTER TABLE items ADD CONSTRAINT fk_items_assigned_user FOREIGN KEY (assigned_user_id) REFERENCES users(id) ON DELETE SET NULL;
|
|
ALTER TABLE items ADD CONSTRAINT fk_items_agent_role FOREIGN KEY (agent_role_id) REFERENCES agent_roles(id) ON DELETE SET NULL;
|
|
ALTER TABLE comments ADD CONSTRAINT fk_comments_activity FOREIGN KEY (activity_id) REFERENCES activities(id);
|
|
|
|
-- ========== Platform Settings ==========
|
|
|
|
CREATE TABLE IF NOT EXISTS platform_settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL DEFAULT '',
|
|
updated_at TEXT NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')::TEXT
|
|
);
|
|
|
|
-- ========== Password Resets ==========
|
|
|
|
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL REFERENCES users(id),
|
|
token_hash TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL,
|
|
used_at TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')::TEXT
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_reset_tokens_token_hash ON password_reset_tokens(token_hash);
|
|
CREATE INDEX IF NOT EXISTS idx_reset_tokens_user_id ON password_reset_tokens(user_id);
|
|
|
|
-- ========== Legacy tables (kept for compatibility) ==========
|
|
|
|
CREATE TABLE IF NOT EXISTS custom_templates (
|
|
id TEXT PRIMARY KEY,
|
|
workspace_id TEXT NOT NULL REFERENCES workspaces(id),
|
|
name TEXT NOT NULL,
|
|
description TEXT NOT NULL DEFAULT '',
|
|
doc_type TEXT NOT NULL DEFAULT 'notes',
|
|
icon TEXT NOT NULL DEFAULT '📝',
|
|
content TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
UNIQUE(workspace_id, name)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS progress_snapshots (
|
|
id TEXT PRIMARY KEY,
|
|
workspace_id TEXT NOT NULL REFERENCES workspaces(id),
|
|
total_tasks INTEGER NOT NULL DEFAULT 0,
|
|
done_tasks INTEGER NOT NULL DEFAULT 0,
|
|
open_tasks INTEGER NOT NULL DEFAULT 0,
|
|
in_progress INTEGER NOT NULL DEFAULT 0,
|
|
percentage REAL NOT NULL DEFAULT 0.0,
|
|
phase_data JSONB NOT NULL DEFAULT '[]',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_snapshots_workspace_time
|
|
ON progress_snapshots(workspace_id, created_at);
|
|
|
|
-- ========== Migration tracking ==========
|
|
|
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
version TEXT PRIMARY KEY,
|
|
applied_at TEXT NOT NULL
|
|
);
|