@syncular/server 0.0.1 → 0.0.2-127

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 (171) hide show
  1. package/README.md +25 -0
  2. package/dist/blobs/adapters/database.d.ts.map +1 -1
  3. package/dist/blobs/adapters/database.js +25 -3
  4. package/dist/blobs/adapters/database.js.map +1 -1
  5. package/dist/blobs/adapters/filesystem.d.ts +31 -0
  6. package/dist/blobs/adapters/filesystem.d.ts.map +1 -0
  7. package/dist/blobs/adapters/filesystem.js +140 -0
  8. package/dist/blobs/adapters/filesystem.js.map +1 -0
  9. package/dist/blobs/adapters/s3.d.ts +3 -2
  10. package/dist/blobs/adapters/s3.d.ts.map +1 -1
  11. package/dist/blobs/adapters/s3.js +49 -0
  12. package/dist/blobs/adapters/s3.js.map +1 -1
  13. package/dist/blobs/index.d.ts +1 -0
  14. package/dist/blobs/index.d.ts.map +1 -1
  15. package/dist/blobs/index.js +6 -5
  16. package/dist/blobs/index.js.map +1 -1
  17. package/dist/clients.d.ts +1 -0
  18. package/dist/clients.d.ts.map +1 -1
  19. package/dist/clients.js.map +1 -1
  20. package/dist/compaction.d.ts +1 -1
  21. package/dist/compaction.js +1 -1
  22. package/dist/dialect/base.d.ts +83 -0
  23. package/dist/dialect/base.d.ts.map +1 -0
  24. package/dist/dialect/base.js +144 -0
  25. package/dist/dialect/base.js.map +1 -0
  26. package/dist/dialect/helpers.d.ts +10 -0
  27. package/dist/dialect/helpers.d.ts.map +1 -0
  28. package/dist/dialect/helpers.js +59 -0
  29. package/dist/dialect/helpers.js.map +1 -0
  30. package/dist/dialect/index.d.ts +2 -0
  31. package/dist/dialect/index.d.ts.map +1 -1
  32. package/dist/dialect/index.js +3 -1
  33. package/dist/dialect/index.js.map +1 -1
  34. package/dist/dialect/types.d.ts +38 -46
  35. package/dist/dialect/types.d.ts.map +1 -1
  36. package/dist/{shapes → handlers}/create-handler.d.ts +18 -5
  37. package/dist/handlers/create-handler.d.ts.map +1 -0
  38. package/dist/{shapes → handlers}/create-handler.js +140 -43
  39. package/dist/handlers/create-handler.js.map +1 -0
  40. package/dist/handlers/index.d.ts.map +1 -0
  41. package/dist/handlers/index.js +4 -0
  42. package/dist/handlers/index.js.map +1 -0
  43. package/dist/handlers/registry.d.ts.map +1 -0
  44. package/dist/handlers/registry.js.map +1 -0
  45. package/dist/{shapes → handlers}/types.d.ts +7 -7
  46. package/dist/{shapes → handlers}/types.d.ts.map +1 -1
  47. package/dist/{shapes → handlers}/types.js.map +1 -1
  48. package/dist/helpers/conflict.d.ts +1 -1
  49. package/dist/helpers/conflict.d.ts.map +1 -1
  50. package/dist/helpers/emitted-change.d.ts +1 -1
  51. package/dist/helpers/emitted-change.d.ts.map +1 -1
  52. package/dist/helpers/index.js +4 -4
  53. package/dist/index.d.ts +2 -1
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +17 -16
  56. package/dist/index.js.map +1 -1
  57. package/dist/notify.d.ts +47 -0
  58. package/dist/notify.d.ts.map +1 -0
  59. package/dist/notify.js +85 -0
  60. package/dist/notify.js.map +1 -0
  61. package/dist/proxy/handler.d.ts +1 -1
  62. package/dist/proxy/handler.d.ts.map +1 -1
  63. package/dist/proxy/handler.js +15 -11
  64. package/dist/proxy/handler.js.map +1 -1
  65. package/dist/proxy/index.d.ts +2 -2
  66. package/dist/proxy/index.d.ts.map +1 -1
  67. package/dist/proxy/index.js +3 -3
  68. package/dist/proxy/index.js.map +1 -1
  69. package/dist/proxy/mutation-detector.d.ts +4 -0
  70. package/dist/proxy/mutation-detector.d.ts.map +1 -1
  71. package/dist/proxy/mutation-detector.js +209 -24
  72. package/dist/proxy/mutation-detector.js.map +1 -1
  73. package/dist/proxy/oplog.d.ts +2 -1
  74. package/dist/proxy/oplog.d.ts.map +1 -1
  75. package/dist/proxy/oplog.js +15 -9
  76. package/dist/proxy/oplog.js.map +1 -1
  77. package/dist/proxy/registry.d.ts +0 -11
  78. package/dist/proxy/registry.d.ts.map +1 -1
  79. package/dist/proxy/registry.js +0 -24
  80. package/dist/proxy/registry.js.map +1 -1
  81. package/dist/proxy/types.d.ts +2 -0
  82. package/dist/proxy/types.d.ts.map +1 -1
  83. package/dist/pull.d.ts +4 -3
  84. package/dist/pull.d.ts.map +1 -1
  85. package/dist/pull.js +565 -314
  86. package/dist/pull.js.map +1 -1
  87. package/dist/push.d.ts +15 -3
  88. package/dist/push.d.ts.map +1 -1
  89. package/dist/push.js +359 -229
  90. package/dist/push.js.map +1 -1
  91. package/dist/realtime/index.js +1 -1
  92. package/dist/realtime/types.d.ts +2 -0
  93. package/dist/realtime/types.d.ts.map +1 -1
  94. package/dist/schema.d.ts +11 -1
  95. package/dist/schema.d.ts.map +1 -1
  96. package/dist/snapshot-chunks/db-metadata.d.ts +6 -1
  97. package/dist/snapshot-chunks/db-metadata.d.ts.map +1 -1
  98. package/dist/snapshot-chunks/db-metadata.js +261 -92
  99. package/dist/snapshot-chunks/db-metadata.js.map +1 -1
  100. package/dist/snapshot-chunks/index.d.ts +0 -1
  101. package/dist/snapshot-chunks/index.d.ts.map +1 -1
  102. package/dist/snapshot-chunks/index.js +2 -3
  103. package/dist/snapshot-chunks/index.js.map +1 -1
  104. package/dist/snapshot-chunks/types.d.ts +20 -5
  105. package/dist/snapshot-chunks/types.d.ts.map +1 -1
  106. package/dist/snapshot-chunks.d.ts +12 -8
  107. package/dist/snapshot-chunks.d.ts.map +1 -1
  108. package/dist/snapshot-chunks.js +40 -12
  109. package/dist/snapshot-chunks.js.map +1 -1
  110. package/dist/subscriptions/index.js +1 -1
  111. package/dist/subscriptions/resolve.d.ts +6 -6
  112. package/dist/subscriptions/resolve.d.ts.map +1 -1
  113. package/dist/subscriptions/resolve.js +53 -14
  114. package/dist/subscriptions/resolve.js.map +1 -1
  115. package/package.json +28 -7
  116. package/src/blobs/adapters/database.test.ts +67 -0
  117. package/src/blobs/adapters/database.ts +34 -9
  118. package/src/blobs/adapters/filesystem.test.ts +132 -0
  119. package/src/blobs/adapters/filesystem.ts +189 -0
  120. package/src/blobs/adapters/s3.test.ts +522 -0
  121. package/src/blobs/adapters/s3.ts +55 -2
  122. package/src/blobs/index.ts +1 -0
  123. package/src/clients.ts +1 -0
  124. package/src/compaction.ts +1 -1
  125. package/src/dialect/base.ts +292 -0
  126. package/src/dialect/helpers.ts +61 -0
  127. package/src/dialect/index.ts +2 -0
  128. package/src/dialect/types.ts +50 -54
  129. package/src/{shapes → handlers}/create-handler.ts +219 -64
  130. package/src/{shapes → handlers}/types.ts +10 -7
  131. package/src/helpers/conflict.ts +1 -1
  132. package/src/helpers/emitted-change.ts +1 -1
  133. package/src/index.ts +2 -1
  134. package/src/notify.test.ts +516 -0
  135. package/src/notify.ts +131 -0
  136. package/src/proxy/handler.test.ts +120 -0
  137. package/src/proxy/handler.ts +18 -10
  138. package/src/proxy/index.ts +2 -1
  139. package/src/proxy/mutation-detector.test.ts +71 -0
  140. package/src/proxy/mutation-detector.ts +227 -29
  141. package/src/proxy/oplog.ts +19 -10
  142. package/src/proxy/registry.ts +0 -33
  143. package/src/proxy/types.ts +2 -0
  144. package/src/pull.ts +788 -405
  145. package/src/push.ts +507 -312
  146. package/src/realtime/types.ts +2 -0
  147. package/src/schema.ts +11 -1
  148. package/src/snapshot-chunks/db-metadata.test.ts +169 -0
  149. package/src/snapshot-chunks/db-metadata.ts +347 -105
  150. package/src/snapshot-chunks/index.ts +0 -1
  151. package/src/snapshot-chunks/types.ts +31 -5
  152. package/src/snapshot-chunks.ts +60 -21
  153. package/src/subscriptions/resolve.ts +73 -18
  154. package/dist/shapes/create-handler.d.ts.map +0 -1
  155. package/dist/shapes/create-handler.js.map +0 -1
  156. package/dist/shapes/index.d.ts.map +0 -1
  157. package/dist/shapes/index.js +0 -4
  158. package/dist/shapes/index.js.map +0 -1
  159. package/dist/shapes/registry.d.ts.map +0 -1
  160. package/dist/shapes/registry.js.map +0 -1
  161. package/dist/snapshot-chunks/adapters/s3.d.ts +0 -63
  162. package/dist/snapshot-chunks/adapters/s3.d.ts.map +0 -1
  163. package/dist/snapshot-chunks/adapters/s3.js +0 -50
  164. package/dist/snapshot-chunks/adapters/s3.js.map +0 -1
  165. package/src/snapshot-chunks/adapters/s3.ts +0 -68
  166. /package/dist/{shapes → handlers}/index.d.ts +0 -0
  167. /package/dist/{shapes → handlers}/registry.d.ts +0 -0
  168. /package/dist/{shapes → handlers}/registry.js +0 -0
  169. /package/dist/{shapes → handlers}/types.js +0 -0
  170. /package/src/{shapes → handlers}/index.ts +0 -0
  171. /package/src/{shapes → handlers}/registry.ts +0 -0
