@vzn/vx 0.0.514 → 0.0.515

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.514",
3
+ "version": "0.0.515",
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.514",
25
- "@vzn/vx-linux-arm64": "0.0.514",
26
- "@vzn/vx-darwin-x64": "0.0.514",
27
- "@vzn/vx-darwin-arm64": "0.0.514"
24
+ "@vzn/vx-linux-x64": "0.0.515",
25
+ "@vzn/vx-linux-arm64": "0.0.515",
26
+ "@vzn/vx-darwin-x64": "0.0.515",
27
+ "@vzn/vx-darwin-arm64": "0.0.515"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -37,6 +37,7 @@ import {
37
37
  closeSync,
38
38
  constants,
39
39
  existsSync,
40
+ linkSync,
40
41
  lstatSync,
41
42
  mkdirSync,
42
43
  openSync,
@@ -181,17 +182,23 @@ export function noteSchemaReset(cache: Cache, warn: (message: string) => void):
181
182
  `[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
  )
183
184
  }
185
+ if (cache.storeReset !== null) {
186
+ const { from, to } = cache.storeReset
187
+ warn(
188
+ `[vx] shared cache store ${cache.storeDir} re-indexed: schema ${from} → ${to} (vx version changed); its artifacts stay and are indexed again as tasks hit them`,
189
+ )
190
+ }
184
191
  if (cache.formatChange !== null) {
185
192
  const { from, to } = cache.formatChange
186
193
  warn(
187
- `[vx] cache format changed: ${from} → ${to} (vx upgraded); every cached task misses once and re-saves, and the old entries, never read again, age out under \`vx cache prune --older-than\` or \`cacheRetention\``,
194
+ `[vx] cache format changed: ${from} → ${to} (vx version changed); every cached task misses once and re-saves, and the old entries, never read again, age out under \`vx cache prune --older-than\` or \`cacheRetention\``,
188
195
  )
189
196
  return
190
197
  }
191
198
  if (cache.schemaReset === null) return
192
199
  const { from, to } = cache.schemaReset
193
200
  warn(
194
- `[vx] cache index reset: schema ${from} → ${to} (vx upgraded); every cached task misses once and re-saves, and \`vx cache prune\` reclaims the old artifacts`,
201
+ `[vx] cache index reset: schema ${from} → ${to} (vx version changed); run history starts over, and each artifact is indexed again when its task next hits`,
195
202
  )
196
203
  }
197
204
  // SCHEMA history (drop+recreate on mismatch; pre-alpha, no migrations):
