sql-kite 1.0.7 → 1.0.8

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.
Files changed (88) hide show
  1. package/package.json +11 -3
  2. package/server/db/connections.js +114 -0
  3. package/server/db/meta-db.js +0 -0
  4. package/server/db/user-db.js +0 -0
  5. package/server/index.js +214 -0
  6. package/server/routes/branches.js +484 -0
  7. package/server/routes/compare.js +109 -0
  8. package/server/routes/export.js +201 -0
  9. package/server/routes/import.js +375 -0
  10. package/server/routes/migrations.js +332 -0
  11. package/server/routes/query.js +67 -0
  12. package/server/routes/schema.js +206 -0
  13. package/server/routes/snapshots.js +322 -0
  14. package/server/routes/tables.js +121 -0
  15. package/server/routes/timeline.js +108 -0
  16. package/server/server.js +0 -0
  17. package/src/commands/import-server.js +2 -5
  18. package/src/commands/import.js +5 -9
  19. package/src/commands/start.js +6 -7
  20. package/src/commands/stop.js +7 -2
  21. package/src/utils/paths.js +61 -1
  22. package/studio-out/404/index.html +1 -0
  23. package/studio-out/404.html +1 -0
  24. package/studio-out/__next.__PAGE__.txt +10 -0
  25. package/studio-out/__next._full.txt +20 -0
  26. package/studio-out/__next._head.txt +6 -0
  27. package/studio-out/__next._index.txt +6 -0
  28. package/studio-out/__next._tree.txt +3 -0
  29. package/studio-out/_next/static/LhecVBdPttfi1VZfXA-dL/_buildManifest.js +11 -0
  30. package/studio-out/_next/static/LhecVBdPttfi1VZfXA-dL/_clientMiddlewareManifest.json +1 -0
  31. package/studio-out/_next/static/LhecVBdPttfi1VZfXA-dL/_ssgManifest.js +1 -0
  32. package/studio-out/_next/static/chunks/118fc599da2f27aa.css +2 -0
  33. package/studio-out/_next/static/chunks/240f2fa81d4fb687.js +1 -0
  34. package/studio-out/_next/static/chunks/42c33ca704af9b68.js +1 -0
  35. package/studio-out/_next/static/chunks/99b69e65b599be96.js +5 -0
  36. package/studio-out/_next/static/chunks/a6dad97d9634a72d.js +1 -0
  37. package/studio-out/_next/static/chunks/a6dad97d9634a72d.js.map +1 -0
  38. package/studio-out/_next/static/chunks/b20313408e970968.css +1 -0
  39. package/studio-out/_next/static/chunks/d104f42a7b0c57b2.js +2 -0
  40. package/studio-out/_next/static/chunks/d4aa9be9c80c98d6.js +1 -0
  41. package/studio-out/_next/static/chunks/f2f58a7e93290fbb.js +1 -0
  42. package/studio-out/_next/static/chunks/f547e106c8e2aa8e.js +1 -0
  43. package/studio-out/_next/static/chunks/f5cb054219e2eeb8.js +109 -0
  44. package/studio-out/_next/static/chunks/turbopack-1577480078e795df.js +4 -0
  45. package/studio-out/_not-found/__next._full.txt +15 -0
  46. package/studio-out/_not-found/__next._head.txt +6 -0
  47. package/studio-out/_not-found/__next._index.txt +6 -0
  48. package/studio-out/_not-found/__next._not-found/__PAGE__.txt +5 -0
  49. package/studio-out/_not-found/__next._not-found.txt +4 -0
  50. package/studio-out/_not-found/__next._tree.txt +2 -0
  51. package/studio-out/_not-found/index.html +1 -0
  52. package/studio-out/_not-found/index.txt +15 -0
  53. package/studio-out/favicon.ico +10 -0
  54. package/studio-out/index.html +37 -0
  55. package/studio-out/index.txt +20 -0
  56. package/studio-out/logo.svg +5 -0
  57. package/studio-out/snapshots/__next._full.txt +15 -0
  58. package/studio-out/snapshots/__next._head.txt +6 -0
  59. package/studio-out/snapshots/__next._index.txt +6 -0
  60. package/studio-out/snapshots/__next._tree.txt +2 -0
  61. package/studio-out/snapshots/__next.snapshots/__PAGE__.txt +5 -0
  62. package/studio-out/snapshots/__next.snapshots.txt +4 -0
  63. package/studio-out/snapshots/index.html +1 -0
  64. package/studio-out/snapshots/index.txt +15 -0
  65. package/studio-out/sql/__next._full.txt +15 -0
  66. package/studio-out/sql/__next._head.txt +6 -0
  67. package/studio-out/sql/__next._index.txt +6 -0
  68. package/studio-out/sql/__next._tree.txt +2 -0
  69. package/studio-out/sql/__next.sql/__PAGE__.txt +5 -0
  70. package/studio-out/sql/__next.sql.txt +4 -0
  71. package/studio-out/sql/index.html +1 -0
  72. package/studio-out/sql/index.txt +15 -0
  73. package/studio-out/tables/__next._full.txt +15 -0
  74. package/studio-out/tables/__next._head.txt +6 -0
  75. package/studio-out/tables/__next._index.txt +6 -0
  76. package/studio-out/tables/__next._tree.txt +2 -0
  77. package/studio-out/tables/__next.tables/__PAGE__.txt +5 -0
  78. package/studio-out/tables/__next.tables.txt +4 -0
  79. package/studio-out/tables/index.html +1 -0
  80. package/studio-out/tables/index.txt +15 -0
  81. package/studio-out/timeline/__next._full.txt +15 -0
  82. package/studio-out/timeline/__next._head.txt +6 -0
  83. package/studio-out/timeline/__next._index.txt +6 -0
  84. package/studio-out/timeline/__next._tree.txt +2 -0
  85. package/studio-out/timeline/__next.timeline/__PAGE__.txt +5 -0
  86. package/studio-out/timeline/__next.timeline.txt +4 -0
  87. package/studio-out/timeline/index.html +1 -0
  88. package/studio-out/timeline/index.txt +15 -0
