@springbrand/space 0.2.0-alpha.1 → 0.2.0-alpha.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@springbrand/space",
3
- "version": "0.2.0-alpha.1",
3
+ "version": "0.2.0-alpha.10",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ export type {
9
9
  AppTableQueryOpts,
10
10
  SpaceCommit,
11
11
  SpaceConditionalWriteResult,
12
+ SpaceStreamWriteOptions,
12
13
  SpaceAppPort,
13
14
  SpaceControlPort,
14
15
  SpaceFileInfo,
@@ -30,6 +30,7 @@ import {
30
30
  type SpaceCommit,
31
31
  type SpaceCommitManifest,
32
32
  type SpaceConditionalWriteResult,
33
+ type SpaceStreamWriteOptions,
33
34
  type SpaceAppPort,
34
35
  type SpaceControlPort,
35
36
  type SpaceFileInfo,
@@ -48,6 +49,7 @@ export type {
48
49
  SpaceCommit,
49
50
  SpaceCommitManifest,
50
51
  SpaceConditionalWriteResult,
52
+ SpaceStreamWriteOptions,
51
53
  SpaceControlPort,
52
54
  SpaceFileInfo,
53
55
  SpaceFileVersion,
@@ -257,6 +259,21 @@ export class SpaceDO extends DurableObject<Env>
257
259
  }
258
260
  }
259
261
 
262
+ private async streamedFileLimit(path: string): Promise<number> {
263
+ const quota = defaultSpaceQuota()
264
+ const existing = await this.statInfo(path)
265
+ const usage = await this.usageOf()
266
+ if (quota.maxFiles !== null && !existing && usage.fileCount >= quota.maxFiles) {
267
+ throw new Error("Space file count quota exceeded")
268
+ }
269
+ return Math.max(0, Math.min(
270
+ quota.maxFileBytes ?? Number.MAX_SAFE_INTEGER,
271
+ quota.maxTotalBytes === null
272
+ ? Number.MAX_SAFE_INTEGER
273
+ : quota.maxTotalBytes - usage.totalBytes + (existing?.size ?? 0),
274
+ ))
275
+ }
276
+
260
277
  private async usageOf(): Promise<SpaceUsage> {
261
278
  let fileCount = 0
262
279
  let directoryCount = 0
@@ -663,6 +680,44 @@ export class SpaceDO extends DurableObject<Env>
663
680
  await this.fs.writeFileBytes(target, bytes)
664
681
  }
665
682
 
683
+ async writeFileStream(
684
+ path: string,
685
+ content: ReadableStream<Uint8Array>,
686
+ options?: SpaceStreamWriteOptions,
687
+ ): Promise<{ path: string; bytes: number }> {
688
+ return this.lane(async () => {
689
+ await this.ensureInit()
690
+ const target = normalizeWritableSpacePath(path)
691
+ const expected = options?.contentLength
692
+ if (expected !== undefined && (!Number.isSafeInteger(expected) || expected < 0)) {
693
+ throw new Error("Stream contentLength must be a non-negative safe integer")
694
+ }
695
+ const limit = await this.streamedFileLimit(target)
696
+ if (expected !== undefined && expected > limit) {
697
+ throw new Error(`Space file exceeds the ${limit}-byte limit: ${target}`)
698
+ }
699
+ let bytes = 0
700
+ const counted = content.pipeThrough(new TransformStream<Uint8Array, Uint8Array>({
701
+ transform(chunk, controller) {
702
+ bytes += chunk.byteLength
703
+ if (bytes > limit) throw new Error(`Space file exceeds the ${limit}-byte limit: ${target}`)
704
+ controller.enqueue(chunk)
705
+ },
706
+ flush() {
707
+ if (expected !== undefined && bytes !== expected) {
708
+ throw new Error("Stream did not match declared contentLength")
709
+ }
710
+ },
711
+ }))
712
+ await this.backend.writeFileStream(
713
+ target,
714
+ counted,
715
+ options?.mediaType ?? "application/octet-stream",
716
+ )
717
+ return { path: target, bytes }
718
+ })
719
+ }
720
+
666
721
  async appendFile(path: string, content: string, mimeType?: string): Promise<void> {
667
722
  void mimeType
668
723
  await this.ensureInit()
@@ -47,6 +47,7 @@ export interface SpaceFsBackend {
47
47
  ready(): Promise<void>
48
48
  hydrate(path: string): Promise<void>
49
49
  materializeAll(): Promise<void>
50
+ writeFileStream(path: string, content: ReadableStream<Uint8Array>, mediaType: string): Promise<void>
50
51
  flushCheckpoint(): Promise<void>
51
52
  push(branch: string): Promise<boolean>
52
53
  fetch(branch: string): Promise<void>
@@ -134,6 +135,10 @@ export class ArtifactsBackend implements SpaceFsBackend {
134
135
  await (this.fs as ArtifactsFileSystem).whenFullyMaterialized()
135
136
  }
136
137
 
138
+ async writeFileStream(): Promise<void> {
139
+ throw new Error("Streaming writes require the SQL Space backend")
140
+ }
141
+
137
142
  async flushCheckpoint(): Promise<void> {
138
143
  if (this.checkpointDirty.size === 0) return
139
144
  const paths = [...this.checkpointDirty]
@@ -199,6 +204,57 @@ export function spaceR2Prefix(spaceId: string): string {
199
204
  return `spaces/v1/${spaceId}`
200
205
  }
201
206
 
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
+
202
258
  export class SqlBackend implements SpaceFsBackend {
203
259
  readonly overlay: FileSystem
204
260
  readonly fs: FileSystem
@@ -212,7 +268,11 @@ export class SqlBackend implements SpaceFsBackend {
212
268
  * row will hold — and a Space that fills its DO storage stops accepting
213
269
  * writes for every other file too.
214
270
  */
215
- constructor(ctx: DurableObjectState, repoName: string, r2?: R2Bucket) {
271
+ constructor(
272
+ private readonly ctx: DurableObjectState,
273
+ private readonly repoName: string,
274
+ private readonly r2?: R2Bucket,
275
+ ) {
216
276
  const workspace = new Workspace({
217
277
  sql: ctx.storage.sql,
218
278
  name: () => repoName,
@@ -229,6 +289,63 @@ export class SqlBackend implements SpaceFsBackend {
229
289
  async ready(): Promise<void> {}
230
290
  async hydrate(_path: string): Promise<void> {}
231
291
  async materializeAll(): Promise<void> {}
292
+ async writeFileStream(
293
+ path: string,
294
+ content: ReadableStream<Uint8Array>,
295
+ mediaType: string,
296
+ ): 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
+ }
348
+ }
232
349
  async flushCheckpoint(): Promise<void> {}
233
350
  async push(_branch: string): Promise<boolean> { return false }
234
351
  async fetch(_branch: string): Promise<void> {}
@@ -182,6 +182,11 @@ export interface SpaceWorkspacePort {
182
182
  data: Uint8Array | ArrayBuffer,
183
183
  mimeType?: string,
184
184
  ): Promise<void>;
185
+ writeFileStream(
186
+ path: string,
187
+ content: ReadableStream<Uint8Array>,
188
+ options?: SpaceStreamWriteOptions,
189
+ ): Promise<{ path: string; bytes: number }>;
185
190
  appendFile(path: string, content: string, mimeType?: string): Promise<void>;
186
191
  exists(path: string): Promise<boolean>;
187
192
  stat(path: string): Promise<SpaceFileInfo | null>;
@@ -199,6 +204,11 @@ export interface SpaceWorkspacePort {
199
204
  glob(pattern: string): Promise<SpaceFileInfo[]>;
200
205
  }
201
206
 
207
+ export interface SpaceStreamWriteOptions {
208
+ contentLength?: number;
209
+ mediaType?: string;
210
+ }
211
+
202
212
  /**
203
213
  * Everything a Space does that is not a file operation.
204
214
  *