local-lite 0.0.24 → 0.0.27

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
@@ -4,6 +4,27 @@ exports.createConvexStorageUploader = exports.ConvexRemoteAdapter = void 0;
4
4
  const logger_js_1 = require("../../utils/logger.js");
5
5
  const convex_js_1 = require("../../storage/convex.js");
6
6
  Object.defineProperty(exports, "createConvexStorageUploader", { enumerable: true, get: function () { return convex_js_1.createConvexStorageUploader; } });
7
+ function getRowTimestamp(value, fallback) {
8
+ const timestamp = value ?? fallback;
9
+ if (typeof timestamp === 'number' && Number.isFinite(timestamp))
10
+ return timestamp;
11
+ if (typeof timestamp === 'string') {
12
+ const numeric = Number(timestamp);
13
+ if (timestamp.trim() !== '' && Number.isFinite(numeric))
14
+ return numeric;
15
+ const parsed = Date.parse(timestamp);
16
+ if (Number.isFinite(parsed))
17
+ return parsed;
18
+ }
19
+ throw new Error('Convex change is missing a valid updatedAt timestamp.');
20
+ }
21
+ function getRowId(row, primaryKey) {
22
+ const id = row?.[primaryKey] ?? row?._id ?? row?.id;
23
+ if (id === undefined || id === null || String(id).length === 0) {
24
+ throw new Error('Convex change is missing its document ID.');
25
+ }
26
+ return String(id);
27
+ }
7
28
  class ConvexRemoteAdapter {
8
29
  client;
9
30
  tables;
@@ -95,11 +116,11 @@ class ConvexRemoteAdapter {
95
116
  (row[this.deletedColumn] &&
96
117
  !['false', '0', 'null', 'undefined'].includes(String(row[this.deletedColumn]).toLowerCase())));
97
118
  const operation = isSoftDeleted ? 'DELETE' : 'UPDATE';
98
- const rowTime = Number(row[this.updatedAtColumn] || row._creationTime || Date.now());
119
+ const rowTime = getRowTimestamp(row[this.updatedAtColumn], row._creationTime);
99
120
  if (rowTime > maxServerTimestamp) {
100
121
  maxServerTimestamp = rowTime;
101
122
  }
102
- const rowId = String(row[this.primaryKey] || row._id || row.id);
123
+ const rowId = getRowId(row, this.primaryKey);
103
124
  const tableName = row.tableName || row._table || (this.tables.length === 1 ? this.tables[0] : 'default');
104
125
  changes.push({
105
126
  tableName,
@@ -118,7 +139,7 @@ class ConvexRemoteAdapter {
118
139
  }
119
140
  catch (error) {
120
141
  this.logger.error((0, logger_js_1.formatConvexError)(error, { operation: 'fetchChanges' }));
121
- return { timestamp: sinceTimestamp, changes: [] };
142
+ throw error;
122
143
  }
123
144
  }
124
145
  /**
@@ -147,8 +168,8 @@ class ConvexRemoteAdapter {
147
168
  const changes = data.map((row) => {
148
169
  const isSoftDeleted = Boolean(row[this.deletedColumn] === true);
149
170
  const op = isSoftDeleted ? 'DELETE' : 'UPDATE';
150
- const rowTime = Number(row[this.updatedAtColumn] || row._creationTime || Date.now());
151
- const rowId = String(row[this.primaryKey] || row._id || row.id);
171
+ const rowTime = getRowTimestamp(row[this.updatedAtColumn], row._creationTime);
172
+ const rowId = getRowId(row, this.primaryKey);
152
173
  const tableName = row.tableName || row._table || (this.tables[0] ?? 'default');
153
174
  return {
154
175
  tableName,
@@ -8,10 +8,20 @@ Object.defineProperty(exports, "createFirebaseStorageUploader", { enumerable: tr
8
8
  * Safely extracts epoch milliseconds from numbers, Firestore Timestamps, or ISO strings.
9
9
  */
10
10
  function extractTimestamp(val) {
11
- if (!val)
12
- return Date.now();
13
- if (typeof val === 'number')
11
+ if (val == null) {
12
+ throw new Error('Firestore change is missing its updatedAt timestamp.');
13
+ }
14
+ if (typeof val === 'number' && Number.isFinite(val))
14
15
  return val;
16
+ if (typeof val === 'string') {
17
+ const numeric = Number(val);
18
+ if (val.trim() !== '' && Number.isFinite(numeric))
19
+ return numeric;
20
+ const parsed = Date.parse(val);
21
+ if (Number.isFinite(parsed))
22
+ return parsed;
23
+ throw new Error(`Firestore change has an invalid updatedAt timestamp: ${val}`);
24
+ }
15
25
  if (typeof val.toMillis === 'function')
16
26
  return val.toMillis();
17
27
  if (typeof val.toDate === 'function')
@@ -19,12 +29,14 @@ function extractTimestamp(val) {
19
29
  if (typeof val.seconds === 'number') {
20
30
  return val.seconds * 1000 + Math.floor((val.nanoseconds || 0) / 1000000);
21
31
  }
22
- if (typeof val.string === 'string') {
23
- const parsed = Date.parse(val);
24
- if (!isNaN(parsed))
25
- return parsed;
32
+ throw new Error('Firestore change has an unsupported updatedAt timestamp.');
33
+ }
34
+ function getRowId(data, primaryKey, documentId) {
35
+ const id = data?.[primaryKey] ?? documentId ?? data?.id;
36
+ if (id === undefined || id === null || String(id).length === 0) {
37
+ throw new Error('Firestore change is missing its document ID.');
26
38
  }
27
- return Date.now();
39
+ return String(id);
28
40
  }
29
41
  class FirestoreRemoteAdapter {
30
42
  db;
@@ -211,7 +223,7 @@ class FirestoreRemoteAdapter {
211
223
  if (rowTime > maxServerTimestamp) {
212
224
  maxServerTimestamp = rowTime;
213
225
  }
214
- const rowId = String(data[this.primaryKey] || doc.id || data.id);
226
+ const rowId = getRowId(data, this.primaryKey, doc.id);
215
227
  const operation = isSoftDeleted ? 'DELETE' : 'UPDATE';
216
228
  changes.push({
217
229
  tableName: table,
@@ -230,7 +242,7 @@ class FirestoreRemoteAdapter {
230
242
  }
231
243
  catch (error) {
232
244
  this.logger.error((0, logger_js_1.formatFirestoreError)(error, { operation: 'fetchChanges' }));
233
- return { timestamp: sinceTimestamp, changes: [] };
245
+ throw error;
234
246
  }
235
247
  }
236
248
  /**
@@ -273,7 +285,7 @@ class FirestoreRemoteAdapter {
273
285
  data[this.deletedColumn] === true ||
274
286
  data[this.deletedColumn] === 1);
275
287
  const rowTime = extractTimestamp(data[this.updatedAtColumn]);
276
- const rowId = String(data[this.primaryKey] || doc.id || data.id);
288
+ const rowId = getRowId(data, this.primaryKey, doc.id);
277
289
  changes.push({
278
290
  tableName: table,
279
291
  operation: isSoftDeleted ? 'DELETE' : 'UPDATE',
@@ -48,6 +48,9 @@ export declare class SessionManager {
48
48
  private db;
49
49
  private events?;
50
50
  private tableName;
51
+ private sqlTableName;
52
+ private sqlUserIndexName;
53
+ private sqlActiveIndexName;
51
54
  private defaultOfflineDurationMs;
52
55
  private network?;
53
56
  constructor(db: DBExecutable, events?: EventEmitter | undefined, options?: SessionManagerOptions);
@@ -5,6 +5,9 @@ exports.mapSessionRow = mapSessionRow;
5
5
  const uuid_js_1 = require("../utils/uuid.js");
6
6
  exports.DEFAULT_SESSIONS_TABLE = '_local_sessions';
7
7
  exports.DEFAULT_OFFLINE_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
8
+ function quoteIdentifier(identifier) {
9
+ return `"${identifier.replace(/"/g, '""')}"`;
10
+ }
8
11
  function mapSessionRow(r) {
9
12
  let userData;
10
13
  try {
@@ -41,12 +44,18 @@ class SessionManager {
41
44
  db;
42
45
  events;
43
46
  tableName;
47
+ sqlTableName;
48
+ sqlUserIndexName;
49
+ sqlActiveIndexName;
44
50
  defaultOfflineDurationMs;
45
51
  network;
46
52
  constructor(db, events, options = {}) {
47
53
  this.db = db;
48
54
  this.events = events;
49
55
  this.tableName = options.tableName ?? exports.DEFAULT_SESSIONS_TABLE;
56
+ this.sqlTableName = quoteIdentifier(this.tableName);
57
+ this.sqlUserIndexName = quoteIdentifier(`idx_${this.tableName}_user`);
58
+ this.sqlActiveIndexName = quoteIdentifier(`idx_${this.tableName}_active`);
50
59
  this.defaultOfflineDurationMs = options.defaultOfflineDurationMs ?? exports.DEFAULT_OFFLINE_DURATION_MS;
51
60
  this.network = options.network;
52
61
  }
@@ -54,7 +63,7 @@ class SessionManager {
54
63
  * Ensures the _local_sessions schema exists.
55
64
  */
56
65
  async initialize() {
57
- await this.db.execute(`CREATE TABLE IF NOT EXISTS ${this.tableName} (
66
+ await this.db.execute(`CREATE TABLE IF NOT EXISTS ${this.sqlTableName} (
58
67
  id TEXT PRIMARY KEY,
59
68
  user_id TEXT NOT NULL,
60
69
  user_data TEXT NOT NULL,
@@ -68,8 +77,8 @@ class SessionManager {
68
77
  metadata TEXT,
69
78
  is_active INTEGER NOT NULL DEFAULT 1
70
79
  );`);
71
- await this.db.execute(`CREATE INDEX IF NOT EXISTS idx_${this.tableName}_user ON ${this.tableName} (user_id);`);
72
- await this.db.execute(`CREATE INDEX IF NOT EXISTS idx_${this.tableName}_active ON ${this.tableName} (is_active);`);
80
+ await this.db.execute(`CREATE INDEX IF NOT EXISTS ${this.sqlUserIndexName} ON ${this.sqlTableName} (user_id);`);
81
+ await this.db.execute(`CREATE INDEX IF NOT EXISTS ${this.sqlActiveIndexName} ON ${this.sqlTableName} (is_active);`);
73
82
  }
74
83
  /**
75
84
  * Creates or updates a user session, persisting it to local SQLite so it remains available offline.
@@ -88,10 +97,10 @@ class SessionManager {
88
97
  const metadataStr = options.metadata ? JSON.stringify(options.metadata) : null;
89
98
  if (setActive) {
90
99
  // Deactivate other sessions
91
- await this.db.execute(`UPDATE ${this.tableName} SET is_active = ?;`, [0]);
100
+ await this.db.execute(`UPDATE ${this.sqlTableName} SET is_active = ?;`, [0]);
92
101
  }
93
102
  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)
103
+ INSERT INTO ${this.sqlTableName} (id, user_id, user_data, token, refresh_token, provider, created_at, last_active_at, expires_at, offline_access_until, metadata, is_active)
95
104
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
96
105
  ON CONFLICT(id) DO UPDATE SET
97
106
  user_id = excluded.user_id,
@@ -141,7 +150,7 @@ class SessionManager {
141
150
  * If offline, the session remains active even if server tokens expired, as long as offlineAccessUntil is valid.
142
151
  */
143
152
  async getActiveSession() {
144
- const rows = await this.db.execute(`SELECT * FROM ${this.tableName} WHERE is_active = ? LIMIT 1;`, [1]);
153
+ const rows = await this.db.execute(`SELECT * FROM ${this.sqlTableName} WHERE is_active = ? LIMIT 1;`, [1]);
145
154
  if (!rows || rows.length === 0) {
146
155
  return null;
147
156
  }
@@ -176,10 +185,10 @@ class SessionManager {
176
185
  async touch(sessionId) {
177
186
  const now = Date.now();
178
187
  if (sessionId) {
179
- await this.db.execute(`UPDATE ${this.tableName} SET last_active_at = ? WHERE id = ?;`, [now, sessionId]);
188
+ await this.db.execute(`UPDATE ${this.sqlTableName} SET last_active_at = ? WHERE id = ?;`, [now, sessionId]);
180
189
  }
181
190
  else {
182
- await this.db.execute(`UPDATE ${this.tableName} SET last_active_at = ? WHERE is_active = ?;`, [now, 1]);
191
+ await this.db.execute(`UPDATE ${this.sqlTableName} SET last_active_at = ? WHERE is_active = ?;`, [now, 1]);
183
192
  }
184
193
  }
185
194
  /**
@@ -195,7 +204,7 @@ class SessionManager {
195
204
  };
196
205
  const userDataStr = JSON.stringify(updatedUser);
197
206
  const now = Date.now();
198
- await this.db.execute(`UPDATE ${this.tableName} SET user_data = ?, last_active_at = ? WHERE id = ?;`, [userDataStr, now, session.id]);
207
+ await this.db.execute(`UPDATE ${this.sqlTableName} SET user_data = ?, last_active_at = ? WHERE id = ?;`, [userDataStr, now, session.id]);
199
208
  session.user = updatedUser;
200
209
  session.lastActiveAt = now;
201
210
  this.events?.emit(this.tableName, { operation: 'UPDATE_USER', session });
@@ -212,7 +221,7 @@ class SessionManager {
212
221
  const refreshToken = tokens.refreshToken !== undefined ? tokens.refreshToken : session.refreshToken;
213
222
  const expiresAt = tokens.expiresAt !== undefined ? tokens.expiresAt : session.expiresAt;
214
223
  const now = Date.now();
215
- await this.db.execute(`UPDATE ${this.tableName} SET token = ?, refresh_token = ?, expires_at = ?, last_active_at = ? WHERE id = ?;`, [token, refreshToken, expiresAt, now, session.id]);
224
+ await this.db.execute(`UPDATE ${this.sqlTableName} SET token = ?, refresh_token = ?, expires_at = ?, last_active_at = ? WHERE id = ?;`, [token, refreshToken, expiresAt, now, session.id]);
216
225
  session.token = token;
217
226
  session.refreshToken = refreshToken;
218
227
  session.expiresAt = expiresAt;
@@ -222,34 +231,34 @@ class SessionManager {
222
231
  * Signs out the active user session.
223
232
  */
224
233
  async clearSession() {
225
- await this.db.execute(`UPDATE ${this.tableName} SET is_active = ? WHERE is_active = ?;`, [0, 1]);
234
+ await this.db.execute(`UPDATE ${this.sqlTableName} SET is_active = ? WHERE is_active = ?;`, [0, 1]);
226
235
  this.events?.emit(this.tableName, { operation: 'CLEAR_SESSION' });
227
236
  }
228
237
  /**
229
238
  * Completely purges all stored sessions (e.g. factory reset or privacy wipe).
230
239
  */
231
240
  async purgeAllSessions() {
232
- await this.db.execute(`DELETE FROM ${this.tableName};`);
241
+ await this.db.execute(`DELETE FROM ${this.sqlTableName};`);
233
242
  this.events?.emit(this.tableName, { operation: 'PURGE_SESSIONS' });
234
243
  }
235
244
  /**
236
245
  * Lists all sessions currently cached in SQLite.
237
246
  */
238
247
  async listSessions() {
239
- const rows = await this.db.execute(`SELECT * FROM ${this.tableName} ORDER BY last_active_at DESC;`);
248
+ const rows = await this.db.execute(`SELECT * FROM ${this.sqlTableName} ORDER BY last_active_at DESC;`);
240
249
  return (rows || []).map((r) => mapSessionRow(r));
241
250
  }
242
251
  /**
243
252
  * Switches the active session to a different account (e.g., multi-account or organization switching offline).
244
253
  */
245
254
  async switchSession(sessionIdOrUserId) {
246
- const rows = await this.db.execute(`SELECT * FROM ${this.tableName} WHERE id = ? OR user_id = ? LIMIT 1;`, [sessionIdOrUserId, sessionIdOrUserId]);
255
+ const rows = await this.db.execute(`SELECT * FROM ${this.sqlTableName} WHERE id = ? OR user_id = ? LIMIT 1;`, [sessionIdOrUserId, sessionIdOrUserId]);
247
256
  if (!rows || rows.length === 0) {
248
257
  return null;
249
258
  }
250
259
  const targetSession = mapSessionRow(rows[0]);
251
- await this.db.execute(`UPDATE ${this.tableName} SET is_active = ?;`, [0]);
252
- await this.db.execute(`UPDATE ${this.tableName} SET is_active = ?, last_active_at = ? WHERE id = ?;`, [1, Date.now(), targetSession.id]);
260
+ await this.db.execute(`UPDATE ${this.sqlTableName} SET is_active = ?;`, [0]);
261
+ await this.db.execute(`UPDATE ${this.sqlTableName} SET is_active = ?, last_active_at = ? WHERE id = ?;`, [1, Date.now(), targetSession.id]);
253
262
  targetSession.isActive = true;
254
263
  targetSession.lastActiveAt = Date.now();
255
264
  this.events?.emit(this.tableName, { operation: 'SWITCH_SESSION', session: targetSession });
@@ -84,7 +84,9 @@ async function createSyncClient(config) {
84
84
  logLevel: config.logLevel,
85
85
  autoSchema,
86
86
  tables: config.tables ?? [],
87
+ relationships: config.relationships ?? [],
87
88
  migrations: config.migrations,
89
+ schemaMigrations: config.schemaMigrations,
88
90
  autoStart: config.autoStart,
89
91
  jsonColumns: config.jsonColumns,
90
92
  autoHydrateJSON: config.autoHydrateJSON,
@@ -1,7 +1,9 @@
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
+ import type { RelationshipDefinition } from '../core/client.js';
4
5
  import { SyncLogger, CustomLogger, LogLevel } from '../utils/logger.js';
6
+ export type { OnDeleteAction, RelationshipDefinition } from '../core/client.js';
5
7
  export interface SupabaseConfigOptions {
6
8
  url?: string;
7
9
  anonKey?: string;
@@ -84,10 +86,20 @@ export interface LocalLiteConfig {
84
86
  */
85
87
  tables?: string[];
86
88
  /**
87
- * Initial SQL table schema migration queries to execute on startup.
89
+ * Parent-child relationships. `onDelete` defaults to `noAction`, which rejects deletion when children exist.
90
+ * Deletes through client.delete(), client.mutate(..., 'DELETE'), or batch are handled atomically.
91
+ */
92
+ relationships?: RelationshipDefinition[];
93
+ /**
94
+ * Legacy idempotent SQL statements executed transactionally on every initialization.
88
95
  * e.g. ["CREATE TABLE IF NOT EXISTS todos (id TEXT PRIMARY KEY, title TEXT);"]
89
96
  */
90
97
  migrations?: string[];
98
+ /**
99
+ * Versioned application schema migrations. Versions must be consecutive integers
100
+ * starting at 1; each migration is applied once and recorded in schema_migrations.
101
+ */
102
+ schemaMigrations?: SchemaMigration[];
91
103
  /**
92
104
  * Automatically create tables and columns dynamically on the fly without manual SQL migrations.
93
105
  * 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';
@@ -16,6 +16,17 @@ import { SearchManager } from '../search/index.js';
16
16
  import { BlobStorageManager, type BlobStorageManagerOptions } from '../storage/index.js';
17
17
  import { JobScheduler, type JobSchedulerOptions } from '../scheduler/index.js';
18
18
  import { SessionManager, type SessionManagerOptions } from '../auth/session.js';
19
+ export type OnDeleteAction = 'cascade' | 'restrict' | 'setNull' | 'noAction';
20
+ export interface RelationshipDefinition {
21
+ /** Table containing the referencing rows. */
22
+ table: string;
23
+ /** Foreign-key field in the referencing table. */
24
+ foreignKey: string;
25
+ /** Referenced parent table; its configured primary key is used. */
26
+ references: string;
27
+ /** Behavior when the referenced parent row is deleted. Defaults to `noAction` (rejects when children exist). */
28
+ onDelete?: OnDeleteAction;
29
+ }
19
30
  export interface LocalFirstClientConfig {
20
31
  local: LocalDBAdapter;
21
32
  remote: RemoteDBAdapter;
@@ -30,7 +41,9 @@ export interface LocalFirstClientConfig {
30
41
  logLevel?: LogLevel;
31
42
  autoSchema?: boolean;
32
43
  tables?: string[];
44
+ relationships?: RelationshipDefinition[];
33
45
  migrations?: string[];
46
+ schemaMigrations?: SchemaMigration[];
34
47
  autoStart?: boolean;
35
48
  coalesceOutbox?: boolean;
36
49
  jsonColumns?: Record<string, string[]>;
@@ -63,6 +76,8 @@ export declare class TransactionalClient {
63
76
  event: TableChangeEvent;
64
77
  }>, queuedOutboxIds: string[]);
65
78
  mutate(tableName: string, rowId: string, operation: SyncOp, payload: Record<string, any> | null, options?: MutateOptions): Promise<void>;
79
+ private deleteRelated;
80
+ private applyMutation;
66
81
  insert(tableName: string, rowIdOrPayload: string | Record<string, any>, payloadOrOptions?: Record<string, any> | MutateOptions, options?: MutateOptions): Promise<string>;
67
82
  update(tableName: string, rowId: string, payload: Record<string, any>, options?: MutateOptions): Promise<void>;
68
83
  updateMany(tableName: string, updates: Array<{
@@ -94,6 +109,7 @@ export declare class LocalFirstClient {
94
109
  autoSchema: boolean;
95
110
  coalesceOutbox: boolean;
96
111
  tables: string[];
112
+ relationships: RelationshipDefinition[];
97
113
  jsonColumns: Record<string, string[]>;
98
114
  autoHydrateJSON: boolean;
99
115
  /** Local column stamped on INSERT/UPDATE writes, or null when disabled. */
@@ -108,6 +124,7 @@ export declare class LocalFirstClient {
108
124
  readonly session: SessionManager;
109
125
  private primaryKey;
110
126
  private migrations;
127
+ private schemaMigrations;
111
128
  private autoStart;
112
129
  private initialization?;
113
130
  constructor(config: LocalFirstClientConfig);
@@ -139,6 +156,7 @@ export declare class LocalFirstClient {
139
156
  */
140
157
  initialize(): Promise<void>;
141
158
  private initializeStorage;
159
+ private applySchemaMigrations;
142
160
  /**
143
161
  * Performs an atomic dual-write: mutates the local data table AND writes
144
162
  * 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;
@@ -82,6 +106,47 @@ class TransactionalClient {
82
106
  this.queuedOutboxIds = queuedOutboxIds;
83
107
  }
84
108
  async mutate(tableName, rowId, operation, payload, options = {}) {
109
+ if (operation === 'DELETE') {
110
+ await this.deleteRelated(tableName, rowId, new Set(), options);
111
+ return;
112
+ }
113
+ await this.applyMutation(tableName, rowId, operation, payload, options);
114
+ }
115
+ async deleteRelated(tableName, rowId, visited, options = {}) {
116
+ const key = `${tableName}:${rowId}`;
117
+ if (visited.has(key))
118
+ return;
119
+ visited.add(key);
120
+ const relationships = this.parent.relationships.filter((relationship) => relationship.references === tableName);
121
+ for (const relationship of relationships) {
122
+ const relatedRows = await this.findMany(relationship.table, {
123
+ where: { [relationship.foreignKey]: rowId },
124
+ });
125
+ if (relatedRows.length === 0)
126
+ continue;
127
+ const action = relationship.onDelete ?? 'noAction';
128
+ if (action === 'restrict' || action === 'noAction') {
129
+ throw new Error(`Cannot delete ${tableName} record while ${relationship.table} records still reference it.`);
130
+ }
131
+ if (action === 'setNull') {
132
+ for (const row of relatedRows) {
133
+ const childId = row[this.parent.getPrimaryKey()];
134
+ if (childId !== undefined && childId !== null) {
135
+ await this.update(relationship.table, String(childId), { [relationship.foreignKey]: null }, options);
136
+ }
137
+ }
138
+ continue;
139
+ }
140
+ for (const row of relatedRows) {
141
+ const childId = row[this.parent.getPrimaryKey()];
142
+ if (childId !== undefined && childId !== null) {
143
+ await this.deleteRelated(relationship.table, String(childId), visited, options);
144
+ }
145
+ }
146
+ }
147
+ await this.applyMutation(tableName, rowId, 'DELETE', null, options);
148
+ }
149
+ async applyMutation(tableName, rowId, operation, payload, options = {}) {
85
150
  const pk = options.primaryKey ?? this.parent.getPrimaryKey();
86
151
  const timestamp = options.customTimestamp ?? Date.now();
87
152
  await table_manager_js_1.TableManager.applyMutation(this.tx, tableName, rowId, operation, payload, pk, this.parent.localMutationOptions(timestamp));
@@ -168,6 +233,7 @@ class LocalFirstClient {
168
233
  autoSchema;
169
234
  coalesceOutbox;
170
235
  tables;
236
+ relationships;
171
237
  jsonColumns;
172
238
  autoHydrateJSON;
173
239
  /** Local column stamped on INSERT/UPDATE writes, or null when disabled. */
@@ -182,6 +248,7 @@ class LocalFirstClient {
182
248
  session;
183
249
  primaryKey;
184
250
  migrations;
251
+ schemaMigrations;
185
252
  autoStart;
186
253
  initialization;
187
254
  constructor(config) {
@@ -192,7 +259,33 @@ class LocalFirstClient {
192
259
  this.autoSchema = config.autoSchema ?? false;
193
260
  this.coalesceOutbox = config.coalesceOutbox ?? true;
194
261
  this.tables = config.tables ?? [];
262
+ this.relationships = config.relationships ?? [];
263
+ for (const relationship of this.relationships) {
264
+ if (!relationship.table || !relationship.foreignKey || !relationship.references) {
265
+ throw new TypeError('Each relationship requires table, foreignKey, and references.');
266
+ }
267
+ if (this.tables.length > 0) {
268
+ for (const tableName of [relationship.table, relationship.references]) {
269
+ if (!this.tables.includes(tableName)) {
270
+ throw new TypeError(`Relationship table "${tableName}" must be included in config.tables.`);
271
+ }
272
+ }
273
+ }
274
+ if (relationship.onDelete !== undefined &&
275
+ !['cascade', 'restrict', 'setNull', 'noAction'].includes(relationship.onDelete)) {
276
+ throw new TypeError(`Unsupported onDelete action: ${relationship.onDelete}`);
277
+ }
278
+ }
279
+ const relationshipKeys = new Set();
280
+ for (const relationship of this.relationships) {
281
+ const key = `${relationship.references}\0${relationship.table}\0${relationship.foreignKey}`;
282
+ if (relationshipKeys.has(key)) {
283
+ throw new TypeError(`Duplicate relationship for ${relationship.table}.${relationship.foreignKey} referencing ${relationship.references}.`);
284
+ }
285
+ relationshipKeys.add(key);
286
+ }
195
287
  this.migrations = config.migrations ?? [];
288
+ this.schemaMigrations = normalizeSchemaMigrations(config.schemaMigrations ?? []);
196
289
  this.autoStart = config.autoStart ?? true;
197
290
  this.jsonColumns = config.jsonColumns ?? {};
198
291
  this.autoHydrateJSON = config.autoHydrateJSON ?? false;
@@ -416,6 +509,7 @@ class LocalFirstClient {
416
509
  }
417
510
  });
418
511
  }
512
+ await this.applySchemaMigrations();
419
513
  if (this.autoSchema && this.tables.length > 0) {
420
514
  for (const table of this.tables) {
421
515
  await this.schemaManager.ensureTable(this.db, table, this.primaryKey);
@@ -424,11 +518,60 @@ class LocalFirstClient {
424
518
  if (this.autoStart)
425
519
  this.syncWorker.start();
426
520
  }
521
+ async applySchemaMigrations() {
522
+ if (this.schemaMigrations.length === 0)
523
+ return;
524
+ await this.db.transaction(async (tx) => {
525
+ await tx.execute(`CREATE TABLE IF NOT EXISTS schema_migrations (
526
+ version INTEGER PRIMARY KEY,
527
+ applied_at INTEGER NOT NULL
528
+ );`);
529
+ const rows = await tx.execute('SELECT version FROM schema_migrations ORDER BY version ASC');
530
+ const appliedVersions = rows.map((row) => Number(row.version));
531
+ for (let index = 0; index < appliedVersions.length; index += 1) {
532
+ if (appliedVersions[index] !== index + 1) {
533
+ throw new Error('The schema_migrations table contains a missing or invalid version.');
534
+ }
535
+ }
536
+ let currentVersion = appliedVersions.at(-1) ?? 0;
537
+ if (currentVersion > this.schemaMigrations.length) {
538
+ throw new Error(`Database schema version ${currentVersion} is newer than the latest available migration (${this.schemaMigrations.length}).`);
539
+ }
540
+ for (const migration of this.schemaMigrations) {
541
+ if (migration.version <= currentVersion)
542
+ continue;
543
+ for (const statement of migration.up) {
544
+ await tx.execute(statement);
545
+ }
546
+ await tx.execute('INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)', [migration.version, Date.now()]);
547
+ currentVersion = migration.version;
548
+ }
549
+ });
550
+ }
427
551
  /**
428
552
  * Performs an atomic dual-write: mutates the local data table AND writes
429
553
  * to sync_outbox within the same database transaction.
430
554
  */
431
555
  async mutate(tableName, rowId, operation, payload, options = {}) {
556
+ if (operation === 'DELETE') {
557
+ if (options.tx) {
558
+ const queuedEvents = [];
559
+ const queuedOutboxIds = [];
560
+ const txClient = new TransactionalClient(this, options.tx, queuedEvents, queuedOutboxIds);
561
+ await txClient.delete(tableName, rowId, options);
562
+ for (const item of queuedEvents) {
563
+ this.events.emit(item.tableName, item.event);
564
+ }
565
+ if (queuedOutboxIds.length > 0) {
566
+ this.events.emit('sync_outbox', { operation: 'INSERT', rowId: 'batch' });
567
+ this.syncWorker.schedulePush(20);
568
+ }
569
+ }
570
+ else {
571
+ await this.transaction((tx) => tx.delete(tableName, rowId, options));
572
+ }
573
+ return;
574
+ }
432
575
  const pk = options.primaryKey ?? this.primaryKey;
433
576
  const timestamp = options.customTimestamp ?? Date.now();
434
577
  let affectedOutboxId;
@@ -475,6 +618,7 @@ class LocalFirstClient {
475
618
  const pendingOutboxOps = [];
476
619
  let pendingOutboxMap;
477
620
  const executeInTx = async (tx) => {
621
+ const txClient = new TransactionalClient(this, tx, queuedEvents, queuedOutboxIds);
478
622
  if (this.coalesceOutbox && mutations.length > 1) {
479
623
  pendingOutboxMap = await outbox_store_js_1.OutboxStore.getActivePendingMap(tx);
480
624
  }
@@ -487,8 +631,16 @@ class LocalFirstClient {
487
631
  : (0, uuid_js_1.generateUUID)());
488
632
  generatedIds.push(rowId);
489
633
  const timestamp = m.customTimestamp ?? options.customTimestamp ?? Date.now();
490
- await table_manager_js_1.TableManager.applyMutation(tx, m.tableName, rowId, m.operation, payload, pk, this.localMutationOptions(timestamp));
491
634
  const skipOutbox = m.skipOutbox ?? options.skipOutbox ?? false;
635
+ if (m.operation === 'DELETE') {
636
+ await txClient.delete(m.tableName, rowId, {
637
+ primaryKey: pk,
638
+ customTimestamp: timestamp,
639
+ skipOutbox,
640
+ });
641
+ continue;
642
+ }
643
+ await table_manager_js_1.TableManager.applyMutation(tx, m.tableName, rowId, m.operation, payload, pk, this.localMutationOptions(timestamp));
492
644
  if (!skipOutbox) {
493
645
  if (this.coalesceOutbox && m.operation !== 'INSERT') {
494
646
  const outboxId = await this.recordOutboxMutation(tx, m.tableName, rowId, m.operation, payload, timestamp, pendingOutboxMap);
@@ -349,7 +349,7 @@ class QueryBuilder {
349
349
  sql += ` ORDER BY ${formatColumnIdentifier(this.orderColumn)} ${this.orderDirection}`;
350
350
  }
351
351
  // LIMIT & OFFSET
352
- if (typeof this.limitCount === 'number' && this.limitCount > 0) {
352
+ if (typeof this.limitCount === 'number' && this.limitCount >= 0) {
353
353
  sql += ` LIMIT ?`;
354
354
  params.push(this.limitCount);
355
355
  if (typeof this.offsetCount === 'number' && this.offsetCount > 0) {
@@ -357,6 +357,10 @@ class QueryBuilder {
357
357
  params.push(this.offsetCount);
358
358
  }
359
359
  }
360
+ else if (typeof this.offsetCount === 'number' && this.offsetCount > 0) {
361
+ sql += ` LIMIT -1 OFFSET ?`;
362
+ params.push(this.offsetCount);
363
+ }
360
364
  return { sql, params };
361
365
  }
362
366
  /**
@@ -398,9 +402,13 @@ class QueryBuilder {
398
402
  async first() {
399
403
  const originalLimit = this.limitCount;
400
404
  this.limitCount = 1;
401
- const rows = await this.execute();
402
- this.limitCount = originalLimit;
403
- return rows.length > 0 ? rows[0] : null;
405
+ try {
406
+ const rows = await this.execute();
407
+ return rows.length > 0 ? rows[0] : null;
408
+ }
409
+ finally {
410
+ this.limitCount = originalLimit;
411
+ }
404
412
  }
405
413
  /**
406
414
  * Executes a COUNT(*) in SQLite and returns the numeric count directly.
@@ -408,10 +416,14 @@ class QueryBuilder {
408
416
  async count() {
409
417
  const originalSelect = this.selectCols;
410
418
  this.selectCols = ['COUNT(*) AS _count'];
411
- const rows = await this.execute();
412
- this.selectCols = originalSelect;
413
- const countVal = rows[0]?._count;
414
- return typeof countVal === 'number' ? countVal : Number(countVal) || 0;
419
+ try {
420
+ const rows = await this.execute();
421
+ const countVal = rows[0]?._count;
422
+ return typeof countVal === 'number' ? countVal : Number(countVal) || 0;
423
+ }
424
+ finally {
425
+ this.selectCols = originalSelect;
426
+ }
415
427
  }
416
428
  /**
417
429
  * Executes a SUM(column) in SQLite and returns the numeric total directly.
@@ -420,10 +432,14 @@ class QueryBuilder {
420
432
  const originalSelect = this.selectCols;
421
433
  const safeCol = formatColumnIdentifier(column);
422
434
  this.selectCols = [`COALESCE(SUM(${safeCol}), 0) AS _sum`];
423
- const rows = await this.execute();
424
- this.selectCols = originalSelect;
425
- const sumVal = rows[0]?._sum;
426
- return typeof sumVal === 'number' ? sumVal : Number(sumVal) || 0;
435
+ try {
436
+ const rows = await this.execute();
437
+ const sumVal = rows[0]?._sum;
438
+ return typeof sumVal === 'number' ? sumVal : Number(sumVal) || 0;
439
+ }
440
+ finally {
441
+ this.selectCols = originalSelect;
442
+ }
427
443
  }
428
444
  /**
429
445
  * Executes an AVG(column) in SQLite and returns the average directly.
@@ -432,10 +448,14 @@ class QueryBuilder {
432
448
  const originalSelect = this.selectCols;
433
449
  const safeCol = formatColumnIdentifier(column);
434
450
  this.selectCols = [`AVG(${safeCol}) AS _avg`];
435
- const rows = await this.execute();
436
- this.selectCols = originalSelect;
437
- const avgVal = rows[0]?._avg;
438
- return avgVal === null || avgVal === undefined ? null : Number(avgVal) || 0;
451
+ try {
452
+ const rows = await this.execute();
453
+ const avgVal = rows[0]?._avg;
454
+ return avgVal === null || avgVal === undefined ? null : Number(avgVal) || 0;
455
+ }
456
+ finally {
457
+ this.selectCols = originalSelect;
458
+ }
439
459
  }
440
460
  }
441
461
  exports.QueryBuilder = QueryBuilder;
@@ -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;
@@ -3,7 +3,7 @@ import { LocalFirstClient } from '../core/client.js';
3
3
  import { type SyncThemeColors } from '../react/theme.js';
4
4
  export interface BuiltinBenchmarkResult {
5
5
  name: string;
6
- category: 'Local-Lite Client' | 'ACID / Transactions' | 'Outbox / Sync' | 'Memory / JSON';
6
+ category: 'Local-Lite Client' | 'ACID / Transactions' | 'Outbox / Sync' | 'Memory / JSON' | 'App Data';
7
7
  description: string;
8
8
  operations: number;
9
9
  durationMs: number;
@@ -38,6 +38,7 @@ exports.BenchmarkModal = BenchmarkModal;
38
38
  const jsx_runtime_1 = require("react/jsx-runtime");
39
39
  const react_1 = __importStar(require("react"));
40
40
  const theme_js_1 = require("../react/theme.js");
41
+ const sql_js_1 = require("../utils/sql.js");
41
42
  let RN;
42
43
  try {
43
44
  RN = require('react-native');
@@ -52,6 +53,26 @@ try {
52
53
  catch {
53
54
  // Web / DOM environment or not installed
54
55
  }
56
+ const now = () => typeof performance !== 'undefined' && typeof performance.now === 'function'
57
+ ? performance.now()
58
+ : Date.now();
59
+ const elapsed = (start) => Math.max(0.01, Math.round((now() - start) * 100) / 100);
60
+ const median = (values) => {
61
+ const sorted = [...values].sort((a, b) => a - b);
62
+ const mid = Math.floor(sorted.length / 2);
63
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
64
+ };
65
+ /** Runs one untimed warm-up pass, then returns the median duration (ms) of `runs` timed passes. */
66
+ async function timeMedian(action, runs = 5) {
67
+ await action();
68
+ const samples = [];
69
+ for (let i = 0; i < runs; i++) {
70
+ const start = now();
71
+ await action();
72
+ samples.push(elapsed(start));
73
+ }
74
+ return median(samples);
75
+ }
55
76
  /**
56
77
  * Executes a comprehensive performance & ACID test suite
57
78
  * directly testing local-lite client APIs:
@@ -65,6 +86,34 @@ async function runSQLiteBenchmark(clientOrDb, onProgress) {
65
86
  const db = client?.db || clientOrDb;
66
87
  try {
67
88
  onProgress?.('Preparing benchmark environment...', 0.05);
89
+ let appRowCount = 0;
90
+ let appTableCount = 0;
91
+ let appTables = [];
92
+ const appDataSamples = [];
93
+ if (client?.maintenance) {
94
+ const stats = await client.maintenance.getStorageStats();
95
+ appTables = Object.entries(stats.tables).filter(([, count]) => count > 0);
96
+ appRowCount = appTables.reduce((total, [, count]) => total + count, 0);
97
+ appTableCount = appTables.length;
98
+ for (const [tableName] of appTables) {
99
+ const rows = await db.execute(`SELECT * FROM ${(0, sql_js_1.escapeIdentifier)(tableName)} LIMIT 1`);
100
+ if (rows[0]) {
101
+ const sample = JSON.stringify(rows[0], (_key, value) => typeof value === 'bigint' ? value.toString() : value);
102
+ if (sample)
103
+ appDataSamples.push(sample.slice(0, 4096));
104
+ }
105
+ }
106
+ }
107
+ const workloadScale = Math.min(10, Math.max(1, Math.ceil(appRowCount / 1000)));
108
+ const scaleWorkload = (count) => count * workloadScale;
109
+ const makeMetadata = (index, tag) => JSON.stringify({
110
+ index,
111
+ tag,
112
+ timestamp: Date.now(),
113
+ ...(appDataSamples.length > 0
114
+ ? { appRecord: appDataSamples[index % appDataSamples.length] }
115
+ : {}),
116
+ });
68
117
  await db.execute('DROP TABLE IF EXISTS "_sync_bench_tags"').catch(() => { });
69
118
  await db.execute('DROP TABLE IF EXISTS "_sync_bench_temp"').catch(() => { });
70
119
  await db.execute('DROP TABLE IF EXISTS "_sync_bench_outbox"').catch(() => { });
@@ -91,9 +140,9 @@ async function runSQLiteBenchmark(clientOrDb, onProgress) {
91
140
  )
92
141
  `);
93
142
  await db.execute('CREATE INDEX IF NOT EXISTS "idx_sync_bench_outbox" ON "_sync_bench_outbox" (status, timestamp)');
94
- // 1. client.batch() Ingestion (500 rows in single atomic transaction)
95
- onProgress?.('Testing client.batch() atomic multi-mutation (500 rows)...', 0.12);
96
- const batchCount = 500;
143
+ // 1. client.batch() Ingestion
144
+ const batchCount = scaleWorkload(500);
145
+ onProgress?.(`Testing client.batch() atomic multi-mutation (${batchCount} rows)...`, 0.12);
97
146
  const batchMutations = Array.from({ length: batchCount }, (_, i) => ({
98
147
  tableName: '_sync_bench_temp',
99
148
  operation: 'INSERT',
@@ -102,11 +151,11 @@ async function runSQLiteBenchmark(clientOrDb, onProgress) {
102
151
  id: `b-batch-${i}`,
103
152
  title: `Item ${i}`,
104
153
  score: Math.floor(Math.random() * 100),
105
- metadata: JSON.stringify({ index: i, tag: 'batch', timestamp: Date.now() }),
154
+ metadata: makeMetadata(i, 'batch'),
106
155
  created_at: new Date().toISOString(),
107
156
  },
108
157
  }));
109
- const t1Start = Date.now();
158
+ const t1Start = now();
110
159
  if (client?.batch) {
111
160
  await client.batch(batchMutations, { skipOutbox: true });
112
161
  }
@@ -117,25 +166,26 @@ async function runSQLiteBenchmark(clientOrDb, onProgress) {
117
166
  }
118
167
  });
119
168
  }
120
- const t1Duration = Math.max(1, Date.now() - t1Start);
169
+ const t1Duration = elapsed(t1Start);
121
170
  list.push({
122
171
  name: 'client.batch() Ingestion',
123
172
  category: 'Local-Lite Client',
124
- description: '500 rows inserted via local-lite atomic batch() mutation pipeline',
173
+ description: `${batchCount.toLocaleString()} rows inserted via local-lite atomic batch() mutation pipeline`,
125
174
  operations: batchCount,
126
175
  durationMs: t1Duration,
127
176
  opsPerSec: Math.round((batchCount / t1Duration) * 1000),
128
177
  status: 'success',
178
+ note: `Calibrated to ${appRowCount.toLocaleString()} app rows across ${appTableCount} tables (${workloadScale}x workload).`,
129
179
  });
130
- // 2. client.insert() Individual Mutations (40 rows)
131
- onProgress?.('Testing client.insert() individual mutations (40 rows)...', 0.24);
132
- const indivCount = 40;
133
- const t2Start = Date.now();
180
+ // 2. client.insert() Individual Mutations
181
+ const indivCount = scaleWorkload(40);
182
+ onProgress?.(`Testing client.insert() individual mutations (${indivCount} rows)...`, 0.24);
183
+ const t2Start = now();
134
184
  for (let i = 0; i < indivCount; i++) {
135
185
  const payload = {
136
186
  title: `Individual Item ${i}`,
137
187
  score: Math.floor(Math.random() * 100),
138
- metadata: JSON.stringify({ index: i, tag: 'indiv' }),
188
+ metadata: makeMetadata(i, 'indiv'),
139
189
  created_at: new Date().toISOString(),
140
190
  };
141
191
  if (client?.insert) {
@@ -145,25 +195,25 @@ async function runSQLiteBenchmark(clientOrDb, onProgress) {
145
195
  await db.execute('INSERT INTO "_sync_bench_temp" (id, title, score, metadata, created_at) VALUES (?, ?, ?, ?, ?)', [`b-indiv-${i}`, payload.title, payload.score, payload.metadata, payload.created_at]);
146
196
  }
147
197
  }
148
- const t2Duration = Math.max(1, Date.now() - t2Start);
198
+ const t2Duration = elapsed(t2Start);
149
199
  list.push({
150
200
  name: 'client.insert() Mutation',
151
201
  category: 'Local-Lite Client',
152
- description: '40 standalone client.insert() calls testing dual-write mutation pipeline',
202
+ description: `${indivCount.toLocaleString()} standalone client.insert() calls testing the dual-write mutation pipeline`,
153
203
  operations: indivCount,
154
204
  durationMs: t2Duration,
155
205
  opsPerSec: Math.round((indivCount / t2Duration) * 1000),
156
206
  status: 'success',
157
207
  });
158
- // 3. client.update() Reactive Updates (150 rows)
159
- onProgress?.('Testing client.update() reactive updates (150 rows)...', 0.36);
160
- const updateCount = 150;
161
- const t3Start = Date.now();
208
+ // 3. client.update() Reactive Updates
209
+ const updateCount = scaleWorkload(150);
210
+ onProgress?.(`Testing client.update() reactive updates (${updateCount} rows)...`, 0.36);
211
+ const t3Start = now();
162
212
  for (let i = 0; i < updateCount; i++) {
163
213
  const patch = {
164
214
  title: `Updated Item ${i}`,
165
215
  score: 999 + i,
166
- metadata: JSON.stringify({ updated: true, rev: i + 1 }),
216
+ metadata: makeMetadata(i, 'update'),
167
217
  };
168
218
  if (client?.update) {
169
219
  await client.update('_sync_bench_temp', `b-batch-${i}`, patch, { skipOutbox: true });
@@ -172,20 +222,20 @@ async function runSQLiteBenchmark(clientOrDb, onProgress) {
172
222
  await db.execute('UPDATE "_sync_bench_temp" SET title = ?, score = ?, metadata = ? WHERE id = ?', [patch.title, patch.score, patch.metadata, `b-batch-${i}`]);
173
223
  }
174
224
  }
175
- const t3Duration = Math.max(1, Date.now() - t3Start);
225
+ const t3Duration = elapsed(t3Start);
176
226
  list.push({
177
227
  name: 'client.update() Pipeline',
178
228
  category: 'Local-Lite Client',
179
- description: '150 client.update() calls testing schema verification & event emission',
229
+ description: `${updateCount.toLocaleString()} client.update() calls testing schema verification & event emission`,
180
230
  operations: updateCount,
181
231
  durationMs: t3Duration,
182
232
  opsPerSec: Math.round((updateCount / t3Duration) * 1000),
183
233
  status: 'success',
184
234
  });
185
- // 4. client.findById() Indexed Lookups (400 queries)
186
- onProgress?.('Testing client.findById() indexed point lookups (400 queries)...', 0.48);
187
- const findCount = 400;
188
- const t4Start = Date.now();
235
+ // 4. client.findById() Indexed Lookups
236
+ const findCount = scaleWorkload(400);
237
+ onProgress?.(`Testing client.findById() indexed point lookups (${findCount} queries)...`, 0.48);
238
+ const t4Start = now();
189
239
  for (let i = 0; i < findCount; i++) {
190
240
  const id = `b-batch-${i % batchCount}`;
191
241
  if (client?.findById) {
@@ -195,21 +245,21 @@ async function runSQLiteBenchmark(clientOrDb, onProgress) {
195
245
  await db.execute('SELECT * FROM "_sync_bench_temp" WHERE id = ?', [id]);
196
246
  }
197
247
  }
198
- const t4Duration = Math.max(1, Date.now() - t4Start);
248
+ const t4Duration = elapsed(t4Start);
199
249
  list.push({
200
250
  name: 'client.findById() Lookups',
201
251
  category: 'Local-Lite Client',
202
- description: '400 point lookups testing PK index resolution & auto JSON hydration',
252
+ description: `${findCount.toLocaleString()} point lookups testing PK index resolution & auto JSON hydration`,
203
253
  operations: findCount,
204
254
  durationMs: t4Duration,
205
255
  opsPerSec: Math.round((findCount / t4Duration) * 1000),
206
256
  status: 'success',
207
257
  });
208
- // 5. client.findByIds() Batched PK Lookups (400 entities loaded in 20 queries)
209
- onProgress?.('Testing client.findByIds() batched lookups (400 entities)...', 0.55);
210
- const batchedQueryCount = 20;
258
+ // 5. client.findByIds() Batched PK Lookups
259
+ const batchedQueryCount = scaleWorkload(20);
211
260
  const batchLookupSize = 20;
212
- const tBatchFindStart = Date.now();
261
+ onProgress?.(`Testing client.findByIds() batched lookups (${batchedQueryCount * batchLookupSize} entities)...`, 0.55);
262
+ const tBatchFindStart = now();
213
263
  for (let i = 0; i < batchedQueryCount; i++) {
214
264
  const ids = Array.from({ length: batchLookupSize }, (_, idx) => `b-batch-${(i * batchLookupSize + idx) % batchCount}`);
215
265
  if (client?.findByIds) {
@@ -220,7 +270,7 @@ async function runSQLiteBenchmark(clientOrDb, onProgress) {
220
270
  await db.execute(`SELECT * FROM "_sync_bench_temp" WHERE id IN (${placeholders})`, ids);
221
271
  }
222
272
  }
223
- const tBatchFindDuration = Math.max(1, Date.now() - tBatchFindStart);
273
+ const tBatchFindDuration = elapsed(tBatchFindStart);
224
274
  const totalBatchedFinds = batchedQueryCount * batchLookupSize;
225
275
  list.push({
226
276
  name: 'client.findByIds() Batched',
@@ -231,10 +281,10 @@ async function runSQLiteBenchmark(clientOrDb, onProgress) {
231
281
  opsPerSec: Math.round((totalBatchedFinds / tBatchFindDuration) * 1000),
232
282
  status: 'success',
233
283
  });
234
- // 6. client.findMany() with SQL Pushdown Filters (40 queries)
235
- onProgress?.('Testing client.findMany() query filter compilation (40 queries)...', 0.6);
236
- const findManyCount = 40;
237
- const t5Start = Date.now();
284
+ // 6. client.findMany() with SQL Pushdown Filters
285
+ const findManyCount = scaleWorkload(40);
286
+ onProgress?.(`Testing client.findMany() query filter compilation (${findManyCount} queries)...`, 0.6);
287
+ const t5Start = now();
238
288
  for (let i = 0; i < findManyCount; i++) {
239
289
  if (client?.findMany) {
240
290
  await client.findMany('_sync_bench_temp', {
@@ -248,21 +298,21 @@ async function runSQLiteBenchmark(clientOrDb, onProgress) {
248
298
  await db.execute('SELECT * FROM "_sync_bench_temp" WHERE score > 50 ORDER BY created_at DESC LIMIT 20');
249
299
  }
250
300
  }
251
- const t5Duration = Math.max(1, Date.now() - t5Start);
301
+ const t5Duration = elapsed(t5Start);
252
302
  list.push({
253
303
  name: 'client.findMany() Filters',
254
304
  category: 'Local-Lite Client',
255
- description: '40 queries through local-lite SQL pushdown ($gt, ORDER BY, LIMIT)',
305
+ description: `${findManyCount.toLocaleString()} queries through local-lite SQL pushdown ($gt, ORDER BY, LIMIT)`,
256
306
  operations: findManyCount,
257
307
  durationMs: t5Duration,
258
308
  opsPerSec: Math.round((findManyCount / t5Duration) * 1000),
259
309
  status: 'success',
260
310
  });
261
311
  // 6. client.findMany() with $contains (Fuzzy Substring Search)
262
- onProgress?.('Testing client.findMany() $contains fuzzy search (50 queries)...', 0.7);
263
- const searchCount = 50;
312
+ const searchCount = scaleWorkload(50);
313
+ onProgress?.(`Testing client.findMany() $contains fuzzy search (${searchCount} queries)...`, 0.7);
264
314
  const searchTokens = ['Item 1', 'Updated', 'rev', 'Large', 'nonexistent'];
265
- const t6Start = Date.now();
315
+ const t6Start = now();
266
316
  for (let i = 0; i < searchCount; i++) {
267
317
  const token = searchTokens[i % searchTokens.length];
268
318
  if (client?.findMany) {
@@ -275,11 +325,11 @@ async function runSQLiteBenchmark(clientOrDb, onProgress) {
275
325
  await db.execute('SELECT * FROM "_sync_bench_temp" WHERE title LIKE ? LIMIT 25', [`%${token}%`]);
276
326
  }
277
327
  }
278
- const t6Duration = Math.max(1, Date.now() - t6Start);
328
+ const t6Duration = elapsed(t6Start);
279
329
  list.push({
280
330
  name: 'client.findMany() $contains Search',
281
331
  category: 'Local-Lite Client',
282
- description: '50 fuzzy text queries compiling $contains into SQL LIKE pushdown',
332
+ description: `${searchCount.toLocaleString()} fuzzy text queries compiling $contains into SQL LIKE pushdown`,
283
333
  operations: searchCount,
284
334
  durationMs: t6Duration,
285
335
  opsPerSec: Math.round((searchCount / t6Duration) * 1000),
@@ -287,8 +337,9 @@ async function runSQLiteBenchmark(clientOrDb, onProgress) {
287
337
  });
288
338
  // 7. client.transaction() ACID Rollback Verification
289
339
  onProgress?.('Testing client.transaction() ACID rollback isolation...', 0.78);
290
- const rollbackStart = Date.now();
291
- for (let c = 0; c < 10; c++) {
340
+ const rollbackStart = now();
341
+ const rollbackCount = scaleWorkload(10);
342
+ for (let c = 0; c < rollbackCount; c++) {
292
343
  try {
293
344
  if (client?.transaction) {
294
345
  await client.transaction(async (txClient) => {
@@ -315,21 +366,21 @@ async function runSQLiteBenchmark(clientOrDb, onProgress) {
315
366
  })
316
367
  : [];
317
368
  const leaked = Array.isArray(leakedCheck) ? leakedCheck.length : 0;
318
- const rbDuration = Math.max(1, Date.now() - rollbackStart);
369
+ const rbDuration = elapsed(rollbackStart);
319
370
  list.push({
320
371
  name: 'client.transaction() Rollback',
321
372
  category: 'ACID / Transactions',
322
- description: `10 aborted transactions via client.transaction() (${leaked} leaked rows, zero pollution)`,
323
- operations: 10,
373
+ description: `${rollbackCount} aborted transactions via client.transaction() (${leaked} leaked rows, zero pollution)`,
374
+ operations: rollbackCount,
324
375
  durationMs: rbDuration,
325
- opsPerSec: Math.round((10 / rbDuration) * 1000),
376
+ opsPerSec: Math.round((rollbackCount / rbDuration) * 1000),
326
377
  status: 'success',
327
378
  note: `${leaked} leaked rows`,
328
379
  });
329
- // 8. Outbox Pipeline Ingestion (200 mutations)
330
- onProgress?.('Testing outbox mutation pipeline (200 mutations)...', 0.85);
331
- const outboxCount = 200;
332
- const tOutboxStart = Date.now();
380
+ // 8. Outbox Pipeline Ingestion
381
+ const outboxCount = scaleWorkload(200);
382
+ onProgress?.(`Testing outbox mutation pipeline (${outboxCount} mutations)...`, 0.85);
383
+ const tOutboxStart = now();
333
384
  await db.transaction(async (tx) => {
334
385
  for (let i = 0; i < outboxCount; i++) {
335
386
  await tx.execute('INSERT INTO "_sync_bench_outbox" (id, tableName, rowId, operation, payload, timestamp, status, retryCount) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [
@@ -344,20 +395,20 @@ async function runSQLiteBenchmark(clientOrDb, onProgress) {
344
395
  ]);
345
396
  }
346
397
  });
347
- const tOutboxDuration = Math.max(1, Date.now() - tOutboxStart);
398
+ const tOutboxDuration = elapsed(tOutboxStart);
348
399
  list.push({
349
400
  name: 'Outbox Pipeline Ingestion',
350
401
  category: 'Outbox / Sync',
351
- description: '200 mutations staged through outbox queue with compound index',
402
+ description: `${outboxCount.toLocaleString()} mutations staged through outbox queue with compound index`,
352
403
  operations: outboxCount,
353
404
  durationMs: tOutboxDuration,
354
405
  opsPerSec: Math.round((outboxCount / tOutboxDuration) * 1000),
355
406
  status: 'success',
356
407
  });
357
408
  // 9. client.delete() Pipeline
358
- onProgress?.('Testing client.delete() removal (50 rows)...', 0.9);
359
- const deleteCount = 50;
360
- const tDelStart = Date.now();
409
+ const deleteCount = scaleWorkload(50);
410
+ onProgress?.(`Testing client.delete() removal (${deleteCount} rows)...`, 0.9);
411
+ const tDelStart = now();
361
412
  for (let i = 0; i < deleteCount; i++) {
362
413
  if (client?.delete) {
363
414
  await client.delete('_sync_bench_temp', `b-batch-${i}`, { skipOutbox: true });
@@ -366,11 +417,11 @@ async function runSQLiteBenchmark(clientOrDb, onProgress) {
366
417
  await db.execute('DELETE FROM "_sync_bench_temp" WHERE id = ?', [`b-batch-${i}`]);
367
418
  }
368
419
  }
369
- const tDelDuration = Math.max(1, Date.now() - tDelStart);
420
+ const tDelDuration = elapsed(tDelStart);
370
421
  list.push({
371
422
  name: 'client.delete() Pipeline',
372
423
  category: 'Local-Lite Client',
373
- description: '50 deletions through local-lite mutate() deletion pipeline',
424
+ description: `${deleteCount.toLocaleString()} deletions through local-lite mutate() deletion pipeline`,
374
425
  operations: deleteCount,
375
426
  durationMs: tDelDuration,
376
427
  opsPerSec: Math.round((deleteCount / tDelDuration) * 1000),
@@ -378,9 +429,9 @@ async function runSQLiteBenchmark(clientOrDb, onProgress) {
378
429
  });
379
430
  // 10. WAL Read/Write Concurrency Under Contention
380
431
  onProgress?.('Testing WAL non-blocking concurrent reads & writes...', 0.95);
381
- const concReadCount = 100;
382
- const concWriteCount = 60;
383
- const tConcStart = Date.now();
432
+ const concReadCount = scaleWorkload(100);
433
+ const concWriteCount = scaleWorkload(60);
434
+ const tConcStart = now();
384
435
  const writePromise = db.transaction(async (tx) => {
385
436
  for (let i = 0; i < concWriteCount; i++) {
386
437
  await tx.execute('INSERT INTO "_sync_bench_temp" (id, title, score, metadata, created_at) VALUES (?, ?, ?, ?, ?)', [`wal-w-${i}`, `WAL Item ${i}`, i, '{"wal":true}', new Date().toISOString()]);
@@ -388,20 +439,20 @@ async function runSQLiteBenchmark(clientOrDb, onProgress) {
388
439
  });
389
440
  const readPromises = Array.from({ length: concReadCount }, (_, idx) => db.execute('SELECT COUNT(*) as c FROM "_sync_bench_temp" WHERE score >= ?', [idx % 50]));
390
441
  await Promise.all([writePromise, ...readPromises]);
391
- const tConcDuration = Math.max(1, Date.now() - tConcStart);
442
+ const tConcDuration = elapsed(tConcStart);
392
443
  const totalConcOps = concWriteCount + concReadCount;
393
444
  list.push({
394
445
  name: 'WAL Concurrency Under Contention',
395
446
  category: 'ACID / Transactions',
396
- description: '60 atomic writes executed simultaneously with 100 concurrent reads in non-blocking WAL mode',
447
+ description: `${concWriteCount.toLocaleString()} atomic writes executed simultaneously with ${concReadCount.toLocaleString()} concurrent reads in non-blocking WAL mode`,
397
448
  operations: totalConcOps,
398
449
  durationMs: tConcDuration,
399
450
  opsPerSec: Math.round((totalConcOps / tConcDuration) * 1000),
400
451
  status: 'success',
401
452
  });
402
453
  // 11. Resilient JSON Fuzz Parsing
403
- onProgress?.('Testing resilient JSON fuzz hydration (1,000 cycles)...', 0.99);
404
- const fuzzCount = 1000;
454
+ const fuzzCount = scaleWorkload(1000);
455
+ onProgress?.(`Testing resilient JSON fuzz hydration (${fuzzCount.toLocaleString()} cycles)...`, 0.99);
405
456
  const fuzzPayloads = [
406
457
  JSON.stringify({ nested: { title: 'Document 🏢', val: 42, tags: ['sync', 'offline'] } }),
407
458
  '{"corrupted": [1, 2, 3, {"unclosed": true',
@@ -410,7 +461,7 @@ async function runSQLiteBenchmark(clientOrDb, onProgress) {
410
461
  '',
411
462
  'null',
412
463
  ];
413
- const t8Start = Date.now();
464
+ const t8Start = now();
414
465
  for (let i = 0; i < fuzzCount; i++) {
415
466
  const raw = fuzzPayloads[i % fuzzPayloads.length];
416
467
  try {
@@ -419,16 +470,58 @@ async function runSQLiteBenchmark(clientOrDb, onProgress) {
419
470
  }
420
471
  catch { }
421
472
  }
422
- const t8Duration = Math.max(1, Date.now() - t8Start);
473
+ const t8Duration = elapsed(t8Start);
423
474
  list.push({
424
475
  name: 'Resilient JSON Fuzz Parsing',
425
476
  category: 'Memory / JSON',
426
- description: '1,000 cycles parsing deep unicode trees, corrupted shapes & fallback structures',
477
+ description: `${fuzzCount.toLocaleString()} cycles parsing deep unicode trees, corrupted shapes & fallback structures`,
427
478
  operations: fuzzCount,
428
479
  durationMs: t8Duration,
429
480
  opsPerSec: Math.round((fuzzCount / t8Duration) * 1000),
430
481
  status: 'success',
431
482
  });
483
+ // 12. Read-only benchmarks against the app's real tables (never writes app data)
484
+ const realTables = [...appTables].sort((a, b) => b[1] - a[1]).slice(0, 3);
485
+ for (const [tableName, rowCount] of realTables) {
486
+ onProgress?.(`Reading real app table "${tableName}"...`, 0.995);
487
+ try {
488
+ const safeTable = (0, sql_js_1.escapeIdentifier)(tableName);
489
+ const pk = client?.getPrimaryKey?.() ?? 'id';
490
+ const safePk = (0, sql_js_1.escapeIdentifier)(pk);
491
+ const pkRows = await db.execute(`SELECT ${safePk} AS pk FROM ${safeTable} LIMIT 200`);
492
+ const ids = pkRows.map((r) => String(r.pk));
493
+ const scanMs = await timeMedian(async () => {
494
+ await db.execute(`SELECT * FROM ${safeTable} LIMIT 500`);
495
+ });
496
+ list.push({
497
+ name: `Real table scan: ${tableName}`,
498
+ category: 'App Data',
499
+ description: `Median of 5 runs reading up to 500 of ${rowCount.toLocaleString()} real rows (SELECT *)`,
500
+ operations: Math.min(500, rowCount),
501
+ durationMs: scanMs,
502
+ opsPerSec: Math.round((Math.min(500, rowCount) / scanMs) * 1000),
503
+ status: 'success',
504
+ });
505
+ if (ids.length > 0 && client?.findById) {
506
+ const lookupMs = await timeMedian(async () => {
507
+ for (const id of ids)
508
+ await client.findById(tableName, id);
509
+ });
510
+ list.push({
511
+ name: `Real point lookups: ${tableName}`,
512
+ category: 'App Data',
513
+ description: `Median of 5 runs of ${ids.length} client.findById() calls on real rows`,
514
+ operations: ids.length,
515
+ durationMs: lookupMs,
516
+ opsPerSec: Math.round((ids.length / lookupMs) * 1000),
517
+ status: 'success',
518
+ });
519
+ }
520
+ }
521
+ catch {
522
+ // Table without the expected primary key column; skip it
523
+ }
524
+ }
432
525
  onProgress?.('Benchmark complete!', 1);
433
526
  }
434
527
  finally {
@@ -478,6 +571,7 @@ function BenchmarkModal({ client, visible, onDismiss, theme = 'auto' }) {
478
571
  '# ⚡ local-lite SQLite Benchmark Report',
479
572
  `- Platform: ${RN && RN.Platform ? RN.Platform.OS : 'web'}`,
480
573
  `- Date: ${new Date().toLocaleString()}`,
574
+ ...(results[0]?.note ? [`- App dataset: ${results[0].note}`] : []),
481
575
  '',
482
576
  '| Benchmark | Category | Operations | Duration | Throughput |',
483
577
  '| :--- | :--- | :--- | :--- | :--- |',
@@ -522,7 +616,7 @@ function BenchmarkModal({ client, visible, onDismiss, theme = 'auto' }) {
522
616
  : r.opsPerSec > 500
523
617
  ? { label: 'Fast ⚡', color: '#8B5CF6', bg: currentTheme.isDark ? '#4C1D95' : '#EDE9FE' }
524
618
  : { label: 'Normal 👍', color: currentTheme.textMuted, bg: currentTheme.border };
525
- return ((0, jsx_runtime_1.jsxs)(View, { style: [styles.card, { backgroundColor: currentTheme.card, borderColor: currentTheme.border }], children: [(0, jsx_runtime_1.jsxs)(View, { style: styles.cardHeader, children: [(0, jsx_runtime_1.jsx)(Text, { style: [styles.cardTitle, { color: currentTheme.text }], children: r.name }), (0, jsx_runtime_1.jsx)(View, { style: [styles.badge, { backgroundColor: rating.bg }], children: (0, jsx_runtime_1.jsx)(Text, { style: [styles.badgeText, { color: rating.color }], children: rating.label }) })] }), (0, jsx_runtime_1.jsx)(Text, { style: [styles.cardDesc, { color: currentTheme.textMuted }], children: r.description }), (0, jsx_runtime_1.jsxs)(View, { style: [styles.cardMetrics, { borderTopColor: currentTheme.border }], children: [(0, jsx_runtime_1.jsxs)(View, { children: [(0, jsx_runtime_1.jsx)(Text, { style: [styles.metricLabel, { color: currentTheme.textMuted }], children: "Duration" }), (0, jsx_runtime_1.jsxs)(Text, { style: [styles.metricVal, { color: currentTheme.text }], children: [r.durationMs, " ms"] })] }), (0, jsx_runtime_1.jsxs)(View, { style: { alignItems: 'flex-end' }, children: [(0, jsx_runtime_1.jsx)(Text, { style: [styles.metricLabel, { color: currentTheme.textMuted }], children: "Throughput" }), (0, jsx_runtime_1.jsxs)(Text, { style: [styles.metricVal, { color: '#8B5CF6' }], children: [r.opsPerSec.toLocaleString(), " ops/s"] })] })] })] }, idx));
619
+ return ((0, jsx_runtime_1.jsxs)(View, { style: [styles.card, { backgroundColor: currentTheme.card, borderColor: currentTheme.border }], children: [(0, jsx_runtime_1.jsxs)(View, { style: styles.cardHeader, children: [(0, jsx_runtime_1.jsx)(Text, { style: [styles.cardTitle, { color: currentTheme.text }], children: r.name }), (0, jsx_runtime_1.jsx)(View, { style: [styles.badge, { backgroundColor: rating.bg }], children: (0, jsx_runtime_1.jsx)(Text, { style: [styles.badgeText, { color: rating.color }], children: rating.label }) })] }), (0, jsx_runtime_1.jsx)(Text, { style: [styles.cardDesc, { color: currentTheme.textMuted }], children: r.description }), r.note && ((0, jsx_runtime_1.jsx)(Text, { style: [styles.cardDesc, { color: currentTheme.textMuted }], children: r.note })), (0, jsx_runtime_1.jsxs)(View, { style: [styles.cardMetrics, { borderTopColor: currentTheme.border }], children: [(0, jsx_runtime_1.jsxs)(View, { children: [(0, jsx_runtime_1.jsx)(Text, { style: [styles.metricLabel, { color: currentTheme.textMuted }], children: "Duration" }), (0, jsx_runtime_1.jsxs)(Text, { style: [styles.metricVal, { color: currentTheme.text }], children: [r.durationMs, " ms"] })] }), (0, jsx_runtime_1.jsxs)(View, { style: { alignItems: 'flex-end' }, children: [(0, jsx_runtime_1.jsx)(Text, { style: [styles.metricLabel, { color: currentTheme.textMuted }], children: "Throughput" }), (0, jsx_runtime_1.jsxs)(Text, { style: [styles.metricVal, { color: '#8B5CF6' }], children: [r.opsPerSec.toLocaleString(), " ops/s"] })] })] })] }, idx));
526
620
  })] }), (0, jsx_runtime_1.jsxs)(View, { style: [
527
621
  styles.bottomBar,
528
622
  { backgroundColor: currentTheme.surface, borderTopColor: currentTheme.border },
@@ -576,7 +670,7 @@ function BenchmarkModal({ client, visible, onDismiss, theme = 'auto' }) {
576
670
  border: `1px solid ${currentTheme.border}`,
577
671
  borderRadius: 12,
578
672
  padding: 16,
579
- }, children: [(0, jsx_runtime_1.jsxs)("div", { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }, children: [(0, jsx_runtime_1.jsx)("span", { style: { fontWeight: 700, fontSize: 14, color: currentTheme.text }, children: r.name }), (0, jsx_runtime_1.jsxs)("span", { style: { fontSize: 11, fontWeight: 700, color: '#8B5CF6', backgroundColor: currentTheme.isDark ? '#8B5CF625' : '#EDE9FE', padding: '2px 8px', borderRadius: 10 }, children: [r.opsPerSec.toLocaleString(), " ops/s"] })] }), (0, jsx_runtime_1.jsx)("div", { style: { fontSize: 12, color: currentTheme.textMuted, marginBottom: 10 }, children: r.description }), (0, jsx_runtime_1.jsxs)("div", { style: { fontSize: 11, color: currentTheme.textMuted }, children: [r.operations.toLocaleString(), " ops in ", r.durationMs, "ms"] })] }, idx)))] }), (0, jsx_runtime_1.jsxs)("div", { style: { padding: '16px 24px', borderTop: `1px solid ${currentTheme.border}`, backgroundColor: currentTheme.surface, display: 'flex', gap: 12 }, children: [(0, jsx_runtime_1.jsx)("button", { onClick: startBenchmark, disabled: isRunning, style: {
673
+ }, children: [(0, jsx_runtime_1.jsxs)("div", { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }, children: [(0, jsx_runtime_1.jsx)("span", { style: { fontWeight: 700, fontSize: 14, color: currentTheme.text }, children: r.name }), (0, jsx_runtime_1.jsxs)("span", { style: { fontSize: 11, fontWeight: 700, color: '#8B5CF6', backgroundColor: currentTheme.isDark ? '#8B5CF625' : '#EDE9FE', padding: '2px 8px', borderRadius: 10 }, children: [r.opsPerSec.toLocaleString(), " ops/s"] })] }), (0, jsx_runtime_1.jsx)("div", { style: { fontSize: 12, color: currentTheme.textMuted, marginBottom: 10 }, children: r.description }), r.note && ((0, jsx_runtime_1.jsx)("div", { style: { fontSize: 11, color: currentTheme.textMuted, marginBottom: 10 }, children: r.note })), (0, jsx_runtime_1.jsxs)("div", { style: { fontSize: 11, color: currentTheme.textMuted }, children: [r.operations.toLocaleString(), " ops in ", r.durationMs, "ms"] })] }, idx)))] }), (0, jsx_runtime_1.jsxs)("div", { style: { padding: '16px 24px', borderTop: `1px solid ${currentTheme.border}`, backgroundColor: currentTheme.surface, display: 'flex', gap: 12 }, children: [(0, jsx_runtime_1.jsx)("button", { onClick: startBenchmark, disabled: isRunning, style: {
580
674
  flex: 1,
581
675
  padding: '12px 18px',
582
676
  backgroundColor: isRunning ? '#6D28D9' : '#8B5CF6',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-lite",
3
- "version": "0.0.24",
3
+ "version": "0.0.27",
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 scripts\\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",