@syncular/client 0.32.1 → 0.33.0

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/client.d.ts CHANGED
@@ -639,6 +639,14 @@ export declare class SyncClient {
639
639
  /**
640
640
  * Pull repeatedly until quiescent: no commits delivered, no bootstrap
641
641
  * pages pending, no resets to recover (§4.5 "pull again" SHOULD).
642
+ *
643
+ * §8.4: every host trigger drives this same client — the scheduler, the
644
+ * realtime supervisor's connect-time catch-up, and app code — so a call
645
+ * that arrives while a drive is in flight waits for that drive and then
646
+ * runs one follow-up drive. The follow-up is required, not defensive: the
647
+ * caller may have mutated the outbox after the in-flight drive captured it.
648
+ * Callers that arrive during the same drive with the same budget share that
649
+ * single follow-up. `sync()` keeps rejecting a concurrent `sync()`.
642
650
  */
643
651
  syncUntilIdle(maxRounds?: number): Promise<SyncIdleResult>;
644
652
  connectRealtime(): Promise<void>;
package/dist/client.js CHANGED
@@ -178,6 +178,14 @@ export class SyncClient {
178
178
  * (the fast-bail the delta path reads).
179
179
  */
180
180
  #syncOutstanding = false;
181
+ /**
182
+ * Idle drives run one after another (§8.4): `#syncIdleTail` settles when
183
+ * the last scheduled drive settles, and `#syncIdlePending` holds the
184
+ * scheduled drives that have not started, keyed by round budget, so a
185
+ * caller joins a waiting drive with its budget instead of queueing another.
186
+ */
187
+ #syncIdleTail;
188
+ #syncIdlePending = new Map();
181
189
  /**
182
190
  * Bumped by every local projection reset (`rebootstrapLocalData`). A sync
183
191
  * round captures this epoch together with its subscription state; a
@@ -1829,7 +1837,12 @@ export class SyncClient {
1829
1837
  if (!sameIntent) {
1830
1838
  throw new ClientSyncError('client.subscription_intent_mismatch', 'subscription id already names different table, scopes, or params');
1831
1839
  }
1832
- return;
1840
+ // §3.3: a revoked registration keeps its id and intent but no progress.
1841
+ // Subscribing again with the same intent re-arms it, so the next pull
1842
+ // resolves its scopes from scratch. A `failed` registration is a
1843
+ // configuration error and stays put.
1844
+ if (existing.status !== 'revoked')
1845
+ return;
1833
1846
  }
1834
1847
  saveSubscription(this.#db, definedObject({
1835
1848
  id: input.id,
@@ -2918,6 +2931,14 @@ export class SyncClient {
2918
2931
  /**
2919
2932
  * Pull repeatedly until quiescent: no commits delivered, no bootstrap
2920
2933
  * pages pending, no resets to recover (§4.5 "pull again" SHOULD).
2934
+ *
2935
+ * §8.4: every host trigger drives this same client — the scheduler, the
2936
+ * realtime supervisor's connect-time catch-up, and app code — so a call
2937
+ * that arrives while a drive is in flight waits for that drive and then
2938
+ * runs one follow-up drive. The follow-up is required, not defensive: the
2939
+ * caller may have mutated the outbox after the in-flight drive captured it.
2940
+ * Callers that arrive during the same drive with the same budget share that
2941
+ * single follow-up. `sync()` keeps rejecting a concurrent `sync()`.
2921
2942
  */
2922
2943
  async syncUntilIdle(maxRounds) {
2923
2944
  // §7.7: an explicit budget is a strict positive round count. Zero,
@@ -2925,6 +2946,31 @@ export class SyncClient {
2925
2946
  if (maxRounds !== undefined &&
2926
2947
  (!Number.isInteger(maxRounds) || maxRounds < 1 || maxRounds > 0xffffffff))
2927
2948
  throw invalidRequest('maxRounds must be a positive integer');
2949
+ const pending = this.#syncIdlePending.get(maxRounds);
2950
+ if (pending !== undefined)
2951
+ return pending;
2952
+ const previous = this.#syncIdleTail;
2953
+ let drive;
2954
+ if (previous === undefined) {
2955
+ drive = this.#syncIdleRounds(maxRounds);
2956
+ }
2957
+ else {
2958
+ drive = previous.then(() => {
2959
+ this.#syncIdlePending.delete(maxRounds);
2960
+ return this.#syncIdleRounds(maxRounds);
2961
+ });
2962
+ this.#syncIdlePending.set(maxRounds, drive);
2963
+ }
2964
+ const tail = drive.then(() => undefined, () => undefined);
2965
+ this.#syncIdleTail = tail;
2966
+ void tail.then(() => {
2967
+ if (this.#syncIdleTail === tail)
2968
+ this.#syncIdleTail = undefined;
2969
+ });
2970
+ return drive;
2971
+ }
2972
+ /** The rounds loop of one idle drive; `syncUntilIdle` owns coalescing. */
2973
+ async #syncIdleRounds(maxRounds) {
2928
2974
  const budget = maxRounds ?? 20;
2929
2975
  const aggregate = {
2930
2976
  pushed: 0,
package/dist/http.js CHANGED
@@ -229,25 +229,30 @@ export function httpBlobTransport(blobsBaseUrl, options) {
229
229
  : response.status === 401
230
230
  ? 'sync.auth_required'
231
231
  : 'sync.transport_failed');
232
- // §5.9.5 always-issue: a JSON body with `url` means presigned delivery;
233
- // an octet-stream body is inline bytes.
232
+ // §5.9.5 always-issue: a JSON body with a `url` means presigned delivery;
233
+ // any other body is inline bytes. The body is read exactly once as bytes,
234
+ // so an inline blob stored as `application/json` stays readable.
234
235
  const contentType = response.headers.get('content-type') ?? '';
236
+ const bytes = new Uint8Array(await transfer('blob download body read failed', () => response.arrayBuffer(), response.status));
235
237
  if (contentType.includes('application/json')) {
236
- const body = (await transfer('blob download body read failed', () => response.json(), response.status));
237
- if (typeof body.url === 'string') {
238
+ let envelope = null;
239
+ try {
240
+ envelope = JSON.parse(new TextDecoder().decode(bytes));
241
+ }
242
+ catch {
243
+ // Not JSON: inline bytes.
244
+ }
245
+ if (typeof envelope?.url === 'string') {
238
246
  return {
239
247
  kind: 'url',
240
- url: body.url,
241
- ...(typeof body.urlExpiresAtMs === 'number'
242
- ? { urlExpiresAtMs: body.urlExpiresAtMs }
248
+ url: envelope.url,
249
+ ...(typeof envelope.urlExpiresAtMs === 'number'
250
+ ? { urlExpiresAtMs: envelope.urlExpiresAtMs }
243
251
  : {}),
244
252
  };
245
253
  }
246
254
  }
247
- return {
248
- kind: 'bytes',
249
- bytes: new Uint8Array(await transfer('blob download body read failed', () => response.arrayBuffer(), response.status)),
250
- };
255
+ return { kind: 'bytes', bytes };
251
256
  },
252
257
  // §5.9.5: bare GET of the signed URL — no host auth (the URL is the grant).
253
258
  fetchUrl: async (url) => {
@@ -2,8 +2,10 @@ import type { WakeReason } from '@syncular/core';
2
2
  import type { SecurityLifecycle } from './client.js';
3
3
  import type { SyncIntent } from './invalidation.js';
4
4
  export interface SyncSchedulerClient {
5
- readonly syncNeeded: boolean;
6
- readonly securityLifecycle: SecurityLifecycle;
5
+ statusSnapshot(): {
6
+ readonly syncNeeded: boolean;
7
+ };
8
+ securityLifecycle(): SecurityLifecycle;
7
9
  syncUntilIdle(maxRounds?: number): Promise<unknown>;
8
10
  onSyncNeeded(listener: (reason: 'startup' | 'hello' | WakeReason) => void): () => void;
9
11
  onSyncIntent(listener: (intent: SyncIntent) => void): () => void;
@@ -47,7 +47,7 @@ export function installSyncScheduler(client, options = {}) {
47
47
  const run = () => {
48
48
  if (stopped || running)
49
49
  return;
50
- if (client.securityLifecycle === 'preflight') {
50
+ if (client.securityLifecycle() === 'preflight') {
51
51
  immediatePending = false;
52
52
  return;
53
53
  }
@@ -110,7 +110,8 @@ export function installSyncScheduler(client, options = {}) {
110
110
  consume({ kind: 'interactive' });
111
111
  });
112
112
  const unsubscribeIntent = client.onSyncIntent(consume);
113
- if (client.syncNeeded && client.securityLifecycle === 'active') {
113
+ if (client.statusSnapshot().syncNeeded &&
114
+ client.securityLifecycle() === 'active') {
114
115
  consume({ kind: 'interactive' });
115
116
  }
116
117
  return {
@@ -76,15 +76,21 @@ export function startSyncWorker(overrides = {}) {
76
76
  let backgroundTimer;
77
77
  let backgroundDue = Number.POSITIVE_INFINITY;
78
78
  function runAutoSync() {
79
- autoSyncScheduled = false;
80
79
  if (closed ||
81
80
  client === undefined ||
82
81
  offline ||
83
82
  client.securityLifecycle() === 'preflight') {
83
+ autoSyncScheduled = false;
84
84
  return;
85
85
  }
86
86
  const running = client;
87
- void serializedSync(() => running.syncUntilIdle())
87
+ void serializedSync(() => {
88
+ // The queued round owns the flag from here. Clearing it at entry would
89
+ // let every wake-up that arrives while this round waits behind the
90
+ // running one queue its own full drive (§8.4 coalescing).
91
+ autoSyncScheduled = false;
92
+ return running.syncUntilIdle();
93
+ })
88
94
  .then((summary) => {
89
95
  if (!closed)
90
96
  post({ t: 'event', event: { kind: 'synced', summary } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.32.1",
3
+ "version": "0.33.0",
4
4
  "description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -97,10 +97,10 @@
97
97
  },
98
98
  "dependencies": {
99
99
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
100
- "@syncular/core": "0.32.1"
100
+ "@syncular/core": "0.33.0"
101
101
  },
102
102
  "devDependencies": {
103
- "@syncular/server": "0.32.1",
103
+ "@syncular/server": "0.33.0",
104
104
  "playwright": "1.63.0",
105
105
  "@electric-sql/pglite": "^0.5.4"
106
106
  }
package/src/client.ts CHANGED
@@ -893,6 +893,17 @@ export class SyncClient {
893
893
  * (the fast-bail the delta path reads).
894
894
  */
895
895
  #syncOutstanding = false;
896
+ /**
897
+ * Idle drives run one after another (§8.4): `#syncIdleTail` settles when
898
+ * the last scheduled drive settles, and `#syncIdlePending` holds the
899
+ * scheduled drives that have not started, keyed by round budget, so a
900
+ * caller joins a waiting drive with its budget instead of queueing another.
901
+ */
902
+ #syncIdleTail: Promise<void> | undefined;
903
+ readonly #syncIdlePending = new Map<
904
+ number | undefined,
905
+ Promise<SyncIdleResult>
906
+ >();
896
907
  /**
897
908
  * Bumped by every local projection reset (`rebootstrapLocalData`). A sync
898
909
  * round captures this epoch together with its subscription state; a
@@ -2903,7 +2914,11 @@ export class SyncClient {
2903
2914
  'subscription id already names different table, scopes, or params',
2904
2915
  );
2905
2916
  }
2906
- return;
2917
+ // §3.3: a revoked registration keeps its id and intent but no progress.
2918
+ // Subscribing again with the same intent re-arms it, so the next pull
2919
+ // resolves its scopes from scratch. A `failed` registration is a
2920
+ // configuration error and stays put.
2921
+ if (existing.status !== 'revoked') return;
2907
2922
  }
2908
2923
  saveSubscription(
2909
2924
  this.#db,
@@ -4227,6 +4242,14 @@ export class SyncClient {
4227
4242
  /**
4228
4243
  * Pull repeatedly until quiescent: no commits delivered, no bootstrap
4229
4244
  * pages pending, no resets to recover (§4.5 "pull again" SHOULD).
4245
+ *
4246
+ * §8.4: every host trigger drives this same client — the scheduler, the
4247
+ * realtime supervisor's connect-time catch-up, and app code — so a call
4248
+ * that arrives while a drive is in flight waits for that drive and then
4249
+ * runs one follow-up drive. The follow-up is required, not defensive: the
4250
+ * caller may have mutated the outbox after the in-flight drive captured it.
4251
+ * Callers that arrive during the same drive with the same budget share that
4252
+ * single follow-up. `sync()` keeps rejecting a concurrent `sync()`.
4230
4253
  */
4231
4254
  async syncUntilIdle(maxRounds?: number): Promise<SyncIdleResult> {
4232
4255
  // §7.7: an explicit budget is a strict positive round count. Zero,
@@ -4236,6 +4259,34 @@ export class SyncClient {
4236
4259
  (!Number.isInteger(maxRounds) || maxRounds < 1 || maxRounds > 0xffffffff)
4237
4260
  )
4238
4261
  throw invalidRequest('maxRounds must be a positive integer');
4262
+ const pending = this.#syncIdlePending.get(maxRounds);
4263
+ if (pending !== undefined) return pending;
4264
+ const previous = this.#syncIdleTail;
4265
+ let drive: Promise<SyncIdleResult>;
4266
+ if (previous === undefined) {
4267
+ drive = this.#syncIdleRounds(maxRounds);
4268
+ } else {
4269
+ drive = previous.then(() => {
4270
+ this.#syncIdlePending.delete(maxRounds);
4271
+ return this.#syncIdleRounds(maxRounds);
4272
+ });
4273
+ this.#syncIdlePending.set(maxRounds, drive);
4274
+ }
4275
+ const tail = drive.then(
4276
+ () => undefined,
4277
+ () => undefined,
4278
+ );
4279
+ this.#syncIdleTail = tail;
4280
+ void tail.then(() => {
4281
+ if (this.#syncIdleTail === tail) this.#syncIdleTail = undefined;
4282
+ });
4283
+ return drive;
4284
+ }
4285
+
4286
+ /** The rounds loop of one idle drive; `syncUntilIdle` owns coalescing. */
4287
+ async #syncIdleRounds(
4288
+ maxRounds: number | undefined,
4289
+ ): Promise<SyncIdleResult> {
4239
4290
  const budget = maxRounds ?? 20;
4240
4291
  const aggregate = {
4241
4292
  pushed: 0,
package/src/http.ts CHANGED
@@ -355,38 +355,35 @@ export function httpBlobTransport(
355
355
  ? 'sync.auth_required'
356
356
  : 'sync.transport_failed',
357
357
  );
358
- // §5.9.5 always-issue: a JSON body with `url` means presigned delivery;
359
- // an octet-stream body is inline bytes.
358
+ // §5.9.5 always-issue: a JSON body with a `url` means presigned delivery;
359
+ // any other body is inline bytes. The body is read exactly once as bytes,
360
+ // so an inline blob stored as `application/json` stays readable.
360
361
  const contentType = response.headers.get('content-type') ?? '';
361
- if (contentType.includes('application/json')) {
362
- const body = (await transfer(
362
+ const bytes = new Uint8Array(
363
+ await transfer(
363
364
  'blob download body read failed',
364
- () => response.json(),
365
+ () => response.arrayBuffer(),
365
366
  response.status,
366
- )) as {
367
- url?: string;
368
- urlExpiresAtMs?: number;
369
- };
370
- if (typeof body.url === 'string') {
367
+ ),
368
+ );
369
+ if (contentType.includes('application/json')) {
370
+ let envelope: { url?: unknown; urlExpiresAtMs?: unknown } | null = null;
371
+ try {
372
+ envelope = JSON.parse(new TextDecoder().decode(bytes));
373
+ } catch {
374
+ // Not JSON: inline bytes.
375
+ }
376
+ if (typeof envelope?.url === 'string') {
371
377
  return {
372
378
  kind: 'url',
373
- url: body.url,
374
- ...(typeof body.urlExpiresAtMs === 'number'
375
- ? { urlExpiresAtMs: body.urlExpiresAtMs }
379
+ url: envelope.url,
380
+ ...(typeof envelope.urlExpiresAtMs === 'number'
381
+ ? { urlExpiresAtMs: envelope.urlExpiresAtMs }
376
382
  : {}),
377
383
  };
378
384
  }
379
385
  }
380
- return {
381
- kind: 'bytes',
382
- bytes: new Uint8Array(
383
- await transfer(
384
- 'blob download body read failed',
385
- () => response.arrayBuffer(),
386
- response.status,
387
- ),
388
- ),
389
- };
386
+ return { kind: 'bytes', bytes };
390
387
  },
391
388
  // §5.9.5: bare GET of the signed URL — no host auth (the URL is the grant).
392
389
  fetchUrl: async (url) => {
@@ -3,8 +3,8 @@ import type { SecurityLifecycle } from './client';
3
3
  import type { SyncIntent } from './invalidation';
4
4
 
5
5
  export interface SyncSchedulerClient {
6
- readonly syncNeeded: boolean;
7
- readonly securityLifecycle: SecurityLifecycle;
6
+ statusSnapshot(): { readonly syncNeeded: boolean };
7
+ securityLifecycle(): SecurityLifecycle;
8
8
  syncUntilIdle(maxRounds?: number): Promise<unknown>;
9
9
  onSyncNeeded(
10
10
  listener: (reason: 'startup' | 'hello' | WakeReason) => void,
@@ -79,7 +79,7 @@ export function installSyncScheduler(
79
79
 
80
80
  const run = (): void => {
81
81
  if (stopped || running) return;
82
- if (client.securityLifecycle === 'preflight') {
82
+ if (client.securityLifecycle() === 'preflight') {
83
83
  immediatePending = false;
84
84
  return;
85
85
  }
@@ -144,7 +144,10 @@ export function installSyncScheduler(
144
144
  consume({ kind: 'interactive' });
145
145
  });
146
146
  const unsubscribeIntent = client.onSyncIntent(consume);
147
- if (client.syncNeeded && client.securityLifecycle === 'active') {
147
+ if (
148
+ client.statusSnapshot().syncNeeded &&
149
+ client.securityLifecycle() === 'active'
150
+ ) {
148
151
  consume({ kind: 'interactive' });
149
152
  }
150
153
 
@@ -164,17 +164,23 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
164
164
  let backgroundDue = Number.POSITIVE_INFINITY;
165
165
 
166
166
  function runAutoSync(): void {
167
- autoSyncScheduled = false;
168
167
  if (
169
168
  closed ||
170
169
  client === undefined ||
171
170
  offline ||
172
171
  client.securityLifecycle() === 'preflight'
173
172
  ) {
173
+ autoSyncScheduled = false;
174
174
  return;
175
175
  }
176
176
  const running = client;
177
- void serializedSync(() => running.syncUntilIdle())
177
+ void serializedSync(() => {
178
+ // The queued round owns the flag from here. Clearing it at entry would
179
+ // let every wake-up that arrives while this round waits behind the
180
+ // running one queue its own full drive (§8.4 coalescing).
181
+ autoSyncScheduled = false;
182
+ return running.syncUntilIdle();
183
+ })
178
184
  .then((summary) => {
179
185
  if (!closed) post({ t: 'event', event: { kind: 'synced', summary } });
180
186
  if (summary.budgetExhausted) consumeSyncIntent({ kind: 'interactive' });