local-lite 0.0.17 → 0.0.19

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
@@ -91,7 +91,8 @@ This recovers sync state; it does not repair corrupt SQLite files or cancel hung
91
91
  * **📶 Network-Aware:** Automatically pauses polling when offline and triggers immediate sync flushes the instant connectivity is restored.
92
92
  * **🔄 Infinite-Loop Safe:** Incoming remote deltas update local tables and fire reactive UI listeners without echoing back into the outbox.
93
93
  * **☁️ Database Backups:** Export and restore SQLite snapshots independently of your live sync backend.
94
- * **🔑 Google Sign-In Out of the Box:** Secure, zero-backend Google OAuth 2.0 (PKCE) authentication with automatic token refresh, user profile retrieval, persistent session storage, and `useGoogleAuth()` hook.
94
+ * **🔑 Google Sign-In Out of the Box:** Secure, zero-backend Google OAuth 2.0 (PKCE) authentication with automatic token refresh, user profile retrieval, persistent session storage, and `useGoogleAuth()` hook.
95
+ * **📨 Action Outbox & Email Queue:** Enqueue transactional emails, push notifications, and webhooks offline with idempotency keys, automatic retry backoff, crash recovery, and TTL expiration.
95
96
 
96
97
 
97
98
  ---
@@ -294,6 +295,46 @@ export function UserProfileHeader() {
294
295
  ---
295
296
 
296
297
 
298
+ ### 4. Offline Actions & Inbuilt Email Queue
299
+
300
+ `local-lite` includes a durable **Generic Action Outbox** (`client.actions`) for side-effects that should not be lost when offline:
301
+
302
+ ```typescript
303
+ import { client } from './sync.config';
304
+
305
+ // ⚡ 1. Enqueue transactional email offline (0ms latency, safe if app closes)
306
+ const emailAction = await client.actions.sendEmail({
307
+ to: 'customer@example.com',
308
+ subject: 'Order #1042 Confirmed',
309
+ html: '<h1>Thank you for your purchase!</h1>',
310
+ template: 'order_receipt',
311
+ templateData: { orderId: 1042, total: '$49.99' },
312
+ idempotencyKey: 'order_receipt_1042', // Prevents sending twice
313
+ ttlMs: 24 * 60 * 60 * 1000, // Expire if offline for >24 hours
314
+ });
315
+
316
+ // ⚡ 2. Register local dispatcher (or let rows replicate to Supabase/Convex/Firebase)
317
+ client.actions.registerEmailHandler(async (email) => {
318
+ // Dispatch via your transactional provider or backend API
319
+ const response = await fetch('https://api.resend.com/emails', {
320
+ method: 'POST',
321
+ headers: { Authorization: `Bearer ${process.env.RESEND_API_KEY}` },
322
+ body: JSON.stringify(email),
323
+ });
324
+ const data = await response.json();
325
+ return { messageId: data.id };
326
+ });
327
+
328
+ // ⚡ 3. Enqueue arbitrary asynchronous actions (e.g., push notifications, analytics)
329
+ await client.actions.enqueue({
330
+ action: 'push_notification',
331
+ payload: { title: 'New chat message', token: 'fcm-xxx' },
332
+ idempotencyKey: 'chat_msg_99',
333
+ });
334
+ ```
335
+
336
+ ---
337
+
297
338
  ## Supabase Backend Setup & Schema
298
339
 
299
340
  To synchronize with Supabase using zero-clock-skew delta queries and `deleted: true` soft deletes, execute the following script in your Supabase project (**Dashboard → SQL Editor**):
@@ -0,0 +1,3 @@
1
+ export * from './types.js';
2
+ export * from './store.js';
3
+ export * from './manager.js';
@@ -0,0 +1,19 @@
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("./types.js"), exports);
18
+ __exportStar(require("./store.js"), exports);
19
+ __exportStar(require("./manager.js"), exports);
@@ -0,0 +1,71 @@
1
+ import { LocalDBAdapter } from '../adapters/local/index.js';
2
+ import { EventEmitter } from '../reactive/event-emitter.js';
3
+ import { ActionFilterOptions, ActionHandler, ActionManagerOptions, ActionPruneOptions, ActionRecord, EmailHandler, EmailPayload, EnqueueActionOptions, FlushActionsResult, SendEmailOptions } from './types.js';
4
+ export declare class ActionManager {
5
+ private db;
6
+ private events;
7
+ private isOnlineFn;
8
+ private options;
9
+ private tableName;
10
+ private handlers;
11
+ private isFlushing;
12
+ private flushRerunRequested;
13
+ private networkUnsubscribe?;
14
+ constructor(db: LocalDBAdapter, events: EventEmitter, isOnline: () => Promise<boolean>, options?: ActionManagerOptions);
15
+ /**
16
+ * Stops active network listeners.
17
+ */
18
+ stop(): void;
19
+ /**
20
+ * Initializes the action store: creates tables/indexes and resets stale executing states.
21
+ */
22
+ initialize(): Promise<void>;
23
+ /**
24
+ * Enqueues an asynchronous action with offline durability and idempotency checks.
25
+ */
26
+ enqueue<TPayload = any, TResult = any>(opts: EnqueueActionOptions<TPayload>): Promise<ActionRecord<TPayload, TResult>>;
27
+ /**
28
+ * Convenience helper to enqueue a transactional email with offline resilience.
29
+ */
30
+ sendEmail(options: SendEmailOptions): Promise<ActionRecord<EmailPayload, any>>;
31
+ /**
32
+ * Registers a local executor for a specific action name.
33
+ * Returns an unregister function.
34
+ */
35
+ registerHandler<TPayload = any, TResult = any>(action: string, handler: ActionHandler<TPayload, TResult>): () => void;
36
+ /**
37
+ * Registers a dedicated handler for 'send_email' actions.
38
+ */
39
+ registerEmailHandler(handler: EmailHandler): () => void;
40
+ /**
41
+ * Flushes all pending actions whose handlers are registered locally.
42
+ * Actions without a registered handler remain in the queue (e.g. to be synced to cloud functions).
43
+ */
44
+ private flushPromise?;
45
+ /**
46
+ * Flushes all pending actions whose handlers are registered locally.
47
+ * Actions without a registered handler remain in the queue (e.g. to be synced to cloud functions).
48
+ */
49
+ flush(): Promise<FlushActionsResult>;
50
+ private performFlush;
51
+ /**
52
+ * Retrieves an action record by ID.
53
+ */
54
+ get(id: string): Promise<ActionRecord | null>;
55
+ /**
56
+ * Lists action records with optional filtering.
57
+ */
58
+ list(filter?: ActionFilterOptions): Promise<ActionRecord[]>;
59
+ /**
60
+ * Cancels a pending action before it has been executed.
61
+ */
62
+ cancel(id: string): Promise<boolean>;
63
+ /**
64
+ * Prunes completed, failed, expired, or cancelled actions from local storage.
65
+ */
66
+ prune(options?: ActionPruneOptions): Promise<number>;
67
+ /**
68
+ * Checks network connectivity via the configured provider.
69
+ */
70
+ isOnline(): Promise<boolean>;
71
+ }
@@ -0,0 +1,287 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ActionManager = void 0;
4
+ const uuid_js_1 = require("../utils/uuid.js");
5
+ const store_js_1 = require("./store.js");
6
+ class ActionManager {
7
+ db;
8
+ events;
9
+ isOnlineFn;
10
+ options;
11
+ tableName;
12
+ handlers = new Map();
13
+ isFlushing = false;
14
+ flushRerunRequested = false;
15
+ networkUnsubscribe;
16
+ constructor(db, events, isOnline, options = {}) {
17
+ this.db = db;
18
+ this.events = events;
19
+ this.isOnlineFn = isOnline;
20
+ this.options = {
21
+ defaultMaxRetries: 3,
22
+ autoFlushOnOnline: true,
23
+ autoFlushOnEnqueue: false,
24
+ ...options,
25
+ };
26
+ this.tableName = options.tableName || store_js_1.DEFAULT_ACTIONS_TABLE;
27
+ if (this.options.network && this.options.autoFlushOnOnline) {
28
+ let lastOnline = undefined;
29
+ this.networkUnsubscribe = this.options.network.subscribe((online) => {
30
+ if (online && lastOnline === false) {
31
+ this.flush().catch(() => { });
32
+ }
33
+ lastOnline = online;
34
+ });
35
+ }
36
+ }
37
+ /**
38
+ * Stops active network listeners.
39
+ */
40
+ stop() {
41
+ if (this.networkUnsubscribe) {
42
+ this.networkUnsubscribe();
43
+ this.networkUnsubscribe = undefined;
44
+ }
45
+ }
46
+ /**
47
+ * Initializes the action store: creates tables/indexes and resets stale executing states.
48
+ */
49
+ async initialize() {
50
+ await store_js_1.ActionStore.ensureSchema(this.db, this.tableName);
51
+ await store_js_1.ActionStore.resetStaleExecuting(this.db, this.tableName);
52
+ }
53
+ /**
54
+ * Enqueues an asynchronous action with offline durability and idempotency checks.
55
+ */
56
+ async enqueue(opts) {
57
+ if (!opts.action || typeof opts.action !== 'string') {
58
+ throw new Error('Action name is required and must be a string');
59
+ }
60
+ // 1. Idempotency check: if key already exists, return existing record
61
+ if (opts.idempotencyKey) {
62
+ const existing = await store_js_1.ActionStore.findByIdempotencyKey(this.db, this.tableName, opts.idempotencyKey);
63
+ if (existing) {
64
+ return existing;
65
+ }
66
+ }
67
+ const now = Date.now();
68
+ const expiresAt = opts.expiresAt !== undefined
69
+ ? opts.expiresAt
70
+ : opts.ttlMs !== undefined
71
+ ? now + opts.ttlMs
72
+ : this.options.defaultTtlMs !== undefined
73
+ ? now + this.options.defaultTtlMs
74
+ : null;
75
+ const record = {
76
+ id: (0, uuid_js_1.generateUUID)(),
77
+ action: opts.action,
78
+ payload: opts.payload,
79
+ idempotencyKey: opts.idempotencyKey ?? null,
80
+ status: 'PENDING',
81
+ createdAt: now,
82
+ expiresAt,
83
+ executedAt: null,
84
+ retryCount: 0,
85
+ maxRetries: opts.maxRetries ?? this.options.defaultMaxRetries ?? 3,
86
+ lastError: null,
87
+ result: null,
88
+ };
89
+ await store_js_1.ActionStore.insertRecord(this.db, this.tableName, record);
90
+ this.events.emit('action:enqueued', record);
91
+ // Auto-flush if configured
92
+ if (this.options.autoFlushOnEnqueue) {
93
+ this.flush().catch(() => { });
94
+ }
95
+ return record;
96
+ }
97
+ /**
98
+ * Convenience helper to enqueue a transactional email with offline resilience.
99
+ */
100
+ async sendEmail(options) {
101
+ const { idempotencyKey, ttlMs, expiresAt, maxRetries, ...emailPayload } = options;
102
+ if (!emailPayload.to) {
103
+ throw new Error("Email 'to' recipient is required");
104
+ }
105
+ if (!emailPayload.subject) {
106
+ throw new Error("Email 'subject' is required");
107
+ }
108
+ return this.enqueue({
109
+ action: 'send_email',
110
+ payload: emailPayload,
111
+ idempotencyKey,
112
+ ttlMs,
113
+ expiresAt,
114
+ maxRetries,
115
+ });
116
+ }
117
+ /**
118
+ * Registers a local executor for a specific action name.
119
+ * Returns an unregister function.
120
+ */
121
+ registerHandler(action, handler) {
122
+ this.handlers.set(action, handler);
123
+ return () => {
124
+ if (this.handlers.get(action) === handler) {
125
+ this.handlers.delete(action);
126
+ }
127
+ };
128
+ }
129
+ /**
130
+ * Registers a dedicated handler for 'send_email' actions.
131
+ */
132
+ registerEmailHandler(handler) {
133
+ return this.registerHandler('send_email', (record, context) => handler(record.payload, record, context));
134
+ }
135
+ /**
136
+ * Flushes all pending actions whose handlers are registered locally.
137
+ * Actions without a registered handler remain in the queue (e.g. to be synced to cloud functions).
138
+ */
139
+ flushPromise;
140
+ /**
141
+ * Flushes all pending actions whose handlers are registered locally.
142
+ * Actions without a registered handler remain in the queue (e.g. to be synced to cloud functions).
143
+ */
144
+ async flush() {
145
+ if (this.flushPromise) {
146
+ this.flushRerunRequested = true;
147
+ return this.flushPromise;
148
+ }
149
+ this.flushPromise = this.performFlush().finally(() => {
150
+ this.flushPromise = undefined;
151
+ });
152
+ return this.flushPromise;
153
+ }
154
+ async performFlush() {
155
+ const result = {
156
+ processed: 0,
157
+ completed: 0,
158
+ failed: 0,
159
+ expired: 0,
160
+ skipped: 0,
161
+ };
162
+ try {
163
+ do {
164
+ this.flushRerunRequested = false;
165
+ const online = await this.isOnline();
166
+ if (!online) {
167
+ break;
168
+ }
169
+ const pending = await store_js_1.ActionStore.getPending(this.db, this.tableName, 50);
170
+ if (pending.length === 0) {
171
+ break;
172
+ }
173
+ const context = {
174
+ db: this.db,
175
+ isOnline: this.isOnlineFn,
176
+ };
177
+ for (const action of pending) {
178
+ result.processed++;
179
+ const now = Date.now();
180
+ // 1. Check TTL expiration
181
+ if (action.expiresAt && action.expiresAt <= now) {
182
+ await store_js_1.ActionStore.updateStatus(this.db, this.tableName, action.id, 'EXPIRED');
183
+ const expiredRecord = { ...action, status: 'EXPIRED' };
184
+ result.expired++;
185
+ this.events.emit('action:expired', expiredRecord);
186
+ continue;
187
+ }
188
+ // 2. Lookup handler
189
+ const handler = this.handlers.get(action.action);
190
+ if (!handler) {
191
+ // No local handler; leave as PENDING for remote sync replication
192
+ result.skipped++;
193
+ continue;
194
+ }
195
+ // 3. Mark EXECUTING
196
+ await store_js_1.ActionStore.updateStatus(this.db, this.tableName, action.id, 'EXECUTING');
197
+ this.events.emit('action:executing', { ...action, status: 'EXECUTING' });
198
+ // 4. Execute handler
199
+ try {
200
+ const res = await handler(action, context);
201
+ const executedAt = Date.now();
202
+ await store_js_1.ActionStore.updateStatus(this.db, this.tableName, action.id, 'COMPLETED', {
203
+ executedAt,
204
+ result: res ?? null,
205
+ });
206
+ result.completed++;
207
+ this.events.emit('action:completed', {
208
+ ...action,
209
+ status: 'COMPLETED',
210
+ executedAt,
211
+ result: res ?? null,
212
+ });
213
+ }
214
+ catch (err) {
215
+ const nextRetry = action.retryCount + 1;
216
+ const isFinalFailure = nextRetry >= action.maxRetries;
217
+ const newStatus = isFinalFailure ? 'FAILED' : 'PENDING';
218
+ const errorMsg = err instanceof Error ? err.message : String(err);
219
+ await store_js_1.ActionStore.updateStatus(this.db, this.tableName, action.id, newStatus, {
220
+ retryCount: nextRetry,
221
+ lastError: errorMsg,
222
+ });
223
+ if (isFinalFailure) {
224
+ result.failed++;
225
+ this.events.emit('action:failed', {
226
+ ...action,
227
+ status: 'FAILED',
228
+ retryCount: nextRetry,
229
+ lastError: errorMsg,
230
+ });
231
+ }
232
+ else {
233
+ this.events.emit('action:retry', {
234
+ ...action,
235
+ status: 'PENDING',
236
+ retryCount: nextRetry,
237
+ lastError: errorMsg,
238
+ });
239
+ }
240
+ }
241
+ }
242
+ } while (this.flushRerunRequested);
243
+ }
244
+ finally { }
245
+ return result;
246
+ }
247
+ /**
248
+ * Retrieves an action record by ID.
249
+ */
250
+ async get(id) {
251
+ return store_js_1.ActionStore.findById(this.db, this.tableName, id);
252
+ }
253
+ /**
254
+ * Lists action records with optional filtering.
255
+ */
256
+ async list(filter) {
257
+ return store_js_1.ActionStore.list(this.db, this.tableName, filter);
258
+ }
259
+ /**
260
+ * Cancels a pending action before it has been executed.
261
+ */
262
+ async cancel(id) {
263
+ const cancelled = await store_js_1.ActionStore.cancel(this.db, this.tableName, id);
264
+ if (cancelled) {
265
+ this.events.emit('action:cancelled', { id });
266
+ }
267
+ return cancelled;
268
+ }
269
+ /**
270
+ * Prunes completed, failed, expired, or cancelled actions from local storage.
271
+ */
272
+ async prune(options) {
273
+ return store_js_1.ActionStore.prune(this.db, this.tableName, options);
274
+ }
275
+ /**
276
+ * Checks network connectivity via the configured provider.
277
+ */
278
+ async isOnline() {
279
+ try {
280
+ return await this.isOnlineFn();
281
+ }
282
+ catch {
283
+ return true;
284
+ }
285
+ }
286
+ }
287
+ exports.ActionManager = ActionManager;
@@ -0,0 +1,54 @@
1
+ import { DBExecutable } from '../core/schema-manager.js';
2
+ import { ActionFilterOptions, ActionPruneOptions, ActionRecord, ActionStatus } from './types.js';
3
+ export declare const DEFAULT_ACTIONS_TABLE = "_local_actions";
4
+ /**
5
+ * Maps raw database row into a typed ActionRecord.
6
+ */
7
+ export declare function mapActionRow(r: any): ActionRecord;
8
+ export declare class ActionStore {
9
+ /**
10
+ * Initializes the action outbox schema and indexes if they do not exist.
11
+ */
12
+ static ensureSchema(db: DBExecutable, tableName?: string): Promise<void>;
13
+ /**
14
+ * Resets interrupted 'EXECUTING' records back to 'PENDING' on startup.
15
+ */
16
+ static resetStaleExecuting(db: DBExecutable, tableName?: string): Promise<void>;
17
+ /**
18
+ * Inserts an action record into storage.
19
+ */
20
+ static insertRecord(db: DBExecutable, tableName: string | undefined, record: ActionRecord): Promise<void>;
21
+ /**
22
+ * Finds an action record by ID.
23
+ */
24
+ static findById(db: DBExecutable, tableName: string | undefined, id: string): Promise<ActionRecord | null>;
25
+ /**
26
+ * Finds an existing action by its idempotency key.
27
+ */
28
+ static findByIdempotencyKey(db: DBExecutable, tableName: string | undefined, key: string): Promise<ActionRecord | null>;
29
+ /**
30
+ * Retrieves pending actions ordered by creation timestamp.
31
+ */
32
+ static getPending(db: DBExecutable, tableName?: string, limit?: number): Promise<ActionRecord[]>;
33
+ /**
34
+ * Updates the status and optional metadata fields of an action record.
35
+ */
36
+ static updateStatus(db: DBExecutable, tableName: string | undefined, id: string, status: ActionStatus, updates?: {
37
+ executedAt?: number | null;
38
+ retryCount?: number;
39
+ lastError?: string | null;
40
+ result?: any;
41
+ }): Promise<void>;
42
+ /**
43
+ * Queries action records with optional filtering.
44
+ */
45
+ static list(db: DBExecutable, tableName?: string, filter?: ActionFilterOptions): Promise<ActionRecord[]>;
46
+ /**
47
+ * Cancels a pending action if it has not yet executed.
48
+ */
49
+ static cancel(db: DBExecutable, tableName: string | undefined, id: string): Promise<boolean>;
50
+ /**
51
+ * Prunes old completed, failed, or expired action records.
52
+ */
53
+ static prune(db: DBExecutable, tableName?: string, options?: ActionPruneOptions): Promise<number>;
54
+ }
@@ -0,0 +1,202 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ActionStore = exports.DEFAULT_ACTIONS_TABLE = void 0;
4
+ exports.mapActionRow = mapActionRow;
5
+ exports.DEFAULT_ACTIONS_TABLE = '_local_actions';
6
+ /**
7
+ * Maps raw database row into a typed ActionRecord.
8
+ */
9
+ function mapActionRow(r) {
10
+ return {
11
+ id: r.id,
12
+ action: r.action,
13
+ payload: r.payload ? (typeof r.payload === 'string' ? JSON.parse(r.payload) : r.payload) : {},
14
+ idempotencyKey: r.idempotency_key ?? null,
15
+ status: r.status,
16
+ createdAt: Number(r.created_at || 0),
17
+ expiresAt: r.expires_at != null ? Number(r.expires_at) : null,
18
+ executedAt: r.executed_at != null ? Number(r.executed_at) : null,
19
+ retryCount: Number(r.retry_count || 0),
20
+ maxRetries: Number(r.max_retries || 3),
21
+ lastError: r.last_error ?? null,
22
+ result: r.result ? (typeof r.result === 'string' ? JSON.parse(r.result) : r.result) : null,
23
+ };
24
+ }
25
+ class ActionStore {
26
+ /**
27
+ * Initializes the action outbox schema and indexes if they do not exist.
28
+ */
29
+ static async ensureSchema(db, tableName = exports.DEFAULT_ACTIONS_TABLE) {
30
+ await db.execute(`CREATE TABLE IF NOT EXISTS ${tableName} (
31
+ id TEXT PRIMARY KEY,
32
+ action TEXT NOT NULL,
33
+ payload TEXT NOT NULL,
34
+ idempotency_key TEXT UNIQUE,
35
+ status TEXT NOT NULL DEFAULT 'PENDING',
36
+ created_at INTEGER NOT NULL,
37
+ expires_at INTEGER,
38
+ executed_at INTEGER,
39
+ retry_count INTEGER NOT NULL DEFAULT 0,
40
+ max_retries INTEGER NOT NULL DEFAULT 3,
41
+ last_error TEXT,
42
+ result TEXT
43
+ );`);
44
+ await db.execute(`CREATE INDEX IF NOT EXISTS idx_${tableName}_status ON ${tableName} (status, created_at);`);
45
+ await db.execute(`CREATE INDEX IF NOT EXISTS idx_${tableName}_idempotency ON ${tableName} (idempotency_key);`);
46
+ await db.execute(`CREATE INDEX IF NOT EXISTS idx_${tableName}_action ON ${tableName} (action, status);`);
47
+ }
48
+ /**
49
+ * Resets interrupted 'EXECUTING' records back to 'PENDING' on startup.
50
+ */
51
+ static async resetStaleExecuting(db, tableName = exports.DEFAULT_ACTIONS_TABLE) {
52
+ await db.execute(`UPDATE ${tableName} SET status = 'PENDING' WHERE status = 'EXECUTING';`);
53
+ }
54
+ /**
55
+ * Inserts an action record into storage.
56
+ */
57
+ static async insertRecord(db, tableName = exports.DEFAULT_ACTIONS_TABLE, record) {
58
+ await db.execute(`INSERT INTO ${tableName} (
59
+ id, action, payload, idempotency_key, status, created_at, expires_at, executed_at, retry_count, max_retries, last_error, result
60
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`, [
61
+ record.id,
62
+ record.action,
63
+ typeof record.payload === 'string' ? record.payload : JSON.stringify(record.payload ?? {}),
64
+ record.idempotencyKey ?? null,
65
+ record.status,
66
+ record.createdAt,
67
+ record.expiresAt ?? null,
68
+ record.executedAt ?? null,
69
+ record.retryCount,
70
+ record.maxRetries,
71
+ record.lastError ?? null,
72
+ record.result ? (typeof record.result === 'string' ? record.result : JSON.stringify(record.result)) : null,
73
+ ]);
74
+ }
75
+ /**
76
+ * Finds an action record by ID.
77
+ */
78
+ static async findById(db, tableName = exports.DEFAULT_ACTIONS_TABLE, id) {
79
+ const rows = await db.execute(`SELECT * FROM ${tableName} WHERE id = ? LIMIT 1;`, [id]);
80
+ if (!rows || rows.length === 0)
81
+ return null;
82
+ return mapActionRow(rows[0]);
83
+ }
84
+ /**
85
+ * Finds an existing action by its idempotency key.
86
+ */
87
+ static async findByIdempotencyKey(db, tableName = exports.DEFAULT_ACTIONS_TABLE, key) {
88
+ const rows = await db.execute(`SELECT * FROM ${tableName} WHERE idempotency_key = ? LIMIT 1;`, [key]);
89
+ if (!rows || rows.length === 0)
90
+ return null;
91
+ return mapActionRow(rows[0]);
92
+ }
93
+ /**
94
+ * Retrieves pending actions ordered by creation timestamp.
95
+ */
96
+ static async getPending(db, tableName = exports.DEFAULT_ACTIONS_TABLE, limit = 50) {
97
+ const rows = await db.execute(`SELECT * FROM ${tableName} WHERE status = 'PENDING' ORDER BY created_at ASC LIMIT ?;`, [limit]);
98
+ return (rows || []).map(mapActionRow);
99
+ }
100
+ /**
101
+ * Updates the status and optional metadata fields of an action record.
102
+ */
103
+ static async updateStatus(db, tableName = exports.DEFAULT_ACTIONS_TABLE, id, status, updates = {}) {
104
+ const sets = ['status = ?'];
105
+ const params = [status];
106
+ if (updates.executedAt !== undefined) {
107
+ sets.push('executed_at = ?');
108
+ params.push(updates.executedAt);
109
+ }
110
+ if (updates.retryCount !== undefined) {
111
+ sets.push('retry_count = ?');
112
+ params.push(updates.retryCount);
113
+ }
114
+ if (updates.lastError !== undefined) {
115
+ sets.push('last_error = ?');
116
+ params.push(updates.lastError);
117
+ }
118
+ if (updates.result !== undefined) {
119
+ sets.push('result = ?');
120
+ params.push(updates.result ? JSON.stringify(updates.result) : null);
121
+ }
122
+ params.push(id);
123
+ await db.execute(`UPDATE ${tableName} SET ${sets.join(', ')} WHERE id = ?;`, params);
124
+ }
125
+ /**
126
+ * Queries action records with optional filtering.
127
+ */
128
+ static async list(db, tableName = exports.DEFAULT_ACTIONS_TABLE, filter = {}) {
129
+ const conditions = [];
130
+ const params = [];
131
+ if (filter.status) {
132
+ if (Array.isArray(filter.status)) {
133
+ if (filter.status.length > 0) {
134
+ conditions.push(`status IN (${filter.status.map(() => '?').join(', ')})`);
135
+ params.push(...filter.status);
136
+ }
137
+ }
138
+ else {
139
+ conditions.push(`status = ?`);
140
+ params.push(filter.status);
141
+ }
142
+ }
143
+ if (filter.action) {
144
+ if (Array.isArray(filter.action)) {
145
+ if (filter.action.length > 0) {
146
+ conditions.push(`action IN (${filter.action.map(() => '?').join(', ')})`);
147
+ params.push(...filter.action);
148
+ }
149
+ }
150
+ else {
151
+ conditions.push(`action = ?`);
152
+ params.push(filter.action);
153
+ }
154
+ }
155
+ const whereClause = conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : '';
156
+ let limitClause = '';
157
+ if (filter.limit !== undefined) {
158
+ limitClause = ` LIMIT ?`;
159
+ params.push(filter.limit);
160
+ if (filter.offset !== undefined) {
161
+ limitClause += ` OFFSET ?`;
162
+ params.push(filter.offset);
163
+ }
164
+ }
165
+ const rows = await db.execute(`SELECT * FROM ${tableName}${whereClause} ORDER BY created_at DESC${limitClause};`, params);
166
+ return (rows || []).map(mapActionRow);
167
+ }
168
+ /**
169
+ * Cancels a pending action if it has not yet executed.
170
+ */
171
+ static async cancel(db, tableName = exports.DEFAULT_ACTIONS_TABLE, id) {
172
+ const rows = await db.execute(`UPDATE ${tableName} SET status = 'CANCELLED' WHERE id = ? AND status = 'PENDING';`, [id]);
173
+ // In drivers that return affected rows or execute successfully
174
+ const record = await this.findById(db, tableName, id);
175
+ return record?.status === 'CANCELLED';
176
+ }
177
+ /**
178
+ * Prunes old completed, failed, or expired action records.
179
+ */
180
+ static async prune(db, tableName = exports.DEFAULT_ACTIONS_TABLE, options = {}) {
181
+ const now = Date.now();
182
+ let deletedCount = 0;
183
+ if (options.completedOlderThanMs !== undefined) {
184
+ const cutoff = now - options.completedOlderThanMs;
185
+ await db.execute(`DELETE FROM ${tableName} WHERE status = 'COMPLETED' AND executed_at IS NOT NULL AND executed_at <= ?;`, [cutoff]);
186
+ }
187
+ if (options.failedOlderThanMs !== undefined) {
188
+ const cutoff = now - options.failedOlderThanMs;
189
+ await db.execute(`DELETE FROM ${tableName} WHERE status = 'FAILED' AND created_at <= ?;`, [cutoff]);
190
+ }
191
+ if (options.expiredOlderThanMs !== undefined) {
192
+ const cutoff = now - options.expiredOlderThanMs;
193
+ await db.execute(`DELETE FROM ${tableName} WHERE status = 'EXPIRED' AND created_at <= ?;`, [cutoff]);
194
+ }
195
+ if (options.cancelledOlderThanMs !== undefined) {
196
+ const cutoff = now - options.cancelledOlderThanMs;
197
+ await db.execute(`DELETE FROM ${tableName} WHERE status = 'CANCELLED' AND created_at <= ?;`, [cutoff]);
198
+ }
199
+ return deletedCount;
200
+ }
201
+ }
202
+ exports.ActionStore = ActionStore;
@@ -0,0 +1,91 @@
1
+ import { LocalDBAdapter } from '../adapters/local/index.js';
2
+ import { CustomLogger } from '../utils/logger.js';
3
+ import { NetworkStatusProvider } from '../core/types.js';
4
+ export type ActionStatus = 'PENDING' | 'EXECUTING' | 'COMPLETED' | 'FAILED' | 'EXPIRED' | 'CANCELLED';
5
+ export interface ActionRecord<TPayload = any, TResult = any> {
6
+ id: string;
7
+ action: string;
8
+ payload: TPayload;
9
+ idempotencyKey?: string | null;
10
+ status: ActionStatus;
11
+ createdAt: number;
12
+ expiresAt?: number | null;
13
+ executedAt?: number | null;
14
+ retryCount: number;
15
+ maxRetries: number;
16
+ lastError?: string | null;
17
+ result?: TResult | null;
18
+ }
19
+ export interface EnqueueActionOptions<TPayload = any> {
20
+ action: string;
21
+ payload: TPayload;
22
+ idempotencyKey?: string | null;
23
+ ttlMs?: number;
24
+ expiresAt?: number | null;
25
+ maxRetries?: number;
26
+ }
27
+ export interface ActionHandlerContext {
28
+ db: LocalDBAdapter;
29
+ isOnline: () => Promise<boolean>;
30
+ }
31
+ export type ActionHandler<TPayload = any, TResult = any> = (action: ActionRecord<TPayload, TResult>, context: ActionHandlerContext) => Promise<TResult | void> | TResult | void;
32
+ export interface ActionFilterOptions {
33
+ status?: ActionStatus | ActionStatus[];
34
+ action?: string | string[];
35
+ limit?: number;
36
+ offset?: number;
37
+ }
38
+ export interface ActionPruneOptions {
39
+ completedOlderThanMs?: number;
40
+ failedOlderThanMs?: number;
41
+ expiredOlderThanMs?: number;
42
+ cancelledOlderThanMs?: number;
43
+ }
44
+ export interface FlushActionsResult {
45
+ processed: number;
46
+ completed: number;
47
+ failed: number;
48
+ expired: number;
49
+ skipped: number;
50
+ }
51
+ export interface ActionManagerOptions {
52
+ tableName?: string;
53
+ defaultMaxRetries?: number;
54
+ defaultTtlMs?: number;
55
+ autoFlushOnOnline?: boolean;
56
+ autoFlushOnEnqueue?: boolean;
57
+ network?: NetworkStatusProvider;
58
+ logger?: boolean | CustomLogger;
59
+ }
60
+ export interface EmailAttachment {
61
+ filename: string;
62
+ content?: string;
63
+ url?: string;
64
+ contentType?: string;
65
+ }
66
+ export interface EmailPayload {
67
+ to: string | string[];
68
+ subject: string;
69
+ html?: string;
70
+ text?: string;
71
+ from?: string;
72
+ replyTo?: string;
73
+ cc?: string | string[];
74
+ bcc?: string | string[];
75
+ template?: string;
76
+ templateData?: Record<string, any>;
77
+ headers?: Record<string, string>;
78
+ attachments?: EmailAttachment[];
79
+ metadata?: Record<string, any>;
80
+ }
81
+ export interface SendEmailOptions extends EmailPayload {
82
+ idempotencyKey?: string;
83
+ ttlMs?: number;
84
+ expiresAt?: number;
85
+ maxRetries?: number;
86
+ }
87
+ export type EmailHandlerResult = {
88
+ messageId?: string;
89
+ [key: string]: any;
90
+ } | void;
91
+ export type EmailHandler = (email: EmailPayload, action: ActionRecord<EmailPayload, any>, context: ActionHandlerContext) => Promise<EmailHandlerResult> | EmailHandlerResult;
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -9,6 +9,7 @@ import { SyncLogger, CustomLogger, LogLevel } from '../utils/logger.js';
9
9
  import { QueryBuilder, AggregateSpec, AggregateResult } from './query-builder.js';
