@syncular/server 0.0.6-159 → 0.0.6-167

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 (93) hide show
  1. package/dist/blobs/adapters/database.d.ts +26 -9
  2. package/dist/blobs/adapters/database.d.ts.map +1 -1
  3. package/dist/blobs/adapters/database.js +65 -21
  4. package/dist/blobs/adapters/database.js.map +1 -1
  5. package/dist/blobs/manager.d.ts +60 -3
  6. package/dist/blobs/manager.d.ts.map +1 -1
  7. package/dist/blobs/manager.js +227 -56
  8. package/dist/blobs/manager.js.map +1 -1
  9. package/dist/blobs/migrate.d.ts.map +1 -1
  10. package/dist/blobs/migrate.js +16 -8
  11. package/dist/blobs/migrate.js.map +1 -1
  12. package/dist/blobs/types.d.ts +4 -0
  13. package/dist/blobs/types.d.ts.map +1 -1
  14. package/dist/dialect/helpers.d.ts +3 -0
  15. package/dist/dialect/helpers.d.ts.map +1 -1
  16. package/dist/dialect/helpers.js +17 -0
  17. package/dist/dialect/helpers.js.map +1 -1
  18. package/dist/handlers/collection.d.ts +0 -2
  19. package/dist/handlers/collection.d.ts.map +1 -1
  20. package/dist/handlers/collection.js +5 -56
  21. package/dist/handlers/collection.js.map +1 -1
  22. package/dist/handlers/create-handler.d.ts +0 -4
  23. package/dist/handlers/create-handler.d.ts.map +1 -1
  24. package/dist/handlers/create-handler.js +6 -34
  25. package/dist/handlers/create-handler.js.map +1 -1
  26. package/dist/notify.d.ts.map +1 -1
  27. package/dist/notify.js +13 -37
  28. package/dist/notify.js.map +1 -1
  29. package/dist/proxy/collection.d.ts +0 -2
  30. package/dist/proxy/collection.d.ts.map +1 -1
  31. package/dist/proxy/collection.js +2 -17
  32. package/dist/proxy/collection.js.map +1 -1
  33. package/dist/proxy/handler.d.ts +1 -1
  34. package/dist/proxy/handler.d.ts.map +1 -1
  35. package/dist/proxy/handler.js +1 -2
  36. package/dist/proxy/handler.js.map +1 -1
  37. package/dist/proxy/index.d.ts +1 -1
  38. package/dist/proxy/index.d.ts.map +1 -1
  39. package/dist/proxy/index.js +1 -1
  40. package/dist/proxy/index.js.map +1 -1
  41. package/dist/proxy/oplog.d.ts.map +1 -1
  42. package/dist/proxy/oplog.js +1 -7
  43. package/dist/proxy/oplog.js.map +1 -1
  44. package/dist/prune.d.ts.map +1 -1
  45. package/dist/prune.js +1 -13
  46. package/dist/prune.js.map +1 -1
  47. package/dist/pull.d.ts.map +1 -1
  48. package/dist/pull.js +186 -54
  49. package/dist/pull.js.map +1 -1
  50. package/dist/push.d.ts +1 -1
  51. package/dist/push.d.ts.map +1 -1
  52. package/dist/push.js +9 -36
  53. package/dist/push.js.map +1 -1
  54. package/dist/snapshot-chunks/db-metadata.d.ts +18 -0
  55. package/dist/snapshot-chunks/db-metadata.d.ts.map +1 -1
  56. package/dist/snapshot-chunks/db-metadata.js +71 -23
  57. package/dist/snapshot-chunks/db-metadata.js.map +1 -1
  58. package/dist/snapshot-chunks.d.ts +5 -1
  59. package/dist/snapshot-chunks.d.ts.map +1 -1
  60. package/dist/snapshot-chunks.js +14 -1
  61. package/dist/snapshot-chunks.js.map +1 -1
  62. package/dist/stats.d.ts.map +1 -1
  63. package/dist/stats.js +1 -13
  64. package/dist/stats.js.map +1 -1
  65. package/dist/subscriptions/resolve.d.ts +1 -1
  66. package/dist/subscriptions/resolve.d.ts.map +1 -1
  67. package/dist/subscriptions/resolve.js +3 -16
  68. package/dist/subscriptions/resolve.js.map +1 -1
  69. package/dist/sync.d.ts.map +1 -1
  70. package/dist/sync.js +2 -4
  71. package/dist/sync.js.map +1 -1
  72. package/package.json +2 -2
  73. package/src/blobs/adapters/database.test.ts +7 -0
  74. package/src/blobs/adapters/database.ts +119 -39
  75. package/src/blobs/manager.ts +339 -53
  76. package/src/blobs/migrate.ts +16 -8
  77. package/src/blobs/types.ts +4 -0
  78. package/src/dialect/helpers.ts +19 -0
  79. package/src/handlers/collection.ts +17 -86
  80. package/src/handlers/create-handler.ts +9 -44
  81. package/src/notify.ts +15 -40
  82. package/src/proxy/collection.ts +5 -27
  83. package/src/proxy/handler.ts +2 -2
  84. package/src/proxy/index.ts +0 -2
  85. package/src/proxy/oplog.ts +1 -9
  86. package/src/prune.ts +1 -12
  87. package/src/pull.ts +280 -105
  88. package/src/push.ts +14 -43
  89. package/src/snapshot-chunks/db-metadata.ts +107 -27
  90. package/src/snapshot-chunks.ts +18 -0
  91. package/src/stats.ts +1 -12
  92. package/src/subscriptions/resolve.ts +4 -20
  93. package/src/sync.ts +6 -6
