local-lite 0.0.0-stage → 0.0.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/LICENSE +21 -0
- package/README.md +349 -2
- package/dist/adapters/local/better-sqlite3.d.ts +23 -0
- package/dist/adapters/local/better-sqlite3.js +46 -0
- package/dist/adapters/local/expo-sqlite.d.ts +8 -0
- package/dist/adapters/local/expo-sqlite.js +30 -0
- package/dist/adapters/local/index.d.ts +13 -0
- package/dist/adapters/local/index.js +22 -0
- package/dist/adapters/local/libsql.d.ts +24 -0
- package/dist/adapters/local/libsql.js +87 -0
- package/dist/adapters/local/node-sqlite.d.ts +24 -0
- package/dist/adapters/local/node-sqlite.js +44 -0
- package/dist/adapters/local/op-sqlite.d.ts +26 -0
- package/dist/adapters/local/op-sqlite.js +59 -0
- package/dist/adapters/local/sql-js.d.ts +26 -0
- package/dist/adapters/local/sql-js.js +63 -0
- package/dist/adapters/network/index.d.ts +11 -0
- package/dist/adapters/network/index.js +53 -0
- package/dist/adapters/remote/convex.d.ts +71 -0
- package/dist/adapters/remote/convex.js +185 -0
- package/dist/adapters/remote/demo.d.ts +21 -0
- package/dist/adapters/remote/demo.js +46 -0
- package/dist/adapters/remote/firestore.d.ts +64 -0
- package/dist/adapters/remote/firestore.js +302 -0
- package/dist/adapters/remote/index.d.ts +10 -0
- package/dist/adapters/remote/index.js +20 -0
- package/dist/adapters/remote/supabase.d.ts +97 -0
- package/dist/adapters/remote/supabase.js +276 -0
- package/dist/config/index.d.ts +24 -0
- package/dist/config/index.js +101 -0
- package/dist/config/resolve-local.d.ts +7 -0
- package/dist/config/resolve-local.js +68 -0
- package/dist/config/resolve-remote.d.ts +7 -0
- package/dist/config/resolve-remote.js +81 -0
- package/dist/config/types.d.ts +159 -0
- package/dist/config/types.js +2 -0
- package/dist/core/client.d.ts +100 -0
- package/dist/core/client.js +273 -0
- package/dist/core/conflict.d.ts +5 -0
- package/dist/core/conflict.js +38 -0
- package/dist/core/meta-store.d.ts +28 -0
- package/dist/core/meta-store.js +64 -0
- package/dist/core/outbox-store.d.ts +45 -0
- package/dist/core/outbox-store.js +141 -0
- package/dist/core/schema-manager.d.ts +39 -0
- package/dist/core/schema-manager.js +150 -0
- package/dist/core/sync-worker.d.ts +74 -0
- package/dist/core/sync-worker.js +335 -0
- package/dist/core/table-manager.d.ts +19 -0
- package/dist/core/table-manager.js +74 -0
- package/dist/core/types.d.ts +73 -0
- package/dist/core/types.js +2 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +33 -0
- package/dist/react/index.d.ts +34 -0
- package/dist/react/index.js +103 -0
- package/dist/reactive/event-emitter.d.ts +7 -0
- package/dist/reactive/event-emitter.js +34 -0
- package/dist/utils/logger.d.ts +51 -0
- package/dist/utils/logger.js +169 -0
- package/dist/utils/sql.d.ts +4 -0
- package/dist/utils/sql.js +9 -0
- package/dist/utils/uuid.d.ts +7 -0
- package/dist/utils/uuid.js +27 -0
- package/package.json +130 -3
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SyncWorker = void 0;
|
|
4
|
+
const conflict_js_1 = require("./conflict.js");
|
|
5
|
+
const index_js_1 = require("../adapters/network/index.js");
|
|
6
|
+
const outbox_store_js_1 = require("./outbox-store.js");
|
|
7
|
+
const meta_store_js_1 = require("./meta-store.js");
|
|
8
|
+
const table_manager_js_1 = require("./table-manager.js");
|
|
9
|
+
const logger_js_1 = require("../utils/logger.js");
|
|
10
|
+
class SyncWorker {
|
|
11
|
+
db;
|
|
12
|
+
remote;
|
|
13
|
+
events;
|
|
14
|
+
network;
|
|
15
|
+
conflictResolver;
|
|
16
|
+
primaryKey;
|
|
17
|
+
autoSchema;
|
|
18
|
+
schemaManager;
|
|
19
|
+
logger;
|
|
20
|
+
retryConfig;
|
|
21
|
+
pushIntervalMs;
|
|
22
|
+
pullIntervalMs;
|
|
23
|
+
maxRetries;
|
|
24
|
+
pushIntervalTimer;
|
|
25
|
+
pullIntervalTimer;
|
|
26
|
+
pushDebounceTimer;
|
|
27
|
+
networkUnsubscribe;
|
|
28
|
+
isPushing = false;
|
|
29
|
+
isPulling = false;
|
|
30
|
+
lastPulledTimestamp = 0;
|
|
31
|
+
cursorLoaded = false;
|
|
32
|
+
deltaQueue = Promise.resolve();
|
|
33
|
+
constructor(db, remote, events, options = {}) {
|
|
34
|
+
this.db = db;
|
|
35
|
+
this.remote = remote;
|
|
36
|
+
this.events = events;
|
|
37
|
+
this.network = options.network ?? new index_js_1.DefaultNetworkStatusProvider();
|
|
38
|
+
this.conflictResolver = (0, conflict_js_1.getConflictResolver)(options.conflictResolver ?? 'last-write-wins');
|
|
39
|
+
this.primaryKey = options.primaryKey ?? 'id';
|
|
40
|
+
this.autoSchema = options.autoSchema ?? false;
|
|
41
|
+
this.schemaManager = options.schemaManager;
|
|
42
|
+
this.pushIntervalMs = options.pushIntervalMs ?? 5000;
|
|
43
|
+
this.pullIntervalMs = options.pullIntervalMs ?? 10000;
|
|
44
|
+
// Configure logger
|
|
45
|
+
if (options.logger instanceof logger_js_1.SyncLogger) {
|
|
46
|
+
this.logger = options.logger;
|
|
47
|
+
}
|
|
48
|
+
else if (typeof options.logger === 'boolean') {
|
|
49
|
+
this.logger = new logger_js_1.SyncLogger({ enabled: options.logger, level: options.logLevel });
|
|
50
|
+
}
|
|
51
|
+
else if (options.logger && typeof options.logger === 'object') {
|
|
52
|
+
this.logger = new logger_js_1.SyncLogger({ customLogger: options.logger, level: options.logLevel });
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
this.logger = options.logLevel ? new logger_js_1.SyncLogger({ level: options.logLevel }) : logger_js_1.defaultLogger;
|
|
56
|
+
}
|
|
57
|
+
// Configure retries & backoff
|
|
58
|
+
const r = options.retry;
|
|
59
|
+
if (typeof r === 'number') {
|
|
60
|
+
this.retryConfig = {
|
|
61
|
+
maxRetries: r,
|
|
62
|
+
initialDelayMs: 1000,
|
|
63
|
+
backoffMultiplier: 1.5,
|
|
64
|
+
maxDelayMs: 30000,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
else if (typeof r === 'object' && r !== null) {
|
|
68
|
+
this.retryConfig = {
|
|
69
|
+
maxRetries: r.maxRetries ?? options.maxRetries ?? 5,
|
|
70
|
+
initialDelayMs: r.initialDelayMs ?? 1000,
|
|
71
|
+
backoffMultiplier: r.backoffMultiplier ?? 1.5,
|
|
72
|
+
maxDelayMs: r.maxDelayMs ?? 30000,
|
|
73
|
+
shouldRetry: r.shouldRetry,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
this.retryConfig = {
|
|
78
|
+
maxRetries: options.maxRetries ?? 5,
|
|
79
|
+
initialDelayMs: 1000,
|
|
80
|
+
backoffMultiplier: 1.5,
|
|
81
|
+
maxDelayMs: 30000,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
this.maxRetries = this.retryConfig.maxRetries ?? 5;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Starts background interval push/pull loops and listens for network & realtime updates.
|
|
88
|
+
*/
|
|
89
|
+
start() {
|
|
90
|
+
this.stop();
|
|
91
|
+
this.pushIntervalTimer = setInterval(() => this.pushLocalChanges(), this.pushIntervalMs);
|
|
92
|
+
this.pullIntervalTimer = setInterval(() => this.pullRemoteChanges(), this.pullIntervalMs);
|
|
93
|
+
// Subscribe to live remote changes
|
|
94
|
+
this.remote.subscribeToLiveChanges((deltas) => {
|
|
95
|
+
this.applyDeltasLocally(deltas).catch((err) => {
|
|
96
|
+
this.logger.error('Error applying live remote deltas:', err);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
// Subscribe to network connectivity changes
|
|
100
|
+
this.networkUnsubscribe = this.network.subscribe((online) => {
|
|
101
|
+
if (online) {
|
|
102
|
+
this.schedulePush(0);
|
|
103
|
+
this.pullRemoteChanges().catch((err) => {
|
|
104
|
+
this.logger.error('Error pulling changes on reconnect:', err);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
// Initial pull
|
|
109
|
+
this.pullRemoteChanges().catch((err) => {
|
|
110
|
+
this.logger.error('Initial pull failed:', err);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Stops all active intervals, timers, and listeners.
|
|
115
|
+
*/
|
|
116
|
+
stop() {
|
|
117
|
+
if (this.pushIntervalTimer) {
|
|
118
|
+
clearInterval(this.pushIntervalTimer);
|
|
119
|
+
this.pushIntervalTimer = undefined;
|
|
120
|
+
}
|
|
121
|
+
if (this.pullIntervalTimer) {
|
|
122
|
+
clearInterval(this.pullIntervalTimer);
|
|
123
|
+
this.pullIntervalTimer = undefined;
|
|
124
|
+
}
|
|
125
|
+
if (this.pushDebounceTimer) {
|
|
126
|
+
clearTimeout(this.pushDebounceTimer);
|
|
127
|
+
this.pushDebounceTimer = undefined;
|
|
128
|
+
}
|
|
129
|
+
if (this.networkUnsubscribe) {
|
|
130
|
+
this.networkUnsubscribe();
|
|
131
|
+
this.networkUnsubscribe = undefined;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Schedules an immediate push with a short debounce.
|
|
136
|
+
*/
|
|
137
|
+
schedulePush(delayMs = 50) {
|
|
138
|
+
if (this.pushDebounceTimer) {
|
|
139
|
+
clearTimeout(this.pushDebounceTimer);
|
|
140
|
+
}
|
|
141
|
+
this.pushDebounceTimer = setTimeout(() => {
|
|
142
|
+
this.pushDebounceTimer = undefined;
|
|
143
|
+
this.pushLocalChanges().catch((err) => {
|
|
144
|
+
this.logger.error('Debounced push failed:', err);
|
|
145
|
+
});
|
|
146
|
+
}, delayMs);
|
|
147
|
+
}
|
|
148
|
+
async isOnline() {
|
|
149
|
+
try {
|
|
150
|
+
return await Promise.resolve(this.network.isOnline());
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Pushes pending outbox records to the remote database.
|
|
158
|
+
*/
|
|
159
|
+
async pushLocalChanges() {
|
|
160
|
+
if (this.isPushing)
|
|
161
|
+
return;
|
|
162
|
+
if (!(await this.isOnline()))
|
|
163
|
+
return;
|
|
164
|
+
this.isPushing = true;
|
|
165
|
+
let pendingRecords = [];
|
|
166
|
+
try {
|
|
167
|
+
pendingRecords = await outbox_store_js_1.OutboxStore.getPending(this.db, this.maxRetries);
|
|
168
|
+
if (pendingRecords.length === 0) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const operations = pendingRecords.map((r) => ({
|
|
172
|
+
id: r.id,
|
|
173
|
+
tableName: r.tableName,
|
|
174
|
+
rowId: r.rowId,
|
|
175
|
+
operation: r.operation,
|
|
176
|
+
payload: r.payload,
|
|
177
|
+
timestamp: r.timestamp,
|
|
178
|
+
}));
|
|
179
|
+
const recordIds = operations.map((o) => o.id);
|
|
180
|
+
// Mark records as PROCESSING
|
|
181
|
+
await outbox_store_js_1.OutboxStore.markProcessing(this.db, recordIds);
|
|
182
|
+
const success = await this.remote.pushChanges(operations);
|
|
183
|
+
if (success) {
|
|
184
|
+
// Successful push: delete rows from outbox
|
|
185
|
+
await outbox_store_js_1.OutboxStore.deleteCompleted(this.db, recordIds);
|
|
186
|
+
this.events.emit('sync_outbox', { operation: 'DELETE', rowId: 'batch' });
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
await this.handlePushFailure(pendingRecords, 'Remote push returned false');
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
this.logger.error('Sync Engine Outbox Push Error:', error);
|
|
194
|
+
if (pendingRecords.length > 0) {
|
|
195
|
+
await this.handlePushFailure(pendingRecords, error?.message || String(error), error);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
this.isPushing = false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
async handlePushFailure(records, errorMessage, errorObj) {
|
|
203
|
+
try {
|
|
204
|
+
const recordIds = records.map((r) => r.id);
|
|
205
|
+
await outbox_store_js_1.OutboxStore.markFailed(this.db, recordIds, errorMessage);
|
|
206
|
+
this.events.emit('sync_outbox', { operation: 'UPDATE', rowId: 'batch' });
|
|
207
|
+
// If custom shouldRetry filter specifies an operation should NOT be retried, mark it FAILED permanently
|
|
208
|
+
if (this.retryConfig.shouldRetry && errorObj) {
|
|
209
|
+
for (const rec of records) {
|
|
210
|
+
if (!this.retryConfig.shouldRetry(errorObj, rec)) {
|
|
211
|
+
await outbox_store_js_1.OutboxStore.markFailed(this.db, [rec.id], `Non-retryable error: ${errorMessage}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// Schedule next retry with exponential backoff
|
|
216
|
+
const currentRetry = Math.max(...records.map((r) => r.retryCount || 0));
|
|
217
|
+
const isInfinite = !Number.isFinite(this.maxRetries) || this.maxRetries === -1;
|
|
218
|
+
if (isInfinite || currentRetry < this.maxRetries) {
|
|
219
|
+
const backoffDelay = Math.min((this.retryConfig.initialDelayMs || 1000) *
|
|
220
|
+
Math.pow(this.retryConfig.backoffMultiplier || 1.5, currentRetry), this.retryConfig.maxDelayMs || 30000);
|
|
221
|
+
this.schedulePush(backoffDelay);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
this.logger.error('Failed to update outbox failure status:', err);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Pulls delta changes from the remote backend since lastPulledTimestamp.
|
|
230
|
+
*/
|
|
231
|
+
async pullRemoteChanges() {
|
|
232
|
+
if (this.isPulling)
|
|
233
|
+
return;
|
|
234
|
+
if (!(await this.isOnline()))
|
|
235
|
+
return;
|
|
236
|
+
this.isPulling = true;
|
|
237
|
+
try {
|
|
238
|
+
if (!this.cursorLoaded) {
|
|
239
|
+
this.lastPulledTimestamp = await meta_store_js_1.MetaStore.getLastSyncedAt(this.db);
|
|
240
|
+
this.cursorLoaded = true;
|
|
241
|
+
}
|
|
242
|
+
const deltas = await this.remote.fetchChanges(this.lastPulledTimestamp);
|
|
243
|
+
if (deltas) {
|
|
244
|
+
await this.applyDeltasLocally(deltas);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
this.logger.error('Sync Engine Delta Pull Error:', error);
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
this.isPulling = false;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Applies incoming server delta changes to local tables with conflict resolution.
|
|
256
|
+
* Serializes execution in a queue to prevent concurrent SQLite transactions.
|
|
257
|
+
*/
|
|
258
|
+
async applyDeltasLocally(deltas) {
|
|
259
|
+
const task = async () => {
|
|
260
|
+
await this.processDeltasLocally(deltas);
|
|
261
|
+
};
|
|
262
|
+
const next = this.deltaQueue.then(task, task);
|
|
263
|
+
this.deltaQueue = next.catch(() => { });
|
|
264
|
+
return await next;
|
|
265
|
+
}
|
|
266
|
+
async processDeltasLocally(deltas) {
|
|
267
|
+
if (!deltas.changes || deltas.changes.length === 0) {
|
|
268
|
+
if (deltas.timestamp && deltas.timestamp > this.lastPulledTimestamp) {
|
|
269
|
+
this.lastPulledTimestamp = deltas.timestamp;
|
|
270
|
+
await meta_store_js_1.MetaStore.setLastSyncedAt(this.db, this.lastPulledTimestamp);
|
|
271
|
+
}
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
await this.db.transaction(async (tx) => {
|
|
275
|
+
for (const change of deltas.changes) {
|
|
276
|
+
// 1. Check for pending local mutations for this (table_name, row_id)
|
|
277
|
+
const localPendingRecord = await outbox_store_js_1.OutboxStore.findPendingByRow(tx, change.tableName, change.rowId);
|
|
278
|
+
// 2. Query current local row if it exists
|
|
279
|
+
const localRow = await table_manager_js_1.TableManager.fetchRow(tx, change.tableName, change.rowId, this.primaryKey, { autoSchema: this.autoSchema, schemaManager: this.schemaManager });
|
|
280
|
+
// 3. Conflict resolution if there is a pending local change
|
|
281
|
+
if (localPendingRecord) {
|
|
282
|
+
const resolution = await Promise.resolve(this.conflictResolver({
|
|
283
|
+
tableName: change.tableName,
|
|
284
|
+
rowId: change.rowId,
|
|
285
|
+
remoteChange: change,
|
|
286
|
+
localOutboxRecord: localPendingRecord,
|
|
287
|
+
localRow,
|
|
288
|
+
}));
|
|
289
|
+
if (resolution === 'keep-local') {
|
|
290
|
+
// Local offline change takes precedence
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (resolution === 'apply-remote') {
|
|
294
|
+
// Remote change wins: remove the superseded local pending outbox mutation
|
|
295
|
+
await outbox_store_js_1.OutboxStore.deleteRecord(tx, localPendingRecord.id);
|
|
296
|
+
}
|
|
297
|
+
else if (typeof resolution === 'object' && resolution.mergePayload) {
|
|
298
|
+
// Merge scenario
|
|
299
|
+
change.payload = resolution.mergePayload;
|
|
300
|
+
await outbox_store_js_1.OutboxStore.updatePayload(tx, localPendingRecord.id, resolution.mergePayload);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// 4. Apply remote change to local database
|
|
304
|
+
await table_manager_js_1.TableManager.applyMutation(tx, change.tableName, change.rowId, change.operation, change.payload, this.primaryKey, { autoSchema: this.autoSchema, schemaManager: this.schemaManager });
|
|
305
|
+
// 5. Emit table change event to notify reactive listeners
|
|
306
|
+
this.events.emit(change.tableName, {
|
|
307
|
+
operation: change.operation,
|
|
308
|
+
rowId: change.rowId,
|
|
309
|
+
payload: change.payload,
|
|
310
|
+
source: 'remote',
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
// Persist sync cursor atomically within the same transaction as delta writes
|
|
314
|
+
if (deltas.timestamp && deltas.timestamp > this.lastPulledTimestamp) {
|
|
315
|
+
await meta_store_js_1.MetaStore.setLastSyncedAt(tx, deltas.timestamp);
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
if (deltas.timestamp) {
|
|
319
|
+
this.lastPulledTimestamp = Math.max(this.lastPulledTimestamp, deltas.timestamp);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
async getLastPulledTimestamp() {
|
|
323
|
+
if (!this.cursorLoaded) {
|
|
324
|
+
this.lastPulledTimestamp = await meta_store_js_1.MetaStore.getLastSyncedAt(this.db);
|
|
325
|
+
this.cursorLoaded = true;
|
|
326
|
+
}
|
|
327
|
+
return this.lastPulledTimestamp;
|
|
328
|
+
}
|
|
329
|
+
async resetSyncCursor() {
|
|
330
|
+
await meta_store_js_1.MetaStore.clearLastSyncedAt(this.db);
|
|
331
|
+
this.lastPulledTimestamp = 0;
|
|
332
|
+
this.cursorLoaded = true;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
exports.SyncWorker = SyncWorker;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { LocalTransaction } from '../adapters/local/index.js';
|
|
2
|
+
import { SyncOp } from './types.js';
|
|
3
|
+
import { SchemaManager } from './schema-manager.js';
|
|
4
|
+
export interface TableMutationOptions {
|
|
5
|
+
autoSchema?: boolean;
|
|
6
|
+
schemaManager?: SchemaManager;
|
|
7
|
+
}
|
|
8
|
+
export declare class TableManager {
|
|
9
|
+
/**
|
|
10
|
+
* Applies an insert, update, or delete mutation on a local data table.
|
|
11
|
+
* If an INSERT is specified but the row already exists, it gracefully executes an UPDATE.
|
|
12
|
+
* If an UPDATE is specified but the row does not exist, it inserts it.
|
|
13
|
+
*/
|
|
14
|
+
static applyMutation(tx: LocalTransaction, tableName: string, rowId: string, operation: SyncOp, payload: Record<string, any> | null, primaryKey?: string, options?: TableMutationOptions): Promise<void>;
|
|
15
|
+
/**
|
|
16
|
+
* Fetches an existing row from a local table by primary key.
|
|
17
|
+
*/
|
|
18
|
+
static fetchRow(tx: LocalTransaction, tableName: string, rowId: string, primaryKey?: string, options?: TableMutationOptions): Promise<Record<string, any> | null>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TableManager = void 0;
|
|
4
|
+
const sql_js_1 = require("../utils/sql.js");
|
|
5
|
+
const schema_manager_js_1 = require("./schema-manager.js");
|
|
6
|
+
const defaultSchemaManager = new schema_manager_js_1.SchemaManager();
|
|
7
|
+
class TableManager {
|
|
8
|
+
/**
|
|
9
|
+
* Applies an insert, update, or delete mutation on a local data table.
|
|
10
|
+
* If an INSERT is specified but the row already exists, it gracefully executes an UPDATE.
|
|
11
|
+
* If an UPDATE is specified but the row does not exist, it inserts it.
|
|
12
|
+
*/
|
|
13
|
+
static async applyMutation(tx, tableName, rowId, operation, payload, primaryKey = 'id', options) {
|
|
14
|
+
const safeTable = (0, sql_js_1.escapeIdentifier)(tableName);
|
|
15
|
+
const safePk = (0, sql_js_1.escapeIdentifier)(primaryKey);
|
|
16
|
+
const sm = options?.schemaManager ?? defaultSchemaManager;
|
|
17
|
+
if (options?.autoSchema) {
|
|
18
|
+
await sm.ensureTable(tx, tableName, primaryKey);
|
|
19
|
+
}
|
|
20
|
+
if (operation === 'DELETE') {
|
|
21
|
+
await tx.execute(`DELETE FROM ${safeTable} WHERE ${safePk} = ?`, [rowId]);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
let rowData;
|
|
25
|
+
if (options?.autoSchema) {
|
|
26
|
+
const formatted = await sm.ensureColumns(tx, tableName, payload, primaryKey);
|
|
27
|
+
rowData = { ...formatted, [primaryKey]: rowId };
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
const formatted = {};
|
|
31
|
+
if (payload && typeof payload === 'object') {
|
|
32
|
+
for (const k of Object.keys(payload)) {
|
|
33
|
+
formatted[k] = (0, schema_manager_js_1.formatValueForStorage)(payload[k]);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
rowData = { ...formatted, [primaryKey]: rowId };
|
|
37
|
+
}
|
|
38
|
+
const keys = Object.keys(rowData);
|
|
39
|
+
if (keys.length === 0)
|
|
40
|
+
return;
|
|
41
|
+
const existing = await tx.execute(`SELECT 1 FROM ${safeTable} WHERE ${safePk} = ? LIMIT 1`, [rowId]);
|
|
42
|
+
if (existing && existing.length > 0) {
|
|
43
|
+
// Row exists -> perform UPDATE
|
|
44
|
+
const updateKeys = keys.filter((k) => k !== primaryKey);
|
|
45
|
+
if (updateKeys.length > 0) {
|
|
46
|
+
const setClause = updateKeys.map((k) => `${(0, sql_js_1.escapeIdentifier)(k)} = ?`).join(', ');
|
|
47
|
+
const values = updateKeys.map((k) => rowData[k]);
|
|
48
|
+
values.push(rowId);
|
|
49
|
+
await tx.execute(`UPDATE ${safeTable} SET ${setClause} WHERE ${safePk} = ?`, values);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
// Row does not exist -> perform INSERT
|
|
54
|
+
const cols = keys.map((k) => (0, sql_js_1.escapeIdentifier)(k)).join(', ');
|
|
55
|
+
const placeholders = keys.map(() => '?').join(', ');
|
|
56
|
+
const values = keys.map((k) => rowData[k]);
|
|
57
|
+
await tx.execute(`INSERT INTO ${safeTable} (${cols}) VALUES (${placeholders})`, values);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Fetches an existing row from a local table by primary key.
|
|
62
|
+
*/
|
|
63
|
+
static async fetchRow(tx, tableName, rowId, primaryKey = 'id', options) {
|
|
64
|
+
const safeTable = (0, sql_js_1.escapeIdentifier)(tableName);
|
|
65
|
+
const safePk = (0, sql_js_1.escapeIdentifier)(primaryKey);
|
|
66
|
+
const sm = options?.schemaManager ?? defaultSchemaManager;
|
|
67
|
+
if (options?.autoSchema) {
|
|
68
|
+
await sm.ensureTable(tx, tableName, primaryKey);
|
|
69
|
+
}
|
|
70
|
+
const rows = await tx.execute(`SELECT * FROM ${safeTable} WHERE ${safePk} = ? LIMIT 1`, [rowId]);
|
|
71
|
+
return rows && rows.length > 0 ? rows[0] : null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
exports.TableManager = TableManager;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export type SyncOp = 'INSERT' | 'UPDATE' | 'DELETE';
|
|
2
|
+
export type OutboxStatus = 'PENDING' | 'PROCESSING' | 'FAILED';
|
|
3
|
+
export interface SyncOperation {
|
|
4
|
+
id: string;
|
|
5
|
+
tableName: string;
|
|
6
|
+
rowId: string;
|
|
7
|
+
operation: SyncOp;
|
|
8
|
+
payload: Record<string, any> | null;
|
|
9
|
+
timestamp: number;
|
|
10
|
+
}
|
|
11
|
+
export interface OutboxRecord extends SyncOperation {
|
|
12
|
+
status: OutboxStatus;
|
|
13
|
+
retryCount: number;
|
|
14
|
+
lastError?: string | null;
|
|
15
|
+
}
|
|
16
|
+
export interface DeltaChange {
|
|
17
|
+
tableName: string;
|
|
18
|
+
operation: SyncOp;
|
|
19
|
+
rowId: string;
|
|
20
|
+
payload: Record<string, any> | null;
|
|
21
|
+
timestamp?: number;
|
|
22
|
+
}
|
|
23
|
+
export interface DeltaChanges {
|
|
24
|
+
timestamp: number;
|
|
25
|
+
changes: DeltaChange[];
|
|
26
|
+
}
|
|
27
|
+
export type ConflictResolution = 'apply-remote' | 'keep-local' | {
|
|
28
|
+
mergePayload: Record<string, any>;
|
|
29
|
+
};
|
|
30
|
+
export interface ConflictContext {
|
|
31
|
+
tableName: string;
|
|
32
|
+
rowId: string;
|
|
33
|
+
remoteChange: DeltaChange;
|
|
34
|
+
localOutboxRecord?: OutboxRecord;
|
|
35
|
+
localRow?: Record<string, any> | null;
|
|
36
|
+
}
|
|
37
|
+
export type ConflictResolver = (context: ConflictContext) => ConflictResolution | Promise<ConflictResolution>;
|
|
38
|
+
export type ConflictStrategy = 'last-write-wins' | 'server-wins' | 'client-wins';
|
|
39
|
+
export interface NetworkStatusProvider {
|
|
40
|
+
isOnline(): boolean | Promise<boolean>;
|
|
41
|
+
subscribe(callback: (isOnline: boolean) => void): () => void;
|
|
42
|
+
}
|
|
43
|
+
export interface RetryConfig {
|
|
44
|
+
/** Maximum retry attempts before marking outbox record as permanently FAILED. Set to `Infinity` or `-1` for infinite retries. Default: 5 */
|
|
45
|
+
maxRetries?: number;
|
|
46
|
+
/** Initial delay before retrying failed operations in milliseconds. Default: 1000 */
|
|
47
|
+
initialDelayMs?: number;
|
|
48
|
+
/** Backoff multiplier applied on consecutive failures (e.g. 2 for exponential). Default: 1.5 */
|
|
49
|
+
backoffMultiplier?: number;
|
|
50
|
+
/** Maximum capped delay between retries in milliseconds. Default: 30000 */
|
|
51
|
+
maxDelayMs?: number;
|
|
52
|
+
/** Custom predicate deciding whether an error is retryable. Return false for non-retryable errors like permanent RLS violations. */
|
|
53
|
+
shouldRetry?: (error: any, operation: SyncOperation) => boolean;
|
|
54
|
+
}
|
|
55
|
+
export interface MutateOptions {
|
|
56
|
+
primaryKey?: string;
|
|
57
|
+
tx?: any;
|
|
58
|
+
customTimestamp?: number;
|
|
59
|
+
skipOutbox?: boolean;
|
|
60
|
+
}
|
|
61
|
+
export interface TableChangeEvent {
|
|
62
|
+
operation: SyncOp;
|
|
63
|
+
rowId: string;
|
|
64
|
+
payload?: Record<string, any> | null;
|
|
65
|
+
source: 'local' | 'remote';
|
|
66
|
+
}
|
|
67
|
+
export interface QueryOptions {
|
|
68
|
+
where?: Record<string, any>;
|
|
69
|
+
orderBy?: string;
|
|
70
|
+
order?: 'asc' | 'desc' | 'ASC' | 'DESC';
|
|
71
|
+
limit?: number;
|
|
72
|
+
offset?: number;
|
|
73
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export * from './core/client.js';
|
|
2
|
+
export * from './core/types.js';
|
|
3
|
+
export * from './core/sync-worker.js';
|
|
4
|
+
export * from './core/conflict.js';
|
|
5
|
+
export * from './core/outbox-store.js';
|
|
6
|
+
export * from './core/meta-store.js';
|
|
7
|
+
export * from './core/table-manager.js';
|
|
8
|
+
export * from './core/schema-manager.js';
|
|
9
|
+
export * from './reactive/event-emitter.js';
|
|
10
|
+
export * from './adapters/local/index.js';
|
|
11
|
+
export * from './adapters/remote/index.js';
|
|
12
|
+
export * from './adapters/network/index.js';
|
|
13
|
+
export * from './config/index.js';
|
|
14
|
+
export * from './react/index.js';
|
|
15
|
+
export * from './utils/uuid.js';
|
|
16
|
+
export * from './utils/sql.js';
|
|
17
|
+
export * from './utils/logger.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./core/client.js"), exports);
|
|
18
|
+
__exportStar(require("./core/types.js"), exports);
|
|
19
|
+
__exportStar(require("./core/sync-worker.js"), exports);
|
|
20
|
+
__exportStar(require("./core/conflict.js"), exports);
|
|
21
|
+
__exportStar(require("./core/outbox-store.js"), exports);
|
|
22
|
+
__exportStar(require("./core/meta-store.js"), exports);
|
|
23
|
+
__exportStar(require("./core/table-manager.js"), exports);
|
|
24
|
+
__exportStar(require("./core/schema-manager.js"), exports);
|
|
25
|
+
__exportStar(require("./reactive/event-emitter.js"), exports);
|
|
26
|
+
__exportStar(require("./adapters/local/index.js"), exports);
|
|
27
|
+
__exportStar(require("./adapters/remote/index.js"), exports);
|
|
28
|
+
__exportStar(require("./adapters/network/index.js"), exports);
|
|
29
|
+
__exportStar(require("./config/index.js"), exports);
|
|
30
|
+
__exportStar(require("./react/index.js"), exports);
|
|
31
|
+
__exportStar(require("./utils/uuid.js"), exports);
|
|
32
|
+
__exportStar(require("./utils/sql.js"), exports);
|
|
33
|
+
__exportStar(require("./utils/logger.js"), exports);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { LocalFirstClient } from '../core/client.js';
|
|
2
|
+
import { QueryOptions } from '../core/types.js';
|
|
3
|
+
export type LiveQueryFn<T> = (client: LocalFirstClient) => Promise<T[]> | T[];
|
|
4
|
+
/**
|
|
5
|
+
* Hook to execute a live reactive table query that automatically re-runs
|
|
6
|
+
* whenever the table is mutated locally or via incoming remote delta.
|
|
7
|
+
*
|
|
8
|
+
* Supports 3 flexible usage patterns:
|
|
9
|
+
* 1. Zero-SQL (all rows):
|
|
10
|
+
* const { data: todos } = useLiveQuery<Todo>(client, 'todos');
|
|
11
|
+
*
|
|
12
|
+
* 2. Declarative options (filtering & sorting without SQL):
|
|
13
|
+
* const { data: active } = useLiveQuery<Todo>(client, 'todos', {
|
|
14
|
+
* where: { completed: 0 },
|
|
15
|
+
* orderBy: 'created_at',
|
|
16
|
+
* order: 'desc'
|
|
17
|
+
* });
|
|
18
|
+
*
|
|
19
|
+
* 3. Custom query callback (advanced raw SQL / joins):
|
|
20
|
+
* const { data } = useLiveQuery(client, 'todos', (c) => c.db.execute('SELECT ...'));
|
|
21
|
+
*/
|
|
22
|
+
export declare function useLiveQuery<T = any>(client: LocalFirstClient | null | undefined, tableName: string, queryOrOptions?: LiveQueryFn<T> | QueryOptions): {
|
|
23
|
+
data: T[];
|
|
24
|
+
refetch: () => Promise<void>;
|
|
25
|
+
loading: boolean;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Hook that tracks the status and pending items in sync_outbox in real time.
|
|
29
|
+
*/
|
|
30
|
+
export declare function useOutboxStatus(client: LocalFirstClient | null | undefined): {
|
|
31
|
+
pendingCount: number;
|
|
32
|
+
pendingRecords: any[];
|
|
33
|
+
refresh: () => Promise<void>;
|
|
34
|
+
};
|