@syncular/server 0.0.1 → 0.0.2-126
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/README.md +25 -0
- package/dist/blobs/adapters/database.d.ts.map +1 -1
- package/dist/blobs/adapters/database.js +25 -3
- package/dist/blobs/adapters/database.js.map +1 -1
- package/dist/blobs/adapters/filesystem.d.ts +31 -0
- package/dist/blobs/adapters/filesystem.d.ts.map +1 -0
- package/dist/blobs/adapters/filesystem.js +140 -0
- package/dist/blobs/adapters/filesystem.js.map +1 -0
- package/dist/blobs/adapters/s3.d.ts +3 -2
- package/dist/blobs/adapters/s3.d.ts.map +1 -1
- package/dist/blobs/adapters/s3.js +49 -0
- package/dist/blobs/adapters/s3.js.map +1 -1
- package/dist/blobs/index.d.ts +1 -0
- package/dist/blobs/index.d.ts.map +1 -1
- package/dist/blobs/index.js +6 -5
- package/dist/blobs/index.js.map +1 -1
- package/dist/clients.d.ts +1 -0
- package/dist/clients.d.ts.map +1 -1
- package/dist/clients.js.map +1 -1
- package/dist/compaction.d.ts +1 -1
- package/dist/compaction.js +1 -1
- package/dist/dialect/base.d.ts +83 -0
- package/dist/dialect/base.d.ts.map +1 -0
- package/dist/dialect/base.js +144 -0
- package/dist/dialect/base.js.map +1 -0
- package/dist/dialect/helpers.d.ts +10 -0
- package/dist/dialect/helpers.d.ts.map +1 -0
- package/dist/dialect/helpers.js +59 -0
- package/dist/dialect/helpers.js.map +1 -0
- package/dist/dialect/index.d.ts +2 -0
- package/dist/dialect/index.d.ts.map +1 -1
- package/dist/dialect/index.js +3 -1
- package/dist/dialect/index.js.map +1 -1
- package/dist/dialect/types.d.ts +38 -46
- package/dist/dialect/types.d.ts.map +1 -1
- package/dist/{shapes → handlers}/create-handler.d.ts +18 -5
- package/dist/handlers/create-handler.d.ts.map +1 -0
- package/dist/{shapes → handlers}/create-handler.js +140 -43
- package/dist/handlers/create-handler.js.map +1 -0
- package/dist/handlers/index.d.ts.map +1 -0
- package/dist/handlers/index.js +4 -0
- package/dist/handlers/index.js.map +1 -0
- package/dist/handlers/registry.d.ts.map +1 -0
- package/dist/handlers/registry.js.map +1 -0
- package/dist/{shapes → handlers}/types.d.ts +7 -7
- package/dist/{shapes → handlers}/types.d.ts.map +1 -1
- package/dist/{shapes → handlers}/types.js.map +1 -1
- package/dist/helpers/conflict.d.ts +1 -1
- package/dist/helpers/conflict.d.ts.map +1 -1
- package/dist/helpers/emitted-change.d.ts +1 -1
- package/dist/helpers/emitted-change.d.ts.map +1 -1
- package/dist/helpers/index.js +4 -4
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -16
- package/dist/index.js.map +1 -1
- package/dist/notify.d.ts +47 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/notify.js +85 -0
- package/dist/notify.js.map +1 -0
- package/dist/proxy/handler.d.ts +1 -1
- package/dist/proxy/handler.d.ts.map +1 -1
- package/dist/proxy/handler.js +15 -11
- package/dist/proxy/handler.js.map +1 -1
- package/dist/proxy/index.d.ts +2 -2
- package/dist/proxy/index.d.ts.map +1 -1
- package/dist/proxy/index.js +3 -3
- package/dist/proxy/index.js.map +1 -1
- package/dist/proxy/mutation-detector.d.ts +4 -0
- package/dist/proxy/mutation-detector.d.ts.map +1 -1
- package/dist/proxy/mutation-detector.js +209 -24
- package/dist/proxy/mutation-detector.js.map +1 -1
- package/dist/proxy/oplog.d.ts +2 -1
- package/dist/proxy/oplog.d.ts.map +1 -1
- package/dist/proxy/oplog.js +15 -9
- package/dist/proxy/oplog.js.map +1 -1
- package/dist/proxy/registry.d.ts +0 -11
- package/dist/proxy/registry.d.ts.map +1 -1
- package/dist/proxy/registry.js +0 -24
- package/dist/proxy/registry.js.map +1 -1
- package/dist/proxy/types.d.ts +2 -0
- package/dist/proxy/types.d.ts.map +1 -1
- package/dist/pull.d.ts +4 -3
- package/dist/pull.d.ts.map +1 -1
- package/dist/pull.js +565 -314
- package/dist/pull.js.map +1 -1
- package/dist/push.d.ts +15 -3
- package/dist/push.d.ts.map +1 -1
- package/dist/push.js +359 -229
- package/dist/push.js.map +1 -1
- package/dist/realtime/index.js +1 -1
- package/dist/realtime/types.d.ts +2 -0
- package/dist/realtime/types.d.ts.map +1 -1
- package/dist/schema.d.ts +11 -1
- package/dist/schema.d.ts.map +1 -1
- package/dist/snapshot-chunks/db-metadata.d.ts +6 -1
- package/dist/snapshot-chunks/db-metadata.d.ts.map +1 -1
- package/dist/snapshot-chunks/db-metadata.js +261 -92
- package/dist/snapshot-chunks/db-metadata.js.map +1 -1
- package/dist/snapshot-chunks/index.d.ts +0 -1
- package/dist/snapshot-chunks/index.d.ts.map +1 -1
- package/dist/snapshot-chunks/index.js +2 -3
- package/dist/snapshot-chunks/index.js.map +1 -1
- package/dist/snapshot-chunks/types.d.ts +20 -5
- package/dist/snapshot-chunks/types.d.ts.map +1 -1
- package/dist/snapshot-chunks.d.ts +12 -8
- package/dist/snapshot-chunks.d.ts.map +1 -1
- package/dist/snapshot-chunks.js +40 -12
- package/dist/snapshot-chunks.js.map +1 -1
- package/dist/subscriptions/index.js +1 -1
- package/dist/subscriptions/resolve.d.ts +6 -6
- package/dist/subscriptions/resolve.d.ts.map +1 -1
- package/dist/subscriptions/resolve.js +53 -14
- package/dist/subscriptions/resolve.js.map +1 -1
- package/package.json +28 -7
- package/src/blobs/adapters/database.test.ts +67 -0
- package/src/blobs/adapters/database.ts +34 -9
- package/src/blobs/adapters/filesystem.test.ts +132 -0
- package/src/blobs/adapters/filesystem.ts +189 -0
- package/src/blobs/adapters/s3.test.ts +522 -0
- package/src/blobs/adapters/s3.ts +55 -2
- package/src/blobs/index.ts +1 -0
- package/src/clients.ts +1 -0
- package/src/compaction.ts +1 -1
- package/src/dialect/base.ts +292 -0
- package/src/dialect/helpers.ts +61 -0
- package/src/dialect/index.ts +2 -0
- package/src/dialect/types.ts +50 -54
- package/src/{shapes → handlers}/create-handler.ts +219 -64
- package/src/{shapes → handlers}/types.ts +10 -7
- package/src/helpers/conflict.ts +1 -1
- package/src/helpers/emitted-change.ts +1 -1
- package/src/index.ts +2 -1
- package/src/notify.test.ts +516 -0
- package/src/notify.ts +131 -0
- package/src/proxy/handler.test.ts +120 -0
- package/src/proxy/handler.ts +18 -10
- package/src/proxy/index.ts +2 -1
- package/src/proxy/mutation-detector.test.ts +71 -0
- package/src/proxy/mutation-detector.ts +227 -29
- package/src/proxy/oplog.ts +19 -10
- package/src/proxy/registry.ts +0 -33
- package/src/proxy/types.ts +2 -0
- package/src/pull.ts +788 -405
- package/src/push.ts +507 -312
- package/src/realtime/types.ts +2 -0
- package/src/schema.ts +11 -1
- package/src/snapshot-chunks/db-metadata.test.ts +169 -0
- package/src/snapshot-chunks/db-metadata.ts +347 -105
- package/src/snapshot-chunks/index.ts +0 -1
- package/src/snapshot-chunks/types.ts +31 -5
- package/src/snapshot-chunks.ts +60 -21
- package/src/subscriptions/resolve.ts +73 -18
- package/dist/shapes/create-handler.d.ts.map +0 -1
- package/dist/shapes/create-handler.js.map +0 -1
- package/dist/shapes/index.d.ts.map +0 -1
- package/dist/shapes/index.js +0 -4
- package/dist/shapes/index.js.map +0 -1
- package/dist/shapes/registry.d.ts.map +0 -1
- package/dist/shapes/registry.js.map +0 -1
- package/dist/snapshot-chunks/adapters/s3.d.ts +0 -63
- package/dist/snapshot-chunks/adapters/s3.d.ts.map +0 -1
- package/dist/snapshot-chunks/adapters/s3.js +0 -50
- package/dist/snapshot-chunks/adapters/s3.js.map +0 -1
- package/src/snapshot-chunks/adapters/s3.ts +0 -68
- /package/dist/{shapes → handlers}/index.d.ts +0 -0
- /package/dist/{shapes → handlers}/registry.d.ts +0 -0
- /package/dist/{shapes → handlers}/registry.js +0 -0
- /package/dist/{shapes → handlers}/types.js +0 -0
- /package/src/{shapes → handlers}/index.ts +0 -0
- /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
|
|
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
|
|
49
|
-
function computeBlobHash(
|
|
50
|
-
|
|
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(
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
152
|
-
`Blob adapter ${blobAdapter.name} does not support direct get() for snapshot chunks`
|
|
153
|
-
);
|
|
330
|
+
return storedRef;
|
|
154
331
|
},
|
|
155
332
|
|
|
156
|
-
async
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
163
|
-
|
|
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 (!
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
392
|
+
|
|
393
|
+
if (
|
|
394
|
+
typeof byteLength === 'number' &&
|
|
395
|
+
Number.isFinite(byteLength) &&
|
|
396
|
+
observedByteLength !== byteLength
|
|
397
|
+
) {
|
|
189
398
|
throw new Error(
|
|
190
|
-
`
|
|
399
|
+
`Snapshot chunk byte length mismatch: expected ${byteLength}, got ${observedByteLength}`
|
|
191
400
|
);
|
|
192
401
|
}
|
|
193
402
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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> {
|
|
@@ -5,19 +5,24 @@
|
|
|
5
5
|
* Enables flexible storage backends (database, S3, R2, etc.)
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type {
|
|
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:
|
|
20
|
-
compression:
|
|
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:
|
|
34
|
-
compression:
|
|
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
|
*/
|