@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.
- package/README.md +4 -0
- package/dist/src/Sqlite.d.ts +71 -2
- package/dist/src/Sqlite.d.ts.map +1 -1
- package/dist/src/Sqlite.js +126 -137
- package/dist/src/local-first/Db.worker.js +12 -3
- package/package.json +4 -4
- package/src/Sqlite.test.ts +446 -420
- package/src/Sqlite.ts +195 -171
- package/src/local-first/Db.worker.ts +17 -3
package/src/Sqlite.test.ts
CHANGED
|
@@ -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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
state.
|
|
62
|
-
return
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
91
|
+
state,
|
|
204
92
|
pool,
|
|
93
|
+
sqliteWasm,
|
|
94
|
+
sqliteVfsPaths,
|
|
205
95
|
reset: () => {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
246
|
-
|
|
148
|
+
const { createWaitForDatabaseRelease, createWasmSqliteDriver, loadSqliteWasm } =
|
|
149
|
+
await import("./Sqlite.ts");
|
|
247
150
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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
|
-
|
|
280
|
-
using
|
|
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(
|
|
285
|
-
assertEqual(
|
|
286
|
-
|
|
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("
|
|
290
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
298
|
-
|
|
231
|
+
it("opens a memory database without a pool", async () => {
|
|
232
|
+
await using _setup = await setupDriver({ mode: "memory" });
|
|
299
233
|
|
|
300
|
-
assertEqual(
|
|
301
|
-
assertEqualBytes(exported, [1, 2, 3]);
|
|
234
|
+
assertEqual(fake.state.events, ["open Memory "]);
|
|
302
235
|
});
|
|
303
236
|
|
|
304
|
-
it("
|
|
305
|
-
|
|
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
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
});
|
|
243
|
+
assertEqual(result, { rows: [{ value: "run" }], changes: 1 });
|
|
244
|
+
assertEqual(fake.state.events, ["run select ?; [1]"]);
|
|
245
|
+
});
|
|
317
246
|
|
|
318
|
-
|
|
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
|
-
|
|
321
|
-
|
|
252
|
+
const result = setup.driver.exec(query);
|
|
253
|
+
setup.driver.exec(query);
|
|
322
254
|
|
|
323
|
-
assertEqual(
|
|
324
|
-
assertEqual(
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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("
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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("
|
|
346
|
-
|
|
313
|
+
it("exports the database", async () => {
|
|
314
|
+
await using setup = await setupDriver();
|
|
347
315
|
|
|
348
|
-
|
|
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
|
|
351
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
326
|
+
assertEqual(fake.state.events, [
|
|
327
|
+
"dispose statement select 1;",
|
|
328
|
+
"close File",
|
|
329
|
+
"dispose pool",
|
|
330
|
+
]);
|
|
360
331
|
});
|
|
361
332
|
|
|
362
|
-
it("deletes
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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(
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
385
|
-
using driver = await run.ok(
|
|
386
|
-
createWasmSqliteDriver(Name.orThrow("MockMemory"), { mode: "memory" }),
|
|
387
|
-
);
|
|
354
|
+
setup.driver.deleteDatabase();
|
|
388
355
|
|
|
389
|
-
|
|
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("
|
|
397
|
-
|
|
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
|
-
|
|
392
|
+
const defect = getDefect(
|
|
393
|
+
await run.abortable(
|
|
394
|
+
createWasmSqliteDriver(setupDriverDeps())(testName, {
|
|
395
|
+
mode: "encrypted",
|
|
396
|
+
encryptionKey: testKey,
|
|
397
|
+
}),
|
|
398
|
+
),
|
|
399
|
+
);
|
|
408
400
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
"
|
|
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("
|
|
421
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
(
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
assertEqual(
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
486
|
-
|
|
454
|
+
|
|
455
|
+
const released = await run(
|
|
456
|
+
createWaitForDatabaseRelease(setupDriverDeps())(testName),
|
|
487
457
|
);
|
|
488
458
|
|
|
489
|
-
assertEqual(
|
|
490
|
-
assertEqual(
|
|
491
|
-
assertEqual(
|
|
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("
|
|
495
|
-
|
|
496
|
-
|
|
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
|
|
507
|
-
|
|
508
|
-
|
|
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
|
-
|
|
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("
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
|
|
525
|
-
|
|
507
|
+
|
|
508
|
+
const defect = getDefect(
|
|
509
|
+
await run.abortable(
|
|
510
|
+
createWaitForDatabaseRelease(setupDriverDeps(err(compileError)))(
|
|
511
|
+
testName,
|
|
512
|
+
),
|
|
513
|
+
),
|
|
526
514
|
);
|
|
527
515
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
});
|