@syncular/client 0.15.33 → 0.15.35

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
@@ -316,6 +316,13 @@ visible. Reusing the same id returns `alreadyApplied: true`. The counts-only
316
316
  result reports retained commits and reset subscriptions without exposing ids,
317
317
  rows, scopes, or clinical values.
318
318
 
319
+ Worker, Tauri, and React Native adapters strictly decode the exact result
320
+ shape before returning it. Missing or additional fields, a non-boolean
321
+ `alreadyApplied`, or counts that are not non-negative safe integers fail with
322
+ `client.invalid_host_response`; raw bridge values and native error prose must
323
+ not be persisted as recovery evidence. `decodeLocalDataRebootstrapResult()` is
324
+ public for custom command hosts that expose the same operation.
325
+
319
326
  This API is not a security erase, sign-out, membership revocation, schema
320
327
  upgrade, or draft deletion mechanism. It fails closed during security
321
328
  preflight and while a schema-floor stop is active. The application must show a
@@ -6,6 +6,8 @@
6
6
  * lease state, and protected bookkeeping. It only discards server-derived
7
7
  * projection state so the registered subscriptions can bootstrap it again.
8
8
  */
9
+ /** A non-direct host returned a response that does not match its public API. */
10
+ export declare const INVALID_HOST_RESPONSE_CODE = "client.invalid_host_response";
9
11
  /** A durable idempotency key supplied by the application repair coordinator. */
10
12
  export interface LocalDataRebootstrapInput {
11
13
  readonly rebootstrapId: string;
@@ -16,6 +18,13 @@ export interface LocalDataRebootstrapResult {
16
18
  readonly retainedCommits: number;
17
19
  readonly resetSubscriptions: number;
18
20
  }
21
+ /**
22
+ * Strictly decode the privacy-safe acknowledgement returned by a Worker or
23
+ * native command bridge. Compile-time host types are not runtime proof: this
24
+ * rejects version drift and malformed bridge values before an application can
25
+ * persist or display them.
26
+ */
27
+ export declare function decodeLocalDataRebootstrapResult(value: unknown): LocalDataRebootstrapResult;
19
28
  /** Validate before entering the recovery transaction. */
20
29
  export declare function compileLocalDataRebootstrap(input: LocalDataRebootstrapInput): string;
21
30
  export declare function localDataRebootstrapMetaKey(rebootstrapId: string): string;
@@ -8,6 +8,44 @@
8
8
  */
9
9
  import { ClientSyncError } from './errors.js';
10
10
  const CODE_LIKE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
11
+ const RESULT_KEYS = [
12
+ 'alreadyApplied',
13
+ 'resetSubscriptions',
14
+ 'retainedCommits',
15
+ ];
16
+ /** A non-direct host returned a response that does not match its public API. */
17
+ export const INVALID_HOST_RESPONSE_CODE = 'client.invalid_host_response';
18
+ function invalidHostResponse() {
19
+ throw new ClientSyncError(INVALID_HOST_RESPONSE_CODE, 'rebootstrapLocalData returned an invalid host response');
20
+ }
21
+ function isCount(value) {
22
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
23
+ }
24
+ /**
25
+ * Strictly decode the privacy-safe acknowledgement returned by a Worker or
26
+ * native command bridge. Compile-time host types are not runtime proof: this
27
+ * rejects version drift and malformed bridge values before an application can
28
+ * persist or display them.
29
+ */
30
+ export function decodeLocalDataRebootstrapResult(value) {
31
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
32
+ return invalidHostResponse();
33
+ }
34
+ const source = value;
35
+ const keys = Object.keys(source).sort();
36
+ if (keys.length !== RESULT_KEYS.length ||
37
+ keys.some((key, index) => key !== RESULT_KEYS[index]) ||
38
+ typeof source.alreadyApplied !== 'boolean' ||
39
+ !isCount(source.retainedCommits) ||
40
+ !isCount(source.resetSubscriptions)) {
41
+ return invalidHostResponse();
42
+ }
43
+ return {
44
+ alreadyApplied: source.alreadyApplied,
45
+ retainedCommits: source.retainedCommits,
46
+ resetSubscriptions: source.resetSubscriptions,
47
+ };
48
+ }
11
49
  /** Validate before entering the recovery transaction. */
