@abloatai/humans 0.66.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.
@@ -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
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/humans",
3
- "version": "0.66.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.66.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",
@@ -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
@@ -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) {