@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.
@@ -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
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "include": ["src"]
4
+ }