@springbrand/space 0.2.0-alpha.15 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@springbrand/space",
3
- "version": "0.2.0-alpha.15",
3
+ "version": "0.2.0-alpha.17",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -14,12 +14,13 @@
14
14
  },
15
15
  "dependencies": {
16
16
  "@cloudflare/shell": "0.4.3",
17
- "@cloudflare/worker-bundler": "0.0.4",
17
+ "@cloudflare/worker-bundler": "0.2.3",
18
18
  "isomorphic-git": "1.38.6"
19
19
  },
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
@@ -14,6 +14,7 @@ export interface SpaceEnv {
14
14
 
15
15
  /** Large-file spill and Space blob storage. */
16
16
  WORKSPACE_R2?: R2Bucket;
17
+ WORKSPACE_R2_PUBLIC_ORIGIN?: string;
17
18
 
18
19
  /**
19
20
  * Cloudflare Artifacts binding. Optional: Artifacts is still a private beta,
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
- bytes: Uint8Array,
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.checkout({ ref: revision ? commitHash : branch, force: true })
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
- const bytes = await ctx.fs.readFileBytes(entry.sourcePath)
196
- const asset: Omit<DeploymentAsset, "etag"> = {
197
- path: toAssetPath(entry.path, assetsDirectory!),
198
- sourcePath: entry.sourcePath,
199
- contentType: inferContentType(entry.path),
200
- size: bytes.byteLength,
201
- }
202
- const etag = writeAsset
203
- ? await writeAsset(asset, bytes)
204
- : await computeAssetEtag(bytes)
205
- assets.push({ ...asset, etag })
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, bytes) => {
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, bytes, {
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
- await discardAssetDeployment(
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 { ArtifactsBackend, resolveSpaceFsBackendMode, SqlBackend, spaceR2Prefix, type SpaceFsBackend } from "./fs-backend"
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
- this.backend = mode === "artifacts"
201
- ? new ArtifactsBackend(this.ctx, this.env, repoName)
202
- : new SqlBackend(this.ctx, repoName, this.env.WORKSPACE_R2)
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 = `${spaceR2Prefix(this.spaceName)}/`
613
- let cursor: string | undefined
614
- do {
615
- const page = await bucket.list({ prefix, limit: 1000, ...(cursor ? { cursor } : {}) })
616
- const keys = page.objects.map((object) => object.key)
617
- if (keys.length > 0) await bucket.delete(keys)
618
- cursor = page.truncated ? page.cursor : undefined
619
- } while (cursor)
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
+ }