10
10
  import { type ExportJsonOptions, type ImportJsonOptions, type ExportCsvOptions, type ImportCsvOptions, type ImportSummary } from '../utils/portability.js';
11
11
  import { type BackupStorage, type CreateDatabaseBackupOptions, type EncryptedBackupOptions, type RestoreEncryptedBackupOptions } from '../backups/index.js';
12
+ import { ActionManager, type ActionManagerOptions } from '../actions/index.js';
12
13
  export interface LocalFirstClientConfig {
13
14
  local: LocalDBAdapter;
14
15
  remote: RemoteDBAdapter;
@@ -39,6 +40,7 @@ export interface LocalFirstClientConfig {
39
40
  * - `false`: disabled
40
41
  */
41
42
  touchUpdatedAt?: boolean | string;
43
+ actions?: ActionManagerOptions;
42
44
  }
43
45
  export declare class TransactionalClient {
44
46
  private parent;
@@ -86,6 +88,7 @@ export declare class LocalFirstClient {
86
88
  /** Local column stamped on INSERT/UPDATE writes, or null when disabled. */
87
89
  touchColumn: string | null;
88
90
  readonly maintenance: DatabaseMaintenance;
91
+ readonly actions: ActionManager;
89
92
  private primaryKey;
90
93
  private migrations;
91
94
  private autoStart;
@@ -14,6 +14,7 @@ const query_js_1 = require("./query.js");
14
14
  const query_builder_js_1 = require("./query-builder.js");
15
15
  const portability_js_1 = require("../utils/portability.js");
16
16
  const index_js_1 = require("../backups/index.js");
17
+ const index_js_2 = require("../actions/index.js");
17
18
  function hasFunctionFilter(value) {
18
19
  if (typeof value === 'function')
19
20
  return true;
@@ -166,6 +167,7 @@ class LocalFirstClient {
166
167
  /** Local column stamped on INSERT/UPDATE writes, or null when disabled. */
167
168
  touchColumn;
168
169
  maintenance;
170
+ actions;
169
171
  primaryKey;
170
172
  migrations;
171
173
  autoStart;
@@ -191,6 +193,11 @@ class LocalFirstClient {
191
193
  : this.remote?.updatedAtColumn || 'updated_at';
192
194
  this.schemaManager = new schema_manager_js_1.SchemaManager();
193
195
  this.maintenance = new maintenance_js_1.DatabaseMaintenance(this.db, () => this.tables);
196
+ const actionNetwork = config.actions?.network ?? config.network;
197
+ this.actions = new index_js_2.ActionManager(this.db, this.events, () => Promise.resolve(actionNetwork ? actionNetwork.isOnline() : true), {
198
+ network: actionNetwork,
199
+ ...config.actions,
200
+ });
194
201
  const workerOptions = {
195
202
  network: config.network,
196
203
  conflictResolver: config.conflictResolver,
@@ -349,6 +356,7 @@ class LocalFirstClient {
349
356
  await outbox_store_js_1.OutboxStore.ensureSchema(this.db);
350
357
  await meta_store_js_1.MetaStore.ensureSchema(this.db);
351
358
  await outbox_store_js_1.OutboxStore.resetStaleProcessing(this.db);
359
+ await this.actions.initialize();
352
360
  if (this.migrations.length > 0) {
353
361
  await this.db.transaction(async (tx) => {
354
362
  for (const query of this.migrations) {
@@ -862,6 +870,7 @@ class LocalFirstClient {
862
870
  */
863
871
  stop() {
864
872
  this.syncWorker.stop();
873
+ this.actions.stop();
865
874
  }
866
875
  /**
867
876
  * Stops background sync workers, cleans up remote listeners,
package/dist/index.d.ts CHANGED
@@ -14,7 +14,7 @@ export * from './adapters/local/index.js';
14
14
  export * from './adapters/remote/index.js';
15
15
  export * from './adapters/network/index.js';
16
16
  export * from './config/index.js';
17
- export type * from './react/index.js';
17
+ export * from './react/index.js';
18
18
  export * from './utils/uuid.js';
19
19
  export * from './utils/sql.js';
20
20
  export * from './utils/logger.js';
@@ -22,3 +22,4 @@ export * from './utils/portability.js';
22
22
  export * from './schema/index.js';
23
23
  export * from './auth/index.js';
24
24
  export * from './backups/index.js';
25
+ export * from './actions/index.js';
package/dist/index.js CHANGED
@@ -30,6 +30,7 @@ __exportStar(require("./adapters/local/index.js"), exports);
30
30
  __exportStar(require("./adapters/remote/index.js"), exports);
31
31
  __exportStar(require("./adapters/network/index.js"), exports);
32
32
  __exportStar(require("./config/index.js"), exports);
33
+ __exportStar(require("./react/index.js"), exports);
33
34
  __exportStar(require("./utils/uuid.js"), exports);
34
35
  __exportStar(require("./utils/sql.js"), exports);
35
36
  __exportStar(require("./utils/logger.js"), exports);
@@ -37,3 +38,4 @@ __exportStar(require("./utils/portability.js"), exports);
37
38
  __exportStar(require("./schema/index.js"), exports);
38
39
  __exportStar(require("./auth/index.js"), exports);
39
40
  __exportStar(require("./backups/index.js"), exports);
41
+ __exportStar(require("./actions/index.js"), exports);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-lite",
3
- "version": "0.0.17",
3
+ "version": "0.0.19",
4
4
  "description": "Never let a lost connection break a promise. Local-first sync that faithfully keeps data safe until you're back online.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -100,6 +100,11 @@
100
100
  "types": "./dist/core/index.d.ts",
101
101
  "import": "./dist/core/index.js",
102
102
  "default": "./dist/core/index.js"
103
+ },
104
+ "./actions": {
105
+ "types": "./dist/actions/index.d.ts",
106
+ "import": "./dist/actions/index.js",
107
+ "default": "./dist/actions/index.js"
103
108
  }
104
109
  },
105
110
  "scripts": {
@@ -153,7 +158,7 @@
153
158
  "@types/node": "^26.6.5",
154
159
  "@types/react": "^19.3.0",
155
160
  "@types/uuid": "^11.0.0",
156
- "expo-sqlite": "^58.0.13",
161
+ "expo-sqlite": "^58.0.14",
157
162
  "react": "^19.3.1",
158
163
  "typescript": "^7.0.2",
159
164
  "vitest": "^5.0.5"