@nexus_js/sync 0.6.0 → 0.7.1
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/dist/engine.d.ts +75 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +250 -0
- package/dist/engine.js.map +1 -0
- package/{src/index.ts → dist/index.d.ts} +3 -13
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/rune.d.ts +67 -0
- package/dist/rune.d.ts.map +1 -0
- package/dist/rune.js +96 -0
- package/dist/rune.js.map +1 -0
- package/package.json +17 -4
- package/src/engine.ts +0 -309
- package/src/rune.ts +0 -140
package/dist/engine.d.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
export type SyncStatus = 'synced' | 'pending' | 'syncing' | 'error' | 'offline';
|
|
14
|
+
export interface SyncOp<T = unknown> {
|
|
15
|
+
id: string;
|
|
16
|
+
store: string;
|
|
17
|
+
type: 'put' | 'delete';
|
|
18
|
+
key: string;
|
|
19
|
+
data?: T;
|
|
20
|
+
ts: number;
|
|
21
|
+
retries: number;
|
|
22
|
+
}
|
|
23
|
+
export interface ConflictInfo<T> {
|
|
24
|
+
local: T;
|
|
25
|
+
remote: T;
|
|
26
|
+
op: SyncOp<T>;
|
|
27
|
+
}
|
|
28
|
+
export interface SyncCollectionOpts<T> {
|
|
29
|
+
/** Server endpoint to flush ops to (POST). Receives `{ ops: SyncOp[] }`. */
|
|
30
|
+
endpoint: string;
|
|
31
|
+
/**
|
|
32
|
+
* Called when the server returns a conflict for an op.
|
|
33
|
+
* Return the version that should win. Default: local wins.
|
|
34
|
+
*/
|
|
35
|
+
onConflict?: (info: ConflictInfo<T>) => T | Promise<T>;
|
|
36
|
+
/** Max retries before an op is moved to "dead letter" (logged, dropped). */
|
|
37
|
+
maxRetries?: number;
|
|
38
|
+
}
|
|
39
|
+
export declare class NexusSyncEngine {
|
|
40
|
+
#private;
|
|
41
|
+
private db;
|
|
42
|
+
private flushing;
|
|
43
|
+
private listeners;
|
|
44
|
+
status: SyncStatus;
|
|
45
|
+
init(): Promise<void>;
|
|
46
|
+
private ensureDB;
|
|
47
|
+
/** Read the local (IndexedDB) value for a key in a collection. */
|
|
48
|
+
get<T>(collection: string, key: string): Promise<T | undefined>;
|
|
49
|
+
/** Read all values in a collection. */
|
|
50
|
+
getAll<T>(collection: string): Promise<T[]>;
|
|
51
|
+
/**
|
|
52
|
+
* Write a value locally (instant) and enqueue a server sync op.
|
|
53
|
+
* The UI is updated immediately — the server sync happens in the background.
|
|
54
|
+
*/
|
|
55
|
+
put<T>(collection: string, key: string, value: T, endpoint: string): Promise<void>;
|
|
56
|
+
/** Delete locally and enqueue a delete op. */
|
|
57
|
+
delete(collection: string, key: string, endpoint: string): Promise<void>;
|
|
58
|
+
/**
|
|
59
|
+
* Flush pending ops to the server. Ops are sent in timestamp order.
|
|
60
|
+
* On conflict, `onConflict` is called and the local store is updated.
|
|
61
|
+
*/
|
|
62
|
+
flush(endpoint?: string, opts?: Partial<SyncCollectionOpts<unknown>>): Promise<void>;
|
|
63
|
+
/** Returns the number of pending (unsynced) ops. */
|
|
64
|
+
pendingCount(): Promise<number>;
|
|
65
|
+
/** Subscribe to status changes (useful for UI indicators). */
|
|
66
|
+
subscribe(cb: () => void): () => void;
|
|
67
|
+
}
|
|
68
|
+
declare global {
|
|
69
|
+
interface Window {
|
|
70
|
+
__NEXUS_DEV__?: boolean;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Singleton engine instance (shared across all $sync calls on a page). */
|
|
74
|
+
export declare const syncEngine: NexusSyncEngine;
|
|
75
|
+
//# sourceMappingURL=engine.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAMH,MAAM,MAAM,UAAU,GAAG,QAAQ,GAAG,SAAS,GAAG,SAAS,GAAG,OAAO,GAAG,SAAS,CAAC;AAEhF,MAAM,WAAW,MAAM,CAAC,CAAC,GAAG,OAAO;IACjC,EAAE,EAAS,MAAM,CAAC;IAClB,KAAK,EAAM,MAAM,CAAC;IAClB,IAAI,EAAO,KAAK,GAAG,QAAQ,CAAC;IAC5B,GAAG,EAAQ,MAAM,CAAC;IAClB,IAAI,CAAC,EAAM,CAAC,CAAC;IACb,EAAE,EAAS,MAAM,CAAC;IAClB,OAAO,EAAI,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,YAAY,CAAC,CAAC;IAC7B,KAAK,EAAK,CAAC,CAAC;IACZ,MAAM,EAAI,CAAC,CAAC;IACZ,EAAE,EAAQ,MAAM,CAAC,CAAC,CAAC,CAAC;CACrB;AAED,MAAM,WAAW,kBAAkB,CAAC,CAAC;IACnC,4EAA4E;IAC5E,QAAQ,EAAK,MAAM,CAAC;IACpB;;;OAGG;IACH,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,YAAY,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;IACvD,4EAA4E;IAC5E,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AA4DD,qBAAa,eAAe;;IAC1B,OAAO,CAAC,EAAE,CAAmC;IAC7C,OAAO,CAAC,QAAQ,CAA8B;IAC9C,OAAO,CAAC,SAAS,CAAiC;IAClD,MAAM,EAAE,UAAU,CAAY;IAExB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAQ3B,OAAO,CAAC,QAAQ;IAKhB,kEAAkE;IAC5D,GAAG,CAAC,CAAC,EAAE,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC;IAIrE,uCAAuC;IACjC,MAAM,CAAC,CAAC,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,EAAE,CAAC;IASjD;;;OAGG;IACG,GAAG,CAAC,CAAC,EACT,UAAU,EAAE,MAAM,EAClB,GAAG,EAAS,MAAM,EAClB,KAAK,EAAO,CAAC,EACb,QAAQ,EAAI,MAAM,GACjB,OAAO,CAAC,IAAI,CAAC;IAqBhB,8CAA8C;IACxC,MAAM,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAiB9E;;;OAGG;IACG,KAAK,CAAC,QAAQ,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,CAAC,kBAAkB,CAAC,OAAO,CAAC,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;IA2E1F,oDAAoD;IAC9C,YAAY,IAAI,OAAO,CAAC,MAAM,CAAC;IAKrC,8DAA8D;IAC9D,SAAS,CAAC,EAAE,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI;CA4BtC;AAED,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,MAAM;QAAG,aAAa,CAAC,EAAE,OAAO,CAAC;KAAE;CAC9C;AAED,2EAA2E;AAC3E,eAAO,MAAM,UAAU,iBAAwB,CAAC"}
|
package/dist/engine.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
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
|
+
const DB_NAME = 'nexus_sync';
|
|
14
|
+
const DB_VERSION = 1;
|
|
15
|
+
const STORES = { data: 'data', ops: 'pending_ops' };
|
|
16
|
+
// ── IDB helpers ───────────────────────────────────────────────────────────────
|
|
17
|
+
function openDB() {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
|
20
|
+
req.onupgradeneeded = () => {
|
|
21
|
+
const db = req.result;
|
|
22
|
+
if (!db.objectStoreNames.contains(STORES.data)) {
|
|
23
|
+
db.createObjectStore(STORES.data);
|
|
24
|
+
}
|
|
25
|
+
if (!db.objectStoreNames.contains(STORES.ops)) {
|
|
26
|
+
const ops = db.createObjectStore(STORES.ops, { keyPath: 'id' });
|
|
27
|
+
ops.createIndex('store', 'store');
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
req.onsuccess = () => resolve(req.result);
|
|
31
|
+
req.onerror = () => reject(req.error);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
function idbGet(db, storeName, key) {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const tx = db.transaction(storeName, 'readonly');
|
|
37
|
+
const req = tx.objectStore(storeName).get(key);
|
|
38
|
+
req.onsuccess = () => resolve(req.result);
|
|
39
|
+
req.onerror = () => reject(req.error);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
function idbPut(db, storeName, key, value) {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const tx = db.transaction(storeName, 'readwrite');
|
|
45
|
+
const req = tx.objectStore(storeName).put(value, key);
|
|
46
|
+
req.onsuccess = () => resolve();
|
|
47
|
+
req.onerror = () => reject(req.error);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function idbDelete(db, storeName, key) {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const tx = db.transaction(storeName, 'readwrite');
|
|
53
|
+
const req = tx.objectStore(storeName).delete(key);
|
|
54
|
+
req.onsuccess = () => resolve();
|
|
55
|
+
req.onerror = () => reject(req.error);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
function idbGetAll(db, storeName) {
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
const tx = db.transaction(storeName, 'readonly');
|
|
61
|
+
const req = tx.objectStore(storeName).getAll();
|
|
62
|
+
req.onsuccess = () => resolve(req.result);
|
|
63
|
+
req.onerror = () => reject(req.error);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
// ── Sync Engine ───────────────────────────────────────────────────────────────
|
|
67
|
+
export class NexusSyncEngine {
|
|
68
|
+
db = null;
|
|
69
|
+
flushing = false;
|
|
70
|
+
listeners = new Set();
|
|
71
|
+
status = 'synced';
|
|
72
|
+
async init() {
|
|
73
|
+
if (this.db)
|
|
74
|
+
return;
|
|
75
|
+
this.db = await openDB();
|
|
76
|
+
this.#startNetworkWatcher();
|
|
77
|
+
// Flush any ops that survived a page refresh
|
|
78
|
+
if (navigator.onLine)
|
|
79
|
+
void this.flush();
|
|
80
|
+
}
|
|
81
|
+
ensureDB() {
|
|
82
|
+
if (!this.db)
|
|
83
|
+
throw new Error('[Nexus Sync] Engine not initialized. Call await sync.init()');
|
|
84
|
+
return this.db;
|
|
85
|
+
}
|
|
86
|
+
/** Read the local (IndexedDB) value for a key in a collection. */
|
|
87
|
+
async get(collection, key) {
|
|
88
|
+
return idbGet(this.ensureDB(), STORES.data, `${collection}:${key}`);
|
|
89
|
+
}
|
|
90
|
+
/** Read all values in a collection. */
|
|
91
|
+
async getAll(collection) {
|
|
92
|
+
const db = this.ensureDB();
|
|
93
|
+
const all = await idbGetAll(db, STORES.data);
|
|
94
|
+
const prefix = `${collection}:`;
|
|
95
|
+
return all
|
|
96
|
+
.filter((r) => r._idbKey?.startsWith(prefix) ?? false)
|
|
97
|
+
.map((r) => r.value);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Write a value locally (instant) and enqueue a server sync op.
|
|
101
|
+
* The UI is updated immediately — the server sync happens in the background.
|
|
102
|
+
*/
|
|
103
|
+
async put(collection, key, value, endpoint) {
|
|
104
|
+
const db = this.ensureDB();
|
|
105
|
+
// 1. Persist locally
|
|
106
|
+
await idbPut(db, STORES.data, `${collection}:${key}`, value);
|
|
107
|
+
// 2. Enqueue sync op
|
|
108
|
+
const op = {
|
|
109
|
+
id: crypto.randomUUID(),
|
|
110
|
+
store: collection,
|
|
111
|
+
type: 'put',
|
|
112
|
+
key,
|
|
113
|
+
data: value,
|
|
114
|
+
ts: Date.now(),
|
|
115
|
+
retries: 0,
|
|
116
|
+
};
|
|
117
|
+
await idbPut(db, STORES.ops, op.id, op);
|
|
118
|
+
this.#setStatus('pending');
|
|
119
|
+
this.#notify();
|
|
120
|
+
// 3. Try to flush immediately if online
|
|
121
|
+
if (navigator.onLine)
|
|
122
|
+
void this.flush(endpoint);
|
|
123
|
+
}
|
|
124
|
+
/** Delete locally and enqueue a delete op. */
|
|
125
|
+
async delete(collection, key, endpoint) {
|
|
126
|
+
const db = this.ensureDB();
|
|
127
|
+
await idbDelete(db, STORES.data, `${collection}:${key}`);
|
|
128
|
+
const op = {
|
|
129
|
+
id: crypto.randomUUID(),
|
|
130
|
+
store: collection,
|
|
131
|
+
type: 'delete',
|
|
132
|
+
key,
|
|
133
|
+
ts: Date.now(),
|
|
134
|
+
retries: 0,
|
|
135
|
+
};
|
|
136
|
+
await idbPut(db, STORES.ops, op.id, op);
|
|
137
|
+
this.#setStatus('pending');
|
|
138
|
+
this.#notify();
|
|
139
|
+
if (navigator.onLine)
|
|
140
|
+
void this.flush(endpoint);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Flush pending ops to the server. Ops are sent in timestamp order.
|
|
144
|
+
* On conflict, `onConflict` is called and the local store is updated.
|
|
145
|
+
*/
|
|
146
|
+
async flush(endpoint, opts) {
|
|
147
|
+
if (this.flushing || !navigator.onLine)
|
|
148
|
+
return;
|
|
149
|
+
const db = this.ensureDB();
|
|
150
|
+
const ops = (await idbGetAll(db, STORES.ops))
|
|
151
|
+
.sort((a, b) => a.ts - b.ts);
|
|
152
|
+
if (ops.length === 0) {
|
|
153
|
+
this.#setStatus('synced');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
this.flushing = true;
|
|
157
|
+
this.#setStatus('syncing');
|
|
158
|
+
const maxRetries = opts?.maxRetries ?? 5;
|
|
159
|
+
const url = endpoint ?? ops[0]?.store ?? '';
|
|
160
|
+
try {
|
|
161
|
+
const res = await fetch(url, {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: { 'content-type': 'application/json', 'x-nexus-sync': '1' },
|
|
164
|
+
body: JSON.stringify({ ops }),
|
|
165
|
+
});
|
|
166
|
+
if (!res.ok)
|
|
167
|
+
throw new Error(`Server returned ${res.status}`);
|
|
168
|
+
const body = (await res.json());
|
|
169
|
+
// Acknowledge successful ops
|
|
170
|
+
for (const id of body.acked ?? []) {
|
|
171
|
+
await idbDelete(db, STORES.ops, id);
|
|
172
|
+
}
|
|
173
|
+
// Handle conflicts
|
|
174
|
+
for (const conflict of body.conflicts ?? []) {
|
|
175
|
+
const op = ops.find((o) => o.id === conflict.opId);
|
|
176
|
+
if (!op)
|
|
177
|
+
continue;
|
|
178
|
+
if (opts?.onConflict) {
|
|
179
|
+
const localValue = await this.get(op.store, op.key);
|
|
180
|
+
const winner = await opts.onConflict({
|
|
181
|
+
local: localValue,
|
|
182
|
+
remote: conflict.serverValue,
|
|
183
|
+
op,
|
|
184
|
+
});
|
|
185
|
+
await idbPut(db, STORES.data, `${op.store}:${op.key}`, winner);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
// Default: local wins — re-enqueue with fresh timestamp
|
|
189
|
+
const updated = { ...op, ts: Date.now() };
|
|
190
|
+
await idbPut(db, STORES.ops, updated.id, updated);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
this.#setStatus('synced');
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// Increment retries on all remaining ops
|
|
197
|
+
for (const op of ops) {
|
|
198
|
+
const updated = { ...op, retries: op.retries + 1 };
|
|
199
|
+
if (updated.retries >= maxRetries) {
|
|
200
|
+
// Dead letter — drop and warn
|
|
201
|
+
console.warn(`[Nexus Sync] Op ${op.id} exceeded max retries, dropping.`, op);
|
|
202
|
+
await idbDelete(db, STORES.ops, op.id);
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
await idbPut(db, STORES.ops, op.id, updated);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
this.#setStatus(navigator.onLine ? 'error' : 'offline');
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
this.flushing = false;
|
|
212
|
+
this.#notify();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** Returns the number of pending (unsynced) ops. */
|
|
216
|
+
async pendingCount() {
|
|
217
|
+
const ops = await idbGetAll(this.ensureDB(), STORES.ops);
|
|
218
|
+
return ops.length;
|
|
219
|
+
}
|
|
220
|
+
/** Subscribe to status changes (useful for UI indicators). */
|
|
221
|
+
subscribe(cb) {
|
|
222
|
+
this.listeners.add(cb);
|
|
223
|
+
return () => this.listeners.delete(cb);
|
|
224
|
+
}
|
|
225
|
+
#setStatus(s) {
|
|
226
|
+
this.status = s;
|
|
227
|
+
}
|
|
228
|
+
#notify() {
|
|
229
|
+
for (const cb of this.listeners)
|
|
230
|
+
cb();
|
|
231
|
+
}
|
|
232
|
+
#startNetworkWatcher() {
|
|
233
|
+
window.addEventListener('online', () => {
|
|
234
|
+
if (window.__NEXUS_DEV__) {
|
|
235
|
+
console.log('%c[Nexus Sync]%c 🟢 Back online — flushing pending ops...', 'color:#818cf8;font-weight:bold', 'color:#a3e635');
|
|
236
|
+
}
|
|
237
|
+
void this.flush();
|
|
238
|
+
});
|
|
239
|
+
window.addEventListener('offline', () => {
|
|
240
|
+
this.#setStatus('offline');
|
|
241
|
+
this.#notify();
|
|
242
|
+
if (window.__NEXUS_DEV__) {
|
|
243
|
+
console.log('%c[Nexus Sync]%c 🔴 Offline — writes queued in IndexedDB', 'color:#818cf8;font-weight:bold', 'color:#f87171');
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/** Singleton engine instance (shared across all $sync calls on a page). */
|
|
249
|
+
export const syncEngine = new NexusSyncEngine();
|
|
250
|
+
//# sourceMappingURL=engine.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"engine.js","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"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"}
|
|
@@ -4,22 +4,12 @@
|
|
|
4
4
|
* Provides offline-first data persistence and background synchronization
|
|
5
5
|
* using IndexedDB as the local store and Server Actions as the sync target.
|
|
6
6
|
*/
|
|
7
|
-
|
|
8
7
|
export { syncEngine, NexusSyncEngine } from './engine.js';
|
|
9
8
|
export type { SyncOp, SyncStatus, SyncCollectionOpts, ConflictInfo } from './engine.js';
|
|
10
|
-
|
|
11
9
|
export { $localSync } from './rune.js';
|
|
12
10
|
export type { LocalSyncState, LocalSyncOpts } from './rune.js';
|
|
13
|
-
|
|
14
11
|
/** Convenience: check if the browser is currently online. */
|
|
15
|
-
export const isOnline
|
|
16
|
-
typeof navigator !== 'undefined' ? navigator.onLine : true;
|
|
17
|
-
|
|
12
|
+
export declare const isOnline: () => boolean;
|
|
18
13
|
/** Returns a promise that resolves when the browser comes back online. */
|
|
19
|
-
export function waitForOnline(): Promise<void
|
|
20
|
-
|
|
21
|
-
return new Promise<void>((resolve) => {
|
|
22
|
-
const handler = () => { window.removeEventListener('online', handler); resolve(); };
|
|
23
|
-
window.addEventListener('online', handler);
|
|
24
|
-
});
|
|
25
|
-
}
|
|
14
|
+
export declare function waitForOnline(): Promise<void>;
|
|
15
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC1D,YAAY,EAAE,MAAM,EAAE,UAAU,EAAE,kBAAkB,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAExF,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AACvC,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAE/D,6DAA6D;AAC7D,eAAO,MAAM,QAAQ,QAAO,OACgC,CAAC;AAE7D,0EAA0E;AAC1E,wBAAgB,aAAa,IAAI,OAAO,CAAC,IAAI,CAAC,CAM7C"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
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
|
+
export { syncEngine, NexusSyncEngine } from './engine.js';
|
|
8
|
+
export { $localSync } from './rune.js';
|
|
9
|
+
/** Convenience: check if the browser is currently online. */
|
|
10
|
+
export const isOnline = () => typeof navigator !== 'undefined' ? navigator.onLine : true;
|
|
11
|
+
/** Returns a promise that resolves when the browser comes back online. */
|
|
12
|
+
export function waitForOnline() {
|
|
13
|
+
if (isOnline())
|
|
14
|
+
return Promise.resolve();
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
const handler = () => { window.removeEventListener('online', handler); resolve(); };
|
|
17
|
+
window.addEventListener('online', handler);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAG1D,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAGvC,6DAA6D;AAC7D,MAAM,CAAC,MAAM,QAAQ,GAAG,GAAY,EAAE,CACpC,OAAO,SAAS,KAAK,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC;AAE7D,0EAA0E;AAC1E,MAAM,UAAU,aAAa;IAC3B,IAAI,QAAQ,EAAE;QAAE,OAAO,OAAO,CAAC,OAAO,EAAE,CAAC;IACzC,OAAO,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,EAAE;QACnC,MAAM,OAAO,GAAG,GAAG,EAAE,GAAG,MAAM,CAAC,mBAAmB,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,CAAC;QACpF,MAAM,CAAC,gBAAgB,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC;IAC7C,CAAC,CAAC,CAAC;AACL,CAAC"}
|
package/dist/rune.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
import { type SyncCollectionOpts, type SyncStatus } from './engine.js';
|
|
30
|
+
export interface LocalSyncState<T> {
|
|
31
|
+
/** Current value (IndexedDB, reflects all local mutations immediately). */
|
|
32
|
+
readonly value: T;
|
|
33
|
+
/** Current sync status. */
|
|
34
|
+
readonly status: SyncStatus;
|
|
35
|
+
/** Pending op count. */
|
|
36
|
+
readonly pending: number;
|
|
37
|
+
/** Overwrite entire value and enqueue a sync op. */
|
|
38
|
+
set(next: T): Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* Array helpers — only valid when T extends unknown[].
|
|
41
|
+
* push/remove mutate the local array atomically.
|
|
42
|
+
*/
|
|
43
|
+
push(item: T extends (infer I)[] ? I : never): Promise<void>;
|
|
44
|
+
remove(predicate: T extends (infer I)[] ? (item: I) => boolean : never): Promise<void>;
|
|
45
|
+
/** Force an immediate flush attempt. */
|
|
46
|
+
flush(): Promise<void>;
|
|
47
|
+
/** Subscribe to value/status changes (returns unsubscribe fn). */
|
|
48
|
+
subscribe(cb: (state: LocalSyncState<T>) => void): () => void;
|
|
49
|
+
}
|
|
50
|
+
export interface LocalSyncOpts<T> extends Partial<SyncCollectionOpts<T>> {
|
|
51
|
+
/** Default value used before IndexedDB is ready. */
|
|
52
|
+
default: T;
|
|
53
|
+
/** Server endpoint to sync ops to. */
|
|
54
|
+
endpoint: string;
|
|
55
|
+
/**
|
|
56
|
+
* Unique key for this value within the collection.
|
|
57
|
+
* Defaults to 'default'. Useful to scope per-user data.
|
|
58
|
+
*/
|
|
59
|
+
key?: string;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Creates a Local-First reactive state container.
|
|
63
|
+
* The returned object is reactive — UI should re-render on .value access
|
|
64
|
+
* (integration with Svelte 5 Runes happens at the compiler layer).
|
|
65
|
+
*/
|
|
66
|
+
export declare function $localSync<T>(collection: string, opts: LocalSyncOpts<T>): LocalSyncState<T>;
|
|
67
|
+
//# sourceMappingURL=rune.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rune.d.ts","sourceRoot":"","sources":["../src/rune.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,OAAO,EAAc,KAAK,kBAAkB,EAAE,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AASnF,MAAM,WAAW,cAAc,CAAC,CAAC;IAC/B,2EAA2E;IAC3E,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAC;IAClB,2BAA2B;IAC3B,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC;IAC5B,wBAAwB;IACxB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,oDAAoD;IACpD,GAAG,CAAC,IAAI,EAAE,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5B;;;OAGG;IACH,IAAI,CAAC,IAAI,EAAE,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,EAAE,GAAG,CAAC,GAAG,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7D,MAAM,CAAC,SAAS,EAAE,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,EAAE,GAAG,CAAC,IAAI,EAAE,CAAC,KAAK,OAAO,GAAG,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACvF,wCAAwC;IACxC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,kEAAkE;IAClE,SAAS,CAAC,EAAE,EAAE,CAAC,KAAK,EAAE,cAAc,CAAC,CAAC,CAAC,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;CAC/D;AAED,MAAM,WAAW,aAAa,CAAC,CAAC,CAAE,SAAQ,OAAO,CAAC,kBAAkB,CAAC,CAAC,CAAC,CAAC;IACtE,oDAAoD;IACpD,OAAO,EAAE,CAAC,CAAC;IACX,sCAAsC;IACtC,QAAQ,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,CAAC,EAC1B,UAAU,EAAE,MAAM,EAClB,IAAI,EAAQ,aAAa,CAAC,CAAC,CAAC,GAC3B,cAAc,CAAC,CAAC,CAAC,CA4DnB"}
|
package/dist/rune.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
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
|
+
import { syncEngine } from './engine.js';
|
|
30
|
+
let engineReady = null;
|
|
31
|
+
function ensureEngine() {
|
|
32
|
+
if (!engineReady)
|
|
33
|
+
engineReady = syncEngine.init();
|
|
34
|
+
return engineReady;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Creates a Local-First reactive state container.
|
|
38
|
+
* The returned object is reactive — UI should re-render on .value access
|
|
39
|
+
* (integration with Svelte 5 Runes happens at the compiler layer).
|
|
40
|
+
*/
|
|
41
|
+
export function $localSync(collection, opts) {
|
|
42
|
+
const storeKey = opts.key ?? 'default';
|
|
43
|
+
let current = opts.default;
|
|
44
|
+
let status = 'synced';
|
|
45
|
+
let pending = 0;
|
|
46
|
+
const subs = new Set();
|
|
47
|
+
function notify() {
|
|
48
|
+
for (const cb of subs)
|
|
49
|
+
cb(state);
|
|
50
|
+
}
|
|
51
|
+
// Load initial value from IndexedDB
|
|
52
|
+
ensureEngine().then(async () => {
|
|
53
|
+
const stored = await syncEngine.get(collection, storeKey);
|
|
54
|
+
if (stored !== undefined) {
|
|
55
|
+
current = stored;
|
|
56
|
+
notify();
|
|
57
|
+
}
|
|
58
|
+
// Track engine status
|
|
59
|
+
syncEngine.subscribe(async () => {
|
|
60
|
+
status = syncEngine.status;
|
|
61
|
+
pending = await syncEngine.pendingCount();
|
|
62
|
+
notify();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
const state = {
|
|
66
|
+
get value() { return current; },
|
|
67
|
+
get status() { return status; },
|
|
68
|
+
get pending() { return pending; },
|
|
69
|
+
async set(next) {
|
|
70
|
+
current = next;
|
|
71
|
+
notify();
|
|
72
|
+
await ensureEngine();
|
|
73
|
+
await syncEngine.put(collection, storeKey, next, opts.endpoint);
|
|
74
|
+
},
|
|
75
|
+
async push(item) {
|
|
76
|
+
if (!Array.isArray(current))
|
|
77
|
+
throw new Error('[Nexus Sync] .push() requires an array value');
|
|
78
|
+
await state.set([...current, item]);
|
|
79
|
+
},
|
|
80
|
+
async remove(predicate) {
|
|
81
|
+
if (!Array.isArray(current))
|
|
82
|
+
throw new Error('[Nexus Sync] .remove() requires an array value');
|
|
83
|
+
await state.set(current.filter((i) => !predicate(i)));
|
|
84
|
+
},
|
|
85
|
+
async flush() {
|
|
86
|
+
await ensureEngine();
|
|
87
|
+
await syncEngine.flush(opts.endpoint, opts);
|
|
88
|
+
},
|
|
89
|
+
subscribe(cb) {
|
|
90
|
+
subs.add(cb);
|
|
91
|
+
return () => subs.delete(cb);
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
return state;
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=rune.js.map
|
package/dist/rune.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rune.js","sourceRoot":"","sources":["../src/rune.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nexus_js/sync",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Nexus Local-First Sync Engine — IndexedDB-backed optimistic mutations with conflict resolution",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "./
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
7
8
|
"exports": {
|
|
8
|
-
".":
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
9
13
|
},
|
|
10
14
|
"keywords": [
|
|
11
15
|
"nexus",
|
|
@@ -31,12 +35,21 @@
|
|
|
31
35
|
"bugs": {
|
|
32
36
|
"url": "https://github.com/bierfor/nexus/issues"
|
|
33
37
|
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^22.0.0",
|
|
40
|
+
"typescript": "^5.5.0"
|
|
41
|
+
},
|
|
34
42
|
"files": [
|
|
35
|
-
"
|
|
43
|
+
"dist",
|
|
36
44
|
"README.md"
|
|
37
45
|
],
|
|
38
46
|
"publishConfig": {
|
|
39
47
|
"access": "public",
|
|
40
48
|
"registry": "https://registry.npmjs.org/"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "tsc -p tsconfig.json",
|
|
52
|
+
"dev": "tsc -p tsconfig.json --watch",
|
|
53
|
+
"clean": "rm -rf dist"
|
|
41
54
|
}
|
|
42
55
|
}
|
package/src/engine.ts
DELETED
|
@@ -1,309 +0,0 @@
|
|
|
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/rune.ts
DELETED
|
@@ -1,140 +0,0 @@
|
|
|
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
|
-
}
|