@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,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
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @syncular/server - Blob storage exports
3
+ */
4
+
5
+ export * from './adapters/database';
6
+ export * from './adapters/s3';
7
+ export * from './manager';
8
+ export * from './migrate';
9
+ export * from './types';