@pylonsync/react-native 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/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": "src/index.ts",
@@ -12,9 +12,9 @@
12
12
  "check": "tsc -p tsconfig.json --noEmit"
13
13
  },
14
14
  "dependencies": {
15
- "@pylonsync/react": "0.3.316",
16
- "@pylonsync/sdk": "0.3.316",
17
- "@pylonsync/sync": "0.3.316"
15
+ "@pylonsync/react": "0.3.318",
16
+ "@pylonsync/sdk": "0.3.318",
17
+ "@pylonsync/sync": "0.3.318"
18
18
  },
19
19
  "peerDependencies": {
20
20
  "react": ">=19",
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  } from "@pylonsync/react";
24
24
  import type { SyncEngineConfig } from "@pylonsync/sync";
25
25
  import { createAsyncStorageBridge } from "./storage";
26
+ import { AsyncStorageReplicaPersistence } from "./replica-persistence";
26
27
 
27
28
  // All hooks, db, callFn, configureClient, etc.
28
29
  export * from "@pylonsync/react";
@@ -30,6 +31,7 @@ export * from "@pylonsync/react";
30
31
  // React Native specific.
31
32
  export { useNetworkStatus } from "./useNetworkStatus";
32
33
  export type { NetworkStatus } from "./useNetworkStatus";
