@syncular/client 0.0.6-168 → 0.0.6-177
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/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/pull-engine.d.ts.map +1 -1
- package/dist/pull-engine.js +61 -75
- package/dist/pull-engine.js.map +1 -1
- package/dist/query-public.d.ts +19 -0
- package/dist/query-public.d.ts.map +1 -0
- package/dist/query-public.js +152 -0
- package/dist/query-public.js.map +1 -0
- package/package.json +3 -3
- package/src/index.ts +1 -1
- package/src/pull-engine.test.ts +136 -0
- package/src/pull-engine.ts +90 -106
- package/src/query-public.ts +277 -0
- package/dist/query/QueryContext.d.ts +0 -33
- package/dist/query/QueryContext.d.ts.map +0 -1
- package/dist/query/QueryContext.js +0 -16
- package/dist/query/QueryContext.js.map +0 -1
- package/dist/query/index.d.ts +0 -7
- package/dist/query/index.d.ts.map +0 -1
- package/dist/query/index.js +0 -7
- package/dist/query/index.js.map +0 -1
- package/dist/query/tracked-select.d.ts +0 -18
- package/dist/query/tracked-select.d.ts.map +0 -1
- package/dist/query/tracked-select.js +0 -90
- package/dist/query/tracked-select.js.map +0 -1
- package/src/query/QueryContext.ts +0 -54
- package/src/query/index.ts +0 -10
- package/src/query/tracked-select.ts +0 -139
package/src/pull-engine.test.ts
CHANGED
|
@@ -149,6 +149,142 @@ describe('applyPullResponse chunk streaming', () => {
|
|
|
149
149
|
expect(streamFetchCount).toBe(1);
|
|
150
150
|
});
|
|
151
151
|
|
|
152
|
+
it('materializes chunked bootstrap snapshots for afterPull plugins via streaming transport', async () => {
|
|
153
|
+
const firstRows = Array.from({ length: 1200 }, (_, index) => ({
|
|
154
|
+
id: `${index + 1}`,
|
|
155
|
+
name: `Item ${index + 1}`,
|
|
156
|
+
}));
|
|
157
|
+
const secondRows = Array.from({ length: 1200 }, (_, index) => ({
|
|
158
|
+
id: `${index + 1201}`,
|
|
159
|
+
name: `Item ${index + 1201}`,
|
|
160
|
+
}));
|
|
161
|
+
const firstChunk = new Uint8Array(gzipSync(encodeSnapshotRows(firstRows)));
|
|
162
|
+
const secondChunk = new Uint8Array(
|
|
163
|
+
gzipSync(encodeSnapshotRows(secondRows))
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
let streamFetchCount = 0;
|
|
167
|
+
let activeStreamFetches = 0;
|
|
168
|
+
let maxConcurrentStreamFetches = 0;
|
|
169
|
+
const transport: SyncTransport = {
|
|
170
|
+
async sync() {
|
|
171
|
+
return {};
|
|
172
|
+
},
|
|
173
|
+
async fetchSnapshotChunk() {
|
|
174
|
+
throw new Error('fetchSnapshotChunk should not be used');
|
|
175
|
+
},
|
|
176
|
+
async fetchSnapshotChunkStream({ chunkId }) {
|
|
177
|
+
streamFetchCount += 1;
|
|
178
|
+
activeStreamFetches += 1;
|
|
179
|
+
maxConcurrentStreamFetches = Math.max(
|
|
180
|
+
maxConcurrentStreamFetches,
|
|
181
|
+
activeStreamFetches
|
|
182
|
+
);
|
|
183
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
184
|
+
activeStreamFetches -= 1;
|
|
185
|
+
|
|
186
|
+
if (chunkId === 'chunk-1') {
|
|
187
|
+
return createStreamFromBytes(firstChunk, 193);
|
|
188
|
+
}
|
|
189
|
+
if (chunkId === 'chunk-2') {
|
|
190
|
+
return createStreamFromBytes(secondChunk, 211);
|
|
191
|
+
}
|
|
192
|
+
throw new Error(`Unexpected chunk id: ${chunkId}`);
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const handlers: ClientHandlerCollection<TestDb> = [
|
|
197
|
+
createClientHandler({
|
|
198
|
+
table: 'items',
|
|
199
|
+
scopes: ['items:{id}'],
|
|
200
|
+
}),
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
let pluginSawRows = 0;
|
|
204
|
+
const options = {
|
|
205
|
+
clientId: 'client-1',
|
|
206
|
+
subscriptions: [
|
|
207
|
+
{
|
|
208
|
+
id: 'items-sub',
|
|
209
|
+
table: 'items',
|
|
210
|
+
scopes: {},
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
stateId: 'default',
|
|
214
|
+
plugins: [
|
|
215
|
+
{
|
|
216
|
+
name: 'after-pull-observer',
|
|
217
|
+
async afterPull(_ctx, { response }) {
|
|
218
|
+
pluginSawRows =
|
|
219
|
+
response.subscriptions[0]?.snapshots?.[0]?.rows.length ?? 0;
|
|
220
|
+
return response;
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const pullState = await buildPullRequest(db, options);
|
|
227
|
+
|
|
228
|
+
const response: SyncPullResponse = {
|
|
229
|
+
ok: true,
|
|
230
|
+
subscriptions: [
|
|
231
|
+
{
|
|
232
|
+
id: 'items-sub',
|
|
233
|
+
status: 'active',
|
|
234
|
+
scopes: {},
|
|
235
|
+
bootstrap: true,
|
|
236
|
+
bootstrapState: null,
|
|
237
|
+
nextCursor: 2,
|
|
238
|
+
commits: [],
|
|
239
|
+
snapshots: [
|
|
240
|
+
{
|
|
241
|
+
table: 'items',
|
|
242
|
+
rows: [],
|
|
243
|
+
chunks: [
|
|
244
|
+
{
|
|
245
|
+
id: 'chunk-1',
|
|
246
|
+
byteLength: firstChunk.length,
|
|
247
|
+
sha256: '',
|
|
248
|
+
encoding: 'json-row-frame-v1',
|
|
249
|
+
compression: 'gzip',
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
id: 'chunk-2',
|
|
253
|
+
byteLength: secondChunk.length,
|
|
254
|
+
sha256: '',
|
|
255
|
+
encoding: 'json-row-frame-v1',
|
|
256
|
+
compression: 'gzip',
|
|
257
|
+
},
|
|
258
|
+
],
|
|
259
|
+
isFirstPage: true,
|
|
260
|
+
isLastPage: true,
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
},
|
|
264
|
+
],
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
await applyPullResponse(
|
|
268
|
+
db,
|
|
269
|
+
transport,
|
|
270
|
+
handlers,
|
|
271
|
+
options,
|
|
272
|
+
pullState,
|
|
273
|
+
response
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const countResult = await sql<{ count: number }>`
|
|
277
|
+
select count(*) as count
|
|
278
|
+
from ${sql.table('items')}
|
|
279
|
+
`.execute(db);
|
|
280
|
+
expect(Number(countResult.rows[0]?.count ?? 0)).toBe(
|
|
281
|
+
firstRows.length + secondRows.length
|
|
282
|
+
);
|
|
283
|
+
expect(pluginSawRows).toBe(firstRows.length + secondRows.length);
|
|
284
|
+
expect(streamFetchCount).toBe(2);
|
|
285
|
+
expect(maxConcurrentStreamFetches).toBe(1);
|
|
286
|
+
});
|
|
287
|
+
|
|
152
288
|
it('rolls back partial chunked bootstrap when a later chunk fails', async () => {
|
|
153
289
|
const firstRows = Array.from({ length: 1500 }, (_, index) => ({
|
|
154
290
|
id: `${index + 1}`,
|
package/src/pull-engine.ts
CHANGED
|
@@ -12,7 +12,6 @@ import type {
|
|
|
12
12
|
SyncSubscriptionRequest,
|
|
13
13
|
SyncTransport,
|
|
14
14
|
} from '@syncular/core';
|
|
15
|
-
import { decodeSnapshotRows } from '@syncular/core';
|
|
16
15
|
import { type Kysely, sql, type Transaction } from 'kysely';
|
|
17
16
|
import {
|
|
18
17
|
type ClientHandlerCollection,
|
|
@@ -29,7 +28,6 @@ import type { SyncClientDb, SyncSubscriptionStateTable } from './schema';
|
|
|
29
28
|
// of the same objects during pull operations
|
|
30
29
|
const jsonCache = new WeakMap<object, string>();
|
|
31
30
|
const jsonCacheStats = { hits: 0, misses: 0 };
|
|
32
|
-
const SNAPSHOT_CHUNK_CONCURRENCY = 8;
|
|
33
31
|
const SNAPSHOT_APPLY_BATCH_ROWS = 500;
|
|
34
32
|
const SNAPSHOT_ROW_FRAME_MAGIC = new Uint8Array([0x53, 0x52, 0x46, 0x31]); // "SRF1"
|
|
35
33
|
const FRAME_LENGTH_BYTES = 4;
|
|
@@ -96,24 +94,6 @@ function toOwnedUint8Array(chunk: Uint8Array): Uint8Array<ArrayBuffer> {
|
|
|
96
94
|
return bytes;
|
|
97
95
|
}
|
|
98
96
|
|
|
99
|
-
async function streamToBytes(
|
|
100
|
-
stream: ReadableStream<Uint8Array>
|
|
101
|
-
): Promise<Uint8Array> {
|
|
102
|
-
const reader = stream.getReader();
|
|
103
|
-
try {
|
|
104
|
-
const chunks: Uint8Array[] = [];
|
|
105
|
-
while (true) {
|
|
106
|
-
const { done, value } = await reader.read();
|
|
107
|
-
if (done) break;
|
|
108
|
-
if (!value || value.length === 0) continue;
|
|
109
|
-
chunks.push(value);
|
|
110
|
-
}
|
|
111
|
-
return concatBytes(chunks);
|
|
112
|
-
} finally {
|
|
113
|
-
reader.releaseLock();
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
97
|
async function maybeGunzipStream(
|
|
118
98
|
stream: ReadableStream<Uint8Array>
|
|
119
99
|
): Promise<ReadableStream<Uint8Array>> {
|
|
@@ -165,14 +145,6 @@ async function maybeGunzipStream(
|
|
|
165
145
|
);
|
|
166
146
|
}
|
|
167
147
|
|
|
168
|
-
async function maybeGunzip(bytes: Uint8Array): Promise<Uint8Array> {
|
|
169
|
-
if (!isGzipBytes(bytes)) return bytes;
|
|
170
|
-
const decompressedStream = await maybeGunzipStream(
|
|
171
|
-
bytesToReadableStream(bytes)
|
|
172
|
-
);
|
|
173
|
-
return streamToBytes(decompressedStream);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
148
|
async function* decodeSnapshotRowStreamBatches(
|
|
177
149
|
stream: ReadableStream<Uint8Array>,
|
|
178
150
|
batchSize: number
|
|
@@ -356,29 +328,62 @@ async function readAllBytesFromStream(
|
|
|
356
328
|
return bytes;
|
|
357
329
|
}
|
|
358
330
|
|
|
359
|
-
async function
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
331
|
+
async function materializeSnapshotChunkRows(
|
|
332
|
+
transport: SyncTransport,
|
|
333
|
+
request: {
|
|
334
|
+
chunkId: string;
|
|
335
|
+
scopeValues?: ScopeValues;
|
|
336
|
+
},
|
|
337
|
+
expectedHash: string | undefined,
|
|
338
|
+
sha256Override?: (bytes: Uint8Array) => Promise<string>
|
|
339
|
+
): Promise<unknown[]> {
|
|
340
|
+
const rawStream = await fetchSnapshotChunkStream(transport, request);
|
|
341
|
+
const decodedStream = await maybeGunzipStream(rawStream);
|
|
342
|
+
let rowStream = decodedStream;
|
|
343
|
+
let chunkHashPromise: Promise<string> | null = null;
|
|
344
|
+
|
|
345
|
+
if (expectedHash) {
|
|
346
|
+
const [hashStream, streamForRows] = decodedStream.tee();
|
|
347
|
+
rowStream = streamForRows;
|
|
348
|
+
chunkHashPromise = readAllBytesFromStream(hashStream).then((bytes) =>
|
|
349
|
+
computeSha256Hex(bytes, sha256Override)
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const rows: unknown[] = [];
|
|
354
|
+
let materializeError: unknown = null;
|
|
355
|
+
|
|
356
|
+
try {
|
|
357
|
+
for await (const batch of decodeSnapshotRowStreamBatches(
|
|
358
|
+
rowStream,
|
|
359
|
+
SNAPSHOT_APPLY_BATCH_ROWS
|
|
360
|
+
)) {
|
|
361
|
+
rows.push(...batch);
|
|
362
|
+
}
|
|
363
|
+
} catch (error) {
|
|
364
|
+
materializeError = error;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (chunkHashPromise) {
|
|
368
|
+
try {
|
|
369
|
+
const actualHash = await chunkHashPromise;
|
|
370
|
+
if (!materializeError && actualHash !== expectedHash) {
|
|
371
|
+
materializeError = new Error(
|
|
372
|
+
`Snapshot chunk integrity check failed: expected sha256 ${expectedHash}, got ${actualHash}`
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
} catch (hashError) {
|
|
376
|
+
if (!materializeError) {
|
|
377
|
+
materializeError = hashError;
|
|
378
|
+
}
|
|
377
379
|
}
|
|
378
380
|
}
|
|
379
381
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
+
if (materializeError) {
|
|
383
|
+
throw materializeError;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return rows;
|
|
382
387
|
}
|
|
383
388
|
|
|
384
389
|
async function materializeChunkedSnapshots(
|
|
@@ -386,69 +391,48 @@ async function materializeChunkedSnapshots(
|
|
|
386
391
|
response: SyncPullResponse,
|
|
387
392
|
sha256Override?: (bytes: Uint8Array) => Promise<string>
|
|
388
393
|
): Promise<SyncPullResponse> {
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
const subscriptions = await Promise.all(
|
|
392
|
-
response.subscriptions.map(async (sub) => {
|
|
393
|
-
if (!sub.bootstrap) return sub;
|
|
394
|
-
if (!sub.snapshots || sub.snapshots.length === 0) return sub;
|
|
395
|
-
|
|
396
|
-
const snapshots = await mapWithConcurrency(
|
|
397
|
-
sub.snapshots,
|
|
398
|
-
SNAPSHOT_CHUNK_CONCURRENCY,
|
|
399
|
-
async (snapshot) => {
|
|
400
|
-
const chunks = snapshot.chunks ?? [];
|
|
401
|
-
if (chunks.length === 0) {
|
|
402
|
-
return snapshot;
|
|
403
|
-
}
|
|
394
|
+
const subscriptions: SyncPullResponse['subscriptions'] = [];
|
|
404
395
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
chunkCache.get(chunk.id) ??
|
|
411
|
-
transport.fetchSnapshotChunk({
|
|
412
|
-
chunkId: chunk.id,
|
|
413
|
-
scopeValues: sub.scopes,
|
|
414
|
-
});
|
|
415
|
-
chunkCache.set(chunk.id, promise);
|
|
416
|
-
|
|
417
|
-
const raw = await promise;
|
|
418
|
-
const bytes = await maybeGunzip(raw);
|
|
419
|
-
|
|
420
|
-
// Verify chunk integrity using sha256 hash
|
|
421
|
-
if (chunk.sha256) {
|
|
422
|
-
const actualHash = await computeSha256Hex(
|
|
423
|
-
bytes,
|
|
424
|
-
sha256Override
|
|
425
|
-
);
|
|
426
|
-
if (actualHash !== chunk.sha256) {
|
|
427
|
-
throw new Error(
|
|
428
|
-
`Snapshot chunk integrity check failed: expected sha256 ${chunk.sha256}, got ${actualHash}`
|
|
429
|
-
);
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
return decodeSnapshotRows(bytes);
|
|
434
|
-
}
|
|
435
|
-
);
|
|
396
|
+
for (const sub of response.subscriptions) {
|
|
397
|
+
if (!sub.bootstrap || !sub.snapshots || sub.snapshots.length === 0) {
|
|
398
|
+
subscriptions.push(sub);
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
436
401
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
402
|
+
const snapshots: SyncPullSubscriptionResponse['snapshots'] = [];
|
|
403
|
+
for (const snapshot of sub.snapshots) {
|
|
404
|
+
const chunks = snapshot.chunks ?? [];
|
|
405
|
+
if (chunks.length === 0) {
|
|
406
|
+
snapshots.push(snapshot);
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
441
409
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
410
|
+
const rows: unknown[] = [];
|
|
411
|
+
for (const chunk of chunks) {
|
|
412
|
+
const chunkRows = await materializeSnapshotChunkRows(
|
|
413
|
+
transport,
|
|
414
|
+
{
|
|
415
|
+
chunkId: chunk.id,
|
|
416
|
+
scopeValues: sub.scopes,
|
|
417
|
+
},
|
|
418
|
+
chunk.sha256,
|
|
419
|
+
sha256Override
|
|
420
|
+
);
|
|
421
|
+
rows.push(...chunkRows);
|
|
422
|
+
}
|
|
445
423
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
424
|
+
snapshots.push({
|
|
425
|
+
...snapshot,
|
|
426
|
+
rows,
|
|
427
|
+
chunks: undefined,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
449
430
|
|
|
450
|
-
|
|
451
|
-
|
|
431
|
+
subscriptions.push({
|
|
432
|
+
...sub,
|
|
433
|
+
snapshots,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
452
436
|
|
|
453
437
|
return { ...response, subscriptions };
|
|
454
438
|
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public query exports used by packages that consume @syncular/client.
|
|
3
|
+
*
|
|
4
|
+
* This wrapper keeps query-builder tracking isolated per chain so branching a
|
|
5
|
+
* base Kysely builder does not leak joined tables into sibling branches.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Kysely } from 'kysely';
|
|
9
|
+
import type { FingerprintCollector } from './query/FingerprintCollector';
|
|
10
|
+
import {
|
|
11
|
+
computeRowFingerprint,
|
|
12
|
+
computeValueFingerprint,
|
|
13
|
+
hasKeyField,
|
|
14
|
+
type MutationTimestampSource,
|
|
15
|
+
} from './query/fingerprint';
|
|
16
|
+
import type { SyncClientDb } from './schema';
|
|
17
|
+
|
|
18
|
+
export { FingerprintCollector } from './query/FingerprintCollector';
|
|
19
|
+
export {
|
|
20
|
+
canFingerprint,
|
|
21
|
+
computeFingerprint,
|
|
22
|
+
} from './query/fingerprint';
|
|
23
|
+
|
|
24
|
+
export type FingerprintMode = 'auto' | 'value';
|
|
25
|
+
|
|
26
|
+
type TrackedSelectFrom<DB> = Kysely<DB>['selectFrom'];
|
|
27
|
+
type SelectFromArgs<DB> = Parameters<Kysely<DB>['selectFrom']>;
|
|
28
|
+
type SelectFromResult<DB> = ReturnType<Kysely<DB>['selectFrom']>;
|
|
29
|
+
|
|
30
|
+
type ExecutableQuery = {
|
|
31
|
+
execute: () => Promise<unknown>;
|
|
32
|
+
executeTakeFirst: () => Promise<unknown>;
|
|
33
|
+
executeTakeFirstOrThrow: () => Promise<unknown>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const JOIN_METHODS = new Set([
|
|
37
|
+
'innerJoin',
|
|
38
|
+
'leftJoin',
|
|
39
|
+
'rightJoin',
|
|
40
|
+
'fullJoin',
|
|
41
|
+
'crossJoin',
|
|
42
|
+
'innerJoinLateral',
|
|
43
|
+
'leftJoinLateral',
|
|
44
|
+
'crossJoinLateral',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
48
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isExecutableQuery(value: unknown): value is ExecutableQuery {
|
|
52
|
+
if (!isRecord(value)) return false;
|
|
53
|
+
return (
|
|
54
|
+
typeof Reflect.get(value, 'execute') === 'function' &&
|
|
55
|
+
typeof Reflect.get(value, 'executeTakeFirst') === 'function' &&
|
|
56
|
+
typeof Reflect.get(value, 'executeTakeFirstOrThrow') === 'function'
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function extractTrackedTableNames(value: unknown): string[] {
|
|
61
|
+
if (typeof value === 'string') {
|
|
62
|
+
const normalized = value.trim();
|
|
63
|
+
if (normalized.length === 0) return [];
|
|
64
|
+
|
|
65
|
+
const aliasIndex = normalized.search(/\s+as\s+/i);
|
|
66
|
+
const withoutAlias =
|
|
67
|
+
aliasIndex >= 0 ? normalized.slice(0, aliasIndex) : normalized;
|
|
68
|
+
const firstToken = withoutAlias.split(/\s+/)[0] ?? '';
|
|
69
|
+
return firstToken.length > 0 ? [firstToken] : [];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (Array.isArray(value)) {
|
|
73
|
+
return value.flatMap((entry) => extractTrackedTableNames(entry));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function addFingerprint(args: {
|
|
80
|
+
rows: unknown;
|
|
81
|
+
primaryTable: string | null;
|
|
82
|
+
trackedTables: ReadonlySet<string>;
|
|
83
|
+
collector: FingerprintCollector;
|
|
84
|
+
engine: MutationTimestampSource;
|
|
85
|
+
keyField: string;
|
|
86
|
+
fingerprintMode: FingerprintMode;
|
|
87
|
+
}): void {
|
|
88
|
+
const {
|
|
89
|
+
rows,
|
|
90
|
+
primaryTable,
|
|
91
|
+
trackedTables,
|
|
92
|
+
collector,
|
|
93
|
+
engine,
|
|
94
|
+
keyField,
|
|
95
|
+
fingerprintMode,
|
|
96
|
+
} = args;
|
|
97
|
+
|
|
98
|
+
const fingerprintScope =
|
|
99
|
+
trackedTables.size > 0
|
|
100
|
+
? Array.from(trackedTables).sort().join('+')
|
|
101
|
+
: (primaryTable ?? 'query');
|
|
102
|
+
|
|
103
|
+
if (
|
|
104
|
+
fingerprintMode === 'auto' &&
|
|
105
|
+
primaryTable &&
|
|
106
|
+
trackedTables.size === 1 &&
|
|
107
|
+
Array.isArray(rows) &&
|
|
108
|
+
hasKeyField(rows, keyField)
|
|
109
|
+
) {
|
|
110
|
+
collector.add(computeRowFingerprint(rows, primaryTable, engine, keyField));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
collector.add(computeValueFingerprint(fingerprintScope, rows));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function addTrackedTablesToScopeCollector(
|
|
118
|
+
scopeCollector: Set<string>,
|
|
119
|
+
trackedTables: ReadonlySet<string>
|
|
120
|
+
): void {
|
|
121
|
+
for (const trackedTable of trackedTables) {
|
|
122
|
+
scopeCollector.add(trackedTable);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function createExecuteProxy<B extends ExecutableQuery>(
|
|
127
|
+
builder: B,
|
|
128
|
+
primaryTable: string | null,
|
|
129
|
+
trackedTables: ReadonlySet<string>,
|
|
130
|
+
scopeCollector: Set<string>,
|
|
131
|
+
collector: FingerprintCollector,
|
|
132
|
+
engine: MutationTimestampSource,
|
|
133
|
+
keyField: string,
|
|
134
|
+
fingerprintMode: FingerprintMode
|
|
135
|
+
): B {
|
|
136
|
+
return new Proxy(builder, {
|
|
137
|
+
get(target, prop, receiver) {
|
|
138
|
+
if (prop === 'execute') {
|
|
139
|
+
return async () => {
|
|
140
|
+
const rows = await target.execute();
|
|
141
|
+
addTrackedTablesToScopeCollector(scopeCollector, trackedTables);
|
|
142
|
+
addFingerprint({
|
|
143
|
+
rows,
|
|
144
|
+
primaryTable,
|
|
145
|
+
trackedTables,
|
|
146
|
+
collector,
|
|
147
|
+
engine,
|
|
148
|
+
keyField,
|
|
149
|
+
fingerprintMode,
|
|
150
|
+
});
|
|
151
|
+
return rows;
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (prop === 'executeTakeFirst') {
|
|
156
|
+
return async () => {
|
|
157
|
+
const row = await target.executeTakeFirst();
|
|
158
|
+
addTrackedTablesToScopeCollector(scopeCollector, trackedTables);
|
|
159
|
+
addFingerprint({
|
|
160
|
+
rows: row,
|
|
161
|
+
primaryTable,
|
|
162
|
+
trackedTables,
|
|
163
|
+
collector,
|
|
164
|
+
engine,
|
|
165
|
+
keyField,
|
|
166
|
+
fingerprintMode,
|
|
167
|
+
});
|
|
168
|
+
return row;
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (prop === 'executeTakeFirstOrThrow') {
|
|
173
|
+
return async () => {
|
|
174
|
+
const row = await target.executeTakeFirstOrThrow();
|
|
175
|
+
addTrackedTablesToScopeCollector(scopeCollector, trackedTables);
|
|
176
|
+
addFingerprint({
|
|
177
|
+
rows: row,
|
|
178
|
+
primaryTable,
|
|
179
|
+
trackedTables,
|
|
180
|
+
collector,
|
|
181
|
+
engine,
|
|
182
|
+
keyField,
|
|
183
|
+
fingerprintMode,
|
|
184
|
+
});
|
|
185
|
+
return row;
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const value = Reflect.get(target, prop, receiver);
|
|
190
|
+
if (typeof value !== 'function') {
|
|
191
|
+
return value;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return (...args: unknown[]) => {
|
|
195
|
+
const nextTrackedTables = new Set(trackedTables);
|
|
196
|
+
|
|
197
|
+
if (
|
|
198
|
+
typeof prop === 'string' &&
|
|
199
|
+
JOIN_METHODS.has(prop) &&
|
|
200
|
+
args.length > 0
|
|
201
|
+
) {
|
|
202
|
+
for (const tableName of extractTrackedTableNames(args[0])) {
|
|
203
|
+
nextTrackedTables.add(tableName);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const result = Reflect.apply(value, target, args);
|
|
208
|
+
if (!isExecutableQuery(result)) {
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return createExecuteProxy(
|
|
213
|
+
result,
|
|
214
|
+
primaryTable,
|
|
215
|
+
nextTrackedTables,
|
|
216
|
+
scopeCollector,
|
|
217
|
+
collector,
|
|
218
|
+
engine,
|
|
219
|
+
keyField,
|
|
220
|
+
fingerprintMode
|
|
221
|
+
);
|
|
222
|
+
};
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function createTrackedSelectFrom<DB extends SyncClientDb>(
|
|
228
|
+
db: Kysely<DB>,
|
|
229
|
+
scopeCollector: Set<string>,
|
|
230
|
+
fingerprintCollector: FingerprintCollector,
|
|
231
|
+
engine: MutationTimestampSource,
|
|
232
|
+
keyField = 'id',
|
|
233
|
+
fingerprintMode: FingerprintMode = 'auto'
|
|
234
|
+
): TrackedSelectFrom<DB> {
|
|
235
|
+
const selectFrom = (...args: SelectFromArgs<DB>) => {
|
|
236
|
+
const trackedTables = new Set<string>(extractTrackedTableNames(args[0]));
|
|
237
|
+
const primaryTable = Array.from(trackedTables)[0] ?? null;
|
|
238
|
+
const builder = db.selectFrom(...args);
|
|
239
|
+
|
|
240
|
+
return createExecuteProxy(
|
|
241
|
+
builder,
|
|
242
|
+
primaryTable,
|
|
243
|
+
trackedTables,
|
|
244
|
+
scopeCollector,
|
|
245
|
+
fingerprintCollector,
|
|
246
|
+
engine,
|
|
247
|
+
keyField,
|
|
248
|
+
fingerprintMode
|
|
249
|
+
) as SelectFromResult<DB>;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
return selectFrom as TrackedSelectFrom<DB>;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export interface QueryContext<DB extends SyncClientDb = SyncClientDb> {
|
|
256
|
+
selectFrom: TrackedSelectFrom<DB>;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function createQueryContext<DB extends SyncClientDb>(
|
|
260
|
+
db: Kysely<DB>,
|
|
261
|
+
scopeCollector: Set<string>,
|
|
262
|
+
fingerprintCollector: FingerprintCollector,
|
|
263
|
+
engine: MutationTimestampSource,
|
|
264
|
+
keyField = 'id',
|
|
265
|
+
fingerprintMode: FingerprintMode = 'auto'
|
|
266
|
+
): QueryContext<DB> {
|
|
267
|
+
return {
|
|
268
|
+
selectFrom: createTrackedSelectFrom(
|
|
269
|
+
db,
|
|
270
|
+
scopeCollector,
|
|
271
|
+
fingerprintCollector,
|
|
272
|
+
engine,
|
|
273
|
+
keyField,
|
|
274
|
+
fingerprintMode
|
|
275
|
+
),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @syncular/client - Query Context
|
|
3
|
-
*
|
|
4
|
-
* Provides a query context with tracked selectFrom for scope tracking
|
|
5
|
-
* and automatic fingerprint generation.
|
|
6
|
-
*/
|
|
7
|
-
import type { Kysely } from 'kysely';
|
|
8
|
-
import type { SyncClientDb } from '../schema';
|
|
9
|
-
import type { FingerprintCollector } from './FingerprintCollector';
|
|
10
|
-
import type { MutationTimestampSource } from './fingerprint';
|
|
11
|
-
import { createTrackedSelectFrom } from './tracked-select';
|
|
12
|
-
export type TrackedSelectFrom<DB> = ReturnType<typeof createTrackedSelectFrom<DB>>;
|
|
13
|
-
/**
|
|
14
|
-
* Query context provided to query functions.
|
|
15
|
-
*
|
|
16
|
-
* Only `selectFrom` is exposed to ensure proper scope tracking and fingerprinting.
|
|
17
|
-
* If you need raw database access, use the db directly outside the query function.
|
|
18
|
-
*/
|
|
19
|
-
export interface QueryContext<DB extends SyncClientDb = SyncClientDb> {
|
|
20
|
-
/**
|
|
21
|
-
* Wrapped selectFrom that:
|
|
22
|
-
* 1. Registers table as watched scope
|
|
23
|
-
* 2. Intercepts .execute() to auto-detect fingerprinting mode:
|
|
24
|
-
* - Result has keyField (default: 'id')? -> row-level fingerprinting
|
|
25
|
-
* - No keyField? -> value-based fingerprinting (for aggregates)
|
|
26
|
-
*/
|
|
27
|
-
selectFrom: TrackedSelectFrom<DB>;
|
|
28
|
-
}
|
|
29
|
-
/**
|
|
30
|
-
* Create a query context with tracked selectFrom.
|
|
31
|
-
*/
|
|
32
|
-
export declare function createQueryContext<DB extends SyncClientDb>(db: Kysely<DB>, scopeCollector: Set<string>, fingerprintCollector: FingerprintCollector, engine: MutationTimestampSource, keyField?: string): QueryContext<DB>;
|
|
33
|
-
//# sourceMappingURL=QueryContext.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"QueryContext.d.ts","sourceRoot":"","sources":["../../src/query/QueryContext.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AACrC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAC9C,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AACnE,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,eAAe,CAAC;AAC7D,OAAO,EAAE,uBAAuB,EAAE,MAAM,kBAAkB,CAAC;AAE3D,MAAM,MAAM,iBAAiB,CAAC,EAAE,IAAI,UAAU,CAC5C,OAAO,uBAAuB,CAAC,EAAE,CAAC,CACnC,CAAC;AAEF;;;;;GAKG;AACH,MAAM,WAAW,YAAY,CAAC,EAAE,SAAS,YAAY,GAAG,YAAY;IAClE;;;;;;OAMG;IACH,UAAU,EAAE,iBAAiB,CAAC,EAAE,CAAC,CAAC;CACnC;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,EAAE,SAAS,YAAY,EACxD,EAAE,EAAE,MAAM,CAAC,EAAE,CAAC,EACd,cAAc,EAAE,GAAG,CAAC,MAAM,CAAC,EAC3B,oBAAoB,EAAE,oBAAoB,EAC1C,MAAM,EAAE,uBAAuB,EAC/B,QAAQ,SAAO,GACd,YAAY,CAAC,EAAE,CAAC,CAUlB"}
|