@vzn/vx-migrate 0.0.643 → 0.0.644

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
@@ -75,7 +75,7 @@ Rules:
75
75
  - `interruptible` maps to nothing: `vx watch` keeps a persistent task up across cycles and restarts it only when its config changes or it dies.
76
76
  - A task's `tags` (Turbo main, after 2.11.5) map to nothing: Turbo keeps them out of the hash and the run.
77
77
  - `envMode: "loose"` (top-level or in `global`, or `TURBO_ENV_MODE=loose`, which wins) is a note: Turbo hands every task the whole environment, vx only the declared names.
78
- - The workspace keys vx has a home for, top level or in `global`, fill what `.vx/workspace.ts` leaves unset: `run.concurrency` (`"10"`, `"50%"` of the cores) → `run.concurrency`; `cacheMaxSize` / `cacheMaxAge` (`"0"` is off; weeks become days, a bare number days; a size in Turbo's grammar, `7.5GB` or bare bytes, restated whole: `7680MB`) → `cache.retention.maxSize` / `.olderThan`. `TURBO_CONCURRENCY`, `TURBO_CACHE_MAX_SIZE` and `TURBO_CACHE_MAX_AGE` win over turbo.json, as under Turbo; a set one decides, its `0` included.
78
+ - The workspace keys vx has a home for, top level or in `global`, fill what `.vx/workspace.ts` leaves unset: `run.concurrency` (`"10"`, `"50%"` of the cores) → `run.concurrency`. `TURBO_CONCURRENCY` wins over turbo.json, as under Turbo; a set one decides, its `0` included. `cacheMaxSize` / `cacheMaxAge` map to nothing: no vx run evicts, `vx cache clean --max-size / --older-than` does.
79
79
  - `TURBO_SCM_BASE`, the base `turbo run --affected` compares with, is the run's base commit (`setBranch({ base })`): a bare `vx run build --affected` compares with the same ref. With it unset on GitHub Actions (`GITHUB_ACTIONS`), Turbo's own base applies: a pull request's `GITHUB_BASE_REF`, else the push event's `before` from `GITHUB_EVENT_PATH`.
80
80
  - Turbo hashes each package's `microfrontends.json` (or `.jsonc`) into every task, or the one file `VC_MICROFRONTENDS_CONFIG_FILE_NAME` names; so does `turbo()`.
81
81
  - Turbo 2.11's `global` block (`futureFlags.globalConfiguration`) is read as the `globalDependencies`, `globalEnv` and `globalPassThroughEnv` it replaces.
@@ -173,7 +173,7 @@ Two cached tasks on one workspace path cannot both keep their cache, and the fir
173
173
 
174
174
  ### nx.json `maxCacheSize`
175
175
 
176
- `maxCacheSize` (or `NX_MAX_CACHE_SIZE`, above it as in Nx) is the run's `cache.retention.maxSize` when `.vx/workspace.ts` sets no retention, in Nx's grammar (`10GB`, `1.5 GB`, bare bytes); `0` is no cap.
176
+ `maxCacheSize` maps to nothing: no vx run evicts, `vx cache clean --max-size` does.
177
177
 
178
178
  ### Lerna
179
179
 
@@ -259,7 +259,7 @@ Reads the root pipeline (`tasks` in Turbo 2, `pipeline` in Turbo 1), per-package
259
259
 
260
260
  ### Nx
261
261
 
262
- Reads the **resolved** project graph only (`.nx/workspace-data/project-graph.json` when exported, else the one the workspace's own `nx graph` exports into a temp file, as `nx()` does; with no `node_modules/.bin/nx`, as in a fresh clone, it stops and names the repo manager's install), through the same mapper `nx()` runs live, executors aside (below). Targets Nx plugins infer at runtime are frozen as the snapshot saw them. `nx:run-commands` is the one shell line `nx()` runs (see [`nx:run-commands`](#nxrun-commands) above: where, parallel or in order, forwarded arguments, `env`, `readyWhen`) — storybook's `compile` is `cd ../../.. && node ./scripts/build/build-package.ts --cwd code/lib/cli`; a plain `command` is that shorthand; `nx:run-script` is the package's script body with its `pre<name>` / `post<name>` hooks folded in (or `yarn run <name>` when the body calls yarn's `run` builtin; an empty script is the placeholder with a todo), `nx:noop` is a group task; Nx 15–16's `@nrwl/workspace:run-commands` and `run-script` (and their `@nx/workspace:` names) are the `nx:` executors they re-exported; **an executor target is its `nx-exec` line**, as `nx()` runs it: the migrator translates no executor, so Nx and `@vzn/vx-migrate` stay installed until each such line is rewritten as the command it runs (a server executor Nx knows, `@nx/js:node` or a dev server, is still a persistent task). The report also says what `.vx/workspace.ts` still holds, as the Turbo migration does: the `nx()` a keep adoption declared, which keeps reading nx.json, and a lockfile with no `@vzn/vx-lockfile` plugin, where Nx keyed each project on the npm packages it depends on. A written command that still runs Nx itself (a run-commands `nx run b:build`, `npx nx test b`, a script's `nx exec -- tsc`) carries a TODO: it works only while Nx is installed. An Nx project no workspace glob lists (an integrated repo's `project.json` library) gets a `package.json` (`name`, `private`) where it has none, and a note names the directories to add to `workspaces` (or `pnpm-workspace.yaml`), so the package manager installs it and a lockfile plugin names it; the migrator never edits the root manifest. A repo with no `package.json` at all (Nx's `nx init` outside JS: Nx pinned in `.nx/installation`, projects as `project.json`) is migrated too, natively only: the root is the directory holding `nx.json`, the graph is exported by `.nx/installation`'s `nx`, each config writes the project's Nx `name`, every edge is the explicit `project#target` Nx's graph draws (a `^` edge needs a lockfile plugin, which has no lockfile to read), and `.vx/workspace.ts` declares no plugin, so nothing is installed and core runs the configs alone; `--keep` refuses, as `nx()` would have no `package.json` to install into, and an executor target's `nx-exec` line is a note to rewrite as a command. A target with `configurations` writes one task per configuration (`build`, `build:ci`). A project's Nx `tags` are written as its `tags`. Named inputs expand from `nx.json` when readable. An output path is kept as written, a `!` one too (`{projectRoot}/dist` → `dist`, `{projectRoot}/bin/tool` → `bin/tool`; one naming an unset `{options.x}` is dropped, as Nx drops it; a dotted `{options.outputPath.base}` walks the options, as Nx does; an extglob is put in vx's grammar: Next's inferred `.next/!(cache)/**/*` is `.next/*/**/*` with `!.next/cache/**/*`, `@(js|map)` is `{js,map}`, and a form with no vx spelling is dropped with a TODO): vx reads a bare path as the file or the whole tree under it, so a directory and an extensionless binary both save and restore. nx.json's `parallel`, `defaultBase` and `maxCacheSize`, which `nx()` applies live, are each a field of the `.vx/workspace.ts` it writes (`run.concurrency`, `baseBranch`, `cache.retention.maxSize`), read from nx.json alone, never the environment; beside a workspace file already there, or for a size vx cannot read (`1.5 GB`), each is a note naming the field to add. vx derives package edges from `package.json`; an Nx graph edge with no manifest path (`implicitDependencies`, a tsconfig path) becomes, for each `^target` of the dependant, an explicit `pkg#target` edge to what Nx's own walk reaches — each dependency that has the target, and through one that lacks it, its dependencies — so the order and the key are Nx's. The reverse too: where a project's manifest names a workspace package its Nx graph does not reach (an `implicitDependencies: ["!a"]` that breaks a manifest cycle), its `^target` becomes the explicit edges Nx draws, since vx's `^` would follow the manifest and bring the cycle back.
262
+ Reads the **resolved** project graph only (`.nx/workspace-data/project-graph.json` when exported, else the one the workspace's own `nx graph` exports into a temp file, as `nx()` does; with no `node_modules/.bin/nx`, as in a fresh clone, it stops and names the repo manager's install), through the same mapper `nx()` runs live, executors aside (below). Targets Nx plugins infer at runtime are frozen as the snapshot saw them. `nx:run-commands` is the one shell line `nx()` runs (see [`nx:run-commands`](#nxrun-commands) above: where, parallel or in order, forwarded arguments, `env`, `readyWhen`) — storybook's `compile` is `cd ../../.. && node ./scripts/build/build-package.ts --cwd code/lib/cli`; a plain `command` is that shorthand; `nx:run-script` is the package's script body with its `pre<name>` / `post<name>` hooks folded in (or `yarn run <name>` when the body calls yarn's `run` builtin; an empty script is the placeholder with a todo), `nx:noop` is a group task; Nx 15–16's `@nrwl/workspace:run-commands` and `run-script` (and their `@nx/workspace:` names) are the `nx:` executors they re-exported; **an executor target is its `nx-exec` line**, as `nx()` runs it: the migrator translates no executor, so Nx and `@vzn/vx-migrate` stay installed until each such line is rewritten as the command it runs (a server executor Nx knows, `@nx/js:node` or a dev server, is still a persistent task). The report also says what `.vx/workspace.ts` still holds, as the Turbo migration does: the `nx()` a keep adoption declared, which keeps reading nx.json, and a lockfile with no `@vzn/vx-lockfile` plugin, where Nx keyed each project on the npm packages it depends on. A written command that still runs Nx itself (a run-commands `nx run b:build`, `npx nx test b`, a script's `nx exec -- tsc`) carries a TODO: it works only while Nx is installed. An Nx project no workspace glob lists (an integrated repo's `project.json` library) gets a `package.json` (`name`, `private`) where it has none, and a note names the directories to add to `workspaces` (or `pnpm-workspace.yaml`), so the package manager installs it and a lockfile plugin names it; the migrator never edits the root manifest. A repo with no `package.json` at all (Nx's `nx init` outside JS: Nx pinned in `.nx/installation`, projects as `project.json`) is migrated too, natively only: the root is the directory holding `nx.json`, the graph is exported by `.nx/installation`'s `nx`, each config writes the project's Nx `name`, every edge is the explicit `project#target` Nx's graph draws (a `^` edge needs a lockfile plugin, which has no lockfile to read), and `.vx/workspace.ts` declares no plugin, so nothing is installed and core runs the configs alone; `--keep` refuses, as `nx()` would have no `package.json` to install into, and an executor target's `nx-exec` line is a note to rewrite as a command. A target with `configurations` writes one task per configuration (`build`, `build:ci`). A project's Nx `tags` are written as its `tags`. Named inputs expand from `nx.json` when readable. An output path is kept as written, a `!` one too (`{projectRoot}/dist` → `dist`, `{projectRoot}/bin/tool` → `bin/tool`; one naming an unset `{options.x}` is dropped, as Nx drops it; a dotted `{options.outputPath.base}` walks the options, as Nx does; an extglob is put in vx's grammar: Next's inferred `.next/!(cache)/**/*` is `.next/*/**/*` with `!.next/cache/**/*`, `@(js|map)` is `{js,map}`, and a form with no vx spelling is dropped with a TODO): vx reads a bare path as the file or the whole tree under it, so a directory and an extensionless binary both save and restore. nx.json's `parallel` and `defaultBase`, which `nx()` applies live, are each a field of the `.vx/workspace.ts` it writes (`run.concurrency`, `baseBranch`), read from nx.json alone, never the environment; beside a workspace file already there, each is a note naming the field to add. vx derives package edges from `package.json`; an Nx graph edge with no manifest path (`implicitDependencies`, a tsconfig path) becomes, for each `^target` of the dependant, an explicit `pkg#target` edge to what Nx's own walk reaches — each dependency that has the target, and through one that lacks it, its dependencies — so the order and the key are Nx's. The reverse too: where a project's manifest names a workspace package its Nx graph does not reach (an `implicitDependencies: ["!a"]` that breaks a manifest cycle), its `^target` becomes the explicit edges Nx draws, since vx's `^` would follow the manifest and bring the cycle back.
263
263
 
264
264
  ### Vite Task
265
265
 
@@ -620,6 +620,7 @@ With no token from an option, a variable or a config file, both plugins read the
620
620
 
621
621
  ## Remote-cache behaviour (both)
622
622
 
623
+ - Every request (`HEAD`, `GET`, `PUT`, the batch `POST`) carries `x-vx-cache-scope`: `trusted` for a task's own key, else the branch scope its key was derived in (`acme/app:feat-x`). The key is the same either way. A server that keeps one bucket per scope uses it to skip its own layering; whether a write to `trusted` is allowed stays the server's call, from the token (a CI ID token). A request without the header, from an older client, leaves the layering to the server. A batch that mixes scopes is one `POST` per scope.
623
624
  - A remote error degrades to a **miss** and a warning that names the request, the artifact and the server — `[vx] plugin '@vzn/vx-migrate': upload 32248a2a7c89e241 to https://cache.example.com/v8/artifacts failed: no answer within 60000 ms` — never the token. The run never fails because of the cache.
624
625
  - The same failure is said once per run: an unreachable server fails the probe, the download and the upload alike, and the run prints the first and, at its end, `[vx] plugin '@vzn/vx-migrate': 2 more requests failed the same way: Unable to connect. Is the computer able to access the url?` (core does the counting, for every cache plugin).
625
626
  - A request answered `5xx` (not `501`), or one that never connected (a refused port, an unresolved host), is sent again after 2 s, `retries` times. A `429` pauses every request to that remote for its `Retry-After` (capped at 10 s a wait, 2 s without one), and the request is sent again when the pause ends, for up to 20 s from its first `429`, as Turbo's client does. A spent deadline is not resent: it has already cost its wait.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx-migrate",
3
- "version": "0.0.643",
3
+ "version": "0.0.644",
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.643"
43
+ "@vzn/vx": "^0.0.644"
44
44
  },
45
45
  "repository": {
46
46
  "type": "git",
@@ -60,7 +60,7 @@ export function adoptionPlugin(
60
60
  mapRun: (ctx: ProjectHookContext) => Promise<AdoptionRun>,
61
61
  reads: readonly string[] = [],
62
62
  // `config`: a workspace-wide key of the adopted tool's (nx.json `parallel`,
63
- // Turbo's `cacheMaxSize`) that the workspace itself leaves unset.
63
+ // Turbo's `concurrency`) that the workspace itself leaves unset.
64
64
  // `discover`: a project the tool has that no member glob lists.
65
65
  stages: Pick<VxPlugin, 'config' | 'discover'> = {},
66
66
  ): VxPlugin {
@@ -1,28 +1,22 @@
1
- // The glob grammar Nx and Turbo share and vx's own differ in brackets and
2
- // extglobs; both adapters translate through here (items 914, 1031).
1
+ // The glob grammar Nx and Turbo share and vx's own differ in extglobs;
2
+ // both adapters translate through here (items 914, 1031).
3
3
 
4
4
  const EXTGLOB = /([?@+*!])\(([^()]*)\)/g
5
5
 
6
6
  /**
7
7
  * A minimatch-family glob (Nx's minimatch, Turbo's wax) in vx's grammar,
8
- * or null when it has no safe form. vx has no character classes (a bracket is a literal, for route
9
- * dirs, item 667) and no extglobs, so `src/**\/*.[jt]s` matched nothing
10
- * and Nx's own default `production` negation,
11
- * `?(*.)+(spec|test).[jt]s?(x)`, excluded nothing (item 914):
12
- * - `[jt]` is the brace set `{j,t}`, plus the literal `[jt]` itself in a
13
- * positive glob (a superset of inputs is safe; the route dir may be
14
- * meant); a range within digits or one case of letters (`[a-c]`,
15
- * `[0-9]`) is its members;
16
- * - a negated class (`[!a]`) is `?` in a positive glob, a superset;
8
+ * or null when it has no safe form. Character classes pass through (vx
9
+ * reads them the same way); vx has no extglobs, so Nx's own default
10
+ * `production` negation, `?(*.)+(spec|test).[jt]s?(x)`, excluded nothing
11
+ * (item 914):
17
12
  * - `?(a|b)` is `{a,b,}` and `@(a|b)` is `{a,b}`;
18
13
  * - `+(a|b)` and `*(a|b)` narrow to one repetition, which only a
19
14
  * NEGATION may do — it then excludes fewer files, never more;
20
- * - any other range, a negated class in a negation, `!(…)`, or nesting is
21
- * null.
15
+ * - `!(…)` or nesting is null.
22
16
  */
23
17
  export function minimatchToVx(glob: string, negated: boolean): string | null {
24
- if (!/[[(]/.test(glob)) return glob
25
- let out = glob.replace(EXTGLOB, (whole, kind: string, body: string) => {
18
+ if (!glob.includes('(')) return glob
19
+ const out = glob.replace(EXTGLOB, (_whole, kind: string, body: string) => {
26
20
  const alts = body.split('|')
27
21
  if (alts.some((a) => /[{},]/.test(a))) return '\0'
28
22
  if (kind === '?') return `{${alts.join(',')},}`
@@ -30,36 +24,7 @@ export function minimatchToVx(glob: string, negated: boolean): string | null {
30
24
  if (!negated || kind === '!') return '\0'
31
25
  return kind === '+' ? `{${alts.join(',')}}` : `{${alts.join(',')},}`
32
26
  })
33
- if (out.includes('\0') || /[?@+*!]\(/.test(out)) return null
34
- out = out.replace(/\[([^\]]*)\]/g, (whole, body: string) => {
35
- if (body === '' || /[{},/]/.test(body)) return '\0'
36
- if (/^[!^]/.test(body)) return negated || body.length === 1 ? '\0' : '?'
37
- const chars = classMembers(body)
38
- if (chars === null) return '\0'
39
- return negated ? `{${chars.join(',')}}` : `{${whole},${chars.join(',')}}`
40
- })
41
- return out.includes('\0') ? null : out
42
- }
43
-
44
- const RANGES = ['0123456789', 'abcdefghijklmnopqrstuvwxyz', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ']
45
-
46
- /** A class body's characters, ranges spelled out; null for a range across kinds or backwards. */
47
- function classMembers(body: string): string[] | null {
48
- const out = new Set<string>()
49
- for (let i = 0; i < body.length; i++) {
50
- const c = body[i]!
51
- // A `-` first or last is itself, as in minimatch.
52
- if (body[i + 1] === '-' && i + 2 < body.length) {
53
- const end = body[i + 2]!
54
- const kind = RANGES.find((r) => r.includes(c))
55
- const from = kind?.indexOf(c) ?? -1
56
- const to = kind?.indexOf(end) ?? -1
57
- if (kind === undefined || to < from) return null
58
- for (const m of kind.slice(from, to + 1)) out.add(m)
59
- i += 2
60
- } else out.add(c)
61
- }
62
- return [...out]
27
+ return out.includes('\0') || /[?@+*!]\(/.test(out) ? null : out
63
28
  }
64
29
 
65
30
  /**
@@ -73,7 +38,7 @@ function classMembers(body: string): string[] | null {
73
38
  */
74
39
  export function withoutTakenBack<T>(entries: readonly T[]): T[] {
75
40
  const list = [...new Set(entries)]
76
- const wild = /[*?{}]/
41
+ const wild = /[*?{}[\]\\]/
77
42
  const negative = list.flatMap((e) =>
78
43
  typeof e === 'string' && e.startsWith('!') ? [e.slice(1)] : [],
79
44
  )
@@ -90,10 +55,10 @@ export function withoutTakenBack<T>(entries: readonly T[]): T[] {
90
55
 
91
56
  /**
92
57
  * A path from the file system as a task glob that names only itself: a dir
93
- * named `my{app}` read as a brace matched `myapp` instead. `[` `]` are
94
- * literal in a task glob already; `*` and `?` stay, since the schema reads
95
- * `\*` as a Windows separator, and they match the name too (a superset).
58
+ * named `my{app}` read as a brace matched `myapp` instead, and `app/[id]` a
59
+ * class. `*` and `?` stay, since the schema reads `\*` as a Windows
60
+ * separator, and they match the name too (a superset).
96
61
  */
97
62
  export function literalGlob(p: string): string {
98
- return p.replace(/^!|[{}]/g, '\\$&')
63
+ return p.replace(/^!|[{}[\]]/g, '\\$&')
99
64
  }
package/src/migrate-nx.ts CHANGED
@@ -5,7 +5,7 @@ import { stat } from 'node:fs/promises'
5
5
  import path from 'node:path'
6
6
  import { type GeneratedProject, type MigrationFormat, type MigrationPlan } from '@vzn/vx'
7
7
  import type { PackageMeta } from './packages.js'
8
- import { mapNxWorkspace, nxSizeText, parseNxGraph, readNxJson } from './nx/nx-map.js'
8
+ import { mapNxWorkspace, parseNxGraph, readNxJson } from './nx/nx-map.js'
9
9
  import { spareTrackedOutputs, trackedFiles, trackedKinds } from './tracked-outputs.js'
10
10
  import { adoptedToolNotes } from './workspace-notes.js'
11
11
  import { relPosix } from './paths.js'
@@ -165,7 +165,7 @@ async function unlistedProjects(
165
165
  /** A `.vx/workspace.ts` field nx.json's run settings call for. */
166
166
  export interface NxWorkspaceField {
167
167
  /** The setting's path: its group, then its field, or a top-level key. */
168
- readonly field: 'run.concurrency' | 'baseBranch' | 'cache.retention'
168
+ readonly field: 'run.concurrency' | 'baseBranch'
169
169
  /** The value as TypeScript source; undefined when vx cannot read it. */
170
170
  readonly source: string | undefined
171
171
  /** What to tell a user whose workspace file this run does not write. */
@@ -187,7 +187,6 @@ export function nxWorkspaceFields(json: Record<string, unknown> | undefined): Nx
187
187
  tasksRunnerOptions?: { default?: { options?: { parallel?: unknown } } }
188
188
  defaultBase?: unknown
189
189
  affected?: { defaultBase?: unknown }
190
- maxCacheSize?: unknown
191
190
  }
192
191
  | undefined
193
192
  const out: NxWorkspaceField[] = []
@@ -207,12 +206,5 @@ export function nxWorkspaceFields(json: Record<string, unknown> | undefined): Nx
207
206
  note: `nx.json \`defaultBase\` ${JSON.stringify(base.trim())}: add \`baseBranch: ${JSON.stringify(branch)}\` to .vx/workspace.ts`,
208
207
  })
209
208
  }
210
- const size = nxSizeText(nx?.maxCacheSize)
211
- if (size !== undefined)
212
- out.push({
213
- field: 'cache.retention',
214
- source: /^\d+[KMG]?B$/.test(size) ? `{ maxSize: ${quoted(size)} }` : undefined,
215
- note: `nx.json \`maxCacheSize\`: add \`cache: { retention: { maxSize: ${JSON.stringify(size)} } }\` to .vx/workspace.ts`,
216
- })
217
209
  return out
218
210
  }
package/src/nx/index.ts CHANGED
@@ -39,7 +39,6 @@ import {
39
39
  mapNxWorkspace,
40
40
  type NxExecutors,
41
41
  type NxGraph,
42
- nxSizeText,
43
42
  parseNxGraph,
44
43
  readNxJson,
45
44
  } from './nx-map.js'
@@ -166,11 +165,6 @@ export function nx(options: NxPluginOptions = {}): VxPlugin {
166
165
  rules: { ...workspace.run?.rules, dependencies },
167
166
  }
168
167
  }
169
- if (workspace.cache?.retention === undefined) {
170
- const maxSize = await nxMaxCacheSize(root)
171
- if (maxSize !== undefined)
172
- workspace.cache = { ...workspace.cache, retention: { maxSize } }
173
- }
174
168
  },
175
169
  // The package manager's members are projects, each with no vx.config
176
170
  // written. An integrated Nx repo keeps projects out of that list
@@ -294,24 +288,6 @@ async function nxDefaultBase(root: string): Promise<string | undefined> {
294
288
  return typeof b === 'string' && b.trim() !== '' ? b.trim().replace(/^origin\//, '') : undefined
295
289
  }
296
290
 
297
- /**
298
- * nx.json's `maxCacheSize`, or `NX_MAX_CACHE_SIZE` above it, as core's
299
- * size: Nx caps its local cache there (`resolveMaxCacheSize`), and read by
300
- * nothing a capped cache grew without bound under vx, as turbo.json's
301
- * `cacheMaxSize` once did (G-49). 1024-based, a fraction truncated to
302
- * bytes; `0` is no cap. A value Nx would refuse is left to core's
303
- * refusal, which names the field.
304
- */
305
- async function nxMaxCacheSize(root: string): Promise<string | undefined> {
306
- const env = process.env['NX_MAX_CACHE_SIZE']
307
- const raw =
308
- env !== undefined
309
- ? env
310
- : ((await readNxJson(root).catch(() => null))?.json as { maxCacheSize?: unknown } | undefined)
311
- ?.maxCacheSize
312
- return nxSizeText(raw)
313
- }
314
-
315
291
  interface LoadedGraph {
316
292
  readonly text: string
317
293
  readonly graph: NxGraph
package/src/nx/nx-map.ts CHANGED
@@ -1522,20 +1522,3 @@ function followNxGraph(
1522
1522
  }
1523
1523
  }
1524
1524
  }
1525
-
1526
- /** Nx's cache-size grammar (`10GB`, `1.5 GB`, bare bytes) as core's size; `0` is no cap. */
1527
- export function nxSizeText(raw: unknown): string | undefined {
1528
- if (typeof raw !== 'string' && typeof raw !== 'number') return undefined
1529
- const text = String(raw).trim()
1530
- const m = /^(\d+\.?\d*|\.\d+)\s?([KMG]?B)?$/.exec(text)
1531
- if (m === null) return text
1532
- const units = ['B', 'KB', 'MB', 'GB']
1533
- let n = Math.floor(Number(m[1]) * 1024 ** units.indexOf(m[2] ?? 'B'))
1534
- if (n === 0) return undefined
1535
- let u = 0
1536
- while (u < units.length - 1 && n % 1024 === 0) {
1537
- n /= 1024
1538
- u++
1539
- }
1540
- return `${n}${units[u]}`
1541
- }
@@ -14,12 +14,14 @@
14
14
  import {
15
15
  definePlugin,
16
16
  type RemoteCacheLayer,
17
+ type RemoteKey,
17
18
  type RemoteOpenContext,
18
19
  type VxPlugin,
19
20
  refuseUnknownOptions,
20
21
  type PluginOptionKinds,
21
22
  } from '@vzn/vx'
22
23
  import { deadline } from '../remote-deadline.js'
24
+ import { scopeHeader } from '../remote-scope.js'
23
25
  import { OutageBreaker } from '../remote-breaker.js'
24
26
  import { Cooldown, withRetry } from '../remote-retry.js'
25
27
  import {
@@ -153,10 +155,11 @@ export class NxRemoteCache implements RemoteCacheLayer {
153
155
  private async request(
154
156
  method: 'GET' | 'PUT',
155
157
  hash: string,
158
+ at: RemoteKey | undefined,
156
159
  body?: Blob,
157
160
  ): Promise<Response | undefined> {
158
161
  if (this.token === undefined) return undefined
159
- const headers: Record<string, string> = {}
162
+ const headers: Record<string, string> = scopeHeader(at)
160
163
  if (this.token !== '') headers['Authorization'] = `Bearer ${this.token}`
161
164
  if (body !== undefined) {
162
165
  headers['Content-Type'] = 'application/octet-stream'
@@ -209,15 +212,18 @@ export class NxRemoteCache implements RemoteCacheLayer {
209
212
  return res
210
213
  }
211
214
 
212
- async get(hash: string): Promise<{ body: Response; durationMs: number | undefined } | null> {
213
- const res = await this.fetch(hash)
215
+ async get(
216
+ hash: string,
217
+ at?: RemoteKey,
218
+ ): Promise<{ body: Response; durationMs: number | undefined } | null> {
219
+ const res = await this.fetch(hash, at)
214
220
  // The Nx wire carries no producing-task duration.
215
221
  return res === null ? null : { body: res, durationMs: undefined }
216
222
  }
217
223
 
218
- private async fetch(hash: string): Promise<Response | null> {
224
+ private async fetch(hash: string, at: RemoteKey | undefined): Promise<Response | null> {
219
225
  if (this.disabled) return null
220
- const res = await this.request('GET', hash)
226
+ const res = await this.request('GET', hash, at)
221
227
  if (res === undefined) return null
222
228
  if (res.status !== 200) {
223
229
  await res.body?.cancel()
@@ -227,17 +233,17 @@ export class NxRemoteCache implements RemoteCacheLayer {
227
233
  return res
228
234
  }
229
235
 
230
- async has(hash: string): Promise<boolean> {
231
- const res = await this.fetch(hash)
236
+ async has(hash: string, at?: RemoteKey): Promise<boolean> {
237
+ const res = await this.fetch(hash, at)
232
238
  if (res === null) return false
233
239
  await res.body?.cancel()
234
240
  return true
235
241
  }
236
242
 
237
- // Nx's record carries no duration; the seam's `meta` is accepted and unused.
238
- async put(hash: string, body: Blob, _meta: { durationMs: number }): Promise<void> {
243
+ // Nx's record carries no duration; only `meta`'s scope is sent.
244
+ async put(hash: string, body: Blob, meta: { durationMs: number } & RemoteKey): Promise<void> {
239
245
  if (this.disabled || this.writesRefused) return
240
- const res = await this.request('PUT', hash, body)
246
+ const res = await this.request('PUT', hash, meta, body)
241
247
  if (res === undefined) return
242
248
  if (res.status === 200 || res.status === 202 || res.status === 409) return
243
249
  throw new Error(`HTTP ${res.status}`)
@@ -0,0 +1,12 @@
1
+ import type { RemoteKey } from '@vzn/vx'
2
+
3
+ /**
4
+ * The bucket a request means (owner, 2026-10-18): `trusted` for a task's
5
+ * own key, else the branch scope its key was derived in. It only states
6
+ * intent; a server checks it against the caller's identity (a CI ID
7
+ * token). A request without it, from an older client, leaves the layering
8
+ * to the server. Shared by `turboCache()` and `nxCache()`.
9
+ */
10
+ export function scopeHeader(at: RemoteKey | undefined): Record<string, string> {
11
+ return { 'x-vx-cache-scope': at?.scope ?? 'trusted' }
12
+ }
@@ -11,7 +11,7 @@
11
11
  import { readFileSync } from 'node:fs'
12
12
  import { availableParallelism } from 'node:os'
13
13
  import path from 'node:path'
14
- import type { ProjectMeta, VxPlugin, WorkspaceConfig } from '@vzn/vx'
14
+ import type { ProjectMeta, VxPlugin } from '@vzn/vx'
15
15
  import type { PackageMeta } from '../packages.js'
16
16
  import { memberProjects, withManifests } from '../manifests.js'
17
17
  import { refuseUnknownOptions, type PluginOptionKinds } from '@vzn/vx'
@@ -65,14 +65,6 @@ export function turbo(options: TurboPluginOptions = {}): VxPlugin {
65
65
  const keys = await workspaceKeys(options.root ?? ctx.workspaceRoot)
66
66
  if (workspace.run?.concurrency === undefined && keys.concurrency !== undefined)
67
67
  workspace.run = { ...workspace.run, concurrency: keys.concurrency }
68
- if (workspace.cache?.retention === undefined && keys.cacheRetention !== undefined)
69
- workspace.cache = {
70
- ...workspace.cache,
71
- // `olderThan` is built with its unit below.
72
- retention: keys.cacheRetention as NonNullable<
73
- NonNullable<WorkspaceConfig['cache']>['retention']
74
- >,
75
- }
76
68
  // `turbo run --affected` compares with TURBO_SCM_BASE when set, and
77
69
  // on GitHub Actions with what the event names.
78
70
  const base =
@@ -99,36 +91,6 @@ export function turbo(options: TurboPluginOptions = {}): VxPlugin {
99
91
  )
100
92
  }
101
93
 
102
- /**
103
- * turbo.json's workspace keys vx has a home for, top level or under
104
- * `global`: `concurrency` (`"10"`, `"50%"` of the cores) and the local
105
- * cache's `cacheMaxSize` / `cacheMaxAge` (`"0"` is off; weeks become days).
106
- * Read by nothing, a repo that capped its cache at 10GB (formbricks) grew
107
- * it without bound under vx. A value core cannot parse is left to core's
108
- * refusal, which names the field.
109
- */
110
- const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB']
111
-
112
- /**
113
- * Turbo's size (turborepo-cache `parse_human_size`): 1024-based, a bare
114
- * number is bytes, a fraction is truncated to bytes. Core takes a whole
115
- * number with a unit, so `7.5GB` (langfuse) is restated as `7680MB` and
116
- * `1000000` as `1000000B`; core refused both. Undefined when Turbo reads
117
- * it as off (under one byte).
118
- */
119
- function turboSize(v: string): string | undefined {
120
- const m = /^(\d+\.?\d*|\.\d+)([KMGT]?B)?$/i.exec(v)
121
- if (!m) return v
122
- let n = Math.floor(Number(m[1]) * 1024 ** SIZE_UNITS.indexOf((m[2] ?? 'B').toUpperCase()))
123
- if (n === 0) return undefined
124
- let u = 0
125
- while (u < SIZE_UNITS.length - 1 && n % 1024 === 0) {
126
- n /= 1024
127
- u++
128
- }
129
- return `${n}${SIZE_UNITS[u]}`
130
- }
131
-
132
94
  const UNKNOWN_SHA = '0'.repeat(40)
133
95
 
134
96
  /**
@@ -173,9 +135,12 @@ function githubActionsBase(root: string, remoteFallback: boolean): string | unde
173
135
  return before === '' ? undefined : before
174
136
  }
175
137
 
138
+ /**
139
+ * turbo.json's workspace keys vx has a home for, top level or under
140
+ * `global`: `concurrency` (`"10"`, `"50%"` of the cores).
141
+ */
176
142
  async function workspaceKeys(root: string): Promise<{
177
143
  concurrency?: number
178
- cacheRetention?: { maxSize?: string; olderThan?: string }
179
144
  remoteBaseFallback?: boolean
180
145
  }> {
181
146
  const file = await turboConfigFile(root)
@@ -199,7 +164,6 @@ async function workspaceKeys(root: string): Promise<{
199
164
  }
200
165
  const out: {
201
166
  concurrency?: number
202
- cacheRetention?: { maxSize?: string; olderThan?: string }
203
167
  remoteBaseFallback?: boolean
204
168
  } = {}
205
169
  const flags = raw['futureFlags'] as { githubActionsRemoteBaseRefFallback?: unknown } | undefined
@@ -212,19 +176,6 @@ async function workspaceKeys(root: string): Promise<{
212
176
  : Number(c)
213
177
  if (Number.isInteger(n) && n > 0) out.concurrency = n
214
178
  }
215
- const size = read('cacheMaxSize', 'TURBO_CACHE_MAX_SIZE')
216
- const maxSize = size === undefined ? undefined : turboSize(size)
217
- const age = read('cacheMaxAge', 'TURBO_CACHE_MAX_AGE')
218
- // Turbo reads a bare number as days (`parse_human_duration`).
219
- const olderThan = age
220
- ?.replace(/^(\d+)w$/i, (_, w: string) => `${Number(w) * 7}d`)
221
- .replace(/^\d+$/, (d) => `${d}d`)
222
- if (maxSize !== undefined || olderThan !== undefined) {
223
- out.cacheRetention = {
224
- ...(maxSize !== undefined ? { maxSize } : {}),
225
- ...(olderThan !== undefined ? { olderThan } : {}),
226
- }
227
- }
228
179
  return out
229
180
  }
230
181
 
@@ -2023,9 +2023,9 @@ function buildTask(
2023
2023
  continue
2024
2024
  }
2025
2025
  const neg = i.startsWith('!')
2026
- // Turbo's globs have character classes and extglobs; a vx bracket is
2027
- // a literal, so `src/**/*.[jt]s` keyed on nothing and an edit
2028
- // replayed the old build (item 1031). A positive glob with no safe
2026
+ // Turbo's globs have extglobs and vx's do not, so
2027
+ // `src/**/*.@(js|ts)` keyed on nothing and an edit replayed the old
2028
+ // build (item 1031). A positive glob with no safe
2029
2029
  // form widens to every package file; a negation with none is dropped
2030
2030
  // (it would exclude fewer files, never more).
2031
2031
  const wax = minimatchToVx(neg ? i.slice(1) : i, neg)
@@ -2103,19 +2103,16 @@ function buildTask(
2103
2103
  const zeroed = new Set<string>()
2104
2104
  const wsOutFiles: string[] = []
2105
2105
  for (const raw of def.outputs ?? []) {
2106
- // The first segment stays a literal (a route directory, item 667);
2107
- // the rest is Turbo's grammar: `dist/**/*.[cm]js` matched nothing, so
2108
- // a hit restored nothing (item 1031).
2109
- const cut = raw.indexOf('/')
2110
- const rest = cut < 0 ? null : minimatchToVx(raw.slice(cut + 1), raw.startsWith('!'))
2111
- if (cut >= 0 && rest === null) {
2106
+ // Turbo's grammar: an extglob in `dist/**/*.@(js|mjs)` matched
2107
+ // nothing, so a hit restored nothing (item 1031).
2108
+ const mapped = minimatchToVx(raw, raw.startsWith('!'))
2109
+ if (mapped === null) {
2112
2110
  todos.push(
2113
2111
  `output ${JSON.stringify(raw)}: glob syntax vx cannot take — task runs uncached; ` +
2114
2112
  'declare the exact outputs in a vx.config to cache it',
2115
2113
  )
2116
2114
  return { name, todos, task, uses }
2117
2115
  }
2118
- const mapped = cut < 0 ? raw : `${raw.slice(0, cut + 1)}${rest}`
2119
2116
  // A `!` output takes a path back, as Turbo's does (A-44): Next's
2120
2117
  // `.next/**` minus `!.next/cache/**` saved the cache and cleaned it
2121
2118
  // before every run while vx outputs could not exclude.
@@ -18,12 +18,14 @@ import path from 'node:path'
18
18
  import {
19
19
  definePlugin,
20
20
  type RemoteCacheLayer,
21
+ type RemoteKey,
21
22
  type RemoteOpenContext,
22
23
  type VxPlugin,
23
24
  refuseUnknownOptions,
24
25
  type PluginOptionKinds,
25
26
  } from '@vzn/vx'
26
27
  import { deadline } from '../remote-deadline.js'
28
+ import { scopeHeader } from '../remote-scope.js'
27
29
  import { OutageBreaker } from '../remote-breaker.js'
28
30
  import { Cooldown, withRetry } from '../remote-retry.js'
29
31
  import {
@@ -614,9 +616,9 @@ export class TurboRemoteCache implements RemoteCacheLayer {
614
616
  await revokeOidc(this.config.oidc!, token, this.fetchImpl, this.wait)
615
617
  }
616
618
 
617
- async has(hash: string): Promise<boolean> {
619
+ async has(hash: string, at?: RemoteKey): Promise<boolean> {
618
620
  if (this.disabled) return false
619
- const res = await this.request('HEAD', `/${hash}`)
621
+ const res = await this.request('HEAD', `/${hash}`, { headers: scopeHeader(at) })
620
622
  if (res === undefined) return false
621
623
  if (res.status === 200) return true
622
624
  if (res.status === 404) {
@@ -626,11 +628,27 @@ export class TurboRemoteCache implements RemoteCacheLayer {
626
628
  throw new Error(`HTTP ${res.status}`)
627
629
  }
628
630
 
629
- async hasMany(hashes: readonly string[]): Promise<Set<string> | null> {
631
+ async hasMany(hashes: readonly string[], at?: readonly RemoteKey[]): Promise<Set<string> | null> {
630
632
  if (this.disabled || hashes.length === 0) return this.disabled ? new Set() : null
633
+ // One query names one scope, so a batch asks once per scope in it.
634
+ const byScope = new Map<string | undefined, string[]>()
635
+ hashes.forEach((h, i) => {
636
+ const scope = at?.[i]?.scope
637
+ byScope.set(scope, [...(byScope.get(scope) ?? []), h])
638
+ })
639
+ const held = new Set<string>()
640
+ for (const [scope, group] of byScope) {
641
+ const found = await this.queryHeld(group, scope === undefined ? {} : { scope })
642
+ if (found === null) return null
643
+ for (const h of found) held.add(h)
644
+ }
645
+ return held
646
+ }
647
+
648
+ private async queryHeld(hashes: readonly string[], at: RemoteKey): Promise<Set<string> | null> {
631
649
  const res = await this.request('POST', '', {
632
650
  body: JSON.stringify({ hashes }),
633
- headers: { 'Content-Type': 'application/json' },
651
+ headers: { 'Content-Type': 'application/json', ...scopeHeader(at) },
634
652
  })
635
653
  if (res === undefined) return new Set()
636
654
  if (res.status !== 200) return null
@@ -653,12 +671,15 @@ export class TurboRemoteCache implements RemoteCacheLayer {
653
671
  return held
654
672
  }
655
673
 
656
- async get(hash: string): Promise<{ body: Response; durationMs: number | undefined } | null> {
674
+ async get(
675
+ hash: string,
676
+ at?: RemoteKey,
677
+ ): Promise<{ body: Response; durationMs: number | undefined } | null> {
657
678
  if (this.disabled) return null
658
679
  // A gateway that keys binary media on Accept base64-encodes a `*/*`
659
680
  // response, and every hit read as a corrupt artifact (nx#33092's class).
660
681
  const res = await this.request('GET', `/${hash}`, {
661
- headers: { Accept: 'application/octet-stream' },
682
+ headers: { Accept: 'application/octet-stream', ...scopeHeader(at) },
662
683
  })
663
684
  if (res === undefined) return null
664
685
  if (res.status !== 200) {
@@ -716,13 +737,14 @@ export class TurboRemoteCache implements RemoteCacheLayer {
716
737
  return new Response(unlinkingStream(temp))
717
738
  }
718
739
 
719
- async put(hash: string, body: Blob, meta: { durationMs: number }): Promise<void> {
740
+ async put(hash: string, body: Blob, meta: { durationMs: number } & RemoteKey): Promise<void> {
720
741
  if (this.disabled || this.writesRefused) return
721
742
  if (!this.absent.has(hash)) {
722
743
  // Part of the upload, so under its deadline. Any answer but 200 or 404
723
744
  // uploads: a lost store costs more. A server the HEAD cannot reach
724
745
  // fails the put, as the PUT would.
725
746
  const head = await this.request('HEAD', `/${hash}`, {
747
+ headers: scopeHeader(meta),
726
748
  timeoutMs: this.config.uploadTimeoutMs,
727
749
  })
728
750
  if (head === undefined || head.status === 200) return
@@ -732,6 +754,7 @@ export class TurboRemoteCache implements RemoteCacheLayer {
732
754
  'Content-Type': 'application/octet-stream',
733
755
  'Content-Length': String(body.size),
734
756
  'x-artifact-duration': String(Math.max(0, Math.round(meta.durationMs))),
757
+ ...scopeHeader(meta),
735
758
  }
736
759
  if (this.key !== undefined) {
737
760
  headers['x-artifact-tag'] = await artifactTag(this.key, hash, this.config.teamId ?? '', body)