@vzn/vx-migrate 0.0.638 → 0.0.639
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 +1 -1
- package/package.json +2 -2
- package/src/shared-outputs.ts +45 -6
- package/src/turbo-cache/index.ts +28 -3
package/README.md
CHANGED
|
@@ -66,7 +66,7 @@ Rules:
|
|
|
66
66
|
- `env` / `passThroughEnv`: explicit names go to `cache.inputs.env` (env only) and `exec.env.passThrough` (both, plus both globals); a wildcard expands over the run's environment under `turbo()`, which maps where the tasks run (a new variable name such a wildcard matches maps afresh); the CLI, which writes for another environment, lists the names the task's package and its workspace dependencies spell (a root task: the whole repo; clerk's `E2E_*`), noted once per entry, and one nothing spells is a todo; `*` is the only wildcard, as in Turbo, so `\*`, a leading `\!`, `?` and `[` are literal: unkey's `NEXT_PUBLIC_\*` names one variable, which vx cannot key, so it is dropped and reported once for the workspace with the count of tasks naming it (openstatus's root `build` env `\*` had written one todo into each of 45 configs; `turbo()`: only when the run's environment sets it, as Turbo hashes nothing otherwise); and a `!` entry (openstatus' `!NEXT_PUBLIC_VERCEL_URL`) removes the names it matches from its list, with no todo, since vx matches no name it is not given. A name both a global list and the task's own list carry is listed once. A name that is no shell variable name (`p-q`) is keyed but left out of `exec.env.passThrough`, as `sh` drops it before the task runs.
|
|
67
67
|
- Framework inference (Turbo hashes and passes Next's `NEXT_PUBLIC_*`, Vite's `VITE_*`, … for a package that depends on the framework): `turbo()` expands the prefix over the run's environment; the CLI writes the names the tracked source and `.env.example` of the package and its workspace dependencies spell (the bare prefix, as a `startsWith` test spells it, is none) (Next bundles theirs: cal.com's web, 23 alone, 58 with them), and a note asks for any only an installed dependency reads.
|
|
68
68
|
|
|
69
|
-
- **Two tasks of one package on one output path** (strapi's `build`, `build:code` and `build:types`, all on `dist/**`): vx cleans a task's outputs before it runs and before a restore, so the loader refuses two cached tasks whose outputs provably overlap. Under vx's default `rules.exclusiveOutputs` an edge between the two changes nothing (twenty's `build:individual` writing into `build`'s `dist`). The mapping resolves it before the file is written — `build` keeps its cache, else the task with a `^` edge, else the first declared (typescript-eslint's `typecheck` has `^typecheck` and writes declarations into `build`'s `dist`: keeping it left `build` uncached); the rest run uncached with a todo naming the keeper and the fix, their own output path. A shared tsc incremental file (create-t3-turbo's `build` and `typecheck` both on `.cache/tsbuildinfo.json`) stays with the keeper alone, and the other task keeps its cache when it emits nothing else (a typecheck); one that emits more runs uncached, as `tsc --incremental` skips files the keeper's tsbuildinfo calls built. Same rule for Nx targets.
|
|
69
|
+
- **Two tasks of one package on one output path** (strapi's `build`, `build:code` and `build:types`, all on `dist/**`): vx cleans a task's outputs before it runs and before a restore, so the loader refuses two cached tasks whose outputs provably overlap. Under vx's default `rules.exclusiveOutputs` an edge between the two changes nothing (twenty's `build:individual` writing into `build`'s `dist`). The mapping resolves it before the file is written — `build` keeps its cache, else the task with a `^` edge, else the first declared (typescript-eslint's `typecheck` has `^typecheck` and writes declarations into `build`'s `dist`: keeping it left `build` uncached); the rest run uncached with a todo naming the keeper and the fix, their own output path. A shared tsc incremental file (create-t3-turbo's `build` and `typecheck` both on `.cache/tsbuildinfo.json`) stays with the keeper alone, and the other task keeps its cache when it emits nothing else (a typecheck); one that emits more runs uncached, as `tsc --incremental` skips files the keeper's tsbuildinfo calls built. A keeper whose output is a directory at any depth (solid's build: `**/dist`) meets a sibling's tree (`coverage/**`) only at a `dist` inside it: the keeper takes that tree back with `!coverage/**` and both keep their cache, as under Turbo. Same rule for Nx targets.
|
|
70
70
|
- **A task whose inputs read another task's outputs** (a `test` keyed on `**/*` beside a `build` writing `dist/**`): vx's default `rules.upfrontKeys` refuses it, since such a key cannot be known before the producer ran. Each overlapping output is taken back from the reader's inputs with `!` (`['**/*', '!dist/**']`), in package and workspace inputs alike, and a workspace output another package writes into this one is taken back from its package inputs too; Turbo and Nx hash what git tracks, and a build's outputs are not, so the key reads what the adopted tool read. A literal input another task writes cannot be taken back: that task runs uncached with a todo. A committed file under such an output is still a source: a reader the `!` would hide it from runs uncached with a todo. A keyed group (an `nx-input:<name>` twin, a transit node) runs nothing, so core lets it read what a task writes: it gets no `!` for a literal, the `!` that would hide a committed file goes, and it keeps its key (typescript-eslint's ast-spec commits the `*.shot` snapshots its `test` outputs, and its twin was written as `{}`, which core refuses). An output that names one committed file (eslint's `eslint-suppressions.json` in a `lint` task's outputs) is dropped, and so is the `!` each reader got for it: the file stays in every key, as Turbo and Nx hash it, and the readers stay cached. Same for Nx targets.
|
|
71
71
|
- **Two packages' tasks on one workspace output** (cal.com's shared `post-install` writes `../../node_modules/@prisma/client/**` from every package with the script): core refuses two cached tasks on one path with no edge between them, so `turbo()` (and `nx()`) keeps the first (in package and task order) cached and runs the rest uncached, each with a todo naming the keeper. A package's own outputs count at their workspace path: typescript-eslint's root project caches `dist` and each package's typecheck `dist/packages/<name>`, one path twice. Edges across packages are not read, so an ordered pair loses its cache too.
|
|
72
72
|
- `cache: false` or `persistent: true` → no `cache` block; a persistent task gets `exec.persistent: {}` and, when some task depends on it, the consumer's `persistentTodo`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-migrate",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.639",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Adopt @vzn/vx from Turborepo, Nx or package.json scripts: run the repo unchanged, migrate it to vx.config.ts, and keep your Turbo or Nx remote cache",
|
|
6
6
|
"keywords": [
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"access": "public"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
|
-
"@vzn/vx": "^0.0.
|
|
43
|
+
"@vzn/vx": "^0.0.639"
|
|
44
44
|
},
|
|
45
45
|
"repository": {
|
|
46
46
|
"type": "git",
|
package/src/shared-outputs.ts
CHANGED
|
@@ -171,6 +171,8 @@ interface Cached {
|
|
|
171
171
|
name: string
|
|
172
172
|
files: string[]
|
|
173
173
|
hasUpstreamEdge: boolean
|
|
174
|
+
/** Globs of a sibling's this task's `!` entries take back (`separable`). */
|
|
175
|
+
back: Set<string>
|
|
174
176
|
}
|
|
175
177
|
|
|
176
178
|
function cachedOutputs(t: GeneratedTask, index: number): Cached | null {
|
|
@@ -186,9 +188,32 @@ function cachedOutputs(t: GeneratedTask, index: number): Cached | null {
|
|
|
186
188
|
const deps = task['dependsOn']
|
|
187
189
|
const hasUpstreamEdge =
|
|
188
190
|
Array.isArray(deps) && deps.some((d) => typeof d === 'string' && d.startsWith('^'))
|
|
189
|
-
return { index, name: t.name, files: strings, hasUpstreamEdge }
|
|
191
|
+
return { index, name: t.name, files: strings, hasUpstreamEdge, back: new Set() }
|
|
190
192
|
}
|
|
191
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Can the keeper's `ga` give `gb`'s tree back? Only when `ga` is a
|
|
196
|
+
* directory at any depth (`**\/dist`, `**\/dist/**`) and `gb`'s literal
|
|
197
|
+
* head names no such directory: the two meet only at a `dist` nested in
|
|
198
|
+
* `gb`'s tree, which is the sibling's. solid's build outputs `**\/dist`
|
|
199
|
+
* beside types' `types/**`; Turbo caches both, and uncaching types made
|
|
200
|
+
* vx the slower of the two.
|
|
201
|
+
*/
|
|
202
|
+
function separable(ga: string, gb: string): boolean {
|
|
203
|
+
const name = /^\*\*\/([^*?[\]{}()!/\\]+)(?:\/\*\*)?$/.exec(ga)?.[1]
|
|
204
|
+
if (name === undefined) return false
|
|
205
|
+
const head: string[] = []
|
|
206
|
+
for (const seg of gb.replace(/^(\.\/)+/, '').split('/')) {
|
|
207
|
+
if (!isLiteralPattern(seg) || seg === '') break
|
|
208
|
+
head.push(seg)
|
|
209
|
+
}
|
|
210
|
+
return head.length > 0 && !head.includes(name)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Does keeper `k`'s `ga` still reach `gb` once its `!` entries are read? */
|
|
214
|
+
const hits = (k: Cached, ga: string, gb: string): boolean =>
|
|
215
|
+
!k.back.has(gb) && outputsOverlap(ga, gb)
|
|
216
|
+
|
|
192
217
|
/**
|
|
193
218
|
* Uncache every task whose declared outputs overlap a keeper's, in place,
|
|
194
219
|
* and return the tasks. Deterministic: keepers are chosen in declaration
|
|
@@ -220,14 +245,28 @@ export function resolveSharedOutputs(tasks: GeneratedTask[]): GeneratedTask[] {
|
|
|
220
245
|
const kept: Cached[] = [keeper]
|
|
221
246
|
for (const c of all) {
|
|
222
247
|
if (c === keeper) continue
|
|
223
|
-
const clash = kept.find((k) =>
|
|
224
|
-
c.files.some((gb) => k.files.some((ga) => outputsOverlap(ga, gb))),
|
|
225
|
-
)
|
|
248
|
+
const clash = kept.find((k) => c.files.some((gb) => k.files.some((ga) => hits(k, ga, gb))))
|
|
226
249
|
if (clash === undefined) {
|
|
227
250
|
kept.push(c)
|
|
228
251
|
continue
|
|
229
252
|
}
|
|
230
253
|
const t = tasks[c.index]!
|
|
254
|
+
const pairs = kept.flatMap((k) =>
|
|
255
|
+
c.files.flatMap((gb) =>
|
|
256
|
+
k.files.filter((ga) => hits(k, ga, gb)).map((ga) => ({ k, ga, gb })),
|
|
257
|
+
),
|
|
258
|
+
)
|
|
259
|
+
if (pairs.every((p) => separable(p.ga, p.gb))) {
|
|
260
|
+
for (const { k, gb } of pairs) {
|
|
261
|
+
if (k.back.has(gb)) continue
|
|
262
|
+
k.back.add(gb)
|
|
263
|
+
;(tasks[k.index]!.task!['cache'] as { outputs: { files: string[] } }).outputs.files.push(
|
|
264
|
+
`!${gb}`,
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
kept.push(c)
|
|
268
|
+
continue
|
|
269
|
+
}
|
|
231
270
|
// tsc's incremental state is no product: create-t3-turbo's build and
|
|
232
271
|
// typecheck both output `.cache/tsbuildinfo.json`, and typecheck ran
|
|
233
272
|
// uncached on every first run (Growth funnel F8). Left to the keeper,
|
|
@@ -236,7 +275,7 @@ export function resolveSharedOutputs(tasks: GeneratedTask[]): GeneratedTask[] {
|
|
|
236
275
|
// the keeper's tsbuildinfo calls built, missing or not, so its save
|
|
237
276
|
// could be a partial dist.
|
|
238
277
|
const sharedFiles = c.files.filter((gb) =>
|
|
239
|
-
kept.some((k) => k.files.some((ga) =>
|
|
278
|
+
kept.some((k) => k.files.some((ga) => hits(k, ga, gb))),
|
|
240
279
|
)
|
|
241
280
|
if (
|
|
242
281
|
sharedFiles.length === c.files.length &&
|
|
@@ -249,7 +288,7 @@ export function resolveSharedOutputs(tasks: GeneratedTask[]): GeneratedTask[] {
|
|
|
249
288
|
continue
|
|
250
289
|
}
|
|
251
290
|
dropped.add(c.index)
|
|
252
|
-
const shared = c.files.find((gb) => clash.files.some((ga) =>
|
|
291
|
+
const shared = c.files.find((gb) => clash.files.some((ga) => hits(clash, ga, gb)))!
|
|
253
292
|
delete t.task!['cache']
|
|
254
293
|
t.todos.push(
|
|
255
294
|
`declares the output ${JSON.stringify(shared)} that ${JSON.stringify(clash.name)} also ` +
|
package/src/turbo-cache/index.ts
CHANGED
|
@@ -385,6 +385,12 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
385
385
|
* the reads off too, and every task after the first upload missed.
|
|
386
386
|
*/
|
|
387
387
|
private writesRefused = false
|
|
388
|
+
/**
|
|
389
|
+
* Keys this process saw the server lack (a 404, a batch answer): `put`
|
|
390
|
+
* asks no HEAD for them. Turbo's PUT replaces a stored artifact and an
|
|
391
|
+
* entry is immutable, so any other key is asked first.
|
|
392
|
+
*/
|
|
393
|
+
private readonly absent = new Set<string>()
|
|
388
394
|
private readonly key: Uint8Array | undefined
|
|
389
395
|
readonly endpoint: string
|
|
390
396
|
/** What `TURBO_REMOTE_CACHE_READ_ONLY` and friends leave this run (`turboRemoteAccess`). */
|
|
@@ -517,7 +523,10 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
517
523
|
const res = await this.request('HEAD', `/${hash}`)
|
|
518
524
|
if (res === undefined) return false
|
|
519
525
|
if (res.status === 200) return true
|
|
520
|
-
if (res.status === 404)
|
|
526
|
+
if (res.status === 404) {
|
|
527
|
+
this.absent.add(hash)
|
|
528
|
+
return false
|
|
529
|
+
}
|
|
521
530
|
throw new Error(`HTTP ${res.status}`)
|
|
522
531
|
}
|
|
523
532
|
|
|
@@ -538,12 +547,14 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
538
547
|
// Each hash answers ArtifactInfo, null, or `{ error: { message } }`; an
|
|
539
548
|
// error entry is not a stored artifact, and it was counted as one (a GET
|
|
540
549
|
// spent on a 404, item 929).
|
|
541
|
-
|
|
550
|
+
const held = new Set(
|
|
542
551
|
hashes.filter((h) => {
|
|
543
552
|
const v = info[h]
|
|
544
553
|
return typeof v === 'object' && v !== null && !('error' in v)
|
|
545
554
|
}),
|
|
546
555
|
)
|
|
556
|
+
for (const h of hashes) if (!held.has(h)) this.absent.add(h)
|
|
557
|
+
return held
|
|
547
558
|
}
|
|
548
559
|
|
|
549
560
|
async get(hash: string): Promise<{ body: Response; durationMs: number | undefined } | null> {
|
|
@@ -556,7 +567,10 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
556
567
|
if (res === undefined) return null
|
|
557
568
|
if (res.status !== 200) {
|
|
558
569
|
await res.body?.cancel()
|
|
559
|
-
if (res.status === 404)
|
|
570
|
+
if (res.status === 404) {
|
|
571
|
+
this.absent.add(hash)
|
|
572
|
+
return null
|
|
573
|
+
}
|
|
560
574
|
throw new Error(`HTTP ${res.status}`)
|
|
561
575
|
}
|
|
562
576
|
const duration = Number(res.headers.get('x-artifact-duration'))
|
|
@@ -608,6 +622,16 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
608
622
|
|
|
609
623
|
async put(hash: string, body: Blob, meta: { durationMs: number }): Promise<void> {
|
|
610
624
|
if (this.disabled || this.writesRefused) return
|
|
625
|
+
if (!this.absent.has(hash)) {
|
|
626
|
+
// Part of the upload, so under its deadline. Any answer but 200 or 404
|
|
627
|
+
// uploads: a lost store costs more. A server the HEAD cannot reach
|
|
628
|
+
// fails the put, as the PUT would.
|
|
629
|
+
const head = await this.request('HEAD', `/${hash}`, {
|
|
630
|
+
timeoutMs: this.config.uploadTimeoutMs,
|
|
631
|
+
})
|
|
632
|
+
if (head === undefined || head.status === 200) return
|
|
633
|
+
if (head.status === 404) this.absent.add(hash)
|
|
634
|
+
}
|
|
611
635
|
const headers: Record<string, string> = {
|
|
612
636
|
'Content-Type': 'application/octet-stream',
|
|
613
637
|
'Content-Length': String(body.size),
|
|
@@ -625,6 +649,7 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
625
649
|
// Turbo's client takes any 2xx (`error_for_status`); a server's 201 or
|
|
626
650
|
// 204 stored the artifact and was counted a failed upload.
|
|
627
651
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
652
|
+
this.absent.delete(hash)
|
|
628
653
|
}
|
|
629
654
|
}
|
|
630
655
|
|