@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.
- package/dist/blobs/adapters/database.d.ts +26 -9
- package/dist/blobs/adapters/database.d.ts.map +1 -1
- package/dist/blobs/adapters/database.js +65 -21
- package/dist/blobs/adapters/database.js.map +1 -1
- package/dist/blobs/manager.d.ts +60 -3
- package/dist/blobs/manager.d.ts.map +1 -1
- package/dist/blobs/manager.js +227 -56
- package/dist/blobs/manager.js.map +1 -1
- package/dist/blobs/migrate.d.ts.map +1 -1
- package/dist/blobs/migrate.js +16 -8
- package/dist/blobs/migrate.js.map +1 -1
- package/dist/blobs/types.d.ts +4 -0
- package/dist/blobs/types.d.ts.map +1 -1
- package/dist/dialect/helpers.d.ts +3 -0
- package/dist/dialect/helpers.d.ts.map +1 -1
- package/dist/dialect/helpers.js +17 -0
- package/dist/dialect/helpers.js.map +1 -1
- package/dist/handlers/collection.d.ts +0 -2
- package/dist/handlers/collection.d.ts.map +1 -1
- package/dist/handlers/collection.js +5 -56
- package/dist/handlers/collection.js.map +1 -1
- package/dist/handlers/create-handler.d.ts +0 -4
- package/dist/handlers/create-handler.d.ts.map +1 -1
- package/dist/handlers/create-handler.js +6 -34
- package/dist/handlers/create-handler.js.map +1 -1
- package/dist/notify.d.ts.map +1 -1
- package/dist/notify.js +13 -37
- package/dist/notify.js.map +1 -1
- package/dist/proxy/collection.d.ts +0 -2
- package/dist/proxy/collection.d.ts.map +1 -1
- package/dist/proxy/collection.js +2 -17
- package/dist/proxy/collection.js.map +1 -1
- package/dist/proxy/handler.d.ts +1 -1
- package/dist/proxy/handler.d.ts.map +1 -1
- package/dist/proxy/handler.js +1 -2
- package/dist/proxy/handler.js.map +1 -1
- package/dist/proxy/index.d.ts +1 -1
- package/dist/proxy/index.d.ts.map +1 -1
- package/dist/proxy/index.js +1 -1
- package/dist/proxy/index.js.map +1 -1
- package/dist/proxy/oplog.d.ts.map +1 -1
- package/dist/proxy/oplog.js +1 -7
- package/dist/proxy/oplog.js.map +1 -1
- package/dist/prune.d.ts.map +1 -1
- package/dist/prune.js +1 -13
- package/dist/prune.js.map +1 -1
- package/dist/pull.d.ts.map +1 -1
- package/dist/pull.js +186 -54
- package/dist/pull.js.map +1 -1
- package/dist/push.d.ts +1 -1
- package/dist/push.d.ts.map +1 -1
- package/dist/push.js +9 -36
- package/dist/push.js.map +1 -1
- package/dist/snapshot-chunks/db-metadata.d.ts +18 -0
- package/dist/snapshot-chunks/db-metadata.d.ts.map +1 -1
- package/dist/snapshot-chunks/db-metadata.js +71 -23
- package/dist/snapshot-chunks/db-metadata.js.map +1 -1
- package/dist/snapshot-chunks.d.ts +5 -1
- package/dist/snapshot-chunks.d.ts.map +1 -1
- package/dist/snapshot-chunks.js +14 -1
- package/dist/snapshot-chunks.js.map +1 -1
- package/dist/stats.d.ts.map +1 -1
- package/dist/stats.js +1 -13
- package/dist/stats.js.map +1 -1
- package/dist/subscriptions/resolve.d.ts +1 -1
- package/dist/subscriptions/resolve.d.ts.map +1 -1
- package/dist/subscriptions/resolve.js +3 -16
- package/dist/subscriptions/resolve.js.map +1 -1
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +2 -4
- package/dist/sync.js.map +1 -1
- package/package.json +2 -2
- package/src/blobs/adapters/database.test.ts +7 -0
- package/src/blobs/adapters/database.ts +119 -39
- package/src/blobs/manager.ts +339 -53
- package/src/blobs/migrate.ts +16 -8
- package/src/blobs/types.ts +4 -0
- package/src/dialect/helpers.ts +19 -0
- package/src/handlers/collection.ts +17 -86
- package/src/handlers/create-handler.ts +9 -44
- package/src/notify.ts +15 -40
- package/src/proxy/collection.ts +5 -27
- package/src/proxy/handler.ts +2 -2
- package/src/proxy/index.ts +0 -2
- package/src/proxy/oplog.ts +1 -9
- package/src/prune.ts +1 -12
- package/src/pull.ts +280 -105
- package/src/push.ts +14 -43
- package/src/snapshot-chunks/db-metadata.ts +107 -27
- package/src/snapshot-chunks.ts +18 -0
- package/src/stats.ts +1 -12
- package/src/subscriptions/resolve.ts +4 -20
- package/src/sync.ts +6 -6
package/src/blobs/manager.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
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 });
|