@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 +1 -1
- package/README.md +2 -1
- package/package.json +2 -2
- package/src/nx-cache/index.ts +13 -18
- package/src/turbo-cache/index.ts +51 -44
- package/src/turbo-cache/scope.ts +89 -0
- package/src/remote-scope.ts +0 -12
package/LICENSE
CHANGED
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
|
-
-
|
|
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.
|
|
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.
|
|
43
|
+
"@vzn/vx": "^0.0.645"
|
|
44
44
|
},
|
|
45
45
|
"repository": {
|
|
46
46
|
"type": "git",
|
package/src/nx-cache/index.ts
CHANGED
|
@@ -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> =
|
|
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
|
|
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
|
|
218
|
+
private async fetch(hash: string): Promise<Response | null> {
|
|
225
219
|
if (this.disabled) return null
|
|
226
|
-
const res = await this.request('GET', hash
|
|
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
|
|
237
|
-
const res = await this.fetch(hash
|
|
238
|
-
if (res === null) return
|
|
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
|
-
|
|
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
|
|
244
|
-
async put(hash: string, body: Blob
|
|
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,
|
|
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}`)
|
package/src/turbo-cache/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
620
|
-
if (this.disabled) return
|
|
621
|
-
const res = await this.request('HEAD', `/${hash}`, { headers:
|
|
622
|
-
if (res === undefined) return
|
|
623
|
-
if (res.status === 200)
|
|
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
|
|
648
|
+
return null
|
|
627
649
|
}
|
|
628
650
|
throw new Error(`HTTP ${res.status}`)
|
|
629
651
|
}
|
|
630
652
|
|
|
631
|
-
async hasMany(hashes: readonly string[]
|
|
632
|
-
if (this.disabled || hashes.length === 0) return this.disabled ? new
|
|
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', ...
|
|
657
|
+
headers: { 'Content-Type': 'application/json', ...this.reads },
|
|
652
658
|
})
|
|
653
|
-
if (res === undefined) return new
|
|
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
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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', ...
|
|
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 }
|
|
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:
|
|
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
|
+
}
|
package/src/remote-scope.ts
DELETED
|
@@ -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
|
-
}
|