@vzn/vx 0.0.511 → 0.0.512

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.511",
3
+ "version": "0.0.512",
4
4
  "description": "The fastest task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.511",
25
- "@vzn/vx-linux-arm64": "0.0.511",
26
- "@vzn/vx-darwin-x64": "0.0.511",
27
- "@vzn/vx-darwin-arm64": "0.0.511"
24
+ "@vzn/vx-linux-x64": "0.0.512",
25
+ "@vzn/vx-linux-arm64": "0.0.512",
26
+ "@vzn/vx-darwin-x64": "0.0.512",
27
+ "@vzn/vx-darwin-arm64": "0.0.512"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -20,6 +20,12 @@
20
20
  // for the user to see — but on a cache hit there's nothing to replay
21
21
  // (the original run was successful and stderr typically empty).
22
22
  //
23
+ // With a shared store (v32, the default: `docs/design/shared-store-2026-10.md`)
24
+ // the entries and their artifacts live in `<store>/store.db` and
25
+ // `<store>/<hash>.tar.zst`, attached to the workspace's own `cache.db`,
26
+ // which keeps the history and the memos: every workspace of one user hits
27
+ // what another saved, and none reads another's runs.
28
+ //
23
29
  // This is core's FLOOR, not a module to replace: remote storage is a
24
30
  // `RemoteCacheLayer` (has / get / put) that `LayeredCache` wraps around
25
31
  // this handle, declared by a plugin's `cache` hook. The contract every
