@pylonsync/sync 0.2.4
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 +13 -0
- package/src/index.ts +1543 -0
- package/src/persistence.ts +283 -0
- package/src/storage.ts +119 -0
- package/tsconfig.json +4 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Row,
|
|
3
|
+
ChangeEvent,
|
|
4
|
+
SyncCursor,
|
|
5
|
+
MutationQueuePersistence,
|
|
6
|
+
PendingMutation,
|
|
7
|
+
} from "./index";
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// IndexedDB persistence layer
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
// Bumped DB_VERSION: version 2 adds a pendingMutations object store so the
|
|
14
|
+
// offline queue survives restarts. The upgrade handler below creates it on
|
|
15
|
+
// existing databases — users never lose their entity mirror.
|
|
16
|
+
const DB_NAME = "pylon_sync";
|
|
17
|
+
const DB_VERSION = 2;
|
|
18
|
+
const STORE_NAME = "entities";
|
|
19
|
+
const CURSOR_STORE = "cursors";
|
|
20
|
+
const MUTATIONS_STORE = "pendingMutations";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* IndexedDB-backed persistence for the sync store.
|
|
24
|
+
* Saves entity rows and sync cursor so data survives page refresh.
|
|
25
|
+
*/
|
|
26
|
+
export class IndexedDBPersistence {
|
|
27
|
+
private db: IDBDatabase | null = null;
|
|
28
|
+
private dbName: string;
|
|
29
|
+
|
|
30
|
+
/** Shared connection. Exposed so sibling persistence classes (e.g. the
|
|
31
|
+
* mutation-queue backend) can reuse the same IDBDatabase — IndexedDB only
|
|
32
|
+
* permits one open handle per (origin, db) at a time while upgrades run.
|
|
33
|
+
*/
|
|
34
|
+
get connection(): IDBDatabase | null {
|
|
35
|
+
return this.db;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
constructor(appName = "default") {
|
|
39
|
+
this.dbName = `${DB_NAME}_${appName}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async open(): Promise<void> {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const request = indexedDB.open(this.dbName, DB_VERSION);
|
|
45
|
+
|
|
46
|
+
request.onupgradeneeded = () => {
|
|
47
|
+
const db = request.result;
|
|
48
|
+
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
49
|
+
db.createObjectStore(STORE_NAME, { keyPath: "_key" });
|
|
50
|
+
}
|
|
51
|
+
if (!db.objectStoreNames.contains(CURSOR_STORE)) {
|
|
52
|
+
db.createObjectStore(CURSOR_STORE, { keyPath: "key" });
|
|
53
|
+
}
|
|
54
|
+
// v2: durable offline mutation queue.
|
|
55
|
+
if (!db.objectStoreNames.contains(MUTATIONS_STORE)) {
|
|
56
|
+
db.createObjectStore(MUTATIONS_STORE, { keyPath: "id" });
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
request.onsuccess = () => {
|
|
61
|
+
this.db = request.result;
|
|
62
|
+
// If another tab later bumps the version, the browser fires
|
|
63
|
+
// `versionchange` on this handle. Close it so the other tab's
|
|
64
|
+
// upgrade can proceed — otherwise THEIR start() hangs on our
|
|
65
|
+
// stale handle. Our app will see the underlying reads fail and
|
|
66
|
+
// degrade to memory-only gracefully.
|
|
67
|
+
this.db.onversionchange = () => {
|
|
68
|
+
this.db?.close();
|
|
69
|
+
this.db = null;
|
|
70
|
+
};
|
|
71
|
+
resolve();
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// `onblocked` fires when we try to upgrade but another tab holds
|
|
75
|
+
// an older-version connection open. Rejecting here (rather than
|
|
76
|
+
// waiting forever) lets `start()` fall back to memory-only mode,
|
|
77
|
+
// which is still functional for the current session. The next tab
|
|
78
|
+
// reload after the other tab closes will pick up the new version.
|
|
79
|
+
request.onblocked = () => {
|
|
80
|
+
reject(new Error("IndexedDB upgrade blocked by another open connection"));
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
request.onerror = () => {
|
|
84
|
+
reject(new Error("Failed to open IndexedDB"));
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Save a row to IndexedDB. */
|
|
90
|
+
async saveRow(entity: string, id: string, data: Row): Promise<void> {
|
|
91
|
+
if (!this.db) return;
|
|
92
|
+
const tx = this.db.transaction(STORE_NAME, "readwrite");
|
|
93
|
+
const store = tx.objectStore(STORE_NAME);
|
|
94
|
+
store.put({ _key: `${entity}:${id}`, entity, id, data });
|
|
95
|
+
return new Promise((resolve) => {
|
|
96
|
+
tx.oncomplete = () => resolve();
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Fetch a row from IndexedDB by key. Used by `persistChange` on update
|
|
101
|
+
* events to merge the patch against what's already on disk. */
|
|
102
|
+
async getRow(entity: string, id: string): Promise<Row | null> {
|
|
103
|
+
if (!this.db) return null;
|
|
104
|
+
const tx = this.db.transaction(STORE_NAME, "readonly");
|
|
105
|
+
const store = tx.objectStore(STORE_NAME);
|
|
106
|
+
const request = store.get(`${entity}:${id}`);
|
|
107
|
+
return new Promise((resolve) => {
|
|
108
|
+
request.onsuccess = () => {
|
|
109
|
+
const rec = request.result as { data?: Row } | undefined;
|
|
110
|
+
resolve(rec?.data ?? null);
|
|
111
|
+
};
|
|
112
|
+
request.onerror = () => resolve(null);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Delete a row from IndexedDB. */
|
|
117
|
+
async deleteRow(entity: string, id: string): Promise<void> {
|
|
118
|
+
if (!this.db) return;
|
|
119
|
+
const tx = this.db.transaction(STORE_NAME, "readwrite");
|
|
120
|
+
const store = tx.objectStore(STORE_NAME);
|
|
121
|
+
store.delete(`${entity}:${id}`);
|
|
122
|
+
return new Promise((resolve) => {
|
|
123
|
+
tx.oncomplete = () => resolve();
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Load all rows for an entity from IndexedDB. */
|
|
128
|
+
async loadAll(entity: string): Promise<Row[]> {
|
|
129
|
+
if (!this.db) return [];
|
|
130
|
+
const tx = this.db.transaction(STORE_NAME, "readonly");
|
|
131
|
+
const store = tx.objectStore(STORE_NAME);
|
|
132
|
+
const request = store.getAll();
|
|
133
|
+
|
|
134
|
+
return new Promise((resolve) => {
|
|
135
|
+
request.onsuccess = () => {
|
|
136
|
+
const rows = (request.result as { entity: string; id: string; data: Row }[])
|
|
137
|
+
.filter((r) => r.entity === entity)
|
|
138
|
+
.map((r) => ({ id: r.id, ...r.data }));
|
|
139
|
+
resolve(rows);
|
|
140
|
+
};
|
|
141
|
+
request.onerror = () => resolve([]);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Load all entities and their rows from IndexedDB. */
|
|
146
|
+
async loadAllEntities(): Promise<Record<string, Row[]>> {
|
|
147
|
+
if (!this.db) return {};
|
|
148
|
+
const tx = this.db.transaction(STORE_NAME, "readonly");
|
|
149
|
+
const store = tx.objectStore(STORE_NAME);
|
|
150
|
+
const request = store.getAll();
|
|
151
|
+
|
|
152
|
+
return new Promise((resolve) => {
|
|
153
|
+
request.onsuccess = () => {
|
|
154
|
+
const result: Record<string, Row[]> = {};
|
|
155
|
+
for (const item of request.result as { entity: string; id: string; data: Row }[]) {
|
|
156
|
+
if (!result[item.entity]) result[item.entity] = [];
|
|
157
|
+
result[item.entity].push({ id: item.id, ...item.data });
|
|
158
|
+
}
|
|
159
|
+
resolve(result);
|
|
160
|
+
};
|
|
161
|
+
request.onerror = () => resolve({});
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Save the sync cursor. */
|
|
166
|
+
async saveCursor(cursor: SyncCursor): Promise<void> {
|
|
167
|
+
if (!this.db) return;
|
|
168
|
+
const tx = this.db.transaction(CURSOR_STORE, "readwrite");
|
|
169
|
+
const store = tx.objectStore(CURSOR_STORE);
|
|
170
|
+
store.put({ key: "cursor", ...cursor });
|
|
171
|
+
return new Promise((resolve) => {
|
|
172
|
+
tx.oncomplete = () => resolve();
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Load the sync cursor. */
|
|
177
|
+
async loadCursor(): Promise<SyncCursor | null> {
|
|
178
|
+
if (!this.db) return null;
|
|
179
|
+
const tx = this.db.transaction(CURSOR_STORE, "readonly");
|
|
180
|
+
const store = tx.objectStore(CURSOR_STORE);
|
|
181
|
+
const request = store.get("cursor");
|
|
182
|
+
|
|
183
|
+
return new Promise((resolve) => {
|
|
184
|
+
request.onsuccess = () => {
|
|
185
|
+
if (request.result) {
|
|
186
|
+
resolve({ last_seq: request.result.last_seq ?? 0 });
|
|
187
|
+
} else {
|
|
188
|
+
resolve(null);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
request.onerror = () => resolve(null);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Clear all stored data. */
|
|
196
|
+
async clear(): Promise<void> {
|
|
197
|
+
if (!this.db) return;
|
|
198
|
+
const tx = this.db.transaction([STORE_NAME, CURSOR_STORE], "readwrite");
|
|
199
|
+
tx.objectStore(STORE_NAME).clear();
|
|
200
|
+
tx.objectStore(CURSOR_STORE).clear();
|
|
201
|
+
return new Promise((resolve) => {
|
|
202
|
+
tx.oncomplete = () => resolve();
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Apply a change event to IndexedDB persistence.
|
|
209
|
+
*/
|
|
210
|
+
export async function persistChange(
|
|
211
|
+
persistence: IndexedDBPersistence,
|
|
212
|
+
change: ChangeEvent
|
|
213
|
+
): Promise<void> {
|
|
214
|
+
switch (change.kind) {
|
|
215
|
+
case "insert":
|
|
216
|
+
case "update":
|
|
217
|
+
if (change.data) {
|
|
218
|
+
// Callers upstream (LocalStore.applyChanges/Async) hydrate the
|
|
219
|
+
// change's `data` with the post-merge row from memory — so even
|
|
220
|
+
// on update the full row lands here. Overwriting is correct;
|
|
221
|
+
// pre-merge patches would have dropped unpatched columns.
|
|
222
|
+
await persistence.saveRow(change.entity, change.row_id, change.data);
|
|
223
|
+
}
|
|
224
|
+
break;
|
|
225
|
+
case "delete":
|
|
226
|
+
await persistence.deleteRow(change.entity, change.row_id);
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// Mutation-queue persistence (the durable offline write buffer)
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* IndexedDB-backed implementation of `MutationQueuePersistence`. Wires the
|
|
237
|
+
* `MutationQueue` into the same database as the entity mirror so everything
|
|
238
|
+
* the app needs to resume a session lives in one place.
|
|
239
|
+
*
|
|
240
|
+
* `saveAll` writes the entire queue on every change. That's O(n) per write,
|
|
241
|
+
* but `n` is bounded by "how many mutations the user queued while offline",
|
|
242
|
+
* which is tiny in practice. If that ever becomes a bottleneck, switch to
|
|
243
|
+
* per-id `put`/`delete` — the schema (`keyPath: "id"`) already supports it.
|
|
244
|
+
*/
|
|
245
|
+
export class IndexedDBMutationPersistence implements MutationQueuePersistence {
|
|
246
|
+
private db: IDBDatabase | null = null;
|
|
247
|
+
|
|
248
|
+
constructor(private readonly parent: IndexedDBPersistence) {}
|
|
249
|
+
|
|
250
|
+
private handle(): IDBDatabase | null {
|
|
251
|
+
return this.parent.connection;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async saveAll(mutations: PendingMutation[]): Promise<void> {
|
|
255
|
+
const db = this.handle();
|
|
256
|
+
if (!db) return;
|
|
257
|
+
const tx = db.transaction(MUTATIONS_STORE, "readwrite");
|
|
258
|
+
const store = tx.objectStore(MUTATIONS_STORE);
|
|
259
|
+
// Clear then re-put everything. Simpler than diffing, and correct under
|
|
260
|
+
// the "save-full-snapshot on every change" contract.
|
|
261
|
+
store.clear();
|
|
262
|
+
for (const m of mutations) {
|
|
263
|
+
store.put(m);
|
|
264
|
+
}
|
|
265
|
+
return new Promise((resolve, reject) => {
|
|
266
|
+
tx.oncomplete = () => resolve();
|
|
267
|
+
tx.onerror = () => reject(tx.error ?? new Error("mutation queue save failed"));
|
|
268
|
+
tx.onabort = () => reject(tx.error ?? new Error("mutation queue save aborted"));
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async loadAll(): Promise<PendingMutation[]> {
|
|
273
|
+
const db = this.handle();
|
|
274
|
+
if (!db) return [];
|
|
275
|
+
const tx = db.transaction(MUTATIONS_STORE, "readonly");
|
|
276
|
+
const store = tx.objectStore(MUTATIONS_STORE);
|
|
277
|
+
return new Promise((resolve, reject) => {
|
|
278
|
+
const req = store.getAll();
|
|
279
|
+
req.onsuccess = () => resolve((req.result as PendingMutation[]) ?? []);
|
|
280
|
+
req.onerror = () => reject(req.error ?? new Error("mutation queue load failed"));
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Storage — synchronous key-value adapter for small, hot pieces of state
|
|
3
|
+
// (auth token, client_id). Pluggable so non-browser hosts can swap in their
|
|
4
|
+
// own backend without forking the sync engine.
|
|
5
|
+
//
|
|
6
|
+
// Why synchronous? `connectWs()` and `currentToken()` are called on hot
|
|
7
|
+
// paths where awaiting a Promise would force the engine to be async-all-the-
|
|
8
|
+
// way-down. The web has localStorage (sync). RN/Tauri/Workers can wrap an
|
|
9
|
+
// async backend (AsyncStorage / Tauri-store / KV) by reading the value into
|
|
10
|
+
// memory at startup and writing through.
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
export interface Storage {
|
|
14
|
+
get(key: string): string | null;
|
|
15
|
+
set(key: string, value: string): void;
|
|
16
|
+
remove(key: string): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Default: localStorage when available, in-memory no-op otherwise.
|
|
21
|
+
//
|
|
22
|
+
// The no-op variant lets the engine boot in SSR, Workers, and Node test
|
|
23
|
+
// environments without crashing on `window` references. Tokens won't survive
|
|
24
|
+
// process restarts in that mode — callers wanting persistence in those
|
|
25
|
+
// environments inject their own adapter.
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
class LocalStorageAdapter implements Storage {
|
|
29
|
+
get(key: string): string | null {
|
|
30
|
+
try {
|
|
31
|
+
return window.localStorage.getItem(key);
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
set(key: string, value: string): void {
|
|
37
|
+
try {
|
|
38
|
+
window.localStorage.setItem(key, value);
|
|
39
|
+
} catch {
|
|
40
|
+
/* quota exceeded, private mode, etc — drop write */
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
remove(key: string): void {
|
|
44
|
+
try {
|
|
45
|
+
window.localStorage.removeItem(key);
|
|
46
|
+
} catch {
|
|
47
|
+
/* swallow */
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
class MemoryStorageAdapter implements Storage {
|
|
53
|
+
private map = new Map<string, string>();
|
|
54
|
+
get(key: string): string | null {
|
|
55
|
+
return this.map.get(key) ?? null;
|
|
56
|
+
}
|
|
57
|
+
set(key: string, value: string): void {
|
|
58
|
+
this.map.set(key, value);
|
|
59
|
+
}
|
|
60
|
+
remove(key: string): void {
|
|
61
|
+
this.map.delete(key);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Pick a default storage adapter for the current host. Returns a real
|
|
67
|
+
* localStorage wrapper in browsers, an in-memory map elsewhere. Apps that
|
|
68
|
+
* need persistence on non-browser hosts (RN, Tauri, Electron) should pass
|
|
69
|
+
* their own adapter via `SyncEngineConfig.storage` and skip this default.
|
|
70
|
+
*/
|
|
71
|
+
export function defaultStorage(): Storage {
|
|
72
|
+
try {
|
|
73
|
+
if (typeof window !== "undefined" && window.localStorage) {
|
|
74
|
+
// Probe — Safari in private mode throws on the first write.
|
|
75
|
+
const probe = "__pylon_probe__";
|
|
76
|
+
window.localStorage.setItem(probe, "1");
|
|
77
|
+
window.localStorage.removeItem(probe);
|
|
78
|
+
return new LocalStorageAdapter();
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
/* localStorage exists but is locked down */
|
|
82
|
+
}
|
|
83
|
+
return new MemoryStorageAdapter();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build a `Storage` wrapper around an async backend (AsyncStorage,
|
|
88
|
+
* Tauri-store, etc). The host is responsible for hydrating `seed` from
|
|
89
|
+
* the async backend at startup and for persisting writes on its own
|
|
90
|
+
* schedule. The wrapper itself stays synchronous so the engine doesn't
|
|
91
|
+
* change shape per platform.
|
|
92
|
+
*
|
|
93
|
+
* Typical RN wiring:
|
|
94
|
+
* ```ts
|
|
95
|
+
* const seed = await AsyncStorage.multiGet(KEYS).then(toRecord);
|
|
96
|
+
* const storage = createWriteThroughStorage(seed, async (k, v) => {
|
|
97
|
+
* if (v === null) await AsyncStorage.removeItem(k);
|
|
98
|
+
* else await AsyncStorage.setItem(k, v);
|
|
99
|
+
* });
|
|
100
|
+
* init({ baseUrl, storage });
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
103
|
+
export function createWriteThroughStorage(
|
|
104
|
+
seed: Record<string, string> | null,
|
|
105
|
+
onWrite: (key: string, value: string | null) => void,
|
|
106
|
+
): Storage {
|
|
107
|
+
const map = new Map<string, string>(Object.entries(seed ?? {}));
|
|
108
|
+
return {
|
|
109
|
+
get: (k) => map.get(k) ?? null,
|
|
110
|
+
set: (k, v) => {
|
|
111
|
+
map.set(k, v);
|
|
112
|
+
onWrite(k, v);
|
|
113
|
+
},
|
|
114
|
+
remove: (k) => {
|
|
115
|
+
map.delete(k);
|
|
116
|
+
onWrite(k, null);
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
package/tsconfig.json
ADDED