local-lite 0.0.24 → 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/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
@@ -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.
@@ -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.
@@ -1,5 +1,9 @@
1
1
  export type SyncOp = 'INSERT' | 'UPDATE' | 'DELETE';
2
2
  export type OutboxStatus = 'PENDING' | 'PROCESSING' | 'FAILED';
3
+ export interface SchemaMigration {
4
+ version: number;
5
+ up: string | string[];
6
+ }
3
7
  export interface SyncOperation {
4
8
  id: string;
5
9
  tableName: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-lite",
3
- "version": "0.0.24",
3
+ "version": "0.0.25",
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",
@@ -141,7 +141,8 @@
141
141
  "build": "tsc",
142
142
  "test": "vitest run",
143
143
  "test:watch": "vitest",
144
- "prepublishOnly": "npm run test && npm run build"
144
+ "test:package": "node package-smoke.mjs",
145
+ "prepublishOnly": "npm run test && npm run build && npm run test:package"
145
146
  },
146
147
  "peerDependencies": {
147
148
  "@libsql/client": "^0.6.0",