@vzn/vx 0.0.617 → 0.0.619

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.617",
3
+ "version": "0.0.619",
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.617",
25
- "@vzn/vx-linux-arm64": "0.0.617",
26
- "@vzn/vx-darwin-x64": "0.0.617",
27
- "@vzn/vx-darwin-arm64": "0.0.617"
24
+ "@vzn/vx-linux-x64": "0.0.619",
25
+ "@vzn/vx-linux-arm64": "0.0.619",
26
+ "@vzn/vx-darwin-x64": "0.0.619",
27
+ "@vzn/vx-darwin-arm64": "0.0.619"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -689,6 +689,14 @@ class Extractor {
689
689
  // resolve was a round trip spent on a comparison never made (item 627).
690
690
  let probe = path.dirname(targetResolved)
691
691
  while (probe.startsWith(baseResolved + path.sep)) {
692
+ // Nothing there resolves either: the lstat answers the common case
693
+ // (a clean pruned the directory) without the failed realpath's round
694
+ // trip and error (X-163). A dangling link is there and falls through,
695
+ // as does an lstat that fails otherwise: the realpath decides.
696
+ if (absent(probe)) {
697
+ probe = path.dirname(probe)
698
+ continue
699
+ }
692
700
  const real = await realOf(probe).then(
693
701
  (r) => r,
694
702
  () => null,
@@ -918,6 +926,15 @@ async function linkOutError(base: string, probe: string, realBase: string): Prom
918
926
  )
919
927
  }
920
928
 
929
+ /** Whether nothing at all is at `p` (a dangling link is something). */
930
+ function absent(p: string): boolean {
931
+ try {
932
+ return lstatSync(p, { throwIfNoEntry: false }) === undefined
933
+ } catch {
934
+ return false
935
+ }
936
+ }
937
+
921
938
  /**
922
939
  * `realpath`, for a path holding a backslash too: Bun's answers ENOENT for
923
940
  * any such path (1.4.2), so the containment walk took a link named
@@ -1399,6 +1399,7 @@ export class Cache implements CacheLayer {
1399
1399
  })
1400
1400
  const liveHashes = live.map((r) => r.hash)
1401
1401
  const fileRows = this.loadOutputFilesBatch(liveHashes)
1402
+ this.outputs.hold(fileRows)
1402
1403
  const dirRows = this.loadOutputDirsBatch(liveHashes)
1403
1404
  for (const row of live) {
1404
1405
  this.touched.add(row.hash)
@@ -1534,7 +1535,7 @@ export class Cache implements CacheLayer {
1534
1535
  // same truncated expectation against the same truncated tree and agrees
1535
1536
  // forever. Refuse instead of silently under-restoring — checked before
1536
1537
  // anything is renamed into place.
1537
- const rows = this.loadOutputFilesBatch([hash]).get(hash) ?? []
1538
+ const rows = this.guard(() => this.outputs.rowsOf(hash))
1538
1539
  const expected = rows
1539
1540
  .filter((r) => workspaceRoot !== undefined || !r.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
1540
1541
  .map((r) => (r.path.startsWith(WORKSPACE_OUTPUT_PREFIX) ? r.path : `outputs/${r.path}`))
@@ -797,8 +797,15 @@ export async function cleanOutputs(args: {
797
797
  projectDir: string
798
798
  outputs: readonly string[]
799
799
  nestedProjectDirs: string[]
800
+ /**
801
+ * What `resolveOutputs` returned for these same arguments, when the
802
+ * caller walked the tree with no await since: a hit that found its tree
803
+ * stale walked it to say so, and a second walk here was half the
804
+ * restore's tree work (X-161).
805
+ */
806
+ resolved?: readonly string[] | undefined
800
807
  }): Promise<string[]> {
801
- const files = await resolveOutputs(args)
808
+ const files = args.resolved ?? (await resolveOutputs(args))
802
809
  // `force: true` makes rm tolerate ENOENT (e.g. when two output
803
810
  // globs overlap and a sibling already deleted a path mid-iteration).
804
811
  // A symlink is unlinked, never followed.
@@ -1131,8 +1138,10 @@ export async function resolveWorkspaceOutputs(args: {
1131
1138
  export async function cleanWorkspaceOutputs(args: {
1132
1139
  workspaceRoot: string
1133
1140
  outputs: readonly string[]
1141
+ /** `cleanOutputs`'s `resolved`, from `resolveWorkspaceOutputs`. */
1142
+ resolved?: readonly string[] | undefined
1134
1143
  }): Promise<string[]> {
1135
- const files = await resolveWorkspaceOutputs(args)
1144
+ const files = args.resolved ?? (await resolveWorkspaceOutputs(args))
1136
1145
  await pruneEmptiedDirs(
1137
1146
  args.workspaceRoot,
1138
1147
  await removeAll(files, args.workspaceRoot),
@@ -49,6 +49,17 @@ export class OutputIndex {
49
49
  private readonly pendingDirs = new Map<string, Array<[string, number]> | null>()
50
50
  /** Stamps taken and not yet written, flushed with the directory snapshots. */
51
51
  private readonly pendingStamps = new Map<string, Array<[string, number, number]>>()
52
+ /**
53
+ * The rows the last batched probe loaded, by hash: a hit's restore and
54
+ * the stamps after it read their rows here instead of the index, twice
55
+ * a restore (X-162). Read for path, size, mode and mtime only; a stamp
56
+ * taken since is the overlay `loadOutputFilesBatch` adds. Dropped when
57
+ * an entry's rows are replaced, and replaced by the next probe; rows
58
+ * held for an entry since pruned serve a restore that finds no artifact.
59
+ * Another process's save under the same key is the race the hit's own
60
+ * skip-restore check, which reads these rows, already runs.
61
+ */
62
+ private readonly held = new Map<string, readonly OutputFileRow[]>()
52
63
 
53
64
  constructor(private readonly db: Database) {
54
65
  this.insertOutputFile = lazyStatement(
@@ -108,6 +119,7 @@ export class OutputIndex {
108
119
  */
109
120
  replaceFileRows(hash: string, rows: ReadonlyArray<[string, number, number, number]>): void {
110
121
  // A stamp taken for the rows this replaces describes their files, not these.
122
+ this.held.delete(hash)
111
123
  this.pendingStamps.delete(hash)
112
124
  this.deleteOutputFiles.run(hash)
113
125
  this.deleteStamps.run(hash)
@@ -116,6 +128,17 @@ export class OutputIndex {
116
128
  }
117
129
  }
118
130
 
131
+ /** Hold a probe's rows for its restores (`held`), in place of the last probe's. */
132
+ hold(rows: ReadonlyMap<string, readonly OutputFileRow[]>): void {
133
+ this.held.clear()
134
+ for (const [hash, list] of rows) this.held.set(hash, list)
135
+ }
136
+
137
+ /** `hash`'s rows: the probe's when held, else read. Path, size, mode and mtime only. */
138
+ rowsOf(hash: string): readonly OutputFileRow[] {
139
+ return this.held.get(hash) ?? this.loadOutputFilesBatch([hash]).get(hash) ?? []
140
+ }
141
+
119
142
  loadOutputFilesBatch(hashes: readonly string[]): Map<string, OutputFileRow[]> {
120
143
  const out = new Map<string, OutputFileRow[]>()
121
144
  if (hashes.length === 0) return out
@@ -298,7 +321,7 @@ export class OutputIndex {
298
321
  * Written with the directory snapshots, in one transaction.
299
322
  */
300
323
  recordOutputStamps(hash: string, projectDir: string, workspaceRoot: string): void {
301
- const rows = this.loadOutputFilesBatch([hash]).get(hash) ?? []
324
+ const rows = this.rowsOf(hash)
302
325
  const stamps: Array<[string, number, number]> = []
303
326
  for (const e of rows) {
304
327
  const ws = e.path.startsWith(WORKSPACE_OUTPUT_PREFIX)
@@ -102,6 +102,10 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
102
102
  // — 1.7 ms each on a four-task real repo whose `link` and `element`
103
103
  // tasks declare `outputs: []` (2026-09-10, VX_TIMING).
104
104
  let skipRestore = !anyOutputs
105
+ // The walk that found the tree stale, handed to the clean when nothing
106
+ // was awaited between them: the clean's own walk would only repeat it
107
+ // (X-161).
108
+ let walked: { proj: string[]; ws: string[] } | undefined
105
109
  if (anyOutputs) {
106
110
  const endRows = span('output rows')
107
111
  const expected = hit.outputRows ?? args.cache.loadOutputFilesBatch([hash]).get(hash) ?? []
@@ -180,6 +184,7 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
180
184
  treeMatches = additive
181
185
  ? rowsPresent(actualRels, projExpected) && rowsPresent(actualWsRels, wsExpected)
182
186
  : setsMatch(actualRels, projExpected) && setsMatch(actualWsRels, wsExpected)
187
+ if (!treeMatches) walked = { proj: actualAbs, ws: actualWsAbs }
183
188
  }
184
189
  if (treeMatches) {
185
190
  const endStat = span('output stat')
@@ -216,6 +221,7 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
216
221
  })
217
222
  endGlob()
218
223
  skipRestore = actual.length === 0 && actualWs.length === 0
224
+ walked = { proj: actual, ws: actualWs }
219
225
  }
220
226
  }
221
227
  if (!skipRestore) {
@@ -233,8 +239,12 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
233
239
  outputs: wsOutputs,
234
240
  })
235
241
  } else {
236
- if (outputs.length > 0) cleanedRels = await cleanOutputs(cleanArgs)
237
- if (wsOutputs.length > 0) cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
242
+ if (outputs.length > 0) {
243
+ cleanedRels = await cleanOutputs({ ...cleanArgs, resolved: walked?.proj })
244
+ }
245
+ if (wsOutputs.length > 0) {
246
+ cleanedWsRels = await cleanWorkspaceOutputs({ ...wsCleanArgs, resolved: walked?.ws })
247
+ }
238
248
  }
239
249
  await args.cache.restoreOutputs(hash, node.projectDir, args.workspaceRoot)
240
250
  // The tree now holds this entry's bytes: stamp them for the next hit
@@ -189,6 +189,18 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
189
189
  // fingerprint. A watch cycle is a new run and reads it afresh.
190
190
  const reads: LoadReads = new Map()
191
191
  const workspaceRoot = await findWorkspaceRoot(options.cwd, reads)
192
+ // A frozen run with nothing staged reads the lock whatever discovery
193
+ // finds, so the read (1.1 MB at 1,000 projects, ~10 ms to read and
194
+ // parse) starts here and overlaps discovery's I/O instead of following
195
+ // the cache open (X-181). A missing or malformed lock still refuses at
196
+ // the config load, after any earlier refusal.
197
+ let lockRead: Promise<Lockfile> | undefined
198
+ const readLock = (): Promise<Lockfile> =>
199
+ (lockRead ??= readLockfile(workspaceRoot).then((read) => {
200
+ if (read === null) throw new UserError(FROZEN_WITHOUT_LOCK)
201
+ return read
202
+ }))
203
+ if (options.frozen === true && options.staged === undefined) void readLock().catch(() => {})
192
204
  // An UNSCOPED run (no explicit scope, at least one bare task name)
193
205
  // enumerates the whole tree whatever the configs say, so git starts
194
206
  // HERE — the walk needs only the root — and overlaps the workspace
@@ -313,13 +325,8 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
313
325
  // Read once, and only when a config is to be read from it: the CLI's
314
326
  // selection pass may have staged every config (`options.staged`), and
315
327
  // its load refused a frozen run without a lock. A 1,000-project lock is
316
- // 1.1 MB of JSON (I-27).
317
- let lockRead: Promise<Lockfile> | undefined
318
- const readLock = (): Promise<Lockfile> =>
319
- (lockRead ??= readLockfile(workspaceRoot).then((read) => {
320
- if (read === null) throw new UserError(FROZEN_WITHOUT_LOCK)
321
- return read
322
- }))
328
+ // 1.1 MB of JSON (I-27); a run with nothing staged started its read
329
+ // beside discovery, above.
323
330
  const staged = options.staged
324
331
  const allStaged =
325
332
  staged !== undefined &&
@@ -179,13 +179,18 @@ export async function frozenProjectConfigs(
179
179
  // The lock is hand-editable; the stored config crosses the same
180
180
  // boundary a freshly evaluated one does.
181
181
  const digest = store === undefined ? undefined : lockDigests.get(lock)
182
- const keys =
182
+ const prefix =
183
183
  digest === undefined
184
184
  ? undefined
185
- : metas.map(
186
- (m) =>
187
- `vx-lock-valid-v${CONFIG_EVAL_VERSION}\0${VERSION}\0${Bun.version}\0${digest}\0${m.name}`,
188
- )
185
+ : `vx-lock-valid-v${CONFIG_EVAL_VERSION}\0${VERSION}\0${Bun.version}\0${digest}`
186
+ // One row for a lock whose every entry was accepted: a warm `--all` run
187
+ // asked for 1,090 per-project rows in one `IN` query, ~10 ms of its
188
+ // config load, where the eval-cache path it replaces is cheaper (X-181).
189
+ // A scoped run still validates, and remembers, only what it loads.
190
+ if (prefix !== undefined && store !== undefined && store.getConfigEval(prefix) !== null) {
191
+ return entries.map((e) => e.config)
192
+ }
193
+ const keys = prefix === undefined ? undefined : metas.map((m) => `${prefix}\0${m.name}`)
189
194
  const known =
190
195
  keys === undefined || store === undefined
191
196
  ? new Map<string, string>()
@@ -199,6 +204,14 @@ export async function frozenProjectConfigs(
199
204
  validateProjectConfig(entries[i]!.config, `${LOCKFILE_NAME} (${metas[i]!.name})`)
200
205
  if (key !== undefined) accepted.push([key, '1'])
201
206
  }
207
+ // Every meta found its entry above, so as many distinct names as the
208
+ // lock holds is every entry of it.
209
+ if (
210
+ prefix !== undefined &&
211
+ new Set(metas.map((m) => m.name)).size === Object.keys(lock.projects).length
212
+ ) {
213
+ accepted.push([prefix, '1'])
214
+ }
202
215
  if (store !== undefined && accepted.length > 0) {
203
216
  if (store.putConfigEvals !== undefined) store.putConfigEvals(accepted)
204
217
  else for (const [k, v] of accepted) store.putConfigEval(k, v)