34
+ export { AsyncStorageReplicaPersistence } from "./replica-persistence";
33
35
  export {
34
36
  AsyncStoragePersistence,
35
37
  OfflineStore,
@@ -55,5 +57,13 @@ export async function init(
55
57
  ): Promise<void> {
56
58
  const storage = await createAsyncStorageBridge();
57
59
  setReactStorage(storage);
58
- reactInit({ ...config, storage });
60
+ // Durable replica by default — without it a cold offline launch renders
61
+ // an empty store (RN has no IndexedDB for the engine to fall back to).
62
+ // Callers can still pass their own adapter or `persist: false`.
63
+ const persistence =
64
+ config?.persistence ??
65
+ (config?.persist === false
66
+ ? undefined
67
+ : new AsyncStorageReplicaPersistence(config?.appName ?? "default"));
68
+ reactInit({ ...config, storage, persistence });
59
69
  }
@@ -0,0 +1,167 @@
1
+ import AsyncStorage from "@react-native-async-storage/async-storage";
2
+ import type { ReplicaPersistence, Row, SyncCursor } from "@pylonsync/sync";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // AsyncStorage-backed replica persistence
6
+ //
7
+ // The engine's durable replica on React Native. Without this, an RN app's
8
+ // replica was memory-only: a cold launch with no connectivity (the gym
9
+ // basement — the core Spotter-class use case) rendered an empty store
10
+ // until the network came back.
11
+ //
12
+ // Layout: one JSON blob per entity (`<prefix>:e:<Entity>`) plus cursor +
13
+ // identity keys. Writes go through an in-memory mirror with per-entity
14
+ // coalescing: a burst of `saveRow`s (a pull page applying) collapses into
15
+ // one AsyncStorage write per entity per tick, and callers' promises
16
+ // resolve with that write's real durability — the engine holds the
17
+ // persisted cursor back when a row write fails, so a crash can never
18
+ // leave the cursor ahead of what's actually on disk (the same contract
19
+ // the IndexedDB backend documents).
20
+ // ---------------------------------------------------------------------------
21
+
22
+ const CURSOR_KEY = "cursor";
23
+ const IDENTITY_KEY = "identity";
24
+
25
+ export class AsyncStorageReplicaPersistence implements ReplicaPersistence {
26
+ private prefix: string;
27
+ private mirror = new Map<string, Map<string, Row>>();
28
+ private pendingFlush = new Map<string, Promise<boolean>>();
29
+ private loaded = false;
30
+
31
+ constructor(appName = "default") {
32
+ this.prefix = `pylon:${appName}:replica`;
33
+ }
34
+
35
+ private key(suffix: string): string {
36
+ return `${this.prefix}:${suffix}`;
37
+ }
38
+
39
+ async open(): Promise<void> {
40
+ if (this.loaded) return;
41
+ const allKeys = await AsyncStorage.getAllKeys();
42
+ const entityKeys = allKeys.filter((k) => k.startsWith(this.key("e:")));
43
+ if (entityKeys.length > 0) {
44
+ // Only the surface every async-storage major ships: per-key gets.
45
+ const values = await Promise.all(entityKeys.map((k) => AsyncStorage.getItem(k)));
46
+ for (let i = 0; i < entityKeys.length; i++) {
47
+ const k = entityKeys[i];
48
+ const v = values[i];
49
+ if (!v) continue;
50
+ const entity = k.slice(this.key("e:").length);
51
+ try {
52
+ const rows = JSON.parse(v) as Row[];
53
+ const m = new Map<string, Row>();
54
+ for (const r of rows) {
55
+ const id = r.id;
56
+ if (typeof id === "string") m.set(id, r);
57
+ }
58
+ this.mirror.set(entity, m);
59
+ } catch {
60
+ // A corrupt blob loses that entity's cache, not the session.
61
+ }
62
+ }
63
+ }
64
+ this.loaded = true;
65
+ }
66
+
67
+ async loadSnapshot(): Promise<{
68
+ entities: Record<string, Row[]>;
69
+ cursor: SyncCursor | null;
70
+ hadCache: boolean;
71
+ }> {
72
+ await this.open();
73
+ const entities: Record<string, Row[]> = {};
74
+ for (const [entity, rows] of this.mirror) {
75
+ entities[entity] = [...rows.values()];
76
+ }
77
+ let cursor: SyncCursor | null = null;
78
+ const rawCursor = await AsyncStorage.getItem(this.key(CURSOR_KEY));
79
+ if (rawCursor) {
80
+ try {
81
+ cursor = JSON.parse(rawCursor) as SyncCursor;
82
+ } catch {
83
+ cursor = null;
84
+ }
85
+ }
86
+ return {
87
+ entities,
88
+ cursor,
89
+ hadCache: this.mirror.size > 0 || cursor != null,
90
+ };
91
+ }
92
+
93
+ async loadIdentity(): Promise<string | null | undefined> {
94
+ const raw = await AsyncStorage.getItem(this.key(IDENTITY_KEY));
95
+ if (raw == null) return undefined; // never recorded
96
+ return raw === "" ? null : raw; // "" encodes anonymous
97
+ }
98
+
99
+ async saveIdentity(userId: string | null): Promise<boolean> {
100
+ try {
101
+ await AsyncStorage.setItem(this.key(IDENTITY_KEY), userId ?? "");
102
+ return true;
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ async saveCursor(cursor: SyncCursor): Promise<boolean> {
109
+ try {
110
+ await AsyncStorage.setItem(this.key(CURSOR_KEY), JSON.stringify(cursor));
111
+ return true;
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+
117
+ async saveRow(entity: string, id: string, data: Row): Promise<boolean> {
118
+ let m = this.mirror.get(entity);
119
+ if (!m) {
120
+ m = new Map();
121
+ this.mirror.set(entity, m);
122
+ }
123
+ m.set(id, data);
124
+ return this.flushEntity(entity);
125
+ }
126
+
127
+ async deleteRow(entity: string, id: string): Promise<boolean> {
128
+ const m = this.mirror.get(entity);
129
+ if (m) m.delete(id);
130
+ return this.flushEntity(entity);
131
+ }
132
+
133
+ /** Coalesce a same-tick burst of writes into one AsyncStorage set per
134
+ * entity; every caller in the burst shares the write's durability. */
135
+ private flushEntity(entity: string): Promise<boolean> {
136
+ const existing = this.pendingFlush.get(entity);
137
+ if (existing) return existing;
138
+ const p = (async () => {
139
+ await Promise.resolve(); // let the burst finish mutating the mirror
140
+ this.pendingFlush.delete(entity);
141
+ const m = this.mirror.get(entity);
142
+ try {
143
+ await AsyncStorage.setItem(
144
+ this.key(`e:${entity}`),
145
+ JSON.stringify(m ? [...m.values()] : []),
146
+ );
147
+ return true;
148
+ } catch {
149
+ return false; // quota/etc — engine holds the cursor back
150
+ }
151
+ })();
152
+ this.pendingFlush.set(entity, p);
153
+ return p;
154
+ }
155
+
156
+ async clear(): Promise<boolean> {
157
+ this.mirror.clear();
158
+ try {
159
+ const allKeys = await AsyncStorage.getAllKeys();
160
+ const mine = allKeys.filter((k) => k.startsWith(this.prefix));
161
+ await Promise.all(mine.map((k) => AsyncStorage.removeItem(k)));
162
+ return true;
163
+ } catch {
164
+ return false;
165
+ }
166
+ }
167
+ }