@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 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
  /**
@@ -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: IndexedDBPersistence, change: ChangeEvent): Promise<boolean>;
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
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.316",
6
+ "version": "0.3.318",
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
9
9
  "types": "./dist/index.d.ts",
@@ -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").IndexedDBPersistence | null = null;
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 = this.config.persist !== false && typeof indexedDB !== "undefined";
640
+ const shouldPersist =
641
+ this.config.persist !== false &&
642
+ (this.config.persistence != null || typeof indexedDB !== "undefined");
633
643
  if (shouldPersist) {
634
644
  try {
635
- const { IndexedDBPersistence } = await import("./persistence");
636
- this.persistence = new IndexedDBPersistence(this.config.appName);
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("./persistence");
731
- const mqPersistence = new IndexedDBMutationPersistence(persistence);
732
- this.mutations.attachPersistence(mqPersistence);
733
- await this.mutations.hydrate();
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.
@@ -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: IndexedDBPersistence,
360
+ persistence: ReplicaPersistence,
334
361
  change: ChangeEvent
335
362
  ): Promise<boolean> {
336
363
  switch (change.kind) {
@@ -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. */