@vzn/vx-migrate 0.0.644 → 0.0.645

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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 vx contributors
3
+ Copyright (c) 2026 VZN
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -564,6 +564,7 @@ Every option falls back to the tool's own environment variable, so a self-hosted
564
564
  | `uploadTimeoutMs` | `TURBO_REMOTE_CACHE_UPLOAD_TIMEOUT`, then `remoteCache.uploadTimeout` (seconds) | PUT deadline (default 60 s; 0 none) |
565
565
  | `retries` | — | resends of a request answered 5xx (not 501) or never connected (default 1, Turbo's); a 429 waits out the limit; 0 turns both off |
566
566
  | `preflight` | `TURBO_PREFLIGHT` (1 or 0), then `remoteCache.preflight` | send Turbo's `OPTIONS` preflight before each artifact request and go where its `Location` points (relative to `apiUrl`); the token goes along only when the API's `Access-Control-Allow-Headers` admits `Authorization`; a redirected preflight is not followed (default off) |
567
+ | `scope` | — | `RemoteScope`: `'header'` (default): every read names the run's refs, own branch first, in `x-artifact-read-scopes`; `'key'`: each entry is keyed on its ref, a read asks every ref's key in one batch (a server that ignores the header) |
567
568
 
568
569
  The signature is Turbo's current scheme (`artifact-signature:v2`: prefix, hash, team id and body, each length-prefixed, under HMAC-SHA256, base64 in `x-artifact-tag`). A signed body is written to a temp file before its tag can be checked, so one past core's artifact ceiling (2 GiB, at zstd's bound) is refused as it passes it, and is a miss.
569
570
 
@@ -620,7 +621,7 @@ With no token from an option, a variable or a config file, both plugins read the
620
621
 
621
622
  ## Remote-cache behaviour (both)
622
623
 
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.
624
+ - Task keys carry no branch. `turboCache()` names the run's refs on every read (`HEAD`, `GET`, the batch `POST`) in `x-artifact-read-scopes`, own branch first (`refs/heads/feat-x, refs/heads/main`): from CI's env, else git's current branch, then the default branch. A `PUT` names none: where a write lands is the server's call, from the CI OIDC token checked against its trust policy. `scope: 'key'` folds the ref into each key instead, for a server that ignores the header. `nxCache()` sends plain requests. How a server implements the header: [Branch caches a server enforces](https://vznjs.github.io/vx/blog/branch-caches-a-server-enforces/).
624
625
  - 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.
625
626
  - 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).
626
627
  - 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.644",
3
+ "version": "0.0.645",
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.644"
43
+ "@vzn/vx": "^0.0.645"
44
44
  },
45
45
  "repository": {
46
46
  "type": "git",
@@ -14,14 +14,12 @@
14
14
  import {
15
15
  definePlugin,
16
16
  type RemoteCacheLayer,
17
- type RemoteKey,
18
17
  type RemoteOpenContext,
19
18
  type VxPlugin,
20
19
  refuseUnknownOptions,
21
20
  type PluginOptionKinds,
22
21
  } from '@vzn/vx'
23
22
  import { deadline } from '../remote-deadline.js'
24
- import { scopeHeader } from '../remote-scope.js'
25
23
  import { OutageBreaker } from '../remote-breaker.js'
26
24
  import { Cooldown, withRetry } from '../remote-retry.js'
27
25
  import {
@@ -155,11 +153,10 @@ export class NxRemoteCache implements RemoteCacheLayer {
155
153
  private async request(
156
154
  method: 'GET' | 'PUT',
157
155
  hash: string,
158
- at: RemoteKey | undefined,
159
156
  body?: Blob,
160
157
  ): Promise<Response | undefined> {
161
158
  if (this.token === undefined) return undefined
162
- const headers: Record<string, string> = scopeHeader(at)
159
+ const headers: Record<string, string> = {}
163
160
  if (this.token !== '') headers['Authorization'] = `Bearer ${this.token}`
164
161
  if (body !== undefined) {
165
162
  headers['Content-Type'] = 'application/octet-stream'
@@ -212,18 +209,15 @@ export class NxRemoteCache implements RemoteCacheLayer {
212
209
  return res
213
210
  }
214
211
 
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)
212
+ async get(hash: string): Promise<{ body: Response; durationMs: number | undefined } | null> {
213
+ const res = await this.fetch(hash)
220
214
  // The Nx wire carries no producing-task duration.
221
215
  return res === null ? null : { body: res, durationMs: undefined }
222
216
  }
223
217
 
224
- private async fetch(hash: string, at: RemoteKey | undefined): Promise<Response | null> {
218
+ private async fetch(hash: string): Promise<Response | null> {
225
219
  if (this.disabled) return null
226
- const res = await this.request('GET', hash, at)
220
+ const res = await this.request('GET', hash)
227
221
  if (res === undefined) return null
228
222
  if (res.status !== 200) {
229
223
  await res.body?.cancel()
@@ -233,17 +227,18 @@ export class NxRemoteCache implements RemoteCacheLayer {
233
227
  return res
234
228
  }
235
229
 
236
- async has(hash: string, at?: RemoteKey): Promise<boolean> {
237
- const res = await this.fetch(hash, at)
238
- if (res === null) return false
230
+ async has(hash: string): Promise<{ size?: number } | null> {
231
+ const res = await this.fetch(hash)
232
+ if (res === null) return null
239
233
  await res.body?.cancel()
240
- return true
234
+ const size = Number(res.headers.get('content-length') ?? NaN)
235
+ return Number.isFinite(size) ? { size } : {}
241
236
  }
242
237
 
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> {
238
+ // Nx's record carries no duration.
239
+ async put(hash: string, body: Blob): Promise<void> {
245
240
  if (this.disabled || this.writesRefused) return
246
- const res = await this.request('PUT', hash, meta, body)
241
+ const res = await this.request('PUT', hash, body)
247
242
  if (res === undefined) return
248
243
  if (res.status === 200 || res.status === 202 || res.status === 409) return
249
244
  throw new Error(`HTTP ${res.status}`)
@@ -16,16 +16,16 @@ import { unlink } from 'node:fs/promises'
16
16
  import { tmpdir } from 'node:os'
17
17
  import path from 'node:path'
18
18
  import {
19
+ type ArtifactInfo,
19
20
  definePlugin,
20
21
  type RemoteCacheLayer,
21
- type RemoteKey,
22
22
  type RemoteOpenContext,
23
23
  type VxPlugin,
24
24
  refuseUnknownOptions,
25
25
  type PluginOptionKinds,
26
26
  } from '@vzn/vx'
27
27
  import { deadline } from '../remote-deadline.js'
28
- import { scopeHeader } from '../remote-scope.js'
28
+ import { keyedByRef, readScopes, remoteScope, type RemoteScope } from './scope.js'
29
29
  import { OutageBreaker } from '../remote-breaker.js'
30
30
  import { Cooldown, withRetry } from '../remote-retry.js'
31
31
  import {
@@ -82,6 +82,14 @@ export interface TurboCacheOptions {
82
82
  * `remoteCache.preflight`; default off.
83
83
  */
84
84
  preflight?: boolean
85
+ /**
86
+ * How branches stay apart (default `'header'`): every read names the run's
87
+ * refs in `x-artifact-read-scopes`, for a server that files writes by the
88
+ * CI token. `'key'` also folds the run's ref into each key and reads each
89
+ * ref's key in order, for a server that ignores the header. The server's
90
+ * OIDC policy alone decides where a write lands.
91
+ */
92
+ scope?: RemoteScope
85
93
  }
86
94
 
87
95
  /** turbo.json's `remoteCache` block: the source below Turbo's environment, as in Turbo. */
@@ -128,6 +136,17 @@ export const MIN_SIGNATURE_KEY_LENGTH = 32
128
136
  // Bun's own hasher and the global `crypto`, not `node:crypto`: importing that
129
137
  // module cost every `@vzn/vx-migrate` user 6.6 ms at startup, remote cache
130
138
  // or not (G-121).
139
+ /** A size and a duration as the wire spells them (a header string, a JSON number), kept when they are counts. */
140
+ function infoOf(size: unknown, durationMs: unknown): ArtifactInfo {
141
+ const count = (v: unknown): number | undefined => {
142
+ const n = typeof v === 'string' && v !== '' ? Number(v) : v
143
+ return typeof n === 'number' && Number.isFinite(n) && n >= 0 ? n : undefined
144
+ }
145
+ const s = count(size)
146
+ const d = count(durationMs)
147
+ return { ...(s === undefined ? {} : { size: s }), ...(d === undefined ? {} : { durationMs: d }) }
148
+ }
149
+
131
150
  function updateLength(mac: Bun.CryptoHasher, byteLength: number): void {
132
151
  const len = Buffer.alloc(8)
133
152
  len.writeBigUInt64LE(BigInt(byteLength))
@@ -450,6 +469,8 @@ export class TurboRemoteCache implements RemoteCacheLayer {
450
469
  private readonly keychainRemote: KeychainRemote
451
470
  /** The remote's rate limit, which every request of this client waits out. */
452
471
  private readonly cooldown = new Cooldown()
472
+ /** Headers every read carries: the refs it may be answered from (`readScopes`). */
473
+ reads: Record<string, string> = {}
453
474
  constructor(
454
475
  private readonly config: TurboCacheConfig,
455
476
  private readonly fetchImpl: typeof fetch = fetch,
@@ -616,41 +637,26 @@ export class TurboRemoteCache implements RemoteCacheLayer {
616
637
  await revokeOidc(this.config.oidc!, token, this.fetchImpl, this.wait)
617
638
  }
618
639
 
619
- async has(hash: string, at?: RemoteKey): Promise<boolean> {
620
- if (this.disabled) return false
621
- const res = await this.request('HEAD', `/${hash}`, { headers: scopeHeader(at) })
622
- if (res === undefined) return false
623
- if (res.status === 200) return true
640
+ async has(hash: string): Promise<ArtifactInfo | null> {
641
+ if (this.disabled) return null
642
+ const res = await this.request('HEAD', `/${hash}`, { headers: this.reads })
643
+ if (res === undefined) return null
644
+ if (res.status === 200)
645
+ return infoOf(res.headers.get('content-length'), res.headers.get('x-artifact-duration'))
624
646
  if (res.status === 404) {
625
647
  this.absent.add(hash)
626
- return false
648
+ return null
627
649
  }
628
650
  throw new Error(`HTTP ${res.status}`)
629
651
  }
630
652
 
631
- async hasMany(hashes: readonly string[], at?: readonly RemoteKey[]): Promise<Set<string> | null> {
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> {
653
+ async hasMany(hashes: readonly string[]): Promise<Map<string, ArtifactInfo> | null> {
654
+ if (this.disabled || hashes.length === 0) return this.disabled ? new Map() : null
649
655
  const res = await this.request('POST', '', {
650
656
  body: JSON.stringify({ hashes }),
651
- headers: { 'Content-Type': 'application/json', ...scopeHeader(at) },
657
+ headers: { 'Content-Type': 'application/json', ...this.reads },
652
658
  })
653
- if (res === undefined) return new Set()
659
+ if (res === undefined) return new Map()
654
660
  if (res.status !== 200) return null
655
661
  // An answer per hash is a few hundred bytes of ArtifactInfo; a reply
656
662
  // past this bound is no answer, read no further than the bound (L-9).
@@ -661,25 +667,24 @@ export class TurboRemoteCache implements RemoteCacheLayer {
661
667
  // Each hash answers ArtifactInfo, null, or `{ error: { message } }`; an
662
668
  // error entry is not a stored artifact, and it was counted as one (a GET
663
669
  // spent on a 404, item 929).
664
- const held = new Set(
665
- hashes.filter((h) => {
666
- const v = info[h]
667
- return typeof v === 'object' && v !== null && !('error' in v)
668
- }),
669
- )
670
- for (const h of hashes) if (!held.has(h)) this.absent.add(h)
670
+ const held = new Map<string, ArtifactInfo>()
671
+ for (const h of hashes) {
672
+ const v = info[h]
673
+ if (typeof v !== 'object' || v === null || 'error' in v) this.absent.add(h)
674
+ else {
675
+ const { size, taskDurationMs } = v as { size?: unknown; taskDurationMs?: unknown }
676
+ held.set(h, infoOf(size, taskDurationMs))
677
+ }
678
+ }
671
679
  return held
672
680
  }
673
681
 
674
- async get(
675
- hash: string,
676
- at?: RemoteKey,
677
- ): Promise<{ body: Response; durationMs: number | undefined } | null> {
682
+ async get(hash: string): Promise<{ body: Response; durationMs: number | undefined } | null> {
678
683
  if (this.disabled) return null
679
684
  // A gateway that keys binary media on Accept base64-encodes a `*/*`
680
685
  // response, and every hit read as a corrupt artifact (nx#33092's class).
681
686
  const res = await this.request('GET', `/${hash}`, {
682
- headers: { Accept: 'application/octet-stream', ...scopeHeader(at) },
687
+ headers: { Accept: 'application/octet-stream', ...this.reads },
683
688
  })
684
689
  if (res === undefined) return null
685
690
  if (res.status !== 200) {
@@ -737,14 +742,14 @@ export class TurboRemoteCache implements RemoteCacheLayer {
737
742
  return new Response(unlinkingStream(temp))
738
743
  }
739
744
 
740
- async put(hash: string, body: Blob, meta: { durationMs: number } & RemoteKey): Promise<void> {
745
+ async put(hash: string, body: Blob, meta: { durationMs: number }): Promise<void> {
741
746
  if (this.disabled || this.writesRefused) return
742
747
  if (!this.absent.has(hash)) {
743
748
  // Part of the upload, so under its deadline. Any answer but 200 or 404
744
749
  // uploads: a lost store costs more. A server the HEAD cannot reach
745
750
  // fails the put, as the PUT would.
746
751
  const head = await this.request('HEAD', `/${hash}`, {
747
- headers: scopeHeader(meta),
752
+ headers: this.reads,
748
753
  timeoutMs: this.config.uploadTimeoutMs,
749
754
  })
750
755
  if (head === undefined || head.status === 200) return
@@ -754,7 +759,6 @@ export class TurboRemoteCache implements RemoteCacheLayer {
754
759
  'Content-Type': 'application/octet-stream',
755
760
  'Content-Length': String(body.size),
756
761
  'x-artifact-duration': String(Math.max(0, Math.round(meta.durationMs))),
757
- ...scopeHeader(meta),
758
762
  }
759
763
  if (this.key !== undefined) {
760
764
  headers['x-artifact-tag'] = await artifactTag(this.key, hash, this.config.teamId ?? '', body)
@@ -824,6 +828,7 @@ const TURBO_CACHE_KEYS: PluginOptionKinds<TurboCacheOptions> = {
824
828
  retries: 'number',
825
829
  preflight: 'boolean',
826
830
  oidc: 'any',
831
+ scope: 'string',
827
832
  }
828
833
 
829
834
  /**
@@ -837,6 +842,7 @@ const TURBO_CACHE_KEYS: PluginOptionKinds<TurboCacheOptions> = {
837
842
  */
838
843
  export function turboCache(options: TurboCacheOptions = {}): VxPlugin {
839
844
  refuseUnknownOptions('turboCache()', options, TURBO_CACHE_KEYS)
845
+ const scope = remoteScope('turbo-cache', options.scope)
840
846
  // The run's layer and warn, for `teardown` to revoke an exchanged token.
841
847
  let opened: { layer: TurboRemoteCache; warn: (m: string) => void } | undefined
842
848
  return definePlugin(import.meta, {
@@ -852,8 +858,9 @@ export function turboCache(options: TurboCacheOptions = {}): VxPlugin {
852
858
  // cache directory, which the sandbox walls, not the shared temp dir a
853
859
  // sandboxed task may read (L-45).
854
860
  const layer = new TurboRemoteCache(config, fetch, ctx.cacheDir)
861
+ layer.reads = readScopes(ctx.refs)
855
862
  if (config.oidc !== undefined) opened = { layer, warn: ctx.warn }
856
- return layer
863
+ return scope === 'key' ? keyedByRef(layer, ctx.refs) : layer
857
864
  },
858
865
  async teardown() {
859
866
  const run = opened
@@ -0,0 +1,89 @@
1
+ import type { ArtifactInfo, RemoteCacheLayer } from '@vzn/vx'
2
+
3
+ /**
4
+ * How a remote keeps branches apart (2026-10-20). `'header'`: plain keys,
5
+ * and every read names the refs it may be answered from, in order, in
6
+ * `x-artifact-read-scopes`; a server that files writes by the caller's CI
7
+ * token reads them, any other ignores it. `'key'`: each entry's key also
8
+ * folds a ref, for a server that keeps one bucket; a read asks each ref's
9
+ * key and the first hit wins.
10
+ */
11
+ export type RemoteScope = 'header' | 'key'
12
+
13
+ export function remoteScope(name: string, scope: unknown): RemoteScope {
14
+ if (scope === undefined || scope === 'header' || scope === 'key') return scope ?? 'header'
15
+ throw new Error(`vx/${name}: scope must be 'header' or 'key', got ${JSON.stringify(scope)}`)
16
+ }
17
+
18
+ /** A server caps the read header at this many refs (the server rules), so vx never sends more. */
19
+ const MAX_READ_SCOPES = 8
20
+
21
+ /** The read header: the refs in order. Writes carry none: the server files them by the token. */
22
+ export function readScopes(refs: readonly string[]): Record<string, string> {
23
+ return refs.length === 0
24
+ ? {}
25
+ : { 'x-artifact-read-scopes': refs.slice(0, MAX_READ_SCOPES).join(', ') }
26
+ }
27
+
28
+ /** The key `ref` stores `hash` under: 16 hex, task-key shaped, so every wire takes it. */
29
+ export function refKey(ref: string, hash: string): string {
30
+ return Bun.hash.xxHash3(`vx-cache-ref\0${ref}\0${hash}`).toString(16).padStart(16, '0')
31
+ }
32
+
33
+ const QUEUE = /^refs\/heads\/gh-readonly-queue\//
34
+
35
+ /**
36
+ * `inner` keyed by ref: a write lands under the run's own ref's key (a
37
+ * merge queue's, its base branch's, which its results become), a read asks
38
+ * each ref's key in order. A merge queue's own ref holds nothing, so it is
39
+ * never asked.
40
+ */
41
+ export function keyedByRef(inner: RemoteCacheLayer, allRefs: readonly string[]): RemoteCacheLayer {
42
+ const refs = QUEUE.test(allRefs[0] ?? '') ? allRefs.slice(1) : allRefs
43
+ const write = refs[0]
44
+ if (write === undefined) return inner
45
+ /** Task key → the stored key a batch probe found it under. */
46
+ const found = new Map<string, string>()
47
+ const hasMany = inner.hasMany
48
+ const firstHeld = async (hash: string): Promise<ArtifactInfo | null> => {
49
+ const known = found.get(hash)
50
+ if (known !== undefined) return inner.has(known)
51
+ for (const ref of refs) {
52
+ const info = await inner.has(refKey(ref, hash))
53
+ if (info !== null) return info
54
+ }
55
+ return null
56
+ }
57
+ return {
58
+ ...(inner.endpoint !== undefined ? { endpoint: inner.endpoint } : {}),
59
+ ...(inner.open !== undefined ? { open: (ctx) => inner.open!(ctx) } : {}),
60
+ has: firstHeld,
61
+ ...(hasMany !== undefined
62
+ ? {
63
+ hasMany: async (hashes) => {
64
+ const keys = hashes.map((h) => refs.map((r) => refKey(r, h)))
65
+ const held = await hasMany.call(inner, keys.flat())
66
+ if (held === null) return null
67
+ const out = new Map<string, ArtifactInfo>()
68
+ hashes.forEach((h, i) => {
69
+ const key = keys[i]!.find((k) => held.has(k))
70
+ if (key === undefined) return
71
+ found.set(h, key)
72
+ out.set(h, held.get(key)!)
73
+ })
74
+ return out
75
+ },
76
+ }
77
+ : {}),
78
+ get: async (hash) => {
79
+ const known = found.get(hash)
80
+ if (known !== undefined) return inner.get(known)
81
+ for (const ref of refs) {
82
+ const hit = await inner.get(refKey(ref, hash))
83
+ if (hit !== null) return hit
84
+ }
85
+ return null
86
+ },
87
+ put: (hash, body, meta) => inner.put(refKey(write, hash), body, meta),
88
+ }
89
+ }
@@ -1,12 +0,0 @@
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
- }