Skip to content

Commit 0922a7b

Browse files
exKAZUuWillBooster-Agentgemini-code-assist[bot]
authored
fix: use prisma wal checkpoint for sqlite cleanup (#599)
Co-authored-by: WillBooster (Codex CLI) <agent@willbooster.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent f264248 commit 0922a7b

File tree

2 files changed

+163
-3
lines changed

2 files changed

+163
-3
lines changed

packages/wb/src/scripts/prismaScripts.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ const POSSIBLE_PRISMA_PATHS = [
1919
class PrismaScripts {
2020
cleanUpLitestream(project: Project): string {
2121
const dirPath = getDatabaseDirPath(project);
22+
const cleanUpCommand = buildWalCheckpointAndRemoveDbCommand(`${dirPath}/prod.sqlite3`);
2223
// Cleanup existing artifacts to avoid issues with Litestream replication.
2324
// Note that don't merge multiple rm commands into one, because if one fails, the subsequent ones won't run.
24-
return `rm -Rf ${dirPath}/prod.sqlite3.*; rm -Rf ${dirPath}/.prod.sqlite3* || true`;
25+
return `${cleanUpCommand}; rm -Rf ${dirPath}/.prod.sqlite3* || true`;
2526
}
2627

2728
deploy(_: Project, additionalOptions = ''): string {
@@ -30,9 +31,10 @@ class PrismaScripts {
3031

3132
deployForce(project: Project): string {
3233
const dirPath = getDatabaseDirPath(project);
34+
const cleanUpCommand = buildWalCheckpointAndRemoveDbCommand(`${dirPath}/prod.sqlite3`);
3335
// `prisma migrate reset` can fail depending on the state of the existing database, so we remove it first.
3436
// Don't skip "migrate deploy" because restored database may be older than the current schema.
35-
return `rm -Rf ${dirPath}/prod.sqlite3*; PRISMA migrate reset --force --skip-seed && rm -Rf ${dirPath}/prod.sqlite3*
37+
return `${cleanUpCommand}; PRISMA migrate reset --force --skip-seed && ${cleanUpCommand}
3638
&& litestream restore -config litestream.yml -o ${dirPath}/prod.sqlite3 ${dirPath}/prod.sqlite3 && ls -ahl ${dirPath}/prod.sqlite3 && ALLOW_TO_SKIP_SEED=0 PRISMA migrate deploy`;
3739
}
3840

@@ -106,7 +108,11 @@ function getPrismaBaseDir(project: Project): string | undefined {
106108
?.dbPath;
107109
}
108110

109-
function cleanUpSqliteDbIfNeeded(project: Project): string | undefined {
111+
function buildWalCheckpointAndRemoveDbCommand(dbPath: string): string {
112+
return `if [ -f "${dbPath}" ]; then printf 'PRAGMA wal_checkpoint(TRUNCATE);' | PRISMA db execute --stdin --url "${FILE_SCHEMA}${dbPath}"; fi && rm -f "${dbPath}" "${dbPath}-wal" "${dbPath}-shm"`;
113+
}
114+
115+
export function cleanUpSqliteDbIfNeeded(project: Project): string | undefined {
110116
const dbUrl = project.env.DATABASE_URL;
111117
if (!dbUrl?.startsWith(FILE_SCHEMA)) return;
112118

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import child_process from 'node:child_process';
2+
import fs from 'node:fs';
3+
import os from 'node:os';
4+
import path from 'node:path';
5+
6+
import { afterEach, describe, expect, it } from 'vitest';
7+
8+
import type { Project } from '../../src/project.js';
9+
import { cleanUpSqliteDbIfNeeded, prismaScripts } from '../../src/scripts/prismaScripts.js';
10+
11+
const createdDirs: string[] = [];
12+
13+
afterEach(() => {
14+
for (const dirPath of createdDirs.splice(0)) {
15+
fs.rmSync(dirPath, { force: true, recursive: true });
16+
}
17+
});
18+
19+
describe('prismaScripts.reset', () => {
20+
it('removes sqlite db and sidecar files', () => {
21+
const dirPath = createProjectDir();
22+
23+
const dbRelativePath = path.join('mount', 'prod.sqlite3');
24+
const absoluteDbPath = path.resolve(dirPath, 'prisma', dbRelativePath);
25+
fs.mkdirSync(path.dirname(absoluteDbPath), { recursive: true });
26+
27+
createDatabaseWithWal(absoluteDbPath);
28+
expect(fs.existsSync(`${absoluteDbPath}-wal`)).toBe(true);
29+
expect(fs.existsSync(`${absoluteDbPath}-shm`)).toBe(true);
30+
expect(fs.statSync(`${absoluteDbPath}-wal`).size).toBeGreaterThan(0);
31+
32+
const project = {
33+
dirPath,
34+
env: { DATABASE_URL: `file:${dbRelativePath}` },
35+
packageJson: { dependencies: {} },
36+
} as unknown as Project;
37+
38+
const cleanupCommand = cleanUpSqliteDbIfNeeded(project);
39+
expect(cleanupCommand).toBeTruthy();
40+
if (!cleanupCommand) throw new Error('cleanup command was not generated');
41+
expect(cleanupCommand).not.toContain('wal_checkpoint');
42+
expect(cleanupCommand).toContain(`${absoluteDbPath}-wal`);
43+
expect(cleanupCommand).toContain(`${absoluteDbPath}-shm`);
44+
45+
child_process.execSync(cleanupCommand, { cwd: dirPath, stdio: 'inherit' });
46+
const walPath = `${absoluteDbPath}-wal`;
47+
expect(fs.existsSync(walPath)).toBe(false);
48+
expect(fs.existsSync(absoluteDbPath)).toBe(false);
49+
expect(fs.existsSync(`${absoluteDbPath}-shm`)).toBe(false);
50+
}, 120_000);
51+
52+
it('does not add sqlite cleanup when DATABASE_URL is not file scheme', () => {
53+
const project = {
54+
dirPath: '/tmp/dummy',
55+
env: { DATABASE_URL: 'postgresql://localhost:5432/db' },
56+
packageJson: { dependencies: {} },
57+
} as unknown as Project;
58+
59+
const command = prismaScripts.reset(project);
60+
expect(command).toBe('PRISMA migrate reset --force');
61+
});
62+
63+
it('uses wal checkpoint in cleanUpLitestream command and executes without mocks', () => {
64+
const dirPath = createProjectDir();
65+
const dbPath = path.resolve(dirPath, 'prisma', 'mount', 'prod.sqlite3');
66+
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
67+
createDatabaseWithWal(dbPath);
68+
69+
const project = {
70+
dirPath,
71+
env: {},
72+
packageJson: { dependencies: {} },
73+
} as unknown as Project;
74+
const command = prismaScripts.cleanUpLitestream(project);
75+
76+
expect(command).toContain('wal_checkpoint(TRUNCATE)');
77+
expect(command).not.toContain('/prod.sqlite3*;');
78+
const checkpointOnlyCommand = extractCheckpointOnlyCommand(command, 'prisma/mount/prod.sqlite3');
79+
child_process.execSync(checkpointOnlyCommand.replaceAll('PRISMA ', 'npx --yes prisma@6.10.1 '), {
80+
cwd: dirPath,
81+
stdio: 'inherit',
82+
});
83+
// If WAL contents are checkpointed into the main DB, deleting WAL should not lose inserted rows.
84+
child_process.execSync(`rm -f "${dbPath}-wal" "${dbPath}-shm"`, { cwd: dirPath, stdio: 'inherit' });
85+
const rowCount = child_process
86+
.execSync(`sqlite3 "${dbPath}" "SELECT COUNT(*) FROM t;"`, {
87+
cwd: dirPath,
88+
encoding: 'utf8',
89+
})
90+
.trim();
91+
expect(rowCount).toBe('1');
92+
93+
child_process.execSync(command.replaceAll('PRISMA ', 'npx --yes prisma@6.10.1 '), {
94+
cwd: dirPath,
95+
stdio: 'inherit',
96+
});
97+
expect(fs.existsSync(dbPath)).toBe(false);
98+
expect(fs.existsSync(`${dbPath}-wal`)).toBe(false);
99+
expect(fs.existsSync(`${dbPath}-shm`)).toBe(false);
100+
}, 120_000);
101+
102+
it('uses wal checkpoint in deployForce cleanup command', () => {
103+
const project = {
104+
dirPath: '/tmp/dummy',
105+
env: {},
106+
packageJson: { dependencies: {} },
107+
} as unknown as Project;
108+
109+
const command = prismaScripts.deployForce(project);
110+
expect(command).toContain('wal_checkpoint(TRUNCATE)');
111+
expect(command).not.toContain('/prod.sqlite3*;');
112+
});
113+
});
114+
115+
function createProjectDir(): string {
116+
const dirPath = fs.mkdtempSync(path.join(os.tmpdir(), 'wb-prisma-scripts-'));
117+
createdDirs.push(dirPath);
118+
fs.mkdirSync(path.join(dirPath, 'prisma'), { recursive: true });
119+
fs.writeFileSync(
120+
path.join(dirPath, 'prisma', 'schema.prisma'),
121+
[
122+
'datasource db {',
123+
' provider = "sqlite"',
124+
' url = env("DATABASE_URL")',
125+
'}',
126+
'',
127+
'generator client {',
128+
' provider = "prisma-client-js"',
129+
'}',
130+
'',
131+
].join('\n')
132+
);
133+
return dirPath;
134+
}
135+
136+
function createDatabaseWithWal(dbPath: string): void {
137+
const sqlite3Path = child_process.execSync('which sqlite3', { encoding: 'utf8' }).trim();
138+
const sql = [
139+
'.dbconfig no_ckpt_on_close on',
140+
'PRAGMA journal_mode=WAL;',
141+
'CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY);',
142+
'INSERT INTO t DEFAULT VALUES;',
143+
].join('\n');
144+
child_process.execSync(`${sqlite3Path} "${dbPath}" <<'SQL'\n${sql}\nSQL`, { stdio: 'inherit' });
145+
}
146+
147+
function extractCheckpointOnlyCommand(command: string, dbRelativePath: string): string {
148+
const marker = `&& rm -f "${dbRelativePath}"`;
149+
const markerIndex = command.indexOf(marker);
150+
if (markerIndex === -1) {
151+
throw new Error(`Failed to find marker in command: ${marker}`);
152+
}
153+
return command.slice(0, markerIndex).trim();
154+
}

0 commit comments

Comments
 (0)