local-lite 0.0.22 → 0.0.25

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 CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 local-lite contributors
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 local-lite contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -63,9 +63,10 @@ remain configurable; the existing default conflict strategy is last-write-wins.
63
63
  `inMemoryRemote: true`; it must never silently acknowledge production writes.
64
64
  - `autoSchema` now defaults to `false` in the configuration factory as well as the
65
65
  core constructor. Set `autoSchema: true` to retain inferred schema behavior.
66
- - Migrations run transactionally before sync starts, including when initialization
67
- is deferred with `autoInitialize: false`. Use idempotent SQL; this is not a
68
- versioned migration framework.
66
+ - Versioned `schemaMigrations` run transactionally before sync starts, including
67
+ when initialization is deferred with `autoInitialize: false`. The existing
68
+ `migrations` option remains available for legacy idempotent SQL that runs on
69
+ every initialization.
69
70
  - `initialize()` is safe to call repeatedly or concurrently. After `stop()`, call
70
71
  `client.syncWorker.start()` to resume. Mutations after stop remain queued.
71
72
  - `autoStart: false` prepares storage without automatic sync. Explicit push/pull
@@ -91,11 +92,11 @@ This recovers sync state; it does not repair corrupt SQLite files or cancel hung
91
92
  * **📶 Network-Aware:** Automatically pauses polling when offline and triggers immediate sync flushes the instant connectivity is restored.
92
93
  * **🔄 Infinite-Loop Safe:** Incoming remote deltas update local tables and fire reactive UI listeners without echoing back into the outbox.
93
94
  * **☁️ Database Backups:** Export and restore SQLite snapshots independently of your live sync backend.
94
- * **🔑 Google Sign-In Out of the Box:** Secure, zero-backend Google OAuth 2.0 (PKCE) authentication with automatic token refresh, user profile retrieval, persistent session storage, and `useGoogleAuth()` hook.
95
- * **📨 Action Outbox & Email Queue:** Enqueue transactional emails, push notifications, and webhooks offline with idempotency keys, automatic retry backoff, crash recovery, and TTL expiration.
96
- * **⚡ Fast Key-Value & Encrypted Preferences (`client.kv`):** Typed storage, TTL expiration, and AES-256-GCM zero-knowledge secret protection.
97
- * **🔍 Instant Full-Text Search (`client.search`):** SQLite FTS5 indexer with prefix matching, BM25 rank scoring, and highlighting.
98
- * **📁 Offline Media & Blob Storage (`client.storage`):** Decoupled binary attachments, SHA-256 verification, and cloud sync queue (S3/Supabase/Firebase).
95
+ * **🔑 Google Sign-In Out of the Box:** Secure, zero-backend Google OAuth 2.0 (PKCE) authentication with automatic token refresh, user profile retrieval, persistent session storage, and `useGoogleAuth()` hook.
96
+ * **📨 Action Outbox & Email Queue:** Enqueue transactional emails, push notifications, and webhooks offline with idempotency keys, automatic retry backoff, crash recovery, and TTL expiration.
97
+ * **⚡ Fast Key-Value & Encrypted Preferences (`client.kv`):** Typed storage, TTL expiration, and AES-256-GCM zero-knowledge secret protection.
98
+ * **🔍 Instant Full-Text Search (`client.search`):** SQLite FTS5 indexer with prefix matching, BM25 rank scoring, and highlighting.
99
+ * **📁 Offline Media & Blob Storage (`client.storage`):** Decoupled binary attachments, SHA-256 verification, and cloud sync queue (S3/Supabase/Firebase).
99
100
  * **⏰ Offline Job Scheduler & Background Cron (`client.scheduler`):** Persistent background task runner supporting human schedules and 5-field cron.
100
101
 
101
102
 
