@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.
- package/package.json +2 -1
- package/src/env.ts +1 -0
- package/src/index.ts +12 -0
- package/src/space/artifacts-fs.ts +7 -0
- package/src/space/deploy-engine.ts +50 -17
- package/src/space/durable-object.ts +93 -22
- package/src/space/file-content-cache.ts +194 -0
- package/src/space/fs-backend.ts +64 -133
- package/src/space/git-objects.ts +11 -24
- package/src/space/space-files.ts +1055 -0
- package/src/space/workspace-port.ts +17 -0
- package/src/space/workspace-site.ts +116 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@springbrand/space",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.17",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"src",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@cloudflare/vitest-pool-workers": "^0.20.1",
|
|
22
22
|
"@cloudflare/workers-types": "^4.20251008.0",
|
|
23
|
+
"@types/node": "26.1.2",
|
|
23
24
|
"typescript": "^7.0.2",
|
|
24
25
|
"vitest": "^4.1.10"
|
|
25
26
|
},
|
package/src/env.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
// ── Durable Object classes ────────────────────────────────────────
|
|
2
2
|
export { SpaceDO } from "./space/durable-object";
|
|
3
|
+
export type {
|
|
4
|
+
RegisterExistingContentsInput,
|
|
5
|
+
SpaceFileEvidence,
|
|
6
|
+
SpaceFileInspection,
|
|
7
|
+
SpaceFileOrigin,
|
|
8
|
+
SpaceUnderstandingStatus,
|
|
9
|
+
} from "./space/space-files";
|
|
10
|
+
export {
|
|
11
|
+
normalizeSpaceFileEvidence,
|
|
12
|
+
SPACE_FILE_EVIDENCE_JSON_SCHEMA,
|
|
13
|
+
} from "./space/space-files";
|
|
3
14
|
|
|
4
15
|
// ── RPC surface ───────────────────────────────────────────────────
|
|
5
16
|
export type {
|
|
@@ -33,6 +44,7 @@ export type {
|
|
|
33
44
|
|
|
34
45
|
// ── Preview hardening ─────────────────────────────────────────────
|
|
35
46
|
export { stripPreviewSecurityHeaders, STRIPPED_PREVIEW_HEADERS } from "./space/preview-headers";
|
|
47
|
+
export { handleWorkspaceSiteRequest } from "./space/workspace-site";
|
|
36
48
|
|
|
37
49
|
// ── Environment bindings type ─────────────────────────────────────
|
|
38
50
|
export type { Env, SpaceEnv } from "./env";
|
|
@@ -138,6 +138,13 @@ export class ArtifactsFileSystem implements FileSystem {
|
|
|
138
138
|
return this.materializePromise
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
async materializeForMigration(): Promise<void> {
|
|
142
|
+
await this.ready()
|
|
143
|
+
if (!this.readyPromise) await this.ready()
|
|
144
|
+
if (!this.readyPromise) throw new Error("Artifacts base is unavailable for migration")
|
|
145
|
+
await this.whenFullyMaterialized()
|
|
146
|
+
}
|
|
147
|
+
|
|
141
148
|
/**
|
|
142
149
|
* Ensure a single path's content is present in the overlay (hydrating it from
|
|
143
150
|
* the base on demand). Lets callers that read through the underlying Workspace
|
|
@@ -30,6 +30,13 @@ export interface DeployContext {
|
|
|
30
30
|
fs: FileSystem
|
|
31
31
|
assetBucket?: R2Bucket
|
|
32
32
|
assetPrefix?: string
|
|
33
|
+
defer?: (promise: Promise<unknown>) => void
|
|
34
|
+
openFileStream?: (path: string) => Promise<{
|
|
35
|
+
body: ReadableStream<Uint8Array>
|
|
36
|
+
size: number
|
|
37
|
+
mediaType: string
|
|
38
|
+
contentVersion: string
|
|
39
|
+
} | null>
|
|
33
40
|
}
|
|
34
41
|
|
|
35
42
|
export interface DeploymentBundle {
|
|
@@ -73,7 +80,7 @@ export interface DeploymentAsset {
|
|
|
73
80
|
|
|
74
81
|
type AssetWriter = (
|
|
75
82
|
asset: Omit<DeploymentAsset, "etag">,
|
|
76
|
-
|
|
83
|
+
body: ReadableStream<Uint8Array>,
|
|
77
84
|
) => Promise<string>
|
|
78
85
|
|
|
79
86
|
const DEFAULT_COMPATIBILITY_DATE = "2025-04-01"
|
|
@@ -152,7 +159,11 @@ async function readBranchFiles(
|
|
|
152
159
|
throw new Error(`Revision "${revision}" was not found on branch "${branch}"`)
|
|
153
160
|
}
|
|
154
161
|
|
|
155
|
-
await ctx.git.
|
|
162
|
+
const branchState = await ctx.git.branch({ list: true })
|
|
163
|
+
const currentBranch = "current" in branchState ? branchState.current : null
|
|
164
|
+
if (revision || currentBranch !== branch) {
|
|
165
|
+
await ctx.git.checkout({ ref: revision ? commitHash : branch, force: true })
|
|
166
|
+
}
|
|
156
167
|
|
|
157
168
|
try {
|
|
158
169
|
const entries: Array<{ sourcePath: string; path: string; size: number }> = []
|
|
@@ -185,6 +196,7 @@ async function readBranchFiles(
|
|
|
185
196
|
|
|
186
197
|
const files: Record<string, string> = { ...configFiles }
|
|
187
198
|
const assets: DeploymentAsset[] = []
|
|
199
|
+
const assetWrites: Array<Promise<DeploymentAsset>> = []
|
|
188
200
|
for (const entry of entries) {
|
|
189
201
|
if (isAssetPath(entry.path, assetsDirectory)) {
|
|
190
202
|
if (entry.size > MAX_STATIC_ASSET_BYTES) {
|
|
@@ -192,17 +204,29 @@ async function readBranchFiles(
|
|
|
192
204
|
`Static asset "${entry.path}" is ${entry.size} bytes; the limit is ${MAX_STATIC_ASSET_BYTES} bytes`,
|
|
193
205
|
)
|
|
194
206
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
207
|
+
assetWrites.push((async () => {
|
|
208
|
+
const asset: Omit<DeploymentAsset, "etag"> = {
|
|
209
|
+
path: toAssetPath(entry.path, assetsDirectory!),
|
|
210
|
+
sourcePath: entry.sourcePath,
|
|
211
|
+
contentType: inferContentType(entry.path),
|
|
212
|
+
size: entry.size,
|
|
213
|
+
}
|
|
214
|
+
const bytes = writeAsset ? null : await ctx.fs.readFileBytes(entry.sourcePath)
|
|
215
|
+
let stream: ReadableStream<Uint8Array> | null = null
|
|
216
|
+
if (writeAsset) {
|
|
217
|
+
if (ctx.openFileStream) {
|
|
218
|
+
const opened = await ctx.openFileStream(entry.sourcePath)
|
|
219
|
+
if (!opened) throw new Error(`Workspace file is missing: ${entry.sourcePath}`)
|
|
220
|
+
stream = opened.body
|
|
221
|
+
} else {
|
|
222
|
+
stream = new Blob([await ctx.fs.readFileBytes(entry.sourcePath)]).stream()
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const etag = writeAsset
|
|
226
|
+
? await writeAsset(asset, stream!)
|
|
227
|
+
: await computeAssetEtag(bytes!)
|
|
228
|
+
return { ...asset, etag }
|
|
229
|
+
})())
|
|
206
230
|
continue
|
|
207
231
|
}
|
|
208
232
|
|
|
@@ -212,6 +236,13 @@ async function readBranchFiles(
|
|
|
212
236
|
files[entry.path] = await ctx.fs.readFile(entry.sourcePath)
|
|
213
237
|
}
|
|
214
238
|
}
|
|
239
|
+
const assetResults = await Promise.allSettled(assetWrites)
|
|
240
|
+
const failedAsset = assetResults.find(
|
|
241
|
+
(result): result is PromiseRejectedResult => result.status === "rejected",
|
|
242
|
+
)
|
|
243
|
+
if (failedAsset) throw failedAsset.reason
|
|
244
|
+
assets.push(...assetResults.map((result) =>
|
|
245
|
+
(result as PromiseFulfilledResult<DeploymentAsset>).value))
|
|
215
246
|
|
|
216
247
|
if (!hasWrangler(configFiles) && assetsDirectory !== null) {
|
|
217
248
|
files["wrangler.json"] = JSON.stringify({
|
|
@@ -260,12 +291,12 @@ async function deployBranch(
|
|
|
260
291
|
branch,
|
|
261
292
|
body.appRoot,
|
|
262
293
|
body.revision,
|
|
263
|
-
async (asset,
|
|
294
|
+
async (asset, body) => {
|
|
264
295
|
if (!ctx.assetBucket || !assetRoot) {
|
|
265
296
|
throw new Error("Static preview deployment requires the WORKSPACE_R2 binding")
|
|
266
297
|
}
|
|
267
298
|
const key = `${assetRoot}${asset.path}`
|
|
268
|
-
const uploaded = await ctx.assetBucket.put(key,
|
|
299
|
+
const uploaded = await ctx.assetBucket.put(key, body, {
|
|
269
300
|
...(asset.contentType ? { httpMetadata: { contentType: asset.contentType } } : {}),
|
|
270
301
|
})
|
|
271
302
|
ctx.sql.exec(
|
|
@@ -341,13 +372,15 @@ async function deployBranch(
|
|
|
341
372
|
})
|
|
342
373
|
}
|
|
343
374
|
if (oldAssetDeploymentId && oldAssetDeploymentId !== assetDeploymentId) {
|
|
344
|
-
|
|
375
|
+
const cleanup = discardAssetDeployment(
|
|
345
376
|
ctx,
|
|
346
377
|
oldAssetDeploymentId,
|
|
347
378
|
ctx.assetPrefix ? `${ctx.assetPrefix}/deployments/${oldAssetDeploymentId}` : undefined,
|
|
348
379
|
).catch((error) => {
|
|
349
380
|
console.warn("Failed to clean replaced static preview deployment", error)
|
|
350
381
|
})
|
|
382
|
+
if (ctx.defer) ctx.defer(cleanup)
|
|
383
|
+
else await cleanup
|
|
351
384
|
}
|
|
352
385
|
|
|
353
386
|
return jsonResponse({
|
|
@@ -389,7 +422,7 @@ export async function buildBranchDeployment(
|
|
|
389
422
|
size: bytes.byteLength,
|
|
390
423
|
}
|
|
391
424
|
const etag = writeAsset
|
|
392
|
-
? await writeAsset(asset, bytes)
|
|
425
|
+
? await writeAsset(asset, new Blob([bytes]).stream())
|
|
393
426
|
: await computeAssetEtag(bytes)
|
|
394
427
|
assets.push({ ...asset, etag })
|
|
395
428
|
}
|
|
@@ -15,7 +15,12 @@ import {
|
|
|
15
15
|
buildInspectorWrapperSource,
|
|
16
16
|
VIBE_APP_MODULE,
|
|
17
17
|
} from "./inspector-wrapper"
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
migrateArtifactsBackend,
|
|
20
|
+
SqlBackend,
|
|
21
|
+
spaceR2Prefix,
|
|
22
|
+
type SpaceFsBackend,
|
|
23
|
+
} from "./fs-backend"
|
|
19
24
|
import { createGitFs, stageWorkdir } from "./git-objects"
|
|
20
25
|
import * as git from "isomorphic-git"
|
|
21
26
|
import {
|
|
@@ -102,6 +107,10 @@ interface DeploymentRow {
|
|
|
102
107
|
// file listing: git's object store and the ArtifactsFileSystem bookkeeping dir.
|
|
103
108
|
const isReservedPath = isReservedSpacePath
|
|
104
109
|
|
|
110
|
+
function isMissingFileError(error: unknown): boolean {
|
|
111
|
+
return error instanceof Error && /ENOENT/u.test(error.message)
|
|
112
|
+
}
|
|
113
|
+
|
|
105
114
|
// ─── SpaceDO ────────────────────────────────────────────────────────────────
|
|
106
115
|
// Agent space Durable Object backed by @cloudflare/shell.
|
|
107
116
|
//
|
|
@@ -195,11 +204,15 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
195
204
|
}
|
|
196
205
|
|
|
197
206
|
private async initializeBackend(): Promise<void> {
|
|
198
|
-
const mode = await resolveSpaceFsBackendMode(this.ctx.storage, this.env)
|
|
199
207
|
const repoName = this.spaceName
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
208
|
+
const sql = new SqlBackend(
|
|
209
|
+
this.ctx,
|
|
210
|
+
repoName,
|
|
211
|
+
this.env.WORKSPACE_R2,
|
|
212
|
+
this.env.WORKSPACE_R2_PUBLIC_ORIGIN,
|
|
213
|
+
)
|
|
214
|
+
await migrateArtifactsBackend(this.ctx, this.env, repoName, sql.workspace)
|
|
215
|
+
this.backend = sql
|
|
203
216
|
}
|
|
204
217
|
|
|
205
218
|
private get spaceName(): string {
|
|
@@ -214,6 +227,7 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
214
227
|
}
|
|
215
228
|
|
|
216
229
|
private async statInfo(path: string): Promise<SpaceFileInfo | null> {
|
|
230
|
+
if (this.backend instanceof SqlBackend) return this.backend.workspace.stat(path)
|
|
217
231
|
try {
|
|
218
232
|
return toFileInfo(path, await this.overlay.stat(path))
|
|
219
233
|
} catch {
|
|
@@ -394,16 +408,12 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
394
408
|
if (!message) throw new Error("A Space commit needs a reason")
|
|
395
409
|
await this.materializeAll()
|
|
396
410
|
await this.flushCheckpoint()
|
|
397
|
-
await stageWorkdir(this.fs, isUnversionedSpacePath)
|
|
411
|
+
const staged = await stageWorkdir(this.fs, isUnversionedSpacePath, prefix)
|
|
398
412
|
|
|
399
413
|
const gitFs = this.gitFs()
|
|
400
414
|
const head = await this.headOid()
|
|
401
415
|
// Nothing moved between HEAD and the index. An empty commit would appear in
|
|
402
416
|
// the person's version list as a change they never made.
|
|
403
|
-
const matrix = await git.statusMatrix({ fs: gitFs, dir: "/" })
|
|
404
|
-
const staged = matrix.some(([filepath, headStatus, , stageStatus]) =>
|
|
405
|
-
!isUnversionedSpacePath(`/${filepath}`) && headStatus !== stageStatus
|
|
406
|
-
)
|
|
407
417
|
if (!staged) {
|
|
408
418
|
if (!head) return { revision: null, treeHash: null, changed: false }
|
|
409
419
|
return {
|
|
@@ -589,6 +599,11 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
589
599
|
// Already gone.
|
|
590
600
|
}
|
|
591
601
|
}
|
|
602
|
+
try {
|
|
603
|
+
if (this.backend instanceof SqlBackend) await this.backend.workspace.clearCache()
|
|
604
|
+
} catch (error) {
|
|
605
|
+
console.warn("Workspace file cache cleanup failed during Space destruction", error)
|
|
606
|
+
}
|
|
592
607
|
await this.deleteSpilledObjects()
|
|
593
608
|
await this.ctx.storage.deleteAll()
|
|
594
609
|
this.initializationPromise = null
|
|
@@ -609,14 +624,18 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
609
624
|
private async deleteSpilledObjects(): Promise<void> {
|
|
610
625
|
const bucket = this.env.WORKSPACE_R2
|
|
611
626
|
if (!bucket) return
|
|
612
|
-
const prefix
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
627
|
+
for (const prefix of [
|
|
628
|
+
`${spaceR2Prefix(this.spaceName)}/`,
|
|
629
|
+
`spaces/v2/${encodeURIComponent(this.spaceName)}/`,
|
|
630
|
+
]) {
|
|
631
|
+
let cursor: string | undefined
|
|
632
|
+
do {
|
|
633
|
+
const page = await bucket.list({ prefix, limit: 1000, ...(cursor ? { cursor } : {}) })
|
|
634
|
+
const keys = page.objects.map((object) => object.key)
|
|
635
|
+
if (keys.length > 0) await bucket.delete(keys)
|
|
636
|
+
cursor = page.truncated ? page.cursor : undefined
|
|
637
|
+
} while (cursor)
|
|
638
|
+
}
|
|
620
639
|
}
|
|
621
640
|
|
|
622
641
|
// ── Filesystem RPC methods ──────────────────────────────────────
|
|
@@ -638,8 +657,9 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
638
657
|
let content: string
|
|
639
658
|
try {
|
|
640
659
|
content = await this.fs.readFile(target)
|
|
641
|
-
} catch {
|
|
642
|
-
return null
|
|
660
|
+
} catch (error) {
|
|
661
|
+
if (isMissingFileError(error)) return null
|
|
662
|
+
throw error
|
|
643
663
|
}
|
|
644
664
|
|
|
645
665
|
if (opts?.offset !== undefined || opts?.limit !== undefined) {
|
|
@@ -713,6 +733,8 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
713
733
|
target,
|
|
714
734
|
counted,
|
|
715
735
|
options?.mediaType ?? "application/octet-stream",
|
|
736
|
+
options?.origin,
|
|
737
|
+
expected,
|
|
716
738
|
)
|
|
717
739
|
return { path: target, bytes }
|
|
718
740
|
})
|
|
@@ -798,6 +820,11 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
798
820
|
*/
|
|
799
821
|
async glob(pattern: string): Promise<SpaceFileInfo[]> {
|
|
800
822
|
await this.ensureInit()
|
|
823
|
+
if (this.backend instanceof SqlBackend) {
|
|
824
|
+
return (await this.backend.workspace.glob(normalizeSpacePattern(pattern)))
|
|
825
|
+
.filter((file) => file.type === "file" && !isReservedPath(file.path))
|
|
826
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
827
|
+
}
|
|
801
828
|
await this.materializeAll()
|
|
802
829
|
const files = await globInfos(this.overlay, normalizeSpacePattern(pattern))
|
|
803
830
|
return files
|
|
@@ -859,6 +886,7 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
859
886
|
await this.ensureInit()
|
|
860
887
|
const target = normalizeSpacePath(path)
|
|
861
888
|
await this.hydrate(target)
|
|
889
|
+
if (this.backend instanceof SqlBackend) return this.backend.workspace.lstat(target)
|
|
862
890
|
try {
|
|
863
891
|
return toFileInfo(target, await this.overlay.lstat(target))
|
|
864
892
|
} catch {
|
|
@@ -874,13 +902,19 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
874
902
|
// throws ENOENT for a missing path, but this RPC's contract returns null.
|
|
875
903
|
try {
|
|
876
904
|
return await this.fs.readFileBytes(target)
|
|
877
|
-
} catch {
|
|
878
|
-
return null
|
|
905
|
+
} catch (error) {
|
|
906
|
+
if (isMissingFileError(error)) return null
|
|
907
|
+
throw error
|
|
879
908
|
}
|
|
880
909
|
}
|
|
881
910
|
|
|
882
911
|
async readDir(dir?: string, opts?: { limit?: number; offset?: number }): Promise<SpaceFileInfo[]> {
|
|
883
912
|
await this.ensureInit()
|
|
913
|
+
if (this.backend instanceof SqlBackend) {
|
|
914
|
+
const base = dir === undefined ? "/" : normalizeSpacePath(dir)
|
|
915
|
+
return (await this.backend.workspace.readDir(base, opts))
|
|
916
|
+
.filter((entry) => !isReservedPath(entry.path))
|
|
917
|
+
}
|
|
884
918
|
await this.materializeAll()
|
|
885
919
|
const base = dir === undefined ? "/" : normalizeSpacePath(dir)
|
|
886
920
|
const entries = await readDirInfos(this.overlay, base, opts)
|
|
@@ -892,6 +926,39 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
892
926
|
await this.fs.mkdir(normalizeWritableSpacePath(path), opts)
|
|
893
927
|
}
|
|
894
928
|
|
|
929
|
+
async registerExistingContents(input: import("./space-files").RegisterExistingContentsInput) {
|
|
930
|
+
await this.ensureInit()
|
|
931
|
+
if (!(this.backend instanceof SqlBackend)) {
|
|
932
|
+
throw new Error("Existing content registration requires the SQL Space backend")
|
|
933
|
+
}
|
|
934
|
+
return this.backend.workspace.registerExistingContents(input)
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
async inspectFile(path: string) {
|
|
938
|
+
await this.ensureInit()
|
|
939
|
+
if (!(this.backend instanceof SqlBackend)) return this.stat(path)
|
|
940
|
+
return this.backend.workspace.inspectFile(normalizeSpacePath(path))
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
async setFileUnderstanding(
|
|
944
|
+
path: string,
|
|
945
|
+
contentVersion: string,
|
|
946
|
+
status: import("./space-files").SpaceUnderstandingStatus,
|
|
947
|
+
evidence?: import("./space-files").SpaceFileEvidence,
|
|
948
|
+
) {
|
|
949
|
+
await this.ensureInit()
|
|
950
|
+
if (!(this.backend instanceof SqlBackend)) {
|
|
951
|
+
throw new Error("File Understanding requires the SQL Space backend")
|
|
952
|
+
}
|
|
953
|
+
return this.backend.workspace.setUnderstanding(path, contentVersion, status, evidence)
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
async beginFileUnderstanding(path: string, contentVersion: string, refresh?: boolean): Promise<boolean> {
|
|
957
|
+
await this.ensureInit()
|
|
958
|
+
if (!(this.backend instanceof SqlBackend)) return false
|
|
959
|
+
return this.backend.workspace.beginUnderstanding(path, contentVersion, refresh)
|
|
960
|
+
}
|
|
961
|
+
|
|
895
962
|
async rm(path: string, opts?: { recursive?: boolean; force?: boolean }): Promise<void> {
|
|
896
963
|
await this.ensureInit()
|
|
897
964
|
// Route through the overlay-aware FS so base files are tombstoned.
|
|
@@ -1130,6 +1197,10 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
1130
1197
|
fs: this.fs,
|
|
1131
1198
|
assetBucket: this.env.WORKSPACE_R2,
|
|
1132
1199
|
assetPrefix: spaceR2Prefix(this.spaceName),
|
|
1200
|
+
defer: (promise) => this.ctx.waitUntil(promise),
|
|
1201
|
+
openFileStream: (path) => this.backend instanceof SqlBackend
|
|
1202
|
+
? this.backend.workspace.openFileStream(path)
|
|
1203
|
+
: Promise.resolve(null),
|
|
1133
1204
|
}
|
|
1134
1205
|
const res = await handleDeployCommand(ctx, "deploy", fakeRequest)
|
|
1135
1206
|
const data = await res.json() as Record<string, unknown>
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
export type CachedFileVersion = {
|
|
2
|
+
attachmentId: string
|
|
3
|
+
contentVersion: string
|
|
4
|
+
size: number
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface FileContentCache {
|
|
8
|
+
get(file: CachedFileVersion): Promise<Uint8Array | null>
|
|
9
|
+
put(file: CachedFileVersion, bytes: Uint8Array): Promise<void>
|
|
10
|
+
remove(file: CachedFileVersion): Promise<void>
|
|
11
|
+
clear(): Promise<void>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type FileContentCacheOptions = {
|
|
15
|
+
maxEntryBytes: number
|
|
16
|
+
maxTotalBytes: number
|
|
17
|
+
touchIntervalMs: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const FILE_CACHE_MAX_ENTRY_BYTES = 1.5 * 1024 * 1024
|
|
21
|
+
export const FILE_CACHE_MAX_TOTAL_BYTES = 200 * 1024 * 1024
|
|
22
|
+
export const FILE_CACHE_TOUCH_INTERVAL_MS = 60 * 60 * 1000
|
|
23
|
+
|
|
24
|
+
const DEFAULT_OPTIONS: FileContentCacheOptions = {
|
|
25
|
+
maxEntryBytes: FILE_CACHE_MAX_ENTRY_BYTES,
|
|
26
|
+
maxTotalBytes: FILE_CACHE_MAX_TOTAL_BYTES,
|
|
27
|
+
touchIntervalMs: FILE_CACHE_TOUCH_INTERVAL_MS,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class NoopFileContentCache implements FileContentCache {
|
|
31
|
+
async get(_file: CachedFileVersion): Promise<null> { return null }
|
|
32
|
+
async put(_file: CachedFileVersion, _bytes: Uint8Array): Promise<void> {}
|
|
33
|
+
async remove(_file: CachedFileVersion): Promise<void> {}
|
|
34
|
+
async clear(): Promise<void> {}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createFileContentCache(
|
|
38
|
+
sql: SqlStorage,
|
|
39
|
+
options: FileContentCacheOptions = DEFAULT_OPTIONS,
|
|
40
|
+
): FileContentCache {
|
|
41
|
+
return options.maxTotalBytes === 0
|
|
42
|
+
? new NoopFileContentCache()
|
|
43
|
+
: new SqliteFileContentCache(sql, options)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type CacheRow = {
|
|
47
|
+
size: number
|
|
48
|
+
content: ArrayBuffer
|
|
49
|
+
accessedAt: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class SqliteFileContentCache implements FileContentCache {
|
|
53
|
+
constructor(
|
|
54
|
+
private readonly sql: SqlStorage,
|
|
55
|
+
private readonly options: FileContentCacheOptions,
|
|
56
|
+
) {
|
|
57
|
+
this.sql.exec(`
|
|
58
|
+
CREATE TABLE IF NOT EXISTS space_file_cache (
|
|
59
|
+
attachment_id TEXT NOT NULL,
|
|
60
|
+
content_version TEXT NOT NULL,
|
|
61
|
+
size INTEGER NOT NULL,
|
|
62
|
+
cached_content BLOB NOT NULL,
|
|
63
|
+
accessed_at INTEGER NOT NULL,
|
|
64
|
+
PRIMARY KEY (attachment_id, content_version),
|
|
65
|
+
CHECK (size = length(cached_content))
|
|
66
|
+
);
|
|
67
|
+
CREATE INDEX IF NOT EXISTS space_file_cache_accessed
|
|
68
|
+
ON space_file_cache(accessed_at, attachment_id, content_version);
|
|
69
|
+
CREATE TABLE IF NOT EXISTS space_file_cache_metrics (
|
|
70
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
71
|
+
hit_total INTEGER NOT NULL DEFAULT 0,
|
|
72
|
+
miss_total INTEGER NOT NULL DEFAULT 0,
|
|
73
|
+
put_total INTEGER NOT NULL DEFAULT 0,
|
|
74
|
+
skip_large_total INTEGER NOT NULL DEFAULT 0,
|
|
75
|
+
eviction_total INTEGER NOT NULL DEFAULT 0,
|
|
76
|
+
cached_bytes INTEGER NOT NULL DEFAULT 0
|
|
77
|
+
);
|
|
78
|
+
INSERT OR IGNORE INTO space_file_cache_metrics (singleton) VALUES (1);
|
|
79
|
+
`)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async get(file: CachedFileVersion): Promise<Uint8Array | null> {
|
|
83
|
+
const row = this.sql.exec<CacheRow>(`
|
|
84
|
+
SELECT size, cached_content AS content, accessed_at AS accessedAt
|
|
85
|
+
FROM space_file_cache
|
|
86
|
+
WHERE attachment_id = ? AND content_version = ?
|
|
87
|
+
`, file.attachmentId, file.contentVersion).toArray()[0]
|
|
88
|
+
if (!row) {
|
|
89
|
+
this.bump("miss_total")
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
if (row.size !== file.size || row.content.byteLength !== file.size) {
|
|
93
|
+
await this.remove(file)
|
|
94
|
+
this.bump("miss_total")
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
if (this.options.touchIntervalMs === 0 || Date.now() - row.accessedAt >= this.options.touchIntervalMs) {
|
|
98
|
+
this.sql.exec(
|
|
99
|
+
`UPDATE space_file_cache SET accessed_at = ?
|
|
100
|
+
WHERE attachment_id = ? AND content_version = ?`,
|
|
101
|
+
this.nextAccessedAt(),
|
|
102
|
+
file.attachmentId,
|
|
103
|
+
file.contentVersion,
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
this.bump("hit_total")
|
|
107
|
+
return new Uint8Array(row.content)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async put(file: CachedFileVersion, bytes: Uint8Array): Promise<void> {
|
|
111
|
+
if (this.options.maxTotalBytes === 0 || file.size !== bytes.byteLength) return
|
|
112
|
+
if (bytes.byteLength > this.options.maxEntryBytes || bytes.byteLength > this.options.maxTotalBytes) {
|
|
113
|
+
this.bump("skip_large_total")
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
const currentBytes = this.sql.exec<{ bytes: number }>(`
|
|
117
|
+
SELECT COALESCE(SUM(size), 0) AS bytes
|
|
118
|
+
FROM space_file_cache
|
|
119
|
+
WHERE NOT (attachment_id = ? AND content_version = ?)
|
|
120
|
+
`,
|
|
121
|
+
file.attachmentId,
|
|
122
|
+
file.contentVersion,
|
|
123
|
+
).one().bytes
|
|
124
|
+
const bytesToFree = Math.max(0, currentBytes + bytes.byteLength - this.options.maxTotalBytes)
|
|
125
|
+
const evictions = bytesToFree === 0 ? 0 : this.sql.exec<{ removed: number }>(`
|
|
126
|
+
DELETE FROM space_file_cache
|
|
127
|
+
WHERE rowid IN (
|
|
128
|
+
SELECT rowid FROM (
|
|
129
|
+
SELECT rowid, size,
|
|
130
|
+
SUM(size) OVER (
|
|
131
|
+
ORDER BY accessed_at, attachment_id, content_version
|
|
132
|
+
) AS freed_bytes
|
|
133
|
+
FROM space_file_cache
|
|
134
|
+
WHERE NOT (attachment_id = ? AND content_version = ?)
|
|
135
|
+
)
|
|
136
|
+
WHERE freed_bytes - size < ?
|
|
137
|
+
)
|
|
138
|
+
RETURNING 1 AS removed
|
|
139
|
+
`, file.attachmentId, file.contentVersion, bytesToFree).toArray().length
|
|
140
|
+
this.sql.exec(
|
|
141
|
+
`INSERT OR REPLACE INTO space_file_cache
|
|
142
|
+
(attachment_id, content_version, size, cached_content, accessed_at)
|
|
143
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
144
|
+
file.attachmentId,
|
|
145
|
+
file.contentVersion,
|
|
146
|
+
bytes.byteLength,
|
|
147
|
+
bytes.slice().buffer,
|
|
148
|
+
this.nextAccessedAt(),
|
|
149
|
+
)
|
|
150
|
+
this.sql.exec(
|
|
151
|
+
`UPDATE space_file_cache_metrics
|
|
152
|
+
SET put_total = put_total + 1,
|
|
153
|
+
eviction_total = eviction_total + ?,
|
|
154
|
+
cached_bytes = (SELECT COALESCE(SUM(size), 0) FROM space_file_cache)
|
|
155
|
+
WHERE singleton = 1`,
|
|
156
|
+
evictions,
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async remove(file: CachedFileVersion): Promise<void> {
|
|
161
|
+
this.sql.exec(
|
|
162
|
+
"DELETE FROM space_file_cache WHERE attachment_id = ? AND content_version = ?",
|
|
163
|
+
file.attachmentId,
|
|
164
|
+
file.contentVersion,
|
|
165
|
+
)
|
|
166
|
+
this.refreshCachedBytes()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async clear(): Promise<void> {
|
|
170
|
+
this.sql.exec("DELETE FROM space_file_cache")
|
|
171
|
+
this.refreshCachedBytes()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private nextAccessedAt(): number {
|
|
175
|
+
const latest = this.sql.exec<{ value: number }>(
|
|
176
|
+
"SELECT COALESCE(MAX(accessed_at), 0) AS value FROM space_file_cache",
|
|
177
|
+
).one().value
|
|
178
|
+
return Math.max(Date.now(), latest + 1)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private bump(column: "hit_total" | "miss_total" | "skip_large_total"): void {
|
|
182
|
+
this.sql.exec(
|
|
183
|
+
`UPDATE space_file_cache_metrics SET ${column} = ${column} + 1 WHERE singleton = 1`,
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private refreshCachedBytes(): void {
|
|
188
|
+
this.sql.exec(`
|
|
189
|
+
UPDATE space_file_cache_metrics
|
|
190
|
+
SET cached_bytes = (SELECT COALESCE(SUM(size), 0) FROM space_file_cache)
|
|
191
|
+
WHERE singleton = 1
|
|
192
|
+
`)
|
|
193
|
+
}
|
|
194
|
+
}
|