local-lite 0.0.25 → 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/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 +1 -0
- package/dist/config/types.d.ts +7 -0
- package/dist/core/client.d.ts +15 -0
- package/dist/core/client.js +96 -1
- package/dist/core/query-builder.js +36 -16
- package/dist/devtools/BenchmarkModal.d.ts +1 -1
- package/dist/devtools/BenchmarkModal.js +166 -72
- package/package.json +2 -2
|
@@ -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,6 +84,7 @@ 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,
|
|
88
89
|
schemaMigrations: config.schemaMigrations,
|
|
89
90
|
autoStart: config.autoStart,
|
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
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;
|
|
@@ -83,6 +85,11 @@ export interface LocalLiteConfig {
|
|
|
83
85
|
* e.g. ['todos', 'notes']
|
|
84
86
|
*/
|
|
85
87
|
tables?: string[];
|
|
88
|
+
/**
|
|
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[];
|
|
86
93
|
/**
|
|
87
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);"]
|
package/dist/core/client.d.ts
CHANGED
|
@@ -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,6 +41,7 @@ export interface LocalFirstClientConfig {
|
|
|
30
41
|
logLevel?: LogLevel;
|
|
31
42
|
autoSchema?: boolean;
|
|
32
43
|
tables?: string[];
|
|
44
|
+
relationships?: RelationshipDefinition[];
|
|
33
45
|
migrations?: string[];
|
|
34
46
|
schemaMigrations?: SchemaMigration[];
|
|
35
47
|
autoStart?: boolean;
|
|
@@ -64,6 +76,8 @@ export declare class TransactionalClient {
|
|
|
64
76
|
event: TableChangeEvent;
|
|
65
77
|
}>, queuedOutboxIds: string[]);
|
|
66
78
|
mutate(tableName: string, rowId: string, operation: SyncOp, payload: Record<string, any> | null, options?: MutateOptions): Promise<void>;
|
|
79
|
+
private deleteRelated;
|
|
80
|
+
private applyMutation;
|
|
67
81
|
insert(tableName: string, rowIdOrPayload: string | Record<string, any>, payloadOrOptions?: Record<string, any> | MutateOptions, options?: MutateOptions): Promise<string>;
|
|
68
82
|
update(tableName: string, rowId: string, payload: Record<string, any>, options?: MutateOptions): Promise<void>;
|
|
69
83
|
updateMany(tableName: string, updates: Array<{
|
|
@@ -95,6 +109,7 @@ export declare class LocalFirstClient {
|
|
|
95
109
|
autoSchema: boolean;
|
|
96
110
|
coalesceOutbox: boolean;
|
|
97
111
|
tables: string[];
|
|
112
|
+
relationships: RelationshipDefinition[];
|
|
98
113
|
jsonColumns: Record<string, string[]>;
|
|
99
114
|
autoHydrateJSON: boolean;
|
|
100
115
|
/** Local column stamped on INSERT/UPDATE writes, or null when disabled. */
|
package/dist/core/client.js
CHANGED
|
@@ -106,6 +106,47 @@ class TransactionalClient {
|
|
|
106
106
|
this.queuedOutboxIds = queuedOutboxIds;
|
|
107
107
|
}
|
|
108
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 = {}) {
|
|
109
150
|
const pk = options.primaryKey ?? this.parent.getPrimaryKey();
|
|
110
151
|
const timestamp = options.customTimestamp ?? Date.now();
|
|
111
152
|
await table_manager_js_1.TableManager.applyMutation(this.tx, tableName, rowId, operation, payload, pk, this.parent.localMutationOptions(timestamp));
|
|
@@ -192,6 +233,7 @@ class LocalFirstClient {
|
|
|
192
233
|
autoSchema;
|
|
193
234
|
coalesceOutbox;
|
|
194
235
|
tables;
|
|
236
|
+
relationships;
|
|
195
237
|
jsonColumns;
|
|
196
238
|
autoHydrateJSON;
|
|
197
239
|
/** Local column stamped on INSERT/UPDATE writes, or null when disabled. */
|
|
@@ -217,6 +259,31 @@ class LocalFirstClient {
|
|
|
217
259
|
this.autoSchema = config.autoSchema ?? false;
|
|
218
260
|
this.coalesceOutbox = config.coalesceOutbox ?? true;
|
|
219
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
|
+
}
|
|
220
287
|
this.migrations = config.migrations ?? [];
|
|
221
288
|
this.schemaMigrations = normalizeSchemaMigrations(config.schemaMigrations ?? []);
|
|
222
289
|
this.autoStart = config.autoStart ?? true;
|
|
@@ -486,6 +553,25 @@ class LocalFirstClient {
|
|
|
486
553
|
* to sync_outbox within the same database transaction.
|
|
487
554
|
*/
|
|
488
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
|
+
}
|
|
489
575
|
const pk = options.primaryKey ?? this.primaryKey;
|
|
490
576
|
const timestamp = options.customTimestamp ?? Date.now();
|
|
491
577
|
let affectedOutboxId;
|
|
@@ -532,6 +618,7 @@ class LocalFirstClient {
|
|
|
532
618
|
const pendingOutboxOps = [];
|
|
533
619
|
let pendingOutboxMap;
|
|
534
620
|
const executeInTx = async (tx) => {
|
|
621
|
+
const txClient = new TransactionalClient(this, tx, queuedEvents, queuedOutboxIds);
|
|
535
622
|
if (this.coalesceOutbox && mutations.length > 1) {
|
|
536
623
|
pendingOutboxMap = await outbox_store_js_1.OutboxStore.getActivePendingMap(tx);
|
|
537
624
|
}
|
|
@@ -544,8 +631,16 @@ class LocalFirstClient {
|
|
|
544
631
|
: (0, uuid_js_1.generateUUID)());
|
|
545
632
|
generatedIds.push(rowId);
|
|
546
633
|
const timestamp = m.customTimestamp ?? options.customTimestamp ?? Date.now();
|
|
547
|
-
await table_manager_js_1.TableManager.applyMutation(tx, m.tableName, rowId, m.operation, payload, pk, this.localMutationOptions(timestamp));
|
|
548
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));
|
|
549
644
|
if (!skipOutbox) {
|
|
550
645
|
if (this.coalesceOutbox && m.operation !== 'INSERT') {
|
|
551
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;
|
|
@@ -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,7 @@
|
|
|
141
141
|
"build": "tsc",
|
|
142
142
|
"test": "vitest run",
|
|
143
143
|
"test:watch": "vitest",
|
|
144
|
-
"test:package": "node package-smoke.mjs",
|
|
144
|
+
"test:package": "node scripts\\package-smoke.mjs",
|
|
145
145
|
"prepublishOnly": "npm run test && npm run build && npm run test:package"
|
|
146
146
|
},
|
|
147
147
|
"peerDependencies": {
|