local-lite 0.0.6 → 0.0.9
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 +40 -6
- package/dist/adapters/local/better-sqlite3.d.ts +1 -0
- package/dist/adapters/local/better-sqlite3.js +1 -0
- package/dist/adapters/local/expo-sqlite.d.ts +1 -0
- package/dist/adapters/local/expo-sqlite.js +1 -0
- package/dist/adapters/local/index.d.ts +2 -0
- package/dist/adapters/local/libsql.d.ts +1 -0
- package/dist/adapters/local/libsql.js +1 -0
- package/dist/adapters/local/node-sqlite.d.ts +1 -0
- package/dist/adapters/local/node-sqlite.js +1 -0
- package/dist/adapters/local/op-sqlite.d.ts +1 -0
- package/dist/adapters/local/op-sqlite.js +1 -0
- package/dist/adapters/local/sql-js.d.ts +1 -0
- package/dist/adapters/local/sql-js.js +1 -0
- package/dist/config/index.js +1 -0
- package/dist/config/types.d.ts +9 -0
- package/dist/core/client.d.ts +23 -1
- package/dist/core/client.js +80 -33
- package/dist/core/schema-manager.d.ts +6 -0
- package/dist/core/schema-manager.js +25 -0
- package/dist/core/table-manager.d.ts +9 -0
- package/dist/core/table-manager.js +13 -0
- package/dist/react/index.js +61 -8
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -246,12 +246,46 @@ export function TodoScreen() {
|
|
|
246
246
|
|
|
247
247
|
## Supabase Backend Setup & Schema
|
|
248
248
|
|
|
249
|
-
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**):
|
|
250
|
-
|
|
251
|
-
```sql
|
|
252
|
-
--
|
|
253
|
-
|
|
254
|
-
|
|
249
|
+
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**):
|
|
250
|
+
|
|
251
|
+
```sql
|
|
252
|
+
-- Ensure every application table has the soft-delete column expected by local-lite.
|
|
253
|
+
DO $$
|
|
254
|
+
DECLARE
|
|
255
|
+
table_record RECORD;
|
|
256
|
+
BEGIN
|
|
257
|
+
FOR table_record IN
|
|
258
|
+
SELECT table_schema, table_name
|
|
259
|
+
FROM information_schema.tables
|
|
260
|
+
WHERE table_schema = 'public'
|
|
261
|
+
AND table_type = 'BASE TABLE'
|
|
262
|
+
LOOP
|
|
263
|
+
EXECUTE format(
|
|
264
|
+
'ALTER TABLE %I.%I ADD COLUMN IF NOT EXISTS deleted BOOLEAN',
|
|
265
|
+
table_record.table_schema,
|
|
266
|
+
table_record.table_name
|
|
267
|
+
);
|
|
268
|
+
EXECUTE format(
|
|
269
|
+
'UPDATE %I.%I SET deleted = FALSE WHERE deleted IS NULL',
|
|
270
|
+
table_record.table_schema,
|
|
271
|
+
table_record.table_name
|
|
272
|
+
);
|
|
273
|
+
EXECUTE format(
|
|
274
|
+
'ALTER TABLE %I.%I ALTER COLUMN deleted SET DEFAULT FALSE',
|
|
275
|
+
table_record.table_schema,
|
|
276
|
+
table_record.table_name
|
|
277
|
+
);
|
|
278
|
+
EXECUTE format(
|
|
279
|
+
'ALTER TABLE %I.%I ALTER COLUMN deleted SET NOT NULL',
|
|
280
|
+
table_record.table_schema,
|
|
281
|
+
table_record.table_name
|
|
282
|
+
);
|
|
283
|
+
END LOOP;
|
|
284
|
+
END $$;
|
|
285
|
+
|
|
286
|
+
-- 1. Automatic Timestamp Trigger
|
|
287
|
+
CREATE OR REPLACE FUNCTION public.set_sync_updated_at()
|
|
288
|
+
RETURNS TRIGGER AS $$
|
|
255
289
|
BEGIN
|
|
256
290
|
NEW.updated_at = timezone('utc'::text, now());
|
|
257
291
|
RETURN NEW;
|
|
@@ -16,6 +16,7 @@ export interface BetterSqliteDatabase {
|
|
|
16
16
|
* Adapter for better-sqlite3 in Node.js, Electron, Next.js, and server environments.
|
|
17
17
|
*/
|
|
18
18
|
export declare class BetterSQLite3Adapter implements LocalDBAdapter {
|
|
19
|
+
readonly supportsSqlPushdown = true;
|
|
19
20
|
private db;
|
|
20
21
|
private txQueue;
|
|
21
22
|
private statementCache;
|
|
@@ -4,6 +4,7 @@ exports.ExpoSQLiteAdapter = void 0;
|
|
|
4
4
|
// Pre-compiled RegExp hoisted to module scope — 0 regex allocations per query
|
|
5
5
|
const READ_QUERY_REGEX = /^(?:SELECT|PRAGMA)\b/i;
|
|
6
6
|
class ExpoSQLiteAdapter {
|
|
7
|
+
supportsSqlPushdown = true;
|
|
7
8
|
db;
|
|
8
9
|
writeQueue = Promise.resolve();
|
|
9
10
|
constructor(expoDbInstance) {
|
|
@@ -3,6 +3,8 @@ export interface LocalTransaction {
|
|
|
3
3
|
}
|
|
4
4
|
export interface LocalDBAdapter {
|
|
5
5
|
execute(query: string, params?: any[]): Promise<any[]>;
|
|
6
|
+
/** True when execute runs real SQL with filtering, ordering, paging, and projection. */
|
|
7
|
+
readonly supportsSqlPushdown?: boolean;
|
|
6
8
|
transaction<T>(action: (tx: LocalTransaction) => Promise<T>): Promise<T>;
|
|
7
9
|
close?(): Promise<void> | void;
|
|
8
10
|
}
|
|
@@ -17,6 +17,7 @@ export interface LibSQLClient {
|
|
|
17
17
|
* Supports local SQLite files, embedded replicas, in-memory databases, and server/edge deployments.
|
|
18
18
|
*/
|
|
19
19
|
export declare class LibSQLAdapter implements LocalDBAdapter {
|
|
20
|
+
readonly supportsSqlPushdown = true;
|
|
20
21
|
private client;
|
|
21
22
|
constructor(libsqlClient: any);
|
|
22
23
|
execute(query: string, params?: any[]): Promise<any[]>;
|
|
@@ -28,6 +28,7 @@ function normalizeRows(res) {
|
|
|
28
28
|
* Supports local SQLite files, embedded replicas, in-memory databases, and server/edge deployments.
|
|
29
29
|
*/
|
|
30
30
|
class LibSQLAdapter {
|
|
31
|
+
supportsSqlPushdown = true;
|
|
31
32
|
client;
|
|
32
33
|
constructor(libsqlClient) {
|
|
33
34
|
this.client = libsqlClient;
|
|
@@ -17,6 +17,7 @@ export interface NodeSqliteDatabaseSync {
|
|
|
17
17
|
* Provides zero-dependency, native SQLite on modern Node.js runtimes.
|
|
18
18
|
*/
|
|
19
19
|
export declare class NodeSQLiteAdapter implements LocalDBAdapter {
|
|
20
|
+
readonly supportsSqlPushdown = true;
|
|
20
21
|
private db;
|
|
21
22
|
private txQueue;
|
|
22
23
|
private statementCache;
|
|
@@ -18,6 +18,7 @@ export interface OpSQLiteDatabase {
|
|
|
18
18
|
* Enables ultra-fast C++ JSI SQLite in bare React Native.
|
|
19
19
|
*/
|
|
20
20
|
export declare class OpSQLiteAdapter implements LocalDBAdapter {
|
|
21
|
+
readonly supportsSqlPushdown = true;
|
|
21
22
|
private db;
|
|
22
23
|
private txMutex;
|
|
23
24
|
constructor(opSqliteInstance: any);
|
|
@@ -19,6 +19,7 @@ export interface SqlJsDatabase {
|
|
|
19
19
|
* Adapter for sql.js (WebAssembly SQLite in Web Browsers, PWAs, and Expo Web).
|
|
20
20
|
*/
|
|
21
21
|
export declare class SqlJsAdapter implements LocalDBAdapter {
|
|
22
|
+
readonly supportsSqlPushdown = true;
|
|
22
23
|
private db;
|
|
23
24
|
constructor(sqlJsDatabase: any);
|
|
24
25
|
execute(query: string, params?: any[]): Promise<any[]>;
|
package/dist/config/index.js
CHANGED
|
@@ -89,6 +89,7 @@ async function createSyncClient(config) {
|
|
|
89
89
|
autoHydrateJSON: config.autoHydrateJSON,
|
|
90
90
|
syncScope: config.syncScope,
|
|
91
91
|
autoPauseOnBackground: config.autoPauseOnBackground,
|
|
92
|
+
touchUpdatedAt: config.touchUpdatedAt,
|
|
92
93
|
});
|
|
93
94
|
// 5. Execute Migrations & Auto-initialize
|
|
94
95
|
if (config.autoInitialize !== false) {
|
package/dist/config/types.d.ts
CHANGED
|
@@ -181,4 +181,13 @@ export interface LocalLiteConfig {
|
|
|
181
181
|
* Scoped synchronization filter (e.g. { org_id: '...' } or dynamic resolver callback).
|
|
182
182
|
*/
|
|
183
183
|
syncScope?: Record<string, any> | (() => Record<string, any> | Promise<Record<string, any>>);
|
|
184
|
+
/**
|
|
185
|
+
* Stamp an "updated at" column on every local INSERT/UPDATE so reactive queries
|
|
186
|
+
* (which compare rows by `id` + `updated_at`/`updatedAt`) re-render after offline edits.
|
|
187
|
+
* Only the local SQLite row is stamped; payloads pushed to the remote are unchanged.
|
|
188
|
+
* - `true` (default): column inferred from the remote adapter's `updatedAtColumn`, else 'updated_at'
|
|
189
|
+
* - `string`: explicit column name
|
|
190
|
+
* - `false`: disabled
|
|
191
|
+
*/
|
|
192
|
+
touchUpdatedAt?: boolean | string;
|
|
184
193
|
}
|
package/dist/core/client.d.ts
CHANGED
|
@@ -27,6 +27,15 @@ export interface LocalFirstClientConfig {
|
|
|
27
27
|
autoHydrateJSON?: boolean;
|
|
28
28
|
syncScope?: Record<string, any> | (() => Record<string, any> | Promise<Record<string, any>>);
|
|
29
29
|
autoPauseOnBackground?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Stamp an "updated at" column on every local INSERT/UPDATE write so reactive
|
|
32
|
+
* queries (which compare rows by `id` + `updated_at`/`updatedAt`) detect offline edits.
|
|
33
|
+
* Only the local SQLite row is stamped; outbox payloads sent to the remote are unchanged.
|
|
34
|
+
* - `true` (default): column inferred from the remote adapter's `updatedAtColumn`, else 'updated_at'
|
|
35
|
+
* - `string`: explicit column name
|
|
36
|
+
* - `false`: disabled
|
|
37
|
+
*/
|
|
38
|
+
touchUpdatedAt?: boolean | string;
|
|
30
39
|
}
|
|
31
40
|
export declare class TransactionalClient {
|
|
32
41
|
private parent;
|
|
@@ -67,12 +76,25 @@ export declare class LocalFirstClient {
|
|
|
67
76
|
tables: string[];
|
|
68
77
|
jsonColumns: Record<string, string[]>;
|
|
69
78
|
autoHydrateJSON: boolean;
|
|
79
|
+
/** Local column stamped on INSERT/UPDATE writes, or null when disabled. */
|
|
80
|
+
touchColumn: string | null;
|
|
70
81
|
private primaryKey;
|
|
71
82
|
private migrations;
|
|
72
83
|
private autoStart;
|
|
73
84
|
private initialization?;
|
|
74
85
|
constructor(config: LocalFirstClientConfig);
|
|
75
86
|
getPrimaryKey(): string;
|
|
87
|
+
/**
|
|
88
|
+
* Options for TableManager.applyMutation on locally-originated writes.
|
|
89
|
+
* The touch value uses the mutation timestamp so it matches what remote
|
|
90
|
+
* adapters (e.g. Supabase) write for the same operation on push.
|
|
91
|
+
*/
|
|
92
|
+
localMutationOptions(timestamp: number): {
|
|
93
|
+
autoSchema: boolean;
|
|
94
|
+
schemaManager: SchemaManager;
|
|
95
|
+
touchColumn: string | null;
|
|
96
|
+
touchValue: string;
|
|
97
|
+
};
|
|
76
98
|
/**
|
|
77
99
|
* Automatically deserializes JSON string columns into JavaScript objects/arrays.
|
|
78
100
|
*/
|
|
@@ -81,7 +103,7 @@ export declare class LocalFirstClient {
|
|
|
81
103
|
* Writes a mutation to the outbox table, optionally coalescing with existing PENDING records for the same row.
|
|
82
104
|
* Returns the affected outboxId, or undefined if an offline INSERT was deleted offline before pushing.
|
|
83
105
|
*/
|
|
84
|
-
recordOutboxMutation(tx: LocalTransaction, tableName: string, rowId: string, operation: SyncOp, payload: Record<string, any> | null, timestamp: number): Promise<string | undefined>;
|
|
106
|
+
recordOutboxMutation(tx: LocalTransaction, tableName: string, rowId: string, operation: SyncOp, payload: Record<string, any> | null, timestamp: number, pendingOutboxMap?: Map<string, OutboxRecord>): Promise<string | undefined>;
|
|
85
107
|
/**
|
|
86
108
|
* Initializes the client: creates sync_outbox schema, ensures autoSchema tables if enabled,
|
|
87
109
|
* applies migrations, and optionally starts automatic sync. Concurrent/repeated calls
|
package/dist/core/client.js
CHANGED
|
@@ -11,6 +11,18 @@ const uuid_js_1 = require("../utils/uuid.js");
|
|
|
11
11
|
const sql_js_1 = require("../utils/sql.js");
|
|
12
12
|
const query_js_1 = require("./query.js");
|
|
13
13
|
const query_builder_js_1 = require("./query-builder.js");
|
|
14
|
+
function hasFunctionFilter(value) {
|
|
15
|
+
if (typeof value === 'function')
|
|
16
|
+
return true;
|
|
17
|
+
if (Array.isArray(value))
|
|
18
|
+
return value.some(hasFunctionFilter);
|
|
19
|
+
if (value && typeof value === 'object')
|
|
20
|
+
return Object.values(value).some(hasFunctionFilter);
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
function shouldUseClientSideQuery(options) {
|
|
24
|
+
return hasFunctionFilter(options?.where);
|
|
25
|
+
}
|
|
14
26
|
async function executeFindById(db, tableName, id, primaryKey, hydrateRow, includeDeleted) {
|
|
15
27
|
const safeTable = (0, sql_js_1.escapeIdentifier)(tableName);
|
|
16
28
|
const safePk = (0, sql_js_1.escapeIdentifier)(primaryKey);
|
|
@@ -62,10 +74,7 @@ class TransactionalClient {
|
|
|
62
74
|
async mutate(tableName, rowId, operation, payload, options = {}) {
|
|
63
75
|
const pk = options.primaryKey ?? this.parent.getPrimaryKey();
|
|
64
76
|
const timestamp = options.customTimestamp ?? Date.now();
|
|
65
|
-
await table_manager_js_1.TableManager.applyMutation(this.tx, tableName, rowId, operation, payload, pk,
|
|
66
|
-
autoSchema: this.parent.autoSchema,
|
|
67
|
-
schemaManager: this.parent.schemaManager,
|
|
68
|
-
});
|
|
77
|
+
await table_manager_js_1.TableManager.applyMutation(this.tx, tableName, rowId, operation, payload, pk, this.parent.localMutationOptions(timestamp));
|
|
69
78
|
if (!options.skipOutbox) {
|
|
70
79
|
const outboxId = await this.parent.recordOutboxMutation(this.tx, tableName, rowId, operation, payload, timestamp);
|
|
71
80
|
if (outboxId) {
|
|
@@ -117,8 +126,11 @@ class TransactionalClient {
|
|
|
117
126
|
return executeFindByIds(this.tx, tableName, ids, this.parent.getPrimaryKey(), (t, r) => this.parent.hydrateRow(t, r), options?.includeDeleted);
|
|
118
127
|
}
|
|
119
128
|
async findMany(tableName, options) {
|
|
120
|
-
|
|
121
|
-
|
|
129
|
+
if (!this.parent.db.supportsSqlPushdown || shouldUseClientSideQuery(options)) {
|
|
130
|
+
const rows = await this.from(tableName).execute();
|
|
131
|
+
return (0, query_js_1.applyQueryOptions)(rows, options);
|
|
132
|
+
}
|
|
133
|
+
return this.from(tableName).applyOptions(options).execute();
|
|
122
134
|
}
|
|
123
135
|
async query(sql, params = []) {
|
|
124
136
|
return (await this.tx.execute(sql, params));
|
|
@@ -142,6 +154,8 @@ class LocalFirstClient {
|
|
|
142
154
|
tables;
|
|
143
155
|
jsonColumns;
|
|
144
156
|
autoHydrateJSON;
|
|
157
|
+
/** Local column stamped on INSERT/UPDATE writes, or null when disabled. */
|
|
158
|
+
touchColumn;
|
|
145
159
|
primaryKey;
|
|
146
160
|
migrations;
|
|
147
161
|
autoStart;
|
|
@@ -158,6 +172,13 @@ class LocalFirstClient {
|
|
|
158
172
|
this.autoStart = config.autoStart ?? true;
|
|
159
173
|
this.jsonColumns = config.jsonColumns ?? {};
|
|
160
174
|
this.autoHydrateJSON = config.autoHydrateJSON ?? false;
|
|
175
|
+
const touch = config.touchUpdatedAt ?? true;
|
|
176
|
+
this.touchColumn =
|
|
177
|
+
touch === false
|
|
178
|
+
? null
|
|
179
|
+
: typeof touch === 'string'
|
|
180
|
+
? touch
|
|
181
|
+
: this.remote?.updatedAtColumn || 'updated_at';
|
|
161
182
|
this.schemaManager = new schema_manager_js_1.SchemaManager();
|
|
162
183
|
const workerOptions = {
|
|
163
184
|
network: config.network,
|
|
@@ -180,6 +201,19 @@ class LocalFirstClient {
|
|
|
180
201
|
getPrimaryKey() {
|
|
181
202
|
return this.primaryKey;
|
|
182
203
|
}
|
|
204
|
+
/**
|
|
205
|
+
* Options for TableManager.applyMutation on locally-originated writes.
|
|
206
|
+
* The touch value uses the mutation timestamp so it matches what remote
|
|
207
|
+
* adapters (e.g. Supabase) write for the same operation on push.
|
|
208
|
+
*/
|
|
209
|
+
localMutationOptions(timestamp) {
|
|
210
|
+
return {
|
|
211
|
+
autoSchema: this.autoSchema,
|
|
212
|
+
schemaManager: this.schemaManager,
|
|
213
|
+
touchColumn: this.touchColumn,
|
|
214
|
+
touchValue: new Date(timestamp).toISOString(),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
183
217
|
/**
|
|
184
218
|
* Automatically deserializes JSON string columns into JavaScript objects/arrays.
|
|
185
219
|
*/
|
|
@@ -228,40 +262,52 @@ class LocalFirstClient {
|
|
|
228
262
|
* Writes a mutation to the outbox table, optionally coalescing with existing PENDING records for the same row.
|
|
229
263
|
* Returns the affected outboxId, or undefined if an offline INSERT was deleted offline before pushing.
|
|
230
264
|
*/
|
|
231
|
-
async recordOutboxMutation(tx, tableName, rowId, operation, payload, timestamp) {
|
|
265
|
+
async recordOutboxMutation(tx, tableName, rowId, operation, payload, timestamp, pendingOutboxMap) {
|
|
266
|
+
const pendingKey = tableName + ':' + rowId;
|
|
232
267
|
if (this.coalesceOutbox) {
|
|
233
|
-
const existing =
|
|
268
|
+
const existing = pendingOutboxMap
|
|
269
|
+
? pendingOutboxMap.get(pendingKey)
|
|
270
|
+
: await outbox_store_js_1.OutboxStore.findPendingByRow(tx, tableName, rowId);
|
|
234
271
|
if (existing && existing.status === 'PENDING') {
|
|
235
272
|
if (operation === 'DELETE') {
|
|
236
273
|
if (existing.operation === 'INSERT') {
|
|
237
|
-
// Created offline and deleted offline before ever being pushed to server: clean up outbox completely
|
|
238
274
|
await outbox_store_js_1.OutboxStore.deleteRecord(tx, existing.id);
|
|
275
|
+
pendingOutboxMap?.delete(pendingKey);
|
|
239
276
|
return undefined;
|
|
240
277
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
278
|
+
await outbox_store_js_1.OutboxStore.updateOperation(tx, existing.id, 'DELETE', null, timestamp);
|
|
279
|
+
if (pendingOutboxMap) {
|
|
280
|
+
existing.operation = 'DELETE';
|
|
281
|
+
existing.payload = null;
|
|
282
|
+
existing.timestamp = timestamp;
|
|
244
283
|
}
|
|
284
|
+
return existing.id;
|
|
245
285
|
}
|
|
246
|
-
|
|
286
|
+
if (operation === 'UPDATE') {
|
|
247
287
|
const merged = { ...(existing.payload || {}), ...(payload || {}) };
|
|
248
288
|
await outbox_store_js_1.OutboxStore.updatePayload(tx, existing.id, merged, timestamp);
|
|
289
|
+
if (pendingOutboxMap) {
|
|
290
|
+
existing.payload = merged;
|
|
291
|
+
existing.timestamp = timestamp;
|
|
292
|
+
}
|
|
249
293
|
return existing.id;
|
|
250
294
|
}
|
|
251
|
-
|
|
295
|
+
if (operation === 'INSERT') {
|
|
252
296
|
await outbox_store_js_1.OutboxStore.updateOperation(tx, existing.id, 'INSERT', payload, timestamp);
|
|
297
|
+
if (pendingOutboxMap) {
|
|
298
|
+
existing.operation = 'INSERT';
|
|
299
|
+
existing.payload = payload;
|
|
300
|
+
existing.timestamp = timestamp;
|
|
301
|
+
}
|
|
253
302
|
return existing.id;
|
|
254
303
|
}
|
|
255
304
|
}
|
|
256
305
|
}
|
|
257
306
|
const outboxId = (0, uuid_js_1.generateUUID)();
|
|
258
|
-
await outbox_store_js_1.OutboxStore.insertRecord(tx, {
|
|
259
|
-
|
|
260
|
-
tableName,
|
|
261
|
-
|
|
262
|
-
operation,
|
|
263
|
-
payload,
|
|
264
|
-
timestamp,
|
|
307
|
+
await outbox_store_js_1.OutboxStore.insertRecord(tx, { id: outboxId, tableName, rowId, operation, payload, timestamp });
|
|
308
|
+
pendingOutboxMap?.set(pendingKey, {
|
|
309
|
+
id: outboxId, tableName, rowId, operation, payload, timestamp,
|
|
310
|
+
status: 'PENDING', retryCount: 0, lastError: null,
|
|
265
311
|
});
|
|
266
312
|
return outboxId;
|
|
267
313
|
}
|
|
@@ -318,10 +364,7 @@ class LocalFirstClient {
|
|
|
318
364
|
let affectedOutboxId;
|
|
319
365
|
const executeInTransaction = async (tx) => {
|
|
320
366
|
// 1. Mutate local table
|
|
321
|
-
await table_manager_js_1.TableManager.applyMutation(tx, tableName, rowId, operation, payload, pk,
|
|
322
|
-
autoSchema: this.autoSchema,
|
|
323
|
-
schemaManager: this.schemaManager,
|
|
324
|
-
});
|
|
367
|
+
await table_manager_js_1.TableManager.applyMutation(tx, tableName, rowId, operation, payload, pk, this.localMutationOptions(timestamp));
|
|
325
368
|
// 2. Insert into sync_outbox (if not skipped)
|
|
326
369
|
if (!options.skipOutbox) {
|
|
327
370
|
affectedOutboxId = await this.recordOutboxMutation(tx, tableName, rowId, operation, payload, timestamp);
|
|
@@ -360,7 +403,11 @@ class LocalFirstClient {
|
|
|
360
403
|
const queuedEvents = [];
|
|
361
404
|
const queuedOutboxIds = [];
|
|
362
405
|
const pendingOutboxOps = [];
|
|
406
|
+
let pendingOutboxMap;
|
|
363
407
|
const executeInTx = async (tx) => {
|
|
408
|
+
if (this.coalesceOutbox && mutations.length > 1) {
|
|
409
|
+
pendingOutboxMap = await outbox_store_js_1.OutboxStore.getActivePendingMap(tx);
|
|
410
|
+
}
|
|
364
411
|
for (const m of mutations) {
|
|
365
412
|
const pk = m.primaryKey ?? options.primaryKey ?? this.primaryKey;
|
|
366
413
|
const payload = m.payload ?? null;
|
|
@@ -369,15 +416,12 @@ class LocalFirstClient {
|
|
|
369
416
|
? String(payload[pk])
|
|
370
417
|
: (0, uuid_js_1.generateUUID)());
|
|
371
418
|
generatedIds.push(rowId);
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
schemaManager: this.schemaManager,
|
|
375
|
-
});
|
|
419
|
+
const timestamp = m.customTimestamp ?? options.customTimestamp ?? Date.now();
|
|
420
|
+
await table_manager_js_1.TableManager.applyMutation(tx, m.tableName, rowId, m.operation, payload, pk, this.localMutationOptions(timestamp));
|
|
376
421
|
const skipOutbox = m.skipOutbox ?? options.skipOutbox ?? false;
|
|
377
422
|
if (!skipOutbox) {
|
|
378
|
-
const timestamp = m.customTimestamp ?? options.customTimestamp ?? Date.now();
|
|
379
423
|
if (this.coalesceOutbox && m.operation !== 'INSERT') {
|
|
380
|
-
const outboxId = await this.recordOutboxMutation(tx, m.tableName, rowId, m.operation, payload, timestamp);
|
|
424
|
+
const outboxId = await this.recordOutboxMutation(tx, m.tableName, rowId, m.operation, payload, timestamp, pendingOutboxMap);
|
|
381
425
|
if (outboxId) {
|
|
382
426
|
queuedOutboxIds.push(outboxId);
|
|
383
427
|
}
|
|
@@ -662,8 +706,11 @@ class LocalFirstClient {
|
|
|
662
706
|
return result;
|
|
663
707
|
}
|
|
664
708
|
async findMany(tableName, options) {
|
|
665
|
-
|
|
666
|
-
|
|
709
|
+
if (!this.db.supportsSqlPushdown || shouldUseClientSideQuery(options)) {
|
|
710
|
+
const rows = await this.from(tableName).execute();
|
|
711
|
+
return (0, query_js_1.applyQueryOptions)(rows, options);
|
|
712
|
+
}
|
|
713
|
+
return this.from(tableName).applyOptions(options).execute();
|
|
667
714
|
}
|
|
668
715
|
/**
|
|
669
716
|
* Finds a single record by primary key.
|
|
@@ -17,6 +17,12 @@ export declare class SchemaManager {
|
|
|
17
17
|
private knownIndexes;
|
|
18
18
|
clearCache(): void;
|
|
19
19
|
hasColumn(tableName: string, column: string): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Returns whether `column` exists on `tableName`, introspecting via PRAGMA table_info
|
|
22
|
+
* at most once per table (cached). Unlike ensureTableIntrospected, this has no
|
|
23
|
+
* side effects (no auto-indexing), so it is safe on hand-written schemas.
|
|
24
|
+
*/
|
|
25
|
+
tableHasColumn(db: DBExecutable, tableName: string, column: string): Promise<boolean>;
|
|
20
26
|
/**
|
|
21
27
|
* Introspects table columns via PRAGMA table_info if not previously cached.
|
|
22
28
|
*/
|
|
@@ -42,6 +42,31 @@ class SchemaManager {
|
|
|
42
42
|
hasColumn(tableName, column) {
|
|
43
43
|
return this.knownColumns.has(`${tableName}:${column.toLowerCase()}`);
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Returns whether `column` exists on `tableName`, introspecting via PRAGMA table_info
|
|
47
|
+
* at most once per table (cached). Unlike ensureTableIntrospected, this has no
|
|
48
|
+
* side effects (no auto-indexing), so it is safe on hand-written schemas.
|
|
49
|
+
*/
|
|
50
|
+
async tableHasColumn(db, tableName, column) {
|
|
51
|
+
if (this.hasColumn(tableName, column))
|
|
52
|
+
return true;
|
|
53
|
+
const probeKey = `${tableName}:__probed__`;
|
|
54
|
+
if (!this.knownColumns.has(probeKey)) {
|
|
55
|
+
try {
|
|
56
|
+
const tableInfo = await db.execute(`PRAGMA table_info(${(0, sql_js_1.escapeIdentifier)(tableName)});`);
|
|
57
|
+
if (Array.isArray(tableInfo) && tableInfo.length > 0) {
|
|
58
|
+
// Only cache once the table exists, so tables created later get re-probed.
|
|
59
|
+
this.knownColumns.add(probeKey);
|
|
60
|
+
for (const col of tableInfo) {
|
|
61
|
+
if (col?.name)
|
|
62
|
+
this.knownColumns.add(`${tableName}:${String(col.name).toLowerCase()}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch { }
|
|
67
|
+
}
|
|
68
|
+
return this.hasColumn(tableName, column);
|
|
69
|
+
}
|
|
45
70
|
/**
|
|
46
71
|
* Introspects table columns via PRAGMA table_info if not previously cached.
|
|
47
72
|
*/
|
|
@@ -4,6 +4,15 @@ import { SchemaManager } from './schema-manager.js';
|
|
|
4
4
|
export interface TableMutationOptions {
|
|
5
5
|
autoSchema?: boolean;
|
|
6
6
|
schemaManager?: SchemaManager;
|
|
7
|
+
/**
|
|
8
|
+
* Column to stamp with `touchValue` on local writes (e.g. 'updated_at').
|
|
9
|
+
* - UPDATE: always overwritten (mirrors what remote adapters do on push).
|
|
10
|
+
* - INSERT: only set when the payload does not already provide a value.
|
|
11
|
+
* Skipped when the column does not exist and autoSchema is disabled.
|
|
12
|
+
*/
|
|
13
|
+
touchColumn?: string | null;
|
|
14
|
+
/** Value written to `touchColumn`. Default: current time as an ISO string. */
|
|
15
|
+
touchValue?: any;
|
|
7
16
|
}
|
|
8
17
|
export declare class TableManager {
|
|
9
18
|
/**
|
|
@@ -13,6 +13,16 @@ function formatPayload(payload) {
|
|
|
13
13
|
}
|
|
14
14
|
return formatted;
|
|
15
15
|
}
|
|
16
|
+
async function withTouchedColumn(tx, sm, tableName, operation, payload, options) {
|
|
17
|
+
const col = options.touchColumn;
|
|
18
|
+
if (operation === 'INSERT' && payload && payload[col] !== undefined && payload[col] !== null) {
|
|
19
|
+
return payload;
|
|
20
|
+
}
|
|
21
|
+
if (!options.autoSchema && !(await sm.tableHasColumn(tx, tableName, col))) {
|
|
22
|
+
return payload;
|
|
23
|
+
}
|
|
24
|
+
return { ...(payload || {}), [col]: options.touchValue ?? new Date().toISOString() };
|
|
25
|
+
}
|
|
16
26
|
class TableManager {
|
|
17
27
|
/**
|
|
18
28
|
* Applies an insert, update, or delete mutation on a local data table.
|
|
@@ -30,6 +40,9 @@ class TableManager {
|
|
|
30
40
|
await tx.execute(`DELETE FROM ${safeTable} WHERE ${safePk} = ?`, [rowId]);
|
|
31
41
|
return;
|
|
32
42
|
}
|
|
43
|
+
if (options?.touchColumn) {
|
|
44
|
+
payload = await withTouchedColumn(tx, sm, tableName, operation, payload, options);
|
|
45
|
+
}
|
|
33
46
|
const formatted = options?.autoSchema
|
|
34
47
|
? await sm.ensureColumns(tx, tableName, payload, primaryKey)
|
|
35
48
|
: formatPayload(payload);
|
package/dist/react/index.js
CHANGED
|
@@ -116,6 +116,39 @@ class LRUQueryCache {
|
|
|
116
116
|
// In-memory query result cache capped with LRU eviction to prevent memory leaks
|
|
117
117
|
const queryCache = new LRUQueryCache();
|
|
118
118
|
const inFlightQueries = new Map();
|
|
119
|
+
const tableVersions = new WeakMap();
|
|
120
|
+
const clientIds = new WeakMap();
|
|
121
|
+
let nextClientId = 1;
|
|
122
|
+
function getClientScope(client) {
|
|
123
|
+
if (!client)
|
|
124
|
+
return 'no-client';
|
|
125
|
+
let id = clientIds.get(client);
|
|
126
|
+
if (!id) {
|
|
127
|
+
id = nextClientId++;
|
|
128
|
+
clientIds.set(client, id);
|
|
129
|
+
}
|
|
130
|
+
return 'client-' + id;
|
|
131
|
+
}
|
|
132
|
+
function getScopedCacheKey(client, key) {
|
|
133
|
+
return getClientScope(client) + ':' + key;
|
|
134
|
+
}
|
|
135
|
+
function getTableVersion(client, tableName) {
|
|
136
|
+
if (!client)
|
|
137
|
+
return 0;
|
|
138
|
+
return tableVersions.get(client)?.get(tableName) || 0;
|
|
139
|
+
}
|
|
140
|
+
function advanceTableVersion(client, tableName) {
|
|
141
|
+
if (!client)
|
|
142
|
+
return 0;
|
|
143
|
+
let versions = tableVersions.get(client);
|
|
144
|
+
if (!versions) {
|
|
145
|
+
versions = new Map();
|
|
146
|
+
tableVersions.set(client, versions);
|
|
147
|
+
}
|
|
148
|
+
const nextVersion = getTableVersion(client, tableName) + 1;
|
|
149
|
+
versions.set(tableName, nextVersion);
|
|
150
|
+
return nextVersion;
|
|
151
|
+
}
|
|
119
152
|
async function fetchDeduped(key, fn) {
|
|
120
153
|
let inFlight = inFlightQueries.get(key);
|
|
121
154
|
if (!inFlight) {
|
|
@@ -155,7 +188,7 @@ function useLiveQuery(client, tableName, queryOrOptions) {
|
|
|
155
188
|
: typeof queryOrOptions === 'function'
|
|
156
189
|
? 'custom'
|
|
157
190
|
: 'all';
|
|
158
|
-
const cacheKey = `${tableName}:${queryKey}
|
|
191
|
+
const cacheKey = getScopedCacheKey(client, `${tableName}:${queryKey}`);
|
|
159
192
|
// Read-through cache: return instant cached rows on mount (0ms latency, zero flicker)
|
|
160
193
|
const [data, setData] = (0, react_1.useState)(() => queryCache.get(cacheKey) || []);
|
|
161
194
|
const [loading, setLoading] = (0, react_1.useState)(() => !queryCache.has(cacheKey));
|
|
@@ -164,8 +197,9 @@ function useLiveQuery(client, tableName, queryOrOptions) {
|
|
|
164
197
|
const refetch = (0, react_1.useCallback)(async () => {
|
|
165
198
|
if (!client || !client.db)
|
|
166
199
|
return;
|
|
200
|
+
const versionAtStart = getTableVersion(client, tableName);
|
|
167
201
|
try {
|
|
168
|
-
const res = await fetchDeduped(cacheKey, async () => {
|
|
202
|
+
const res = await fetchDeduped(cacheKey + ':v' + versionAtStart, async () => {
|
|
169
203
|
const param = queryParamRef.current;
|
|
170
204
|
if (typeof param === 'function')
|
|
171
205
|
return (await param(client)) || [];
|
|
@@ -173,6 +207,8 @@ function useLiveQuery(client, tableName, queryOrOptions) {
|
|
|
173
207
|
return await client.findMany(tableName, param);
|
|
174
208
|
return await client.findMany(tableName);
|
|
175
209
|
});
|
|
210
|
+
if (versionAtStart !== getTableVersion(client, tableName))
|
|
211
|
+
return;
|
|
176
212
|
queryCache.set(cacheKey, res);
|
|
177
213
|
setData((prev) => (areRowArraysEqual(prev, res) ? prev : res));
|
|
178
214
|
}
|
|
@@ -196,6 +232,7 @@ function useLiveQuery(client, tableName, queryOrOptions) {
|
|
|
196
232
|
return;
|
|
197
233
|
refetch();
|
|
198
234
|
const unsubscribe = client.events.on(tableName, () => {
|
|
235
|
+
advanceTableVersion(client, tableName);
|
|
199
236
|
scheduleRefetch();
|
|
200
237
|
});
|
|
201
238
|
return () => {
|
|
@@ -297,10 +334,11 @@ function useLiveQueries(client, queries) {
|
|
|
297
334
|
return JSON.stringify(queries, (_, val) => (typeof val === 'function' ? 'fn' : val));
|
|
298
335
|
}, [queries]);
|
|
299
336
|
const items = (0, react_1.useMemo)(() => normalizeMultiQueries(queries), [signature]);
|
|
337
|
+
const clientScope = getClientScope(client);
|
|
300
338
|
const [data, setData] = (0, react_1.useState)(() => {
|
|
301
339
|
const initial = {};
|
|
302
340
|
for (const item of items) {
|
|
303
|
-
const cached = queryCache.get(item.cacheKey);
|
|
341
|
+
const cached = queryCache.get(clientScope + ':' + item.cacheKey);
|
|
304
342
|
initial[item.key] = cached ? cached : [];
|
|
305
343
|
}
|
|
306
344
|
return initial;
|
|
@@ -309,7 +347,7 @@ function useLiveQueries(client, queries) {
|
|
|
309
347
|
if (items.length === 0)
|
|
310
348
|
return false;
|
|
311
349
|
for (const item of items) {
|
|
312
|
-
if (!queryCache.has(item.cacheKey))
|
|
350
|
+
if (!queryCache.has(clientScope + ':' + item.cacheKey))
|
|
313
351
|
return true;
|
|
314
352
|
}
|
|
315
353
|
return false;
|
|
@@ -320,14 +358,18 @@ function useLiveQueries(client, queries) {
|
|
|
320
358
|
try {
|
|
321
359
|
const results = await Promise.all(items.map(async (item) => {
|
|
322
360
|
try {
|
|
323
|
-
const
|
|
361
|
+
const versionAtStart = getTableVersion(client, item.table);
|
|
362
|
+
const scopedCacheKey = clientScope + ':' + item.cacheKey;
|
|
363
|
+
const rows = await fetchDeduped(scopedCacheKey + ':v' + versionAtStart, async () => {
|
|
324
364
|
if (item.fn)
|
|
325
365
|
return (await item.fn(client)) || [];
|
|
326
366
|
if (item.options)
|
|
327
367
|
return await client.findMany(item.table, item.options);
|
|
328
368
|
return await client.findMany(item.table);
|
|
329
369
|
});
|
|
330
|
-
|
|
370
|
+
if (versionAtStart !== getTableVersion(client, item.table))
|
|
371
|
+
return null;
|
|
372
|
+
queryCache.set(scopedCacheKey, rows);
|
|
331
373
|
return { key: item.key, rows };
|
|
332
374
|
}
|
|
333
375
|
catch (err) {
|
|
@@ -343,6 +385,8 @@ function useLiveQueries(client, queries) {
|
|
|
343
385
|
let changed = false;
|
|
344
386
|
const next = { ...prev };
|
|
345
387
|
for (const res of results) {
|
|
388
|
+
if (!res)
|
|
389
|
+
continue;
|
|
346
390
|
if (!areRowArraysEqual(prev[res.key], res.rows)) {
|
|
347
391
|
next[res.key] = res.rows;
|
|
348
392
|
changed = true;
|
|
@@ -362,6 +406,7 @@ function useLiveQueries(client, queries) {
|
|
|
362
406
|
refetch();
|
|
363
407
|
const tables = Array.from(new Set(items.map((i) => i.table).filter(Boolean)));
|
|
364
408
|
const unsubs = tables.map((tbl) => client.events.on(tbl, () => {
|
|
409
|
+
advanceTableVersion(client, tbl);
|
|
365
410
|
scheduleRefetch();
|
|
366
411
|
}));
|
|
367
412
|
return () => {
|
|
@@ -377,7 +422,7 @@ function useLiveQueries(client, queries) {
|
|
|
377
422
|
* Automatically re-renders only when this specific row or table changes.
|
|
378
423
|
*/
|
|
379
424
|
function useLiveRow(client, tableName, rowId, options) {
|
|
380
|
-
const cacheKey = `${tableName}:row:${rowId || 'null'}:${Boolean(options?.includeDeleted)}
|
|
425
|
+
const cacheKey = getScopedCacheKey(client, `${tableName}:row:${rowId || 'null'}:${Boolean(options?.includeDeleted)}`);
|
|
381
426
|
const [data, setData] = (0, react_1.useState)(() => {
|
|
382
427
|
if (!rowId)
|
|
383
428
|
return null;
|
|
@@ -392,10 +437,13 @@ function useLiveRow(client, tableName, rowId, options) {
|
|
|
392
437
|
return;
|
|
393
438
|
}
|
|
394
439
|
try {
|
|
395
|
-
const
|
|
440
|
+
const versionAtStart = getTableVersion(client, tableName);
|
|
441
|
+
const res = await fetchDeduped(cacheKey + ':v' + versionAtStart, async () => {
|
|
396
442
|
const r = await client.findById(tableName, rowId, options);
|
|
397
443
|
return r ? [r] : [];
|
|
398
444
|
});
|
|
445
|
+
if (versionAtStart !== getTableVersion(client, tableName))
|
|
446
|
+
return;
|
|
399
447
|
const row = res.length > 0 ? res[0] : null;
|
|
400
448
|
if (row) {
|
|
401
449
|
queryCache.set(cacheKey, [row]);
|
|
@@ -424,6 +472,7 @@ function useLiveRow(client, tableName, rowId, options) {
|
|
|
424
472
|
return;
|
|
425
473
|
refetch();
|
|
426
474
|
const unsubscribe = client.events.on(tableName, (event) => {
|
|
475
|
+
advanceTableVersion(client, tableName);
|
|
427
476
|
// Precise row-level invalidation: only refetch if event affects this rowId or is a bulk event
|
|
428
477
|
if (!event || !event.rowId || event.rowId === rowId || event.rowId === 'batch') {
|
|
429
478
|
scheduleRefetch();
|
|
@@ -455,6 +504,7 @@ function useLiveInfiniteQuery(client, tableName, options = {}) {
|
|
|
455
504
|
return;
|
|
456
505
|
if (isNextPage)
|
|
457
506
|
setIsFetchingNextPage(true);
|
|
507
|
+
const versionAtStart = getTableVersion(client, tableName);
|
|
458
508
|
try {
|
|
459
509
|
const currentOpts = optionsRef.current;
|
|
460
510
|
const fetchLimit = pages * pageSize + 1; // +1 to check for hasNextPage
|
|
@@ -463,6 +513,8 @@ function useLiveInfiniteQuery(client, tableName, options = {}) {
|
|
|
463
513
|
limit: fetchLimit,
|
|
464
514
|
offset: currentOpts.offset || 0,
|
|
465
515
|
});
|
|
516
|
+
if (versionAtStart !== getTableVersion(client, tableName))
|
|
517
|
+
return;
|
|
466
518
|
if (rows.length > pages * pageSize) {
|
|
467
519
|
setHasNextPage(true);
|
|
468
520
|
setData(rows.slice(0, pages * pageSize));
|
|
@@ -504,6 +556,7 @@ function useLiveInfiniteQuery(client, tableName, options = {}) {
|
|
|
504
556
|
return;
|
|
505
557
|
loadData(1, false);
|
|
506
558
|
const unsubscribe = client.events.on(tableName, () => {
|
|
559
|
+
advanceTableVersion(client, tableName);
|
|
507
560
|
scheduleRefetch();
|
|
508
561
|
});
|
|
509
562
|
return () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "local-lite",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
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",
|
|
@@ -130,6 +130,7 @@
|
|
|
130
130
|
},
|
|
131
131
|
"devDependencies": {
|
|
132
132
|
"@supabase/supabase-js": "^2.118.0",
|
|
133
|
+
"@types/node": "^26.6.5",
|
|
133
134
|
"@types/react": "^19.3.0",
|
|
134
135
|
"@types/uuid": "^11.0.0",
|
|
135
136
|
"expo-sqlite": "^58.0.13",
|