@@ -299,46 +300,46 @@ export function UserProfileHeader() {
299
300
  ---
300
301
 
301
302
 
302
- ### 4. Offline Actions & Inbuilt Email Queue
303
-
304
- `local-lite` includes a durable **Generic Action Outbox** (`client.actions`) for side-effects that should not be lost when offline:
305
-
306
- ```typescript
307
- import { client } from './sync.config';
308
-
309
- // ⚡ 1. Enqueue transactional email offline (0ms latency, safe if app closes)
310
- const emailAction = await client.actions.sendEmail({
311
- to: 'customer@example.com',
312
- subject: 'Order #1042 Confirmed',
313
- html: '<h1>Thank you for your purchase!</h1>',
314
- template: 'order_receipt',
315
- templateData: { orderId: 1042, total: '$49.99' },
316
- idempotencyKey: 'order_receipt_1042', // Prevents sending twice
317
- ttlMs: 24 * 60 * 60 * 1000, // Expire if offline for >24 hours
318
- });
319
-
320
- // ⚡ 2. Register local dispatcher (or let rows replicate to Supabase/Convex/Firebase)
321
- client.actions.registerEmailHandler(async (email) => {
322
- // Dispatch via your transactional provider or backend API
323
- const response = await fetch('https://api.resend.com/emails', {
324
- method: 'POST',
325
- headers: { Authorization: `Bearer ${process.env.RESEND_API_KEY}` },
326
- body: JSON.stringify(email),
327
- });
328
- const data = await response.json();
329
- return { messageId: data.id };
330
- });
331
-
332
- // ⚡ 3. Enqueue arbitrary asynchronous actions (e.g., push notifications, analytics)
333
- await client.actions.enqueue({
334
- action: 'push_notification',
335
- payload: { title: 'New chat message', token: 'fcm-xxx' },
336
- idempotencyKey: 'chat_msg_99',
337
- });
338
- ```
339
-
340
- ---
341
-
303
+ ### 4. Offline Actions & Inbuilt Email Queue
304
+
305
+ `local-lite` includes a durable **Generic Action Outbox** (`client.actions`) for side-effects that should not be lost when offline:
306
+
307
+ ```typescript
308
+ import { client } from './sync.config';
309
+
310
+ // ⚡ 1. Enqueue transactional email offline (0ms latency, safe if app closes)
311
+ const emailAction = await client.actions.sendEmail({
312
+ to: 'customer@example.com',
313
+ subject: 'Order #1042 Confirmed',
314
+ html: '<h1>Thank you for your purchase!</h1>',
315
+ template: 'order_receipt',
316
+ templateData: { orderId: 1042, total: '$49.99' },
317
+ idempotencyKey: 'order_receipt_1042', // Prevents sending twice
318
+ ttlMs: 24 * 60 * 60 * 1000, // Expire if offline for >24 hours
319
+ });
320
+
321
+ // ⚡ 2. Register local dispatcher (or let rows replicate to Supabase/Convex/Firebase)
322
+ client.actions.registerEmailHandler(async (email) => {
323
+ // Dispatch via your transactional provider or backend API
324
+ const response = await fetch('https://api.resend.com/emails', {
325
+ method: 'POST',
326
+ headers: { Authorization: `Bearer ${process.env.RESEND_API_KEY}` },
327
+ body: JSON.stringify(email),
328
+ });
329
+ const data = await response.json();
330
+ return { messageId: data.id };
331
+ });
332
+
333
+ // ⚡ 3. Enqueue arbitrary asynchronous actions (e.g., push notifications, analytics)
334
+ await client.actions.enqueue({
335
+ action: 'push_notification',
336
+ payload: { title: 'New chat message', token: 'fcm-xxx' },
337
+ idempotencyKey: 'chat_msg_99',
338
+ });
339
+ ```
340
+
341
+ ---
342
+
342
343
  ## Supabase Backend Setup & Schema
343
344
 
344
345
  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**):
