@syncular/client 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 +23 -0
- package/dist/blobs/index.js +3 -3
- package/dist/client.d.ts +10 -5
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +70 -21
- package/dist/client.js.map +1 -1
- package/dist/conflicts.d.ts.map +1 -1
- package/dist/conflicts.js +1 -7
- package/dist/conflicts.js.map +1 -1
- package/dist/create-client.d.ts +5 -1
- package/dist/create-client.d.ts.map +1 -1
- package/dist/create-client.js +22 -10
- package/dist/create-client.js.map +1 -1
- package/dist/engine/SyncEngine.d.ts +24 -2
- package/dist/engine/SyncEngine.d.ts.map +1 -1
- package/dist/engine/SyncEngine.js +290 -43
- package/dist/engine/SyncEngine.js.map +1 -1
- package/dist/engine/index.js +2 -2
- package/dist/engine/types.d.ts +16 -4
- package/dist/engine/types.d.ts.map +1 -1
- package/dist/handlers/create-handler.d.ts +15 -5
- package/dist/handlers/create-handler.d.ts.map +1 -1
- package/dist/handlers/create-handler.js +35 -24
- package/dist/handlers/create-handler.js.map +1 -1
- package/dist/handlers/types.d.ts +5 -5
- package/dist/handlers/types.d.ts.map +1 -1
- package/dist/index.js +19 -19
- package/dist/migrate.d.ts +1 -1
- package/dist/migrate.d.ts.map +1 -1
- package/dist/migrate.js +148 -28
- package/dist/migrate.js.map +1 -1
- package/dist/mutations.d.ts +3 -1
- package/dist/mutations.d.ts.map +1 -1
- package/dist/mutations.js +93 -18
- package/dist/mutations.js.map +1 -1
- package/dist/outbox.d.ts.map +1 -1
- package/dist/outbox.js +1 -11
- package/dist/outbox.js.map +1 -1
- package/dist/plugins/incrementing-version.d.ts +1 -1
- package/dist/plugins/incrementing-version.js +2 -2
- package/dist/plugins/index.js +2 -2
- package/dist/proxy/dialect.js +1 -1
- package/dist/proxy/driver.js +1 -1
- package/dist/proxy/index.js +4 -4
- package/dist/proxy/mutations.js +1 -1
- package/dist/pull-engine.d.ts +29 -3
- package/dist/pull-engine.d.ts.map +1 -1
- package/dist/pull-engine.js +314 -78
- package/dist/pull-engine.js.map +1 -1
- package/dist/push-engine.d.ts.map +1 -1
- package/dist/push-engine.js +28 -3
- package/dist/push-engine.js.map +1 -1
- package/dist/query/QueryContext.js +1 -1
- package/dist/query/index.js +3 -3
- package/dist/query/tracked-select.d.ts +2 -1
- package/dist/query/tracked-select.d.ts.map +1 -1
- package/dist/query/tracked-select.js +1 -1
- package/dist/schema.d.ts +2 -2
- package/dist/schema.d.ts.map +1 -1
- package/dist/sync-loop.d.ts +5 -1
- package/dist/sync-loop.d.ts.map +1 -1
- package/dist/sync-loop.js +167 -18
- package/dist/sync-loop.js.map +1 -1
- package/package.json +30 -6
- package/src/client.test.ts +369 -0
- package/src/client.ts +101 -22
- package/src/conflicts.ts +1 -10
- package/src/create-client.ts +33 -5
- package/src/engine/SyncEngine.test.ts +157 -0
- package/src/engine/SyncEngine.ts +359 -40
- package/src/engine/types.ts +22 -4
- package/src/handlers/create-handler.ts +86 -37
- package/src/handlers/types.ts +10 -4
- package/src/migrate.ts +215 -33
- package/src/mutations.ts +143 -21
- package/src/outbox.ts +1 -15
- package/src/plugins/incrementing-version.ts +2 -2
- package/src/pull-engine.test.ts +147 -0
- package/src/pull-engine.ts +392 -77
- package/src/push-engine.ts +33 -1
- package/src/query/tracked-select.ts +1 -1
- package/src/schema.ts +2 -2
- package/src/sync-loop.ts +215 -19
|
@@ -10,7 +10,7 @@ import type { Kysely } from 'kysely';
|
|
|
10
10
|
import type { FingerprintCollector } from './FingerprintCollector';
|
|
11
11
|
|
|
12
12
|
/** Portable type alias for Kysely's selectFrom method signature */
|
|
13
|
-
|
|
13
|
+
type TrackedSelectFrom<DB> = Kysely<DB>['selectFrom'];
|
|
14
14
|
|
|
15
15
|
import {
|
|
16
16
|
computeRowFingerprint,
|
package/src/schema.ts
CHANGED
|
@@ -26,8 +26,8 @@ export interface SyncSubscriptionStateTable {
|
|
|
26
26
|
state_id: string;
|
|
27
27
|
/** Subscription identifier (client-chosen) */
|
|
28
28
|
subscription_id: string;
|
|
29
|
-
/**
|
|
30
|
-
|
|
29
|
+
/** Table name for this subscription */
|
|
30
|
+
table: string;
|
|
31
31
|
/** JSON string of ScopeValues for this subscription */
|
|
32
32
|
scopes_json: string;
|
|
33
33
|
/** JSON string of params */
|
package/src/sync-loop.ts
CHANGED
|
@@ -7,12 +7,27 @@
|
|
|
7
7
|
import type {
|
|
8
8
|
SyncPullResponse,
|
|
9
9
|
SyncPullSubscriptionResponse,
|
|
10
|
+
SyncPushRequest,
|
|
11
|
+
SyncPushResponse,
|
|
10
12
|
SyncSubscriptionRequest,
|
|
11
13
|
SyncTransport,
|
|
12
14
|
} from '@syncular/core';
|
|
13
15
|
import type { Kysely } from 'kysely';
|
|
16
|
+
import { upsertConflictsForRejectedCommit } from './conflicts';
|
|
14
17
|
import type { ClientTableRegistry } from './handlers/registry';
|
|
15
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
getNextSendableOutboxCommit,
|
|
20
|
+
markOutboxCommitAcked,
|
|
21
|
+
markOutboxCommitFailed,
|
|
22
|
+
markOutboxCommitPending,
|
|
23
|
+
} from './outbox';
|
|
24
|
+
import type { SyncClientPluginContext } from './plugins/types';
|
|
25
|
+
import {
|
|
26
|
+
applyPullResponse,
|
|
27
|
+
buildPullRequest,
|
|
28
|
+
type SyncPullOnceOptions,
|
|
29
|
+
syncPullOnce,
|
|
30
|
+
} from './pull-engine';
|
|
16
31
|
import { type SyncPushOnceOptions, syncPushOnce } from './push-engine';
|
|
17
32
|
import type { SyncClientDb } from './schema';
|
|
18
33
|
|
|
@@ -56,6 +71,16 @@ interface SyncPullUntilSettledResult {
|
|
|
56
71
|
rounds: number;
|
|
57
72
|
}
|
|
58
73
|
|
|
74
|
+
interface TransportWithWsPush extends SyncTransport {
|
|
75
|
+
pushViaWs(request: SyncPushRequest): Promise<SyncPushResponse | null>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function hasPushViaWs(
|
|
79
|
+
transport: SyncTransport
|
|
80
|
+
): transport is TransportWithWsPush {
|
|
81
|
+
return 'pushViaWs' in transport && typeof transport.pushViaWs === 'function';
|
|
82
|
+
}
|
|
83
|
+
|
|
59
84
|
function needsAnotherPull(res: SyncPullResponse): boolean {
|
|
60
85
|
for (const sub of res.subscriptions ?? []) {
|
|
61
86
|
if (sub.status !== 'active') continue;
|
|
@@ -94,7 +119,7 @@ function mergePullResponse(
|
|
|
94
119
|
async function syncPullUntilSettled<DB extends SyncClientDb>(
|
|
95
120
|
db: Kysely<DB>,
|
|
96
121
|
transport: SyncTransport,
|
|
97
|
-
|
|
122
|
+
handlers: ClientTableRegistry<DB>,
|
|
98
123
|
options: SyncPullUntilSettledOptions
|
|
99
124
|
): Promise<SyncPullUntilSettledResult> {
|
|
100
125
|
const maxRounds = Math.max(1, Math.min(1000, options.maxRounds ?? 20));
|
|
@@ -104,7 +129,7 @@ async function syncPullUntilSettled<DB extends SyncClientDb>(
|
|
|
104
129
|
|
|
105
130
|
for (let i = 0; i < maxRounds; i++) {
|
|
106
131
|
rounds += 1;
|
|
107
|
-
const res = await syncPullOnce(db, transport,
|
|
132
|
+
const res = await syncPullOnce(db, transport, handlers, options);
|
|
108
133
|
mergePullResponse(aggregatedBySubId, res);
|
|
109
134
|
|
|
110
135
|
if (!needsAnotherPull(res)) break;
|
|
@@ -133,6 +158,10 @@ export interface SyncOnceOptions {
|
|
|
133
158
|
stateId?: string;
|
|
134
159
|
maxPushCommits?: number;
|
|
135
160
|
maxPullRounds?: number;
|
|
161
|
+
/** When 'ws', peek outbox first and skip push if empty. */
|
|
162
|
+
trigger?: 'ws' | 'local' | 'poll';
|
|
163
|
+
/** Custom SHA-256 hash function (for platforms without crypto.subtle) */
|
|
164
|
+
sha256?: (bytes: Uint8Array) => Promise<string>;
|
|
136
165
|
}
|
|
137
166
|
|
|
138
167
|
export interface SyncOnceResult {
|
|
@@ -141,20 +170,22 @@ export interface SyncOnceResult {
|
|
|
141
170
|
pullResponse: SyncPullResponse;
|
|
142
171
|
}
|
|
143
172
|
|
|
144
|
-
|
|
173
|
+
/**
|
|
174
|
+
* Sync once using a WS-first push strategy for the first outbox commit.
|
|
175
|
+
*
|
|
176
|
+
* - If transport supports `pushViaWs` and the commit succeeds over WS, this call
|
|
177
|
+
* sends only an HTTP pull request.
|
|
178
|
+
* - Otherwise it falls back to combined HTTP push+pull.
|
|
179
|
+
*
|
|
180
|
+
* Remaining outbox commits are then settled via `syncPushUntilSettled`.
|
|
181
|
+
*/
|
|
182
|
+
async function syncOnceCombined<DB extends SyncClientDb>(
|
|
145
183
|
db: Kysely<DB>,
|
|
146
184
|
transport: SyncTransport,
|
|
147
|
-
|
|
185
|
+
handlers: ClientTableRegistry<DB>,
|
|
148
186
|
options: SyncOnceOptions
|
|
149
187
|
): Promise<SyncOnceResult> {
|
|
150
|
-
const
|
|
151
|
-
clientId: options.clientId,
|
|
152
|
-
actorId: options.actorId,
|
|
153
|
-
plugins: options.plugins,
|
|
154
|
-
maxCommits: options.maxPushCommits,
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
const pulled = await syncPullUntilSettled(db, transport, shapes, {
|
|
188
|
+
const pullOpts: SyncPullOnceOptions = {
|
|
158
189
|
clientId: options.clientId,
|
|
159
190
|
actorId: options.actorId,
|
|
160
191
|
plugins: options.plugins,
|
|
@@ -164,12 +195,177 @@ export async function syncOnce<DB extends SyncClientDb>(
|
|
|
164
195
|
maxSnapshotPages: options.maxSnapshotPages,
|
|
165
196
|
dedupeRows: options.dedupeRows,
|
|
166
197
|
stateId: options.stateId,
|
|
167
|
-
|
|
168
|
-
}
|
|
198
|
+
sha256: options.sha256,
|
|
199
|
+
};
|
|
169
200
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
201
|
+
// Build pull request (reads subscription state)
|
|
202
|
+
const pullState = await buildPullRequest(db, pullOpts);
|
|
203
|
+
const { clientId } = pullState.request;
|
|
204
|
+
|
|
205
|
+
// Grab at most one outbox commit
|
|
206
|
+
const outbox = await getNextSendableOutboxCommit(db);
|
|
207
|
+
|
|
208
|
+
const plugins = options.plugins ?? [];
|
|
209
|
+
const ctx: SyncClientPluginContext = {
|
|
210
|
+
actorId: options.actorId ?? 'unknown',
|
|
211
|
+
clientId,
|
|
174
212
|
};
|
|
213
|
+
|
|
214
|
+
// Build push request, running beforePush plugins
|
|
215
|
+
let pushRequest: SyncPushRequest | undefined;
|
|
216
|
+
if (outbox) {
|
|
217
|
+
pushRequest = {
|
|
218
|
+
clientId,
|
|
219
|
+
clientCommitId: outbox.client_commit_id,
|
|
220
|
+
operations: outbox.operations,
|
|
221
|
+
schemaVersion: outbox.schema_version,
|
|
222
|
+
};
|
|
223
|
+
for (const plugin of plugins) {
|
|
224
|
+
if (!plugin.beforePush) continue;
|
|
225
|
+
pushRequest = await plugin.beforePush(ctx, pushRequest);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Try WS push first for the first outbox commit (if realtime transport supports it).
|
|
230
|
+
// Fall back to HTTP push in the combined request when WS is unavailable or fails.
|
|
231
|
+
let wsPushResponse: SyncPushResponse | null = null;
|
|
232
|
+
if (pushRequest && hasPushViaWs(transport)) {
|
|
233
|
+
try {
|
|
234
|
+
wsPushResponse = await transport.pushViaWs(pushRequest);
|
|
235
|
+
} catch {
|
|
236
|
+
wsPushResponse = null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const combined = await transport.sync({
|
|
241
|
+
clientId,
|
|
242
|
+
...(pushRequest && !wsPushResponse
|
|
243
|
+
? {
|
|
244
|
+
push: {
|
|
245
|
+
clientCommitId: pushRequest.clientCommitId,
|
|
246
|
+
operations: pushRequest.operations,
|
|
247
|
+
schemaVersion: pushRequest.schemaVersion,
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
: {}),
|
|
251
|
+
pull: {
|
|
252
|
+
limitCommits: pullState.request.limitCommits,
|
|
253
|
+
limitSnapshotRows: pullState.request.limitSnapshotRows,
|
|
254
|
+
maxSnapshotPages: pullState.request.maxSnapshotPages,
|
|
255
|
+
dedupeRows: pullState.request.dedupeRows,
|
|
256
|
+
subscriptions: pullState.request.subscriptions,
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Process push response
|
|
261
|
+
let pushedCommits = 0;
|
|
262
|
+
if (outbox && pushRequest) {
|
|
263
|
+
let pushRes = wsPushResponse ?? combined.push;
|
|
264
|
+
if (!pushRes) {
|
|
265
|
+
await markOutboxCommitPending(db, {
|
|
266
|
+
id: outbox.id,
|
|
267
|
+
error: 'MISSING_PUSH_RESPONSE',
|
|
268
|
+
});
|
|
269
|
+
throw new Error('Server returned no push response');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Run afterPush plugins
|
|
273
|
+
for (const plugin of plugins) {
|
|
274
|
+
if (!plugin.afterPush) continue;
|
|
275
|
+
pushRes = await plugin.afterPush(ctx, {
|
|
276
|
+
request: pushRequest,
|
|
277
|
+
response: pushRes,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const responseJson = JSON.stringify(pushRes);
|
|
282
|
+
|
|
283
|
+
if (pushRes.status === 'applied' || pushRes.status === 'cached') {
|
|
284
|
+
await markOutboxCommitAcked(db, {
|
|
285
|
+
id: outbox.id,
|
|
286
|
+
commitSeq: pushRes.commitSeq ?? null,
|
|
287
|
+
responseJson,
|
|
288
|
+
});
|
|
289
|
+
pushedCommits = 1;
|
|
290
|
+
} else {
|
|
291
|
+
// Check if all errors are retriable
|
|
292
|
+
const errorResults = pushRes.results.filter((r) => r.status === 'error');
|
|
293
|
+
const allRetriable =
|
|
294
|
+
errorResults.length > 0 &&
|
|
295
|
+
errorResults.every((r) => r.retriable === true);
|
|
296
|
+
|
|
297
|
+
if (allRetriable) {
|
|
298
|
+
await markOutboxCommitPending(db, {
|
|
299
|
+
id: outbox.id,
|
|
300
|
+
error: 'Retriable',
|
|
301
|
+
responseJson,
|
|
302
|
+
});
|
|
303
|
+
pushedCommits = 1;
|
|
304
|
+
} else {
|
|
305
|
+
await upsertConflictsForRejectedCommit(db, {
|
|
306
|
+
outboxCommitId: outbox.id,
|
|
307
|
+
clientCommitId: outbox.client_commit_id,
|
|
308
|
+
response: pushRes,
|
|
309
|
+
});
|
|
310
|
+
await markOutboxCommitFailed(db, {
|
|
311
|
+
id: outbox.id,
|
|
312
|
+
error: 'REJECTED',
|
|
313
|
+
responseJson,
|
|
314
|
+
});
|
|
315
|
+
pushedCommits = 1;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Settle remaining outbox commits
|
|
320
|
+
const remaining = await syncPushUntilSettled(db, transport, {
|
|
321
|
+
clientId: options.clientId,
|
|
322
|
+
actorId: options.actorId,
|
|
323
|
+
plugins: options.plugins,
|
|
324
|
+
maxCommits: (options.maxPushCommits ?? 20) - 1,
|
|
325
|
+
});
|
|
326
|
+
pushedCommits += remaining.pushedCount;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Process pull response
|
|
330
|
+
let pullResponse: SyncPullResponse = { ok: true, subscriptions: [] };
|
|
331
|
+
let pullRounds = 0;
|
|
332
|
+
if (combined.pull) {
|
|
333
|
+
pullResponse = await applyPullResponse(
|
|
334
|
+
db,
|
|
335
|
+
transport,
|
|
336
|
+
handlers,
|
|
337
|
+
pullOpts,
|
|
338
|
+
pullState,
|
|
339
|
+
combined.pull
|
|
340
|
+
);
|
|
341
|
+
pullRounds = 1;
|
|
342
|
+
|
|
343
|
+
// Continue pulling if more data
|
|
344
|
+
if (needsAnotherPull(pullResponse)) {
|
|
345
|
+
const aggregatedBySubId = new Map<string, SyncPullSubscriptionResponse>();
|
|
346
|
+
mergePullResponse(aggregatedBySubId, pullResponse);
|
|
347
|
+
|
|
348
|
+
const more = await syncPullUntilSettled(db, transport, handlers, {
|
|
349
|
+
...pullOpts,
|
|
350
|
+
maxRounds: (options.maxPullRounds ?? 20) - 1,
|
|
351
|
+
});
|
|
352
|
+
pullRounds += more.rounds;
|
|
353
|
+
mergePullResponse(aggregatedBySubId, more.response);
|
|
354
|
+
pullResponse = {
|
|
355
|
+
ok: true,
|
|
356
|
+
subscriptions: Array.from(aggregatedBySubId.values()),
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return { pushedCommits, pullRounds, pullResponse };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export async function syncOnce<DB extends SyncClientDb>(
|
|
365
|
+
db: Kysely<DB>,
|
|
366
|
+
transport: SyncTransport,
|
|
367
|
+
handlers: ClientTableRegistry<DB>,
|
|
368
|
+
options: SyncOnceOptions
|
|
369
|
+
): Promise<SyncOnceResult> {
|
|
370
|
+
return syncOnceCombined(db, transport, handlers, options);
|
|
175
371
|
}
|