@evolu/web 3.4.1 → 3.5.0

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.
@@ -3,531 +3,557 @@ import {
3
3
  assert,
4
4
  assertEqual,
5
5
  assertEqualBytes,
6
+ assertInstanceOf,
6
7
  assertSame,
7
- assertTrue,
8
8
  assertType,
9
9
  EncryptionKey,
10
+ err,
10
11
  Name,
12
+ ok,
11
13
  sql,
12
14
  testCreateRun,
15
+ type Result,
13
16
  } from "@evolu/common";
14
17
  import { describe, it, mock } from "node:test";
15
18
 
16
- // OPFS with an optional pool directory. Each pool file lists the error names
17
- // its next openings reject with, in order.
18
- const opfsMock = (() => {
19
- const state = {
20
- closedAccessHandleCount: 0,
21
- directoryNames: [] as Array<string>,
22
- getDirectoryCount: 0,
23
- hasOpaqueDirectory: true,
24
- openedAccessHandleCount: 0,
25
- poolFiles: null as Array<Array<string>> | null,
26
- };
27
-
28
- const opaqueDirectory = {
29
- // for await also iterates a sync iterable.
30
- values: function* () {
31
- yield { kind: "directory" };
32
- for (const errorNames of state.poolFiles ?? [])
33
- yield {
34
- kind: "file",
35
- createSyncAccessHandle: () => {
36
- const errorName = errorNames.shift();
37
- if (errorName !== undefined)
38
- return Promise.reject(new DOMException("Held", errorName));
39
- state.openedAccessHandleCount += 1;
40
- return Promise.resolve({
41
- close: () => {
42
- state.closedAccessHandleCount += 1;
43
- },
44
- });
45
- },
46
- };
19
+ /** The options of the databases the fake opens. */
20
+ interface FakeDatabaseOptions {
21
+ readonly type: string;
22
+ readonly path?: string;
23
+ readonly vfs?: unknown;
24
+ readonly key?: unknown;
25
+ }
26
+
27
+ /**
28
+ * A fake of `@evolu/sqlite-wasm` that records what the driver does with it, in
29
+ * order, in `events`. Each test resets it and sets the results it needs.
30
+ */
31
+ const fake = (() => {
32
+ const sqliteWasm = { label: "sqliteWasm" };
33
+ // The values SqliteVfsPath validated, which the driver does once, on import.
34
+ const sqliteVfsPaths: Array<string> = [];
35
+ const pool = {
36
+ unlink: (path: string) => {
37
+ state.events.push(`unlink ${path}`);
38
+ return state.unlinkResult;
47
39
  },
48
- };
49
-
50
- const poolDirectory = {
51
- getDirectoryHandle: (name: string) => {
52
- state.directoryNames.push(name);
53
- return state.hasOpaqueDirectory
54
- ? Promise.resolve(opaqueDirectory)
55
- : Promise.reject(new DOMException("Missing", "NotFoundError"));
40
+ [Symbol.dispose]: () => {
41
+ state.events.push("dispose pool");
56
42
  },
57
43
  };
58
44
 
59
- const root = {
60
- getDirectoryHandle: (name: string) => {
61
- state.directoryNames.push(name);
62
- return state.poolFiles
63
- ? Promise.resolve(poolDirectory)
64
- : Promise.reject(new DOMException("Missing", "NotFoundError"));
45
+ const createDatabase = (options: { readonly type: string }) => ({
46
+ prepare: (query: string) => {
47
+ state.events.push(`prepare ${query}`);
48
+ return ok({
49
+ run: (parameters: ReadonlyArray<unknown>) => {
50
+ state.events.push(`run statement ${JSON.stringify(parameters)}`);
51
+ return state.statementRunResult;
52
+ },
53
+ [Symbol.dispose]: () => {
54
+ state.events.push(`dispose statement ${query}`);
55
+ },
56
+ });
65
57
  },
66
- };
67
-
68
- Object.defineProperty(navigator, "storage", {
69
- configurable: true,
70
- value: {
71
- getDirectory: () => {
72
- state.getDirectoryCount += 1;
73
- return Promise.resolve(root);
74
- },
58
+ run: (query: string, parameters: ReadonlyArray<unknown>) => {
59
+ state.events.push(`run ${query} ${JSON.stringify(parameters)}`);
60
+ return state.runResult;
75
61
  },
76
- });
77
-
78
- return {
79
- reset: (
80
- poolFiles: Array<Array<string>> | null = null,
81
- { hasOpaqueDirectory = true } = {},
82
- ) => {
83
- state.closedAccessHandleCount = 0;
84
- state.directoryNames.length = 0;
85
- state.getDirectoryCount = 0;
86
- state.hasOpaqueDirectory = hasOpaqueDirectory;
87
- state.openedAccessHandleCount = 0;
88
- state.poolFiles = poolFiles;
62
+ export: () => state.exportResult,
63
+ [Symbol.dispose]: () => {
64
+ state.events.push(`close ${options.type}`);
89
65
  },
90
- state,
91
- };
92
- })();
93
-
94
- // Lets resolved mocks settle before test time advances.
95
- const flushMicrotasks = (): Promise<void> =>
96
- new Promise((resolve) => {
97
- setImmediate(resolve);
98
66
  });
99
67
 
100
- const sqliteMock = (() => {
101
- class PreparedStatement {
102
- finalized = false;
103
- resetCount = 0;
104
- stepCount = 0;
105
- readonly bound: Array<ReadonlyArray<unknown>> = [];
106
-
107
- bind(parameters: ReadonlyArray<unknown>): void {
108
- this.bound.push(parameters);
109
- }
110
-
111
- step(): boolean {
112
- this.stepCount += 1;
113
- return this.stepCount === 1;
114
- }
115
-
116
- get(): Record<string, unknown> {
117
- return { data: "prepared" };
118
- }
119
-
120
- finalize(): void {
121
- this.finalized = true;
122
- }
123
- }
124
-
125
- class Database {
126
- readonly execSql: Array<string> = [];
127
- readonly filename: string;
128
-
129
- constructor(filename: string) {
130
- this.filename = filename;
131
- state.createdDatabases.push(this);
132
- }
133
-
134
- prepare(): PreparedStatement {
135
- const statement = new PreparedStatement();
136
- state.preparedStatements.push(statement);
137
- return statement;
138
- }
139
-
140
- exec(sql: string): ReadonlyArray<Record<string, unknown>> {
141
- this.execSql.push(sql);
142
- return [{ data: "row" }];
143
- }
144
-
145
- changes(): number {
146
- return 1;
147
- }
148
-
149
- close(): void {
150
- state.closedDatabases.push(this.filename);
151
- state.events.push(`close:${this.filename}`);
152
- }
153
- }
154
-
155
- const state = {
156
- closedDatabases: [] as Array<string>,
157
- createdDatabases: [] as Array<Database>,
158
- deletedFilenames: [] as Array<string>,
68
+ const initialState = () => ({
69
+ createSqliteWasmResult: ok(sqliteWasm) as Result<unknown, unknown>,
70
+ createSqliteWasmSources: [] as Array<unknown>,
71
+ databaseOptions: [] as Array<FakeDatabaseOptions>,
72
+ databaseResult: null as Result<never, unknown> | null,
159
73
  events: [] as Array<string>,
160
- pausedVfsNames: [] as Array<string>,
161
- preparedStatements: [] as Array<PreparedStatement>,
162
- unpausedVfsNames: [] as Array<string>,
163
- };
164
-
165
- let poolPaused = false;
166
- const pool = {
167
- isPaused: mock.fn(() => poolPaused),
168
- OpfsSAHPoolDb: Database,
169
- pauseVfs: mock.fn(() => {
170
- poolPaused = true;
171
- state.pausedVfsNames.push(pool.vfsName);
172
- state.events.push(`pause:${pool.vfsName}`);
173
- return pool;
174
- }),
175
- unpauseVfs: mock.fn(() => {
176
- poolPaused = false;
177
- state.unpausedVfsNames.push(pool.vfsName);
178
- state.events.push(`unpause:${pool.vfsName}`);
179
- return Promise.resolve(pool);
180
- }),
181
- unlink: mock.fn((filename: string) => {
182
- state.deletedFilenames.push(filename);
183
- state.events.push(`unlink:${filename}`);
184
- return true;
185
- }),
186
- vfsName: "mock-sahpool",
187
- };
188
-
189
- const sqlite3 = {
190
- capi: {
191
- sqlite3_js_db_export: mock.fn(() => new Uint8Array([1, 2, 3])),
192
- sqlite3_reset: mock.fn((statement: PreparedStatement) => {
193
- statement.resetCount += 1;
194
- return 0;
195
- }),
196
- sqlite3mc_vfs_create: mock.fn(),
197
- },
198
- installOpfsSAHPoolVfs: mock.fn(() => Promise.resolve(pool)),
199
- oo1: { DB: Database },
200
- };
74
+ exportResult: ok(new Uint8Array([1, 2, 3])) as Result<Uint8Array, unknown>,
75
+ poolDeps: [] as Array<Record<string, unknown>>,
76
+ poolOptions: [] as Array<unknown>,
77
+ poolResults: [] as Array<Result<never, unknown>>,
78
+ runResult: ok({ rows: [{ value: "run" }], changes: 1 }) as Result<
79
+ unknown,
80
+ unknown
81
+ >,
82
+ statementRunResult: ok({
83
+ rows: [{ value: "statement" }],
84
+ changes: 2,
85
+ }) as Result<unknown, unknown>,
86
+ unlinkResult: ok(true) as Result<boolean, unknown>,
87
+ });
88
+ const state = initialState();
201
89
 
202
90
  return {
203
- consoleWarn: mock.fn<typeof console.warn>(),
91
+ state,
204
92
  pool,
93
+ sqliteWasm,
94
+ sqliteVfsPaths,
205
95
  reset: () => {
206
- opfsMock.reset();
207
- state.closedDatabases.length = 0;
208
- state.createdDatabases.length = 0;
209
- state.deletedFilenames.length = 0;
210
- state.events.length = 0;
211
- state.pausedVfsNames.length = 0;
212
- state.preparedStatements.length = 0;
213
- state.unpausedVfsNames.length = 0;
214
- poolPaused = false;
215
- pool.isPaused.mock.resetCalls();
216
- pool.pauseVfs.mock.resetCalls();
217
- pool.unpauseVfs.mock.resetCalls();
218
- pool.unlink.mock.resetCalls();
219
- sqlite3.capi.sqlite3_js_db_export.mock.resetCalls();
220
- sqlite3.capi.sqlite3_reset.mock.resetCalls();
221
- sqlite3.capi.sqlite3mc_vfs_create.mock.resetCalls();
222
- sqlite3.installOpfsSAHPoolVfs.mock.resetCalls();
96
+ Object.assign(state, initialState());
97
+ },
98
+ exports: {
99
+ sqliteWasmUrl: new URL("https://example.test/sqlite3.wasm"),
100
+ OpfsName: { orThrow: (value: string) => value },
101
+ SqliteVfsPath: {
102
+ orThrow: (value: string) => {
103
+ sqliteVfsPaths.push(value);
104
+ return value;
105
+ },
106
+ },
107
+ createSqliteWasm: (source: unknown) => () => {
108
+ state.createSqliteWasmSources.push(source);
109
+ return state.createSqliteWasmResult;
110
+ },
111
+ openSahPool:
112
+ (options: { readonly directory: ReadonlyArray<string> }) =>
113
+ (run: { readonly deps: Record<string, unknown> }) => {
114
+ state.events.push(`open pool ${options.directory.join("/")}`);
115
+ state.poolDeps.push(run.deps);
116
+ state.poolOptions.push(options);
117
+ return Promise.resolve(state.poolResults.shift() ?? ok(pool));
118
+ },
119
+ createSqliteDatabase:
120
+ (deps: { readonly sqliteWasm: unknown }) =>
121
+ (options: FakeDatabaseOptions) => {
122
+ assertSame(deps.sqliteWasm, sqliteWasm);
123
+ state.events.push(`open ${options.type} ${options.path ?? ""}`);
124
+ state.databaseOptions.push(options);
125
+ return state.databaseResult ?? ok(createDatabase(options));
126
+ },
127
+ createEncryptedSqliteDatabase:
128
+ (options: FakeDatabaseOptions) =>
129
+ (run: { readonly deps: Record<string, unknown> }) => {
130
+ assertSame(run.deps.sqliteWasm, sqliteWasm);
131
+ assertSame(run.deps.subtleCrypto, testSubtleCrypto);
132
+ state.events.push(`open ${options.type} ${options.path ?? ""}`);
133
+ state.databaseOptions.push(options);
134
+ return Promise.resolve(
135
+ state.databaseResult ??
136
+ ok({ database: createDatabase(options), keyDerivation: "Raw" }),
137
+ );
138
+ },
223
139
  },
224
- sqlite3,
225
- state,
226
140
  };
227
141
  })();
228
142
 
229
143
  mock.module("@evolu/sqlite-wasm", {
230
144
  // @ts-expect-error -- Node.js 24.20 replaces the deprecated defaultExport option with exports, which @types/node 24.13 does not declare yet.
231
- exports: {
232
- default: mock.fn(() => {
233
- const config = Reflect.get(globalThis, "sqlite3ApiConfig") as
234
- | {
235
- readonly warn?: (arg: unknown) => void;
236
- }
237
- | undefined;
238
- config?.warn?.("Ignoring inability to install OPFS sqlite3_vfs");
239
- config?.warn?.("kept warning");
240
- return Promise.resolve(sqliteMock.sqlite3);
241
- }),
242
- },
145
+ exports: fake.exports,
243
146
  });
244
147
 
245
- mock.method(console, "warn", sqliteMock.consoleWarn);
246
- const { createWasmSqliteDriver } = await import("./Sqlite.ts");
148
+ const { createWaitForDatabaseRelease, createWasmSqliteDriver, loadSqliteWasm } =
149
+ await import("./Sqlite.ts");
247
150
 
248
- describe("createWasmSqliteDriver coverage helpers", () => {
249
- it("filters sqlite init warnings", () => {
250
- assertEqual(
251
- sqliteMock.consoleWarn.mock.calls.map(({ arguments: args }) => args),
252
- [["kept warning"]],
253
- );
254
- });
151
+ const testOpfsRoot = { getDirectory: () => Promise.reject(new Error("Fake")) };
152
+ const testSubtleCrypto = { label: "subtleCrypto" } as unknown as SubtleCrypto;
153
+ const testKey = EncryptionKey.orThrow(new Uint8Array(32).fill(42));
154
+ const testName = Name.orThrow("Test");
255
155
 
256
- it("opens plain OPFS SAH-pool database", async () => {
257
- sqliteMock.reset();
156
+ const setupDriverDeps = (
157
+ sqliteWasm: Result<unknown, unknown> = ok(fake.sqliteWasm),
158
+ ) => ({
159
+ opfsRoot: testOpfsRoot,
160
+ sqliteWasmLoad: Promise.resolve(sqliteWasm) as never,
161
+ subtleCrypto: testSubtleCrypto,
162
+ });
258
163
 
259
- await using run = testCreateRun();
260
- using _driver = await run.ok(
261
- createWasmSqliteDriver(Name.orThrow("MockPlain")),
262
- );
164
+ const setupDriver = async (
165
+ options?: Parameters<ReturnType<typeof createWasmSqliteDriver>>[1],
166
+ ) => {
167
+ fake.reset();
168
+ await using disposer = new AsyncDisposableStack();
169
+ const run = disposer.use(testCreateRun());
170
+ const driver = disposer.use(
171
+ await run.ok(createWasmSqliteDriver(setupDriverDeps())(testName, options)),
172
+ );
173
+ const disposables = disposer.move();
174
+ return {
175
+ driver,
176
+ run,
177
+ [Symbol.asyncDispose]: () => disposables.disposeAsync(),
178
+ };
179
+ };
263
180
 
264
- assertEqual(
265
- sqliteMock.sqlite3.installOpfsSAHPoolVfs.mock.calls.map(
266
- ({ arguments: args }) => args,
267
- ),
268
- [[{ name: "MockPlain" }]],
269
- );
270
- assertEqual(
271
- sqliteMock.state.createdDatabases[0]?.filename,
272
- "file:evolu1.db",
273
- );
181
+ // Lets resolved promises settle before test time advances.
182
+ const flushMicrotasks = (): Promise<void> =>
183
+ new Promise((resolve) => {
184
+ setImmediate(resolve);
274
185
  });
275
186
 
276
- it("opens memory database", async () => {
277
- sqliteMock.reset();
187
+ const heldError = {
188
+ type: "SahPoolHeldError",
189
+ fileName: "slot",
190
+ cause: new DOMException("Held", "NoModificationAllowedError"),
191
+ } as const;
192
+
193
+ /** The Error the driver threw, which the Run reports as its panic's defect. */
194
+ const getDefect = (result: Result<unknown, unknown>): Error => {
195
+ assert(!result.ok, "Expected the driver to fail.");
196
+ assertType(AbortError, result.error);
197
+ assert(result.error.reason.type === "PanicAbortReason", "Expected a panic.");
198
+ assertInstanceOf(result.error.reason.defect, Error);
199
+ return result.error.reason.defect;
200
+ };
201
+
202
+ describe("createWasmSqliteDriver", () => {
203
+ it("validates its path, /evolu1.db, as a SqliteVfsPath", () => {
204
+ assertEqual(fake.sqliteVfsPaths, ["/evolu1.db"]);
205
+ });
278
206
 
279
- await using run = testCreateRun();
280
- using _driver = await run.ok(
281
- createWasmSqliteDriver(Name.orThrow("MockMemory"), { mode: "memory" }),
282
- );
207
+ it("opens /evolu1.db of the pool in .<name>, without waiting for held files", async () => {
208
+ await using _setup = await setupDriver();
283
209
 
284
- assertEqual(sqliteMock.sqlite3.installOpfsSAHPoolVfs.mock.callCount(), 0);
285
- assertEqual(sqliteMock.state.createdDatabases[0]?.filename, ":memory:");
286
- assertEqual(opfsMock.state.getDirectoryCount, 0);
210
+ assertEqual(fake.state.events, ["open pool .Test", "open File /evolu1.db"]);
211
+ assertEqual(fake.state.poolOptions, [{ directory: [".Test"] }]);
212
+ assertSame(fake.state.poolDeps[0]?.opfsRoot, testOpfsRoot);
213
+ assertSame(fake.state.poolDeps[0]?.sqliteWasm, fake.sqliteWasm);
214
+ assertSame(fake.state.databaseOptions[0]?.vfs, fake.pool);
287
215
  });
288
216
 
289
- it("executes non-prepared query and exports database", async () => {
290
- sqliteMock.reset();
217
+ it("opens /evolu1.db of the pool in .<name> encrypted with the key", async () => {
218
+ await using _setup = await setupDriver({
219
+ mode: "encrypted",
220
+ encryptionKey: testKey,
221
+ });
291
222
 
292
- await using run = testCreateRun();
293
- using driver = await run.ok(
294
- createWasmSqliteDriver(Name.orThrow("MockPlain")),
295
- );
223
+ assertEqual(fake.state.events, [
224
+ "open pool .Test",
225
+ "open EncryptedFile /evolu1.db",
226
+ ]);
227
+ assertSame(fake.state.databaseOptions[0]?.vfs, fake.pool);
228
+ assertSame(fake.state.databaseOptions[0]?.key, testKey);
229
+ });
296
230
 
297
- const result = driver.exec(sql`select ${"row"};`);
298
- const exported = driver.export();
231
+ it("opens a memory database without a pool", async () => {
232
+ await using _setup = await setupDriver({ mode: "memory" });
299
233
 
300
- assertEqual(result, { rows: [{ data: "row" }], changes: 1 });
301
- assertEqualBytes(exported, [1, 2, 3]);
234
+ assertEqual(fake.state.events, ["open Memory "]);
302
235
  });
303
236
 
304
- it("executes prepared query and finalizes statement on dispose", async () => {
305
- sqliteMock.reset();
237
+ it("runs a query without the prepare option once", async () => {
238
+ await using setup = await setupDriver();
239
+ fake.state.events.length = 0;
306
240
 
307
- await using run = testCreateRun();
308
- {
309
- using driver = await run.ok(
310
- createWasmSqliteDriver(Name.orThrow("MockPlain")),
311
- );
241
+ const result = setup.driver.exec(sql`select ${1};`);
312
242
 
313
- const result = driver.exec({
314
- ...sql`select ${"prepared"};`,
315
- options: { prepare: true },
316
- });
243
+ assertEqual(result, { rows: [{ value: "run" }], changes: 1 });
244
+ assertEqual(fake.state.events, ["run select ?; [1]"]);
245
+ });
317
246
 
318
- assertEqual(result, { rows: [{ data: "prepared" }], changes: 1 });
247
+ it("prepares a query with the prepare option once and runs the statement each time", async () => {
248
+ await using setup = await setupDriver();
249
+ fake.state.events.length = 0;
250
+ const query = sql.prepared`select ${1};`;
319
251
 
320
- driver.exec({ ...sql`select 1;`, options: { prepare: true } });
321
- }
252
+ const result = setup.driver.exec(query);
253
+ setup.driver.exec(query);
322
254
 
323
- assertEqual(sqliteMock.state.preparedStatements[0]?.bound, [["prepared"]]);
324
- assertEqual(sqliteMock.state.preparedStatements[0]?.resetCount, 1);
325
- assertEqual(sqliteMock.state.preparedStatements[0]?.finalized, true);
326
- assertEqual(sqliteMock.state.preparedStatements[1]?.bound, []);
327
- assertEqual(sqliteMock.state.preparedStatements[1]?.finalized, true);
255
+ assertEqual(result, { rows: [{ value: "statement" }], changes: 2 });
256
+ assertEqual(fake.state.events, [
257
+ "prepare select ?;",
258
+ "run statement [1]",
259
+ "run statement [1]",
260
+ ]);
328
261
  });
329
262
 
330
- it("closes OPFS database on dispose", async () => {
331
- sqliteMock.reset();
332
-
333
- await using run = testCreateRun();
334
- {
335
- using _driver = await run.ok(
336
- createWasmSqliteDriver(Name.orThrow("MockPlain")),
337
- );
263
+ it("throws SQLite's error with the error as the cause", async () => {
264
+ await using setup = await setupDriver();
265
+ const sqliteError = {
266
+ type: "SqliteError",
267
+ message: "NOT NULL constraint failed: t.name",
268
+ };
269
+ fake.state.runResult = err(sqliteError);
270
+ fake.state.statementRunResult = err(sqliteError);
271
+
272
+ for (const query of [
273
+ sql`insert into t (name) values (null);`,
274
+ sql.prepared`insert into t (name) values (null);`,
275
+ ]) {
276
+ const thrown = (() => {
277
+ try {
278
+ setup.driver.exec(query);
279
+ } catch (error) {
280
+ return error;
281
+ }
282
+ return null;
283
+ })();
284
+ assertInstanceOf(thrown, Error);
285
+ assertSame(thrown.message, "NOT NULL constraint failed: t.name");
286
+ assertSame(thrown.cause, sqliteError);
338
287
  }
288
+ });
339
289
 
340
- assertEqual(sqliteMock.state.closedDatabases, ["file:evolu1.db"]);
341
- assertEqual(sqliteMock.state.pausedVfsNames, ["mock-sahpool"]);
342
- assertEqual(sqliteMock.pool.unlink.mock.callCount(), 0);
290
+ it("throws an error without a message with its type as the message", async () => {
291
+ await using setup = await setupDriver();
292
+ const parameterCountError = {
293
+ type: "SqliteParameterCountError",
294
+ expected: 1,
295
+ actual: 0,
296
+ };
297
+ fake.state.runResult = err(parameterCountError);
298
+
299
+ const thrown = (() => {
300
+ try {
301
+ setup.driver.exec(sql`select 1;`);
302
+ } catch (error) {
303
+ return error;
304
+ }
305
+ return null;
306
+ })();
307
+
308
+ assertInstanceOf(thrown, Error);
309
+ assertSame(thrown.message, "SqliteParameterCountError");
310
+ assertSame(thrown.cause, parameterCountError);
343
311
  });
344
312
 
345
- it("unpauses OPFS SAH-pool database on reopen", async () => {
346
- sqliteMock.reset();
313
+ it("exports the database", async () => {
314
+ await using setup = await setupDriver();
347
315
 
348
- await using run = testCreateRun();
316
+ assertEqualBytes(setup.driver.export(), [1, 2, 3]);
317
+ });
318
+
319
+ it("disposes the statements, then the database, then the pool", async () => {
349
320
  {
350
- using _driver = await run.ok(
351
- createWasmSqliteDriver(Name.orThrow("MockPlain")),
352
- );
321
+ await using setup = await setupDriver();
322
+ setup.driver.exec(sql.prepared`select 1;`);
323
+ fake.state.events.length = 0;
353
324
  }
354
325
 
355
- using _driver = await run.ok(
356
- createWasmSqliteDriver(Name.orThrow("MockPlain")),
357
- );
358
-
359
- assertEqual(sqliteMock.state.unpausedVfsNames, ["mock-sahpool"]);
326
+ assertEqual(fake.state.events, [
327
+ "dispose statement select 1;",
328
+ "close File",
329
+ "dispose pool",
330
+ ]);
360
331
  });
361
332
 
362
- it("deletes plain OPFS database file", async () => {
363
- sqliteMock.reset();
364
-
365
- await using run = testCreateRun();
366
- using driver = await run.ok(
367
- createWasmSqliteDriver(Name.orThrow("MockPlain")),
368
- );
333
+ it("deletes /evolu1.db after closing the database and before disposing the pool", async () => {
334
+ await using setup = await setupDriver({
335
+ mode: "encrypted",
336
+ encryptionKey: testKey,
337
+ });
338
+ fake.state.events.length = 0;
369
339
 
370
- driver.deleteDatabase();
340
+ setup.driver.deleteDatabase();
341
+ setup.driver[Symbol.dispose]();
371
342
 
372
- assertEqual(sqliteMock.state.closedDatabases, ["file:evolu1.db"]);
373
- assertEqual(sqliteMock.state.deletedFilenames, ["/evolu1.db"]);
374
- assertEqual(sqliteMock.state.events, [
375
- "close:file:evolu1.db",
376
- "unlink:/evolu1.db",
377
- "pause:mock-sahpool",
343
+ assertEqual(fake.state.events, [
344
+ "close EncryptedFile",
345
+ "unlink /evolu1.db",
346
+ "dispose pool",
378
347
  ]);
379
348
  });
380
349
 
381
- it("deletes memory database", async () => {
382
- sqliteMock.reset();
350
+ it("deletes a memory database by closing it", async () => {
351
+ await using setup = await setupDriver({ mode: "memory" });
352
+ fake.state.events.length = 0;
383
353
 
384
- await using run = testCreateRun();
385
- using driver = await run.ok(
386
- createWasmSqliteDriver(Name.orThrow("MockMemory"), { mode: "memory" }),
387
- );
354
+ setup.driver.deleteDatabase();
388
355
 
389
- driver.deleteDatabase();
390
-
391
- assertEqual(sqliteMock.state.events, ["close::memory:"]);
392
- assertEqual(sqliteMock.pool.unlink.mock.callCount(), 0);
393
- assertEqual(sqliteMock.pool.pauseVfs.mock.callCount(), 0);
356
+ assertEqual(fake.state.events, ["close Memory"]);
394
357
  });
395
358
 
396
- it("deletes encrypted OPFS database file", async () => {
397
- sqliteMock.reset();
359
+ it("disposes the pool when deleting the file fails, and throws", async () => {
360
+ await using setup = await setupDriver();
361
+ fake.state.events.length = 0;
362
+ const ioError = { type: "SqliteVfsIoError", cause: null };
363
+ fake.state.unlinkResult = err(ioError);
364
+
365
+ const thrown = (() => {
366
+ try {
367
+ setup.driver.deleteDatabase();
368
+ } catch (error) {
369
+ return error;
370
+ }
371
+ return null;
372
+ })();
373
+
374
+ assertInstanceOf(thrown, Error);
375
+ assertSame(thrown.cause, ioError);
376
+ assertEqual(fake.state.events, [
377
+ "close File",
378
+ "unlink /evolu1.db",
379
+ "dispose pool",
380
+ ]);
381
+ });
398
382
 
383
+ it("disposes the pool when the database fails to open, and throws", async () => {
384
+ fake.reset();
385
+ const sqliteError = {
386
+ type: "SqliteError",
387
+ message: "file is not a database",
388
+ };
389
+ fake.state.databaseResult = err(sqliteError);
399
390
  await using run = testCreateRun();
400
- using driver = await run.ok(
401
- createWasmSqliteDriver(Name.orThrow("MockEncrypted"), {
402
- mode: "encrypted",
403
- encryptionKey: EncryptionKey.orThrow(new Uint8Array(32).fill(42)),
404
- }),
405
- );
406
391
 
407
- driver.deleteDatabase();
392
+ const defect = getDefect(
393
+ await run.abortable(
394
+ createWasmSqliteDriver(setupDriverDeps())(testName, {
395
+ mode: "encrypted",
396
+ encryptionKey: testKey,
397
+ }),
398
+ ),
399
+ );
408
400
 
409
- assertEqual(sqliteMock.state.closedDatabases, [
410
- "file:evolu1.db?vfs=multipleciphers-opfs-sahpool",
411
- ]);
412
- assertEqual(sqliteMock.state.deletedFilenames, ["/evolu1.db"]);
413
- assertEqual(sqliteMock.state.events, [
414
- "close:file:evolu1.db?vfs=multipleciphers-opfs-sahpool",
415
- "unlink:/evolu1.db",
416
- "pause:mock-sahpool",
401
+ assertSame(defect.message, "file is not a database");
402
+ assertSame(defect.cause, sqliteError);
403
+ assertEqual(fake.state.events, [
404
+ "open pool .Test",
405
+ "open EncryptedFile /evolu1.db",
406
+ "dispose pool",
417
407
  ]);
418
408
  });
419
409
 
420
- it("configures encrypted OPFS database", async () => {
421
- sqliteMock.reset();
422
-
410
+ it("throws when SQLite failed to load, before opening anything", async () => {
411
+ fake.reset();
412
+ const compileError = { type: "SqliteWasmCompileError", cause: null };
423
413
  await using run = testCreateRun();
424
- using _driver = await run.ok(
425
- createWasmSqliteDriver(Name.orThrow("MockEncrypted"), {
426
- mode: "encrypted",
427
- encryptionKey: EncryptionKey.orThrow(new Uint8Array(32).fill(42)),
428
- }),
429
- );
430
414
 
431
- assertEqual(
432
- sqliteMock.sqlite3.installOpfsSAHPoolVfs.mock.calls.map(
433
- ({ arguments: args }) => args,
415
+ const defect = getDefect(
416
+ await run.abortable(
417
+ createWasmSqliteDriver(setupDriverDeps(err(compileError)))(testName, {
418
+ mode: "memory",
419
+ }),
434
420
  ),
435
- [[{ directory: ".MockEncrypted" }]],
436
- );
437
- assertEqual(
438
- sqliteMock.state.createdDatabases[0]?.filename,
439
- "file:evolu1.db?vfs=multipleciphers-opfs-sahpool",
440
421
  );
441
- assertTrue(
442
- sqliteMock.state.createdDatabases[0]?.execSql[0]?.includes(
443
- "PRAGMA cipher = 'sqlcipher';",
444
- ),
445
- );
446
- });
447
- });
448
422
 
449
- describe("createWasmSqliteDriver held pool files", () => {
450
- for (const errorName of ["InvalidStateError", "NoModificationAllowedError"])
451
- it(`sets up the pool once a file held with ${errorName} is released`, async () => {
452
- sqliteMock.reset();
453
- opfsMock.reset([[], [errorName, errorName]]);
423
+ assertSame(defect.cause, compileError);
424
+ assertEqual(fake.state.events, []);
425
+ });
454
426
 
427
+ for (const poolError of [
428
+ { type: "SahPoolSetupError", cause: null },
429
+ heldError,
430
+ ])
431
+ it(`throws without retrying when the pool fails with ${poolError.type}`, async () => {
432
+ fake.reset();
433
+ fake.state.poolResults.push(err(poolError));
455
434
  await using run = testCreateRun();
456
- const driver = run.ok(createWasmSqliteDriver(Name.orThrow("MockPlain")));
457
- await flushMicrotasks();
458
435
 
459
- assertEqual(opfsMock.state.directoryNames, [".MockPlain", ".opaque"]);
460
- run.deps.time.advance("49ms");
461
- await flushMicrotasks();
462
- assertEqual(sqliteMock.sqlite3.installOpfsSAHPoolVfs.mock.callCount(), 0);
463
-
464
- // The retry delay doubles after each held pass.
465
- run.deps.time.advance("1ms");
466
- await flushMicrotasks();
467
- run.deps.time.advance("99ms");
436
+ const driver = run.abortable(
437
+ createWasmSqliteDriver(setupDriverDeps())(testName),
438
+ );
468
439
  await flushMicrotasks();
469
- assertEqual(sqliteMock.sqlite3.installOpfsSAHPoolVfs.mock.callCount(), 0);
470
-
471
- run.deps.time.advance("1ms");
472
- using _driver = await driver;
440
+ // Longer than a retry would wait.
441
+ run.deps.time.advance("1s");
473
442
 
474
- // The free file opens on each of the three passes, the held one once.
475
- assertEqual(sqliteMock.sqlite3.installOpfsSAHPoolVfs.mock.callCount(), 1);
476
- assertEqual(opfsMock.state.openedAccessHandleCount, 4);
477
- assertEqual(opfsMock.state.closedAccessHandleCount, 4);
443
+ const defect = getDefect(await driver);
444
+ assertSame(defect.message, poolError.type);
445
+ assertSame(defect.cause, poolError);
446
+ assertEqual(fake.state.events, ["open pool .Test"]);
478
447
  });
448
+ });
479
449
 
480
- it("sets up the pool at once when its directory has no files yet", async () => {
481
- sqliteMock.reset();
482
- opfsMock.reset([], { hasOpaqueDirectory: false });
483
-
450
+ describe("createWaitForDatabaseRelease", () => {
451
+ it("opens the pool in .<name>, waiting up to 10 seconds for held files, and disposes it once it opens", async () => {
452
+ fake.reset();
484
453
  await using run = testCreateRun();
485
- using _driver = await run.ok(
486
- createWasmSqliteDriver(Name.orThrow("MockPlain")),
454
+
455
+ const released = await run(
456
+ createWaitForDatabaseRelease(setupDriverDeps())(testName),
487
457
  );
488
458
 
489
- assertEqual(opfsMock.state.directoryNames, [".MockPlain", ".opaque"]);
490
- assertEqual(opfsMock.state.openedAccessHandleCount, 0);
491
- assertEqual(sqliteMock.sqlite3.installOpfsSAHPoolVfs.mock.callCount(), 1);
459
+ assertEqual(released, ok());
460
+ assertEqual(fake.state.events, ["open pool .Test", "dispose pool"]);
461
+ assertEqual(fake.state.poolOptions, [
462
+ { directory: [".Test"], heldTimeout: "10s" },
463
+ ]);
464
+ assertSame(fake.state.poolDeps[0]?.opfsRoot, testOpfsRoot);
465
+ assertSame(fake.state.poolDeps[0]?.sqliteWasm, fake.sqliteWasm);
492
466
  });
493
467
 
494
- it("warns once when pool files stay held for five seconds", async () => {
495
- sqliteMock.reset();
496
- opfsMock.reset([Array.from({ length: 12 }, () => "InvalidStateError")]);
497
-
468
+ it("fails with DatabaseHeldError when the pool is still held, without retrying itself", async () => {
469
+ fake.reset();
470
+ fake.state.poolResults.push(err(heldError));
498
471
  await using run = testCreateRun();
499
- const driver = run.ok(createWasmSqliteDriver(Name.orThrow("MockPlain")));
500
- for (let second = 0; second < 12; second += 1) {
501
- await flushMicrotasks();
502
- run.deps.time.advance("1s");
503
- }
504
- using _driver = await driver;
505
472
 
506
- const warnings = run.deps.console
507
- .getEntriesSnapshot()
508
- .filter((entry) => entry.method === "warn");
473
+ const released = run(
474
+ createWaitForDatabaseRelease(setupDriverDeps())(testName),
475
+ );
476
+ await flushMicrotasks();
477
+ // Longer than a retry would wait.
478
+ run.deps.time.advance("1s");
479
+
509
480
  assertEqual(
510
- warnings.map((entry) => entry.args),
511
- [
512
- [
513
- "Waiting for an ended DbWorker to release the files of database MockPlain.",
514
- ],
515
- ],
481
+ await released,
482
+ err({ type: "DatabaseHeldError", name: testName }),
516
483
  );
484
+ assertEqual(fake.state.events, ["open pool .Test"]);
517
485
  });
518
486
 
519
- it("fails before setting up the pool when a file cannot be opened", async () => {
520
- sqliteMock.reset();
521
- opfsMock.reset([["UnknownError"]]);
487
+ it("throws without retrying when the pool fails otherwise than held", async () => {
488
+ fake.reset();
489
+ const setupError = { type: "SahPoolSetupError", cause: null };
490
+ fake.state.poolResults.push(err(setupError));
491
+ await using run = testCreateRun();
522
492
 
493
+ const defect = getDefect(
494
+ await run.abortable(
495
+ createWaitForDatabaseRelease(setupDriverDeps())(testName),
496
+ ),
497
+ );
498
+
499
+ assertSame(defect.cause, setupError);
500
+ assertEqual(fake.state.events, ["open pool .Test"]);
501
+ });
502
+
503
+ it("throws when SQLite failed to load, before opening anything", async () => {
504
+ fake.reset();
505
+ const compileError = { type: "SqliteWasmCompileError", cause: null };
523
506
  await using run = testCreateRun();
524
- const result = await run.abortable(
525
- createWasmSqliteDriver(Name.orThrow("MockPlain")),
507
+
508
+ const defect = getDefect(
509
+ await run.abortable(
510
+ createWaitForDatabaseRelease(setupDriverDeps(err(compileError)))(
511
+ testName,
512
+ ),
513
+ ),
526
514
  );
527
515
 
528
- assert(!result.ok, "Expected the driver to fail.");
529
- assertType(AbortError, result.error);
530
- assertSame(result.error.reason.type, "PanicAbortReason");
531
- assertEqual(sqliteMock.sqlite3.installOpfsSAHPoolVfs.mock.callCount(), 0);
516
+ assertSame(defect.cause, compileError);
517
+ assertEqual(fake.state.events, []);
518
+ });
519
+ });
520
+
521
+ describe("loadSqliteWasm", () => {
522
+ const setupFetch = () => {
523
+ fake.reset();
524
+ const requests: Array<unknown> = [];
525
+ const responses: Array<Promise<Response>> = [];
526
+ const run = testCreateRun({
527
+ nativeFetch: (input) => {
528
+ requests.push(input);
529
+ const response = Promise.resolve(new Response());
530
+ responses.push(response);
531
+ return response;
532
+ },
533
+ });
534
+ return { requests, responses, run };
535
+ };
536
+
537
+ it("compiles the response of a fetch of sqliteWasmUrl, before it arrives, so it can compile while it downloads", async () => {
538
+ const { requests, responses, run } = setupFetch();
539
+ await using _run = run;
540
+
541
+ const result = await run(loadSqliteWasm);
542
+
543
+ assertEqual(result, ok(fake.sqliteWasm));
544
+ assertEqual(requests, [fake.exports.sqliteWasmUrl]);
545
+ assertEqual(fake.state.createSqliteWasmSources.length, 1);
546
+ assertSame(fake.state.createSqliteWasmSources[0], responses[0]);
547
+ });
548
+
549
+ it("fails as createSqliteWasm fails, such as with SqliteWasmCompileError for a failed fetch", async () => {
550
+ const { run } = setupFetch();
551
+ await using _run = run;
552
+ const compileError = { type: "SqliteWasmCompileError", cause: null };
553
+ fake.state.createSqliteWasmResult = err(compileError);
554
+
555
+ const result = await run(loadSqliteWasm);
556
+
557
+ assertEqual(result, err(compileError));
532
558
  });
533
559
  });