@@ -0,0 +1,484 @@
1
+ import { copyFileSync, existsSync, mkdirSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { getCurrentBranch, closeBranchConnection, getMetaDb } from '../db/connections.js';
4
+
5
+ /**
6
+ * Branch Operations
7
+ *
8
+ * Handles creating, switching, and managing branches
9
+ */
10
+
11
+ export default async function branchesRoutes(fastify, options) {
12
+
13
+ /**
14
+ * Get all branches
15
+ */
16
+ fastify.get('/', async (request, reply) => {
17
+ const metaDb = fastify.getMetaDb();
18
+ const currentBranch = getCurrentBranch(fastify.projectPath);
19
+
20
+ try {
21
+ const branches = metaDb.prepare(`
22
+ SELECT
23
+ name,
24
+ db_file,
25
+ created_at,
26
+ created_from,
27
+ description
28
+ FROM branches
29
+ ORDER BY
30
+ CASE WHEN name = 'main' THEN 0 ELSE 1 END,
31
+ created_at DESC
32
+ `).all();
33
+
34
+ return {
35
+ current: currentBranch,
36
+ branches: branches.map(b => ({
37
+ ...b,
38
+ is_current: b.name === currentBranch
39
+ }))
40
+ };
41
+ } catch (error) {
42
+ reply.code(500).send({ error: error.message });
43
+ }
44
+ });
45
+
46
+ /**
47
+ * Get current branch info
48
+ */
49
+ fastify.get('/current', async (request, reply) => {
50
+ const metaDb = fastify.getMetaDb();
51
+ const currentBranch = getCurrentBranch(fastify.projectPath);
52
+
53
+ try {
54
+ const branch = metaDb.prepare(`
55
+ SELECT * FROM branches WHERE name = ?
56
+ `).get(currentBranch);
57
+
58
+ if (!branch) {
59
+ return reply.code(404).send({ error: 'Current branch not found' });
60
+ }
61
+
62
+ return branch;
63
+ } catch (error) {
64
+ reply.code(500).send({ error: error.message });
65
+ }
66
+ });
67
+
68
+ /**
69
+ * Create a new branch
70
+ *
71
+ * RULE: Every branch must be created from an existing branch.
72
+ * No empty databases. No undefined states.
73
+ */
74
+ fastify.post('/create', async (request, reply) => {
75
+ const { name, description, baseBranch } = request.body;
76
+ const metaDb = fastify.getMetaDb();
77
+ const currentBranch = getCurrentBranch(fastify.projectPath);
78
+
79
+ // Validate branch name
80
+ if (!name || !/^[a-zA-Z0-9_/-]+$/.test(name)) {
81
+ return reply.code(400).send({
82
+ error: 'Invalid branch name. Use only letters, numbers, hyphens, slashes, and underscores.'
83
+ });
84
+ }
85
+
86
+ // Base branch is REQUIRED - no exceptions
87
+ if (!baseBranch) {
88
+ return reply.code(400).send({
89
+ error: 'Base branch is required. Every branch must be created from an existing branch.'
90
+ });
91
+ }
92
+
93
+ // Check if branch already exists
94
+ const existingBranch = metaDb.prepare(`
95
+ SELECT name FROM branches WHERE name = ?
96
+ `).get(name);
97
+
98
+ if (existingBranch) {
99
+ return reply.code(400).send({ error: 'Branch already exists' });
100
+ }
101
+
102
+ try {
103
+ // Get source branch DB file
104
+ const sourceInfo = metaDb.prepare(`
105
+ SELECT db_file FROM branches WHERE name = ?
106
+ `).get(baseBranch);
107
+
108
+ if (!sourceInfo) {
109
+ return reply.code(404).send({ error: `Base branch "${baseBranch}" not found` });
110
+ }
111
+
112
+ const sourceDbPath = join(fastify.projectPath, sourceInfo.db_file);
113
+ // Sanitize branch name for filename: replace slashes with hyphens
114
+ const safeDbName = name.replace(/\//g, '-');
115
+ const newDbFile = `${safeDbName}.db.sqlite`;
116
+ const newDbPath = join(fastify.projectPath, newDbFile);
117
+
118
+ // CRITICAL: Checkpoint WAL to ensure clean snapshot
119
+ // This freezes the base branch state at this exact moment
120
+ try {
121
+ const sourceDb = fastify.getUserDb(baseBranch);
122
+ sourceDb.pragma('wal_checkpoint(TRUNCATE)');
123
+ } catch (walError) {
124
+ console.warn('WAL checkpoint warning:', walError.message);
125
+ }
126
+
127
+ // Copy database file (full snapshot)
128
+ copyFileSync(sourceDbPath, newDbPath);
129
+
130
+ // Copy WAL and SHM files if they exist (though checkpoint should have cleared them)
131
+ ['.wal', '.shm'].forEach(ext => {
132
+ const sourceWalPath = sourceDbPath + ext;
133
+ const newWalPath = newDbPath + ext;
134
+ if (existsSync(sourceWalPath)) {
135
+ copyFileSync(sourceWalPath, newWalPath);
136
+ }
137
+ });
138
+
139
+ // Get snapshot metadata (file size, etc.)
140
+ const { statSync } = await import('fs');
141
+ const dbStats = statSync(newDbPath);
142
+ const snapshotTime = new Date().toISOString();
143
+
144
+ // Ensure snapshots directory exists
145
+ const snapshotsDir = join(fastify.projectPath, '.studio', 'snapshots');
146
+ if (!existsSync(snapshotsDir)) {
147
+ mkdirSync(snapshotsDir, { recursive: true });
148
+ }
149
+
150
+ // Create branch record with full metadata
151
+ metaDb.prepare(`
152
+ INSERT INTO branches (name, db_file, created_from, description)
153
+ VALUES (?, ?, ?, ?)
154
+ `).run(name, newDbFile, baseBranch, description || '');
155
+
156
+ // Create implicit snapshot for this branch creation
157
+ // This gives us traceability and restore capability
158
+ const snapshotFilename = `${name}-creation-${Date.now()}.snapshot.db`;
159
+ copyFileSync(newDbPath, join(fastify.projectPath, '.studio', 'snapshots', snapshotFilename));
160
+
161
+ metaDb.prepare(`
162
+ INSERT INTO snapshots (branch, filename, name, size, description)
163
+ VALUES (?, ?, ?, ?, ?)
164
+ `).run(name, snapshotFilename, 'Branch Creation', dbStats.size, `Initial state copied from ${baseBranch}`);
165
+
166
+ // Log event in BOTH branches for full traceability
167
+
168
+ // Event in source branch: "I was the base for a new branch"
169
+ metaDb.prepare(`
170
+ INSERT INTO events (branch, type, data)
171
+ VALUES (?, 'branch_created_from_here', ?)
172
+ `).run(baseBranch, JSON.stringify({
173
+ new_branch: name,
174
+ created_at: snapshotTime
175
+ }));
176
+
177
+ // Event in new branch: "I was created from a base branch"
178
+ metaDb.prepare(`
179
+ INSERT INTO events (branch, type, data)
180
+ VALUES (?, 'branch_created', ?)
181
+ `).run(name, JSON.stringify({
182
+ base_branch: baseBranch,
183
+ created_at: snapshotTime
184
+ }));
185
+
186
+ return {
187
+ success: true,
188
+ branch: {
189
+ name,
190
+ db_file: newDbFile,
191
+ created_from: baseBranch,
192
+ created_at: snapshotTime,
193
+ snapshot_created: true
194
+ }
195
+ };
196
+ } catch (error) {
197
+ reply.code(500).send({ error: error.message });
198
+ }
199
+ });
200
+
201
+ /**
202
+ * Switch to a different branch
203
+ */
204
+ fastify.post('/switch', async (request, reply) => {
205
+ const { name } = request.body;
206
+ const metaDb = fastify.getMetaDb();
207
+ const currentBranch = getCurrentBranch(fastify.projectPath);
208
+
209
+ if (!name) {
210
+ return reply.code(400).send({ error: 'Branch name is required' });
211
+ }
212
+
213
+ if (currentBranch === name) {
214
+ return { success: true, message: 'Already on this branch' };
215
+ }
216
+
217
+ try {
218
+ // Check if target branch exists
219
+ const targetBranch = metaDb.prepare(`
220
+ SELECT * FROM branches WHERE name = ?
221
+ `).get(name);
222
+
223
+ if (!targetBranch) {
224
+ return reply.code(404).send({ error: 'Branch not found' });
225
+ }
226
+
227
+ // Close current branch connection
228
+ closeBranchConnection(fastify.projectPath, currentBranch);
229
+
230
+ // Update current branch
231
+ metaDb.prepare(`
232
+ INSERT OR REPLACE INTO settings (key, value)
233
+ VALUES ('current_branch', ?)
234
+ `).run(name);
235
+
236
+ // Log switch event in NEW branch
237
+ metaDb.prepare(`
238
+ INSERT INTO events (branch, type, data)
239
+ VALUES (?, 'branch_switched', ?)
240
+ `).run(name, JSON.stringify({
241
+ from: currentBranch,
242
+ to: name
243
+ }));
244
+
245
+ return {
246
+ success: true,
247
+ previous: currentBranch,
248
+ current: name
249
+ };
250
+ } catch (error) {
251
+ reply.code(500).send({ error: error.message });
252
+ }
253
+ });
254
+
255
+ /**
256
+ * Delete a branch
257
+ */
258
+ fastify.delete('/:name', async (request, reply) => {
259
+ const { name } = request.params;
260
+ const metaDb = fastify.getMetaDb();
261
+ const currentBranch = getCurrentBranch(fastify.projectPath);
262
+
263
+ // Cannot delete main branch
264
+ if (name === 'main') {
265
+ return reply.code(400).send({ error: 'Cannot delete main branch' });
266
+ }
267
+
268
+ // Cannot delete current branch
269
+ if (name === currentBranch) {
270
+ return reply.code(400).send({
271
+ error: 'Cannot delete current branch. Switch to another branch first.'
272
+ });
273
+ }
274
+
275
+ try {
276
+ const branch = metaDb.prepare(`
277
+ SELECT * FROM branches WHERE name = ?
278
+ `).get(name);
279
+
280
+ if (!branch) {
281
+ return reply.code(404).send({ error: 'Branch not found' });
282
+ }
283
+
284
+ // Close connection if exists
285
+ closeBranchConnection(fastify.projectPath, name);
286
+
287
+ // Delete branch record (cascade will handle related data)
288
+ metaDb.prepare(`
289
+ DELETE FROM branches WHERE name = ?
290
+ `).run(name);
291
+
292
+ // Delete related data
293
+ metaDb.prepare(`DELETE FROM migrations WHERE branch = ?`).run(name);
294
+ metaDb.prepare(`DELETE FROM events WHERE branch = ?`).run(name);
295
+ metaDb.prepare(`DELETE FROM snapshots WHERE branch = ?`).run(name);
296
+
297
+ // Note: We don't delete the DB file for safety
298
+ // Users can manually delete if needed
299
+
300
+ // Log event in current branch
301
+ metaDb.prepare(`
302
+ INSERT INTO events (branch, type, data)
303
+ VALUES (?, 'branch_deleted', ?)
304
+ `).run(currentBranch, JSON.stringify({ branch: name }));
305
+
306
+ return { success: true };
307
+ } catch (error) {
308
+ reply.code(500).send({ error: error.message });
309
+ }
310
+ });
311
+
312
+ /**
313
+ * Get branch statistics (migrations applied, snapshots, etc.)
314
+ */
315
+ fastify.get('/:name/stats', async (request, reply) => {
316
+ const { name } = request.params;
317
+ const metaDb = fastify.getMetaDb();
318
+
319
+ try {
320
+ const branch = metaDb.prepare(`
321
+ SELECT * FROM branches WHERE name = ?
322
+ `).get(name);
323
+
324
+ if (!branch) {
325
+ return reply.code(404).send({ error: 'Branch not found' });
326
+ }
327
+
328
+ const migrationsCount = metaDb.prepare(`
329
+ SELECT COUNT(*) as count FROM migrations WHERE branch = ?
330
+ `).get(name);
331
+
332
+ const snapshotsCount = metaDb.prepare(`
333
+ SELECT COUNT(*) as count FROM snapshots WHERE branch = ?
334
+ `).get(name);
335
+
336
+ const eventsCount = metaDb.prepare(`
337
+ SELECT COUNT(*) as count FROM events WHERE branch = ?
338
+ `).get(name);
339
+
340
+ return {
341
+ ...branch,
342
+ stats: {
343
+ migrations_applied: migrationsCount.count,
344
+ snapshots: snapshotsCount.count,
345
+ events: eventsCount.count
346
+ }
347
+ };
348
+ } catch (error) {
349
+ reply.code(500).send({ error: error.message });
350
+ }
351
+ });
352
+
353
+ /**
354
+ * Promote a branch to another branch
355
+ *
356
+ * Philosophy: Promote states, not scripts.
357
+ * This replaces the target branch DB with the source branch DB.
358
+ */
359
+ fastify.post('/promote', async (request, reply) => {
360
+ const { sourceBranch, targetBranch, createSnapshot = true } = request.body;
361
+ const metaDb = fastify.getMetaDb();
362
+ const currentBranch = getCurrentBranch(fastify.projectPath);
363
+
364
+ // Validation
365
+ if (!sourceBranch || !targetBranch) {
366
+ return reply.code(400).send({ error: 'Source and target branches required' });
367
+ }
368
+
369
+ if (sourceBranch === targetBranch) {
370
+ return reply.code(400).send({ error: 'Source and target must be different' });
371
+ }
372
+
373
+ try {
374
+ // Get both branch info
375
+ const source = metaDb.prepare(`
376
+ SELECT * FROM branches WHERE name = ?
377
+ `).get(sourceBranch);
378
+
379
+ const target = metaDb.prepare(`
380
+ SELECT * FROM branches WHERE name = ?
381
+ `).get(targetBranch);
382
+
383
+ if (!source) {
384
+ return reply.code(404).send({ error: `Source branch "${sourceBranch}" not found` });
385
+ }
386
+
387
+ if (!target) {
388
+ return reply.code(404).send({ error: `Target branch "${targetBranch}" not found` });
389
+ }
390
+
391
+ const sourceDbPath = join(fastify.projectPath, source.db_file);
392
+ const targetDbPath = join(fastify.projectPath, target.db_file);
393
+
394
+ // WAL checkpoint on both branches
395
+ try {
396
+ const sourceDb = fastify.getUserDb(sourceBranch);
397
+ const targetDb = fastify.getUserDb(targetBranch);
398
+ sourceDb.pragma('wal_checkpoint(TRUNCATE)');
399
+ targetDb.pragma('wal_checkpoint(TRUNCATE)');
400
+ } catch (walError) {
401
+ console.warn('WAL checkpoint warning:', walError.message);
402
+ }
403
+
404
+ // Close connections before file operations
405
+ closeBranchConnection(fastify.projectPath, sourceBranch);
406
+ closeBranchConnection(fastify.projectPath, targetBranch);
407
+
408
+ // Create snapshot of target before promotion (if requested)
409
+ let snapshotId = null;
410
+ if (createSnapshot) {
411
+ const snapshotsDir = join(fastify.projectPath, '.studio', 'snapshots');
412
+ if (!existsSync(snapshotsDir)) {
413
+ mkdirSync(snapshotsDir, { recursive: true });
414
+ }
415
+
416
+ const snapshotFilename = `${targetBranch}-pre-promote-${Date.now()}.snapshot.db`;
417
+ const snapshotPath = join(snapshotsDir, snapshotFilename);
418
+ copyFileSync(targetDbPath, snapshotPath);
419
+
420
+ const { statSync } = await import('fs');
421
+ const snapshotStats = statSync(snapshotPath);
422
+
423
+ const result = metaDb.prepare(`
424
+ INSERT INTO snapshots (branch, filename, name, size, description, type)
425
+ VALUES (?, ?, ?, ?, ?, ?)
426
+ `).run(
427
+ targetBranch,
428
+ snapshotFilename,
429
+ `Before promoting ${sourceBranch}`,
430
+ snapshotStats.size,
431
+ `Automatic snapshot before promoting ${sourceBranch} to ${targetBranch}`,
432
+ 'auto-before-promote'
433
+ );
434
+
435
+ snapshotId = result.lastInsertRowid;
436
+ }
437
+
438
+ // Replace target DB with source DB
439
+ copyFileSync(sourceDbPath, targetDbPath);
440
+
441
+ // Also copy WAL and SHM files
442
+ ['.wal', '.shm'].forEach(ext => {
443
+ const sourceWalPath = sourceDbPath + ext;
444
+ const targetWalPath = targetDbPath + ext;
445
+ if (existsSync(sourceWalPath)) {
446
+ copyFileSync(sourceWalPath, targetWalPath);
447
+ }
448
+ });
449
+
450
+ const promotionTime = new Date().toISOString();
451
+
452
+ // Log promotion event in target branch
453
+ metaDb.prepare(`
454
+ INSERT INTO events (branch, type, data)
455
+ VALUES (?, 'branch_promoted', ?)
456
+ `).run(targetBranch, JSON.stringify({
457
+ source: sourceBranch,
458
+ target: targetBranch,
459
+ promoted_at: promotionTime,
460
+ snapshot_id: snapshotId
461
+ }));
462
+
463
+ // Log in source branch that it was promoted
464
+ metaDb.prepare(`
465
+ INSERT INTO events (branch, type, data)
466
+ VALUES (?, 'branch_promoted_from_here', ?)
467
+ `).run(sourceBranch, JSON.stringify({
468
+ source: sourceBranch,
469
+ target: targetBranch,
470
+ promoted_at: promotionTime
471
+ }));
472
+
473
+ return {
474
+ success: true,
475
+ source: sourceBranch,
476
+ target: targetBranch,
477
+ snapshot_created: createSnapshot,
478
+ snapshot_id: snapshotId
479
+ };
480
+ } catch (error) {
481
+ reply.code(500).send({ error: error.message });
482
+ }
483
+ });
484
+ }
@@ -0,0 +1,109 @@
1
+ import { getReadOnlyUserDb, closeReadOnlyBranchConnection, getUserDb } from '../db/connections.js';
2
+
3
+ function stripSqlComments(sql) {
4
+ const withoutBlock = sql.replace(/\/\*[\s\S]*?\*\//g, ' ');
5
+ return withoutBlock.replace(/--.*$/gm, ' ');
6
+ }
7
+
8
+ function hasForbiddenKeyword(sql) {
9
+ const forbidden = /(\bINSERT\b|\bUPDATE\b|\bDELETE\b|\bALTER\b|\bDROP\b|\bCREATE\b|\bTRUNCATE\b|\bATTACH\b)/i;
10
+ return forbidden.test(sql);
11
+ }
12
+
13
+ function shouldInjectLimit(sql) {
14
+ return !/\blimit\b/i.test(sql);
15
+ }
16
+
17
+ function injectLimit(sql, limit) {
18
+ const trimmed = sql.trim();
19
+ const hasSemicolon = trimmed.endsWith(';');
20
+ const base = hasSemicolon ? trimmed.slice(0, -1) : trimmed;
21
+ return `${base} LIMIT ${limit}${hasSemicolon ? ';' : ''}`;
22
+ }
23
+
24
+ function getStatementType(sql) {
25
+ const cleaned = stripSqlComments(sql).trim();
26
+ const match = cleaned.match(/^(\w+)/i);
27
+ return match ? match[1].toUpperCase() : '';
28
+ }
29
+
30
+ export default async function compareRoutes(fastify) {
31
+ // Force WAL checkpoint for branches before compare mode
32
+ fastify.post('/checkpoint', async (request, reply) => {
33
+ const { branches } = request.body || {};
34
+ if (!Array.isArray(branches) || branches.length === 0) {
35
+ return reply.code(400).send({ error: 'branches is required' });
36
+ }
37
+
38
+ try {
39
+ branches.forEach((branch) => {
40
+ const db = getUserDb(fastify.projectPath, branch);
41
+ db.pragma('wal_checkpoint(TRUNCATE)');
42
+ });
43
+ return { success: true };
44
+ } catch (error) {
45
+ return reply.code(500).send({ error: error.message });
46
+ }
47
+ });
48
+
49
+ // Execute a read-only compare query against a branch
50
+ fastify.post('/query', async (request, reply) => {
51
+ const { sql, branch } = request.body || {};
52
+
53
+ if (!branch) {
54
+ return reply.code(400).send({ error: 'branch is required' });
55
+ }
56
+
57
+ if (!sql || sql.trim() === '') {
58
+ return reply.code(400).send({ error: 'SQL query is required' });
59
+ }
60
+
61
+ const cleaned = stripSqlComments(sql);
62
+ if (hasForbiddenKeyword(cleaned)) {
63
+ return reply.code(400).send({ error: 'Write operations are disabled in Compare Mode' });
64
+ }
65
+
66
+ const statementType = getStatementType(cleaned);
67
+ const allowed = ['SELECT', 'PRAGMA', 'EXPLAIN', 'WITH'];
68
+ if (!allowed.includes(statementType)) {
69
+ return reply.code(400).send({ error: 'Write operations are disabled in Compare Mode' });
70
+ }
71
+
72
+ const startTime = Date.now();
73
+
74
+ try {
75
+ const db = getReadOnlyUserDb(fastify.projectPath, branch);
76
+ let sqlToRun = sql;
77
+
78
+ if ((statementType === 'SELECT' || statementType === 'WITH') && shouldInjectLimit(sql)) {
79
+ sqlToRun = injectLimit(sql, 500);
80
+ }
81
+
82
+ const rows = db.prepare(sqlToRun).all();
83
+ return {
84
+ type: 'select',
85
+ rows,
86
+ executionTime: Date.now() - startTime
87
+ };
88
+ } catch (error) {
89
+ return reply.code(400).send({ error: error.message, sql });
90
+ }
91
+ });
92
+
93
+ // Close compare connections
94
+ fastify.post('/close', async (request, reply) => {
95
+ const { branches } = request.body || {};
96
+ if (!Array.isArray(branches) || branches.length === 0) {
97
+ return reply.code(400).send({ error: 'branches is required' });
98
+ }
99
+
100
+ try {
101
+ branches.forEach((branch) => {
102
+ closeReadOnlyBranchConnection(fastify.projectPath, branch);
103
+ });
104
+ return { success: true };
105
+ } catch (error) {
106
+ return reply.code(500).send({ error: error.message });
107
+ }
108
+ });
109
+ }