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 +4 -3
- package/dist/adapters/remote/convex.js +26 -5
- package/dist/adapters/remote/firestore.js +23 -11
- package/dist/auth/session.d.ts +3 -0
- package/dist/auth/session.js +25 -16
- package/dist/config/index.js +2 -0
- package/dist/config/types.d.ts +14 -2
- package/dist/core/client.d.ts +19 -1
- package/dist/core/client.js +153 -1
- package/dist/core/query-builder.js +36 -16
- package/dist/core/types.d.ts +4 -0
- package/dist/devtools/BenchmarkModal.d.ts +1 -1
- package/dist/devtools/BenchmarkModal.js +166 -72
- package/package.json +3 -2
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
|
|
@@ -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 =
|
|
119
|
+
const rowTime = getRowTimestamp(row[this.updatedAtColumn], row._creationTime);
|
|
99
120
|
if (rowTime > maxServerTimestamp) {
|
|
100
121
|
maxServerTimestamp = rowTime;
|
|
101
122
|
}
|
|
102
|
-
const rowId =
|
|
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
|
-
|
|
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 =
|
|
151
|
-
const rowId =
|
|
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 (
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
288
|
+
const rowId = getRowId(data, this.primaryKey, doc.id);
|
|
277
289
|
changes.push({
|
|
278
290
|
tableName: table,
|
|
279
291
|
operation: isSoftDeleted ? 'DELETE' : 'UPDATE',
|
package/dist/auth/session.d.ts
CHANGED
|
@@ -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);
|
package/dist/auth/session.js
CHANGED
|
@@ -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.
|
|
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
|
|
72
|
-
await this.db.execute(`CREATE INDEX IF NOT EXISTS
|
|
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.
|
|
100
|
+
await this.db.execute(`UPDATE ${this.sqlTableName} SET is_active = ?;`, [0]);
|
|
92
101
|
}
|
|
93
102
|
const sql = `
|
|
94
|
-
INSERT INTO ${this.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
252
|
-
await this.db.execute(`UPDATE ${this.
|
|
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 });
|
package/dist/config/index.js
CHANGED
|
@@ -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,
|
package/dist/config/types.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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,
|
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';
|
|
@@ -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.
|
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;
|
|
@@ -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
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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;
|
package/dist/core/types.d.ts
CHANGED
|
@@ -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
|
|
95
|
-
|
|
96
|
-
|
|
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:
|
|
154
|
+
metadata: makeMetadata(i, 'batch'),
|
|
106
155
|
created_at: new Date().toISOString(),
|
|
107
156
|
},
|
|
108
157
|
}));
|
|
109
|
-
const t1Start =
|
|
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 =
|
|
169
|
+
const t1Duration = elapsed(t1Start);
|
|
121
170
|
list.push({
|
|
122
171
|
name: 'client.batch() Ingestion',
|
|
123
172
|
category: 'Local-Lite Client',
|
|
124
|
-
description:
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const t2Start =
|
|
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:
|
|
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 =
|
|
198
|
+
const t2Duration = elapsed(t2Start);
|
|
149
199
|
list.push({
|
|
150
200
|
name: 'client.insert() Mutation',
|
|
151
201
|
category: 'Local-Lite Client',
|
|
152
|
-
description:
|
|
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
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const t3Start =
|
|
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:
|
|
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 =
|
|
225
|
+
const t3Duration = elapsed(t3Start);
|
|
176
226
|
list.push({
|
|
177
227
|
name: 'client.update() Pipeline',
|
|
178
228
|
category: 'Local-Lite Client',
|
|
179
|
-
description:
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const t4Start =
|
|
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 =
|
|
248
|
+
const t4Duration = elapsed(t4Start);
|
|
199
249
|
list.push({
|
|
200
250
|
name: 'client.findById() Lookups',
|
|
201
251
|
category: 'Local-Lite Client',
|
|
202
|
-
description:
|
|
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
|
|
209
|
-
|
|
210
|
-
const batchedQueryCount = 20;
|
|
258
|
+
// 5. client.findByIds() Batched PK Lookups
|
|
259
|
+
const batchedQueryCount = scaleWorkload(20);
|
|
211
260
|
const batchLookupSize = 20;
|
|
212
|
-
|
|
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 =
|
|
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
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const t5Start =
|
|
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 =
|
|
301
|
+
const t5Duration = elapsed(t5Start);
|
|
252
302
|
list.push({
|
|
253
303
|
name: 'client.findMany() Filters',
|
|
254
304
|
category: 'Local-Lite Client',
|
|
255
|
-
description:
|
|
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
|
-
|
|
263
|
-
|
|
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 =
|
|
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 =
|
|
328
|
+
const t6Duration = elapsed(t6Start);
|
|
279
329
|
list.push({
|
|
280
330
|
name: 'client.findMany() $contains Search',
|
|
281
331
|
category: 'Local-Lite Client',
|
|
282
|
-
description:
|
|
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 =
|
|
291
|
-
|
|
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 =
|
|
369
|
+
const rbDuration = elapsed(rollbackStart);
|
|
319
370
|
list.push({
|
|
320
371
|
name: 'client.transaction() Rollback',
|
|
321
372
|
category: 'ACID / Transactions',
|
|
322
|
-
description:
|
|
323
|
-
operations:
|
|
373
|
+
description: `${rollbackCount} aborted transactions via client.transaction() (${leaked} leaked rows, zero pollution)`,
|
|
374
|
+
operations: rollbackCount,
|
|
324
375
|
durationMs: rbDuration,
|
|
325
|
-
opsPerSec: Math.round((
|
|
376
|
+
opsPerSec: Math.round((rollbackCount / rbDuration) * 1000),
|
|
326
377
|
status: 'success',
|
|
327
378
|
note: `${leaked} leaked rows`,
|
|
328
379
|
});
|
|
329
|
-
// 8. Outbox Pipeline Ingestion
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const tOutboxStart =
|
|
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 =
|
|
398
|
+
const tOutboxDuration = elapsed(tOutboxStart);
|
|
348
399
|
list.push({
|
|
349
400
|
name: 'Outbox Pipeline Ingestion',
|
|
350
401
|
category: 'Outbox / Sync',
|
|
351
|
-
description:
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
const tDelStart =
|
|
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 =
|
|
420
|
+
const tDelDuration = elapsed(tDelStart);
|
|
370
421
|
list.push({
|
|
371
422
|
name: 'client.delete() Pipeline',
|
|
372
423
|
category: 'Local-Lite Client',
|
|
373
|
-
description:
|
|
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 =
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
404
|
-
|
|
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 =
|
|
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 =
|
|
473
|
+
const t8Duration = elapsed(t8Start);
|
|
423
474
|
list.push({
|
|
424
475
|
name: 'Resilient JSON Fuzz Parsing',
|
|
425
476
|
category: 'Memory / JSON',
|
|
426
|
-
description:
|
|
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.
|
|
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
|
-
"
|
|
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",
|