@syncular/server 0.0.1-60

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.
Files changed (211) hide show
  1. package/dist/blobs/adapters/database.d.ts +83 -0
  2. package/dist/blobs/adapters/database.d.ts.map +1 -0
  3. package/dist/blobs/adapters/database.js +180 -0
  4. package/dist/blobs/adapters/database.js.map +1 -0
  5. package/dist/blobs/adapters/s3.d.ts +82 -0
  6. package/dist/blobs/adapters/s3.d.ts.map +1 -0
  7. package/dist/blobs/adapters/s3.js +170 -0
  8. package/dist/blobs/adapters/s3.js.map +1 -0
  9. package/dist/blobs/index.d.ts +9 -0
  10. package/dist/blobs/index.d.ts.map +1 -0
  11. package/dist/blobs/index.js +9 -0
  12. package/dist/blobs/index.js.map +1 -0
  13. package/dist/blobs/manager.d.ts +195 -0
  14. package/dist/blobs/manager.d.ts.map +1 -0
  15. package/dist/blobs/manager.js +440 -0
  16. package/dist/blobs/manager.js.map +1 -0
  17. package/dist/blobs/migrate.d.ts +27 -0
  18. package/dist/blobs/migrate.d.ts.map +1 -0
  19. package/dist/blobs/migrate.js +119 -0
  20. package/dist/blobs/migrate.js.map +1 -0
  21. package/dist/blobs/types.d.ts +54 -0
  22. package/dist/blobs/types.d.ts.map +1 -0
  23. package/dist/blobs/types.js +5 -0
  24. package/dist/blobs/types.js.map +1 -0
  25. package/dist/clients.d.ts +14 -0
  26. package/dist/clients.d.ts.map +1 -0
  27. package/dist/clients.js +7 -0
  28. package/dist/clients.js.map +1 -0
  29. package/dist/compaction.d.ts +27 -0
  30. package/dist/compaction.d.ts.map +1 -0
  31. package/dist/compaction.js +49 -0
  32. package/dist/compaction.js.map +1 -0
  33. package/dist/dialect/index.d.ts +5 -0
  34. package/dist/dialect/index.d.ts.map +1 -0
  35. package/dist/dialect/index.js +5 -0
  36. package/dist/dialect/index.js.map +1 -0
  37. package/dist/dialect/types.d.ts +170 -0
  38. package/dist/dialect/types.d.ts.map +1 -0
  39. package/dist/dialect/types.js +8 -0
  40. package/dist/dialect/types.js.map +1 -0
  41. package/dist/helpers/conflict.d.ts +52 -0
  42. package/dist/helpers/conflict.d.ts.map +1 -0
  43. package/dist/helpers/conflict.js +49 -0
  44. package/dist/helpers/conflict.js.map +1 -0
  45. package/dist/helpers/emitted-change.d.ts +56 -0
  46. package/dist/helpers/emitted-change.d.ts.map +1 -0
  47. package/dist/helpers/emitted-change.js +46 -0
  48. package/dist/helpers/emitted-change.js.map +1 -0
  49. package/dist/helpers/index.d.ts +10 -0
  50. package/dist/helpers/index.d.ts.map +1 -0
  51. package/dist/helpers/index.js +10 -0
  52. package/dist/helpers/index.js.map +1 -0
  53. package/dist/helpers/paginate.d.ts +49 -0
  54. package/dist/helpers/paginate.d.ts.map +1 -0
  55. package/dist/helpers/paginate.js +54 -0
  56. package/dist/helpers/paginate.js.map +1 -0
  57. package/dist/helpers/scope-strings.d.ts +74 -0
  58. package/dist/helpers/scope-strings.d.ts.map +1 -0
  59. package/dist/helpers/scope-strings.js +82 -0
  60. package/dist/helpers/scope-strings.js.map +1 -0
  61. package/dist/index.d.ts +28 -0
  62. package/dist/index.d.ts.map +1 -0
  63. package/dist/index.js +27 -0
  64. package/dist/index.js.map +1 -0
  65. package/dist/migrate.d.ts +14 -0
  66. package/dist/migrate.d.ts.map +1 -0
  67. package/dist/migrate.js +13 -0
  68. package/dist/migrate.js.map +1 -0
  69. package/dist/proxy/handler.d.ts +42 -0
  70. package/dist/proxy/handler.d.ts.map +1 -0
  71. package/dist/proxy/handler.js +99 -0
  72. package/dist/proxy/handler.js.map +1 -0
  73. package/dist/proxy/index.d.ts +9 -0
  74. package/dist/proxy/index.d.ts.map +1 -0
  75. package/dist/proxy/index.js +14 -0
  76. package/dist/proxy/index.js.map +1 -0
  77. package/dist/proxy/mutation-detector.d.ts +31 -0
  78. package/dist/proxy/mutation-detector.d.ts.map +1 -0
  79. package/dist/proxy/mutation-detector.js +61 -0
  80. package/dist/proxy/mutation-detector.js.map +1 -0
  81. package/dist/proxy/oplog.d.ts +30 -0
  82. package/dist/proxy/oplog.d.ts.map +1 -0
  83. package/dist/proxy/oplog.js +110 -0
  84. package/dist/proxy/oplog.js.map +1 -0
  85. package/dist/proxy/registry.d.ts +35 -0
  86. package/dist/proxy/registry.d.ts.map +1 -0
  87. package/dist/proxy/registry.js +49 -0
  88. package/dist/proxy/registry.js.map +1 -0
  89. package/dist/proxy/types.d.ts +44 -0
  90. package/dist/proxy/types.d.ts.map +1 -0
  91. package/dist/proxy/types.js +7 -0
  92. package/dist/proxy/types.js.map +1 -0
  93. package/dist/prune.d.ts +37 -0
  94. package/dist/prune.d.ts.map +1 -0
  95. package/dist/prune.js +112 -0
  96. package/dist/prune.js.map +1 -0
  97. package/dist/pull.d.ts +31 -0
  98. package/dist/pull.d.ts.map +1 -0
  99. package/dist/pull.js +414 -0
  100. package/dist/pull.js.map +1 -0
  101. package/dist/push.d.ts +33 -0
  102. package/dist/push.d.ts.map +1 -0
  103. package/dist/push.js +329 -0
  104. package/dist/push.js.map +1 -0
  105. package/dist/realtime/in-memory.d.ts +13 -0
  106. package/dist/realtime/in-memory.d.ts.map +1 -0
  107. package/dist/realtime/in-memory.js +28 -0
  108. package/dist/realtime/in-memory.js.map +1 -0
  109. package/dist/realtime/index.d.ts +3 -0
  110. package/dist/realtime/index.d.ts.map +1 -0
  111. package/dist/realtime/index.js +2 -0
  112. package/dist/realtime/index.js.map +1 -0
  113. package/dist/realtime/types.d.ts +50 -0
  114. package/dist/realtime/types.d.ts.map +1 -0
  115. package/dist/realtime/types.js +7 -0
  116. package/dist/realtime/types.js.map +1 -0
  117. package/dist/schema.d.ts +164 -0
  118. package/dist/schema.d.ts.map +1 -0
  119. package/dist/schema.js +10 -0
  120. package/dist/schema.js.map +1 -0
  121. package/dist/shapes/create-handler.d.ts +119 -0
  122. package/dist/shapes/create-handler.d.ts.map +1 -0
  123. package/dist/shapes/create-handler.js +327 -0
  124. package/dist/shapes/create-handler.js.map +1 -0
  125. package/dist/shapes/index.d.ts +4 -0
  126. package/dist/shapes/index.d.ts.map +1 -0
  127. package/dist/shapes/index.js +4 -0
  128. package/dist/shapes/index.js.map +1 -0
  129. package/dist/shapes/registry.d.ts +20 -0
  130. package/dist/shapes/registry.d.ts.map +1 -0
  131. package/dist/shapes/registry.js +88 -0
  132. package/dist/shapes/registry.js.map +1 -0
  133. package/dist/shapes/types.d.ts +204 -0
  134. package/dist/shapes/types.d.ts.map +1 -0
  135. package/dist/shapes/types.js +2 -0
  136. package/dist/shapes/types.js.map +1 -0
  137. package/dist/snapshot-chunks/adapters/s3.d.ts +63 -0
  138. package/dist/snapshot-chunks/adapters/s3.d.ts.map +1 -0
  139. package/dist/snapshot-chunks/adapters/s3.js +50 -0
  140. package/dist/snapshot-chunks/adapters/s3.js.map +1 -0
  141. package/dist/snapshot-chunks/db-metadata.d.ts +33 -0
  142. package/dist/snapshot-chunks/db-metadata.d.ts.map +1 -0
  143. package/dist/snapshot-chunks/db-metadata.js +169 -0
  144. package/dist/snapshot-chunks/db-metadata.js.map +1 -0
  145. package/dist/snapshot-chunks/index.d.ts +9 -0
  146. package/dist/snapshot-chunks/index.d.ts.map +1 -0
  147. package/dist/snapshot-chunks/index.js +9 -0
  148. package/dist/snapshot-chunks/index.js.map +1 -0
  149. package/dist/snapshot-chunks/types.d.ts +65 -0
  150. package/dist/snapshot-chunks/types.d.ts.map +1 -0
  151. package/dist/snapshot-chunks/types.js +8 -0
  152. package/dist/snapshot-chunks/types.js.map +1 -0
  153. package/dist/snapshot-chunks.d.ts +59 -0
  154. package/dist/snapshot-chunks.d.ts.map +1 -0
  155. package/dist/snapshot-chunks.js +202 -0
  156. package/dist/snapshot-chunks.js.map +1 -0
  157. package/dist/stats.d.ts +19 -0
  158. package/dist/stats.d.ts.map +1 -0
  159. package/dist/stats.js +57 -0
  160. package/dist/stats.js.map +1 -0
  161. package/dist/subscriptions/index.d.ts +2 -0
  162. package/dist/subscriptions/index.d.ts.map +1 -0
  163. package/dist/subscriptions/index.js +2 -0
  164. package/dist/subscriptions/index.js.map +1 -0
  165. package/dist/subscriptions/resolve.d.ts +35 -0
  166. package/dist/subscriptions/resolve.d.ts.map +1 -0
  167. package/dist/subscriptions/resolve.js +134 -0
  168. package/dist/subscriptions/resolve.js.map +1 -0
  169. package/package.json +80 -0
  170. package/src/blobs/adapters/database.ts +290 -0
  171. package/src/blobs/adapters/s3.ts +271 -0
  172. package/src/blobs/index.ts +9 -0
  173. package/src/blobs/manager.ts +600 -0
  174. package/src/blobs/migrate.ts +150 -0
  175. package/src/blobs/types.ts +70 -0
  176. package/src/clients.ts +21 -0
  177. package/src/compaction.ts +77 -0
  178. package/src/dialect/index.ts +5 -0
  179. package/src/dialect/types.ts +222 -0
  180. package/src/helpers/conflict.ts +64 -0
  181. package/src/helpers/emitted-change.ts +69 -0
  182. package/src/helpers/index.ts +10 -0
  183. package/src/helpers/paginate.ts +82 -0
  184. package/src/helpers/scope-strings.ts +101 -0
  185. package/src/index.ts +28 -0
  186. package/src/migrate.ts +20 -0
  187. package/src/proxy/handler.ts +152 -0
  188. package/src/proxy/index.ts +18 -0
  189. package/src/proxy/mutation-detector.ts +83 -0
  190. package/src/proxy/oplog.ts +144 -0
  191. package/src/proxy/registry.ts +56 -0
  192. package/src/proxy/types.ts +46 -0
  193. package/src/prune.ts +200 -0
  194. package/src/pull.ts +551 -0
  195. package/src/push.ts +457 -0
  196. package/src/realtime/in-memory.ts +33 -0
  197. package/src/realtime/index.ts +5 -0
  198. package/src/realtime/types.ts +55 -0
  199. package/src/schema.ts +172 -0
  200. package/src/shapes/create-handler.ts +590 -0
  201. package/src/shapes/index.ts +3 -0
  202. package/src/shapes/registry.ts +109 -0
  203. package/src/shapes/types.ts +267 -0
  204. package/src/snapshot-chunks/adapters/s3.ts +68 -0
  205. package/src/snapshot-chunks/db-metadata.ts +238 -0
  206. package/src/snapshot-chunks/index.ts +9 -0
  207. package/src/snapshot-chunks/types.ts +79 -0
  208. package/src/snapshot-chunks.ts +301 -0
  209. package/src/stats.ts +104 -0
  210. package/src/subscriptions/index.ts +1 -0
  211. package/src/subscriptions/resolve.ts +185 -0
