@pylonsync/react-native 0.3.316 → 0.3.317
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 +4 -4
- package/src/index.ts +11 -1
- package/src/replica-persistence.ts +167 -0
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.3.
|
|
6
|
+
"version": "0.3.317",
|
|
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.
|
|
16
|
-
"@pylonsync/sdk": "0.3.
|
|
17
|
-
"@pylonsync/sync": "0.3.
|
|
15
|
+
"@pylonsync/react": "0.3.317",
|
|
16
|
+
"@pylonsync/sdk": "0.3.317",
|
|
17
|
+
"@pylonsync/sync": "0.3.317"
|
|
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
|
-
|
|
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
|
+
}
|