@abloatai/humans 0.65.0 → 0.66.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.
@@ -676,7 +676,7 @@ export declare class BaseSyncedStore<TCollaboration extends EventMap<TCollaborat
676
676
  * with {@link Database.processDeltaBatch} — the lower-level local write this
677
677
  * eventually drives through `flushPendingDeltas`.
678
678
  */
679
- protected applyDeltaFrame(deltas: SyncDelta[]): void;
679
+ protected applyDeltaFrame(deltas: SyncDelta[]): Promise<void>;
680
680
  /**
681
681
  * Per-delta bookkeeping + enqueue. Returns `true` when the delta was
682
682
  * pushed onto `pendingDeltas` (a regular batchable I/U/C/D delta that a
@@ -877,7 +877,7 @@ export class BaseSyncedStore {
877
877
  getAllPoolIds: () => this.objectPool.getAllIds(),
878
878
  get bootstrapDeltaQueue() { return store.bootstrapDeltaQueue; },
879
879
  set bootstrapDeltaQueue(queue) { store.bootstrapDeltaQueue = queue; },
880
- applyDeltaFrame: (deltas) => { this.applyDeltaFrame(deltas); },
880
+ applyDeltaFrame: (deltas) => { void this.applyDeltaFrame(deltas); },
881
881
  };
882
882
  }
883
883
  /** Apply bootstrap data to the {@link InstanceCache}, removing entities that are no longer present (ghost removal). Pool writes are delegated to {@link SyncClient}. */
