@springbrand/space 0.2.0-alpha.16 → 0.2.0-alpha.17

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.
@@ -2,7 +2,6 @@ import {
2
2
  createWorkspaceStateBackend,
3
3
  FileSystemStateBackend,
4
4
  InMemoryFs,
5
- Workspace,
6
5
  WorkspaceFileSystem,
7
6
  type FileSystem,
8
7
  } from "@cloudflare/shell"
@@ -12,33 +11,16 @@ import { ArtifactsFileSystem } from "./artifacts-fs"
12
11
  import { ArtifactsSync, type ArtifactsRemoteStore } from "./artifacts-sync"
13
12
  import { CheckpointStore } from "./checkpoint"
14
13
  import { createArtifactsBaseSource } from "./git-objects"
14
+ import { inferMimeType } from "./fileinfo"
15
+ import type { FileContentCacheOptions } from "./file-content-cache"
16
+ import { SpaceFiles } from "./space-files"
15
17
 
16
- export const ARTIFACTS_REMOTE_URL_KEY = "artifacts:remoteUrl"
17
- export const SPACE_FS_BACKEND_KEY = "space:fsBackend"
18
+ const ARTIFACTS_REMOTE_URL_KEY = "artifacts:remoteUrl"
19
+ const SPACE_FS_BACKEND_KEY = "space:fsBackend"
18
20
  const ARTIFACTS_BASE_BRANCH = "main"
19
21
  const ARTIFACTS_INIT_TIMEOUT_MS = 10_000
20
22
  const CHECKPOINT_DEBOUNCE_MS = 2_000
21
23
 
