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 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
- -- 1. Automatic Timestamp Trigger
253
- CREATE OR REPLACE FUNCTION public.set_sync_updated_at()
254
- RETURNS TRIGGER AS $$
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;
@@ -5,6 +5,7 @@ exports.BetterSQLite3Adapter = void 0;
5
5
  * Adapter for better-sqlite3 in Node.js, Electron, Next.js, and server environments.
6
6
  */
7
7
  class BetterSQLite3Adapter {
8
+ supportsSqlPushdown = true;
8
9
  db;
9
10
  txQueue = Promise.resolve();
10
11
  statementCache = new Map();
@@ -8,6 +8,7 @@ export interface ExpoDatabase {
8
8
  closeSync?(): void;
9
9
  }
10
10
  export declare class ExpoSQLiteAdapter implements LocalDBAdapter {
11
+ readonly supportsSqlPushdown = true;
11
12
  private db;
12
13
  private writeQueue;
13
14
  constructor(expoDbInstance: any);
@@ -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;
@@ -6,6 +6,7 @@ exports.NodeSQLiteAdapter = void 0;
6
6
  * Provides zero-dependency, native SQLite on modern Node.js runtimes.
7
7
  */
8
8
  class NodeSQLiteAdapter {
9
+ supportsSqlPushdown = true;
9
10
  db;
10
11
  txQueue = Promise.resolve();
11
12
  statementCache = new Map();
@@ -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);
@@ -26,6 +26,7 @@ function extractRows(res) {
26
26
  * Enables ultra-fast C++ JSI SQLite in bare React Native.
27
27
  */
28
28
  class OpSQLiteAdapter {
29
+ supportsSqlPushdown = true;
29
30
  db;
30
31
  txMutex = Promise.resolve();
31
32
  constructor(opSqliteInstance) {
@@ -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[]>;
@@ -5,6 +5,7 @@ exports.SqlJsAdapter = void 0;
5
5
  * Adapter for sql.js (WebAssembly SQLite in Web Browsers, PWAs, and Expo Web).
6
6
  */
7
7
  class SqlJsAdapter {
8
+ supportsSqlPushdown = true;
8
9
  db;
9
10
  constructor(sqlJsDatabase) {
10
11
  this.db = sqlJsDatabase;
@@ -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) {
@@ -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
  }
@@ -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
@@ -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
- const rows = await this.from(tableName).applyOptions(options).execute();
121
- return (0, query_js_1.applyQueryOptions)(rows, options);
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 = await outbox_store_js_1.OutboxStore.findPendingByRow(tx, tableName, rowId);
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
- else {
242
- await outbox_store_js_1.OutboxStore.updateOperation(tx, existing.id, 'DELETE', null, timestamp);
243
- return existing.id;
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
- else if (operation === 'UPDATE') {
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
- else if (operation === 'INSERT') {
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
- id: outboxId,
260
- tableName,
261
- rowId,
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
- await table_manager_js_1.TableManager.applyMutation(tx, m.tableName, rowId, m.operation, payload, pk, {
373
- autoSchema: this.autoSchema,
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
- const rows = await this.from(tableName).applyOptions(options).execute();
666
- return (0, query_js_1.applyQueryOptions)(rows, options);
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);
@@ -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 rows = await fetchDeduped(item.cacheKey, async () => {
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
- queryCache.set(item.cacheKey, rows);
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 res = await fetchDeduped(cacheKey, async () => {
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.6",
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",