@syncular/client 0.15.40 → 0.15.43
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 +11 -4
- package/dist/client.js +81 -34
- package/dist/encryption.d.ts +6 -1
- package/dist/encryption.js +17 -3
- package/dist/reactive-store.js +9 -2
- package/dist/realtime-supervisor.d.ts +17 -2
- package/dist/realtime-supervisor.js +75 -17
- package/dist/schema.js +70 -4
- package/dist/worker-entry.js +13 -1
- package/package.json +3 -3
- package/src/client.ts +99 -45
- package/src/encryption.ts +21 -2
- package/src/reactive-store.ts +10 -4
- package/src/realtime-supervisor.ts +89 -18
- package/src/schema.ts +73 -4
- package/src/worker-entry.ts +15 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.43",
|
|
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,7 +89,7 @@
|
|
|
89
89
|
},
|
|
90
90
|
"dependencies": {
|
|
91
91
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
92
|
-
"@syncular/core": "0.15.
|
|
92
|
+
"@syncular/core": "0.15.43"
|
|
93
93
|
},
|
|
94
94
|
"peerDependencies": {
|
|
95
95
|
"better-sqlite3": ">=11"
|
|
@@ -100,7 +100,7 @@
|
|
|
100
100
|
}
|
|
101
101
|
},
|
|
102
102
|
"devDependencies": {
|
|
103
|
-
"@syncular/server": "0.15.
|
|
103
|
+
"@syncular/server": "0.15.43",
|
|
104
104
|
"@types/better-sqlite3": "^7.6.13",
|
|
105
105
|
"better-sqlite3": "^12.11.1"
|
|
106
106
|
}
|
package/src/client.ts
CHANGED
|
@@ -558,6 +558,16 @@ export class SyncClient {
|
|
|
558
558
|
* (the fast-bail the delta path reads).
|
|
559
559
|
*/
|
|
560
560
|
#syncOutstanding = false;
|
|
561
|
+
/**
|
|
562
|
+
* Bumped by every local projection reset (`rebootstrapLocalData`). A sync
|
|
563
|
+
* round captures this epoch together with its subscription state; a
|
|
564
|
+
* mismatch at SUB_END means a reset landed while the round was in flight,
|
|
565
|
+
* so the captured cursors predate the rewind and persisting them would
|
|
566
|
+
* overwrite it and silently skip the fresh bootstrap. The Rust core is
|
|
567
|
+
* synchronous over `&mut self` and gets this fencing for free; the epoch
|
|
568
|
+
* restores the equivalent semantics across the round's await points.
|
|
569
|
+
*/
|
|
570
|
+
#localResetEpoch = 0;
|
|
561
571
|
/** Retry policy belongs to the operation that classified the failure. */
|
|
562
572
|
#retryDelayMs = 250;
|
|
563
573
|
readonly #hasBlobs: boolean;
|
|
@@ -2124,53 +2134,62 @@ export class SyncClient {
|
|
|
2124
2134
|
const rejectionCount = this.#rejections.length;
|
|
2125
2135
|
try {
|
|
2126
2136
|
return this.#applyBatch((batch) => {
|
|
2127
|
-
const initialRowIds = this.#localPurgeRowIds(purge);
|
|
2128
2137
|
const targetsByTable = this.#localPurgeTargetsByTable(purge);
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
operation.values !== undefined &&
|
|
2147
|
-
targets.some((target) =>
|
|
2148
|
-
localDataPurgeTargetMatches(target, operation.values ?? {}),
|
|
2149
|
-
)
|
|
2150
|
-
) {
|
|
2151
|
-
return true;
|
|
2152
|
-
}
|
|
2153
|
-
const beforeValues = images.get(opIndex)?.values;
|
|
2154
|
-
return (
|
|
2155
|
-
beforeValues !== undefined &&
|
|
2156
|
-
targets.some((target) =>
|
|
2157
|
-
localDataPurgeTargetMatches(target, beforeValues),
|
|
2158
|
-
)
|
|
2138
|
+
// Doomed detection runs to fixpoint, matching the Rust core's rule:
|
|
2139
|
+
// a commit is doomed when any of its ops touches a base-matching
|
|
2140
|
+
// row. Base values only become visible here as rollbacks restore
|
|
2141
|
+
// before-images and rebase later commits' images onto them (a
|
|
2142
|
+
// stacked edit over a moved row initially shows the moved values),
|
|
2143
|
+
// so each pass rolls back what it caught — newest-first, so an
|
|
2144
|
+
// older doomed write never reappears — and re-scans until the
|
|
2145
|
+
// outbox is stable.
|
|
2146
|
+
const doomed: OutboxCommit[] = [];
|
|
2147
|
+
let rowIds = this.#localPurgeRowIds(purge);
|
|
2148
|
+
for (;;) {
|
|
2149
|
+
const caught = listOutbox(this.#db)
|
|
2150
|
+
.filter((commit) => {
|
|
2151
|
+
const images = new Map(
|
|
2152
|
+
listOutboxBeforeImages(this.#db, commit.clientCommitId).map(
|
|
2153
|
+
(image) => [image.opIndex, image],
|
|
2154
|
+
),
|
|
2159
2155
|
);
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2156
|
+
return commit.operations.some((operation, opIndex) => {
|
|
2157
|
+
const targets = targetsByTable.get(operation.table);
|
|
2158
|
+
if (targets === undefined) return false;
|
|
2159
|
+
if (
|
|
2160
|
+
rowIds.get(operation.table)?.has(operation.rowId) === true
|
|
2161
|
+
) {
|
|
2162
|
+
return true;
|
|
2163
|
+
}
|
|
2164
|
+
if (
|
|
2165
|
+
operation.values !== undefined &&
|
|
2166
|
+
targets.some((target) =>
|
|
2167
|
+
localDataPurgeTargetMatches(target, operation.values ?? {}),
|
|
2168
|
+
)
|
|
2169
|
+
) {
|
|
2170
|
+
return true;
|
|
2171
|
+
}
|
|
2172
|
+
const beforeValues = images.get(opIndex)?.values;
|
|
2173
|
+
return (
|
|
2174
|
+
beforeValues !== undefined &&
|
|
2175
|
+
targets.some((target) =>
|
|
2176
|
+
localDataPurgeTargetMatches(target, beforeValues),
|
|
2177
|
+
)
|
|
2178
|
+
);
|
|
2179
|
+
});
|
|
2180
|
+
})
|
|
2181
|
+
.sort((a, b) => b.seq - a.seq);
|
|
2182
|
+
if (caught.length === 0) break;
|
|
2183
|
+
for (const commit of caught) {
|
|
2184
|
+
this.#rollbackFailedCommit(commit, batch);
|
|
2185
|
+
}
|
|
2186
|
+
doomed.push(...caught);
|
|
2187
|
+
// Rollback may reveal a target row hidden by an optimistic delete
|
|
2188
|
+
// or move; the re-selected set drives both the next scan and the
|
|
2189
|
+
// final row deletion below.
|
|
2190
|
+
rowIds = this.#localPurgeRowIds(purge);
|
|
2168
2191
|
}
|
|
2169
2192
|
|
|
2170
|
-
// Rollback may reveal a target row hidden by an optimistic delete or
|
|
2171
|
-
// move, so select the final base/visible set only after doomed commits
|
|
2172
|
-
// have been removed.
|
|
2173
|
-
const rowIds = this.#localPurgeRowIds(purge);
|
|
2174
2193
|
let purgedRows = 0;
|
|
2175
2194
|
for (const [tableName, ids] of rowIds) {
|
|
2176
2195
|
if (ids.size === 0) continue;
|
|
@@ -2296,6 +2315,9 @@ export class SyncClient {
|
|
|
2296
2315
|
this.#needsPull = priorNeedsPull;
|
|
2297
2316
|
throw error;
|
|
2298
2317
|
}
|
|
2318
|
+
// Fence any in-flight sync round: its captured subscription state now
|
|
2319
|
+
// predates the rewind, so its SUB_END cursors must stay unpersisted.
|
|
2320
|
+
this.#localResetEpoch += 1;
|
|
2299
2321
|
|
|
2300
2322
|
if (!priorUpgrading) this.#config.onUpgrading?.(true);
|
|
2301
2323
|
this.#config.onSyncNeeded?.('startup');
|
|
@@ -2575,6 +2597,9 @@ export class SyncClient {
|
|
|
2575
2597
|
// mapping.
|
|
2576
2598
|
const { pushFrames, outbox, deferred } =
|
|
2577
2599
|
await this.#encodeOutboxForPush();
|
|
2600
|
+
// Captured together with the subscription state below: the response
|
|
2601
|
+
// apply persists SUB_END cursors only while this epoch is current.
|
|
2602
|
+
const resetEpoch = this.#localResetEpoch;
|
|
2578
2603
|
const subs = loadSubscriptions(this.#db).filter(
|
|
2579
2604
|
(sub) => sub.status === 'active',
|
|
2580
2605
|
);
|
|
@@ -2625,6 +2650,7 @@ export class SyncClient {
|
|
|
2625
2650
|
outbox,
|
|
2626
2651
|
subs,
|
|
2627
2652
|
'pull',
|
|
2653
|
+
resetEpoch,
|
|
2628
2654
|
);
|
|
2629
2655
|
// §4.8 E1: the push half may have drained commits that pinned rows of
|
|
2630
2656
|
// a shrunk window unit — retry any deferred evictions now.
|
|
@@ -2998,6 +3024,7 @@ export class SyncClient {
|
|
|
2998
3024
|
sentCommits: readonly OutboxCommit[],
|
|
2999
3025
|
sentSubs: readonly SubscriptionRecord[] | undefined,
|
|
3000
3026
|
mode: 'pull' | 'delta',
|
|
3027
|
+
resetEpoch: number = this.#localResetEpoch,
|
|
3001
3028
|
): Promise<SyncSummary> {
|
|
3002
3029
|
const summary = emptySummary(sentCommits.length);
|
|
3003
3030
|
const commitsById = new Map(
|
|
@@ -3258,7 +3285,12 @@ export class SyncClient {
|
|
|
3258
3285
|
if (
|
|
3259
3286
|
section !== undefined &&
|
|
3260
3287
|
!section.skip &&
|
|
3261
|
-
section.sub !== undefined
|
|
3288
|
+
section.sub !== undefined &&
|
|
3289
|
+
// Reset fence: a rebootstrap that landed mid-round already
|
|
3290
|
+
// rewound this subscription's cursor; the captured SUB_END
|
|
3291
|
+
// state predates the rewind and must stay unpersisted so the
|
|
3292
|
+
// next round runs the fresh bootstrap.
|
|
3293
|
+
this.#localResetEpoch === resetEpoch
|
|
3262
3294
|
) {
|
|
3263
3295
|
const applied = this.#finishSection(
|
|
3264
3296
|
section.sub,
|
|
@@ -3315,7 +3347,14 @@ export class SyncClient {
|
|
|
3315
3347
|
.map((sub) => sub.id);
|
|
3316
3348
|
// §7.4.5: the reset is over once the first post-reset pull round leaves
|
|
3317
3349
|
// no subscription mid-bootstrap — the tables are rebuilt and current.
|
|
3318
|
-
|
|
3350
|
+
// The epoch check keeps a round that predates a mid-flight rebootstrap
|
|
3351
|
+
// from declaring that reset finished before its bootstrap ran.
|
|
3352
|
+
if (
|
|
3353
|
+
this.#upgrading &&
|
|
3354
|
+
mode === 'pull' &&
|
|
3355
|
+
bootstrapping.length === 0 &&
|
|
3356
|
+
this.#localResetEpoch === resetEpoch
|
|
3357
|
+
) {
|
|
3319
3358
|
this.#setUpgrading(false);
|
|
3320
3359
|
}
|
|
3321
3360
|
return { ...summary, bootstrapping };
|
|
@@ -3331,6 +3370,12 @@ export class SyncClient {
|
|
|
3331
3370
|
): boolean {
|
|
3332
3371
|
const commit = commitsById.get(frame.clientCommitId);
|
|
3333
3372
|
if (commit === undefined) return false;
|
|
3373
|
+
// `commitsById` is the round's send-time snapshot. The commit can leave
|
|
3374
|
+
// the outbox before this frame is handled — a local purge doomed it while
|
|
3375
|
+
// the round was in flight, or an earlier duplicate frame in this response
|
|
3376
|
+
// already drained it. Its recorded outcome stands, and skipping keeps the
|
|
3377
|
+
// cached outbox count exact (a drain may only be counted once).
|
|
3378
|
+
if (!this.#outboxCommitExists(frame.clientCommitId)) return false;
|
|
3334
3379
|
if (frame.status === 'applied' || frame.status === 'cached') {
|
|
3335
3380
|
// §6.3: applied and cached both drain the outbox — cached means
|
|
3336
3381
|
// "already applied, you may have missed the ack".
|
|
@@ -3427,6 +3472,15 @@ export class SyncClient {
|
|
|
3427
3472
|
return true;
|
|
3428
3473
|
}
|
|
3429
3474
|
|
|
3475
|
+
#outboxCommitExists(clientCommitId: string): boolean {
|
|
3476
|
+
return (
|
|
3477
|
+
this.#db.query(
|
|
3478
|
+
'SELECT 1 FROM _syncular_outbox WHERE client_commit_id = ? LIMIT 1',
|
|
3479
|
+
[clientCommitId],
|
|
3480
|
+
).length > 0
|
|
3481
|
+
);
|
|
3482
|
+
}
|
|
3483
|
+
|
|
3430
3484
|
#decodeServerRow(
|
|
3431
3485
|
tableName: string | undefined,
|
|
3432
3486
|
payload: Uint8Array,
|
package/src/encryption.ts
CHANGED
|
@@ -13,11 +13,13 @@ import {
|
|
|
13
13
|
DecryptError,
|
|
14
14
|
decryptValue,
|
|
15
15
|
encryptValue,
|
|
16
|
+
KEY_LENGTH,
|
|
16
17
|
type NonceSource,
|
|
17
18
|
type PlainValue,
|
|
18
19
|
type RowColumn,
|
|
19
20
|
type RowValue,
|
|
20
21
|
} from '@syncular/core';
|
|
22
|
+
import { ClientSyncError } from './errors';
|
|
21
23
|
import type { CompiledClientTable } from './schema';
|
|
22
24
|
|
|
23
25
|
/**
|
|
@@ -59,12 +61,29 @@ export interface EncryptionKeyringConfig {
|
|
|
59
61
|
readonly keyIdColumns?: Readonly<Record<string, string>>;
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* Convert a portable keyring into the direct-client encryption contract.
|
|
66
|
+
* Key lengths are validated here, at install time: every side of the wire
|
|
67
|
+
* enforces {@link KEY_LENGTH}-byte keys, so a wrong-length key fails loud
|
|
68
|
+
* with a clear config error before any sync round runs.
|
|
69
|
+
*/
|
|
63
70
|
export function encryptionConfigFromKeyring(
|
|
64
71
|
keyring: EncryptionKeyringConfig,
|
|
65
72
|
): EncryptionConfig {
|
|
73
|
+
for (const [keyId, key] of Object.entries(keyring.keys)) {
|
|
74
|
+
if (key.length !== KEY_LENGTH) {
|
|
75
|
+
throw new ClientSyncError(
|
|
76
|
+
'sync.invalid_request',
|
|
77
|
+
`encryption key ${JSON.stringify(keyId)} must be ${KEY_LENGTH} bytes (AES-256), got ${key.length}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
66
81
|
return {
|
|
67
|
-
|
|
82
|
+
// Own-property lookup only: the keyId arrives from the wire envelope, so
|
|
83
|
+
// a prototype-chain name like "constructor" must read as a missing key
|
|
84
|
+
// and take the clean DecryptError path.
|
|
85
|
+
keyProvider: (keyId) =>
|
|
86
|
+
Object.hasOwn(keyring.keys, keyId) ? keyring.keys[keyId] : undefined,
|
|
68
87
|
...(keyring.keyIdColumns !== undefined
|
|
69
88
|
? { keyIdColumns: keyring.keyIdColumns }
|
|
70
89
|
: {}),
|
package/src/reactive-store.ts
CHANGED
|
@@ -850,10 +850,16 @@ export class ReactiveClientStore {
|
|
|
850
850
|
// Releasing a window is best-effort teardown. A resource owner may have
|
|
851
851
|
// already closed the underlying worker/native handle before React effect
|
|
852
852
|
// cleanup runs (notably during schema-changing HMR). Do not let that
|
|
853
|
-
// harmless ordering race escape as an unhandled rejection
|
|
854
|
-
void
|
|
855
|
-
|
|
856
|
-
|
|
853
|
+
// harmless ordering race escape as an unhandled rejection — and the
|
|
854
|
+
// interface permits a plain void return, so a closed handle may also
|
|
855
|
+
// throw synchronously; swallow that the same way.
|
|
856
|
+
try {
|
|
857
|
+
void Promise.resolve(this.client.setWindow(group.base, [])).catch(
|
|
858
|
+
() => undefined,
|
|
859
|
+
);
|
|
860
|
+
} catch {
|
|
861
|
+
// Same teardown race, surfaced synchronously.
|
|
862
|
+
}
|
|
857
863
|
}
|
|
858
864
|
this.#windowClaims.clear();
|
|
859
865
|
}
|
|
@@ -53,8 +53,18 @@ export interface RealtimeSupervisorSnapshot {
|
|
|
53
53
|
export interface RealtimeSupervisorOptions {
|
|
54
54
|
/** Host online/offline evidence. Unknown remains connectable and observable. */
|
|
55
55
|
readonly connectivity?: RealtimeSupervisorSignal<ClientDiagnosticsConnectivity>;
|
|
56
|
-
/** Browser/native foreground evidence. Background
|
|
56
|
+
/** Browser/native foreground evidence. Background suspends a tab-owned
|
|
57
|
+
* socket; with `sharedTransport` it only serves as a resume nudge. */
|
|
57
58
|
readonly lifecycle?: RealtimeSupervisorSignal<RealtimeSupervisorLifecycleState>;
|
|
59
|
+
/**
|
|
60
|
+
* The client's realtime transport is shared with sibling tabs or webviews
|
|
61
|
+
* (multi-tab handles proxying to one leader socket, native cores behind
|
|
62
|
+
* several views). Backgrounding THIS view then keeps the socket open,
|
|
63
|
+
* because a sibling may still be visible and a follower's disconnect would
|
|
64
|
+
* tear down realtime for everyone. Offline and protection evidence still
|
|
65
|
+
* suspend.
|
|
66
|
+
*/
|
|
67
|
+
readonly sharedTransport?: boolean;
|
|
58
68
|
/** Publish preflight before draining keys so reconnect stops in the same turn. */
|
|
59
69
|
readonly protection?: RealtimeSupervisorSignal<RealtimeSupervisorProtectionState>;
|
|
60
70
|
/** Deterministic test/host timer seam. */
|
|
@@ -215,6 +225,7 @@ export class RealtimeSupervisor {
|
|
|
215
225
|
readonly #connectivity?: RealtimeSupervisorOptions['connectivity'];
|
|
216
226
|
readonly #lifecycle?: RealtimeSupervisorOptions['lifecycle'];
|
|
217
227
|
readonly #protection?: RealtimeSupervisorOptions['protection'];
|
|
228
|
+
readonly #sharedTransport: boolean;
|
|
218
229
|
readonly #scheduleTimer: NonNullable<RealtimeSupervisorOptions['schedule']>;
|
|
219
230
|
readonly #random: () => number;
|
|
220
231
|
readonly #initialDelayMs: number;
|
|
@@ -247,6 +258,7 @@ export class RealtimeSupervisor {
|
|
|
247
258
|
this.#connectivity = options.connectivity;
|
|
248
259
|
this.#lifecycle = options.lifecycle;
|
|
249
260
|
this.#protection = options.protection;
|
|
261
|
+
this.#sharedTransport = options.sharedTransport === true;
|
|
250
262
|
this.#scheduleTimer = options.schedule ?? scheduleTimer;
|
|
251
263
|
this.#random = options.random ?? Math.random;
|
|
252
264
|
this.#initialDelayMs = boundedDelay(
|
|
@@ -304,6 +316,7 @@ export class RealtimeSupervisor {
|
|
|
304
316
|
this.#unsubscribeLifecycle?.();
|
|
305
317
|
this.#unsubscribeProtection?.();
|
|
306
318
|
this.#publish({ phase: 'stopped', attempt: 0 });
|
|
319
|
+
this.#listeners.clear();
|
|
307
320
|
if (disconnect) this.#disconnect();
|
|
308
321
|
}
|
|
309
322
|
|
|
@@ -336,7 +349,9 @@ export class RealtimeSupervisor {
|
|
|
336
349
|
) {
|
|
337
350
|
return 'offline';
|
|
338
351
|
}
|
|
339
|
-
if (this.#lifecycle?.current() === 'background')
|
|
352
|
+
if (!this.#sharedTransport && this.#lifecycle?.current() === 'background') {
|
|
353
|
+
return 'background';
|
|
354
|
+
}
|
|
340
355
|
return undefined;
|
|
341
356
|
}
|
|
342
357
|
|
|
@@ -410,43 +425,77 @@ export class RealtimeSupervisor {
|
|
|
410
425
|
this.#reconcileHostState();
|
|
411
426
|
return;
|
|
412
427
|
}
|
|
428
|
+
// Count an attempt only when this call actually schedules one. Repeated
|
|
429
|
+
// 'disconnected' diagnostics beside a pending retry keep the backoff and
|
|
430
|
+
// the worker-mode reconnect nudge intact.
|
|
431
|
+
if (
|
|
432
|
+
this.#connected ||
|
|
433
|
+
this.#connecting ||
|
|
434
|
+
this.#cancelRetry !== undefined
|
|
435
|
+
) {
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
413
438
|
const delay = this.#retryDelay();
|
|
414
439
|
this.#attempt = Math.min(32, this.#attempt + 1);
|
|
415
440
|
this.#schedule(delay);
|
|
416
441
|
}
|
|
417
442
|
|
|
443
|
+
/**
|
|
444
|
+
* A superseded attempt observed a generation bump mid-flight. Whenever a
|
|
445
|
+
* newer generation is mid-connect or holds the transport, that generation
|
|
446
|
+
* owns the shared socket and the stale attempt leaves every field alone.
|
|
447
|
+
* With the supervisor otherwise quiescent (suspended or stopped), the
|
|
448
|
+
* socket this attempt opened is unwanted — release it.
|
|
449
|
+
*/
|
|
450
|
+
#releaseSupersededSocket(): void {
|
|
451
|
+
if (this.#connecting || this.#connected || this.#transportConnected) {
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
this.#disconnect();
|
|
455
|
+
}
|
|
456
|
+
|
|
418
457
|
async #connect(): Promise<void> {
|
|
419
458
|
if (!this.#canConnect() || this.#connected || this.#connecting) return;
|
|
459
|
+
const generation = this.#generation;
|
|
420
460
|
this.#connecting = true;
|
|
421
461
|
this.#publish({ phase: 'connecting', attempt: this.#attempt });
|
|
422
|
-
const generation = this.#generation;
|
|
423
462
|
let failed = false;
|
|
424
463
|
try {
|
|
425
464
|
await this.#client.connectRealtime();
|
|
465
|
+
if (generation !== this.#generation) {
|
|
466
|
+
this.#releaseSupersededSocket();
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
426
469
|
this.#transportConnected = true;
|
|
427
|
-
if (
|
|
470
|
+
if (!this.#canConnect()) {
|
|
471
|
+
this.#transportConnected = false;
|
|
428
472
|
this.#disconnect();
|
|
429
473
|
return;
|
|
430
474
|
}
|
|
431
475
|
await this.#catchUpConnectedTransport(generation);
|
|
432
476
|
} catch {
|
|
477
|
+
if (generation !== this.#generation) {
|
|
478
|
+
this.#releaseSupersededSocket();
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
433
481
|
failed = true;
|
|
434
482
|
this.#connected = false;
|
|
435
483
|
this.#transportConnected = false;
|
|
436
484
|
this.#disconnect();
|
|
437
485
|
} finally {
|
|
438
|
-
|
|
486
|
+
// A superseded attempt leaves the successor's in-flight state alone.
|
|
487
|
+
if (generation === this.#generation) this.#connecting = false;
|
|
439
488
|
}
|
|
440
489
|
if (failed && generation === this.#generation) this.#scheduleRetry();
|
|
441
490
|
}
|
|
442
491
|
|
|
443
492
|
async #catchUpConnectedTransport(generation: number): Promise<void> {
|
|
444
493
|
await this.#client.syncUntilIdle();
|
|
445
|
-
if (
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
) {
|
|
494
|
+
if (generation !== this.#generation) {
|
|
495
|
+
this.#releaseSupersededSocket();
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (!this.#canConnect() || !this.#transportConnected) {
|
|
450
499
|
this.#disconnect();
|
|
451
500
|
return;
|
|
452
501
|
}
|
|
@@ -459,20 +508,25 @@ export class RealtimeSupervisor {
|
|
|
459
508
|
if (!this.#canConnect() || this.#connecting || !this.#transportConnected) {
|
|
460
509
|
return;
|
|
461
510
|
}
|
|
511
|
+
const generation = this.#generation;
|
|
462
512
|
this.#connecting = true;
|
|
463
513
|
this.#clearRetry();
|
|
464
514
|
this.#publish({ phase: 'connecting', attempt: this.#attempt });
|
|
465
|
-
const generation = this.#generation;
|
|
466
515
|
let failed = false;
|
|
467
516
|
try {
|
|
468
517
|
await this.#catchUpConnectedTransport(generation);
|
|
469
518
|
} catch {
|
|
519
|
+
if (generation !== this.#generation) {
|
|
520
|
+
this.#releaseSupersededSocket();
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
470
523
|
failed = true;
|
|
471
524
|
this.#connected = false;
|
|
472
525
|
this.#transportConnected = false;
|
|
473
526
|
this.#disconnect();
|
|
474
527
|
} finally {
|
|
475
|
-
|
|
528
|
+
// A superseded adoption leaves the successor's in-flight state alone.
|
|
529
|
+
if (generation === this.#generation) this.#connecting = false;
|
|
476
530
|
}
|
|
477
531
|
if (failed && generation === this.#generation) this.#scheduleRetry();
|
|
478
532
|
}
|
|
@@ -497,8 +551,13 @@ export class RealtimeSupervisor {
|
|
|
497
551
|
if (disconnect) this.#disconnect();
|
|
498
552
|
return;
|
|
499
553
|
}
|
|
500
|
-
if (!this.#realtimeSupported)
|
|
501
|
-
|
|
554
|
+
if (!this.#realtimeSupported) {
|
|
555
|
+
// A live socket is positive evidence the earlier 'unsupported' report
|
|
556
|
+
// was transient (a mid-restart or mis-detected capability probe), so it
|
|
557
|
+
// recovers the supervisor. Every other report keeps the latch closed.
|
|
558
|
+
if (snapshot.host.realtime !== 'connected') return;
|
|
559
|
+
this.#realtimeSupported = true;
|
|
560
|
+
}
|
|
502
561
|
const block = this.#hostBlock();
|
|
503
562
|
if (block) {
|
|
504
563
|
this.#suspend(block);
|
|
@@ -506,8 +565,10 @@ export class RealtimeSupervisor {
|
|
|
506
565
|
}
|
|
507
566
|
if (snapshot.host.realtime === 'connected') {
|
|
508
567
|
this.#transportConnected = true;
|
|
509
|
-
|
|
510
|
-
|
|
568
|
+
// Steady state: diagnostics re-announce 'connected' on every sync
|
|
569
|
+
// round and subscription change. An already-adopted transport keeps
|
|
570
|
+
// its phase and skips the redundant catch-up.
|
|
571
|
+
if (this.#connecting || this.#connected) return;
|
|
511
572
|
void this.#adoptConnectedTransport();
|
|
512
573
|
return;
|
|
513
574
|
}
|
|
@@ -566,12 +627,22 @@ export class RealtimeSupervisor {
|
|
|
566
627
|
}
|
|
567
628
|
}
|
|
568
629
|
|
|
569
|
-
/**
|
|
630
|
+
/**
|
|
631
|
+
* Install one supervisor and make client disposal cancel it before close.
|
|
632
|
+
* A client accepts exactly one supervisor for its lifetime; installing a
|
|
633
|
+
* second throws, because silently keeping the first would discard the second
|
|
634
|
+
* call's options.
|
|
635
|
+
*/
|
|
570
636
|
export function installRealtimeSupervisor<T extends RealtimeSupervisorClient>(
|
|
571
637
|
client: T,
|
|
572
638
|
options?: RealtimeSupervisorOptions,
|
|
573
639
|
): T {
|
|
574
|
-
if (attachment(client))
|
|
640
|
+
if (attachment(client)) {
|
|
641
|
+
throw new Error(
|
|
642
|
+
'installRealtimeSupervisor: this client already has a supervisor; ' +
|
|
643
|
+
'install one supervisor per client lifetime',
|
|
644
|
+
);
|
|
645
|
+
}
|
|
575
646
|
const supervisor = new RealtimeSupervisor(client, options);
|
|
576
647
|
Object.defineProperty(client, REALTIME_SUPERVISOR_KEY, {
|
|
577
648
|
configurable: false,
|
package/src/schema.ts
CHANGED
|
@@ -342,6 +342,22 @@ function createSyncedTable(
|
|
|
342
342
|
|
|
343
343
|
const FTS_SOURCE_ID_COLUMN = '_syncular_source_id';
|
|
344
344
|
|
|
345
|
+
/**
|
|
346
|
+
* Create (or migrate) one contentful FTS5 projection for a table plus its
|
|
347
|
+
* synchronizing triggers. Every open drops and recreates the full trigger set
|
|
348
|
+
* (`_bi`, `_ai`, `_ad`, `_au`, and, when the table has a unique index, `_bu`),
|
|
349
|
+
* so existing databases pick up newly added guards. The BEFORE INSERT (`_bi`)
|
|
350
|
+
* and BEFORE UPDATE (`_bu`) guards remove projection rows for entries displaced
|
|
351
|
+
* by `INSERT OR REPLACE` / `UPDATE OR REPLACE` through the primary key or a
|
|
352
|
+
* secondary unique index — cases where SQLite does not reliably fire the AFTER
|
|
353
|
+
* DELETE trigger for the displaced row.
|
|
354
|
+
*
|
|
355
|
+
* Limitation: because a BEFORE trigger runs before SQLite resolves the
|
|
356
|
+
* conflict, an `OR IGNORE` write whose row is dropped still executes these
|
|
357
|
+
* displacement DELETEs, transiently removing the surviving row's projection
|
|
358
|
+
* entry. Syncular's mirror writes never use `OR IGNORE`; see the guard-block
|
|
359
|
+
* comment below for the full rationale.
|
|
360
|
+
*/
|
|
345
361
|
function createFtsProjection(
|
|
346
362
|
db: ClientDatabase,
|
|
347
363
|
table: CompiledClientTable,
|
|
@@ -371,18 +387,58 @@ function createFtsProjection(
|
|
|
371
387
|
].join(', ');
|
|
372
388
|
const deleteFor = (value: string) =>
|
|
373
389
|
`DELETE FROM ${quoteIdent(index.name)} WHERE ${quoteIdent(FTS_SOURCE_ID_COLUMN)} = ${value}`;
|
|
390
|
+
const deleteDisplaced = (select: string) =>
|
|
391
|
+
`DELETE FROM ${quoteIdent(index.name)} WHERE ${quoteIdent(FTS_SOURCE_ID_COLUMN)} IN (${select})`;
|
|
374
392
|
|
|
375
393
|
// A clean insert cannot already have a projection row because the source
|
|
376
394
|
// primary key is unique. Keep clean inserts linear by moving replacement
|
|
377
|
-
// cleanup behind
|
|
378
|
-
// reliably invoke DELETE triggers for
|
|
379
|
-
|
|
395
|
+
// cleanup behind indexed source-table existence checks. SQLite does not
|
|
396
|
+
// reliably invoke DELETE triggers for rows displaced by REPLACE, and a
|
|
397
|
+
// REPLACE can displace rows through TWO paths: the primary key AND any
|
|
398
|
+
// secondary UNIQUE index (a different-PK row whose unique key matches the
|
|
399
|
+
// incoming row). The BEFORE INSERT guard covers both for `INSERT OR REPLACE`;
|
|
400
|
+
// the mirroring BEFORE UPDATE guard covers `UPDATE OR REPLACE` that pushes a
|
|
401
|
+
// different-PK row out through a unique index (the AFTER UPDATE trigger only
|
|
402
|
+
// knows the updated row's own old/new ids, so it would leave that displaced
|
|
403
|
+
// row's projection entry behind as a ghost hit). Primary-key displacement by
|
|
404
|
+
// `UPDATE OR REPLACE` needs no BEFORE guard: the new pk equals the displaced
|
|
405
|
+
// row's pk, so the AFTER UPDATE delete of the new source id already clears it.
|
|
406
|
+
//
|
|
407
|
+
// Known limitation — `OR IGNORE`: a BEFORE trigger fires before SQLite
|
|
408
|
+
// resolves the conflict, and SQLite exposes no signal for the eventual
|
|
409
|
+
// resolution. So an `INSERT OR IGNORE` / `UPDATE OR IGNORE` whose row is
|
|
410
|
+
// dropped on conflict still runs these displacement DELETEs, removing the
|
|
411
|
+
// SURVIVING row's projection entry (a false negative until that row is next
|
|
412
|
+
// rewritten). Moving cleanup to AFTER triggers would dodge the no-op but
|
|
413
|
+
// reintroduce the REPLACE-doesn't-fire-DELETE gap and a full-projection scan
|
|
414
|
+
// per write, so the guards stay BEFORE. Syncular's own mirror writes use
|
|
415
|
+
// `INSERT … ON CONFLICT (pk) DO UPDATE` (apply.ts), which never takes the
|
|
416
|
+
// IGNORE path; only hand-written `OR IGNORE` against a mirror table is exposed.
|
|
417
|
+
for (const suffix of ['bi', 'ai', 'ad', 'au', 'bu']) {
|
|
380
418
|
db.exec(`DROP TRIGGER IF EXISTS ${quoteIdent(`${index.name}_${suffix}`)}`);
|
|
381
419
|
}
|
|
382
420
|
const replacementExists = `EXISTS (SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = new.${quoteIdent(table.primaryKey)})`;
|
|
421
|
+
// Per secondary UNIQUE index: the different-PK rows about to be displaced
|
|
422
|
+
// via that unique key. `=` makes NULL unique values match nothing, which is
|
|
423
|
+
// exactly SQLite's unique-index semantics (NULLs never conflict).
|
|
424
|
+
const uniqueIndexes = table.indexes.filter((spec) => spec.unique);
|
|
425
|
+
const displacedByUnique = uniqueIndexes.map((spec) => {
|
|
426
|
+
const match = spec.columns
|
|
427
|
+
.map((column) => `${quoteIdent(column)} = new.${quoteIdent(column)}`)
|
|
428
|
+
.join(' AND ');
|
|
429
|
+
return `SELECT ${sourceId} FROM ${quoteIdent(table.name)} WHERE ${match} AND ${quoteIdent(table.primaryKey)} != new.${quoteIdent(table.primaryKey)}`;
|
|
430
|
+
});
|
|
431
|
+
const insertGuardCondition = [
|
|
432
|
+
replacementExists,
|
|
433
|
+
...displacedByUnique.map((select) => `EXISTS (${select})`),
|
|
434
|
+
].join(' OR ');
|
|
435
|
+
const insertGuardBody = [
|
|
436
|
+
deleteFor(newSourceId),
|
|
437
|
+
...displacedByUnique.map(deleteDisplaced),
|
|
438
|
+
].join('; ');
|
|
383
439
|
|
|
384
440
|
db.exec(
|
|
385
|
-
`CREATE TRIGGER ${quoteIdent(`${index.name}_bi`)} BEFORE INSERT ON ${quoteIdent(table.name)} WHEN ${
|
|
441
|
+
`CREATE TRIGGER ${quoteIdent(`${index.name}_bi`)} BEFORE INSERT ON ${quoteIdent(table.name)} WHEN ${insertGuardCondition} BEGIN ${insertGuardBody}; END`,
|
|
386
442
|
);
|
|
387
443
|
db.exec(
|
|
388
444
|
`CREATE TRIGGER ${quoteIdent(`${index.name}_ai`)} AFTER INSERT ON ${quoteIdent(table.name)} BEGIN INSERT INTO ${quoteIdent(index.name)} (${projectionColumns}) VALUES (${newValues}); END`,
|
|
@@ -393,6 +449,19 @@ function createFtsProjection(
|
|
|
393
449
|
db.exec(
|
|
394
450
|
`CREATE TRIGGER ${quoteIdent(`${index.name}_au`)} AFTER UPDATE ON ${quoteIdent(table.name)} BEGIN ${deleteFor(oldSourceId)}; ${deleteFor(newSourceId)}; INSERT INTO ${quoteIdent(index.name)} (${projectionColumns}) VALUES (${newValues}); END`,
|
|
395
451
|
);
|
|
452
|
+
// BEFORE UPDATE guard for `UPDATE OR REPLACE`: clear the projection of any
|
|
453
|
+
// different-PK row about to be displaced through a secondary unique index by
|
|
454
|
+
// the new values. Only meaningful when the table has a unique index; with
|
|
455
|
+
// none, no update can displace a foreign row, so the trigger is omitted.
|
|
456
|
+
if (displacedByUnique.length > 0) {
|
|
457
|
+
const updateGuardCondition = displacedByUnique
|
|
458
|
+
.map((select) => `EXISTS (${select})`)
|
|
459
|
+
.join(' OR ');
|
|
460
|
+
const updateGuardBody = displacedByUnique.map(deleteDisplaced).join('; ');
|
|
461
|
+
db.exec(
|
|
462
|
+
`CREATE TRIGGER ${quoteIdent(`${index.name}_bu`)} BEFORE UPDATE ON ${quoteIdent(table.name)} WHEN ${updateGuardCondition} BEGIN ${updateGuardBody}; END`,
|
|
463
|
+
);
|
|
464
|
+
}
|
|
396
465
|
|
|
397
466
|
if (!existed) {
|
|
398
467
|
db.exec(
|
package/src/worker-entry.ts
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
type WorkerErrorShape,
|
|
43
43
|
type WorkerInitConfig,
|
|
44
44
|
type WorkerInitResult,
|
|
45
|
+
type WorkerMethod,
|
|
45
46
|
type WorkerToMainMessage,
|
|
46
47
|
} from './worker-protocol';
|
|
47
48
|
|
|
@@ -491,9 +492,22 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
491
492
|
},
|
|
492
493
|
};
|
|
493
494
|
|
|
495
|
+
// Local purge/rebootstrap rewrite the same durable state an in-flight
|
|
496
|
+
// sync round captured at send time; running their RPCs on the sync chain
|
|
497
|
+
// orders them against RPC- and auto-driven rounds (the client core's
|
|
498
|
+
// reset fence covers hosts that call the core directly).
|
|
499
|
+
const syncChainMethods: ReadonlySet<WorkerMethod> = new Set<WorkerMethod>([
|
|
500
|
+
'purgeLocalData',
|
|
501
|
+
'rebootstrapLocalData',
|
|
502
|
+
]);
|
|
503
|
+
|
|
494
504
|
async function dispatch(message: WorkerCallMessage): Promise<unknown> {
|
|
495
505
|
const method = api[message.method] as (...args: unknown[]) => unknown;
|
|
496
|
-
|
|
506
|
+
const invoke = () => method.apply(api, message.args as unknown[]);
|
|
507
|
+
if (syncChainMethods.has(message.method)) {
|
|
508
|
+
return await serializedSync(async () => invoke());
|
|
509
|
+
}
|
|
510
|
+
return await invoke();
|
|
497
511
|
}
|
|
498
512
|
|
|
499
513
|
function run(
|