@@ -0,0 +1,600 @@
1
+ /**
2
+ * Server-side blob manager.
3
+ *
4
+ * Orchestrates blob uploads and downloads using a pluggable storage adapter.
5
+ * Handles metadata tracking, upload verification, and garbage collection.
6
+ */
7
+
8
+ import type {
9
+ BlobMetadata,
10
+ BlobStorageAdapter,
11
+ BlobUploadCompleteResponse,
12
+ BlobUploadInitResponse,
13
+ } from '@syncular/core';
14
+ import { parseBlobHash } from '@syncular/core';
15
+ import { type Kysely, sql } from 'kysely';
16
+ import type { SyncBlobUploadsDb } from './types';
17
+
18
+ // ============================================================================
19
+ // Blob Manager
20
+ // ============================================================================
21
+
22
+ export interface BlobManagerOptions<
23
+ DB extends SyncBlobUploadsDb = SyncBlobUploadsDb,
24
+ > {
25
+ /** Database instance for tracking uploads */
26
+ db: Kysely<DB>;
27
+ /** Storage adapter (S3, R2, database, etc.) */
28
+ adapter: BlobStorageAdapter;
29
+ /** Default presigned URL expiration in seconds. Default: 3600 (1 hour) */
30
+ defaultExpiresIn?: number;
31
+ /** How long incomplete uploads are kept before cleanup. Default: 86400 (24 hours) */
32
+ uploadTtlSeconds?: number;
33
+ }
34
+
35
+ export interface InitiateUploadOptions {
36
+ hash: string;
37
+ size: number;
38
+ mimeType: string;
39
+ actorId: string;
40
+ }
41
+
42
+ export interface GetDownloadUrlOptions {
43
+ hash: string;
44
+ /** Optional: verify actor has access to this blob via a scope check */
45
+ actorId?: string;
46
+ }
47
+
48
+ /**
49
+ * Create a blob manager for handling server-side blob operations.
50
+ */
51
+ export function createBlobManager<DB extends SyncBlobUploadsDb>(
52
+ options: BlobManagerOptions<DB>
53
+ ) {
54
+ const {
55
+ db,
56
+ adapter,
57
+ defaultExpiresIn = 3600,
58
+ uploadTtlSeconds = 86400,
59
+ } = options;
60
+
61
+ return {
62
+ /**
63
+ * Initiate a blob upload.
64
+ *
65
+ * Checks for deduplication and returns a presigned URL for uploading.
66
+ */
67
+ async initiateUpload(
68
+ opts: InitiateUploadOptions
69
+ ): Promise<BlobUploadInitResponse> {
70
+ const { hash, size, mimeType, actorId } = opts;
71
+
72
+ // Validate hash format
73
+ if (!parseBlobHash(hash)) {
74
+ throw new BlobValidationError('Invalid blob hash format');
75
+ }
76
+
77
+ // Check if blob already exists (deduplication)
78
+ const exists = await adapter.exists(hash);
79
+ if (exists) {
80
+ // Also check if we have a complete upload record
81
+ const existingResult = await sql<{ status: 'pending' | 'complete' }>`
82
+ select status
83
+ from ${sql.table('sync_blob_uploads')}
84
+ where hash = ${hash} and status = 'complete'
85
+ limit 1
86
+ `.execute(db);
87
+ const existing = existingResult.rows[0];
88
+
89
+ if (existing) {
90
+ return { exists: true };
91
+ }
92
+
93
+ // Blob exists in storage but we don't have a record - create one
94
+ const existsExpiresAt = new Date(
95
+ Date.now() + uploadTtlSeconds * 1000
96
+ ).toISOString();
97
+ const existsCompletedAt = new Date().toISOString();
98
+
99
+ await sql`
100
+ insert into ${sql.table('sync_blob_uploads')} (
101
+ hash,
102
+ size,
103
+ mime_type,
104
+ status,
105
+ actor_id,
106
+ expires_at,
107
+ completed_at
108
+ )
109
+ values (
110
+ ${hash},
111
+ ${size},
112
+ ${mimeType},
113
+ 'complete',
114
+ ${actorId},
115
+ ${existsExpiresAt},
116
+ ${existsCompletedAt}
117
+ )
118
+ on conflict (hash) do nothing
119
+ `.execute(db);
120
+
121
+ return { exists: true };
122
+ }
123
+
124
+ // Create pending upload record
125
+ const expiresAt = new Date(
126
+ Date.now() + uploadTtlSeconds * 1000
127
+ ).toISOString();
128
+
129
+ await sql`
130
+ insert into ${sql.table('sync_blob_uploads')} (
131
+ hash,
132
+ size,
133
+ mime_type,
134
+ status,
135
+ actor_id,
136
+ expires_at,
137
+ completed_at
138
+ )
139
+ values (
140
+ ${hash},
141
+ ${size},
142
+ ${mimeType},
143
+ 'pending',
144
+ ${actorId},
145
+ ${expiresAt},
146
+ ${null}
147
+ )
148
+ on conflict (hash)
149
+ do update set
150
+ size = ${size},
151
+ mime_type = ${mimeType},
152
+ status = 'pending',
153
+ actor_id = ${actorId},
154
+ expires_at = ${expiresAt},
155
+ completed_at = ${null}
156
+ `.execute(db);
157
+
158
+ // Generate presigned upload URL
159
+ const signed = await adapter.signUpload({
160
+ hash,
161
+ size,
162
+ mimeType,
163
+ expiresIn: defaultExpiresIn,
164
+ });
165
+
166
+ return {
167
+ exists: false,
168
+ uploadId: hash, // Use hash as upload ID
169
+ uploadUrl: signed.url,
170
+ uploadMethod: signed.method,
171
+ uploadHeaders: signed.headers,
172
+ };
173
+ },
174
+
175
+ /**
176
+ * Complete a blob upload.
177
+ *
178
+ * Verifies the blob exists in storage and marks the upload as complete.
179
+ */
180
+ async completeUpload(hash: string): Promise<BlobUploadCompleteResponse> {
181
+ // Validate hash format
182
+ if (!parseBlobHash(hash)) {
183
+ return { ok: false, error: 'Invalid blob hash format' };
184
+ }
185
+
186
+ // Check upload record exists
187
+ const uploadResult = await sql<{
188
+ hash: string;
189
+ size: number;
190
+ mime_type: string;
191
+ status: 'pending' | 'complete';
192
+ created_at: string;
193
+ }>`
194
+ select hash, size, mime_type, status, created_at
195
+ from ${sql.table('sync_blob_uploads')}
196
+ where hash = ${hash}
197
+ limit 1
198
+ `.execute(db);
199
+ const upload = uploadResult.rows[0];
200
+
201
+ if (!upload) {
202
+ return { ok: false, error: 'Upload not found' };
203
+ }
204
+
205
+ if (upload.status === 'complete') {
206
+ // Already complete - return metadata
207
+ return {
208
+ ok: true,
209
+ metadata: {
210
+ hash: upload.hash,
211
+ size: upload.size,
212
+ mimeType: upload.mime_type,
213
+ createdAt: upload.created_at,
214
+ uploadComplete: true,
215
+ },
216
+ };
217
+ }
218
+
219
+ // Verify blob exists in storage
220
+ const exists = await adapter.exists(hash);
221
+ if (!exists) {
222
+ return { ok: false, error: 'Blob not found in storage' };
223
+ }
224
+
225
+ // Optionally verify size matches
226
+ if (adapter.getMetadata) {
227
+ const meta = await adapter.getMetadata(hash);
228
+ if (meta && meta.size !== upload.size) {
229
+ return {
230
+ ok: false,
231
+ error: `Size mismatch: expected ${upload.size}, got ${meta.size}`,
232
+ };
233
+ }
234
+ }
235
+
236
+ // Mark upload as complete
237
+ const completedAt = new Date().toISOString();
238
+ await sql`
239
+ update ${sql.table('sync_blob_uploads')}
240
+ set status = 'complete', completed_at = ${completedAt}
241
+ where hash = ${hash}
242
+ `.execute(db);
243
+
244
+ return {
245
+ ok: true,
246
+ metadata: {
247
+ hash: upload.hash,
248
+ size: upload.size,
249
+ mimeType: upload.mime_type,
250
+ createdAt: upload.created_at,
251
+ uploadComplete: true,
252
+ },
253
+ };
254
+ },
255
+
256
+ /**
257
+ * Get a presigned download URL for a blob.
258
+ */
259
+ async getDownloadUrl(
260
+ opts: GetDownloadUrlOptions
261
+ ): Promise<{ url: string; expiresAt: string; metadata: BlobMetadata }> {
262
+ const { hash } = opts;
263
+
264
+ // Validate hash format
265
+ if (!parseBlobHash(hash)) {
266
+ throw new BlobNotFoundError('Invalid blob hash format');
267
+ }
268
+
269
+ // Get upload record (must be complete)
270
+ const uploadResult = await sql<{
271
+ hash: string;
272
+ size: number;
273
+ mime_type: string;
274
+ status: 'pending' | 'complete';
275
+ created_at: string;
276
+ }>`
277
+ select hash, size, mime_type, status, created_at
278
+ from ${sql.table('sync_blob_uploads')}
279
+ where hash = ${hash} and status = 'complete'
280
+ limit 1
281
+ `.execute(db);
282
+ const upload = uploadResult.rows[0];
283
+
284
+ if (!upload) {
285
+ throw new BlobNotFoundError('Blob not found');
286
+ }
287
+
288
+ // Generate presigned download URL
289
+ const url = await adapter.signDownload({
290
+ hash,
291
+ expiresIn: defaultExpiresIn,
292
+ });
293
+
294
+ const expiresAt = new Date(
295
+ Date.now() + defaultExpiresIn * 1000
296
+ ).toISOString();
297
+
298
+ return {
299
+ url,
300
+ expiresAt,
301
+ metadata: {
302
+ hash: upload.hash,
303
+ size: upload.size,
304
+ mimeType: upload.mime_type,
305
+ createdAt: upload.created_at,
306
+ uploadComplete: true,
307
+ },
308
+ };
309
+ },
310
+
311
+ /**
312
+ * Get blob metadata without generating a download URL.
313
+ */
314
+ async getMetadata(hash: string): Promise<BlobMetadata | null> {
315
+ // Validate hash format
316
+ if (!parseBlobHash(hash)) {
317
+ return null;
318
+ }
319
+
320
+ const uploadResult = await sql<{
321
+ hash: string;
322
+ size: number;
323
+ mime_type: string;
324
+ status: 'pending' | 'complete';
325
+ created_at: string;
326
+ }>`
327
+ select hash, size, mime_type, status, created_at
328
+ from ${sql.table('sync_blob_uploads')}
329
+ where hash = ${hash} and status = 'complete'
330
+ limit 1
331
+ `.execute(db);
332
+ const upload = uploadResult.rows[0];
333
+
334
+ if (!upload) {
335
+ return null;
336
+ }
337
+
338
+ return {
339
+ hash: upload.hash,
340
+ size: upload.size,
341
+ mimeType: upload.mime_type,
342
+ createdAt: upload.created_at,
343
+ uploadComplete: true,
344
+ };
345
+ },
346
+
347
+ /**
348
+ * Check if a blob exists and is complete.
349
+ */
350
+ async exists(hash: string): Promise<boolean> {
351
+ if (!parseBlobHash(hash)) return false;
352
+
353
+ const rowResult = await sql<{ hash: string }>`
354
+ select hash
355
+ from ${sql.table('sync_blob_uploads')}
356
+ where hash = ${hash} and status = 'complete'
357
+ limit 1
358
+ `.execute(db);
359
+
360
+ return rowResult.rows.length > 0;
361
+ },
362
+
363
+ /**
364
+ * Clean up expired/orphaned uploads.
365
+ *
366
+ * Deletes upload records (and optionally storage) for:
367
+ * - Pending uploads that have expired
368
+ * - Completed uploads with no references (if refCheck provided)
369
+ */
370
+ async cleanup(options?: {
371
+ /** Check if a blob hash is referenced by any row */
372
+ isReferenced?: (hash: string) => Promise<boolean>;
373
+ /** Delete from storage too (not just tracking table) */
374
+ deleteFromStorage?: boolean;
375
+ }): Promise<{ deleted: number }> {
376
+ const now = new Date().toISOString();
377
+
378
+ // Find expired pending uploads
379
+ const expiredResult = await sql<{ hash: string }>`
380
+ select hash
381
+ from ${sql.table('sync_blob_uploads')}
382
+ where status = 'pending' and expires_at < ${now}
383
+ `.execute(db);
384
+ const expired = expiredResult.rows;
385
+
386
+ let deleted = 0;
387
+
388
+ for (const row of expired) {
389
+ if (options?.deleteFromStorage) {
390
+ try {
391
+ await adapter.delete(row.hash);
392
+ } catch {
393
+ // Ignore storage errors during cleanup
394
+ }
395
+ }
396
+
397
+ await sql`
398
+ delete from ${sql.table('sync_blob_uploads')}
399
+ where hash = ${row.hash}
400
+ `.execute(db);
401
+
402
+ deleted++;
403
+ }
404
+
405
+ // If reference check provided, also clean up unreferenced complete uploads
406
+ if (options?.isReferenced) {
407
+ const completeResult = await sql<{ hash: string }>`
408
+ select hash
409
+ from ${sql.table('sync_blob_uploads')}
410
+ where status = 'complete'
411
+ `.execute(db);
412
+ const complete = completeResult.rows;
413
+
414
+ for (const row of complete) {
415
+ const referenced = await options.isReferenced(row.hash);
416
+ if (!referenced) {
417
+ if (options?.deleteFromStorage) {
418
+ try {
419
+ await adapter.delete(row.hash);
420
+ } catch {
421
+ // Ignore storage errors during cleanup
422
+ }
423
+ }
424
+
425
+ await sql`
426
+ delete from ${sql.table('sync_blob_uploads')}
427
+ where hash = ${row.hash}
428
+ `.execute(db);
429
+
430
+ deleted++;
431
+ }
432
+ }
433
+ }
434
+
435
+ return { deleted };
436
+ },
437
+
438
+ /** The underlying storage adapter */
439
+ adapter,
440
+ };
441
+ }
442
+
443
+ export type BlobManager = ReturnType<typeof createBlobManager>;
444
+
445
+ // ============================================================================
446
+ // Garbage Collection Scheduler
447
+ // ============================================================================
448
+
449
+ export interface BlobCleanupSchedulerOptions {
450
+ /** Blob manager instance */
451
+ blobManager: BlobManager;
452
+ /** Interval between cleanup runs in milliseconds. Default: 3600000 (1 hour) */
453
+ intervalMs?: number;
454
+ /** Delete from storage too (not just tracking table). Default: true */
455
+ deleteFromStorage?: boolean;
456
+ /** Optional: Check if a blob hash is referenced by any row */
457
+ isReferenced?: (hash: string) => Promise<boolean>;
458
+ /** Optional: Called after each cleanup run */
459
+ onCleanup?: (result: { deleted: number; error?: Error }) => void;
460
+ }
461
+
462
+ /**
463
+ * Create a garbage collection scheduler for blob storage.
464
+ *
465
+ * Periodically runs cleanup to remove:
466
+ * - Expired pending uploads
467
+ * - Unreferenced blobs (if isReferenced callback provided)
468
+ *
469
+ * @example
470
+ * ```typescript
471
+ * const scheduler = createBlobCleanupScheduler({
472
+ * blobManager,
473
+ * intervalMs: 60 * 60 * 1000, // 1 hour
474
+ * deleteFromStorage: true,
475
+ * isReferenced: async (hash) => {
476
+ * const row = await db.selectFrom('my_table')
477
+ * .select('id')
478
+ * .where('blob_hash', '=', hash)
479
+ * .executeTakeFirst();
480
+ * return !!row;
481
+ * },
482
+ * onCleanup: (result) => {
483
+ * console.log(`Cleanup complete: ${result.deleted} blobs removed`);
484
+ * },
485
+ * });
486
+ *
487
+ * // Start the scheduler
488
+ * scheduler.start();
489
+ *
490
+ * // Stop when shutting down
491
+ * scheduler.stop();
492
+ * ```
493
+ */
494
+ export function createBlobCleanupScheduler(
495
+ options: BlobCleanupSchedulerOptions
496
+ ) {
497
+ const {
498
+ blobManager,
499
+ intervalMs = 3600000, // 1 hour
500
+ deleteFromStorage = true,
501
+ isReferenced,
502
+ onCleanup,
503
+ } = options;
504
+
505
+ let intervalId: ReturnType<typeof setInterval> | null = null;
506
+ let isRunning = false;
507
+
508
+ const runCleanup = async (): Promise<{ deleted: number; error?: Error }> => {
509
+ if (isRunning) {
510
+ return { deleted: 0 };
511
+ }
512
+
513
+ isRunning = true;
514
+
515
+ try {
516
+ const result = await blobManager.cleanup({
517
+ deleteFromStorage,
518
+ isReferenced,
519
+ });
520
+
521
+ onCleanup?.({ deleted: result.deleted });
522
+ return { deleted: result.deleted };
523
+ } catch (err) {
524
+ const error = err instanceof Error ? err : new Error(String(err));
525
+ onCleanup?.({ deleted: 0, error });
526
+ return { deleted: 0, error };
527
+ } finally {
528
+ isRunning = false;
529
+ }
530
+ };
531
+
532
+ return {
533
+ /**
534
+ * Start the cleanup scheduler.
535
+ * Optionally runs an immediate cleanup before starting the interval.
536
+ */
537
+ start(options?: { immediate?: boolean }): void {
538
+ if (intervalId) {
539
+ return; // Already running
540
+ }
541
+
542
+ if (options?.immediate) {
543
+ void runCleanup();
544
+ }
545
+
546
+ intervalId = setInterval(() => {
547
+ void runCleanup();
548
+ }, intervalMs);
549
+ },
550
+
551
+ /**
552
+ * Stop the cleanup scheduler.
553
+ */
554
+ stop(): void {
555
+ if (intervalId) {
556
+ clearInterval(intervalId);
557
+ intervalId = null;
558
+ }
559
+ },
560
+
561
+ /**
562
+ * Run a single cleanup manually.
563
+ */
564
+ async runOnce(): Promise<{ deleted: number; error?: Error }> {
565
+ return runCleanup();
566
+ },
567
+
568
+ /**
569
+ * Check if the scheduler is currently active.
570
+ */
571
+ get active(): boolean {
572
+ return intervalId !== null;
573
+ },
574
+
575
+ /**
576
+ * Check if a cleanup is currently in progress.
577
+ */
578
+ get running(): boolean {
579
+ return isRunning;
580
+ },
581
+ };
582
+ }
583
+
584
+ // ============================================================================
585
+ // Errors
586
+ // ============================================================================
587
+
588
+ export class BlobValidationError extends Error {
589
+ constructor(message: string) {
590
+ super(message);
591
+ this.name = 'BlobValidationError';
592
+ }
593
+ }
594
+
595
+ export class BlobNotFoundError extends Error {
596
+ constructor(message: string) {
597
+ super(message);
598
+ this.name = 'BlobNotFoundError';
599
+ }
600
+ }