@syncular/server 0.0.1-100

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 (225) 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 +202 -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/base.d.ts +83 -0
  34. package/dist/dialect/base.d.ts.map +1 -0
  35. package/dist/dialect/base.js +144 -0
  36. package/dist/dialect/base.js.map +1 -0
  37. package/dist/dialect/helpers.d.ts +10 -0
  38. package/dist/dialect/helpers.d.ts.map +1 -0
  39. package/dist/dialect/helpers.js +59 -0
  40. package/dist/dialect/helpers.js.map +1 -0
  41. package/dist/dialect/index.d.ts +7 -0
  42. package/dist/dialect/index.d.ts.map +1 -0
  43. package/dist/dialect/index.js +7 -0
  44. package/dist/dialect/index.js.map +1 -0
  45. package/dist/dialect/types.d.ts +149 -0
  46. package/dist/dialect/types.d.ts.map +1 -0
  47. package/dist/dialect/types.js +8 -0
  48. package/dist/dialect/types.js.map +1 -0
  49. package/dist/helpers/conflict.d.ts +52 -0
  50. package/dist/helpers/conflict.d.ts.map +1 -0
  51. package/dist/helpers/conflict.js +49 -0
  52. package/dist/helpers/conflict.js.map +1 -0
  53. package/dist/helpers/emitted-change.d.ts +56 -0
  54. package/dist/helpers/emitted-change.d.ts.map +1 -0
  55. package/dist/helpers/emitted-change.js +46 -0
  56. package/dist/helpers/emitted-change.js.map +1 -0
  57. package/dist/helpers/index.d.ts +10 -0
  58. package/dist/helpers/index.d.ts.map +1 -0
  59. package/dist/helpers/index.js +10 -0
  60. package/dist/helpers/index.js.map +1 -0
  61. package/dist/helpers/paginate.d.ts +49 -0
  62. package/dist/helpers/paginate.d.ts.map +1 -0
  63. package/dist/helpers/paginate.js +54 -0
  64. package/dist/helpers/paginate.js.map +1 -0
  65. package/dist/helpers/scope-strings.d.ts +74 -0
  66. package/dist/helpers/scope-strings.d.ts.map +1 -0
  67. package/dist/helpers/scope-strings.js +82 -0
  68. package/dist/helpers/scope-strings.js.map +1 -0
  69. package/dist/index.d.ts +28 -0
  70. package/dist/index.d.ts.map +1 -0
  71. package/dist/index.js +27 -0
  72. package/dist/index.js.map +1 -0
  73. package/dist/migrate.d.ts +14 -0
  74. package/dist/migrate.d.ts.map +1 -0
  75. package/dist/migrate.js +13 -0
  76. package/dist/migrate.js.map +1 -0
  77. package/dist/proxy/handler.d.ts +42 -0
  78. package/dist/proxy/handler.d.ts.map +1 -0
  79. package/dist/proxy/handler.js +102 -0
  80. package/dist/proxy/handler.js.map +1 -0
  81. package/dist/proxy/index.d.ts +9 -0
  82. package/dist/proxy/index.d.ts.map +1 -0
  83. package/dist/proxy/index.js +14 -0
  84. package/dist/proxy/index.js.map +1 -0
  85. package/dist/proxy/mutation-detector.d.ts +35 -0
  86. package/dist/proxy/mutation-detector.d.ts.map +1 -0
  87. package/dist/proxy/mutation-detector.js +246 -0
  88. package/dist/proxy/mutation-detector.js.map +1 -0
  89. package/dist/proxy/oplog.d.ts +30 -0
  90. package/dist/proxy/oplog.d.ts.map +1 -0
  91. package/dist/proxy/oplog.js +110 -0
  92. package/dist/proxy/oplog.js.map +1 -0
  93. package/dist/proxy/registry.d.ts +35 -0
  94. package/dist/proxy/registry.d.ts.map +1 -0
  95. package/dist/proxy/registry.js +49 -0
  96. package/dist/proxy/registry.js.map +1 -0
  97. package/dist/proxy/types.d.ts +44 -0
  98. package/dist/proxy/types.d.ts.map +1 -0
  99. package/dist/proxy/types.js +7 -0
  100. package/dist/proxy/types.js.map +1 -0
  101. package/dist/prune.d.ts +37 -0
  102. package/dist/prune.d.ts.map +1 -0
  103. package/dist/prune.js +112 -0
  104. package/dist/prune.js.map +1 -0
  105. package/dist/pull.d.ts +31 -0
  106. package/dist/pull.d.ts.map +1 -0
  107. package/dist/pull.js +608 -0
  108. package/dist/pull.js.map +1 -0
  109. package/dist/push.d.ts +33 -0
  110. package/dist/push.d.ts.map +1 -0
  111. package/dist/push.js +412 -0
  112. package/dist/push.js.map +1 -0
  113. package/dist/realtime/in-memory.d.ts +13 -0
  114. package/dist/realtime/in-memory.d.ts.map +1 -0
  115. package/dist/realtime/in-memory.js +28 -0
  116. package/dist/realtime/in-memory.js.map +1 -0
  117. package/dist/realtime/index.d.ts +3 -0
  118. package/dist/realtime/index.d.ts.map +1 -0
  119. package/dist/realtime/index.js +2 -0
  120. package/dist/realtime/index.js.map +1 -0
  121. package/dist/realtime/types.d.ts +50 -0
  122. package/dist/realtime/types.d.ts.map +1 -0
  123. package/dist/realtime/types.js +7 -0
  124. package/dist/realtime/types.js.map +1 -0
  125. package/dist/schema.d.ts +164 -0
  126. package/dist/schema.d.ts.map +1 -0
  127. package/dist/schema.js +10 -0
  128. package/dist/schema.js.map +1 -0
  129. package/dist/shapes/create-handler.d.ts +119 -0
  130. package/dist/shapes/create-handler.d.ts.map +1 -0
  131. package/dist/shapes/create-handler.js +327 -0
  132. package/dist/shapes/create-handler.js.map +1 -0
  133. package/dist/shapes/index.d.ts +4 -0
  134. package/dist/shapes/index.d.ts.map +1 -0
  135. package/dist/shapes/index.js +4 -0
  136. package/dist/shapes/index.js.map +1 -0
  137. package/dist/shapes/registry.d.ts +20 -0
  138. package/dist/shapes/registry.d.ts.map +1 -0
  139. package/dist/shapes/registry.js +88 -0
  140. package/dist/shapes/registry.js.map +1 -0
  141. package/dist/shapes/types.d.ts +204 -0
  142. package/dist/shapes/types.d.ts.map +1 -0
  143. package/dist/shapes/types.js +2 -0
  144. package/dist/shapes/types.js.map +1 -0
  145. package/dist/snapshot-chunks/adapters/s3.d.ts +74 -0
  146. package/dist/snapshot-chunks/adapters/s3.d.ts.map +1 -0
  147. package/dist/snapshot-chunks/adapters/s3.js +50 -0
  148. package/dist/snapshot-chunks/adapters/s3.js.map +1 -0
  149. package/dist/snapshot-chunks/db-metadata.d.ts +38 -0
  150. package/dist/snapshot-chunks/db-metadata.d.ts.map +1 -0
  151. package/dist/snapshot-chunks/db-metadata.js +324 -0
  152. package/dist/snapshot-chunks/db-metadata.js.map +1 -0
  153. package/dist/snapshot-chunks/index.d.ts +9 -0
  154. package/dist/snapshot-chunks/index.d.ts.map +1 -0
  155. package/dist/snapshot-chunks/index.js +9 -0
  156. package/dist/snapshot-chunks/index.js.map +1 -0
  157. package/dist/snapshot-chunks/types.d.ts +78 -0
  158. package/dist/snapshot-chunks/types.d.ts.map +1 -0
  159. package/dist/snapshot-chunks/types.js +8 -0
  160. package/dist/snapshot-chunks/types.js.map +1 -0
  161. package/dist/snapshot-chunks.d.ts +60 -0
  162. package/dist/snapshot-chunks.d.ts.map +1 -0
  163. package/dist/snapshot-chunks.js +223 -0
  164. package/dist/snapshot-chunks.js.map +1 -0
  165. package/dist/stats.d.ts +19 -0
  166. package/dist/stats.d.ts.map +1 -0
  167. package/dist/stats.js +57 -0
  168. package/dist/stats.js.map +1 -0
  169. package/dist/subscriptions/index.d.ts +2 -0
  170. package/dist/subscriptions/index.d.ts.map +1 -0
  171. package/dist/subscriptions/index.js +2 -0
  172. package/dist/subscriptions/index.js.map +1 -0
  173. package/dist/subscriptions/resolve.d.ts +35 -0
  174. package/dist/subscriptions/resolve.d.ts.map +1 -0
  175. package/dist/subscriptions/resolve.js +134 -0
  176. package/dist/subscriptions/resolve.js.map +1 -0
  177. package/package.json +80 -0
  178. package/src/blobs/adapters/database.test.ts +67 -0
  179. package/src/blobs/adapters/database.ts +315 -0
  180. package/src/blobs/adapters/s3.ts +271 -0
  181. package/src/blobs/index.ts +9 -0
  182. package/src/blobs/manager.ts +600 -0
  183. package/src/blobs/migrate.ts +150 -0
  184. package/src/blobs/types.ts +70 -0
  185. package/src/clients.ts +21 -0
  186. package/src/compaction.ts +77 -0
  187. package/src/dialect/base.ts +292 -0
  188. package/src/dialect/helpers.ts +61 -0
  189. package/src/dialect/index.ts +7 -0
  190. package/src/dialect/types.ts +197 -0
  191. package/src/helpers/conflict.ts +64 -0
  192. package/src/helpers/emitted-change.ts +69 -0
  193. package/src/helpers/index.ts +10 -0
  194. package/src/helpers/paginate.ts +82 -0
  195. package/src/helpers/scope-strings.ts +101 -0
  196. package/src/index.ts +28 -0
  197. package/src/migrate.ts +20 -0
  198. package/src/proxy/handler.test.ts +120 -0
  199. package/src/proxy/handler.ts +159 -0
  200. package/src/proxy/index.ts +18 -0
  201. package/src/proxy/mutation-detector.test.ts +71 -0
  202. package/src/proxy/mutation-detector.ts +281 -0
  203. package/src/proxy/oplog.ts +146 -0
  204. package/src/proxy/registry.ts +56 -0
  205. package/src/proxy/types.ts +46 -0
  206. package/src/prune.ts +200 -0
  207. package/src/pull.ts +858 -0
  208. package/src/push.ts +583 -0
  209. package/src/realtime/in-memory.ts +33 -0
  210. package/src/realtime/index.ts +5 -0
  211. package/src/realtime/types.ts +55 -0
  212. package/src/schema.ts +172 -0
  213. package/src/shapes/create-handler.ts +590 -0
  214. package/src/shapes/index.ts +3 -0
  215. package/src/shapes/registry.ts +109 -0
  216. package/src/shapes/types.ts +267 -0
  217. package/src/snapshot-chunks/adapters/s3.ts +68 -0
  218. package/src/snapshot-chunks/db-metadata.test.ts +100 -0
  219. package/src/snapshot-chunks/db-metadata.ts +466 -0
  220. package/src/snapshot-chunks/index.ts +9 -0
  221. package/src/snapshot-chunks/types.ts +103 -0
  222. package/src/snapshot-chunks.ts +329 -0
  223. package/src/stats.ts +104 -0
  224. package/src/subscriptions/index.ts +1 -0
  225. package/src/subscriptions/resolve.ts +185 -0