@@ -6,7 +6,14 @@
6
6
  */
7
7
 
8
8
  import { createHash } from 'node:crypto';
9
- import type { BlobStorageAdapter, SyncSnapshotChunkRef } from '@syncular/core';
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';
10
17
  import type { Kysely } from 'kysely';
11
18
  import type { SyncCoreDb } from '../schema';
12
19
  import type { SnapshotChunkMetadata, SnapshotChunkPageKey } from './types';
@@ -37,7 +44,19 @@ export function createDbMetadataChunkStorage(
37
44
  body: Uint8Array;
38
45
  }
39
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>;
40
56
  readChunk: (chunkId: string) => Promise<Uint8Array | null>;
57
+ readChunkStream: (
58
+ chunkId: string
59
+ ) => Promise<ReadableStream<Uint8Array> | null>;
41
60
  findChunk: (
42
61
  pageKey: SnapshotChunkPageKey
43
62
  ) => Promise<SyncSnapshotChunkRef | null>;
@@ -45,9 +64,71 @@ export function createDbMetadataChunkStorage(
45
64
  } {
46
65
  const { db, blobAdapter, chunkIdPrefix = 'chunk_' } = options;
47
66
 
48
- // Generate deterministic blob hash from content
49
- function computeBlobHash(body: Uint8Array): string {
50
- return `sha256:${createHash('sha256').update(body).digest('hex')}`;
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
+ }
51
132
  }
