@pnpm/store.index 1100.1.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 +44 -8
  2. package/lib/index.js +111 -16
  3. package/package.json +10 -6
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,
@@ -41,18 +42,22 @@ export declare function pickStoreIndexKey(resolution: {
41
42
  */
42
43
  export declare function closeAllStoreIndexes(): void;
43
44
  export declare class StoreIndex {
44
- private db;
45
- private closed;
45
+ protected db: DatabaseSyncType;
46
+ protected closed: boolean;
46
47
  private pendingWrites;
47
48
  private flushScheduled;
48
- private stmtGet;
49
- private stmtSet;
50
- private stmtDel;
51
- private stmtHas;
52
- private stmtAll;
53
- private stmtKeys;
49
+ protected stmtGet: StatementSync;
50
+ protected stmtSet: StatementSync;
51
+ protected stmtDel: StatementSync;
52
+ protected stmtHas: StatementSync;
53
+ protected stmtAll: StatementSync;
54
+ protected stmtKeys: StatementSync;
54
55
  private readonly exitHandler;
55
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;
56
61
  get(key: string): unknown | undefined;
57
62
  /**
58
63
  * Get the raw msgpack-encoded buffer for a key without decoding.
@@ -98,4 +103,35 @@ export declare class StoreIndex {
98
103
  deleteMany(keys: string[]): void;
99
104
  checkpoint(): void;
100
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;
101
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);
@@ -99,9 +102,23 @@ export class StoreIndex {
99
102
  stmtKeys;
100
103
  exitHandler;
101
104
  constructor(storeDir) {
102
- 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) {
103
120
  fs.mkdirSync(storeDir, { recursive: true });
104
- this.db = new DatabaseSync(dbPath);
121
+ this.db = new DatabaseSync(`${storeDir}/index.db`);
105
122
  // Set busy_timeout FIRST so SQLite's internal busy handler is active
106
123
  // during all subsequent operations. On Windows, file locking is mandatory
107
124
  // and concurrent processes (e.g. parallel dlx calls) will contend.
@@ -123,22 +140,15 @@ export class StoreIndex {
123
140
  ) WITHOUT ROWID
124
141
  `);
125
142
  });
143
+ }
144
+ /** Prepare the prepared statements. Overridden by {@link ReadOnlyStoreIndex} to skip the write statements. */
145
+ prepareStatements() {
126
146
  this.stmtGet = this.db.prepare('SELECT data FROM package_index WHERE key = ?');
127
147
  this.stmtSet = this.db.prepare('INSERT OR REPLACE INTO package_index (key, data) VALUES (?, ?)');
128
148
  this.stmtDel = this.db.prepare('DELETE FROM package_index WHERE key = ?');
129
149
  this.stmtHas = this.db.prepare('SELECT 1 FROM package_index WHERE key = ?');
130
150
  this.stmtAll = this.db.prepare('SELECT key, data FROM package_index');
131
151
  this.stmtKeys = this.db.prepare('SELECT key FROM package_index');
132
- this.exitHandler = () => this.close();
133
- // Multiple StoreIndex instances may be created (e.g. in tests), each adding
134
- // an exit listener. Raise the limit to avoid MaxListenersExceededWarning.
135
- // Skip when maxListeners is 0 (unlimited).
136
- const currentMax = process.getMaxListeners();
137
- if (currentMax !== 0 && currentMax < openInstances.size + 11) {
138
- process.setMaxListeners(Math.max(currentMax + 10, openInstances.size + 11));
139
- }
140
- process.on('exit', this.exitHandler);
141
- openInstances.add(this);
142
152
  }
143
153
  get(key) {
144
154
  const row = sqliteRetry(() => this.stmtGet.get(key));
@@ -292,18 +302,103 @@ export class StoreIndex {
292
302
  this.closed = true;
293
303
  openInstances.delete(this);
294
304
  process.removeListener('exit', this.exitHandler);
305
+ this.optimizeBeforeClose();
295
306
  try {
296
- this.db.exec('PRAGMA optimize');
307
+ this.db.close();
297
308
  }
298
309
  catch {
299
- // 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.
300
311
  }
312
+ }
313
+ /** Run `PRAGMA optimize` before closing. Overridden by {@link ReadOnlyStoreIndex} to skip it (the DB is immutable). */
314
+ optimizeBeforeClose() {
301
315
  try {
302
- this.db.close();
316
+ this.db.exec('PRAGMA optimize');
303
317
  }
304
318
  catch {
305
- // 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.`);
306
338
  }
339
+ this.db = new DatabaseSync(immutableSqliteUri(`${storeDir}/index.db`));
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();
307
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;
308
403
  }
309
404
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pnpm/store.index",
3
- "version": "1100.1.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,13 +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
- "@jest/globals": "30.3.0",
32
- "@types/node": "^22.19.17",
35
+ "@jest/globals": "30.4.1",
36
+ "@types/node": "^22.19.19",
33
37
  "tempy": "3.0.0",
34
- "@pnpm/store.index": "1100.1.0"
38
+ "@pnpm/store.index": "1100.2.0"
35
39
  },
36
40
  "engines": {
37
41
  "node": ">=22.13"