@pylonsync/sync 0.3.316 → 0.3.318
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 +8 -0
- package/dist/persistence.d.ts +24 -2
- package/dist/session-resolver.d.ts +5 -0
- package/package.json +1 -1
- package/src/custom-persistence.test.ts +144 -0
- package/src/index.ts +39 -8
- package/src/persistence.ts +29 -2
- package/src/session-resolver.ts +9 -0
package/dist/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { type RoomError, type RoomMember, type RoomSubscriber } from "./room-sub
|
|
|
4
4
|
import { SessionResolver } from "./session-resolver";
|
|
5
5
|
export { RoomSubscriptions, type RoomError, type RoomErrorCode, type RoomMember, type RoomMessage, type RoomMessageSubscriber, type RoomSubscriber, } from "./room-subscriptions";
|
|
6
6
|
export { IndexedDBPersistence, persistChange } from "./persistence";
|
|
7
|
+
export type { ReplicaPersistence } from "./persistence";
|
|
7
8
|
export { buildRequest, pylonFetch, pylonFetchRaw, PylonHttpError, resolveBaseUrl, } from "./transport";
|
|
8
9
|
export type { PylonRequestInit, TransportConfig } from "./transport";
|
|
9
10
|
export { LocalStore } from "./local-store";
|
|
@@ -26,6 +27,13 @@ export interface SyncEngineConfig {
|
|
|
26
27
|
token?: string;
|
|
27
28
|
/** Enable IndexedDB persistence. Data survives page refresh. Default: true in browser. */
|
|
28
29
|
persist?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Replica persistence backend. Default: IndexedDB where available
|
|
32
|
+
* (browsers). Non-browser hosts (React Native, Tauri) inject an adapter
|
|
33
|
+
* here — without one their replica is memory-only and a cold offline
|
|
34
|
+
* launch shows an empty store. `persist: false` still disables entirely.
|
|
35
|
+
*/
|
|
36
|
+
persistence?: import("./persistence").ReplicaPersistence;
|
|
29
37
|
/** App name for IndexedDB database naming. Default: "default". */
|
|
30
38
|
appName?: string;
|
|
31
39
|
/**
|
package/dist/persistence.d.ts
CHANGED
|
@@ -1,9 +1,31 @@
|
|
|
1
1
|
import type { Row, ChangeEvent, SyncCursor, MutationQueuePersistence, PendingMutation } from "./index";
|
|
2
|
+
/**
|
|
3
|
+
* The engine's durable-replica backend. IndexedDBPersistence is the browser
|
|
4
|
+
* implementation; non-browser hosts (React Native, Tauri) implement this
|
|
5
|
+
* and pass it via `SyncEngineConfig.persistence` — the exact call surface
|
|
6
|
+
* the engine + `persistChange` use, nothing more.
|
|
7
|
+
*/
|
|
8
|
+
export interface ReplicaPersistence {
|
|
9
|
+
open(): Promise<void>;
|
|
10
|
+
/** Rows + cursor read as ONE consistent snapshot (see warm-load notes). */
|
|
11
|
+
loadSnapshot(): Promise<{
|
|
12
|
+
entities: Record<string, Row[]>;
|
|
13
|
+
cursor: SyncCursor | null;
|
|
14
|
+
hadCache: boolean;
|
|
15
|
+
}>;
|
|
16
|
+
/** `undefined` = never recorded (fresh install); `null` = anonymous. */
|
|
17
|
+
loadIdentity(): Promise<string | null | undefined>;
|
|
18
|
+
saveIdentity(userId: string | null): Promise<boolean>;
|
|
19
|
+
saveCursor(cursor: SyncCursor): Promise<boolean>;
|
|
20
|
+
saveRow(entity: string, id: string, data: Row): Promise<boolean>;
|
|
21
|
+
deleteRow(entity: string, id: string): Promise<boolean>;
|
|
22
|
+
clear(): Promise<boolean>;
|
|
23
|
+
}
|
|
2
24
|
/**
|
|
3
25
|
* IndexedDB-backed persistence for the sync store.
|
|
4
26
|
* Saves entity rows and sync cursor so data survives page refresh.
|
|
5
27
|
*/
|
|
6
|
-
export declare class IndexedDBPersistence {
|
|
28
|
+
export declare class IndexedDBPersistence implements ReplicaPersistence {
|
|
7
29
|
private db;
|
|
8
30
|
private dbName;
|
|
9
31
|
/** Shared connection. Exposed so sibling persistence classes (e.g. the
|
|
@@ -103,7 +125,7 @@ export declare class IndexedDBPersistence {
|
|
|
103
125
|
* can hold the persisted cursor back rather than skip the row on restart).
|
|
104
126
|
* A change with no `data` is a no-op and counts as durable.
|
|
105
127
|
*/
|
|
106
|
-
export declare function persistChange(persistence:
|
|
128
|
+
export declare function persistChange(persistence: ReplicaPersistence, change: ChangeEvent): Promise<boolean>;
|
|
107
129
|
/**
|
|
108
130
|
* IndexedDB-backed implementation of `MutationQueuePersistence`. Wires the
|
|
109
131
|
* `MutationQueue` into the same database as the entity mirror so everything
|
|
@@ -28,6 +28,7 @@ export interface TokenTransition {
|
|
|
28
28
|
}
|
|
29
29
|
export declare class SessionResolver {
|
|
30
30
|
private _resolved;
|
|
31
|
+
private _observed;
|
|
31
32
|
/** `undefined` until the first observation — distinguishes "we've
|
|
32
33
|
* never seen a token" from "the token is null." Same for tenant. */
|
|
33
34
|
private lastSeenToken;
|
|
@@ -57,6 +58,10 @@ export declare class SessionResolver {
|
|
|
57
58
|
* Production callers should use `inspectSession` + `commitObservation`
|
|
58
59
|
* to control the timing of state mutation. */
|
|
59
60
|
observeSession(next: ResolvedSession): SessionTransition;
|
|
61
|
+
/** Has /api/auth/me actually resolved THIS run? False on an offline
|
|
62
|
+
* start — callers that would destroy state on an identity mismatch
|
|
63
|
+
* (replica wipes) must not act on the EMPTY placeholder session. */
|
|
64
|
+
hasObserved(): boolean;
|
|
60
65
|
/** Feed in the current bearer token. Returns whether it flipped
|
|
61
66
|
* since the previous observation. The engine uses this in pull()
|
|
62
67
|
* to decide whether to reset before reading the cursor. */
|
package/package.json
CHANGED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Pluggable replica persistence (SyncEngineConfig.persistence).
|
|
2
|
+
//
|
|
3
|
+
// Non-browser hosts (React Native, Tauri) have no IndexedDB — before this
|
|
4
|
+
// config existed their replica was memory-only and a cold offline launch
|
|
5
|
+
// rendered an empty store. These tests drive the engine with a MEMORY
|
|
6
|
+
// adapter and prove:
|
|
7
|
+
//
|
|
8
|
+
// 1. A cached replica hydrates the store on start() with the network
|
|
9
|
+
// DOWN — the offline-gym cold start.
|
|
10
|
+
// 2. Applied changes flow through the adapter (saveRow), so the next
|
|
11
|
+
// cold start has them.
|
|
12
|
+
|
|
13
|
+
import { afterEach, beforeEach, expect, test } from "bun:test";
|
|
14
|
+
import {
|
|
15
|
+
SyncEngine,
|
|
16
|
+
type ReplicaPersistence,
|
|
17
|
+
type Row,
|
|
18
|
+
type SyncCursor,
|
|
19
|
+
} from "./index";
|
|
20
|
+
|
|
21
|
+
class MemoryPersistence implements ReplicaPersistence {
|
|
22
|
+
entities = new Map<string, Map<string, Row>>();
|
|
23
|
+
cursor: SyncCursor | null = null;
|
|
24
|
+
identity: string | null | undefined = undefined;
|
|
25
|
+
savedRows: Array<[string, string]> = [];
|
|
26
|
+
|
|
27
|
+
async open(): Promise<void> {}
|
|
28
|
+
async loadSnapshot() {
|
|
29
|
+
const entities: Record<string, Row[]> = {};
|
|
30
|
+
for (const [e, rows] of this.entities) entities[e] = [...rows.values()];
|
|
31
|
+
return {
|
|
32
|
+
entities,
|
|
33
|
+
cursor: this.cursor,
|
|
34
|
+
hadCache: this.entities.size > 0 || this.cursor != null,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
async loadIdentity() {
|
|
38
|
+
return this.identity;
|
|
39
|
+
}
|
|
40
|
+
async saveIdentity(userId: string | null) {
|
|
41
|
+
this.identity = userId;
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
async saveCursor(cursor: SyncCursor) {
|
|
45
|
+
this.cursor = cursor;
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
async saveRow(entity: string, id: string, data: Row) {
|
|
49
|
+
let m = this.entities.get(entity);
|
|
50
|
+
if (!m) {
|
|
51
|
+
m = new Map();
|
|
52
|
+
this.entities.set(entity, m);
|
|
53
|
+
}
|
|
54
|
+
m.set(id, data);
|
|
55
|
+
this.savedRows.push([entity, id]);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
async deleteRow(entity: string, id: string) {
|
|
59
|
+
this.entities.get(entity)?.delete(id);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
async clear() {
|
|
63
|
+
this.entities.clear();
|
|
64
|
+
this.cursor = null;
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const realFetch = globalThis.fetch;
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
globalThis.fetch = realFetch;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
function offlineFetch(): typeof fetch {
|
|
75
|
+
return (() => Promise.reject(new TypeError("network down"))) as typeof fetch;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function makeEngine(persistence: ReplicaPersistence): SyncEngine {
|
|
79
|
+
return new SyncEngine({
|
|
80
|
+
baseUrl: "http://sync-test.invalid",
|
|
81
|
+
transport: "poll",
|
|
82
|
+
persistence,
|
|
83
|
+
appName: "custom-persistence-test",
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let engine: SyncEngine | null = null;
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
engine = null;
|
|
90
|
+
});
|
|
91
|
+
afterEach(async () => {
|
|
92
|
+
await engine?.stop();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("cold OFFLINE start hydrates the store from a custom adapter", async () => {
|
|
96
|
+
const mem = new MemoryPersistence();
|
|
97
|
+
mem.identity = "user-1";
|
|
98
|
+
mem.cursor = { last_seq: 42 };
|
|
99
|
+
mem.entities.set(
|
|
100
|
+
"Workout",
|
|
101
|
+
new Map([
|
|
102
|
+
["w1", { id: "w1", title: "Cached Leg Day", ownerId: "user-1" }],
|
|
103
|
+
["w2", { id: "w2", title: "Cached Push Day", ownerId: "user-1" }],
|
|
104
|
+
]),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
globalThis.fetch = offlineFetch();
|
|
108
|
+
engine = makeEngine(mem);
|
|
109
|
+
await engine.start();
|
|
110
|
+
|
|
111
|
+
const rows = engine.store.list("Workout");
|
|
112
|
+
expect(rows.length).toBe(2);
|
|
113
|
+
expect(rows.map((r) => r.title).sort()).toEqual([
|
|
114
|
+
"Cached Leg Day",
|
|
115
|
+
"Cached Push Day",
|
|
116
|
+
]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("applied changes persist through the adapter", async () => {
|
|
120
|
+
const mem = new MemoryPersistence();
|
|
121
|
+
globalThis.fetch = offlineFetch();
|
|
122
|
+
engine = makeEngine(mem);
|
|
123
|
+
await engine.start();
|
|
124
|
+
|
|
125
|
+
await engine.store.applyChangesAsync([
|
|
126
|
+
{
|
|
127
|
+
kind: "insert",
|
|
128
|
+
entity: "Workout",
|
|
129
|
+
row_id: "w9",
|
|
130
|
+
seq: 1,
|
|
131
|
+
data: { id: "w9", title: "New Session" },
|
|
132
|
+
timestamp: "",
|
|
133
|
+
},
|
|
134
|
+
]);
|
|
135
|
+
// Adapter writes settle on the applyChanges await (the engine passes
|
|
136
|
+
// each change through persistChange → saveRow).
|
|
137
|
+
expect(mem.savedRows.some(([e, id]) => e === "Workout" && id === "w9")).toBe(true);
|
|
138
|
+
|
|
139
|
+
// ...and the NEXT engine (fresh memory) sees it on a cold offline start.
|
|
140
|
+
await engine.stop();
|
|
141
|
+
engine = makeEngine(mem);
|
|
142
|
+
await engine.start();
|
|
143
|
+
expect(engine.store.list("Workout").some((r) => r.id === "w9")).toBe(true);
|
|
144
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -43,6 +43,7 @@ export {
|
|
|
43
43
|
type RoomSubscriber,
|
|
44
44
|
} from "./room-subscriptions";
|
|
45
45
|
export { IndexedDBPersistence, persistChange } from "./persistence";
|
|
46
|
+
export type { ReplicaPersistence } from "./persistence";
|
|
46
47
|
export {
|
|
47
48
|
buildRequest,
|
|
48
49
|
pylonFetch,
|
|
@@ -117,6 +118,13 @@ export interface SyncEngineConfig {
|
|
|
117
118
|
token?: string;
|
|
118
119
|
/** Enable IndexedDB persistence. Data survives page refresh. Default: true in browser. */
|
|
119
120
|
persist?: boolean;
|
|
121
|
+
/**
|
|
122
|
+
* Replica persistence backend. Default: IndexedDB where available
|
|
123
|
+
* (browsers). Non-browser hosts (React Native, Tauri) inject an adapter
|
|
124
|
+
* here — without one their replica is memory-only and a cold offline
|
|
125
|
+
* launch shows an empty store. `persist: false` still disables entirely.
|
|
126
|
+
*/
|
|
127
|
+
persistence?: import("./persistence").ReplicaPersistence;
|
|
120
128
|
/** App name for IndexedDB database naming. Default: "default". */
|
|
121
129
|
appName?: string;
|
|
122
130
|
/**
|
|
@@ -198,7 +206,7 @@ export class SyncEngine {
|
|
|
198
206
|
* at all and SSR-only consumers never reach start(). */
|
|
199
207
|
private transport: Transport | null = null;
|
|
200
208
|
private _connectionStatus: SyncConnectionStatus = "offline";
|
|
201
|
-
private persistence: import("./persistence").
|
|
209
|
+
private persistence: import("./persistence").ReplicaPersistence | null = null;
|
|
202
210
|
|
|
203
211
|
/**
|
|
204
212
|
* Flips true once `start()` has either:
|
|
@@ -629,11 +637,17 @@ export class SyncEngine {
|
|
|
629
637
|
this.armInitialSyncFallback();
|
|
630
638
|
|
|
631
639
|
// Load persisted data if available.
|
|
632
|
-
const shouldPersist =
|
|
640
|
+
const shouldPersist =
|
|
641
|
+
this.config.persist !== false &&
|
|
642
|
+
(this.config.persistence != null || typeof indexedDB !== "undefined");
|
|
633
643
|
if (shouldPersist) {
|
|
634
644
|
try {
|
|
635
|
-
|
|
636
|
-
|
|
645
|
+
if (this.config.persistence) {
|
|
646
|
+
this.persistence = this.config.persistence;
|
|
647
|
+
} else {
|
|
648
|
+
const { IndexedDBPersistence } = await import("./persistence");
|
|
649
|
+
this.persistence = new IndexedDBPersistence(this.config.appName);
|
|
650
|
+
}
|
|
637
651
|
await this.persistence.open();
|
|
638
652
|
|
|
639
653
|
// Warm-load entities + cursor in ONE readonly transaction so
|
|
@@ -727,10 +741,20 @@ export class SyncEngine {
|
|
|
727
741
|
// would let pull+reconcile sweep the optimistic ghosts before
|
|
728
742
|
// push() ever fires.
|
|
729
743
|
try {
|
|
730
|
-
const { IndexedDBMutationPersistence } = await import(
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
744
|
+
const { IndexedDBMutationPersistence, IndexedDBPersistence } = await import(
|
|
745
|
+
"./persistence"
|
|
746
|
+
);
|
|
747
|
+
// The durable mutation queue rides the IDB connection; a custom
|
|
748
|
+
// ReplicaPersistence (RN/Tauri) doesn't carry one, so the queue
|
|
749
|
+
// stays in-memory there — pending offline writes survive within
|
|
750
|
+
// the session but not across a force-kill. Durable queues for
|
|
751
|
+
// custom adapters are a follow-up (MutationQueuePersistence is
|
|
752
|
+
// already an interface).
|
|
753
|
+
if (persistence instanceof IndexedDBPersistence) {
|
|
754
|
+
const mqPersistence = new IndexedDBMutationPersistence(persistence);
|
|
755
|
+
this.mutations.attachPersistence(mqPersistence);
|
|
756
|
+
await this.mutations.hydrate();
|
|
757
|
+
}
|
|
734
758
|
// The hydrated offline writes are drained in the leader path
|
|
735
759
|
// below (after `initMultiTab` settles), NOT here. We're still
|
|
736
760
|
// pre-election at this point, so `isMultiTabLeader` is false
|
|
@@ -1447,6 +1471,13 @@ export class SyncEngine {
|
|
|
1447
1471
|
if (!this._hadCachedReplica) return;
|
|
1448
1472
|
const prev = this._replicaIdentity;
|
|
1449
1473
|
if (prev === undefined) return;
|
|
1474
|
+
// OFFLINE start: /api/auth/me never resolved, so the session is the
|
|
1475
|
+
// EMPTY placeholder (userId null) — that's "unknown", not "anonymous".
|
|
1476
|
+
// Wiping here destroyed the cached replica on every airplane-mode
|
|
1477
|
+
// reload (the offline cold start persistence exists FOR). Keep the
|
|
1478
|
+
// cache; if the session later resolves as a different identity, the
|
|
1479
|
+
// observeSession/observeToken verdicts reset the replica then.
|
|
1480
|
+
if (!this.session.hasObserved()) return;
|
|
1450
1481
|
const now = this.session.resolved().userId;
|
|
1451
1482
|
if (prev === now) return;
|
|
1452
1483
|
// Identity flipped across the reload. Drop the prior identity's rows.
|
package/src/persistence.ts
CHANGED
|
@@ -6,6 +6,33 @@ import type {
|
|
|
6
6
|
PendingMutation,
|
|
7
7
|
} from "./index";
|
|
8
8
|
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Replica persistence contract
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The engine's durable-replica backend. IndexedDBPersistence is the browser
|
|
15
|
+
* implementation; non-browser hosts (React Native, Tauri) implement this
|
|
16
|
+
* and pass it via `SyncEngineConfig.persistence` — the exact call surface
|
|
17
|
+
* the engine + `persistChange` use, nothing more.
|
|
18
|
+
*/
|
|
19
|
+
export interface ReplicaPersistence {
|
|
20
|
+
open(): Promise<void>;
|
|
21
|
+
/** Rows + cursor read as ONE consistent snapshot (see warm-load notes). */
|
|
22
|
+
loadSnapshot(): Promise<{
|
|
23
|
+
entities: Record<string, Row[]>;
|
|
24
|
+
cursor: SyncCursor | null;
|
|
25
|
+
hadCache: boolean;
|
|
26
|
+
}>;
|
|
27
|
+
/** `undefined` = never recorded (fresh install); `null` = anonymous. */
|
|
28
|
+
loadIdentity(): Promise<string | null | undefined>;
|
|
29
|
+
saveIdentity(userId: string | null): Promise<boolean>;
|
|
30
|
+
saveCursor(cursor: SyncCursor): Promise<boolean>;
|
|
31
|
+
saveRow(entity: string, id: string, data: Row): Promise<boolean>;
|
|
32
|
+
deleteRow(entity: string, id: string): Promise<boolean>;
|
|
33
|
+
clear(): Promise<boolean>;
|
|
34
|
+
}
|
|
35
|
+
|
|
9
36
|
// ---------------------------------------------------------------------------
|
|
10
37
|
// IndexedDB persistence layer
|
|
11
38
|
// ---------------------------------------------------------------------------
|
|
@@ -23,7 +50,7 @@ const MUTATIONS_STORE = "pendingMutations";
|
|
|
23
50
|
* IndexedDB-backed persistence for the sync store.
|
|
24
51
|
* Saves entity rows and sync cursor so data survives page refresh.
|
|
25
52
|
*/
|
|
26
|
-
export class IndexedDBPersistence {
|
|
53
|
+
export class IndexedDBPersistence implements ReplicaPersistence {
|
|
27
54
|
private db: IDBDatabase | null = null;
|
|
28
55
|
private dbName: string;
|
|
29
56
|
|
|
@@ -330,7 +357,7 @@ export class IndexedDBPersistence {
|
|
|
330
357
|
* A change with no `data` is a no-op and counts as durable.
|
|
331
358
|
*/
|
|
332
359
|
export async function persistChange(
|
|
333
|
-
persistence:
|
|
360
|
+
persistence: ReplicaPersistence,
|
|
334
361
|
change: ChangeEvent
|
|
335
362
|
): Promise<boolean> {
|
|
336
363
|
switch (change.kind) {
|
package/src/session-resolver.ts
CHANGED
|
@@ -50,6 +50,7 @@ const EMPTY_SESSION: ResolvedSession = {
|
|
|
50
50
|
|
|
51
51
|
export class SessionResolver {
|
|
52
52
|
private _resolved: ResolvedSession = EMPTY_SESSION;
|
|
53
|
+
private _observed = false;
|
|
53
54
|
/** `undefined` until the first observation — distinguishes "we've
|
|
54
55
|
* never seen a token" from "the token is null." Same for tenant. */
|
|
55
56
|
private lastSeenToken: string | null | undefined = undefined;
|
|
@@ -110,9 +111,17 @@ export class SessionResolver {
|
|
|
110
111
|
observeSession(next: ResolvedSession): SessionTransition {
|
|
111
112
|
const verdict = this.inspectSession(next);
|
|
112
113
|
this.commitObservation(next);
|
|
114
|
+
this._observed = true;
|
|
113
115
|
return verdict;
|
|
114
116
|
}
|
|
115
117
|
|
|
118
|
+
/** Has /api/auth/me actually resolved THIS run? False on an offline
|
|
119
|
+
* start — callers that would destroy state on an identity mismatch
|
|
120
|
+
* (replica wipes) must not act on the EMPTY placeholder session. */
|
|
121
|
+
hasObserved(): boolean {
|
|
122
|
+
return this._observed;
|
|
123
|
+
}
|
|
124
|
+
|
|
116
125
|
/** Feed in the current bearer token. Returns whether it flipped
|
|
117
126
|
* since the previous observation. The engine uses this in pull()
|
|
118
127
|
* to decide whether to reset before reading the cursor. */
|