@@ -30,19 +30,66 @@ export interface BlobManagerOptions<
30
30
  defaultExpiresIn?: number;
31
31
  /** How long incomplete uploads are kept before cleanup. Default: 86400 (24 hours) */
32
32
  uploadTtlSeconds?: number;
33
+ /** Optional cleanup throughput tuning knobs. */
34
+ cleanupTuning?: BlobCleanupTuning;
33
35
  }
34
36
 
37
+ export interface BlobCleanupTuning {
38
+ /** Cleanup select/delete batch size. Default: 250 */
39
+ batchSize?: number;
40
+ /** Max concurrent storage deletes during cleanup. Default: 8 */
41
+ storageDeleteConcurrency?: number;
42
+ /** Max concurrent reference checks for completed uploads. Default: 16 */
43
+ referenceCheckConcurrency?: number;
44
+ }
45
+
46
+ export const BLOB_CLEANUP_TUNING_PRESETS = {
47
+ server: {
48
+ batchSize: 500,
49
+ storageDeleteConcurrency: 16,
50
+ referenceCheckConcurrency: 24,
51
+ },
52
+ edge: {
53
+ batchSize: 100,
54
+ storageDeleteConcurrency: 4,
55
+ referenceCheckConcurrency: 8,
56
+ },
57
+ } as const satisfies Record<'server' | 'edge', Required<BlobCleanupTuning>>;
58
+
35
59
  export interface InitiateUploadOptions {
36
60
  hash: string;
37
61
  size: number;
38
62
  mimeType: string;
39
63
  actorId: string;
64
+ partitionId?: string;
40
65
  }
41
66
 
42
67
  export interface GetDownloadUrlOptions {
43
68
  hash: string;
44
69
  /** Optional: verify actor has access to this blob via a scope check */
45
70
  actorId?: string;
71
+ partitionId?: string;
72
+ }
73
+
74
+ export interface CompleteUploadOptions {
75
+ /**
76
+ * Optional actor identity to authorize upload completion.
77
+ * When provided, only the initiating actor may mark an upload complete.
78
+ */
79
+ actorId?: string;
80
+ partitionId?: string;
81
+ }
82
+
83
+ export interface BlobUploadRecord {
84
+ partitionId: string;
85
+ hash: string;
86
+ size: number;
87
+ mimeType: string;
88
+ status: 'pending' | 'complete';
89
+ actorId: string;
90
+ createdAt: string;
91
+ expiresAt: string;
92
+ completedAt: string | null;
46
93
  }
47
94
 
