opencode-swarm-plugin 0.12.30 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.beads/issues.jsonl +204 -10
- package/.opencode/skills/tdd/SKILL.md +182 -0
- package/README.md +165 -17
- package/bin/swarm.ts +120 -31
- package/bun.lock +23 -0
- package/dist/index.js +4020 -438
- package/dist/pglite.data +0 -0
- package/dist/pglite.wasm +0 -0
- package/dist/plugin.js +4008 -514
- package/examples/commands/swarm.md +114 -19
- package/examples/skills/beads-workflow/SKILL.md +75 -28
- package/examples/skills/swarm-coordination/SKILL.md +92 -1
- package/global-skills/testing-patterns/SKILL.md +430 -0
- package/global-skills/testing-patterns/references/dependency-breaking-catalog.md +586 -0
- package/package.json +11 -5
- package/src/index.ts +44 -5
- package/src/streams/agent-mail.test.ts +777 -0
- package/src/streams/agent-mail.ts +535 -0
- package/src/streams/debug.test.ts +500 -0
- package/src/streams/debug.ts +629 -0
- package/src/streams/effect/ask.integration.test.ts +314 -0
- package/src/streams/effect/ask.ts +202 -0
- package/src/streams/effect/cursor.integration.test.ts +418 -0
- package/src/streams/effect/cursor.ts +288 -0
- package/src/streams/effect/deferred.test.ts +357 -0
- package/src/streams/effect/deferred.ts +445 -0
- package/src/streams/effect/index.ts +17 -0
- package/src/streams/effect/layers.ts +73 -0
- package/src/streams/effect/lock.test.ts +385 -0
- package/src/streams/effect/lock.ts +399 -0
- package/src/streams/effect/mailbox.test.ts +260 -0
- package/src/streams/effect/mailbox.ts +318 -0
- package/src/streams/events.test.ts +628 -0
- package/src/streams/events.ts +214 -0
- package/src/streams/index.test.ts +229 -0
- package/src/streams/index.ts +492 -0
- package/src/streams/migrations.test.ts +355 -0
- package/src/streams/migrations.ts +269 -0
- package/src/streams/projections.test.ts +611 -0
- package/src/streams/projections.ts +302 -0
- package/src/streams/store.integration.test.ts +548 -0
- package/src/streams/store.ts +546 -0
- package/src/streams/swarm-mail.ts +552 -0
- package/src/swarm-mail.integration.test.ts +970 -0
- package/src/swarm-mail.ts +739 -0
- package/src/swarm.ts +84 -59
- package/src/tool-availability.ts +35 -2
- package/global-skills/mcp-tool-authoring/SKILL.md +0 -695
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Schema Migration System
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
5
|
+
import { PGlite } from "@electric-sql/pglite";
|
|
6
|
+
import {
|
|
7
|
+
runMigrations,
|
|
8
|
+
getCurrentVersion,
|
|
9
|
+
rollbackTo,
|
|
10
|
+
isMigrationApplied,
|
|
11
|
+
getPendingMigrations,
|
|
12
|
+
getAppliedMigrations,
|
|
13
|
+
migrations,
|
|
14
|
+
} from "./migrations";
|
|
15
|
+
|
|
16
|
+
describe("Schema Migrations", () => {
|
|
17
|
+
let db: PGlite;
|
|
18
|
+
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
// Use in-memory database for tests
|
|
21
|
+
db = new PGlite();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(async () => {
|
|
25
|
+
await db.close();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("Fresh Install", () => {
|
|
29
|
+
it("should start with version 0", async () => {
|
|
30
|
+
const version = await getCurrentVersion(db);
|
|
31
|
+
expect(version).toBe(0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("should run all migrations on fresh database", async () => {
|
|
35
|
+
const result = await runMigrations(db);
|
|
36
|
+
|
|
37
|
+
expect(result.applied).toEqual([1, 2]);
|
|
38
|
+
expect(result.current).toBe(2);
|
|
39
|
+
|
|
40
|
+
const version = await getCurrentVersion(db);
|
|
41
|
+
expect(version).toBe(2);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("should create cursors table with correct schema", async () => {
|
|
45
|
+
await runMigrations(db);
|
|
46
|
+
|
|
47
|
+
// Verify table exists
|
|
48
|
+
const tableResult = await db.query<{ exists: boolean }>(
|
|
49
|
+
`SELECT EXISTS (
|
|
50
|
+
SELECT FROM information_schema.tables
|
|
51
|
+
WHERE table_name = 'cursors'
|
|
52
|
+
) as exists`,
|
|
53
|
+
);
|
|
54
|
+
expect(tableResult.rows[0]?.exists).toBe(true);
|
|
55
|
+
|
|
56
|
+
// Verify columns
|
|
57
|
+
const columnsResult = await db.query<{ column_name: string }>(
|
|
58
|
+
`SELECT column_name FROM information_schema.columns
|
|
59
|
+
WHERE table_name = 'cursors'
|
|
60
|
+
ORDER BY ordinal_position`,
|
|
61
|
+
);
|
|
62
|
+
const columns = columnsResult.rows.map((r) => r.column_name);
|
|
63
|
+
expect(columns).toContain("id");
|
|
64
|
+
expect(columns).toContain("stream");
|
|
65
|
+
expect(columns).toContain("checkpoint");
|
|
66
|
+
expect(columns).toContain("position");
|
|
67
|
+
expect(columns).toContain("updated_at");
|
|
68
|
+
|
|
69
|
+
// Verify unique constraint exists
|
|
70
|
+
const constraintsResult = await db.query<{ constraint_name: string }>(
|
|
71
|
+
`SELECT constraint_name FROM information_schema.table_constraints
|
|
72
|
+
WHERE table_name = 'cursors' AND constraint_type = 'UNIQUE'`,
|
|
73
|
+
);
|
|
74
|
+
expect(constraintsResult.rows.length).toBeGreaterThan(0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("should create deferred table with correct schema", async () => {
|
|
78
|
+
await runMigrations(db);
|
|
79
|
+
|
|
80
|
+
const tableResult = await db.query<{ exists: boolean }>(
|
|
81
|
+
`SELECT EXISTS (
|
|
82
|
+
SELECT FROM information_schema.tables
|
|
83
|
+
WHERE table_name = 'deferred'
|
|
84
|
+
) as exists`,
|
|
85
|
+
);
|
|
86
|
+
expect(tableResult.rows[0]?.exists).toBe(true);
|
|
87
|
+
|
|
88
|
+
const columnsResult = await db.query<{ column_name: string }>(
|
|
89
|
+
`SELECT column_name FROM information_schema.columns
|
|
90
|
+
WHERE table_name = 'deferred'
|
|
91
|
+
ORDER BY ordinal_position`,
|
|
92
|
+
);
|
|
93
|
+
const columns = columnsResult.rows.map((r) => r.column_name);
|
|
94
|
+
expect(columns).toContain("id");
|
|
95
|
+
expect(columns).toContain("url");
|
|
96
|
+
expect(columns).toContain("resolved");
|
|
97
|
+
expect(columns).toContain("value");
|
|
98
|
+
expect(columns).toContain("error");
|
|
99
|
+
expect(columns).toContain("expires_at");
|
|
100
|
+
expect(columns).toContain("created_at");
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("Idempotency", () => {
|
|
105
|
+
it("should be safe to run migrations multiple times", async () => {
|
|
106
|
+
// First run
|
|
107
|
+
const result1 = await runMigrations(db);
|
|
108
|
+
expect(result1.applied).toEqual([1, 2]);
|
|
109
|
+
|
|
110
|
+
// Second run - should apply nothing
|
|
111
|
+
const result2 = await runMigrations(db);
|
|
112
|
+
expect(result2.applied).toEqual([]);
|
|
113
|
+
expect(result2.current).toBe(2);
|
|
114
|
+
|
|
115
|
+
// Version should still be 2
|
|
116
|
+
const version = await getCurrentVersion(db);
|
|
117
|
+
expect(version).toBe(2);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("Incremental Upgrade", () => {
|
|
122
|
+
it("should apply only new migrations", async () => {
|
|
123
|
+
// Manually apply migration 1
|
|
124
|
+
await db.exec(migrations[0]!.up);
|
|
125
|
+
await db.exec(`
|
|
126
|
+
CREATE TABLE IF NOT EXISTS schema_version (
|
|
127
|
+
version INTEGER PRIMARY KEY,
|
|
128
|
+
applied_at BIGINT NOT NULL,
|
|
129
|
+
description TEXT
|
|
130
|
+
);
|
|
131
|
+
`);
|
|
132
|
+
await db.query(
|
|
133
|
+
`INSERT INTO schema_version (version, applied_at, description)
|
|
134
|
+
VALUES ($1, $2, $3)`,
|
|
135
|
+
[1, Date.now(), migrations[0]!.description],
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// Now run migrations - should only apply 2
|
|
139
|
+
const result = await runMigrations(db);
|
|
140
|
+
expect(result.applied).toEqual([2]);
|
|
141
|
+
expect(result.current).toBe(2);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("Rollback", () => {
|
|
146
|
+
it("should rollback to target version", async () => {
|
|
147
|
+
// Apply all migrations
|
|
148
|
+
await runMigrations(db);
|
|
149
|
+
expect(await getCurrentVersion(db)).toBe(2);
|
|
150
|
+
|
|
151
|
+
// Rollback to version 1
|
|
152
|
+
const result = await rollbackTo(db, 1);
|
|
153
|
+
expect(result.rolledBack).toEqual([2]);
|
|
154
|
+
expect(result.current).toBe(1);
|
|
155
|
+
|
|
156
|
+
// Version should be 1
|
|
157
|
+
const version = await getCurrentVersion(db);
|
|
158
|
+
expect(version).toBe(1);
|
|
159
|
+
|
|
160
|
+
// Cursors table should still exist
|
|
161
|
+
const cursorsExists = await db.query<{ exists: boolean }>(
|
|
162
|
+
`SELECT EXISTS (
|
|
163
|
+
SELECT FROM information_schema.tables
|
|
164
|
+
WHERE table_name = 'cursors'
|
|
165
|
+
) as exists`,
|
|
166
|
+
);
|
|
167
|
+
expect(cursorsExists.rows[0]?.exists).toBe(true);
|
|
168
|
+
|
|
169
|
+
// Deferred table should be gone
|
|
170
|
+
const deferredExists = await db.query<{ exists: boolean }>(
|
|
171
|
+
`SELECT EXISTS (
|
|
172
|
+
SELECT FROM information_schema.tables
|
|
173
|
+
WHERE table_name = 'deferred'
|
|
174
|
+
) as exists`,
|
|
175
|
+
);
|
|
176
|
+
expect(deferredExists.rows[0]?.exists).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("should rollback to version 0", async () => {
|
|
180
|
+
await runMigrations(db);
|
|
181
|
+
|
|
182
|
+
const result = await rollbackTo(db, 0);
|
|
183
|
+
expect(result.rolledBack).toEqual([2, 1]);
|
|
184
|
+
expect(result.current).toBe(0);
|
|
185
|
+
|
|
186
|
+
// All tables should be gone
|
|
187
|
+
const cursorsExists = await db.query<{ exists: boolean }>(
|
|
188
|
+
`SELECT EXISTS (
|
|
189
|
+
SELECT FROM information_schema.tables
|
|
190
|
+
WHERE table_name = 'cursors'
|
|
191
|
+
) as exists`,
|
|
192
|
+
);
|
|
193
|
+
expect(cursorsExists.rows[0]?.exists).toBe(false);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("should do nothing if target version >= current", async () => {
|
|
197
|
+
await runMigrations(db);
|
|
198
|
+
|
|
199
|
+
const result = await rollbackTo(db, 2);
|
|
200
|
+
expect(result.rolledBack).toEqual([]);
|
|
201
|
+
expect(result.current).toBe(2);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("Migration Status", () => {
|
|
206
|
+
it("should check if migration is applied", async () => {
|
|
207
|
+
expect(await isMigrationApplied(db, 1)).toBe(false);
|
|
208
|
+
|
|
209
|
+
await runMigrations(db);
|
|
210
|
+
|
|
211
|
+
expect(await isMigrationApplied(db, 1)).toBe(true);
|
|
212
|
+
expect(await isMigrationApplied(db, 2)).toBe(true);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("should list pending migrations", async () => {
|
|
216
|
+
const pending1 = await getPendingMigrations(db);
|
|
217
|
+
expect(pending1).toHaveLength(2);
|
|
218
|
+
expect(pending1.map((m) => m.version)).toEqual([1, 2]);
|
|
219
|
+
|
|
220
|
+
// Apply migration 1
|
|
221
|
+
const migration = migrations[0];
|
|
222
|
+
if (!migration) throw new Error("Migration not found");
|
|
223
|
+
|
|
224
|
+
await db.exec(migration.up);
|
|
225
|
+
await db.exec(`
|
|
226
|
+
CREATE TABLE IF NOT EXISTS schema_version (
|
|
227
|
+
version INTEGER PRIMARY KEY,
|
|
228
|
+
applied_at BIGINT NOT NULL,
|
|
229
|
+
description TEXT
|
|
230
|
+
);
|
|
231
|
+
`);
|
|
232
|
+
await db.query(
|
|
233
|
+
`INSERT INTO schema_version (version, applied_at, description)
|
|
234
|
+
VALUES ($1, $2, $3)`,
|
|
235
|
+
[1, Date.now(), migration.description],
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const pending2 = await getPendingMigrations(db);
|
|
239
|
+
expect(pending2).toHaveLength(1);
|
|
240
|
+
expect(pending2.map((m) => m.version)).toEqual([2]);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("should list applied migrations", async () => {
|
|
244
|
+
const applied1 = await getAppliedMigrations(db);
|
|
245
|
+
expect(applied1).toHaveLength(0);
|
|
246
|
+
|
|
247
|
+
await runMigrations(db);
|
|
248
|
+
|
|
249
|
+
const applied2 = await getAppliedMigrations(db);
|
|
250
|
+
expect(applied2).toHaveLength(2);
|
|
251
|
+
expect(applied2.map((m) => m.version)).toEqual([1, 2]);
|
|
252
|
+
expect(applied2[0]?.description).toBe(
|
|
253
|
+
"Add cursors table for DurableCursor",
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe("Data Persistence", () => {
|
|
259
|
+
it("should preserve data across migrations", async () => {
|
|
260
|
+
// Apply migration 1 (cursors table)
|
|
261
|
+
await db.exec(migrations[0]!.up);
|
|
262
|
+
await db.exec(`
|
|
263
|
+
CREATE TABLE IF NOT EXISTS schema_version (
|
|
264
|
+
version INTEGER PRIMARY KEY,
|
|
265
|
+
applied_at BIGINT NOT NULL,
|
|
266
|
+
description TEXT
|
|
267
|
+
);
|
|
268
|
+
`);
|
|
269
|
+
await db.query(
|
|
270
|
+
`INSERT INTO schema_version (version, applied_at, description)
|
|
271
|
+
VALUES ($1, $2, $3)`,
|
|
272
|
+
[1, Date.now(), migrations[0]!.description],
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
// Insert test data
|
|
276
|
+
await db.query(
|
|
277
|
+
`INSERT INTO cursors (stream, checkpoint, position, updated_at)
|
|
278
|
+
VALUES ($1, $2, $3, $4)`,
|
|
279
|
+
["test-stream", "test-checkpoint", 42, Date.now()],
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
// Apply remaining migrations
|
|
283
|
+
await runMigrations(db);
|
|
284
|
+
|
|
285
|
+
// Data should still be there
|
|
286
|
+
const result = await db.query<{ position: number }>(
|
|
287
|
+
`SELECT position FROM cursors WHERE stream = $1`,
|
|
288
|
+
["test-stream"],
|
|
289
|
+
);
|
|
290
|
+
expect(result.rows[0]?.position).toBe(42);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
describe("Error Handling", () => {
|
|
295
|
+
it("should rollback failed migrations", async () => {
|
|
296
|
+
// Apply good migration first
|
|
297
|
+
const migration = migrations[0];
|
|
298
|
+
if (!migration) throw new Error("Migration not found");
|
|
299
|
+
|
|
300
|
+
await db.exec(migration.up);
|
|
301
|
+
await db.exec(`
|
|
302
|
+
CREATE TABLE IF NOT EXISTS schema_version (
|
|
303
|
+
version INTEGER PRIMARY KEY,
|
|
304
|
+
applied_at BIGINT NOT NULL,
|
|
305
|
+
description TEXT
|
|
306
|
+
);
|
|
307
|
+
`);
|
|
308
|
+
await db.query(
|
|
309
|
+
`INSERT INTO schema_version (version, applied_at, description)
|
|
310
|
+
VALUES ($1, $2, $3)`,
|
|
311
|
+
[1, Date.now(), migration.description],
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
// Try to run invalid SQL in a transaction
|
|
315
|
+
try {
|
|
316
|
+
await db.exec("BEGIN");
|
|
317
|
+
await db.exec("THIS IS INVALID SQL");
|
|
318
|
+
await db.exec("COMMIT");
|
|
319
|
+
throw new Error("Should have thrown");
|
|
320
|
+
} catch {
|
|
321
|
+
await db.exec("ROLLBACK");
|
|
322
|
+
// Expected to fail
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Version should still be 1
|
|
326
|
+
const version = await getCurrentVersion(db);
|
|
327
|
+
expect(version).toBe(1);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe("Schema Version Table", () => {
|
|
332
|
+
it("should record migration metadata", async () => {
|
|
333
|
+
await runMigrations(db);
|
|
334
|
+
|
|
335
|
+
const result = await db.query<{
|
|
336
|
+
version: number;
|
|
337
|
+
applied_at: string;
|
|
338
|
+
description: string;
|
|
339
|
+
}>(
|
|
340
|
+
`SELECT version, applied_at, description FROM schema_version ORDER BY version`,
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
expect(result.rows).toHaveLength(2);
|
|
344
|
+
expect(result.rows[0]?.version).toBe(1);
|
|
345
|
+
expect(result.rows[0]?.description).toBe(
|
|
346
|
+
"Add cursors table for DurableCursor",
|
|
347
|
+
);
|
|
348
|
+
expect(result.rows[1]?.version).toBe(2);
|
|
349
|
+
|
|
350
|
+
// Applied_at should be recent
|
|
351
|
+
const appliedAt = parseInt(result.rows[0]?.applied_at as string);
|
|
352
|
+
expect(appliedAt).toBeGreaterThan(Date.now() - 10000);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
});
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema Migration System for PGLite Event Store
|
|
3
|
+
*
|
|
4
|
+
* Version-based migrations with up/down support.
|
|
5
|
+
* Tracks applied migrations in schema_version table.
|
|
6
|
+
* Idempotent - safe to run multiple times.
|
|
7
|
+
*/
|
|
8
|
+
import type { PGlite } from "@electric-sql/pglite";
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// Types
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
export interface Migration {
|
|
15
|
+
version: number;
|
|
16
|
+
description: string;
|
|
17
|
+
up: string;
|
|
18
|
+
down: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface SchemaVersion {
|
|
22
|
+
version: number;
|
|
23
|
+
applied_at: number;
|
|
24
|
+
description: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ============================================================================
|
|
28
|
+
// Migration Definitions
|
|
29
|
+
// ============================================================================
|
|
30
|
+
|
|
31
|
+
export const migrations: Migration[] = [
|
|
32
|
+
{
|
|
33
|
+
version: 1,
|
|
34
|
+
description: "Add cursors table for DurableCursor",
|
|
35
|
+
up: `
|
|
36
|
+
CREATE TABLE IF NOT EXISTS cursors (
|
|
37
|
+
id SERIAL PRIMARY KEY,
|
|
38
|
+
stream TEXT NOT NULL,
|
|
39
|
+
checkpoint TEXT NOT NULL,
|
|
40
|
+
position BIGINT NOT NULL DEFAULT 0,
|
|
41
|
+
updated_at BIGINT NOT NULL,
|
|
42
|
+
UNIQUE(stream, checkpoint)
|
|
43
|
+
);
|
|
44
|
+
CREATE INDEX IF NOT EXISTS idx_cursors_checkpoint ON cursors(checkpoint);
|
|
45
|
+
CREATE INDEX IF NOT EXISTS idx_cursors_stream ON cursors(stream);
|
|
46
|
+
`,
|
|
47
|
+
down: `DROP TABLE IF EXISTS cursors;`,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
version: 2,
|
|
51
|
+
description: "Add deferred table for DurableDeferred",
|
|
52
|
+
up: `
|
|
53
|
+
CREATE TABLE IF NOT EXISTS deferred (
|
|
54
|
+
id SERIAL PRIMARY KEY,
|
|
55
|
+
url TEXT NOT NULL UNIQUE,
|
|
56
|
+
resolved BOOLEAN NOT NULL DEFAULT FALSE,
|
|
57
|
+
value JSONB,
|
|
58
|
+
error TEXT,
|
|
59
|
+
expires_at BIGINT NOT NULL,
|
|
60
|
+
created_at BIGINT NOT NULL
|
|
61
|
+
);
|
|
62
|
+
CREATE INDEX IF NOT EXISTS idx_deferred_url ON deferred(url);
|
|
63
|
+
CREATE INDEX IF NOT EXISTS idx_deferred_expires ON deferred(expires_at);
|
|
64
|
+
CREATE INDEX IF NOT EXISTS idx_deferred_resolved ON deferred(resolved);
|
|
65
|
+
`,
|
|
66
|
+
down: `DROP TABLE IF EXISTS deferred;`,
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
// ============================================================================
|
|
71
|
+
// Migration Execution
|
|
72
|
+
// ============================================================================
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Initialize schema_version table if it doesn't exist
|
|
76
|
+
*/
|
|
77
|
+
async function ensureVersionTable(db: PGlite): Promise<void> {
|
|
78
|
+
await db.exec(`
|
|
79
|
+
CREATE TABLE IF NOT EXISTS schema_version (
|
|
80
|
+
version INTEGER PRIMARY KEY,
|
|
81
|
+
applied_at BIGINT NOT NULL,
|
|
82
|
+
description TEXT
|
|
83
|
+
);
|
|
84
|
+
`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Get the current schema version
|
|
89
|
+
*
|
|
90
|
+
* Returns 0 if no migrations have been applied
|
|
91
|
+
*/
|
|
92
|
+
export async function getCurrentVersion(db: PGlite): Promise<number> {
|
|
93
|
+
await ensureVersionTable(db);
|
|
94
|
+
|
|
95
|
+
const result = await db.query<{ version: number }>(
|
|
96
|
+
`SELECT MAX(version) as version FROM schema_version`,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
return result.rows[0]?.version ?? 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Get all applied migrations
|
|
104
|
+
*/
|
|
105
|
+
export async function getAppliedMigrations(
|
|
106
|
+
db: PGlite,
|
|
107
|
+
): Promise<SchemaVersion[]> {
|
|
108
|
+
await ensureVersionTable(db);
|
|
109
|
+
|
|
110
|
+
const result = await db.query<{
|
|
111
|
+
version: number;
|
|
112
|
+
applied_at: string;
|
|
113
|
+
description: string | null;
|
|
114
|
+
}>(
|
|
115
|
+
`SELECT version, applied_at, description FROM schema_version ORDER BY version ASC`,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
return result.rows.map((row) => ({
|
|
119
|
+
version: row.version,
|
|
120
|
+
applied_at: parseInt(row.applied_at as string),
|
|
121
|
+
description: row.description,
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Run all pending migrations
|
|
127
|
+
*
|
|
128
|
+
* Idempotent - safe to run multiple times.
|
|
129
|
+
* Only runs migrations that haven't been applied yet.
|
|
130
|
+
*/
|
|
131
|
+
export async function runMigrations(db: PGlite): Promise<{
|
|
132
|
+
applied: number[];
|
|
133
|
+
current: number;
|
|
134
|
+
}> {
|
|
135
|
+
await ensureVersionTable(db);
|
|
136
|
+
|
|
137
|
+
const currentVersion = await getCurrentVersion(db);
|
|
138
|
+
const applied: number[] = [];
|
|
139
|
+
|
|
140
|
+
// Find migrations that need to be applied
|
|
141
|
+
const pendingMigrations = migrations.filter(
|
|
142
|
+
(m) => m.version > currentVersion,
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
if (pendingMigrations.length === 0) {
|
|
146
|
+
return { applied: [], current: currentVersion };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Sort by version to ensure correct order
|
|
150
|
+
pendingMigrations.sort((a, b) => a.version - b.version);
|
|
151
|
+
|
|
152
|
+
// Apply each migration in a transaction
|
|
153
|
+
for (const migration of pendingMigrations) {
|
|
154
|
+
await db.exec("BEGIN");
|
|
155
|
+
try {
|
|
156
|
+
// Run the migration SQL
|
|
157
|
+
await db.exec(migration.up);
|
|
158
|
+
|
|
159
|
+
// Record the migration
|
|
160
|
+
await db.query(
|
|
161
|
+
`INSERT INTO schema_version (version, applied_at, description)
|
|
162
|
+
VALUES ($1, $2, $3)`,
|
|
163
|
+
[migration.version, Date.now(), migration.description],
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
await db.exec("COMMIT");
|
|
167
|
+
applied.push(migration.version);
|
|
168
|
+
|
|
169
|
+
console.log(
|
|
170
|
+
`[migrations] Applied migration ${migration.version}: ${migration.description}`,
|
|
171
|
+
);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
await db.exec("ROLLBACK");
|
|
174
|
+
const err = error as Error;
|
|
175
|
+
console.error(
|
|
176
|
+
`[migrations] Failed to apply migration ${migration.version}: ${err.message}`,
|
|
177
|
+
);
|
|
178
|
+
throw new Error(`Migration ${migration.version} failed: ${err.message}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const finalVersion = await getCurrentVersion(db);
|
|
183
|
+
return { applied, current: finalVersion };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Rollback to a specific version
|
|
188
|
+
*
|
|
189
|
+
* WARNING: This will DROP tables and LOSE DATA.
|
|
190
|
+
* Only use for testing or emergency recovery.
|
|
191
|
+
*/
|
|
192
|
+
export async function rollbackTo(
|
|
193
|
+
db: PGlite,
|
|
194
|
+
targetVersion: number,
|
|
195
|
+
): Promise<{
|
|
196
|
+
rolledBack: number[];
|
|
197
|
+
current: number;
|
|
198
|
+
}> {
|
|
199
|
+
const currentVersion = await getCurrentVersion(db);
|
|
200
|
+
const rolledBack: number[] = [];
|
|
201
|
+
|
|
202
|
+
if (targetVersion >= currentVersion) {
|
|
203
|
+
return { rolledBack: [], current: currentVersion };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Find migrations to rollback (in reverse order)
|
|
207
|
+
const migrationsToRollback = migrations
|
|
208
|
+
.filter((m) => m.version > targetVersion && m.version <= currentVersion)
|
|
209
|
+
.sort((a, b) => b.version - a.version); // Descending order
|
|
210
|
+
|
|
211
|
+
for (const migration of migrationsToRollback) {
|
|
212
|
+
await db.exec("BEGIN");
|
|
213
|
+
try {
|
|
214
|
+
// Run the down migration
|
|
215
|
+
await db.exec(migration.down);
|
|
216
|
+
|
|
217
|
+
// Remove from version table
|
|
218
|
+
await db.query(`DELETE FROM schema_version WHERE version = $1`, [
|
|
219
|
+
migration.version,
|
|
220
|
+
]);
|
|
221
|
+
|
|
222
|
+
await db.exec("COMMIT");
|
|
223
|
+
rolledBack.push(migration.version);
|
|
224
|
+
|
|
225
|
+
console.log(
|
|
226
|
+
`[migrations] Rolled back migration ${migration.version}: ${migration.description}`,
|
|
227
|
+
);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
await db.exec("ROLLBACK");
|
|
230
|
+
const err = error as Error;
|
|
231
|
+
console.error(
|
|
232
|
+
`[migrations] Failed to rollback migration ${migration.version}: ${err.message}`,
|
|
233
|
+
);
|
|
234
|
+
throw new Error(
|
|
235
|
+
`Rollback of migration ${migration.version} failed: ${err.message}`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const finalVersion = await getCurrentVersion(db);
|
|
241
|
+
return { rolledBack, current: finalVersion };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Check if a specific migration has been applied
|
|
246
|
+
*/
|
|
247
|
+
export async function isMigrationApplied(
|
|
248
|
+
db: PGlite,
|
|
249
|
+
version: number,
|
|
250
|
+
): Promise<boolean> {
|
|
251
|
+
await ensureVersionTable(db);
|
|
252
|
+
|
|
253
|
+
const result = await db.query<{ count: string }>(
|
|
254
|
+
`SELECT COUNT(*) as count FROM schema_version WHERE version = $1`,
|
|
255
|
+
[version],
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
return parseInt(result.rows[0]?.count || "0") > 0;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Get pending migrations (not yet applied)
|
|
263
|
+
*/
|
|
264
|
+
export async function getPendingMigrations(db: PGlite): Promise<Migration[]> {
|
|
265
|
+
const currentVersion = await getCurrentVersion(db);
|
|
266
|
+
return migrations
|
|
267
|
+
.filter((m) => m.version > currentVersion)
|
|
268
|
+
.sort((a, b) => a.version - b.version);
|
|
269
|
+
}
|