trekoon 0.1.2 → 0.1.5

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.
@@ -2,10 +2,142 @@ import { Database } from "bun:sqlite";
2
2
 
3
3
  import { BASE_SCHEMA_STATEMENTS, SCHEMA_VERSION } from "./schema";
4
4
 
5
+ const BASE_MIGRATION_VERSION = 1;
5
6
  const BASE_MIGRATION_NAME = `0001_base_schema_v${SCHEMA_VERSION}`;
7
+ const LEGACY_BASE_MIGRATION_NAME_PATTERNS: readonly string[] = [
8
+ "0001_base_schema_v*",
9
+ ];
6
10
 
7
- function hasMigration(db: Database, name: string): boolean {
8
- const migrationTableExists: { count: number } | null = db
11
+ const BASE_ROLLBACK_STATEMENTS: readonly string[] = [
12
+ "DROP TABLE IF EXISTS sync_conflicts;",
13
+ "DROP TABLE IF EXISTS sync_cursors;",
14
+ "DROP TABLE IF EXISTS git_context;",
15
+ "DROP TABLE IF EXISTS events;",
16
+ "DROP TABLE IF EXISTS dependencies;",
17
+ "DROP TABLE IF EXISTS subtasks;",
18
+ "DROP TABLE IF EXISTS tasks;",
19
+ "DROP TABLE IF EXISTS epics;",
20
+ ];
21
+
22
+ const INDEX_MIGRATION_UP_STATEMENTS: readonly string[] = [
23
+ "CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at);",
24
+ "CREATE INDEX IF NOT EXISTS idx_events_git_branch ON events(git_branch);",
25
+ "CREATE INDEX IF NOT EXISTS idx_events_created_at_id ON events(created_at, id);",
26
+ "CREATE INDEX IF NOT EXISTS idx_dependencies_source ON dependencies(source_id);",
27
+ "CREATE INDEX IF NOT EXISTS idx_dependencies_depends_on ON dependencies(depends_on_id);",
28
+ ];
29
+
30
+ const INDEX_MIGRATION_DOWN_STATEMENTS: readonly string[] = [
31
+ "DROP INDEX IF EXISTS idx_events_created_at;",
32
+ "DROP INDEX IF EXISTS idx_events_git_branch;",
33
+ "DROP INDEX IF EXISTS idx_events_created_at_id;",
34
+ "DROP INDEX IF EXISTS idx_dependencies_source;",
35
+ "DROP INDEX IF EXISTS idx_dependencies_depends_on;",
36
+ ];
37
+
38
+ const EVENT_ARCHIVE_MIGRATION_UP_STATEMENTS: readonly string[] = [
39
+ `
40
+ CREATE TABLE IF NOT EXISTS event_archive (
41
+ id TEXT PRIMARY KEY,
42
+ entity_kind TEXT NOT NULL,
43
+ entity_id TEXT NOT NULL,
44
+ operation TEXT NOT NULL,
45
+ payload TEXT NOT NULL,
46
+ git_branch TEXT,
47
+ git_head TEXT,
48
+ created_at INTEGER NOT NULL,
49
+ updated_at INTEGER NOT NULL,
50
+ version INTEGER NOT NULL DEFAULT 1
51
+ );
52
+ `,
53
+ "CREATE INDEX IF NOT EXISTS idx_event_archive_created_at ON event_archive(created_at);",
54
+ ];
55
+
56
+ const EVENT_ARCHIVE_MIGRATION_DOWN_STATEMENTS: readonly string[] = [
57
+ "DROP INDEX IF EXISTS idx_event_archive_created_at;",
58
+ "DROP TABLE IF EXISTS event_archive;",
59
+ ];
60
+
61
+ interface Migration {
62
+ readonly version: number;
63
+ readonly name: string;
64
+ up(db: Database): void;
65
+ down(db: Database): void;
66
+ }
67
+
68
+ interface AppliedMigrationRow {
69
+ readonly version: number;
70
+ readonly name: string;
71
+ readonly applied_at: number;
72
+ }
73
+
74
+ export interface AppliedMigration {
75
+ readonly version: number;
76
+ readonly name: string;
77
+ readonly appliedAt: number;
78
+ }
79
+
80
+ export interface MigrationStatus {
81
+ readonly currentVersion: number;
82
+ readonly latestVersion: number;
83
+ readonly applied: readonly AppliedMigration[];
84
+ readonly pending: ReadonlyArray<{ version: number; name: string }>;
85
+ }
86
+
87
+ export interface RollbackSummary {
88
+ readonly fromVersion: number;
89
+ readonly toVersion: number;
90
+ readonly rolledBack: number;
91
+ readonly rolledBackMigrations: readonly string[];
92
+ }
93
+
94
+ const MIGRATIONS: readonly Migration[] = [
95
+ {
96
+ version: BASE_MIGRATION_VERSION,
97
+ name: BASE_MIGRATION_NAME,
98
+ up(db: Database): void {
99
+ for (const statement of BASE_SCHEMA_STATEMENTS) {
100
+ db.exec(statement);
101
+ }
102
+ },
103
+ down(db: Database): void {
104
+ for (const statement of BASE_ROLLBACK_STATEMENTS) {
105
+ db.exec(statement);
106
+ }
107
+ },
108
+ },
109
+ {
110
+ version: 2,
111
+ name: "0002_sync_dependency_indexes",
112
+ up(db: Database): void {
113
+ for (const statement of INDEX_MIGRATION_UP_STATEMENTS) {
114
+ db.exec(statement);
115
+ }
116
+ },
117
+ down(db: Database): void {
118
+ for (const statement of INDEX_MIGRATION_DOWN_STATEMENTS) {
119
+ db.exec(statement);
120
+ }
121
+ },
122
+ },
123
+ {
124
+ version: 3,
125
+ name: "0003_event_archive_retention",
126
+ up(db: Database): void {
127
+ for (const statement of EVENT_ARCHIVE_MIGRATION_UP_STATEMENTS) {
128
+ db.exec(statement);
129
+ }
130
+ },
131
+ down(db: Database): void {
132
+ for (const statement of EVENT_ARCHIVE_MIGRATION_DOWN_STATEMENTS) {
133
+ db.exec(statement);
134
+ }
135
+ },
136
+ },
137
+ ];
138
+
139
+ function migrationTableExists(db: Database): boolean {
140
+ const row = db
9
141
  .query(
10
142
  `
11
143
  SELECT COUNT(*) AS count
@@ -15,32 +147,221 @@ function hasMigration(db: Database, name: string): boolean {
15
147
  )
16
148
  .get() as { count: number } | null;
17
149
 
18
- if (!migrationTableExists || migrationTableExists.count === 0) {
19
- return false;
150
+ return (row?.count ?? 0) > 0;
151
+ }
152
+
153
+ function hasMigrationVersionColumn(db: Database): boolean {
154
+ const columns = db.query("PRAGMA table_info(schema_migrations);").all() as Array<{ name: string }>;
155
+ return columns.some((column) => column.name === "version");
156
+ }
157
+
158
+ function ensureMigrationTable(db: Database): void {
159
+ db.exec(`
160
+ CREATE TABLE IF NOT EXISTS schema_migrations (
161
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
162
+ version INTEGER NOT NULL UNIQUE,
163
+ name TEXT NOT NULL UNIQUE,
164
+ applied_at INTEGER NOT NULL
165
+ );
166
+ `);
167
+ }
168
+
169
+ function ensureMigrationVersionColumn(db: Database): void {
170
+ if (!migrationTableExists(db) || hasMigrationVersionColumn(db)) {
171
+ return;
172
+ }
173
+
174
+ db.exec("ALTER TABLE schema_migrations ADD COLUMN version INTEGER;");
175
+ db.query("UPDATE schema_migrations SET version = ? WHERE version IS NULL AND name = ?;").run(BASE_MIGRATION_VERSION, BASE_MIGRATION_NAME);
176
+
177
+ for (const legacyPattern of LEGACY_BASE_MIGRATION_NAME_PATTERNS) {
178
+ db.query("UPDATE schema_migrations SET version = ? WHERE version IS NULL AND name GLOB ?;").run(
179
+ BASE_MIGRATION_VERSION,
180
+ legacyPattern,
181
+ );
182
+ }
183
+
184
+ db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_schema_migrations_version ON schema_migrations(version);");
185
+
186
+ const unresolvedRow = db
187
+ .query(
188
+ `
189
+ SELECT COUNT(*) AS count
190
+ FROM schema_migrations
191
+ WHERE version IS NULL;
192
+ `,
193
+ )
194
+ .get() as { count: number } | null;
195
+
196
+ if ((unresolvedRow?.count ?? 0) > 0) {
197
+ throw new Error(
198
+ "Unable to infer one or more schema_migrations.version values during legacy upgrade. Repair schema_migrations entries manually so every row has a valid version, then rerun migrations.",
199
+ );
200
+ }
201
+ }
202
+
203
+ function validateMigrationPlan(): void {
204
+ const seen = new Set<number>();
205
+
206
+ for (let index = 0; index < MIGRATIONS.length; index += 1) {
207
+ const migration: Migration = MIGRATIONS[index]!;
208
+
209
+ if (migration.version !== index + 1) {
210
+ throw new Error(`Migration versions must be contiguous from 1 (found ${migration.version} at index ${index}).`);
211
+ }
212
+
213
+ if (seen.has(migration.version)) {
214
+ throw new Error(`Duplicate migration version ${migration.version}.`);
215
+ }
216
+
217
+ seen.add(migration.version);
218
+ }
219
+ }
220
+
221
+ function runExclusive<T>(db: Database, operation: () => T): T {
222
+ db.exec("BEGIN EXCLUSIVE TRANSACTION;");
223
+
224
+ try {
225
+ const result: T = operation();
226
+ db.exec("COMMIT;");
227
+ return result;
228
+ } catch (error: unknown) {
229
+ db.exec("ROLLBACK;");
230
+ throw error;
231
+ }
232
+ }
233
+
234
+ function currentVersion(db: Database): number {
235
+ if (!migrationTableExists(db)) {
236
+ return 0;
20
237
  }
21
238
 
22
- const row: { count: number } | null = db
23
- .query("SELECT COUNT(*) AS count FROM schema_migrations WHERE name = ?;")
24
- .get(name) as { count: number } | null;
239
+ const row = db
240
+ .query("SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations;")
241
+ .get() as { version: number } | null;
25
242
 
26
- return Boolean(row && row.count > 0);
243
+ return row?.version ?? 0;
244
+ }
245
+
246
+ function listAppliedMigrations(db: Database): AppliedMigrationRow[] {
247
+ if (!migrationTableExists(db)) {
248
+ return [];
249
+ }
250
+
251
+ return db
252
+ .query(
253
+ `
254
+ SELECT version, name, applied_at
255
+ FROM schema_migrations
256
+ WHERE version IS NOT NULL
257
+ ORDER BY version ASC;
258
+ `,
259
+ )
260
+ .all() as AppliedMigrationRow[];
261
+ }
262
+
263
+ function migrationForVersion(version: number): Migration {
264
+ const found = MIGRATIONS.find((migration) => migration.version === version);
265
+
266
+ if (!found) {
267
+ throw new Error(`No migration definition found for version ${version}.`);
268
+ }
269
+
270
+ return found;
271
+ }
272
+
273
+ function recordMigration(db: Database, migration: Migration): void {
274
+ db.query("INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?);").run(
275
+ migration.version,
276
+ migration.name,
277
+ Date.now(),
278
+ );
27
279
  }
28
280
 
29
281
  export function migrateDatabase(db: Database): void {
30
- if (hasMigration(db, BASE_MIGRATION_NAME)) {
31
- return;
282
+ runExclusive(db, (): void => {
283
+ ensureMigrationTable(db);
284
+ ensureMigrationVersionColumn(db);
285
+ validateMigrationPlan();
286
+
287
+ const version: number = currentVersion(db);
288
+
289
+ for (const migration of MIGRATIONS) {
290
+ if (migration.version <= version) {
291
+ continue;
292
+ }
293
+
294
+ migration.up(db);
295
+ recordMigration(db, migration);
296
+ }
297
+ });
298
+ }
299
+
300
+ export function describeMigrations(db: Database): MigrationStatus {
301
+ ensureMigrationTable(db);
302
+ ensureMigrationVersionColumn(db);
303
+ validateMigrationPlan();
304
+
305
+ const appliedRows: AppliedMigrationRow[] = listAppliedMigrations(db);
306
+ const latestVersion: number = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
307
+ const activeVersion: number = appliedRows[appliedRows.length - 1]?.version ?? 0;
308
+ const appliedVersions = new Set(appliedRows.map((row) => row.version));
309
+
310
+ return {
311
+ currentVersion: activeVersion,
312
+ latestVersion,
313
+ applied: appliedRows.map((row) => ({
314
+ version: row.version,
315
+ name: row.name,
316
+ appliedAt: row.applied_at,
317
+ })),
318
+ pending: MIGRATIONS.filter((migration) => !appliedVersions.has(migration.version)).map((migration) => ({
319
+ version: migration.version,
320
+ name: migration.name,
321
+ })),
322
+ };
323
+ }
324
+
325
+ export function rollbackDatabase(db: Database, targetVersion: number): RollbackSummary {
326
+ if (!Number.isInteger(targetVersion) || targetVersion < 0) {
327
+ throw new Error("Rollback target version must be a non-negative integer.");
32
328
  }
33
329
 
34
- const now: number = Date.now();
330
+ return runExclusive(db, (): RollbackSummary => {
331
+ ensureMigrationTable(db);
332
+ ensureMigrationVersionColumn(db);
333
+ validateMigrationPlan();
35
334
 
36
- db.transaction((): void => {
37
- for (const statement of BASE_SCHEMA_STATEMENTS) {
38
- db.exec(statement);
335
+ const fromVersion: number = currentVersion(db);
336
+ if (targetVersion > fromVersion) {
337
+ throw new Error(`Cannot roll back to version ${targetVersion}; current version is ${fromVersion}.`);
39
338
  }
40
339
 
41
- db.query("INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?);").run(
42
- BASE_MIGRATION_NAME,
43
- now,
44
- );
45
- })();
340
+ const appliedDescending = db
341
+ .query(
342
+ `
343
+ SELECT version, name, applied_at
344
+ FROM schema_migrations
345
+ WHERE version IS NOT NULL AND version > ?
346
+ ORDER BY version DESC;
347
+ `,
348
+ )
349
+ .all(targetVersion) as AppliedMigrationRow[];
350
+
351
+ const rolledBackMigrations: string[] = [];
352
+
353
+ for (const row of appliedDescending) {
354
+ const migration: Migration = migrationForVersion(row.version);
355
+ migration.down(db);
356
+ db.query("DELETE FROM schema_migrations WHERE version = ?;").run(row.version);
357
+ rolledBackMigrations.push(migration.name);
358
+ }
359
+
360
+ return {
361
+ fromVersion,
362
+ toVersion: targetVersion,
363
+ rolledBack: appliedDescending.length,
364
+ rolledBackMigrations,
365
+ };
366
+ });
46
367
  }
@@ -5,6 +5,7 @@ export const BASE_SCHEMA_STATEMENTS: readonly string[] = [
5
5
  `
6
6
  CREATE TABLE IF NOT EXISTS schema_migrations (
7
7
  id INTEGER PRIMARY KEY AUTOINCREMENT,
8
+ version INTEGER NOT NULL UNIQUE,
8
9
  name TEXT NOT NULL UNIQUE,
9
10
  applied_at INTEGER NOT NULL
10
11
  );
@@ -6,6 +6,7 @@ export interface MutableRow {
6
6
 
7
7
  export interface MigrationRecord {
8
8
  readonly id: number;
9
+ readonly version: number;
9
10
  readonly name: string;
10
11
  readonly applied_at: number;
11
12
  }
@@ -41,11 +41,19 @@ interface EventPayload {
41
41
  readonly fields?: Record<string, unknown>;
42
42
  }
43
43
 
44
+ function isObjectRecord(value: unknown): value is Record<string, unknown> {
45
+ return typeof value === "object" && value !== null && !Array.isArray(value);
46
+ }
47
+
44
48
  function parsePayload(rawPayload: string): EventPayload {
45
49
  try {
46
50
  const parsed: unknown = JSON.parse(rawPayload);
47
51
 
48
- if (typeof parsed === "object" && parsed !== null) {
52
+ if (isObjectRecord(parsed)) {
53
+ if ("fields" in parsed && !isObjectRecord(parsed.fields)) {
54
+ return {};
55
+ }
56
+
49
57
  return parsed as EventPayload;
50
58
  }
51
59
  } catch {