local-lite 0.0.1 → 0.0.3
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/README.md +14 -2
- package/dist/core/client.d.ts +56 -3
- package/dist/core/client.js +212 -40
- package/dist/core/outbox-store.d.ts +32 -1
- package/dist/core/outbox-store.js +126 -2
- package/dist/core/query.d.ts +13 -0
- package/dist/core/query.js +150 -0
- package/dist/core/sync-worker.d.ts +16 -1
- package/dist/core/sync-worker.js +142 -4
- package/dist/core/types.d.ts +84 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/react/index.d.ts +21 -7
- package/dist/react/index.js +94 -12
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -6,6 +6,18 @@ Built around the **Transactional Outbox Pattern**, `local-lite` ensures instant,
|
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
+
## 📖 Documentation
|
|
10
|
+
|
|
11
|
+
* **[Quickstart & Setup](docs/getting-started.md)** - 5-minute setup with Expo, React Native, or Node.js
|
|
12
|
+
* **[Architecture](docs/architecture.md)** - Transactional outbox pattern, SyncWorker loops, and event pipeline
|
|
13
|
+
* **[Configuration Reference](docs/configuration.md)** - Options for `defineSyncConfig()`, retries, and loggers
|
|
14
|
+
* **[Local SQLite Adapters](docs/adapters-local.md)** - Expo SQLite, OP-SQLite, better-sqlite3, node:sqlite, and LibSQL
|
|
15
|
+
* **[Remote Cloud Adapters](docs/adapters-remote.md)** - Supabase, Convex, Cloud Firestore, and custom adapters
|
|
16
|
+
* **[React & Expo Hooks](docs/react-hooks.md)** - `useLiveQuery()` and `useSyncStatus()`
|
|
17
|
+
* **[Conflict Resolution](docs/conflict-resolution.md)** - LWW, Client-Wins, Server-Wins, and custom resolvers
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
9
21
|
## Features
|
|
10
22
|
|
|
11
23
|
* **⚡ 0ms Optimistic Writes:** Write to local SQLite immediately via atomic dual-write (`client.mutate()`).
|
|
@@ -135,7 +147,7 @@ export const client = await createSyncClient(syncConfig);
|
|
|
135
147
|
### 2. Use in React Native / Expo UI
|
|
136
148
|
|
|
137
149
|
```tsx
|
|
138
|
-
import { useLiveQuery,
|
|
150
|
+
import { useLiveQuery, useSyncStatus } from 'local-lite/react';
|
|
139
151
|
import { client } from './sync.config';
|
|
140
152
|
|
|
141
153
|
export function TodoScreen() {
|
|
@@ -145,7 +157,7 @@ export function TodoScreen() {
|
|
|
145
157
|
order: 'desc',
|
|
146
158
|
});
|
|
147
159
|
|
|
148
|
-
const { pendingCount } =
|
|
160
|
+
const { pendingCount, isOnline, isSyncing } = useSyncStatus(client);
|
|
149
161
|
|
|
150
162
|
const addTodo = async (title: string) => {
|
|
151
163
|
await client.insert('todos', {
|
package/dist/core/client.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { LocalDBAdapter } from '../adapters/local/index.js';
|
|
1
|
+
import { LocalDBAdapter, LocalTransaction } from '../adapters/local/index.js';
|
|
2
2
|
import { RemoteDBAdapter } from '../adapters/remote/index.js';
|
|
3
3
|
import { EventEmitter } from '../reactive/event-emitter.js';
|
|
4
4
|
import { SyncWorker } from './sync-worker.js';
|
|
5
5
|
import { SchemaManager } from './schema-manager.js';
|
|
6
|
-
import { ConflictResolver, ConflictStrategy, MutateOptions, NetworkStatusProvider, QueryOptions, RetryConfig, SyncOp } from './types.js';
|
|
6
|
+
import { BatchMutation, ConflictResolver, ConflictStrategy, MutateOptions, NetworkStatusProvider, OutboxRecord, OutboxStatus, QueryOptions, RetryConfig, SyncMetrics, SyncOp, TableChangeEvent } from './types.js';
|
|
7
7
|
import { SyncLogger, CustomLogger, LogLevel } from '../utils/logger.js';
|
|
8
8
|
export interface LocalFirstClientConfig {
|
|
9
9
|
local: LocalDBAdapter;
|
|
@@ -20,6 +20,21 @@ export interface LocalFirstClientConfig {
|
|
|
20
20
|
autoSchema?: boolean;
|
|
21
21
|
tables?: string[];
|
|
22
22
|
}
|
|
23
|
+
export declare class TransactionalClient {
|
|
24
|
+
private parent;
|
|
25
|
+
private tx;
|
|
26
|
+
private queuedTableEvents;
|
|
27
|
+
private queuedOutboxIds;
|
|
28
|
+
constructor(parent: LocalFirstClient, tx: LocalTransaction, queuedTableEvents: Array<{
|
|
29
|
+
tableName: string;
|
|
30
|
+
event: TableChangeEvent;
|
|
31
|
+
}>, queuedOutboxIds: string[]);
|
|
32
|
+
mutate(tableName: string, rowId: string, operation: SyncOp, payload: Record<string, any> | null, options?: MutateOptions): Promise<void>;
|
|
33
|
+
insert(tableName: string, rowIdOrPayload: string | Record<string, any>, payloadOrOptions?: Record<string, any> | MutateOptions, options?: MutateOptions): Promise<string>;
|
|
34
|
+
update(tableName: string, rowId: string, payload: Record<string, any>, options?: MutateOptions): Promise<void>;
|
|
35
|
+
delete(tableName: string, rowId: string, options?: MutateOptions): Promise<void>;
|
|
36
|
+
get rawTx(): LocalTransaction;
|
|
37
|
+
}
|
|
23
38
|
export declare class LocalFirstClient {
|
|
24
39
|
db: LocalDBAdapter;
|
|
25
40
|
remote: RemoteDBAdapter;
|
|
@@ -30,6 +45,7 @@ export declare class LocalFirstClient {
|
|
|
30
45
|
tables: string[];
|
|
31
46
|
private primaryKey;
|
|
32
47
|
constructor(config: LocalFirstClientConfig);
|
|
48
|
+
getPrimaryKey(): string;
|
|
33
49
|
/**
|
|
34
50
|
* Initializes the client: creates sync_outbox schema, ensures autoSchema tables if enabled,
|
|
35
51
|
* and starts the background sync worker.
|
|
@@ -40,6 +56,21 @@ export declare class LocalFirstClient {
|
|
|
40
56
|
* to sync_outbox within the same database transaction.
|
|
41
57
|
*/
|
|
42
58
|
mutate(tableName: string, rowId: string, operation: SyncOp, payload: Record<string, any> | null, options?: MutateOptions): Promise<void>;
|
|
59
|
+
/**
|
|
60
|
+
* Executes multiple mutations in a single atomic database transaction.
|
|
61
|
+
* Inserts all corresponding outbox entries together and schedules a single push.
|
|
62
|
+
*/
|
|
63
|
+
batch(mutations: BatchMutation[], options?: MutateOptions): Promise<string[]>;
|
|
64
|
+
/**
|
|
65
|
+
* Convenience helper to insert an array of records into a table atomically.
|
|
66
|
+
*/
|
|
67
|
+
insertMany(tableName: string, records: Record<string, any>[], options?: MutateOptions): Promise<string[]>;
|
|
68
|
+
/**
|
|
69
|
+
* Runs an arbitrary callback inside an atomic transaction with a scoped TransactionalClient.
|
|
70
|
+
* If the callback resolves, all changes and outbox records commit and events are emitted.
|
|
71
|
+
* If the callback throws, SQLite rolls back and no change events are emitted.
|
|
72
|
+
*/
|
|
73
|
+
transaction<T>(action: (txClient: TransactionalClient) => Promise<T>): Promise<T>;
|
|
43
74
|
/**
|
|
44
75
|
* Convenience helper to insert a record into a table.
|
|
45
76
|
* Auto-generates a UUID if rowId is omitted or passed inside payload.
|
|
@@ -65,11 +96,12 @@ export declare class LocalFirstClient {
|
|
|
65
96
|
delete(tableName: string, rowId: string, options?: MutateOptions): Promise<void>;
|
|
66
97
|
/**
|
|
67
98
|
* Reads records from a local SQLite table without requiring manual SQL.
|
|
68
|
-
* Supports where filtering, order, limit, and offset.
|
|
99
|
+
* Supports where filtering with rich operators ($in, $gt, $contains, $or, etc.), order, limit, and offset.
|
|
69
100
|
* Gracefully returns [] if table or columns have not been populated yet on empty schemas.
|
|
70
101
|
*
|
|
71
102
|
* Example:
|
|
72
103
|
* const tenants = await client.findMany('tenants', { where: { status: 'ACTIVE' }, orderBy: 'name' });
|
|
104
|
+
* const expensive = await client.findMany('properties', { where: { rent: { $gte: 2000 } } });
|
|
73
105
|
*/
|
|
74
106
|
findMany<T = any>(tableName: string, options?: QueryOptions): Promise<T[]>;
|
|
75
107
|
/**
|
|
@@ -84,6 +116,27 @@ export declare class LocalFirstClient {
|
|
|
84
116
|
* Can accept an external transaction `options.tx` for custom atomic SQL workflows.
|
|
85
117
|
*/
|
|
86
118
|
trackMutation(tableName: string, rowId: string, operation: SyncOp, payload: Record<string, any> | null, options?: MutateOptions): Promise<string>;
|
|
119
|
+
/**
|
|
120
|
+
* Returns real-time telemetry metrics captured by the sync engine.
|
|
121
|
+
*/
|
|
122
|
+
getMetrics(): Promise<SyncMetrics>;
|
|
123
|
+
/**
|
|
124
|
+
* Fetches outbox records with optional status filtering and pagination.
|
|
125
|
+
*/
|
|
126
|
+
getOutboxRecords(options?: {
|
|
127
|
+
status?: OutboxStatus;
|
|
128
|
+
limit?: number;
|
|
129
|
+
offset?: number;
|
|
130
|
+
}): Promise<OutboxRecord[]>;
|
|
131
|
+
/**
|
|
132
|
+
* Re-queues failed outbox mutations for retry by resetting their status to PENDING.
|
|
133
|
+
* Triggers an immediate sync push.
|
|
134
|
+
*/
|
|
135
|
+
retryFailedMutations(ids?: string[]): Promise<number>;
|
|
136
|
+
/**
|
|
137
|
+
* Permanently clears failed mutations (removes poison-pill outbox records).
|
|
138
|
+
*/
|
|
139
|
+
clearFailedMutations(ids?: string[]): Promise<number>;
|
|
87
140
|
/**
|
|
88
141
|
* Retrieves the persisted last_synced_at timestamp cursor (in ms).
|
|
89
142
|
*/
|
package/dist/core/client.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.LocalFirstClient = void 0;
|
|
3
|
+
exports.LocalFirstClient = exports.TransactionalClient = void 0;
|
|
4
4
|
const event_emitter_js_1 = require("../reactive/event-emitter.js");
|
|
5
5
|
const sync_worker_js_1 = require("./sync-worker.js");
|
|
6
6
|
const table_manager_js_1 = require("./table-manager.js");
|
|
@@ -9,6 +9,75 @@ const meta_store_js_1 = require("./meta-store.js");
|
|
|
9
9
|
const schema_manager_js_1 = require("./schema-manager.js");
|
|
10
10
|
const uuid_js_1 = require("../utils/uuid.js");
|
|
11
11
|
const sql_js_1 = require("../utils/sql.js");
|
|
12
|
+
const query_js_1 = require("./query.js");
|
|
13
|
+
class TransactionalClient {
|
|
14
|
+
parent;
|
|
15
|
+
tx;
|
|
16
|
+
queuedTableEvents;
|
|
17
|
+
queuedOutboxIds;
|
|
18
|
+
constructor(parent, tx, queuedTableEvents, queuedOutboxIds) {
|
|
19
|
+
this.parent = parent;
|
|
20
|
+
this.tx = tx;
|
|
21
|
+
this.queuedTableEvents = queuedTableEvents;
|
|
22
|
+
this.queuedOutboxIds = queuedOutboxIds;
|
|
23
|
+
}
|
|
24
|
+
async mutate(tableName, rowId, operation, payload, options = {}) {
|
|
25
|
+
const pk = options.primaryKey ?? this.parent.getPrimaryKey();
|
|
26
|
+
const outboxId = (0, uuid_js_1.generateUUID)();
|
|
27
|
+
const timestamp = options.customTimestamp ?? Date.now();
|
|
28
|
+
await table_manager_js_1.TableManager.applyMutation(this.tx, tableName, rowId, operation, payload, pk, {
|
|
29
|
+
autoSchema: this.parent.autoSchema,
|
|
30
|
+
schemaManager: this.parent.schemaManager,
|
|
31
|
+
});
|
|
32
|
+
if (!options.skipOutbox) {
|
|
33
|
+
await outbox_store_js_1.OutboxStore.insertRecord(this.tx, {
|
|
34
|
+
id: outboxId,
|
|
35
|
+
tableName,
|
|
36
|
+
rowId,
|
|
37
|
+
operation,
|
|
38
|
+
payload,
|
|
39
|
+
timestamp,
|
|
40
|
+
});
|
|
41
|
+
this.queuedOutboxIds.push(outboxId);
|
|
42
|
+
}
|
|
43
|
+
this.queuedTableEvents.push({
|
|
44
|
+
tableName,
|
|
45
|
+
event: {
|
|
46
|
+
operation,
|
|
47
|
+
rowId,
|
|
48
|
+
payload,
|
|
49
|
+
source: 'local',
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
async insert(tableName, rowIdOrPayload, payloadOrOptions, options) {
|
|
54
|
+
let rowId;
|
|
55
|
+
let payload;
|
|
56
|
+
let opts = {};
|
|
57
|
+
if (typeof rowIdOrPayload === 'string') {
|
|
58
|
+
rowId = rowIdOrPayload;
|
|
59
|
+
payload = payloadOrOptions || {};
|
|
60
|
+
opts = options || {};
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
payload = rowIdOrPayload || {};
|
|
64
|
+
rowId = payload[this.parent.getPrimaryKey()] || (0, uuid_js_1.generateUUID)();
|
|
65
|
+
opts = payloadOrOptions || {};
|
|
66
|
+
}
|
|
67
|
+
await this.mutate(tableName, rowId, 'INSERT', payload, opts);
|
|
68
|
+
return rowId;
|
|
69
|
+
}
|
|
70
|
+
async update(tableName, rowId, payload, options = {}) {
|
|
71
|
+
await this.mutate(tableName, rowId, 'UPDATE', payload, options);
|
|
72
|
+
}
|
|
73
|
+
async delete(tableName, rowId, options = {}) {
|
|
74
|
+
await this.mutate(tableName, rowId, 'DELETE', null, options);
|
|
75
|
+
}
|
|
76
|
+
get rawTx() {
|
|
77
|
+
return this.tx;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
exports.TransactionalClient = TransactionalClient;
|
|
12
81
|
class LocalFirstClient {
|
|
13
82
|
db;
|
|
14
83
|
remote;
|
|
@@ -41,6 +110,9 @@ class LocalFirstClient {
|
|
|
41
110
|
};
|
|
42
111
|
this.syncWorker = new sync_worker_js_1.SyncWorker(this.db, this.remote, this.events, workerOptions);
|
|
43
112
|
}
|
|
113
|
+
getPrimaryKey() {
|
|
114
|
+
return this.primaryKey;
|
|
115
|
+
}
|
|
44
116
|
/**
|
|
45
117
|
* Initializes the client: creates sync_outbox schema, ensures autoSchema tables if enabled,
|
|
46
118
|
* and starts the background sync worker.
|
|
@@ -101,6 +173,108 @@ class LocalFirstClient {
|
|
|
101
173
|
this.syncWorker.schedulePush(20);
|
|
102
174
|
}
|
|
103
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* Executes multiple mutations in a single atomic database transaction.
|
|
178
|
+
* Inserts all corresponding outbox entries together and schedules a single push.
|
|
179
|
+
*/
|
|
180
|
+
async batch(mutations, options = {}) {
|
|
181
|
+
if (!mutations || mutations.length === 0)
|
|
182
|
+
return [];
|
|
183
|
+
const generatedIds = [];
|
|
184
|
+
const queuedEvents = [];
|
|
185
|
+
const queuedOutboxIds = [];
|
|
186
|
+
const executeInTx = async (tx) => {
|
|
187
|
+
for (const m of mutations) {
|
|
188
|
+
const pk = m.primaryKey ?? options.primaryKey ?? this.primaryKey;
|
|
189
|
+
const payload = m.payload ?? null;
|
|
190
|
+
const rowId = m.rowId ||
|
|
191
|
+
(payload && payload[pk] !== undefined && payload[pk] !== null
|
|
192
|
+
? String(payload[pk])
|
|
193
|
+
: (0, uuid_js_1.generateUUID)());
|
|
194
|
+
generatedIds.push(rowId);
|
|
195
|
+
await table_manager_js_1.TableManager.applyMutation(tx, m.tableName, rowId, m.operation, payload, pk, {
|
|
196
|
+
autoSchema: this.autoSchema,
|
|
197
|
+
schemaManager: this.schemaManager,
|
|
198
|
+
});
|
|
199
|
+
const skipOutbox = m.skipOutbox ?? options.skipOutbox ?? false;
|
|
200
|
+
if (!skipOutbox) {
|
|
201
|
+
const outboxId = (0, uuid_js_1.generateUUID)();
|
|
202
|
+
const timestamp = m.customTimestamp ?? options.customTimestamp ?? Date.now();
|
|
203
|
+
await outbox_store_js_1.OutboxStore.insertRecord(tx, {
|
|
204
|
+
id: outboxId,
|
|
205
|
+
tableName: m.tableName,
|
|
206
|
+
rowId,
|
|
207
|
+
operation: m.operation,
|
|
208
|
+
payload,
|
|
209
|
+
timestamp,
|
|
210
|
+
});
|
|
211
|
+
queuedOutboxIds.push(outboxId);
|
|
212
|
+
}
|
|
213
|
+
queuedEvents.push({
|
|
214
|
+
tableName: m.tableName,
|
|
215
|
+
event: {
|
|
216
|
+
operation: m.operation,
|
|
217
|
+
rowId,
|
|
218
|
+
payload,
|
|
219
|
+
source: 'local',
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
if (options.tx) {
|
|
225
|
+
await executeInTx(options.tx);
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
await this.db.transaction(executeInTx);
|
|
229
|
+
}
|
|
230
|
+
// Emit all buffered events after transaction commits
|
|
231
|
+
for (const item of queuedEvents) {
|
|
232
|
+
this.events.emit(item.tableName, item.event);
|
|
233
|
+
}
|
|
234
|
+
if (queuedOutboxIds.length > 0) {
|
|
235
|
+
this.events.emit('sync_outbox', { operation: 'INSERT', rowId: 'batch' });
|
|
236
|
+
this.syncWorker.schedulePush(20);
|
|
237
|
+
}
|
|
238
|
+
return generatedIds;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Convenience helper to insert an array of records into a table atomically.
|
|
242
|
+
*/
|
|
243
|
+
async insertMany(tableName, records, options = {}) {
|
|
244
|
+
const pk = options.primaryKey ?? this.primaryKey;
|
|
245
|
+
const mutations = records.map((record) => {
|
|
246
|
+
const rowId = record[pk] || (0, uuid_js_1.generateUUID)();
|
|
247
|
+
return {
|
|
248
|
+
tableName,
|
|
249
|
+
rowId,
|
|
250
|
+
operation: 'INSERT',
|
|
251
|
+
payload: record,
|
|
252
|
+
primaryKey: pk,
|
|
253
|
+
};
|
|
254
|
+
});
|
|
255
|
+
return await this.batch(mutations, options);
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Runs an arbitrary callback inside an atomic transaction with a scoped TransactionalClient.
|
|
259
|
+
* If the callback resolves, all changes and outbox records commit and events are emitted.
|
|
260
|
+
* If the callback throws, SQLite rolls back and no change events are emitted.
|
|
261
|
+
*/
|
|
262
|
+
async transaction(action) {
|
|
263
|
+
const queuedEvents = [];
|
|
264
|
+
const queuedOutboxIds = [];
|
|
265
|
+
const result = await this.db.transaction(async (tx) => {
|
|
266
|
+
const txClient = new TransactionalClient(this, tx, queuedEvents, queuedOutboxIds);
|
|
267
|
+
return await action(txClient);
|
|
268
|
+
});
|
|
269
|
+
for (const item of queuedEvents) {
|
|
270
|
+
this.events.emit(item.tableName, item.event);
|
|
271
|
+
}
|
|
272
|
+
if (queuedOutboxIds.length > 0) {
|
|
273
|
+
this.events.emit('sync_outbox', { operation: 'INSERT', rowId: 'batch' });
|
|
274
|
+
this.syncWorker.schedulePush(20);
|
|
275
|
+
}
|
|
276
|
+
return result;
|
|
277
|
+
}
|
|
104
278
|
/**
|
|
105
279
|
* Convenience helper to insert a record into a table.
|
|
106
280
|
* Auto-generates a UUID if rowId is omitted or passed inside payload.
|
|
@@ -146,11 +320,12 @@ class LocalFirstClient {
|
|
|
146
320
|
}
|
|
147
321
|
/**
|
|
148
322
|
* Reads records from a local SQLite table without requiring manual SQL.
|
|
149
|
-
* Supports where filtering, order, limit, and offset.
|
|
323
|
+
* Supports where filtering with rich operators ($in, $gt, $contains, $or, etc.), order, limit, and offset.
|
|
150
324
|
* Gracefully returns [] if table or columns have not been populated yet on empty schemas.
|
|
151
325
|
*
|
|
152
326
|
* Example:
|
|
153
327
|
* const tenants = await client.findMany('tenants', { where: { status: 'ACTIVE' }, orderBy: 'name' });
|
|
328
|
+
* const expensive = await client.findMany('properties', { where: { rent: { $gte: 2000 } } });
|
|
154
329
|
*/
|
|
155
330
|
async findMany(tableName, options) {
|
|
156
331
|
const safeTable = (0, sql_js_1.escapeIdentifier)(tableName);
|
|
@@ -165,44 +340,7 @@ class LocalFirstClient {
|
|
|
165
340
|
}
|
|
166
341
|
throw err;
|
|
167
342
|
}
|
|
168
|
-
|
|
169
|
-
return [];
|
|
170
|
-
let result = [...rows];
|
|
171
|
-
// 1. Where filtering
|
|
172
|
-
if (options?.where) {
|
|
173
|
-
const filterKeys = Object.keys(options.where);
|
|
174
|
-
result = result.filter((row) => filterKeys.every((k) => row[k] === options.where[k]));
|
|
175
|
-
}
|
|
176
|
-
// 2. Order by
|
|
177
|
-
if (options?.orderBy) {
|
|
178
|
-
const key = options.orderBy;
|
|
179
|
-
const isAsc = (options.order || 'asc').toLowerCase() === 'asc';
|
|
180
|
-
result.sort((a, b) => {
|
|
181
|
-
const valA = a[key];
|
|
182
|
-
const valB = b[key];
|
|
183
|
-
if (valA === valB)
|
|
184
|
-
return 0;
|
|
185
|
-
if (valA === null || valA === undefined)
|
|
186
|
-
return isAsc ? 1 : -1;
|
|
187
|
-
if (valB === null || valB === undefined)
|
|
188
|
-
return isAsc ? -1 : 1;
|
|
189
|
-
if (typeof valA === 'string' && typeof valB === 'string') {
|
|
190
|
-
return isAsc ? valA.localeCompare(valB) : valB.localeCompare(valA);
|
|
191
|
-
}
|
|
192
|
-
if (typeof valA === 'number' && typeof valB === 'number') {
|
|
193
|
-
return isAsc ? valA - valB : valB - valA;
|
|
194
|
-
}
|
|
195
|
-
return isAsc ? (valA > valB ? 1 : -1) : (valA < valB ? 1 : -1);
|
|
196
|
-
});
|
|
197
|
-
}
|
|
198
|
-
// 3. Offset and Limit
|
|
199
|
-
if (options?.offset !== undefined && options.offset > 0) {
|
|
200
|
-
result = result.slice(options.offset);
|
|
201
|
-
}
|
|
202
|
-
if (options?.limit !== undefined && options.limit > 0) {
|
|
203
|
-
result = result.slice(0, options.limit);
|
|
204
|
-
}
|
|
205
|
-
return result;
|
|
343
|
+
return (0, query_js_1.applyQueryOptions)(rows, options);
|
|
206
344
|
}
|
|
207
345
|
/**
|
|
208
346
|
* Finds a single record by primary key.
|
|
@@ -250,6 +388,40 @@ class LocalFirstClient {
|
|
|
250
388
|
this.syncWorker.schedulePush(20);
|
|
251
389
|
return outboxId;
|
|
252
390
|
}
|
|
391
|
+
/**
|
|
392
|
+
* Returns real-time telemetry metrics captured by the sync engine.
|
|
393
|
+
*/
|
|
394
|
+
async getMetrics() {
|
|
395
|
+
return await this.syncWorker.getMetrics();
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Fetches outbox records with optional status filtering and pagination.
|
|
399
|
+
*/
|
|
400
|
+
async getOutboxRecords(options) {
|
|
401
|
+
return await outbox_store_js_1.OutboxStore.getAllRecords(this.db, options);
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Re-queues failed outbox mutations for retry by resetting their status to PENDING.
|
|
405
|
+
* Triggers an immediate sync push.
|
|
406
|
+
*/
|
|
407
|
+
async retryFailedMutations(ids) {
|
|
408
|
+
const count = await outbox_store_js_1.OutboxStore.retryFailed(this.db, ids);
|
|
409
|
+
if (count > 0) {
|
|
410
|
+
this.events.emit('sync_outbox', { operation: 'UPDATE', rowId: 'retry' });
|
|
411
|
+
this.syncWorker.schedulePush(0);
|
|
412
|
+
}
|
|
413
|
+
return count;
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Permanently clears failed mutations (removes poison-pill outbox records).
|
|
417
|
+
*/
|
|
418
|
+
async clearFailedMutations(ids) {
|
|
419
|
+
const count = await outbox_store_js_1.OutboxStore.clearFailed(this.db, ids);
|
|
420
|
+
if (count > 0) {
|
|
421
|
+
this.events.emit('sync_outbox', { operation: 'DELETE', rowId: 'clear' });
|
|
422
|
+
}
|
|
423
|
+
return count;
|
|
424
|
+
}
|
|
253
425
|
/**
|
|
254
426
|
* Retrieves the persisted last_synced_at timestamp cursor (in ms).
|
|
255
427
|
*/
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { LocalDBAdapter, LocalTransaction } from '../adapters/local/index.js';
|
|
2
|
-
import { OutboxRecord, SyncOperation } from './types.js';
|
|
2
|
+
import { OutboxRecord, OutboxStatus, SyncOperation } from './types.js';
|
|
3
3
|
export declare class OutboxStore {
|
|
4
4
|
/**
|
|
5
5
|
* Initializes the sync_outbox schema if it does not already exist.
|
|
@@ -9,6 +9,10 @@ export declare class OutboxStore {
|
|
|
9
9
|
* Inserts a new outbox entry within an existing database transaction.
|
|
10
10
|
*/
|
|
11
11
|
static insertRecord(tx: LocalTransaction, op: SyncOperation): Promise<void>;
|
|
12
|
+
/**
|
|
13
|
+
* Inserts multiple outbox entries within an existing database transaction.
|
|
14
|
+
*/
|
|
15
|
+
static insertRecords(tx: LocalTransaction, ops: SyncOperation[]): Promise<void>;
|
|
12
16
|
/**
|
|
13
17
|
* Fetches pending and eligible failed outbox records for synchronization.
|
|
14
18
|
*/
|
|
@@ -42,4 +46,31 @@ export declare class OutboxStore {
|
|
|
42
46
|
* Marks a batch of outbox IDs as 'FAILED' and increments retry counters.
|
|
43
47
|
*/
|
|
44
48
|
static markFailed(db: LocalDBAdapter, ids: string[], errorMessage: string): Promise<void>;
|
|
49
|
+
/**
|
|
50
|
+
* Retrieves accurate counts of outbox records categorized by status.
|
|
51
|
+
*/
|
|
52
|
+
static getCounts(db: LocalDBAdapter): Promise<{
|
|
53
|
+
pending: number;
|
|
54
|
+
failed: number;
|
|
55
|
+
processing: number;
|
|
56
|
+
total: number;
|
|
57
|
+
}>;
|
|
58
|
+
/**
|
|
59
|
+
* Re-queues failed outbox records for retry by resetting their status to PENDING and resetting retry_count.
|
|
60
|
+
* If ids are specified, only those records are reset. Otherwise, all FAILED records are reset.
|
|
61
|
+
* Returns the count of affected records.
|
|
62
|
+
*/
|
|
63
|
+
static retryFailed(db: LocalDBAdapter, ids?: string[]): Promise<number>;
|
|
64
|
+
/**
|
|
65
|
+
* Permanently clears failed outbox records (removes poison-pill mutations).
|
|
66
|
+
*/
|
|
67
|
+
static clearFailed(db: LocalDBAdapter, ids?: string[]): Promise<number>;
|
|
68
|
+
/**
|
|
69
|
+
* Retrieves all outbox records, optionally filtered by status, ordered by timestamp.
|
|
70
|
+
*/
|
|
71
|
+
static getAllRecords(db: LocalDBAdapter, options?: {
|
|
72
|
+
status?: OutboxStatus;
|
|
73
|
+
limit?: number;
|
|
74
|
+
offset?: number;
|
|
75
|
+
}): Promise<OutboxRecord[]>;
|
|
45
76
|
}
|
|
@@ -32,6 +32,14 @@ class OutboxStore {
|
|
|
32
32
|
op.timestamp,
|
|
33
33
|
]);
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Inserts multiple outbox entries within an existing database transaction.
|
|
37
|
+
*/
|
|
38
|
+
static async insertRecords(tx, ops) {
|
|
39
|
+
for (const op of ops) {
|
|
40
|
+
await this.insertRecord(tx, op);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
35
43
|
/**
|
|
36
44
|
* Fetches pending and eligible failed outbox records for synchronization.
|
|
37
45
|
*/
|
|
@@ -53,7 +61,7 @@ class OutboxStore {
|
|
|
53
61
|
tableName: r.table_name,
|
|
54
62
|
rowId: r.row_id,
|
|
55
63
|
operation: r.operation,
|
|
56
|
-
payload: r.payload ? JSON.parse(r.payload) : null,
|
|
64
|
+
payload: r.payload ? (typeof r.payload === 'string' ? JSON.parse(r.payload) : r.payload) : null,
|
|
57
65
|
timestamp: Number(r.timestamp),
|
|
58
66
|
status: r.status,
|
|
59
67
|
retryCount: Number(r.retry_count || 0),
|
|
@@ -75,7 +83,7 @@ class OutboxStore {
|
|
|
75
83
|
tableName: r.table_name,
|
|
76
84
|
rowId: r.row_id,
|
|
77
85
|
operation: r.operation,
|
|
78
|
-
payload: r.payload ? JSON.parse(r.payload) : null,
|
|
86
|
+
payload: r.payload ? (typeof r.payload === 'string' ? JSON.parse(r.payload) : r.payload) : null,
|
|
79
87
|
timestamp: Number(r.timestamp),
|
|
80
88
|
status: r.status,
|
|
81
89
|
retryCount: Number(r.retry_count || 0),
|
|
@@ -137,5 +145,121 @@ class OutboxStore {
|
|
|
137
145
|
last_error = ?
|
|
138
146
|
WHERE id IN (${placeholders})`, [errorMessage, ...ids]);
|
|
139
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Retrieves accurate counts of outbox records categorized by status.
|
|
150
|
+
*/
|
|
151
|
+
static async getCounts(db) {
|
|
152
|
+
try {
|
|
153
|
+
const rows = await db.execute(`SELECT * FROM sync_outbox`);
|
|
154
|
+
if (!rows || rows.length === 0) {
|
|
155
|
+
return { pending: 0, failed: 0, processing: 0, total: 0 };
|
|
156
|
+
}
|
|
157
|
+
let pending = 0;
|
|
158
|
+
let failed = 0;
|
|
159
|
+
let processing = 0;
|
|
160
|
+
for (const row of rows) {
|
|
161
|
+
if (row.status === 'PENDING')
|
|
162
|
+
pending++;
|
|
163
|
+
else if (row.status === 'FAILED')
|
|
164
|
+
failed++;
|
|
165
|
+
else if (row.status === 'PROCESSING')
|
|
166
|
+
processing++;
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
pending,
|
|
170
|
+
failed,
|
|
171
|
+
processing,
|
|
172
|
+
total: rows.length,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return { pending: 0, failed: 0, processing: 0, total: 0 };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Re-queues failed outbox records for retry by resetting their status to PENDING and resetting retry_count.
|
|
181
|
+
* If ids are specified, only those records are reset. Otherwise, all FAILED records are reset.
|
|
182
|
+
* Returns the count of affected records.
|
|
183
|
+
*/
|
|
184
|
+
static async retryFailed(db, ids) {
|
|
185
|
+
try {
|
|
186
|
+
if (ids && ids.length > 0) {
|
|
187
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
188
|
+
await db.execute(`UPDATE sync_outbox
|
|
189
|
+
SET status = 'PENDING', retry_count = 0, last_error = NULL
|
|
190
|
+
WHERE status = 'FAILED' AND id IN (${placeholders})`, ids);
|
|
191
|
+
return ids.length;
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
const failedRecords = await db.execute(`SELECT id FROM sync_outbox WHERE status = 'FAILED'`);
|
|
195
|
+
const count = failedRecords?.length || 0;
|
|
196
|
+
if (count > 0) {
|
|
197
|
+
await db.execute(`UPDATE sync_outbox SET status = 'PENDING', retry_count = 0, last_error = NULL WHERE status = 'FAILED'`);
|
|
198
|
+
}
|
|
199
|
+
return count;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return 0;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Permanently clears failed outbox records (removes poison-pill mutations).
|
|
208
|
+
*/
|
|
209
|
+
static async clearFailed(db, ids) {
|
|
210
|
+
try {
|
|
211
|
+
if (ids && ids.length > 0) {
|
|
212
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
213
|
+
await db.execute(`DELETE FROM sync_outbox WHERE status = 'FAILED' AND id IN (${placeholders})`, ids);
|
|
214
|
+
return ids.length;
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
const failedRecords = await db.execute(`SELECT id FROM sync_outbox WHERE status = 'FAILED'`);
|
|
218
|
+
const count = failedRecords?.length || 0;
|
|
219
|
+
if (count > 0) {
|
|
220
|
+
await db.execute(`DELETE FROM sync_outbox WHERE status = 'FAILED'`);
|
|
221
|
+
}
|
|
222
|
+
return count;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Retrieves all outbox records, optionally filtered by status, ordered by timestamp.
|
|
231
|
+
*/
|
|
232
|
+
static async getAllRecords(db, options) {
|
|
233
|
+
try {
|
|
234
|
+
const rows = await db.execute(`SELECT * FROM sync_outbox`);
|
|
235
|
+
if (!rows || rows.length === 0)
|
|
236
|
+
return [];
|
|
237
|
+
let mapped = rows.map((r) => ({
|
|
238
|
+
id: r.id,
|
|
239
|
+
tableName: r.table_name,
|
|
240
|
+
rowId: r.row_id,
|
|
241
|
+
operation: r.operation,
|
|
242
|
+
payload: r.payload ? (typeof r.payload === 'string' ? JSON.parse(r.payload) : r.payload) : null,
|
|
243
|
+
timestamp: Number(r.timestamp),
|
|
244
|
+
status: r.status,
|
|
245
|
+
retryCount: Number(r.retry_count || 0),
|
|
246
|
+
lastError: r.last_error,
|
|
247
|
+
}));
|
|
248
|
+
if (options?.status) {
|
|
249
|
+
mapped = mapped.filter((r) => r.status === options.status);
|
|
250
|
+
}
|
|
251
|
+
mapped.sort((a, b) => b.timestamp - a.timestamp);
|
|
252
|
+
if (options?.offset && options.offset > 0) {
|
|
253
|
+
mapped = mapped.slice(options.offset);
|
|
254
|
+
}
|
|
255
|
+
if (options?.limit && options.limit > 0) {
|
|
256
|
+
mapped = mapped.slice(0, options.limit);
|
|
257
|
+
}
|
|
258
|
+
return mapped;
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
263
|
+
}
|
|
140
264
|
}
|
|
141
265
|
exports.OutboxStore = OutboxStore;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { FieldFilter, QueryOptions, WhereFilter } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Checks whether a single field value matches a field filter condition.
|
|
4
|
+
*/
|
|
5
|
+
export declare function matchesField(fieldValue: any, filter: FieldFilter): boolean;
|
|
6
|
+
/**
|
|
7
|
+
* Evaluates whether a given row matches a WhereFilter.
|
|
8
|
+
*/
|
|
9
|
+
export declare function matchesWhere(row: Record<string, any>, where?: WhereFilter): boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Applies in-memory filtering, sorting, offset, and limit to an array of rows.
|
|
12
|
+
*/
|
|
13
|
+
export declare function applyQueryOptions<T = any>(rows: T[], options?: QueryOptions): T[];
|