@syncular/client 0.15.35 → 0.15.36

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
@@ -312,9 +312,15 @@ const result = await client.rebootstrapLocalData({
312
312
  The reset, durable idempotency marker, and optimistic outbox replay are one
313
313
  SQLite transaction. An interruption therefore leaves either the old
314
314
  projection or the fully reset projection with pending offline work still
315
- visible. Reusing the same id returns `alreadyApplied: true`. The counts-only
316
- result reports retained commits and reset subscriptions without exposing ids,
317
- rows, scopes, or clinical values.
315
+ visible. Reusing the same id returns `alreadyApplied: true` together with the
316
+ original `retainedCommits` and `resetSubscriptions` counts. The core stores
317
+ that counts-only receipt atomically with the reset, so an application crash
318
+ after the SQLite commit but before its own acknowledgement does not turn the
319
+ retry into a misleading zero-impact result. Markers written before Syncular
320
+ 0.15.36 cannot reconstruct their historical counts and preserve the former
321
+ zero-count replay behavior. A malformed or unreadable persisted receipt fails
322
+ closed with the sanitized `sync.local_corrupt` client-local code and performs
323
+ no reset. No receipt exposes ids, rows, scopes, or clinical values.
318
324
 
319
325
  Worker, Tauri, and React Native adapters strictly decode the exact result
320
326
  shape before returning it. Missing or additional fields, a non-boolean
package/dist/client.js CHANGED
@@ -17,6 +17,7 @@ import { ChangeAccumulator, ChangeEmitter, InvalidationEmitter, invalidationFrom
17
17
  import { singleOwnerLock, } from './leader-lock.js';
18
18
  import { compileLocalDataPurge, localDataPurgeMetaKey, localDataPurgeTargetMatches, } from './local-purge.js';
19
19
  import { compileLocalDataRebootstrap, localDataRebootstrapMetaKey, } from './local-rebootstrap.js';
20
+ import { decodeLocalDataRebootstrapReceipt, encodeLocalDataRebootstrapReceipt, } from './local-rebootstrap-receipt.js';
20
21
  import { appendOutboxCommit, deleteOutboxCommit, dropOutboxCommitsInScope, encodeOutboxCommit, listOutbox, listOutboxBeforeImages, OutboxEncodeError, replaceOutboxBeforeImages, } from './outbox.js';
21
22
  import { activeFailureRecords, listCommitOutcomes, persistCommitOutcomeResolution, pruneCommitOutcomes, commitOutcome as readCommitOutcome, recordCommitOutcome, } from './outcomes.js';
22
23
  import { assertReadOnlyQuery } from './query-guard.js';
@@ -1509,11 +1510,13 @@ export class SyncClient {
1509
1510
  this.#requireActive();
1510
1511
  const rebootstrapId = compileLocalDataRebootstrap(input);
1511
1512
  const metaKey = localDataRebootstrapMetaKey(rebootstrapId);
1512
- if (getMeta(this.#db, metaKey) !== undefined) {
1513
+ const persistedReceipt = getMeta(this.#db, metaKey);
1514
+ if (persistedReceipt !== undefined) {
1515
+ const receipt = decodeLocalDataRebootstrapReceipt(persistedReceipt);
1513
1516
  return {
1514
1517
  alreadyApplied: true,
1515
- retainedCommits: 0,
1516
- resetSubscriptions: 0,
1518
+ retainedCommits: receipt.retainedCommits,
1519
+ resetSubscriptions: receipt.resetSubscriptions,
1517
1520
  };
1518
1521
  }
1519
1522
  if (this.#schemaFloor !== undefined) {
@@ -1536,7 +1539,10 @@ export class SyncClient {
1536
1539
  for (const commit of pending) {
1537
1540
  this.#applyOperationsLocally(commit.operations, batch);
1538
1541
  }
1539
- setMeta(this.#db, metaKey, 'v1');
1542
+ setMeta(this.#db, metaKey, encodeLocalDataRebootstrapReceipt({
1543
+ retainedCommits: pending.length,
1544
+ resetSubscriptions,
1545
+ }));
1540
1546
  });
1541
1547
  }
1542
1548
  catch (error) {
@@ -0,0 +1,12 @@
1
+ export interface LocalDataRebootstrapReceipt {
2
+ readonly retainedCommits: number;
3
+ readonly resetSubscriptions: number;
4
+ }
5
+ /** Encode only the bounded counts that are safe to replay outside the core. */
6
+ export declare function encodeLocalDataRebootstrapReceipt(receipt: LocalDataRebootstrapReceipt): string;
7
+ /**
8
+ * Decode a committed receipt without leaking its application-owned key. The
9
+ * original counts were not retained by pre-0.15.36 `v1` markers, so those
10
+ * historical repairs keep their former zero-count replay behavior.
11
+ */
12
+ export declare function decodeLocalDataRebootstrapReceipt(value: string): LocalDataRebootstrapReceipt;
@@ -0,0 +1,59 @@
1
+ import { ClientSyncError } from './errors.js';
2
+ const LEGACY_MARKER = 'v1';
3
+ const RECEIPT_VERSION = 2;
4
+ const RECEIPT_KEYS = [
5
+ 'resetSubscriptions',
6
+ 'retainedCommits',
7
+ 'version',
8
+ ];
9
+ function invalidReceipt() {
10
+ throw new ClientSyncError('sync.local_corrupt', 'persisted local rebootstrap receipt is invalid');
11
+ }
12
+ function isCount(value) {
13
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
14
+ }
15
+ /** Encode only the bounded counts that are safe to replay outside the core. */
16
+ export function encodeLocalDataRebootstrapReceipt(receipt) {
17
+ if (!isCount(receipt.retainedCommits) ||
18
+ !isCount(receipt.resetSubscriptions)) {
19
+ return invalidReceipt();
20
+ }
21
+ return JSON.stringify({
22
+ version: RECEIPT_VERSION,
23
+ retainedCommits: receipt.retainedCommits,
24
+ resetSubscriptions: receipt.resetSubscriptions,
25
+ });
26
+ }
27
+ /**
28
+ * Decode a committed receipt without leaking its application-owned key. The
29
+ * original counts were not retained by pre-0.15.36 `v1` markers, so those
30
+ * historical repairs keep their former zero-count replay behavior.
31
+ */
32
+ export function decodeLocalDataRebootstrapReceipt(value) {
33
+ if (value === LEGACY_MARKER) {
34
+ return { retainedCommits: 0, resetSubscriptions: 0 };
35
+ }
36
+ let parsed;
37
+ try {
38
+ parsed = JSON.parse(value);
39
+ }
40
+ catch {
41
+ return invalidReceipt();
42
+ }
43
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
44
+ return invalidReceipt();
45
+ }
46
+ const source = parsed;
47
+ const keys = Object.keys(source).sort();
48
+ if (keys.length !== RECEIPT_KEYS.length ||
49
+ keys.some((key, index) => key !== RECEIPT_KEYS[index]) ||
50
+ source.version !== RECEIPT_VERSION ||
51
+ !isCount(source.retainedCommits) ||
52
+ !isCount(source.resetSubscriptions)) {
53
+ return invalidReceipt();
54
+ }
55
+ return {
56
+ retainedCommits: source.retainedCommits,
57
+ resetSubscriptions: source.resetSubscriptions,
58
+ };
59
+ }
@@ -12,7 +12,11 @@ export declare const INVALID_HOST_RESPONSE_CODE = "client.invalid_host_response"
12
12
  export interface LocalDataRebootstrapInput {
13
13
  readonly rebootstrapId: string;
14
14
  }
15
- /** Privacy-safe acknowledgement; no row or subscription identifiers escape. */
15
+ /**
16
+ * Privacy-safe acknowledgement; no row or subscription identifiers escape.
17
+ * An idempotent replay sets `alreadyApplied` while retaining the first call's
18
+ * exact counts when that repair was first applied by Syncular 0.15.36 or later.
19
+ */
16
20
  export interface LocalDataRebootstrapResult {
17
21
  readonly alreadyApplied: boolean;
18
22
  readonly retainedCommits: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.15.35",
3
+ "version": "0.15.36",
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",
@@ -81,7 +81,7 @@
81
81
  },
82
82
  "dependencies": {
83
83
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
84
- "@syncular/core": "0.15.35"
84
+ "@syncular/core": "0.15.36"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "better-sqlite3": ">=11"
@@ -92,7 +92,7 @@
92
92
  }
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.15.35",
95
+ "@syncular/server": "0.15.36",
96
96
  "@types/better-sqlite3": "^7.6.13",
97
97
  "better-sqlite3": "^12.11.1"
98
98
  }
package/src/client.ts CHANGED
@@ -109,6 +109,10 @@ import {
109
109
  type LocalDataRebootstrapResult,
110
110
  localDataRebootstrapMetaKey,
111
111
  } from './local-rebootstrap';
112
+ import {
113
+ decodeLocalDataRebootstrapReceipt,
114
+ encodeLocalDataRebootstrapReceipt,
115
+ } from './local-rebootstrap-receipt';
112
116
  import {
113
117
  appendOutboxCommit,
114
118
  deleteOutboxCommit,
@@ -2244,11 +2248,13 @@ export class SyncClient {
2244
2248
  this.#requireActive();
2245
2249
  const rebootstrapId = compileLocalDataRebootstrap(input);
2246
2250
  const metaKey = localDataRebootstrapMetaKey(rebootstrapId);
2247
- if (getMeta(this.#db, metaKey) !== undefined) {
2251
+ const persistedReceipt = getMeta(this.#db, metaKey);
2252
+ if (persistedReceipt !== undefined) {
2253
+ const receipt = decodeLocalDataRebootstrapReceipt(persistedReceipt);
2248
2254
  return {
2249
2255
  alreadyApplied: true,
2250
- retainedCommits: 0,
2251
- resetSubscriptions: 0,
2256
+ retainedCommits: receipt.retainedCommits,
2257
+ resetSubscriptions: receipt.resetSubscriptions,
2252
2258
  };
2253
2259
  }
2254
2260
  if (this.#schemaFloor !== undefined) {
@@ -2275,7 +2281,14 @@ export class SyncClient {
2275
2281
  for (const commit of pending) {
2276
2282
  this.#applyOperationsLocally(commit.operations, batch);
2277
2283
  }
2278
- setMeta(this.#db, metaKey, 'v1');
2284
+ setMeta(
2285
+ this.#db,
2286
+ metaKey,
2287
+ encodeLocalDataRebootstrapReceipt({
2288
+ retainedCommits: pending.length,
2289
+ resetSubscriptions,
2290
+ }),
2291
+ );
2279
2292
  });
2280
2293
  } catch (error) {
2281
2294
  this.#upgrading = priorUpgrading;
@@ -0,0 +1,79 @@
1
+ import { ClientSyncError } from './errors';
2
+
3
+ const LEGACY_MARKER = 'v1';
4
+ const RECEIPT_VERSION = 2;
5
+ const RECEIPT_KEYS = [
6
+ 'resetSubscriptions',
7
+ 'retainedCommits',
8
+ 'version',
9
+ ] as const;
10
+
11
+ export interface LocalDataRebootstrapReceipt {
12
+ readonly retainedCommits: number;
13
+ readonly resetSubscriptions: number;
14
+ }
15
+
16
+ function invalidReceipt(): never {
17
+ throw new ClientSyncError(
18
+ 'sync.local_corrupt',
19
+ 'persisted local rebootstrap receipt is invalid',
20
+ );
21
+ }
22
+
23
+ function isCount(value: unknown): value is number {
24
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
25
+ }
26
+
27
+ /** Encode only the bounded counts that are safe to replay outside the core. */
28
+ export function encodeLocalDataRebootstrapReceipt(
29
+ receipt: LocalDataRebootstrapReceipt,
30
+ ): string {
31
+ if (
32
+ !isCount(receipt.retainedCommits) ||
33
+ !isCount(receipt.resetSubscriptions)
34
+ ) {
35
+ return invalidReceipt();
36
+ }
37
+ return JSON.stringify({
38
+ version: RECEIPT_VERSION,
39
+ retainedCommits: receipt.retainedCommits,
40
+ resetSubscriptions: receipt.resetSubscriptions,
41
+ });
42
+ }
43
+
44
+ /**
45
+ * Decode a committed receipt without leaking its application-owned key. The
46
+ * original counts were not retained by pre-0.15.36 `v1` markers, so those
47
+ * historical repairs keep their former zero-count replay behavior.
48
+ */
49
+ export function decodeLocalDataRebootstrapReceipt(
50
+ value: string,
51
+ ): LocalDataRebootstrapReceipt {
52
+ if (value === LEGACY_MARKER) {
53
+ return { retainedCommits: 0, resetSubscriptions: 0 };
54
+ }
55
+ let parsed: unknown;
56
+ try {
57
+ parsed = JSON.parse(value);
58
+ } catch {
59
+ return invalidReceipt();
60
+ }
61
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
62
+ return invalidReceipt();
63
+ }
64
+ const source = parsed as Record<string, unknown>;
65
+ const keys = Object.keys(source).sort();
66
+ if (
67
+ keys.length !== RECEIPT_KEYS.length ||
68
+ keys.some((key, index) => key !== RECEIPT_KEYS[index]) ||
69
+ source.version !== RECEIPT_VERSION ||
70
+ !isCount(source.retainedCommits) ||
71
+ !isCount(source.resetSubscriptions)
72
+ ) {
73
+ return invalidReceipt();
74
+ }
75
+ return {
76
+ retainedCommits: source.retainedCommits,
77
+ resetSubscriptions: source.resetSubscriptions,
78
+ };
79
+ }
@@ -24,7 +24,11 @@ export interface LocalDataRebootstrapInput {
24
24
  readonly rebootstrapId: string;
25
25
  }
26
26
 
27
- /** Privacy-safe acknowledgement; no row or subscription identifiers escape. */
27
+ /**
28
+ * Privacy-safe acknowledgement; no row or subscription identifiers escape.
29
+ * An idempotent replay sets `alreadyApplied` while retaining the first call's
30
+ * exact counts when that repair was first applied by Syncular 0.15.36 or later.
31
+ */
28
32
  export interface LocalDataRebootstrapResult {
29
33
  readonly alreadyApplied: boolean;
30
34
  readonly retainedCommits: number;