@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.
Files changed (3) hide show
  1. package/lib/index.d.ts +76 -7
  2. package/lib/index.js +159 -16
  3. 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
- private db;
23
- private closed;
45
+ protected db: DatabaseSyncType;
46
+ protected closed: boolean;
24
47
  private pendingWrites;
25
48
  private flushScheduled;
26
- private stmtGet;
27
- private stmtSet;
28
- private stmtDel;
29
- private stmtHas;
30
- private stmtAll;
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
- const dbPath = `${storeDir}/index.db`;
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(dbPath);
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.exitHandler = () => this.close();
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.exec('PRAGMA optimize');
307
+ this.db.close();
249
308
  }
250
309
  catch {
251
- // PRAGMA optimize is a performance hint; safe to ignore if the DB is locked.
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.close();
316
+ this.db.exec('PRAGMA optimize');
255
317
  }
256
318
  catch {
257
- // The DB may be locked by another connection; the OS will reclaim it on process exit.
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.0.0",
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": "https://github.com/pnpm/pnpm/tree/main/store/index",
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": "1.11.8"
31
+ "msgpackr": "2.0.4",
32
+ "@pnpm/error": "1100.0.0"
29
33
  },
30
34
  "devDependencies": {
31
- "@types/node": "^22.19.15",
35
+ "@jest/globals": "30.4.1",
36
+ "@types/node": "^22.19.19",
32
37
  "tempy": "3.0.0",
33
- "@pnpm/store.index": "1100.0.0"
38
+ "@pnpm/store.index": "1100.2.0"
34
39
  },
35
40
  "engines": {
36
41
  "node": ">=22.13"