@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.
@@ -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.1",
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.1",
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
@@ -106,6 +106,11 @@ export class SyncClient extends EventEmitter {
106
106
  * {@link SyncClient.getEchoMetrics} exposes its counters.
107
107
  */
108
108
  private readonly echoTracker = new UnconfirmedWrites();
109
+ private readonly pendingDeletes = new Set<string>();
110
+
111
+ isDeletePending(id: string): boolean {
112
+ return this.pendingDeletes.has(id);
113
+ }
109
114
 
110
115
  // Connection state
111
116
  private connectionState: 'connected' | 'disconnected' | 'connecting' = 'disconnected';
@@ -312,7 +317,14 @@ export class SyncClient extends EventEmitter {
312
317
  // Guard: if the model was disposed (e.g. by a concurrent DELETE rollback or
313
318
  // cascade), don't re-add it — Object.assign cannot restore the private
314
319
  // isDisposed flag, so the model would be added in a broken state.
315
- if (model.disposed) {
320
+ if (model.disposed && transaction.type === 'delete') {
321
+ const restored = this.objectPool.createFromData({
322
+ ...model.toJSON(),
323
+ ...(previousState && typeof previousState === 'object' ? previousState : {}),
324
+ __typename: transaction.modelName,
325
+ });
326
+ if (restored) this.objectPool.add(restored, ModelScope.live);
327
+ } else if (model.disposed) {
316
328
  // Follow-on of an already-logged permanent error, not its own
317
329
  // problem: the tx that failed has already surfaced the cause in
318
330
  // MutationQueue. Restoring a disposed model is a no-op by
@@ -330,6 +342,8 @@ export class SyncClient extends EventEmitter {
330
342
  }
331
343
  }
332
344
 
345
+ if (transaction.type === 'delete') this.pendingDeletes.delete(transaction.modelId);
346
+
333
347
  this.notifyObservers({
334
348
  type: 'rollback',
335
349
  modelType: transaction.modelName,
@@ -1084,7 +1098,17 @@ export class SyncClient extends EventEmitter {
1084
1098
  delete(model: Model, options?: WriteOptions): Promise<void> | undefined {
1085
1099
  // Clear pending mutations first to prevent "not found" errors on fast delete
1086
1100
  this.mutationQueue.cancelTransactionsForModel(model.id);
1087
- return this.mutate('delete', model, () => this.objectPool.remove(model.id), options);
1101
+ this.pendingDeletes.add(model.id);
1102
+ this.emit('optimistic:delete', model.id);
1103
+ const confirmation = this.mutate('delete', model, () => this.objectPool.remove(model.id), options);
1104
+ // Confirmation can precede the IndexedDB delta write. Keep the guard until
1105
+ // the persisted remove is applied, except when no durable row exists.
1106
+ void confirmation?.then(async () => {
1107
+ if (!await this.database.getStore(model.getModelName())?.get(model.id)) {
1108
+ this.pendingDeletes.delete(model.id);
1109
+ }
1110
+ }).catch(() => undefined);
1111
+ return confirmation;
1088
1112
  }
1089
1113
 
1090
1114
  private fileUploadContext(): FileUploadContext {
@@ -1871,6 +1895,7 @@ export class SyncClient extends EventEmitter {
1871
1895
  // otherwise re-add it for the brief window before the matching delete
1872
1896
  // confirmation lands.
1873
1897
  if (this.echoTracker.consumeEcho(transactionId)) {
1898
+ if (action === 'remove') this.pendingDeletes.delete(modelId);
1874
1899
  // A direct assignment can re-enter change tracking while this
1875
1900
  // optimistic write is in flight. Leaving the acknowledged field dirty
1876
1901
  // makes conflict resolution preserve it over the next collaborator
@@ -1944,6 +1969,7 @@ export class SyncClient extends EventEmitter {
1944
1969
  }
1945
1970
  case 'remove':
1946
1971
  idsToRemove.push(modelId);
1972
+ this.pendingDeletes.delete(modelId);
1947
1973
  break;
1948
1974
  case 'archive':
1949
1975
  idsToArchive.push(modelId);
@@ -127,7 +127,9 @@ export function createInternalComponents<S extends SchemaRecord>(
127
127
  // leaves so a late answer cannot overwrite a row the pool already knows to
128
128
  // be further along.
129
129
  position: syncClient.position,
130
+ isDeletePending: (id) => syncClient.isDeletePending(id),
130
131
  });
132
+ syncClient.on('optimistic:delete', (id: string) => hydration.markDeleted(id));
131
133
 
132
134
  // Drop the lazy-lane hydration ledger on reconnect. While connected, the
133
135
  // WebSocket delta stream keeps hydrated rows fresh so repeat reads serve
@@ -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',
@@ -71,6 +71,7 @@ export interface OnDemandLoaderOptions {
71
71
  * against before a snapshot may overwrite it (see {@link RowWatermarks}).
72
72
  */
73
73
  readonly position: Pick<LogPositionPort, 'readFloor'>;
74
+ readonly isDeletePending?: (id: string) => boolean;
74
75
  }
75
76
 
76
77
  export interface FetchOptions<T> {
@@ -153,6 +154,11 @@ function snapshotPosition(
153
154
  }
154
155
 
155
156
  export class OnDemandLoader {
157
+ private readonly activeReadDeletes = new Set<Set<string>>();
158
+
159
+ markDeleted(id: string): void {
160
+ for (const deleted of this.activeReadDeletes) deleted.add(id);
161
+ }
156
162
  private readonly readEvidence = new WeakMap<object, number>();
157
163
  private readonly inFlight = new Map<string, Promise<Model[]>>();
158
164
  /**
@@ -302,8 +308,10 @@ export class OnDemandLoader {
302
308
  // through to the blocking fetch that brings parent and children together;
303
309
  // the second open is served by the fast path.
304
310
  if (!explicitComplete && !hasExpand) {
305
- const local = await this.readLocal(modelName, typename, ModelClass, clauses, hasExpand, expand);
306
- if (local.length > 0) {
311
+ let suppressed = false;
312
+ const local = await this.readLocal(modelName, typename, ModelClass, clauses, hasExpand, expand,
313
+ () => { suppressed = true; });
314
+ if (local.length > 0 || suppressed) {
307
315
  this.scheduleHydratingFetch(queryKey, modelName, typename, clauses, options);
308
316
  return applyLimit(local, options?.limit);
309
317
  }
@@ -330,16 +338,30 @@ export class OnDemandLoader {
330
338
  clauses: readonly WhereClause[],
331
339
  hasExpand: boolean,
332
340
  expand: readonly string[] | undefined,
341
+ onSuppressed?: () => void,
333
342
  ): Promise<Model[]> {
334
- let local = scanPool(this.opts.objectPool, ModelClass, clauses);
343
+ let local = scanPool(this.opts.objectPool, ModelClass, clauses)
344
+ .filter((model) => !this.opts.isDeletePending?.(model.id));
335
345
  if (local.length === 0) {
336
- const fromIdb = await scanIdb(this.opts.database, typename, clauses);
337
- const idbModels = fromIdb
338
- .map((raw) => this.hydrateOne(raw, LOCAL, typename))
339
- .filter((m): m is Model => m !== null);
340
- if (idbModels.length > 0) {
341
- this.opts.objectPool.addBatch(idbModels, ModelScope.live);
342
- local = idbModels;
346
+ const deleted = new Set<string>();
347
+ this.activeReadDeletes.add(deleted);
348
+ try {
349
+ const fromIdb = await scanIdb(this.opts.database, typename, clauses);
350
+ if (fromIdb.some((raw) => {
351
+ const id = raw && typeof raw === 'object' ? (raw as { id?: unknown }).id : undefined;
352
+ return typeof id === 'string' && (this.opts.isDeletePending?.(id) || deleted.has(id));
353
+ })) {
354
+ onSuppressed?.();
355
+ }
356
+ const idbModels = fromIdb
357
+ .map((raw) => this.hydrateOne(raw, LOCAL, typename, { deleted }))
358
+ .filter((m): m is Model => m !== null);
359
+ if (idbModels.length > 0) {
360
+ this.opts.objectPool.addBatch(idbModels, ModelScope.live);
361
+ local = idbModels;
362
+ }
363
+ } finally {
364
+ this.activeReadDeletes.delete(deleted);
343
365
  }
344
366
  }
345
367
  if (hasExpand && expand && local.length > 0) {
@@ -370,41 +392,47 @@ export class OnDemandLoader {
370
392
  clauses: readonly WhereClause[],
371
393
  options: FetchOptions<unknown> | undefined,
372
394
  ): Promise<Model[]> {
373
- const network = await this.queryNetwork(modelName, clauses, options);
374
- const networkRows = network.rows;
375
- const evidenceById = new Map(network.evidence.map((entry) => [entry.id, entry.stamp]));
376
- const acceptedRows: unknown[] = [];
377
- const networkModels = networkRows
378
- // Strict: a row the server returned whose type name this client never
379
- // registered is a genuine schema collision (the pushed schema differs
380
- // from the local one). Throw here, naming the cause, rather than silently
381
- // dropping the row and failing downstream as `entity_not_found`.
382
- .map((raw) =>
383
- this.hydrateOne(
384
- raw,
385
- { kind: 'network', position: snapshotPosition(raw, evidenceById, network.position) },
386
- typename,
387
- { strict: true, acceptedRows },
388
- ),
389
- )
390
- .filter((m): m is Model => m !== null);
391
-
392
- for (const model of networkModels) {
393
- const stamp = evidenceById.get(model.id);
394
- if (stamp === undefined) continue;
395
- // The read's evidence, kept for the premise a guarded write may cite;
396
- // and the position the pooled row now reflects, for freshness.
397
- this.readEvidence.set(model, stamp);
398
- this.opts.objectPool.watermarks.advance(model, stamp);
399
- }
395
+ const deleted = new Set<string>();
396
+ this.activeReadDeletes.add(deleted);
397
+ try {
398
+ const network = await this.queryNetwork(modelName, clauses, options, deleted);
399
+ const networkRows = network.rows;
400
+ const evidenceById = new Map(network.evidence.map((entry) => [entry.id, entry.stamp]));
401
+ const acceptedRows: unknown[] = [];
402
+ const networkModels = networkRows
403
+ // Strict: a row the server returned whose type name this client never
404
+ // registered is a genuine schema collision (the pushed schema differs
405
+ // from the local one). Throw here, naming the cause, rather than silently
406
+ // dropping the row and failing downstream as `entity_not_found`.
407
+ .map((raw) =>
408
+ this.hydrateOne(
409
+ raw,
410
+ { kind: 'network', position: snapshotPosition(raw, evidenceById, network.position) },
411
+ typename,
412
+ { strict: true, acceptedRows, deleted },
413
+ ),
414
+ )
415
+ .filter((m): m is Model => m !== null);
400
416
 
401
- if (networkModels.length > 0) {
402
- this.opts.objectPool.addBatch(networkModels, ModelScope.live);
403
- // Persist only accepted snapshots: a stale response must not roll disk back.
404
- await this.persistToIdb(modelName, acceptedRows);
405
- }
417
+ for (const model of networkModels) {
418
+ const stamp = evidenceById.get(model.id);
419
+ if (stamp === undefined) continue;
420
+ // The read's evidence, kept for the premise a guarded write may cite;
421
+ // and the position the pooled row now reflects, for freshness.
422
+ this.readEvidence.set(model, stamp);
423
+ this.opts.objectPool.watermarks.advance(model, stamp);
424
+ }
425
+
426
+ if (networkModels.length > 0) {
427
+ this.opts.objectPool.addBatch(networkModels, ModelScope.live);
428
+ // Persist only accepted snapshots: a stale response must not roll disk back.
429
+ await this.persistToIdb(modelName, acceptedRows);
430
+ }
406
431
 
407
- return networkModels;
432
+ return networkModels;
433
+ } finally {
434
+ this.activeReadDeletes.delete(deleted);
435
+ }
408
436
  }
409
437
 
410
438
  /**
@@ -473,12 +501,18 @@ export class OnDemandLoader {
473
501
  );
474
502
  if (missing.length === 0) continue;
475
503
 
476
- const rows = await this.readChildrenLocal(targetTypename, foreignKey, missing);
477
- const models = rows
478
- .map((raw) => this.hydrateOne(this.stampTypename(raw, targetTypename), LOCAL, targetTypename))
479
- .filter((m): m is Model => m !== null);
480
- if (models.length > 0) {
481
- this.opts.objectPool.addBatch(models, ModelScope.live);
504
+ const deleted = new Set<string>();
505
+ this.activeReadDeletes.add(deleted);
506
+ try {
507
+ const rows = await this.readChildrenLocal(targetTypename, foreignKey, missing);
508
+ const models = rows
509
+ .map((raw) => this.hydrateOne(this.stampTypename(raw, targetTypename), LOCAL, targetTypename, { deleted }))
510
+ .filter((m): m is Model => m !== null);
511
+ if (models.length > 0) {
512
+ this.opts.objectPool.addBatch(models, ModelScope.live);
513
+ }
514
+ } finally {
515
+ this.activeReadDeletes.delete(deleted);
482
516
  }
483
517
  }
484
518
  }
@@ -532,11 +566,12 @@ export class OnDemandLoader {
532
566
  raw: unknown,
533
567
  origin: HydrationOrigin,
534
568
  typename?: string,
535
- opts?: { strict?: boolean; acceptedRows?: unknown[] },
569
+ opts?: { strict?: boolean; acceptedRows?: unknown[]; deleted?: ReadonlySet<string> },
536
570
  ): Model | null {
537
571
  if (!raw || typeof raw !== 'object') return null;
538
572
  const obj = raw as Record<string, unknown>;
539
573
  if (typeof obj.id !== 'string') return null;
574
+ if (this.opts.isDeletePending?.(obj.id) || opts?.deleted?.has(obj.id)) return null;
540
575
  if (this.opts.objectPool.has(obj.id)) {
541
576
  // Keep the existing instance alive when a query refreshes it. A query
542
577
  // can carry fresher server state after a missed delta, but unlike the
@@ -617,6 +652,7 @@ export class OnDemandLoader {
617
652
  modelName: string,
618
653
  clauses: readonly WhereClause[],
619
654
  options: FetchOptions<unknown> | undefined,
655
+ deleted: ReadonlySet<string>,
620
656
  ): Promise<{
621
657
  rows: unknown[];
622
658
  evidence: readonly { id: string; stamp: number }[];
@@ -672,7 +708,7 @@ export class OnDemandLoader {
672
708
  // own typed pool, then leave the nested arrays in place on the
673
709
  // primary row.
674
710
  if (options?.expand && options.expand.length > 0) {
675
- await this.hydrateExpanded(modelName, normalized, options.expand, position);
711
+ await this.hydrateExpanded(modelName, normalized, options.expand, position, deleted);
676
712
  }
677
713
  return { rows: normalized, evidence, position };
678
714
  }
@@ -689,6 +725,7 @@ export class OnDemandLoader {
689
725
  rows: unknown[],
690
726
  relationNames: readonly string[],
691
727
  position: number,
728
+ deleted: ReadonlySet<string>,
692
729
  ): Promise<void> {
693
730
  const writes: Promise<void>[] = [];
694
731
  const parentDef = this.getModelDef(parentModelName);
@@ -711,7 +748,7 @@ export class OnDemandLoader {
711
748
  const stampedItems: unknown[] = [];
712
749
  for (const item of items) {
713
750
  const stamped = this.stampTypename(item, targetTypename);
714
- const m = this.hydrateOne(stamped, origin, targetTypename, { acceptedRows: stampedItems });
751
+ const m = this.hydrateOne(stamped, origin, targetTypename, { acceptedRows: stampedItems, deleted });
715
752
  if (m) models.push(m);
716
753
  }
717
754
  if (models.length > 0) {
@@ -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
  /**