@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.
- package/dist/blobs/adapters/database.d.ts +83 -0
- package/dist/blobs/adapters/database.d.ts.map +1 -0
- package/dist/blobs/adapters/database.js +180 -0
- package/dist/blobs/adapters/database.js.map +1 -0
- package/dist/blobs/adapters/s3.d.ts +82 -0
- package/dist/blobs/adapters/s3.d.ts.map +1 -0
- package/dist/blobs/adapters/s3.js +170 -0
- package/dist/blobs/adapters/s3.js.map +1 -0
- package/dist/blobs/index.d.ts +9 -0
- package/dist/blobs/index.d.ts.map +1 -0
- package/dist/blobs/index.js +9 -0
- package/dist/blobs/index.js.map +1 -0
- package/dist/blobs/manager.d.ts +195 -0
- package/dist/blobs/manager.d.ts.map +1 -0
- package/dist/blobs/manager.js +440 -0
- package/dist/blobs/manager.js.map +1 -0
- package/dist/blobs/migrate.d.ts +27 -0
- package/dist/blobs/migrate.d.ts.map +1 -0
- package/dist/blobs/migrate.js +119 -0
- package/dist/blobs/migrate.js.map +1 -0
- package/dist/blobs/types.d.ts +54 -0
- package/dist/blobs/types.d.ts.map +1 -0
- package/dist/blobs/types.js +5 -0
- package/dist/blobs/types.js.map +1 -0
- package/dist/clients.d.ts +14 -0
- package/dist/clients.d.ts.map +1 -0
- package/dist/clients.js +7 -0
- package/dist/clients.js.map +1 -0
- package/dist/compaction.d.ts +27 -0
- package/dist/compaction.d.ts.map +1 -0
- package/dist/compaction.js +49 -0
- package/dist/compaction.js.map +1 -0
- package/dist/dialect/index.d.ts +5 -0
- package/dist/dialect/index.d.ts.map +1 -0
- package/dist/dialect/index.js +5 -0
- package/dist/dialect/index.js.map +1 -0
- package/dist/dialect/types.d.ts +170 -0
- package/dist/dialect/types.d.ts.map +1 -0
- package/dist/dialect/types.js +8 -0
- package/dist/dialect/types.js.map +1 -0
- package/dist/helpers/conflict.d.ts +52 -0
- package/dist/helpers/conflict.d.ts.map +1 -0
- package/dist/helpers/conflict.js +49 -0
- package/dist/helpers/conflict.js.map +1 -0
- package/dist/helpers/emitted-change.d.ts +56 -0
- package/dist/helpers/emitted-change.d.ts.map +1 -0
- package/dist/helpers/emitted-change.js +46 -0
- package/dist/helpers/emitted-change.js.map +1 -0
- package/dist/helpers/index.d.ts +10 -0
- package/dist/helpers/index.d.ts.map +1 -0
- package/dist/helpers/index.js +10 -0
- package/dist/helpers/index.js.map +1 -0
- package/dist/helpers/paginate.d.ts +49 -0
- package/dist/helpers/paginate.d.ts.map +1 -0
- package/dist/helpers/paginate.js +54 -0
- package/dist/helpers/paginate.js.map +1 -0
- package/dist/helpers/scope-strings.d.ts +74 -0
- package/dist/helpers/scope-strings.d.ts.map +1 -0
- package/dist/helpers/scope-strings.js +82 -0
- package/dist/helpers/scope-strings.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/migrate.d.ts +14 -0
- package/dist/migrate.d.ts.map +1 -0
- package/dist/migrate.js +13 -0
- package/dist/migrate.js.map +1 -0
- package/dist/proxy/handler.d.ts +42 -0
- package/dist/proxy/handler.d.ts.map +1 -0
- package/dist/proxy/handler.js +99 -0
- package/dist/proxy/handler.js.map +1 -0
- package/dist/proxy/index.d.ts +9 -0
- package/dist/proxy/index.d.ts.map +1 -0
- package/dist/proxy/index.js +14 -0
- package/dist/proxy/index.js.map +1 -0
- package/dist/proxy/mutation-detector.d.ts +31 -0
- package/dist/proxy/mutation-detector.d.ts.map +1 -0
- package/dist/proxy/mutation-detector.js +61 -0
- package/dist/proxy/mutation-detector.js.map +1 -0
- package/dist/proxy/oplog.d.ts +30 -0
- package/dist/proxy/oplog.d.ts.map +1 -0
- package/dist/proxy/oplog.js +110 -0
- package/dist/proxy/oplog.js.map +1 -0
- package/dist/proxy/registry.d.ts +35 -0
- package/dist/proxy/registry.d.ts.map +1 -0
- package/dist/proxy/registry.js +49 -0
- package/dist/proxy/registry.js.map +1 -0
- package/dist/proxy/types.d.ts +44 -0
- package/dist/proxy/types.d.ts.map +1 -0
- package/dist/proxy/types.js +7 -0
- package/dist/proxy/types.js.map +1 -0
- package/dist/prune.d.ts +37 -0
- package/dist/prune.d.ts.map +1 -0
- package/dist/prune.js +112 -0
- package/dist/prune.js.map +1 -0
- package/dist/pull.d.ts +31 -0
- package/dist/pull.d.ts.map +1 -0
- package/dist/pull.js +414 -0
- package/dist/pull.js.map +1 -0
- package/dist/push.d.ts +33 -0
- package/dist/push.d.ts.map +1 -0
- package/dist/push.js +329 -0
- package/dist/push.js.map +1 -0
- package/dist/realtime/in-memory.d.ts +13 -0
- package/dist/realtime/in-memory.d.ts.map +1 -0
- package/dist/realtime/in-memory.js +28 -0
- package/dist/realtime/in-memory.js.map +1 -0
- package/dist/realtime/index.d.ts +3 -0
- package/dist/realtime/index.d.ts.map +1 -0
- package/dist/realtime/index.js +2 -0
- package/dist/realtime/index.js.map +1 -0
- package/dist/realtime/types.d.ts +50 -0
- package/dist/realtime/types.d.ts.map +1 -0
- package/dist/realtime/types.js +7 -0
- package/dist/realtime/types.js.map +1 -0
- package/dist/schema.d.ts +164 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +10 -0
- package/dist/schema.js.map +1 -0
- package/dist/shapes/create-handler.d.ts +119 -0
- package/dist/shapes/create-handler.d.ts.map +1 -0
- package/dist/shapes/create-handler.js +327 -0
- package/dist/shapes/create-handler.js.map +1 -0
- package/dist/shapes/index.d.ts +4 -0
- package/dist/shapes/index.d.ts.map +1 -0
- package/dist/shapes/index.js +4 -0
- package/dist/shapes/index.js.map +1 -0
- package/dist/shapes/registry.d.ts +20 -0
- package/dist/shapes/registry.d.ts.map +1 -0
- package/dist/shapes/registry.js +88 -0
- package/dist/shapes/registry.js.map +1 -0
- package/dist/shapes/types.d.ts +204 -0
- package/dist/shapes/types.d.ts.map +1 -0
- package/dist/shapes/types.js +2 -0
- package/dist/shapes/types.js.map +1 -0
- package/dist/snapshot-chunks/adapters/s3.d.ts +63 -0
- package/dist/snapshot-chunks/adapters/s3.d.ts.map +1 -0
- package/dist/snapshot-chunks/adapters/s3.js +50 -0
- package/dist/snapshot-chunks/adapters/s3.js.map +1 -0
- package/dist/snapshot-chunks/db-metadata.d.ts +33 -0
- package/dist/snapshot-chunks/db-metadata.d.ts.map +1 -0
- package/dist/snapshot-chunks/db-metadata.js +169 -0
- package/dist/snapshot-chunks/db-metadata.js.map +1 -0
- package/dist/snapshot-chunks/index.d.ts +9 -0
- package/dist/snapshot-chunks/index.d.ts.map +1 -0
- package/dist/snapshot-chunks/index.js +9 -0
- package/dist/snapshot-chunks/index.js.map +1 -0
- package/dist/snapshot-chunks/types.d.ts +65 -0
- package/dist/snapshot-chunks/types.d.ts.map +1 -0
- package/dist/snapshot-chunks/types.js +8 -0
- package/dist/snapshot-chunks/types.js.map +1 -0
- package/dist/snapshot-chunks.d.ts +59 -0
- package/dist/snapshot-chunks.d.ts.map +1 -0
- package/dist/snapshot-chunks.js +202 -0
- package/dist/snapshot-chunks.js.map +1 -0
- package/dist/stats.d.ts +19 -0
- package/dist/stats.d.ts.map +1 -0
- package/dist/stats.js +57 -0
- package/dist/stats.js.map +1 -0
- package/dist/subscriptions/index.d.ts +2 -0
- package/dist/subscriptions/index.d.ts.map +1 -0
- package/dist/subscriptions/index.js +2 -0
- package/dist/subscriptions/index.js.map +1 -0
- package/dist/subscriptions/resolve.d.ts +35 -0
- package/dist/subscriptions/resolve.d.ts.map +1 -0
- package/dist/subscriptions/resolve.js +134 -0
- package/dist/subscriptions/resolve.js.map +1 -0
- package/package.json +80 -0
- package/src/blobs/adapters/database.ts +290 -0
- package/src/blobs/adapters/s3.ts +271 -0
- package/src/blobs/index.ts +9 -0
- package/src/blobs/manager.ts +600 -0
- package/src/blobs/migrate.ts +150 -0
- package/src/blobs/types.ts +70 -0
- package/src/clients.ts +21 -0
- package/src/compaction.ts +77 -0
- package/src/dialect/index.ts +5 -0
- package/src/dialect/types.ts +222 -0
- package/src/helpers/conflict.ts +64 -0
- package/src/helpers/emitted-change.ts +69 -0
- package/src/helpers/index.ts +10 -0
- package/src/helpers/paginate.ts +82 -0
- package/src/helpers/scope-strings.ts +101 -0
- package/src/index.ts +28 -0
- package/src/migrate.ts +20 -0
- package/src/proxy/handler.ts +152 -0
- package/src/proxy/index.ts +18 -0
- package/src/proxy/mutation-detector.ts +83 -0
- package/src/proxy/oplog.ts +144 -0
- package/src/proxy/registry.ts +56 -0
- package/src/proxy/types.ts +46 -0
- package/src/prune.ts +200 -0
- package/src/pull.ts +551 -0
- package/src/push.ts +457 -0
- package/src/realtime/in-memory.ts +33 -0
- package/src/realtime/index.ts +5 -0
- package/src/realtime/types.ts +55 -0
- package/src/schema.ts +172 -0
- package/src/shapes/create-handler.ts +590 -0
- package/src/shapes/index.ts +3 -0
- package/src/shapes/registry.ts +109 -0
- package/src/shapes/types.ts +267 -0
- package/src/snapshot-chunks/adapters/s3.ts +68 -0
- package/src/snapshot-chunks/db-metadata.ts +238 -0
- package/src/snapshot-chunks/index.ts +9 -0
- package/src/snapshot-chunks/types.ts +79 -0
- package/src/snapshot-chunks.ts +301 -0
- package/src/stats.ts +104 -0
- package/src/subscriptions/index.ts +1 -0
- package/src/subscriptions/resolve.ts +185 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database blob storage adapter.
|
|
3
|
+
*
|
|
4
|
+
* Stores blobs directly in the database. Useful for development and small deployments.
|
|
5
|
+
* Since there's no external service, this adapter generates signed tokens that allow
|
|
6
|
+
* uploads/downloads through the server's blob routes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
BlobSignDownloadOptions,
|
|
11
|
+
BlobSignedUpload,
|
|
12
|
+
BlobSignUploadOptions,
|
|
13
|
+
BlobStorageAdapter,
|
|
14
|
+
} from '@syncular/core';
|
|
15
|
+
import { type Kysely, sql } from 'kysely';
|
|
16
|
+
import type { SyncBlobsDb } from '../types';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Token signer interface for creating/verifying upload/download tokens.
|
|
20
|
+
*/
|
|
21
|
+
export interface BlobTokenSigner {
|
|
22
|
+
/**
|
|
23
|
+
* Sign a token for blob upload/download authorization.
|
|
24
|
+
* @param payload The data to sign
|
|
25
|
+
* @param expiresIn Expiration time in seconds
|
|
26
|
+
* @returns A signed token string
|
|
27
|
+
*/
|
|
28
|
+
sign(
|
|
29
|
+
payload: { hash: string; action: 'upload' | 'download'; expiresAt: number },
|
|
30
|
+
expiresIn: number
|
|
31
|
+
): Promise<string>;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Verify and decode a signed token.
|
|
35
|
+
* @returns The payload if valid, null if invalid/expired
|
|
36
|
+
*/
|
|
37
|
+
verify(token: string): Promise<{
|
|
38
|
+
hash: string;
|
|
39
|
+
action: 'upload' | 'download';
|
|
40
|
+
expiresAt: number;
|
|
41
|
+
} | null>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Create a simple HMAC-based token signer.
|
|
46
|
+
*/
|
|
47
|
+
export function createHmacTokenSigner(secret: string): BlobTokenSigner {
|
|
48
|
+
const encoder = new TextEncoder();
|
|
49
|
+
|
|
50
|
+
async function hmacSign(data: string): Promise<string> {
|
|
51
|
+
const key = await crypto.subtle.importKey(
|
|
52
|
+
'raw',
|
|
53
|
+
encoder.encode(secret),
|
|
54
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
55
|
+
false,
|
|
56
|
+
['sign']
|
|
57
|
+
);
|
|
58
|
+
const signature = await crypto.subtle.sign(
|
|
59
|
+
'HMAC',
|
|
60
|
+
key,
|
|
61
|
+
encoder.encode(data)
|
|
62
|
+
);
|
|
63
|
+
return bufferToHex(new Uint8Array(signature));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
async sign(payload, _expiresIn) {
|
|
68
|
+
const data = JSON.stringify(payload);
|
|
69
|
+
const dataB64 = btoa(data);
|
|
70
|
+
const sig = await hmacSign(dataB64);
|
|
71
|
+
return `${dataB64}.${sig}`;
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
async verify(token) {
|
|
75
|
+
const [dataB64, sig] = token.split('.');
|
|
76
|
+
if (!dataB64 || !sig) return null;
|
|
77
|
+
|
|
78
|
+
const expectedSig = await hmacSign(dataB64);
|
|
79
|
+
if (sig !== expectedSig) return null;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const data = JSON.parse(atob(dataB64)) as {
|
|
83
|
+
hash: string;
|
|
84
|
+
action: 'upload' | 'download';
|
|
85
|
+
expiresAt: number;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
if (Date.now() > data.expiresAt) return null;
|
|
89
|
+
|
|
90
|
+
return data;
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function bufferToHex(buffer: Uint8Array): string {
|
|
99
|
+
return Array.from(buffer)
|
|
100
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
101
|
+
.join('');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface DatabaseBlobStorageAdapterOptions<
|
|
105
|
+
DB extends SyncBlobsDb = SyncBlobsDb,
|
|
106
|
+
> {
|
|
107
|
+
/** Kysely database instance */
|
|
108
|
+
db: Kysely<DB>;
|
|
109
|
+
/** Base URL for the blob routes (e.g., "https://api.example.com/api/sync") */
|
|
110
|
+
baseUrl: string;
|
|
111
|
+
/** Token signer for authorization */
|
|
112
|
+
tokenSigner: BlobTokenSigner;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Create a database blob storage adapter.
|
|
117
|
+
*
|
|
118
|
+
* This adapter stores blobs directly in the database and generates signed URLs
|
|
119
|
+
* that point back to the server for upload/download.
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* ```typescript
|
|
123
|
+
* const adapter = createDatabaseBlobStorageAdapter({
|
|
124
|
+
* db: kysely,
|
|
125
|
+
* baseUrl: 'https://api.example.com/api/sync',
|
|
126
|
+
* tokenSigner: createHmacTokenSigner(process.env.BLOB_SECRET),
|
|
127
|
+
* });
|
|
128
|
+
* ```
|
|
129
|
+
*/
|
|
130
|
+
export function createDatabaseBlobStorageAdapter<DB extends SyncBlobsDb>(
|
|
131
|
+
options: DatabaseBlobStorageAdapterOptions<DB>
|
|
132
|
+
): BlobStorageAdapter {
|
|
133
|
+
const { db, baseUrl, tokenSigner } = options;
|
|
134
|
+
|
|
135
|
+
// Normalize base URL (remove trailing slash)
|
|
136
|
+
const normalizedBaseUrl = baseUrl.replace(/\/$/, '');
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
name: 'database',
|
|
140
|
+
|
|
141
|
+
async signUpload(opts: BlobSignUploadOptions): Promise<BlobSignedUpload> {
|
|
142
|
+
const expiresAt = Date.now() + opts.expiresIn * 1000;
|
|
143
|
+
const token = await tokenSigner.sign(
|
|
144
|
+
{ hash: opts.hash, action: 'upload', expiresAt },
|
|
145
|
+
opts.expiresIn
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// URL points to server's blob upload endpoint
|
|
149
|
+
const url = `${normalizedBaseUrl}/blobs/${encodeURIComponent(opts.hash)}/upload?token=${encodeURIComponent(token)}`;
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
url,
|
|
153
|
+
method: 'PUT',
|
|
154
|
+
headers: {
|
|
155
|
+
'Content-Type': opts.mimeType,
|
|
156
|
+
'Content-Length': String(opts.size),
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
async signDownload(opts: BlobSignDownloadOptions): Promise<string> {
|
|
162
|
+
const expiresAt = Date.now() + opts.expiresIn * 1000;
|
|
163
|
+
const token = await tokenSigner.sign(
|
|
164
|
+
{ hash: opts.hash, action: 'download', expiresAt },
|
|
165
|
+
opts.expiresIn
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
return `${normalizedBaseUrl}/blobs/${encodeURIComponent(opts.hash)}/download?token=${encodeURIComponent(token)}`;
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
async exists(hash: string): Promise<boolean> {
|
|
172
|
+
const rowResult = await sql<{ hash: string }>`
|
|
173
|
+
select hash
|
|
174
|
+
from ${sql.table('sync_blobs')}
|
|
175
|
+
where hash = ${hash}
|
|
176
|
+
limit 1
|
|
177
|
+
`.execute(db);
|
|
178
|
+
return rowResult.rows.length > 0;
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
async delete(hash: string): Promise<void> {
|
|
182
|
+
await sql`
|
|
183
|
+
delete from ${sql.table('sync_blobs')}
|
|
184
|
+
where hash = ${hash}
|
|
185
|
+
`.execute(db);
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
async getMetadata(
|
|
189
|
+
hash: string
|
|
190
|
+
): Promise<{ size: number; mimeType?: string } | null> {
|
|
191
|
+
const rowResult = await sql<{ size: number; mime_type: string }>`
|
|
192
|
+
select size, mime_type
|
|
193
|
+
from ${sql.table('sync_blobs')}
|
|
194
|
+
where hash = ${hash}
|
|
195
|
+
limit 1
|
|
196
|
+
`.execute(db);
|
|
197
|
+
const row = rowResult.rows[0];
|
|
198
|
+
|
|
199
|
+
if (!row) return null;
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
size: row.size,
|
|
203
|
+
mimeType: row.mime_type,
|
|
204
|
+
};
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
async put(
|
|
208
|
+
hash: string,
|
|
209
|
+
data: Uint8Array,
|
|
210
|
+
metadata?: Record<string, unknown>
|
|
211
|
+
): Promise<void> {
|
|
212
|
+
const mimeType =
|
|
213
|
+
typeof metadata?.mimeType === 'string'
|
|
214
|
+
? metadata.mimeType
|
|
215
|
+
: 'application/octet-stream';
|
|
216
|
+
await storeBlobInDatabase(db, {
|
|
217
|
+
hash,
|
|
218
|
+
size: data.length,
|
|
219
|
+
mimeType,
|
|
220
|
+
body: data,
|
|
221
|
+
});
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
async get(hash: string): Promise<Uint8Array | null> {
|
|
225
|
+
const result = await readBlobFromDatabase(db, hash);
|
|
226
|
+
return result?.body ?? null;
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Store a blob in the database.
|
|
233
|
+
* Called by the server routes when handling direct uploads.
|
|
234
|
+
*/
|
|
235
|
+
export async function storeBlobInDatabase<DB extends SyncBlobsDb>(
|
|
236
|
+
db: Kysely<DB>,
|
|
237
|
+
args: {
|
|
238
|
+
hash: string;
|
|
239
|
+
size: number;
|
|
240
|
+
mimeType: string;
|
|
241
|
+
body: Uint8Array;
|
|
242
|
+
}
|
|
243
|
+
): Promise<void> {
|
|
244
|
+
await sql`
|
|
245
|
+
insert into ${sql.table('sync_blobs')} (
|
|
246
|
+
hash,
|
|
247
|
+
size,
|
|
248
|
+
mime_type,
|
|
249
|
+
body,
|
|
250
|
+
created_at
|
|
251
|
+
)
|
|
252
|
+
values (
|
|
253
|
+
${args.hash},
|
|
254
|
+
${args.size},
|
|
255
|
+
${args.mimeType},
|
|
256
|
+
${args.body},
|
|
257
|
+
${new Date().toISOString()}
|
|
258
|
+
)
|
|
259
|
+
on conflict (hash) do nothing
|
|
260
|
+
`.execute(db);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Read a blob from the database.
|
|
265
|
+
* Called by the server routes when handling direct downloads.
|
|
266
|
+
*/
|
|
267
|
+
export async function readBlobFromDatabase<DB extends SyncBlobsDb>(
|
|
268
|
+
db: Kysely<DB>,
|
|
269
|
+
hash: string
|
|
270
|
+
): Promise<{ body: Uint8Array; mimeType: string; size: number } | null> {
|
|
271
|
+
const rowResult = await sql<{
|
|
272
|
+
body: Uint8Array;
|
|
273
|
+
mime_type: string;
|
|
274
|
+
size: number;
|
|
275
|
+
}>`
|
|
276
|
+
select body, mime_type, size
|
|
277
|
+
from ${sql.table('sync_blobs')}
|
|
278
|
+
where hash = ${hash}
|
|
279
|
+
limit 1
|
|
280
|
+
`.execute(db);
|
|
281
|
+
const row = rowResult.rows[0];
|
|
282
|
+
|
|
283
|
+
if (!row) return null;
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
body: row.body,
|
|
287
|
+
mimeType: row.mime_type,
|
|
288
|
+
size: row.size,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S3-compatible blob storage adapter.
|
|
3
|
+
*
|
|
4
|
+
* Works with AWS S3, Cloudflare R2, MinIO, and other S3-compatible services.
|
|
5
|
+
* Requires @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner as peer dependencies.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
BlobSignDownloadOptions,
|
|
10
|
+
BlobSignedUpload,
|
|
11
|
+
BlobSignUploadOptions,
|
|
12
|
+
BlobStorageAdapter,
|
|
13
|
+
} from '@syncular/core';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* S3 client interface (minimal subset of @aws-sdk/client-s3).
|
|
17
|
+
* This allows users to pass in their own configured S3 client.
|
|
18
|
+
*/
|
|
19
|
+
export interface S3ClientLike {
|
|
20
|
+
send(command: unknown): Promise<unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Function to create presigned URLs.
|
|
25
|
+
* This should be getSignedUrl from @aws-sdk/s3-request-presigner.
|
|
26
|
+
*/
|
|
27
|
+
export type GetSignedUrlFn = (
|
|
28
|
+
client: S3ClientLike,
|
|
29
|
+
command: unknown,
|
|
30
|
+
options: { expiresIn: number }
|
|
31
|
+
) => Promise<string>;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* S3 command constructors.
|
|
35
|
+
* These should be imported from @aws-sdk/client-s3.
|
|
36
|
+
*/
|
|
37
|
+
export interface S3Commands {
|
|
38
|
+
PutObjectCommand: new (input: {
|
|
39
|
+
Bucket: string;
|
|
40
|
+
Key: string;
|
|
41
|
+
ContentLength: number;
|
|
42
|
+
ContentType: string;
|
|
43
|
+
ChecksumSHA256?: string;
|
|
44
|
+
}) => unknown;
|
|
45
|
+
GetObjectCommand: new (input: { Bucket: string; Key: string }) => unknown;
|
|
46
|
+
HeadObjectCommand: new (input: { Bucket: string; Key: string }) => unknown;
|
|
47
|
+
DeleteObjectCommand: new (input: { Bucket: string; Key: string }) => unknown;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface S3BlobStorageAdapterOptions {
|
|
51
|
+
/** S3 client instance */
|
|
52
|
+
client: S3ClientLike;
|
|
53
|
+
/** S3 bucket name */
|
|
54
|
+
bucket: string;
|
|
55
|
+
/** Optional key prefix for all blobs */
|
|
56
|
+
keyPrefix?: string;
|
|
57
|
+
/** S3 command constructors */
|
|
58
|
+
commands: S3Commands;
|
|
59
|
+
/** getSignedUrl function from @aws-sdk/s3-request-presigner */
|
|
60
|
+
getSignedUrl: GetSignedUrlFn;
|
|
61
|
+
/**
|
|
62
|
+
* Whether to require SHA-256 checksum validation on upload.
|
|
63
|
+
* Supported by S3 and R2. Default: true.
|
|
64
|
+
*/
|
|
65
|
+
requireChecksum?: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Create an S3-compatible blob storage adapter.
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* ```typescript
|
|
73
|
+
* import { S3Client, PutObjectCommand, GetObjectCommand, HeadObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
|
|
74
|
+
* import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
|
75
|
+
*
|
|
76
|
+
* const adapter = createS3BlobStorageAdapter({
|
|
77
|
+
* client: new S3Client({ region: 'us-east-1' }),
|
|
78
|
+
* bucket: 'my-bucket',
|
|
79
|
+
* keyPrefix: 'blobs/',
|
|
80
|
+
* commands: { PutObjectCommand, GetObjectCommand, HeadObjectCommand, DeleteObjectCommand },
|
|
81
|
+
* getSignedUrl,
|
|
82
|
+
* });
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
export function createS3BlobStorageAdapter(
|
|
86
|
+
options: S3BlobStorageAdapterOptions
|
|
87
|
+
): BlobStorageAdapter {
|
|
88
|
+
const {
|
|
89
|
+
client,
|
|
90
|
+
bucket,
|
|
91
|
+
keyPrefix = '',
|
|
92
|
+
commands,
|
|
93
|
+
getSignedUrl,
|
|
94
|
+
requireChecksum = true,
|
|
95
|
+
} = options;
|
|
96
|
+
|
|
97
|
+
function getKey(hash: string): string {
|
|
98
|
+
// Remove "sha256:" prefix and use hex as key
|
|
99
|
+
const hex = hash.startsWith('sha256:') ? hash.slice(7) : hash;
|
|
100
|
+
return `${keyPrefix}${hex}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
name: 's3',
|
|
105
|
+
|
|
106
|
+
async signUpload(opts: BlobSignUploadOptions): Promise<BlobSignedUpload> {
|
|
107
|
+
const key = getKey(opts.hash);
|
|
108
|
+
|
|
109
|
+
// Extract hex hash for checksum (S3 expects base64-encoded SHA-256)
|
|
110
|
+
const hexHash = opts.hash.startsWith('sha256:')
|
|
111
|
+
? opts.hash.slice(7)
|
|
112
|
+
: opts.hash;
|
|
113
|
+
|
|
114
|
+
// Convert hex to base64 for S3 checksum header
|
|
115
|
+
const checksumBase64 = hexToBase64(hexHash);
|
|
116
|
+
|
|
117
|
+
const commandInput: {
|
|
118
|
+
Bucket: string;
|
|
119
|
+
Key: string;
|
|
120
|
+
ContentLength: number;
|
|
121
|
+
ContentType: string;
|
|
122
|
+
ChecksumSHA256?: string;
|
|
123
|
+
} = {
|
|
124
|
+
Bucket: bucket,
|
|
125
|
+
Key: key,
|
|
126
|
+
ContentLength: opts.size,
|
|
127
|
+
ContentType: opts.mimeType,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
if (requireChecksum) {
|
|
131
|
+
commandInput.ChecksumSHA256 = checksumBase64;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const command = new commands.PutObjectCommand(commandInput);
|
|
135
|
+
const url = await getSignedUrl(client, command, {
|
|
136
|
+
expiresIn: opts.expiresIn,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const headers: Record<string, string> = {
|
|
140
|
+
'Content-Type': opts.mimeType,
|
|
141
|
+
'Content-Length': String(opts.size),
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
if (requireChecksum) {
|
|
145
|
+
headers['x-amz-checksum-sha256'] = checksumBase64;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
url,
|
|
150
|
+
method: 'PUT',
|
|
151
|
+
headers,
|
|
152
|
+
};
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
async signDownload(opts: BlobSignDownloadOptions): Promise<string> {
|
|
156
|
+
const key = getKey(opts.hash);
|
|
157
|
+
const command = new commands.GetObjectCommand({
|
|
158
|
+
Bucket: bucket,
|
|
159
|
+
Key: key,
|
|
160
|
+
});
|
|
161
|
+
return getSignedUrl(client, command, { expiresIn: opts.expiresIn });
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
async exists(hash: string): Promise<boolean> {
|
|
165
|
+
const key = getKey(hash);
|
|
166
|
+
try {
|
|
167
|
+
const command = new commands.HeadObjectCommand({
|
|
168
|
+
Bucket: bucket,
|
|
169
|
+
Key: key,
|
|
170
|
+
});
|
|
171
|
+
await client.send(command);
|
|
172
|
+
return true;
|
|
173
|
+
} catch (err) {
|
|
174
|
+
// Check for NotFound error
|
|
175
|
+
if (isNotFoundError(err)) {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
throw err;
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
async delete(hash: string): Promise<void> {
|
|
183
|
+
const key = getKey(hash);
|
|
184
|
+
const command = new commands.DeleteObjectCommand({
|
|
185
|
+
Bucket: bucket,
|
|
186
|
+
Key: key,
|
|
187
|
+
});
|
|
188
|
+
await client.send(command);
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
async getMetadata(
|
|
192
|
+
hash: string
|
|
193
|
+
): Promise<{ size: number; mimeType?: string } | null> {
|
|
194
|
+
const key = getKey(hash);
|
|
195
|
+
try {
|
|
196
|
+
const command = new commands.HeadObjectCommand({
|
|
197
|
+
Bucket: bucket,
|
|
198
|
+
Key: key,
|
|
199
|
+
});
|
|
200
|
+
const response = (await client.send(command)) as {
|
|
201
|
+
ContentLength?: number;
|
|
202
|
+
ContentType?: string;
|
|
203
|
+
};
|
|
204
|
+
return {
|
|
205
|
+
size: response.ContentLength ?? 0,
|
|
206
|
+
mimeType: response.ContentType,
|
|
207
|
+
};
|
|
208
|
+
} catch (err) {
|
|
209
|
+
if (isNotFoundError(err)) {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
throw err;
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function isNotFoundError(err: unknown): boolean {
|
|
219
|
+
if (typeof err !== 'object' || err === null) return false;
|
|
220
|
+
const e = err as { name?: string; $metadata?: { httpStatusCode?: number } };
|
|
221
|
+
return (
|
|
222
|
+
e.name === 'NotFound' ||
|
|
223
|
+
e.name === 'NoSuchKey' ||
|
|
224
|
+
e.$metadata?.httpStatusCode === 404
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function hexToBase64(hex: string): string {
|
|
229
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
230
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
231
|
+
bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Use Buffer if available (Node/Bun), otherwise manual base64
|
|
235
|
+
if (typeof Buffer !== 'undefined') {
|
|
236
|
+
return Buffer.from(bytes).toString('base64');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Manual base64 encoding
|
|
240
|
+
const chars =
|
|
241
|
+
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
242
|
+
let result = '';
|
|
243
|
+
const len = bytes.length;
|
|
244
|
+
const remainder = len % 3;
|
|
245
|
+
|
|
246
|
+
for (let i = 0; i < len - remainder; i += 3) {
|
|
247
|
+
const a = bytes[i]!;
|
|
248
|
+
const b = bytes[i + 1]!;
|
|
249
|
+
const c = bytes[i + 2]!;
|
|
250
|
+
result +=
|
|
251
|
+
chars.charAt((a >> 2) & 0x3f) +
|
|
252
|
+
chars.charAt(((a << 4) | (b >> 4)) & 0x3f) +
|
|
253
|
+
chars.charAt(((b << 2) | (c >> 6)) & 0x3f) +
|
|
254
|
+
chars.charAt(c & 0x3f);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (remainder === 1) {
|
|
258
|
+
const a = bytes[len - 1]!;
|
|
259
|
+
result += `${chars.charAt((a >> 2) & 0x3f) + chars.charAt((a << 4) & 0x3f)}==`;
|
|
260
|
+
} else if (remainder === 2) {
|
|
261
|
+
const a = bytes[len - 2]!;
|
|
262
|
+
const b = bytes[len - 1]!;
|
|
263
|
+
result +=
|
|
264
|
+
chars.charAt((a >> 2) & 0x3f) +
|
|
265
|
+
chars.charAt(((a << 4) | (b >> 4)) & 0x3f) +
|
|
266
|
+
chars.charAt((b << 2) & 0x3f) +
|
|
267
|
+
'=';
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return result;
|
|
271
|
+
}
|