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 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, useOutboxStatus } from 'local-lite';
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 } = useOutboxStatus(client);
160
+ const { pendingCount, isOnline, isSyncing } = useSyncStatus(client);
149
161
 
150
162
  const addTodo = async (title: string) => {
151
163
  await client.insert('todos', {
@@ -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
  */
@@ -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
- if (!rows || rows.length === 0)
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[];