52
133
 
53
134
  // Generate unique chunk ID
@@ -55,6 +136,138 @@ export function createDbMetadataChunkStorage(
55
136
  return `${chunkIdPrefix}${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
56
137
  }
57
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
+
58
271
  return {
59
272
  name: `db-metadata+${blobAdapter.name}`,
60
273
 
@@ -67,9 +280,7 @@ export function createDbMetadataChunkStorage(
67
280
  }
68
281
  ): Promise<SyncSnapshotChunkRef> {
69
282
  const { body, ...metaWithoutBody } = metadata;
70
- const blobHash = computeBlobHash(body);
71
- const chunkId = generateChunkId();
72
- const now = new Date().toISOString();
283
+ const blobHash = computeBlobHash(metaWithoutBody);
73
284
 
74
285
  // Check if blob already exists (content-addressed dedup)
75
286
  const blobExists = await blobAdapter.exists(blobHash);
@@ -77,7 +288,17 @@ export function createDbMetadataChunkStorage(
77
288
  if (!blobExists) {
78
289
  // Store body in blob adapter
79
290
  if (blobAdapter.put) {
80
- await blobAdapter.put(blobHash, body);
291
+ await blobAdapter.put(blobHash, body, {
292
+ disableChecksum: true,
293
+ byteLength: body.length,
294
+ contentLength: body.length,
295
+ });
296
+ } else if (blobAdapter.putStream) {
297
+ await blobAdapter.putStream(blobHash, bytesToStream(body), {
298
+ disableChecksum: true,
299
+ byteLength: body.length,
300
+ contentLength: body.length,
301
+ });
81
302
  } else {
82
303
  throw new Error(
83
304
  `Blob adapter ${blobAdapter.name} does not support direct put() for snapshot chunks`
@@ -85,119 +306,140 @@ export function createDbMetadataChunkStorage(
85
306
  }
86
307
  }
87
308
 
88
- // Upsert metadata in database
89
- await db
90
- .insertInto('sync_snapshot_chunks')
91
- .values({
92
- chunk_id: chunkId,
93
- scope_key: metaWithoutBody.scopeKey,
94
- scope: metaWithoutBody.scope,
95
- as_of_commit_seq: metaWithoutBody.asOfCommitSeq,
96
- row_cursor: metaWithoutBody.rowCursor ?? '',
97
- row_limit: metaWithoutBody.rowLimit,
98
- encoding: metaWithoutBody.encoding,
99
- compression: metaWithoutBody.compression,
100
- sha256: metaWithoutBody.sha256,
101
- byte_length: body.length,
102
- blob_hash: blobHash,
103
- expires_at: metaWithoutBody.expiresAt,
104
- created_at: now,
105
- })
106
- .onConflict((oc) =>
107
- oc
108
- .columns([
109
- 'scope_key',
110
- 'scope',
111
- 'as_of_commit_seq',
112
- 'row_cursor',
113
- 'row_limit',
114
- 'encoding',
115
- 'compression',
116
- ])
117
- .doUpdateSet({
118
- expires_at: metaWithoutBody.expiresAt,
119
- blob_hash: blobHash,
120
- sha256: metaWithoutBody.sha256,
121
- byte_length: body.length,
122
- row_cursor: metaWithoutBody.rowCursor ?? '',
123
- })
124
- )
125
- .execute();
126
-
127
- return {
128
- id: chunkId,
129
- sha256: metaWithoutBody.sha256,
309
+ await upsertChunkMetadata(metaWithoutBody, {
310
+ blobHash,
130
311
  byteLength: body.length,
312
+ });
313
+
314
+ const storedRef = await readStoredRef({
315
+ partitionId: metaWithoutBody.partitionId,
316
+ scopeKey: metaWithoutBody.scopeKey,
317
+ scope: metaWithoutBody.scope,
318
+ asOfCommitSeq: metaWithoutBody.asOfCommitSeq,
319
+ rowCursor: metaWithoutBody.rowCursor,
320
+ rowLimit: metaWithoutBody.rowLimit,
131
321
  encoding: metaWithoutBody.encoding,
132
322
  compression: metaWithoutBody.compression,
133
- };
134
- },
323
+ includeExpired: true,
324
+ });
135
325
 
136
- async readChunk(chunkId: string): Promise<Uint8Array | null> {
137
- // Get metadata to find blob hash
138
- const row = await db
139
- .selectFrom('sync_snapshot_chunks')
140
- .select(['blob_hash'])
141
- .where('chunk_id', '=', chunkId)
142
- .executeTakeFirst();
143
-
144
- if (!row) return null;
145
-
146
- // Read from blob adapter
147
- if (blobAdapter.get) {
148
- return blobAdapter.get(row.blob_hash);
326
+ if (!storedRef) {
327
+ throw new Error('Failed to read stored snapshot chunk reference');
149
328
  }
150
329
 
151
- throw new Error(
152
- `Blob adapter ${blobAdapter.name} does not support direct get() for snapshot chunks`
153
- );
330
+ return storedRef;
154
331
  },
155
332
 
156
- async findChunk(
157
- pageKey: SnapshotChunkPageKey
158
- ): Promise<SyncSnapshotChunkRef | null> {
159
- const nowIso = new Date().toISOString();
160
- const rowCursorKey = pageKey.rowCursor ?? '';
333
+ async storeChunkStream(
334
+ metadata: Omit<
335
+ SnapshotChunkMetadata,
336
+ 'chunkId' | 'byteLength' | 'blobHash'
337
+ > & {
338
+ bodyStream: ReadableStream<Uint8Array>;
339
+ byteLength?: number;
340
+ }
341
+ ): Promise<SyncSnapshotChunkRef> {
342
+ const { bodyStream, byteLength, ...metaWithoutBody } = metadata;
343
+ const blobHash = computeBlobHash(metaWithoutBody);
161
344
 
162
- const row = await db
163
- .selectFrom('sync_snapshot_chunks')
164
- .select([
165
- 'chunk_id',
166
- 'sha256',
167
- 'byte_length',
168
- 'encoding',
169
- 'compression',
170
- ])
171
- .where('scope_key', '=', pageKey.scopeKey)
172
- .where('scope', '=', pageKey.scope)
173
- .where('as_of_commit_seq', '=', pageKey.asOfCommitSeq)
174
- .where('row_cursor', '=', rowCursorKey)
175
- .where('row_limit', '=', pageKey.rowLimit)
176
- .where('encoding', '=', pageKey.encoding)
177
- .where('compression', '=', pageKey.compression)
178
- .where('expires_at', '>', nowIso)
179
- .executeTakeFirst();
345
+ const blobExists = await blobAdapter.exists(blobHash);
346
+ let observedByteLength: number;
180
347
 
181
- if (!row) return null;
348
+ if (!blobExists) {
349
+ if (blobAdapter.putStream) {
350
+ const [uploadStream, countStream] = bodyStream.tee();
351
+ const uploadPromise =
352
+ typeof byteLength === 'number'
353
+ ? blobAdapter.putStream(blobHash, uploadStream, {
354
+ disableChecksum: true,
355
+ byteLength,
356
+ contentLength: byteLength,
357
+ })
358
+ : blobAdapter.putStream(blobHash, uploadStream, {
359
+ disableChecksum: true,
360
+ });
361
+ const countPromise = streamByteLength(countStream);
182
362
 
183
- if (row.encoding !== 'ndjson') {
184
- throw new Error(
185
- `Unexpected snapshot chunk encoding: ${String(row.encoding)}`
186
- );
363
+ const [, countedByteLength] = await Promise.all([
364
+ uploadPromise,
365
+ countPromise,
366
+ ]);
367
+ observedByteLength = countedByteLength;
368
+ } else if (blobAdapter.put) {
369
+ const body = await streamToBytes(bodyStream);
370
+ await blobAdapter.put(blobHash, body);
371
+ observedByteLength = body.length;
372
+ } else {
373
+ throw new Error(
374
+ `Blob adapter ${blobAdapter.name} does not support direct put() for snapshot chunks`
375
+ );
376
+ }
377
+ } else if (typeof byteLength === 'number') {
378
+ observedByteLength = byteLength;
379
+ await bodyStream.cancel();
380
+ } else if (blobAdapter.getMetadata) {
381
+ const metadata = await blobAdapter.getMetadata(blobHash);
382
+ if (!metadata) {
383
+ throw new Error(
384
+ `Blob metadata missing for existing chunk ${blobHash}`
385
+ );
386
+ }
387
+ observedByteLength = metadata.size;
388
+ await bodyStream.cancel();
389
+ } else {
390
+ observedByteLength = await streamByteLength(bodyStream);
187
391
  }
188
- if (row.compression !== 'gzip') {
392
+
393
+ if (
394
+ typeof byteLength === 'number' &&
395
+ Number.isFinite(byteLength) &&
396
+ observedByteLength !== byteLength
397
+ ) {
189
398
  throw new Error(
190
- `Unexpected snapshot chunk compression: ${String(row.compression)}`
399
+ `Snapshot chunk byte length mismatch: expected ${byteLength}, got ${observedByteLength}`
191
400
  );
192
401
  }
193
402
 
194
- return {
195
- id: row.chunk_id,
196
- sha256: row.sha256,
197
- byteLength: Number(row.byte_length ?? 0),
198
- encoding: row.encoding,
199
- compression: row.compression,
200
- };
403
+ await upsertChunkMetadata(metaWithoutBody, {
404
+ blobHash,
405
+ byteLength: observedByteLength,
406
+ });
407
+
408
+ const storedRef = await readStoredRef({
409
+ partitionId: metaWithoutBody.partitionId,
410
+ scopeKey: metaWithoutBody.scopeKey,
411
+ scope: metaWithoutBody.scope,
412
+ asOfCommitSeq: metaWithoutBody.asOfCommitSeq,
413
+ rowCursor: metaWithoutBody.rowCursor,
414
+ rowLimit: metaWithoutBody.rowLimit,
415
+ encoding: metaWithoutBody.encoding,
416
+ compression: metaWithoutBody.compression,
417
+ includeExpired: true,
418
+ });
419
+
420
+ if (!storedRef) {
421
+ throw new Error('Failed to read stored snapshot chunk reference');
422
+ }
423
+
424
+ return storedRef;
425
+ },
426
+
427
+ async readChunk(chunkId: string): Promise<Uint8Array | null> {
428
+ const stream = await readChunkStreamById(chunkId);
429
+ if (!stream) return null;
430
+ return streamToBytes(stream);
431
+ },
432
+
433
+ async readChunkStream(
434
+ chunkId: string
435
+ ): Promise<ReadableStream<Uint8Array> | null> {
436
+ return readChunkStreamById(chunkId);
437
+ },
438
+
439
+ async findChunk(
440
+ pageKey: SnapshotChunkPageKey
441
+ ): Promise<SyncSnapshotChunkRef | null> {
442
+ return readStoredRef(pageKey);
201
443
  },
202
444
 
203
445
  async cleanupExpired(beforeIso: string): Promise<number> {
@@ -4,6 +4,5 @@
4
4
  * Separates chunk metadata (database) from body content (blob storage).
5
5
  */
6
6
 
7
- export * from './adapters/s3';
8
7
  export * from './db-metadata';
9
8
  export * from './types';
@@ -5,19 +5,24 @@
5
5
  * Enables flexible storage backends (database, S3, R2, etc.)
6
6
  */
7
7
 
8
- import type { SyncSnapshotChunkRef } from '@syncular/core';
8
+ import type {
9
+ SyncSnapshotChunkCompression,
10
+ SyncSnapshotChunkEncoding,
11
+ SyncSnapshotChunkRef,
12
+ } from '@syncular/core';
9
13
 
10
14
  /**
11
15
  * Page key for identifying a specific chunk
12
16
  */
13
17
  export interface SnapshotChunkPageKey {
18
+ partitionId: string;
14
19
  scopeKey: string;
15
20
  scope: string;
16
21
  asOfCommitSeq: number;
17
22
  rowCursor: string | null;
18
23
  rowLimit: number;
19
- encoding: 'ndjson';
20
- compression: 'gzip';
24
+ encoding: SyncSnapshotChunkEncoding;
25
+ compression: SyncSnapshotChunkCompression;
21
26
  }
22
27
 
23
28
  /**
@@ -25,13 +30,14 @@ export interface SnapshotChunkPageKey {
25
30
  */
26
31
  export interface SnapshotChunkMetadata {
27
32
  chunkId: string;
33
+ partitionId: string;
28
34
  scopeKey: string;
29
35
  scope: string;
30
36
  asOfCommitSeq: number;
31
37
  rowCursor: string | null;
32
38
  rowLimit: number;
33
- encoding: 'ndjson';
34
- compression: 'gzip';
39
+ encoding: SyncSnapshotChunkEncoding;
40
+ compression: SyncSnapshotChunkCompression;
35
41
  sha256: string;
36
42
  byteLength: number;
37
43
  blobHash: string; // Reference to blob storage
@@ -58,11 +64,31 @@ export interface SnapshotChunkStorage {
58
64
  }
59
65
  ): Promise<SyncSnapshotChunkRef>;
60
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
+
61
81
  /**
62
82
  * Read chunk body by chunk ID
63
83
  */
64
84
  readChunk(chunkId: string): Promise<Uint8Array | null>;
65
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
+
66
92
  /**
67
93
  * Find existing chunk by page key
68
94
  */