48
95
  /**
@@ -56,8 +103,78 @@ export function createBlobManager<DB extends SyncBlobUploadsDb>(
56
103
  adapter,
57
104
  defaultExpiresIn = 3600,
58
105
  uploadTtlSeconds = 86400,
106
+ cleanupTuning,
59
107
  } = options;
60
108
 
109
+ function positiveIntOrDefault(value: number | undefined, fallback: number) {
110
+ if (value === undefined) return fallback;
111
+ if (!Number.isFinite(value)) return fallback;
112
+ const normalized = Math.trunc(value);
113
+ return normalized > 0 ? normalized : fallback;
114
+ }
115
+
116
+ function resolvePartitionId(partitionId?: string): string {
117
+ return partitionId ?? 'default';
118
+ }
119
+
120
+ function toStoragePartitionOptions(partitionId: string): {
121
+ partitionId?: string;
122
+ } {
123
+ if (partitionId === 'default') return {};
124
+ return { partitionId };
125
+ }
126
+
127
+ const CLEANUP_BATCH_SIZE = positiveIntOrDefault(
128
+ cleanupTuning?.batchSize,
129
+ 250
130
+ );
131
+ const STORAGE_DELETE_CONCURRENCY = positiveIntOrDefault(
132
+ cleanupTuning?.storageDeleteConcurrency,
133
+ 8
134
+ );
135
+ const REFERENCE_CHECK_CONCURRENCY = positiveIntOrDefault(
136
+ cleanupTuning?.referenceCheckConcurrency,
137
+ 16
138
+ );
139
+
140
+ async function runWithConcurrency<T>(
141
+ items: readonly T[],
142
+ concurrency: number,
143
+ worker: (item: T) => Promise<void>
144
+ ): Promise<void> {
145
+ if (items.length === 0) return;
146
+
147
+ const workerCount = Math.max(1, Math.min(concurrency, items.length));
148
+ let nextIndex = 0;
149
+
150
+ async function runWorker(): Promise<void> {
151
+ while (nextIndex < items.length) {
152
+ const index = nextIndex;
153
+ nextIndex += 1;
154
+ const item = items[index];
155
+ if (item === undefined) continue;
156
+ await worker(item);
157
+ }
158
+ }
159
+
160
+ await Promise.all(Array.from({ length: workerCount }, () => runWorker()));
161
+ }
162
+
163
+ async function deleteUploadRowsByHashes(
164
+ partitionId: string,
165
+ hashes: readonly string[]
166
+ ): Promise<number> {
167
+ if (hashes.length === 0) return 0;
168
+
169
+ const deletedResult = await sql`
170
+ delete from ${sql.table('sync_blob_uploads')}
171
+ where partition_id = ${partitionId}
172
+ and hash in (${sql.join(hashes)})
173
+ `.execute(db);
174
+
175
+ return Number(deletedResult.numAffectedRows ?? 0);
176
+ }
177
+
61
178
  return {
62
179
  /**
63
180
  * Initiate a blob upload.
@@ -68,6 +185,8 @@ export function createBlobManager<DB extends SyncBlobUploadsDb>(
68
185
  opts: InitiateUploadOptions
69
186
  ): Promise<BlobUploadInitResponse> {
70
187
  const { hash, size, mimeType, actorId } = opts;
188
+ const partitionId = resolvePartitionId(opts.partitionId);
189
+ const storagePartitionOptions = toStoragePartitionOptions(partitionId);
71
190
 
72
191
  // Validate hash format
73
192
  if (!parseBlobHash(hash)) {
@@ -75,13 +194,15 @@ export function createBlobManager<DB extends SyncBlobUploadsDb>(
75
194
  }
76
195
 
77
196
  // Check if blob already exists (deduplication)
78
- const exists = await adapter.exists(hash);
197
+ const exists = await adapter.exists(hash, storagePartitionOptions);
79
198
  if (exists) {
80
199
  // Also check if we have a complete upload record
81
200
  const existingResult = await sql<{ status: 'pending' | 'complete' }>`
82
201
  select status
83
202
  from ${sql.table('sync_blob_uploads')}
84
- where hash = ${hash} and status = 'complete'
203
+ where partition_id = ${partitionId}
204
+ and hash = ${hash}
205
+ and status = 'complete'
85
206
  limit 1
86
207
  `.execute(db);
87
208
  const existing = existingResult.rows[0];
@@ -98,6 +219,7 @@ export function createBlobManager<DB extends SyncBlobUploadsDb>(
98
219
 
99
220
  await sql`
100
221
  insert into ${sql.table('sync_blob_uploads')} (
222
+ partition_id,
101
223
  hash,
102
224
  size,
103
225
  mime_type,
@@ -107,6 +229,7 @@ export function createBlobManager<DB extends SyncBlobUploadsDb>(
107
229
  completed_at
108
230
  )
109
231
  values (
232
+ ${partitionId},
110
233
  ${hash},
111
234
  ${size},
112
235
  ${mimeType},
@@ -115,7 +238,7 @@ export function createBlobManager<DB extends SyncBlobUploadsDb>(
115
238
  ${existsExpiresAt},
116
239
  ${existsCompletedAt}
117
240
  )
118
- on conflict (hash) do nothing
241
+ on conflict (partition_id, hash) do nothing
119
242
  `.execute(db);
120
243
 
121
244
  return { exists: true };
@@ -128,6 +251,7 @@ export function createBlobManager<DB extends SyncBlobUploadsDb>(
128
251
 
129
252
  await sql`
130
253
  insert into ${sql.table('sync_blob_uploads')} (
254
+ partition_id,
131
255
  hash,
132
256
  size,
133
257
  mime_type,
@@ -137,6 +261,7 @@ export function createBlobManager<DB extends SyncBlobUploadsDb>(
137
261
  completed_at
138
262
  )
139
263
  values (
264
+ ${partitionId},
140
265
  ${hash},
141
266
  ${size},
142
267
  ${mimeType},
@@ -145,7 +270,7 @@ export function createBlobManager<DB extends SyncBlobUploadsDb>(
145
270
  ${expiresAt},
146
271
  ${null}
147
272
  )
148
- on conflict (hash)
273
+ on conflict (partition_id, hash)
149
274
  do update set
150
275
  size = ${size},
151
276
  mime_type = ${mimeType},
@@ -158,6 +283,7 @@ export function createBlobManager<DB extends SyncBlobUploadsDb>(
158
283
  // Generate presigned upload URL
159
284
  const signed = await adapter.signUpload({
160
285
  hash,
286
+ partitionId: storagePartitionOptions.partitionId,
161
287
  size,
162
288
  mimeType,
163
289
  expiresIn: defaultExpiresIn,
@@ -177,7 +303,13 @@ export function createBlobManager<DB extends SyncBlobUploadsDb>(
177
303
  *
178
304
  * Verifies the blob exists in storage and marks the upload as complete.
179
305
  */
