@vzn/vx 0.0.181 → 0.0.183

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.181",
3
+ "version": "0.0.183",
4
4
  "description": "A fast, correct task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,10 +17,10 @@
17
17
  "node": ">=18"
18
18
  },
19
19
  "optionalDependencies": {
20
- "@vzn/vx-linux-x64": "0.0.181",
21
- "@vzn/vx-linux-arm64": "0.0.181",
22
- "@vzn/vx-darwin-x64": "0.0.181",
23
- "@vzn/vx-darwin-arm64": "0.0.181"
20
+ "@vzn/vx-linux-x64": "0.0.183",
21
+ "@vzn/vx-linux-arm64": "0.0.183",
22
+ "@vzn/vx-darwin-x64": "0.0.183",
23
+ "@vzn/vx-darwin-arm64": "0.0.183"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -74,7 +74,14 @@ export function nonJsonPaths(value: unknown): NonJsonValue[] {
74
74
  ancestors.push(v)
75
75
  for (const key of Object.keys(v)) {
76
76
  keys.push(key)
77
- walk((v as Record<string, unknown>)[key], false)
77
+ // Read by descriptor: a getter is code, and each read may answer
78
+ // differently. `vx show` read one three times, so the key, the
79
+ // check and the command could each see another value, and a
80
+ // throwing one surfaced as vx's own stack (D-67). Never called here.
81
+ const d = Object.getOwnPropertyDescriptor(v, key)
82
+ if (d !== undefined && d.get !== undefined) found('a getter')
83
+ else if (d !== undefined && d.set !== undefined) found('a setter')
84
+ else walk(d?.value, false)
78
85
  keys.pop()
79
86
  }
80
87
  ancestors.pop()
@@ -291,6 +291,8 @@ export interface LoadProjectConfigOptions {
291
291
  * order given, so a failure names the first broken file the way a
292
292
  * one-by-one load did). A single-path load is the one-element case.
293
293
  */
294
+ const LOAD_WIDTH = 64
295
+
294
296
  export async function loadProjectConfigs(
295
297
  configPaths: readonly string[],
296
298
  opts?: LoadProjectConfigOptions,
@@ -372,7 +374,6 @@ export async function loadProjectConfigs(
372
374
  }
373
375
  }
374
376
  }
375
- const out: ProjectConfig[] = []
376
377
  // What the round learned, written ONCE at the end: one transaction per
377
378
  // table where each evaluation was its own (1,000 configs cold: 100 ms of
378
379
  // autocommit inserts against 5, item 615). Written in `finally`, so a
@@ -380,73 +381,107 @@ export async function loadProjectConfigs(
380
381
  // evaluation.
381
382
  const evals: Array<readonly [string, string]> = []
382
383
  const learnedClosures: Array<readonly [string, readonly string[]]> = []
384
+ interface Loaded {
385
+ config: ProjectConfig
386
+ evaluated?: readonly [string, string]
387
+ closure?: readonly [string, readonly string[]]
388
+ }
389
+ const loadOne = async (entry: (typeof prepared)[number]): Promise<Loaded> => {
390
+ const { configPath, cacheKey } = entry
391
+ // A fast key that missed: the closure is stale or the file changed.
392
+ // Take the slow path for this one config, which re-indexes it.
393
+ let bytes = entry.bytes
394
+ let closure = entry.closure
395
+ let key = cacheKey
396
+ if (entry.indexed) {
397
+ bytes = await Bun.file(configPath).bytes()
398
+ const keyed = await configEvalKey({
399
+ configPath,
400
+ bytes,
401
+ workspaceRoot: evalCache!.workspaceRoot,
402
+ workspaceFingerprint: evalCache!.workspaceFingerprint,
403
+ ...slowKeyHash,
404
+ })
405
+ key = keyed?.key ?? null
406
+ closure = keyed !== null && keyed.indexable ? keyed.closure : undefined
407
+ const slowRow = key === null ? null : (store!.getConfigEval(key) ?? null)
408
+ const slowHit = slowRow === null ? undefined : storedConfig(slowRow)
409
+ if (slowHit !== undefined) {
410
+ return closure === undefined
411
+ ? { config: slowHit }
412
+ : { config: slowHit, closure: [configPath, closure] }
413
+ }
414
+ }
415
+ // A REPEAT load in this process re-evaluates in a worker, because the
416
+ // bust above cannot reach the config's import closure — see
417
+ // config-eval.ts. A FIRST load keeps the in-process import, so the
418
+ // single `vx run` hot path never pays for a worker.
419
+ const repeat = loadedConfigs.has(configPath)
420
+ loadedConfigs.add(configPath)
421
+ if (repeat) refuseUnprovidedImports(bytes!, configPath, 'Project')
422
+ const mod = repeat
423
+ ? await evaluateConfigFresh(configPath).catch((err: unknown) => {
424
+ throw configLoadError(err, configPath, 'Project') ?? err
425
+ })
426
+ : await loadDefaultExport(configPath, 'Project', bytes!)
427
+ assertDefaultObject(mod, 'Project', configPath)
428
+ // Validation runs HERE, on whichever object we ended up with, so a
429
+ // malformed config reports the identical UserError whether it was
430
+ // evaluated in-process or in a worker.
431
+ validateProjectConfig(mod as ProjectConfig, configPath)
432
+ const json = JSON.stringify(mod)
433
+ // A tree of its own, as a hit and the lock hand out. The module object
434
+ // shares what the config shares: one preset's task in two configs, one
435
+ // `exec` in two tasks. A `project` hook that edits in place then edited
436
+ // them all, so a cold run ran `echo P +plug +plug` where the warm run
437
+ // and `--frozen` ran `echo P +plug`, under another key (item 967). A
438
+ // config is JSON data (config-schema.ts), so the copy loses nothing.
439
+ const config = JSON.parse(json) as ProjectConfig
440
+ if (key === null) return { config }
441
+ return closure === undefined
442
+ ? { config, evaluated: [key, json] }
443
+ : { config, evaluated: [key, json], closure: [configPath, closure] }
444
+ }
383
445
  // One worker for every repeat load in this round, however many there are.
384
446
  const endRound = beginEvalRound()
385
447
  try {
386
- for (const entry of prepared) {
387
- const { configPath, cacheKey } = entry
388
- const hit = cacheKey === null ? undefined : hits.get(cacheKey)
448
+ // Loaded LOAD_WIDTH at a time: one import after another put 1,000 cold
449
+ // configs at ~160 ms of imports where 64 at once take ~60 (D-68), and
450
+ // the width bounds the files a module load may hold open. A failure
451
+ // stops nothing (the rest are evaluated and stored for the next
452
+ // attempt); the error thrown is the first in order, as the serial
453
+ // loop's was.
454
+ const results: Array<Loaded | { failed: unknown }> = []
455
+ // A hit is taken here, synchronously as before: through the lanes each
456
+ // cost the warm path a call and an await.
457
+ const misses: number[] = []
458
+ for (let i = 0; i < prepared.length; i++) {
459
+ const key = prepared[i]!.cacheKey
460
+ const hit = key === null ? undefined : hits.get(key)
389
461
  // Stored AFTER validation, so a hit needs none; the key covers every
390
462
  // byte the evaluation could have read.
391
463
  const cached = hit === undefined ? undefined : storedConfig(hit)
392
- if (cached !== undefined) {
393
- out.push(cached)
394
- continue
395
- }
396
- // A fast key that missed: the closure is stale or the file changed.
397
- // Take the slow path for this one config, which re-indexes it.
398
- let bytes = entry.bytes
399
- let closure = entry.closure
400
- let key = cacheKey
401
- if (entry.indexed) {
402
- bytes = await Bun.file(configPath).bytes()
403
- const keyed = await configEvalKey({
404
- configPath,
405
- bytes,
406
- workspaceRoot: evalCache!.workspaceRoot,
407
- workspaceFingerprint: evalCache!.workspaceFingerprint,
408
- ...slowKeyHash,
409
- })
410
- key = keyed?.key ?? null
411
- closure = keyed !== null && keyed.indexable ? keyed.closure : undefined
412
- const slowRow = key === null ? null : (store!.getConfigEval(key) ?? null)
413
- const slowHit = slowRow === null ? undefined : storedConfig(slowRow)
414
- if (slowHit !== undefined) {
415
- out.push(slowHit)
416
- if (closure !== undefined) learnedClosures.push([configPath, closure])
417
- continue
418
- }
419
- }
420
- // A REPEAT load in this process re-evaluates in a worker, because the
421
- // bust above cannot reach the config's import closure — see
422
- // config-eval.ts. A FIRST load keeps the in-process import, so the
423
- // single `vx run` hot path never pays for a worker.
424
- const repeat = loadedConfigs.has(configPath)
425
- loadedConfigs.add(configPath)
426
- if (repeat) refuseUnprovidedImports(bytes!, configPath, 'Project')
427
- const mod = repeat
428
- ? await evaluateConfigFresh(configPath).catch((err: unknown) => {
429
- throw configLoadError(err, configPath, 'Project') ?? err
430
- })
431
- : await loadDefaultExport(configPath, 'Project', bytes!)
432
- assertDefaultObject(mod, 'Project', configPath)
433
- // Validation runs HERE, on whichever object we ended up with, so a
434
- // malformed config reports the identical UserError whether it was
435
- // evaluated in-process or in a worker.
436
- validateProjectConfig(mod as ProjectConfig, configPath)
437
- const json = JSON.stringify(mod)
438
- if (key !== null) {
439
- evals.push([key, json])
440
- if (closure !== undefined) learnedClosures.push([configPath, closure])
464
+ if (cached !== undefined) results[i] = { config: cached }
465
+ else misses.push(i)
466
+ }
467
+ let next = 0
468
+ const lane = async (): Promise<void> => {
469
+ while (next < misses.length) {
470
+ const i = misses[next++]!
471
+ results[i] = await loadOne(prepared[i]!).catch((err: unknown) => ({ failed: err }))
441
472
  }
442
- // A tree of its own, as a hit and the lock hand out. The module object
443
- // shares what the config shares: one preset's task in two configs, one
444
- // `exec` in two tasks. A `project` hook that edits in place then edited
445
- // them all, so a cold run ran `echo P +plug +plug` where the warm run
446
- // and `--frozen` ran `echo P +plug`, under another key (item 967). A
447
- // config is JSON data (config-schema.ts), so the copy loses nothing.
448
- out.push(JSON.parse(json) as ProjectConfig)
449
473
  }
474
+ await Promise.all(Array.from({ length: Math.min(LOAD_WIDTH, misses.length) }, lane))
475
+ const out: ProjectConfig[] = []
476
+ for (const r of results) {
477
+ if ('failed' in r) continue
478
+ if (r.evaluated !== undefined) evals.push(r.evaluated)
479
+ if (r.closure !== undefined) learnedClosures.push(r.closure)
480
+ out.push(r.config)
481
+ }
482
+ const first = results.find((r) => 'failed' in r)
483
+ if (first !== undefined) throw (first as { failed: unknown }).failed
484
+ return out
450
485
  } finally {
451
486
  endRound()
452
487
  if (store !== undefined) {
@@ -460,7 +495,6 @@ export async function loadProjectConfigs(
460
495
  }
461
496
  }
462
497
  }
463
- return out
464
498
  }
465
499
 
466
500
  export async function loadProjectConfig(