@@ -269,12 +276,6 @@ const STORE_TABLES = ['entry_inputs', 'output_files', 'entry_stdout', 'store_met
269
276
  const SELECT_ENTRY =
270
277
  "SELECT e.*, COALESCE(s.stdout, '') AS stdout FROM entries e LEFT JOIN entry_stdout s ON s.hash = e.hash"
271
278
 
272
- /** A schema version's number (`v28` → 28); one that is not `v<n>` is older than any. */
273
- function schemaOrdinal(version: string): number {
274
- const m = /^v(\d+)$/.exec(version)
275
- return m === null ? -1 : Number(m[1])
276
- }
277
-
278
279
  /**
279
280
  * Artifacts `indexed` does not hold, and temps, past the in-flight grace
280
281
  * window: one readdir, one stat per candidate. `indexed` is asked only
@@ -574,6 +575,13 @@ export class Cache implements CacheLayer {
574
575
  */
575
576
  readonly formatChange: SchemaReset | null = null
576
577
 
578
+ /**
579
+ * Set when THIS open found the shared store written under another
580
+ * `SCHEMA_VERSION` and dropped its inventory; the artifacts stay and are
581
+ * indexed again as they hit. Reported by `noteSchemaReset`.
582
+ */
583
+ readonly storeReset: SchemaReset | null = null
584
+
577
585
  /** A reading verb's open: it never resets the index (the constructor's `mode`). */
578
586
  static inspect(cacheDir: string): Cache {
579
587
  return new Cache(cacheDir, undefined, undefined, undefined, 'inspect')
@@ -607,7 +615,7 @@ export class Cache implements CacheLayer {
607
615
  } catch {
608
616
  return null
609
617
  }
610
- if (found === undefined || schemaOrdinal(found) >= schemaOrdinal(SCHEMA_VERSION)) return null
618
+ if (found === undefined || found === SCHEMA_VERSION) return null
611
619
  const aged = await scanOrphanFiles(cacheDir, () => new Set())
612
620
  let orphanBytes = 0
613
621
  for (const o of aged) orphanBytes += o.size
@@ -634,7 +642,7 @@ export class Cache implements CacheLayer {
634
642
  /**
635
643
  * The shared store's directory, unversioned: every key is seeded with
636
644
  * `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
645
+ * and the store's own schema is `store_meta.schema` (`matchStoreSchema`). Or
638
646
  * `null` for an index that holds its entries itself (a `cacheDir`).
639
647
  * Undefined follows the layout the index records: a reading verb, a
640
648
  * plugin's handle.
@@ -744,20 +752,14 @@ export class Cache implements CacheLayer {
744
752
  | { value: string }
745
753
  | undefined
746
754
  )?.value
747
- // Only an EARLIER schema is reset, and only by an opener that may write
748
- // it. A newer one is another vx's index and history: an older binary
749
- // (a global install beside a workspace's own) dropped every table of it
750
- // and announced "vx upgraded", and so did a reading verb, a dry prune
751
- // among them (item 896).
755
+ // Any other schema is reset by an opener that may write it: the index is
756
+ // an inventory the artifacts rebuild, whichever vx wrote it (owner,
757
+ // 2026-10-06; a newer one used to be refused, item 896). A reading verb
758
+ // leaves it as it was.
752
759
  const refuseUnreadable = (found: string): void => {
753
- if (schemaOrdinal(found) > schemaOrdinal(SCHEMA_VERSION)) {
754
- throw new UserError(
755
- `the cache at ${cacheDir} holds index schema ${found}, written by a newer vx; this vx reads ${SCHEMA_VERSION} and leaves it untouched. Run the newer vx, or give this one another --cache-dir`,
756
- )
757
- }
758
760
  if (mode === 'inspect') {
759
761
  throw new UserError(
760
- `the cache at ${cacheDir} holds index schema ${found} from an earlier vx; this vx reads ${SCHEMA_VERSION}, so nothing in it is readable here, and this verb leaves it untouched. The next \`vx run\` resets it`,
762
+ `the cache at ${cacheDir} holds index schema ${found} from another vx version; this vx reads ${SCHEMA_VERSION}, so nothing in it is readable here, and this verb leaves it untouched. The next \`vx run\` resets it`,
761
763
  )
762
764
  }
763
765
  }
@@ -850,9 +852,19 @@ export class Cache implements CacheLayer {
850
852
  }
851
853
  this.storeDir = storeDir
852
854
  this.artifactDir = storeDir ?? cacheDir
853
- if (storeDir !== undefined) this.attachStore(storeDir, mode === 'inspect')
855
+ if (storeDir !== undefined) {
856
+ this.attachStore(storeDir, mode === 'inspect')
857
+ this.storeReset = this.matchStoreSchema(mode === 'open' && this.writeBlocked === null)
858
+ }
854
859
 
855
860
  createTables(this.db, storeDir === undefined ? 'main' : 'store')
861
+ if (storeDir !== undefined && mode === 'open' && this.writeBlocked === null) {
862
+ this.db
863
+ .prepare(
864
+ "INSERT INTO store.store_meta(key, value) VALUES ('schema', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
865
+ )
866
+ .run(SCHEMA_VERSION)
867
+ }
856
868
  if (mode === 'open' && this.writeBlocked === null && storeDir !== recorded) {
857
869
  if (storeDir === undefined)
858
870
  this.db.prepare("DELETE FROM schema_meta WHERE key = 'store_dir'").run()
@@ -977,6 +989,57 @@ export class Cache implements CacheLayer {
977
989
  }
978
990
  }
979
991
 
992
+ /**
993
+ * The store is an inventory of the artifacts beside it, and only this vx
994
+ * reads it: a store another `SCHEMA_VERSION` wrote has its tables dropped
995
+ * (owner, 2026-10-06), the artifacts kept. Each is indexed again from its
996
+ * own bytes on its next hit (`adopt`). A reading verb reads such a store
997
+ * as empty and changes nothing.
998
+ */
999
+ private matchStoreSchema(writable: boolean): SchemaReset | null {
1000
+ const tables = new Set(
1001
+ (
1002
+ this.db
1003
+ .prepare("SELECT name FROM store.sqlite_master WHERE type = 'table'")
1004
+ .all() as Array<{
1005
+ name: string
1006
+ }>
1007
+ ).map((r) => r.name),
1008
+ )
1009
+ if (!tables.has('entries')) return null
1010
+ const found = tables.has('store_meta')
1011
+ ? (
1012
+ this.db.prepare("SELECT value FROM store.store_meta WHERE key = 'schema'").get() as {
1013
+ value: string
1014
+ } | null
1015
+ )?.value
1016
+ : undefined
1017
+ if (found === SCHEMA_VERSION) return null
1018
+ if (!writable) {
1019
+ this.db.exec('DETACH DATABASE store')
1020
+ this.db.prepare('ATTACH DATABASE ? AS store').run(':memory:')
1021
+ return null
1022
+ }
1023
+ let reset: SchemaReset | null = null
1024
+ this.db
1025
+ .transaction(() => {
1026
+ // Re-read under the write lock: another workspace's open may have
1027
+ // reset it first.
1028
+ const now = (
1029
+ this.db.prepare("SELECT value FROM store.store_meta WHERE key = 'schema'").get() as {
1030
+ value: string
1031
+ } | null
1032
+ )?.value
1033
+ if (now === SCHEMA_VERSION) return
1034
+ for (const t of STORE_TABLES) {
1035
+ if (t !== 'store_meta') this.db.exec(`DROP TABLE IF EXISTS store.${t}`)
1036
+ }
1037
+ reset = { from: found ?? 'an earlier schema', to: SCHEMA_VERSION }
1038
+ })
1039
+ .immediate()
1040
+ return reset
1041
+ }
1042
+
980
1043
  // --- config evaluations: `ConfigEvalStore`, delegated to `ConfigEvalTable` ---
981
1044
  getConfigEval(key: string): string | null {
982
1045
  return this.guard(() => this.configEvals.getConfigEval(key))
@@ -1107,11 +1170,53 @@ export class Cache implements CacheLayer {
1107
1170
  // ctx is accepted but ignored — the local layer reads metadata from
1108
1171
  // the entries row. It's part of the contract so LayeredCache can
1109
1172
  // route metadata to `ingest()` on remote-hit without a separate API.
1110
- async get(hash: string, _ctx?: CacheGetContext): Promise<CacheEntry | null> {
1173
+ async get(hash: string, ctx?: CacheGetContext): Promise<CacheEntry | null> {
1111
1174
  // Local reads disabled (e.g. `--force` / `--cache=local:w`): report a
1112
1175
  // miss so the task re-executes. The artifact + index are untouched.
1113
1176
  if (!this.read) return null
1114
- return this.guard(() => this.readEntry(hash))
1177
+ return this.guard(async () => {
1178
+ const entry = await this.readEntry(hash)
1179
+ if (entry !== null || ctx === undefined || !this.write) return entry
1180
+ return (await this.adopt(hash, ctx)) ? this.readEntry(hash) : null
1181
+ })
1182
+ }
1183
+
1184
+ /**
1185
+ * Index an artifact the index does not know, from its own bytes: the
1186
+ * artifact is the record, the index only an inventory of it (owner,
1187
+ * 2026-10-06). A reset index (a `SCHEMA_VERSION` change, a `cache.db` or
1188
+ * `store.db` deleted) loses nothing a task asks for again. The artifact
1189
+ * is checked as an ingest checks a remote's (its recorded key, its names
1190
+ * against the declared outputs); one that fails is a miss, and the run
1191
+ * that follows saves over it.
1192
+ */
1193
+ private async adopt(hash: string, ctx: CacheGetContext): Promise<boolean> {
1194
+ const finalPath = this.tarPath(hash)
1195
+ if (!existsSync(finalPath)) return false
1196
+ const tmpPath = this.tempPath(hash)
1197
+ try {
1198
+ // A second name for the same bytes: the index step renames it over
1199
+ // the artifact inside its write transaction, as for a save.
1200
+ linkSync(finalPath, tmpPath)
1201
+ } catch {
1202
+ return false
1203
+ }
1204
+ try {
1205
+ await this.writeArtifactAndIndex(
1206
+ hash,
1207
+ { tmpPath },
1208
+ {
1209
+ taskId: ctx.taskId,
1210
+ command: ctx.command,
1211
+ durationMs: 0,
1212
+ ...(ctx.outputs !== undefined ? { outputs: ctx.outputs } : {}),
1213
+ },
1214
+ )
1215
+ return true
1216
+ } catch (err) {
1217
+ if (err instanceof CorruptArtifactError || err instanceof ArchiveSecurityError) return false
1218
+ throw err
1219
+ }
1115
1220
  }
1116
1221
 
1117
1222
  /**
@@ -1160,8 +1265,21 @@ export class Cache implements CacheLayer {
1160
1265
  * The up-front short-circuit probe is the caller; per-hit `get` stays for
1161
1266
  * the lazy path.
1162
1267
  */
1163
- async getMany(hashes: readonly string[]): Promise<Map<string, CacheEntry>> {
1164
- return this.guard(() => this.getManyEntries(hashes))
1268
+ async getMany(
1269
+ hashes: readonly string[],
1270
+ ctx?: (hash: string) => CacheGetContext,
1271
+ ): Promise<Map<string, CacheEntry>> {
1272
+ return this.guard(async () => {
1273
+ const out = await this.getManyEntries(hashes)
1274
+ if (ctx === undefined || !this.write || out.size === hashes.length) return out
1275
+ // An artifact the index does not know is still a hit (`adopt`).
1276
+ for (const hash of hashes) {
1277
+ if (out.has(hash) || !(await this.adopt(hash, ctx(hash)))) continue
1278
+ const entry = await this.readEntry(hash)
1279
+ if (entry !== null) out.set(hash, entry)
1280
+ }
1281
+ return out
1282
+ })
1165
1283
  }
1166
1284
 
1167
1285
  private async getManyEntries(hashes: readonly string[]): Promise<Map<string, CacheEntry>> {
@@ -565,9 +565,12 @@ export interface CacheLayer {
565
565
  /**
566
566
  * Optional batched `get` (same answers, fewer round trips). The
567
567
  * short-circuit probe uses it when a layer offers one; a layer without it
568
- * is probed hash by hash.
568
+ * is probed hash by hash. `ctx` is each hash's `get` context.
569
569
  */
570
- getMany?(hashes: readonly string[]): Promise<Map<string, CacheEntry>>
570
+ getMany?(
571
+ hashes: readonly string[],
572
+ ctx?: (hash: string) => CacheGetContext,
573
+ ): Promise<Map<string, CacheEntry>>
571
574
  /**
572
575
  * Lightweight existence probe. `'local'` / `'remote'` names the layer
573
576
  * that holds the artifact; `null` is a miss. NEVER moves bytes: no
package/src/cli/cache.ts CHANGED
@@ -176,7 +176,7 @@ async function pruneCmd(args: readonly string[]): Promise<number> {
176
176
  const earlier = await Cache.orphansBeforeReset(dir)
177
177
  if (earlier !== null) {
178
178
  warnToStderr(
179
- `[vx] the cache index is schema ${earlier.found} from an earlier vx: the prune resets it first, and every artifact past the hour's grace is then an orphan`,
179
+ `[vx] the cache index is schema ${earlier.found} from another vx version: the prune resets it first, and every artifact past the hour's grace is then an orphan`,
180
180
  )
181
181
  printPruned({ evicted: 0, bytesFreed: 0, ...earlier }, true, json)
182
182
  return 0
@@ -245,9 +245,9 @@ async function collectWorkspaceInfo(
245
245
  schemaVersion: SCHEMA_VERSION,
246
246
  cacheEntries: stats.entryCount,
247
247
  cacheBytes: stats.totalBytes,
248
- // The index is authoritative, so a row-less artifact is bytes nothing
249
- // will ever hit — and only `vx cache prune` reclaims them (after an
250
- // upgrade's schema reset, most often).
248
+ // A row-less artifact is indexed again only when its task asks for its
249
+ // key; the rest only `vx cache prune` reclaims (after an upgrade's
250
+ // schema reset, most often).
251
251
  orphans: { artifacts: orphans.orphans, bytes: orphans.orphanBytes },
252
252
  runs24h: stats.runCountLast24h,
253
253
  hits24h: stats.hitCountLast24h,
@@ -46,6 +46,7 @@ import type { CacheEntry, CacheLayer, GitFilesCache } from '../cache/index.js'
46
46
  import type { TaskNode } from '../graph/index.js'
47
47
  import { deriveStableKeys, workspaceInputsReach } from './stable-keys.js'
48
48
  import type { HashCache } from './task-hash.js'
49
+ import { getContext } from './remote-prefetch.js'
49
50
 
50
51
  export interface ShortCircuitArgs {
51
52
  nodes: Map<string, TaskNode>
@@ -138,7 +139,14 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
138
139
  if (args.cache.getMany !== undefined) {
139
140
  try {
140
141
  const endProbe = span('probe')
141
- const hits = await args.cache.getMany(candidates.map((c) => c.hash))
142
+ const byHash = new Map(candidates.map((c) => [c.hash, c.node]))
143
+ const hits = await args.cache.getMany(
144
+ candidates.map((c) => c.hash),
145
+ (hash) => {
146
+ const node = byHash.get(hash)!
147
+ return getContext(node, node.config.exec?.command ?? '')
148
+ },
149
+ )
142
150
  endProbe()
143
151
  for (const { hash, node } of candidates) {
144
152
  const hit = hits.get(hash) ?? null
@@ -157,8 +165,7 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
157
165
  while (next < candidates.length) {
158
166
  const { hash, node } = candidates[next++]!
159
167
  try {
160
- const command = node.config.exec?.command ?? ''
161
- const hit = await args.cache.get(hash, { taskId: node.id, command })
168
+ const hit = await args.cache.get(hash, getContext(node, node.config.exec?.command ?? ''))
162
169
  preProbed.set(node.id, { hash, hit })
163
170
  if (hit !== null && !keptOut.has(node.id)) restoreTier.add(node.id)
164
171
  } catch {