@vzn/vx 0.0.187 → 0.0.189

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/README.md CHANGED
@@ -32,25 +32,24 @@ Same graph, commands and concurrency: [how it is measured](https://vznjs.github.
32
32
 
33
33
  ## Try it on your repo
34
34
 
35
- In a Turborepo or Nx workspace, add one file (`npx @vzn/vx init` writes
36
- it and prints the command to run next):
37
-
38
- ```ts
39
- // vx.workspace.ts
40
- import { defineWorkspace } from '@vzn/vx'
41
- import { turbo } from '@vzn/vx-migrate' // Nx: import { nx } and use nx()
42
-
43
- export default defineWorkspace({ plugins: [turbo()] })
44
- ```
45
-
46
- Then:
35
+ In a Turborepo or Nx workspace:
47
36
 
48
37
  ```sh
49
38
  npm install -D @vzn/vx @vzn/vx-migrate
39
+ npx vx init # writes vx.workspace.ts, below; nothing else
50
40
  npx vx run build --all # your turbo.json or Nx targets, run by vx
51
41
  npx vx run build --all # again: up-to-date in milliseconds
52
42
  ```
53
43
 
44
+ The one file `vx init` writes (in an Nx repo, `nx` for `turbo`):
45
+
46
+ ```ts
47
+ import type { WorkspaceConfig } from '@vzn/vx'
48
+ import { turbo } from '@vzn/vx-migrate'
49
+
50
+ export default { plugins: [turbo()] } satisfies WorkspaceConfig
51
+ ```
52
+
54
53
  Nothing is rewritten. `@vzn/vx-migrate` is not on npm yet (first publish
55
54
  pending); [`examples/turbo`](examples/turbo) runs this setup, and
56
55
  `packages/vx/tests/try-it.unsafe.test.ts` runs these exact lines on a Turbo
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.187",
3
+ "version": "0.0.189",
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.187",
21
- "@vzn/vx-linux-arm64": "0.0.187",
22
- "@vzn/vx-darwin-x64": "0.0.187",
23
- "@vzn/vx-darwin-arm64": "0.0.187"
20
+ "@vzn/vx-linux-x64": "0.0.189",
21
+ "@vzn/vx-linux-arm64": "0.0.189",
22
+ "@vzn/vx-darwin-x64": "0.0.189",
23
+ "@vzn/vx-darwin-arm64": "0.0.189"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -407,6 +407,7 @@ export class Cache implements CacheLayer {
407
407
 
408
408
  private readonly db: Database
409
409
  private readonly insertEntry: ReturnType<Database['prepare']>
410
+ private readonly deleteEntryRow: ReturnType<Database['prepare']>
410
411
  private readonly selectEntry: ReturnType<Database['prepare']>
411
412
  private readonly bumpAccessed: ReturnType<Database['prepare']>
412
413
  private readonly touched = new Set<string>()
@@ -642,6 +643,7 @@ export class Cache implements CacheLayer {
642
643
 
643
644
  createTables(this.db)
644
645
 
646
+ this.deleteEntryRow = this.db.prepare('DELETE FROM entries WHERE hash = ?')
645
647
  this.insertEntry = this.db.prepare(`
646
648
  INSERT INTO entries(hash, project, task, command, exit_code, duration_ms, size_bytes, stdout, created_at, accessed_at, cpu_ms, peak_rss_bytes)
647
649
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -951,6 +953,39 @@ export class Cache implements CacheLayer {
951
953
  return this.outputs.outputDirsCurrent(projectDir, rows)
952
954
  }
953
955
  async restoreOutputs(hash: string, projectDir: string, workspaceRoot?: string): Promise<void> {
956
+ try {
957
+ await this.restoreOutputsOnce(hash, projectDir, workspaceRoot)
958
+ } catch (err) {
959
+ if (!(err instanceof CorruptArtifactError)) throw err
960
+ // Bad bytes under a good key failed the task on every run until
961
+ // `--force`, since nothing rewrote them (J's lead, nx#30338). The
962
+ // entry goes, and the caller runs the task, whose save stores it
963
+ // again; running is always correct, only slower than a hit.
964
+ if (this.write) await this.dropEntry(hash)
965
+ throw new ArtifactVanishedError(
966
+ hash,
967
+ `${err.message}; ${this.write ? 'dropped it' : 'left it (this cache is read-only)'}`,
968
+ err,
969
+ )
970
+ }
971
+ }
972
+
973
+ /** Remove one entry: its artifact first, then its rows (as `prune` orders them). */
974
+ private async dropEntry(hash: string): Promise<void> {
975
+ await rm(this.tarPath(hash), { force: true })
976
+ try {
977
+ this.deleteEntryRow.run(hash)
978
+ } catch (err) {
979
+ if (!isIndexFull(err)) throw err
980
+ this.deleteEntryRow.run(hash)
981
+ }
982
+ }
983
+
984
+ private async restoreOutputsOnce(
985
+ hash: string,
986
+ projectDir: string,
987
+ workspaceRoot?: string,
988
+ ): Promise<void> {
954
989
  // In-process extraction — no fork+exec on the hot path
955
990
  // (~5-10ms reclaimed per hit vs the prior subprocess `tar -xf`).
956
991
  //
@@ -476,11 +476,22 @@ export class CorruptArtifactError extends Error {
476
476
  * `cacheRetention` of another workspace sharing the cache directory. The
477
477
  * bytes are not wrong, they are gone, so the entry is a MISS and the task
478
478
  * runs (execute-task.ts); nothing about the cache needs throwing away.
479
+ * Also thrown, with its `reason`, for an artifact whose bytes ARE wrong
480
+ * (`CorruptArtifactError` at restore): the entry is dropped and it is a
481
+ * miss the same way, so the task's save stores it again (A-52).
479
482
  */
480
483
  export class ArtifactVanishedError extends Error {
481
- constructor(public readonly hash: string) {
484
+ constructor(
485
+ public readonly hash: string,
486
+ /** Why the entry cannot be restored, when it is not gone but unreadable. */
487
+ reason?: string,
488
+ /** The `CorruptArtifactError` behind a `reason`. */
489
+ cause?: unknown,
490
+ ) {
482
491
  super(
483
- `its cache artifact ${hash} vanished before the restore (a prune or another run's retention)`,
492
+ reason ??
493
+ `its cache artifact ${hash} vanished before the restore (a prune or another run's retention)`,
494
+ cause === undefined ? undefined : { cause },
484
495
  )
485
496
  this.name = 'ArtifactVanishedError'
486
497
  }
@@ -417,8 +417,8 @@ export class LayeredCache implements CacheLayer {
417
417
  } catch (err) {
418
418
  // The bytes came off the network — a corrupt/truncated remote
419
419
  // artifact must degrade to a cache miss (task re-executes), not
420
- // crash the run. Local-layer reads outside this block still
421
- // propagate: local corruption is a real fault, not a network one.
420
+ // crash the run. A corrupt LOCAL artifact is a miss too, one level
421
+ // down: `Cache.restoreOutputs` drops it (A-52).
422
422
  this.reportRemoteError('download', hash, err)
423
423
  return false
424
424
  }
package/src/cli/init.ts CHANGED
@@ -149,7 +149,11 @@ async function adopt(
149
149
  args: InitArgs,
150
150
  ): Promise<number> {
151
151
  const name = `vx.workspace.${args.mjs ? 'mjs' : 'ts'}`
152
- const body = `import { ${runner} } from '@vzn/vx-migrate'\n\nexport default { plugins: [${runner}()] }`
152
+ const remote = remoteCacheSignal(root, runner)
153
+ const cache = REMOTE_CACHE[runner]
154
+ const imports = remote === undefined ? runner : `${runner}, ${cache}`
155
+ const plugins = remote === undefined ? `${runner}()` : `${runner}(), ${cache}()`
156
+ const body = `import { ${imports} } from '@vzn/vx-migrate'\n\nexport default { plugins: [${plugins}] }`
153
157
  const text = args.mjs
154
158
  ? `${body}\n`
155
159
  : `import type { WorkspaceConfig } from '@vzn/vx'\n${body} satisfies WorkspaceConfig\n`
@@ -167,19 +171,69 @@ async function adopt(
167
171
  if (existing !== undefined && existing !== name) await unlink(path.join(root, existing))
168
172
  await Bun.write(path.join(root, name), text)
169
173
  }
170
- const wrote =
171
- existing !== undefined && !args.force
172
- ? `${existing} already declares ${runner}().`
173
- : args.dry
174
- ? `would write ${name} (dry run, nothing written).`
175
- : `wrote ${name}.`
174
+ const kept = existing !== undefined && !args.force
175
+ const wrote = kept
176
+ ? `${existing} already declares ${runner}().`
177
+ : args.dry
178
+ ? `would write ${name} (dry run, nothing written).`
179
+ : `wrote ${name}.`
180
+ const cacheLine =
181
+ remote === undefined
182
+ ? ''
183
+ : kept && !readFileSync(path.join(root, existing), 'utf8').includes(`${cache}(`)
184
+ ? `${remote}: add ${cache}() from @vzn/vx-migrate to its plugins and vx shares that remote cache.\n`
185
+ : kept
186
+ ? ''
187
+ : `${cache}(): ${remote}, so vx shares that remote cache (inert where the variable is unset).\n`
176
188
  process.stdout.write(
177
189
  `vx init: ${source} found — ${runner}() from @vzn/vx-migrate runs this repo as it is; nothing else written.\n` +
178
- `${wrote}\n\nnext: ${adoptionNext(root, runner, source)}\n`,
190
+ `${wrote}\n${cacheLine}\nnext: ${adoptionNext(root, runner, source)}\n`,
179
191
  )
180
192
  return 0
181
193
  }
182
194
 
195
+ const REMOTE_CACHE = { turbo: 'turboCache', nx: 'nxCache' } as const
196
+
197
+ /** The variable each runner's self-hosted remote cache is set by, as its plugin reads it. */
198
+ const REMOTE_CACHE_ENV = { turbo: 'TURBO_TOKEN', nx: 'NX_SELF_HOSTED_REMOTE_CACHE_SERVER' } as const
199
+
200
+ const CI_FILES = ['.gitlab-ci.yml', '.circleci/config.yml']
201
+
202
+ /**
203
+ * Where the repo shows a remote cache its runner uses, or undefined: turbo.json's
204
+ * enabled `remoteCache`, or a CI file that sets the runner's cache variable (a
205
+ * local repo holds no token; CI does).
206
+ */
207
+ function remoteCacheSignal(root: string, runner: 'turbo' | 'nx'): string | undefined {
208
+ if (runner === 'turbo') {
209
+ for (const f of ['turbo.json', 'turbo.jsonc']) {
210
+ let rc: unknown
211
+ try {
212
+ rc = (
213
+ Bun.JSONC.parse(readFileSync(path.join(root, f), 'utf8')) as { remoteCache?: unknown }
214
+ )?.remoteCache
215
+ } catch {
216
+ continue
217
+ }
218
+ if (typeof rc === 'object' && rc !== null && (rc as { enabled?: unknown }).enabled !== false)
219
+ return `${f} names a remoteCache`
220
+ }
221
+ }
222
+ const variable = REMOTE_CACHE_ENV[runner]
223
+ const workflows = new Bun.Glob('.github/workflows/*.{yml,yaml}')
224
+ const files = [...workflows.scanSync({ cwd: root, dot: true })].sort().concat(CI_FILES)
225
+ for (const f of files) {
226
+ let text: string
227
+ try {
228
+ text = readFileSync(path.join(root, f), 'utf8')
229
+ } catch {
230
+ continue
231
+ }
232
+ if (text.includes(variable)) return `${f} sets ${variable}`
233
+ }
234
+ return undefined
235
+ }
236
+
183
237
  type PackageManager = 'pnpm' | 'yarn' | 'bun' | 'npm'
184
238
 
185
239
  const LOCKFILES: ReadonlyArray<[string, PackageManager]> = [