@pylonsync/sync 0.22.26 → 0.22.27
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/index.d.ts +12 -36
- package/dist/local-store.d.ts +18 -3
- package/dist/mutation-queue.d.ts +6 -2
- package/dist/persistence.d.ts +3 -4
- package/dist/server-subscriptions.d.ts +2 -0
- package/dist/subscription-coordinator.d.ts +1 -7
- package/package.json +1 -1
- package/src/bootstrap-speedup.test.ts +50 -51
- package/src/client-performance.test.ts +366 -0
- package/src/crdt-follower-catchup.test.ts +56 -25
- package/src/index.ts +146 -132
- package/src/local-store.ts +112 -47
- package/src/mutation-owner-token.test.ts +6 -1
- package/src/mutation-owner.test.ts +6 -1
- package/src/mutation-queue.ts +58 -35
- package/src/persistence.ts +16 -4
- package/src/push-scheduling.test.ts +171 -0
- package/src/reconcile.test.ts +19 -0
- package/src/round6-codex.test.ts +1 -4
- package/src/scenarios.test.ts +6 -8
- package/src/server-subscriptions.ts +6 -0
- package/src/subscription-coordinator.test.ts +5 -2
- package/src/subscription-coordinator.ts +12 -14
package/src/index.ts
CHANGED
|
@@ -491,19 +491,6 @@ export class SyncEngine {
|
|
|
491
491
|
*/
|
|
492
492
|
private binaryHandlers: Set<(bytes: Uint8Array) => void> = new Set();
|
|
493
493
|
|
|
494
|
-
/**
|
|
495
|
-
* Latest server CRDT SNAPSHOT frame per `entity|rowId`, kept so a
|
|
496
|
-
* follower tab that registers interest in an ALREADY-subscribed row
|
|
497
|
-
* gets state immediately. The server sends its catch-up snapshot only
|
|
498
|
-
* when a fresh `crdt-subscribe` goes over the WS — when the leader
|
|
499
|
-
* (or another follower) already holds the subscription, no wire
|
|
500
|
-
* traffic happens and, pre-cache, the new tab's LoroDoc stayed empty
|
|
501
|
-
* until the next live edit. Server broadcasts are always FULL merged
|
|
502
|
-
* snapshots (CRDT_FRAME_SNAPSHOT), so one frame per row is complete
|
|
503
|
-
* state. Bounded FIFO — see LAST_CRDT_FRAMES_MAX.
|
|
504
|
-
*/
|
|
505
|
-
private lastCrdtFrames: Map<string, Uint8Array> = new Map();
|
|
506
|
-
|
|
507
494
|
/**
|
|
508
495
|
* Server-side ephemeral subscriptions (CRDT row subs, reactive query
|
|
509
496
|
* subs, future kinds). Owns the WS replay bookkeeping — each kind
|
|
@@ -631,14 +618,6 @@ export class SyncEngine {
|
|
|
631
618
|
this.subscriptions = new SubscriptionCoordinator(this.serverSubs, {
|
|
632
619
|
isLeader: () => this.isMultiTabLeader,
|
|
633
620
|
broadcastToTabs: (payload) => this.broadcastToTabs(payload),
|
|
634
|
-
// Follower registered interest in a row whose WS subscription is
|
|
635
|
-
// already alive — no server catch-up will come, so replay the
|
|
636
|
-
// cached snapshot over the tab channel. Loro imports are
|
|
637
|
-
// idempotent, so tabs that already have the state are unaffected.
|
|
638
|
-
replayCrdtFrame: (entity, rowId) => {
|
|
639
|
-
const cached = this.lastCrdtFrames.get(`${entity}|${rowId}`);
|
|
640
|
-
if (cached) this.broadcastToTabs({ type: "binary", bytes: cached });
|
|
641
|
-
},
|
|
642
621
|
});
|
|
643
622
|
this.rooms = new RoomSubscriptions((msg) => {
|
|
644
623
|
// Leader: send over the WS. Followers don't open a transport;
|
|
@@ -1098,10 +1077,10 @@ export class SyncEngine {
|
|
|
1098
1077
|
// restoring it is correct on both tabs.
|
|
1099
1078
|
this.mutations.add(op.change, op.prevRow, op.owner, op.ownerPending);
|
|
1100
1079
|
}
|
|
1101
|
-
void this.
|
|
1080
|
+
void this.pushNew(ops.map((op) => op.id));
|
|
1102
1081
|
},
|
|
1103
1082
|
onMutationsAcked: (opIds: string[]) => {
|
|
1104
|
-
|
|
1083
|
+
this.mutations.markAppliedMany(opIds);
|
|
1105
1084
|
this.mutations.clear();
|
|
1106
1085
|
},
|
|
1107
1086
|
onMutationsQueued: (opIds: string[]) => {
|
|
@@ -1624,13 +1603,33 @@ export class SyncEngine {
|
|
|
1624
1603
|
tombstoneSeq: number,
|
|
1625
1604
|
opts: { fromBroadcast?: boolean } = {},
|
|
1626
1605
|
): Promise<void> {
|
|
1606
|
+
const epoch = this.replicaEpoch;
|
|
1627
1607
|
return this.chainApply(async () => {
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
upserts,
|
|
1631
|
-
removalIds,
|
|
1632
|
-
tombstoneSeq,
|
|
1608
|
+
if (epoch !== this.replicaEpoch) return;
|
|
1609
|
+
const changes = this.store.applyReconcileInMemory(
|
|
1610
|
+
entity, upserts, removalIds, tombstoneSeq,
|
|
1633
1611
|
);
|
|
1612
|
+
if (epoch !== this.replicaEpoch) return;
|
|
1613
|
+
if (changes.length > 0 && this.store._persistFn) {
|
|
1614
|
+
try {
|
|
1615
|
+
if (this.persistence?.saveBatch) {
|
|
1616
|
+
const durable = await this.persistence.saveBatch(changes, null);
|
|
1617
|
+
if (epoch !== this.replicaEpoch) return;
|
|
1618
|
+
if (!durable) this.persistDegraded = true;
|
|
1619
|
+
} else {
|
|
1620
|
+
for (const change of changes) {
|
|
1621
|
+
if (epoch !== this.replicaEpoch) break;
|
|
1622
|
+
const durable = await this.store._persistFn(change);
|
|
1623
|
+
if (epoch !== this.replicaEpoch) return;
|
|
1624
|
+
if (durable === false) this.persistDegraded = true;
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
} catch {
|
|
1628
|
+
if (epoch !== this.replicaEpoch) return;
|
|
1629
|
+
this.persistDegraded = true;
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
if (epoch !== this.replicaEpoch) return;
|
|
1634
1633
|
// Leader fans the reconcile batch out so each follower can
|
|
1635
1634
|
// converge without its own fetch. Suppress when we ourselves
|
|
1636
1635
|
// received this batch via the channel (promotion mid-flight
|
|
@@ -1656,6 +1655,7 @@ export class SyncEngine {
|
|
|
1656
1655
|
}
|
|
1657
1656
|
for (const timer of this.retryTimers) clearTimeout(timer);
|
|
1658
1657
|
this.retryTimers.clear();
|
|
1658
|
+
this.pushRetryTimer = null;
|
|
1659
1659
|
if (this.transport) {
|
|
1660
1660
|
this.transport.stop();
|
|
1661
1661
|
this.transport = null;
|
|
@@ -1797,6 +1797,9 @@ export class SyncEngine {
|
|
|
1797
1797
|
// re-latches the flag.)
|
|
1798
1798
|
this.persistDegraded = false;
|
|
1799
1799
|
if (wipeMutations) {
|
|
1800
|
+
this.cancelPushRetry();
|
|
1801
|
+
this.pushFailureCount = 0;
|
|
1802
|
+
this.newPushIds.clear();
|
|
1800
1803
|
// Identity flip: discard the outgoing identity's pending offline
|
|
1801
1804
|
// writes (and persist the empty queue to disk via the mutation
|
|
1802
1805
|
// backend). persistence.clear() deliberately leaves MUTATIONS_STORE
|
|
@@ -2400,6 +2403,7 @@ export class SyncEngine {
|
|
|
2400
2403
|
// under the new context.
|
|
2401
2404
|
const cursorBeforeFetch = this.cursor.last_seq;
|
|
2402
2405
|
const sessionBeforeFetch = this.session.signature();
|
|
2406
|
+
const epochBeforeFetch = this.replicaEpoch;
|
|
2403
2407
|
let serverRows: Row[];
|
|
2404
2408
|
let fetchTruncated: boolean;
|
|
2405
2409
|
try {
|
|
@@ -2410,6 +2414,9 @@ export class SyncEngine {
|
|
|
2410
2414
|
// Network errors are expected (offline, transient 5xx). Skip
|
|
2411
2415
|
// this entity; the next reconcile trigger will retry.
|
|
2412
2416
|
const status = (err as { status?: number })?.status;
|
|
2417
|
+
if (this.replicaEpoch !== epochBeforeFetch || this.session.signature() !== sessionBeforeFetch) {
|
|
2418
|
+
return;
|
|
2419
|
+
}
|
|
2413
2420
|
if (status === 404) {
|
|
2414
2421
|
// Entity removed from the manifest — definitive. Drop its rows.
|
|
2415
2422
|
this.reconcile403Streak.delete(entity);
|
|
@@ -2576,23 +2583,13 @@ export class SyncEngine {
|
|
|
2576
2583
|
entity: string,
|
|
2577
2584
|
tombstoneSeq: number,
|
|
2578
2585
|
): Promise<void> {
|
|
2579
|
-
const
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
removed = true;
|
|
2586
|
-
if (this.persistence) {
|
|
2587
|
-
try {
|
|
2588
|
-
await this.persistence.deleteRow(entity, id);
|
|
2589
|
-
} catch {
|
|
2590
|
-
/* best-effort */
|
|
2591
|
-
}
|
|
2592
|
-
}
|
|
2593
|
-
}
|
|
2586
|
+
const removalIds: string[] = [];
|
|
2587
|
+
for (const row of this.store.list(entity)) {
|
|
2588
|
+
if (typeof row.id === "string") removalIds.push(row.id);
|
|
2589
|
+
}
|
|
2590
|
+
if (removalIds.length > 0) {
|
|
2591
|
+
await this.enqueueReconcile(entity, [], removalIds, tombstoneSeq);
|
|
2594
2592
|
}
|
|
2595
|
-
if (removed) this.store.notify();
|
|
2596
2593
|
}
|
|
2597
2594
|
|
|
2598
2595
|
/**
|
|
@@ -2990,7 +2987,6 @@ export class SyncEngine {
|
|
|
2990
2987
|
}
|
|
2991
2988
|
this.store.notify();
|
|
2992
2989
|
}
|
|
2993
|
-
this.evictCrdtFrame(entity, rowId);
|
|
2994
2990
|
for (const listener of this.rowEvictionListeners) {
|
|
2995
2991
|
listener(entity, rowId);
|
|
2996
2992
|
}
|
|
@@ -3134,37 +3130,54 @@ export class SyncEngine {
|
|
|
3134
3130
|
return parsed;
|
|
3135
3131
|
}
|
|
3136
3132
|
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
*
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
// promise, but that push had snapshotted its batch before this
|
|
3148
|
-
// caller's mutation was queued. Send any mutation no push has
|
|
3149
|
-
// attempted yet. Mutations that failed transiently were attempted
|
|
3150
|
-
// and wait for their backoff retry instead.
|
|
3151
|
-
if (this.mutations.pending().some((m) => !this.attemptedOps.has(m.id))) {
|
|
3152
|
-
await this.push();
|
|
3153
|
-
}
|
|
3133
|
+
private inFlightPush: Promise<void> | null = null;
|
|
3134
|
+
private newPushIds = new Set<string>();
|
|
3135
|
+
private pushReplayRequested = false;
|
|
3136
|
+
private pushRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
3137
|
+
|
|
3138
|
+
/** Explicit recovery retries all pending writes. Concurrent callers share
|
|
3139
|
+
* the complete drain, including writes added during the request. */
|
|
3140
|
+
push(): Promise<void> {
|
|
3141
|
+
this.pushReplayRequested = true;
|
|
3142
|
+
return this.inFlightPush ?? this.drainPush();
|
|
3154
3143
|
}
|
|
3155
3144
|
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3145
|
+
private pushNew(ids: string[]): Promise<void> {
|
|
3146
|
+
for (const id of ids) this.newPushIds.add(id);
|
|
3147
|
+
return this.inFlightPush ?? this.drainPush();
|
|
3148
|
+
}
|
|
3149
|
+
|
|
3150
|
+
private drainPush(): Promise<void> {
|
|
3151
|
+
this.inFlightPush = (async () => {
|
|
3152
|
+
try {
|
|
3153
|
+
do {
|
|
3154
|
+
await this.opQueue.enqueue("push", async () => {
|
|
3155
|
+
const replay = this.pushReplayRequested;
|
|
3156
|
+
this.pushReplayRequested = false;
|
|
3157
|
+
const ids = this.newPushIds;
|
|
3158
|
+
this.newPushIds = new Set();
|
|
3159
|
+
if (replay) this.cancelPushRetry();
|
|
3160
|
+
await this.pushInner(replay ? null : ids);
|
|
3161
|
+
});
|
|
3162
|
+
} while (this.pushReplayRequested || this.newPushIds.size > 0);
|
|
3163
|
+
} finally {
|
|
3164
|
+
this.inFlightPush = null;
|
|
3165
|
+
}
|
|
3166
|
+
})();
|
|
3167
|
+
return this.inFlightPush;
|
|
3168
|
+
}
|
|
3159
3169
|
|
|
3160
|
-
private async pushInner(): Promise<void> {
|
|
3161
|
-
|
|
3162
|
-
//
|
|
3163
|
-
const
|
|
3164
|
-
|
|
3165
|
-
|
|
3170
|
+
private async pushInner(newIds: Set<string> | null): Promise<void> {
|
|
3171
|
+
// Followers forward new writes only. During backoff, new writes settle
|
|
3172
|
+
// locally without copying or sending the existing queue again.
|
|
3173
|
+
const delta = newIds !== null && (!this.isMultiTabLeader || this.pushRetryTimer !== null);
|
|
3174
|
+
const queued: PendingMutation[] = delta ? [] : this.mutations.pending();
|
|
3175
|
+
if (delta) {
|
|
3176
|
+
for (const id of newIds) {
|
|
3177
|
+
const mutation = this.mutations.get(id);
|
|
3178
|
+
if (mutation?.status === "pending") queued.push(mutation);
|
|
3179
|
+
}
|
|
3166
3180
|
}
|
|
3167
|
-
for (const id of pendingIds) this.attemptedOps.add(id);
|
|
3168
3181
|
if (queued.length === 0) return;
|
|
3169
3182
|
|
|
3170
3183
|
// Multi-tab follower: we don't own the network. Forward the
|
|
@@ -3208,6 +3221,12 @@ export class SyncEngine {
|
|
|
3208
3221
|
}
|
|
3209
3222
|
if (pending.length === 0) return;
|
|
3210
3223
|
|
|
3224
|
+
if (this.pushRetryTimer !== null) {
|
|
3225
|
+
for (const mutation of pending) this.mutations.settleQueued(mutation.id);
|
|
3226
|
+
this.broadcastToTabs({ type: "mutations-queued", opIds: pending.map((mutation) => mutation.id) });
|
|
3227
|
+
return;
|
|
3228
|
+
}
|
|
3229
|
+
|
|
3211
3230
|
try {
|
|
3212
3231
|
// Sent with the token the owner check ran against.
|
|
3213
3232
|
const resp = await this.request<PushResponse>(
|
|
@@ -3228,6 +3247,7 @@ export class SyncEngine {
|
|
|
3228
3247
|
// back to positional. Invariant: a partial-failure batch lands
|
|
3229
3248
|
// the correct status on each mutation by id, never by position.
|
|
3230
3249
|
// Test: `push_partial_failure_maps_results_by_op_id`.
|
|
3250
|
+
const appliedIds: string[] = [];
|
|
3231
3251
|
let maxAppliedSeq = 0;
|
|
3232
3252
|
let hasInFlightDedupe = false;
|
|
3233
3253
|
if (Array.isArray(resp.results)) {
|
|
@@ -3252,7 +3272,7 @@ export class SyncEngine {
|
|
|
3252
3272
|
// from the client's perspective.
|
|
3253
3273
|
// deduped: legacy server response — treat as replayed.
|
|
3254
3274
|
if (r.status === "applied" || r.status === "replayed" || r.status === "deduped") {
|
|
3255
|
-
|
|
3275
|
+
appliedIds.push(m.id);
|
|
3256
3276
|
if (typeof r.seq === "number" && r.seq > maxAppliedSeq) {
|
|
3257
3277
|
maxAppliedSeq = r.seq;
|
|
3258
3278
|
}
|
|
@@ -3286,7 +3306,7 @@ export class SyncEngine {
|
|
|
3286
3306
|
const errors = Array.isArray(resp.errors) ? resp.errors : [];
|
|
3287
3307
|
for (let i = 0; i < pending.length; i++) {
|
|
3288
3308
|
if (i < applied) {
|
|
3289
|
-
|
|
3309
|
+
appliedIds.push(pending[i].id);
|
|
3290
3310
|
} else if (errors[i - applied]) {
|
|
3291
3311
|
this.failPushedMutation(pending[i], errors[i - applied]);
|
|
3292
3312
|
} else {
|
|
@@ -3295,6 +3315,8 @@ export class SyncEngine {
|
|
|
3295
3315
|
}
|
|
3296
3316
|
}
|
|
3297
3317
|
|
|
3318
|
+
this.mutations.markAppliedMany(appliedIds);
|
|
3319
|
+
|
|
3298
3320
|
// Broadcast per-op outcomes BEFORE clearing locally so followers
|
|
3299
3321
|
// can update their queue status. Filter strictly by current
|
|
3300
3322
|
// status — pending[i] is the LIVE PendingMutation that markApplied
|
|
@@ -3342,9 +3364,7 @@ export class SyncEngine {
|
|
|
3342
3364
|
// takes the Proceed slot). 250ms is short enough that user
|
|
3343
3365
|
// perception doesn't notice, long enough to not hot-loop.
|
|
3344
3366
|
if (hasInFlightDedupe) {
|
|
3345
|
-
this.
|
|
3346
|
-
void this.push();
|
|
3347
|
-
});
|
|
3367
|
+
this.schedulePushRetry(250);
|
|
3348
3368
|
}
|
|
3349
3369
|
} catch (err) {
|
|
3350
3370
|
// Whole-request failure. CRITICAL distinction:
|
|
@@ -3403,13 +3423,29 @@ export class SyncEngine {
|
|
|
3403
3423
|
console.warn(
|
|
3404
3424
|
`[sync] /api/sync/push transient failure (status ${status ?? "offline"}); keeping ${pending.length} mutation(s) pending, retrying in ${delayMs}ms`,
|
|
3405
3425
|
);
|
|
3406
|
-
this.
|
|
3407
|
-
void this.push();
|
|
3408
|
-
});
|
|
3426
|
+
this.schedulePushRetry(delayMs);
|
|
3409
3427
|
}
|
|
3410
3428
|
}
|
|
3411
3429
|
}
|
|
3412
3430
|
|
|
3431
|
+
private cancelPushRetry(): void {
|
|
3432
|
+
if (this.pushRetryTimer === null) return;
|
|
3433
|
+
clearTimeout(this.pushRetryTimer);
|
|
3434
|
+
this.retryTimers.delete(this.pushRetryTimer);
|
|
3435
|
+
this.pushRetryTimer = null;
|
|
3436
|
+
}
|
|
3437
|
+
|
|
3438
|
+
private schedulePushRetry(ms: number): void {
|
|
3439
|
+
this.cancelPushRetry();
|
|
3440
|
+
const timer = setTimeout(() => {
|
|
3441
|
+
this.retryTimers.delete(timer);
|
|
3442
|
+
this.pushRetryTimer = null;
|
|
3443
|
+
if (this.running) void this.push();
|
|
3444
|
+
}, ms);
|
|
3445
|
+
this.pushRetryTimer = timer;
|
|
3446
|
+
this.retryTimers.add(timer);
|
|
3447
|
+
}
|
|
3448
|
+
|
|
3413
3449
|
/** Run `fn` after `ms` unless the engine stops first. A retry from a
|
|
3414
3450
|
* stopped engine would otherwise still reach the network. */
|
|
3415
3451
|
private later(ms: number, fn: () => void): void {
|
|
@@ -3504,7 +3540,7 @@ export class SyncEngine {
|
|
|
3504
3540
|
const outcome = this.mutations.waitForOutcome(opId);
|
|
3505
3541
|
// Mark the promise handled: push() can settle it before we await it.
|
|
3506
3542
|
outcome.catch(() => {});
|
|
3507
|
-
await this.
|
|
3543
|
+
await this.pushNew([opId]);
|
|
3508
3544
|
if (!this.isMultiTabLeader) {
|
|
3509
3545
|
// A follower forwards the write and waits for the leader's verdict.
|
|
3510
3546
|
// A frozen or dying leader must not hang the caller: after the
|
|
@@ -4011,7 +4047,8 @@ export class SyncEngine {
|
|
|
4011
4047
|
},
|
|
4012
4048
|
setStatus: (s) => this.setConnectionStatus(s),
|
|
4013
4049
|
performPollTick: async () => {
|
|
4014
|
-
|
|
4050
|
+
if (this.pushRetryTimer === null) await this.push();
|
|
4051
|
+
await this.pull();
|
|
4015
4052
|
},
|
|
4016
4053
|
performReconnectPull: async () => {
|
|
4017
4054
|
// Wrapped in try so a transient pull failure doesn't kill the
|
|
@@ -4177,19 +4214,7 @@ export class SyncEngine {
|
|
|
4177
4214
|
* follower tab forwarded a CRDT sub, mirror over the multi-tab
|
|
4178
4215
|
* channel so followers see Loro updates too. */
|
|
4179
4216
|
private dispatchBinaryFrame(bytes: Uint8Array): void {
|
|
4180
|
-
|
|
4181
|
-
// lastCrdtFrames). Header-only peek; the payload stays opaque.
|
|
4182
|
-
const key = crdtFrameKey(bytes);
|
|
4183
|
-
if (key !== null) {
|
|
4184
|
-
if (!this.lastCrdtFrames.has(key) &&
|
|
4185
|
-
this.lastCrdtFrames.size >= LAST_CRDT_FRAMES_MAX) {
|
|
4186
|
-
const oldest = this.lastCrdtFrames.keys().next().value;
|
|
4187
|
-
if (oldest !== undefined) this.lastCrdtFrames.delete(oldest);
|
|
4188
|
-
}
|
|
4189
|
-
// Re-insert to refresh FIFO position.
|
|
4190
|
-
this.lastCrdtFrames.delete(key);
|
|
4191
|
-
this.lastCrdtFrames.set(key, bytes);
|
|
4192
|
-
}
|
|
4217
|
+
const route = crdtFrameRoute(bytes);
|
|
4193
4218
|
for (const handler of this.binaryHandlers) {
|
|
4194
4219
|
try {
|
|
4195
4220
|
handler(bytes);
|
|
@@ -4197,28 +4222,15 @@ export class SyncEngine {
|
|
|
4197
4222
|
console.warn("[sync] binary handler threw:", err);
|
|
4198
4223
|
}
|
|
4199
4224
|
}
|
|
4200
|
-
//
|
|
4201
|
-
//
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
//
|
|
4206
|
-
// Trade-off: when ANY follower has forwarded a CRDT sub on ANY
|
|
4207
|
-
// key, we broadcast EVERY binary frame regardless of which row
|
|
4208
|
-
// it's for. Acceptable for now; the lever to pull if it shows
|
|
4209
|
-
// up in profiling is a binaryRoutes map keyed by the Loro doc
|
|
4210
|
-
// id parsed from the frame header.
|
|
4211
|
-
if (this.subscriptions.hasCrdtForwarders()) {
|
|
4225
|
+
// Known CRDT frames need a follower subscribed to this row.
|
|
4226
|
+
// Other binary formats retain the general follower fallback.
|
|
4227
|
+
if (route
|
|
4228
|
+
? this.subscriptions.hasCrdtForwardersFor(route.entity, route.rowId)
|
|
4229
|
+
: this.subscriptions.hasCrdtForwarders()) {
|
|
4212
4230
|
this.broadcastToTabs({ type: "binary", bytes });
|
|
4213
4231
|
}
|
|
4214
4232
|
}
|
|
4215
4233
|
|
|
4216
|
-
/** Drop the cached snapshot for a revoked row so it can't be
|
|
4217
|
-
* replayed to a late-joining tab after policy said no. */
|
|
4218
|
-
private evictCrdtFrame(entity: string, rowId: string): void {
|
|
4219
|
-
this.lastCrdtFrames.delete(`${entity}|${rowId}`);
|
|
4220
|
-
}
|
|
4221
|
-
|
|
4222
4234
|
private async request<T>(
|
|
4223
4235
|
method: string,
|
|
4224
4236
|
path: string,
|
|
@@ -4355,16 +4367,7 @@ export async function getServerData(
|
|
|
4355
4367
|
return { entities: entityData, cursor };
|
|
4356
4368
|
}
|
|
4357
4369
|
|
|
4358
|
-
/**
|
|
4359
|
-
* Stable equality check for reconciler diffs. Keys are sorted so
|
|
4360
|
-
* `{a:1,b:2}` and `{b:2,a:1}` compare equal — without that, every
|
|
4361
|
-
* reconcile pass would think every row had changed (insertion order
|
|
4362
|
-
* varies by mutation path on the server). Recursive on objects only;
|
|
4363
|
-
* arrays and primitives use their natural shape.
|
|
4364
|
-
*/
|
|
4365
|
-
/** Cap on cached per-row CRDT snapshots (see lastCrdtFrames). 64 rows of
|
|
4366
|
-
* collaborative state is far beyond what one browser session edits at
|
|
4367
|
-
* once; FIFO eviction keeps a long-lived leader tab bounded. */
|
|
4370
|
+
/** @deprecated The engine no longer caches binary frames. */
|
|
4368
4371
|
export const LAST_CRDT_FRAMES_MAX = 64;
|
|
4369
4372
|
|
|
4370
4373
|
/**
|
|
@@ -4381,22 +4384,33 @@ export const LAST_CRDT_FRAMES_MAX = 64;
|
|
|
4381
4384
|
* this parser and the Swift one change with it.
|
|
4382
4385
|
*
|
|
4383
4386
|
* The engine still treats the PAYLOAD as opaque — this reads only the
|
|
4384
|
-
* routing header
|
|
4387
|
+
* routing header. A snapshot type does not prove the payload is complete.
|
|
4385
4388
|
*/
|
|
4386
4389
|
export function crdtFrameKey(bytes: Uint8Array): string | null {
|
|
4387
|
-
const
|
|
4388
|
-
|
|
4390
|
+
const route = crdtFrameRoute(bytes);
|
|
4391
|
+
return route?.snapshot ? `${route.entity}|${route.rowId}` : null;
|
|
4392
|
+
}
|
|
4393
|
+
|
|
4394
|
+
const crdtHeaderDecoder = new TextDecoder("utf-8", { fatal: true });
|
|
4395
|
+
|
|
4396
|
+
function crdtFrameRoute(bytes: Uint8Array): { entity: string; rowId: string; snapshot: boolean } | null {
|
|
4397
|
+
if (bytes.length < 5 || (bytes[0] !== 0x10 && bytes[0] !== 0x11)) return null;
|
|
4389
4398
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
4390
4399
|
const entityLen = view.getUint16(1, false);
|
|
4391
4400
|
const entityEnd = 3 + entityLen;
|
|
4392
|
-
if (entityEnd + 2 > bytes.length) return null;
|
|
4401
|
+
if (entityLen === 0 || entityEnd + 2 > bytes.length) return null;
|
|
4393
4402
|
const rowIdLen = view.getUint16(entityEnd, false);
|
|
4394
4403
|
const rowIdEnd = entityEnd + 2 + rowIdLen;
|
|
4395
|
-
if (rowIdEnd > bytes.length) return null;
|
|
4396
|
-
|
|
4397
|
-
|
|
4398
|
-
|
|
4399
|
-
|
|
4404
|
+
if (rowIdLen === 0 || rowIdEnd > bytes.length) return null;
|
|
4405
|
+
try {
|
|
4406
|
+
return {
|
|
4407
|
+
entity: crdtHeaderDecoder.decode(bytes.subarray(3, entityEnd)),
|
|
4408
|
+
rowId: crdtHeaderDecoder.decode(bytes.subarray(entityEnd + 2, rowIdEnd)),
|
|
4409
|
+
snapshot: bytes[0] === 0x10,
|
|
4410
|
+
};
|
|
4411
|
+
} catch {
|
|
4412
|
+
return null;
|
|
4413
|
+
}
|
|
4400
4414
|
}
|
|
4401
4415
|
|
|
4402
4416
|
function rowsDiffer(a: Row, b: Row): boolean {
|