@@ -31,6 +37,7 @@ import {
31
37
  closeSync,
32
38
  constants,
33
39
  existsSync,
40
+ lstatSync,
34
41
  mkdirSync,
35
42
  openSync,
36
43
  renameSync,
@@ -163,6 +170,17 @@ function outOfFdsAtOpen(dbFile: string, err: unknown): UserError | undefined {
163
170
 
164
171
  /** Say once, on the channel the opener has, that an upgrade emptied the index. */
165
172
  export function noteSchemaReset(cache: Cache, warn: (message: string) => void): void {
173
+ if (cache.storeFallback !== null) {
174
+ warn(
175
+ `[vx] shared cache store ${cache.storeFallback} is not usable; entries stay in ${cache.storeDir ?? 'this workspace'}, where no other workspace hits them`,
176
+ )
177
+ }
178
+ if (cache.storeMoved !== null) {
179
+ const { from, to } = cache.storeMoved
180
+ warn(
181
+ `[vx] cache entries now live in the shared store ${to}; the ones in ${from} miss once, and \`vx cache prune\` reclaims their artifacts`,
182
+ )
183
+ }
166
184
  if (cache.formatChange !== null) {
167
185
  const { from, to } = cache.formatChange
168
186
  warn(
@@ -237,7 +255,15 @@ export function noteSchemaReset(cache: Cache, warn: (message: string) => void):
237
255
  // restored_remote counts — a hit that restored outputs apart from
238
256
  // one that found them in place, for every reader after the run.
239
257
  // The cache KEY is unchanged.
240
- export const SCHEMA_VERSION = 'v31'
258
+ // v32: the entry tables (entries, entry_stdout, output_files,
259
+ // entry_inputs) may live in a shared store attached as `store`;
260
+ // output_files' ino + ctime_ms moved to the workspace's own
261
+ // output_stamps, output_dirs lost its foreign key, and the value
262
+ // salt moved to store_meta. The cache KEY is unchanged.
263
+ export const SCHEMA_VERSION = 'v32'
264
+
265
+ /** The tables a store holds: dropped from a workspace index that held them itself. */
266
+ const STORE_TABLES = ['entry_inputs', 'output_files', 'entry_stdout', 'store_meta', 'entries']
241
267
 
242
268
  /** An entry row with its stdout, which lives apart (v29); none stored reads as ''. */
243
269
  const SELECT_ENTRY =
@@ -403,6 +429,39 @@ function openCacheDir(cacheDir: string): string | null {
403
429
  return null
404
430
  }
405
431
 
432
+ /** `openCacheDir` for the shared store: why it cannot be used, or null. Never throws. */
433
+ function storeBlocked(storeDir: string, storeRoot: string | null): string | null {
434
+ try {
435
+ if (storeRoot !== null) {
436
+ // `~/.vx`, `~/.vx/<id>`, `~/.vx/<id>/cache`: owner-only, each level
437
+ // made alone and re-checked, as Nx makes `~/.nx/<id>`. Artifacts are
438
+ // replayed into the workspace, so nobody else may write them.
439
+ const vxHome = path.dirname(path.dirname(storeRoot))
440
+ for (const dir of [vxHome, path.dirname(storeRoot), storeRoot]) {
441
+ const refused = ensurePrivateDir(dir)
442
+ if (refused !== null) return refused
443
+ }
444
+ }
445
+ return openCacheDir(storeDir)
446
+ } catch (err) {
447
+ return errorText(err)
448
+ }
449
+ }
450
+
451
+ function ensurePrivateDir(dir: string): string | null {
452
+ try {
453
+ mkdirSync(dir, { mode: 0o700 })
454
+ } catch (err) {
455
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return errorText(err)
456
+ }
457
+ const st = lstatSync(dir)
458
+ if (!st.isDirectory()) return `${dir} is not a directory`
459
+ const uid = process.getuid?.()
460
+ if (uid !== undefined && st.uid !== uid) return `${dir} belongs to another user`
461
+ if ((st.mode & 0o077) !== 0) return `${dir} is open to other users (chmod 700 it)`
462
+ return null
463
+ }
464
+
406
465
  // Make the cache dir invisible to git, every time it is created: a `*`
407
466
  // .gitignore inside it (the Cargo / Nx convention). Two reasons, both
408
467
  // measured. A cache nobody ignored gets COMMITTED by the next `git add -A`;
@@ -482,6 +541,21 @@ export class Cache implements CacheLayer {
482
541
  /** Why this process cannot write into `cacheDir`, or `null`; decided at open. */
483
542
  private readonly writeBlocked: string | null
484
543
  private readonly dbFile: string
544
+ /** Where the artifacts live: the shared store, or `cacheDir` itself. */
545
+ private readonly artifactDir: string
546
+
547
+ /** The shared store this index attached, or undefined when it holds its entries itself. */
548
+ readonly storeDir: string | undefined
549
+
550
+ /**
551
+ * The store this open was asked for and could not use, with why: the
552
+ * entries went to a store inside `cacheDir` instead. Said once by
553
+ * `noteSchemaReset`.
554
+ */
555
+ readonly storeFallback: string | null = null
556
+
557
+ /** Set when this open moved a workspace index's own entries out for a shared store. */
558
+ readonly storeMoved: { from: string; to: string } | null = null
485
559
 
486
560
  /**
487
561
  * Set when THIS open found an index written by another `SCHEMA_VERSION`
@@ -557,6 +631,15 @@ export class Cache implements CacheLayer {
557
631
  * and left as it was.
558
632
  */
559
633
  mode: 'open' | 'inspect' = 'open',
634
+ /**
635
+ * The shared store's directory, unversioned: every key is seeded with
636
+ * `CACHE_VERSION`, so two vx versions never read each other's entries,
637
+ * and a store table only ever gains columns (owner, 2026-10-06). Or
638
+ * `null` for an index that holds its entries itself (a `cacheDir`).
639
+ * Undefined follows the layout the index records: a reading verb, a
640
+ * plugin's handle.
641
+ */
642
+ storeRoot?: string | null,
560
643
  ) {
561
644
  this.inspecting = mode === 'inspect'
562
645
  this.read = localPolicy.read
@@ -693,8 +776,11 @@ export class Cache implements CacheLayer {
693
776
  return null
694
777
  }
695
778
  if (found === SCHEMA_VERSION) return null
779
+ // `main.` on every name: unqualified, a table the index lacks
780
+ // resolves to an attached store's (none is attached yet; this
781
+ // keeps it so).
696
782
  this.db.exec(
697
- 'DROP TABLE IF EXISTS entries; DROP TABLE IF EXISTS runs; DROP TABLE IF EXISTS file_hashes; DROP TABLE IF EXISTS blob_sizes; DROP TABLE IF EXISTS blob_verdicts; DROP TABLE IF EXISTS output_files; DROP TABLE IF EXISTS invocations; DROP TABLE IF EXISTS run_task_inputs; DROP TABLE IF EXISTS entry_inputs; DROP TABLE IF EXISTS config_evals; DROP TABLE IF EXISTS config_closures; DROP TABLE IF EXISTS output_dirs; DROP TABLE IF EXISTS entry_stdout;',
783
+ 'DROP TABLE IF EXISTS main.entries; DROP TABLE IF EXISTS main.runs; DROP TABLE IF EXISTS main.file_hashes; DROP TABLE IF EXISTS main.blob_sizes; DROP TABLE IF EXISTS main.blob_verdicts; DROP TABLE IF EXISTS main.output_files; DROP TABLE IF EXISTS main.output_stamps; DROP TABLE IF EXISTS main.invocations; DROP TABLE IF EXISTS main.run_task_inputs; DROP TABLE IF EXISTS main.entry_inputs; DROP TABLE IF EXISTS main.config_evals; DROP TABLE IF EXISTS main.config_closures; DROP TABLE IF EXISTS main.output_dirs; DROP TABLE IF EXISTS main.entry_stdout; DROP TABLE IF EXISTS main.store_meta;',
698
784
  )
699
785
  this.db
700
786
  .prepare("UPDATE schema_meta SET value = ? WHERE key = 'version'")
@@ -712,7 +798,71 @@ export class Cache implements CacheLayer {
712
798
  }
713
799
  }
714
800
 
715
- createTables(this.db)
801
+ // Where the entry tables live (v32): `store_dir` names the shared store
802
+ // this index was last opened with; absent, the index holds them itself.
803
+ const recorded = readable(
804
+ () =>
805
+ (
806
+ this.db.prepare("SELECT value FROM schema_meta WHERE key = 'store_dir'").get() as {
807
+ value: string
808
+ } | null
809
+ )?.value,
810
+ )
811
+ let storeDir =
812
+ mode === 'inspect' || storeRoot === undefined
813
+ ? recorded
814
+ : storeRoot === null
815
+ ? undefined
816
+ : storeRoot
817
+ if (storeDir !== undefined && mode === 'open' && this.writeBlocked === null) {
818
+ const blocked = storeBlocked(storeDir, typeof storeRoot === 'string' ? storeRoot : null)
819
+ if (blocked !== null) {
820
+ // A home this user cannot write (a container, a read-only mount):
821
+ // the entries stay in this workspace rather than fail the run.
822
+ const fallback = cacheDir
823
+ if (fallback !== storeDir) {
824
+ // Said by the open that falls back; the ones after it find it recorded.
825
+ if (recorded !== fallback) this.storeFallback = `${storeDir} (${blocked})`
826
+ storeDir = fallback
827
+ openCacheDir(fallback)
828
+ }
829
+ }
830
+ }
831
+ if (storeDir !== undefined && mode === 'open' && this.writeBlocked === null) {
832
+ // An index that held its entries itself would shadow the store's:
833
+ // SQLite resolves an unqualified name in `main` first. Its history
834
+ // and memos stay; its entries' artifacts are orphans `prune` reaps.
835
+ const holds = readable(
836
+ () =>
837
+ this.db
838
+ .prepare("SELECT 1 FROM main.sqlite_master WHERE type = 'table' AND name = 'entries'")
839
+ .get() != null,
840
+ )
841
+ if (holds) {
842
+ const had = this.db.prepare('SELECT 1 FROM main.entries LIMIT 1').get() != null
843
+ this.db
844
+ .transaction(() => {
845
+ for (const t of STORE_TABLES) this.db.exec(`DROP TABLE IF EXISTS main.${t}`)
846
+ })
847
+ .immediate()
848
+ if (had) this.storeMoved = { from: cacheDir, to: storeDir }
849
+ }
850
+ }
851
+ this.storeDir = storeDir
852
+ this.artifactDir = storeDir ?? cacheDir
853
+ if (storeDir !== undefined) this.attachStore(storeDir, mode === 'inspect')
854
+
855
+ createTables(this.db, storeDir === undefined ? 'main' : 'store')
856
+ if (mode === 'open' && this.writeBlocked === null && storeDir !== recorded) {
857
+ if (storeDir === undefined)
858
+ this.db.prepare("DELETE FROM schema_meta WHERE key = 'store_dir'").run()
859
+ else
860
+ this.db
861
+ .prepare(
862
+ "INSERT INTO schema_meta(key, value) VALUES ('store_dir', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
863
+ )
864
+ .run(storeDir)
865
+ }
716
866
 
717
867
  this.deleteEntryRow = lazyStatement(this.db, 'DELETE FROM entries WHERE hash = ?')
718
868
  this.insertEntry = lazyStatement(
@@ -800,6 +950,33 @@ export class Cache implements CacheLayer {
800
950
  }
801
951
  }
802
952
 
953
+ /**
954
+ * Attach the shared store as `store`, with the pragmas the index takes:
955
+ * WAL for both, so a workspace's run and another's share the store as
956
+ * two runs on one `--cache-dir` share an index. A reading verb over a
957
+ * store with no file yet reads an empty one in memory.
958
+ */
959
+ private attachStore(storeDir: string, inspecting: boolean): void {
960
+ const storeFile = path.join(storeDir, 'store.db')
961
+ const absent = inspecting && !existsSync(storeFile)
962
+ try {
963
+ this.db.prepare('ATTACH DATABASE ? AS store').run(absent ? ':memory:' : storeFile)
964
+ this.db.exec('PRAGMA store.journal_mode = WAL')
965
+ if (!absent) this.db.fileControl('store', SQLITE_FCNTL_PERSIST_WAL, 1)
966
+ this.db.exec('PRAGMA store.journal_size_limit = 67108864')
967
+ this.db.exec('PRAGMA store.synchronous = NORMAL')
968
+ } catch (err) {
969
+ try {
970
+ closeDb(this.db)
971
+ } catch {
972
+ // The refusal below is the error worth reporting.
973
+ }
974
+ throw unreadableIndex(err)
975
+ ? unreadableIndexError(storeFile, err)
976
+ : (outOfFdsAtOpen(storeFile, err) ?? err)
977
+ }
978
+ }
979
+
803
980
  // --- config evaluations: `ConfigEvalStore`, delegated to `ConfigEvalTable` ---
804
981
  getConfigEval(key: string): string | null {
805
982
  return this.guard(() => this.configEvals.getConfigEval(key))
@@ -908,7 +1085,7 @@ export class Cache implements CacheLayer {
908
1085
  private loadValueSalt(): string {
909
1086
  const read = (): string | undefined =>
910
1087
  (
911
- this.db.prepare("SELECT value FROM schema_meta WHERE key = 'value_salt'").get() as {
1088
+ this.db.prepare("SELECT value FROM store_meta WHERE key = 'value_salt'").get() as {
912
1089
  value: string
913
1090
  } | null
914
1091
  )?.value
@@ -921,7 +1098,7 @@ export class Cache implements CacheLayer {
921
1098
  // Two processes opening one new store: the first salt stands.
922
1099
  this.db
923
1100
  .prepare(
924
- "INSERT INTO schema_meta(key, value) VALUES ('value_salt', ?) ON CONFLICT(key) DO NOTHING",
1101
+ "INSERT INTO store_meta(key, value) VALUES ('value_salt', ?) ON CONFLICT(key) DO NOTHING",
925
1102
  )
926
1103
  .run(salt)
927
1104
  return read() ?? salt
@@ -1079,11 +1256,15 @@ export class Cache implements CacheLayer {
1079
1256
  /** Remove one entry: its artifact first, then its rows (as `prune` orders them). */
1080
1257
  private async dropEntry(hash: string): Promise<void> {
1081
1258
  await rm(this.tarPath(hash), { force: true })
1082
- try {
1259
+ const drop = this.db.transaction(() => {
1083
1260
  this.deleteEntryRow.run(hash)
1261
+ this.outputs.forget([hash])
1262
+ })
1263
+ try {
1264
+ drop()
1084
1265
  } catch (err) {
1085
1266
  if (!isIndexFull(err)) throw err
1086
- this.deleteEntryRow.run(hash)
1267
+ drop()
1087
1268
  }
1088
1269
  }
1089
1270
 
@@ -1921,6 +2102,7 @@ export class Cache implements CacheLayer {
1921
2102
  const { test, params } = inHashes(rows)
1922
2103
  const deleteRows = this.db.transaction(() => {
1923
2104
  this.db.prepare(`DELETE FROM entries WHERE hash ${test}`).run(...params)
2105
+ this.outputs.forget(rows)
1924
2106
  })
1925
2107
  try {
1926
2108
  deleteRows()
@@ -1930,6 +2112,13 @@ export class Cache implements CacheLayer {
1930
2112
  }
1931
2113
  }
1932
2114
 
2115
+ // This workspace's rows for entries another workspace pruned from a
2116
+ // shared store: never read (a lookup starts at the entry), only kept.
2117
+ try {
2118
+ this.outputs.forgetGone()
2119
+ } catch (err) {
2120
+ if (!isIndexFull(err)) throw err
2121
+ }
1933
2122
  const orphans = await this.reapOrphans()
1934
2123
  return { evicted: victims.size, bytesFreed, ...orphans }
1935
2124
  }
@@ -1994,7 +2183,7 @@ export class Cache implements CacheLayer {
1994
2183
  }>
1995
2184
  let names: string[]
1996
2185
  try {
1997
- names = await readdir(this.cacheDir)
2186
+ names = await readdir(this.artifactDir)
1998
2187
  } catch {
1999
2188
  return { phantoms: new Map(), stale: [] }
2000
2189
  }
@@ -2019,8 +2208,8 @@ export class Cache implements CacheLayer {
2019
2208
 
2020
2209
  /** Row-less artifacts and temps past the in-flight grace window: one readdir, one stat per candidate. */
2021
2210
  private async scanOrphans(): Promise<Array<{ file: string; size: number }>> {
2022
- return scanOrphanFiles(
2023
- this.cacheDir,
2211
+ const found = await scanOrphanFiles(
2212
+ this.artifactDir,
2024
2213
  () =>
2025
2214
  new Set(
2026
2215
  (this.db.prepare('SELECT hash FROM entries').all() as Array<{ hash: string }>).map(
@@ -2028,6 +2217,10 @@ export class Cache implements CacheLayer {
2028
2217
  ),
2029
2218
  ),
2030
2219
  )
2220
+ // With a shared store, no artifact in `cacheDir` is indexed: they are
2221
+ // what this workspace saved before its entries moved out.
2222
+ if (this.artifactDir === this.cacheDir) return found
2223
+ return [...found, ...(await scanOrphanFiles(this.cacheDir, () => new Set()))]
2031
2224
  }
2032
2225
 
2033
2226
  close(): void {
@@ -2070,7 +2263,7 @@ export class Cache implements CacheLayer {
2070
2263
  }
2071
2264
 
2072
2265
  private tarPath(hash: string): string {
2073
- return path.join(this.cacheDir, `${hash}.tar.zst`)
2266
+ return path.join(this.artifactDir, `${hash}.tar.zst`)
2074
2267
  }
2075
2268
  }
2076
2269
 
@@ -1,5 +1,6 @@
1
1
  // The output fingerprint index: per-entry `output_files` rows (size, mode,
2
- // millisecond mtime) and `output_dirs` rows (directory mtimes), and the two
2
+ // millisecond mtime), this workspace's `output_stamps` (inode and ctime) and
3
+ // `output_dirs` rows (directory mtimes), and the two
3
4
  // proofs a cache hit runs against the tree before deciding not to restore.
4
5
  // Owns its statements over the store's handle; `Cache` delegates, and
5
6
  // writes the file rows through `replaceFileRows` inside its own save
@@ -30,6 +31,7 @@ export class OutputIndex {
30
31
  private readonly deleteOutputDirs: ReturnType<Database['prepare']>
31
32
  private readonly entryExists: ReturnType<Database['prepare']>
32
33
  private readonly stampOutputFile: ReturnType<Database['prepare']>
34
+ private readonly deleteStamps: ReturnType<Database['prepare']>
33
35
  /**
34
36
  * Snapshots taken and not yet written, the last per hash. A snapshot is
35
37
  * read by the NEXT run's hit check, never by the task that took it, so
@@ -63,8 +65,34 @@ export class OutputIndex {
63
65
  this.entryExists = lazyStatement(this.db, 'SELECT 1 FROM entries WHERE hash = ?')
64
66
  this.stampOutputFile = lazyStatement(
65
67
  this.db,
66
- 'UPDATE output_files SET ino = ?, ctime_ms = ? WHERE entry_hash = ? AND path = ?',
68
+ 'INSERT INTO output_stamps(entry_hash, path, ino, ctime_ms) VALUES (?, ?, ?, ?) ON CONFLICT(entry_hash, path) DO UPDATE SET ino = excluded.ino, ctime_ms = excluded.ctime_ms',
67
69
  )
70
+ this.deleteStamps = lazyStatement(this.db, 'DELETE FROM output_stamps WHERE entry_hash = ?')
71
+ }
72
+
73
+ /**
74
+ * Drop this workspace's stamps and snapshots of `hashes`, whose entries
75
+ * are gone: they live apart from the entry (it may be another database's),
76
+ * so no cascade takes them.
77
+ */
78
+ forget(hashes: readonly string[]): void {
79
+ if (hashes.length === 0) return
80
+ for (const h of hashes) {
81
+ this.pendingStamps.delete(h)
82
+ this.pendingDirs.delete(h)
83
+ }
84
+ const { test, params } = inHashes(hashes)
85
+ this.db.prepare(`DELETE FROM output_stamps WHERE entry_hash ${test}`).run(...params)
86
+ this.db.prepare(`DELETE FROM output_dirs WHERE entry_hash ${test}`).run(...params)
87
+ }
88
+
89
+ /** `forget` every entry no longer stored: another workspace's prune of a shared store. */
90
+ forgetGone(): void {
91
+ this.db.transaction(() => {
92
+ this.db.exec(
93
+ 'DELETE FROM output_stamps WHERE entry_hash NOT IN (SELECT hash FROM entries); DELETE FROM output_dirs WHERE entry_hash NOT IN (SELECT hash FROM entries)',
94
+ )
95
+ })()
68
96
  }
69
97
 
70
98
  /**
@@ -76,6 +104,7 @@ export class OutputIndex {
76
104
  // A stamp taken for the rows this replaces describes their files, not these.
77
105
  this.pendingStamps.delete(hash)
78
106
  this.deleteOutputFiles.run(hash)
107
+ this.deleteStamps.run(hash)
79
108
  for (const [rel, size, mode, mtime] of rows) {
80
109
  this.insertOutputFile.run(hash, rel, size, mode, mtime)
81
110
  }
@@ -93,7 +122,7 @@ export class OutputIndex {
93
122
  // reads its rows twice.
94
123
  const { test, params } = inHashes(hashes)
95
124
  const stmt = this.db.query(
96
- `SELECT entry_hash, path, size_bytes, mode, mtime_ms, ino, ctime_ms FROM output_files WHERE entry_hash ${test}`,
125
+ `SELECT f.entry_hash, f.path, f.size_bytes, f.mode, f.mtime_ms, s.ino, s.ctime_ms FROM output_files f LEFT JOIN output_stamps s ON s.entry_hash = f.entry_hash AND s.path = f.path WHERE f.entry_hash ${test}`,
97
126
  )
98
127
  const rows = stmt.all(...params) as Array<{
99
128
  entry_hash: string
@@ -296,7 +325,9 @@ export class OutputIndex {
296
325
  this.pendingStamps.clear()
297
326
  this.db.transaction(() => {
298
327
  for (const [hash, rows] of stamps) {
299
- for (const [rel, ino, ctime] of rows) this.stampOutputFile.run(ino, ctime, hash, rel)
328
+ // A stamp outlives nothing: an entry pruned since has no rows to vouch for.
329
+ if (this.entryExists.get(hash) === null) continue
330
+ for (const [rel, ino, ctime] of rows) this.stampOutputFile.run(hash, rel, ino, ctime)
300
331
  }
301
332
  })()
302
333
  }
@@ -306,9 +337,8 @@ export class OutputIndex {
306
337
  this.db.transaction(() => {
307
338
  for (const [hash, rows] of pending) {
308
339
  this.deleteOutputDirs.run(hash)
309
- // The rows reference the entry: one pruned by another process
310
- // between the snapshot and this flush has nothing to describe,
311
- // and its insert would fail the whole transaction on the FK.
340
+ // The rows describe the entry: one pruned by another process
341
+ // between the snapshot and this flush has nothing to describe.
312
342
  if (rows === null || this.entryExists.get(hash) === null) continue
313
343
  for (const [rel, mtime] of rows) this.insertOutputDir.run(hash, rel, mtime)
314
344
  }
@@ -19,12 +19,21 @@ export function lazyStatement(db: Database, sql: string): ReturnType<Database['p
19
19
  } as unknown as ReturnType<Database['prepare']>
20
20
  }
21
21
 
22
- export function createTables(db: Database): void {
22
+ /**
23
+ * Create every table. `store` names the schema holding the ENTRY tables
24
+ * (`entries`, `entry_stdout`, `output_files`, `entry_inputs`, `store_meta`):
25
+ * `main` when one `cache.db` holds everything (a `cacheDir`), `store` when
26
+ * they live in the shared store attached beside it (v32). The rest is the
27
+ * workspace's own: its history, its memos, and what its disk looked like
28
+ * after a save or restore. A store table is never named with its schema in
29
+ * a statement; SQLite finds it in `main` or in `store`, whichever has it.
30
+ */
31
+ export function createTables(db: Database, store: 'main' | 'store' = 'main'): void {
23
32
  // Cached config evaluations (workspace/config-cache.ts): the validated
24
33
  // config as JSON, keyed by everything the evaluation could observe. A
25
34
  // separate exec so the artifact schema stays byte-identical.
26
35
  db.exec(`
27
- CREATE TABLE IF NOT EXISTS config_evals (
36
+ CREATE TABLE IF NOT EXISTS main.config_evals (
28
37
  key TEXT PRIMARY KEY,
29
38
  json TEXT NOT NULL,
30
39
  created_at INTEGER NOT NULL
@@ -34,7 +43,7 @@ export function createTables(db: Database): void {
34
43
  db.exec(`
35
44
  -- The queryable index: command, exit_code, duration, size and
36
45
  -- timestamps; stdout in entry_stdout below.
37
- CREATE TABLE IF NOT EXISTS entries (
46
+ CREATE TABLE IF NOT EXISTS ${store}.entries (
38
47
  hash TEXT PRIMARY KEY,
39
48
  project TEXT NOT NULL,
40
49
  task TEXT NOT NULL,
@@ -52,12 +61,12 @@ export function createTables(db: Database): void {
52
61
  -- carries so it survives a remote round trip; a local hit replays it
53
62
  -- from here. Its own table: an UPDATE rewrites a whole record, and the
54
63
  -- accessed_at bump rewrote up to 16 MB a hit. No row: empty stdout.
55
- CREATE TABLE IF NOT EXISTS entry_stdout (
64
+ CREATE TABLE IF NOT EXISTS ${store}.entry_stdout (
56
65
  hash TEXT PRIMARY KEY,
57
66
  stdout TEXT NOT NULL,
58
67
  FOREIGN KEY (hash) REFERENCES entries(hash) ON DELETE CASCADE
59
68
  );
60
- CREATE TABLE IF NOT EXISTS runs (
69
+ CREATE TABLE IF NOT EXISTS main.runs (
61
70
  id INTEGER PRIMARY KEY AUTOINCREMENT,
62
71
  -- '' when the outcome derived no cache key (skipped / persistent).
63
72
  -- Every reader that must not mistake it for a key guards hash != ''.
@@ -113,23 +122,23 @@ export function createTables(db: Database): void {
113
122
  -- runs_ended (2026-09-09) served only the retention DELETE, which
114
123
  -- prunes on started_at now (a row ends after it starts, so the
115
124
  -- 30-day window moves by at most one task's duration).
116
- DROP INDEX IF EXISTS runs_hash;
117
- DROP INDEX IF EXISTS runs_project;
118
- DROP INDEX IF EXISTS runs_ended;
119
- CREATE INDEX IF NOT EXISTS runs_started_at ON runs(started_at);
120
- CREATE INDEX IF NOT EXISTS runs_run_id ON runs(run_id);
125
+ DROP INDEX IF EXISTS main.runs_hash;
126
+ DROP INDEX IF EXISTS main.runs_project;
127
+ DROP INDEX IF EXISTS main.runs_ended;
128
+ CREATE INDEX IF NOT EXISTS main.runs_started_at ON runs(started_at);
129
+ CREATE INDEX IF NOT EXISTS main.runs_run_id ON runs(run_id);
121
130
  -- The one keyed index, PARTIAL over failed rows: a green run's
122
131
  -- inserts only evaluate its predicate, so the append-only cost above
123
132
  -- holds, and the flakiness probe after a miss (failure-mode.ts,
124
133
  -- "did this key ever fail?") reads a handful of leaves instead of
125
134
  -- scanning the table (2026-09-10: 10–95 ms at 170k rows without it).
126
- CREATE INDEX IF NOT EXISTS runs_failed ON runs(hash) WHERE status = 'failed';
135
+ CREATE INDEX IF NOT EXISTS main.runs_failed ON runs(hash) WHERE status = 'failed';
127
136
  -- Per-file (mtime, size, content_hash) cache. Lets Cache.key()
128
137
  -- skip the content-hash on inputs whose stat hasn't changed
129
138
  -- since the last run. Pure performance optimization; the stored
130
139
  -- hash is the exact same one content-hashing would compute now,
131
140
  -- so the cache key derivation is unchanged.
132
- CREATE TABLE IF NOT EXISTS file_hashes (
141
+ CREATE TABLE IF NOT EXISTS main.file_hashes (
133
142
  path TEXT PRIMARY KEY,
134
143
  mtime_ms INTEGER NOT NULL,
135
144
  size_bytes INTEGER NOT NULL,
@@ -142,14 +151,14 @@ export function createTables(db: Database): void {
142
151
  -- worktree size git recorded (A-60). Fixed for its OID, so a warm
143
152
  -- run asks git for none; swept with file_hashes by seen_at, the
144
153
  -- time the row was written.
145
- CREATE TABLE IF NOT EXISTS blob_sizes (
154
+ CREATE TABLE IF NOT EXISTS main.blob_sizes (
146
155
  oid TEXT PRIMARY KEY,
147
156
  size INTEGER NOT NULL,
148
157
  seen_at INTEGER NOT NULL
149
158
  );
150
159
  -- v30: the paths an index distrusts, by a hash of the index file and the
151
160
  -- pathspecs (A-60): a warm run reads one row, not one per blob.
152
- CREATE TABLE IF NOT EXISTS blob_verdicts (
161
+ CREATE TABLE IF NOT EXISTS main.blob_verdicts (
153
162
  digest TEXT PRIMARY KEY,
154
163
  paths TEXT NOT NULL,
155
164
  seen_at INTEGER NOT NULL
@@ -163,24 +172,34 @@ export function createTables(db: Database): void {
163
172
  -- ON DELETE CASCADE keeps these rows in sync with entries:
164
173
  -- a cache prune that drops an entry sweeps its output rows
165
174
  -- automatically.
166
- CREATE TABLE IF NOT EXISTS output_files (
175
+ CREATE TABLE IF NOT EXISTS ${store}.output_files (
167
176
  entry_hash TEXT NOT NULL,
168
177
  path TEXT NOT NULL,
169
178
  size_bytes INTEGER NOT NULL,
170
179
  mode INTEGER NOT NULL,
171
180
  mtime_ms INTEGER NOT NULL,
172
- -- v28: the inode and ctime this machine saw after the save or
173
- -- restore that left the file equal to the entry; NULL until then.
174
- ino INTEGER,
175
- ctime_ms INTEGER,
176
181
  PRIMARY KEY (entry_hash, path),
177
182
  FOREIGN KEY (entry_hash) REFERENCES entries(hash) ON DELETE CASCADE
178
183
  );
184
+ -- v28, its own table since v32: the inode and ctime THIS workspace saw
185
+ -- after the save or restore that left the file equal to the entry. No
186
+ -- row: never current. Apart from output_files because the entry is
187
+ -- shared and the disk is not: two worktrees on one store overwrote each
188
+ -- other's stamps, and every switch between them restored. No foreign
189
+ -- key (the entry may live in another database); a prune and a re-save
190
+ -- delete what they orphan.
191
+ CREATE TABLE IF NOT EXISTS main.output_stamps (
192
+ entry_hash TEXT NOT NULL,
193
+ path TEXT NOT NULL,
194
+ ino INTEGER NOT NULL,
195
+ ctime_ms INTEGER NOT NULL,
196
+ PRIMARY KEY (entry_hash, path)
197
+ );
179
198
  -- Each config's ORDERED import closure (the config first), so a warm
180
199
  -- load keys it by stat-hashing the list (the file_hashes memo) instead
181
200
  -- of reading and scanning every file. Machine-local; pruned with
182
201
  -- config_evals (2026-09-03).
183
- CREATE TABLE IF NOT EXISTS config_closures (
202
+ CREATE TABLE IF NOT EXISTS main.config_closures (
184
203
  config_path TEXT PRIMARY KEY,
185
204
  files_json TEXT NOT NULL,
186
205
  created_at INTEGER NOT NULL
@@ -190,19 +209,18 @@ export function createTables(db: Database): void {
190
209
  -- hit, unchanged mtimes prove the output SET is unchanged, replacing
191
210
  -- the glob walk that cost 0.36 ms per hit. Machine-local: a remote
192
211
  -- ingest writes none, and the first hit after it walks and records.
193
- CREATE TABLE IF NOT EXISTS output_dirs (
212
+ CREATE TABLE IF NOT EXISTS main.output_dirs (
194
213
  entry_hash TEXT NOT NULL,
195
214
  path TEXT NOT NULL,
196
215
  mtime_ms INTEGER NOT NULL,
197
- PRIMARY KEY (entry_hash, path),
198
- FOREIGN KEY (entry_hash) REFERENCES entries(hash) ON DELETE CASCADE
216
+ PRIMARY KEY (entry_hash, path)
199
217
  );
200
218
  -- v22 (Tier 3): one header row per vx-run invocation. The runs
201
219
  -- table is per-task; this is the per-invocation record that
202
220
  -- carries git/CI/host context, the command, tags, and run-level
203
221
  -- counts so the dashboard never reconstructs a header with a
204
222
  -- lossy GROUP BY over runs.
205
- CREATE TABLE IF NOT EXISTS invocations (
223
+ CREATE TABLE IF NOT EXISTS main.invocations (
206
224
  run_id TEXT PRIMARY KEY,
207
225
  command TEXT NOT NULL,
208
226
  requested_tasks TEXT NOT NULL,
@@ -233,9 +251,9 @@ export function createTables(db: Database): void {
233
251
  vx_version TEXT NOT NULL,
234
252
  tags TEXT NOT NULL DEFAULT '{}'
235
253
  );
236
- CREATE INDEX IF NOT EXISTS invocations_started ON invocations(started_at);
237
- CREATE INDEX IF NOT EXISTS invocations_branch ON invocations(branch);
238
- CREATE INDEX IF NOT EXISTS invocations_ci ON invocations(ci);
254
+ CREATE INDEX IF NOT EXISTS main.invocations_started ON invocations(started_at);
255
+ CREATE INDEX IF NOT EXISTS main.invocations_branch ON invocations(branch);
256
+ CREATE INDEX IF NOT EXISTS main.invocations_ci ON invocations(ci);
239
257
  -- v22 (Tier 3): the input-fingerprint moat. One row per cache-key
240
258
  -- component, keyed by the cache-ENTRY hash it belongs to (NOT a
241
259
  -- run id). Written inside the entry-save transaction — only on a
@@ -246,7 +264,7 @@ export function createTables(db: Database): void {
246
264
  -- SQL over (kind,name,hash); a JSON blob would force an app-side
247
265
  -- parse + compare on every probe. ON DELETE CASCADE keeps these
248
266
  -- rows in sync with entries (a prune sweeps them automatically).
249
- CREATE TABLE IF NOT EXISTS entry_inputs (
267
+ CREATE TABLE IF NOT EXISTS ${store}.entry_inputs (
250
268
  entry_hash TEXT NOT NULL,
251
269
  kind TEXT NOT NULL,
252
270
  name TEXT NOT NULL,
@@ -254,6 +272,13 @@ export function createTables(db: Database): void {
254
272
  PRIMARY KEY (entry_hash, kind, name),
255
273
  FOREIGN KEY (entry_hash) REFERENCES entries(hash) ON DELETE CASCADE
256
274
  );
275
+ -- v32: what belongs to the entries rather than to one workspace: the
276
+ -- salt their value digests are taken under, so a digest an entry_inputs
277
+ -- row holds compares with the one another workspace's run takes.
278
+ CREATE TABLE IF NOT EXISTS ${store}.store_meta (
279
+ key TEXT PRIMARY KEY,
280
+ value TEXT NOT NULL
281
+ );
257
282
  `)
258
283
  }
259
284
 
package/src/cli/info.ts CHANGED
@@ -91,6 +91,7 @@ export function renderInfo(f: InfoFacts): string {
91
91
  ['workers', describeWorkers(f.workers)],
92
92
  ['memory', describeMemory(f.memory)],
93
93
  ['cache dir', f.cacheDir],
94
+ ['cache store', f.cacheStore ?? 'none: the cache dir holds the entries'],
94
95
  ['cache versions', `keys ${f.cacheVersion} · index schema ${f.schemaVersion}`],
95
96
  ['cache entries', `${f.cacheEntries} (${formatBytes(f.cacheBytes)})`],
96
97
  // Only when there is something to say.
@@ -19,12 +19,15 @@ import {
19
19
  type ProjectMeta,
20
20
  readLockfile,
21
21
  resolveCacheDir,
22
+ resolveStoreRoot,
22
23
  } from '../workspace/index.js'
23
24
 
24
25
  export interface CliWorkspace {
25
26
  workspaceConfig: WorkspaceConfig | null
26
27
  plugins: readonly VxPlugin[]
27
28
  cacheDir: string
29
+ /** The shared store a run opens `cacheDir` with (`resolveStoreRoot`). */
30
+ storeRoot: string | null
28
31
  }
29
32
 
30
33
  export const warnToStderr = (message: string): void => {
@@ -33,7 +36,12 @@ export const warnToStderr = (message: string): void => {
33
36
 
34
37
  export async function loadCliWorkspace(workspaceRoot: string): Promise<CliWorkspace> {
35
38
  const { workspaceConfig, plugins } = await loadWorkspacePlugins(workspaceRoot, warnToStderr)
36
- return { workspaceConfig, plugins, cacheDir: resolveCacheDir(workspaceRoot, workspaceConfig) }
39
+ return {
40
+ workspaceConfig,
41
+ plugins,
42
+ cacheDir: resolveCacheDir(workspaceRoot, workspaceConfig),
43
+ storeRoot: resolveStoreRoot(workspaceRoot, workspaceConfig),
44
+ }
37
45
  }
38
46
 
39
47
  /**
@@ -131,7 +139,14 @@ export async function loadCliProjects(
131
139
  const cache =
132
140
  opts.noCreate === true && !existsSync(cacheDir)
133
141
  ? null
134
- : new Cache(cacheDir, { read: true, write: true }, workspaceRoot)
142
+ : new Cache(
143
+ cacheDir,
144
+ { read: true, write: true },
145
+ workspaceRoot,
146
+ undefined,
147
+ 'open',
148
+ opts.cacheDir === undefined ? ws.storeRoot : null,
149
+ )
135
150
  if (cache !== null) noteSchemaReset(cache, warnToStderr)
136
151
  try {
137
152
  const loaded = await loadProjects({
package/src/config.ts CHANGED
@@ -3,7 +3,11 @@
3
3
  export interface WorkspaceConfig {
4
4
  /** Maximum concurrent tasks. Defaults to the number of CPUs. */
5
5
  concurrency?: number
6
- /** Cache directory, relative to the workspace root. Defaults to `.vx/cache`. */
6
+ /**
7
+ * Cache directory, relative to the workspace root. Named, it holds the whole
8
+ * cache and shares nothing; unset, the workspace keeps its index in
9
+ * `.vx/cache` and its entries in the user's shared store.
10
+ */
7
11
  cacheDir?: string
8
12
  /**
9
13
  * Default per-task timeout (ms) — the lowest-precedence fallback for a
@@ -28,6 +28,7 @@ import {
28
28
  lockfilePath,
29
29
  type ProjectMeta,
30
30
  resolveCacheDir,
31
+ resolveStoreRoot,
31
32
  } from '../workspace/index.js'
32
33
  import { flakyTasks, type FlakyTask } from './failure-mode.js'
33
34
  import type { VxPlugin } from './plugin.js'
@@ -81,6 +82,8 @@ export interface InfoFacts {
81
82
  */
82
83
  memory: { usableBytes: number; totalBytes: number; cgroupLimitBytes: number | null }
83
84
  cacheDir: string
85
+ /** The shared store holding the entries, or null when `cacheDir` holds them. */
86
+ cacheStore: string | null
84
87
  cacheVersion: string
85
88
  schemaVersion: string
86
89
  cacheEntries: number
@@ -149,6 +152,7 @@ async function collectWorkspaceInfo(
149
152
  ? resolveCacheDir(root, workspaceConfig)
150
153
  : path.resolve(cwd, opts.cacheDir)
151
154
  const metas = await discoverProjects(await loadWorkspace(root, reads), plugins, cacheDir, warn)
155
+ const storeRoot = opts.cacheDir === undefined ? resolveStoreRoot(root, workspaceConfig) : null
152
156
  const cache = Cache.inspect(cacheDir)
153
157
  noteSchemaReset(cache, warn)
154
158
  let stats
@@ -232,6 +236,8 @@ async function collectWorkspaceInfo(
232
236
  workers: workersFact(workspaceConfig?.concurrency),
233
237
  memory: memoryFact(),
234
238
  cacheDir,
239
+ // The store the index records, else the one a first run would open.
240
+ cacheStore: cache.storeDir ?? storeRoot,
235
241
  // The two versions a bug report needs and the reset notice names: the
236
242
  // key prefix (a bump orphans every entry) and the index schema (a
237
243
  // mismatch drops every table).
@@ -38,6 +38,7 @@ import {
38
38
  type Lockfile,
39
39
  readLockfile,
40
40
  resolveCacheDir,
41
+ resolveStoreRoot,
41
42
  type ProjectEntry,
42
43
  } from '../workspace/index.js'
43
44
  import {
@@ -215,9 +216,12 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
215
216
  mark('workspace config')
216
217
  // `--cache-dir <path>` (RunOptions.cacheDir) overrides the workspace
217
218
  // `cacheDir` field + the `.vx/cache` default; resolved relative to cwd.
219
+ // A cache dir named anywhere holds everything; the default shares its
220
+ // entries through the user's store.
218
221
  const cacheDir = options.cacheDir
219
222
  ? path.resolve(options.cwd, options.cacheDir)
220
223
  : resolveCacheDir(workspaceRoot, workspaceConfig)
224
+ const storeRoot = options.cacheDir ? null : resolveStoreRoot(workspaceRoot, workspaceConfig)
221
225
  const projectMetas =
222
226
  reused !== undefined
223
227
  ? reused.projects
@@ -278,6 +282,8 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
278
282
  { read: policy.localRead, write: policy.localWrite },
279
283
  workspaceRoot,
280
284
  options.artifactCeiling,
285
+ 'open',
286
+ storeRoot,
281
287
  )
282
288
  localCache.assertWritable()
283
289
  noteSchemaReset(localCache, (m) => log.status(m))
@@ -61,6 +61,7 @@ export {
61
61
  memberBaseDirs,
62
62
  PROJECT_CONFIG_FILENAMES,
63
63
  resolveCacheDir,
64
+ resolveStoreRoot,
64
65
  type ProjectEntry,
65
66
  type ProjectMeta,
66
67
  type Workspace,
@@ -0,0 +1,200 @@
1
+ // Which repository a workspace is, so its checkouts share one store under
2
+ // `~/.vx/<id>` and other repositories keep theirs: Nx 23.2's
3
+ // `~/.nx/<id>` (`utils/workspace-id.js`, `utils/git-utils.js`), ported
4
+ // rule for rule (owner, 2026-10-06: "do exactly like nx"), minus the Nx
5
+ // Cloud id. Read from the `.git` files; git is asked only when they cannot
6
+ // settle it (no parseable remote, an `include`, an `insteadOf`).
7
+
8
+ import {
9
+ closeSync,
10
+ constants,
11
+ existsSync,
12
+ fstatSync,
13
+ lstatSync,
14
+ openSync,
15
+ readFileSync,
16
+ statSync,
17
+ } from 'node:fs'
18
+ import path from 'node:path'
19
+
20
+ /**
21
+ * 16 hex of sha256 of the repo key, sha256(`<identity>#<workspace path in
22
+ * the repo>`). The identity is the remote's `host/owner/repo` in lower
23
+ * case, so ssh, https and token URLs agree; with no remote, the sorted
24
+ * first root commit; null for a shallow clone with no remote and outside
25
+ * git: those share nothing.
26
+ */
27
+ export function repoIdOf(root: string): string | null {
28
+ const located = locateGitDir(root)
29
+ if (located === null) return null
30
+ const identity = remoteIdentity(root, located.commonDir) ?? firstCommit(root)
31
+ if (identity === null) return null
32
+ const rel = path.relative(located.gitRoot, path.resolve(root)).split(path.sep).join('/')
33
+ const key = sha256(`${identity}#${rel}`)
34
+ return sha256(key).slice(0, 16)
35
+ }
36
+
37
+ const sha256 = (s: string): string => new Bun.CryptoHasher('sha256').update(s).digest('hex')
38
+
39
+ interface GitDir {
40
+ gitRoot: string
41
+ commonDir: string
42
+ }
43
+
44
+ /** The repository `dir` is in: walk up to a `.git`; null past one not ours. */
45
+ function locateGitDir(dir: string): GitDir | null {
46
+ let current = path.resolve(dir)
47
+ for (;;) {
48
+ const at = gitDirAt(current)
49
+ if (at !== undefined) return at
50
+ const up = path.dirname(current)
51
+ if (up === current) return null
52
+ current = up
53
+ }
54
+ }
55
+
56
+ /** Undefined with no `.git` here (walk on); null for one vx will not read. */
57
+ function gitDirAt(dir: string): GitDir | null | undefined {
58
+ const dotGit = path.join(dir, '.git')
59
+ let entry
60
+ try {
61
+ entry = statSync(dotGit)
62
+ } catch {
63
+ return undefined
64
+ }
65
+ if (entry.isDirectory()) {
66
+ // A `.git` planted in a world-writable ancestor (`/tmp`) must not name
67
+ // the repository of everything under it: git's own shape and owner checks.
68
+ if (!existsSync(path.join(dotGit, 'HEAD')) || !existsSync(path.join(dotGit, 'objects'))) {
69
+ return null
70
+ }
71
+ return ownedRealDir(dotGit) ? { gitRoot: dir, commonDir: dotGit } : null
72
+ }
73
+ if (entry.isFile()) {
74
+ const pointer = /^gitdir:\s*(.+)$/m.exec(readOwnedFile(dotGit) ?? '')
75
+ if (pointer === null) return null
76
+ const gitDir = path.resolve(dir, pointer[1]!.trim())
77
+ const shared = readOwnedFile(path.join(gitDir, 'commondir'))?.trim()
78
+ const commonDir = shared ? path.resolve(gitDir, shared) : gitDir
79
+ return ownedRealDir(commonDir) ? { gitRoot: dir, commonDir } : null
80
+ }
81
+ return undefined
82
+ }
83
+
84
+ const ownUid = (): number | undefined => process.getuid?.()
85
+
86
+ function ownedRealDir(p: string): boolean {
87
+ try {
88
+ const st = lstatSync(p)
89
+ return st.isDirectory() && (ownUid() === undefined || st.uid === ownUid())
90
+ } catch {
91
+ return false
92
+ }
93
+ }
94
+
95
+ /** A regular file of ours, read without following a link or blocking on a FIFO. */
96
+ function readOwnedFile(p: string): string | null {
97
+ let fd: number | undefined
98
+ try {
99
+ fd = openSync(p, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
100
+ const st = fstatSync(fd)
101
+ if (!st.isFile() || (ownUid() !== undefined && st.uid !== ownUid())) return null
102
+ return readFileSync(fd, 'utf8')
103
+ } catch {
104
+ return null
105
+ } finally {
106
+ if (fd !== undefined) closeSync(fd)
107
+ }
108
+ }
109
+
110
+ /** The remote's identity from the config file, else from `git remote -v`. */
111
+ function remoteIdentity(root: string, commonDir: string): string | null {
112
+ const config = readOwnedFile(path.join(commonDir, 'config'))
113
+ const remotes = config === null ? null : parseGitConfigRemotes(config)
114
+ const urls = remotes ?? remotesFromGit(root)
115
+ const found = new Map<string, string>()
116
+ let first: string | null = null
117
+ for (const [name, url] of urls) {
118
+ const info = parseRemoteUrl(url)
119
+ if (info !== null && !found.has(name)) {
120
+ found.set(name, info)
121
+ first ??= info
122
+ }
123
+ }
124
+ const picked = found.get('origin') ?? found.get('upstream') ?? found.get('base') ?? first
125
+ return picked === null ? null : picked.toLowerCase().replace(/\/+$/, '')
126
+ }
127
+
128
+ /**
129
+ * Remote name → url pairs in file order, or null when the file cannot be
130
+ * answered for whole: an `include`, a `url "…"` rewrite, or a value git
131
+ * would unescape or cut at a comment.
132
+ */
133
+ export function parseGitConfigRemotes(contents: string): [string, string][] | null {
134
+ const remotes: [string, string][] = []
135
+ const seen = new Set<string>()
136
+ let remote: string | null = null
137
+ for (const raw of contents.split('\n')) {
138
+ const line = raw.trim()
139
+ if (line === '' || line.startsWith('#') || line.startsWith(';')) continue
140
+ if (line.startsWith('[')) {
141
+ const close = line.indexOf(']')
142
+ const section = (close === -1 ? line.slice(1) : line.slice(1, close)).trim()
143
+ if (/^include(If)?\b/i.test(section) || /^url\s+"/i.test(section)) return null
144
+ remote = /^remote\s+"(.*)"$/i.exec(section)?.[1] ?? null
145
+ continue
146
+ }
147
+ if (remote === null) continue
148
+ const eq = line.indexOf('=')
149
+ if (eq === -1 || line.slice(0, eq).trim().toLowerCase() !== 'url') continue
150
+ const value = line.slice(eq + 1).trim()
151
+ if (/[#;\\]/.test(value)) return null
152
+ const url = value.replace(/^"(.*)"$/, '$1')
153
+ if (url.includes('"')) return null
154
+ // `remote.<name>.url` is multi-valued; git fetches from the first.
155
+ if (!seen.has(remote)) {
156
+ seen.add(remote)
157
+ remotes.push([remote, url])
158
+ }
159
+ }
160
+ return remotes
161
+ }
162
+
163
+ function remotesFromGit(root: string): [string, string][] {
164
+ const out = git(root, ['remote', '-v'])
165
+ if (out === null) return []
166
+ const pairs: [string, string][] = []
167
+ for (const line of out.split('\n')) {
168
+ const m = /^(\w+)\s+(\S+)\s+\((fetch|push)\)$/.exec(line.trim())
169
+ if (m !== null) pairs.push([m[1]!, m[2]!])
170
+ }
171
+ return pairs
172
+ }
173
+
174
+ /** `host/owner/repo` for the four url shapes Nx reads; null for the rest. */
175
+ export function parseRemoteUrl(url: string): string | null {
176
+ const u = url.trim()
177
+ const m =
178
+ /^git@([^:]+):([^/]+)\/(.+?)(\.git)?$/.exec(u) ??
179
+ /^https?:\/\/[^@]+@([^/]+)\/([^/]+)\/(.+?)(\.git)?$/.exec(u) ??
180
+ /^https?:\/\/([^@/]+)\/([^/]+)\/(.+?)(\.git)?$/.exec(u)
181
+ if (m !== null) return `${m[1]}/${m[2]}/${m[3]}`
182
+ const ssh = /^ssh:\/\/[^@]+@([^:/]+)(:[0-9]+)?\/([^/]+)\/(.+?)(\.git)?$/.exec(u)
183
+ return ssh === null ? null : `${ssh[1]}/${ssh[3]}/${ssh[4]}`
184
+ }
185
+
186
+ /** The sorted-first root commit, so every clone agrees; null when shallow. */
187
+ function firstCommit(root: string): string | null {
188
+ if (git(root, ['rev-parse', '--is-shallow-repository'])?.trim() === 'true') return null
189
+ const roots = git(root, ['rev-list', '--max-parents=0', 'HEAD'])
190
+ return roots?.split(/\r?\n/).filter(Boolean).sort()[0] ?? null
191
+ }
192
+
193
+ function git(cwd: string, args: string[]): string | null {
194
+ try {
195
+ const p = Bun.spawnSync(['git', ...args], { cwd, stdout: 'pipe', stderr: 'ignore' })
196
+ return p.exitCode === 0 ? p.stdout.toString() : null
197
+ } catch {
198
+ return null
199
+ }
200
+ }
@@ -1,5 +1,6 @@
1
1
  import { constants, type Dirent } from 'node:fs'
2
2
  import { access, readdir, realpath, stat } from 'node:fs/promises'
3
+ import { homedir } from 'node:os'
3
4
  import path from 'node:path'
4
5
  import type { ProjectConfig, WorkspaceConfig } from '../config.js'
5
6
  import {
@@ -13,6 +14,7 @@ import {
13
14
  normalizeBunGlob,
14
15
  } from '../util/index.js'
15
16
  import { type LoadReads, readOnce, unreadable } from './load-reads.js'
17
+ import { repoIdOf } from './repo-id.js'
16
18
 
17
19
  export interface PackageJson {
18
20
  name: string
@@ -349,15 +351,35 @@ function extglobRefusal(pattern: string, file: string, field: string): UserError
349
351
  }
350
352
 
351
353
  /**
352
- * Resolve the cache directory for a workspace. Respects the user's
353
- * `defineWorkspace({ cacheDir })` override (relative to the workspace
354
- * root) and falls back to `.vx/cache` when no config is set.
354
+ * The workspace's own cache directory (index, history, memos): the
355
+ * workspace's `cacheDir`, else `VX_CACHE_DIR`, else `.vx/cache`, relative
356
+ * to the workspace root.
355
357
  */
356
358
  export function resolveCacheDir(root: string, config: WorkspaceConfig | null): string {
357
- const rel = config?.cacheDir ?? path.join('.vx', 'cache')
359
+ const rel = config?.cacheDir ?? (process.env['VX_CACHE_DIR'] || path.join('.vx', 'cache'))
358
360
  return path.resolve(root, rel)
359
361
  }
360
362
 
363
+ /**
364
+ * Where this repository's shared store lives: `~/.vx/<repo id>/cache`, as
365
+ * Nx 23 keeps `~/.nx/<id>/cache` (owner, 2026-10-06), so every checkout of
366
+ * the repository hits what another saved and no other repository shares
367
+ * it (`repoIdOf`). Null when the workspace names its cache (`cacheDir`,
368
+ * `VX_CACHE_DIR`): that directory then holds everything, as one
369
+ * workspace's alone. Null with no repository identity or no home.
370
+ */
371
+ export function resolveStoreRoot(root: string, config: WorkspaceConfig | null): string | null {
372
+ if (config?.cacheDir !== undefined || process.env['VX_CACHE_DIR']) return null
373
+ // HOME first: Bun's homedir() keeps the HOME the process started with (1.4.2).
374
+ const home = process.env['HOME'] || homedir()
375
+ if (!path.isAbsolute(home)) return null
376
+ let id = repoIds.get(root)
377
+ if (id === undefined) repoIds.set(root, (id = repoIdOf(root)))
378
+ return id === null ? null : path.join(home, '.vx', id, 'cache')
379
+ }
380
+
381
+ const repoIds = new Map<string, string | null>()
382
+
361
383
  /**
362
384
  * Read the workspace's package-glob list, supporting all common
363
385
  * package managers: