@useblacksmith/vitest 0.3.0 → 0.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 +2 -2
- package/dist/affected.d.ts +27 -9
- package/dist/affected.js +294 -46
- package/dist/browser.d.ts +11 -0
- package/dist/browser.js +57 -0
- package/dist/cache.d.ts +9 -0
- package/dist/cache.js +12 -3
- package/dist/fs-recorder.d.ts +18 -0
- package/dist/fs-recorder.js +104 -0
- package/dist/git.d.ts +7 -0
- package/dist/git.js +55 -9
- package/dist/index.js +12 -1
- package/dist/record-child.d.ts +1 -0
- package/dist/record-child.js +60 -0
- package/dist/record-reads.js +278 -83
- package/package.json +6 -2
- package/dist/bloom.d.ts +0 -23
- package/dist/bloom.js +0 -70
package/dist/record-reads.js
CHANGED
|
@@ -1,107 +1,302 @@
|
|
|
1
1
|
import { createRequire, syncBuiltinESMExports } from 'node:module';
|
|
2
|
+
import os from 'node:os';
|
|
2
3
|
import path from 'node:path';
|
|
3
|
-
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { afterAll } from 'vitest';
|
|
7
|
+
import { BRAND, CHILD_RECORD_ENV, copyProperties, isIgnored, wrapReads, } from './fs-recorder.js';
|
|
4
8
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* Runs as the first setup file of every test file. Vitest records what a
|
|
10
|
+
* test imports; this records the files it reads through `node:fs` and the
|
|
11
|
+
* node processes and worker threads it starts (their modules and reads, via
|
|
12
|
+
* `record-child`), adding each to the dependency set vitest keeps per test
|
|
13
|
+
* file.
|
|
14
|
+
*
|
|
15
|
+
* The functions on the builtin are replaced, so every specifier that reaches
|
|
16
|
+
* them (`fs`, `node:fs`, `fs/promises`, `require`) is covered.
|
|
11
17
|
*/
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const fs = createRequire(import.meta.url)('node:fs');
|
|
17
|
-
const BRAND = Symbol.for('blacksmith.recordReads');
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const childProcess = require('node:child_process');
|
|
21
|
+
const workerThreads = require('node:worker_threads');
|
|
18
22
|
const CONTEXT = Symbol.for('blacksmith.recordReads.context');
|
|
19
|
-
// `
|
|
20
|
-
// builtin
|
|
21
|
-
// a slot the latest evaluation points at the current context's global.
|
|
23
|
+
// The `vm` pools load this file in one context and run tests in another; the
|
|
24
|
+
// builtin is shared, so it carries the context that holds the worker state.
|
|
22
25
|
const shared = fs;
|
|
23
26
|
shared[CONTEXT] = globalThis;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
return null;
|
|
27
|
+
const ORIGINALS = Symbol.for('blacksmith.recordReads.originals');
|
|
28
|
+
// Unwrapped, so this module's own file handling records nothing; kept on the
|
|
29
|
+
// builtin because a later evaluation in the same process finds `fs` wrapped.
|
|
30
|
+
const { existsSync: exists, mkdtempSync: mkdtemp, readFileSync: read, statSync: stat, unlinkSync: unlink, } = (shared[ORIGINALS] ??= {
|
|
31
|
+
existsSync: fs.existsSync,
|
|
32
|
+
mkdtempSync: fs.mkdtempSync,
|
|
33
|
+
readFileSync: fs.readFileSync,
|
|
34
|
+
statSync: fs.statSync,
|
|
35
|
+
unlinkSync: fs.unlinkSync,
|
|
36
|
+
});
|
|
37
|
+
function workerState() {
|
|
38
|
+
return shared[CONTEXT]?.__vitest_worker__;
|
|
38
39
|
}
|
|
39
|
-
function
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (file === null)
|
|
40
|
+
function dependencies() {
|
|
41
|
+
return workerState()?.dependencies;
|
|
42
|
+
}
|
|
43
|
+
function record(file, cwd = process.cwd()) {
|
|
44
|
+
const set = dependencies();
|
|
45
|
+
if (!set)
|
|
46
46
|
return;
|
|
47
|
-
const absolute = path.resolve(file);
|
|
48
|
-
if (absolute
|
|
47
|
+
const absolute = path.resolve(cwd, file);
|
|
48
|
+
if (isIgnored(absolute))
|
|
49
49
|
return;
|
|
50
|
-
|
|
50
|
+
set.add(absolute);
|
|
51
|
+
}
|
|
52
|
+
wrapReads(fs, (file) => record(file));
|
|
53
|
+
/** Beside this module; `.ts` when the plugin runs from source (its own test fixtures). */
|
|
54
|
+
const recordChild = fileURLToPath(new URL(import.meta.url.endsWith('.ts') ? './record-child.ts' : './record-child.js', import.meta.url));
|
|
55
|
+
let recordDir;
|
|
56
|
+
let spawned = 0;
|
|
57
|
+
/** Where one child (and its descendants) appends what it loaded. */
|
|
58
|
+
function recordFile() {
|
|
59
|
+
recordDir ??= mkdtemp(path.join(os.tmpdir(), 'blacksmith-child-'));
|
|
60
|
+
return path.join(recordDir, `${process.pid}-${spawned++}.txt`);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* `given` (the call's `env`, or the parent's when it had none) with the
|
|
64
|
+
* recorder preloaded and a file to report to; `null` when the caller asked
|
|
65
|
+
* for a child free of node options with `env: { ...process.env, NODE_OPTIONS:
|
|
66
|
+
* undefined }`, which is left alone.
|
|
67
|
+
*/
|
|
68
|
+
function recordedEnv(given) {
|
|
69
|
+
const cleared = given !== undefined &&
|
|
70
|
+
Object.prototype.hasOwnProperty.call(given, 'NODE_OPTIONS') &&
|
|
71
|
+
given.NODE_OPTIONS === undefined;
|
|
72
|
+
if (cleared)
|
|
73
|
+
return null;
|
|
74
|
+
const env = { ...(given ?? process.env) };
|
|
75
|
+
const file = recordFile();
|
|
76
|
+
env[CHILD_RECORD_ENV] = file;
|
|
77
|
+
env.NODE_OPTIONS = [
|
|
78
|
+
env.NODE_OPTIONS,
|
|
79
|
+
`--import ${pathToFileURL(recordChild).href}`,
|
|
80
|
+
]
|
|
81
|
+
.filter(Boolean)
|
|
82
|
+
.join(' ');
|
|
83
|
+
return { env, file };
|
|
84
|
+
}
|
|
85
|
+
const OUTSTANDING = Symbol.for('blacksmith.recordReads.outstanding');
|
|
86
|
+
// On the shared builtin, like the context: the `vm` pools evaluate this file
|
|
87
|
+
// once per test file while the wrapped `child_process` and `Worker` keep the
|
|
88
|
+
// first evaluation's closures, so every evaluation must see one set.
|
|
89
|
+
/** Children that have not exited yet, each still charged to the test file that started it. */
|
|
90
|
+
function outstanding() {
|
|
91
|
+
return (shared[OUTSTANDING] ??= new Set());
|
|
92
|
+
}
|
|
93
|
+
function startChild(file, cwd) {
|
|
94
|
+
const child = { file, cwd, test: workerState()?.filepath };
|
|
95
|
+
if (file !== null)
|
|
96
|
+
outstanding().add(child);
|
|
97
|
+
return child;
|
|
98
|
+
}
|
|
99
|
+
/** The options object of a `child_process` call, inserted when the call had none. */
|
|
100
|
+
function optionsOf(args) {
|
|
101
|
+
let index = args.findIndex((arg, i) => i > 0 && typeof arg === 'object' && arg !== null && !Array.isArray(arg));
|
|
102
|
+
if (index === -1) {
|
|
103
|
+
index = Array.isArray(args[1]) ? 2 : 1;
|
|
104
|
+
args.splice(index, 0, {});
|
|
105
|
+
}
|
|
106
|
+
const options = { ...args[index] };
|
|
107
|
+
args[index] = options;
|
|
108
|
+
return options;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Prepares a `child_process` call: the child gets the recorder preloaded and
|
|
112
|
+
* a file to report to, and the command plus every argument naming a file is
|
|
113
|
+
* recorded right away, which is all a non-node child (a shell script, a
|
|
114
|
+
* binary) can contribute.
|
|
115
|
+
*/
|
|
116
|
+
function instrument(args) {
|
|
117
|
+
if (!dependencies())
|
|
118
|
+
return startChild(null, process.cwd());
|
|
119
|
+
const options = optionsOf(args);
|
|
120
|
+
const cwd = typeof options.cwd === 'string'
|
|
121
|
+
? path.resolve(options.cwd)
|
|
122
|
+
: options.cwd instanceof URL
|
|
123
|
+
? fileURLToPath(options.cwd)
|
|
124
|
+
: process.cwd();
|
|
125
|
+
const recorded = recordedEnv(options.env);
|
|
126
|
+
if (recorded)
|
|
127
|
+
options.env = recorded.env;
|
|
128
|
+
const words = [
|
|
129
|
+
...(typeof args[0] === 'string' ? args[0].split(/\s+/) : []),
|
|
130
|
+
...(Array.isArray(args[1]) ? args[1] : []),
|
|
131
|
+
];
|
|
132
|
+
for (const word of words) {
|
|
133
|
+
if (typeof word !== 'string' || word === '')
|
|
134
|
+
continue;
|
|
135
|
+
const candidate = path.resolve(cwd, word);
|
|
136
|
+
try {
|
|
137
|
+
if (exists(candidate) && stat(candidate).isFile())
|
|
138
|
+
record(candidate);
|
|
139
|
+
}
|
|
140
|
+
catch { }
|
|
141
|
+
}
|
|
142
|
+
return startChild(recorded?.file ?? null, cwd);
|
|
51
143
|
}
|
|
52
|
-
/**
|
|
53
|
-
function
|
|
54
|
-
|
|
55
|
-
const file = toPath(target);
|
|
56
|
-
if (file === null)
|
|
144
|
+
/** What the child has reported so far, relative to its working directory, while its test file is the one running. */
|
|
145
|
+
function recordReported(child) {
|
|
146
|
+
if (child.file === null || child.test !== workerState()?.filepath)
|
|
57
147
|
return;
|
|
58
|
-
let
|
|
148
|
+
let reported;
|
|
59
149
|
try {
|
|
60
|
-
|
|
150
|
+
reported = read(child.file, 'utf8');
|
|
61
151
|
}
|
|
62
152
|
catch {
|
|
63
153
|
return;
|
|
64
154
|
}
|
|
65
|
-
for (const
|
|
66
|
-
|
|
155
|
+
for (const line of reported.split('\n'))
|
|
156
|
+
if (line !== '')
|
|
157
|
+
record(line, child.cwd);
|
|
67
158
|
}
|
|
68
|
-
|
|
69
|
-
|
|
159
|
+
/** Records what the exited child reported and forgets it. */
|
|
160
|
+
function collect(child) {
|
|
161
|
+
if (child.file === null)
|
|
162
|
+
return;
|
|
163
|
+
recordReported(child);
|
|
164
|
+
try {
|
|
165
|
+
unlink(child.file);
|
|
166
|
+
}
|
|
167
|
+
catch { }
|
|
168
|
+
outstanding().delete(child);
|
|
169
|
+
}
|
|
170
|
+
// Vitest ships the test file's dependency set as soon as its hooks are done,
|
|
171
|
+
// so a child still running then (a server the test tears down, a worker it
|
|
172
|
+
// never joins) reports what it has loaded so far here. Whether this hook
|
|
173
|
+
// runs before or after the file's own `afterAll` (`sequence.hooks`), the
|
|
174
|
+
// child's exit still reports to this file while it is the one running.
|
|
175
|
+
afterAll(() => {
|
|
176
|
+
const current = workerState()?.filepath;
|
|
177
|
+
for (const child of outstanding()) {
|
|
178
|
+
if (child.test !== current)
|
|
179
|
+
continue;
|
|
180
|
+
recordReported(child);
|
|
181
|
+
outstanding().delete(child);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
function wrapChildProcess(name, sync) {
|
|
185
|
+
const target = childProcess;
|
|
186
|
+
const original = target[name];
|
|
70
187
|
if (typeof original !== 'function' || BRAND in original)
|
|
71
188
|
return;
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
189
|
+
const call = original;
|
|
190
|
+
const wrapped = sync
|
|
191
|
+
? function (...args) {
|
|
192
|
+
const child = instrument(args);
|
|
193
|
+
try {
|
|
194
|
+
return call.apply(this, args);
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
collect(child);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
: function (...args) {
|
|
201
|
+
const child = instrument(args);
|
|
202
|
+
const process = call.apply(this, args);
|
|
203
|
+
// Only `exit`: an `error` listener would keep a spawn failure from
|
|
204
|
+
// surfacing as the uncaught error it is. A child that never ran
|
|
205
|
+
// reported nothing and is forgotten with its test file.
|
|
206
|
+
if (typeof process?.once === 'function') {
|
|
207
|
+
process.once('exit', () => collect(child));
|
|
208
|
+
}
|
|
209
|
+
return process;
|
|
210
|
+
};
|
|
211
|
+
// Node's own promisified `exec`/`execFile` call the unwrapped function;
|
|
212
|
+
// `promisify(execFile)` must go through the wrapper to reach the child.
|
|
213
|
+
if (promisify.custom in original) {
|
|
214
|
+
Object.defineProperty(wrapped, promisify.custom, {
|
|
215
|
+
value: (...args) => {
|
|
216
|
+
let child;
|
|
217
|
+
const promise = new Promise((resolve, reject) => {
|
|
218
|
+
child = wrapped(...args, (error, stdout, stderr) => error
|
|
219
|
+
? reject(Object.assign(error, { stdout, stderr }))
|
|
220
|
+
: resolve({ stdout, stderr }));
|
|
221
|
+
});
|
|
222
|
+
return Object.defineProperty(promise, 'child', {
|
|
223
|
+
value: child,
|
|
224
|
+
enumerable: true,
|
|
225
|
+
});
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
copyProperties(wrapped, original);
|
|
79
230
|
Object.defineProperty(wrapped, BRAND, { value: true });
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
231
|
+
target[name] = wrapped;
|
|
232
|
+
}
|
|
233
|
+
for (const name of ['spawn', 'exec', 'execFile', 'fork']) {
|
|
234
|
+
wrapChildProcess(name, false);
|
|
235
|
+
}
|
|
236
|
+
for (const name of ['spawnSync', 'execSync', 'execFileSync']) {
|
|
237
|
+
wrapChildProcess(name, true);
|
|
238
|
+
}
|
|
239
|
+
/** The script a worker runs, when it is a file; `null` for `eval`, `data:` and `node:` sources. */
|
|
240
|
+
function workerScript(filename, options) {
|
|
241
|
+
if (options?.eval === true)
|
|
242
|
+
return null;
|
|
243
|
+
if (filename instanceof URL) {
|
|
244
|
+
return filename.protocol === 'file:' ? fileURLToPath(filename) : null;
|
|
245
|
+
}
|
|
246
|
+
return filename.startsWith('data:') || filename.startsWith('node:')
|
|
247
|
+
? null
|
|
248
|
+
: filename;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* A worker thread has its own `fs` builtin and module registry, so it is
|
|
252
|
+
* recorded like a child: the preload comes through its `env` (a worker reads
|
|
253
|
+
* `NODE_OPTIONS` from there), and the script it runs is recorded right away.
|
|
254
|
+
* A worker sharing the parent's environment (`env: SHARE_ENV`) contributes
|
|
255
|
+
* only its script, and so does one whose `NODE_OPTIONS` node refuses to
|
|
256
|
+
* change (the parent's holds an option a worker's may not carry, `--title`
|
|
257
|
+
* say): it is then started exactly as asked.
|
|
258
|
+
*/
|
|
259
|
+
function wrapWorker() {
|
|
260
|
+
const target = workerThreads;
|
|
261
|
+
const Original = workerThreads.Worker;
|
|
262
|
+
if (BRAND in Original)
|
|
263
|
+
return;
|
|
264
|
+
function RecordedWorker(filename, options) {
|
|
265
|
+
const construct = (given) => Reflect.construct(Original, [filename, given], new.target ?? RecordedWorker);
|
|
266
|
+
if (!dependencies())
|
|
267
|
+
return construct(options);
|
|
268
|
+
const script = workerScript(filename, options);
|
|
269
|
+
if (script !== null)
|
|
270
|
+
record(script);
|
|
271
|
+
const recorded = options?.env === workerThreads.SHARE_ENV
|
|
272
|
+
? null
|
|
273
|
+
: recordedEnv(options?.env);
|
|
274
|
+
if (!recorded)
|
|
275
|
+
return construct(options);
|
|
276
|
+
const child = startChild(recorded.file, process.cwd());
|
|
277
|
+
let worker;
|
|
278
|
+
try {
|
|
279
|
+
worker = construct({ ...options, env: recorded.env });
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
outstanding().delete(child);
|
|
283
|
+
if (error.code !== 'ERR_WORKER_INVALID_EXEC_ARGV') {
|
|
284
|
+
throw error;
|
|
285
|
+
}
|
|
286
|
+
return construct(options);
|
|
287
|
+
}
|
|
288
|
+
// `exit` follows an uncaught error in the worker too; an `error`
|
|
289
|
+
// listener here would swallow that error instead of letting Vitest report it.
|
|
290
|
+
worker.once('exit', () => collect(child));
|
|
291
|
+
return worker;
|
|
292
|
+
}
|
|
293
|
+
RecordedWorker.prototype = Original.prototype;
|
|
294
|
+
Object.setPrototypeOf(RecordedWorker, Original);
|
|
295
|
+
copyProperties(RecordedWorker, Original);
|
|
296
|
+
Object.defineProperty(RecordedWorker, BRAND, { value: true });
|
|
297
|
+
target.Worker = RecordedWorker;
|
|
298
|
+
}
|
|
299
|
+
wrapWorker();
|
|
105
300
|
// `import { readFileSync } from 'node:fs'` binds the builtin's ESM namespace,
|
|
106
301
|
// which only sees the wrapped functions once the namespace is resynced; the
|
|
107
302
|
// vm pools keep their own per-context namespaces and expose their resync.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@useblacksmith/vitest",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Runs only the Vitest tests affected since the last successful run, with Blacksmith-backed caching",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
"build": "tsc -p tsconfig.build.json",
|
|
22
22
|
"prepack": "npm run build",
|
|
23
23
|
"check:types": "tsc --noEmit",
|
|
24
|
-
"test": "vitest run"
|
|
24
|
+
"test": "vitest run",
|
|
25
|
+
"release": "scripts/release.sh"
|
|
25
26
|
},
|
|
26
27
|
"peerDependencies": {
|
|
27
28
|
"vitest": ">=5.1.0"
|
|
@@ -29,6 +30,9 @@
|
|
|
29
30
|
"devDependencies": {
|
|
30
31
|
"@types/node": "^26.6.6",
|
|
31
32
|
"@types/picomatch": "^4.0.3",
|
|
33
|
+
"@vitest/browser-playwright": "^5.1.0",
|
|
34
|
+
"@vitest/coverage-v8": "^5.1.0",
|
|
35
|
+
"playwright": "^1.65.0",
|
|
32
36
|
"typescript": "^7.0.2",
|
|
33
37
|
"vite": "^8.3.5",
|
|
34
38
|
"vitest": "^5.1.0"
|
package/dist/bloom.d.ts
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
/** Serialized form stored in the snapshot: `m` bits, `k` hashes, bits base64-encoded. */
|
|
2
|
-
export interface BloomJSON {
|
|
3
|
-
m: number;
|
|
4
|
-
k: number;
|
|
5
|
-
bits: string;
|
|
6
|
-
}
|
|
7
|
-
/**
|
|
8
|
-
* Set membership for the test files a snapshot vouches for. A false positive
|
|
9
|
-
* would skip a test the shard never covered, so the default sizing keeps the
|
|
10
|
-
* rate at one in a billion: ~43 bits per entry.
|
|
11
|
-
*/
|
|
12
|
-
export declare class BloomFilter {
|
|
13
|
-
readonly m: number;
|
|
14
|
-
readonly k: number;
|
|
15
|
-
private readonly bits;
|
|
16
|
-
private constructor();
|
|
17
|
-
static from(keys: Iterable<string>, falsePositiveRate?: number): BloomFilter;
|
|
18
|
-
static parse(value: unknown): BloomFilter | null;
|
|
19
|
-
has(key: string): boolean;
|
|
20
|
-
toJSON(): BloomJSON;
|
|
21
|
-
/** Kirsch–Mitzenmacher double hashing over the two 64-bit halves of sha256. */
|
|
22
|
-
private indexes;
|
|
23
|
-
}
|
package/dist/bloom.js
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
/**
|
|
3
|
-
* Set membership for the test files a snapshot vouches for. A false positive
|
|
4
|
-
* would skip a test the shard never covered, so the default sizing keeps the
|
|
5
|
-
* rate at one in a billion: ~43 bits per entry.
|
|
6
|
-
*/
|
|
7
|
-
export class BloomFilter {
|
|
8
|
-
m;
|
|
9
|
-
k;
|
|
10
|
-
bits;
|
|
11
|
-
constructor(m, k, bits) {
|
|
12
|
-
this.m = m;
|
|
13
|
-
this.k = k;
|
|
14
|
-
this.bits = bits;
|
|
15
|
-
}
|
|
16
|
-
static from(keys, falsePositiveRate = 1e-9) {
|
|
17
|
-
const entries = [...keys];
|
|
18
|
-
const n = Math.max(entries.length, 1);
|
|
19
|
-
const m = Math.ceil((-n * Math.log(falsePositiveRate)) / Math.LN2 ** 2);
|
|
20
|
-
const k = Math.max(1, Math.round((m / n) * Math.LN2));
|
|
21
|
-
const filter = new BloomFilter(m, k, new Uint8Array(Math.ceil(m / 8)));
|
|
22
|
-
for (const key of entries) {
|
|
23
|
-
for (const index of filter.indexes(key)) {
|
|
24
|
-
const byte = index >> 3;
|
|
25
|
-
filter.bits[byte] = (filter.bits[byte] ?? 0) | (1 << (index & 7));
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
return filter;
|
|
29
|
-
}
|
|
30
|
-
static parse(value) {
|
|
31
|
-
if (typeof value !== 'object' || value === null)
|
|
32
|
-
return null;
|
|
33
|
-
const { m, k, bits } = value;
|
|
34
|
-
if (!Number.isInteger(m) ||
|
|
35
|
-
!Number.isInteger(k) ||
|
|
36
|
-
m < 1 ||
|
|
37
|
-
k < 1 ||
|
|
38
|
-
typeof bits !== 'string') {
|
|
39
|
-
return null;
|
|
40
|
-
}
|
|
41
|
-
const bytes = Buffer.from(bits, 'base64');
|
|
42
|
-
if (bytes.length !== Math.ceil(m / 8))
|
|
43
|
-
return null;
|
|
44
|
-
return new BloomFilter(m, k, new Uint8Array(bytes));
|
|
45
|
-
}
|
|
46
|
-
has(key) {
|
|
47
|
-
for (const index of this.indexes(key)) {
|
|
48
|
-
if (((this.bits[index >> 3] ?? 0) & (1 << (index & 7))) === 0)
|
|
49
|
-
return false;
|
|
50
|
-
}
|
|
51
|
-
return true;
|
|
52
|
-
}
|
|
53
|
-
toJSON() {
|
|
54
|
-
return {
|
|
55
|
-
m: this.m,
|
|
56
|
-
k: this.k,
|
|
57
|
-
bits: Buffer.from(this.bits).toString('base64'),
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
/** Kirsch–Mitzenmacher double hashing over the two 64-bit halves of sha256. */
|
|
61
|
-
*indexes(key) {
|
|
62
|
-
const digest = createHash('sha256').update(key).digest();
|
|
63
|
-
const h1 = digest.readBigUInt64BE(0);
|
|
64
|
-
const h2 = digest.readBigUInt64BE(8) | 1n;
|
|
65
|
-
const m = BigInt(this.m);
|
|
66
|
-
for (let i = 0n; i < BigInt(this.k); i++) {
|
|
67
|
-
yield Number((h1 + i * h2) % m);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
}
|