Skip to content

Commit 826ba94

Browse files
committed
♻️ enforce sqlite single-writer state and bootstrap legacy migration
Introduce explicit read/write store modes, enforce a single writer path, and add one-time legacy JSON bootstrap on CLI startup.\n\nAlso updates routers/services/tests for read-mode access, resets in-memory TDD runtime state on baseline reset, and adds migration/mode contract coverage.
1 parent 6a9c32e commit 826ba94

25 files changed

Lines changed: 568 additions & 105 deletions

src/cli.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import {
5353
generateStaticReport,
5454
getReportFileUrl,
5555
} from './services/static-report-generator.js';
56+
import { bootstrapLegacyStateIfNeeded } from './tdd/state-store.js';
5657
import { openBrowser } from './utils/browser.js';
5758
import { colors } from './utils/colors.js';
5859
import { loadConfig } from './utils/config-loader.js';
@@ -331,6 +332,11 @@ output.configure({
331332
json: jsonArg,
332333
});
333334

335+
bootstrapLegacyStateIfNeeded({
336+
workingDir: process.cwd(),
337+
output,
338+
});
339+
334340
const config = await loadConfig(configPath, {});
335341
const services = createServices(config);
336342
const pluginServices = createPluginServices(services);

src/server/handlers/tdd-handler.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ export const createTddHandler = (
202202
createStateStore({
203203
workingDir,
204204
output,
205+
mode: 'write',
205206
});
206207

207208
/**
@@ -736,6 +737,11 @@ export const createTddHandler = (
736737
// Clear state store data entirely - fresh start
737738
stateStore.resetReportData();
738739

740+
// Reset in-memory TDD runtime caches so the current process is also fresh.
741+
if (typeof tddService.resetRuntimeState === 'function') {
742+
tddService.resetRuntimeState();
743+
}
744+
739745
output.info(
740746
`Baselines reset - ${deletedBaselines} baselines deleted, ${deletedCurrents} current screenshots deleted, ${deletedDiffs} diffs deleted`
741747
);

src/server/routers/dashboard.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export function createDashboardRouter(context) {
2626

2727
// API endpoint for fetching report data
2828
if (pathname === '/api/report-data') {
29-
let stateStore = createStateStore({ workingDir, output });
29+
let stateStore = createStateStore({ workingDir, output, mode: 'read' });
3030
try {
3131
let data = stateStore.readReportData();
3232
if (!data) {
@@ -58,7 +58,7 @@ export function createDashboardRouter(context) {
5858
return true;
5959
}
6060

61-
let stateStore = createStateStore({ workingDir, output });
61+
let stateStore = createStateStore({ workingDir, output, mode: 'read' });
6262
try {
6363
let reportData = stateStore.readReportData();
6464
if (!reportData) {
@@ -94,7 +94,7 @@ export function createDashboardRouter(context) {
9494
if (SPA_ROUTES.includes(pathname) || pathname.startsWith('/comparison/')) {
9595
let reportData = null;
9696

97-
let stateStore = createStateStore({ workingDir, output });
97+
let stateStore = createStateStore({ workingDir, output, mode: 'read' });
9898
try {
9999
reportData = stateStore.readReportData();
100100
if (reportData) {

src/server/routers/events.js

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,19 @@ export function createEventsRouter(context) {
1717
/**
1818
* Read and parse report data with baseline metadata included
1919
*/
20-
let readReportData = stateStore => {
21-
let data = stateStore.readReportData();
22-
if (!data) {
23-
return null;
24-
}
20+
let readReportData = () => {
21+
let snapshotStore = createStateStore({ workingDir, mode: 'read' });
22+
try {
23+
let data = snapshotStore.readReportData();
24+
if (!data) {
25+
return null;
26+
}
2527

26-
data.baseline = stateStore.getBaselineMetadata();
27-
return data;
28+
data.baseline = snapshotStore.getBaselineMetadata();
29+
return data;
30+
} finally {
31+
snapshotStore.close();
32+
}
2833
};
2934

3035
/**
@@ -111,7 +116,7 @@ export function createEventsRouter(context) {
111116
return false;
112117
}
113118

114-
let stateStore = createStateStore({ workingDir });
119+
let subscriptionStore = createStateStore({ workingDir, mode: 'read' });
115120

116121
// Set SSE headers
117122
res.writeHead(200, {
@@ -122,7 +127,7 @@ export function createEventsRouter(context) {
122127
});
123128

124129
// Send initial full data immediately
125-
let lastSentData = readReportData(stateStore);
130+
let lastSentData = readReportData();
126131
if (lastSentData) {
127132
sendEvent(res, 'reportData', lastSentData);
128133
}
@@ -131,7 +136,7 @@ export function createEventsRouter(context) {
131136
let updateQueued = false;
132137

133138
let sendUpdate = () => {
134-
let newData = readReportData(stateStore);
139+
let newData = readReportData();
135140
if (!newData) return;
136141

137142
if (!lastSentData) {
@@ -159,7 +164,7 @@ export function createEventsRouter(context) {
159164
});
160165
};
161166

162-
let unsubscribe = stateStore.subscribe(queueUpdate);
167+
let unsubscribe = subscriptionStore.subscribe(queueUpdate);
163168

164169
// Heartbeat to keep connection alive (every 30 seconds)
165170
let heartbeatInterval = setInterval(() => {
@@ -173,7 +178,7 @@ export function createEventsRouter(context) {
173178
closed = true;
174179
clearInterval(heartbeatInterval);
175180
unsubscribe();
176-
stateStore.close();
181+
subscriptionStore.close();
177182
};
178183

179184
req.on('close', cleanup);

src/server/routers/health.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export function createHealthRouter({
2626

2727
let reportData = null;
2828
let baselineInfo = null;
29-
let stateStore = createStateStore({ workingDir });
29+
let stateStore = createStateStore({ workingDir, mode: 'read' });
3030
try {
3131
reportData = stateStore.readReportData();
3232
baselineInfo = stateStore.getBaselineMetadata();

src/services/static-report-generator.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ export async function generateStaticReport(workingDir, options = {}) {
157157
try {
158158
let reportData = null;
159159
let baselineMetadata = null;
160-
let stateStore = createStateStore({ workingDir });
160+
let stateStore = createStateStore({ workingDir, mode: 'read' });
161161
try {
162162
reportData = stateStore.readReportData();
163163
baselineMetadata = stateStore.getBaselineMetadata();

src/tdd/metadata/baseline-metadata.js

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ function resolveWorkingDirFromBaselinePath(baselinePath) {
2121
return resolvedPath;
2222
}
2323

24-
function withStateStore(workingDir, operation) {
25-
let store = createStateStore({ workingDir });
24+
function withStateStore(workingDir, mode, operation) {
25+
let store = createStateStore({ workingDir, mode });
2626

2727
try {
2828
return operation(store);
@@ -37,10 +37,11 @@ function withStateStore(workingDir, operation) {
3737
* @param {string} baselinePath - Path to baselines directory
3838
* @returns {Object|null} Baseline metadata or null if not found
3939
*/
40-
export function loadBaselineMetadata(baselinePath) {
40+
export function loadBaselineMetadata(baselinePath, options = {}) {
41+
let { mode = 'read' } = options;
4142
let workingDir = resolveWorkingDirFromBaselinePath(baselinePath);
4243

43-
return withStateStore(workingDir, store => {
44+
return withStateStore(workingDir, mode, store => {
4445
try {
4546
return store.getBaselineMetadata();
4647
} catch (error) {
@@ -59,7 +60,7 @@ export function loadBaselineMetadata(baselinePath) {
5960
export function saveBaselineMetadata(baselinePath, metadata) {
6061
let workingDir = resolveWorkingDirFromBaselinePath(baselinePath);
6162

62-
withStateStore(workingDir, store => {
63+
withStateStore(workingDir, 'write', store => {
6364
store.setBaselineMetadata(metadata);
6465
});
6566
}
@@ -70,8 +71,10 @@ export function saveBaselineMetadata(baselinePath, metadata) {
7071
* @param {string} workingDir - Working directory containing .vizzly
7172
* @returns {Object|null} Baseline build metadata or null
7273
*/
73-
export function loadBaselineBuildMetadata(workingDir) {
74-
return withStateStore(workingDir, store => {
74+
export function loadBaselineBuildMetadata(workingDir, options = {}) {
75+
let { mode = 'read' } = options;
76+
77+
return withStateStore(workingDir, mode, store => {
7578
try {
7679
return store.getBaselineBuildMetadata();
7780
} catch (error) {
@@ -90,7 +93,7 @@ export function loadBaselineBuildMetadata(workingDir) {
9093
* @param {Object} metadata - Metadata object to save
9194
*/
9295
export function saveBaselineBuildMetadata(workingDir, metadata) {
93-
withStateStore(workingDir, store => {
96+
withStateStore(workingDir, 'write', store => {
9497
store.setBaselineBuildMetadata(metadata);
9598
});
9699
}

src/tdd/metadata/hotspot-metadata.js

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88

99
import { createStateStore } from '../state-store.js';
1010

11-
function withStateStore(workingDir, operation) {
12-
let store = createStateStore({ workingDir });
11+
function withStateStore(workingDir, mode, operation) {
12+
let store = createStateStore({ workingDir, mode });
1313

1414
try {
1515
return operation(store);
@@ -24,8 +24,10 @@ function withStateStore(workingDir, operation) {
2424
* @param {string} workingDir - Working directory containing .vizzly folder
2525
* @returns {Object|null} Hotspot data keyed by screenshot name, or null if not found
2626
*/
27-
export function loadHotspotMetadata(workingDir) {
28-
return withStateStore(workingDir, store => {
27+
export function loadHotspotMetadata(workingDir, options = {}) {
28+
let { mode = 'read' } = options;
29+
30+
return withStateStore(workingDir, mode, store => {
2931
try {
3032
return store.getHotspotMetadata();
3133
} catch {
@@ -42,7 +44,7 @@ export function loadHotspotMetadata(workingDir) {
4244
* @param {Object} summary - Summary information about the hotspots
4345
*/
4446
export function saveHotspotMetadata(workingDir, hotspotData, summary = {}) {
45-
withStateStore(workingDir, store => {
47+
withStateStore(workingDir, 'write', store => {
4648
store.setHotspotMetadata(hotspotData, summary);
4749
});
4850
}

src/tdd/metadata/region-metadata.js

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77

88
import { createStateStore } from '../state-store.js';
99

10-
function withStateStore(workingDir, operation) {
11-
let store = createStateStore({ workingDir });
10+
function withStateStore(workingDir, mode, operation) {
11+
let store = createStateStore({ workingDir, mode });
1212

1313
try {
1414
return operation(store);
@@ -23,8 +23,10 @@ function withStateStore(workingDir, operation) {
2323
* @param {string} workingDir - Working directory containing .vizzly folder
2424
* @returns {Object|null} Region data keyed by screenshot name, or null if not found
2525
*/
26-
export function loadRegionMetadata(workingDir) {
27-
return withStateStore(workingDir, store => {
26+
export function loadRegionMetadata(workingDir, options = {}) {
27+
let { mode = 'read' } = options;
28+
29+
return withStateStore(workingDir, mode, store => {
2830
try {
2931
return store.getRegionMetadata();
3032
} catch {
@@ -41,7 +43,7 @@ export function loadRegionMetadata(workingDir) {
4143
* @param {Object} summary - Summary information about the regions
4244
*/
4345
export function saveRegionMetadata(workingDir, regionData, summary = {}) {
44-
withStateStore(workingDir, store => {
46+
withStateStore(workingDir, 'write', store => {
4547
store.setRegionMetadata(regionData, summary);
4648
});
4749
}

src/tdd/state-store.js

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
* SQLite is the only supported backend.
77
*/
88

9+
import { existsSync } from 'node:fs';
10+
import { join } from 'node:path';
911
import { STATE_METADATA_KEYS } from './state-store/constants.js';
1012
import {
1113
createSqliteStateStore,
@@ -14,6 +16,60 @@ import {
1416

1517
export { STATE_METADATA_KEYS, createSqliteStateStore, getStateDbPath };
1618

17-
export function createStateStore(options) {
19+
export function createStateStore(options = {}) {
1820
return createSqliteStateStore(options);
1921
}
22+
23+
let LEGACY_STATE_FILES = [
24+
'report-data.json',
25+
'comparison-details.json',
26+
join('baselines', 'metadata.json'),
27+
'hotspots.json',
28+
'regions.json',
29+
'baseline-metadata.json',
30+
];
31+
32+
/**
33+
* Bootstrap legacy JSON state into SQLite once, the first time CLI runs
34+
* in a project that has legacy files and no state.db yet.
35+
*
36+
* @returns {boolean} true when bootstrap migration was executed
37+
*/
38+
export function bootstrapLegacyStateIfNeeded(options = {}) {
39+
let {
40+
workingDir = process.cwd(),
41+
output = {},
42+
createStore = createStateStore,
43+
fs = {},
44+
joinPath = join,
45+
} = options;
46+
47+
let { existsSync: existsSyncImpl = existsSync } = fs;
48+
let vizzlyDir = joinPath(workingDir, '.vizzly');
49+
let stateDbPath = joinPath(vizzlyDir, 'state.db');
50+
51+
if (!existsSyncImpl(vizzlyDir) || existsSyncImpl(stateDbPath)) {
52+
return false;
53+
}
54+
55+
let hasLegacyFiles = LEGACY_STATE_FILES.some(relativePath =>
56+
existsSyncImpl(joinPath(vizzlyDir, relativePath))
57+
);
58+
59+
if (!hasLegacyFiles) {
60+
return false;
61+
}
62+
63+
try {
64+
let store = createStore({ workingDir, output, mode: 'write' });
65+
store.close();
66+
output.debug?.('state', 'bootstrapped legacy JSON state to SQLite');
67+
return true;
68+
} catch (error) {
69+
output.debug?.(
70+
'state',
71+
`legacy bootstrap migration skipped: ${error.message}`
72+
);
73+
return false;
74+
}
75+
}

0 commit comments

Comments
 (0)