local-lite 0.0.7 → 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;
@@ -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
  */
@@ -74,10 +74,7 @@ class TransactionalClient {
74
74
  async mutate(tableName, rowId, operation, payload, options = {}) {
75
75
  const pk = options.primaryKey ?? this.parent.getPrimaryKey();
76
76
  const timestamp = options.customTimestamp ?? Date.now();
77
- await table_manager_js_1.TableManager.applyMutation(this.tx, tableName, rowId, operation, payload, pk, {
78
- autoSchema: this.parent.autoSchema,
79
- schemaManager: this.parent.schemaManager,
80
- });
77
+ await table_manager_js_1.TableManager.applyMutation(this.tx, tableName, rowId, operation, payload, pk, this.parent.localMutationOptions(timestamp));
81
78
  if (!options.skipOutbox) {
82
79
  const outboxId = await this.parent.recordOutboxMutation(this.tx, tableName, rowId, operation, payload, timestamp);
83
80
  if (outboxId) {
@@ -157,6 +154,8 @@ class LocalFirstClient {
157
154
  tables;
158
155
  jsonColumns;
159
156
  autoHydrateJSON;
157
+ /** Local column stamped on INSERT/UPDATE writes, or null when disabled. */
158
+ touchColumn;
160
159
  primaryKey;
161
160
  migrations;
162
161
  autoStart;
@@ -173,6 +172,13 @@ class LocalFirstClient {
173
172
  this.autoStart = config.autoStart ?? true;
174
173
  this.jsonColumns = config.jsonColumns ?? {};
175
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';
176
182
  this.schemaManager = new schema_manager_js_1.SchemaManager();
177
183
  const workerOptions = {
178
184
  network: config.network,
@@ -195,6 +201,19 @@ class LocalFirstClient {
195
201
  getPrimaryKey() {
196
202
  return this.primaryKey;
197
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
+ }
198
217
  /**
199
218
  * Automatically deserializes JSON string columns into JavaScript objects/arrays.
200
219
  */
@@ -345,10 +364,7 @@ class LocalFirstClient {
345
364
  let affectedOutboxId;
346
365
  const executeInTransaction = async (tx) => {
347
366
  // 1. Mutate local table
348
- await table_manager_js_1.TableManager.applyMutation(tx, tableName, rowId, operation, payload, pk, {
349
- autoSchema: this.autoSchema,
350
- schemaManager: this.schemaManager,
351
- });
367
+ await table_manager_js_1.TableManager.applyMutation(tx, tableName, rowId, operation, payload, pk, this.localMutationOptions(timestamp));
352
368
  // 2. Insert into sync_outbox (if not skipped)
353
369
  if (!options.skipOutbox) {
354
370
  affectedOutboxId = await this.recordOutboxMutation(tx, tableName, rowId, operation, payload, timestamp);
@@ -400,13 +416,10 @@ class LocalFirstClient {
400
416
  ? String(payload[pk])
401
417
  : (0, uuid_js_1.generateUUID)());
402
418
  generatedIds.push(rowId);
403
- await table_manager_js_1.TableManager.applyMutation(tx, m.tableName, rowId, m.operation, payload, pk, {
404
- autoSchema: this.autoSchema,
405
- schemaManager: this.schemaManager,
406
- });
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));
407
421
  const skipOutbox = m.skipOutbox ?? options.skipOutbox ?? false;
408
422
  if (!skipOutbox) {
409
- const timestamp = m.customTimestamp ?? options.customTimestamp ?? Date.now();
410
423
  if (this.coalesceOutbox && m.operation !== 'INSERT') {
411
424
  const outboxId = await this.recordOutboxMutation(tx, m.tableName, rowId, m.operation, payload, timestamp, pendingOutboxMap);
412
425
  if (outboxId) {
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-lite",
3
- "version": "0.0.7",
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",
@@ -129,9 +129,9 @@
129
129
  }
130
130
  },
131
131
  "devDependencies": {
132
- "@supabase/supabase-js": "^2.118.0",
133
- "@types/node": "^26.6.5",
134
- "@types/react": "^19.3.0",
132
+ "@supabase/supabase-js": "^2.118.0",
133
+ "@types/node": "^26.6.5",
134
+ "@types/react": "^19.3.0",
135
135
  "@types/uuid": "^11.0.0",
136
136
  "expo-sqlite": "^58.0.13",
137
137
  "react": "^19.3.1",
@@ -141,4 +141,4 @@
141
141
  "dependencies": {
142
142
  "uuid": "^14.0.3"
143
143
  }
144
- }
144
+ }