@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
|
@@ -676,7 +676,7 @@ export declare class BaseSyncedStore<TCollaboration extends EventMap<TCollaborat
|
|
|
676
676
|
* with {@link Database.processDeltaBatch} — the lower-level local write this
|
|
677
677
|
* eventually drives through `flushPendingDeltas`.
|
|
678
678
|
*/
|
|
679
|
-
protected applyDeltaFrame(deltas: SyncDelta[]): void
|
|
679
|
+
protected applyDeltaFrame(deltas: SyncDelta[]): Promise<void>;
|
|
680
680
|
/**
|
|
681
681
|
* Per-delta bookkeeping + enqueue. Returns `true` when the delta was
|
|
682
682
|
* pushed onto `pendingDeltas` (a regular batchable I/U/C/D delta that a
|
|
@@ -877,7 +877,7 @@ export class BaseSyncedStore {
|
|
|
877
877
|
getAllPoolIds: () => this.objectPool.getAllIds(),
|
|
878
878
|
get bootstrapDeltaQueue() { return store.bootstrapDeltaQueue; },
|
|
879
879
|
set bootstrapDeltaQueue(queue) { store.bootstrapDeltaQueue = queue; },
|
|
880
|
-
applyDeltaFrame: (deltas) => { this.applyDeltaFrame(deltas); },
|
|
880
|
+
applyDeltaFrame: (deltas) => { void this.applyDeltaFrame(deltas); },
|
|
881
881
|
};
|
|
882
882
|
}
|
|
883
883
|
/** Apply bootstrap data to the {@link InstanceCache}, removing entities that are no longer present (ghost removal). Pool writes are delegated to {@link SyncClient}. */
|
|
@@ -1100,7 +1100,7 @@ export class BaseSyncedStore {
|
|
|
1100
1100
|
onConnectionEvent: this.onConnectionEvent,
|
|
1101
1101
|
updateSyncStatus: (updates) => { this.updateSyncStatus(updates); },
|
|
1102
1102
|
processDeltaWithBatching: (delta) => { this.processDeltaWithBatching(delta); },
|
|
1103
|
-
applyDeltaFrame: (deltas) =>
|
|
1103
|
+
applyDeltaFrame: (deltas) => this.applyDeltaFrame(deltas),
|
|
1104
1104
|
handleBootstrapRequired: (hint) => { this.handleBootstrapRequired(hint); },
|
|
1105
1105
|
handleBootstrapData: (data) => { this.handleBootstrapData(data); },
|
|
1106
1106
|
performCredentialRefresh: () => this.performCredentialRefresh(),
|
|
@@ -1231,7 +1231,7 @@ export class BaseSyncedStore {
|
|
|
1231
1231
|
* eventually drives through `flushPendingDeltas`.
|
|
1232
1232
|
*/
|
|
1233
1233
|
applyDeltaFrame(deltas) {
|
|
1234
|
-
deltaPipeline.
|
|
1234
|
+
return deltaPipeline.persistDeltaFrame(this.deltaPipelineContext, deltas);
|
|
1235
1235
|
}
|
|
1236
1236
|
/**
|
|
1237
1237
|
* Per-delta bookkeeping + enqueue. Returns `true` when the delta was
|
|
@@ -57,6 +57,8 @@ export declare class SyncClient extends EventEmitter {
|
|
|
57
57
|
* {@link SyncClient.getEchoMetrics} exposes its counters.
|
|
58
58
|
*/
|
|
59
59
|
private readonly echoTracker;
|
|
60
|
+
private readonly pendingDeletes;
|
|
61
|
+
isDeletePending(id: string): boolean;
|
|
60
62
|
private connectionState;
|
|
61
63
|
private isDisposed;
|
|
62
64
|
/**
|
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
|
|
@@ -28,6 +28,19 @@ const PAGE_LIMIT = 5000;
|
|
|
28
28
|
const MAX_PAGES_PER_MODEL = 200;
|
|
29
29
|
/** How many model chunks a cold start fetches at once. */
|
|
30
30
|
const CHUNK_CONCURRENCY = 3;
|
|
31
|
+
/** Keep bootstrap URLs below common proxy limits when the subscribed group set grows. */
|
|
32
|
+
function bootstrapRequest(baseUrl, params) {
|
|
33
|
+
const url = `${baseUrl}/sync/bootstrap?${params.toString()}`;
|
|
34
|
+
if (url.length <= 8_000)
|
|
35
|
+
return { url, method: 'GET' };
|
|
36
|
+
const syncGroups = params.getAll('syncGroups');
|
|
37
|
+
params.delete('syncGroups');
|
|
38
|
+
return {
|
|
39
|
+
url: `${baseUrl}/sync/bootstrap?${params.toString()}`,
|
|
40
|
+
method: 'POST',
|
|
41
|
+
body: JSON.stringify({ syncGroups }),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
31
44
|
/**
|
|
32
45
|
* The reason handed to `abort()` when a request is stopped deliberately —
|
|
33
46
|
* superseded by a newer bootstrap, or abandoned because the bootstrap it
|
|
@@ -359,7 +372,7 @@ export class BootstrapFetcher {
|
|
|
359
372
|
if (this.options.instantModels && this.options.instantModels.length > 0) {
|
|
360
373
|
params.append('models', this.options.instantModels.join(','));
|
|
361
374
|
}
|
|
362
|
-
const
|
|
375
|
+
const request = bootstrapRequest(this.options.baseUrl, params);
|
|
363
376
|
// If offline, try the cached bootstrap. Skipped for a scoped override: the
|
|
364
377
|
// cache holds the full snapshot, which is not a valid answer to a subset
|
|
365
378
|
// request; a scoped hydrate just soft-fails offline and retries on re-enter.
|
|
@@ -384,7 +397,7 @@ export class BootstrapFetcher {
|
|
|
384
397
|
code: 'bootstrap_offline_no_cache',
|
|
385
398
|
});
|
|
386
399
|
}
|
|
387
|
-
this.runtime.logger.info('Fetching fresh bootstrap data', { url });
|
|
400
|
+
this.runtime.logger.info('Fetching fresh bootstrap data', { url: request.url });
|
|
388
401
|
const lane = syncGroupsOverride ? 'scoped' : 'bootstrap';
|
|
389
402
|
// Chunk a COLD start by model: each instant model is its own request, so
|
|
390
403
|
// one giant model can't make the whole snapshot undeliverable, and a
|
|
@@ -401,7 +414,7 @@ export class BootstrapFetcher {
|
|
|
401
414
|
try {
|
|
402
415
|
const data = chunked
|
|
403
416
|
? await this.fetchChunkedBootstrap(instantModels, this.options.syncGroups)
|
|
404
|
-
: await this.fetchWithRetries(
|
|
417
|
+
: await this.fetchWithRetries(request, lane);
|
|
405
418
|
this.runtime.logger.info('Bootstrap data fetched', {
|
|
406
419
|
type: data.type,
|
|
407
420
|
lastSyncId: data.lastSyncId,
|
|
@@ -448,12 +461,12 @@ export class BootstrapFetcher {
|
|
|
448
461
|
* (5xx, 429, timeouts, network blips) consume attempts. A cancellation is
|
|
449
462
|
* deliberate and therefore non-retryable — it leaves through the same gate.
|
|
450
463
|
*/
|
|
451
|
-
async fetchWithRetries(
|
|
464
|
+
async fetchWithRetries(request, lane) {
|
|
452
465
|
let lastError = null;
|
|
453
466
|
const capacityDeadline = Date.now() + this.options.fetchTimeout;
|
|
454
467
|
for (let attempt = 0; attempt < this.options.maxRetries;) {
|
|
455
468
|
try {
|
|
456
|
-
return await this.fetchOnce(
|
|
469
|
+
return await this.fetchOnce(request, lane);
|
|
457
470
|
}
|
|
458
471
|
catch (error) {
|
|
459
472
|
// SessionError should NOT be retried - the session is invalid and needs re-authentication
|
|
@@ -539,8 +552,8 @@ export class BootstrapFetcher {
|
|
|
539
552
|
params.append('limit', String(PAGE_LIMIT));
|
|
540
553
|
if (cursor !== undefined)
|
|
541
554
|
params.append('cursor', cursor);
|
|
542
|
-
const
|
|
543
|
-
const data = await this.fetchWithRetries(
|
|
555
|
+
const request = bootstrapRequest(this.options.baseUrl, params);
|
|
556
|
+
const data = await this.fetchWithRetries(request, 'bootstrap');
|
|
544
557
|
chunks.push(data);
|
|
545
558
|
if (data.nextCursor === undefined)
|
|
546
559
|
break;
|
|
@@ -576,7 +589,7 @@ export class BootstrapFetcher {
|
|
|
576
589
|
if (this.options.instantModels && this.options.instantModels.length > 0) {
|
|
577
590
|
params.append('models', this.options.instantModels.join(','));
|
|
578
591
|
}
|
|
579
|
-
const
|
|
592
|
+
const request = bootstrapRequest(this.options.baseUrl, params);
|
|
580
593
|
// Note: ETag caching is deliberately app-side, not SDK-side. The server
|
|
581
594
|
// still returns an ETag on responses, which is captured below and
|
|
582
595
|
// forwarded to callers via BootstrapFetchResult.etag — apps that want
|
|
@@ -587,15 +600,16 @@ export class BootstrapFetcher {
|
|
|
587
600
|
const controller = new AbortController();
|
|
588
601
|
this.activeControllers.set(controller, 'bootstrap');
|
|
589
602
|
try {
|
|
590
|
-
return await this.fetchWithETagUsing(
|
|
603
|
+
return await this.fetchWithETagUsing(request, headers, controller);
|
|
591
604
|
}
|
|
592
605
|
finally {
|
|
593
606
|
this.activeControllers.delete(controller);
|
|
594
607
|
}
|
|
595
608
|
}
|
|
596
|
-
async fetchWithETagUsing(
|
|
597
|
-
const res = await fetch(url, {
|
|
598
|
-
method:
|
|
609
|
+
async fetchWithETagUsing(request, headers, controller) {
|
|
610
|
+
const res = await fetch(request.url, {
|
|
611
|
+
method: request.method,
|
|
612
|
+
body: request.body,
|
|
599
613
|
headers,
|
|
600
614
|
signal: controller.signal,
|
|
601
615
|
});
|
|
@@ -726,17 +740,17 @@ export class BootstrapFetcher {
|
|
|
726
740
|
* registry) — chunk requests run through here concurrently and must
|
|
727
741
|
* not cancel each other.
|
|
728
742
|
*/
|
|
729
|
-
async fetchOnce(
|
|
743
|
+
async fetchOnce(request, lane) {
|
|
730
744
|
const controller = new AbortController();
|
|
731
745
|
this.activeControllers.set(controller, lane);
|
|
732
746
|
try {
|
|
733
|
-
return await this.fetchOnceWith(
|
|
747
|
+
return await this.fetchOnceWith(request, controller);
|
|
734
748
|
}
|
|
735
749
|
finally {
|
|
736
750
|
this.activeControllers.delete(controller);
|
|
737
751
|
}
|
|
738
752
|
}
|
|
739
|
-
async fetchOnceWith(
|
|
753
|
+
async fetchOnceWith(request, controller) {
|
|
740
754
|
const timeoutId = setTimeout(() => {
|
|
741
755
|
this.runtime.observability.breadcrumb('Bootstrap fetch timeout', 'sync.bootstrap', 'warning', {
|
|
742
756
|
timeoutMs: this.options.fetchTimeout,
|
|
@@ -745,8 +759,9 @@ export class BootstrapFetcher {
|
|
|
745
759
|
}, this.options.fetchTimeout);
|
|
746
760
|
let response;
|
|
747
761
|
try {
|
|
748
|
-
response = await fetch(url, {
|
|
749
|
-
method:
|
|
762
|
+
response = await fetch(request.url, {
|
|
763
|
+
method: request.method,
|
|
764
|
+
body: request.body,
|
|
750
765
|
headers: withAuthHeaders(this.options.getAuthToken, {
|
|
751
766
|
'Content-Type': 'application/json',
|
|
752
767
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
@@ -66,6 +66,7 @@ export interface OnDemandLoaderOptions {
|
|
|
66
66
|
* against before a snapshot may overwrite it (see {@link RowWatermarks}).
|
|
67
67
|
*/
|
|
68
68
|
readonly position: Pick<LogPositionPort, 'readFloor'>;
|
|
69
|
+
readonly isDeletePending?: (id: string) => boolean;
|
|
69
70
|
}
|
|
70
71
|
export interface FetchOptions<T> {
|
|
71
72
|
/**
|
|
@@ -99,6 +100,8 @@ export interface FetchOptions<T> {
|
|
|
99
100
|
}
|
|
100
101
|
export declare class OnDemandLoader {
|
|
101
102
|
private readonly opts;
|
|
103
|
+
private readonly activeReadDeletes;
|
|
104
|
+
markDeleted(id: string): void;
|
|
102
105
|
private readonly readEvidence;
|
|
103
106
|
private readonly inFlight;
|
|
104
107
|
/**
|
|
@@ -47,6 +47,11 @@ function snapshotPosition(raw, evidenceById, readFloorAtIssue) {
|
|
|
47
47
|
}
|
|
48
48
|
export class OnDemandLoader {
|
|
49
49
|
opts;
|
|
50
|
+
activeReadDeletes = new Set();
|
|
51
|
+
markDeleted(id) {
|
|
52
|
+
for (const deleted of this.activeReadDeletes)
|
|
53
|
+
deleted.add(id);
|
|
54
|
+
}
|
|
50
55
|
readEvidence = new WeakMap();
|
|
51
56
|
inFlight = new Map();
|
|
52
57
|
/**
|
|
@@ -166,8 +171,9 @@ export class OnDemandLoader {
|
|
|
166
171
|
// through to the blocking fetch that brings parent and children together;
|
|
167
172
|
// the second open is served by the fast path.
|
|
168
173
|
if (!explicitComplete && !hasExpand) {
|
|
169
|
-
|
|
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
|
}
|
|
@@ -62,6 +62,7 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
|
|
|
62
62
|
* the state itself lives in {@link SyncCursor}.
|
|
63
63
|
*/
|
|
64
64
|
private readonly cursor;
|
|
65
|
+
private catchUp;
|
|
65
66
|
constructor(options: SyncWebSocketOptions);
|
|
66
67
|
/** The persisted resume position, sent on the upgrade URL. */
|
|
67
68
|
protected resumeCursor(): string;
|
|
@@ -121,6 +122,12 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
|
|
|
121
122
|
* Public wrapper for sending ack from outside the class
|
|
122
123
|
*/
|
|
123
124
|
acknowledge(syncId: number): void;
|
|
125
|
+
/** Publish completion only after the store's chunk-persistence lane drains. */
|
|
126
|
+
completeCatchUp(payload: {
|
|
127
|
+
exchangeId: string;
|
|
128
|
+
currentSyncId: number;
|
|
129
|
+
chunks: number;
|
|
130
|
+
}): void;
|
|
124
131
|
/**
|
|
125
132
|
* Stop the periodic catchup interval
|
|
126
133
|
*/
|
|
@@ -160,6 +167,9 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
|
|
|
160
167
|
* of the batch).
|
|
161
168
|
*/
|
|
162
169
|
protected handleSyncResponse(rawPayload: unknown): void;
|
|
170
|
+
protected handleCatchUpBegin(rawPayload: unknown): void;
|
|
171
|
+
protected handleCatchUpChunk(rawPayload: unknown): void;
|
|
172
|
+
protected handleCatchUpEnd(rawPayload: unknown): void;
|
|
163
173
|
/**
|
|
164
174
|
* Handle bootstrap response from server
|
|
165
175
|
*/
|
|
@@ -38,6 +38,7 @@ export class SyncWebSocket extends WsTransport {
|
|
|
38
38
|
* the state itself lives in {@link SyncCursor}.
|
|
39
39
|
*/
|
|
40
40
|
cursor;
|
|
41
|
+
catchUp = null;
|
|
41
42
|
constructor(options) {
|
|
42
43
|
super({
|
|
43
44
|
...options,
|
|
@@ -221,6 +222,10 @@ export class SyncWebSocket extends WsTransport {
|
|
|
221
222
|
acknowledge(syncId) {
|
|
222
223
|
this.sendAck(syncId);
|
|
223
224
|
}
|
|
225
|
+
/** Publish completion only after the store's chunk-persistence lane drains. */
|
|
226
|
+
completeCatchUp(payload) {
|
|
227
|
+
this.emit('catchup_complete', payload);
|
|
228
|
+
}
|
|
224
229
|
/**
|
|
225
230
|
* Stop the periodic catchup interval
|
|
226
231
|
*/
|
|
@@ -415,6 +420,44 @@ export class SyncWebSocket extends WsTransport {
|
|
|
415
420
|
this.cursor.syncCursor = payload.cursor;
|
|
416
421
|
}
|
|
417
422
|
}
|
|
423
|
+
handleCatchUpBegin(rawPayload) {
|
|
424
|
+
if (!isRecord(rawPayload))
|
|
425
|
+
return;
|
|
426
|
+
const { exchangeId, fromSyncId, currentSyncId } = rawPayload;
|
|
427
|
+
if (typeof exchangeId !== 'string' || !Number.isSafeInteger(fromSyncId) || !Number.isSafeInteger(currentSyncId))
|
|
428
|
+
return;
|
|
429
|
+
this.catchUp = { exchangeId, currentSyncId: currentSyncId, nextSequence: 0 };
|
|
430
|
+
this.emit('catchup_begin', { exchangeId, fromSyncId: fromSyncId, currentSyncId: currentSyncId });
|
|
431
|
+
}
|
|
432
|
+
handleCatchUpChunk(rawPayload) {
|
|
433
|
+
if (!isRecord(rawPayload) || !this.catchUp)
|
|
434
|
+
return;
|
|
435
|
+
const { exchangeId, sequence, position, deltas } = rawPayload;
|
|
436
|
+
if (exchangeId !== this.catchUp.exchangeId || sequence !== this.catchUp.nextSequence
|
|
437
|
+
|| !Number.isSafeInteger(position) || position > this.catchUp.currentSyncId
|
|
438
|
+
|| !Array.isArray(deltas))
|
|
439
|
+
return;
|
|
440
|
+
const normalized = [];
|
|
441
|
+
for (const raw of deltas) {
|
|
442
|
+
const delta = this.normalizeWireDelta(raw);
|
|
443
|
+
if (delta)
|
|
444
|
+
normalized.push(delta);
|
|
445
|
+
}
|
|
446
|
+
if (normalized.length !== deltas.length)
|
|
447
|
+
return;
|
|
448
|
+
this.catchUp.nextSequence++;
|
|
449
|
+
this.emit('catchup_chunk', { exchangeId, sequence, position, deltas: normalized });
|
|
450
|
+
}
|
|
451
|
+
handleCatchUpEnd(rawPayload) {
|
|
452
|
+
if (!isRecord(rawPayload) || !this.catchUp)
|
|
453
|
+
return;
|
|
454
|
+
const { exchangeId, currentSyncId, chunks } = rawPayload;
|
|
455
|
+
if (exchangeId !== this.catchUp.exchangeId || currentSyncId !== this.catchUp.currentSyncId
|
|
456
|
+
|| chunks !== this.catchUp.nextSequence)
|
|
457
|
+
return;
|
|
458
|
+
this.catchUp = null;
|
|
459
|
+
this.emit('catchup_end', { exchangeId, currentSyncId, chunks });
|
|
460
|
+
}
|
|
418
461
|
/**
|
|
419
462
|
* Handle bootstrap response from server
|
|
420
463
|
*/
|
|
@@ -118,8 +118,10 @@ export declare function enqueueDelta(ctx: DeltaPipelineContext, delta: SyncDelta
|
|
|
118
118
|
}): boolean;
|
|
119
119
|
/** Debounce a flush for live single-delta traffic. */
|
|
120
120
|
export declare function scheduleDeltaFlush(ctx: DeltaPipelineContext): void;
|
|
121
|
-
/** Apply an authoritative
|
|
121
|
+
/** Apply an authoritative legacy frame without exposing its persistence promise. */
|
|
122
122
|
export declare function applyDeltaFrame(ctx: DeltaPipelineContext, deltas: SyncDelta[]): void;
|
|
123
|
+
/** Persist one resumable chunk before its raw tail position may advance. */
|
|
124
|
+
export declare function persistDeltaFrame(ctx: DeltaPipelineContext, deltas: SyncDelta[]): Promise<void>;
|
|
123
125
|
/**
|
|
124
126
|
* Flushes the queued deltas: deduplicates them, applies custom-entity deltas
|
|
125
127
|
* straight to the pool, writes the rest to the local store and then the pool,
|
|
@@ -164,8 +164,12 @@ export function scheduleDeltaFlush(ctx) {
|
|
|
164
164
|
}, ctx.smartSyncOptions.batchingDelay);
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
|
-
/** Apply an authoritative
|
|
167
|
+
/** Apply an authoritative legacy frame without exposing its persistence promise. */
|
|
168
168
|
export function applyDeltaFrame(ctx, deltas) {
|
|
169
|
+
void persistDeltaFrame(ctx, deltas).catch(ctx.handleFlushError);
|
|
170
|
+
}
|
|
171
|
+
/** Persist one resumable chunk before its raw tail position may advance. */
|
|
172
|
+
export async function persistDeltaFrame(ctx, deltas) {
|
|
169
173
|
let enqueuedAny = false;
|
|
170
174
|
for (const delta of deltas) {
|
|
171
175
|
if (enqueueDelta(ctx, delta, { authoritative: true }))
|
|
@@ -177,7 +181,7 @@ export function applyDeltaFrame(ctx, deltas) {
|
|
|
177
181
|
clearTimeout(ctx.batchTimer);
|
|
178
182
|
ctx.batchTimer = null;
|
|
179
183
|
}
|
|
180
|
-
|
|
184
|
+
await ctx.flushPendingDeltas();
|
|
181
185
|
}
|
|
182
186
|
/**
|
|
183
187
|
* Flushes the queued deltas: deduplicates them, applies custom-entity deltas
|
|
@@ -20,7 +20,7 @@ export interface SocketEventHost<TCollaboration extends EventMap<TCollaboration>
|
|
|
20
20
|
onConnectionEvent?: (event: string) => void;
|
|
21
21
|
updateSyncStatus(updates: Partial<SyncStatus>): void;
|
|
22
22
|
processDeltaWithBatching(delta: SyncDelta): void;
|
|
23
|
-
applyDeltaFrame(deltas: SyncDelta[]): void
|
|
23
|
+
applyDeltaFrame(deltas: SyncDelta[]): Promise<void>;
|
|
24
24
|
handleBootstrapRequired(hint: BootstrapHint): void;
|
|
25
25
|
handleBootstrapData(data: BootstrapDataEvent): void;
|
|
26
26
|
performCredentialRefresh(): Promise<'refreshed' | 'session_error' | 'network_error'>;
|