@abloatai/humans 0.65.0 → 0.66.1

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
@@ -57,6 +57,8 @@ export declare class SyncClient extends EventEmitter {
57
57
  * {@link SyncClient.getEchoMetrics} exposes its counters.
58
58
  */
59
59
  private readonly echoTracker;
60
+ private readonly pendingDeletes;
61
+ isDeletePending(id: string): boolean;
60
62
  private connectionState;
61
63
  private isDisposed;
62
64
  /**
@@ -69,6 +69,10 @@ export class SyncClient extends EventEmitter {
69
69
  * {@link SyncClient.getEchoMetrics} exposes its counters.
70
70
  */
71
71
  echoTracker = new UnconfirmedWrites();
72
+ pendingDeletes = new Set();
73
+ isDeletePending(id) {
74
+ return this.pendingDeletes.has(id);
75
+ }
72
76
  // Connection state
73
77
  connectionState = 'disconnected';
74
78
  // Configuration
@@ -237,7 +241,16 @@ export class SyncClient extends EventEmitter {
237
241
  // Guard: if the model was disposed (e.g. by a concurrent DELETE rollback or
238
242
  // cascade), don't re-add it — Object.assign cannot restore the private
239
243
  // isDisposed flag, so the model would be added in a broken state.
240
- if (model.disposed) {
244
+ if (model.disposed && transaction.type === 'delete') {
245
+ const restored = this.objectPool.createFromData({
246
+ ...model.toJSON(),
247
+ ...(previousState && typeof previousState === 'object' ? previousState : {}),
248
+ __typename: transaction.modelName,
249
+ });
250
+ if (restored)
251
+ this.objectPool.add(restored, ModelScope.live);
252
+ }
253
+ else if (model.disposed) {
241
254
  // Follow-on of an already-logged permanent error, not its own
242
255
  // problem: the tx that failed has already surfaced the cause in
243
256
  // MutationQueue. Restoring a disposed model is a no-op by
@@ -256,6 +269,8 @@ export class SyncClient extends EventEmitter {
256
269
  this.objectPool.add(model, ModelScope.live);
257
270
  }
258
271
  }
272
+ if (transaction.type === 'delete')
273
+ this.pendingDeletes.delete(transaction.modelId);
259
274
  this.notifyObservers({
260
275
  type: 'rollback',
261
276
  modelType: transaction.modelName,
@@ -876,7 +891,17 @@ export class SyncClient extends EventEmitter {
876
891
  delete(model, options) {
877
892
  // Clear pending mutations first to prevent "not found" errors on fast delete
878
893
  this.mutationQueue.cancelTransactionsForModel(model.id);
879
- return this.mutate('delete', model, () => this.objectPool.remove(model.id), options);
894
+ this.pendingDeletes.add(model.id);
895
+ this.emit('optimistic:delete', model.id);
896
+ const confirmation = this.mutate('delete', model, () => this.objectPool.remove(model.id), options);
897
+ // Confirmation can precede the IndexedDB delta write. Keep the guard until
898
+ // the persisted remove is applied, except when no durable row exists.
899
+ void confirmation?.then(async () => {
900
+ if (!await this.database.getStore(model.getModelName())?.get(model.id)) {
901
+ this.pendingDeletes.delete(model.id);
902
+ }
903
+ }).catch(() => undefined);
904
+ return confirmation;
880
905
  }
881
906
  fileUploadContext() {
882
907
  return {
@@ -1530,6 +1555,8 @@ export class SyncClient extends EventEmitter {
1530
1555
  // otherwise re-add it for the brief window before the matching delete
1531
1556
  // confirmation lands.
1532
1557
  if (this.echoTracker.consumeEcho(transactionId)) {
1558
+ if (action === 'remove')
1559
+ this.pendingDeletes.delete(modelId);
1533
1560
  // A direct assignment can re-enter change tracking while this
1534
1561
  // optimistic write is in flight. Leaving the acknowledged field dirty
1535
1562
  // makes conflict resolution preserve it over the next collaborator
@@ -1600,6 +1627,7 @@ export class SyncClient extends EventEmitter {
1600
1627
  }
1601
1628
  case 'remove':
1602
1629
  idsToRemove.push(modelId);
1630
+ this.pendingDeletes.delete(modelId);
1603
1631
  break;
1604
1632
  case 'archive':
1605
1633
  idsToArchive.push(modelId);
@@ -68,7 +68,9 @@ export function createInternalComponents(input) {
68
68
  // leaves so a late answer cannot overwrite a row the pool already knows to
69
69
  // be further along.
70
70
  position: syncClient.position,
71
+ isDeletePending: (id) => syncClient.isDeletePending(id),
71
72
  });
73
+ syncClient.on('optimistic:delete', (id) => hydration.markDeleted(id));
72
74
  // Drop the lazy-lane hydration ledger on reconnect. While connected, the
73
75
  // WebSocket delta stream keeps hydrated rows fresh so repeat reads serve
74
76
  // pure-local with no network; after a drop, deltas may have been missed, so
@@ -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',
@@ -66,6 +66,7 @@ export interface OnDemandLoaderOptions {
66
66
  * against before a snapshot may overwrite it (see {@link RowWatermarks}).
67
67
  */
68
68
  readonly position: Pick<LogPositionPort, 'readFloor'>;
69
+ readonly isDeletePending?: (id: string) => boolean;
69
70
  }
70
71
  export interface FetchOptions<T> {
71
72
  /**
@@ -99,6 +100,8 @@ export interface FetchOptions<T> {
99
100
  }
100
101
  export declare class OnDemandLoader {
101
102
  private readonly opts;
103
+ private readonly activeReadDeletes;
104
+ markDeleted(id: string): void;
102
105
  private readonly readEvidence;
103
106
  private readonly inFlight;
104
107
  /**
@@ -47,6 +47,11 @@ function snapshotPosition(raw, evidenceById, readFloorAtIssue) {
47
47
  }
48
48
  export class OnDemandLoader {
49
49
  opts;
50
+ activeReadDeletes = new Set();
51
+ markDeleted(id) {
52
+ for (const deleted of this.activeReadDeletes)
53
+ deleted.add(id);
54
+ }
50
55
  readEvidence = new WeakMap();
51
56
  inFlight = new Map();
52
57
  /**
@@ -166,8 +171,9 @@ export class OnDemandLoader {
166
171
  // through to the blocking fetch that brings parent and children together;
167
172
  // the second open is served by the fast path.
168
173
  if (!explicitComplete && !hasExpand) {
169
- const local = await this.readLocal(modelName, typename, ModelClass, clauses, hasExpand, expand);
170
- if (local.length > 0) {
174
+ let suppressed = false;
175
+ const local = await this.readLocal(modelName, typename, ModelClass, clauses, hasExpand, expand, () => { suppressed = true; });
176
+ if (local.length > 0 || suppressed) {
171
177
  this.scheduleHydratingFetch(queryKey, modelName, typename, clauses, options);
172
178
  return applyLimit(local, options?.limit);
173
179
  }
@@ -185,16 +191,30 @@ export class OnDemandLoader {
185
191
  * pool from IDB as a side effect. Resolves requested `expand` relations from
186
192
  * their own local stores too. Never touches the network.
187
193
  */
188
- async readLocal(modelName, typename, ModelClass, clauses, hasExpand, expand) {
189
- let local = scanPool(this.opts.objectPool, ModelClass, clauses);
194
+ async readLocal(modelName, typename, ModelClass, clauses, hasExpand, expand, onSuppressed) {
195
+ let local = scanPool(this.opts.objectPool, ModelClass, clauses)
196
+ .filter((model) => !this.opts.isDeletePending?.(model.id));
190
197
  if (local.length === 0) {
191
- const fromIdb = await scanIdb(this.opts.database, typename, clauses);
192
- const idbModels = fromIdb
193
- .map((raw) => this.hydrateOne(raw, LOCAL, typename))
194
- .filter((m) => m !== null);
195
- if (idbModels.length > 0) {
196
- this.opts.objectPool.addBatch(idbModels, ModelScope.live);
197
- local = idbModels;
198
+ const deleted = new Set();
199
+ this.activeReadDeletes.add(deleted);
200
+ try {
201
+ const fromIdb = await scanIdb(this.opts.database, typename, clauses);
202
+ if (fromIdb.some((raw) => {
203
+ const id = raw && typeof raw === 'object' ? raw.id : undefined;
204
+ return typeof id === 'string' && (this.opts.isDeletePending?.(id) || deleted.has(id));
205
+ })) {
206
+ onSuppressed?.();
207
+ }
208
+ const idbModels = fromIdb
209
+ .map((raw) => this.hydrateOne(raw, LOCAL, typename, { deleted }))
210
+ .filter((m) => m !== null);
211
+ if (idbModels.length > 0) {
212
+ this.opts.objectPool.addBatch(idbModels, ModelScope.live);
213
+ local = idbModels;
214
+ }
215
+ }
216
+ finally {
217
+ this.activeReadDeletes.delete(deleted);
198
218
  }
199
219
  }
200
220
  if (hasExpand && expand && local.length > 0) {
@@ -218,32 +238,39 @@ export class OnDemandLoader {
218
238
  * revalidation kicked off after an `'unknown'` local hit.
219
239
  */
220
240
  async fetchFromNetwork(modelName, typename, clauses, options) {
221
- const network = await this.queryNetwork(modelName, clauses, options);
222
- const networkRows = network.rows;
223
- const evidenceById = new Map(network.evidence.map((entry) => [entry.id, entry.stamp]));
224
- const acceptedRows = [];
225
- const networkModels = networkRows
226
- // Strict: a row the server returned whose type name this client never
227
- // registered is a genuine schema collision (the pushed schema differs
228
- // from the local one). Throw here, naming the cause, rather than silently
229
- // dropping the row and failing downstream as `entity_not_found`.
230
- .map((raw) => this.hydrateOne(raw, { kind: 'network', position: snapshotPosition(raw, evidenceById, network.position) }, typename, { strict: true, acceptedRows }))
231
- .filter((m) => m !== null);
232
- for (const model of networkModels) {
233
- const stamp = evidenceById.get(model.id);
234
- if (stamp === undefined)
235
- continue;
236
- // The read's evidence, kept for the premise a guarded write may cite;
237
- // and the position the pooled row now reflects, for freshness.
238
- this.readEvidence.set(model, stamp);
239
- this.opts.objectPool.watermarks.advance(model, stamp);
241
+ const deleted = new Set();
242
+ this.activeReadDeletes.add(deleted);
243
+ try {
244
+ const network = await this.queryNetwork(modelName, clauses, options, deleted);
245
+ const networkRows = network.rows;
246
+ const evidenceById = new Map(network.evidence.map((entry) => [entry.id, entry.stamp]));
247
+ const acceptedRows = [];
248
+ const networkModels = networkRows
249
+ // Strict: a row the server returned whose type name this client never
250
+ // registered is a genuine schema collision (the pushed schema differs
251
+ // from the local one). Throw here, naming the cause, rather than silently
252
+ // dropping the row and failing downstream as `entity_not_found`.
253
+ .map((raw) => this.hydrateOne(raw, { kind: 'network', position: snapshotPosition(raw, evidenceById, network.position) }, typename, { strict: true, acceptedRows, deleted }))
254
+ .filter((m) => m !== null);
255
+ for (const model of networkModels) {
256
+ const stamp = evidenceById.get(model.id);
257
+ if (stamp === undefined)
258
+ continue;
259
+ // The read's evidence, kept for the premise a guarded write may cite;
260
+ // and the position the pooled row now reflects, for freshness.
261
+ this.readEvidence.set(model, stamp);
262
+ this.opts.objectPool.watermarks.advance(model, stamp);
263
+ }
264
+ if (networkModels.length > 0) {
265
+ this.opts.objectPool.addBatch(networkModels, ModelScope.live);
266
+ // Persist only accepted snapshots: a stale response must not roll disk back.
267
+ await this.persistToIdb(modelName, acceptedRows);
268
+ }
269
+ return networkModels;
240
270
  }
241
- if (networkModels.length > 0) {
242
- this.opts.objectPool.addBatch(networkModels, ModelScope.live);
243
- // Persist only accepted snapshots: a stale response must not roll disk back.
244
- await this.persistToIdb(modelName, acceptedRows);
271
+ finally {
272
+ this.activeReadDeletes.delete(deleted);
245
273
  }
246
- return networkModels;
247
274
  }
248
275
  /**
249
276
  * Fires the single background confirm for a query that was just served from
@@ -301,12 +328,19 @@ export class OnDemandLoader {
301
328
  const missing = parentIds.filter((pid) => this.opts.objectPool.getByForeignKey(targetTypename, foreignKey, pid).length === 0);
302
329
  if (missing.length === 0)
303
330
  continue;
304
- const rows = await this.readChildrenLocal(targetTypename, foreignKey, missing);
305
- const models = rows
306
- .map((raw) => this.hydrateOne(this.stampTypename(raw, targetTypename), LOCAL, targetTypename))
307
- .filter((m) => m !== null);
308
- if (models.length > 0) {
309
- this.opts.objectPool.addBatch(models, ModelScope.live);
331
+ const deleted = new Set();
332
+ this.activeReadDeletes.add(deleted);
333
+ try {
334
+ const rows = await this.readChildrenLocal(targetTypename, foreignKey, missing);
335
+ const models = rows
336
+ .map((raw) => this.hydrateOne(this.stampTypename(raw, targetTypename), LOCAL, targetTypename, { deleted }))
337
+ .filter((m) => m !== null);
338
+ if (models.length > 0) {
339
+ this.opts.objectPool.addBatch(models, ModelScope.live);
340
+ }
341
+ }
342
+ finally {
343
+ this.activeReadDeletes.delete(deleted);
310
344
  }
311
345
  }
312
346
  }
@@ -358,6 +392,8 @@ export class OnDemandLoader {
358
392
  const obj = raw;
359
393
  if (typeof obj.id !== 'string')
360
394
  return null;
395
+ if (this.opts.isDeletePending?.(obj.id) || opts?.deleted?.has(obj.id))
396
+ return null;
361
397
  if (this.opts.objectPool.has(obj.id)) {
362
398
  // Keep the existing instance alive when a query refreshes it. A query
363
399
  // can carry fresher server state after a missed delta, but unlike the
@@ -430,7 +466,7 @@ export class OnDemandLoader {
430
466
  void _dropRowVariant;
431
467
  return { __typename: typename, ...rest };
432
468
  }
433
- async queryNetwork(modelName, clauses, options) {
469
+ async queryNetwork(modelName, clauses, options, deleted) {
434
470
  const typename = this.resolveTypename(modelName);
435
471
  const orderEntries = options?.orderBy ? Object.entries(options.orderBy) : [];
436
472
  const firstOrder = orderEntries[0];
@@ -476,7 +512,7 @@ export class OnDemandLoader {
476
512
  // own typed pool, then leave the nested arrays in place on the
477
513
  // primary row.
478
514
  if (options?.expand && options.expand.length > 0) {
479
- await this.hydrateExpanded(modelName, normalized, options.expand, position);
515
+ await this.hydrateExpanded(modelName, normalized, options.expand, position, deleted);
480
516
  }
481
517
  return { rows: normalized, evidence, position };
482
518
  }
@@ -487,7 +523,7 @@ export class OnDemandLoader {
487
523
  * `__typename` field gets mangled by `postgres.camel` (`__typename`
488
524
  * → `_Typename`), so the SDK can't trust whatever string lands.
489
525
  */
490
- async hydrateExpanded(parentModelName, rows, relationNames, position) {
526
+ async hydrateExpanded(parentModelName, rows, relationNames, position, deleted) {
491
527
  const writes = [];
492
528
  const parentDef = this.getModelDef(parentModelName);
493
529
  // Nested rows carry no evidence of their own; the read floor at issue
@@ -510,7 +546,7 @@ export class OnDemandLoader {
510
546
  const stampedItems = [];
511
547
  for (const item of items) {
512
548
  const stamped = this.stampTypename(item, targetTypename);
513
- const m = this.hydrateOne(stamped, origin, targetTypename, { acceptedRows: stampedItems });
549
+ const m = this.hydrateOne(stamped, origin, targetTypename, { acceptedRows: stampedItems, deleted });
514
550
  if (m)
515
551
  models.push(m);
516
552
  }
@@ -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'>;