@nexus_js/sync 0.6.0
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/LICENSE +21 -0
- package/README.md +17 -0
- package/package.json +42 -0
- package/src/engine.ts +309 -0
- package/src/index.ts +25 -0
- package/src/rune.ts +140 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nexus Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# @nexus_js/sync
|
|
2
|
+
|
|
3
|
+
Nexus Local-First Sync Engine — IndexedDB-backed optimistic mutations with conflict resolution.
|
|
4
|
+
|
|
5
|
+
## Documentation
|
|
6
|
+
|
|
7
|
+
All guides, API reference, and examples live on **[nexusjs.dev](https://nexusjs.dev)**.
|
|
8
|
+
|
|
9
|
+
## Links
|
|
10
|
+
|
|
11
|
+
- **Website:** [https://nexusjs.dev](https://nexusjs.dev)
|
|
12
|
+
- **Repository:** [github.com/bierfor/nexus](https://github.com/bierfor/nexus) (see `packages/sync/`)
|
|
13
|
+
- **Issues:** [github.com/bierfor/nexus/issues](https://github.com/bierfor/nexus/issues)
|
|
14
|
+
|
|
15
|
+
## License
|
|
16
|
+
|
|
17
|
+
MIT © Nexus contributors
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nexus_js/sync",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Nexus Local-First Sync Engine — IndexedDB-backed optimistic mutations with conflict resolution",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"nexus",
|
|
12
|
+
"local-first",
|
|
13
|
+
"sync",
|
|
14
|
+
"indexeddb",
|
|
15
|
+
"offline",
|
|
16
|
+
"framework",
|
|
17
|
+
"full-stack",
|
|
18
|
+
"svelte",
|
|
19
|
+
"islands",
|
|
20
|
+
"ssr",
|
|
21
|
+
"vite",
|
|
22
|
+
"server-actions"
|
|
23
|
+
],
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"homepage": "https://nexusjs.dev",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "https://github.com/bierfor/nexus.git",
|
|
29
|
+
"directory": "packages/sync"
|
|
30
|
+
},
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/bierfor/nexus/issues"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"src",
|
|
36
|
+
"README.md"
|
|
37
|
+
],
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public",
|
|
40
|
+
"registry": "https://registry.npmjs.org/"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nexus Local-First Sync Engine.
|
|
3
|
+
*
|
|
4
|
+
* Inspired by Replicache / ElectricSQL. Uses IndexedDB as the local store.
|
|
5
|
+
*
|
|
6
|
+
* Guarantees:
|
|
7
|
+
* - Writes are immediate (IndexedDB) — zero perceived latency for the user.
|
|
8
|
+
* - A "pending ops" queue is maintained. When online, ops are flushed to the
|
|
9
|
+
* server. When offline, they accumulate and are retried on reconnect.
|
|
10
|
+
* - Conflict resolution is pluggable via onConflict hook.
|
|
11
|
+
* - All pending ops survive page refreshes (persisted in IDB, not memory).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const DB_NAME = 'nexus_sync';
|
|
15
|
+
const DB_VERSION = 1;
|
|
16
|
+
const STORES = { data: 'data', ops: 'pending_ops' } as const;
|
|
17
|
+
|
|
18
|
+
export type SyncStatus = 'synced' | 'pending' | 'syncing' | 'error' | 'offline';
|
|
19
|
+
|
|
20
|
+
export interface SyncOp<T = unknown> {
|
|
21
|
+
id: string; // uuid for idempotency
|
|
22
|
+
store: string; // logical collection name
|
|
23
|
+
type: 'put' | 'delete'; // operation type
|
|
24
|
+
key: string; // record key within collection
|
|
25
|
+
data?: T; // payload (undefined for deletes)
|
|
26
|
+
ts: number; // client timestamp (for ordering)
|
|
27
|
+
retries: number; // how many sync attempts have failed
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ConflictInfo<T> {
|
|
31
|
+
local: T; // what the client has
|
|
32
|
+
remote: T; // what the server returned
|
|
33
|
+
op: SyncOp<T>; // the pending op that caused the conflict
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SyncCollectionOpts<T> {
|
|
37
|
+
/** Server endpoint to flush ops to (POST). Receives `{ ops: SyncOp[] }`. */
|
|
38
|
+
endpoint: string;
|
|
39
|
+
/**
|
|
40
|
+
* Called when the server returns a conflict for an op.
|
|
41
|
+
* Return the version that should win. Default: local wins.
|
|
42
|
+
*/
|
|
43
|
+
onConflict?: (info: ConflictInfo<T>) => T | Promise<T>;
|
|
44
|
+
/** Max retries before an op is moved to "dead letter" (logged, dropped). */
|
|
45
|
+
maxRetries?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── IDB helpers ───────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
function openDB(): Promise<IDBDatabase> {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
|
53
|
+
req.onupgradeneeded = () => {
|
|
54
|
+
const db = req.result;
|
|
55
|
+
if (!db.objectStoreNames.contains(STORES.data)) {
|
|
56
|
+
db.createObjectStore(STORES.data);
|
|
57
|
+
}
|
|
58
|
+
if (!db.objectStoreNames.contains(STORES.ops)) {
|
|
59
|
+
const ops = db.createObjectStore(STORES.ops, { keyPath: 'id' });
|
|
60
|
+
ops.createIndex('store', 'store');
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
req.onsuccess = () => resolve(req.result);
|
|
64
|
+
req.onerror = () => reject(req.error);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function idbGet<T>(db: IDBDatabase, storeName: string, key: string): Promise<T | undefined> {
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
const tx = db.transaction(storeName, 'readonly');
|
|
71
|
+
const req = tx.objectStore(storeName).get(key);
|
|
72
|
+
req.onsuccess = () => resolve(req.result as T | undefined);
|
|
73
|
+
req.onerror = () => reject(req.error);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function idbPut(db: IDBDatabase, storeName: string, key: string, value: unknown): Promise<void> {
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
const tx = db.transaction(storeName, 'readwrite');
|
|
80
|
+
const req = tx.objectStore(storeName).put(value, key);
|
|
81
|
+
req.onsuccess = () => resolve();
|
|
82
|
+
req.onerror = () => reject(req.error);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function idbDelete(db: IDBDatabase, storeName: string, key: string): Promise<void> {
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
const tx = db.transaction(storeName, 'readwrite');
|
|
89
|
+
const req = tx.objectStore(storeName).delete(key);
|
|
90
|
+
req.onsuccess = () => resolve();
|
|
91
|
+
req.onerror = () => reject(req.error);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function idbGetAll<T>(db: IDBDatabase, storeName: string): Promise<T[]> {
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
const tx = db.transaction(storeName, 'readonly');
|
|
98
|
+
const req = tx.objectStore(storeName).getAll();
|
|
99
|
+
req.onsuccess = () => resolve(req.result as T[]);
|
|
100
|
+
req.onerror = () => reject(req.error);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Sync Engine ───────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
export class NexusSyncEngine {
|
|
107
|
+
private db: IDBDatabase | null = null;
|
|
108
|
+
private flushing: boolean = false;
|
|
109
|
+
private listeners: Set<() => void> = new Set();
|
|
110
|
+
status: SyncStatus = 'synced';
|
|
111
|
+
|
|
112
|
+
async init(): Promise<void> {
|
|
113
|
+
if (this.db) return;
|
|
114
|
+
this.db = await openDB();
|
|
115
|
+
this.#startNetworkWatcher();
|
|
116
|
+
// Flush any ops that survived a page refresh
|
|
117
|
+
if (navigator.onLine) void this.flush();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private ensureDB(): IDBDatabase {
|
|
121
|
+
if (!this.db) throw new Error('[Nexus Sync] Engine not initialized. Call await sync.init()');
|
|
122
|
+
return this.db;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Read the local (IndexedDB) value for a key in a collection. */
|
|
126
|
+
async get<T>(collection: string, key: string): Promise<T | undefined> {
|
|
127
|
+
return idbGet<T>(this.ensureDB(), STORES.data, `${collection}:${key}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Read all values in a collection. */
|
|
131
|
+
async getAll<T>(collection: string): Promise<T[]> {
|
|
132
|
+
const db = this.ensureDB();
|
|
133
|
+
const all = await idbGetAll<{ key: string; value: T }>(db, STORES.data);
|
|
134
|
+
const prefix = `${collection}:`;
|
|
135
|
+
return all
|
|
136
|
+
.filter((r) => (r as unknown as { _idbKey?: string })._idbKey?.startsWith(prefix) ?? false)
|
|
137
|
+
.map((r) => r.value);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Write a value locally (instant) and enqueue a server sync op.
|
|
142
|
+
* The UI is updated immediately — the server sync happens in the background.
|
|
143
|
+
*/
|
|
144
|
+
async put<T>(
|
|
145
|
+
collection: string,
|
|
146
|
+
key: string,
|
|
147
|
+
value: T,
|
|
148
|
+
endpoint: string,
|
|
149
|
+
): Promise<void> {
|
|
150
|
+
const db = this.ensureDB();
|
|
151
|
+
// 1. Persist locally
|
|
152
|
+
await idbPut(db, STORES.data, `${collection}:${key}`, value);
|
|
153
|
+
// 2. Enqueue sync op
|
|
154
|
+
const op: SyncOp<T> = {
|
|
155
|
+
id: crypto.randomUUID(),
|
|
156
|
+
store: collection,
|
|
157
|
+
type: 'put',
|
|
158
|
+
key,
|
|
159
|
+
data: value,
|
|
160
|
+
ts: Date.now(),
|
|
161
|
+
retries: 0,
|
|
162
|
+
};
|
|
163
|
+
await idbPut(db, STORES.ops, op.id, op);
|
|
164
|
+
this.#setStatus('pending');
|
|
165
|
+
this.#notify();
|
|
166
|
+
// 3. Try to flush immediately if online
|
|
167
|
+
if (navigator.onLine) void this.flush(endpoint);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Delete locally and enqueue a delete op. */
|
|
171
|
+
async delete(collection: string, key: string, endpoint: string): Promise<void> {
|
|
172
|
+
const db = this.ensureDB();
|
|
173
|
+
await idbDelete(db, STORES.data, `${collection}:${key}`);
|
|
174
|
+
const op: SyncOp = {
|
|
175
|
+
id: crypto.randomUUID(),
|
|
176
|
+
store: collection,
|
|
177
|
+
type: 'delete',
|
|
178
|
+
key,
|
|
179
|
+
ts: Date.now(),
|
|
180
|
+
retries: 0,
|
|
181
|
+
};
|
|
182
|
+
await idbPut(db, STORES.ops, op.id, op);
|
|
183
|
+
this.#setStatus('pending');
|
|
184
|
+
this.#notify();
|
|
185
|
+
if (navigator.onLine) void this.flush(endpoint);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Flush pending ops to the server. Ops are sent in timestamp order.
|
|
190
|
+
* On conflict, `onConflict` is called and the local store is updated.
|
|
191
|
+
*/
|
|
192
|
+
async flush(endpoint?: string, opts?: Partial<SyncCollectionOpts<unknown>>): Promise<void> {
|
|
193
|
+
if (this.flushing || !navigator.onLine) return;
|
|
194
|
+
const db = this.ensureDB();
|
|
195
|
+
const ops = (await idbGetAll<SyncOp>(db, STORES.ops))
|
|
196
|
+
.sort((a, b) => a.ts - b.ts);
|
|
197
|
+
|
|
198
|
+
if (ops.length === 0) {
|
|
199
|
+
this.#setStatus('synced');
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
this.flushing = true;
|
|
204
|
+
this.#setStatus('syncing');
|
|
205
|
+
|
|
206
|
+
const maxRetries = opts?.maxRetries ?? 5;
|
|
207
|
+
const url = endpoint ?? ops[0]?.store ?? '';
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const res = await fetch(url, {
|
|
211
|
+
method: 'POST',
|
|
212
|
+
headers: { 'content-type': 'application/json', 'x-nexus-sync': '1' },
|
|
213
|
+
body: JSON.stringify({ ops }),
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
if (!res.ok) throw new Error(`Server returned ${res.status}`);
|
|
217
|
+
|
|
218
|
+
const body = (await res.json()) as {
|
|
219
|
+
acked: string[];
|
|
220
|
+
conflicts: Array<{ opId: string; serverValue: unknown }>;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// Acknowledge successful ops
|
|
224
|
+
for (const id of body.acked ?? []) {
|
|
225
|
+
await idbDelete(db, STORES.ops, id);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Handle conflicts
|
|
229
|
+
for (const conflict of body.conflicts ?? []) {
|
|
230
|
+
const op = ops.find((o) => o.id === conflict.opId);
|
|
231
|
+
if (!op) continue;
|
|
232
|
+
if (opts?.onConflict) {
|
|
233
|
+
const localValue = await this.get(op.store, op.key);
|
|
234
|
+
const winner = await opts.onConflict({
|
|
235
|
+
local: localValue as unknown,
|
|
236
|
+
remote: conflict.serverValue,
|
|
237
|
+
op,
|
|
238
|
+
});
|
|
239
|
+
await idbPut(db, STORES.data, `${op.store}:${op.key}`, winner);
|
|
240
|
+
} else {
|
|
241
|
+
// Default: local wins — re-enqueue with fresh timestamp
|
|
242
|
+
const updated: SyncOp = { ...op, ts: Date.now() };
|
|
243
|
+
await idbPut(db, STORES.ops, updated.id, updated);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
this.#setStatus('synced');
|
|
248
|
+
} catch {
|
|
249
|
+
// Increment retries on all remaining ops
|
|
250
|
+
for (const op of ops) {
|
|
251
|
+
const updated = { ...op, retries: op.retries + 1 };
|
|
252
|
+
if (updated.retries >= maxRetries) {
|
|
253
|
+
// Dead letter — drop and warn
|
|
254
|
+
console.warn(`[Nexus Sync] Op ${op.id} exceeded max retries, dropping.`, op);
|
|
255
|
+
await idbDelete(db, STORES.ops, op.id);
|
|
256
|
+
} else {
|
|
257
|
+
await idbPut(db, STORES.ops, op.id, updated);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
this.#setStatus(navigator.onLine ? 'error' : 'offline');
|
|
261
|
+
} finally {
|
|
262
|
+
this.flushing = false;
|
|
263
|
+
this.#notify();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Returns the number of pending (unsynced) ops. */
|
|
268
|
+
async pendingCount(): Promise<number> {
|
|
269
|
+
const ops = await idbGetAll<SyncOp>(this.ensureDB(), STORES.ops);
|
|
270
|
+
return ops.length;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Subscribe to status changes (useful for UI indicators). */
|
|
274
|
+
subscribe(cb: () => void): () => void {
|
|
275
|
+
this.listeners.add(cb);
|
|
276
|
+
return () => this.listeners.delete(cb);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
#setStatus(s: SyncStatus): void {
|
|
280
|
+
this.status = s;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
#notify(): void {
|
|
284
|
+
for (const cb of this.listeners) cb();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
#startNetworkWatcher(): void {
|
|
288
|
+
window.addEventListener('online', () => {
|
|
289
|
+
if (window.__NEXUS_DEV__) {
|
|
290
|
+
console.log('%c[Nexus Sync]%c 🟢 Back online — flushing pending ops...', 'color:#818cf8;font-weight:bold', 'color:#a3e635');
|
|
291
|
+
}
|
|
292
|
+
void this.flush();
|
|
293
|
+
});
|
|
294
|
+
window.addEventListener('offline', () => {
|
|
295
|
+
this.#setStatus('offline');
|
|
296
|
+
this.#notify();
|
|
297
|
+
if (window.__NEXUS_DEV__) {
|
|
298
|
+
console.log('%c[Nexus Sync]%c 🔴 Offline — writes queued in IndexedDB', 'color:#818cf8;font-weight:bold', 'color:#f87171');
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
declare global {
|
|
305
|
+
interface Window { __NEXUS_DEV__?: boolean; }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Singleton engine instance (shared across all $sync calls on a page). */
|
|
309
|
+
export const syncEngine = new NexusSyncEngine();
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nexus_js/sync — Local-First Sync Engine
|
|
3
|
+
*
|
|
4
|
+
* Provides offline-first data persistence and background synchronization
|
|
5
|
+
* using IndexedDB as the local store and Server Actions as the sync target.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { syncEngine, NexusSyncEngine } from './engine.js';
|
|
9
|
+
export type { SyncOp, SyncStatus, SyncCollectionOpts, ConflictInfo } from './engine.js';
|
|
10
|
+
|
|
11
|
+
export { $localSync } from './rune.js';
|
|
12
|
+
export type { LocalSyncState, LocalSyncOpts } from './rune.js';
|
|
13
|
+
|
|
14
|
+
/** Convenience: check if the browser is currently online. */
|
|
15
|
+
export const isOnline = (): boolean =>
|
|
16
|
+
typeof navigator !== 'undefined' ? navigator.onLine : true;
|
|
17
|
+
|
|
18
|
+
/** Returns a promise that resolves when the browser comes back online. */
|
|
19
|
+
export function waitForOnline(): Promise<void> {
|
|
20
|
+
if (isOnline()) return Promise.resolve();
|
|
21
|
+
return new Promise<void>((resolve) => {
|
|
22
|
+
const handler = () => { window.removeEventListener('online', handler); resolve(); };
|
|
23
|
+
window.addEventListener('online', handler);
|
|
24
|
+
});
|
|
25
|
+
}
|
package/src/rune.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* $localSync — the Local-First reactive rune.
|
|
3
|
+
*
|
|
4
|
+
* Usage (inside a .nx island):
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* import { $localSync } from '@nexus_js/sync';
|
|
8
|
+
*
|
|
9
|
+
* const captures = $localSync<string[]>('my-captures', {
|
|
10
|
+
* default: [],
|
|
11
|
+
* endpoint: '/_nexus/action/sync-captures',
|
|
12
|
+
* onConflict: ({ local, remote }) => [...new Set([...local, ...remote])],
|
|
13
|
+
* });
|
|
14
|
+
*
|
|
15
|
+
* // Read — always reflects local IndexedDB state
|
|
16
|
+
* console.log(captures.value);
|
|
17
|
+
*
|
|
18
|
+
* // Mutate — instant UI update, background server sync
|
|
19
|
+
* await captures.add('pikachu');
|
|
20
|
+
* await captures.remove('pikachu');
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* The framework automatically:
|
|
24
|
+
* - Persists every mutation to IndexedDB (works offline).
|
|
25
|
+
* - Syncs to the server when online.
|
|
26
|
+
* - Retries failed syncs on reconnect.
|
|
27
|
+
* - Calls onConflict if the server disagrees.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { syncEngine, type SyncCollectionOpts, type SyncStatus } from './engine.js';
|
|
31
|
+
|
|
32
|
+
let engineReady: Promise<void> | null = null;
|
|
33
|
+
|
|
34
|
+
function ensureEngine(): Promise<void> {
|
|
35
|
+
if (!engineReady) engineReady = syncEngine.init();
|
|
36
|
+
return engineReady;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface LocalSyncState<T> {
|
|
40
|
+
/** Current value (IndexedDB, reflects all local mutations immediately). */
|
|
41
|
+
readonly value: T;
|
|
42
|
+
/** Current sync status. */
|
|
43
|
+
readonly status: SyncStatus;
|
|
44
|
+
/** Pending op count. */
|
|
45
|
+
readonly pending: number;
|
|
46
|
+
/** Overwrite entire value and enqueue a sync op. */
|
|
47
|
+
set(next: T): Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* Array helpers — only valid when T extends unknown[].
|
|
50
|
+
* push/remove mutate the local array atomically.
|
|
51
|
+
*/
|
|
52
|
+
push(item: T extends (infer I)[] ? I : never): Promise<void>;
|
|
53
|
+
remove(predicate: T extends (infer I)[] ? (item: I) => boolean : never): Promise<void>;
|
|
54
|
+
/** Force an immediate flush attempt. */
|
|
55
|
+
flush(): Promise<void>;
|
|
56
|
+
/** Subscribe to value/status changes (returns unsubscribe fn). */
|
|
57
|
+
subscribe(cb: (state: LocalSyncState<T>) => void): () => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface LocalSyncOpts<T> extends Partial<SyncCollectionOpts<T>> {
|
|
61
|
+
/** Default value used before IndexedDB is ready. */
|
|
62
|
+
default: T;
|
|
63
|
+
/** Server endpoint to sync ops to. */
|
|
64
|
+
endpoint: string;
|
|
65
|
+
/**
|
|
66
|
+
* Unique key for this value within the collection.
|
|
67
|
+
* Defaults to 'default'. Useful to scope per-user data.
|
|
68
|
+
*/
|
|
69
|
+
key?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Creates a Local-First reactive state container.
|
|
74
|
+
* The returned object is reactive — UI should re-render on .value access
|
|
75
|
+
* (integration with Svelte 5 Runes happens at the compiler layer).
|
|
76
|
+
*/
|
|
77
|
+
export function $localSync<T>(
|
|
78
|
+
collection: string,
|
|
79
|
+
opts: LocalSyncOpts<T>,
|
|
80
|
+
): LocalSyncState<T> {
|
|
81
|
+
const storeKey = opts.key ?? 'default';
|
|
82
|
+
let current = opts.default;
|
|
83
|
+
let status: SyncStatus = 'synced';
|
|
84
|
+
let pending = 0;
|
|
85
|
+
const subs = new Set<(s: LocalSyncState<T>) => void>();
|
|
86
|
+
|
|
87
|
+
function notify(): void {
|
|
88
|
+
for (const cb of subs) cb(state);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Load initial value from IndexedDB
|
|
92
|
+
ensureEngine().then(async () => {
|
|
93
|
+
const stored = await syncEngine.get<T>(collection, storeKey);
|
|
94
|
+
if (stored !== undefined) {
|
|
95
|
+
current = stored;
|
|
96
|
+
notify();
|
|
97
|
+
}
|
|
98
|
+
// Track engine status
|
|
99
|
+
syncEngine.subscribe(async () => {
|
|
100
|
+
status = syncEngine.status;
|
|
101
|
+
pending = await syncEngine.pendingCount();
|
|
102
|
+
notify();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const state: LocalSyncState<T> = {
|
|
107
|
+
get value() { return current; },
|
|
108
|
+
get status() { return status; },
|
|
109
|
+
get pending() { return pending; },
|
|
110
|
+
|
|
111
|
+
async set(next: T): Promise<void> {
|
|
112
|
+
current = next;
|
|
113
|
+
notify();
|
|
114
|
+
await ensureEngine();
|
|
115
|
+
await syncEngine.put(collection, storeKey, next, opts.endpoint);
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
async push(item): Promise<void> {
|
|
119
|
+
if (!Array.isArray(current)) throw new Error('[Nexus Sync] .push() requires an array value');
|
|
120
|
+
await state.set([...current, item] as T);
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
async remove(predicate): Promise<void> {
|
|
124
|
+
if (!Array.isArray(current)) throw new Error('[Nexus Sync] .remove() requires an array value');
|
|
125
|
+
await state.set((current as unknown[]).filter((i) => !(predicate as (x: unknown) => boolean)(i)) as T);
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
async flush(): Promise<void> {
|
|
129
|
+
await ensureEngine();
|
|
130
|
+
await syncEngine.flush(opts.endpoint, opts as Partial<SyncCollectionOpts<unknown>>);
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
subscribe(cb): () => void {
|
|
134
|
+
subs.add(cb);
|
|
135
|
+
return () => subs.delete(cb);
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
return state;
|
|
140
|
+
}
|