newdrive/db/migrations/000002_phase1_core.up.sql
Elijah 077cf7601a
Some checks failed
CI / Backend (push) Failing after 4s
CI / Frontend (push) Successful in 11s
CI / Contracts and repository policy (push) Failing after 5s
CI / Container (push) Has been skipped
Initial phase 1 baseline implementation
2026-07-16 19:14:01 -07:00

387 lines
17 KiB
SQL

-- Phase 1 persistence foundation. UUIDv7 is provided by PostgreSQL 18.
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuidv7(),
username TEXT NOT NULL,
username_key TEXT NOT NULL,
display_name TEXT NOT NULL,
password_hash TEXT NOT NULL,
root_node_id UUID,
setup_completed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
password_changed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT users_username_not_blank CHECK (btrim(username) <> ''),
CONSTRAINT users_username_key_not_blank CHECK (btrim(username_key) <> ''),
CONSTRAINT users_display_name_not_blank CHECK (btrim(display_name) <> ''),
CONSTRAINT users_username_key_unique UNIQUE (username_key)
);
CREATE UNIQUE INDEX users_single_owner ON users ((TRUE));
CREATE TABLE totp_credentials (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
encrypted_secret BYTEA NOT NULL,
enabled_at TIMESTAMPTZ,
verified_step BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT totp_secret_not_empty CHECK (octet_length(encrypted_secret) > 0),
CONSTRAINT totp_verified_step_nonnegative CHECK (verified_step IS NULL OR verified_step >= 0)
);
CREATE TABLE recovery_codes (
id UUID PRIMARY KEY DEFAULT uuidv7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code_hash BYTEA NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT recovery_codes_hash_not_empty CHECK (octet_length(code_hash) > 0),
CONSTRAINT recovery_codes_hash_unique UNIQUE (user_id, code_hash)
);
CREATE TABLE client_instances (
id UUID PRIMARY KEY DEFAULT uuidv7(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
display_name TEXT NOT NULL,
client_type TEXT NOT NULL,
platform TEXT,
software_version TEXT,
protocol_version TEXT,
last_seen_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT client_instances_display_name_not_blank CHECK (btrim(display_name) <> ''),
CONSTRAINT client_instances_type_not_blank CHECK (btrim(client_type) <> '')
);
CREATE INDEX client_instances_owner_active_idx
ON client_instances (owner_id, created_at DESC)
WHERE revoked_at IS NULL;
CREATE TABLE sessions (
id UUID PRIMARY KEY DEFAULT uuidv7(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
client_instance_id UUID REFERENCES client_instances(id) ON DELETE SET NULL,
token_hash BYTEA NOT NULL UNIQUE,
csrf_token_hash BYTEA NOT NULL,
remote_address INET,
user_agent TEXT,
authenticated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT sessions_token_hash_not_empty CHECK (octet_length(token_hash) > 0),
CONSTRAINT sessions_csrf_hash_not_empty CHECK (octet_length(csrf_token_hash) > 0),
CONSTRAINT sessions_expiry_after_creation CHECK (expires_at > created_at)
);
CREATE INDEX sessions_owner_active_idx
ON sessions (owner_id, expires_at)
WHERE revoked_at IS NULL;
CREATE TABLE api_tokens (
id UUID PRIMARY KEY DEFAULT uuidv7(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
client_instance_id UUID REFERENCES client_instances(id) ON DELETE SET NULL,
name TEXT NOT NULL,
token_hash BYTEA NOT NULL UNIQUE,
actions TEXT[] NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_used_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
CONSTRAINT api_tokens_name_not_blank CHECK (btrim(name) <> ''),
CONSTRAINT api_tokens_hash_not_empty CHECK (octet_length(token_hash) > 0),
CONSTRAINT api_tokens_actions_not_empty CHECK (cardinality(actions) > 0),
CONSTRAINT api_tokens_expiry_after_creation CHECK (expires_at IS NULL OR expires_at > created_at)
);
CREATE INDEX api_tokens_owner_active_idx
ON api_tokens (owner_id, created_at DESC)
WHERE revoked_at IS NULL;
CREATE TABLE blobs (
id UUID PRIMARY KEY DEFAULT uuidv7(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
object_id UUID NOT NULL DEFAULT uuidv7(),
sha256 BYTEA NOT NULL,
size_bytes BIGINT NOT NULL,
state TEXT NOT NULL,
verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT blobs_object_id_unique UNIQUE (object_id),
CONSTRAINT blobs_owner_sha256_unique UNIQUE (owner_id, sha256),
CONSTRAINT blobs_sha256_length CHECK (octet_length(sha256) = 32),
CONSTRAINT blobs_size_nonnegative CHECK (size_bytes >= 0),
CONSTRAINT blobs_state_valid CHECK (state IN ('staged', 'available', 'unavailable')),
CONSTRAINT blobs_available_verified CHECK (state <> 'available' OR verified_at IS NOT NULL)
);
CREATE INDEX blobs_gc_candidates_idx ON blobs (created_at) WHERE state <> 'available';
CREATE TABLE nodes (
id UUID PRIMARY KEY DEFAULT uuidv7(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
parent_id UUID REFERENCES nodes(id) ON DELETE RESTRICT DEFERRABLE INITIALLY IMMEDIATE,
name TEXT NOT NULL,
name_key TEXT NOT NULL,
node_type TEXT NOT NULL,
current_blob_id UUID REFERENCES blobs(id) ON DELETE RESTRICT,
size_bytes BIGINT NOT NULL DEFAULT 0,
mime_type TEXT,
node_revision BIGINT NOT NULL DEFAULT 1,
content_revision BIGINT NOT NULL DEFAULT 0,
content_modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
metadata_updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
favorite BOOLEAN NOT NULL DEFAULT FALSE,
trash_root_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT nodes_name_not_blank CHECK (btrim(name) <> ''),
CONSTRAINT nodes_name_key_not_blank CHECK (btrim(name_key) <> ''),
CONSTRAINT nodes_type_valid CHECK (node_type IN ('directory', 'file')),
CONSTRAINT nodes_file_blob_shape CHECK (
(node_type = 'directory' AND current_blob_id IS NULL AND size_bytes = 0 AND content_revision = 0)
OR (node_type = 'file' AND current_blob_id IS NOT NULL)
),
CONSTRAINT nodes_size_nonnegative CHECK (size_bytes >= 0),
CONSTRAINT nodes_node_revision_positive CHECK (node_revision > 0),
CONSTRAINT nodes_content_revision_nonnegative CHECK (content_revision >= 0),
CONSTRAINT nodes_not_own_parent CHECK (parent_id IS NULL OR parent_id <> id)
);
CREATE UNIQUE INDEX nodes_sibling_name_unique
ON nodes (owner_id, parent_id, name_key) NULLS NOT DISTINCT;
CREATE INDEX nodes_parent_listing_idx ON nodes (owner_id, parent_id, name_key, id);
CREATE INDEX nodes_current_blob_idx ON nodes (current_blob_id) WHERE current_blob_id IS NOT NULL;
CREATE INDEX nodes_trash_roots_idx ON nodes (owner_id, trash_root_at) WHERE trash_root_at IS NOT NULL;
CREATE INDEX nodes_name_trgm_idx ON nodes USING gin (name gin_trgm_ops);
ALTER TABLE users
ADD CONSTRAINT users_root_node_fk
FOREIGN KEY (root_node_id) REFERENCES nodes(id) ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED;
CREATE TABLE credential_root_grants (
id UUID PRIMARY KEY DEFAULT uuidv7(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
session_id UUID REFERENCES sessions(id) ON DELETE CASCADE,
api_token_id UUID REFERENCES api_tokens(id) ON DELETE CASCADE,
root_node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
actions TEXT[] NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT credential_root_grants_one_credential CHECK (num_nonnulls(session_id, api_token_id) = 1),
CONSTRAINT credential_root_grants_actions_not_empty CHECK (cardinality(actions) > 0)
);
CREATE UNIQUE INDEX credential_root_grants_session_unique
ON credential_root_grants (session_id, root_node_id) WHERE session_id IS NOT NULL;
CREATE UNIQUE INDEX credential_root_grants_token_unique
ON credential_root_grants (api_token_id, root_node_id) WHERE api_token_id IS NOT NULL;
CREATE TABLE idempotency_records (
id UUID PRIMARY KEY DEFAULT uuidv7(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
credential_kind TEXT NOT NULL,
credential_id UUID NOT NULL,
client_instance_id UUID REFERENCES client_instances(id) ON DELETE SET NULL,
idempotency_key TEXT NOT NULL,
request_fingerprint BYTEA NOT NULL,
operation_type TEXT NOT NULL,
state TEXT NOT NULL,
response_reference JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL,
CONSTRAINT idempotency_key_not_blank CHECK (btrim(idempotency_key) <> ''),
CONSTRAINT idempotency_fingerprint_not_empty CHECK (octet_length(request_fingerprint) > 0),
CONSTRAINT idempotency_operation_not_blank CHECK (btrim(operation_type) <> ''),
CONSTRAINT idempotency_credential_kind_valid CHECK (credential_kind IN ('session', 'api_token', 'internal')),
CONSTRAINT idempotency_state_valid CHECK (state IN ('in_progress', 'completed', 'failed')),
CONSTRAINT idempotency_expiry_after_creation CHECK (expires_at > created_at),
CONSTRAINT idempotency_scope_unique UNIQUE (owner_id, credential_kind, credential_id, idempotency_key)
);
CREATE INDEX idempotency_records_expiry_idx ON idempotency_records (expires_at);
CREATE TABLE changes (
sequence BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
event_id UUID NOT NULL DEFAULT uuidv7() UNIQUE,
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
event_type TEXT NOT NULL,
schema_version INTEGER NOT NULL DEFAULT 1,
node_id UUID NOT NULL,
node_revision BIGINT,
content_revision BIGINT,
subtree_effect BOOLEAN NOT NULL DEFAULT FALSE,
resulting_state JSONB,
tombstone JSONB,
previous_state JSONB,
mutation_origin JSONB NOT NULL,
committed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT changes_event_type_not_blank CHECK (btrim(event_type) <> ''),
CONSTRAINT changes_schema_version_positive CHECK (schema_version > 0),
CONSTRAINT changes_node_revision_positive CHECK (node_revision IS NULL OR node_revision > 0),
CONSTRAINT changes_content_revision_nonnegative CHECK (content_revision IS NULL OR content_revision >= 0),
CONSTRAINT changes_result_shape CHECK (num_nonnulls(resulting_state, tombstone) = 1)
);
CREATE INDEX changes_owner_sequence_idx ON changes (owner_id, sequence);
CREATE INDEX changes_node_history_idx ON changes (owner_id, node_id, sequence DESC);
CREATE TABLE replacement_recovery (
id UUID PRIMARY KEY DEFAULT uuidv7(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
blob_id UUID NOT NULL REFERENCES blobs(id) ON DELETE RESTRICT,
replaced_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
restored_at TIMESTAMPTZ,
CONSTRAINT replacement_recovery_expiry_after_replace CHECK (expires_at > replaced_at)
);
CREATE INDEX replacement_recovery_active_idx
ON replacement_recovery (owner_id, expires_at)
WHERE restored_at IS NULL;
CREATE INDEX replacement_recovery_blob_idx ON replacement_recovery (blob_id);
CREATE TABLE commit_intents (
id UUID PRIMARY KEY DEFAULT uuidv7(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
operation_id UUID NOT NULL,
node_id UUID REFERENCES nodes(id) ON DELETE RESTRICT,
parent_id UUID REFERENCES nodes(id) ON DELETE RESTRICT,
expected_node_revision BIGINT,
target_name TEXT NOT NULL,
target_name_key TEXT NOT NULL,
object_id UUID NOT NULL,
sha256 BYTEA NOT NULL,
size_bytes BIGINT NOT NULL,
state TEXT NOT NULL,
promoted_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
failure_code TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT commit_intents_operation_unique UNIQUE (owner_id, operation_id),
CONSTRAINT commit_intents_target_name_not_blank CHECK (btrim(target_name) <> ''),
CONSTRAINT commit_intents_target_key_not_blank CHECK (btrim(target_name_key) <> ''),
CONSTRAINT commit_intents_expected_revision_positive CHECK (expected_node_revision IS NULL OR expected_node_revision > 0),
CONSTRAINT commit_intents_sha256_length CHECK (octet_length(sha256) = 32),
CONSTRAINT commit_intents_size_nonnegative CHECK (size_bytes >= 0),
CONSTRAINT commit_intents_state_valid CHECK (state IN ('pending', 'promoted', 'completed', 'failed'))
);
CREATE INDEX commit_intents_recovery_idx
ON commit_intents (state, created_at)
WHERE state IN ('pending', 'promoted');
CREATE TABLE resource_leases (
id UUID PRIMARY KEY DEFAULT uuidv7(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
purpose TEXT NOT NULL,
resource_kind TEXT NOT NULL,
resource_id UUID NOT NULL,
holder_id UUID NOT NULL,
acquired_at TIMESTAMPTZ NOT NULL DEFAULT now(),
heartbeat_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
released_at TIMESTAMPTZ,
CONSTRAINT resource_leases_purpose_not_blank CHECK (btrim(purpose) <> ''),
CONSTRAINT resource_leases_kind_not_blank CHECK (btrim(resource_kind) <> ''),
CONSTRAINT resource_leases_expiry_after_acquire CHECK (expires_at > acquired_at)
);
CREATE UNIQUE INDEX resource_leases_active_unique
ON resource_leases (owner_id, purpose, resource_kind, resource_id)
WHERE released_at IS NULL;
CREATE INDEX resource_leases_expiry_idx
ON resource_leases (expires_at)
WHERE released_at IS NULL;
CREATE TABLE jobs (
id UUID PRIMARY KEY DEFAULT uuidv7(),
owner_id UUID REFERENCES users(id) ON DELETE CASCADE,
job_type TEXT NOT NULL,
state TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
progress JSONB NOT NULL DEFAULT '{}'::jsonb,
attempt_count BIGINT NOT NULL DEFAULT 0,
max_attempts BIGINT NOT NULL DEFAULT 5,
lease_holder UUID,
lease_expires_at TIMESTAMPTZ,
heartbeat_at TIMESTAMPTZ,
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
cancel_requested_at TIMESTAMPTZ,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
last_error_code TEXT,
last_error_detail TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT jobs_type_not_blank CHECK (btrim(job_type) <> ''),
CONSTRAINT jobs_state_valid CHECK (state IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')),
CONSTRAINT jobs_attempt_count_nonnegative CHECK (attempt_count >= 0),
CONSTRAINT jobs_max_attempts_positive CHECK (max_attempts > 0)
);
CREATE INDEX jobs_claim_idx ON jobs (next_attempt_at, created_at)
WHERE state = 'queued';
CREATE INDEX jobs_lease_idx ON jobs (lease_expires_at)
WHERE state = 'running';
CREATE TABLE settings (
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
setting_key TEXT NOT NULL,
value JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (owner_id, setting_key),
CONSTRAINT settings_key_not_blank CHECK (btrim(setting_key) <> '')
);
CREATE TABLE audit_events (
sequence BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
event_id UUID NOT NULL DEFAULT uuidv7() UNIQUE,
owner_id UUID REFERENCES users(id) ON DELETE SET NULL,
actor JSONB NOT NULL,
action TEXT NOT NULL,
resource_type TEXT,
resource_id UUID,
outcome TEXT NOT NULL,
request_id UUID,
operation_id UUID,
details JSONB NOT NULL DEFAULT '{}'::jsonb,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT audit_events_action_not_blank CHECK (btrim(action) <> ''),
CONSTRAINT audit_events_outcome_valid CHECK (outcome IN ('succeeded', 'denied', 'failed'))
);
CREATE INDEX audit_events_owner_time_idx ON audit_events (owner_id, occurred_at DESC);
CREATE INDEX audit_events_resource_idx ON audit_events (resource_type, resource_id, occurred_at DESC);
CREATE TABLE export_leases (
id UUID PRIMARY KEY DEFAULT uuidv7(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
operation_id UUID NOT NULL,
state TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
released_at TIMESTAMPTZ,
CONSTRAINT export_leases_operation_unique UNIQUE (owner_id, operation_id),
CONSTRAINT export_leases_state_valid CHECK (state IN ('active', 'released', 'expired')),
CONSTRAINT export_leases_expiry_after_creation CHECK (expires_at > created_at)
);
CREATE TABLE export_items (
export_lease_id UUID NOT NULL REFERENCES export_leases(id) ON DELETE CASCADE,
node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE RESTRICT,
blob_id UUID REFERENCES blobs(id) ON DELETE RESTRICT,
node_revision BIGINT NOT NULL,
relative_path TEXT NOT NULL,
PRIMARY KEY (export_lease_id, node_id),
CONSTRAINT export_items_node_revision_positive CHECK (node_revision > 0),
CONSTRAINT export_items_relative_path_not_blank CHECK (btrim(relative_path) <> '')
);
CREATE INDEX export_items_blob_idx ON export_items (blob_id) WHERE blob_id IS NOT NULL;