diff --git a/tests/fixtures/schema/setup-v1.8.0.sql b/tests/fixtures/schema/setup-v1.8.0.sql new file mode 100644 index 0000000..badd255 --- /dev/null +++ b/tests/fixtures/schema/setup-v1.8.0.sql @@ -0,0 +1,657 @@ +-- GitMem Schema Setup +-- Run this SQL in your Supabase SQL Editor to create the required tables. +-- Dashboard → SQL Editor → New query → Paste → Run + +-- Enable pgvector extension for semantic search +CREATE EXTENSION IF NOT EXISTS vector; + +-- ============================================================================ +-- Learnings table (scars, wins, patterns) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS gitmem_learnings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + learning_type TEXT NOT NULL CHECK (learning_type IN ('scar', 'win', 'pattern', 'anti_pattern')), + title TEXT NOT NULL, + description TEXT NOT NULL, + severity TEXT CHECK (severity IN ('critical', 'high', 'medium', 'low')), + scar_type TEXT DEFAULT 'operational', + counter_arguments TEXT[] DEFAULT '{}', + problem_context TEXT DEFAULT '', + solution_approach TEXT DEFAULT '', + applies_when TEXT[] DEFAULT '{}', + keywords TEXT[] DEFAULT '{}', + domain TEXT[] DEFAULT '{}', + embedding vector(1536), + project TEXT DEFAULT 'default', + source_date DATE DEFAULT CURRENT_DATE, + source_linear_issue TEXT, + persona_name TEXT, + why_this_matters TEXT, + action_protocol TEXT, + self_check_criteria TEXT, + is_active BOOLEAN DEFAULT true, + decay_multiplier FLOAT DEFAULT 1.0, + repeat_mistake BOOLEAN DEFAULT false, + related_scar_id UUID, + repeat_mistake_details JSONB, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Migration: ensure all columns exist (idempotent for upgrades) +ALTER TABLE gitmem_learnings ADD COLUMN IF NOT EXISTS embedding vector(1536); +ALTER TABLE gitmem_learnings ADD COLUMN IF NOT EXISTS source_linear_issue TEXT; +ALTER TABLE gitmem_learnings ADD COLUMN IF NOT EXISTS persona_name TEXT; +ALTER TABLE gitmem_learnings ADD COLUMN IF NOT EXISTS why_this_matters TEXT; +ALTER TABLE gitmem_learnings ADD COLUMN IF NOT EXISTS action_protocol TEXT; +ALTER TABLE gitmem_learnings ADD COLUMN IF NOT EXISTS self_check_criteria TEXT; +ALTER TABLE gitmem_learnings ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT true; +ALTER TABLE gitmem_learnings ADD COLUMN IF NOT EXISTS decay_multiplier FLOAT DEFAULT 1.0; +ALTER TABLE gitmem_learnings ADD COLUMN IF NOT EXISTS repeat_mistake BOOLEAN DEFAULT false; +ALTER TABLE gitmem_learnings ADD COLUMN IF NOT EXISTS related_scar_id UUID; +ALTER TABLE gitmem_learnings ADD COLUMN IF NOT EXISTS repeat_mistake_details JSONB; + +-- Indexes (created after migration ensures columns exist) +CREATE INDEX IF NOT EXISTS idx_gitmem_learnings_embedding + ON gitmem_learnings USING ivfflat (embedding vector_cosine_ops) + WITH (lists = 10); + +CREATE INDEX IF NOT EXISTS idx_gitmem_learnings_type + ON gitmem_learnings (learning_type); + +CREATE INDEX IF NOT EXISTS idx_gitmem_learnings_project + ON gitmem_learnings (project); + +-- ============================================================================ +-- Sessions table +-- ============================================================================ +CREATE TABLE IF NOT EXISTS gitmem_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + session_title TEXT DEFAULT 'Interactive Session', + session_date DATE DEFAULT CURRENT_DATE, + agent TEXT DEFAULT 'Unknown', + project TEXT DEFAULT 'default', + linear_issue TEXT, + recording_path TEXT, + transcript_path TEXT, + decisions TEXT[] DEFAULT '{}', + open_threads TEXT[] DEFAULT '{}', + closing_reflection JSONB, + close_compliance JSONB, + rapport_summary TEXT, + embedding vector(1536), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Migration: ensure all columns exist +ALTER TABLE gitmem_sessions ADD COLUMN IF NOT EXISTS embedding vector(1536); +ALTER TABLE gitmem_sessions ADD COLUMN IF NOT EXISTS linear_issue TEXT; +ALTER TABLE gitmem_sessions ADD COLUMN IF NOT EXISTS recording_path TEXT; +ALTER TABLE gitmem_sessions ADD COLUMN IF NOT EXISTS transcript_path TEXT; +ALTER TABLE gitmem_sessions ADD COLUMN IF NOT EXISTS close_compliance JSONB; +ALTER TABLE gitmem_sessions ADD COLUMN IF NOT EXISTS rapport_summary TEXT; + +CREATE INDEX IF NOT EXISTS idx_gitmem_sessions_agent + ON gitmem_sessions (agent); + +CREATE INDEX IF NOT EXISTS idx_gitmem_sessions_created + ON gitmem_sessions (created_at DESC); + +-- ============================================================================ +-- Decisions table +-- ============================================================================ +CREATE TABLE IF NOT EXISTS gitmem_decisions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + decision_date DATE DEFAULT CURRENT_DATE, + title TEXT NOT NULL, + decision TEXT NOT NULL, + rationale TEXT NOT NULL, + alternatives_considered TEXT[] DEFAULT '{}', + personas_involved TEXT[] DEFAULT '{}', + docs_affected TEXT[] DEFAULT '{}', + linear_issue TEXT, + session_id UUID REFERENCES gitmem_sessions(id), + project TEXT DEFAULT 'default', + embedding vector(1536), + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Migration: ensure all columns exist +ALTER TABLE gitmem_decisions ADD COLUMN IF NOT EXISTS embedding vector(1536); +ALTER TABLE gitmem_decisions ADD COLUMN IF NOT EXISTS personas_involved TEXT[] DEFAULT '{}'; +ALTER TABLE gitmem_decisions ADD COLUMN IF NOT EXISTS docs_affected TEXT[] DEFAULT '{}'; +ALTER TABLE gitmem_decisions ADD COLUMN IF NOT EXISTS linear_issue TEXT; + +CREATE INDEX IF NOT EXISTS idx_gitmem_decisions_session + ON gitmem_decisions (session_id); + +-- ============================================================================ +-- Scar usage tracking +-- ============================================================================ +CREATE TABLE IF NOT EXISTS gitmem_scar_usage ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + scar_id UUID REFERENCES gitmem_learnings(id), + session_id UUID REFERENCES gitmem_sessions(id), + agent TEXT DEFAULT 'Unknown', + issue_id TEXT, + issue_identifier TEXT, + reference_type TEXT CHECK (reference_type IN ('explicit', 'implicit', 'acknowledged', 'refuted', 'none')), + reference_context TEXT, + surfaced_at TIMESTAMPTZ, + acknowledged_at TIMESTAMPTZ, + referenced BOOLEAN, + execution_successful BOOLEAN, + variant_id UUID, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_gitmem_scar_usage_scar + ON gitmem_scar_usage (scar_id); + +CREATE INDEX IF NOT EXISTS idx_gitmem_scar_usage_session + ON gitmem_scar_usage (session_id); + +-- Migration: add columns for older installs +ALTER TABLE gitmem_scar_usage ADD COLUMN IF NOT EXISTS issue_id TEXT; +ALTER TABLE gitmem_scar_usage ADD COLUMN IF NOT EXISTS issue_identifier TEXT; +ALTER TABLE gitmem_scar_usage ADD COLUMN IF NOT EXISTS acknowledged_at TIMESTAMPTZ; +ALTER TABLE gitmem_scar_usage ADD COLUMN IF NOT EXISTS referenced BOOLEAN; +ALTER TABLE gitmem_scar_usage ADD COLUMN IF NOT EXISTS variant_id UUID; + +-- ============================================================================ +-- Threads table (cross-session work tracking) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS gitmem_threads ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + thread_id TEXT NOT NULL UNIQUE, + text TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('emerging', 'active', 'cooling', 'dormant', 'archived', 'resolved')), + thread_class TEXT DEFAULT 'operational' CHECK (thread_class IN ('operational', 'backlog')), + vitality_score FLOAT DEFAULT 1.0, + last_touched_at TIMESTAMPTZ DEFAULT NOW(), + touch_count INT DEFAULT 1, + resolved_at TIMESTAMPTZ, + resolution_note TEXT, + source_session UUID REFERENCES gitmem_sessions(id), + resolved_by_session UUID REFERENCES gitmem_sessions(id), + related_issues TEXT[] DEFAULT '{}', + domain TEXT[] DEFAULT '{}', + project TEXT DEFAULT 'default', + metadata JSONB DEFAULT '{}', + embedding vector(1536), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_gitmem_threads_status + ON gitmem_threads (status); + +CREATE INDEX IF NOT EXISTS idx_gitmem_threads_project + ON gitmem_threads (project); + +-- ============================================================================ +-- Knowledge triples (knowledge graph) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS knowledge_triples ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + subject TEXT NOT NULL, + predicate TEXT NOT NULL CHECK (predicate IN ('created_in', 'influenced_by', 'supersedes', 'demonstrates', 'affects_doc', 'created_thread', 'resolves_thread', 'relates_to_thread')), + object TEXT NOT NULL, + event_time TIMESTAMPTZ DEFAULT NOW(), + decay_weight FLOAT DEFAULT 1.0, + half_life_days INT DEFAULT 9999, + decay_floor FLOAT DEFAULT 0.1, + source_type TEXT, + source_id UUID, + source_linear_issue TEXT, + domain TEXT[] DEFAULT '{}', + project TEXT DEFAULT 'default', + created_by TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_gitmem_triples_subject + ON knowledge_triples (subject); + +CREATE INDEX IF NOT EXISTS idx_gitmem_triples_project + ON knowledge_triples (project); + +-- ============================================================================ +-- Query metrics (performance tracking) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS gitmem_query_metrics ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + session_id UUID REFERENCES gitmem_sessions(id), + agent TEXT, + tool_name TEXT NOT NULL, + query_text TEXT, + tables_searched TEXT[], + latency_ms INT, + result_count INT, + similarity_scores FLOAT[], + context_bytes INT, + phase_tag TEXT, + linear_issue TEXT, + memories_surfaced UUID[], + metadata JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ============================================================================ +-- Scar enforcement variants (A/B testing) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS scar_enforcement_variants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + scar_id UUID NOT NULL REFERENCES gitmem_learnings(id), + variant_type TEXT NOT NULL, + variant_config JSONB, + metadata JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ============================================================================ +-- Lite views (exclude embedding columns for reduced context) +-- ============================================================================ +CREATE OR REPLACE VIEW gitmem_learnings_lite AS + SELECT id, learning_type, title, description, severity, scar_type, + counter_arguments, problem_context, solution_approach, applies_when, + keywords, domain, project, source_date, source_linear_issue, + persona_name, is_active, decay_multiplier, created_at, updated_at + FROM gitmem_learnings; + +CREATE OR REPLACE VIEW gitmem_sessions_lite AS + SELECT id, session_title, session_date, agent, project, linear_issue, + decisions, open_threads, closing_reflection, close_compliance, + rapport_summary, created_at, updated_at + FROM gitmem_sessions; + +CREATE OR REPLACE VIEW gitmem_decisions_lite AS + SELECT id, decision_date, title, decision, rationale, + alternatives_considered, personas_involved, docs_affected, + linear_issue, session_id, project, created_at + FROM gitmem_decisions; + +CREATE OR REPLACE VIEW gitmem_threads_lite AS + SELECT id, thread_id, text, status, thread_class, vitality_score, + last_touched_at, touch_count, resolved_at, resolution_note, + source_session, resolved_by_session, related_issues, domain, + project, metadata, created_at, updated_at + FROM gitmem_threads; + +-- ============================================================================ +-- Semantic search RPC function +-- ============================================================================ +CREATE OR REPLACE FUNCTION gitmem_semantic_search( + query_embedding vector(1536), + match_count INT DEFAULT 5, + similarity_threshold FLOAT DEFAULT 0.0 +) +RETURNS TABLE ( + id UUID, + title TEXT, + description TEXT, + severity TEXT, + counter_arguments TEXT[], + similarity FLOAT +) +LANGUAGE plpgsql +AS $$ +BEGIN + RETURN QUERY + SELECT + l.id, + l.title, + l.description, + l.severity, + l.counter_arguments, + 1 - (l.embedding <=> query_embedding) AS similarity + FROM gitmem_learnings l + WHERE l.embedding IS NOT NULL + AND 1 - (l.embedding <=> query_embedding) > similarity_threshold + ORDER BY l.embedding <=> query_embedding + LIMIT match_count; +END; +$$; + +-- ============================================================================ +-- Scar search with temporal + behavioral decay weighting +-- ============================================================================ +CREATE OR REPLACE FUNCTION gitmem_scar_search( + query_embedding vector(1536), + match_count INT DEFAULT 5, + similarity_threshold FLOAT DEFAULT 0.0 +) +RETURNS TABLE ( + id UUID, + title TEXT, + description TEXT, + severity TEXT, + scar_type TEXT, + counter_arguments TEXT[], + decay_multiplier FLOAT, + similarity FLOAT, + weighted_similarity FLOAT +) +LANGUAGE plpgsql +AS $$ +DECLARE + decay_days INT; + temporal_weight FLOAT; +BEGIN + RETURN QUERY + SELECT + l.id, + l.title, + l.description, + l.severity, + l.scar_type, + l.counter_arguments, + COALESCE(l.decay_multiplier, 1.0) AS decay_multiplier, + (1 - (l.embedding <=> query_embedding))::FLOAT AS similarity, + -- Weighted similarity: raw * temporal_decay * behavioral_decay + ( + (1 - (l.embedding <=> query_embedding)) * + -- Temporal decay based on scar_type + CASE + WHEN l.scar_type = 'process' THEN 1.0 -- permanent + WHEN l.scar_type = 'incident' THEN + GREATEST(0.1, 1.0 - 0.9 * (EXTRACT(EPOCH FROM (NOW() - l.created_at)) / (180.0 * 86400))) + WHEN l.scar_type = 'context' THEN + GREATEST(0.1, 1.0 - 0.9 * (EXTRACT(EPOCH FROM (NOW() - l.created_at)) / (30.0 * 86400))) + ELSE 1.0 -- default: no decay + END * + -- Behavioral decay multiplier + COALESCE(l.decay_multiplier, 1.0) + )::FLOAT AS weighted_similarity + FROM gitmem_learnings l + WHERE l.embedding IS NOT NULL + AND COALESCE(l.is_active, true) = true + AND (1 - (l.embedding <=> query_embedding)) > similarity_threshold + ORDER BY weighted_similarity DESC + LIMIT match_count; +END; +$$; + +-- ============================================================================ +-- Refresh behavioral decay scores from scar_usage patterns +-- Aggregates last 90 days of usage, computes dismiss rate, +-- updates decay_multiplier on learnings (minimum 3 surfacings required) +-- ============================================================================ +CREATE OR REPLACE FUNCTION refresh_scar_behavioral_scores() +RETURNS TABLE (scars_updated INT, scars_scanned INT) +LANGUAGE plpgsql +AS $$ +DECLARE + v_scanned INT := 0; + v_updated INT := 0; +BEGIN + -- Aggregate scar_usage from last 90 days, compute dismiss rate + WITH usage_stats AS ( + SELECT + su.scar_id, + COUNT(*) AS times_surfaced, + COUNT(*) FILTER (WHERE su.reference_type IN ('none', 'refuted')) AS times_dismissed + FROM gitmem_scar_usage su + WHERE su.surfaced_at >= NOW() - INTERVAL '90 days' + GROUP BY su.scar_id + HAVING COUNT(*) >= 3 -- minimum surfacings for meaningful signal + ) + UPDATE gitmem_learnings l + SET decay_multiplier = GREATEST(0.1, 1.0 - (us.times_dismissed::FLOAT / us.times_surfaced::FLOAT) * 0.8), + updated_at = NOW() + FROM usage_stats us + WHERE l.id = us.scar_id + AND COALESCE(l.is_active, true) = true; + + GET DIAGNOSTICS v_updated = ROW_COUNT; + + SELECT COUNT(DISTINCT scar_id) INTO v_scanned + FROM gitmem_scar_usage + WHERE surfaced_at >= NOW() - INTERVAL '90 days'; + + RETURN QUERY SELECT v_updated, v_scanned; +END; +$$; + +-- ============================================================================ +-- Row Level Security +-- ============================================================================ + +-- Enable RLS on all tables +ALTER TABLE gitmem_learnings ENABLE ROW LEVEL SECURITY; +ALTER TABLE gitmem_sessions ENABLE ROW LEVEL SECURITY; +ALTER TABLE gitmem_decisions ENABLE ROW LEVEL SECURITY; +ALTER TABLE gitmem_scar_usage ENABLE ROW LEVEL SECURITY; +ALTER TABLE gitmem_threads ENABLE ROW LEVEL SECURITY; +ALTER TABLE knowledge_triples ENABLE ROW LEVEL SECURITY; +ALTER TABLE gitmem_query_metrics ENABLE ROW LEVEL SECURITY; +ALTER TABLE scar_enforcement_variants ENABLE ROW LEVEL SECURITY; + +-- Service role has full access (used by the MCP server) +-- Drop-then-create for idempotency (CREATE POLICY has no IF NOT EXISTS) +DO $$ BEGIN + -- gitmem_learnings + DROP POLICY IF EXISTS "Service role full access" ON gitmem_learnings; + CREATE POLICY "Service role full access" ON gitmem_learnings FOR ALL USING (auth.role() = 'service_role'); + DROP POLICY IF EXISTS "Block anonymous access" ON gitmem_learnings; + CREATE POLICY "Block anonymous access" ON gitmem_learnings FOR ALL USING (auth.role() != 'anon'); + -- gitmem_sessions + DROP POLICY IF EXISTS "Service role full access" ON gitmem_sessions; + CREATE POLICY "Service role full access" ON gitmem_sessions FOR ALL USING (auth.role() = 'service_role'); + DROP POLICY IF EXISTS "Block anonymous access" ON gitmem_sessions; + CREATE POLICY "Block anonymous access" ON gitmem_sessions FOR ALL USING (auth.role() != 'anon'); + -- gitmem_decisions + DROP POLICY IF EXISTS "Service role full access" ON gitmem_decisions; + CREATE POLICY "Service role full access" ON gitmem_decisions FOR ALL USING (auth.role() = 'service_role'); + DROP POLICY IF EXISTS "Block anonymous access" ON gitmem_decisions; + CREATE POLICY "Block anonymous access" ON gitmem_decisions FOR ALL USING (auth.role() != 'anon'); + -- gitmem_scar_usage + DROP POLICY IF EXISTS "Service role full access" ON gitmem_scar_usage; + CREATE POLICY "Service role full access" ON gitmem_scar_usage FOR ALL USING (auth.role() = 'service_role'); + DROP POLICY IF EXISTS "Block anonymous access" ON gitmem_scar_usage; + CREATE POLICY "Block anonymous access" ON gitmem_scar_usage FOR ALL USING (auth.role() != 'anon'); + -- gitmem_threads + DROP POLICY IF EXISTS "Service role full access" ON gitmem_threads; + CREATE POLICY "Service role full access" ON gitmem_threads FOR ALL USING (auth.role() = 'service_role'); + DROP POLICY IF EXISTS "Block anonymous access" ON gitmem_threads; + CREATE POLICY "Block anonymous access" ON gitmem_threads FOR ALL USING (auth.role() != 'anon'); + -- knowledge_triples + DROP POLICY IF EXISTS "Service role full access" ON knowledge_triples; + CREATE POLICY "Service role full access" ON knowledge_triples FOR ALL USING (auth.role() = 'service_role'); + DROP POLICY IF EXISTS "Block anonymous access" ON knowledge_triples; + CREATE POLICY "Block anonymous access" ON knowledge_triples FOR ALL USING (auth.role() != 'anon'); + -- gitmem_query_metrics + DROP POLICY IF EXISTS "Service role full access" ON gitmem_query_metrics; + CREATE POLICY "Service role full access" ON gitmem_query_metrics FOR ALL USING (auth.role() = 'service_role'); + DROP POLICY IF EXISTS "Block anonymous access" ON gitmem_query_metrics; + CREATE POLICY "Block anonymous access" ON gitmem_query_metrics FOR ALL USING (auth.role() != 'anon'); + -- scar_enforcement_variants + DROP POLICY IF EXISTS "Service role full access" ON scar_enforcement_variants; + CREATE POLICY "Service role full access" ON scar_enforcement_variants FOR ALL USING (auth.role() = 'service_role'); + DROP POLICY IF EXISTS "Block anonymous access" ON scar_enforcement_variants; + CREATE POLICY "Block anonymous access" ON scar_enforcement_variants FOR ALL USING (auth.role() != 'anon'); +END $$; + +-- ============================================================================ +-- Auto-update timestamps +-- ============================================================================ +CREATE OR REPLACE FUNCTION gitmem_update_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS gitmem_learnings_updated ON gitmem_learnings; +CREATE TRIGGER gitmem_learnings_updated + BEFORE UPDATE ON gitmem_learnings + FOR EACH ROW EXECUTE FUNCTION gitmem_update_timestamp(); + +DROP TRIGGER IF EXISTS gitmem_sessions_updated ON gitmem_sessions; +CREATE TRIGGER gitmem_sessions_updated + BEFORE UPDATE ON gitmem_sessions + FOR EACH ROW EXECUTE FUNCTION gitmem_update_timestamp(); + +-- ============================================================================ +-- License Management Tables (deployed on GitMem's infrastructure, not user's) +-- These tables are included here for reference and for our own deployment. +-- Users do NOT need to run these — they exist on gitmem-api.supabase.co +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS gitmem_licenses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + api_key TEXT UNIQUE NOT NULL, + tier TEXT NOT NULL DEFAULT 'pro' CHECK (tier IN ('pro', 'dev')), + owner_email TEXT, + is_active BOOLEAN NOT NULL DEFAULT true, + max_activations INTEGER NOT NULL DEFAULT 3, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ, + last_validated_at TIMESTAMPTZ, + metadata JSONB DEFAULT '{}' +); + +CREATE TABLE IF NOT EXISTS gitmem_license_activations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + license_id UUID NOT NULL REFERENCES gitmem_licenses(id) ON DELETE CASCADE, + install_id TEXT NOT NULL, + activated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + device_info JSONB DEFAULT '{}', + UNIQUE(license_id, install_id) +); + +-- RLS: service role only (these are admin tables on our infrastructure) +ALTER TABLE gitmem_licenses ENABLE ROW LEVEL SECURITY; +ALTER TABLE gitmem_license_activations ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Service role full access" ON gitmem_licenses + FOR ALL USING (auth.role() = 'service_role'); + +CREATE POLICY "Service role full access" ON gitmem_license_activations + FOR ALL USING (auth.role() = 'service_role'); + +-- Index for fast key lookup +CREATE INDEX IF NOT EXISTS idx_gitmem_licenses_api_key + ON gitmem_licenses (api_key); + +CREATE INDEX IF NOT EXISTS idx_gitmem_license_activations_license + ON gitmem_license_activations (license_id); + +-- ============================================================================ +-- License Validation RPC +-- Checks key validity, device limit, registers/updates activation +-- ============================================================================ +CREATE OR REPLACE FUNCTION gitmem_validate_license(p_api_key TEXT, p_install_id TEXT) +RETURNS TABLE(tier TEXT, valid BOOLEAN, message TEXT) +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_license_id UUID; + v_tier TEXT; + v_is_active BOOLEAN; + v_max_activations INTEGER; + v_expires_at TIMESTAMPTZ; + v_current_activations INTEGER; +BEGIN + -- Look up the license + SELECT l.id, l.tier, l.is_active, l.max_activations, l.expires_at + INTO v_license_id, v_tier, v_is_active, v_max_activations, v_expires_at + FROM gitmem_licenses l + WHERE l.api_key = p_api_key; + + -- Key not found + IF v_license_id IS NULL THEN + RETURN QUERY SELECT NULL::TEXT, false, 'Invalid license key'::TEXT; + RETURN; + END IF; + + -- Key deactivated + IF NOT v_is_active THEN + RETURN QUERY SELECT NULL::TEXT, false, 'License has been deactivated'::TEXT; + RETURN; + END IF; + + -- Key expired + IF v_expires_at IS NOT NULL AND v_expires_at < NOW() THEN + RETURN QUERY SELECT NULL::TEXT, false, 'License has expired'::TEXT; + RETURN; + END IF; + + -- Count current activations (excluding this install_id if already registered) + SELECT COUNT(*) + INTO v_current_activations + FROM gitmem_license_activations a + WHERE a.license_id = v_license_id + AND a.install_id != p_install_id; + + -- Check device limit (if this is a new device) + IF v_current_activations >= v_max_activations THEN + -- Check if this install_id already exists (re-validation) + IF NOT EXISTS ( + SELECT 1 FROM gitmem_license_activations a + WHERE a.license_id = v_license_id AND a.install_id = p_install_id + ) THEN + RETURN QUERY SELECT NULL::TEXT, false, + format('Device limit reached (%s/%s). Deactivate another device or contact support.', v_current_activations, v_max_activations)::TEXT; + RETURN; + END IF; + END IF; + + -- Register or update activation + INSERT INTO gitmem_license_activations (license_id, install_id, last_seen_at) + VALUES (v_license_id, p_install_id, NOW()) + ON CONFLICT (license_id, install_id) + DO UPDATE SET last_seen_at = NOW(); + + -- Update last_validated_at on the license + UPDATE gitmem_licenses SET last_validated_at = NOW() WHERE id = v_license_id; + + -- Success + RETURN QUERY SELECT v_tier, true, 'Valid'::TEXT; +END; +$$; + +-- ============================================================================ +-- Device Deactivation RPC +-- Removes a device activation for a license key + install_id pair. +-- Called by `gitmem-mcp deactivate` to free up a device slot. +-- ============================================================================ +CREATE OR REPLACE FUNCTION gitmem_deactivate_device(p_api_key TEXT, p_install_id TEXT) +RETURNS TABLE(success BOOLEAN, message TEXT) +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_license_id UUID; + v_deleted INTEGER; +BEGIN + -- Look up the license + SELECT l.id INTO v_license_id + FROM gitmem_licenses l + WHERE l.api_key = p_api_key; + + IF v_license_id IS NULL THEN + RETURN QUERY SELECT false, 'Invalid license key'::TEXT; + RETURN; + END IF; + + -- Delete the activation for this install_id + DELETE FROM gitmem_license_activations a + WHERE a.license_id = v_license_id + AND a.install_id = p_install_id; + + GET DIAGNOSTICS v_deleted = ROW_COUNT; + + IF v_deleted > 0 THEN + RETURN QUERY SELECT true, 'Device deactivated'::TEXT; + ELSE + RETURN QUERY SELECT true, 'Device was not registered (already deactivated)'::TEXT; + END IF; +END; +$$; diff --git a/tests/helpers/schema-drift.ts b/tests/helpers/schema-drift.ts new file mode 100644 index 0000000..67b19dc --- /dev/null +++ b/tests/helpers/schema-drift.ts @@ -0,0 +1,529 @@ +/** + * GIT-109 step 2: static schema-drift analysis. + * + * Customers never run SQL after initial setup (standing rule, 2026-09-21), so + * every table and column gitmem touches must exist in the v1.8.0 setup.sql — + * or be deliberately guarded. Release B found closes lost to exactly this: the + * code wrote production-only columns a setup.sql store does not have. + * + * This walks src/ with the TypeScript compiler API and collects, per call site: + * writes — table + payload keys reaching directUpsert / directPatch / + * upsertRecord / storage.upsert + * reads — table + select / filter / order keys reaching directQuery, + * directQueryAll, listRecords, getRecord + * and compares them with a parsed setup.sql (table -> columns, views included). + * + * Resolution is deliberately conservative: a table or payload it cannot + * resolve statically is reported as UNRESOLVED, never assumed fine. + */ +import * as fs from "fs"; +import * as path from "path"; +import ts from "typescript"; + +// ---------------------------------------------------------------- schema + +export type Schema = Map>; + +const CONSTRAINT_WORDS = /^(PRIMARY|UNIQUE|CONSTRAINT|FOREIGN|CHECK|EXCLUDE)\b/i; + +/** setup.sql -> table/view name -> columns. */ +export function parseSchema(sql: string): Schema { + const schema: Schema = new Map(); + const clean = sql.replace(/--[^\n]*/g, ""); + const add = (t: string, c: string) => { + if (!schema.has(t)) schema.set(t, new Set()); + schema.get(t)!.add(c); + }; + + // CREATE TABLE ... ( body ); — body split on top-level commas + const tableRe = /CREATE TABLE(?: IF NOT EXISTS)?\s+(?:public\.)?(\w+)\s*\(/gi; + let m: RegExpExecArray | null; + while ((m = tableRe.exec(clean))) { + const table = m[1]; + let depth = 1, i = tableRe.lastIndex, start = i; + const parts: string[] = []; + for (; i < clean.length && depth > 0; i++) { + const ch = clean[i]; + if (ch === "(") depth++; + else if (ch === ")") { depth--; if (depth === 0) parts.push(clean.slice(start, i)); } + else if (ch === "," && depth === 1) { parts.push(clean.slice(start, i)); start = i + 1; } + } + if (!schema.has(table)) schema.set(table, new Set()); + for (const p of parts) { + const line = p.trim(); + if (!line || CONSTRAINT_WORDS.test(line)) continue; + const col = line.match(/^"?(\w+)"?/); + if (col) add(table, col[1]); + } + } + + const alterRe = /ALTER TABLE\s+(?:IF EXISTS\s+)?(?:public\.)?(\w+)\s+ADD COLUMN(?: IF NOT EXISTS)?\s+"?(\w+)"?/gi; + while ((m = alterRe.exec(clean))) add(m[1], m[2]); + + const viewRe = /CREATE (?:OR REPLACE )?VIEW\s+(?:public\.)?(\w+)\s+AS\s+SELECT\s+([\s\S]+?)\s+FROM\s+(?:public\.)?(\w+)/gi; + while ((m = viewRe.exec(clean))) { + const [, view, list, base] = m; + if (!schema.has(view)) schema.set(view, new Set()); + for (const item of list.split(",").map((s) => s.trim())) { + if (item === "*") { for (const c of schema.get(base) ?? []) add(view, c); continue; } + const alias = item.match(/\bAS\s+(\w+)$/i); + add(view, alias ? alias[1] : item.replace(/^\w+\./, "").match(/^(\w+)/)?.[1] ?? item); + } + } + return schema; +} + +// ---------------------------------------------------------------- code scan + +export type AccessKind = "write" | "select" | "filter" | "order"; + +export interface Access { + file: string; + line: number; + call: string; + table: string; + column: string; + kind: AccessKind; +} + +export interface Unresolved { + file: string; + line: number; + call: string; + what: string; +} + +export interface ScanResult { + accesses: Access[]; + /** Generic helpers whose table is a parameter (storage.ts, supabase-client.ts): checked at their callers. */ + passThrough: Unresolved[]; + /** Tables reached with no column information (e.g. a bare select=*). */ + tables: Array<{ file: string; line: number; call: string; table: string }>; + unresolved: Unresolved[]; + /** Call sites in free-tier-only code (local JSON files, not the store). */ + freeTierOnly: number; + callSites: number; +} + +const WRITE_CALLS = new Set(["directUpsert", "upsertRecord"]); +const PATCH_CALLS = new Set(["directPatch"]); +const QUERY_CALLS = new Set(["directQuery", "directQueryAll"]); +const DEFAULT_PREFIX = "gitmem_"; + +function calleeName(expr: ts.LeftHandSideExpression): { name: string; receiver?: string } | null { + if (ts.isIdentifier(expr)) return { name: expr.text }; + if (ts.isPropertyAccessExpression(expr)) { + const recv = expr.expression; + const receiver = ts.isIdentifier(recv) ? recv.text : ts.isCallExpression(recv) && ts.isIdentifier(recv.expression) ? `${recv.expression.text}()` : undefined; + return { name: expr.name.text, receiver }; + } + return null; +} + +/** Find the nearest declaration of `name` visible from `from` (same file). */ +function findDeclaration(name: string, from: ts.Node): ts.VariableDeclaration | ts.ParameterDeclaration | null { + let scope: ts.Node | undefined = from; + while (scope) { + let found: ts.VariableDeclaration | ts.ParameterDeclaration | null = null; + const visit = (n: ts.Node): void => { + if (found) return; + if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && n.name.text === name && n.pos < from.pos) found = n; + else if (ts.isParameter(n) && ts.isIdentifier(n.name) && n.name.text === name) found = n; + if (n !== scope && (ts.isFunctionLike(n) || ts.isClassLike(n))) { + // parameters of the enclosing function are visited via the scope itself + return; + } + ts.forEachChild(n, visit); + }; + if (ts.isFunctionLike(scope)) for (const p of scope.parameters) visit(p); + ts.forEachChild(scope, visit); + if (found) return found; + scope = scope.parent; + } + return null; +} + +function resolveTable(expr: ts.Expression | undefined): string | null { + if (!expr) return null; + if (ts.isStringLiteralLike(expr)) return expr.text; + if (ts.isParenthesizedExpression(expr) || ts.isAsExpression(expr) || ts.isNonNullExpression(expr)) return resolveTable(expr.expression); + if (ts.isCallExpression(expr) && ts.isIdentifier(expr.expression) && expr.expression.text === "getTableName") { + const arg = expr.arguments[0]; + return arg && ts.isStringLiteralLike(arg) ? DEFAULT_PREFIX + arg.text : null; + } + if (ts.isIdentifier(expr)) { + const decl = findDeclaration(expr.text, expr); + if (decl && ts.isVariableDeclaration(decl) && decl.initializer) return resolveTable(decl.initializer); + } + return null; +} + +interface KeySet { keys: Set; complete: boolean; why: string[] } + +function propName(p: ts.ObjectLiteralElementLike): string | null { + const n = p.name; + if (!n) return null; + if (ts.isIdentifier(n) || ts.isStringLiteralLike(n) || ts.isNumericLiteral(n)) return n.text; + return null; +} + +/** Keys of an object-valued expression, following spreads, conditionals and later assignments. */ +function resolveKeys(expr: ts.Expression | undefined, seen = new Set()): KeySet { + const out: KeySet = { keys: new Set(), complete: true, why: [] }; + const merge = (k: KeySet) => { k.keys.forEach((x) => out.keys.add(x)); if (!k.complete) { out.complete = false; out.why.push(...k.why); } }; + if (!expr) return { keys: new Set(), complete: false, why: ["no payload"] }; + if (seen.has(expr)) return out; + seen.add(expr); + + if (ts.isParenthesizedExpression(expr) || ts.isAsExpression(expr) || ts.isNonNullExpression(expr) || ts.isSatisfiesExpression?.(expr)) { + return resolveKeys((expr as ts.ParenthesizedExpression).expression, seen); + } + if (ts.isObjectLiteralExpression(expr)) { + for (const p of expr.properties) { + if (ts.isSpreadAssignment(p)) merge(resolveKeys(p.expression, seen)); + else if (ts.isShorthandPropertyAssignment(p)) out.keys.add(p.name.text); + else { + const n = propName(p); + if (n) out.keys.add(n); else { out.complete = false; out.why.push(`computed key ${p.getText().slice(0, 40)}`); } + } + } + return out; + } + // `cond && {…}` / `cond ? {…} : {…}` + if (ts.isBinaryExpression(expr) && (expr.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken || expr.operatorToken.kind === ts.SyntaxKind.BarBarToken || expr.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken)) { + if (ts.isObjectLiteralExpression(expr.right) || ts.isParenthesizedExpression(expr.right)) return resolveKeys(expr.right, seen); + } + if (ts.isConditionalExpression(expr)) { + merge(resolveKeys(expr.whenTrue, seen)); + merge(resolveKeys(expr.whenFalse, seen)); + return out; + } + if (ts.isIdentifier(expr)) { + const decl = findDeclaration(expr.text, expr); + // A callback parameter: `rows.map((r) => write(r))` — the elements of rows. + if (decl && ts.isParameter(decl) && ts.isIdentifier(decl.name)) { + const fn = decl.parent; + const call = fn.parent; + if ((ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) && ts.isCallExpression(call) && call.arguments[0] === fn && + ts.isPropertyAccessExpression(call.expression) && /^(map|forEach|flatMap)$/.test(call.expression.name.text)) { + return elementKeys(call.expression.expression, seen); + } + } + if (decl && ts.isVariableDeclaration(decl) && decl.initializer) { + merge(resolveKeys(decl.initializer, seen)); + // later `x.prop = …` / `x["prop"] = …` in the same function + const fn = enclosingFunction(decl) ?? decl.getSourceFile(); + const visit = (n: ts.Node): void => { + if (ts.isBinaryExpression(n) && n.operatorToken.kind === ts.SyntaxKind.EqualsToken) { + const l = n.left; + if (ts.isPropertyAccessExpression(l) && ts.isIdentifier(l.expression) && l.expression.text === expr.text) out.keys.add(l.name.text); + if (ts.isElementAccessExpression(l) && ts.isIdentifier(l.expression) && l.expression.text === expr.text && ts.isStringLiteralLike(l.argumentExpression)) out.keys.add(l.argumentExpression.text); + } + ts.forEachChild(n, visit); + }; + visit(fn); + return out; + } + return { keys: new Set(), complete: false, why: [`payload ${expr.text} not resolvable`] }; + } + if (ts.isCallExpression(expr)) { + // Column filters narrow a payload; the payload is their first argument. + const name = ts.isIdentifier(expr.expression) ? expr.expression.text : ts.isPropertyAccessExpression(expr.expression) ? expr.expression.name.text : ""; + if (/^filterTo|Columns$/.test(name) && expr.arguments[0]) { + const k = resolveKeys(expr.arguments[0], seen); + k.why.push(`filtered by ${name}`); + return k; + } + // A function in this file that returns an object literal. + const local = ts.isIdentifier(expr.expression) ? findLocalFunction(expr.expression.text, expr.getSourceFile()) : null; + if (local) return returnKeys(local, seen); + if (ts.isAwaitExpression(expr.parent)) {/* fallthrough */} + return { keys: new Set(), complete: false, why: [`payload from ${name || "call"}()`] }; + } + if (ts.isAwaitExpression(expr)) return resolveKeys(expr.expression, seen); + return { keys: new Set(), complete: false, why: [`payload ${ts.SyntaxKind[expr.kind]}`] }; +} + +function findLocalFunction(name: string, sf: ts.SourceFile): ts.FunctionLikeDeclaration | null { + let found: ts.FunctionLikeDeclaration | null = null; + const visit = (n: ts.Node): void => { + if (found) return; + if (ts.isFunctionDeclaration(n) && n.name?.text === name && n.body) found = n; + else if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && n.name.text === name && n.initializer && + (ts.isArrowFunction(n.initializer) || ts.isFunctionExpression(n.initializer))) found = n.initializer; + else ts.forEachChild(n, visit); + }; + visit(sf); + return found; +} + +/** Keys of every object a function returns (its own returns, not nested functions'). */ +function returnKeys(fn: ts.FunctionLikeDeclaration, seen: Set): KeySet { + const out: KeySet = { keys: new Set(), complete: true, why: [] }; + const merge = (k: KeySet) => { k.keys.forEach((x) => out.keys.add(x)); if (!k.complete) { out.complete = false; out.why.push(...k.why); } }; + if (fn.body && !ts.isBlock(fn.body)) { merge(resolveKeys(fn.body as ts.Expression, seen)); return out; } + const visit = (n: ts.Node): void => { + if (n !== fn && ts.isFunctionLike(n)) return; + if (ts.isReturnStatement(n) && n.expression) merge(resolveKeys(n.expression, seen)); + ts.forEachChild(n, visit); + }; + if (fn.body) visit(fn.body); + if (out.keys.size === 0 && out.complete) return { keys: out.keys, complete: false, why: ["function returns no object literal"] }; + return out; +} + +/** Keys of the elements of an array-valued expression. */ +function elementKeys(expr: ts.Expression, seen: Set): KeySet { + const out: KeySet = { keys: new Set(), complete: true, why: [] }; + const merge = (k: KeySet) => { k.keys.forEach((x) => out.keys.add(x)); if (!k.complete) { out.complete = false; out.why.push(...k.why); } }; + if (ts.isParenthesizedExpression(expr) || ts.isAsExpression(expr)) return elementKeys(expr.expression, seen); + if (ts.isArrayLiteralExpression(expr)) { + for (const el of expr.elements) merge(ts.isSpreadElement(el) ? elementKeys(el.expression, seen) : resolveKeys(el, seen)); + return out; + } + if (ts.isIdentifier(expr)) { + const d = findDeclaration(expr.text, expr); + if (d && ts.isVariableDeclaration(d) && d.initializer) return elementKeys(d.initializer, seen); + } + if (ts.isCallExpression(expr) && ts.isPropertyAccessExpression(expr.expression)) { + const m = expr.expression.name.text; + if (/^(filter|slice|concat|reverse|sort)$/.test(m)) return elementKeys(expr.expression.expression, seen); + const cb = expr.arguments[0]; + if (m === "map" && cb && (ts.isArrowFunction(cb) || ts.isFunctionExpression(cb))) return returnKeys(cb, seen); + } + return { keys: new Set(), complete: false, why: [`elements of ${expr.getText().slice(0, 40)}`] }; +} + +function enclosingFunction(n: ts.Node): ts.Node | null { + let p: ts.Node | undefined = n.parent; + while (p) { if (ts.isFunctionLike(p)) return p; p = p.parent; } + return null; +} + +/** PostgREST select list -> base columns (alias:col, col::cast, col->>path; embeds skipped). */ +export function selectColumns(select: string): { columns: string[]; star: boolean } { + const cols: string[] = []; + let star = false, depth = 0, cur = ""; + const flush = () => { + const item = cur.trim(); cur = ""; + if (!item) return; + if (item === "*") { star = true; return; } + if (item.includes("(")) return; // resource embedding rel(...) + const body = item.includes(":") && !item.includes("::") ? item.split(":").pop()! : item.replace(/^\w+:(?!:)/, ""); + const col = body.split("::")[0].split("->")[0].trim(); + if (/^\w+$/.test(col)) cols.push(col); + }; + for (const ch of select) { + if (ch === "(") depth++; + if (ch === ")") depth--; + if (ch === "," && depth === 0) flush(); else cur += ch; + } + flush(); + return { columns: cols, star }; +} + +function resolveString(expr: ts.Expression | undefined): string | null { + if (!expr) return null; + if (ts.isStringLiteralLike(expr)) return expr.text; + if (ts.isIdentifier(expr)) { + const decl = findDeclaration(expr.text, expr); + if (decl && ts.isVariableDeclaration(decl) && decl.initializer) return resolveString(decl.initializer); + } + if (ts.isBinaryExpression(expr) && expr.operatorToken.kind === ts.SyntaxKind.PlusToken) { + const l = resolveString(expr.left), r = resolveString(expr.right); + return l !== null && r !== null ? l + r : null; + } + return null; +} + +/** Inside code that only runs without Supabase (local JSON files). */ +function isFreeTierOnly(n: ts.Node): boolean { + let child: ts.Node = n; + let p: ts.Node | undefined = n.parent; + while (p) { + if (ts.isIfStatement(p)) { + const cond = p.expression.getText(); + const positive = /^hasSupabase\(\)(\s*&&.*)?$/.test(cond); + const negative = /^!hasSupabase\(\)/.test(cond); + if (positive && p.elseStatement === child) return true; + if (negative && p.thenStatement === child) return true; + } + if ((ts.isFunctionDeclaration(p) || ts.isMethodDeclaration(p)) && p.name && /Free$/.test(p.name.getText())) return true; + child = p; + p = p.parent; + } + return false; +} + +function objectProp(obj: ts.Expression | undefined, key: string): ts.Expression | undefined { + if (!obj) return undefined; + if (ts.isIdentifier(obj)) { + const d = findDeclaration(obj.text, obj); + return d && ts.isVariableDeclaration(d) ? objectProp(d.initializer, key) : undefined; + } + if (!ts.isObjectLiteralExpression(obj)) return undefined; + for (const p of obj.properties) { + if (ts.isPropertyAssignment(p) && propName(p) === key) return p.initializer; + if (ts.isShorthandPropertyAssignment(p) && p.name.text === key) { + const d = findDeclaration(key, p); + return d && ts.isVariableDeclaration(d) ? d.initializer : undefined; + } + } + return undefined; +} + +export function scanSources(srcDir: string, exclude: (file: string) => boolean = () => false): ScanResult { + const result: ScanResult = { accesses: [], passThrough: [], tables: [], unresolved: [], freeTierOnly: 0, callSites: 0 }; + const files: string[] = []; + const walk = (d: string) => { + for (const e of fs.readdirSync(d, { withFileTypes: true })) { + const p = path.join(d, e.name); + if (e.isDirectory()) walk(p); + else if (e.name.endsWith(".ts") && !e.name.endsWith(".test.ts") && !e.name.endsWith(".d.ts")) files.push(p); + } + }; + walk(srcDir); + + for (const file of files) { + const rel = path.relative(path.dirname(srcDir), file); + if (exclude(rel)) continue; + const sf = ts.createSourceFile(file, fs.readFileSync(file, "utf-8"), ts.ScriptTarget.Latest, true); + const visit = (n: ts.Node): void => { + if (ts.isCallExpression(n)) inspect(n, sf, rel, result); + ts.forEachChild(n, visit); + }; + visit(sf); + } + return result; +} + +function inspect(call: ts.CallExpression, sf: ts.SourceFile, file: string, r: ScanResult): void { + const c = calleeName(call.expression); + if (!c) return; + const isStorageUpsert = c.name === "upsert" && (c.receiver === "storage" || c.receiver === "getStorage()"); + const kind = WRITE_CALLS.has(c.name) ? "write" : PATCH_CALLS.has(c.name) ? "patch" : QUERY_CALLS.has(c.name) ? "query" + : c.name === "listRecords" ? "list" : c.name === "getRecord" ? "get" : isStorageUpsert ? "storage" : null; + if (!kind) return; + // Skip the definitions themselves (supabase-client.ts calling its own helpers with parameters). + const line = sf.getLineAndCharacterOfPosition(call.getStart()).line + 1; + const callText = c.receiver ? `${c.receiver}.${c.name}` : c.name; + r.callSites++; + if (isFreeTierOnly(call)) { r.freeTierOnly++; return; } + + const args = call.arguments; + const addCols = (table: string, cols: Iterable, k: AccessKind) => { + for (const col of cols) r.accesses.push({ file, line, call: callText, table, column: col, kind: k }); + }; + const unresolved = (what: string) => r.unresolved.push({ file, line, call: callText, what }); + + let tableExpr: ts.Expression | undefined = args[0]; + let table: string | null; + if (kind === "storage") { + const coll = args[0] && ts.isStringLiteralLike(args[0]) ? args[0].text : null; + table = coll ? DEFAULT_PREFIX + coll : null; + } else if (kind === "list") { + tableExpr = objectProp(args[0], "table"); + table = resolveTable(tableExpr); + } else { + table = resolveTable(tableExpr); + } + if (!table) { + const paramOf = (e: ts.Expression | undefined): boolean => { + if (!e) return false; + if (ts.isIdentifier(e)) { + const d = findDeclaration(e.text, e); + if (d && ts.isParameter(d)) return true; + if (d && ts.isVariableDeclaration(d)) return paramOf(d.initializer); + // shorthand `{ table }` inside listRecords({ table, ... }) + return false; + } + if (ts.isCallExpression(e) && ts.isIdentifier(e.expression) && e.expression.text === "getTableName") return paramOf(e.arguments[0]); + return false; + }; + const isParam = paramOf(tableExpr) || (kind === "list" && !tableExpr && !!args[0] && ts.isObjectLiteralExpression(args[0]) && + args[0].properties.some((p) => ts.isShorthandPropertyAssignment(p) && p.name.text === "table" && (() => { const d = findDeclaration("table", p); return !!d && ts.isParameter(d); })())); + const isStorageParam = kind === "storage" && args[0] && ts.isIdentifier(args[0]); + if (isParam || isStorageParam) r.passThrough.push({ file, line, call: callText, what: `table is parameter ${tableExpr?.getText() ?? args[0]?.getText()}` }); + else unresolved(`table ${tableExpr?.getText().slice(0, 50) ?? "(none)"}`); + return; + } + + const keysFrom = (e: ts.Expression | undefined, k: AccessKind, label: string) => { + const ks = resolveKeys(e); + addCols(table!, [...ks.keys].filter((x) => !(k === "filter" && (x === "or" || x === "and" || x === "not"))), k); + if (!ks.complete) unresolved(`${label}: ${ks.why.join("; ")}`); + }; + + switch (kind) { + case "write": + case "storage": + keysFrom(args[1], "write", "payload"); + break; + case "patch": + keysFrom(args[1], "filter", "patch filter"); + keysFrom(args[2], "write", "patch payload"); + break; + case "get": + addCols(table, ["id"], "filter"); + break; + case "query": + case "list": { + const opts = kind === "query" ? args[1] : args[0]; + const selExpr = objectProp(opts, kind === "query" ? "select" : "columns"); + if (selExpr) { + const sel = resolveString(selExpr); + if (sel === null) unresolved(`select ${selExpr.getText().slice(0, 50)}`); + else { + const { columns, star } = selectColumns(sel); + addCols(table, columns, "select"); + if (star || columns.length === 0) r.tables.push({ file, line, call: callText, table }); + } + } else { + r.tables.push({ file, line, call: callText, table }); // default select=* + } + const filt = objectProp(opts, "filters"); + if (filt) keysFrom(filt, "filter", "filters"); + const order = kind === "query" ? resolveString(objectProp(opts, "order")) : null; + if (order) addCols(table, order.split(",").map((o) => o.split(".")[0].trim()).filter(Boolean), "order"); + const orderBy = kind === "list" ? objectProp(opts, "orderBy") : undefined; + if (orderBy) { + const col = resolveString(objectProp(orderBy, "column")); + if (col) addCols(table, [col], "order"); + } + break; + } + } +} + +// ---------------------------------------------------------------- compare + +export interface Finding { + table: string; + /** null: the table itself is missing. */ + column: string | null; + kind: AccessKind | "table"; + sites: string[]; + /** Present in the current schema/setup.sql but not in v1.8.0. */ + addedAfterFloor: boolean; +} + +export function compare(scan: ScanResult, floor: Schema, current: Schema): Finding[] { + const byKey = new Map(); + const note = (table: string, column: string | null, kind: Finding["kind"], site: string) => { + const key = `${table}${column ?? ""}${column === null ? "table" : kind}`; + let f = byKey.get(key); + if (!f) { + const inCurrent = column === null ? current.has(table) : !!current.get(table)?.has(column); + f = { table, column, kind: column === null ? "table" : kind, sites: [], addedAfterFloor: inCurrent }; + byKey.set(key, f); + } + if (!f.sites.includes(site)) f.sites.push(site); + }; + for (const a of scan.accesses) { + const site = `${a.file}:${a.line}`; + if (!floor.has(a.table)) note(a.table, null, "table", site); + else if (!floor.get(a.table)!.has(a.column)) note(a.table, a.column, a.kind, site); + } + for (const t of scan.tables) if (!floor.has(t.table)) note(t.table, null, "table", `${t.file}:${t.line}`); + return [...byKey.values()].sort((x, y) => `${x.table}.${x.column}`.localeCompare(`${y.table}.${y.column}`)); +} diff --git a/tests/unit/schema-drift.test.ts b/tests/unit/schema-drift.test.ts new file mode 100644 index 0000000..d29936b --- /dev/null +++ b/tests/unit/schema-drift.test.ts @@ -0,0 +1,179 @@ +/** + * GIT-109 step 2: nothing gitmem reads or writes may be missing from the + * v1.8.0 setup.sql, unless it is allowlisted against the ticket that owns it. + * + * Standing rule (Chris, 2026-09-21): customers never run SQL after initial + * setup, so every store in the field may still be on the v1.8.0 schema. The + * pinned copy is tests/fixtures/schema/setup-v1.8.0.sql (byte-identical to + * `git show v1.8.0:schema/setup.sql`). + * + * What fails: + * - a table or column reached from src/ that v1.8.0 does not have and that + * is not on ALLOWED below; + * - an ALLOWED entry that no longer matches anything (the list only shrinks); + * - an ALLOWED entry without a GIT-### ticket; + * - a call site the analyzer cannot resolve that is not on KNOWN_UNRESOLVED + * (so coverage cannot silently decay); + * - a SESSION_COLUMNS entry missing from v1.8.0 that is not production-only. + * See tests/helpers/schema-drift.ts for what is collected and how. + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { parseSchema, scanSources, compare, selectColumns } from "../helpers/schema-drift.js"; +import type { Finding, ScanResult, Schema } from "../helpers/schema-drift.js"; +import { SESSION_COLUMNS, PRODUCTION_ONLY_SESSION_COLUMNS } from "../../src/services/session-columns.js"; + +const REPO = path.resolve(__dirname, "../.."); +const FLOOR_SQL = path.join(REPO, "tests/fixtures/schema/setup-v1.8.0.sql"); +const CURRENT_SQL = path.join(REPO, "schema/setup.sql"); + +interface Allowed { + table: string; + column: string | null; + kind: Finding["kind"]; + ticket: string; + reason: string; +} + +/** + * Known gaps, each owned by a ticket. Remove an entry when its ticket lands — + * the test fails on an entry that matches nothing. + */ +const ALLOWED: Allowed[] = [ + // Written only where the store has the column (store-columns.ts probe). + { table: "gitmem_learnings", column: "archived_at", kind: "write", ticket: "GIT-109", reason: "probe-guarded: archive-learning.ts writes it only if supportedColumns() finds it" }, + { table: "gitmem_sessions", column: "claude_code_session_id", kind: "write", ticket: "GIT-109", reason: "probe-guarded: production-only, dropped by filterToStoreSessionColumns() on setup.sql stores" }, + { table: "gitmem_sessions", column: "task_observations", kind: "write", ticket: "GIT-109", reason: "probe-guarded: absorb-observations.ts writes it only if supportedColumns() finds it" }, + // Scar variant A/B testing is dev-tier only (hasVariants()); customer schemas do not provision it. + { table: "variant_assignments", column: null, kind: "table", ticket: "GIT-106", reason: "dev tier only; table not in setup.sql" }, + { table: "variant_performance_metrics", column: null, kind: "table", ticket: "GIT-106", reason: "dev tier only; table not in setup.sql" }, + { table: "scar_enforcement_variants", column: "active", kind: "filter", ticket: "GIT-106", reason: "dev tier only; setup.sql's table has a different shape" }, + { table: "scar_enforcement_variants", column: "active", kind: "write", ticket: "GIT-106", reason: "dev tier only; setup.sql's table has a different shape" }, + { table: "scar_enforcement_variants", column: "description", kind: "write", ticket: "GIT-106", reason: "dev tier only; setup.sql's table has a different shape" }, + { table: "scar_enforcement_variants", column: "enforcement_config", kind: "write", ticket: "GIT-106", reason: "dev tier only; setup.sql's table has a different shape" }, + { table: "scar_enforcement_variants", column: "variant_name", kind: "write", ticket: "GIT-106", reason: "dev tier only; setup.sql's table has a different shape" }, + { table: "scar_enforcement_variants", column: "variant_version", kind: "write", ticket: "GIT-106", reason: "dev tier only; setup.sql's table has a different shape" }, +]; + +/** + * Call sites the analyzer cannot resolve statically, by file and what is + * missing. Each is checked another way or is out of reach; a NEW one fails. + */ +const KNOWN_UNRESOLVED: Array<{ file: string; what: RegExp; why: string }> = [ + { file: "src/services/thread-supabase.ts", what: /^filters: /, why: "filters come from buildScopedThreadQuery() in thread-scope.ts (another file); thread-scope tests pin them" }, + { file: "src/services/write-health.ts", what: /^table /, why: "loops over getTableName(\"learnings\" | \"decisions\"), select id only" }, + { file: "src/tools/session-close.ts", what: /sessionData not resolvable; filtered by filterToStoreSessionColumns/, why: "narrowed to SESSION_COLUMNS, checked against v1.8.0 below" }, +]; + +let floor: Schema; +let current: Schema; +let scan: ScanResult; +let findings: Finding[]; + +beforeAll(() => { + floor = parseSchema(fs.readFileSync(FLOOR_SQL, "utf-8")); + current = parseSchema(fs.readFileSync(CURRENT_SQL, "utf-8")); + scan = scanSources(path.join(REPO, "src")); + findings = compare(scan, floor, current); +}); + +const matches = (a: Allowed, f: Finding) => a.table === f.table && a.column === f.column && a.kind === f.kind; +const label = (f: Finding) => `${f.table}.${f.column ?? "(table)"} [${f.kind}]${f.addedAfterFloor ? " (added after 1.8.0)" : " (in no setup.sql)"} at ${f.sites.join(", ")}`; + +describe("schema parser", () => { + it("reads the v1.8.0 tables, ALTER-added columns and views", () => { + expect([...floor.keys()]).toEqual(expect.arrayContaining([ + "gitmem_learnings", "gitmem_sessions", "gitmem_decisions", "gitmem_scar_usage", "gitmem_threads", + "knowledge_triples", "gitmem_query_metrics", "scar_enforcement_variants", "gitmem_threads_lite", "gitmem_sessions_lite", + ])); + expect(floor.get("gitmem_learnings")).toContain("decay_multiplier"); // ALTER TABLE ... ADD COLUMN + expect(floor.get("gitmem_threads")).toContain("resolved_by_session"); + expect(floor.get("gitmem_threads")).not.toContain("CHECK"); + expect(floor.get("gitmem_sessions_lite")).toContain("close_compliance"); // view + expect(floor.get("gitmem_sessions_lite")).not.toContain("embedding"); + }); + + it("the pinned floor is the v1.8.0 schema, not a later one", () => { + expect(fs.readFileSync(FLOOR_SQL, "utf-8")).toMatch(/refresh_scar_behavioral_scores/); + // The release-B setup.sql re-creates the usage table idempotently (GIT-84); v1.8.0 did not. + expect(fs.readFileSync(FLOOR_SQL, "utf-8")).not.toBe(fs.readFileSync(CURRENT_SQL, "utf-8")); + }); + + it("parses PostgREST select lists", () => { + expect(selectColumns("id,title:t,embedding::text,metadata->>session_id,rel(a,b),*")).toEqual({ + columns: ["id", "t", "embedding", "metadata"], star: true, + }); + }); +}); + +describe("analyzer catches drift (planted in a synthetic source tree)", () => { + let dir: string; + beforeAll(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "gitmem-drift-")); + fs.mkdirSync(path.join(dir, "src")); + fs.writeFileSync(path.join(dir, "src", "planted.ts"), ` + declare const supabase: any; declare function getTableName(b: string): string; declare function hasSupabase(): boolean; + declare const getStorage: () => any; + export async function a() { + const row = { id: "x", bogus_column: 1, ...(true && { also_bogus: 2 }) }; + row.assigned_later = 3; + await supabase.directUpsert(getTableName("sessions"), row); + await supabase.directQuery("knowledge_triples", { select: "id,no_such_col", filters: { nope: "eq.1" }, order: "gone.desc" }); + await supabase.directPatch(getTableName("threads"), { thread_id: "t" }, { status: "resolved", not_a_col: 1 }); + await supabase.directQuery("missing_table", { select: "id" }); + if (!hasSupabase()) await getStorage().upsert("sessions", { local_only_field: 1 }); + }`); + }); + afterAll(() => fs.rmSync(dir, { recursive: true, force: true })); + + it("reports every planted column and table, and ignores free-tier-only writes", () => { + const s = scanSources(path.join(dir, "src")); + const got = compare(s, floor, current).map((f) => `${f.table}.${f.column ?? "(table)"}:${f.kind}`).sort(); + expect(got).toEqual([ + "gitmem_sessions.also_bogus:write", + "gitmem_sessions.assigned_later:write", + "gitmem_sessions.bogus_column:write", + "gitmem_threads.not_a_col:write", + "knowledge_triples.gone:order", + "knowledge_triples.no_such_col:select", + "knowledge_triples.nope:filter", + "missing_table.(table):table", + ]); + expect(s.freeTierOnly).toBe(1); + }); +}); + +describe("src/ against the v1.8.0 schema", () => { + it("scans a meaningful surface", () => { + expect(scan.callSites).toBeGreaterThan(50); + expect(scan.accesses.length).toBeGreaterThan(300); + }); + + it("every table and column is in v1.8.0 or allowlisted by ticket", () => { + const unexpected = findings.filter((f) => !ALLOWED.some((a) => matches(a, f))); + expect(unexpected.map(label), "not in v1.8.0 setup.sql and not allowlisted").toEqual([]); + }); + + it("every allowlist entry still matches a finding, and names a ticket", () => { + for (const a of ALLOWED) { + expect(a.ticket, `${a.table}.${a.column}`).toMatch(/^GIT-\d+$/); + expect(findings.some((f) => matches(a, f)), `stale ALLOWED entry ${a.table}.${a.column} [${a.kind}] (${a.ticket}) — remove it`).toBe(true); + } + }); + + it("no call site is unresolved except the known ones", () => { + const unknown = scan.unresolved.filter((u) => !KNOWN_UNRESOLVED.some((k) => k.file === u.file && k.what.test(u.what))); + expect(unknown.map((u) => `${u.file}:${u.line} ${u.call} — ${u.what}`)).toEqual([]); + for (const k of KNOWN_UNRESOLVED) { + expect(scan.unresolved.some((u) => u.file === k.file && k.what.test(u.what)), `stale KNOWN_UNRESOLVED ${k.file}`).toBe(true); + } + }); + + it("session writes: SESSION_COLUMNS is v1.8.0 plus the probe-guarded production-only set", () => { + const sessions = floor.get("gitmem_sessions")!; + const missing = [...SESSION_COLUMNS].filter((c) => !sessions.has(c) && !PRODUCTION_ONLY_SESSION_COLUMNS.has(c)); + expect(missing, "SESSION_COLUMNS entries a v1.8.0 store rejects").toEqual([]); + }); +});