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.
- package/package.json +11 -3
- package/server/db/connections.js +114 -0
- package/server/db/meta-db.js +0 -0
- package/server/db/user-db.js +0 -0
- package/server/index.js +214 -0
- package/server/routes/branches.js +484 -0
- package/server/routes/compare.js +109 -0
- package/server/routes/export.js +201 -0
- package/server/routes/import.js +375 -0
- package/server/routes/migrations.js +332 -0
- package/server/routes/query.js +67 -0
- package/server/routes/schema.js +206 -0
- package/server/routes/snapshots.js +322 -0
- package/server/routes/tables.js +121 -0
- package/server/routes/timeline.js +108 -0
- package/server/server.js +0 -0
- package/src/commands/import-server.js +2 -5
- package/src/commands/import.js +5 -9
- package/src/commands/start.js +6 -7
- package/src/commands/stop.js +7 -2
- package/src/utils/paths.js +61 -1
- package/studio-out/404/index.html +1 -0
- package/studio-out/404.html +1 -0
- package/studio-out/__next.__PAGE__.txt +10 -0
- package/studio-out/__next._full.txt +20 -0
- package/studio-out/__next._head.txt +6 -0
- package/studio-out/__next._index.txt +6 -0
- package/studio-out/__next._tree.txt +3 -0
- package/studio-out/_next/static/LhecVBdPttfi1VZfXA-dL/_buildManifest.js +11 -0
- package/studio-out/_next/static/LhecVBdPttfi1VZfXA-dL/_clientMiddlewareManifest.json +1 -0
- package/studio-out/_next/static/LhecVBdPttfi1VZfXA-dL/_ssgManifest.js +1 -0
- package/studio-out/_next/static/chunks/118fc599da2f27aa.css +2 -0
- package/studio-out/_next/static/chunks/240f2fa81d4fb687.js +1 -0
- package/studio-out/_next/static/chunks/42c33ca704af9b68.js +1 -0
- package/studio-out/_next/static/chunks/99b69e65b599be96.js +5 -0
- package/studio-out/_next/static/chunks/a6dad97d9634a72d.js +1 -0
- package/studio-out/_next/static/chunks/a6dad97d9634a72d.js.map +1 -0
- package/studio-out/_next/static/chunks/b20313408e970968.css +1 -0
- package/studio-out/_next/static/chunks/d104f42a7b0c57b2.js +2 -0
- package/studio-out/_next/static/chunks/d4aa9be9c80c98d6.js +1 -0
- package/studio-out/_next/static/chunks/f2f58a7e93290fbb.js +1 -0
- package/studio-out/_next/static/chunks/f547e106c8e2aa8e.js +1 -0
- package/studio-out/_next/static/chunks/f5cb054219e2eeb8.js +109 -0
- package/studio-out/_next/static/chunks/turbopack-1577480078e795df.js +4 -0
- package/studio-out/_not-found/__next._full.txt +15 -0
- package/studio-out/_not-found/__next._head.txt +6 -0
- package/studio-out/_not-found/__next._index.txt +6 -0
- package/studio-out/_not-found/__next._not-found/__PAGE__.txt +5 -0
- package/studio-out/_not-found/__next._not-found.txt +4 -0
- package/studio-out/_not-found/__next._tree.txt +2 -0
- package/studio-out/_not-found/index.html +1 -0
- package/studio-out/_not-found/index.txt +15 -0
- package/studio-out/favicon.ico +10 -0
- package/studio-out/index.html +37 -0
- package/studio-out/index.txt +20 -0
- package/studio-out/logo.svg +5 -0
- package/studio-out/snapshots/__next._full.txt +15 -0
- package/studio-out/snapshots/__next._head.txt +6 -0
- package/studio-out/snapshots/__next._index.txt +6 -0
- package/studio-out/snapshots/__next._tree.txt +2 -0
- package/studio-out/snapshots/__next.snapshots/__PAGE__.txt +5 -0
- package/studio-out/snapshots/__next.snapshots.txt +4 -0
- package/studio-out/snapshots/index.html +1 -0
- package/studio-out/snapshots/index.txt +15 -0
- package/studio-out/sql/__next._full.txt +15 -0
- package/studio-out/sql/__next._head.txt +6 -0
- package/studio-out/sql/__next._index.txt +6 -0
- package/studio-out/sql/__next._tree.txt +2 -0
- package/studio-out/sql/__next.sql/__PAGE__.txt +5 -0
- package/studio-out/sql/__next.sql.txt +4 -0
- package/studio-out/sql/index.html +1 -0
- package/studio-out/sql/index.txt +15 -0
- package/studio-out/tables/__next._full.txt +15 -0
- package/studio-out/tables/__next._head.txt +6 -0
- package/studio-out/tables/__next._index.txt +6 -0
- package/studio-out/tables/__next._tree.txt +2 -0
- package/studio-out/tables/__next.tables/__PAGE__.txt +5 -0
- package/studio-out/tables/__next.tables.txt +4 -0
- package/studio-out/tables/index.html +1 -0
- package/studio-out/tables/index.txt +15 -0
- package/studio-out/timeline/__next._full.txt +15 -0
- package/studio-out/timeline/__next._head.txt +6 -0
- package/studio-out/timeline/__next._index.txt +6 -0
- package/studio-out/timeline/__next._tree.txt +2 -0
- package/studio-out/timeline/__next.timeline/__PAGE__.txt +5 -0
- package/studio-out/timeline/__next.timeline.txt +4 -0
- package/studio-out/timeline/index.html +1 -0
- 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
|
+
}
|