@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 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
@@ -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) => this.#handleRealtimeText(text),
2071
- onBinary: (bytes) => this.#routeRealtimeBinary(bytes),
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 (const frame of message.frames.slice(1)) {
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
- let outboxCount = responseOutboxCount ?? countOutbox(this.#db);
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
- const drained = this.#handlePushResult(frame, commitsById, summary, batch, rejectionDetailsByCommit.get(frame.clientCommitId), frame === lastFinalPushResult);
2335
- if (drained)
2336
- outboxCount -= 1;
2337
- }, () => this.#statusSnapshot(outboxCount));
2338
- responseOutboxCount = outboxCount;
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, pruneOutcomes) {
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;
@@ -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.16.1",
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.16.1"
92
+ "@syncular/core": "0.17.0"
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.16.1"
95
+ "@syncular/server": "0.17.0"
96
96
  }
97
97
  }
@@ -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) => this.#handleRealtimeText(text),
2935
- onBinary: (bytes) => this.#routeRealtimeBinary(bytes),
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 (const frame of message.frames.slice(1)) {
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
- let outboxCount = responseOutboxCount ?? countOutbox(this.#db);
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
- const drained = this.#handlePushResult(
3249
- frame,
3250
- commitsById,
3251
- summary,
3252
- batch,
3253
- rejectionDetailsByCommit.get(frame.clientCommitId),
3254
- frame === lastFinalPushResult,
3255
- );
3256
- if (drained) outboxCount -= 1;
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
- responseOutboxCount = outboxCount;
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;
@@ -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 {