diff --git a/lib/db/migrations/002_knowledge.sql b/lib/db/migrations/002_knowledge.sql new file mode 100644 index 0000000..da24ca8 --- /dev/null +++ b/lib/db/migrations/002_knowledge.sql @@ -0,0 +1,61 @@ +CREATE TABLE pages ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + space_id uuid REFERENCES spaces(id) ON DELETE SET NULL, + slug text NOT NULL, + title text NOT NULL, + body_md text NOT NULL DEFAULT '', + body_html text, + parent_id uuid REFERENCES pages(id) ON DELETE SET NULL, + embedding vector(1024), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (space_id, slug) +); + +CREATE TABLE page_revisions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + page_id uuid NOT NULL REFERENCES pages(id) ON DELETE CASCADE, + body_md text NOT NULL, + edited_by text, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE refs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + space_id uuid REFERENCES spaces(id) ON DELETE SET NULL, + kind text NOT NULL + CHECK (kind IN ('url','video','pdf','image','file')), + source_url text, + title text, + description text, + summary text, + body_text text, + blob_path text, + thumbnail text, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + embedding vector(1024), + status text NOT NULL DEFAULT 'ingested' + CHECK (status IN ('ingested','indexed','enriched')), + source_kind text, + external_id text, + captured_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX idx_pages_space ON pages(space_id); +CREATE INDEX idx_pages_parent ON pages(parent_id); +CREATE INDEX idx_pages_fts ON pages + USING GIN (to_tsvector('english', title || ' ' || body_md)); +CREATE INDEX idx_pages_embed ON pages + USING hnsw (embedding vector_cosine_ops); + +CREATE INDEX idx_refs_space ON refs(space_id); +CREATE INDEX idx_refs_kind ON refs(kind); +CREATE INDEX idx_refs_external ON refs(source_kind, external_id) + WHERE source_kind IS NOT NULL; +CREATE INDEX idx_refs_fts ON refs USING GIN ( + to_tsvector('english', + coalesce(title,'') || ' ' || coalesce(summary,'') || ' ' || coalesce(body_text,'')) +); +CREATE INDEX idx_refs_embed ON refs USING hnsw (embedding vector_cosine_ops); diff --git a/tests/db/migration_002.test.js b/tests/db/migration_002.test.js new file mode 100644 index 0000000..3f489bc --- /dev/null +++ b/tests/db/migration_002.test.js @@ -0,0 +1,29 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { resetDb, withClient } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; + +describe('migration 002 — knowledge', () => { + beforeEach(async () => { await resetDb(); await migrateUp(); }); + + it('creates pages, page_revisions, refs', async () => { + await withClient(async (c) => { + for (const t of ['pages','page_revisions','refs']) { + const { rows } = await c.query( + `SELECT to_regclass('public.' || $1) AS t;`, [t] + ); + expect(rows[0].t).toBe(t); + } + }); + }); + + it('refs.kind check enforces enum', async () => { + await withClient(async (c) => { + const { rows: [s] } = await c.query( + `INSERT INTO spaces(slug,name) VALUES('h','H') RETURNING id;` + ); + await expect(c.query( + `INSERT INTO refs(space_id, kind) VALUES($1, 'invalid');`, [s.id] + )).rejects.toThrow(/check/i); + }); + }); +});