diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5ca3d7..3816d5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,18 @@ on: jobs: test: runs-on: ubuntu-latest + services: + postgres: + image: pgvector/pgvector:pg17 + env: + POSTGRES_PASSWORD: memory-test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready --health-interval 5s --health-timeout 5s + --health-retries 10 + env: + TEST_DATABASE_URL: postgres://postgres:memory-test@localhost:5432/postgres steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fb6e9bd..0544612 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,23 +24,15 @@ the pieces fit together. bun run typecheck && bun run test ``` -- `bun run test` runs the unit suite (`bun test ./src`) — every `core/*` - module, most services, and the route layer have colocated `*.test.ts` - files. It needs no external services. -- `bun run test:coverage` runs the same suite with lcov + text coverage +- `bun run test` runs the unit suite in `src/` and the end-to-end suite in + `tests/`. The end-to-end tests drive the mounted routes and migrations + against a real pgvector Postgres: set `TEST_DATABASE_URL` to a server the + tests can create and drop databases on (for `docker compose up -d`, + `postgres://memory:memory-dev-password@localhost:5434/memory`). Each suite + creates its own database and drops it afterwards. Without + `TEST_DATABASE_URL` those suites skip. +- `bun run test:coverage` runs the unit suite with lcov + text coverage reports. -- `bun run test:e2e` runs the integration suite (`bun test ./e2e`) — files - under the top-level `e2e/` directory drive the full stack against a - **real** pgvector Postgres and a **real** embedding endpoint. It needs both - reachable: - - `TEST_DATABASE_URL` (defaults to the `docker compose` connection string) - - `TEST_EMBED_BASE_URL` / `TEST_EMBED_MODEL` (default to a local Ollama at - `http://localhost:11434` / `nomic-embed-text`) - - If either is unreachable, the affected tests skip loudly with a logged - reason rather than failing. If you're changing anything in the capture or - search pipeline, run this suite with both dependencies up before opening a - PR. `bun run typecheck` (`tsc --noEmit`) must be clean before any commit. diff --git a/package.json b/package.json index c9ca4db..89a3312 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "typecheck": "tsc --noEmit", "build": "tsc -p tsconfig.build.json", "prepack": "npm run build", - "test": "bun test ./src", + "test": "bun test ./src ./tests", "test:coverage": "bun test --coverage --coverage-reporter=lcov --coverage-reporter=text ./src" }, "dependencies": { diff --git a/tests/add-search.test.ts b/tests/add-search.test.ts new file mode 100644 index 0000000..bc31908 --- /dev/null +++ b/tests/add-search.test.ts @@ -0,0 +1,106 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { createInMemoryGrantStore } from "@intx/authz"; +import type { Hono } from "hono"; +import type { TenantEnv } from "@intx/hub-api"; + +import type { Memory } from "../src/memory.ts"; +import { + allow, + createTestApp, + createTestDb, + createTestMemory, + seedPrincipal, + testDatabaseUrl, + type TestDb, +} from "./lib/db-harness.ts"; + +describe.skipIf(testDatabaseUrl() === undefined)("add and search", () => { + let db: TestDb; + let memory: Memory | undefined; + let app: Hono; + + beforeAll(async () => { + db = await createTestDb(); + await seedPrincipal(db, "acme", "alice"); + await seedPrincipal(db, "globex", "bob"); + const grantStore = createInMemoryGrantStore([ + allow("alice", "add"), + allow("alice", "search"), + allow("bob", "add"), + allow("bob", "search"), + ]); + memory = createTestMemory(db, grantStore); + app = createTestApp({ + memory, + grantStore, + callers: { + alice: { tenantId: "acme", principalId: "alice" }, + bob: { tenantId: "globex", principalId: "bob" }, + }, + }); + }); + + afterAll(async () => { + await memory?.close(); + await db?.close(); + }); + + function post(token: string, tenantId: string, path: string, body: unknown) { + return app.request(`/api/tenants/${tenantId}/memory${path}`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: JSON.stringify(body), + }); + } + + test("search ranks the most relevant added document first", async () => { + const docs = [ + { + title: "Staging deploys", + text: "Staging deploys run from main. Every staging deploy is automatic after merge.", + }, + { + title: "Lunch menu", + text: "Tacos on Tuesday, pizza on Friday, salad on Monday. Catering deploys to the staging lobby.", + }, + { title: "Vacation policy", text: "Request vacation two weeks ahead." }, + ]; + for (const doc of docs) { + const res = await post("alice", "acme", "/add", doc); + expect(res.status).toBe(200); + } + + const res = await post("alice", "acme", "/search", { + query: "staging deploy", + }); + expect(res.status).toBe(200); + const { items } = (await res.json()) as { items: { title: string }[] }; + expect(items.map((i) => i.title)).toEqual([ + "Staging deploys", + "Lunch menu", + ]); + }); + + test("another tenant's search returns none of the first tenant's documents", async () => { + const add = await post("alice", "acme", "/add", { + title: "Quarterly roadmap", + text: "The quarterly roadmap covers billing and onboarding.", + }); + expect(add.status).toBe(200); + + async function titles(token: string, tenantId: string): Promise { + const res = await post(token, tenantId, "/search", { + query: "quarterly roadmap", + }); + expect(res.status).toBe(200); + const { items } = (await res.json()) as { items: { title: string }[] }; + return items.map((i) => i.title); + } + + expect(await titles("alice", "acme")).toContain("Quarterly roadmap"); + expect(await titles("bob", "globex")).toEqual([]); + }); +}); diff --git a/tests/distill-tick.test.ts b/tests/distill-tick.test.ts new file mode 100644 index 0000000..891a563 --- /dev/null +++ b/tests/distill-tick.test.ts @@ -0,0 +1,111 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { createInMemoryGrantStore } from "@intx/authz"; + +import { runDistillTick } from "../src/distiller/tick.ts"; +import { createMemoryHttpClient } from "../src/http-client.ts"; +import type { Memory } from "../src/memory.ts"; +import { + allow, + createTestApp, + createTestDb, + createTestMemory, + seedPrincipal, + testDatabaseUrl, + type TestDb, +} from "./lib/db-harness.ts"; + +describe.skipIf(testDatabaseUrl() === undefined)("distill tick", () => { + let db: TestDb; + let memory: Memory | undefined; + let tick: (after: number) => ReturnType; + + beforeAll(async () => { + db = await createTestDb(); + await seedPrincipal(db, "acme", "alice"); + await seedPrincipal(db, "acme", "distiller"); + const grantStore = createInMemoryGrantStore([ + allow("alice", "add"), + allow("distiller", "add"), + allow("distiller", "search"), + { + id: "g-distiller-tenant-tag", + resource: "memory.tenant:acme", + action: "search", + effect: "allow", + origin: "role", + conditions: null, + expiresAt: null, + roleId: null, + principalId: "distiller", + }, + ]); + memory = createTestMemory(db, grantStore); + const app = createTestApp({ + memory, + grantStore, + callers: { + alice: { tenantId: "acme", principalId: "alice" }, + distiller: { tenantId: "acme", principalId: "distiller" }, + }, + }); + const client = (authToken: string) => + createMemoryHttpClient({ + baseUrl: "http://hub.test", + tenantId: "acme", + authToken, + fetch: ((input: string, init?: RequestInit) => + app.request(input, init)) as typeof fetch, + }); + + for (const title of ["Standup notes", "Deploy checklist"]) { + await client("alice").add({ + title, + text: `${title} body`, + share: { tenant: true }, + }); + } + tick = (after) => + runDistillTick({ + client: client("distiller"), + after, + distill: async (entry) => ({ + action: "write", + title: `Claim from ${entry.title}`, + text: `Distilled: ${entry.title}`, + }), + }); + }); + + afterAll(async () => { + await memory?.close(); + await db?.close(); + }); + + async function documentCount(): Promise { + const [row] = await db.sql<{ n: number }[]>` + SELECT count(*)::int AS n FROM memory.document`; + return row?.n ?? 0; + } + + test("a tick writes one distilled claim per captured document, and a replay from its cursor writes nothing", async () => { + const first = await tick(0); + expect(first.wrote).toBe(2); + expect(await documentCount()).toBe(4); + const claims = await db.sql<{ provenance: string; source: string }[]>` + SELECT v.provenance, src_doc.title AS source + FROM memory.version v + JOIN memory.edge e ON e.rel = 'derived_from' AND e.from_ref = v.document_id + JOIN memory.version src ON src.id = e.to_ref + JOIN memory.document src_doc ON src_doc.id = src.document_id + WHERE v.generator_agent_id IS NOT NULL + ORDER BY source`; + expect([...claims]).toEqual([ + { provenance: "inferred", source: "Deploy checklist" }, + { provenance: "inferred", source: "Standup notes" }, + ]); + + const second = await tick(first.nextCursor); + expect(second.wrote).toBe(0); + expect(await documentCount()).toBe(4); + }); +}); diff --git a/tests/grants.test.ts b/tests/grants.test.ts new file mode 100644 index 0000000..371813f --- /dev/null +++ b/tests/grants.test.ts @@ -0,0 +1,103 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { createInMemoryGrantStore } from "@intx/authz"; +import type { Hono } from "hono"; +import type { TenantEnv } from "@intx/hub-api"; + +import type { Memory } from "../src/memory.ts"; +import { + allow, + createTestApp, + createTestDb, + createTestMemory, + seedPrincipal, + testDatabaseUrl, + type TestDb, +} from "./lib/db-harness.ts"; + +describe.skipIf(testDatabaseUrl() === undefined)("grants, forget and purge", () => { + let db: TestDb; + let memory: Memory | undefined; + let app: Hono; + + beforeAll(async () => { + db = await createTestDb(); + for (const principal of ["alice", "carol", "dave"]) { + await seedPrincipal(db, "acme", principal); + } + const grantStore = createInMemoryGrantStore([ + ...["add", "search", "forget", "purge"].map((a) => allow("alice", a)), + ...["search", "forget", "purge"].map((a) => allow("carol", a)), + allow("dave", "add"), + ]); + memory = createTestMemory(db, grantStore); + app = createTestApp({ + memory, + grantStore, + callers: { + alice: { tenantId: "acme", principalId: "alice" }, + carol: { tenantId: "acme", principalId: "carol" }, + dave: { tenantId: "acme", principalId: "dave" }, + }, + }); + }); + + afterAll(async () => { + await memory?.close(); + await db?.close(); + }); + + function post(token: string, path: string, body: unknown = {}) { + return app.request(`/api/tenants/acme/memory${path}`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: JSON.stringify(body), + }); + } + + async function addDocument(title: string): Promise { + const res = await post("alice", "/add", { title, text: `${title} body` }); + expect(res.status).toBe(200); + return ((await res.json()) as { documentId: string }).documentId; + } + + test("search without the memory:search grant is 403", async () => { + const res = await post("dave", "/search", { query: "anything" }); + expect(res.status).toBe(403); + }); + + test("forget succeeds for the creator and is 403 for anyone else", async () => { + const documentId = await addDocument("Forget me"); + + const other = await post("carol", `/documents/${documentId}/forget`); + expect(other.status).toBe(403); + + const creator = await post("alice", `/documents/${documentId}/forget`); + expect(creator.status).toBe(200); + const [row] = await db.sql<{ status: string }[]>` + SELECT status FROM memory.version WHERE document_id = ${documentId}`; + expect(row?.status).toBe("tombstoned"); + }); + + test("purge removes the document, its versions and its chunks", async () => { + const documentId = await addDocument("Purge me"); + const [before] = await db.sql<{ n: number }[]>` + SELECT count(*)::int AS n FROM memory.chunk c + JOIN memory.version v ON v.id = c.version_id + WHERE v.document_id = ${documentId}`; + expect(before?.n).toBeGreaterThan(0); + + const res = await post("alice", `/documents/${documentId}/purge`); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ documentId, deleted: true }); + + const [left] = await db.sql<{ docs: number; versions: number; chunks: number }[]>` + SELECT + (SELECT count(*)::int FROM memory.document WHERE id = ${documentId}) AS docs, + (SELECT count(*)::int FROM memory.version WHERE document_id = ${documentId}) AS versions, + (SELECT count(*)::int FROM memory.chunk WHERE document_id = ${documentId}) AS chunks`; + expect(left).toEqual({ docs: 0, versions: 0, chunks: 0 }); + }); +}); diff --git a/tests/lib/db-harness.ts b/tests/lib/db-harness.ts new file mode 100644 index 0000000..64e3921 --- /dev/null +++ b/tests/lib/db-harness.ts @@ -0,0 +1,201 @@ +// Real-Postgres harness for the tests/ suite. Each suite gets a fresh +// database on the server named by TEST_DATABASE_URL (any pgvector Postgres +// whose user can create databases, such as compose.yml's), with Interchange's control-plane tables and the memory +// migrations applied; `close` drops it. + +import { type DBConfig, runMigrations } from "@intx/db"; +import type { GrantRule, GrantStore } from "@intx/authz"; +import { createRequireGrant, type TenantEnv } from "@intx/hub-api"; +import { Hono } from "hono"; +import postgres from "postgres"; + +import { createMemory, type Memory } from "../../src/memory.ts"; +import { runMemoryMigrations } from "../../src/migrations.ts"; +import { createMemoryRoutes } from "../../src/routes/mount.ts"; + +const FTS_LANGUAGE = "english"; + +/** + * Gate for `describe.skipIf`: the suite skips when no server is configured, + * except in CI, where a missing server fails the run instead. + */ +export function testDatabaseUrl(): string | undefined { + const url = process.env["TEST_DATABASE_URL"]; + if (url === undefined && process.env["CI"] !== undefined) { + throw new Error("TEST_DATABASE_URL is required in CI"); + } + return url; +} + +function dbConfigFromUrl(url: URL, database: string): DBConfig { + return { + host: url.hostname, + port: Number(url.port || 5432), + user: decodeURIComponent(url.username), + password: decodeURIComponent(url.password), + database, + }; +} + +export type TestDb = { + config: DBConfig; + databaseUrl: string; + sql: postgres.Sql; + close: () => Promise; +}; + +/** An empty database on the test server, with nothing migrated. */ +export async function createEmptyDb(): Promise { + const serverUrl = testDatabaseUrl(); + if (serverUrl === undefined) { + throw new Error("TEST_DATABASE_URL is required for the tests/ suite"); + } + const url = new URL(serverUrl); + const database = `memory_test_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; + const admin = postgres(serverUrl, { max: 1, onnotice: () => undefined }); + try { + await admin.unsafe(`CREATE DATABASE "${database}"`); + } catch (err) { + await admin.end(); + throw err; + } + + const dbUrl = new URL(url); + dbUrl.pathname = `/${database}`; + const sql = postgres(dbUrl.toString(), { max: 1, onnotice: () => undefined }); + return { + config: dbConfigFromUrl(url, database), + databaseUrl: dbUrl.toString(), + sql, + close: async () => { + try { + await sql.end(); + } finally { + await admin.unsafe(`DROP DATABASE IF EXISTS "${database}" WITH (FORCE)`); + await admin.end(); + } + }, + }; +} + +/** A fresh database with Interchange's tables and the memory migrations. */ +export async function createTestDb(): Promise { + const db = await createEmptyDb(); + try { + await runMigrations(db.config, { schema: "public" }); + await runMemoryMigrations(db.config, { + schema: "public", + ftsLanguage: FTS_LANGUAGE, + }); + } catch (err) { + await db.close(); + throw err; + } + return db; +} + +/** Insert the tenant and principal rows memory's foreign keys point at. */ +export async function seedPrincipal( + db: TestDb, + tenantId: string, + principalId: string, +): Promise { + await db.sql` + INSERT INTO public.tenant (id, name, slug, domain) + VALUES (${tenantId}, ${tenantId}, ${tenantId}, ${`${tenantId}.test`}) + ON CONFLICT (id) DO NOTHING`; + await db.sql` + INSERT INTO public.principal (id, tenant_id, kind, ref_id, status) + VALUES (${principalId}, ${tenantId}, 'user', ${principalId}, 'active')`; +} + +/** The engine-backed plane on the test database, lexical-only. */ +export function createTestMemory(db: TestDb, grantStore: GrantStore): Memory { + return createMemory({ + config: { + memory: { + databaseUrl: db.databaseUrl, + dbPoolMax: 4, + ftsLanguage: FTS_LANGUAGE, + rerank: { + baseUrl: undefined, + model: undefined, + apiKey: undefined, + maxDocChars: undefined, + timeoutMs: undefined, + }, + }, + }, + grantStore, + conditionRegistry: {}, + }); +} + +export function allow(principalId: string, action: string): GrantRule { + return { + id: `g-${principalId}-${action}`, + resource: "memory", + action, + effect: "allow", + origin: "role", + conditions: null, + expiresAt: null, + roleId: null, + principalId, + }; +} + +export type TestCaller = { tenantId: string; principalId: string }; + +/** + * A host app with the memory routes mounted under the tenant tree. The + * stand-in tenant middleware seats the caller named by the bearer token and + * refuses a path naming another tenant, the way a hub's session middleware + * would. + */ +export function createTestApp(opts: { + memory: Memory; + grantStore: GrantStore; + callers: Record; +}): Hono { + const app = new Hono(); + app.use("/api/tenants/:tenantId/*", async (c, next) => { + const token = c.req.header("authorization")?.replace(/^Bearer /, ""); + const found = token === undefined ? undefined : opts.callers[token]; + if (found === undefined) return c.json({ error: "unauthenticated" }, 401); + if (c.req.param("tenantId") !== found.tenantId) { + return c.json({ error: "wrong tenant" }, 403); + } + c.set("principal", { + id: found.principalId, + tenantId: found.tenantId, + kind: "user", + refId: found.principalId, + status: "active", + createdAt: new Date(0), + updatedAt: new Date(0), + }); + c.set("tenant", { + id: found.tenantId, + name: found.tenantId, + slug: found.tenantId, + domain: `${found.tenantId}.test`, + parentId: null, + config: null, + createdAt: new Date(0), + updatedAt: new Date(0), + }); + await next(); + }); + app.route( + "/api/tenants/:tenantId/memory", + createMemoryRoutes({ + memory: opts.memory, + requireGrant: createRequireGrant({ + grantStore: opts.grantStore, + conditionRegistry: {}, + }), + }), + ); + return app; +} diff --git a/tests/migrations.test.ts b/tests/migrations.test.ts new file mode 100644 index 0000000..7cdfcb5 --- /dev/null +++ b/tests/migrations.test.ts @@ -0,0 +1,178 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { runMigrations } from "@intx/db"; + +import { runMemoryMigrations } from "../src/migrations.ts"; +import { createEmptyDb, testDatabaseUrl, type TestDb } from "./lib/db-harness.ts"; + +const options = { schema: "public", ftsLanguage: "english" }; + +describe.skipIf(testDatabaseUrl() === undefined)("memory migrations", () => { + let db: TestDb; + + beforeAll(async () => { + db = await createEmptyDb(); + await runMigrations(db.config, { schema: "public" }); + }); + + afterAll(async () => { + await db.close(); + }); + + async function snapshot(schema: string): Promise { + // Tables, constraints and indexes: pgvector's extension objects land in + // public by design and are not memory's tables. + const rows = await db.sql<{ item: string }[]>` + SELECT 'column ' || table_name || '.' || column_name || ' ' || data_type AS item + FROM information_schema.columns WHERE table_schema = ${schema} + UNION ALL + SELECT 'constraint ' || conrelid::regclass || ' ' || pg_get_constraintdef(c.oid) + FROM pg_constraint c JOIN pg_namespace n ON n.oid = c.connamespace + WHERE n.nspname = ${schema} + UNION ALL + SELECT 'index ' || indexdef FROM pg_indexes WHERE schemaname = ${schema} + ORDER BY 1`; + return rows.map((r) => r.item); + } + + test("a second run is a no-op and no tables land in public", async () => { + const publicBefore = await snapshot("public"); + + await runMemoryMigrations(db.config, options); + const afterFirst = await snapshot("memory"); + await runMemoryMigrations(db.config, options); + const afterSecond = await snapshot("memory"); + + expect(afterFirst.some((i) => i.startsWith("column document.id "))).toBe(true); + expect(afterSecond).toEqual(afterFirst); + expect(await snapshot("public")).toEqual(publicBefore); + }); + + test("a replay behind a long reader waits for it instead of failing", async () => { + let release = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + const reader = db.sql.begin(async (tx) => { + await tx`SELECT count(*) FROM memory.version`; + await held; + }); + let settled = false; + const run = runMemoryMigrations(db.config, options).finally(() => { + settled = true; + }); + try { + await new Promise((resolve) => setTimeout(resolve, 1_000)); + expect(settled).toBe(false); + } finally { + release(); + await reader; + } + await run; + }, 30_000); +}); + +describe.skipIf(testDatabaseUrl() === undefined)("concurrent memory migrations", () => { + let db: TestDb; + + beforeAll(async () => { + db = await createEmptyDb(); + await runMigrations(db.config, { schema: "public" }); + }); + + afterAll(async () => { + await db.close(); + }); + + // Every replica of a rolling deploy runs the migrations at boot. + const runThree = () => + Promise.all([0, 1, 2].map(() => runMemoryMigrations(db.config, options))); + + test("three runners on a fresh database all succeed", async () => { + await runThree(); + }, 30_000); + + test("three runners on a migrated database all succeed", async () => { + await runThree(); + }, 30_000); +}); + +describe.skipIf(testDatabaseUrl() === undefined)("memory migrations in a non-public host schema", () => { + let db: TestDb; + + beforeAll(async () => { + db = await createEmptyDb(); + await runMigrations(db.config, { schema: "hub" }); + }); + + afterAll(async () => { + await db.close(); + }); + + test("foreign keys point at the host schema, and a replay against another schema fails", async () => { + const hub = { schema: "hub", ftsLanguage: "english" }; + await runMemoryMigrations(db.config, hub); + await runMemoryMigrations(db.config, hub); + + const targets = await db.sql<{ target: string }[]>` + SELECT DISTINCT confrelid::regclass::text AS target + FROM pg_constraint c JOIN pg_namespace n ON n.oid = c.connamespace + WHERE n.nspname = 'memory' AND c.contype = 'f' + AND confrelid::regclass::text NOT LIKE 'memory.%' + ORDER BY 1`; + expect(targets.map((t) => t.target)).toEqual(["hub.principal", "hub.tenant"]); + + await runMigrations(db.config, { schema: "public" }); + await expect(runMemoryMigrations(db.config, options)).rejects.toThrow( + "already exists", + ); + }); +}); + +describe.skipIf(testDatabaseUrl() === undefined)("upgrading a database the ledger runner left before 0004", () => { + let db: TestDb; + + beforeAll(async () => { + db = await createEmptyDb(); + await runMigrations(db.config, { schema: "public" }); + const dir = join(import.meta.dirname, "..", "migrations"); + for (const file of (await readdir(dir)).sort()) { + if (file >= "0004") break; + const raw = await readFile(join(dir, file), "utf8"); + await db.sql.unsafe(raw.replaceAll("{{FTS_LANGUAGE}}", "english")); + } + await db.sql` + INSERT INTO memory.document (id, tenant_id, kind, title, adapter, external_ref) + VALUES ('doc-old', 'acme', 'note', 'Old claim', 'test', 'old')`; + await db.sql` + INSERT INTO memory.version + (id, tenant_id, document_id, version, content_hash, occurred_at, + created_by_kind, provenance) + VALUES ('ver-old', 'acme', 'doc-old', 1, 'h', now(), 'agent', 'inferred')`; + await db.sql` + INSERT INTO public.tenant (id, name, slug, domain) + VALUES ('acme', 'acme', 'acme', 'acme.test')`; + }); + + afterAll(async () => { + await db.close(); + }); + + test("the temporal_class backfill applies once and a later replay leaves new rows alone", async () => { + await runMemoryMigrations(db.config, options); + const [old] = await db.sql<{ temporal_class: string }[]>` + SELECT temporal_class FROM memory.version WHERE id = 'ver-old'`; + expect(old?.temporal_class).toBe("state"); + + await db.sql` + INSERT INTO memory.version + (id, tenant_id, document_id, version, content_hash, occurred_at, + created_by_kind, provenance, temporal_class) + VALUES ('ver-new', 'acme', 'doc-old', 2, 'h2', now(), 'agent', 'inferred', 'event')`; + await runMemoryMigrations(db.config, options); + const [fresh] = await db.sql<{ temporal_class: string }[]>` + SELECT temporal_class FROM memory.version WHERE id = 'ver-new'`; + expect(fresh?.temporal_class).toBe("event"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 7c24beb..94f57a4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,5 +16,5 @@ "forceConsistentCasingInFileNames": true, "types": ["bun"] }, - "include": ["src", "scripts"] + "include": ["src", "scripts", "tests"] }