local-lite 0.0.0-stage → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +349 -2
- package/dist/adapters/local/better-sqlite3.d.ts +23 -0
- package/dist/adapters/local/better-sqlite3.js +46 -0
- package/dist/adapters/local/expo-sqlite.d.ts +8 -0
- package/dist/adapters/local/expo-sqlite.js +30 -0
- package/dist/adapters/local/index.d.ts +13 -0
- package/dist/adapters/local/index.js +22 -0
- package/dist/adapters/local/libsql.d.ts +24 -0
- package/dist/adapters/local/libsql.js +87 -0
- package/dist/adapters/local/node-sqlite.d.ts +24 -0
- package/dist/adapters/local/node-sqlite.js +44 -0
- package/dist/adapters/local/op-sqlite.d.ts +26 -0
- package/dist/adapters/local/op-sqlite.js +59 -0
- package/dist/adapters/local/sql-js.d.ts +26 -0
- package/dist/adapters/local/sql-js.js +63 -0
- package/dist/adapters/network/index.d.ts +11 -0
- package/dist/adapters/network/index.js +53 -0
- package/dist/adapters/remote/convex.d.ts +71 -0
- package/dist/adapters/remote/convex.js +185 -0
- package/dist/adapters/remote/demo.d.ts +21 -0
- package/dist/adapters/remote/demo.js +46 -0
- package/dist/adapters/remote/firestore.d.ts +64 -0
- package/dist/adapters/remote/firestore.js +302 -0
- package/dist/adapters/remote/index.d.ts +10 -0
- package/dist/adapters/remote/index.js +20 -0
- package/dist/adapters/remote/supabase.d.ts +97 -0
- package/dist/adapters/remote/supabase.js +276 -0
- package/dist/config/index.d.ts +24 -0
- package/dist/config/index.js +101 -0
- package/dist/config/resolve-local.d.ts +7 -0
- package/dist/config/resolve-local.js +68 -0
- package/dist/config/resolve-remote.d.ts +7 -0
- package/dist/config/resolve-remote.js +81 -0
- package/dist/config/types.d.ts +159 -0
- package/dist/config/types.js +2 -0
- package/dist/core/client.d.ts +100 -0
- package/dist/core/client.js +273 -0
- package/dist/core/conflict.d.ts +5 -0
- package/dist/core/conflict.js +38 -0
- package/dist/core/meta-store.d.ts +28 -0
- package/dist/core/meta-store.js +64 -0
- package/dist/core/outbox-store.d.ts +45 -0
- package/dist/core/outbox-store.js +141 -0
- package/dist/core/schema-manager.d.ts +39 -0
- package/dist/core/schema-manager.js +150 -0
- package/dist/core/sync-worker.d.ts +74 -0
- package/dist/core/sync-worker.js +335 -0
- package/dist/core/table-manager.d.ts +19 -0
- package/dist/core/table-manager.js +74 -0
- package/dist/core/types.d.ts +73 -0
- package/dist/core/types.js +2 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +33 -0
- package/dist/react/index.d.ts +34 -0
- package/dist/react/index.js +103 -0
- package/dist/reactive/event-emitter.d.ts +7 -0
- package/dist/reactive/event-emitter.js +34 -0
- package/dist/utils/logger.d.ts +51 -0
- package/dist/utils/logger.js +169 -0
- package/dist/utils/sql.d.ts +4 -0
- package/dist/utils/sql.js +9 -0
- package/dist/utils/uuid.d.ts +7 -0
- package/dist/utils/uuid.js +27 -0
- package/package.json +130 -3
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { LocalDBAdapter } from '../adapters/local/index.js';
|
|
2
|
+
import { RemoteDBAdapter } from '../adapters/remote/index.js';
|
|
3
|
+
import { ConflictResolver, ConflictStrategy, NetworkStatusProvider, RetryConfig } from '../core/types.js';
|
|
4
|
+
import { SyncLogger, CustomLogger, LogLevel } from '../utils/logger.js';
|
|
5
|
+
export interface SupabaseConfigOptions {
|
|
6
|
+
url?: string;
|
|
7
|
+
anonKey?: string;
|
|
8
|
+
client?: any;
|
|
9
|
+
schema?: string;
|
|
10
|
+
updatedAtColumn?: string;
|
|
11
|
+
deletedColumn?: string;
|
|
12
|
+
deletedValue?: any;
|
|
13
|
+
deletedAtColumn?: string;
|
|
14
|
+
pushRpcName?: string;
|
|
15
|
+
channelName?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface ConvexConfigOptions {
|
|
18
|
+
/**
|
|
19
|
+
* ConvexClient instance.
|
|
20
|
+
*/
|
|
21
|
+
client: any;
|
|
22
|
+
/**
|
|
23
|
+
* Primary key field name. Default: config.primaryKey or 'id'
|
|
24
|
+
*/
|
|
25
|
+
primaryKey?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Convex mutation function or name for pushing changes.
|
|
28
|
+
* Default: 'sync:pushBatch'
|
|
29
|
+
*/
|
|
30
|
+
pushMutation?: any;
|
|
31
|
+
/**
|
|
32
|
+
* Convex query function or name for pulling changes.
|
|
33
|
+
* Default: 'sync:pullDeltas'
|
|
34
|
+
*/
|
|
35
|
+
pullQuery?: any;
|
|
36
|
+
/**
|
|
37
|
+
* Column used for soft deletes. Default: 'deleted'
|
|
38
|
+
*/
|
|
39
|
+
deletedColumn?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Column used for updatedAt timestamp. Default: 'updatedAt'
|
|
42
|
+
*/
|
|
43
|
+
updatedAtColumn?: string;
|
|
44
|
+
}
|
|
45
|
+
export interface FirebaseConfigOptions {
|
|
46
|
+
/**
|
|
47
|
+
* Firestore database instance (from modular firebase/firestore, @react-native-firebase/firestore, or compat SDK).
|
|
48
|
+
*/
|
|
49
|
+
firestore: any;
|
|
50
|
+
/**
|
|
51
|
+
* Primary key field name. Default: config.primaryKey or 'id'
|
|
52
|
+
*/
|
|
53
|
+
primaryKey?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Field used for soft deletes. Default: 'deleted'
|
|
56
|
+
*/
|
|
57
|
+
deletedColumn?: string;
|
|
58
|
+
/**
|
|
59
|
+
* Field used for updatedAt timestamp tracking. Default: 'updatedAt'
|
|
60
|
+
*/
|
|
61
|
+
updatedAtColumn?: string;
|
|
62
|
+
/**
|
|
63
|
+
* Optional reference to modular firebase/firestore module if not globally available.
|
|
64
|
+
*/
|
|
65
|
+
firestoreModule?: any;
|
|
66
|
+
}
|
|
67
|
+
export interface LocalLiteConfig {
|
|
68
|
+
/**
|
|
69
|
+
* Local SQLite Database instance, name, factory function, or LocalDBAdapter.
|
|
70
|
+
* - Pass an Expo database instance (e.g. from SQLite.openDatabaseAsync('app.db'))
|
|
71
|
+
* - Pass a factory: () => SQLite.openDatabaseAsync('app.db')
|
|
72
|
+
* - Pass a database file name: 'app.db'
|
|
73
|
+
* - Or a custom LocalDBAdapter
|
|
74
|
+
*/
|
|
75
|
+
database?: any | string | (() => Promise<any> | any) | LocalDBAdapter;
|
|
76
|
+
/**
|
|
77
|
+
* Database name if database is not provided. Default: 'local_lite.db'
|
|
78
|
+
*/
|
|
79
|
+
databaseName?: string;
|
|
80
|
+
/**
|
|
81
|
+
* List of tables to sync.
|
|
82
|
+
* e.g. ['todos', 'notes']
|
|
83
|
+
*/
|
|
84
|
+
tables?: string[];
|
|
85
|
+
/**
|
|
86
|
+
* Initial SQL table schema migration queries to execute on startup.
|
|
87
|
+
* e.g. ["CREATE TABLE IF NOT EXISTS todos (id TEXT PRIMARY KEY, title TEXT);"]
|
|
88
|
+
*/
|
|
89
|
+
migrations?: string[];
|
|
90
|
+
/**
|
|
91
|
+
* Automatically create tables and columns dynamically on the fly without manual SQL migrations.
|
|
92
|
+
* When true (default if migrations is not provided), tables in `tables` are created automatically,
|
|
93
|
+
* and any missing columns are added dynamically on insert/update/pull.
|
|
94
|
+
*/
|
|
95
|
+
autoSchema?: boolean;
|
|
96
|
+
/**
|
|
97
|
+
* Primary key column. Default: 'id'
|
|
98
|
+
*/
|
|
99
|
+
primaryKey?: string;
|
|
100
|
+
/**
|
|
101
|
+
* Supabase backend connection options.
|
|
102
|
+
*/
|
|
103
|
+
supabase?: SupabaseConfigOptions;
|
|
104
|
+
/**
|
|
105
|
+
* Convex backend connection options.
|
|
106
|
+
*/
|
|
107
|
+
convex?: ConvexConfigOptions;
|
|
108
|
+
/**
|
|
109
|
+
* Firebase / Firestore backend connection options.
|
|
110
|
+
*/
|
|
111
|
+
firebase?: FirebaseConfigOptions;
|
|
112
|
+
/**
|
|
113
|
+
* Custom remote adapter (overrides Supabase / Convex / Firebase).
|
|
114
|
+
*/
|
|
115
|
+
remote?: RemoteDBAdapter;
|
|
116
|
+
/**
|
|
117
|
+
* Whether to use an in-memory simulated remote backend if Supabase, Convex, or Firebase credentials are not provided.
|
|
118
|
+
* Allows running and testing full local-first sync workflows without cloud infrastructure.
|
|
119
|
+
* Default: true
|
|
120
|
+
*/
|
|
121
|
+
inMemoryRemote?: boolean;
|
|
122
|
+
/**
|
|
123
|
+
* Conflict resolution strategy. Default: 'last-write-wins'
|
|
124
|
+
*/
|
|
125
|
+
conflictResolver?: ConflictStrategy | ConflictResolver;
|
|
126
|
+
/**
|
|
127
|
+
* Periodic outbox push interval in ms. Default: 5000
|
|
128
|
+
*/
|
|
129
|
+
pushIntervalMs?: number;
|
|
130
|
+
/**
|
|
131
|
+
* Periodic remote pull interval in ms. Default: 10000
|
|
132
|
+
*/
|
|
133
|
+
pullIntervalMs?: number;
|
|
134
|
+
/**
|
|
135
|
+
* Maximum retry attempts for failed push items. Default: 5
|
|
136
|
+
*/
|
|
137
|
+
maxRetries?: number;
|
|
138
|
+
/**
|
|
139
|
+
* Advanced retry configuration with exponential backoff and custom retry predicates.
|
|
140
|
+
*/
|
|
141
|
+
retry?: number | RetryConfig;
|
|
142
|
+
/**
|
|
143
|
+
* Custom logger or false to silence logs in production.
|
|
144
|
+
*/
|
|
145
|
+
logger?: SyncLogger | CustomLogger | boolean;
|
|
146
|
+
/**
|
|
147
|
+
* Minimum log level. Default: 'info'
|
|
148
|
+
*/
|
|
149
|
+
logLevel?: LogLevel;
|
|
150
|
+
/**
|
|
151
|
+
* Network status provider. Default: DefaultNetworkStatusProvider
|
|
152
|
+
*/
|
|
153
|
+
network?: NetworkStatusProvider;
|
|
154
|
+
/**
|
|
155
|
+
* Automatically call client.initialize() and create tables.
|
|
156
|
+
* Default: true
|
|
157
|
+
*/
|
|
158
|
+
autoInitialize?: boolean;
|
|
159
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { LocalDBAdapter } from '../adapters/local/index.js';
|
|
2
|
+
import { RemoteDBAdapter } from '../adapters/remote/index.js';
|
|
3
|
+
import { EventEmitter } from '../reactive/event-emitter.js';
|
|
4
|
+
import { SyncWorker } from './sync-worker.js';
|
|
5
|
+
import { SchemaManager } from './schema-manager.js';
|
|
6
|
+
import { ConflictResolver, ConflictStrategy, MutateOptions, NetworkStatusProvider, QueryOptions, RetryConfig, SyncOp } from './types.js';
|
|
7
|
+
import { SyncLogger, CustomLogger, LogLevel } from '../utils/logger.js';
|
|
8
|
+
export interface LocalFirstClientConfig {
|
|
9
|
+
local: LocalDBAdapter;
|
|
10
|
+
remote: RemoteDBAdapter;
|
|
11
|
+
network?: NetworkStatusProvider;
|
|
12
|
+
conflictResolver?: ConflictStrategy | ConflictResolver;
|
|
13
|
+
primaryKey?: string;
|
|
14
|
+
pushIntervalMs?: number;
|
|
15
|
+
pullIntervalMs?: number;
|
|
16
|
+
maxRetries?: number;
|
|
17
|
+
retry?: number | RetryConfig;
|
|
18
|
+
logger?: SyncLogger | CustomLogger | boolean;
|
|
19
|
+
logLevel?: LogLevel;
|
|
20
|
+
autoSchema?: boolean;
|
|
21
|
+
tables?: string[];
|
|
22
|
+
}
|
|
23
|
+
export declare class LocalFirstClient {
|
|
24
|
+
db: LocalDBAdapter;
|
|
25
|
+
remote: RemoteDBAdapter;
|
|
26
|
+
events: EventEmitter;
|
|
27
|
+
syncWorker: SyncWorker;
|
|
28
|
+
schemaManager: SchemaManager;
|
|
29
|
+
autoSchema: boolean;
|
|
30
|
+
tables: string[];
|
|
31
|
+
private primaryKey;
|
|
32
|
+
constructor(config: LocalFirstClientConfig);
|
|
33
|
+
/**
|
|
34
|
+
* Initializes the client: creates sync_outbox schema, ensures autoSchema tables if enabled,
|
|
35
|
+
* and starts the background sync worker.
|
|
36
|
+
*/
|
|
37
|
+
initialize(): Promise<void>;
|
|
38
|
+
/**
|
|
39
|
+
* Performs an atomic dual-write: mutates the local data table AND writes
|
|
40
|
+
* to sync_outbox within the same database transaction.
|
|
41
|
+
*/
|
|
42
|
+
mutate(tableName: string, rowId: string, operation: SyncOp, payload: Record<string, any> | null, options?: MutateOptions): Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* Convenience helper to insert a record into a table.
|
|
45
|
+
* Auto-generates a UUID if rowId is omitted or passed inside payload.
|
|
46
|
+
*
|
|
47
|
+
* Example:
|
|
48
|
+
* await client.insert('todos', { title: 'Buy milk' });
|
|
49
|
+
* await client.insert('todos', 'custom-id', { title: 'Buy milk' });
|
|
50
|
+
*/
|
|
51
|
+
insert(tableName: string, rowIdOrPayload: string | Record<string, any>, payloadOrOptions?: Record<string, any> | MutateOptions, options?: MutateOptions): Promise<string>;
|
|
52
|
+
/**
|
|
53
|
+
* Convenience helper to update an existing record by ID.
|
|
54
|
+
*
|
|
55
|
+
* Example:
|
|
56
|
+
* await client.update('todos', 'todo-1', { completed: 1 });
|
|
57
|
+
*/
|
|
58
|
+
update(tableName: string, rowId: string, payload: Record<string, any>, options?: MutateOptions): Promise<void>;
|
|
59
|
+
/**
|
|
60
|
+
* Convenience helper to delete a record by ID.
|
|
61
|
+
*
|
|
62
|
+
* Example:
|
|
63
|
+
* await client.delete('todos', 'todo-1');
|
|
64
|
+
*/
|
|
65
|
+
delete(tableName: string, rowId: string, options?: MutateOptions): Promise<void>;
|
|
66
|
+
/**
|
|
67
|
+
* Reads records from a local SQLite table without requiring manual SQL.
|
|
68
|
+
* Supports where filtering, order, limit, and offset.
|
|
69
|
+
* Gracefully returns [] if table or columns have not been populated yet on empty schemas.
|
|
70
|
+
*
|
|
71
|
+
* Example:
|
|
72
|
+
* const tenants = await client.findMany('tenants', { where: { status: 'ACTIVE' }, orderBy: 'name' });
|
|
73
|
+
*/
|
|
74
|
+
findMany<T = any>(tableName: string, options?: QueryOptions): Promise<T[]>;
|
|
75
|
+
/**
|
|
76
|
+
* Finds a single record by primary key.
|
|
77
|
+
*
|
|
78
|
+
* Example:
|
|
79
|
+
* const tenant = await client.findById('tenants', 'ten-1');
|
|
80
|
+
*/
|
|
81
|
+
findById<T = any>(tableName: string, id: string): Promise<T | null>;
|
|
82
|
+
/**
|
|
83
|
+
* Tracks an existing mutation into sync_outbox without modifying the table.
|
|
84
|
+
* Can accept an external transaction `options.tx` for custom atomic SQL workflows.
|
|
85
|
+
*/
|
|
86
|
+
trackMutation(tableName: string, rowId: string, operation: SyncOp, payload: Record<string, any> | null, options?: MutateOptions): Promise<string>;
|
|
87
|
+
/**
|
|
88
|
+
* Retrieves the persisted last_synced_at timestamp cursor (in ms).
|
|
89
|
+
*/
|
|
90
|
+
getLastSyncedAt(): Promise<number>;
|
|
91
|
+
/**
|
|
92
|
+
* Resets the persistent sync cursor back to 0, forcing the next pull
|
|
93
|
+
* to query all remote records.
|
|
94
|
+
*/
|
|
95
|
+
resetSyncCursor(): Promise<void>;
|
|
96
|
+
/**
|
|
97
|
+
* Stops all active intervals, background sync loops, and network listeners.
|
|
98
|
+
*/
|
|
99
|
+
stop(): void;
|
|
100
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LocalFirstClient = void 0;
|
|
4
|
+
const event_emitter_js_1 = require("../reactive/event-emitter.js");
|
|
5
|
+
const sync_worker_js_1 = require("./sync-worker.js");
|
|
6
|
+
const table_manager_js_1 = require("./table-manager.js");
|
|
7
|
+
const outbox_store_js_1 = require("./outbox-store.js");
|
|
8
|
+
const meta_store_js_1 = require("./meta-store.js");
|
|
9
|
+
const schema_manager_js_1 = require("./schema-manager.js");
|
|
10
|
+
const uuid_js_1 = require("../utils/uuid.js");
|
|
11
|
+
const sql_js_1 = require("../utils/sql.js");
|
|
12
|
+
class LocalFirstClient {
|
|
13
|
+
db;
|
|
14
|
+
remote;
|
|
15
|
+
events;
|
|
16
|
+
syncWorker;
|
|
17
|
+
schemaManager;
|
|
18
|
+
autoSchema;
|
|
19
|
+
tables;
|
|
20
|
+
primaryKey;
|
|
21
|
+
constructor(config) {
|
|
22
|
+
this.db = config.local;
|
|
23
|
+
this.remote = config.remote;
|
|
24
|
+
this.events = new event_emitter_js_1.EventEmitter();
|
|
25
|
+
this.primaryKey = config.primaryKey ?? 'id';
|
|
26
|
+
this.autoSchema = config.autoSchema ?? false;
|
|
27
|
+
this.tables = config.tables ?? [];
|
|
28
|
+
this.schemaManager = new schema_manager_js_1.SchemaManager();
|
|
29
|
+
const workerOptions = {
|
|
30
|
+
network: config.network,
|
|
31
|
+
conflictResolver: config.conflictResolver,
|
|
32
|
+
primaryKey: this.primaryKey,
|
|
33
|
+
pushIntervalMs: config.pushIntervalMs,
|
|
34
|
+
pullIntervalMs: config.pullIntervalMs,
|
|
35
|
+
maxRetries: config.maxRetries,
|
|
36
|
+
retry: config.retry,
|
|
37
|
+
logger: config.logger,
|
|
38
|
+
logLevel: config.logLevel,
|
|
39
|
+
autoSchema: this.autoSchema,
|
|
40
|
+
schemaManager: this.schemaManager,
|
|
41
|
+
};
|
|
42
|
+
this.syncWorker = new sync_worker_js_1.SyncWorker(this.db, this.remote, this.events, workerOptions);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Initializes the client: creates sync_outbox schema, ensures autoSchema tables if enabled,
|
|
46
|
+
* and starts the background sync worker.
|
|
47
|
+
*/
|
|
48
|
+
async initialize() {
|
|
49
|
+
await outbox_store_js_1.OutboxStore.ensureSchema(this.db);
|
|
50
|
+
await meta_store_js_1.MetaStore.ensureSchema(this.db);
|
|
51
|
+
await outbox_store_js_1.OutboxStore.resetStaleProcessing(this.db);
|
|
52
|
+
if (this.autoSchema && this.tables.length > 0) {
|
|
53
|
+
for (const table of this.tables) {
|
|
54
|
+
await this.schemaManager.ensureTable(this.db, table, this.primaryKey);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
this.syncWorker.start();
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Performs an atomic dual-write: mutates the local data table AND writes
|
|
61
|
+
* to sync_outbox within the same database transaction.
|
|
62
|
+
*/
|
|
63
|
+
async mutate(tableName, rowId, operation, payload, options = {}) {
|
|
64
|
+
const pk = options.primaryKey ?? this.primaryKey;
|
|
65
|
+
const outboxId = (0, uuid_js_1.generateUUID)();
|
|
66
|
+
const timestamp = options.customTimestamp ?? Date.now();
|
|
67
|
+
const executeInTransaction = async (tx) => {
|
|
68
|
+
// 1. Mutate local table
|
|
69
|
+
await table_manager_js_1.TableManager.applyMutation(tx, tableName, rowId, operation, payload, pk, {
|
|
70
|
+
autoSchema: this.autoSchema,
|
|
71
|
+
schemaManager: this.schemaManager,
|
|
72
|
+
});
|
|
73
|
+
// 2. Insert into sync_outbox (if not skipped)
|
|
74
|
+
if (!options.skipOutbox) {
|
|
75
|
+
await outbox_store_js_1.OutboxStore.insertRecord(tx, {
|
|
76
|
+
id: outboxId,
|
|
77
|
+
tableName,
|
|
78
|
+
rowId,
|
|
79
|
+
operation,
|
|
80
|
+
payload,
|
|
81
|
+
timestamp,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
if (options.tx) {
|
|
86
|
+
await executeInTransaction(options.tx);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
await this.db.transaction(executeInTransaction);
|
|
90
|
+
}
|
|
91
|
+
// 3. Emit reactive change events
|
|
92
|
+
this.events.emit(tableName, {
|
|
93
|
+
operation,
|
|
94
|
+
rowId,
|
|
95
|
+
payload,
|
|
96
|
+
source: 'local',
|
|
97
|
+
});
|
|
98
|
+
if (!options.skipOutbox) {
|
|
99
|
+
this.events.emit('sync_outbox', { operation: 'INSERT', rowId: outboxId });
|
|
100
|
+
// 4. Trigger immediate debounced push
|
|
101
|
+
this.syncWorker.schedulePush(20);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Convenience helper to insert a record into a table.
|
|
106
|
+
* Auto-generates a UUID if rowId is omitted or passed inside payload.
|
|
107
|
+
*
|
|
108
|
+
* Example:
|
|
109
|
+
* await client.insert('todos', { title: 'Buy milk' });
|
|
110
|
+
* await client.insert('todos', 'custom-id', { title: 'Buy milk' });
|
|
111
|
+
*/
|
|
112
|
+
async insert(tableName, rowIdOrPayload, payloadOrOptions, options) {
|
|
113
|
+
let rowId;
|
|
114
|
+
let payload;
|
|
115
|
+
let opts = {};
|
|
116
|
+
if (typeof rowIdOrPayload === 'string') {
|
|
117
|
+
rowId = rowIdOrPayload;
|
|
118
|
+
payload = payloadOrOptions || {};
|
|
119
|
+
opts = options || {};
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
payload = rowIdOrPayload || {};
|
|
123
|
+
rowId = payload[this.primaryKey] || (0, uuid_js_1.generateUUID)();
|
|
124
|
+
opts = payloadOrOptions || {};
|
|
125
|
+
}
|
|
126
|
+
await this.mutate(tableName, rowId, 'INSERT', payload, opts);
|
|
127
|
+
return rowId;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Convenience helper to update an existing record by ID.
|
|
131
|
+
*
|
|
132
|
+
* Example:
|
|
133
|
+
* await client.update('todos', 'todo-1', { completed: 1 });
|
|
134
|
+
*/
|
|
135
|
+
async update(tableName, rowId, payload, options = {}) {
|
|
136
|
+
await this.mutate(tableName, rowId, 'UPDATE', payload, options);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Convenience helper to delete a record by ID.
|
|
140
|
+
*
|
|
141
|
+
* Example:
|
|
142
|
+
* await client.delete('todos', 'todo-1');
|
|
143
|
+
*/
|
|
144
|
+
async delete(tableName, rowId, options = {}) {
|
|
145
|
+
await this.mutate(tableName, rowId, 'DELETE', null, options);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Reads records from a local SQLite table without requiring manual SQL.
|
|
149
|
+
* Supports where filtering, order, limit, and offset.
|
|
150
|
+
* Gracefully returns [] if table or columns have not been populated yet on empty schemas.
|
|
151
|
+
*
|
|
152
|
+
* Example:
|
|
153
|
+
* const tenants = await client.findMany('tenants', { where: { status: 'ACTIVE' }, orderBy: 'name' });
|
|
154
|
+
*/
|
|
155
|
+
async findMany(tableName, options) {
|
|
156
|
+
const safeTable = (0, sql_js_1.escapeIdentifier)(tableName);
|
|
157
|
+
let rows;
|
|
158
|
+
try {
|
|
159
|
+
rows = await this.db.execute(`SELECT * FROM ${safeTable}`);
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
const msg = String(err?.message || err || '');
|
|
163
|
+
if (msg.includes('no such table') || msg.includes('no such column')) {
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
throw err;
|
|
167
|
+
}
|
|
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;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Finds a single record by primary key.
|
|
209
|
+
*
|
|
210
|
+
* Example:
|
|
211
|
+
* const tenant = await client.findById('tenants', 'ten-1');
|
|
212
|
+
*/
|
|
213
|
+
async findById(tableName, id) {
|
|
214
|
+
const results = await this.findMany(tableName, {
|
|
215
|
+
where: { [this.primaryKey]: id },
|
|
216
|
+
limit: 1,
|
|
217
|
+
});
|
|
218
|
+
return results.length > 0 ? results[0] : null;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Tracks an existing mutation into sync_outbox without modifying the table.
|
|
222
|
+
* Can accept an external transaction `options.tx` for custom atomic SQL workflows.
|
|
223
|
+
*/
|
|
224
|
+
async trackMutation(tableName, rowId, operation, payload, options = {}) {
|
|
225
|
+
const outboxId = (0, uuid_js_1.generateUUID)();
|
|
226
|
+
const timestamp = options.customTimestamp ?? Date.now();
|
|
227
|
+
const executeInsert = async (tx) => {
|
|
228
|
+
await outbox_store_js_1.OutboxStore.insertRecord(tx, {
|
|
229
|
+
id: outboxId,
|
|
230
|
+
tableName,
|
|
231
|
+
rowId,
|
|
232
|
+
operation,
|
|
233
|
+
payload,
|
|
234
|
+
timestamp,
|
|
235
|
+
});
|
|
236
|
+
};
|
|
237
|
+
if (options.tx) {
|
|
238
|
+
await executeInsert(options.tx);
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
await this.db.transaction(executeInsert);
|
|
242
|
+
}
|
|
243
|
+
this.events.emit(tableName, {
|
|
244
|
+
operation,
|
|
245
|
+
rowId,
|
|
246
|
+
payload,
|
|
247
|
+
source: 'local',
|
|
248
|
+
});
|
|
249
|
+
this.events.emit('sync_outbox', { operation: 'INSERT', rowId: outboxId });
|
|
250
|
+
this.syncWorker.schedulePush(20);
|
|
251
|
+
return outboxId;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Retrieves the persisted last_synced_at timestamp cursor (in ms).
|
|
255
|
+
*/
|
|
256
|
+
async getLastSyncedAt() {
|
|
257
|
+
return await this.syncWorker.getLastPulledTimestamp();
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Resets the persistent sync cursor back to 0, forcing the next pull
|
|
261
|
+
* to query all remote records.
|
|
262
|
+
*/
|
|
263
|
+
async resetSyncCursor() {
|
|
264
|
+
await this.syncWorker.resetSyncCursor();
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Stops all active intervals, background sync loops, and network listeners.
|
|
268
|
+
*/
|
|
269
|
+
stop() {
|
|
270
|
+
this.syncWorker.stop();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
exports.LocalFirstClient = LocalFirstClient;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { ConflictResolver, ConflictStrategy } from './types.js';
|
|
2
|
+
export declare const lastWriteWinsResolver: ConflictResolver;
|
|
3
|
+
export declare const serverWinsResolver: ConflictResolver;
|
|
4
|
+
export declare const clientWinsResolver: ConflictResolver;
|
|
5
|
+
export declare function getConflictResolver(strategyOrResolver?: ConflictStrategy | ConflictResolver): ConflictResolver;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.clientWinsResolver = exports.serverWinsResolver = exports.lastWriteWinsResolver = void 0;
|
|
4
|
+
exports.getConflictResolver = getConflictResolver;
|
|
5
|
+
const lastWriteWinsResolver = (context) => {
|
|
6
|
+
const remoteTime = context.remoteChange.timestamp ?? 0;
|
|
7
|
+
const localTime = context.localOutboxRecord?.timestamp ?? 0;
|
|
8
|
+
if (remoteTime >= localTime) {
|
|
9
|
+
return 'apply-remote';
|
|
10
|
+
}
|
|
11
|
+
return 'keep-local';
|
|
12
|
+
};
|
|
13
|
+
exports.lastWriteWinsResolver = lastWriteWinsResolver;
|
|
14
|
+
const serverWinsResolver = () => {
|
|
15
|
+
return 'apply-remote';
|
|
16
|
+
};
|
|
17
|
+
exports.serverWinsResolver = serverWinsResolver;
|
|
18
|
+
const clientWinsResolver = (context) => {
|
|
19
|
+
if (context.localOutboxRecord) {
|
|
20
|
+
return 'keep-local';
|
|
21
|
+
}
|
|
22
|
+
return 'apply-remote';
|
|
23
|
+
};
|
|
24
|
+
exports.clientWinsResolver = clientWinsResolver;
|
|
25
|
+
function getConflictResolver(strategyOrResolver) {
|
|
26
|
+
if (typeof strategyOrResolver === 'function') {
|
|
27
|
+
return strategyOrResolver;
|
|
28
|
+
}
|
|
29
|
+
switch (strategyOrResolver) {
|
|
30
|
+
case 'server-wins':
|
|
31
|
+
return exports.serverWinsResolver;
|
|
32
|
+
case 'client-wins':
|
|
33
|
+
return exports.clientWinsResolver;
|
|
34
|
+
case 'last-write-wins':
|
|
35
|
+
default:
|
|
36
|
+
return exports.lastWriteWinsResolver;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { LocalDBAdapter, LocalTransaction } from '../adapters/local/index.js';
|
|
2
|
+
export declare class MetaStore {
|
|
3
|
+
/**
|
|
4
|
+
* Initializes the sync_meta schema if it does not already exist.
|
|
5
|
+
* This table is strictly local to the device and is never synced to the remote server.
|
|
6
|
+
*/
|
|
7
|
+
static ensureSchema(db: LocalDBAdapter): Promise<void>;
|
|
8
|
+
/**
|
|
9
|
+
* Gets a metadata string value by key.
|
|
10
|
+
*/
|
|
11
|
+
static get(db: LocalDBAdapter, key: string): Promise<string | null>;
|
|
12
|
+
/**
|
|
13
|
+
* Sets a metadata value by key (atomic upsert).
|
|
14
|
+
*/
|
|
15
|
+
static set(db: LocalDBAdapter | LocalTransaction, key: string, value: string): Promise<void>;
|
|
16
|
+
/**
|
|
17
|
+
* Retrieves the persisted last_synced_at cursor timestamp (in ms). Returns 0 if never synced.
|
|
18
|
+
*/
|
|
19
|
+
static getLastSyncedAt(db: LocalDBAdapter): Promise<number>;
|
|
20
|
+
/**
|
|
21
|
+
* Persists the last_synced_at cursor timestamp.
|
|
22
|
+
*/
|
|
23
|
+
static setLastSyncedAt(db: LocalDBAdapter | LocalTransaction, timestamp: number): Promise<void>;
|
|
24
|
+
/**
|
|
25
|
+
* Clears the persisted cursor (forces next pull to re-query active remote records).
|
|
26
|
+
*/
|
|
27
|
+
static clearLastSyncedAt(db: LocalDBAdapter | LocalTransaction): Promise<void>;
|
|
28
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MetaStore = void 0;
|
|
4
|
+
class MetaStore {
|
|
5
|
+
/**
|
|
6
|
+
* Initializes the sync_meta schema if it does not already exist.
|
|
7
|
+
* This table is strictly local to the device and is never synced to the remote server.
|
|
8
|
+
*/
|
|
9
|
+
static async ensureSchema(db) {
|
|
10
|
+
await db.execute(`CREATE TABLE IF NOT EXISTS sync_meta (
|
|
11
|
+
key TEXT PRIMARY KEY,
|
|
12
|
+
value TEXT NOT NULL
|
|
13
|
+
);`);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Gets a metadata string value by key.
|
|
17
|
+
*/
|
|
18
|
+
static async get(db, key) {
|
|
19
|
+
try {
|
|
20
|
+
const rows = await db.execute(`SELECT value FROM sync_meta WHERE key = ? LIMIT 1`, [key]);
|
|
21
|
+
if (rows && rows.length > 0) {
|
|
22
|
+
return rows[0].value ?? null;
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Sets a metadata value by key (atomic upsert).
|
|
32
|
+
*/
|
|
33
|
+
static async set(db, key, value) {
|
|
34
|
+
await db.execute(`INSERT OR REPLACE INTO sync_meta (key, value) VALUES (?, ?)`, [key, String(value)]);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Retrieves the persisted last_synced_at cursor timestamp (in ms). Returns 0 if never synced.
|
|
38
|
+
*/
|
|
39
|
+
static async getLastSyncedAt(db) {
|
|
40
|
+
const val = await this.get(db, 'last_synced_at');
|
|
41
|
+
if (!val)
|
|
42
|
+
return 0;
|
|
43
|
+
const parsed = parseInt(val, 10);
|
|
44
|
+
return isNaN(parsed) ? 0 : parsed;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Persists the last_synced_at cursor timestamp.
|
|
48
|
+
*/
|
|
49
|
+
static async setLastSyncedAt(db, timestamp) {
|
|
50
|
+
await this.set(db, 'last_synced_at', String(timestamp));
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Clears the persisted cursor (forces next pull to re-query active remote records).
|
|
54
|
+
*/
|
|
55
|
+
static async clearLastSyncedAt(db) {
|
|
56
|
+
try {
|
|
57
|
+
await db.execute(`DELETE FROM sync_meta WHERE key = 'last_synced_at'`);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Ignored if table doesn't exist yet
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
exports.MetaStore = MetaStore;
|