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 +21 -21
- package/README.md +49 -48
- package/dist/actions/store.js +15 -15
- package/dist/auth/session.js +27 -27
- package/dist/config/index.js +1 -0
- package/dist/config/types.d.ts +7 -2
- package/dist/core/client.d.ts +19 -2
- package/dist/core/client.js +91 -6
- package/dist/core/maintenance.d.ts +3 -1
- package/dist/core/maintenance.js +6 -2
- package/dist/core/meta-store.js +3 -3
- package/dist/core/outbox-store.js +23 -23
- package/dist/core/types.d.ts +4 -0
- package/dist/{react → devtools}/BenchmarkModal.d.ts +1 -1
- package/dist/{react → devtools}/BenchmarkModal.js +20 -20
- package/dist/{react → devtools}/SyncInspector.d.ts +3 -2
- package/dist/{react → devtools}/SyncInspector.js +43 -33
- package/dist/devtools/index.d.ts +3 -0
- package/dist/devtools/index.js +20 -0
- package/dist/keys/store.js +14 -14
- package/dist/kv/store.js +13 -13
- package/dist/react/index.d.ts +0 -3
- package/dist/react/index.js +0 -4
- package/dist/scheduler/store.js +28 -28
- package/dist/schema/generator.js +28 -28
- package/dist/search/store.js +15 -15
- package/dist/storage/store.js +24 -24
- package/dist/utils/portability.d.ts +4 -0
- package/dist/utils/portability.js +7 -1
- package/dist/utils/sql.d.ts +5 -0
- package/dist/utils/sql.js +21 -0
- package/package.json +205 -199
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
|
-
-
|
|
67
|
-
is deferred with `autoInitialize: false`.
|
|
68
|
-
|
|
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**):
|
package/dist/actions/store.js
CHANGED
|
@@ -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,
|
package/dist/auth/session.js
CHANGED
|
@@ -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,
|
package/dist/config/index.js
CHANGED
|
@@ -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,
|
package/dist/config/types.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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,
|
package/dist/core/client.d.ts
CHANGED
|
@@ -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
|
-
|
|
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),
|
package/dist/core/client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
984
|
-
|
|
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
|
-
|
|
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(
|
|
65
|
+
getStorageStats(options?: {
|
|
66
|
+
includeInternal?: boolean;
|
|
67
|
+
}): Promise<StorageStats>;
|
|
66
68
|
}
|
package/dist/core/maintenance.js
CHANGED
|
@@ -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);
|
package/dist/core/meta-store.js
CHANGED
|
@@ -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
|
}
|