@@ -0,0 +1,466 @@
1
+ /**
2
+ * @syncular/server - Database-backed metadata store for snapshot chunks
3
+ *
4
+ * Stores chunk metadata in sync_snapshot_chunks_metadata table,
5
+ * body content in blob storage adapter.
6
+ */
7
+
8
+ import { createHash } from 'node:crypto';
9
+ import {
10
+ type BlobStorageAdapter,
11
+ SYNC_SNAPSHOT_CHUNK_COMPRESSION,
12
+ SYNC_SNAPSHOT_CHUNK_ENCODING,
13
+ type SyncSnapshotChunkCompression,
14
+ type SyncSnapshotChunkEncoding,
15
+ type SyncSnapshotChunkRef,
16
+ } from '@syncular/core';
17
+ import type { Kysely } from 'kysely';
18
+ import type { SyncCoreDb } from '../schema';
19
+ import type { SnapshotChunkMetadata, SnapshotChunkPageKey } from './types';
20
+
21
+ export interface DbMetadataSnapshotChunkStorageOptions {
22
+ /** Database instance */
23
+ db: Kysely<SyncCoreDb>;
24
+ /** Blob storage adapter for body content */
25
+ blobAdapter: BlobStorageAdapter;
26
+ /** Optional prefix for chunk IDs */
27
+ chunkIdPrefix?: string;
28
+ }
29
+
30
+ /**
31
+ * Create a snapshot chunk storage that uses:
32
+ * - Database for metadata (scope, commit seq, etc.)
33
+ * - Blob adapter for body content
34
+ */
35
+ export function createDbMetadataChunkStorage(
36
+ options: DbMetadataSnapshotChunkStorageOptions
37
+ ): {
38
+ name: string;
39
+ storeChunk: (
40
+ metadata: Omit<
41
+ SnapshotChunkMetadata,
42
+ 'chunkId' | 'byteLength' | 'blobHash'
43
+ > & {
44
+ body: Uint8Array;
45
+ }
46
+ ) => Promise<SyncSnapshotChunkRef>;
47
+ storeChunkStream: (
48
+ metadata: Omit<
49
+ SnapshotChunkMetadata,
50
+ 'chunkId' | 'byteLength' | 'blobHash'
51
+ > & {
52
+ bodyStream: ReadableStream<Uint8Array>;
53
+ byteLength?: number;
54
+ }
55
+ ) => Promise<SyncSnapshotChunkRef>;
56
+ readChunk: (chunkId: string) => Promise<Uint8Array | null>;
57
+ readChunkStream: (
58
+ chunkId: string
59
+ ) => Promise<ReadableStream<Uint8Array> | null>;
60
+ findChunk: (
61
+ pageKey: SnapshotChunkPageKey
62
+ ) => Promise<SyncSnapshotChunkRef | null>;
63
+ cleanupExpired: (beforeIso: string) => Promise<number>;
64
+ } {
65
+ const { db, blobAdapter, chunkIdPrefix = 'chunk_' } = options;
66
+
67
+ // Generate deterministic blob hash from chunk identity metadata.
68
+ function computeBlobHash(metadata: {
69
+ encoding: SyncSnapshotChunkEncoding;
70
+ compression: SyncSnapshotChunkCompression;
71
+ sha256: string;
72
+ }): string {
73
+ const digest = createHash('sha256')
74
+ .update(`${metadata.encoding}:${metadata.compression}:${metadata.sha256}`)
75
+ .digest('hex');
76
+ return `sha256:${digest}`;
77
+ }
78
+
79
+ function bytesToStream(bytes: Uint8Array): ReadableStream<Uint8Array> {
80
+ return new ReadableStream<Uint8Array>({
81
+ start(controller) {
82
+ controller.enqueue(bytes);
83
+ controller.close();
84
+ },
85
+ });
86
+ }
87
+
88
+ async function streamToBytes(
89
+ stream: ReadableStream<Uint8Array>
90
+ ): Promise<Uint8Array> {
91
+ const reader = stream.getReader();
92
+ try {
93
+ const chunks: Uint8Array[] = [];
94
+ let total = 0;
95
+
96
+ while (true) {
97
+ const { done, value } = await reader.read();
98
+ if (done) break;
99
+ if (!value) continue;
100
+ chunks.push(value);
101
+ total += value.length;
102
+ }
103
+
104
+ const out = new Uint8Array(total);
105
+ let offset = 0;
106
+ for (const chunk of chunks) {
107
+ out.set(chunk, offset);
108
+ offset += chunk.length;
109
+ }
110
+ return out;
111
+ } finally {
112
+ reader.releaseLock();
113
+ }
114
+ }
115
+
116
+ async function streamByteLength(
117
+ stream: ReadableStream<Uint8Array>
118
+ ): Promise<number> {
119
+ const reader = stream.getReader();
120
+ try {
121
+ let total = 0;
122
+ while (true) {
123
+ const { done, value } = await reader.read();
124
+ if (done) break;
125
+ if (!value) continue;
126
+ total += value.length;
127
+ }
128
+ return total;
129
+ } finally {
130
+ reader.releaseLock();
131
+ }
132
+ }
133
+
134
+ // Generate unique chunk ID
135
+ function generateChunkId(): string {
136
+ return `${chunkIdPrefix}${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
137
+ }
138
+
139
+ async function readStoredRef(args: {
140
+ partitionId: string;
141
+ scopeKey: string;
142
+ scope: string;
143
+ asOfCommitSeq: number;
144
+ rowCursor: string | null;
145
+ rowLimit: number;
146
+ encoding: SyncSnapshotChunkEncoding;
147
+ compression: SyncSnapshotChunkCompression;
148
+ nowIso?: string;
149
+ includeExpired?: boolean;
150
+ }): Promise<SyncSnapshotChunkRef | null> {
151
+ const nowIso = args.nowIso ?? new Date().toISOString();
152
+ const rowCursorKey = args.rowCursor ?? '';
153
+ const baseQuery = db
154
+ .selectFrom('sync_snapshot_chunks')
155
+ .select(['chunk_id', 'sha256', 'byte_length', 'encoding', 'compression'])
156
+ .where('partition_id', '=', args.partitionId)
157
+ .where('scope_key', '=', args.scopeKey)
158
+ .where('scope', '=', args.scope)
159
+ .where('as_of_commit_seq', '=', args.asOfCommitSeq)
160
+ .where('row_cursor', '=', rowCursorKey)
161
+ .where('row_limit', '=', args.rowLimit)
162
+ .where('encoding', '=', args.encoding)
163
+ .where('compression', '=', args.compression);
164
+
165
+ const row = await (args.includeExpired
166
+ ? baseQuery.executeTakeFirst()
167
+ : baseQuery.where('expires_at', '>', nowIso).executeTakeFirst());
168
+
169
+ if (!row) return null;
170
+
171
+ if (row.encoding !== SYNC_SNAPSHOT_CHUNK_ENCODING) {
172
+ throw new Error(
173
+ `Unexpected snapshot chunk encoding: ${String(row.encoding)}`
174
+ );
175
+ }
176
+ if (row.compression !== SYNC_SNAPSHOT_CHUNK_COMPRESSION) {
177
+ throw new Error(
178
+ `Unexpected snapshot chunk compression: ${String(row.compression)}`
179
+ );
180
+ }
181
+
182
+ return {
183
+ id: row.chunk_id,
184
+ sha256: row.sha256,
185
+ byteLength: Number(row.byte_length ?? 0),
186
+ encoding: row.encoding,
187
+ compression: row.compression,
188
+ };
189
+ }
190
+
191
+ async function readBlobHash(chunkId: string): Promise<string | null> {
192
+ const row = await db
193
+ .selectFrom('sync_snapshot_chunks')
194
+ .select(['blob_hash'])
195
+ .where('chunk_id', '=', chunkId)
196
+ .executeTakeFirst();
197
+ return row?.blob_hash ?? null;
198
+ }
199
+
200
+ async function upsertChunkMetadata(
201
+ metadata: Omit<
202
+ SnapshotChunkMetadata,
203
+ 'chunkId' | 'byteLength' | 'blobHash'
204
+ >,
205
+ args: { blobHash: string; byteLength: number }
206
+ ): Promise<void> {
207
+ const chunkId = generateChunkId();
208
+ const now = new Date().toISOString();
209
+
210
+ await db
211
+ .insertInto('sync_snapshot_chunks')
212
+ .values({
213
+ chunk_id: chunkId,
214
+ partition_id: metadata.partitionId,
215
+ scope_key: metadata.scopeKey,
216
+ scope: metadata.scope,
217
+ as_of_commit_seq: metadata.asOfCommitSeq,
218
+ row_cursor: metadata.rowCursor ?? '',
219
+ row_limit: metadata.rowLimit,
220
+ encoding: metadata.encoding,
221
+ compression: metadata.compression,
222
+ sha256: metadata.sha256,
223
+ byte_length: args.byteLength,
224
+ blob_hash: args.blobHash,
225
+ expires_at: metadata.expiresAt,
226
+ created_at: now,
227
+ })
228
+ .onConflict((oc) =>
229
+ oc
230
+ .columns([
231
+ 'partition_id',
232
+ 'scope_key',
233
+ 'scope',
234
+ 'as_of_commit_seq',
235
+ 'row_cursor',
236
+ 'row_limit',
237
+ 'encoding',
238
+ 'compression',
239
+ ])
240
+ .doUpdateSet({
241
+ expires_at: metadata.expiresAt,
242
+ blob_hash: args.blobHash,
243
+ sha256: metadata.sha256,
244
+ byte_length: args.byteLength,
245
+ row_cursor: metadata.rowCursor ?? '',
246
+ })
247
+ )
248
+ .execute();
249
+ }
250
+
251
+ async function readChunkStreamById(
252
+ chunkId: string
253
+ ): Promise<ReadableStream<Uint8Array> | null> {
254
+ const blobHash = await readBlobHash(chunkId);
255
+ if (!blobHash) return null;
256
+
257
+ if (blobAdapter.getStream) {
258
+ return blobAdapter.getStream(blobHash);
259
+ }
260
+
261
+ if (blobAdapter.get) {
262
+ const bytes = await blobAdapter.get(blobHash);
263
+ return bytes ? bytesToStream(bytes) : null;
264
+ }
265
+
266
+ throw new Error(
267
+ `Blob adapter ${blobAdapter.name} does not support direct get() for snapshot chunks`
268
+ );
269
+ }
270
+
271
+ return {
272
+ name: `db-metadata+${blobAdapter.name}`,
273
+
274
+ async storeChunk(
275
+ metadata: Omit<
276
+ SnapshotChunkMetadata,
277
+ 'chunkId' | 'byteLength' | 'blobHash'
278
+ > & {
279
+ body: Uint8Array;
280
+ }
281
+ ): Promise<SyncSnapshotChunkRef> {
282
+ const { body, ...metaWithoutBody } = metadata;
283
+ const blobHash = computeBlobHash(metaWithoutBody);
284
+
285
+ // Check if blob already exists (content-addressed dedup)
286
+ const blobExists = await blobAdapter.exists(blobHash);
287
+
288
+ if (!blobExists) {
289
+ // Store body in blob adapter
290
+ if (blobAdapter.put) {
291
+ await blobAdapter.put(blobHash, body);
292
+ } else if (blobAdapter.putStream) {
293
+ await blobAdapter.putStream(blobHash, bytesToStream(body));
294
+ } else {
295
+ throw new Error(
296
+ `Blob adapter ${blobAdapter.name} does not support direct put() for snapshot chunks`
297
+ );
298
+ }
299
+ }
300
+
301
+ await upsertChunkMetadata(metaWithoutBody, {
302
+ blobHash,
303
+ byteLength: body.length,
304
+ });
305
+
306
+ const storedRef = await readStoredRef({
307
+ partitionId: metaWithoutBody.partitionId,
308
+ scopeKey: metaWithoutBody.scopeKey,
309
+ scope: metaWithoutBody.scope,
310
+ asOfCommitSeq: metaWithoutBody.asOfCommitSeq,
311
+ rowCursor: metaWithoutBody.rowCursor,
312
+ rowLimit: metaWithoutBody.rowLimit,
313
+ encoding: metaWithoutBody.encoding,
314
+ compression: metaWithoutBody.compression,
315
+ includeExpired: true,
316
+ });
317
+
318
+ if (!storedRef) {
319
+ throw new Error('Failed to read stored snapshot chunk reference');
320
+ }
321
+
322
+ return storedRef;
323
+ },
324
+
325
+ async storeChunkStream(
326
+ metadata: Omit<
327
+ SnapshotChunkMetadata,
328
+ 'chunkId' | 'byteLength' | 'blobHash'
329
+ > & {
330
+ bodyStream: ReadableStream<Uint8Array>;
331
+ byteLength?: number;
332
+ }
333
+ ): Promise<SyncSnapshotChunkRef> {
334
+ const { bodyStream, byteLength, ...metaWithoutBody } = metadata;
335
+ const blobHash = computeBlobHash(metaWithoutBody);
336
+
337
+ const blobExists = await blobAdapter.exists(blobHash);
338
+ let observedByteLength: number;
339
+
340
+ if (!blobExists) {
341
+ if (blobAdapter.putStream) {
342
+ const [uploadStream, countStream] = bodyStream.tee();
343
+ const uploadPromise =
344
+ typeof byteLength === 'number'
345
+ ? blobAdapter.putStream(blobHash, uploadStream, {
346
+ byteLength,
347
+ contentLength: byteLength,
348
+ })
349
+ : blobAdapter.putStream(blobHash, uploadStream);
350
+ const countPromise = streamByteLength(countStream);
351
+
352
+ const [, countedByteLength] = await Promise.all([
353
+ uploadPromise,
354
+ countPromise,
355
+ ]);
356
+ observedByteLength = countedByteLength;
357
+ } else if (blobAdapter.put) {
358
+ const body = await streamToBytes(bodyStream);
359
+ await blobAdapter.put(blobHash, body);
360
+ observedByteLength = body.length;
361
+ } else {
362
+ throw new Error(
363
+ `Blob adapter ${blobAdapter.name} does not support direct put() for snapshot chunks`
364
+ );
365
+ }
366
+ } else if (typeof byteLength === 'number') {
367
+ observedByteLength = byteLength;
368
+ await bodyStream.cancel();
369
+ } else if (blobAdapter.getMetadata) {
370
+ const metadata = await blobAdapter.getMetadata(blobHash);
371
+ if (!metadata) {
372
+ throw new Error(
373
+ `Blob metadata missing for existing chunk ${blobHash}`
374
+ );
375
+ }
376
+ observedByteLength = metadata.size;
377
+ await bodyStream.cancel();
378
+ } else {
379
+ observedByteLength = await streamByteLength(bodyStream);
380
+ }
381
+
382
+ if (
383
+ typeof byteLength === 'number' &&
384
+ Number.isFinite(byteLength) &&
385
+ observedByteLength !== byteLength
386
+ ) {
387
+ throw new Error(
388
+ `Snapshot chunk byte length mismatch: expected ${byteLength}, got ${observedByteLength}`
389
+ );
390
+ }
391
+
392
+ await upsertChunkMetadata(metaWithoutBody, {
393
+ blobHash,
394
+ byteLength: observedByteLength,
395
+ });
396
+
397
+ const storedRef = await readStoredRef({
398
+ partitionId: metaWithoutBody.partitionId,
399
+ scopeKey: metaWithoutBody.scopeKey,
400
+ scope: metaWithoutBody.scope,
401
+ asOfCommitSeq: metaWithoutBody.asOfCommitSeq,
402
+ rowCursor: metaWithoutBody.rowCursor,
403
+ rowLimit: metaWithoutBody.rowLimit,
404
+ encoding: metaWithoutBody.encoding,
405
+ compression: metaWithoutBody.compression,
406
+ includeExpired: true,
407
+ });
408
+
409
+ if (!storedRef) {
410
+ throw new Error('Failed to read stored snapshot chunk reference');
411
+ }
412
+
413
+ return storedRef;
414
+ },
415
+
416
+ async readChunk(chunkId: string): Promise<Uint8Array | null> {
417
+ const stream = await readChunkStreamById(chunkId);
418
+ if (!stream) return null;
419
+ return streamToBytes(stream);
420
+ },
421
+
422
+ async readChunkStream(
423
+ chunkId: string
424
+ ): Promise<ReadableStream<Uint8Array> | null> {
425
+ return readChunkStreamById(chunkId);
426
+ },
427
+
428
+ async findChunk(
429
+ pageKey: SnapshotChunkPageKey
430
+ ): Promise<SyncSnapshotChunkRef | null> {
431
+ return readStoredRef(pageKey);
432
+ },
433
+
434
+ async cleanupExpired(beforeIso: string): Promise<number> {
435
+ // Find expired chunks
436
+ const expiredRows = await db
437
+ .selectFrom('sync_snapshot_chunks')
438
+ .select(['chunk_id', 'blob_hash'])
439
+ .where('expires_at', '<=', beforeIso)
440
+ .execute();
441
+
442
+ if (expiredRows.length === 0) return 0;
443
+
444
+ // Delete from blob storage (best effort)
445
+ for (const row of expiredRows) {
446
+ try {
447
+ await blobAdapter.delete(row.blob_hash);
448
+ } catch {
449
+ // Ignore deletion errors - blob may be shared or already deleted
450
+ // Log for observability but don't fail the cleanup
451
+ console.warn(
452
+ `Failed to delete blob ${row.blob_hash} for chunk ${row.chunk_id}, may be already deleted or shared`
453
+ );
454
+ }
455
+ }
456
+
457
+ // Delete metadata from database
458
+ const result = await db
459
+ .deleteFrom('sync_snapshot_chunks')
460
+ .where('expires_at', '<=', beforeIso)
461
+ .executeTakeFirst();
462
+
463
+ return Number(result.numDeletedRows ?? 0);
464
+ },
465
+ };
466
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @syncular/server - Snapshot chunk storage
3
+ *
4
+ * Separates chunk metadata (database) from body content (blob storage).
5
+ */
6
+
7
+ export * from './adapters/s3';
8
+ export * from './db-metadata';
9
+ export * from './types';
@@ -0,0 +1,103 @@
1
+ /**
2
+ * @syncular/server - Snapshot chunk storage types
3
+ *
4
+ * Separates chunk metadata (in database) from chunk body (in blob storage).
5
+ * Enables flexible storage backends (database, S3, R2, etc.)
6
+ */
7
+
8
+ import type {
9
+ SyncSnapshotChunkCompression,
10
+ SyncSnapshotChunkEncoding,
11
+ SyncSnapshotChunkRef,
12
+ } from '@syncular/core';
13
+
14
+ /**
15
+ * Page key for identifying a specific chunk
16
+ */
17
+ export interface SnapshotChunkPageKey {
18
+ partitionId: string;
19
+ scopeKey: string;
20
+ scope: string;
21
+ asOfCommitSeq: number;
22
+ rowCursor: string | null;
23
+ rowLimit: number;
24
+ encoding: SyncSnapshotChunkEncoding;
25
+ compression: SyncSnapshotChunkCompression;
26
+ }
27
+
28
+ /**
29
+ * Metadata stored in the database for each chunk
30
+ */
31
+ export interface SnapshotChunkMetadata {
32
+ chunkId: string;
33
+ partitionId: string;
34
+ scopeKey: string;
35
+ scope: string;
36
+ asOfCommitSeq: number;
37
+ rowCursor: string | null;
38
+ rowLimit: number;
39
+ encoding: SyncSnapshotChunkEncoding;
40
+ compression: SyncSnapshotChunkCompression;
41
+ sha256: string;
42
+ byteLength: number;
43
+ blobHash: string; // Reference to blob storage
44
+ expiresAt: string;
45
+ }
46
+
47
+ /**
48
+ * Storage interface for snapshot chunks
49
+ */
50
+ export interface SnapshotChunkStorage {
51
+ /** Storage adapter name */
52
+ readonly name: string;
53
+
54
+ /**
55
+ * Store a chunk. Returns chunk reference.
56
+ * If chunk with same content already exists (by hash), returns existing reference.
57
+ */
58
+ storeChunk(
59
+ metadata: Omit<
60
+ SnapshotChunkMetadata,
61
+ 'chunkId' | 'byteLength' | 'blobHash'
62
+ > & {
63
+ body: Uint8Array;
64
+ }
65
+ ): Promise<SyncSnapshotChunkRef>;
66
+
67
+ /**
68
+ * Store a chunk from a stream.
69
+ * Preferred for large payloads to avoid full buffering in memory.
70
+ */
71
+ storeChunkStream?(
72
+ metadata: Omit<
73
+ SnapshotChunkMetadata,
74
+ 'chunkId' | 'byteLength' | 'blobHash'
75
+ > & {
76
+ bodyStream: ReadableStream<Uint8Array>;
77
+ byteLength?: number;
78
+ }
79
+ ): Promise<SyncSnapshotChunkRef>;
80
+
81
+ /**
82
+ * Read chunk body by chunk ID
83
+ */
84
+ readChunk(chunkId: string): Promise<Uint8Array | null>;
85
+
86
+ /**
87
+ * Read chunk body as a stream.
88
+ * Preferred for large payloads to avoid full buffering in memory.
89
+ */
90
+ readChunkStream?(chunkId: string): Promise<ReadableStream<Uint8Array> | null>;
91
+
92
+ /**
93
+ * Find existing chunk by page key
94
+ */
95
+ findChunk(
96
+ pageKey: SnapshotChunkPageKey
97
+ ): Promise<SyncSnapshotChunkRef | null>;
98
+
99
+ /**
100
+ * Delete expired chunks. Returns number deleted.
101
+ */
102
+ cleanupExpired(beforeIso: string): Promise<number>;
103
+ }