@@ -27,19 +27,19 @@ class ActionStore {
27
27
  * Initializes the action outbox schema and indexes if they do not exist.
28
28
  */
29
29
  static async ensureSchema(db, tableName = exports.DEFAULT_ACTIONS_TABLE) {
30
- await db.execute(`CREATE TABLE IF NOT EXISTS ${tableName} (
31
- id TEXT PRIMARY KEY,
32
- action TEXT NOT NULL,
33
- payload TEXT NOT NULL,
34
- idempotency_key TEXT UNIQUE,
35
- status TEXT NOT NULL DEFAULT 'PENDING',
36
- created_at INTEGER NOT NULL,
37
- expires_at INTEGER,
38
- executed_at INTEGER,
39
- retry_count INTEGER NOT NULL DEFAULT 0,
40
- max_retries INTEGER NOT NULL DEFAULT 3,
41
- last_error TEXT,
42
- result TEXT
30
+ await db.execute(`CREATE TABLE IF NOT EXISTS ${tableName} (
31
+ id TEXT PRIMARY KEY,
32
+ action TEXT NOT NULL,
33
+ payload TEXT NOT NULL,
34
+ idempotency_key TEXT UNIQUE,
35
+ status TEXT NOT NULL DEFAULT 'PENDING',
36
+ created_at INTEGER NOT NULL,
37
+ expires_at INTEGER,
38
+ executed_at INTEGER,
39
+ retry_count INTEGER NOT NULL DEFAULT 0,
40
+ max_retries INTEGER NOT NULL DEFAULT 3,
41
+ last_error TEXT,
42
+ result TEXT
43
43
  );`);
44
44
  await db.execute(`CREATE INDEX IF NOT EXISTS idx_${tableName}_status ON ${tableName} (status, created_at);`);
45
45
  await db.execute(`CREATE INDEX IF NOT EXISTS idx_${tableName}_idempotency ON ${tableName} (idempotency_key);`);
@@ -55,8 +55,8 @@ class ActionStore {
55
55
  * Inserts an action record into storage.
56
56
  */
57
57
  static async insertRecord(db, tableName = exports.DEFAULT_ACTIONS_TABLE, record) {
58
- await db.execute(`INSERT INTO ${tableName} (
59
- id, action, payload, idempotency_key, status, created_at, expires_at, executed_at, retry_count, max_retries, last_error, result
58
+ await db.execute(`INSERT INTO ${tableName} (
59
+ id, action, payload, idempotency_key, status, created_at, expires_at, executed_at, retry_count, max_retries, last_error, result
60
60
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`, [
61
61
  record.id,
62
62
  record.action,
@@ -54,19 +54,19 @@ class SessionManager {
54
54
  * Ensures the _local_sessions schema exists.
55
55
  */
56
56
  async initialize() {
57
- await this.db.execute(`CREATE TABLE IF NOT EXISTS ${this.tableName} (
58
- id TEXT PRIMARY KEY,
59
- user_id TEXT NOT NULL,
60
- user_data TEXT NOT NULL,
61
- token TEXT,
62
- refresh_token TEXT,
63
- provider TEXT,
64
- created_at INTEGER NOT NULL,
65
- last_active_at INTEGER NOT NULL,
66
- expires_at INTEGER,
67
- offline_access_until INTEGER,
68
- metadata TEXT,
69
- is_active INTEGER NOT NULL DEFAULT 1
57
+ await this.db.execute(`CREATE TABLE IF NOT EXISTS ${this.tableName} (
58
+ id TEXT PRIMARY KEY,
59
+ user_id TEXT NOT NULL,
60
+ user_data TEXT NOT NULL,
61
+ token TEXT,
62
+ refresh_token TEXT,
63
+ provider TEXT,
64
+ created_at INTEGER NOT NULL,
65
+ last_active_at INTEGER NOT NULL,
66
+ expires_at INTEGER,
67
+ offline_access_until INTEGER,
68
+ metadata TEXT,
69
+ is_active INTEGER NOT NULL DEFAULT 1
70
70
  );`);
71
71
  await this.db.execute(`CREATE INDEX IF NOT EXISTS idx_${this.tableName}_user ON ${this.tableName} (user_id);`);
72
72
  await this.db.execute(`CREATE INDEX IF NOT EXISTS idx_${this.tableName}_active ON ${this.tableName} (is_active);`);
@@ -90,20 +90,20 @@ class SessionManager {
90
90
  // Deactivate other sessions
91
91
  await this.db.execute(`UPDATE ${this.tableName} SET is_active = ?;`, [0]);
92
92
  }
93
- const sql = `
94
- INSERT INTO ${this.tableName} (id, user_id, user_data, token, refresh_token, provider, created_at, last_active_at, expires_at, offline_access_until, metadata, is_active)
95
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
96
- ON CONFLICT(id) DO UPDATE SET
97
- user_id = excluded.user_id,
98
- user_data = excluded.user_data,
99
- token = excluded.token,
100
- refresh_token = excluded.refresh_token,
101
- provider = excluded.provider,
102
- last_active_at = excluded.last_active_at,
103
- expires_at = excluded.expires_at,
104
- offline_access_until = excluded.offline_access_until,
105
- metadata = excluded.metadata,
106
- is_active = excluded.is_active;
93
+ const sql = `
94
+ INSERT INTO ${this.tableName} (id, user_id, user_data, token, refresh_token, provider, created_at, last_active_at, expires_at, offline_access_until, metadata, is_active)
95
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
96
+ ON CONFLICT(id) DO UPDATE SET
97
+ user_id = excluded.user_id,
98
+ user_data = excluded.user_data,
99
+ token = excluded.token,
100
+ refresh_token = excluded.refresh_token,
101
+ provider = excluded.provider,
102
+ last_active_at = excluded.last_active_at,
103
+ expires_at = excluded.expires_at,
104
+ offline_access_until = excluded.offline_access_until,
105
+ metadata = excluded.metadata,
106
+ is_active = excluded.is_active;
107
107
  `;
108
108
  await this.db.execute(sql, [
109
109
  id,
@@ -85,6 +85,7 @@ async function createSyncClient(config) {
85
85
  autoSchema,
86
86
  tables: config.tables ?? [],
87
87
  migrations: config.migrations,
88
+ schemaMigrations: config.schemaMigrations,
88
89
  autoStart: config.autoStart,
89
90
  jsonColumns: config.jsonColumns,
90
91
  autoHydrateJSON: config.autoHydrateJSON,
@@ -1,6 +1,6 @@
1
1
  import { LocalDBAdapter } from '../adapters/local/index.js';
2
2
  import { RemoteDBAdapter } from '../adapters/remote/index.js';
3
- import { ConflictResolver, ConflictStrategy, NetworkStatusProvider, RetryConfig } from '../core/types.js';
3
+ import { ConflictResolver, ConflictStrategy, NetworkStatusProvider, RetryConfig, SchemaMigration } from '../core/types.js';
4
4
  import { SyncLogger, CustomLogger, LogLevel } from '../utils/logger.js';
5
5
  export interface SupabaseConfigOptions {
6
6
  url?: string;
@@ -84,10 +84,15 @@ export interface LocalLiteConfig {
84
84
  */
85
85
  tables?: string[];
86
86
  /**
87
- * Initial SQL table schema migration queries to execute on startup.
87
+ * Legacy idempotent SQL statements executed transactionally on every initialization.
88
88
  * e.g. ["CREATE TABLE IF NOT EXISTS todos (id TEXT PRIMARY KEY, title TEXT);"]
89
89
  */
90
90
  migrations?: string[];
91
+ /**
92
+ * Versioned application schema migrations. Versions must be consecutive integers
93
+ * starting at 1; each migration is applied once and recorded in schema_migrations.
94
+ */
95
+ schemaMigrations?: SchemaMigration[];
91
96
  /**
92
97
  * Automatically create tables and columns dynamically on the fly without manual SQL migrations.
93
98
  * Opt-in (default: false). When true, tables in `tables` are created automatically,
@@ -4,7 +4,7 @@ import { EventEmitter } from '../reactive/event-emitter.js';
4
4
  import { SyncWorker } from './sync-worker.js';
5
5
  import { DatabaseMaintenance } from './maintenance.js';
6
6
  import { SchemaManager } from './schema-manager.js';
7
- import { BatchMutation, ConflictResolver, ConflictStrategy, MutateOptions, NetworkStatusProvider, OutboxRecord, OutboxStatus, QueryOptions, RetryConfig, SyncMetrics, SyncOp, TableChangeEvent, WhereFilter } from './types.js';
7
+ import { BatchMutation, ConflictResolver, ConflictStrategy, MutateOptions, NetworkStatusProvider, OutboxRecord, OutboxStatus, QueryOptions, RetryConfig, SchemaMigration, SyncMetrics, SyncOp, TableChangeEvent, WhereFilter } from './types.js';
8
8
  import { SyncLogger, CustomLogger, LogLevel } from '../utils/logger.js';
9
9
  import { QueryBuilder, AggregateSpec, AggregateResult } from './query-builder.js';
10
10
  import { type ExportJsonOptions, type ImportJsonOptions, type ExportCsvOptions, type ImportCsvOptions, type ImportSummary } from '../utils/portability.js';
@@ -31,6 +31,7 @@ export interface LocalFirstClientConfig {
31
31
  autoSchema?: boolean;
32
32
  tables?: string[];
33
33
  migrations?: string[];
34
+ schemaMigrations?: SchemaMigration[];
34
35
  autoStart?: boolean;
35
36
  coalesceOutbox?: boolean;
36
37
  jsonColumns?: Record<string, string[]>;
@@ -108,6 +109,7 @@ export declare class LocalFirstClient {
108
109
  readonly session: SessionManager;
109
110
  private primaryKey;
110
111
  private migrations;
112
+ private schemaMigrations;
111
113
  private autoStart;
112
114
  private initialization?;
113
115
  constructor(config: LocalFirstClientConfig);
@@ -139,6 +141,7 @@ export declare class LocalFirstClient {
139
141
  */
140
142
  initialize(): Promise<void>;
141
143
  private initializeStorage;
144
+ private applySchemaMigrations;
142
145
  /**
143
146
  * Performs an atomic dual-write: mutates the local data table AND writes
144
147
  * to sync_outbox within the same database transaction.
@@ -347,7 +350,21 @@ export declare class LocalFirstClient {
347
350
  dropTables?: boolean;
348
351
  includeInternal?: boolean;
349
352
  }): Promise<void>;
350
- private getAllTableNames;
353
+ /**
354
+ * Checks if a table name belongs to internal system, FTS, or engine tables.
355
+ */
356
+ isInternalTable(tableName: string): boolean;
357
+ /**
358
+ * Returns list of database tables.
359
+ * By default, internal tables (FTS shadow tables, local-lite engine tables) are excluded.
360
+ * Pass `{ includeInternal: true }` to include internal tables.
361
+ */
362
+ getTables(options?: {
363
+ includeInternal?: boolean;
364
+ }): Promise<string[]>;
365
+ getAllTableNames(options?: {
366
+ includeInternal?: boolean;
367
+ }): Promise<string[]>;
351
368
  /**
352
369
  * Exports the entire local database state as a portable binary snapshot.
353
370
  * If the local adapter has a native export mechanism (e.g. serialize or export),
@@ -21,6 +21,30 @@ const index_js_5 = require("../search/index.js");
21
21
  const index_js_6 = require("../storage/index.js");
22
22
  const index_js_7 = require("../scheduler/index.js");
23
23
  const session_js_1 = require("../auth/session.js");
24
+ function normalizeSchemaMigrations(migrations) {
25
+ if (!Array.isArray(migrations)) {
26
+ throw new TypeError('schemaMigrations must be an array.');
27
+ }
28
+ if (migrations.some((migration) => !migration ||
29
+ typeof migration !== 'object' ||
30
+ !Number.isSafeInteger(migration.version) ||
31
+ migration.version < 1)) {
32
+ throw new TypeError('Each schema migration must have a positive integer version.');
33
+ }
34
+ const ordered = [...migrations].sort((a, b) => a.version - b.version);
35
+ return ordered.map((migration, index) => {
36
+ const expectedVersion = index + 1;
37
+ if (!migration || !Number.isSafeInteger(migration.version) || migration.version !== expectedVersion) {
38
+ throw new TypeError(`schemaMigrations must use consecutive integer versions starting at 1; expected version ${expectedVersion}.`);
39
+ }
40
+ const statements = Array.isArray(migration.up) ? migration.up : [migration.up];
41
+ if (statements.length === 0 ||
42
+ statements.some((statement) => typeof statement !== 'string' || statement.trim().length === 0)) {
43
+ throw new TypeError(`schemaMigrations version ${migration.version} must contain non-empty SQL.`);
44
+ }
45
+ return { version: migration.version, up: statements.map((statement) => statement.trim()) };
46
+ });
47
+ }
24
48
  function hasFunctionFilter(value) {
25
49
  if (typeof value === 'function')
26
50
  return true;
@@ -182,6 +206,7 @@ class LocalFirstClient {
182
206
  session;
183
207
  primaryKey;
184
208
  migrations;
209
+ schemaMigrations;
185
210
  autoStart;
186
211
  initialization;
187
212
  constructor(config) {
@@ -193,6 +218,7 @@ class LocalFirstClient {
193
218
  this.coalesceOutbox = config.coalesceOutbox ?? true;
194
219
  this.tables = config.tables ?? [];
195
220
  this.migrations = config.migrations ?? [];
221
+ this.schemaMigrations = normalizeSchemaMigrations(config.schemaMigrations ?? []);
196
222
  this.autoStart = config.autoStart ?? true;
197
223
  this.jsonColumns = config.jsonColumns ?? {};
198
224
  this.autoHydrateJSON = config.autoHydrateJSON ?? false;
@@ -416,6 +442,7 @@ class LocalFirstClient {
416
442
  }
417
443
  });
418
444
  }
445
+ await this.applySchemaMigrations();
419
446
  if (this.autoSchema && this.tables.length > 0) {
420
447
  for (const table of this.tables) {
421
448
  await this.schemaManager.ensureTable(this.db, table, this.primaryKey);
@@ -424,6 +451,36 @@ class LocalFirstClient {
424
451
  if (this.autoStart)
425
452
  this.syncWorker.start();
426
453
  }
454
+ async applySchemaMigrations() {
455
+ if (this.schemaMigrations.length === 0)
456
+ return;
457
+ await this.db.transaction(async (tx) => {
458
+ await tx.execute(`CREATE TABLE IF NOT EXISTS schema_migrations (
459
+ version INTEGER PRIMARY KEY,
460
+ applied_at INTEGER NOT NULL
461
+ );`);
462
+ const rows = await tx.execute('SELECT version FROM schema_migrations ORDER BY version ASC');
463
+ const appliedVersions = rows.map((row) => Number(row.version));
464
+ for (let index = 0; index < appliedVersions.length; index += 1) {
465
+ if (appliedVersions[index] !== index + 1) {
466
+ throw new Error('The schema_migrations table contains a missing or invalid version.');
467
+ }
468
+ }
469
+ let currentVersion = appliedVersions.at(-1) ?? 0;
470
+ if (currentVersion > this.schemaMigrations.length) {
471
+ throw new Error(`Database schema version ${currentVersion} is newer than the latest available migration (${this.schemaMigrations.length}).`);
472
+ }
473
+ for (const migration of this.schemaMigrations) {
474
+ if (migration.version <= currentVersion)
475
+ continue;
476
+ for (const statement of migration.up) {
477
+ await tx.execute(statement);
478
+ }
479
+ await tx.execute('INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)', [migration.version, Date.now()]);
480
+ currentVersion = migration.version;
481
+ }
482
+ });
483
+ }
427
484
  /**
428
485
  * Performs an atomic dual-write: mutates the local data table AND writes
429
486
  * to sync_outbox within the same database transaction.
@@ -978,22 +1035,50 @@ class LocalFirstClient {
978
1035
  await this.resetSyncCursor();
979
1036
  this.events.emit('reset', { dropTables: options.dropTables ?? false });
980
1037
  }
981
- async getAllTableNames() {
1038
+ /**
1039
+ * Checks if a table name belongs to internal system, FTS, or engine tables.
1040
+ */
1041
+ isInternalTable(tableName) {
1042
+ return (0, sql_js_1.isInternalTable)(tableName);
1043
+ }
1044
+ /**
1045
+ * Returns list of database tables.
1046
+ * By default, internal tables (FTS shadow tables, local-lite engine tables) are excluded.
1047
+ * Pass `{ includeInternal: true }` to include internal tables.
1048
+ */
1049
+ async getTables(options = {}) {
1050
+ const includeInternal = options.includeInternal ?? false;
1051
+ const allTables = await this.getAllTableNames(options);
1052
+ if (includeInternal) {
1053
+ return allTables;
1054
+ }
1055
+ return allTables.filter(name => !(0, sql_js_1.isInternalTable)(name));
1056
+ }
1057
+ async getAllTableNames(options = {}) {
1058
+ const includeInternal = options.includeInternal ?? false;
982
1059
  const list = new Set(this.tables);
983
- list.add('sync_outbox');
984
- list.add('sync_meta');
1060
+ if (includeInternal) {
1061
+ list.add('sync_outbox');
1062
+ list.add('sync_meta');
1063
+ }
985
1064
  try {
986
- const rows = await this.db.execute(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`);
1065
+ const rows = await this.db.execute(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'android_%'`);
987
1066
  if (Array.isArray(rows)) {
988
1067
  for (const r of rows) {
989
- if (r.name)
990
- list.add(r.name);
1068
+ if (r.name) {
1069
+ if (includeInternal || !(0, sql_js_1.isInternalTable)(r.name)) {
1070
+ list.add(r.name);
1071
+ }
1072
+ }
991
1073
  }
992
1074
  }
993
1075
  }
994
1076
  catch {
995
1077
  // Non-SQLite database adapter, fallback to configured tables
996
1078
  }
1079
+ if (!includeInternal) {
1080
+ return Array.from(list).filter(name => !(0, sql_js_1.isInternalTable)(name));
1081
+ }
997
1082
  return Array.from(list);
998
1083
  }
999
1084
  /**
@@ -62,5 +62,7 @@ export declare class DatabaseMaintenance {
62
62
  /**
63
63
  * Gathers storage metrics: table row counts, outbox status distribution, and database size.
64
64
  */
65
- getStorageStats(): Promise<StorageStats>;
65
+ getStorageStats(options?: {
66
+ includeInternal?: boolean;
67
+ }): Promise<StorageStats>;
66
68
  }
@@ -76,17 +76,21 @@ class DatabaseMaintenance {
76
76
  /**
77
77
  * Gathers storage metrics: table row counts, outbox status distribution, and database size.
78
78
  */
79
- async getStorageStats() {
79
+ async getStorageStats(options = {}) {
80
+ const includeInternal = options.includeInternal ?? false;
80
81
  // 1. Table row counts
81
82
  const tableCounts = {};
82
83
  let tableNames = [];
83
84
  try {
84
- const masterRows = await this.db.execute(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`);
85
+ const masterRows = await this.db.execute(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'android_%'`);
85
86
  tableNames = masterRows.map((r) => r.name);
86
87
  }
87
88
  catch {
88
89
  tableNames = this.getKnownTables();
89
90
  }
91
+ if (!includeInternal) {
92
+ tableNames = tableNames.filter(name => !(0, sql_js_1.isInternalTable)(name));
93
+ }
90
94
  for (const tableName of tableNames) {
91
95
  try {
92
96
  const safeTable = (0, sql_js_1.escapeIdentifier)(tableName);
@@ -7,9 +7,9 @@ class MetaStore {
7
7
  * This table is strictly local to the device and is never synced to the remote server.
8
8
  */
9
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
10
+ await db.execute(`CREATE TABLE IF NOT EXISTS sync_meta (
11
+ key TEXT PRIMARY KEY,
12
+ value TEXT NOT NULL
13
13
  );`);
14
14
  }
15
15
  /**
@@ -22,16 +22,16 @@ class OutboxStore {
22
22
  * Initializes the sync_outbox schema if it does not already exist.
23
23
  */
24
24
  static async ensureSchema(db) {
25
- await db.execute(`CREATE TABLE IF NOT EXISTS sync_outbox (
26
- id TEXT PRIMARY KEY,
27
- table_name TEXT NOT NULL,
28
- row_id TEXT NOT NULL,
29
- operation TEXT NOT NULL,
30
- payload TEXT,
31
- timestamp INTEGER NOT NULL,
32
- status TEXT NOT NULL DEFAULT 'PENDING',
33
- retry_count INTEGER NOT NULL DEFAULT 0,
34
- last_error TEXT
25
+ await db.execute(`CREATE TABLE IF NOT EXISTS sync_outbox (
26
+ id TEXT PRIMARY KEY,
27
+ table_name TEXT NOT NULL,
28
+ row_id TEXT NOT NULL,
29
+ operation TEXT NOT NULL,
30
+ payload TEXT,
31
+ timestamp INTEGER NOT NULL,
32
+ status TEXT NOT NULL DEFAULT 'PENDING',
33
+ retry_count INTEGER NOT NULL DEFAULT 0,
34
+ last_error TEXT
35
35
  );`);
36
36
  // Indexes for the hot paths: pending scans (push), per-row conflict lookups (pull),
37
37
  // and status counts (telemetry). Executed separately: many drivers prepare one statement.
@@ -43,7 +43,7 @@ class OutboxStore {
43
43
  * Inserts a new outbox entry within an existing database transaction.
44
44
  */
45
45
  static async insertRecord(tx, op) {
46
- await tx.execute(`INSERT INTO sync_outbox (id, table_name, row_id, operation, payload, timestamp, status, retry_count)
46
+ await tx.execute(`INSERT INTO sync_outbox (id, table_name, row_id, operation, payload, timestamp, status, retry_count)
47
47
  VALUES (?, ?, ?, ?, ?, ?, 'PENDING', 0)`, [
48
48
  op.id,
49
49
  op.tableName,
@@ -85,11 +85,11 @@ class OutboxStore {
85
85
  static async getPending(db, maxRetries = 5, limit = 50) {
86
86
  const isInfinite = !Number.isFinite(maxRetries) || maxRetries === -1;
87
87
  const query = isInfinite
88
- ? `SELECT * FROM sync_outbox
89
- WHERE status = 'PENDING' OR status = 'FAILED'
88
+ ? `SELECT * FROM sync_outbox
89
+ WHERE status = 'PENDING' OR status = 'FAILED'
90
90
  ORDER BY timestamp ASC LIMIT ?`
91
- : `SELECT * FROM sync_outbox
92
- WHERE status = 'PENDING' OR (status = 'FAILED' AND retry_count < ?)
91
+ : `SELECT * FROM sync_outbox
92
+ WHERE status = 'PENDING' OR (status = 'FAILED' AND retry_count < ?)
93
93
  ORDER BY timestamp ASC LIMIT ?`;
94
94
  const params = isInfinite ? [limit] : [maxRetries, limit];
95
95
  const rows = await db.execute(query, params);
@@ -101,8 +101,8 @@ class OutboxStore {
101
101
  * Finds the latest pending outbox record for a given table and row.
102
102
  */
103
103
  static async findPendingByRow(tx, tableName, rowId) {
104
- const rows = await tx.execute(`SELECT * FROM sync_outbox
105
- WHERE table_name = ? AND row_id = ? AND status != 'FAILED'
104
+ const rows = await tx.execute(`SELECT * FROM sync_outbox
105
+ WHERE table_name = ? AND row_id = ? AND status != 'FAILED'
106
106
  ORDER BY timestamp DESC LIMIT 1`, [tableName, rowId]);
107
107
  if (!rows || rows.length === 0)
108
108
  return undefined;
@@ -193,10 +193,10 @@ class OutboxStore {
193
193
  if (ids.length === 0)
194
194
  return;
195
195
  const placeholders = ids.map(() => '?').join(',');
196
- await db.execute(`UPDATE sync_outbox
197
- SET status = 'FAILED',
198
- retry_count = retry_count + 1,
199
- last_error = ?
196
+ await db.execute(`UPDATE sync_outbox
197
+ SET status = 'FAILED',
198
+ retry_count = retry_count + 1,
199
+ last_error = ?
200
200
  WHERE id IN (${placeholders})`, [errorMessage, ...ids]);
201
201
  }
202
202
  /**
@@ -235,8 +235,8 @@ class OutboxStore {
235
235
  try {
236
236
  if (ids && ids.length > 0) {
237
237
  const placeholders = ids.map(() => '?').join(',');
238
- await db.execute(`UPDATE sync_outbox
239
- SET status = 'PENDING', retry_count = 0, last_error = NULL
238
+ await db.execute(`UPDATE sync_outbox
239
+ SET status = 'PENDING', retry_count = 0, last_error = NULL
240
240
  WHERE status = 'FAILED' AND id IN (${placeholders})`, ids);
241
241
  return ids.length;
242
242
  }