@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 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
+ }