@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.
Files changed (83) hide show
  1. package/README.md +23 -0
  2. package/dist/blobs/index.js +3 -3
  3. package/dist/client.d.ts +10 -5
  4. package/dist/client.d.ts.map +1 -1
  5. package/dist/client.js +70 -21
  6. package/dist/client.js.map +1 -1
  7. package/dist/conflicts.d.ts.map +1 -1
  8. package/dist/conflicts.js +1 -7
  9. package/dist/conflicts.js.map +1 -1
  10. package/dist/create-client.d.ts +5 -1
  11. package/dist/create-client.d.ts.map +1 -1
  12. package/dist/create-client.js +22 -10
  13. package/dist/create-client.js.map +1 -1
  14. package/dist/engine/SyncEngine.d.ts +24 -2
  15. package/dist/engine/SyncEngine.d.ts.map +1 -1
  16. package/dist/engine/SyncEngine.js +290 -43
  17. package/dist/engine/SyncEngine.js.map +1 -1
  18. package/dist/engine/index.js +2 -2
  19. package/dist/engine/types.d.ts +16 -4
  20. package/dist/engine/types.d.ts.map +1 -1
  21. package/dist/handlers/create-handler.d.ts +15 -5
  22. package/dist/handlers/create-handler.d.ts.map +1 -1
  23. package/dist/handlers/create-handler.js +35 -24
  24. package/dist/handlers/create-handler.js.map +1 -1
  25. package/dist/handlers/types.d.ts +5 -5
  26. package/dist/handlers/types.d.ts.map +1 -1
  27. package/dist/index.js +19 -19
  28. package/dist/migrate.d.ts +1 -1
  29. package/dist/migrate.d.ts.map +1 -1
  30. package/dist/migrate.js +148 -28
  31. package/dist/migrate.js.map +1 -1
  32. package/dist/mutations.d.ts +3 -1
  33. package/dist/mutations.d.ts.map +1 -1
  34. package/dist/mutations.js +93 -18
  35. package/dist/mutations.js.map +1 -1
  36. package/dist/outbox.d.ts.map +1 -1
  37. package/dist/outbox.js +1 -11
  38. package/dist/outbox.js.map +1 -1
  39. package/dist/plugins/incrementing-version.d.ts +1 -1
  40. package/dist/plugins/incrementing-version.js +2 -2
  41. package/dist/plugins/index.js +2 -2
  42. package/dist/proxy/dialect.js +1 -1
  43. package/dist/proxy/driver.js +1 -1
  44. package/dist/proxy/index.js +4 -4
  45. package/dist/proxy/mutations.js +1 -1
  46. package/dist/pull-engine.d.ts +29 -3
  47. package/dist/pull-engine.d.ts.map +1 -1
  48. package/dist/pull-engine.js +314 -78
  49. package/dist/pull-engine.js.map +1 -1
  50. package/dist/push-engine.d.ts.map +1 -1
  51. package/dist/push-engine.js +28 -3
  52. package/dist/push-engine.js.map +1 -1
  53. package/dist/query/QueryContext.js +1 -1
  54. package/dist/query/index.js +3 -3
  55. package/dist/query/tracked-select.d.ts +2 -1
  56. package/dist/query/tracked-select.d.ts.map +1 -1
  57. package/dist/query/tracked-select.js +1 -1
  58. package/dist/schema.d.ts +2 -2
  59. package/dist/schema.d.ts.map +1 -1
  60. package/dist/sync-loop.d.ts +5 -1
  61. package/dist/sync-loop.d.ts.map +1 -1
  62. package/dist/sync-loop.js +167 -18
  63. package/dist/sync-loop.js.map +1 -1
  64. package/package.json +30 -6
  65. package/src/client.test.ts +369 -0
  66. package/src/client.ts +101 -22
  67. package/src/conflicts.ts +1 -10
  68. package/src/create-client.ts +33 -5
  69. package/src/engine/SyncEngine.test.ts +157 -0
  70. package/src/engine/SyncEngine.ts +359 -40
  71. package/src/engine/types.ts +22 -4
  72. package/src/handlers/create-handler.ts +86 -37
  73. package/src/handlers/types.ts +10 -4
  74. package/src/migrate.ts +215 -33
  75. package/src/mutations.ts +143 -21
  76. package/src/outbox.ts +1 -15
  77. package/src/plugins/incrementing-version.ts +2 -2
  78. package/src/pull-engine.test.ts +147 -0
  79. package/src/pull-engine.ts +392 -77
  80. package/src/push-engine.ts +33 -1
  81. package/src/query/tracked-select.ts +1 -1
  82. package/src/schema.ts +2 -2
  83. 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
- export type TrackedSelectFrom<DB> = Kysely<DB>['selectFrom'];
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
- /** Shape name (table) for this subscription */
30
- shape: string;
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 { type SyncPullOnceOptions, syncPullOnce } from './pull-engine';
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
- shapes: ClientTableRegistry<DB>,
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, shapes, options);
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
- export async function syncOnce<DB extends SyncClientDb>(
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
- shapes: ClientTableRegistry<DB>,
185
+ handlers: ClientTableRegistry<DB>,
148
186
  options: SyncOnceOptions
149
187
  ): Promise<SyncOnceResult> {
150
- const pushed = await syncPushUntilSettled(db, transport, {
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
- maxRounds: options.maxPullRounds,
168
- });
198
+ sha256: options.sha256,
199
+ };
169
200
 
170
- return {
171
- pushedCommits: pushed.pushedCount,
172
- pullRounds: pulled.rounds,
173
- pullResponse: pulled.response,
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
  }