Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 87 additions & 81 deletions packages/projects-db/src/migrate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,15 @@
* page_content rows to the implicit cascade-delete that DROP TABLE performs
* when foreign keys are enabled.
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';

import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';

import { runMigrations } from './migrate.js';
import { migrations } from './schema.js';

let tmpDir: string;
let db: DatabaseSync;

beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'projects-db-migrate-test-'));
db = new DatabaseSync(join(tmpDir, 'test.db'));
db.exec('PRAGMA foreign_keys = ON');
});

afterEach(() => {
db.close();
rmSync(tmpDir, { recursive: true, force: true });
});

/** Apply every migration strictly below `version`, simulating an older DB. */
const migrateTo = (version: number): void => {
const migrateTo = (db: DatabaseSync, version: number): void => {
db.exec(
`CREATE TABLE IF NOT EXISTS _migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (datetime('now')))`
);
Expand All @@ -41,9 +24,41 @@ const migrateTo = (version: number): void => {
}
};

/**
* Build one migrated database per migration scenario, then isolate each
* assertion with a savepoint. Replaying the full migration history for every
* test is particularly expensive on Windows runners and is not part of the
* behavior these assertions exercise.
*/
const useMigrationFixture = (version: number, seed: (db: DatabaseSync) => void): (() => DatabaseSync) => {
let db: DatabaseSync | undefined;

beforeAll(() => {
db = new DatabaseSync(':memory:');
db.exec('PRAGMA foreign_keys = ON');
migrateTo(db, version);
seed(db);
runMigrations(db);
});

beforeEach(() => {
db!.exec('SAVEPOINT test_case');
});

afterEach(() => {
db!.exec('ROLLBACK TO test_case');
db!.exec('RELEASE test_case');
});

afterAll(() => {
db!.close();
});

return () => db!;
};

describe('v10 pages rebuild', () => {
it('preserves page_content rows across the rebuild', () => {
migrateTo(10);
const getDb = useMigrationFixture(10, (db) => {
db.prepare("INSERT INTO projects (id, label, slug) VALUES ('proj_1', 'P', 'p')").run();
db.prepare(
"INSERT INTO pages (id, project_id, parent_id, title, kind) VALUES ('pg_1', 'proj_1', NULL, 'Root', 'doc')"
Expand All @@ -53,8 +68,10 @@ describe('v10 pages rebuild', () => {
).run();
db.prepare("INSERT INTO page_content (page_id, body) VALUES ('pg_1', '# hello')").run();
db.prepare("INSERT INTO page_content (page_id, body) VALUES ('pg_2', '# child')").run();
});

runMigrations(db); // applies v10
it('preserves page_content rows across the rebuild', () => {
const db = getDb();

const content = db.prepare('SELECT page_id, body FROM page_content ORDER BY page_id').all();
expect(content).toEqual([
Expand All @@ -70,10 +87,9 @@ describe('v10 pages rebuild', () => {
});

it('keeps the kind CHECK: notebook allowed, unknown kinds rejected', () => {
const db = getDb();
// 'drawing' was a planned kind that never shipped — PageKind is
// 'doc' | 'notebook' and the schema CHECK must match it.
runMigrations(db);
db.prepare("INSERT INTO projects (id, label, slug) VALUES ('proj_1', 'P', 'p')").run();
expect(() =>
db.prepare("INSERT INTO pages (id, project_id, title, kind) VALUES ('pg_n', 'proj_1', 'NB', 'notebook')").run()
).not.toThrow();
Expand All @@ -83,28 +99,25 @@ describe('v10 pages rebuild', () => {
});

it('cascade-deletes page_content when its page is removed (FK intact)', () => {
runMigrations(db);
db.prepare("INSERT INTO projects (id, label, slug) VALUES ('proj_1', 'P', 'p')").run();
db.prepare("INSERT INTO pages (id, project_id, title, kind) VALUES ('pg_1', 'proj_1', 'Root', 'doc')").run();
db.prepare("INSERT INTO page_content (page_id, body) VALUES ('pg_1', 'x')").run();
db.prepare("DELETE FROM pages WHERE id = 'pg_1'").run();
const count = db.prepare('SELECT COUNT(*) AS n FROM page_content').get() as { n: number };
const db = getDb();
db.prepare(
"INSERT INTO pages (id, project_id, title, kind) VALUES ('pg_cascade', 'proj_1', 'Cascade', 'doc')"
).run();
db.prepare("INSERT INTO page_content (page_id, body) VALUES ('pg_cascade', 'x')").run();
db.prepare("DELETE FROM pages WHERE id = 'pg_cascade'").run();
const count = db.prepare("SELECT COUNT(*) AS n FROM page_content WHERE page_id = 'pg_cascade'").get() as {
n: number;
};
expect(count.n).toBe(0);
});
});

describe('v12 shaping removal', () => {
/** Seed a pre-v12 DB with one project/column and return helpers. */
const seedPreV12 = (): void => {
migrateTo(12);
const getDb = useMigrationFixture(12, (db) => {
db.prepare("INSERT INTO projects (id, label, slug) VALUES ('proj_1', 'P', 'p')").run();
db.prepare(
"INSERT INTO pipeline_columns (id, project_id, label, sort_order) VALUES ('col_1', 'proj_1', 'Backlog', 0)"
).run();
};

it('folds ticket shaping into the description and drops the column', () => {
seedPreV12();
db.prepare(
`INSERT INTO tickets (id, project_id, column_id, title, description, shaping)
VALUES ('tkt_1', 'proj_1', 'col_1', 'T', 'Existing body.',
Expand All @@ -114,8 +127,16 @@ describe('v12 shaping removal', () => {
`INSERT INTO tickets (id, project_id, column_id, title, description, shaping)
VALUES ('tkt_2', 'proj_1', 'col_1', 'T2', '', '{"doneLooksLike":" ","appetite":"small","outOfScope":""}')`
).run();
db.prepare(
`INSERT INTO inbox_items (id, title, note, status, shaping, created_at)
VALUES ('inb_1', 'I', NULL, 'shaped',
'{"outcome":"Demo booked","appetite":"small","notDoing":"No counter-offer"}',
'2020-01-01 00:00:00.000')`
).run();
});

runMigrations(db); // applies v12
it('folds ticket shaping into the description and drops the column', () => {
const db = getDb();

const t1 = db.prepare("SELECT description FROM tickets WHERE id = 'tkt_1'").get() as { description: string };
expect(t1.description).toBe('Existing body.\n\n**Done when:** redirect works\n\n**Out of scope:** password reset');
Expand All @@ -128,15 +149,7 @@ describe('v12 shaping removal', () => {
});

it("folds inbox shaping into the note and collapses 'shaped' to 'new' with a fresh createdAt", () => {
seedPreV12();
db.prepare(
`INSERT INTO inbox_items (id, title, note, status, shaping, created_at)
VALUES ('inb_1', 'I', NULL, 'shaped',
'{"outcome":"Demo booked","appetite":"small","notDoing":"No counter-offer"}',
'2020-01-01 00:00:00.000')`
).run();

runMigrations(db);
const db = getDb();

const row = db.prepare("SELECT note, status, created_at FROM inbox_items WHERE id = 'inb_1'").get() as {
note: string;
Expand All @@ -153,13 +166,14 @@ describe('v12 shaping removal', () => {
});

describe('v13 source cutover', () => {
it('moves workspace_dir into sources and drops workspace_dir', () => {
migrateTo(13);
const getDb = useMigrationFixture(13, (db) => {
db.prepare(
"INSERT INTO projects (id, label, slug, workspace_dir) VALUES ('proj_1', 'P', 'p', '/tmp/project')"
).run();
});

runMigrations(db);
it('moves workspace_dir into sources and drops workspace_dir', () => {
const db = getDb();

const row = db.prepare("SELECT sources FROM projects WHERE id = 'proj_1'").get() as { sources: string };
expect(JSON.parse(row.sources)).toEqual([
Expand All @@ -171,11 +185,12 @@ describe('v13 source cutover', () => {
});

describe('v14 inbox status tightening', () => {
it('removes shaped from the inbox status check', () => {
migrateTo(14);
const getDb = useMigrationFixture(14, (db) => {
db.prepare("INSERT INTO inbox_items (id, title, status) VALUES ('inb_1', 'I', 'shaped')").run();
});

runMigrations(db);
it('removes shaped from the inbox status check', () => {
const db = getDb();

const row = db.prepare("SELECT status FROM inbox_items WHERE id = 'inb_1'").get() as { status: string };
expect(row.status).toBe('new');
Expand All @@ -186,7 +201,7 @@ describe('v14 inbox status tightening', () => {
});

describe('v15 pipeline column categories', () => {
const insertColumn = (id: string, projectId: string, label: string, sortOrder: number): void => {
const insertColumn = (db: DatabaseSync, id: string, projectId: string, label: string, sortOrder: number): void => {
db.prepare('INSERT INTO pipeline_columns (id, project_id, label, sort_order) VALUES (?, ?, ?, ?)').run(
id,
projectId,
Expand All @@ -195,50 +210,41 @@ describe('v15 pipeline column categories', () => {
);
};

const categoriesOf = (projectId: string): string[] =>
const categoriesOf = (db: DatabaseSync, projectId: string): string[] =>
(
db
.prepare('SELECT category FROM pipeline_columns WHERE project_id = ? ORDER BY sort_order')
.all(projectId) as Array<{ category: string }>
).map((r) => r.category);

it('backfills first → todo, last → done, middle → doing', () => {
migrateTo(15);
const getDb = useMigrationFixture(15, (db) => {
db.prepare("INSERT INTO projects (id, label, slug) VALUES ('proj_1', 'P', 'p')").run();
insertColumn('proj_1__backlog', 'proj_1', 'Backlog', 0);
insertColumn('proj_1__spec', 'proj_1', 'Spec', 1);
insertColumn('proj_1__impl', 'proj_1', 'Implementation', 2);
insertColumn('proj_1__review', 'proj_1', 'Review', 3);
insertColumn('proj_1__done', 'proj_1', 'Completed', 4);

runMigrations(db);
insertColumn(db, 'proj_1__backlog', 'proj_1', 'Backlog', 0);
insertColumn(db, 'proj_1__spec', 'proj_1', 'Spec', 1);
insertColumn(db, 'proj_1__impl', 'proj_1', 'Implementation', 2);
insertColumn(db, 'proj_1__review', 'proj_1', 'Review', 3);
insertColumn(db, 'proj_1__done', 'proj_1', 'Completed', 4);
db.prepare("INSERT INTO projects (id, label, slug) VALUES ('proj_2', 'P2', 'p2')").run();
insertColumn(db, 'proj_2__a', 'proj_2', 'A', 0);
insertColumn(db, 'proj_2__b', 'proj_2', 'B', 1);
db.prepare("INSERT INTO projects (id, label, slug) VALUES ('proj_3', 'P3', 'p3')").run();
insertColumn(db, 'proj_3__only', 'proj_3', 'Only', 0);
});

expect(categoriesOf('proj_1')).toEqual(['todo', 'doing', 'doing', 'doing', 'done']);
it('backfills first → todo, last → done, middle → doing', () => {
expect(categoriesOf(getDb(), 'proj_1')).toEqual(['todo', 'doing', 'doing', 'doing', 'done']);
});

it('backfills a two-column pipeline as todo → done', () => {
migrateTo(15);
db.prepare("INSERT INTO projects (id, label, slug) VALUES ('proj_2', 'P2', 'p2')").run();
insertColumn('proj_2__a', 'proj_2', 'A', 0);
insertColumn('proj_2__b', 'proj_2', 'B', 1);

runMigrations(db);

expect(categoriesOf('proj_2')).toEqual(['todo', 'done']);
expect(categoriesOf(getDb(), 'proj_2')).toEqual(['todo', 'done']);
});

it('backfills a single-column pipeline as done (last wins)', () => {
migrateTo(15);
db.prepare("INSERT INTO projects (id, label, slug) VALUES ('proj_3', 'P3', 'p3')").run();
insertColumn('proj_3__only', 'proj_3', 'Only', 0);

runMigrations(db);

expect(categoriesOf('proj_3')).toEqual(['done']);
expect(categoriesOf(getDb(), 'proj_3')).toEqual(['done']);
});

it('rejects invalid categories after the migration', () => {
runMigrations(db);
const db = getDb();
db.prepare("INSERT INTO projects (id, label, slug) VALUES ('proj_4', 'P4', 'p4')").run();
expect(() =>
db
Expand Down
23 changes: 14 additions & 9 deletions packages/projects-db/src/resident-repo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,14 @@
* messages/alarms round-trips, the delete cascades, and the log bound
* (docs/residents-in-projects-db-plan.md).
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';

import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';

import { runMigrations } from './migrate.js';
import { ProjectsRepo } from './repo.js';
import type { ResidentMessageRow, ResidentRow } from './types.js';

let tmpDir: string;
let db: DatabaseSync;
let repo: ProjectsRepo;

Expand Down Expand Up @@ -43,17 +39,26 @@ const messageRow = (id: number, overrides: Partial<ResidentMessageRow> = {}): Re
...overrides,
});

beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'projects-db-resident-test-'));
db = new DatabaseSync(join(tmpDir, 'test.db'));
beforeAll(() => {
db = new DatabaseSync(':memory:');
db.exec('PRAGMA foreign_keys = ON');
runMigrations(db);
repo = new ProjectsRepo(db);
});

afterEach(() => {
db.exec(`
DELETE FROM resident_messages;
DELETE FROM resident_channels;
DELETE FROM resident_memories;
DELETE FROM resident_alarms;
DELETE FROM resident_agents;
DELETE FROM team_handbook;
`);
});

afterAll(() => {
db.close();
rmSync(tmpDir, { recursive: true, force: true });
});

describe('resident roster', () => {
Expand Down
Loading
Loading