Repository navigation
Expand file tree
/
Copy pathsql-driver-sqlite-absent-file.test.ts
More file actions
322 lines (279 loc) · 13.6 KB
/
Copy pathsql-driver-sqlite-absent-file.test.ts
File metadata and controls
322 lines (279 loc) · 13.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
/**
* `sqliteAbsentFile` — the driver's open-mode contract (#6743).
*
* The defect this closes lived one layer below every dry-run guard the CLI had
* already built: `os migrate plan` deferred its DDL (#3917) and suppressed its
* seed, and still created a 0-table database, because SQLite brings the file
* into existence at OPEN — for this driver, at the `PRAGMA auto_vacuum` that
* `connect()` issues first.
*
* The judgement lives here rather than in the CLI on purpose. A caller-side
* `existsSync` would be a second opinion about the driver's own open
* semantics, free to drift from it; the driver is the only place that knows
* what it is about to open.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, existsSync, readdirSync, writeFileSync, mkdirSync, chmodSync, lstatSync, symlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { SqlDriver, resolveSqliteAbsentFileTarget } from './index.js';
describe('resolveSqliteAbsentFileTarget — the one absent-file judgement (#6743)', () => {
let dir: string;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'os-absent-')); });
afterEach(() => { try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } });
it('redirects a missing file to :memory: only under empty-in-memory', () => {
const missing = join(dir, 'nope.db');
expect(resolveSqliteAbsentFileTarget(missing, 'empty-in-memory'))
.toEqual({ filename: ':memory:', openedEmptyInMemory: true });
// Default and explicit 'create' are the same answer: SQLite's own.
expect(resolveSqliteAbsentFileTarget(missing, 'create'))
.toEqual({ filename: missing, openedEmptyInMemory: false });
expect(resolveSqliteAbsentFileTarget(missing, undefined))
.toEqual({ filename: missing, openedEmptyInMemory: false });
});
it('leaves an existing file alone — the mode is about creation, not access', () => {
const present = join(dir, 'there.db');
writeFileSync(present, '');
expect(resolveSqliteAbsentFileTarget(present, 'empty-in-memory'))
.toEqual({ filename: present, openedEmptyInMemory: false });
});
it('passes pseudo-filenames through untouched — they are already fileless', () => {
for (const pseudo of [':memory:', ':other:']) {
expect(resolveSqliteAbsentFileTarget(pseudo, 'empty-in-memory'))
.toEqual({ filename: pseudo, openedEmptyInMemory: false });
}
});
});
describe('SqlDriver({ sqliteAbsentFile }) — opening must not create (#6743)', () => {
let dir: string;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'os-absent-drv-')); });
afterEach(() => { try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } });
const build = (filename: string, mode?: 'create' | 'empty-in-memory') =>
new SqlDriver({
client: 'better-sqlite3',
connection: { filename },
useNullAsDefault: true,
...(mode ? { sqliteAbsentFile: mode } : {}),
} as any);
it('creates nothing on disk — not the file, not -wal, not -shm, not the directory', async () => {
const dataDir = join(dir, 'data');
const dbFile = join(dataDir, 'objectstack.db');
const driver = build(dbFile, 'empty-in-memory');
try {
// `connect()` is where it used to happen: `ensureDatabaseExists()`
// mkdir'd the directory and the first PRAGMA created the file.
await driver.connect();
// A real, usable connection — the report a plan renders is computed
// against exactly this.
const rows: any = await (driver as any).knex.raw(
"SELECT name FROM sqlite_master WHERE type = 'table'",
);
expect(rows.filter((r: { name: string }) => !r.name.startsWith('sqlite_'))).toEqual([]);
expect(driver.sqliteOpenedEmptyInMemory).toBe(true);
} finally {
await driver.disconnect();
}
expect(existsSync(dbFile)).toBe(false);
expect(existsSync(`${dbFile}-wal`)).toBe(false);
expect(existsSync(`${dbFile}-shm`)).toBe(false);
expect(existsSync(dataDir)).toBe(false);
});
it('keeps config naming the DECLARED target so the CLI can still print it', async () => {
const dbFile = join(dir, 'data', 'objectstack.db');
const driver = build(dbFile, 'empty-in-memory');
try {
await driver.connect();
// `describeDriverConnection` renders this. Rewriting it to `:memory:`
// would change `os migrate plan`'s "Database:" line, which the ruling on
// #6743 explicitly refuses to pay for the hygiene fix.
expect(((driver as any).config as any).connection.filename).toBe(dbFile);
} finally {
await driver.disconnect();
}
});
it('opens an EXISTING file normally — the mode changes creation, not access', async () => {
const dbFile = join(dir, 'existing.db');
const seed = build(dbFile);
await seed.connect();
await (seed as any).knex.schema.createTable('kept', (t: any) => { t.string('id'); });
await seed.disconnect();
expect(existsSync(dbFile)).toBe(true);
const driver = build(dbFile, 'empty-in-memory');
try {
await driver.connect();
expect(driver.sqliteOpenedEmptyInMemory).toBe(false);
const rows: any = await (driver as any).knex.raw(
"SELECT name FROM sqlite_master WHERE type = 'table'",
);
expect(rows.map((r: { name: string }) => r.name)).toContain('kept');
} finally {
await driver.disconnect();
}
});
it('without the option, the file is created — the behaviour every existing caller keeps', async () => {
const dataDir = join(dir, 'default-mode');
const dbFile = join(dataDir, 'objectstack.db');
const driver = build(dbFile);
try {
await driver.connect();
expect(driver.sqliteOpenedEmptyInMemory).toBe(false);
} finally {
await driver.disconnect();
}
expect(existsSync(dbFile)).toBe(true);
});
it('is inert for :memory: and for non-sqlite dialects', async () => {
const mem = build(':memory:', 'empty-in-memory');
try {
await mem.connect();
expect(mem.sqliteOpenedEmptyInMemory).toBe(false);
} finally {
await mem.disconnect();
}
// A pg config is never opened here; the point is only that the option is
// stripped from what Knex receives rather than handed on as an option pg
// has never heard of.
const pg = new SqlDriver({
client: 'pg',
connection: { host: 'localhost', database: 'nope' },
sqliteAbsentFile: 'empty-in-memory',
} as any);
expect(((pg as any).config as any).sqliteAbsentFile).toBeUndefined();
expect(pg.sqliteOpenedEmptyInMemory).toBe(false);
// A server dialect's connection STRING is a URL, not a filename (#24961):
// it is never handed to the absent-file judgement, so it is neither
// rewritten to `:memory:` nor walked on disk.
const url = 'postgres://app@db.example:5432/main';
const pgUrl = new SqlDriver({ client: 'pg', connection: url, sqliteAbsentFile: 'empty-in-memory' } as any);
expect((pgUrl as any).knex.client.config.connection).toMatchObject({ connectionString: url });
expect(pgUrl.sqliteOpenedEmptyInMemory).toBe(false);
await pgUrl.disconnect();
});
it('a directory that already exists is not disturbed', async () => {
const dataDir = join(dir, 'preexisting');
mkdirSync(dataDir, { recursive: true });
writeFileSync(join(dataDir, 'unrelated.txt'), 'keep me');
const driver = build(join(dataDir, 'objectstack.db'), 'empty-in-memory');
try {
await driver.connect();
} finally {
await driver.disconnect();
}
expect(readdirSync(dataDir)).toEqual(['unrelated.txt']);
});
});
/**
* An unopenable path is not an absent one (#24961).
*
* `existsSync` answers `false` for every path it cannot stat, so the judgement
* used to read a path under a regular file as an absent file and open an empty
* `:memory:` database over it: `os migrate plan` reported the datasource
* connected and planned tables into a file that cannot exist, while the boot
* that writes refused the same path. The judgement now refuses it, naming the
* file and the operating system's answer — and still writes nothing to find
* out.
*/
describe('resolveSqliteAbsentFileTarget — a file that can be neither opened nor created is refused (#24961)', () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'os-24961-'));
writeFileSync(join(dir, 'plain'), 'a regular file, not a directory');
});
afterEach(() => {
try { chmodSync(join(dir, 'locked'), 0o755); } catch { /* not every case makes it */ }
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
});
/** What `fn` threw; a failure naming its answer when it threw nothing. */
function thrownBy(fn: () => unknown): any {
let answer: unknown;
try {
answer = fn();
} catch (error) {
return error;
}
throw new Error(`answered ${JSON.stringify(answer)} for a file that can be neither opened nor created`);
}
/** The driver's refusal of the file: its own error name, the system's code, and the file. */
function expectFileRefusal(error: any, filename: string, code: string): void {
expect(error?.name).toBe('SqliteDatabaseFileError');
expect(error?.code).toBe(code);
expect(error?.filename).toBe(filename);
}
it('refuses a path under a regular file with ENOTDIR, at either depth, and writes nothing', () => {
const before = readdirSync(dir).sort();
const at = join(dir, 'plain', 'archive.db');
expectFileRefusal(thrownBy(() => resolveSqliteAbsentFileTarget(at, 'empty-in-memory')), at, 'ENOTDIR');
const through = join(dir, 'plain', 'sub', 'archive.db');
expectFileRefusal(thrownBy(() => resolveSqliteAbsentFileTarget(through, 'empty-in-memory')), through, 'ENOTDIR');
expect(readdirSync(dir).sort()).toEqual(before);
});
it('refuses a directory whose name a dangling symbolic link holds, and leaves the link dangling', () => {
const link = join(dir, 'link');
symlinkSync(join(dir, 'nowhere'), link);
const filename = join(link, 'archive.db');
expectFileRefusal(thrownBy(() => resolveSqliteAbsentFileTarget(filename, 'empty-in-memory')), filename, 'ENOENT');
expect(lstatSync(link).isSymbolicLink()).toBe(true);
expect(existsSync(join(dir, 'nowhere'))).toBe(false);
});
// Permission is judged as the process holds it, so the case splits on who
// runs it: a superuser may create in a 0555 directory, and the writing boot
// does — so for it the absent answer is the consistent one.
const superuser = typeof process.getuid === 'function' && process.getuid() === 0;
it.skipIf(superuser)('refuses an absent file in a directory this process may not write, with EACCES', () => {
const locked = join(dir, 'locked');
mkdirSync(locked);
chmodSync(locked, 0o555);
const filename = join(locked, 'archive.db');
expectFileRefusal(thrownBy(() => resolveSqliteAbsentFileTarget(filename, 'empty-in-memory')), filename, 'EACCES');
const deeper = join(locked, 'sub', 'archive.db');
expectFileRefusal(thrownBy(() => resolveSqliteAbsentFileTarget(deeper, 'empty-in-memory')), deeper, 'EACCES');
expect(readdirSync(locked)).toEqual([]);
});
it.runIf(superuser)('as the superuser, an absent file in a 0555 directory is absent: the writing boot can create it', () => {
const locked = join(dir, 'locked');
mkdirSync(locked);
chmodSync(locked, 0o555);
expect(resolveSqliteAbsentFileTarget(join(locked, 'archive.db'), 'empty-in-memory'))
.toEqual({ filename: ':memory:', openedEmptyInMemory: true });
expect(readdirSync(locked)).toEqual([]);
});
it('CONTROL: an absent file in a directory that exists, or can be made, is still absent and writes nothing', () => {
const before = readdirSync(dir).sort();
expect(resolveSqliteAbsentFileTarget(join(dir, 'fresh.db'), 'empty-in-memory'))
.toEqual({ filename: ':memory:', openedEmptyInMemory: true });
expect(resolveSqliteAbsentFileTarget(join(dir, 'new', 'deeper', 'fresh.db'), 'empty-in-memory'))
.toEqual({ filename: ':memory:', openedEmptyInMemory: true });
expect(readdirSync(dir).sort()).toEqual(before);
});
it('only empty-in-memory asks: the default mode hands the path on for connect() to refuse', () => {
const at = join(dir, 'plain', 'archive.db');
expect(resolveSqliteAbsentFileTarget(at, 'create')).toEqual({ filename: at, openedEmptyInMemory: false });
expect(resolveSqliteAbsentFileTarget(at, undefined)).toEqual({ filename: at, openedEmptyInMemory: false });
});
it('SqlDriver under empty-in-memory refuses at construction with the same error the writing boot\'s connect() names the file with', async () => {
const before = readdirSync(dir).sort();
const at = join(dir, 'plain', 'archive.db');
const build = (mode?: 'empty-in-memory') =>
new SqlDriver({
client: 'better-sqlite3',
connection: { filename: at },
useNullAsDefault: true,
...(mode ? { sqliteAbsentFile: mode } : {}),
} as any);
// The read-only boot: no driver over a file that cannot exist.
expectFileRefusal(thrownBy(() => build('empty-in-memory')), at, 'ENOTDIR');
// The boot that may write, over the same path: its connect() refuses it,
// as it did before (#23696) — so the two boots now agree.
const writing = build();
let refusal: unknown;
try {
await writing.connect().catch((e: unknown) => { refusal = e; });
} finally {
await writing.disconnect();
}
expect((refusal as any)?.name).toBe('SqliteDatabaseFileError');
expect((refusal as any)?.filename).toBe(at);
expect(readdirSync(dir).sort()).toEqual(before);
});
});