@vzn/vx 0.0.188 → 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 +11 -12
- package/package.json +5 -5
- package/src/cache/cache.ts +35 -0
- package/src/cache/layer.ts +13 -2
- package/src/cache/layered-cache.ts +2 -2
- package/src/cli/init.ts +62 -8
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
|
|
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.
|
|
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.
|
|
21
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
22
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
23
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
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"
|
package/src/cache/cache.ts
CHANGED
|
@@ -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
|
//
|
package/src/cache/layer.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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.
|
|
421
|
-
//
|
|
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
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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]> = [
|