@syncular/client 0.15.34 → 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 +16 -3
- package/dist/client.js +10 -4
- package/dist/local-rebootstrap-receipt.d.ts +12 -0
- package/dist/local-rebootstrap-receipt.js +59 -0
- package/dist/local-rebootstrap.d.ts +14 -1
- package/dist/local-rebootstrap.js +38 -0
- package/dist/worker-host.d.ts +1 -1
- package/dist/worker-host.js +3 -2
- package/package.json +3 -3
- package/src/client.ts +17 -4
- package/src/local-rebootstrap-receipt.ts +79 -0
- package/src/local-rebootstrap.ts +54 -1
- package/src/worker-host.ts +8 -5
package/README.md
CHANGED
|
@@ -312,9 +312,22 @@ 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
|
|
316
|
-
|
|
317
|
-
|
|
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.
|
|
324
|
+
|
|
325
|
+
Worker, Tauri, and React Native adapters strictly decode the exact result
|
|
326
|
+
shape before returning it. Missing or additional fields, a non-boolean
|
|
327
|
+
`alreadyApplied`, or counts that are not non-negative safe integers fail with
|
|
328
|
+
`client.invalid_host_response`; raw bridge values and native error prose must
|
|
329
|
+
not be persisted as recovery evidence. `decodeLocalDataRebootstrapResult()` is
|
|
330
|
+
public for custom command hosts that expose the same operation.
|
|
318
331
|
|
|
319
332
|
This API is not a security erase, sign-out, membership revocation, schema
|
|
320
333
|
upgrade, or draft deletion mechanism. It fails closed during security
|
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
|
-
|
|
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:
|
|
1516
|
-
resetSubscriptions:
|
|
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,
|
|
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
|
+
}
|
|
@@ -6,16 +6,29 @@
|
|
|
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;
|
|
12
14
|
}
|
|
13
|
-
/**
|
|
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
|
+
*/
|
|
14
20
|
export interface LocalDataRebootstrapResult {
|
|
15
21
|
readonly alreadyApplied: boolean;
|
|
16
22
|
readonly retainedCommits: number;
|
|
17
23
|
readonly resetSubscriptions: number;
|
|
18
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Strictly decode the privacy-safe acknowledgement returned by a Worker or
|
|
27
|
+
* native command bridge. Compile-time host types are not runtime proof: this
|
|
28
|
+
* rejects version drift and malformed bridge values before an application can
|
|
29
|
+
* persist or display them.
|
|
30
|
+
*/
|
|
31
|
+
export declare function decodeLocalDataRebootstrapResult(value: unknown): LocalDataRebootstrapResult;
|
|
19
32
|
/** Validate before entering the recovery transaction. */
|
|
20
33
|
export declare function compileLocalDataRebootstrap(input: LocalDataRebootstrapInput): string;
|
|
21
34
|
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 ||
|
package/dist/worker-host.d.ts
CHANGED
|
@@ -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
|
|
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';
|
package/dist/worker-host.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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:
|
|
2251
|
-
resetSubscriptions:
|
|
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(
|
|
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
|
+
}
|
package/src/local-rebootstrap.ts
CHANGED
|
@@ -10,19 +10,72 @@
|
|
|
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 {
|
|
16
24
|
readonly rebootstrapId: string;
|
|
17
25
|
}
|
|
18
26
|
|
|
19
|
-
/**
|
|
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
|
+
*/
|
|
20
32
|
export interface LocalDataRebootstrapResult {
|
|
21
33
|
readonly alreadyApplied: boolean;
|
|
22
34
|
readonly retainedCommits: number;
|
|
23
35
|
readonly resetSubscriptions: number;
|
|
24
36
|
}
|
|
25
37
|
|
|
38
|
+
function invalidHostResponse(): never {
|
|
39
|
+
throw new ClientSyncError(
|
|
40
|
+
INVALID_HOST_RESPONSE_CODE,
|
|
41
|
+
'rebootstrapLocalData returned an invalid host response',
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isCount(value: unknown): value is number {
|
|
46
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Strictly decode the privacy-safe acknowledgement returned by a Worker or
|
|
51
|
+
* native command bridge. Compile-time host types are not runtime proof: this
|
|
52
|
+
* rejects version drift and malformed bridge values before an application can
|
|
53
|
+
* persist or display them.
|
|
54
|
+
*/
|
|
55
|
+
export function decodeLocalDataRebootstrapResult(
|
|
56
|
+
value: unknown,
|
|
57
|
+
): LocalDataRebootstrapResult {
|
|
58
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
59
|
+
return invalidHostResponse();
|
|
60
|
+
}
|
|
61
|
+
const source = value as Record<string, unknown>;
|
|
62
|
+
const keys = Object.keys(source).sort();
|
|
63
|
+
if (
|
|
64
|
+
keys.length !== RESULT_KEYS.length ||
|
|
65
|
+
keys.some((key, index) => key !== RESULT_KEYS[index]) ||
|
|
66
|
+
typeof source.alreadyApplied !== 'boolean' ||
|
|
67
|
+
!isCount(source.retainedCommits) ||
|
|
68
|
+
!isCount(source.resetSubscriptions)
|
|
69
|
+
) {
|
|
70
|
+
return invalidHostResponse();
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
alreadyApplied: source.alreadyApplied,
|
|
74
|
+
retainedCommits: source.retainedCommits,
|
|
75
|
+
resetSubscriptions: source.resetSubscriptions,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
26
79
|
/** Validate before entering the recovery transaction. */
|
|
27
80
|
export function compileLocalDataRebootstrap(
|
|
28
81
|
input: LocalDataRebootstrapInput,
|
package/src/worker-host.ts
CHANGED
|
@@ -65,9 +65,10 @@ import {
|
|
|
65
65
|
webLocksLeaderLock,
|
|
66
66
|
} from './leader-lock';
|
|
67
67
|
import type { LocalDataPurgeInput, LocalDataPurgeResult } from './local-purge';
|
|
68
|
-
import
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
519
|
+
return decodeLocalDataRebootstrapResult(
|
|
520
|
+
await this.#call('rebootstrapLocalData', [input]),
|
|
521
|
+
);
|
|
519
522
|
}
|
|
520
523
|
|
|
521
524
|
sync(): Promise<SyncSummary> {
|