44 * Artifacts is a git-compatible, versioned remote (see
55 * https://developers.cloudflare.com/artifacts/). Each app gets its own repo,
66 * and the SpaceDO mirrors every commit/deploy there via `git push` so Artifacts
7- * is the durable source of truth for history — the SpaceDO's local isomorphic-git
8- * (backed by the Workspace SQLite FS) remains the live working tree used to
9- * build/serve previews .
7+ * is the durable source of truth — the SpaceDO's local isomorphic-git (backed by
8+ * an in-memory overlay FS) is the live working tree used to build/serve previews
9+ * and is rehydrated from Artifacts on cold start .
1010 *
1111 * All operations here are best-effort: Artifacts is a beta product and may be
1212 * absent in local dev (no binding). Failures are logged and reported via return
@@ -19,6 +19,31 @@ const REMOTE_NAME = "artifacts"
1919const TOKEN_REFRESH_SKEW_MS = 60_000
2020/** Requested token lifetime (seconds). Artifacts allows 60s..1y. */
2121const TOKEN_TTL_SECONDS = 3600
22+ /** Max attempts for a single push before giving up (best-effort). */
23+ const PUSH_MAX_ATTEMPTS = 4
24+ /** Base backoff between push retries; grows linearly per attempt. */
25+ const PUSH_RETRY_BASE_MS = 100
26+
27+ const sleep = ( ms : number ) : Promise < void > => new Promise ( ( r ) => setTimeout ( r , ms ) )
28+
29+ /**
30+ * Detect a rejected push that a retry can plausibly resolve.
31+ *
32+ * The push protocol sends the ref's *current* server value (`oldoid`, read
33+ * from a fresh ref advertisement) as the expected old value. If another writer
34+ * updates the ref between that advertisement and the receive-pack, the server
35+ * rejects the update as "stale info"/"stale ref" — even under `force`, because
36+ * the mismatch is detected server-side. Re-running the push re-reads a fresh
37+ * `oldoid`, so a bounded retry converges. isomorphic-git surfaces this as a
38+ * `GitPushError`; treat those (and explicit stale wording) as retryable.
39+ */
40+ function isRetryablePushError ( err : unknown ) : boolean {
41+ const e = err as { code ?: string ; message ?: string ; data ?: unknown } | null
42+ if ( ! e ) return false
43+ if ( e . code === "GitPushError" ) return true
44+ const text = String ( e . message ?? err )
45+ return / s t a l e | n o t - f a s t - f o r w a r d | r e j e c t e d | f e t c h f i r s t | f a i l e d t o u p d a t e r e f / i. test ( text )
46+ }
2247
2348/**
2449 * Artifacts repo names allow alphanumerics, dots, hyphens and underscores.
@@ -35,17 +60,52 @@ export interface ArtifactsSyncLogger {
3560 info ?( message : string , ...args : unknown [ ] ) : void
3661}
3762
63+ /**
64+ * Durable persistence for the repo's git remote URL. The URL is only ever
65+ * surfaced as a by-value result of `create()`/`import()`; a `get()` handle
66+ * exposes methods only (its data properties are unreadable across the local-dev
67+ * remote-binding proxy). Cloudflare's guidance is to save the value — so the
68+ * SpaceDO backs this with durable DO storage, which survives eviction even
69+ * though the in-memory working tree does not.
70+ */
71+ export interface ArtifactsRemoteStore {
72+ read ( ) : Promise < string | null >
73+ write ( remoteUrl : string ) : Promise < void >
74+ }
75+
76+ /** Dispose an RPC stub/result if it is disposable; never throws. */
77+ function disposeQuietly ( value : unknown ) : void {
78+ // `Symbol.dispose` is not in the ES2022 lib we target, so look it up at
79+ // runtime instead of referencing it statically.
80+ const disposeSym = ( Symbol as unknown as { dispose ?: symbol } ) . dispose
81+ if ( ! disposeSym || value == null ) return
82+ try {
83+ const fn = ( value as Record < symbol , unknown > ) [ disposeSym ]
84+ if ( typeof fn === "function" ) ( fn as ( ) => void ) . call ( value )
85+ } catch {
86+ // best-effort cleanup
87+ }
88+ }
89+
3890export class ArtifactsSync {
3991 private readonly repoName : string
4092 private remoteUrl : string | null = null
4193 private remoteRegistered = false
4294 private token : string | null = null
4395 private tokenExpiresAt = 0
96+ /**
97+ * Serializes pushes within this DO instance. Concurrent pushes to the same
98+ * branch (e.g. a commit's fire-and-forget mirror overlapping a deploy's push,
99+ * or rapid successive commits) race on the ref advertisement and get rejected
100+ * as "stale ref". Chaining pushes eliminates that self-inflicted race.
101+ */
102+ private pushQueue : Promise < boolean > = Promise . resolve ( true )
44103
45104 constructor (
46105 private readonly artifacts : Artifacts ,
47106 private readonly git : Git ,
48107 repoName : string ,
108+ private readonly store ?: ArtifactsRemoteStore ,
49109 private readonly logger : ArtifactsSyncLogger = console ,
50110 ) {
51111 this . repoName = sanitizeRepoName ( repoName )
@@ -55,34 +115,21 @@ export class ArtifactsSync {
55115 * Ensure the app's Artifacts repo exists and the local git repo has an
56116 * `artifacts` remote pointing at it. Idempotent. Returns false (and logs) if
57117 * the repo could not be ensured — callers should treat sync as unavailable.
118+ *
119+ * Resolution order for the remote URL: in-memory cache -> durable store ->
120+ * provision (create, persist). This means a cold-started DO (empty in-memory
121+ * FS) recovers the remote from durable storage without any `get()` handle.
58122 */
59123 private async ensureRepo ( ) : Promise < boolean > {
60124 if ( this . remoteRegistered && this . remoteUrl ) return true
61125
62126 try {
63- let remote : string
64- try {
65- const existing = await this . artifacts . get ( this . repoName )
66- remote = existing . remote
67- } catch {
68- // Not found (or not ready) — create it. `create` throws ALREADY_EXISTS
69- // on a race; fall back to get in that case.
70- try {
71- const created = await this . artifacts . create ( this . repoName , {
72- setDefaultBranch : "main" ,
73- } )
74- remote = created . remote
75- // `create` hands back an initial token — reuse it to avoid an extra
76- // round-trip on the first push.
77- this . token = created . token
78- this . tokenExpiresAt = Date . parse ( created . tokenExpiresAt ) || 0
79- } catch {
80- const existing = await this . artifacts . get ( this . repoName )
81- remote = existing . remote
82- }
83- }
127+ let remote = this . remoteUrl ?? ( await this . store ?. read ( ) ) ?? null
128+ if ( ! remote ) remote = await this . provisionRemote ( )
129+ if ( ! remote ) return false
84130
85131 this . remoteUrl = remote
132+ await this . store ?. write ( remote )
86133 await this . registerRemote ( remote )
87134 this . remoteRegistered = true
88135 return true
@@ -92,6 +139,49 @@ export class ArtifactsSync {
92139 }
93140 }
94141
142+ /**
143+ * Obtain the repo's git remote URL, creating the repo if needed.
144+ *
145+ * Only `create()`/`import()` return the `remote` as a by-value RPC result; a
146+ * `get()` handle exposes methods only, and its data properties cannot be read
147+ * across the remote-binding proxy used in local dev (they resolve as method
148+ * calls and throw "does not implement the method"). So we create first and,
149+ * on ALREADY_EXISTS, fall back to reading the handle's `remote` — which works
150+ * with the native binding in production; in local dev it may fail, in which
151+ * case sync degrades to unavailable (best-effort) until a value is persisted.
152+ */
153+ private async provisionRemote ( ) : Promise < string | null > {
154+ let created : ArtifactsCreateRepoResult | null = null
155+ try {
156+ created = await this . artifacts . create ( this . repoName , { setDefaultBranch : "main" } )
157+ const remote = await created . remote
158+ try {
159+ // Reuse the initial token to avoid an extra round-trip on first push.
160+ this . token = await created . token
161+ this . tokenExpiresAt = Date . parse ( await created . tokenExpiresAt ) || 0
162+ } catch {
163+ // Token unreadable here — getWriteToken() will mint one on demand.
164+ }
165+ return remote
166+ } catch {
167+ const repo = await this . artifacts . get ( this . repoName ) . catch ( ( ) => null )
168+ if ( ! repo ) return null
169+ try {
170+ return await repo . remote
171+ } catch ( e ) {
172+ this . logger . warn (
173+ "ArtifactsSync: repo exists but its remote URL is not readable and none is persisted; sync unavailable" ,
174+ e ,
175+ )
176+ return null
177+ } finally {
178+ disposeQuietly ( repo )
179+ }
180+ } finally {
181+ if ( created ) disposeQuietly ( created )
182+ }
183+ }
184+
95185 private async registerRemote ( url : string ) : Promise < void > {
96186 try {
97187 await this . git . remote ( { add : { name : REMOTE_NAME , url } } )
@@ -112,39 +202,73 @@ export class ArtifactsSync {
112202 if ( this . token && Date . now ( ) < this . tokenExpiresAt - TOKEN_REFRESH_SKEW_MS ) {
113203 return this . token
114204 }
205+ const repo = await this . artifacts . get ( this . repoName ) . catch ( ( ) => null )
206+ if ( ! repo ) {
207+ this . logger . warn ( "ArtifactsSync.getWriteToken failed to get repo handle" )
208+ return null
209+ }
115210 try {
116- const repo = await this . artifacts . get ( this . repoName )
211+ // createToken() is a method call whose result is a by-value plain object,
212+ // so its `plaintext`/`expiresAt` fields are directly readable.
117213 const result = await repo . createToken ( "write" , TOKEN_TTL_SECONDS )
118214 this . token = result . plaintext
119215 this . tokenExpiresAt = Date . parse ( result . expiresAt ) || 0
120216 return this . token
121217 } catch ( e ) {
122218 this . logger . warn ( "ArtifactsSync.getWriteToken failed" , e )
123219 return null
220+ } finally {
221+ disposeQuietly ( repo )
124222 }
125223 }
126224
127225 /**
128226 * Mirror `branch` to Artifacts. Best-effort: returns true on success, false
129227 * (with a logged warning) otherwise. Never throws.
228+ *
229+ * Pushes are serialized per DO instance (see `pushQueue`) so overlapping
230+ * callers can't race each other into a "stale ref" rejection, and each push
231+ * is retried with backoff to absorb races from any other writer.
130232 */
131233 async push ( branch : string ) : Promise < boolean > {
234+ const run = this . pushQueue . then (
235+ ( ) => this . pushWithRetry ( branch ) ,
236+ ( ) => this . pushWithRetry ( branch ) ,
237+ )
238+ // Keep the chain alive regardless of this push's outcome.
239+ this . pushQueue = run . catch ( ( ) => false )
240+ return run
241+ }
242+
243+ private async pushWithRetry ( branch : string ) : Promise < boolean > {
132244 if ( ! ( await this . ensureRepo ( ) ) ) return false
133- const token = await this . getWriteToken ( )
134- if ( ! token ) return false
135- try {
136- await this . git . push ( {
137- remote : REMOTE_NAME ,
138- ref : branch ,
139- force : true ,
140- username : "x" ,
141- password : token ,
142- } )
143- return true
144- } catch ( e ) {
145- this . logger . warn ( `ArtifactsSync.push failed for branch "${ branch } "` , e )
146- return false
245+
246+ for ( let attempt = 1 ; attempt <= PUSH_MAX_ATTEMPTS ; attempt ++ ) {
247+ const token = await this . getWriteToken ( )
248+ if ( ! token ) return false
249+ try {
250+ await this . git . push ( {
251+ remote : REMOTE_NAME ,
252+ ref : branch ,
253+ force : true ,
254+ username : "x" ,
255+ password : token ,
256+ } )
257+ return true
258+ } catch ( e ) {
259+ const retryable = isRetryablePushError ( e )
260+ if ( retryable && attempt < PUSH_MAX_ATTEMPTS ) {
261+ this . logger . warn (
262+ `ArtifactsSync.push retry ${ attempt } /${ PUSH_MAX_ATTEMPTS } for branch "${ branch } " (stale ref race)` ,
263+ )
264+ await sleep ( PUSH_RETRY_BASE_MS * attempt )
265+ continue
266+ }
267+ this . logger . warn ( `ArtifactsSync.push failed for branch "${ branch } "` , e )
268+ return false
269+ }
147270 }
271+ return false
148272 }
149273
150274 /**
0 commit comments