22
- export type SpaceFsBackendMode = "artifacts" | "sql"
23
-
24
- export async function resolveSpaceFsBackendMode(
25
- storage: Pick<DurableObjectStorage, "get" | "put" | "sql">,
26
- env: Pick<Env, "ENABLE_ARTIFACTS" | "ARTIFACTS">,
27
- ): Promise<SpaceFsBackendMode> {
28
- let mode = await storage.get<SpaceFsBackendMode>(SPACE_FS_BACKEND_KEY)
29
- if (mode) return mode
30
-
31
- const priorArtifacts = (await storage.get<string>(ARTIFACTS_REMOTE_URL_KEY)) !== undefined
32
- let hasCheckpoint = false
33
- try {
34
- hasCheckpoint = storage.sql.exec("SELECT 1 FROM space_checkpoint LIMIT 1").toArray().length > 0
35
- } catch {}
36
- const artifactsEnabled = env.ENABLE_ARTIFACTS === "true" && !!env.ARTIFACTS
37
- mode = priorArtifacts || hasCheckpoint || artifactsEnabled ? "artifacts" : "sql"
38
- await storage.put(SPACE_FS_BACKEND_KEY, mode)
39
- return mode
40
- }
41
-
42
24
  export interface SpaceFsBackend {
43
25
  readonly fs: FileSystem
44
26
  readonly overlay: FileSystem
@@ -47,7 +29,13 @@ export interface SpaceFsBackend {
47
29
  ready(): Promise<void>
48
30
  hydrate(path: string): Promise<void>
49
31
  materializeAll(): Promise<void>
50
- writeFileStream(path: string, content: ReadableStream<Uint8Array>, mediaType: string): Promise<void>
32
+ writeFileStream(
33
+ path: string,
34
+ content: ReadableStream<Uint8Array>,
35
+ mediaType: string,
36
+ origin?: import("./space-files").SpaceFileOrigin,
37
+ contentLength?: number,
38
+ ): Promise<void>
51
39
  flushCheckpoint(): Promise<void>
52
40
  push(branch: string): Promise<boolean>
53
41
  fetch(branch: string): Promise<void>
@@ -135,6 +123,10 @@ export class ArtifactsBackend implements SpaceFsBackend {
135
123
  await (this.fs as ArtifactsFileSystem).whenFullyMaterialized()
136
124
  }
137
125
 
126
+ async materializeForMigration(): Promise<void> {
127
+ await (this.fs as ArtifactsFileSystem).materializeForMigration()
128
+ }
129
+
138
130
  async writeFileStream(): Promise<void> {
139
131
  throw new Error("Streaming writes require the SQL Space backend")
140
132
  }
@@ -204,63 +196,12 @@ export function spaceR2Prefix(spaceId: string): string {
204
196
  return `spaces/v1/${spaceId}`
205
197
  }
206
198
 
207
- const R2_MULTIPART_CHUNK_BYTES = 5 * 1024 * 1024
208
-
209
- async function putStream(
210
- bucket: R2Bucket,
211
- key: string,
212
- content: ReadableStream<Uint8Array>,
213
- mediaType: string,
214
- ): Promise<R2Object> {
215
- const upload = await bucket.createMultipartUpload(key, {
216
- httpMetadata: { contentType: mediaType },
217
- })
218
- const uploaded: R2UploadedPart[] = []
219
- const reader = content.getReader()
220
- let buffer = new Uint8Array(R2_MULTIPART_CHUNK_BYTES)
221
- let buffered = 0
222
- let partNumber = 1
223
- const flush = async () => {
224
- if (buffered === 0) return
225
- uploaded.push(await upload.uploadPart(partNumber++, buffer.slice(0, buffered)))
226
- buffer = new Uint8Array(R2_MULTIPART_CHUNK_BYTES)
227
- buffered = 0
228
- }
229
- try {
230
- while (true) {
231
- const next = await reader.read()
232
- if (next.done) break
233
- let offset = 0
234
- while (offset < next.value.byteLength) {
235
- const copied = Math.min(buffer.byteLength - buffered, next.value.byteLength - offset)
236
- buffer.set(next.value.subarray(offset, offset + copied), buffered)
237
- buffered += copied
238
- offset += copied
239
- if (buffered === buffer.byteLength) await flush()
240
- }
241
- }
242
- await flush()
243
- if (uploaded.length === 0) {
244
- await upload.abort()
245
- return (await bucket.put(key, new Uint8Array(), {
246
- httpMetadata: { contentType: mediaType },
247
- }))
248
- }
249
- return upload.complete(uploaded)
250
- } catch (error) {
251
- await upload.abort().catch(() => undefined)
252
- throw error
253
- } finally {
254
- reader.releaseLock()
255
- }
256
- }
257
-
258
199
  export class SqlBackend implements SpaceFsBackend {
259
200
  readonly overlay: FileSystem
260
201
  readonly fs: FileSystem
261
202
  readonly git: Git
262
203
  readonly stateBackend: FileSystemStateBackend
263
- readonly workspace: Workspace
204
+ readonly workspace: SpaceFiles
264
205
 
265
206
  /**
266
207
  * Large files spill to R2 rather than to the DO's own SQLite. A Space holds
@@ -272,12 +213,11 @@ export class SqlBackend implements SpaceFsBackend {
272
213
  private readonly ctx: DurableObjectState,
273
214
  private readonly repoName: string,
274
215
  private readonly r2?: R2Bucket,
216
+ publicOrigin = "https://workspace.invalid",
217
+ fileCacheOptions?: FileContentCacheOptions,
275
218
  ) {
276
- const workspace = new Workspace({
277
- sql: ctx.storage.sql,
278
- name: () => repoName,
279
- ...(r2 ? { r2, r2Prefix: spaceR2Prefix(repoName) } : {}),
280
- })
219
+ if (!r2) throw new Error("Space files require WORKSPACE_R2")
220
+ const workspace = new SpaceFiles(ctx, r2, repoName, publicOrigin, fileCacheOptions)
281
221
  this.workspace = workspace
282
222
  const fs = new WorkspaceFileSystem(workspace)
283
223
  this.overlay = fs
@@ -286,67 +226,58 @@ export class SqlBackend implements SpaceFsBackend {
286
226
  this.stateBackend = createWorkspaceStateBackend(workspace)
287
227
  }
288
228
 
289
- async ready(): Promise<void> {}
229
+ async ready(): Promise<void> { await this.workspace.ready() }
290
230
  async hydrate(_path: string): Promise<void> {}
291
231
  async materializeAll(): Promise<void> {}
292
232
  async writeFileStream(
293
233
  path: string,
294
234
  content: ReadableStream<Uint8Array>,
295
235
  mediaType: string,
236
+ origin: import("./space-files").SpaceFileOrigin = "agent",
237
+ contentLength?: number,
296
238
  ): Promise<void> {
297
- if (!this.r2) throw new Error("Streaming writes require an R2 binding")
298
- await this.workspace.exists("/")
299
- const existing = await this.workspace.stat(path)
300
- if (existing?.type === "directory") throw new Error(`EISDIR: ${path} is a directory`)
301
- const parent = path.slice(0, path.lastIndexOf("/")) || "/"
302
- await this.workspace.mkdir(parent, { recursive: true })
303
-
304
- // @cloudflare/shell@0.4.3 owns this table. Its public writeFileStream buffers;
305
- // this adapter keeps the same row contract while R2 consumes the body stream.
306
- const table = "cf_workspace_default"
307
- const previous = this.ctx.storage.sql.exec<{ r2Key: string | null }>(
308
- `SELECT r2_key AS r2Key FROM ${table} WHERE path = ?`,
309
- path,
310
- ).toArray()[0]
311
- const key = `${spaceR2Prefix(this.repoName)}/streams/${crypto.randomUUID()}`
312
- try {
313
- const object = await putStream(this.r2, key, content, mediaType)
314
- const name = path.split("/").at(-1)!
315
- const now = Math.floor(Date.now() / 1_000)
316
- this.ctx.storage.sql.exec(
317
- `INSERT INTO ${table}
318
- (path, parent_path, name, type, mime_type, size, storage_backend,
319
- r2_key, content_encoding, content, created_at, modified_at)
320
- VALUES (?, ?, ?, 'file', ?, ?, 'r2', ?, 'base64', NULL, ?, ?)
321
- ON CONFLICT(path) DO UPDATE SET
322
- parent_path = excluded.parent_path,
323
- name = excluded.name,
324
- type = 'file',
325
- mime_type = excluded.mime_type,
326
- size = excluded.size,
327
- storage_backend = 'r2',
328
- r2_key = excluded.r2_key,
329
- content_encoding = 'base64',
330
- content = NULL,
331
- modified_at = excluded.modified_at`,
332
- path,
333
- parent,
334
- name,
335
- mediaType,
336
- object.size,
337
- key,
338
- now,
339
- now,
340
- )
341
- } catch (error) {
342
- await this.r2.delete(key).catch(() => undefined)
343
- throw error
344
- }
345
- if (previous?.r2Key && previous.r2Key !== key) {
346
- await this.r2.delete(previous.r2Key).catch(() => undefined)
347
- }
239
+ await this.workspace.writeFileStream(path, content, mediaType, origin, contentLength)
348
240
  }
349
241
  async flushCheckpoint(): Promise<void> {}
350
242
  async push(_branch: string): Promise<boolean> { return false }
351
243
  async fetch(_branch: string): Promise<void> {}
352
244
  }
245
+
246
+ export async function migrateArtifactsFiles(
247
+ source: FileSystem,
248
+ target: SpaceFiles,
249
+ ): Promise<void> {
250
+ const paths = (await source.glob("/**")).sort((left, right) =>
251
+ left.length - right.length || left.localeCompare(right))
252
+ for (const path of paths) {
253
+ if (path === "/") continue
254
+ const stat = await source.lstat(path)
255
+ if (stat.type === "directory") {
256
+ await target.mkdir(path, { recursive: true })
257
+ } else if (stat.type === "symlink") {
258
+ await target.symlink(await source.readlink(path), path)
259
+ } else {
260
+ await target.writeFileBytes(path, await source.readFileBytes(path), inferMimeType(path))
261
+ }
262
+ }
263
+ }
264
+
265
+ export async function migrateArtifactsBackend(
266
+ ctx: DurableObjectState,
267
+ env: Env,
268
+ repoName: string,
269
+ target: SpaceFiles,
270
+ ): Promise<void> {
271
+ if (await ctx.storage.get(SPACE_FS_BACKEND_KEY) === "sql") return
272
+ const hasRemote = await ctx.storage.get(ARTIFACTS_REMOTE_URL_KEY) !== undefined
273
+ let hasCheckpoint = false
274
+ try {
275
+ hasCheckpoint = ctx.storage.sql.exec("SELECT 1 FROM space_checkpoint LIMIT 1").toArray().length > 0
276
+ } catch {}
277
+ if (hasRemote || hasCheckpoint) {
278
+ const artifacts = new ArtifactsBackend(ctx, env, repoName)
279
+ await artifacts.materializeForMigration()
280
+ await migrateArtifactsFiles(artifacts.fs, target)
281
+ }
282
+ await ctx.storage.put(SPACE_FS_BACKEND_KEY, "sql")
283
+ }
@@ -272,27 +272,6 @@ export function createArtifactsBaseSource(opts: {
272
272
  }
273
273
  }
274
274
 
275
- /**
276
- * Drop the git index so the next status walk re-reads file contents.
277
- *
278
- * Git decides a file is unchanged when its size and modification time still
279
- * match the index, and only hashes it otherwise. On a real filesystem that is
280
- * a safe shortcut; here it is not. A Space's files live in memory or SQLite,
281
- * where an agent can rewrite a file to different content of the same length
282
- * within the same millisecond — and git would then see nothing to commit. The
283
- * work would be in the Space but absent from every version of it.
284
- *
285
- * The index is pure cache: deleting it costs one re-hash of the tree and
286
- * cannot lose anything.
287
- */
288
- async function discardIndexStatCache(fs: FileSystem): Promise<void> {
289
- try {
290
- await fs.rm("/.git/index", { force: true })
291
- } catch {
292
- // No index yet, or the backend refused; the status walk still works.
293
- }
294
- }
295
-
296
275
  /**
297
276
  * Stage the whole working tree (like the shell's `git add .`, which also
298
277
  * stages deletions via `statusMatrix`) while skipping reserved paths — the
@@ -302,14 +281,22 @@ async function discardIndexStatCache(fs: FileSystem): Promise<void> {
302
281
  export async function stageWorkdir(
303
282
  fs: FileSystem,
304
283
  skip: (absPath: string) => boolean,
305
- ): Promise<void> {
284
+ prefix = "/",
285
+ ): Promise<boolean> {
306
286
  const gitFs = createGitFs(fs)
307
- await discardIndexStatCache(fs)
308
- const matrix = await git.statusMatrix({ fs: gitFs, dir: "/" })
287
+ const relativePrefix = prefix.replace(/^\/+|\/+$/gu, "")
288
+ const matrix = await git.statusMatrix({
289
+ fs: gitFs,
290
+ dir: "/",
291
+ ...(relativePrefix ? { filepaths: [relativePrefix] } : {}),
292
+ })
293
+ let changed = false
309
294
  for (const [filepath, head, workdir, stage] of matrix) {
310
295
  if (skip(`/${filepath}`)) continue
296
+ if (head !== workdir) changed = true
311
297
  if (`${head}${workdir}${stage}` === "111") continue
312
298
  if (workdir === 0) await git.remove({ fs: gitFs, dir: "/", filepath })
313
299
  else await git.add({ fs: gitFs, dir: "/", filepath })
314
300
  }
301
+ return changed
315
302
  }