@pylonsync/sync 0.22.40 → 0.22.43
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 +30 -1
- package/dist/room-subscriptions.d.ts +12 -1
- package/package.json +1 -1
- package/src/index.ts +117 -11
- package/src/reconcile.test.ts +145 -0
- package/src/room-subscriptions.test.ts +12 -0
- package/src/room-subscriptions.ts +31 -6
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { LocalStore } from "./local-store";
|
|
|
2
2
|
import { MutationQueue } from "./mutation-queue";
|
|
3
3
|
import { type RoomError, type RoomMember, type RoomSubscriber } from "./room-subscriptions";
|
|
4
4
|
import { SessionResolver } from "./session-resolver";
|
|
5
|
-
export { RoomSubscriptions, type RoomError, type RoomErrorCode, type RoomMember, type RoomMessage, type RoomMessageSubscriber, type RoomSubscriber, } from "./room-subscriptions";
|
|
5
|
+
export { RoomSubscriptions, rowRoom, type RoomError, type RoomErrorCode, type RoomMember, type RoomMessage, type RoomMessageSubscriber, type RoomSubscriber, } from "./room-subscriptions";
|
|
6
6
|
export { IndexedDBPersistence, persistChange } from "./persistence";
|
|
7
7
|
export type { ReplicaPersistence } from "./persistence";
|
|
8
8
|
export { BEARER_REJECTED_HEADER, bearerRejected, buildRequest, clearRejectedToken, pylonFetch, pylonFetchRaw, PylonHttpError, resolveBaseUrl, } from "./transport";
|
|
@@ -733,6 +733,35 @@ export declare class SyncEngine {
|
|
|
733
733
|
*/
|
|
734
734
|
observeEntity(entity: string): void;
|
|
735
735
|
reconcile(entities?: string[]): Promise<void>;
|
|
736
|
+
/** Entities a server hint (`sync-reconcile`) asked to reconcile that
|
|
737
|
+
* have not reconciled yet. */
|
|
738
|
+
private hintPending;
|
|
739
|
+
private hintTimer;
|
|
740
|
+
private hintRetries;
|
|
741
|
+
private lastHintReconcileAt;
|
|
742
|
+
/** Hints that arrive this close together share one reconcile. */
|
|
743
|
+
private static readonly HINT_DEBOUNCE_MS;
|
|
744
|
+
/** At most one hinted reconcile per this interval. */
|
|
745
|
+
private static readonly HINT_MIN_INTERVAL_MS;
|
|
746
|
+
/** Backoff for entities whose fetch raced a newer change: doubling from
|
|
747
|
+
* the base, capped. */
|
|
748
|
+
private static readonly HINT_RETRY_BASE_MS;
|
|
749
|
+
private static readonly HINT_RETRY_MAX_MS;
|
|
750
|
+
/**
|
|
751
|
+
* The server said a change elsewhere (a share, an unshare, a folder
|
|
752
|
+
* membership) changed which rows of `entities` this user may read
|
|
753
|
+
* (`sync-reconcile`). No other event will tell this replica, so the
|
|
754
|
+
* entities stay pending until a reconcile of them completes: hints are
|
|
755
|
+
* merged, run at most once a second, and an entity whose fetch raced a
|
|
756
|
+
* newer change is retried with backoff until it lands or the engine
|
|
757
|
+
* stops.
|
|
758
|
+
*/
|
|
759
|
+
private queueHintReconcile;
|
|
760
|
+
private scheduleHintReconcile;
|
|
761
|
+
private flushHintReconcile;
|
|
762
|
+
/** `reconcile`, resolving with the entities skipped because the cursor
|
|
763
|
+
* moved during their fetch. */
|
|
764
|
+
private reconcileReportingSkips;
|
|
736
765
|
/** Scoped reconcile batch that has not started running yet. */
|
|
737
766
|
private scopedReconcileBatch;
|
|
738
767
|
private scopedReconcileSeq;
|
|
@@ -4,7 +4,18 @@ export interface RoomMember {
|
|
|
4
4
|
data?: Record<string, unknown>;
|
|
5
5
|
}
|
|
6
6
|
/** Reason codes the SDK exposes on a room subscription error. */
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* - `NOT_IN_ROOM`: subscribed before joining (rejoin and resubscribe).
|
|
9
|
+
* - `ROOM_FORBIDDEN`: a room tied to a row the caller may not read.
|
|
10
|
+
* - `ROOM_REVOKED`: the caller lost access to that row while in the room.
|
|
11
|
+
*/
|
|
12
|
+
export type RoomErrorCode = "NOT_IN_ROOM" | "ROOM_FORBIDDEN" | "ROOM_REVOKED" | "UNKNOWN";
|
|
13
|
+
/**
|
|
14
|
+
* The room tied to a row: its members are the people who may read the row,
|
|
15
|
+
* and the server checks that on every room operation. `@` starts only these
|
|
16
|
+
* names.
|
|
17
|
+
*/
|
|
18
|
+
export declare function rowRoom(entity: string, id: string): string;
|
|
8
19
|
export interface RoomError {
|
|
9
20
|
code: RoomErrorCode;
|
|
10
21
|
message?: string;
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { OpQueue } from "./op-queue";
|
|
|
22
22
|
import {
|
|
23
23
|
RoomSubscriptions,
|
|
24
24
|
type RoomError,
|
|
25
|
+
type RoomErrorCode,
|
|
25
26
|
type RoomMember,
|
|
26
27
|
type RoomSubscriber,
|
|
27
28
|
} from "./room-subscriptions";
|
|
@@ -37,6 +38,7 @@ import {
|
|
|
37
38
|
import { generateClientId, generateId } from "./ids";
|
|
38
39
|
export {
|
|
39
40
|
RoomSubscriptions,
|
|
41
|
+
rowRoom,
|
|
40
42
|
type RoomError,
|
|
41
43
|
type RoomErrorCode,
|
|
42
44
|
type RoomMember,
|
|
@@ -1649,6 +1651,12 @@ export class SyncEngine {
|
|
|
1649
1651
|
/** Stop the sync engine. */
|
|
1650
1652
|
stop(): void {
|
|
1651
1653
|
this.running = false;
|
|
1654
|
+
if (this.hintTimer !== null) {
|
|
1655
|
+
clearTimeout(this.hintTimer);
|
|
1656
|
+
this.hintTimer = null;
|
|
1657
|
+
}
|
|
1658
|
+
this.hintPending.clear();
|
|
1659
|
+
this.hintRetries = 0;
|
|
1652
1660
|
if (this.identityRetryTimer !== null) {
|
|
1653
1661
|
clearTimeout(this.identityRetryTimer);
|
|
1654
1662
|
this.identityRetryTimer = null;
|
|
@@ -2301,10 +2309,75 @@ export class SyncEngine {
|
|
|
2301
2309
|
}
|
|
2302
2310
|
|
|
2303
2311
|
async reconcile(entities?: string[]): Promise<void> {
|
|
2312
|
+
await this.reconcileReportingSkips(entities);
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
/** Entities a server hint (`sync-reconcile`) asked to reconcile that
|
|
2316
|
+
* have not reconciled yet. */
|
|
2317
|
+
private hintPending = new Set<string>();
|
|
2318
|
+
private hintTimer: ReturnType<typeof setTimeout> | null = null;
|
|
2319
|
+
private hintRetries = 0;
|
|
2320
|
+
private lastHintReconcileAt = 0;
|
|
2321
|
+
/** Hints that arrive this close together share one reconcile. */
|
|
2322
|
+
private static readonly HINT_DEBOUNCE_MS = 100;
|
|
2323
|
+
/** At most one hinted reconcile per this interval. */
|
|
2324
|
+
private static readonly HINT_MIN_INTERVAL_MS = 1_000;
|
|
2325
|
+
/** Backoff for entities whose fetch raced a newer change: doubling from
|
|
2326
|
+
* the base, capped. */
|
|
2327
|
+
private static readonly HINT_RETRY_BASE_MS = 250;
|
|
2328
|
+
private static readonly HINT_RETRY_MAX_MS = 10_000;
|
|
2329
|
+
|
|
2330
|
+
/**
|
|
2331
|
+
* The server said a change elsewhere (a share, an unshare, a folder
|
|
2332
|
+
* membership) changed which rows of `entities` this user may read
|
|
2333
|
+
* (`sync-reconcile`). No other event will tell this replica, so the
|
|
2334
|
+
* entities stay pending until a reconcile of them completes: hints are
|
|
2335
|
+
* merged, run at most once a second, and an entity whose fetch raced a
|
|
2336
|
+
* newer change is retried with backoff until it lands or the engine
|
|
2337
|
+
* stops.
|
|
2338
|
+
*/
|
|
2339
|
+
private queueHintReconcile(entities: string[]): void {
|
|
2340
|
+
for (const e of entities) this.hintPending.add(e);
|
|
2341
|
+
this.scheduleHintReconcile(SyncEngine.HINT_DEBOUNCE_MS);
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
private scheduleHintReconcile(delayMs: number): void {
|
|
2345
|
+
if (this.hintTimer !== null || !this.running) return;
|
|
2346
|
+
const wait = Math.max(delayMs, this.lastHintReconcileAt + SyncEngine.HINT_MIN_INTERVAL_MS - Date.now());
|
|
2347
|
+
this.hintTimer = setTimeout(() => {
|
|
2348
|
+
this.hintTimer = null;
|
|
2349
|
+
void this.flushHintReconcile();
|
|
2350
|
+
}, wait);
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
private async flushHintReconcile(): Promise<void> {
|
|
2354
|
+
if (!this.running || this.hintPending.size === 0) return;
|
|
2355
|
+
const entities = [...this.hintPending];
|
|
2356
|
+
this.hintPending.clear();
|
|
2357
|
+
this.lastHintReconcileAt = Date.now();
|
|
2358
|
+
let skipped: string[];
|
|
2359
|
+
try {
|
|
2360
|
+
skipped = await this.reconcileReportingSkips(entities);
|
|
2361
|
+
} catch {
|
|
2362
|
+
skipped = entities;
|
|
2363
|
+
}
|
|
2364
|
+
for (const e of skipped) this.hintPending.add(e);
|
|
2365
|
+
this.hintRetries = skipped.length > 0 ? this.hintRetries + 1 : 0;
|
|
2366
|
+
if (this.hintPending.size === 0) return;
|
|
2367
|
+
const delay =
|
|
2368
|
+
skipped.length > 0
|
|
2369
|
+
? Math.min(SyncEngine.HINT_RETRY_BASE_MS * 2 ** (this.hintRetries - 1), SyncEngine.HINT_RETRY_MAX_MS)
|
|
2370
|
+
: SyncEngine.HINT_DEBOUNCE_MS;
|
|
2371
|
+
this.scheduleHintReconcile(delay);
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
/** `reconcile`, resolving with the entities skipped because the cursor
|
|
2375
|
+
* moved during their fetch. */
|
|
2376
|
+
private async reconcileReportingSkips(entities?: string[]): Promise<string[]> {
|
|
2304
2377
|
const minIntervalMs = this.config.reconcileMinIntervalMs ?? 2_000;
|
|
2305
2378
|
const now = Date.now();
|
|
2306
2379
|
if (entities === undefined && now - this.lastReconcileAt < minIntervalMs) {
|
|
2307
|
-
return;
|
|
2380
|
+
return [];
|
|
2308
2381
|
}
|
|
2309
2382
|
// Reconcile waits behind any in-flight pull / refresh / push so it
|
|
2310
2383
|
// can't apply rows captured under a stale session.
|
|
@@ -2313,7 +2386,7 @@ export class SyncEngine {
|
|
|
2313
2386
|
// the same window share one fetch per entity.
|
|
2314
2387
|
return this.opQueue.enqueue("reconcile", async () => {
|
|
2315
2388
|
try {
|
|
2316
|
-
await this.reconcileInner();
|
|
2389
|
+
return await this.reconcileInner();
|
|
2317
2390
|
} finally {
|
|
2318
2391
|
this.lastReconcileAt = Date.now();
|
|
2319
2392
|
}
|
|
@@ -2328,9 +2401,9 @@ export class SyncEngine {
|
|
|
2328
2401
|
for (const e of entities) open.entities.add(e);
|
|
2329
2402
|
return open.promise;
|
|
2330
2403
|
}
|
|
2331
|
-
const batch: { entities: Set<string>; promise: Promise<
|
|
2404
|
+
const batch: { entities: Set<string>; promise: Promise<string[]> } = {
|
|
2332
2405
|
entities: new Set(entities),
|
|
2333
|
-
promise: Promise.resolve(),
|
|
2406
|
+
promise: Promise.resolve([]),
|
|
2334
2407
|
};
|
|
2335
2408
|
this.scopedReconcileBatch = batch;
|
|
2336
2409
|
this.scopedReconcileSeq += 1;
|
|
@@ -2339,7 +2412,7 @@ export class SyncEngine {
|
|
|
2339
2412
|
async () => {
|
|
2340
2413
|
if (this.scopedReconcileBatch === batch) this.scopedReconcileBatch = null;
|
|
2341
2414
|
try {
|
|
2342
|
-
await this.reconcileInner([...batch.entities]);
|
|
2415
|
+
return await this.reconcileInner([...batch.entities]);
|
|
2343
2416
|
} finally {
|
|
2344
2417
|
this.lastReconcileAt = Date.now();
|
|
2345
2418
|
}
|
|
@@ -2351,14 +2424,14 @@ export class SyncEngine {
|
|
|
2351
2424
|
/** Scoped reconcile batch that has not started running yet. */
|
|
2352
2425
|
private scopedReconcileBatch: {
|
|
2353
2426
|
entities: Set<string>;
|
|
2354
|
-
promise: Promise<
|
|
2427
|
+
promise: Promise<string[]>;
|
|
2355
2428
|
} | null = null;
|
|
2356
2429
|
private scopedReconcileSeq = 0;
|
|
2357
2430
|
|
|
2358
|
-
private async reconcileInner(entities?: string[]): Promise<
|
|
2431
|
+
private async reconcileInner(entities?: string[]): Promise<string[]> {
|
|
2359
2432
|
// Same reasoning as pullInner: the leader reconciles, broadcasts
|
|
2360
2433
|
// results, and follower replicas converge via the channel.
|
|
2361
|
-
if (!this.isMultiTabLeader) return;
|
|
2434
|
+
if (!this.isMultiTabLeader) return [];
|
|
2362
2435
|
// Sweep entities with local rows PLUS entities the app has observed
|
|
2363
2436
|
// via useQuery (even when empty locally). Without the observed set,
|
|
2364
2437
|
// a server row in a never-cached entity is never reconciled and
|
|
@@ -2366,7 +2439,8 @@ export class SyncEngine {
|
|
|
2366
2439
|
const names =
|
|
2367
2440
|
entities ??
|
|
2368
2441
|
[...new Set([...this.store.entityNames(), ...this.observedEntities])];
|
|
2369
|
-
if (names.length === 0) return;
|
|
2442
|
+
if (names.length === 0) return [];
|
|
2443
|
+
const skipped: string[] = [];
|
|
2370
2444
|
// Tombstone seq for any local row the server doesn't return. Using
|
|
2371
2445
|
// the current cursor means future inserts (which have higher seqs)
|
|
2372
2446
|
// bypass the tombstone — re-creation server-side still propagates.
|
|
@@ -2442,11 +2516,16 @@ export class SyncEngine {
|
|
|
2442
2516
|
const streak = (this.reconcile403Streak.get(entity) ?? 0) + 1;
|
|
2443
2517
|
if (streak < 2) {
|
|
2444
2518
|
this.reconcile403Streak.set(entity, streak);
|
|
2519
|
+
skipped.push(entity);
|
|
2445
2520
|
return;
|
|
2446
2521
|
}
|
|
2447
2522
|
this.reconcile403Streak.delete(entity);
|
|
2448
2523
|
await this.dropEntity(entity, tombstoneSeq);
|
|
2524
|
+
return;
|
|
2449
2525
|
}
|
|
2526
|
+
// Not reconciled (offline, a 5xx): reported, so a server hint
|
|
2527
|
+
// stays pending and retries.
|
|
2528
|
+
skipped.push(entity);
|
|
2450
2529
|
return;
|
|
2451
2530
|
}
|
|
2452
2531
|
// Successful fetch — the entity is readable, so any prior 403 streak
|
|
@@ -2458,7 +2537,9 @@ export class SyncEngine {
|
|
|
2458
2537
|
// for a row our snapshot just captured. Bail out for this
|
|
2459
2538
|
// entity; reconcile() is triggered again on visibility-change
|
|
2460
2539
|
// and reconnect, and the WS event already carried the latest
|
|
2461
|
-
// state for the affected row.
|
|
2540
|
+
// state for the affected row. Reported, so a server hint can
|
|
2541
|
+
// retry (flushHintReconcile).
|
|
2542
|
+
skipped.push(entity);
|
|
2462
2543
|
return;
|
|
2463
2544
|
}
|
|
2464
2545
|
if (this.session.signature() !== sessionBeforeFetch) {
|
|
@@ -2477,6 +2558,7 @@ export class SyncEngine {
|
|
|
2477
2558
|
);
|
|
2478
2559
|
}),
|
|
2479
2560
|
);
|
|
2561
|
+
return skipped;
|
|
2480
2562
|
}
|
|
2481
2563
|
|
|
2482
2564
|
/** Fetch every row for an entity. Uses cursor pagination so big tables
|
|
@@ -4103,6 +4185,15 @@ export class SyncEngine {
|
|
|
4103
4185
|
// fresh /api/auth/me read which updates the cached session AND,
|
|
4104
4186
|
// on tenant flip, resets the replica so stale rows from the
|
|
4105
4187
|
// previous tenant disappear.
|
|
4188
|
+
// A change elsewhere (a share, an unshare, a folder membership)
|
|
4189
|
+
// changed which rows of these entities this user may read, without a
|
|
4190
|
+
// change event for the rows themselves. Reconcile them.
|
|
4191
|
+
if (msg.type === "sync-reconcile" && Array.isArray(msg.entities)) {
|
|
4192
|
+
const entities = msg.entities.filter((e): e is string => typeof e === "string");
|
|
4193
|
+
if (entities.length > 0) this.queueHintReconcile(entities);
|
|
4194
|
+
return;
|
|
4195
|
+
}
|
|
4196
|
+
|
|
4106
4197
|
if (msg.type === "session-changed") {
|
|
4107
4198
|
void this.refreshResolvedSession();
|
|
4108
4199
|
return;
|
|
@@ -4152,6 +4243,20 @@ export class SyncEngine {
|
|
|
4152
4243
|
}
|
|
4153
4244
|
return;
|
|
4154
4245
|
}
|
|
4246
|
+
// The server removed us from a room tied to a row we may no longer
|
|
4247
|
+
// read. It stops sending the room's events; clear the members and tell
|
|
4248
|
+
// the hook, which stops rejoining.
|
|
4249
|
+
if (msg.type === "room-revoked" && typeof msg.room === "string") {
|
|
4250
|
+
const room = msg.room;
|
|
4251
|
+
const error: RoomError = { code: "ROOM_REVOKED" };
|
|
4252
|
+
this.rooms.applySnapshot(room, []);
|
|
4253
|
+
this.rooms.applyError(room, error);
|
|
4254
|
+
if ((this.roomForwarders.get(room)?.size ?? 0) > 0) {
|
|
4255
|
+
this.broadcastToTabs({ type: "room-fanout-snapshot", room, members: [] });
|
|
4256
|
+
this.broadcastToTabs({ type: "room-fanout-error", room, error });
|
|
4257
|
+
}
|
|
4258
|
+
return;
|
|
4259
|
+
}
|
|
4155
4260
|
if (
|
|
4156
4261
|
msg.type === "error" &&
|
|
4157
4262
|
typeof msg.room === "string" &&
|
|
@@ -4163,7 +4268,8 @@ export class SyncEngine {
|
|
|
4163
4268
|
// path right now, but the dispatch is keyed by `code` so future
|
|
4164
4269
|
// error codes (rate-limited, room-full, etc.) route here too.
|
|
4165
4270
|
const room = msg.room;
|
|
4166
|
-
const code =
|
|
4271
|
+
const code: RoomErrorCode =
|
|
4272
|
+
msg.code === "NOT_IN_ROOM" || msg.code === "ROOM_FORBIDDEN" ? msg.code : "UNKNOWN";
|
|
4167
4273
|
const error: RoomError = {
|
|
4168
4274
|
code,
|
|
4169
4275
|
message: typeof msg.message === "string" ? msg.message : undefined,
|
package/src/reconcile.test.ts
CHANGED
|
@@ -668,3 +668,148 @@ describe("SyncEngine cookie auth", () => {
|
|
|
668
668
|
expect(notifications).toBe(1);
|
|
669
669
|
});
|
|
670
670
|
});
|
|
671
|
+
|
|
672
|
+
// The server sends `sync-reconcile` when a row of another entity changed
|
|
673
|
+
// which rows of these entities the user may read (a share, an unshare):
|
|
674
|
+
// no change event of the rows themselves will come.
|
|
675
|
+
describe("sync-reconcile hint", () => {
|
|
676
|
+
let restore: (() => void) | null = null;
|
|
677
|
+
|
|
678
|
+
afterEach(() => {
|
|
679
|
+
restore?.();
|
|
680
|
+
restore = null;
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
type Internals = {
|
|
684
|
+
dispatchInboundMessage(m: Record<string, unknown>): void;
|
|
685
|
+
running: boolean;
|
|
686
|
+
cursor: { last_seq: number };
|
|
687
|
+
hintPending: Set<string>;
|
|
688
|
+
};
|
|
689
|
+
const inner = (engine: SyncEngine) => engine as unknown as Internals;
|
|
690
|
+
const deliver = (engine: SyncEngine, msg: Record<string, unknown>) => inner(engine).dispatchInboundMessage(msg);
|
|
691
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
692
|
+
|
|
693
|
+
function documentServer(onFetch: (n: number) => void = () => {}) {
|
|
694
|
+
let fetches = 0;
|
|
695
|
+
restore = installFetch(async (url) => {
|
|
696
|
+
if (url.includes("/api/entities/Document/cursor")) {
|
|
697
|
+
fetches += 1;
|
|
698
|
+
onFetch(fetches);
|
|
699
|
+
return {
|
|
700
|
+
status: 200,
|
|
701
|
+
body: { data: [{ id: "shared", title: "Now shared" }], next_cursor: null, has_more: false },
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
return { status: 404, body: {} };
|
|
705
|
+
});
|
|
706
|
+
return () => fetches;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
test("reconciles the named entities: the shared row arrives, the unshared one leaves", async () => {
|
|
710
|
+
const fetches = documentServer();
|
|
711
|
+
const engine = makeEngine();
|
|
712
|
+
inner(engine).running = true;
|
|
713
|
+
seedStore(engine, "Document", [{ id: "unshared", title: "No longer shared" }]);
|
|
714
|
+
|
|
715
|
+
deliver(engine, { type: "sync-reconcile", entities: ["Document"] });
|
|
716
|
+
await sleep(250);
|
|
717
|
+
|
|
718
|
+
expect(fetches()).toBe(1);
|
|
719
|
+
expect(engine.store.list("Document").map((r) => (r as { id: string }).id)).toEqual(["shared"]);
|
|
720
|
+
engine.stop();
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
test("hints that arrive together share one fetch", async () => {
|
|
724
|
+
const fetches = documentServer();
|
|
725
|
+
const engine = makeEngine();
|
|
726
|
+
inner(engine).running = true;
|
|
727
|
+
for (let i = 0; i < 5; i++) deliver(engine, { type: "sync-reconcile", entities: ["Document"] });
|
|
728
|
+
await sleep(250);
|
|
729
|
+
expect(fetches()).toBe(1);
|
|
730
|
+
engine.stop();
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
test("keeps retrying while newer changes race the fetch, then lands", async () => {
|
|
734
|
+
const engine = makeEngine();
|
|
735
|
+
// The first two fetches race a change event: the cursor moves.
|
|
736
|
+
const fetches = documentServer((n) => {
|
|
737
|
+
if (n <= 2) inner(engine).cursor.last_seq += 1;
|
|
738
|
+
});
|
|
739
|
+
inner(engine).running = true;
|
|
740
|
+
|
|
741
|
+
deliver(engine, { type: "sync-reconcile", entities: ["Document"] });
|
|
742
|
+
await sleep(250);
|
|
743
|
+
expect(fetches()).toBe(1);
|
|
744
|
+
expect(engine.store.list("Document")).toEqual([]);
|
|
745
|
+
expect([...inner(engine).hintPending]).toEqual(["Document"]);
|
|
746
|
+
|
|
747
|
+
// Retries run at most once a second.
|
|
748
|
+
await sleep(2300);
|
|
749
|
+
expect(fetches()).toBe(3);
|
|
750
|
+
expect(engine.store.list("Document").map((r) => (r as { id: string }).id)).toEqual(["shared"]);
|
|
751
|
+
expect(inner(engine).hintPending.size).toBe(0);
|
|
752
|
+
engine.stop();
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
test("stop drops pending hints", async () => {
|
|
756
|
+
const fetches = documentServer();
|
|
757
|
+
const engine = makeEngine();
|
|
758
|
+
inner(engine).running = true;
|
|
759
|
+
deliver(engine, { type: "sync-reconcile", entities: ["Document"] });
|
|
760
|
+
engine.stop();
|
|
761
|
+
await sleep(250);
|
|
762
|
+
expect(fetches()).toBe(0);
|
|
763
|
+
expect(inner(engine).hintPending.size).toBe(0);
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
test("ignores a hint without entity names", async () => {
|
|
767
|
+
const fetches = documentServer();
|
|
768
|
+
const engine = makeEngine();
|
|
769
|
+
inner(engine).running = true;
|
|
770
|
+
deliver(engine, { type: "sync-reconcile", entities: [42, null] });
|
|
771
|
+
deliver(engine, { type: "sync-reconcile" });
|
|
772
|
+
await sleep(250);
|
|
773
|
+
expect(fetches()).toBe(0);
|
|
774
|
+
engine.stop();
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
describe("sync-reconcile hint after a failed fetch", () => {
|
|
779
|
+
let restore: (() => void) | null = null;
|
|
780
|
+
afterEach(() => {
|
|
781
|
+
restore?.();
|
|
782
|
+
restore = null;
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
test("a 5xx keeps the hint pending and the retry lands", async () => {
|
|
786
|
+
let fetches = 0;
|
|
787
|
+
restore = installFetch(async (url) => {
|
|
788
|
+
if (url.includes("/api/entities/Document/cursor")) {
|
|
789
|
+
fetches += 1;
|
|
790
|
+
if (fetches === 1) return { status: 503, body: {} };
|
|
791
|
+
return { status: 200, body: { data: [], next_cursor: null, has_more: false } };
|
|
792
|
+
}
|
|
793
|
+
return { status: 404, body: {} };
|
|
794
|
+
});
|
|
795
|
+
const engine = makeEngine();
|
|
796
|
+
const inner = engine as unknown as {
|
|
797
|
+
running: boolean;
|
|
798
|
+
hintPending: Set<string>;
|
|
799
|
+
dispatchInboundMessage(m: Record<string, unknown>): void;
|
|
800
|
+
};
|
|
801
|
+
inner.running = true;
|
|
802
|
+
seedStore(engine, "Document", [{ id: "unshared", title: "No longer shared" }]);
|
|
803
|
+
|
|
804
|
+
inner.dispatchInboundMessage({ type: "sync-reconcile", entities: ["Document"] });
|
|
805
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
806
|
+
expect(fetches).toBe(1);
|
|
807
|
+
expect([...inner.hintPending]).toEqual(["Document"]);
|
|
808
|
+
expect(engine.store.list("Document").length).toBe(1);
|
|
809
|
+
|
|
810
|
+
await new Promise((r) => setTimeout(r, 1200));
|
|
811
|
+
expect(fetches).toBe(2);
|
|
812
|
+
expect(engine.store.list("Document")).toEqual([]);
|
|
813
|
+
engine.stop();
|
|
814
|
+
});
|
|
815
|
+
});
|
|
@@ -299,3 +299,15 @@ describe("RoomSubscriptions: resubscribe after a rejoin", () => {
|
|
|
299
299
|
expect(sent).toEqual([{ type: "room-sub-resubscribe", room: "channel:a" }]);
|
|
300
300
|
});
|
|
301
301
|
});
|
|
302
|
+
|
|
303
|
+
test("presence from someone not in the list adds them back", () => {
|
|
304
|
+
const { rooms } = makeHarness();
|
|
305
|
+
rooms.register("doc", () => {});
|
|
306
|
+
rooms.applySnapshot("doc", [{ user_id: "alice", joined_at: "t", data: {} }]);
|
|
307
|
+
// A leave relayed from another machine, though Alice is still connected
|
|
308
|
+
// through this one.
|
|
309
|
+
rooms.applyUpdate("doc", "leave", { user_id: "alice", joined_at: "", data: {} }, undefined);
|
|
310
|
+
expect(rooms.members("doc")).toEqual([]);
|
|
311
|
+
rooms.applyUpdate("doc", "presence", { user_id: "alice", joined_at: "", data: {} }, { cursor: 4 });
|
|
312
|
+
expect(rooms.members("doc")).toEqual([{ user_id: "alice", joined_at: "", data: { cursor: 4 } }]);
|
|
313
|
+
});
|
|
@@ -51,7 +51,21 @@ export interface RoomMember {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
/** Reason codes the SDK exposes on a room subscription error. */
|
|
54
|
-
|
|
54
|
+
/**
|
|
55
|
+
* - `NOT_IN_ROOM`: subscribed before joining (rejoin and resubscribe).
|
|
56
|
+
* - `ROOM_FORBIDDEN`: a room tied to a row the caller may not read.
|
|
57
|
+
* - `ROOM_REVOKED`: the caller lost access to that row while in the room.
|
|
58
|
+
*/
|
|
59
|
+
export type RoomErrorCode = "NOT_IN_ROOM" | "ROOM_FORBIDDEN" | "ROOM_REVOKED" | "UNKNOWN";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The room tied to a row: its members are the people who may read the row,
|
|
63
|
+
* and the server checks that on every room operation. `@` starts only these
|
|
64
|
+
* names.
|
|
65
|
+
*/
|
|
66
|
+
export function rowRoom(entity: string, id: string): string {
|
|
67
|
+
return `@${entity}/${id}`;
|
|
68
|
+
}
|
|
55
69
|
|
|
56
70
|
export interface RoomError {
|
|
57
71
|
code: RoomErrorCode;
|
|
@@ -272,11 +286,22 @@ export class RoomSubscriptions {
|
|
|
272
286
|
_data && typeof _data === "object"
|
|
273
287
|
? (_data as Record<string, unknown>)
|
|
274
288
|
: undefined;
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
289
|
+
if (entry.members.some((m) => m.user_id === member.user_id)) {
|
|
290
|
+
entry.members = entry.members.map((m) =>
|
|
291
|
+
m.user_id === member.user_id
|
|
292
|
+
? { ...m, ...member, ...(presence ? { data: presence } : {}) }
|
|
293
|
+
: m,
|
|
294
|
+
);
|
|
295
|
+
} else {
|
|
296
|
+
// Presence from someone not in the list is in the room: on a
|
|
297
|
+
// cluster, a leave relayed from one machine can drop a person
|
|
298
|
+
// still connected through another. Their next presence puts them
|
|
299
|
+
// back.
|
|
300
|
+
entry.members = [
|
|
301
|
+
...entry.members,
|
|
302
|
+
{ ...member, joined_at: member.joined_at ?? "", ...(presence ? { data: presence } : {}) },
|
|
303
|
+
];
|
|
304
|
+
}
|
|
280
305
|
break;
|
|
281
306
|
}
|
|
282
307
|
case "broadcast": {
|