180
- async completeUpload(hash: string): Promise<BlobUploadCompleteResponse> {
306
+ async completeUpload(
307
+ hash: string,
308
+ options?: CompleteUploadOptions
309
+ ): Promise<BlobUploadCompleteResponse> {
310
+ const partitionId = resolvePartitionId(options?.partitionId);
311
+ const storagePartitionOptions = toStoragePartitionOptions(partitionId);
312
+
181
313
  // Validate hash format
182
314
  if (!parseBlobHash(hash)) {
183
315
  return { ok: false, error: 'Invalid blob hash format' };
@@ -189,11 +321,12 @@ export function createBlobManager<DB extends SyncBlobUploadsDb>(
189
321
  size: number;
190
322
  mime_type: string;
191
323
  status: 'pending' | 'complete';
324
+ actor_id: string;
192
325
  created_at: string;
193
326
  }>`
194
- select hash, size, mime_type, status, created_at
327
+ select hash, size, mime_type, status, actor_id, created_at
195
328
  from ${sql.table('sync_blob_uploads')}
196
- where hash = ${hash}
329
+ where partition_id = ${partitionId} and hash = ${hash}
197
330
  limit 1
198
331
  `.execute(db);
199
332
  const upload = uploadResult.rows[0];
@@ -202,6 +335,13 @@ export function createBlobManager<DB extends SyncBlobUploadsDb>(
202
335
  return { ok: false, error: 'Upload not found' };
203
336
  }
204
337
 
338
+ if (
339
+ options?.actorId !== undefined &&
340
+ upload.actor_id !== options.actorId
341
+ ) {
342
+ return { ok: false, error: 'FORBIDDEN' };
343
+ }
344
+
205
345
  if (upload.status === 'complete') {
206
346
  // Already complete - return metadata
207
347
  return {
@@ -217,14 +357,14 @@ export function createBlobManager<DB extends SyncBlobUploadsDb>(
217
357
  }
218
358
 
219
359
  // Verify blob exists in storage
220
- const exists = await adapter.exists(hash);
360
+ const exists = await adapter.exists(hash, storagePartitionOptions);
221
361
  if (!exists) {
222
362
  return { ok: false, error: 'Blob not found in storage' };
223
363
  }
224
364
 
225
365
  // Optionally verify size matches
226
366
  if (adapter.getMetadata) {
227
- const meta = await adapter.getMetadata(hash);
367
+ const meta = await adapter.getMetadata(hash, storagePartitionOptions);
228
368
  if (meta && meta.size !== upload.size) {
229
369
  return {
230
370
  ok: false,
@@ -238,7 +378,7 @@ export function createBlobManager<DB extends SyncBlobUploadsDb>(
238
378
  await sql`
239
379
  update ${sql.table('sync_blob_uploads')}
240
380
  set status = 'complete', completed_at = ${completedAt}
241
- where hash = ${hash}
381
+ where partition_id = ${partitionId} and hash = ${hash}
242
382
  `.execute(db);
243
383
 
244
384
  return {
@@ -260,6 +400,8 @@ export function createBlobManager<DB extends SyncBlobUploadsDb>(
260
400
  opts: GetDownloadUrlOptions
261
401
  ): Promise<{ url: string; expiresAt: string; metadata: BlobMetadata }> {
262
402
  const { hash } = opts;
403
+ const partitionId = resolvePartitionId(opts.partitionId);
404
+ const storagePartitionOptions = toStoragePartitionOptions(partitionId);
263
405
 
264
406
  // Validate hash format
265
407
  if (!parseBlobHash(hash)) {
@@ -276,7 +418,9 @@ export function createBlobManager<DB extends SyncBlobUploadsDb>(
276
418
  }>`
277
419
  select hash, size, mime_type, status, created_at
278
420
  from ${sql.table('sync_blob_uploads')}
279
- where hash = ${hash} and status = 'complete'
421
+ where partition_id = ${partitionId}
422
+ and hash = ${hash}
423
+ and status = 'complete'
280
424
  limit 1
281
425
  `.execute(db);
282
426
  const upload = uploadResult.rows[0];
@@ -288,6 +432,7 @@ export function createBlobManager<DB extends SyncBlobUploadsDb>(
288
432
  // Generate presigned download URL
289
433
  const url = await adapter.signDownload({
290
434
  hash,
435
+ partitionId: storagePartitionOptions.partitionId,
291
436
  expiresIn: defaultExpiresIn,
292
437
  });
293
438
 
@@ -308,10 +453,70 @@ export function createBlobManager<DB extends SyncBlobUploadsDb>(
308
453
  };
309
454
  },
310
455
 
456
+ /**
457
+ * Get upload record for a blob hash, including pending uploads.
458
+ */
459
+ async getUploadRecord(
460
+ hash: string,
461
+ options?: { partitionId?: string }
462
+ ): Promise<BlobUploadRecord | null> {
463
+ const partitionId = resolvePartitionId(options?.partitionId);
464
+ if (!parseBlobHash(hash)) {
465
+ return null;
466
+ }
467
+
468
+ const uploadResult = await sql<{
469
+ partition_id: string;
470
+ hash: string;
471
+ size: number;
472
+ mime_type: string;
473
+ status: 'pending' | 'complete';
474
+ actor_id: string;
475
+ created_at: string;
476
+ expires_at: string;
477
+ completed_at: string | null;
478
+ }>`
479
+ select
480
+ partition_id,
481
+ hash,
482
+ size,
483
+ mime_type,
484
+ status,
485
+ actor_id,
486
+ created_at,
487
+ expires_at,
488
+ completed_at
489
+ from ${sql.table('sync_blob_uploads')}
490
+ where partition_id = ${partitionId} and hash = ${hash}
491
+ limit 1
492
+ `.execute(db);
493
+ const upload = uploadResult.rows[0];
494
+
495
+ if (!upload) {
496
+ return null;
497
+ }
498
+
499
+ return {
500
+ partitionId: upload.partition_id,
501
+ hash: upload.hash,
502
+ size: upload.size,
503
+ mimeType: upload.mime_type,
504
+ status: upload.status,
505
+ actorId: upload.actor_id,
506
+ createdAt: upload.created_at,
507
+ expiresAt: upload.expires_at,
508
+ completedAt: upload.completed_at,
509
+ };
510
+ },
511
+
311
512
  /**
312
513
  * Get blob metadata without generating a download URL.
313
514
  */
314
- async getMetadata(hash: string): Promise<BlobMetadata | null> {
515
+ async getMetadata(
516
+ hash: string,
517
+ options?: { partitionId?: string }
518
+ ): Promise<BlobMetadata | null> {
519
+ const partitionId = resolvePartitionId(options?.partitionId);
315
520
  // Validate hash format
316
521
  if (!parseBlobHash(hash)) {
317
522
  return null;
@@ -326,7 +531,9 @@ export function createBlobManager<DB extends SyncBlobUploadsDb>(
326
531
  }>`
327
532
  select hash, size, mime_type, status, created_at
328
533
  from ${sql.table('sync_blob_uploads')}
329
- where hash = ${hash} and status = 'complete'
534
+ where partition_id = ${partitionId}
535
+ and hash = ${hash}
536
+ and status = 'complete'
330
537
  limit 1
331
538
  `.execute(db);
332
539
  const upload = uploadResult.rows[0];
@@ -347,13 +554,19 @@ export function createBlobManager<DB extends SyncBlobUploadsDb>(
347
554
  /**
348
555
  * Check if a blob exists and is complete.
349
556
  */
350
- async exists(hash: string): Promise<boolean> {
557
+ async exists(
558
+ hash: string,
559
+ options?: { partitionId?: string }
560
+ ): Promise<boolean> {
561
+ const partitionId = resolvePartitionId(options?.partitionId);
351
562
  if (!parseBlobHash(hash)) return false;
352
563
 
353
564
  const rowResult = await sql<{ hash: string }>`
354
565
  select hash
355
566
  from ${sql.table('sync_blob_uploads')}
356
- where hash = ${hash} and status = 'complete'
567
+ where partition_id = ${partitionId}
568
+ and hash = ${hash}
569
+ and status = 'complete'
357
570
  limit 1
358
571
  `.execute(db);
359
572
 
@@ -372,62 +585,131 @@ export function createBlobManager<DB extends SyncBlobUploadsDb>(
372
585
  isReferenced?: (hash: string) => Promise<boolean>;
373
586
  /** Delete from storage too (not just tracking table) */
374
587
  deleteFromStorage?: boolean;
588
+ /** Optional partition filter for cleanup */
589
+ partitionId?: string;
375
590
  }): Promise<{ deleted: number }> {
376
591
  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;
592
+ const partitionId = resolvePartitionId(options?.partitionId);
593
+ const storagePartitionOptions = toStoragePartitionOptions(partitionId);
385
594
 
386
595
  let deleted = 0;
387
596
 
388
- for (const row of expired) {
597
+ // Find and delete expired pending uploads in batches.
598
+ while (true) {
599
+ const expiredResult = await sql<{ hash: string }>`
600
+ select hash
601
+ from ${sql.table('sync_blob_uploads')}
602
+ where partition_id = ${partitionId}
603
+ and status = 'pending'
604
+ and expires_at < ${now}
605
+ order by expires_at asc
606
+ limit ${CLEANUP_BATCH_SIZE}
607
+ `.execute(db);
608
+ const expiredHashes = expiredResult.rows
609
+ .map((row) => row.hash)
610
+ .filter((hash): hash is string => typeof hash === 'string');
611
+
612
+ if (expiredHashes.length === 0) {
613
+ break;
614
+ }
615
+
389
616
  if (options?.deleteFromStorage) {
390
- try {
391
- await adapter.delete(row.hash);
392
- } catch {
393
- // Ignore storage errors during cleanup
394
- }
617
+ await runWithConcurrency(
618
+ expiredHashes,
619
+ STORAGE_DELETE_CONCURRENCY,
620
+ async (hash) => {
621
+ try {
622
+ await adapter.delete(hash, storagePartitionOptions);
623
+ } catch {
624
+ // Ignore storage errors during cleanup
625
+ }
626
+ }
627
+ );
395
628
  }
396
629
 
397
- await sql`
398
- delete from ${sql.table('sync_blob_uploads')}
399
- where hash = ${row.hash}
400
- `.execute(db);
630
+ deleted += await deleteUploadRowsByHashes(partitionId, expiredHashes);
401
631
 
402
- deleted++;
632
+ if (expiredHashes.length < CLEANUP_BATCH_SIZE) {
633
+ break;
634
+ }
403
635
  }
404
636
 
405
637
  // If reference check provided, also clean up unreferenced complete uploads
638
+ // in batches with bounded parallelism.
406
639
  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;
640
+ const isReferenced = options.isReferenced;
641
+ let afterHash: string | null = null;
642
+
643
+ while (true) {
644
+ let completeRows: Array<{ hash: string }>;
645
+ if (afterHash === null) {
646
+ const completeResult = await sql<{ hash: string }>`
647
+ select hash
648
+ from ${sql.table('sync_blob_uploads')}
649
+ where partition_id = ${partitionId}
650
+ and status = 'complete'
651
+ order by hash asc
652
+ limit ${CLEANUP_BATCH_SIZE}
653
+ `.execute(db);
654
+ completeRows = completeResult.rows;
655
+ } else {
656
+ const completeResult = await sql<{ hash: string }>`
657
+ select hash
658
+ from ${sql.table('sync_blob_uploads')}
659
+ where partition_id = ${partitionId}
660
+ and status = 'complete'
661
+ and hash > ${afterHash}
662
+ order by hash asc
663
+ limit ${CLEANUP_BATCH_SIZE}
664
+ `.execute(db);
665
+ completeRows = completeResult.rows;
666
+ }
413
667
 
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
668
+ const completeHashes = completeRows
669
+ .map((row) => row.hash)
670
+ .filter((hash): hash is string => typeof hash === 'string');
671
+
672
+ if (completeHashes.length === 0) {
673
+ break;
674
+ }
675
+
676
+ afterHash = completeHashes[completeHashes.length - 1] ?? afterHash;
677
+
678
+ const unreferencedHashes: string[] = [];
679
+ await runWithConcurrency(
680
+ completeHashes,
681
+ REFERENCE_CHECK_CONCURRENCY,
682
+ async (hash) => {
683
+ const referenced = await isReferenced(hash);
684
+ if (!referenced) {
685
+ unreferencedHashes.push(hash);
422
686
  }
423
687
  }
688
+ );
424
689
 
425
- await sql`
426
- delete from ${sql.table('sync_blob_uploads')}
427
- where hash = ${row.hash}
428
- `.execute(db);
690
+ if (unreferencedHashes.length > 0) {
691
+ if (options?.deleteFromStorage) {
692
+ await runWithConcurrency(
693
+ unreferencedHashes,
694
+ STORAGE_DELETE_CONCURRENCY,
695
+ async (hash) => {
696
+ try {
697
+ await adapter.delete(hash, storagePartitionOptions);
698
+ } catch {
699
+ // Ignore storage errors during cleanup
700
+ }
701
+ }
702
+ );
703
+ }
704
+
705
+ deleted += await deleteUploadRowsByHashes(
706
+ partitionId,
707
+ unreferencedHashes
708
+ );
709
+ }
429
710
 
430
- deleted++;
711
+ if (completeHashes.length < CLEANUP_BATCH_SIZE) {
712
+ break;
431
713
  }
432
714
  }
433
715
  }
@@ -455,6 +737,8 @@ export interface BlobCleanupSchedulerOptions {
455
737
  deleteFromStorage?: boolean;
456
738
  /** Optional: Check if a blob hash is referenced by any row */
457
739
  isReferenced?: (hash: string) => Promise<boolean>;
740
+ /** Optional partition to scope cleanup. Defaults to "default". */
741
+ partitionId?: string;
458
742
  /** Optional: Called after each cleanup run */
459
743
  onCleanup?: (result: { deleted: number; error?: Error }) => void;
460
744
  }
@@ -499,6 +783,7 @@ export function createBlobCleanupScheduler(
499
783
  intervalMs = 3600000, // 1 hour
500
784
  deleteFromStorage = true,
501
785
  isReferenced,
786
+ partitionId,
502
787
  onCleanup,
503
788
  } = options;
504
789
 
@@ -516,6 +801,7 @@ export function createBlobCleanupScheduler(
516
801
  const result = await blobManager.cleanup({
517
802
  deleteFromStorage,
518
803
  isReferenced,
804
+ partitionId,
519
805
  });
520
806
 
521
807
  onCleanup?.({ deleted: result.deleted });