@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 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.638",
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.638"
43
+ "@vzn/vx": "^0.0.639"
44
44
  },
45
45
  "repository": {
46
46
  "type": "git",
@@ -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) => outputsOverlap(ga, gb))),
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) => outputsOverlap(ga, gb)))!
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 ` +
@@ -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) return false
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
- return new Set(
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) return null
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