@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.
- package/dist/local/BaseSyncedStore.d.ts +1 -1
- package/dist/local/BaseSyncedStore.js +3 -3
- 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/BootstrapFetcher.js +32 -17
- package/dist/local/sync/OnDemandLoader.d.ts +3 -0
- package/dist/local/sync/OnDemandLoader.js +81 -45
- package/dist/local/sync/SyncWebSocket.d.ts +10 -0
- package/dist/local/sync/SyncWebSocket.js +43 -0
- package/dist/local/sync/deltaPipeline.d.ts +3 -1
- package/dist/local/sync/deltaPipeline.js +6 -2
- package/dist/local/sync/socketEventWiring.d.ts +1 -1
- package/dist/local/sync/socketEventWiring.js +22 -2
- package/package.json +2 -2
- package/src/local/BaseSyncedStore.ts +4 -4
- package/src/local/SyncClient.ts +28 -2
- package/src/local/client/createInternalComponents.ts +2 -0
- package/src/local/sync/BootstrapFetcher.ts +32 -17
- package/src/local/sync/OnDemandLoader.ts +89 -52
- package/src/local/sync/SyncWebSocket.ts +39 -0
- package/src/local/sync/deltaPipeline.ts +7 -2
- package/src/local/sync/socketEventWiring.ts +24 -3
|
@@ -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.
|
|
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.
|
|
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) =>
|
|
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.
|
|
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
|
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
|
|
@@ -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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
744
|
-
const data = await this.fetchWithRetries(
|
|
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
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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(
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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) {
|
|
@@ -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
|
|
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
|
-
|
|
301
|
+
await ctx.flushPendingDeltas();
|
|
297
302
|
}
|
|
298
303
|
|
|
299
304
|
/**
|