@syncular/client 0.16.1 → 0.17.0
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 +8 -0
- package/dist/bun-database.js +11 -0
- package/dist/client.js +54 -19
- package/dist/node-database.js +8 -0
- package/package.json +3 -3
- package/src/bun-database.ts +10 -0
- package/src/client.ts +66 -23
- package/src/node-database.ts +7 -0
package/README.md
CHANGED
|
@@ -543,6 +543,14 @@ Node 22.13 or newer. No SQLite package or native addon is required. Both
|
|
|
543
543
|
adapters support synchronous `exec`, `query`, nested transactions, boolean
|
|
544
544
|
bindings, `null`, `Uint8Array` BLOB values, and §5.3 SQLite-image attachment.
|
|
545
545
|
|
|
546
|
+
Persistent Bun and Node databases use SQLite WAL journaling with
|
|
547
|
+
`synchronous=FULL`. Each application or sync commit retains its own durable
|
|
548
|
+
transaction. SQLite keeps in-memory databases on its memory journal. Use a
|
|
549
|
+
local filesystem path; WAL uses adjacent `-wal` and `-shm` files. Close every
|
|
550
|
+
connection before copying the database file, or use SQLite's backup facilities
|
|
551
|
+
while it is open. Copying only the main file while writers are active can omit
|
|
552
|
+
committed WAL contents.
|
|
553
|
+
|
|
546
554
|
Runtime-specific imports remain available:
|
|
547
555
|
|
|
548
556
|
```ts
|
package/dist/bun-database.js
CHANGED
|
@@ -20,6 +20,17 @@ export class BunClientDatabase {
|
|
|
20
20
|
#tx = { depth: 0 };
|
|
21
21
|
constructor(path = ':memory:') {
|
|
22
22
|
this.db = new Database(path);
|
|
23
|
+
// Match native Rust persistence: append durable commits to the WAL
|
|
24
|
+
// instead of creating and syncing a rollback journal per transaction.
|
|
25
|
+
// SQLite retains its in-memory journal for :memory: databases.
|
|
26
|
+
try {
|
|
27
|
+
this.db.run('PRAGMA journal_mode = WAL');
|
|
28
|
+
this.db.run('PRAGMA synchronous = FULL');
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
this.db.close();
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
23
34
|
}
|
|
24
35
|
exec(sql, params = []) {
|
|
25
36
|
this.db.query(sql).run(...coerceParams(params));
|
package/dist/client.js
CHANGED
|
@@ -770,7 +770,7 @@ export class SyncClient {
|
|
|
770
770
|
* Re-entrant calls share the outer batch so a nested apply never
|
|
771
771
|
* double-emits (e.g. purge → blob reconcile → replay inside one round).
|
|
772
772
|
*/
|
|
773
|
-
#applyBatch(fn, statusSnapshotOverride) {
|
|
773
|
+
#applyBatch(fn, statusSnapshotOverride, onRollback) {
|
|
774
774
|
if (this.#batch !== undefined)
|
|
775
775
|
return fn(this.#batch);
|
|
776
776
|
const batch = new ChangeAccumulator();
|
|
@@ -796,6 +796,7 @@ export class SyncClient {
|
|
|
796
796
|
}
|
|
797
797
|
catch (error) {
|
|
798
798
|
this.#batch = undefined;
|
|
799
|
+
onRollback?.(error);
|
|
799
800
|
throw error;
|
|
800
801
|
}
|
|
801
802
|
if (revision !== undefined) {
|
|
@@ -929,6 +930,9 @@ export class SyncClient {
|
|
|
929
930
|
throw new ClientSyncError('sync.invalid_request', `blob content address mismatch for ${blobId} (§5.9.5)`);
|
|
930
931
|
}
|
|
931
932
|
putCachedBlob(this.#db, blobId, bytes, this.#now());
|
|
933
|
+
// The referencing row can arrive before its body. Pin the new cache entry
|
|
934
|
+
// from current visible references before applying the size cap (§5.9.7 B1).
|
|
935
|
+
this.#reconcileBlobs(false);
|
|
932
936
|
this.#enforceBlobCacheCap();
|
|
933
937
|
const stored = getCachedBlob(this.#db, blobId);
|
|
934
938
|
if (stored === undefined) {
|
|
@@ -2067,8 +2071,16 @@ export class SyncClient {
|
|
|
2067
2071
|
}
|
|
2068
2072
|
let openedSocket;
|
|
2069
2073
|
const socket = await connector({
|
|
2070
|
-
onText: (text) =>
|
|
2071
|
-
|
|
2074
|
+
onText: (text) => {
|
|
2075
|
+
if (generation !== this.#realtimeGeneration || !this.#started)
|
|
2076
|
+
return;
|
|
2077
|
+
this.#handleRealtimeText(text);
|
|
2078
|
+
},
|
|
2079
|
+
onBinary: (bytes) => {
|
|
2080
|
+
if (generation !== this.#realtimeGeneration || !this.#started)
|
|
2081
|
+
return;
|
|
2082
|
+
this.#routeRealtimeBinary(bytes);
|
|
2083
|
+
},
|
|
2072
2084
|
onClose: () => {
|
|
2073
2085
|
if (openedSocket === undefined || this.#socket !== openedSocket)
|
|
2074
2086
|
return;
|
|
@@ -2311,12 +2323,14 @@ export class SyncClient {
|
|
|
2311
2323
|
let section;
|
|
2312
2324
|
let errorFrame;
|
|
2313
2325
|
let deltaCursor = -1;
|
|
2314
|
-
let responseOutboxCount;
|
|
2315
2326
|
// Each durable observer transaction emits its own revisioned batch.
|
|
2316
2327
|
// Async decrypt/download work happens outside SQLite transactions.
|
|
2317
2328
|
this.#beginDiagnosticsDeferral();
|
|
2318
2329
|
try {
|
|
2319
|
-
for (
|
|
2330
|
+
for (let index = 1; index < message.frames.length; index += 1) {
|
|
2331
|
+
const frame = message.frames[index];
|
|
2332
|
+
if (frame === undefined)
|
|
2333
|
+
break;
|
|
2320
2334
|
switch (frame.type) {
|
|
2321
2335
|
case 'RESP_HEADER':
|
|
2322
2336
|
break;
|
|
@@ -2329,13 +2343,41 @@ export class SyncClient {
|
|
|
2329
2343
|
});
|
|
2330
2344
|
break;
|
|
2331
2345
|
case 'PUSH_RESULT': {
|
|
2332
|
-
|
|
2346
|
+
const results = [frame];
|
|
2347
|
+
if (frame.status !== 'rejected') {
|
|
2348
|
+
while (index + 1 < message.frames.length) {
|
|
2349
|
+
const next = message.frames[index + 1];
|
|
2350
|
+
if (next?.type !== 'PUSH_RESULT' || next.status === 'rejected')
|
|
2351
|
+
break;
|
|
2352
|
+
results.push(next);
|
|
2353
|
+
index += 1;
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
const conflictCount = this.#conflicts.length;
|
|
2357
|
+
const rejectionCount = this.#rejections.length;
|
|
2358
|
+
let outboxCount = countOutbox(this.#db);
|
|
2333
2359
|
this.#applyBatch((batch) => {
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2360
|
+
let drained = false;
|
|
2361
|
+
for (const result of results) {
|
|
2362
|
+
if (this.#handlePushResult(result, commitsById, summary, batch, rejectionDetailsByCommit.get(result.clientCommitId))) {
|
|
2363
|
+
outboxCount -= 1;
|
|
2364
|
+
drained = true;
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
if (drained &&
|
|
2368
|
+
results.some((result) => result === lastFinalPushResult)) {
|
|
2369
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
2370
|
+
}
|
|
2371
|
+
}, () => this.#statusSnapshot(outboxCount), (cause) => {
|
|
2372
|
+
this.#conflicts.length = conflictCount;
|
|
2373
|
+
this.#rejections.length = rejectionCount;
|
|
2374
|
+
const error = new ClientSyncError('client.outcome_persistence_failed', 'local commit outcome could not be persisted');
|
|
2375
|
+
error.cause = cause;
|
|
2376
|
+
throw error;
|
|
2377
|
+
});
|
|
2378
|
+
for (const conflict of this.#conflicts.slice(conflictCount)) {
|
|
2379
|
+
this.#config.onConflict?.(conflict);
|
|
2380
|
+
}
|
|
2339
2381
|
break;
|
|
2340
2382
|
}
|
|
2341
2383
|
case 'PUSH_RESULT_DETAILS':
|
|
@@ -2501,7 +2543,7 @@ export class SyncClient {
|
|
|
2501
2543
|
}
|
|
2502
2544
|
return { ...summary, bootstrapping };
|
|
2503
2545
|
}
|
|
2504
|
-
#handlePushResult(frame, commitsById, summary, batch, rejectionDetails
|
|
2546
|
+
#handlePushResult(frame, commitsById, summary, batch, rejectionDetails) {
|
|
2505
2547
|
const commit = commitsById.get(frame.clientCommitId);
|
|
2506
2548
|
if (commit === undefined)
|
|
2507
2549
|
return false;
|
|
@@ -2525,9 +2567,6 @@ export class SyncClient {
|
|
|
2525
2567
|
})),
|
|
2526
2568
|
});
|
|
2527
2569
|
deleteOutboxCommit(this.#db, frame.clientCommitId);
|
|
2528
|
-
if (pruneOutcomes) {
|
|
2529
|
-
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
2530
|
-
}
|
|
2531
2570
|
batch.status();
|
|
2532
2571
|
batch.outcomes();
|
|
2533
2572
|
summary.applied.push(frame.clientCommitId);
|
|
@@ -2562,7 +2601,6 @@ export class SyncClient {
|
|
|
2562
2601
|
outcomeResults.push({ status: 'conflict', conflict });
|
|
2563
2602
|
batch.conflicts();
|
|
2564
2603
|
summary.conflicts.push(conflict);
|
|
2565
|
-
this.#config.onConflict?.(conflict);
|
|
2566
2604
|
}
|
|
2567
2605
|
else if (result.status === 'error') {
|
|
2568
2606
|
const details = rejectionDetails?.get(result.opIndex);
|
|
@@ -2592,9 +2630,6 @@ export class SyncClient {
|
|
|
2592
2630
|
results: outcomeResults,
|
|
2593
2631
|
operations: commit.operations,
|
|
2594
2632
|
});
|
|
2595
|
-
if (pruneOutcomes) {
|
|
2596
|
-
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
2597
|
-
}
|
|
2598
2633
|
batch.outcomes();
|
|
2599
2634
|
// §7.2: remove the rejected optimistic layer. Before-images restore
|
|
2600
2635
|
// validator-rejected updates even when the server emitted no new COMMIT;
|
package/dist/node-database.js
CHANGED
|
@@ -32,6 +32,14 @@ export class NodeClientDatabase {
|
|
|
32
32
|
#tx = { depth: 0 };
|
|
33
33
|
constructor(path = ':memory:') {
|
|
34
34
|
this.db = new DatabaseSync(path);
|
|
35
|
+
try {
|
|
36
|
+
this.db.exec('PRAGMA journal_mode = WAL');
|
|
37
|
+
this.db.exec('PRAGMA synchronous = FULL');
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
this.db.close();
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
35
43
|
}
|
|
36
44
|
exec(sql, params = []) {
|
|
37
45
|
this.db.prepare(sql).run(...coerceParams(params));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -89,9 +89,9 @@
|
|
|
89
89
|
},
|
|
90
90
|
"dependencies": {
|
|
91
91
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
92
|
-
"@syncular/core": "0.
|
|
92
|
+
"@syncular/core": "0.17.0"
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.
|
|
95
|
+
"@syncular/server": "0.17.0"
|
|
96
96
|
}
|
|
97
97
|
}
|
package/src/bun-database.ts
CHANGED
|
@@ -36,6 +36,16 @@ export class BunClientDatabase implements ClientDatabase {
|
|
|
36
36
|
|
|
37
37
|
constructor(path = ':memory:') {
|
|
38
38
|
this.db = new Database(path);
|
|
39
|
+
// Match native Rust persistence: append durable commits to the WAL
|
|
40
|
+
// instead of creating and syncing a rollback journal per transaction.
|
|
41
|
+
// SQLite retains its in-memory journal for :memory: databases.
|
|
42
|
+
try {
|
|
43
|
+
this.db.run('PRAGMA journal_mode = WAL');
|
|
44
|
+
this.db.run('PRAGMA synchronous = FULL');
|
|
45
|
+
} catch (error) {
|
|
46
|
+
this.db.close();
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
39
49
|
}
|
|
40
50
|
|
|
41
51
|
exec(sql: string, params: readonly SqlValue[] = []): void {
|
package/src/client.ts
CHANGED
|
@@ -1360,6 +1360,7 @@ export class SyncClient {
|
|
|
1360
1360
|
#applyBatch<T>(
|
|
1361
1361
|
fn: (batch: ChangeAccumulator) => T,
|
|
1362
1362
|
statusSnapshotOverride?: () => SyncStatusSnapshot,
|
|
1363
|
+
onRollback?: (error: unknown) => never,
|
|
1363
1364
|
): T {
|
|
1364
1365
|
if (this.#batch !== undefined) return fn(this.#batch);
|
|
1365
1366
|
const batch = new ChangeAccumulator();
|
|
@@ -1383,6 +1384,7 @@ export class SyncClient {
|
|
|
1383
1384
|
});
|
|
1384
1385
|
} catch (error) {
|
|
1385
1386
|
this.#batch = undefined;
|
|
1387
|
+
onRollback?.(error);
|
|
1386
1388
|
throw error;
|
|
1387
1389
|
}
|
|
1388
1390
|
if (revision !== undefined) {
|
|
@@ -1552,6 +1554,9 @@ export class SyncClient {
|
|
|
1552
1554
|
);
|
|
1553
1555
|
}
|
|
1554
1556
|
putCachedBlob(this.#db, blobId, bytes, this.#now());
|
|
1557
|
+
// The referencing row can arrive before its body. Pin the new cache entry
|
|
1558
|
+
// from current visible references before applying the size cap (§5.9.7 B1).
|
|
1559
|
+
this.#reconcileBlobs(false);
|
|
1555
1560
|
this.#enforceBlobCacheCap();
|
|
1556
1561
|
const stored = getCachedBlob(this.#db, blobId);
|
|
1557
1562
|
if (stored === undefined) {
|
|
@@ -2931,8 +2936,14 @@ export class SyncClient {
|
|
|
2931
2936
|
}
|
|
2932
2937
|
let openedSocket: RealtimeSocket | undefined;
|
|
2933
2938
|
const socket = await connector({
|
|
2934
|
-
onText: (text) =>
|
|
2935
|
-
|
|
2939
|
+
onText: (text) => {
|
|
2940
|
+
if (generation !== this.#realtimeGeneration || !this.#started) return;
|
|
2941
|
+
this.#handleRealtimeText(text);
|
|
2942
|
+
},
|
|
2943
|
+
onBinary: (bytes) => {
|
|
2944
|
+
if (generation !== this.#realtimeGeneration || !this.#started) return;
|
|
2945
|
+
this.#routeRealtimeBinary(bytes);
|
|
2946
|
+
},
|
|
2936
2947
|
onClose: () => {
|
|
2937
2948
|
if (openedSocket === undefined || this.#socket !== openedSocket) return;
|
|
2938
2949
|
this.#socket = undefined;
|
|
@@ -3223,13 +3234,14 @@ export class SyncClient {
|
|
|
3223
3234
|
let section: OpenSection | undefined;
|
|
3224
3235
|
let errorFrame: ClientSyncError | undefined;
|
|
3225
3236
|
let deltaCursor = -1;
|
|
3226
|
-
let responseOutboxCount: number | undefined;
|
|
3227
3237
|
|
|
3228
3238
|
// Each durable observer transaction emits its own revisioned batch.
|
|
3229
3239
|
// Async decrypt/download work happens outside SQLite transactions.
|
|
3230
3240
|
this.#beginDiagnosticsDeferral();
|
|
3231
3241
|
try {
|
|
3232
|
-
for (
|
|
3242
|
+
for (let index = 1; index < message.frames.length; index += 1) {
|
|
3243
|
+
const frame = message.frames[index];
|
|
3244
|
+
if (frame === undefined) break;
|
|
3233
3245
|
switch (frame.type) {
|
|
3234
3246
|
case 'RESP_HEADER':
|
|
3235
3247
|
break;
|
|
@@ -3242,22 +3254,61 @@ export class SyncClient {
|
|
|
3242
3254
|
});
|
|
3243
3255
|
break;
|
|
3244
3256
|
case 'PUSH_RESULT': {
|
|
3245
|
-
|
|
3257
|
+
const results: PushResultFrame[] = [frame];
|
|
3258
|
+
if (frame.status !== 'rejected') {
|
|
3259
|
+
while (index + 1 < message.frames.length) {
|
|
3260
|
+
const next = message.frames[index + 1];
|
|
3261
|
+
if (next?.type !== 'PUSH_RESULT' || next.status === 'rejected')
|
|
3262
|
+
break;
|
|
3263
|
+
results.push(next);
|
|
3264
|
+
index += 1;
|
|
3265
|
+
}
|
|
3266
|
+
}
|
|
3267
|
+
const conflictCount = this.#conflicts.length;
|
|
3268
|
+
const rejectionCount = this.#rejections.length;
|
|
3269
|
+
let outboxCount = countOutbox(this.#db);
|
|
3246
3270
|
this.#applyBatch(
|
|
3247
3271
|
(batch) => {
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3272
|
+
let drained = false;
|
|
3273
|
+
for (const result of results) {
|
|
3274
|
+
if (
|
|
3275
|
+
this.#handlePushResult(
|
|
3276
|
+
result,
|
|
3277
|
+
commitsById,
|
|
3278
|
+
summary,
|
|
3279
|
+
batch,
|
|
3280
|
+
rejectionDetailsByCommit.get(result.clientCommitId),
|
|
3281
|
+
)
|
|
3282
|
+
) {
|
|
3283
|
+
outboxCount -= 1;
|
|
3284
|
+
drained = true;
|
|
3285
|
+
}
|
|
3286
|
+
}
|
|
3287
|
+
if (
|
|
3288
|
+
drained &&
|
|
3289
|
+
results.some((result) => result === lastFinalPushResult)
|
|
3290
|
+
) {
|
|
3291
|
+
pruneCommitOutcomes(
|
|
3292
|
+
this.#db,
|
|
3293
|
+
this.#outcomeRetentionMaxEntries,
|
|
3294
|
+
);
|
|
3295
|
+
}
|
|
3257
3296
|
},
|
|
3258
3297
|
() => this.#statusSnapshot(outboxCount),
|
|
3298
|
+
(cause) => {
|
|
3299
|
+
this.#conflicts.length = conflictCount;
|
|
3300
|
+
this.#rejections.length = rejectionCount;
|
|
3301
|
+
const error = new ClientSyncError(
|
|
3302
|
+
'client.outcome_persistence_failed',
|
|
3303
|
+
'local commit outcome could not be persisted',
|
|
3304
|
+
);
|
|
3305
|
+
error.cause = cause;
|
|
3306
|
+
throw error;
|
|
3307
|
+
},
|
|
3259
3308
|
);
|
|
3260
|
-
|
|
3309
|
+
for (const conflict of this.#conflicts.slice(conflictCount)) {
|
|
3310
|
+
this.#config.onConflict?.(conflict);
|
|
3311
|
+
}
|
|
3261
3312
|
break;
|
|
3262
3313
|
}
|
|
3263
3314
|
case 'PUSH_RESULT_DETAILS':
|
|
@@ -3506,7 +3557,6 @@ export class SyncClient {
|
|
|
3506
3557
|
summary: MutableSummary,
|
|
3507
3558
|
batch: ChangeAccumulator,
|
|
3508
3559
|
rejectionDetails: ReadonlyMap<number, RejectionDetails> | undefined,
|
|
3509
|
-
pruneOutcomes: boolean,
|
|
3510
3560
|
): boolean {
|
|
3511
3561
|
const commit = commitsById.get(frame.clientCommitId);
|
|
3512
3562
|
if (commit === undefined) return false;
|
|
@@ -3529,9 +3579,6 @@ export class SyncClient {
|
|
|
3529
3579
|
})),
|
|
3530
3580
|
});
|
|
3531
3581
|
deleteOutboxCommit(this.#db, frame.clientCommitId);
|
|
3532
|
-
if (pruneOutcomes) {
|
|
3533
|
-
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
3534
|
-
}
|
|
3535
3582
|
batch.status();
|
|
3536
3583
|
batch.outcomes();
|
|
3537
3584
|
summary.applied.push(frame.clientCommitId);
|
|
@@ -3569,7 +3616,6 @@ export class SyncClient {
|
|
|
3569
3616
|
outcomeResults.push({ status: 'conflict', conflict });
|
|
3570
3617
|
batch.conflicts();
|
|
3571
3618
|
summary.conflicts.push(conflict);
|
|
3572
|
-
this.#config.onConflict?.(conflict);
|
|
3573
3619
|
} else if (result.status === 'error') {
|
|
3574
3620
|
const details = rejectionDetails?.get(result.opIndex);
|
|
3575
3621
|
const rejection: RejectionRecord = {
|
|
@@ -3597,9 +3643,6 @@ export class SyncClient {
|
|
|
3597
3643
|
results: outcomeResults,
|
|
3598
3644
|
operations: commit.operations,
|
|
3599
3645
|
});
|
|
3600
|
-
if (pruneOutcomes) {
|
|
3601
|
-
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
3602
|
-
}
|
|
3603
3646
|
batch.outcomes();
|
|
3604
3647
|
// §7.2: remove the rejected optimistic layer. Before-images restore
|
|
3605
3648
|
// validator-rejected updates even when the server emitted no new COMMIT;
|
package/src/node-database.ts
CHANGED
|
@@ -40,6 +40,13 @@ export class NodeClientDatabase implements ClientDatabase {
|
|
|
40
40
|
|
|
41
41
|
constructor(path = ':memory:') {
|
|
42
42
|
this.db = new DatabaseSync(path);
|
|
43
|
+
try {
|
|
44
|
+
this.db.exec('PRAGMA journal_mode = WAL');
|
|
45
|
+
this.db.exec('PRAGMA synchronous = FULL');
|
|
46
|
+
} catch (error) {
|
|
47
|
+
this.db.close();
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
43
50
|
}
|
|
44
51
|
|
|
45
52
|
exec(sql: string, params: readonly SqlValue[] = []): void {
|