@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.
- package/dist/local/SyncClient.d.ts +2 -0
- package/dist/local/SyncClient.js +30 -2
- package/dist/local/client/createInternalComponents.js +2 -0
- package/dist/local/sync/OnDemandLoader.d.ts +3 -0
- package/dist/local/sync/OnDemandLoader.js +81 -45
- package/package.json +2 -2
- package/src/local/SyncClient.ts +28 -2
- package/src/local/client/createInternalComponents.ts +2 -0
- package/src/local/sync/OnDemandLoader.ts +89 -52
|
@@ -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
|
/**
|
package/dist/local/SyncClient.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
242
|
-
this.
|
|
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
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
.
|
|
308
|
-
|
|
309
|
-
|
|
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.
|
|
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.
|
|
88
|
+
"@abloatai/transaction": "0.66.1",
|
|
89
89
|
"events": "^3.3.0",
|
|
90
90
|
"mobx": "^6.13.7",
|
|
91
91
|
"uuid": "^11.1.0",
|
package/src/local/SyncClient.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
306
|
-
|
|
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
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
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
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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) {
|