@syncular/server 0.0.1-92 → 0.0.1-95
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/dialect/base.d.ts +5 -21
- package/dist/dialect/base.d.ts.map +1 -1
- package/dist/dialect/base.js +33 -1
- package/dist/dialect/base.js.map +1 -1
- package/dist/dialect/types.d.ts +23 -44
- package/dist/dialect/types.d.ts.map +1 -1
- package/dist/pull.d.ts.map +1 -1
- package/dist/pull.js +132 -87
- package/dist/pull.js.map +1 -1
- package/dist/snapshot-chunks/adapters/s3.d.ts +11 -0
- package/dist/snapshot-chunks/adapters/s3.d.ts.map +1 -1
- package/dist/snapshot-chunks/db-metadata.d.ts +5 -0
- package/dist/snapshot-chunks/db-metadata.d.ts.map +1 -1
- package/dist/snapshot-chunks/db-metadata.js +168 -49
- package/dist/snapshot-chunks/db-metadata.js.map +1 -1
- package/dist/snapshot-chunks/types.d.ts +13 -0
- package/dist/snapshot-chunks/types.d.ts.map +1 -1
- package/dist/snapshot-chunks.d.ts +2 -1
- package/dist/snapshot-chunks.d.ts.map +1 -1
- package/dist/snapshot-chunks.js +27 -7
- package/dist/snapshot-chunks.js.map +1 -1
- package/package.json +1 -1
- package/src/dialect/base.ts +55 -25
- package/src/dialect/types.ts +27 -52
- package/src/pull.ts +167 -101
- package/src/snapshot-chunks/db-metadata.ts +218 -56
- package/src/snapshot-chunks/types.ts +20 -0
- package/src/snapshot-chunks.ts +31 -9
|
@@ -37,7 +37,19 @@ export function createDbMetadataChunkStorage(
|
|
|
37
37
|
body: Uint8Array;
|
|
38
38
|
}
|
|
39
39
|
) => Promise<SyncSnapshotChunkRef>;
|
|
40
|
+
storeChunkStream: (
|
|
41
|
+
metadata: Omit<
|
|
42
|
+
SnapshotChunkMetadata,
|
|
43
|
+
'chunkId' | 'byteLength' | 'blobHash'
|
|
44
|
+
> & {
|
|
45
|
+
bodyStream: ReadableStream<Uint8Array>;
|
|
46
|
+
byteLength: number;
|
|
47
|
+
}
|
|
48
|
+
) => Promise<SyncSnapshotChunkRef>;
|
|
40
49
|
readChunk: (chunkId: string) => Promise<Uint8Array | null>;
|
|
50
|
+
readChunkStream: (
|
|
51
|
+
chunkId: string
|
|
52
|
+
) => Promise<ReadableStream<Uint8Array> | null>;
|
|
41
53
|
findChunk: (
|
|
42
54
|
pageKey: SnapshotChunkPageKey
|
|
43
55
|
) => Promise<SyncSnapshotChunkRef | null>;
|
|
@@ -50,6 +62,43 @@ export function createDbMetadataChunkStorage(
|
|
|
50
62
|
return `sha256:${createHash('sha256').update(body).digest('hex')}`;
|
|
51
63
|
}
|
|
52
64
|
|
|
65
|
+
function bytesToStream(bytes: Uint8Array): ReadableStream<Uint8Array> {
|
|
66
|
+
return new ReadableStream<Uint8Array>({
|
|
67
|
+
start(controller) {
|
|
68
|
+
controller.enqueue(bytes);
|
|
69
|
+
controller.close();
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function streamToBytes(
|
|
75
|
+
stream: ReadableStream<Uint8Array>
|
|
76
|
+
): Promise<Uint8Array> {
|
|
77
|
+
const reader = stream.getReader();
|
|
78
|
+
try {
|
|
79
|
+
const chunks: Uint8Array[] = [];
|
|
80
|
+
let total = 0;
|
|
81
|
+
|
|
82
|
+
while (true) {
|
|
83
|
+
const { done, value } = await reader.read();
|
|
84
|
+
if (done) break;
|
|
85
|
+
if (!value) continue;
|
|
86
|
+
chunks.push(value);
|
|
87
|
+
total += value.length;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const out = new Uint8Array(total);
|
|
91
|
+
let offset = 0;
|
|
92
|
+
for (const chunk of chunks) {
|
|
93
|
+
out.set(chunk, offset);
|
|
94
|
+
offset += chunk.length;
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
} finally {
|
|
98
|
+
reader.releaseLock();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
53
102
|
// Generate unique chunk ID
|
|
54
103
|
function generateChunkId(): string {
|
|
55
104
|
return `${chunkIdPrefix}${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
|
@@ -107,6 +156,86 @@ export function createDbMetadataChunkStorage(
|
|
|
107
156
|
};
|
|
108
157
|
}
|
|
109
158
|
|
|
159
|
+
async function readBlobHash(chunkId: string): Promise<string | null> {
|
|
160
|
+
const row = await db
|
|
161
|
+
.selectFrom('sync_snapshot_chunks')
|
|
162
|
+
.select(['blob_hash'])
|
|
163
|
+
.where('chunk_id', '=', chunkId)
|
|
164
|
+
.executeTakeFirst();
|
|
165
|
+
return row?.blob_hash ?? null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function upsertChunkMetadata(
|
|
169
|
+
metadata: Omit<
|
|
170
|
+
SnapshotChunkMetadata,
|
|
171
|
+
'chunkId' | 'byteLength' | 'blobHash'
|
|
172
|
+
>,
|
|
173
|
+
args: { blobHash: string; byteLength: number }
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
const chunkId = generateChunkId();
|
|
176
|
+
const now = new Date().toISOString();
|
|
177
|
+
|
|
178
|
+
await db
|
|
179
|
+
.insertInto('sync_snapshot_chunks')
|
|
180
|
+
.values({
|
|
181
|
+
chunk_id: chunkId,
|
|
182
|
+
partition_id: metadata.partitionId,
|
|
183
|
+
scope_key: metadata.scopeKey,
|
|
184
|
+
scope: metadata.scope,
|
|
185
|
+
as_of_commit_seq: metadata.asOfCommitSeq,
|
|
186
|
+
row_cursor: metadata.rowCursor ?? '',
|
|
187
|
+
row_limit: metadata.rowLimit,
|
|
188
|
+
encoding: metadata.encoding,
|
|
189
|
+
compression: metadata.compression,
|
|
190
|
+
sha256: metadata.sha256,
|
|
191
|
+
byte_length: args.byteLength,
|
|
192
|
+
blob_hash: args.blobHash,
|
|
193
|
+
expires_at: metadata.expiresAt,
|
|
194
|
+
created_at: now,
|
|
195
|
+
})
|
|
196
|
+
.onConflict((oc) =>
|
|
197
|
+
oc
|
|
198
|
+
.columns([
|
|
199
|
+
'partition_id',
|
|
200
|
+
'scope_key',
|
|
201
|
+
'scope',
|
|
202
|
+
'as_of_commit_seq',
|
|
203
|
+
'row_cursor',
|
|
204
|
+
'row_limit',
|
|
205
|
+
'encoding',
|
|
206
|
+
'compression',
|
|
207
|
+
])
|
|
208
|
+
.doUpdateSet({
|
|
209
|
+
expires_at: metadata.expiresAt,
|
|
210
|
+
blob_hash: args.blobHash,
|
|
211
|
+
sha256: metadata.sha256,
|
|
212
|
+
byte_length: args.byteLength,
|
|
213
|
+
row_cursor: metadata.rowCursor ?? '',
|
|
214
|
+
})
|
|
215
|
+
)
|
|
216
|
+
.execute();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function readChunkStreamById(
|
|
220
|
+
chunkId: string
|
|
221
|
+
): Promise<ReadableStream<Uint8Array> | null> {
|
|
222
|
+
const blobHash = await readBlobHash(chunkId);
|
|
223
|
+
if (!blobHash) return null;
|
|
224
|
+
|
|
225
|
+
if (blobAdapter.getStream) {
|
|
226
|
+
return blobAdapter.getStream(blobHash);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (blobAdapter.get) {
|
|
230
|
+
const bytes = await blobAdapter.get(blobHash);
|
|
231
|
+
return bytes ? bytesToStream(bytes) : null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
throw new Error(
|
|
235
|
+
`Blob adapter ${blobAdapter.name} does not support direct get() for snapshot chunks`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
110
239
|
return {
|
|
111
240
|
name: `db-metadata+${blobAdapter.name}`,
|
|
112
241
|
|
|
@@ -120,8 +249,6 @@ export function createDbMetadataChunkStorage(
|
|
|
120
249
|
): Promise<SyncSnapshotChunkRef> {
|
|
121
250
|
const { body, ...metaWithoutBody } = metadata;
|
|
122
251
|
const blobHash = computeBlobHash(body);
|
|
123
|
-
const chunkId = generateChunkId();
|
|
124
|
-
const now = new Date().toISOString();
|
|
125
252
|
|
|
126
253
|
// Check if blob already exists (content-addressed dedup)
|
|
127
254
|
const blobExists = await blobAdapter.exists(blobHash);
|
|
@@ -130,6 +257,8 @@ export function createDbMetadataChunkStorage(
|
|
|
130
257
|
// Store body in blob adapter
|
|
131
258
|
if (blobAdapter.put) {
|
|
132
259
|
await blobAdapter.put(blobHash, body);
|
|
260
|
+
} else if (blobAdapter.putStream) {
|
|
261
|
+
await blobAdapter.putStream(blobHash, bytesToStream(body));
|
|
133
262
|
} else {
|
|
134
263
|
throw new Error(
|
|
135
264
|
`Blob adapter ${blobAdapter.name} does not support direct put() for snapshot chunks`
|
|
@@ -137,46 +266,10 @@ export function createDbMetadataChunkStorage(
|
|
|
137
266
|
}
|
|
138
267
|
}
|
|
139
268
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
.
|
|
143
|
-
|
|
144
|
-
chunk_id: chunkId,
|
|
145
|
-
partition_id: metaWithoutBody.partitionId,
|
|
146
|
-
scope_key: metaWithoutBody.scopeKey,
|
|
147
|
-
scope: metaWithoutBody.scope,
|
|
148
|
-
as_of_commit_seq: metaWithoutBody.asOfCommitSeq,
|
|
149
|
-
row_cursor: metaWithoutBody.rowCursor ?? '',
|
|
150
|
-
row_limit: metaWithoutBody.rowLimit,
|
|
151
|
-
encoding: metaWithoutBody.encoding,
|
|
152
|
-
compression: metaWithoutBody.compression,
|
|
153
|
-
sha256: metaWithoutBody.sha256,
|
|
154
|
-
byte_length: body.length,
|
|
155
|
-
blob_hash: blobHash,
|
|
156
|
-
expires_at: metaWithoutBody.expiresAt,
|
|
157
|
-
created_at: now,
|
|
158
|
-
})
|
|
159
|
-
.onConflict((oc) =>
|
|
160
|
-
oc
|
|
161
|
-
.columns([
|
|
162
|
-
'partition_id',
|
|
163
|
-
'scope_key',
|
|
164
|
-
'scope',
|
|
165
|
-
'as_of_commit_seq',
|
|
166
|
-
'row_cursor',
|
|
167
|
-
'row_limit',
|
|
168
|
-
'encoding',
|
|
169
|
-
'compression',
|
|
170
|
-
])
|
|
171
|
-
.doUpdateSet({
|
|
172
|
-
expires_at: metaWithoutBody.expiresAt,
|
|
173
|
-
blob_hash: blobHash,
|
|
174
|
-
sha256: metaWithoutBody.sha256,
|
|
175
|
-
byte_length: body.length,
|
|
176
|
-
row_cursor: metaWithoutBody.rowCursor ?? '',
|
|
177
|
-
})
|
|
178
|
-
)
|
|
179
|
-
.execute();
|
|
269
|
+
await upsertChunkMetadata(metaWithoutBody, {
|
|
270
|
+
blobHash,
|
|
271
|
+
byteLength: body.length,
|
|
272
|
+
});
|
|
180
273
|
|
|
181
274
|
const storedRef = await readStoredRef({
|
|
182
275
|
partitionId: metaWithoutBody.partitionId,
|
|
@@ -197,24 +290,93 @@ export function createDbMetadataChunkStorage(
|
|
|
197
290
|
return storedRef;
|
|
198
291
|
},
|
|
199
292
|
|
|
200
|
-
async
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
293
|
+
async storeChunkStream(
|
|
294
|
+
metadata: Omit<
|
|
295
|
+
SnapshotChunkMetadata,
|
|
296
|
+
'chunkId' | 'byteLength' | 'blobHash'
|
|
297
|
+
> & {
|
|
298
|
+
bodyStream: ReadableStream<Uint8Array>;
|
|
299
|
+
byteLength: number;
|
|
300
|
+
}
|
|
301
|
+
): Promise<SyncSnapshotChunkRef> {
|
|
302
|
+
const { bodyStream, byteLength, ...metaWithoutBody } = metadata;
|
|
303
|
+
const [uploadStream, hashStream] = bodyStream.tee();
|
|
207
304
|
|
|
208
|
-
|
|
305
|
+
const hasher = createHash('sha256');
|
|
306
|
+
let observedByteLength = 0;
|
|
307
|
+
const hashReader = hashStream.getReader();
|
|
308
|
+
try {
|
|
309
|
+
while (true) {
|
|
310
|
+
const { done, value } = await hashReader.read();
|
|
311
|
+
if (done) break;
|
|
312
|
+
if (!value) continue;
|
|
313
|
+
hasher.update(value);
|
|
314
|
+
observedByteLength += value.length;
|
|
315
|
+
}
|
|
316
|
+
} finally {
|
|
317
|
+
hashReader.releaseLock();
|
|
318
|
+
}
|
|
209
319
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
320
|
+
if (observedByteLength !== byteLength) {
|
|
321
|
+
throw new Error(
|
|
322
|
+
`Snapshot chunk byte length mismatch: expected ${byteLength}, got ${observedByteLength}`
|
|
323
|
+
);
|
|
213
324
|
}
|
|
214
325
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
);
|
|
326
|
+
const blobHash = `sha256:${hasher.digest('hex')}`;
|
|
327
|
+
|
|
328
|
+
const blobExists = await blobAdapter.exists(blobHash);
|
|
329
|
+
if (!blobExists) {
|
|
330
|
+
if (blobAdapter.putStream) {
|
|
331
|
+
await blobAdapter.putStream(blobHash, uploadStream, {
|
|
332
|
+
byteLength: observedByteLength,
|
|
333
|
+
});
|
|
334
|
+
} else if (blobAdapter.put) {
|
|
335
|
+
const body = await streamToBytes(uploadStream);
|
|
336
|
+
await blobAdapter.put(blobHash, body);
|
|
337
|
+
} else {
|
|
338
|
+
throw new Error(
|
|
339
|
+
`Blob adapter ${blobAdapter.name} does not support direct put() for snapshot chunks`
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
} else {
|
|
343
|
+
await uploadStream.cancel();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
await upsertChunkMetadata(metaWithoutBody, {
|
|
347
|
+
blobHash,
|
|
348
|
+
byteLength: observedByteLength,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const storedRef = await readStoredRef({
|
|
352
|
+
partitionId: metaWithoutBody.partitionId,
|
|
353
|
+
scopeKey: metaWithoutBody.scopeKey,
|
|
354
|
+
scope: metaWithoutBody.scope,
|
|
355
|
+
asOfCommitSeq: metaWithoutBody.asOfCommitSeq,
|
|
356
|
+
rowCursor: metaWithoutBody.rowCursor,
|
|
357
|
+
rowLimit: metaWithoutBody.rowLimit,
|
|
358
|
+
encoding: metaWithoutBody.encoding,
|
|
359
|
+
compression: metaWithoutBody.compression,
|
|
360
|
+
includeExpired: true,
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
if (!storedRef) {
|
|
364
|
+
throw new Error('Failed to read stored snapshot chunk reference');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return storedRef;
|
|
368
|
+
},
|
|
369
|
+
|
|
370
|
+
async readChunk(chunkId: string): Promise<Uint8Array | null> {
|
|
371
|
+
const stream = await readChunkStreamById(chunkId);
|
|
372
|
+
if (!stream) return null;
|
|
373
|
+
return streamToBytes(stream);
|
|
374
|
+
},
|
|
375
|
+
|
|
376
|
+
async readChunkStream(
|
|
377
|
+
chunkId: string
|
|
378
|
+
): Promise<ReadableStream<Uint8Array> | null> {
|
|
379
|
+
return readChunkStreamById(chunkId);
|
|
218
380
|
},
|
|
219
381
|
|
|
220
382
|
async findChunk(
|
|
@@ -60,11 +60,31 @@ export interface SnapshotChunkStorage {
|
|
|
60
60
|
}
|
|
61
61
|
): Promise<SyncSnapshotChunkRef>;
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Store a chunk from a stream.
|
|
65
|
+
* Preferred for large payloads to avoid full buffering in memory.
|
|
66
|
+
*/
|
|
67
|
+
storeChunkStream?(
|
|
68
|
+
metadata: Omit<
|
|
69
|
+
SnapshotChunkMetadata,
|
|
70
|
+
'chunkId' | 'byteLength' | 'blobHash'
|
|
71
|
+
> & {
|
|
72
|
+
bodyStream: ReadableStream<Uint8Array>;
|
|
73
|
+
byteLength: number;
|
|
74
|
+
}
|
|
75
|
+
): Promise<SyncSnapshotChunkRef>;
|
|
76
|
+
|
|
63
77
|
/**
|
|
64
78
|
* Read chunk body by chunk ID
|
|
65
79
|
*/
|
|
66
80
|
readChunk(chunkId: string): Promise<Uint8Array | null>;
|
|
67
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Read chunk body as a stream.
|
|
84
|
+
* Preferred for large payloads to avoid full buffering in memory.
|
|
85
|
+
*/
|
|
86
|
+
readChunkStream?(chunkId: string): Promise<ReadableStream<Uint8Array> | null>;
|
|
87
|
+
|
|
68
88
|
/**
|
|
69
89
|
* Find existing chunk by page key
|
|
70
90
|
*/
|
package/src/snapshot-chunks.ts
CHANGED
|
@@ -32,7 +32,7 @@ export interface SnapshotChunkRow {
|
|
|
32
32
|
compression: 'gzip';
|
|
33
33
|
sha256: string;
|
|
34
34
|
byteLength: number;
|
|
35
|
-
body: Uint8Array
|
|
35
|
+
body: Uint8Array | ReadableStream<Uint8Array>;
|
|
36
36
|
expiresAt: string;
|
|
37
37
|
}
|
|
38
38
|
|
|
@@ -203,7 +203,12 @@ export async function readSnapshotChunk<DB extends SyncCoreDb>(
|
|
|
203
203
|
chunkId: string,
|
|
204
204
|
options?: {
|
|
205
205
|
/** External chunk storage for reading from S3/R2/etc */
|
|
206
|
-
chunkStorage?: {
|
|
206
|
+
chunkStorage?: {
|
|
207
|
+
readChunk(chunkId: string): Promise<Uint8Array | null>;
|
|
208
|
+
readChunkStream?(
|
|
209
|
+
chunkId: string
|
|
210
|
+
): Promise<ReadableStream<Uint8Array> | null>;
|
|
211
|
+
};
|
|
207
212
|
}
|
|
208
213
|
): Promise<SnapshotChunkRow | null> {
|
|
209
214
|
const rowResult = await sql<{
|
|
@@ -257,15 +262,32 @@ export async function readSnapshotChunk<DB extends SyncCoreDb>(
|
|
|
257
262
|
}
|
|
258
263
|
|
|
259
264
|
// Read body from external storage if available, otherwise use inline body
|
|
260
|
-
let body: Uint8Array
|
|
265
|
+
let body: Uint8Array | ReadableStream<Uint8Array>;
|
|
261
266
|
if (options?.chunkStorage) {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
+
if (options.chunkStorage.readChunkStream) {
|
|
268
|
+
const externalBodyStream =
|
|
269
|
+
await options.chunkStorage.readChunkStream(chunkId);
|
|
270
|
+
if (externalBodyStream) {
|
|
271
|
+
body = externalBodyStream;
|
|
272
|
+
} else {
|
|
273
|
+
const externalBody = await options.chunkStorage.readChunk(chunkId);
|
|
274
|
+
if (externalBody) {
|
|
275
|
+
body = externalBody;
|
|
276
|
+
} else if (row.body) {
|
|
277
|
+
body = coerceChunkRow(row.body);
|
|
278
|
+
} else {
|
|
279
|
+
throw new Error(`Snapshot chunk body missing for chunk ${chunkId}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
267
282
|
} else {
|
|
268
|
-
|
|
283
|
+
const externalBody = await options.chunkStorage.readChunk(chunkId);
|
|
284
|
+
if (externalBody) {
|
|
285
|
+
body = externalBody;
|
|
286
|
+
} else if (row.body) {
|
|
287
|
+
body = coerceChunkRow(row.body);
|
|
288
|
+
} else {
|
|
289
|
+
throw new Error(`Snapshot chunk body missing for chunk ${chunkId}`);
|
|
290
|
+
}
|
|
269
291
|
}
|
|
270
292
|
} else {
|
|
271
293
|
body = coerceChunkRow(row.body);
|