@@ -1100,7 +1100,7 @@ export class BaseSyncedStore {
1100
1100
  onConnectionEvent: this.onConnectionEvent,
1101
1101
  updateSyncStatus: (updates) => { this.updateSyncStatus(updates); },
1102
1102
  processDeltaWithBatching: (delta) => { this.processDeltaWithBatching(delta); },
1103
- applyDeltaFrame: (deltas) => { this.applyDeltaFrame(deltas); },
1103
+ applyDeltaFrame: (deltas) => this.applyDeltaFrame(deltas),
1104
1104
  handleBootstrapRequired: (hint) => { this.handleBootstrapRequired(hint); },
1105
1105
  handleBootstrapData: (data) => { this.handleBootstrapData(data); },
1106
1106
  performCredentialRefresh: () => this.performCredentialRefresh(),
@@ -1231,7 +1231,7 @@ export class BaseSyncedStore {
1231
1231
  * eventually drives through `flushPendingDeltas`.
1232
1232
  */
1233
1233
  applyDeltaFrame(deltas) {
1234
- deltaPipeline.applyDeltaFrame(this.deltaPipelineContext, deltas);
1234
+ return deltaPipeline.persistDeltaFrame(this.deltaPipelineContext, deltas);
1235
1235
  }
1236
1236
  /**
1237
1237
  * Per-delta bookkeeping + enqueue. Returns `true` when the delta was
@@ -28,6 +28,19 @@ const PAGE_LIMIT = 5000;
28
28
  const MAX_PAGES_PER_MODEL = 200;
29
29
  /** How many model chunks a cold start fetches at once. */
30
30
  const CHUNK_CONCURRENCY = 3;
31
+ /** Keep bootstrap URLs below common proxy limits when the subscribed group set grows. */
32
+ function bootstrapRequest(baseUrl, params) {
33
+ const url = `${baseUrl}/sync/bootstrap?${params.toString()}`;
34
+ if (url.length <= 8_000)
35
+ return { url, method: 'GET' };
36
+ const syncGroups = params.getAll('syncGroups');
37
+ params.delete('syncGroups');
38
+ return {
39
+ url: `${baseUrl}/sync/bootstrap?${params.toString()}`,
40
+ method: 'POST',
41
+ body: JSON.stringify({ syncGroups }),
42
+ };
43
+ }
31
44
  /**
32
45
  * The reason handed to `abort()` when a request is stopped deliberately —
33
46
  * superseded by a newer bootstrap, or abandoned because the bootstrap it
@@ -359,7 +372,7 @@ export class BootstrapFetcher {
359
372
  if (this.options.instantModels && this.options.instantModels.length > 0) {
360
373
  params.append('models', this.options.instantModels.join(','));
361
374
  }
362
- const url = `${this.options.baseUrl}/sync/bootstrap?${params.toString()}`;
375
+ const request = bootstrapRequest(this.options.baseUrl, params);
363
376
  // If offline, try the cached bootstrap. Skipped for a scoped override: the
364
377
  // cache holds the full snapshot, which is not a valid answer to a subset
365
378
  // request; a scoped hydrate just soft-fails offline and retries on re-enter.
@@ -384,7 +397,7 @@ export class BootstrapFetcher {
384
397
  code: 'bootstrap_offline_no_cache',
385
398
  });
386
399
  }
387
- this.runtime.logger.info('Fetching fresh bootstrap data', { url });
400
+ this.runtime.logger.info('Fetching fresh bootstrap data', { url: request.url });
388
401
  const lane = syncGroupsOverride ? 'scoped' : 'bootstrap';
389
402
  // Chunk a COLD start by model: each instant model is its own request, so
390
403
  // one giant model can't make the whole snapshot undeliverable, and a
@@ -401,7 +414,7 @@ export class BootstrapFetcher {
401
414
  try {
402
415
  const data = chunked
403
416
  ? await this.fetchChunkedBootstrap(instantModels, this.options.syncGroups)
404
- : await this.fetchWithRetries(url, lane);
417
+ : await this.fetchWithRetries(request, lane);
405
418
  this.runtime.logger.info('Bootstrap data fetched', {
406
419
  type: data.type,
407
420
  lastSyncId: data.lastSyncId,
@@ -448,12 +461,12 @@ export class BootstrapFetcher {
448
461
  * (5xx, 429, timeouts, network blips) consume attempts. A cancellation is
449
462
  * deliberate and therefore non-retryable — it leaves through the same gate.
450
463
  */
451
- async fetchWithRetries(url, lane) {
464
+ async fetchWithRetries(request, lane) {
452
465
  let lastError = null;
453
466
  const capacityDeadline = Date.now() + this.options.fetchTimeout;
454
467
  for (let attempt = 0; attempt < this.options.maxRetries;) {
455
468
  try {
456
- return await this.fetchOnce(url, lane);
469
+ return await this.fetchOnce(request, lane);
457
470
  }
458
471
  catch (error) {
459
472
  // SessionError should NOT be retried - the session is invalid and needs re-authentication
@@ -539,8 +552,8 @@ export class BootstrapFetcher {
539
552
  params.append('limit', String(PAGE_LIMIT));
540
553
  if (cursor !== undefined)
541
554
  params.append('cursor', cursor);
542
- const url = `${this.options.baseUrl}/sync/bootstrap?${params.toString()}`;
543
- const data = await this.fetchWithRetries(url, 'bootstrap');
555
+ const request = bootstrapRequest(this.options.baseUrl, params);
556
+ const data = await this.fetchWithRetries(request, 'bootstrap');
544
557
  chunks.push(data);
545
558
  if (data.nextCursor === undefined)
546
559
  break;
@@ -576,7 +589,7 @@ export class BootstrapFetcher {
576
589
  if (this.options.instantModels && this.options.instantModels.length > 0) {
577
590
  params.append('models', this.options.instantModels.join(','));
578
591
  }
579
- const url = `${this.options.baseUrl}/sync/bootstrap?${params.toString()}`;
592
+ const request = bootstrapRequest(this.options.baseUrl, params);
580
593
  // Note: ETag caching is deliberately app-side, not SDK-side. The server
581
594
  // still returns an ETag on responses, which is captured below and
582
595
  // forwarded to callers via BootstrapFetchResult.etag — apps that want
@@ -587,15 +600,16 @@ export class BootstrapFetcher {
587
600
  const controller = new AbortController();
588
601
  this.activeControllers.set(controller, 'bootstrap');
589
602
  try {
590
- return await this.fetchWithETagUsing(url, headers, controller);
603
+ return await this.fetchWithETagUsing(request, headers, controller);
591
604
  }
592
605
  finally {
593
606
  this.activeControllers.delete(controller);
594
607
  }
595
608
  }
596
- async fetchWithETagUsing(url, headers, controller) {
597
- const res = await fetch(url, {
598
- method: 'GET',
609
+ async fetchWithETagUsing(request, headers, controller) {
610
+ const res = await fetch(request.url, {
611
+ method: request.method,
612
+ body: request.body,
599
613
  headers,
600
614
  signal: controller.signal,
601
615
  });
@@ -726,17 +740,17 @@ export class BootstrapFetcher {
726
740
  * registry) — chunk requests run through here concurrently and must
727
741
  * not cancel each other.
728
742
  */
729
- async fetchOnce(url, lane) {
743
+ async fetchOnce(request, lane) {
730
744
  const controller = new AbortController();
731
745
  this.activeControllers.set(controller, lane);
732
746
  try {
733
- return await this.fetchOnceWith(url, controller);
747
+ return await this.fetchOnceWith(request, controller);
734
748
  }
735
749
  finally {
736
750
  this.activeControllers.delete(controller);
737
751
  }
738
752
  }
739
- async fetchOnceWith(url, controller) {
753
+ async fetchOnceWith(request, controller) {
740
754
  const timeoutId = setTimeout(() => {
741
755
  this.runtime.observability.breadcrumb('Bootstrap fetch timeout', 'sync.bootstrap', 'warning', {
742
756
  timeoutMs: this.options.fetchTimeout,
@@ -745,8 +759,9 @@ export class BootstrapFetcher {
745
759
  }, this.options.fetchTimeout);
746
760
  let response;
747
761
  try {
748
- response = await fetch(url, {
749
- method: 'GET',
762
+ response = await fetch(request.url, {
763
+ method: request.method,
764
+ body: request.body,
750
765
  headers: withAuthHeaders(this.options.getAuthToken, {
751
766
  'Content-Type': 'application/json',
752
767
  'Cache-Control': 'no-cache, no-store, must-revalidate',
@@ -62,6 +62,7 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
62
62
  * the state itself lives in {@link SyncCursor}.
63
63
  */
64
64
  private readonly cursor;
65
+ private catchUp;
65
66
  constructor(options: SyncWebSocketOptions);
66
67
  /** The persisted resume position, sent on the upgrade URL. */
67
68
  protected resumeCursor(): string;
@@ -121,6 +122,12 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
121
122
  * Public wrapper for sending ack from outside the class
122
123
  */
123
124
  acknowledge(syncId: number): void;
125
+ /** Publish completion only after the store's chunk-persistence lane drains. */
126
+ completeCatchUp(payload: {
127
+ exchangeId: string;
128
+ currentSyncId: number;
129
+ chunks: number;
130
+ }): void;
124
131
  /**
125
132
  * Stop the periodic catchup interval
126
133
  */
@@ -160,6 +167,9 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
160
167
  * of the batch).
161
168
  */
162
169
  protected handleSyncResponse(rawPayload: unknown): void;
170
+ protected handleCatchUpBegin(rawPayload: unknown): void;
171
+ protected handleCatchUpChunk(rawPayload: unknown): void;
172
+ protected handleCatchUpEnd(rawPayload: unknown): void;
163
173
  /**
164
174
  * Handle bootstrap response from server
165
175
  */
@@ -38,6 +38,7 @@ export class SyncWebSocket extends WsTransport {
38
38
  * the state itself lives in {@link SyncCursor}.
39
39
  */
40
40
  cursor;
41
+ catchUp = null;
41
42
  constructor(options) {
42
43
  super({
43
44
  ...options,
@@ -221,6 +222,10 @@ export class SyncWebSocket extends WsTransport {
221
222
  acknowledge(syncId) {
222
223
  this.sendAck(syncId);
223
224
  }
225
+ /** Publish completion only after the store's chunk-persistence lane drains. */
226
+ completeCatchUp(payload) {
227
+ this.emit('catchup_complete', payload);
228
+ }
224
229
  /**
225
230
  * Stop the periodic catchup interval
226
231
  */
@@ -415,6 +420,44 @@ export class SyncWebSocket extends WsTransport {
415
420
  this.cursor.syncCursor = payload.cursor;
416
421
  }
417
422
  }
423
+ handleCatchUpBegin(rawPayload) {
424
+ if (!isRecord(rawPayload))
425
+ return;
426
+ const { exchangeId, fromSyncId, currentSyncId } = rawPayload;
427
+ if (typeof exchangeId !== 'string' || !Number.isSafeInteger(fromSyncId) || !Number.isSafeInteger(currentSyncId))
428
+ return;
429
+ this.catchUp = { exchangeId, currentSyncId: currentSyncId, nextSequence: 0 };
430
+ this.emit('catchup_begin', { exchangeId, fromSyncId: fromSyncId, currentSyncId: currentSyncId });
431
+ }
432
+ handleCatchUpChunk(rawPayload) {
433
+ if (!isRecord(rawPayload) || !this.catchUp)
434
+ return;
435
+ const { exchangeId, sequence, position, deltas } = rawPayload;
436
+ if (exchangeId !== this.catchUp.exchangeId || sequence !== this.catchUp.nextSequence
437
+ || !Number.isSafeInteger(position) || position > this.catchUp.currentSyncId
438
+ || !Array.isArray(deltas))
439
+ return;
440
+ const normalized = [];
441
+ for (const raw of deltas) {
442
+ const delta = this.normalizeWireDelta(raw);
443
+ if (delta)
444
+ normalized.push(delta);
445
+ }
446
+ if (normalized.length !== deltas.length)
447
+ return;
448
+ this.catchUp.nextSequence++;
449
+ this.emit('catchup_chunk', { exchangeId, sequence, position, deltas: normalized });
450
+ }
451
+ handleCatchUpEnd(rawPayload) {
452
+ if (!isRecord(rawPayload) || !this.catchUp)
453
+ return;
454
+ const { exchangeId, currentSyncId, chunks } = rawPayload;
455
+ if (exchangeId !== this.catchUp.exchangeId || currentSyncId !== this.catchUp.currentSyncId
456
+ || chunks !== this.catchUp.nextSequence)
457
+ return;
458
+ this.catchUp = null;
459
+ this.emit('catchup_end', { exchangeId, currentSyncId, chunks });
460
+ }
418
461
  /**
419
462
  * Handle bootstrap response from server
420
463
  */
@@ -118,8 +118,10 @@ export declare function enqueueDelta(ctx: DeltaPipelineContext, delta: SyncDelta
118
118
  }): boolean;
119
119
  /** Debounce a flush for live single-delta traffic. */
120
120
  export declare function scheduleDeltaFlush(ctx: DeltaPipelineContext): void;
121
- /** Apply an authoritative delta frame as one atomic flush. */
121
+ /** Apply an authoritative legacy frame without exposing its persistence promise. */
122
122
  export declare function applyDeltaFrame(ctx: DeltaPipelineContext, deltas: SyncDelta[]): void;
123
+ /** Persist one resumable chunk before its raw tail position may advance. */
124
+ export declare function persistDeltaFrame(ctx: DeltaPipelineContext, deltas: SyncDelta[]): Promise<void>;
123
125
  /**
124
126
  * Flushes the queued deltas: deduplicates them, applies custom-entity deltas
125
127
  * straight to the pool, writes the rest to the local store and then the pool,
@@ -164,8 +164,12 @@ export function scheduleDeltaFlush(ctx) {
164
164
  }, ctx.smartSyncOptions.batchingDelay);
165
165
  }
166
166
  }
167
- /** Apply an authoritative delta frame as one atomic flush. */
167
+ /** Apply an authoritative legacy frame without exposing its persistence promise. */
168
168
  export function applyDeltaFrame(ctx, deltas) {
169
+ void persistDeltaFrame(ctx, deltas).catch(ctx.handleFlushError);
170
+ }
171
+ /** Persist one resumable chunk before its raw tail position may advance. */
172
+ export async function persistDeltaFrame(ctx, deltas) {
169
173
  let enqueuedAny = false;
170
174
  for (const delta of deltas) {
171
175
  if (enqueueDelta(ctx, delta, { authoritative: true }))
@@ -177,7 +181,7 @@ export function applyDeltaFrame(ctx, deltas) {
177
181
  clearTimeout(ctx.batchTimer);
178
182
  ctx.batchTimer = null;
179
183
  }
180
- void ctx.flushPendingDeltas().catch(ctx.handleFlushError);
184
+ await ctx.flushPendingDeltas();
181
185
  }
182
186
  /**
183
187
  * Flushes the queued deltas: deduplicates them, applies custom-entity deltas
@@ -20,7 +20,7 @@ export interface SocketEventHost<TCollaboration extends EventMap<TCollaboration>
20
20
  onConnectionEvent?: (event: string) => void;
21
21
  updateSyncStatus(updates: Partial<SyncStatus>): void;
22
22
  processDeltaWithBatching(delta: SyncDelta): void;
23
- applyDeltaFrame(deltas: SyncDelta[]): void;
23
+ applyDeltaFrame(deltas: SyncDelta[]): Promise<void>;
24
24
  handleBootstrapRequired(hint: BootstrapHint): void;
25
25
  handleBootstrapData(data: BootstrapDataEvent): void;
26
26
  performCredentialRefresh(): Promise<'refreshed' | 'session_error' | 'network_error'>;
@@ -35,7 +35,27 @@ export function wireSocketEvents(deps) {
35
35
  // A catch-up/reconnect frame is already complete — apply it as ONE
36
36
  // atomic flush so the gallery re-renders once, not once per 50-delta
37
37
  // chunk. See `applyDeltaFrame`.
38
- deps.applyDeltaFrame(deltas);
38
+ void deps.applyDeltaFrame(deltas).catch((error) => {
39
+ deps.updateSyncStatus({ state: 'error', error: error instanceof Error ? error : new Error(String(error)) });
40
+ });
41
+ });
42
+ let catchUpLane = Promise.resolve();
43
+ const onCatchUpChunk = deps.syncWebSocket.subscribe('catchup_chunk', (chunk) => {
44
+ catchUpLane = catchUpLane.then(async () => {
45
+ await deps.applyDeltaFrame(chunk.deltas);
46
+ // The raw position covers filtered rows too. Advance only after this
47
+ // chunk's visible rows are durable; replay is therefore idempotent.
48
+ deps.syncWebSocket.acknowledge(chunk.position);
49
+ });
50
+ void catchUpLane.catch((error) => {
51
+ deps.updateSyncStatus({ state: 'error', error: error instanceof Error ? error : new Error(String(error)) });
52
+ });
53
+ });
54
+ const onCatchUpEnd = deps.syncWebSocket.subscribe('catchup_end', (end) => {
55
+ void catchUpLane.then(() => {
56
+ deps.syncWebSocket.acknowledge(end.currentSyncId);
57
+ deps.syncWebSocket.completeCatchUp(end);
58
+ }).catch(() => undefined);
39
59
  });
40
60
  // Bootstrap events
41
61
  const onBootstrapRequired = deps.syncWebSocket.subscribe('bootstrap_required', (hint) => { deps.handleBootstrapRequired(hint); });
@@ -122,5 +142,5 @@ export function wireSocketEvents(deps) {
122
142
  deps.runtime.logger.debug('[BaseSyncedStore] WebSocket reconnection gave up', { attempts });
123
143
  deps.updateSyncStatus({ state: 'reconnecting' });
124
144
  });
125
- deps.disposers.push(onConnected, onDisconnected, onReconnecting, onDelta, onDeltaBatch, onBootstrapRequired, onBootstrapData, onError, onSessionError, onHandshakeFailed, onReconnectFailed, () => { deps.areaOfInterest.dispose(); });
145
+ deps.disposers.push(onConnected, onDisconnected, onReconnecting, onDelta, onDeltaBatch, onCatchUpChunk, onCatchUpEnd, onBootstrapRequired, onBootstrapData, onError, onSessionError, onHandshakeFailed, onReconnectFailed, () => { deps.areaOfInterest.dispose(); });
126
146
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/humans",
3
- "version": "0.65.0",
3
+ "version": "0.66.0",
4
4
  "description": "The optional human-facing local-state package for Ablo: presence, live queries, and React bindings.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -85,7 +85,7 @@
85
85
  "directory": "packages/humans"
86
86
  },
87
87
  "dependencies": {
88
- "@abloatai/transaction": "0.65.0",
88
+ "@abloatai/transaction": "0.66.0",
89
89
  "events": "^3.3.0",
90
90
  "mobx": "^6.13.7",
91
91
  "uuid": "^11.1.0",
@@ -1247,7 +1247,7 @@ export class BaseSyncedStore<
1247
1247
  getAllPoolIds: () => this.objectPool.getAllIds(),
1248
1248
  get bootstrapDeltaQueue() { return store.bootstrapDeltaQueue; },
1249
1249
  set bootstrapDeltaQueue(queue) { store.bootstrapDeltaQueue = queue; },
1250
- applyDeltaFrame: (deltas) => { this.applyDeltaFrame(deltas); },
1250
+ applyDeltaFrame: (deltas) => { void this.applyDeltaFrame(deltas); },
1251
1251
  };
1252
1252
  }
1253
1253
 
@@ -1495,7 +1495,7 @@ export class BaseSyncedStore<
1495
1495
  onConnectionEvent: this.onConnectionEvent,
1496
1496
  updateSyncStatus: (updates) => { this.updateSyncStatus(updates); },
1497
1497
  processDeltaWithBatching: (delta) => { this.processDeltaWithBatching(delta); },
1498
- applyDeltaFrame: (deltas) => { this.applyDeltaFrame(deltas); },
1498
+ applyDeltaFrame: (deltas) => this.applyDeltaFrame(deltas),
1499
1499
  handleBootstrapRequired: (hint) => { this.handleBootstrapRequired(hint); },
1500
1500
  handleBootstrapData: (data) => { this.handleBootstrapData(data); },
1501
1501
  performCredentialRefresh: () => this.performCredentialRefresh(),
@@ -1636,8 +1636,8 @@ export class BaseSyncedStore<
1636
1636
  * with {@link Database.processDeltaBatch} — the lower-level local write this
1637
1637
  * eventually drives through `flushPendingDeltas`.
1638
1638
  */
1639
- protected applyDeltaFrame(deltas: SyncDelta[]): void {
1640
- deltaPipeline.applyDeltaFrame(this.deltaPipelineContext, deltas);
1639
+ protected applyDeltaFrame(deltas: SyncDelta[]): Promise<void> {
1640
+ return deltaPipeline.persistDeltaFrame(this.deltaPipelineContext, deltas);
1641
1641
  }
1642
1642
  /**
1643
1643
  * Per-delta bookkeeping + enqueue. Returns `true` when the delta was
@@ -134,6 +134,19 @@ const MAX_PAGES_PER_MODEL = 200;
134
134
  /** How many model chunks a cold start fetches at once. */
135
135
  const CHUNK_CONCURRENCY = 3;
136
136
 
137
+ /** Keep bootstrap URLs below common proxy limits when the subscribed group set grows. */
138
+ function bootstrapRequest(baseUrl: string, params: URLSearchParams): { url: string; method: 'GET' | 'POST'; body?: string } {
139
+ const url = `${baseUrl}/sync/bootstrap?${params.toString()}`;
140
+ if (url.length <= 8_000) return { url, method: 'GET' };
141
+ const syncGroups = params.getAll('syncGroups');
142
+ params.delete('syncGroups');
143
+ return {
144
+ url: `${baseUrl}/sync/bootstrap?${params.toString()}`,
145
+ method: 'POST',
146
+ body: JSON.stringify({ syncGroups }),
147
+ };
148
+ }
149
+
137
150
  /**
138
151
  * Which cancellation lane a request belongs to. Cancellation targets one lane
139
152
  * at a time, so superseding a cold-start bootstrap cannot take down a scoped
@@ -526,7 +539,7 @@ export class BootstrapFetcher {
526
539
  params.append('models', this.options.instantModels.join(','));
527
540
  }
528
541
 
529
- const url = `${this.options.baseUrl}/sync/bootstrap?${params.toString()}`;
542
+ const request = bootstrapRequest(this.options.baseUrl, params);
530
543
 
531
544
  // If offline, try the cached bootstrap. Skipped for a scoped override: the
532
545
  // cache holds the full snapshot, which is not a valid answer to a subset
@@ -554,7 +567,7 @@ export class BootstrapFetcher {
554
567
  });
555
568
  }
556
569
 
557
- this.runtime.logger.info('Fetching fresh bootstrap data', { url });
570
+ this.runtime.logger.info('Fetching fresh bootstrap data', { url: request.url });
558
571
 
559
572
  const lane: CancelLane = syncGroupsOverride ? 'scoped' : 'bootstrap';
560
573
 
@@ -575,7 +588,7 @@ export class BootstrapFetcher {
575
588
  try {
576
589
  const data = chunked
577
590
  ? await this.fetchChunkedBootstrap(instantModels, this.options.syncGroups)
578
- : await this.fetchWithRetries(url, lane);
591
+ : await this.fetchWithRetries(request, lane);
579
592
 
580
593
  this.runtime.logger.info('Bootstrap data fetched', {
581
594
  type: data.type,
@@ -628,12 +641,12 @@ export class BootstrapFetcher {
628
641
  * (5xx, 429, timeouts, network blips) consume attempts. A cancellation is
629
642
  * deliberate and therefore non-retryable — it leaves through the same gate.
630
643
  */
631
- private async fetchWithRetries(url: string, lane: CancelLane): Promise<BootstrapData> {
644
+ private async fetchWithRetries(request: ReturnType<typeof bootstrapRequest>, lane: CancelLane): Promise<BootstrapData> {
632
645
  let lastError: Error | null = null;
633
646
  const capacityDeadline = Date.now() + this.options.fetchTimeout;
634
647
  for (let attempt = 0; attempt < this.options.maxRetries;) {
635
648
  try {
636
- return await this.fetchOnce(url, lane);
649
+ return await this.fetchOnce(request, lane);
637
650
  } catch (error) {
638
651
  // SessionError should NOT be retried - the session is invalid and needs re-authentication
639
652
  if (AbloSessionError.isSessionError(error)) {
@@ -740,8 +753,8 @@ export class BootstrapFetcher {
740
753
  params.append('models', model);
741
754
  params.append('limit', String(PAGE_LIMIT));
742
755
  if (cursor !== undefined) params.append('cursor', cursor);
743
- const url = `${this.options.baseUrl}/sync/bootstrap?${params.toString()}`;
744
- const data = await this.fetchWithRetries(url, 'bootstrap');
756
+ const request = bootstrapRequest(this.options.baseUrl, params);
757
+ const data = await this.fetchWithRetries(request, 'bootstrap');
745
758
  chunks.push(data);
746
759
  if (data.nextCursor === undefined) break;
747
760
  cursor = data.nextCursor;
@@ -781,7 +794,7 @@ export class BootstrapFetcher {
781
794
  if (this.options.instantModels && this.options.instantModels.length > 0) {
782
795
  params.append('models', this.options.instantModels.join(','));
783
796
  }
784
- const url = `${this.options.baseUrl}/sync/bootstrap?${params.toString()}`;
797
+ const request = bootstrapRequest(this.options.baseUrl, params);
785
798
 
786
799
  // Note: ETag caching is deliberately app-side, not SDK-side. The server
787
800
  // still returns an ETag on responses, which is captured below and
@@ -799,19 +812,20 @@ export class BootstrapFetcher {
799
812
  const controller = new AbortController();
800
813
  this.activeControllers.set(controller, 'bootstrap');
801
814
  try {
802
- return await this.fetchWithETagUsing(url, headers, controller);
815
+ return await this.fetchWithETagUsing(request, headers, controller);
803
816
  } finally {
804
817
  this.activeControllers.delete(controller);
805
818
  }
806
819
  }
807
820
 
808
821
  private async fetchWithETagUsing(
809
- url: string,
822
+ request: ReturnType<typeof bootstrapRequest>,
810
823
  headers: Record<string, string>,
811
824
  controller: AbortController,
812
825
  ): Promise<BootstrapFetchResult> {
813
- const res = await fetch(url, {
814
- method: 'GET',
826
+ const res = await fetch(request.url, {
827
+ method: request.method,
828
+ body: request.body,
815
829
  headers,
816
830
  signal: controller.signal,
817
831
  });
@@ -973,17 +987,17 @@ export class BootstrapFetcher {
973
987
  * registry) — chunk requests run through here concurrently and must
974
988
  * not cancel each other.
975
989
  */
976
- private async fetchOnce(url: string, lane: CancelLane): Promise<BootstrapData> {
990
+ private async fetchOnce(request: ReturnType<typeof bootstrapRequest>, lane: CancelLane): Promise<BootstrapData> {
977
991
  const controller = new AbortController();
978
992
  this.activeControllers.set(controller, lane);
979
993
  try {
980
- return await this.fetchOnceWith(url, controller);
994
+ return await this.fetchOnceWith(request, controller);
981
995
  } finally {
982
996
  this.activeControllers.delete(controller);
983
997
  }
984
998
  }
985
999
 
986
- private async fetchOnceWith(url: string, controller: AbortController): Promise<BootstrapData> {
1000
+ private async fetchOnceWith(request: ReturnType<typeof bootstrapRequest>, controller: AbortController): Promise<BootstrapData> {
987
1001
  const timeoutId = setTimeout(() => {
988
1002
  this.runtime.observability.breadcrumb('Bootstrap fetch timeout', 'sync.bootstrap', 'warning', {
989
1003
  timeoutMs: this.options.fetchTimeout,
@@ -998,8 +1012,9 @@ export class BootstrapFetcher {
998
1012
 
999
1013
  let response: Response;
1000
1014
  try {
1001
- response = await fetch(url, {
1002
- method: 'GET',
1015
+ response = await fetch(request.url, {
1016
+ method: request.method,
1017
+ body: request.body,
1003
1018
  headers: withAuthHeaders(this.options.getAuthToken, {
1004
1019
  'Content-Type': 'application/json',
1005
1020
  'Cache-Control': 'no-cache, no-store, must-revalidate',
@@ -110,6 +110,7 @@ export class SyncWebSocket<
110
110
  * the state itself lives in {@link SyncCursor}.
111
111
  */
112
112
  private readonly cursor: SyncCursor;
113
+ private catchUp: { exchangeId: string; currentSyncId: number; nextSequence: number } | null = null;
113
114
 
114
115
  constructor(options: SyncWebSocketOptions) {
115
116
  super({
@@ -318,6 +319,11 @@ export class SyncWebSocket<
318
319
  this.sendAck(syncId);
319
320
  }
320
321
 
322
+ /** Publish completion only after the store's chunk-persistence lane drains. */
323
+ completeCatchUp(payload: { exchangeId: string; currentSyncId: number; chunks: number }): void {
324
+ this.emit('catchup_complete', payload);
325
+ }
326
+
321
327
  /**
322
328
  * Stop the periodic catchup interval
323
329
  */
@@ -548,6 +554,39 @@ export class SyncWebSocket<
548
554
  }
549
555
  }
550
556
 
557
+ protected override handleCatchUpBegin(rawPayload: unknown): void {
558
+ if (!isRecord(rawPayload)) return;
559
+ const { exchangeId, fromSyncId, currentSyncId } = rawPayload;
560
+ if (typeof exchangeId !== 'string' || !Number.isSafeInteger(fromSyncId) || !Number.isSafeInteger(currentSyncId)) return;
561
+ this.catchUp = { exchangeId, currentSyncId: currentSyncId as number, nextSequence: 0 };
562
+ this.emit('catchup_begin', { exchangeId, fromSyncId: fromSyncId as number, currentSyncId: currentSyncId as number });
563
+ }
564
+
565
+ protected override handleCatchUpChunk(rawPayload: unknown): void {
566
+ if (!isRecord(rawPayload) || !this.catchUp) return;
567
+ const { exchangeId, sequence, position, deltas } = rawPayload;
568
+ if (exchangeId !== this.catchUp.exchangeId || sequence !== this.catchUp.nextSequence
569
+ || !Number.isSafeInteger(position) || (position as number) > this.catchUp.currentSyncId
570
+ || !Array.isArray(deltas)) return;
571
+ const normalized: SyncDelta[] = [];
572
+ for (const raw of deltas) {
573
+ const delta = this.normalizeWireDelta(raw);
574
+ if (delta) normalized.push(delta);
575
+ }
576
+ if (normalized.length !== deltas.length) return;
577
+ this.catchUp.nextSequence++;
578
+ this.emit('catchup_chunk', { exchangeId, sequence, position, deltas: normalized });
579
+ }
580
+
581
+ protected override handleCatchUpEnd(rawPayload: unknown): void {
582
+ if (!isRecord(rawPayload) || !this.catchUp) return;
583
+ const { exchangeId, currentSyncId, chunks } = rawPayload;
584
+ if (exchangeId !== this.catchUp.exchangeId || currentSyncId !== this.catchUp.currentSyncId
585
+ || chunks !== this.catchUp.nextSequence) return;
586
+ this.catchUp = null;
587
+ this.emit('catchup_end', { exchangeId, currentSyncId, chunks });
588
+ }
589
+
551
590
  /**
552
591
  * Handle bootstrap response from server
553
592
  */
@@ -282,8 +282,13 @@ export function scheduleDeltaFlush(ctx: DeltaPipelineContext): void {
282
282
  }
283
283
  }
284
284
 
285
- /** Apply an authoritative delta frame as one atomic flush. */
285
+ /** Apply an authoritative legacy frame without exposing its persistence promise. */
286
286
  export function applyDeltaFrame(ctx: DeltaPipelineContext, deltas: SyncDelta[]): void {
287
+ void persistDeltaFrame(ctx, deltas).catch(ctx.handleFlushError);
288
+ }
289
+
290
+ /** Persist one resumable chunk before its raw tail position may advance. */
291
+ export async function persistDeltaFrame(ctx: DeltaPipelineContext, deltas: SyncDelta[]): Promise<void> {
287
292
  let enqueuedAny = false;
288
293
  for (const delta of deltas) {
289
294
  if (enqueueDelta(ctx, delta, { authoritative: true })) enqueuedAny = true;
@@ -293,7 +298,7 @@ export function applyDeltaFrame(ctx: DeltaPipelineContext, deltas: SyncDelta[]):
293
298
  clearTimeout(ctx.batchTimer);
294
299
  ctx.batchTimer = null;
295
300
  }
296
- void ctx.flushPendingDeltas().catch(ctx.handleFlushError);
301
+ await ctx.flushPendingDeltas();
297
302
  }
298
303
 
299
304
  /**
@@ -27,7 +27,7 @@ export interface SocketEventHost<TCollaboration extends EventMap<TCollaboration>
27
27
  onConnectionEvent?: (event: string) => void;
28
28
  updateSyncStatus(updates: Partial<SyncStatus>): void;
29
29
  processDeltaWithBatching(delta: SyncDelta): void;
30
- applyDeltaFrame(deltas: SyncDelta[]): void;
30
+ applyDeltaFrame(deltas: SyncDelta[]): Promise<void>;
31
31
  handleBootstrapRequired(hint: BootstrapHint): void;
32
32
  handleBootstrapData(data: BootstrapDataEvent): void;
33
33
  performCredentialRefresh(): Promise<'refreshed' | 'session_error' | 'network_error'>;
@@ -77,7 +77,28 @@ export function wireSocketEvents<TCollaboration extends EventMap<TCollaboration>
77
77
  // A catch-up/reconnect frame is already complete — apply it as ONE
78
78
  // atomic flush so the gallery re-renders once, not once per 50-delta
79
79
  // chunk. See `applyDeltaFrame`.
80
- deps.applyDeltaFrame(deltas);
80
+ void deps.applyDeltaFrame(deltas).catch((error: unknown) => {
81
+ deps.updateSyncStatus({ state: 'error', error: error instanceof Error ? error : new Error(String(error)) });
82
+ });
83
+ });
84
+
85
+ let catchUpLane = Promise.resolve();
86
+ const onCatchUpChunk = deps.syncWebSocket.subscribe('catchup_chunk', (chunk) => {
87
+ catchUpLane = catchUpLane.then(async () => {
88
+ await deps.applyDeltaFrame(chunk.deltas);
89
+ // The raw position covers filtered rows too. Advance only after this
90
+ // chunk's visible rows are durable; replay is therefore idempotent.
91
+ deps.syncWebSocket.acknowledge(chunk.position);
92
+ });
93
+ void catchUpLane.catch((error: unknown) => {
94
+ deps.updateSyncStatus({ state: 'error', error: error instanceof Error ? error : new Error(String(error)) });
95
+ });
96
+ });
97
+ const onCatchUpEnd = deps.syncWebSocket.subscribe('catchup_end', (end) => {
98
+ void catchUpLane.then(() => {
99
+ deps.syncWebSocket.acknowledge(end.currentSyncId);
100
+ deps.syncWebSocket.completeCatchUp(end);
101
+ }).catch(() => undefined);
81
102
  });
82
103
 
83
104
  // Bootstrap events
@@ -181,7 +202,7 @@ export function wireSocketEvents<TCollaboration extends EventMap<TCollaboration>
181
202
 
182
203
  deps.disposers.push(
183
204
  onConnected, onDisconnected, onReconnecting,
184
- onDelta, onDeltaBatch, onBootstrapRequired,
205
+ onDelta, onDeltaBatch, onCatchUpChunk, onCatchUpEnd, onBootstrapRequired,
185
206
  onBootstrapData,
186
207
  onError, onSessionError, onHandshakeFailed, onReconnectFailed,
187
208
  () => { deps.areaOfInterest.dispose(); },