@syncular/client 0.15.27 → 0.15.29
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 +29 -0
- package/dist/client.d.ts +12 -0
- package/dist/client.js +60 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/local-rebootstrap.d.ts +21 -0
- package/dist/local-rebootstrap.js +22 -0
- package/dist/worker-entry.js +1 -0
- package/dist/worker-host.d.ts +2 -0
- package/dist/worker-host.js +3 -0
- package/dist/worker-protocol.d.ts +3 -0
- package/package.json +3 -3
- package/src/client.ts +71 -0
- package/src/index.ts +1 -0
- package/src/local-rebootstrap.ts +45 -0
- package/src/worker-entry.ts +2 -0
- package/src/worker-host.ts +10 -0
- package/src/worker-protocol.ts +8 -0
package/README.md
CHANGED
|
@@ -293,6 +293,35 @@ Only bounded, non-empty, code-like values on plaintext string schema columns
|
|
|
293
293
|
are accepted. There is intentionally no full-table mode. The result exposes
|
|
294
294
|
counts only—never row ids or selector values.
|
|
295
295
|
|
|
296
|
+
## Application-authorized projection rebootstrap
|
|
297
|
+
|
|
298
|
+
`rebootstrapLocalData({ rebootstrapId })` is the recovery primitive for a
|
|
299
|
+
locally damaged or persistently inconsistent replicated projection. It drops
|
|
300
|
+
and recreates only Syncular's server-derived tables, rewinds the existing
|
|
301
|
+
subscription registrations, and requests a fresh bootstrap. It preserves the
|
|
302
|
+
client id, lease state, pending outbox commits, commit outcomes, subscription
|
|
303
|
+
intent, and protected bookkeeping.
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
const result = await client.rebootstrapLocalData({
|
|
307
|
+
rebootstrapId: crypto.randomUUID(),
|
|
308
|
+
});
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
The reset, durable idempotency marker, and optimistic outbox replay are one
|
|
312
|
+
SQLite transaction. An interruption therefore leaves either the old
|
|
313
|
+
projection or the fully reset projection with pending offline work still
|
|
314
|
+
visible. Reusing the same id returns `alreadyApplied: true`. The counts-only
|
|
315
|
+
result reports retained commits and reset subscriptions without exposing ids,
|
|
316
|
+
rows, scopes, or clinical values.
|
|
317
|
+
|
|
318
|
+
This API is not a security erase, sign-out, membership revocation, schema
|
|
319
|
+
upgrade, or draft deletion mechanism. It fails closed during security
|
|
320
|
+
preflight and while a schema-floor stop is active. The application must show a
|
|
321
|
+
preview and explicit confirmation, preserve app-owned drafts/files separately,
|
|
322
|
+
and reserve the operation for diagnostics/support recovery rather than normal
|
|
323
|
+
startup.
|
|
324
|
+
|
|
296
325
|
Validator rejections may include bounded `details` (`fieldPaths`, `reason`,
|
|
297
326
|
`requiredAction`, and explicitly safe `references`). The details persist with
|
|
298
327
|
the rejection. Treat every value as a machine hint: map known values to
|
package/dist/client.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type { EncryptionConfig } from './encryption.js';
|
|
|
15
15
|
import { type ClientChangeListener, type CommandResult, type InvalidationListener, type LocalRevision, type SyncIntent, type SyncStatusSnapshot } from './invalidation.js';
|
|
16
16
|
import { type LeaderLock } from './leader-lock.js';
|
|
17
17
|
import { type LocalDataPurgeInput, type LocalDataPurgeResult } from './local-purge.js';
|
|
18
|
+
import { type LocalDataRebootstrapInput, type LocalDataRebootstrapResult } from './local-rebootstrap.js';
|
|
18
19
|
import { type OutboxCommit } from './outbox.js';
|
|
19
20
|
import { type CommitOutcome, type CommitOutcomeQuery, type ConflictRecord, type RejectionRecord, type ResolveCommitOutcomeInput } from './outcomes.js';
|
|
20
21
|
import { type ClientSchema } from './schema.js';
|
|
@@ -435,6 +436,17 @@ export declare class SyncClient {
|
|
|
435
436
|
* reused id with different selectors fails closed.
|
|
436
437
|
*/
|
|
437
438
|
purgeLocalData(input: LocalDataPurgeInput): LocalDataPurgeResult;
|
|
439
|
+
/**
|
|
440
|
+
* Rebuild the server-derived local projection without sacrificing offline
|
|
441
|
+
* work or device identity. The reset, subscription rewind, durable
|
|
442
|
+
* idempotency marker, and optimistic outbox replay are one SQLite
|
|
443
|
+
* transaction, so an interruption cannot expose a half-repaired replica.
|
|
444
|
+
*
|
|
445
|
+
* This is an application-authorized repair primitive, not a security purge.
|
|
446
|
+
* It is unavailable during security preflight and while a schema-floor stop
|
|
447
|
+
* is active: neither condition can be repaired by redownloading data.
|
|
448
|
+
*/
|
|
449
|
+
rebootstrapLocalData(input: LocalDataRebootstrapInput): LocalDataRebootstrapResult;
|
|
438
450
|
/** Host-facing patch result with explicit network work intent (§7.5). */
|
|
439
451
|
patchCommand(table: string, rowId: string, partial: Readonly<Record<string, unknown>>, options?: {
|
|
440
452
|
readonly baseVersion?: number;
|
package/dist/client.js
CHANGED
|
@@ -16,6 +16,7 @@ import { ClientSyncError } from './errors.js';
|
|
|
16
16
|
import { ChangeAccumulator, ChangeEmitter, InvalidationEmitter, invalidationFromChange, } from './invalidation.js';
|
|
17
17
|
import { singleOwnerLock, } from './leader-lock.js';
|
|
18
18
|
import { compileLocalDataPurge, localDataPurgeMetaKey, localDataPurgeTargetMatches, } from './local-purge.js';
|
|
19
|
+
import { compileLocalDataRebootstrap, localDataRebootstrapMetaKey, } from './local-rebootstrap.js';
|
|
19
20
|
import { appendOutboxCommit, deleteOutboxCommit, dropOutboxCommitsInScope, encodeOutboxCommit, listOutbox, listOutboxBeforeImages, OutboxEncodeError, replaceOutboxBeforeImages, } from './outbox.js';
|
|
20
21
|
import { activeFailureRecords, listCommitOutcomes, persistCommitOutcomeResolution, pruneCommitOutcomes, commitOutcome as readCommitOutcome, recordCommitOutcome, } from './outcomes.js';
|
|
21
22
|
import { assertReadOnlyQuery } from './query-guard.js';
|
|
@@ -1494,6 +1495,65 @@ export class SyncClient {
|
|
|
1494
1495
|
throw error;
|
|
1495
1496
|
}
|
|
1496
1497
|
}
|
|
1498
|
+
/**
|
|
1499
|
+
* Rebuild the server-derived local projection without sacrificing offline
|
|
1500
|
+
* work or device identity. The reset, subscription rewind, durable
|
|
1501
|
+
* idempotency marker, and optimistic outbox replay are one SQLite
|
|
1502
|
+
* transaction, so an interruption cannot expose a half-repaired replica.
|
|
1503
|
+
*
|
|
1504
|
+
* This is an application-authorized repair primitive, not a security purge.
|
|
1505
|
+
* It is unavailable during security preflight and while a schema-floor stop
|
|
1506
|
+
* is active: neither condition can be repaired by redownloading data.
|
|
1507
|
+
*/
|
|
1508
|
+
rebootstrapLocalData(input) {
|
|
1509
|
+
this.#requireActive();
|
|
1510
|
+
const rebootstrapId = compileLocalDataRebootstrap(input);
|
|
1511
|
+
const metaKey = localDataRebootstrapMetaKey(rebootstrapId);
|
|
1512
|
+
if (getMeta(this.#db, metaKey) !== undefined) {
|
|
1513
|
+
return {
|
|
1514
|
+
alreadyApplied: true,
|
|
1515
|
+
retainedCommits: 0,
|
|
1516
|
+
resetSubscriptions: 0,
|
|
1517
|
+
};
|
|
1518
|
+
}
|
|
1519
|
+
if (this.#schemaFloor !== undefined) {
|
|
1520
|
+
throw new ClientSyncError('sync.invalid_request', 'local rebootstrap cannot bypass an active schema-floor stop; update the application first');
|
|
1521
|
+
}
|
|
1522
|
+
const pending = listOutbox(this.#db);
|
|
1523
|
+
const resetSubscriptions = loadSubscriptions(this.#db).length;
|
|
1524
|
+
const priorUpgrading = this.#upgrading;
|
|
1525
|
+
const priorNeedsPull = this.#needsPull;
|
|
1526
|
+
try {
|
|
1527
|
+
this.#applyBatch((batch) => {
|
|
1528
|
+
this.#upgrading = true;
|
|
1529
|
+
this.#needsPull = true;
|
|
1530
|
+
batch.status();
|
|
1531
|
+
dropAndRecreateSyncedTables(this.#db, this.#schema);
|
|
1532
|
+
resetSubscriptionsForBump(this.#db);
|
|
1533
|
+
for (const table of this.#schema.tables.values()) {
|
|
1534
|
+
batch.table(table.name);
|
|
1535
|
+
}
|
|
1536
|
+
for (const commit of pending) {
|
|
1537
|
+
this.#applyOperationsLocally(commit.operations, batch);
|
|
1538
|
+
}
|
|
1539
|
+
setMeta(this.#db, metaKey, 'v1');
|
|
1540
|
+
});
|
|
1541
|
+
}
|
|
1542
|
+
catch (error) {
|
|
1543
|
+
this.#upgrading = priorUpgrading;
|
|
1544
|
+
this.#needsPull = priorNeedsPull;
|
|
1545
|
+
throw error;
|
|
1546
|
+
}
|
|
1547
|
+
if (!priorUpgrading)
|
|
1548
|
+
this.#config.onUpgrading?.(true);
|
|
1549
|
+
this.#config.onSyncNeeded?.('startup');
|
|
1550
|
+
this.#config.onSyncIntent?.({ kind: 'interactive' });
|
|
1551
|
+
return {
|
|
1552
|
+
alreadyApplied: false,
|
|
1553
|
+
retainedCommits: pending.length,
|
|
1554
|
+
resetSubscriptions,
|
|
1555
|
+
};
|
|
1556
|
+
}
|
|
1497
1557
|
#localPurgeTargetsByTable(purge) {
|
|
1498
1558
|
const byTable = new Map();
|
|
1499
1559
|
for (const target of purge.targets) {
|
package/dist/index.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ export * from './http.js';
|
|
|
22
22
|
export * from './invalidation.js';
|
|
23
23
|
export * from './leader-lock.js';
|
|
24
24
|
export * from './local-purge.js';
|
|
25
|
+
export * from './local-rebootstrap.js';
|
|
25
26
|
export * from './multi-tab.js';
|
|
26
27
|
export * from './naming.js';
|
|
27
28
|
export * from './outbox.js';
|
package/dist/index.js
CHANGED
|
@@ -22,6 +22,7 @@ export * from './http.js';
|
|
|
22
22
|
export * from './invalidation.js';
|
|
23
23
|
export * from './leader-lock.js';
|
|
24
24
|
export * from './local-purge.js';
|
|
25
|
+
export * from './local-rebootstrap.js';
|
|
25
26
|
export * from './multi-tab.js';
|
|
26
27
|
export * from './naming.js';
|
|
27
28
|
export * from './outbox.js';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application-authorized recovery of the replicated local projection.
|
|
3
|
+
*
|
|
4
|
+
* This is deliberately separate from `purgeLocalData`: rebootstrap keeps
|
|
5
|
+
* device identity, the outbox, commit outcomes, subscription registrations,
|
|
6
|
+
* lease state, and protected bookkeeping. It only discards server-derived
|
|
7
|
+
* projection state so the registered subscriptions can bootstrap it again.
|
|
8
|
+
*/
|
|
9
|
+
/** A durable idempotency key supplied by the application repair coordinator. */
|
|
10
|
+
export interface LocalDataRebootstrapInput {
|
|
11
|
+
readonly rebootstrapId: string;
|
|
12
|
+
}
|
|
13
|
+
/** Privacy-safe acknowledgement; no row or subscription identifiers escape. */
|
|
14
|
+
export interface LocalDataRebootstrapResult {
|
|
15
|
+
readonly alreadyApplied: boolean;
|
|
16
|
+
readonly retainedCommits: number;
|
|
17
|
+
readonly resetSubscriptions: number;
|
|
18
|
+
}
|
|
19
|
+
/** Validate before entering the recovery transaction. */
|
|
20
|
+
export declare function compileLocalDataRebootstrap(input: LocalDataRebootstrapInput): string;
|
|
21
|
+
export declare function localDataRebootstrapMetaKey(rebootstrapId: string): string;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application-authorized recovery of the replicated local projection.
|
|
3
|
+
*
|
|
4
|
+
* This is deliberately separate from `purgeLocalData`: rebootstrap keeps
|
|
5
|
+
* device identity, the outbox, commit outcomes, subscription registrations,
|
|
6
|
+
* lease state, and protected bookkeeping. It only discards server-derived
|
|
7
|
+
* projection state so the registered subscriptions can bootstrap it again.
|
|
8
|
+
*/
|
|
9
|
+
import { ClientSyncError } from './errors.js';
|
|
10
|
+
const CODE_LIKE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
|
|
11
|
+
/** Validate before entering the recovery transaction. */
|
|
12
|
+
export function compileLocalDataRebootstrap(input) {
|
|
13
|
+
if (input.rebootstrapId.length === 0 ||
|
|
14
|
+
input.rebootstrapId.length > 128 ||
|
|
15
|
+
!CODE_LIKE_VALUE.test(input.rebootstrapId)) {
|
|
16
|
+
throw new ClientSyncError('sync.invalid_request', 'local rebootstrap rebootstrapId must be a 1–128 character code-like identifier');
|
|
17
|
+
}
|
|
18
|
+
return input.rebootstrapId;
|
|
19
|
+
}
|
|
20
|
+
export function localDataRebootstrapMetaKey(rebootstrapId) {
|
|
21
|
+
return `localRebootstrap:${rebootstrapId}`;
|
|
22
|
+
}
|
package/dist/worker-entry.js
CHANGED
|
@@ -321,6 +321,7 @@ export function startSyncWorker(overrides = {}) {
|
|
|
321
321
|
return result.value;
|
|
322
322
|
},
|
|
323
323
|
purgeLocalData: (input) => requireClient().purgeLocalData(input),
|
|
324
|
+
rebootstrapLocalData: (input) => requireClient().rebootstrapLocalData(input),
|
|
324
325
|
sync: () => {
|
|
325
326
|
const running = requireClient();
|
|
326
327
|
return serializedSync(() => running.sync());
|
package/dist/worker-host.d.ts
CHANGED
|
@@ -30,6 +30,7 @@ import type { EncryptionKeyringConfig } from './encryption.js';
|
|
|
30
30
|
import { ChangeEmitter, type ClientChangeListener, InvalidationEmitter, type InvalidationListener, type LocalRevision, type SyncStatusSnapshot } from './invalidation.js';
|
|
31
31
|
import { type LeaderLease, type LeaderLock } from './leader-lock.js';
|
|
32
32
|
import type { LocalDataPurgeInput, LocalDataPurgeResult } from './local-purge.js';
|
|
33
|
+
import type { LocalDataRebootstrapInput, LocalDataRebootstrapResult } from './local-rebootstrap.js';
|
|
33
34
|
import { type CrossTabChannel, FollowerLink, LeaderBridge, type LeadershipState } from './multi-tab.js';
|
|
34
35
|
import type { OutboxCommit } from './outbox.js';
|
|
35
36
|
import type { CommitOutcome, CommitOutcomeQuery, ResolveCommitOutcomeInput } from './outcomes.js';
|
|
@@ -189,6 +190,7 @@ export declare class SyncClientHandle {
|
|
|
189
190
|
readonly baseVersion?: number;
|
|
190
191
|
}): Promise<string>;
|
|
191
192
|
purgeLocalData(input: LocalDataPurgeInput): Promise<LocalDataPurgeResult>;
|
|
193
|
+
rebootstrapLocalData(input: LocalDataRebootstrapInput): Promise<LocalDataRebootstrapResult>;
|
|
192
194
|
sync(): Promise<SyncSummary>;
|
|
193
195
|
syncUntilIdle(maxRounds?: number): Promise<SyncSummary>;
|
|
194
196
|
query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]>;
|
package/dist/worker-host.js
CHANGED
|
@@ -247,6 +247,9 @@ export class SyncClientHandle {
|
|
|
247
247
|
purgeLocalData(input) {
|
|
248
248
|
return this.#call('purgeLocalData', [input]);
|
|
249
249
|
}
|
|
250
|
+
rebootstrapLocalData(input) {
|
|
251
|
+
return this.#call('rebootstrapLocalData', [input]);
|
|
252
|
+
}
|
|
250
253
|
sync() {
|
|
251
254
|
return this.#call('sync', []);
|
|
252
255
|
}
|
|
@@ -24,6 +24,7 @@ import type { ClientDiagnosticsRequest, ClientDiagnosticsSnapshot } from './diag
|
|
|
24
24
|
import type { EncryptionKeyringConfig } from './encryption.js';
|
|
25
25
|
import type { ClientChangeBatch, LocalRevision, SyncStatusSnapshot } from './invalidation.js';
|
|
26
26
|
import type { LocalDataPurgeInput, LocalDataPurgeResult } from './local-purge.js';
|
|
27
|
+
import type { LocalDataRebootstrapInput, LocalDataRebootstrapResult } from './local-rebootstrap.js';
|
|
27
28
|
import type { OutboxCommit } from './outbox.js';
|
|
28
29
|
import type { CommitOutcome, CommitOutcomeQuery, ResolveCommitOutcomeInput } from './outcomes.js';
|
|
29
30
|
import type { ClientSchema } from './schema.js';
|
|
@@ -102,6 +103,8 @@ export interface WorkerApi {
|
|
|
102
103
|
}): string;
|
|
103
104
|
/** Application-authorized, idempotent local security purge. */
|
|
104
105
|
purgeLocalData(input: LocalDataPurgeInput): LocalDataPurgeResult;
|
|
106
|
+
/** Application-authorized, outbox-preserving projection recovery. */
|
|
107
|
+
rebootstrapLocalData(input: LocalDataRebootstrapInput): LocalDataRebootstrapResult;
|
|
105
108
|
sync(): Promise<SyncSummary>;
|
|
106
109
|
syncUntilIdle(maxRounds?: number): Promise<SyncSummary>;
|
|
107
110
|
query(sql: string, params?: readonly SqlValue[]): SqlRow[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.29",
|
|
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.29"
|
|
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.29",
|
|
96
96
|
"@types/better-sqlite3": "^7.6.13",
|
|
97
97
|
"better-sqlite3": "^12.11.1"
|
|
98
98
|
}
|
package/src/client.ts
CHANGED
|
@@ -103,6 +103,12 @@ import {
|
|
|
103
103
|
localDataPurgeMetaKey,
|
|
104
104
|
localDataPurgeTargetMatches,
|
|
105
105
|
} from './local-purge';
|
|
106
|
+
import {
|
|
107
|
+
compileLocalDataRebootstrap,
|
|
108
|
+
type LocalDataRebootstrapInput,
|
|
109
|
+
type LocalDataRebootstrapResult,
|
|
110
|
+
localDataRebootstrapMetaKey,
|
|
111
|
+
} from './local-rebootstrap';
|
|
106
112
|
import {
|
|
107
113
|
appendOutboxCommit,
|
|
108
114
|
deleteOutboxCommit,
|
|
@@ -2222,6 +2228,71 @@ export class SyncClient {
|
|
|
2222
2228
|
}
|
|
2223
2229
|
}
|
|
2224
2230
|
|
|
2231
|
+
/**
|
|
2232
|
+
* Rebuild the server-derived local projection without sacrificing offline
|
|
2233
|
+
* work or device identity. The reset, subscription rewind, durable
|
|
2234
|
+
* idempotency marker, and optimistic outbox replay are one SQLite
|
|
2235
|
+
* transaction, so an interruption cannot expose a half-repaired replica.
|
|
2236
|
+
*
|
|
2237
|
+
* This is an application-authorized repair primitive, not a security purge.
|
|
2238
|
+
* It is unavailable during security preflight and while a schema-floor stop
|
|
2239
|
+
* is active: neither condition can be repaired by redownloading data.
|
|
2240
|
+
*/
|
|
2241
|
+
rebootstrapLocalData(
|
|
2242
|
+
input: LocalDataRebootstrapInput,
|
|
2243
|
+
): LocalDataRebootstrapResult {
|
|
2244
|
+
this.#requireActive();
|
|
2245
|
+
const rebootstrapId = compileLocalDataRebootstrap(input);
|
|
2246
|
+
const metaKey = localDataRebootstrapMetaKey(rebootstrapId);
|
|
2247
|
+
if (getMeta(this.#db, metaKey) !== undefined) {
|
|
2248
|
+
return {
|
|
2249
|
+
alreadyApplied: true,
|
|
2250
|
+
retainedCommits: 0,
|
|
2251
|
+
resetSubscriptions: 0,
|
|
2252
|
+
};
|
|
2253
|
+
}
|
|
2254
|
+
if (this.#schemaFloor !== undefined) {
|
|
2255
|
+
throw new ClientSyncError(
|
|
2256
|
+
'sync.invalid_request',
|
|
2257
|
+
'local rebootstrap cannot bypass an active schema-floor stop; update the application first',
|
|
2258
|
+
);
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
const pending = listOutbox(this.#db);
|
|
2262
|
+
const resetSubscriptions = loadSubscriptions(this.#db).length;
|
|
2263
|
+
const priorUpgrading = this.#upgrading;
|
|
2264
|
+
const priorNeedsPull = this.#needsPull;
|
|
2265
|
+
try {
|
|
2266
|
+
this.#applyBatch((batch) => {
|
|
2267
|
+
this.#upgrading = true;
|
|
2268
|
+
this.#needsPull = true;
|
|
2269
|
+
batch.status();
|
|
2270
|
+
dropAndRecreateSyncedTables(this.#db, this.#schema);
|
|
2271
|
+
resetSubscriptionsForBump(this.#db);
|
|
2272
|
+
for (const table of this.#schema.tables.values()) {
|
|
2273
|
+
batch.table(table.name);
|
|
2274
|
+
}
|
|
2275
|
+
for (const commit of pending) {
|
|
2276
|
+
this.#applyOperationsLocally(commit.operations, batch);
|
|
2277
|
+
}
|
|
2278
|
+
setMeta(this.#db, metaKey, 'v1');
|
|
2279
|
+
});
|
|
2280
|
+
} catch (error) {
|
|
2281
|
+
this.#upgrading = priorUpgrading;
|
|
2282
|
+
this.#needsPull = priorNeedsPull;
|
|
2283
|
+
throw error;
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
if (!priorUpgrading) this.#config.onUpgrading?.(true);
|
|
2287
|
+
this.#config.onSyncNeeded?.('startup');
|
|
2288
|
+
this.#config.onSyncIntent?.({ kind: 'interactive' });
|
|
2289
|
+
return {
|
|
2290
|
+
alreadyApplied: false,
|
|
2291
|
+
retainedCommits: pending.length,
|
|
2292
|
+
resetSubscriptions,
|
|
2293
|
+
};
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2225
2296
|
#localPurgeTargetsByTable(
|
|
2226
2297
|
purge: CompiledLocalDataPurge,
|
|
2227
2298
|
): Map<string, CompiledLocalDataPurgeTarget[]> {
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application-authorized recovery of the replicated local projection.
|
|
3
|
+
*
|
|
4
|
+
* This is deliberately separate from `purgeLocalData`: rebootstrap keeps
|
|
5
|
+
* device identity, the outbox, commit outcomes, subscription registrations,
|
|
6
|
+
* lease state, and protected bookkeeping. It only discards server-derived
|
|
7
|
+
* projection state so the registered subscriptions can bootstrap it again.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { ClientSyncError } from './errors';
|
|
11
|
+
|
|
12
|
+
const CODE_LIKE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
|
|
13
|
+
|
|
14
|
+
/** A durable idempotency key supplied by the application repair coordinator. */
|
|
15
|
+
export interface LocalDataRebootstrapInput {
|
|
16
|
+
readonly rebootstrapId: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Privacy-safe acknowledgement; no row or subscription identifiers escape. */
|
|
20
|
+
export interface LocalDataRebootstrapResult {
|
|
21
|
+
readonly alreadyApplied: boolean;
|
|
22
|
+
readonly retainedCommits: number;
|
|
23
|
+
readonly resetSubscriptions: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Validate before entering the recovery transaction. */
|
|
27
|
+
export function compileLocalDataRebootstrap(
|
|
28
|
+
input: LocalDataRebootstrapInput,
|
|
29
|
+
): string {
|
|
30
|
+
if (
|
|
31
|
+
input.rebootstrapId.length === 0 ||
|
|
32
|
+
input.rebootstrapId.length > 128 ||
|
|
33
|
+
!CODE_LIKE_VALUE.test(input.rebootstrapId)
|
|
34
|
+
) {
|
|
35
|
+
throw new ClientSyncError(
|
|
36
|
+
'sync.invalid_request',
|
|
37
|
+
'local rebootstrap rebootstrapId must be a 1–128 character code-like identifier',
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return input.rebootstrapId;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function localDataRebootstrapMetaKey(rebootstrapId: string): string {
|
|
44
|
+
return `localRebootstrap:${rebootstrapId}`;
|
|
45
|
+
}
|
package/src/worker-entry.ts
CHANGED
|
@@ -419,6 +419,8 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
419
419
|
return result.value;
|
|
420
420
|
},
|
|
421
421
|
purgeLocalData: (input) => requireClient().purgeLocalData(input),
|
|
422
|
+
rebootstrapLocalData: (input) =>
|
|
423
|
+
requireClient().rebootstrapLocalData(input),
|
|
422
424
|
sync: () => {
|
|
423
425
|
const running = requireClient();
|
|
424
426
|
return serializedSync(() => running.sync());
|
package/src/worker-host.ts
CHANGED
|
@@ -65,6 +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,
|
|
71
|
+
} from './local-rebootstrap';
|
|
68
72
|
import {
|
|
69
73
|
broadcastChannelFactory,
|
|
70
74
|
type CrossTabChannel,
|
|
@@ -493,6 +497,12 @@ export class SyncClientHandle {
|
|
|
493
497
|
return this.#call('purgeLocalData', [input]);
|
|
494
498
|
}
|
|
495
499
|
|
|
500
|
+
rebootstrapLocalData(
|
|
501
|
+
input: LocalDataRebootstrapInput,
|
|
502
|
+
): Promise<LocalDataRebootstrapResult> {
|
|
503
|
+
return this.#call('rebootstrapLocalData', [input]);
|
|
504
|
+
}
|
|
505
|
+
|
|
496
506
|
sync(): Promise<SyncSummary> {
|
|
497
507
|
return this.#call('sync', []);
|
|
498
508
|
}
|
package/src/worker-protocol.ts
CHANGED
|
@@ -45,6 +45,10 @@ import type {
|
|
|
45
45
|
SyncStatusSnapshot,
|
|
46
46
|
} from './invalidation';
|
|
47
47
|
import type { LocalDataPurgeInput, LocalDataPurgeResult } from './local-purge';
|
|
48
|
+
import type {
|
|
49
|
+
LocalDataRebootstrapInput,
|
|
50
|
+
LocalDataRebootstrapResult,
|
|
51
|
+
} from './local-rebootstrap';
|
|
48
52
|
import type { OutboxCommit } from './outbox';
|
|
49
53
|
import type {
|
|
50
54
|
CommitOutcome,
|
|
@@ -151,6 +155,10 @@ export interface WorkerApi {
|
|
|
151
155
|
): string;
|
|
152
156
|
/** Application-authorized, idempotent local security purge. */
|
|
153
157
|
purgeLocalData(input: LocalDataPurgeInput): LocalDataPurgeResult;
|
|
158
|
+
/** Application-authorized, outbox-preserving projection recovery. */
|
|
159
|
+
rebootstrapLocalData(
|
|
160
|
+
input: LocalDataRebootstrapInput,
|
|
161
|
+
): LocalDataRebootstrapResult;
|
|
154
162
|
sync(): Promise<SyncSummary>;
|
|
155
163
|
syncUntilIdle(maxRounds?: number): Promise<SyncSummary>;
|
|
156
164
|
query(sql: string, params?: readonly SqlValue[]): SqlRow[];
|