|
1 | 1 | import { describe, expect, test } from "bun:test"; |
2 | 2 | import { Database } from "../../shared/sqlite"; |
3 | 3 | import { closeQuietly } from "../../shared/sqlite-helpers"; |
4 | | -import { LATEST_MIGRATION_VERSION, MIGRATIONS, runMigrations } from "./migrations"; |
5 | | -import { initializeDatabase, LATEST_SUPPORTED_VERSION } from "./storage-db"; |
| 4 | +import { LATEST_MIGRATION_VERSION, runMigrations } from "./migrations"; |
| 5 | +import { initializeDatabase, LATEST_SUPPORTED_VERSION } from "./storage-db"; // ESM import (not require) — matches codebase pattern |
6 | 6 |
|
7 | 7 | function columnNames(db: Database, table: string): string[] { |
8 | 8 | return (db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>).map( |
9 | 9 | (c) => c.name, |
10 | 10 | ); |
11 | 11 | } |
| 12 | + |
12 | 13 | function tableExists(db: Database, name: string): boolean { |
13 | 14 | return Boolean( |
14 | 15 | db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?").get(name), |
15 | 16 | ); |
16 | 17 | } |
17 | 18 |
|
18 | | -describe("migration v51 — skill_memory embeddings + FTS", () => { |
19 | | - test("fresh DB: delta_embedding column and skill_memory_fts exist, no throw", () => { |
| 19 | +describe("migration v51 — skill_memory table", () => { |
| 20 | + test("creates skill_memory table with correct columns on fresh DB, idempotently", () => { |
20 | 21 | const db = new Database(":memory:"); |
21 | 22 | try { |
22 | 23 | initializeDatabase(db); |
23 | 24 | runMigrations(db); |
24 | | - runMigrations(db); // idempotency |
| 25 | + runMigrations(db); // idempotency check |
| 26 | + |
| 27 | + expect(tableExists(db, "skill_memory")).toBe(true); |
| 28 | + |
| 29 | + const cols = columnNames(db, "skill_memory"); |
| 30 | + expect(cols).toContain("id"); |
| 31 | + expect(cols).toContain("skill_id"); |
| 32 | + expect(cols).toContain("resolved_path"); |
| 33 | + expect(cols).toContain("tier"); |
| 34 | + expect(cols).toContain("skill_source"); |
| 35 | + expect(cols).toContain("project_identity"); |
| 36 | + expect(cols).toContain("intent"); |
| 37 | + expect(cols).toContain("intent_embedding"); |
| 38 | + expect(cols).toContain("embedding_model_version"); |
| 39 | + expect(cols).toContain("kind"); |
| 40 | + expect(cols).toContain("delta"); |
| 41 | + expect(cols).toContain("tags"); |
| 42 | + expect(cols).toContain("hit_count"); |
| 43 | + expect(cols).toContain("pinned"); |
| 44 | + expect(cols).toContain("normalized_hash"); |
| 45 | + expect(cols).toContain("created_at"); |
| 46 | + expect(cols).toContain("last_used_at"); |
25 | 47 |
|
26 | | - expect(columnNames(db, "skill_memory")).toContain("delta_embedding"); |
27 | | - expect(tableExists(db, "skill_memory_fts")).toBe(true); |
| 48 | + expect( |
| 49 | + db |
| 50 | + .prepare("SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1") |
| 51 | + .get(), |
| 52 | + ).toEqual({ version: LATEST_MIGRATION_VERSION }); |
28 | 53 | } finally { |
29 | 54 | closeQuietly(db); |
30 | 55 | } |
31 | 56 | }); |
32 | 57 |
|
33 | | - test("FTS triggers keep skill_memory_fts in sync with skill_memory", () => { |
| 58 | + test("skill_memory CHECK constraints reject invalid tier and kind values", () => { |
34 | 59 | const db = new Database(":memory:"); |
35 | 60 | try { |
36 | 61 | initializeDatabase(db); |
37 | 62 | runMigrations(db); |
38 | | - db.prepare( |
39 | | - `INSERT INTO skill_memory |
40 | | - (skill_id, resolved_path, tier, project_identity, intent, kind, delta, normalized_hash, hit_count, pinned, created_at) |
41 | | - VALUES (?,?,?,?,?,?,?,?,0,0,?)`, |
42 | | - ).run( |
43 | | - "s1", |
44 | | - "/p/SKILL.md", |
45 | | - "global", |
46 | | - "git:abc", |
47 | | - "fix a flaky auth test", |
48 | | - "fix", |
49 | | - "mock Date.now in auth tests", |
50 | | - "h1", |
51 | | - Date.now(), |
52 | | - ); |
53 | 63 |
|
54 | | - const hit = db |
55 | | - .prepare( |
56 | | - `SELECT m.id FROM skill_memory_fts f JOIN skill_memory m ON m.id = f.rowid |
57 | | - WHERE skill_memory_fts MATCH ?`, |
58 | | - ) |
59 | | - .get('"auth"'); |
60 | | - expect(hit).toBeTruthy(); |
61 | | - } finally { |
62 | | - closeQuietly(db); |
63 | | - } |
64 | | - }); |
| 64 | + const insert = db.prepare(` |
| 65 | + INSERT INTO skill_memory |
| 66 | + (skill_id, resolved_path, tier, project_identity, intent, kind, delta, normalized_hash, hit_count, pinned, created_at) |
| 67 | + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?) |
| 68 | + `); |
65 | 69 |
|
66 | | - // requires `import { MIGRATIONS } from "./migrations";` (added above) |
67 | | - test("v51 migration backfills FTS for rows that pre-existed v40", () => { |
68 | | - const db = new Database(":memory:"); |
69 | | - try { |
70 | | - initializeDatabase(db); |
71 | | - // Build a PRE-v40 schema: apply every migration BELOW v40 directly via up() (runMigrations has no |
72 | | - // target-version param). skill_memory is created at v39; the FTS table + delta_embedding do NOT exist yet. |
73 | | - for (const m of MIGRATIONS.filter((x) => x.version < 51).sort( |
74 | | - (a, b) => a.version - b.version, |
75 | | - )) { |
76 | | - m.up(db); |
77 | | - } |
78 | | - // Insert a row under the pre-v51 schema — no FTS table yet, so no AFTER-INSERT trigger indexes it. |
79 | | - db.prepare( |
80 | | - `INSERT INTO skill_memory |
81 | | - (skill_id, resolved_path, tier, project_identity, intent, kind, delta, normalized_hash, hit_count, pinned, created_at) |
82 | | - VALUES (?,?,?,?,?,?,?,?,0,0,?)`, |
83 | | - ).run( |
84 | | - "s2", |
85 | | - "/p/SKILL.md", |
86 | | - "global", |
87 | | - "git:abc", |
88 | | - "handle oauth refresh", |
89 | | - "fix", |
90 | | - "rotate the token early", |
91 | | - "h2", |
92 | | - Date.now(), |
93 | | - ); |
94 | | - // Apply ONLY v40's up() — its body must ALTER + create the FTS table + BACKFILL the pre-existing row. |
95 | | - const v51 = MIGRATIONS.find((m) => m.version === 51); |
96 | | - if (!v51) throw new Error("v40 migration not found"); |
97 | | - v51.up(db); |
98 | | - const hit = db |
99 | | - .prepare( |
100 | | - `SELECT m.id FROM skill_memory_fts f JOIN skill_memory m ON m.id = f.rowid WHERE skill_memory_fts MATCH ?`, |
101 | | - ) |
102 | | - .get('"oauth"'); |
103 | | - expect(hit).toBeTruthy(); |
| 70 | + // Valid row |
| 71 | + expect(() => |
| 72 | + insert.run( |
| 73 | + "test-skill", |
| 74 | + "/path/SKILL.md", |
| 75 | + "project", |
| 76 | + "git:abc123", |
| 77 | + "test intent", |
| 78 | + "gotcha", |
| 79 | + "test delta", |
| 80 | + "hash1", |
| 81 | + Date.now(), |
| 82 | + ), |
| 83 | + ).not.toThrow(); |
| 84 | + |
| 85 | + // Invalid tier |
| 86 | + expect(() => |
| 87 | + insert.run( |
| 88 | + "test-skill", |
| 89 | + "/path/SKILL.md", |
| 90 | + "invalid-tier", |
| 91 | + "git:abc123", |
| 92 | + "test intent", |
| 93 | + "gotcha", |
| 94 | + "test delta", |
| 95 | + "hash2", |
| 96 | + Date.now(), |
| 97 | + ), |
| 98 | + ).toThrow(); |
| 99 | + |
| 100 | + // Invalid kind |
| 101 | + expect(() => |
| 102 | + insert.run( |
| 103 | + "test-skill", |
| 104 | + "/path/SKILL.md", |
| 105 | + "project", |
| 106 | + "git:abc123", |
| 107 | + "test intent", |
| 108 | + "general", |
| 109 | + "test delta", |
| 110 | + "hash3", |
| 111 | + Date.now(), |
| 112 | + ), |
| 113 | + ).toThrow(); |
104 | 114 | } finally { |
105 | 115 | closeQuietly(db); |
106 | 116 | } |
107 | 117 | }); |
108 | 118 |
|
109 | 119 | test("LATEST_SUPPORTED_VERSION equals LATEST_MIGRATION_VERSION after v51", () => { |
| 120 | + // This test will fail until storage-db.ts is bumped to 39. |
| 121 | + // Belt-and-braces: mirrors schema-version-fence.test.ts but is co-located with the migration. |
| 122 | + // If this feels redundant, keep it with this comment — co-location aids discoverability. |
| 123 | + // NOTE: use ESM import at the top of the file (not require()) to match codebase pattern. |
110 | 124 | expect(LATEST_SUPPORTED_VERSION).toBe(LATEST_MIGRATION_VERSION); |
111 | | - expect(LATEST_SUPPORTED_VERSION).toBe(52); |
112 | 125 | }); |
113 | 126 | }); |
0 commit comments