@springbrand/space 0.2.0-alpha.6 → 0.2.0-alpha.9
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 +1 -1
- package/src/index.ts +1 -0
- package/src/space/durable-object.ts +55 -0
- package/src/space/fs-backend.ts +118 -1
- package/src/space/workspace-port.ts +10 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -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()
|
package/src/space/fs-backend.ts
CHANGED
|
@@ -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(
|
|
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
|
*
|