12
50
  export function compileLocalDataRebootstrap(input) {
13
51
  if (input.rebootstrapId.length === 0 ||
@@ -31,7 +31,7 @@ import { ClientSyncError } from './errors.js';
31
31
  import { ChangeEmitter, type ClientChangeListener, InvalidationEmitter, type InvalidationListener, type LocalRevision, type SyncStatusSnapshot } from './invalidation.js';
32
32
  import { type LeaderLease, type LeaderLock } from './leader-lock.js';
33
33
  import type { LocalDataPurgeInput, LocalDataPurgeResult } from './local-purge.js';
34
- import type { LocalDataRebootstrapInput, LocalDataRebootstrapResult } from './local-rebootstrap.js';
34
+ import { type LocalDataRebootstrapInput, type LocalDataRebootstrapResult } from './local-rebootstrap.js';
35
35
  import { type CrossTabChannel, FollowerLink, LeaderBridge, type LeadershipState } from './multi-tab.js';
36
36
  import type { OutboxCommit } from './outbox.js';
37
37
  import type { CommitOutcome, CommitOutcomeQuery, ResolveCommitOutcomeInput } from './outcomes.js';
@@ -3,6 +3,7 @@ import { ClientDiagnosticsEmitter, withClientDiagnosticsHost, } from './diagnost
3
3
  import { ClientSyncError } from './errors.js';
4
4
  import { ChangeEmitter, InvalidationEmitter, invalidationFromChange, } from './invalidation.js';
5
5
  import { singleOwnerLock, webLocksLeaderLock, } from './leader-lock.js';
6
+ import { decodeLocalDataRebootstrapResult, } from './local-rebootstrap.js';
6
7
  import { broadcastChannelFactory, FollowerLink, LeaderBridge, multiTabChannelName, newTabId, } from './multi-tab.js';
7
8
  import { NOT_LEADER_CODE, WORKER_FAILED_CODE, WORKER_RESTART_REQUIRED_CODE, } from './worker-protocol.js';
8
9
  const WORKER_BUNDLE_LOAD_FAILURE = /(?:failed to fetch dynamically imported module|error loading dynamically imported module|importing a module script failed|failed to load module script)/iu;
@@ -255,8 +256,8 @@ export class SyncClientHandle {
255
256
  purgeLocalData(input) {
256
257
  return this.#call('purgeLocalData', [input]);
257
258
  }
258
- rebootstrapLocalData(input) {
259
- return this.#call('rebootstrapLocalData', [input]);
259
+ async rebootstrapLocalData(input) {
260
+ return decodeLocalDataRebootstrapResult(await this.#call('rebootstrapLocalData', [input]));
260
261
  }
261
262
  sync() {
262
263
  return this.#call('sync', []);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.15.33",
3
+ "version": "0.15.35",
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.33"
84
+ "@syncular/core": "0.15.35"
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.33",
95
+ "@syncular/server": "0.15.35",
96
96
  "@types/better-sqlite3": "^7.6.13",
97
97
  "better-sqlite3": "^12.11.1"
98
98
  }
@@ -10,6 +10,14 @@
10
10
  import { ClientSyncError } from './errors';
11
11
 
12
12
  const CODE_LIKE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
13
+ const RESULT_KEYS = [
14
+ 'alreadyApplied',
15
+ 'resetSubscriptions',
16
+ 'retainedCommits',
17
+ ] as const;
18
+
19
+ /** A non-direct host returned a response that does not match its public API. */
20
+ export const INVALID_HOST_RESPONSE_CODE = 'client.invalid_host_response';
13
21
 
14
22
  /** A durable idempotency key supplied by the application repair coordinator. */
15
23
  export interface LocalDataRebootstrapInput {
@@ -23,6 +31,47 @@ export interface LocalDataRebootstrapResult {
23
31
  readonly resetSubscriptions: number;
24
32
  }
25
33
 
34
+ function invalidHostResponse(): never {
35
+ throw new ClientSyncError(
36
+ INVALID_HOST_RESPONSE_CODE,
37
+ 'rebootstrapLocalData returned an invalid host response',
38
+ );
39
+ }
40
+
41
+ function isCount(value: unknown): value is number {
42
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
43
+ }
44
+
45
+ /**
46
+ * Strictly decode the privacy-safe acknowledgement returned by a Worker or
47
+ * native command bridge. Compile-time host types are not runtime proof: this
48
+ * rejects version drift and malformed bridge values before an application can
49
+ * persist or display them.
50
+ */
51
+ export function decodeLocalDataRebootstrapResult(
52
+ value: unknown,
53
+ ): LocalDataRebootstrapResult {
54
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
55
+ return invalidHostResponse();
56
+ }
57
+ const source = value as Record<string, unknown>;
58
+ const keys = Object.keys(source).sort();
59
+ if (
60
+ keys.length !== RESULT_KEYS.length ||
61
+ keys.some((key, index) => key !== RESULT_KEYS[index]) ||
62
+ typeof source.alreadyApplied !== 'boolean' ||
63
+ !isCount(source.retainedCommits) ||
64
+ !isCount(source.resetSubscriptions)
65
+ ) {
66
+ return invalidHostResponse();
67
+ }
68
+ return {
69
+ alreadyApplied: source.alreadyApplied,
70
+ retainedCommits: source.retainedCommits,
71
+ resetSubscriptions: source.resetSubscriptions,
72
+ };
73
+ }
74
+
26
75
  /** Validate before entering the recovery transaction. */
27
76
  export function compileLocalDataRebootstrap(
28
77
  input: LocalDataRebootstrapInput,
@@ -65,9 +65,10 @@ import {
65
65
  webLocksLeaderLock,
66
66
  } from './leader-lock';
67
67
  import type { LocalDataPurgeInput, LocalDataPurgeResult } from './local-purge';
68
- import type {
69
- LocalDataRebootstrapInput,
70
- LocalDataRebootstrapResult,
68
+ import {
69
+ decodeLocalDataRebootstrapResult,
70
+ type LocalDataRebootstrapInput,
71
+ type LocalDataRebootstrapResult,
71
72
  } from './local-rebootstrap';
72
73
  import {
73
74
  broadcastChannelFactory,
@@ -512,10 +513,12 @@ export class SyncClientHandle {
512
513
  return this.#call('purgeLocalData', [input]);
513
514
  }
514
515
 
515
- rebootstrapLocalData(
516
+ async rebootstrapLocalData(
516
517
  input: LocalDataRebootstrapInput,
517
518
  ): Promise<LocalDataRebootstrapResult> {
518
- return this.#call('rebootstrapLocalData', [input]);
519
+ return decodeLocalDataRebootstrapResult(
520
+ await this.#call('rebootstrapLocalData', [input]),
521
+ );
519
522
  }
520
523
 
521
524
  sync(): Promise<SyncSummary> {