@springbrand/space 0.1.0-alpha.1

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.
@@ -0,0 +1,331 @@
1
+ /**
2
+ * Cloudflare Artifacts sync for the SpaceDO.
3
+ *
4
+ * Artifacts is a git-compatible, versioned remote (see
5
+ * https://developers.cloudflare.com/artifacts/). Each app gets its own repo,
6
+ * and the SpaceDO mirrors every commit/deploy there via `git push` so Artifacts
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.
10
+ *
11
+ * All operations here are best-effort: Artifacts is a beta product and may be
12
+ * absent in local dev (no binding). Failures are logged and reported via return
13
+ * values; they must never break a commit or deploy.
14
+ */
15
+ import type { Git } from "@cloudflare/shell/git"
16
+
17
+ const REMOTE_NAME = "artifacts"
18
+ /** Refresh the write token this many ms before it actually expires. */
19
+ const TOKEN_REFRESH_SKEW_MS = 60_000
20
+ /** Requested token lifetime (seconds). Artifacts allows 60s..1y. */
21
+ const 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 /stale|not-fast-forward|rejected|fetch first|failed to update ref/i.test(text)
46
+ }
47
+
48
+ /**
49
+ * Artifacts repo names allow alphanumerics, dots, hyphens and underscores.
50
+ * SpaceDO instance names are already conservative, but sanitize defensively so
51
+ * an unexpected name never fails `create()` with INVALID_REPO_NAME.
52
+ */
53
+ function sanitizeRepoName(name: string): string {
54
+ const cleaned = name.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^-+/, "")
55
+ return cleaned.length > 0 ? cleaned : "space"
56
+ }
57
+
58
+ export interface ArtifactsSyncLogger {
59
+ warn(message: string, ...args: unknown[]): void
60
+ info?(message: string, ...args: unknown[]): void
61
+ }
62
+
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
+
90
+ export class ArtifactsSync {
91
+ private readonly repoName: string
92
+ private remoteUrl: string | null = null
93
+ private remoteRegistered = false
94
+ private token: string | null = null
95
+ 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)
103
+
104
+ constructor(
105
+ private readonly artifacts: Artifacts,
106
+ private readonly git: Git,
107
+ repoName: string,
108
+ private readonly store?: ArtifactsRemoteStore,
109
+ private readonly logger: ArtifactsSyncLogger = console,
110
+ ) {
111
+ this.repoName = sanitizeRepoName(repoName)
112
+ }
113
+
114
+ /**
115
+ * Ensure the app's Artifacts repo exists and the local git repo has an
116
+ * `artifacts` remote pointing at it. Idempotent. Returns false (and logs) if
117
+ * 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.
122
+ */
123
+ private async ensureRepo(): Promise<boolean> {
124
+ if (this.remoteRegistered && this.remoteUrl) return true
125
+
126
+ try {
127
+ let remote = this.remoteUrl ?? (await this.store?.read()) ?? null
128
+ if (!remote) remote = await this.provisionRemote()
129
+ if (!remote) return false
130
+
131
+ this.remoteUrl = remote
132
+ await this.store?.write(remote)
133
+ await this.registerRemote(remote)
134
+ this.remoteRegistered = true
135
+ return true
136
+ } catch (e) {
137
+ this.logger.warn("ArtifactsSync.ensureRepo failed", e)
138
+ return false
139
+ }
140
+ }
141
+
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
+
185
+ private async registerRemote(url: string): Promise<void> {
186
+ try {
187
+ await this.git.remote({ add: { name: REMOTE_NAME, url } })
188
+ } catch {
189
+ // Remote already exists — ensure the URL is current by removing and
190
+ // re-adding (Artifacts remotes are stable, but be defensive).
191
+ try {
192
+ await this.git.remote({ remove: REMOTE_NAME })
193
+ await this.git.remote({ add: { name: REMOTE_NAME, url } })
194
+ } catch (e) {
195
+ this.logger.warn("ArtifactsSync.registerRemote failed", e)
196
+ }
197
+ }
198
+ }
199
+
200
+ /** Return a valid write token, minting/refreshing as needed. */
201
+ private async getWriteToken(): Promise<string | null> {
202
+ if (this.token && Date.now() < this.tokenExpiresAt - TOKEN_REFRESH_SKEW_MS) {
203
+ return this.token
204
+ }
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
+ }
210
+ try {
211
+ // createToken() is a method call whose result is a by-value plain object,
212
+ // so its `plaintext`/`expiresAt` fields are directly readable.
213
+ const result = await repo.createToken("write", TOKEN_TTL_SECONDS)
214
+ this.token = result.plaintext
215
+ this.tokenExpiresAt = Date.parse(result.expiresAt) || 0
216
+ return this.token
217
+ } catch (e) {
218
+ this.logger.warn("ArtifactsSync.getWriteToken failed", e)
219
+ return null
220
+ } finally {
221
+ disposeQuietly(repo)
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Mirror `branch` to Artifacts. Best-effort: returns true on success, false
227
+ * (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.
232
+ */
233
+ 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
+ /**
244
+ * Guard against destroying remote history. Pushes use `force` (to converge
245
+ * under stale-ref races), which makes them capable of silently REPLACING the
246
+ * remote branch. That is only safe when the local branch contains the remote
247
+ * head. After a cold start the local branch is reconciled onto the fetched
248
+ * head (see `reconcileLocalBranch` in git-objects.ts), so a remote head that
249
+ * is NOT an ancestor of the local head means the histories genuinely
250
+ * diverged (e.g. commits made while the base could not be loaded) — refuse
251
+ * the push instead of deleting earlier commits. When the remote head is
252
+ * unknown (first push, or the base was never fetched) there is nothing
253
+ * verifiable to destroy, so the push proceeds (best-effort sync).
254
+ */
255
+ private async remoteHeadContainedIn(branch: string): Promise<boolean> {
256
+ let remoteHead: string | null = null
257
+ try {
258
+ const tracking = await this.git.log({ ref: `refs/remotes/${REMOTE_NAME}/${branch}`, depth: 1 })
259
+ remoteHead = tracking[0]?.oid ?? null
260
+ } catch {
261
+ return true // No tracking ref — nothing known to protect.
262
+ }
263
+ if (!remoteHead) return true
264
+ try {
265
+ const history = await this.git.log({ ref: branch, depth: 10_000 })
266
+ return history.some((entry) => entry.oid === remoteHead)
267
+ } catch {
268
+ // Local branch unreadable — let the push attempt surface the real error.
269
+ return true
270
+ }
271
+ }
272
+
273
+ private async pushWithRetry(branch: string): Promise<boolean> {
274
+ if (!(await this.ensureRepo())) return false
275
+ if (!(await this.remoteHeadContainedIn(branch))) {
276
+ this.logger.warn(
277
+ `ArtifactsSync.push refused for branch "${branch}": local history does not contain the remote head; not force-overwriting remote history`,
278
+ )
279
+ return false
280
+ }
281
+
282
+ for (let attempt = 1; attempt <= PUSH_MAX_ATTEMPTS; attempt++) {
283
+ const token = await this.getWriteToken()
284
+ if (!token) return false
285
+ try {
286
+ await this.git.push({
287
+ remote: REMOTE_NAME,
288
+ ref: branch,
289
+ force: true,
290
+ username: "x",
291
+ password: token,
292
+ })
293
+ return true
294
+ } catch (e) {
295
+ const retryable = isRetryablePushError(e)
296
+ if (retryable && attempt < PUSH_MAX_ATTEMPTS) {
297
+ this.logger.warn(
298
+ `ArtifactsSync.push retry ${attempt}/${PUSH_MAX_ATTEMPTS} for branch "${branch}" (stale ref race)`,
299
+ )
300
+ await sleep(PUSH_RETRY_BASE_MS * attempt)
301
+ continue
302
+ }
303
+ this.logger.warn(`ArtifactsSync.push failed for branch "${branch}"`, e)
304
+ return false
305
+ }
306
+ }
307
+ return false
308
+ }
309
+
310
+ /**
311
+ * Fetch `branch` from Artifacts to reconcile the local mirror before a
312
+ * restore. Best-effort: returns true on success, false otherwise.
313
+ */
314
+ async fetch(branch: string): Promise<boolean> {
315
+ if (!(await this.ensureRepo())) return false
316
+ const token = await this.getWriteToken()
317
+ if (!token) return false
318
+ try {
319
+ await this.git.fetch({
320
+ remote: REMOTE_NAME,
321
+ ref: branch,
322
+ username: "x",
323
+ password: token,
324
+ })
325
+ return true
326
+ } catch (e) {
327
+ this.logger.warn(`ArtifactsSync.fetch failed for branch "${branch}"`, e)
328
+ return false
329
+ }
330
+ }
331
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Durable checkpoint of the workspace overlay in the SpaceDO's own SQLite
3
+ * storage.
4
+ *
5
+ * Why this exists: the workspace (overlay workdir + `.git` objects) is
6
+ * in-memory, and Cloudflare resets DO instances on every code update, eviction,
7
+ * or runtime update. Artifacts only holds PUSHED commits — so until the first
8
+ * `deploy_space` (and between deploys), a reset wipes everything the agent has
9
+ * written. The checkpoint closes that gap: every write is mirrored into DO
10
+ * storage (debounced), and a cold-started DO replays it on top of the
11
+ * Artifacts base — the overlay shadows the base, so the newer checkpointed
12
+ * content wins automatically.
13
+ *
14
+ * Rows are a delta against the last successful push: after a push succeeds the
15
+ * table is cleared (the base now covers those files) and only subsequent
16
+ * writes are checkpointed. Tombstones record deletions relative to the base.
17
+ *
18
+ * Everything here is best-effort: checkpoint failures must never break a file
19
+ * operation.
20
+ */
21
+
22
+ /** The checkpoint contents, applied over the Artifacts base after a cold start. */
23
+ export interface CheckpointSnapshot {
24
+ /** Absolute path -> current bytes (shadows the base). */
25
+ files: Array<[path: string, bytes: Uint8Array]>
26
+ /** Absolute paths deleted relative to the base (applied as whiteouts). */
27
+ tombstones: string[]
28
+ }
29
+
30
+ export interface CheckpointSource {
31
+ load(): CheckpointSnapshot
32
+ }
33
+
34
+ const TABLE = "space_checkpoint"
35
+
36
+ /** Minimal shape of `ctx.storage.sql` this store relies on. */
37
+ export interface SqlStorageLike {
38
+ exec(
39
+ query: string,
40
+ ...bindings: unknown[]
41
+ ): { toArray(): Array<Record<string, unknown>> }
42
+ }
43
+
44
+ export class CheckpointStore implements CheckpointSource {
45
+ private readonly sql: SqlStorageLike
46
+
47
+ constructor(storage: { sql: SqlStorageLike }) {
48
+ this.sql = storage.sql
49
+ this.sql.exec(
50
+ `CREATE TABLE IF NOT EXISTS ${TABLE} (` +
51
+ `path TEXT PRIMARY KEY, data BLOB, tombstone INTEGER NOT NULL DEFAULT 0)`,
52
+ )
53
+ }
54
+
55
+ /**
56
+ * Record a path's current bytes — or, with `null`, its deletion. Deleting a
57
+ * path also drops any checkpointed rows BENEATH it (a recursive `rm`), so a
58
+ * later restore cannot resurrect them; base files under the path are hidden
59
+ * by the whiteout's ancestor coverage.
60
+ */
61
+ save(path: string, bytes: Uint8Array | null): void {
62
+ if (bytes === null) {
63
+ this.sql.exec(
64
+ `DELETE FROM ${TABLE} WHERE path = ? OR substr(path, 1, ?) = ?`,
65
+ path,
66
+ path.length + 1,
67
+ `${path}/`,
68
+ )
69
+ this.sql.exec(
70
+ `INSERT OR REPLACE INTO ${TABLE} (path, data, tombstone) VALUES (?, NULL, 1)`,
71
+ path,
72
+ )
73
+ return
74
+ }
75
+ // `.slice().buffer`: a fresh, exactly-sized ArrayBuffer for the blob binding.
76
+ this.sql.exec(
77
+ `INSERT OR REPLACE INTO ${TABLE} (path, data, tombstone) VALUES (?, ?, 0)`,
78
+ path,
79
+ bytes.slice().buffer,
80
+ )
81
+ }
82
+
83
+ /**
84
+ * Drop every row. Call after a successful Artifacts push — the pushed base
85
+ * now covers all checkpointed content, so the checkpoint restarts empty.
86
+ */
87
+ clear(): void {
88
+ this.sql.exec(`DELETE FROM ${TABLE}`)
89
+ }
90
+
91
+ load(): CheckpointSnapshot {
92
+ const files: Array<[string, Uint8Array]> = []
93
+ const tombstones: string[] = []
94
+ for (const row of this.sql.exec(`SELECT path, data, tombstone FROM ${TABLE}`).toArray()) {
95
+ const path = row.path as string
96
+ if (row.tombstone) tombstones.push(path)
97
+ else files.push([path, new Uint8Array(row.data as ArrayBuffer)])
98
+ }
99
+ return { files, tombstones }
100
+ }
101
+ }