@pnpm/store.index 1100.0.0 → 1100.2.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/lib/index.d.ts +76 -7
- package/lib/index.js +159 -16
- package/package.json +10 -5
package/lib/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { DatabaseSync as DatabaseSyncType, StatementSync } from 'node:sqlite';
|
|
1
2
|
/**
|
|
2
3
|
* Pack data for storage using msgpackr.
|
|
3
4
|
* Use this when data will be packed in one thread and stored by another,
|
|
@@ -13,24 +14,55 @@ export declare function storeIndexKey(integrity: string, pkgId: string): string;
|
|
|
13
14
|
export declare function gitHostedStoreIndexKey(pkgId: string, opts: {
|
|
14
15
|
built: boolean;
|
|
15
16
|
}): string;
|
|
17
|
+
/**
|
|
18
|
+
* Pick the store index key for a tarball-shaped resolution.
|
|
19
|
+
*
|
|
20
|
+
* Git-hosted tarballs (`resolution.gitHosted === true`) are addressed by
|
|
21
|
+
* `gitHostedStoreIndexKey(pkgId, { built })` — their cached content depends
|
|
22
|
+
* on whether build scripts ran during fetch (`preparePackage`), so the
|
|
23
|
+
* `built` dimension is part of the key. The integrity-only key would
|
|
24
|
+
* collapse the built/not-built variants into one slot.
|
|
25
|
+
*
|
|
26
|
+
* Tarballs with integrity that aren't git-hosted are addressed by
|
|
27
|
+
* `storeIndexKey(integrity, pkgId)`.
|
|
28
|
+
*
|
|
29
|
+
* Resolutions that have neither flag fall through to
|
|
30
|
+
* `gitHostedStoreIndexKey` — these are typically lockfile entries written
|
|
31
|
+
* by older pnpm versions that lacked integrity.
|
|
32
|
+
*/
|
|
33
|
+
export declare function pickStoreIndexKey(resolution: {
|
|
34
|
+
gitHosted?: boolean;
|
|
35
|
+
integrity?: string;
|
|
36
|
+
}, pkgId: string, opts: {
|
|
37
|
+
built: boolean;
|
|
38
|
+
}): string;
|
|
16
39
|
/**
|
|
17
40
|
* Close all open StoreIndex instances.
|
|
18
41
|
* Useful in tests that need to remove the store directory.
|
|
19
42
|
*/
|
|
20
43
|
export declare function closeAllStoreIndexes(): void;
|
|
21
44
|
export declare class StoreIndex {
|
|
22
|
-
|
|
23
|
-
|
|
45
|
+
protected db: DatabaseSyncType;
|
|
46
|
+
protected closed: boolean;
|
|
24
47
|
private pendingWrites;
|
|
25
48
|
private flushScheduled;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
49
|
+
protected stmtGet: StatementSync;
|
|
50
|
+
protected stmtSet: StatementSync;
|
|
51
|
+
protected stmtDel: StatementSync;
|
|
52
|
+
protected stmtHas: StatementSync;
|
|
53
|
+
protected stmtAll: StatementSync;
|
|
54
|
+
protected stmtKeys: StatementSync;
|
|
31
55
|
private readonly exitHandler;
|
|
32
56
|
constructor(storeDir: string);
|
|
57
|
+
/** Open the SQLite connection. Overridden by {@link ReadOnlyStoreIndex}. */
|
|
58
|
+
protected openDatabase(storeDir: string): void;
|
|
59
|
+
/** Prepare the prepared statements. Overridden by {@link ReadOnlyStoreIndex} to skip the write statements. */
|
|
60
|
+
protected prepareStatements(): void;
|
|
33
61
|
get(key: string): unknown | undefined;
|
|
62
|
+
/**
|
|
63
|
+
* Get the raw msgpack-encoded buffer for a key without decoding.
|
|
64
|
+
*/
|
|
65
|
+
getRaw(key: string): Uint8Array | undefined;
|
|
34
66
|
set(key: string, data: unknown): void;
|
|
35
67
|
delete(key: string): boolean;
|
|
36
68
|
has(key: string): boolean;
|
|
@@ -39,6 +71,11 @@ export declare class StoreIndex {
|
|
|
39
71
|
* Yields [key, data] pairs where key is `integrity\tpkgId`.
|
|
40
72
|
*/
|
|
41
73
|
entries(): IterableIterator<[string, unknown]>;
|
|
74
|
+
/**
|
|
75
|
+
* Iterate over all index keys without decoding values.
|
|
76
|
+
* Much faster than entries() when only keys are needed.
|
|
77
|
+
*/
|
|
78
|
+
keys(): IterableIterator<string>;
|
|
42
79
|
/**
|
|
43
80
|
* Queue pre-packed writes to be flushed on the next tick.
|
|
44
81
|
* Used by the fetch phase for throughput.
|
|
@@ -64,5 +101,37 @@ export declare class StoreIndex {
|
|
|
64
101
|
* then VACUUM to reclaim disk space.
|
|
65
102
|
*/
|
|
66
103
|
deleteMany(keys: string[]): void;
|
|
104
|
+
checkpoint(): void;
|
|
67
105
|
close(): void;
|
|
106
|
+
/** Run `PRAGMA optimize` before closing. Overridden by {@link ReadOnlyStoreIndex} to skip it (the DB is immutable). */
|
|
107
|
+
protected optimizeBeforeClose(): void;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* A {@link StoreIndex} opened read-only for installs against a store on a
|
|
111
|
+
* read-only filesystem (`frozenStore`). The index is a WAL-mode database, and a
|
|
112
|
+
* normal WAL read creates an `index.db-shm` sidecar in the store directory —
|
|
113
|
+
* which fails on a read-only directory and surfaces as "attempt to write a
|
|
114
|
+
* readonly database" on the first query. Opening via the SQLite `immutable=1`
|
|
115
|
+
* URI tells SQLite the file cannot change, so it bypasses the WAL/shm machinery
|
|
116
|
+
* and reads the file directly, creating no sidecars.
|
|
117
|
+
*
|
|
118
|
+
* The store is assumed complete; every write is a programming error and throws.
|
|
119
|
+
*/
|
|
120
|
+
export declare class ReadOnlyStoreIndex extends StoreIndex {
|
|
121
|
+
protected openDatabase(storeDir: string): void;
|
|
122
|
+
protected prepareStatements(): void;
|
|
123
|
+
protected optimizeBeforeClose(): void;
|
|
124
|
+
set(_key: string, _data: unknown): void;
|
|
125
|
+
delete(_key: string): boolean;
|
|
126
|
+
queueWrites(_writes: Array<{
|
|
127
|
+
key: string;
|
|
128
|
+
buffer: Uint8Array;
|
|
129
|
+
}>): void;
|
|
130
|
+
setRawMany(_entries: Array<{
|
|
131
|
+
key: string;
|
|
132
|
+
buffer: Uint8Array;
|
|
133
|
+
}>): void;
|
|
134
|
+
deleteMany(_keys: string[]): void;
|
|
135
|
+
checkpoint(): void;
|
|
136
|
+
private throwReadOnly;
|
|
68
137
|
}
|
package/lib/index.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import { createRequire } from 'node:module';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { PnpmError } from '@pnpm/error';
|
|
3
5
|
import { Packr } from 'msgpackr';
|
|
6
|
+
const FROZEN_STORE_WRITE_MESSAGE = 'Cannot write to the package store because frozenStore is enabled (the store is opened read-only). This indicates the store is missing content the install needs.';
|
|
4
7
|
// Use createRequire to load node:sqlite because it is a prefix-only builtin
|
|
5
8
|
// that Jest's ESM module resolver cannot handle.
|
|
6
9
|
const req = createRequire(import.meta.url);
|
|
@@ -54,6 +57,28 @@ export function storeIndexKey(integrity, pkgId) {
|
|
|
54
57
|
export function gitHostedStoreIndexKey(pkgId, opts) {
|
|
55
58
|
return storeIndexKey(pkgId, opts.built ? 'built' : 'not-built');
|
|
56
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Pick the store index key for a tarball-shaped resolution.
|
|
62
|
+
*
|
|
63
|
+
* Git-hosted tarballs (`resolution.gitHosted === true`) are addressed by
|
|
64
|
+
* `gitHostedStoreIndexKey(pkgId, { built })` — their cached content depends
|
|
65
|
+
* on whether build scripts ran during fetch (`preparePackage`), so the
|
|
66
|
+
* `built` dimension is part of the key. The integrity-only key would
|
|
67
|
+
* collapse the built/not-built variants into one slot.
|
|
68
|
+
*
|
|
69
|
+
* Tarballs with integrity that aren't git-hosted are addressed by
|
|
70
|
+
* `storeIndexKey(integrity, pkgId)`.
|
|
71
|
+
*
|
|
72
|
+
* Resolutions that have neither flag fall through to
|
|
73
|
+
* `gitHostedStoreIndexKey` — these are typically lockfile entries written
|
|
74
|
+
* by older pnpm versions that lacked integrity.
|
|
75
|
+
*/
|
|
76
|
+
export function pickStoreIndexKey(resolution, pkgId, opts) {
|
|
77
|
+
if (resolution.gitHosted || !resolution.integrity) {
|
|
78
|
+
return gitHostedStoreIndexKey(pkgId, opts);
|
|
79
|
+
}
|
|
80
|
+
return storeIndexKey(resolution.integrity, pkgId);
|
|
81
|
+
}
|
|
57
82
|
const openInstances = new Set();
|
|
58
83
|
/**
|
|
59
84
|
* Close all open StoreIndex instances.
|
|
@@ -74,11 +99,26 @@ export class StoreIndex {
|
|
|
74
99
|
stmtDel;
|
|
75
100
|
stmtHas;
|
|
76
101
|
stmtAll;
|
|
102
|
+
stmtKeys;
|
|
77
103
|
exitHandler;
|
|
78
104
|
constructor(storeDir) {
|
|
79
|
-
|
|
105
|
+
this.openDatabase(storeDir);
|
|
106
|
+
this.prepareStatements();
|
|
107
|
+
this.exitHandler = () => this.close();
|
|
108
|
+
// Multiple StoreIndex instances may be created (e.g. in tests), each adding
|
|
109
|
+
// an exit listener. Raise the limit to avoid MaxListenersExceededWarning.
|
|
110
|
+
// Skip when maxListeners is 0 (unlimited).
|
|
111
|
+
const currentMax = process.getMaxListeners();
|
|
112
|
+
if (currentMax !== 0 && currentMax < openInstances.size + 11) {
|
|
113
|
+
process.setMaxListeners(Math.max(currentMax + 10, openInstances.size + 11));
|
|
114
|
+
}
|
|
115
|
+
process.on('exit', this.exitHandler);
|
|
116
|
+
openInstances.add(this);
|
|
117
|
+
}
|
|
118
|
+
/** Open the SQLite connection. Overridden by {@link ReadOnlyStoreIndex}. */
|
|
119
|
+
openDatabase(storeDir) {
|
|
80
120
|
fs.mkdirSync(storeDir, { recursive: true });
|
|
81
|
-
this.db = new DatabaseSync(
|
|
121
|
+
this.db = new DatabaseSync(`${storeDir}/index.db`);
|
|
82
122
|
// Set busy_timeout FIRST so SQLite's internal busy handler is active
|
|
83
123
|
// during all subsequent operations. On Windows, file locking is mandatory
|
|
84
124
|
// and concurrent processes (e.g. parallel dlx calls) will contend.
|
|
@@ -100,21 +140,15 @@ export class StoreIndex {
|
|
|
100
140
|
) WITHOUT ROWID
|
|
101
141
|
`);
|
|
102
142
|
});
|
|
143
|
+
}
|
|
144
|
+
/** Prepare the prepared statements. Overridden by {@link ReadOnlyStoreIndex} to skip the write statements. */
|
|
145
|
+
prepareStatements() {
|
|
103
146
|
this.stmtGet = this.db.prepare('SELECT data FROM package_index WHERE key = ?');
|
|
104
147
|
this.stmtSet = this.db.prepare('INSERT OR REPLACE INTO package_index (key, data) VALUES (?, ?)');
|
|
105
148
|
this.stmtDel = this.db.prepare('DELETE FROM package_index WHERE key = ?');
|
|
106
149
|
this.stmtHas = this.db.prepare('SELECT 1 FROM package_index WHERE key = ?');
|
|
107
150
|
this.stmtAll = this.db.prepare('SELECT key, data FROM package_index');
|
|
108
|
-
this.
|
|
109
|
-
// Multiple StoreIndex instances may be created (e.g. in tests), each adding
|
|
110
|
-
// an exit listener. Raise the limit to avoid MaxListenersExceededWarning.
|
|
111
|
-
// Skip when maxListeners is 0 (unlimited).
|
|
112
|
-
const currentMax = process.getMaxListeners();
|
|
113
|
-
if (currentMax !== 0 && currentMax < openInstances.size + 11) {
|
|
114
|
-
process.setMaxListeners(Math.max(currentMax + 10, openInstances.size + 11));
|
|
115
|
-
}
|
|
116
|
-
process.on('exit', this.exitHandler);
|
|
117
|
-
openInstances.add(this);
|
|
151
|
+
this.stmtKeys = this.db.prepare('SELECT key FROM package_index');
|
|
118
152
|
}
|
|
119
153
|
get(key) {
|
|
120
154
|
const row = sqliteRetry(() => this.stmtGet.get(key));
|
|
@@ -123,6 +157,13 @@ export class StoreIndex {
|
|
|
123
157
|
}
|
|
124
158
|
return undefined;
|
|
125
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* Get the raw msgpack-encoded buffer for a key without decoding.
|
|
162
|
+
*/
|
|
163
|
+
getRaw(key) {
|
|
164
|
+
const row = sqliteRetry(() => this.stmtGet.get(key));
|
|
165
|
+
return row?.data;
|
|
166
|
+
}
|
|
126
167
|
set(key, data) {
|
|
127
168
|
const buffer = packr.pack(data);
|
|
128
169
|
sqliteRetry(() => {
|
|
@@ -148,6 +189,15 @@ export class StoreIndex {
|
|
|
148
189
|
yield [row.key, packr.unpack(row.data)];
|
|
149
190
|
}
|
|
150
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* Iterate over all index keys without decoding values.
|
|
194
|
+
* Much faster than entries() when only keys are needed.
|
|
195
|
+
*/
|
|
196
|
+
*keys() {
|
|
197
|
+
for (const row of this.stmtKeys.iterate()) {
|
|
198
|
+
yield row.key;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
151
201
|
/**
|
|
152
202
|
* Queue pre-packed writes to be flushed on the next tick.
|
|
153
203
|
* Used by the fetch phase for throughput.
|
|
@@ -237,6 +287,14 @@ export class StoreIndex {
|
|
|
237
287
|
});
|
|
238
288
|
this.db.exec('VACUUM');
|
|
239
289
|
}
|
|
290
|
+
checkpoint() {
|
|
291
|
+
this.flush();
|
|
292
|
+
// wal_checkpoint can hit SQLITE_BUSY if another process is reading the
|
|
293
|
+
// same index.db concurrently. Retry for consistency with other ops here.
|
|
294
|
+
sqliteRetry(() => {
|
|
295
|
+
this.db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
|
|
296
|
+
});
|
|
297
|
+
}
|
|
240
298
|
close() {
|
|
241
299
|
if (this.closed)
|
|
242
300
|
return;
|
|
@@ -244,18 +302,103 @@ export class StoreIndex {
|
|
|
244
302
|
this.closed = true;
|
|
245
303
|
openInstances.delete(this);
|
|
246
304
|
process.removeListener('exit', this.exitHandler);
|
|
305
|
+
this.optimizeBeforeClose();
|
|
247
306
|
try {
|
|
248
|
-
this.db.
|
|
307
|
+
this.db.close();
|
|
249
308
|
}
|
|
250
309
|
catch {
|
|
251
|
-
//
|
|
310
|
+
// The DB may be locked by another connection; the OS will reclaim it on process exit.
|
|
252
311
|
}
|
|
312
|
+
}
|
|
313
|
+
/** Run `PRAGMA optimize` before closing. Overridden by {@link ReadOnlyStoreIndex} to skip it (the DB is immutable). */
|
|
314
|
+
optimizeBeforeClose() {
|
|
253
315
|
try {
|
|
254
|
-
this.db.
|
|
316
|
+
this.db.exec('PRAGMA optimize');
|
|
255
317
|
}
|
|
256
318
|
catch {
|
|
257
|
-
//
|
|
319
|
+
// PRAGMA optimize is a performance hint; safe to ignore if the DB is locked.
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* A {@link StoreIndex} opened read-only for installs against a store on a
|
|
325
|
+
* read-only filesystem (`frozenStore`). The index is a WAL-mode database, and a
|
|
326
|
+
* normal WAL read creates an `index.db-shm` sidecar in the store directory —
|
|
327
|
+
* which fails on a read-only directory and surfaces as "attempt to write a
|
|
328
|
+
* readonly database" on the first query. Opening via the SQLite `immutable=1`
|
|
329
|
+
* URI tells SQLite the file cannot change, so it bypasses the WAL/shm machinery
|
|
330
|
+
* and reads the file directly, creating no sidecars.
|
|
331
|
+
*
|
|
332
|
+
* The store is assumed complete; every write is a programming error and throws.
|
|
333
|
+
*/
|
|
334
|
+
export class ReadOnlyStoreIndex extends StoreIndex {
|
|
335
|
+
openDatabase(storeDir) {
|
|
336
|
+
if (!nodeSupportsImmutableSqliteUri()) {
|
|
337
|
+
throw new PnpmError('FROZEN_STORE_UNSUPPORTED_NODE', `frozenStore opens the store index read-only via a SQLite "immutable" URI, which requires Node.js >=22.15.0, >=23.11.0, or >=24.0.0, but the current version is ${process.versions.node}. Upgrade Node.js, or run without frozenStore.`);
|
|
258
338
|
}
|
|
339
|
+
this.db = new DatabaseSync(immutableSqliteUri(`${storeDir}/index.db`));
|
|
259
340
|
}
|
|
341
|
+
prepareStatements() {
|
|
342
|
+
this.stmtGet = this.db.prepare('SELECT data FROM package_index WHERE key = ?');
|
|
343
|
+
this.stmtHas = this.db.prepare('SELECT 1 FROM package_index WHERE key = ?');
|
|
344
|
+
this.stmtAll = this.db.prepare('SELECT key, data FROM package_index');
|
|
345
|
+
this.stmtKeys = this.db.prepare('SELECT key FROM package_index');
|
|
346
|
+
}
|
|
347
|
+
optimizeBeforeClose() { }
|
|
348
|
+
set(_key, _data) {
|
|
349
|
+
this.throwReadOnly();
|
|
350
|
+
}
|
|
351
|
+
delete(_key) {
|
|
352
|
+
this.throwReadOnly();
|
|
353
|
+
}
|
|
354
|
+
queueWrites(_writes) {
|
|
355
|
+
this.throwReadOnly();
|
|
356
|
+
}
|
|
357
|
+
setRawMany(_entries) {
|
|
358
|
+
this.throwReadOnly();
|
|
359
|
+
}
|
|
360
|
+
deleteMany(_keys) {
|
|
361
|
+
this.throwReadOnly();
|
|
362
|
+
}
|
|
363
|
+
checkpoint() {
|
|
364
|
+
this.throwReadOnly();
|
|
365
|
+
}
|
|
366
|
+
throwReadOnly() {
|
|
367
|
+
throw new PnpmError('FROZEN_STORE_WRITE', FROZEN_STORE_WRITE_MESSAGE);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Build the `file://…?immutable=1` URI used to open `index.db` read-only (see
|
|
372
|
+
* the frozen-store rationale at the call site). `pathToFileURL` yields a
|
|
373
|
+
* canonical file URL on every platform: it percent-encodes the URI delimiters
|
|
374
|
+
* that could otherwise truncate the path or inject a query/fragment (`?`, `#`,
|
|
375
|
+
* `%`, spaces) and, on Windows, maps the drive letter and backslashes into a
|
|
376
|
+
* valid `file:///C:/…` form. A raw `file:${path}` concatenation would mis-parse
|
|
377
|
+
* those. See https://sqlite.org/uri.html.
|
|
378
|
+
*/
|
|
379
|
+
function immutableSqliteUri(dbPath) {
|
|
380
|
+
const url = pathToFileURL(dbPath);
|
|
381
|
+
url.searchParams.set('immutable', '1');
|
|
382
|
+
return url.href;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Whether the running Node.js can open a `file:…?immutable=1` SQLite URI.
|
|
386
|
+
*
|
|
387
|
+
* `node:sqlite` only passes `SQLITE_OPEN_URI` to SQLite — so the `immutable=1`
|
|
388
|
+
* query is honored rather than treated as part of a literal filename — starting
|
|
389
|
+
* in v22.15.0 (22.x line), v23.11.0 (23.x line), and every v24+. On older
|
|
390
|
+
* runtimes the URI is opened as a literal path and fails with a cryptic
|
|
391
|
+
* "unable to open database file"; we detect that up front to give actionable
|
|
392
|
+
* guidance instead.
|
|
393
|
+
*/
|
|
394
|
+
function nodeSupportsImmutableSqliteUri() {
|
|
395
|
+
const [major, minor] = process.versions.node.split('.', 2).map(Number);
|
|
396
|
+
if (major < 22)
|
|
397
|
+
return false;
|
|
398
|
+
if (major === 22)
|
|
399
|
+
return minor >= 15;
|
|
400
|
+
if (major === 23)
|
|
401
|
+
return minor >= 11;
|
|
402
|
+
return true;
|
|
260
403
|
}
|
|
261
404
|
//# sourceMappingURL=index.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pnpm/store.index",
|
|
3
|
-
"version": "1100.
|
|
3
|
+
"version": "1100.2.0",
|
|
4
4
|
"description": "SQLite-backed index for the pnpm content-addressable store",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pnpm",
|
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
],
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"funding": "https://opencollective.com/pnpm",
|
|
12
|
-
"repository":
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "https://github.com/pnpm/pnpm/tree/main/store/index"
|
|
15
|
+
},
|
|
13
16
|
"homepage": "https://github.com/pnpm/pnpm/tree/main/store/index#readme",
|
|
14
17
|
"bugs": {
|
|
15
18
|
"url": "https://github.com/pnpm/pnpm/issues"
|
|
@@ -25,12 +28,14 @@
|
|
|
25
28
|
"!*.map"
|
|
26
29
|
],
|
|
27
30
|
"dependencies": {
|
|
28
|
-
"msgpackr": "
|
|
31
|
+
"msgpackr": "2.0.4",
|
|
32
|
+
"@pnpm/error": "1100.0.0"
|
|
29
33
|
},
|
|
30
34
|
"devDependencies": {
|
|
31
|
-
"@
|
|
35
|
+
"@jest/globals": "30.4.1",
|
|
36
|
+
"@types/node": "^22.19.19",
|
|
32
37
|
"tempy": "3.0.0",
|
|
33
|
-
"@pnpm/store.index": "1100.
|
|
38
|
+
"@pnpm/store.index": "1100.2.0"
|
|
34
39
|
},
|
|
35
40
|
"engines": {
|
|
36
41
|
"node": ">=22.13"
|