@syncular/client 0.4.1 → 0.5.0
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 +4 -4
- package/dist/apply.d.ts +5 -1
- package/dist/apply.js +6 -4
- package/dist/client.d.ts +49 -2
- package/dist/client.js +527 -259
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/invalidation.d.ts +81 -48
- package/dist/invalidation.js +130 -42
- package/dist/reactive-store.d.ts +74 -0
- package/dist/reactive-store.js +576 -0
- package/dist/schema.js +1 -0
- package/dist/state.d.ts +9 -0
- package/dist/state.js +29 -0
- package/dist/window.d.ts +6 -1
- package/dist/window.js +0 -0
- package/dist/worker-entry.js +78 -52
- package/dist/worker-host.d.ts +8 -4
- package/dist/worker-host.js +26 -6
- package/dist/worker-protocol.d.ts +12 -14
- package/package.json +3 -3
- package/src/apply.ts +18 -5
- package/src/client.ts +685 -311
- package/src/index.ts +1 -0
- package/src/invalidation.ts +216 -62
- package/src/reactive-store.ts +695 -0
- package/src/schema.ts +3 -0
- package/src/state.ts +32 -0
- package/src/window.ts +0 -0
- package/src/worker-entry.ts +83 -54
- package/src/worker-host.ts +44 -8
- package/src/worker-protocol.ts +20 -13
package/src/state.ts
CHANGED
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { ScopeMap } from '@syncular/core';
|
|
8
8
|
import type { ClientDatabase } from './database';
|
|
9
|
+
import type { LocalRevision } from './invalidation';
|
|
10
|
+
|
|
11
|
+
export const LOCAL_REVISION_KEY = 'localRevision';
|
|
12
|
+
const MAX_U64 = 18_446_744_073_709_551_615n;
|
|
9
13
|
|
|
10
14
|
export type SubscriptionStatus = 'active' | 'revoked' | 'failed';
|
|
11
15
|
|
|
@@ -124,3 +128,31 @@ export function setMeta(db: ClientDatabase, key: string, value: string): void {
|
|
|
124
128
|
value,
|
|
125
129
|
]);
|
|
126
130
|
}
|
|
131
|
+
|
|
132
|
+
/** Read the durable client-local observer revision (SPEC §7.5). */
|
|
133
|
+
export function getLocalRevision(db: ClientDatabase): LocalRevision {
|
|
134
|
+
const raw = getMeta(db, LOCAL_REVISION_KEY);
|
|
135
|
+
if (raw === undefined) return 0n;
|
|
136
|
+
if (!/^(0|[1-9][0-9]*)$/.test(raw)) {
|
|
137
|
+
throw new Error(`invalid persisted local revision ${JSON.stringify(raw)}`);
|
|
138
|
+
}
|
|
139
|
+
const revision = BigInt(raw);
|
|
140
|
+
if (revision > MAX_U64) {
|
|
141
|
+
throw new Error(`persisted local revision exceeds u64: ${raw}`);
|
|
142
|
+
}
|
|
143
|
+
return revision;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Increment the durable revision. The caller MUST own the same transaction as
|
|
148
|
+
* the observer-visible writes represented by the corresponding change batch.
|
|
149
|
+
*/
|
|
150
|
+
export function bumpLocalRevision(db: ClientDatabase): LocalRevision {
|
|
151
|
+
const current = getLocalRevision(db);
|
|
152
|
+
if (current === MAX_U64) {
|
|
153
|
+
throw new Error('local revision exhausted u64');
|
|
154
|
+
}
|
|
155
|
+
const next = current + 1n;
|
|
156
|
+
setMeta(db, LOCAL_REVISION_KEY, next.toString());
|
|
157
|
+
return next;
|
|
158
|
+
}
|
package/src/window.ts
CHANGED
|
Binary file
|
package/src/worker-entry.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* same bootstrap, the same RPC, a different SQLite.
|
|
10
10
|
*
|
|
11
11
|
* The §8.4 host loop lives HERE: wake-ups (hello/`sync` events, delta
|
|
12
|
-
* drops) coalesce into one
|
|
12
|
+
* drops) coalesce into one intent-driven `syncUntilIdle` round in the worker;
|
|
13
13
|
* RPC-driven and auto-driven sync rounds serialize on one queue because
|
|
14
14
|
* the core owns exactly one loop.
|
|
15
15
|
*/
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
httpSyncTransport,
|
|
23
23
|
webSocketRealtimeConnector,
|
|
24
24
|
} from './http';
|
|
25
|
+
import type { CommandEffects, SyncIntent } from './invalidation';
|
|
25
26
|
import type {
|
|
26
27
|
RealtimeConnector,
|
|
27
28
|
SegmentDownloader,
|
|
@@ -133,36 +134,67 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
133
134
|
return next;
|
|
134
135
|
}
|
|
135
136
|
|
|
136
|
-
// -- §
|
|
137
|
+
// -- SPEC §7.5 host loop: event-driven intents, no interactive polling --
|
|
137
138
|
let autoSync = true;
|
|
138
|
-
let wakeJitterMs = 200;
|
|
139
139
|
let autoSyncScheduled = false;
|
|
140
|
-
|
|
140
|
+
let backgroundTimer: ReturnType<typeof setTimeout> | undefined;
|
|
141
|
+
let backgroundDue = Number.POSITIVE_INFINITY;
|
|
142
|
+
|
|
143
|
+
function runAutoSync(): void {
|
|
144
|
+
autoSyncScheduled = false;
|
|
145
|
+
if (closed || client === undefined) return;
|
|
146
|
+
const running = client;
|
|
147
|
+
void serializedSync(() => running.syncUntilIdle())
|
|
148
|
+
.then((summary) => {
|
|
149
|
+
if (!closed) post({ t: 'event', event: { kind: 'synced', summary } });
|
|
150
|
+
})
|
|
151
|
+
.catch((error: unknown) => {
|
|
152
|
+
if (!closed) {
|
|
153
|
+
post({
|
|
154
|
+
t: 'event',
|
|
155
|
+
event: { kind: 'synced', error: toErrorShape(error) },
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function consumeSyncIntent(intent: SyncIntent): void {
|
|
162
|
+
if (!autoSync || closed || client === undefined || intent.kind === 'none') {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (intent.kind === 'background') {
|
|
166
|
+
if (autoSyncScheduled) return;
|
|
167
|
+
const due = Date.now() + Math.max(0, intent.delayMs);
|
|
168
|
+
if (backgroundTimer !== undefined && due >= backgroundDue) return;
|
|
169
|
+
if (backgroundTimer !== undefined) clearTimeout(backgroundTimer);
|
|
170
|
+
backgroundDue = due;
|
|
171
|
+
backgroundTimer = setTimeout(
|
|
172
|
+
() => {
|
|
173
|
+
backgroundTimer = undefined;
|
|
174
|
+
backgroundDue = Number.POSITIVE_INFINITY;
|
|
175
|
+
if (!autoSyncScheduled) {
|
|
176
|
+
autoSyncScheduled = true;
|
|
177
|
+
runAutoSync();
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
Math.max(0, due - Date.now()),
|
|
181
|
+
);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (backgroundTimer !== undefined) {
|
|
185
|
+
clearTimeout(backgroundTimer);
|
|
186
|
+
backgroundTimer = undefined;
|
|
187
|
+
backgroundDue = Number.POSITIVE_INFINITY;
|
|
188
|
+
}
|
|
141
189
|
if (!autoSync || autoSyncScheduled || closed || client === undefined) {
|
|
142
190
|
return;
|
|
143
191
|
}
|
|
144
192
|
autoSyncScheduled = true;
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
void serializedSync(() => running.syncUntilIdle())
|
|
151
|
-
.then((summary) => {
|
|
152
|
-
if (!closed)
|
|
153
|
-
post({ t: 'event', event: { kind: 'synced', summary } });
|
|
154
|
-
})
|
|
155
|
-
.catch((error: unknown) => {
|
|
156
|
-
if (!closed) {
|
|
157
|
-
post({
|
|
158
|
-
t: 'event',
|
|
159
|
-
event: { kind: 'synced', error: toErrorShape(error) },
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
});
|
|
163
|
-
},
|
|
164
|
-
Math.floor(Math.random() * wakeJitterMs),
|
|
165
|
-
);
|
|
193
|
+
queueMicrotask(runAutoSync);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function consumeEffects(effects: CommandEffects): void {
|
|
197
|
+
consumeSyncIntent(effects.sync);
|
|
166
198
|
}
|
|
167
199
|
|
|
168
200
|
function requireClient(): SyncClient {
|
|
@@ -215,7 +247,6 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
215
247
|
);
|
|
216
248
|
}
|
|
217
249
|
autoSync = config.autoSync ?? true;
|
|
218
|
-
wakeJitterMs = config.wakeJitterMs ?? 200;
|
|
219
250
|
|
|
220
251
|
database =
|
|
221
252
|
overrides.openDatabase !== undefined
|
|
@@ -270,8 +301,9 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
270
301
|
...(config.limits !== undefined ? { limits: config.limits } : {}),
|
|
271
302
|
onSyncNeeded: (reason) => {
|
|
272
303
|
post({ t: 'event', event: { kind: 'sync-needed', reason } });
|
|
273
|
-
|
|
304
|
+
consumeSyncIntent({ kind: 'interactive' });
|
|
274
305
|
},
|
|
306
|
+
onSyncIntent: consumeSyncIntent,
|
|
275
307
|
onConflict: (conflict) => {
|
|
276
308
|
post({ t: 'event', event: { kind: 'conflict', conflict } });
|
|
277
309
|
},
|
|
@@ -284,10 +316,8 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
284
316
|
post({ t: 'event', event: { kind: 'presence', scopeKey } });
|
|
285
317
|
},
|
|
286
318
|
});
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
started.onInvalidate((event) => {
|
|
290
|
-
if (!closed) post({ t: 'event', event: { kind: 'invalidate', event } });
|
|
319
|
+
started.onChange((batch) => {
|
|
320
|
+
if (!closed) post({ t: 'event', event: { kind: 'change', batch } });
|
|
291
321
|
});
|
|
292
322
|
await started.start();
|
|
293
323
|
realtimeConnector =
|
|
@@ -302,6 +332,10 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
302
332
|
)
|
|
303
333
|
: undefined;
|
|
304
334
|
client = started;
|
|
335
|
+
// `start()` may discover persisted subscriptions/outbox work. Its callback
|
|
336
|
+
// fires before this worker publishes the initialized client, so consume
|
|
337
|
+
// the durable state once here as well; coalescing makes this a single task.
|
|
338
|
+
if (started.syncNeeded) consumeSyncIntent({ kind: 'interactive' });
|
|
305
339
|
return { clientId: started.clientId };
|
|
306
340
|
}
|
|
307
341
|
|
|
@@ -309,35 +343,26 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
309
343
|
subscribe: (input) => requireClient().subscribe(input),
|
|
310
344
|
unsubscribe: (id) => requireClient().unsubscribe(id),
|
|
311
345
|
setWindow: async (base, units) => {
|
|
312
|
-
await requireClient().
|
|
313
|
-
|
|
314
|
-
// unit needs a bootstrap pull to become visible. In autoSync mode the
|
|
315
|
-
// host loop owns rounds, so schedule one now — otherwise the new unit
|
|
316
|
-
// waits for an unrelated server wake-up (§8.4). A no-op change still
|
|
317
|
-
// schedules a harmless idempotent round.
|
|
318
|
-
scheduleAutoSync();
|
|
346
|
+
const result = await requireClient().setWindowCommand(base, units);
|
|
347
|
+
consumeEffects(result.effects);
|
|
319
348
|
},
|
|
320
349
|
windowState: (base) => requireClient().windowState(base),
|
|
321
350
|
mutate: (mutations) => {
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
// to drain the outbox promptly. `mutate` is synchronous — its outbox
|
|
326
|
-
// write has committed before this line — and the round itself serializes
|
|
327
|
-
// on SyncClient's operation chain, so no ordering hack is needed here;
|
|
328
|
-
// the §8.4 jitter in `scheduleAutoSync` is the only deferral, and it
|
|
329
|
-
// coalesces bursts. (Historically this used setTimeout to dodge a
|
|
330
|
-
// nested-transaction flake; that race is fixed at the source now — the
|
|
331
|
-
// real cause was cross-operation interleaving, serialized in the core.)
|
|
332
|
-
scheduleAutoSync();
|
|
333
|
-
return id;
|
|
351
|
+
const result = requireClient().mutateCommand(mutations);
|
|
352
|
+
consumeEffects(result.effects);
|
|
353
|
+
return result.value;
|
|
334
354
|
},
|
|
335
355
|
patch: (table, rowId, partial, options) => {
|
|
336
356
|
// Same §8.4 rule as `mutate`: a local write must push without the app
|
|
337
|
-
// orchestrating sync, so
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
357
|
+
// orchestrating sync, so consume the core's immediate intent.
|
|
358
|
+
const result = requireClient().patchCommand(
|
|
359
|
+
table,
|
|
360
|
+
rowId,
|
|
361
|
+
partial,
|
|
362
|
+
options,
|
|
363
|
+
);
|
|
364
|
+
consumeEffects(result.effects);
|
|
365
|
+
return result.value;
|
|
341
366
|
},
|
|
342
367
|
sync: () => {
|
|
343
368
|
const running = requireClient();
|
|
@@ -348,6 +373,9 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
348
373
|
return serializedSync(() => running.syncUntilIdle(maxRounds));
|
|
349
374
|
},
|
|
350
375
|
query: (sql, params) => requireClient().query(sql, params),
|
|
376
|
+
querySnapshot: (spec) => requireClient().querySnapshot(spec),
|
|
377
|
+
localRevision: () => requireClient().localRevision,
|
|
378
|
+
statusSnapshot: () => requireClient().statusSnapshot(),
|
|
351
379
|
conflicts: () => requireClient().conflicts,
|
|
352
380
|
rejections: () => requireClient().rejections,
|
|
353
381
|
schemaFloor: () => requireClient().schemaFloor,
|
|
@@ -369,6 +397,7 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
369
397
|
},
|
|
370
398
|
close: async () => {
|
|
371
399
|
closed = true;
|
|
400
|
+
if (backgroundTimer !== undefined) clearTimeout(backgroundTimer);
|
|
372
401
|
await client?.close();
|
|
373
402
|
database?.close();
|
|
374
403
|
client = undefined;
|
package/src/worker-host.ts
CHANGED
|
@@ -28,6 +28,8 @@ import type {
|
|
|
28
28
|
LeaseState,
|
|
29
29
|
MutationInput,
|
|
30
30
|
PresencePeer,
|
|
31
|
+
QueryReadSpec,
|
|
32
|
+
QuerySnapshot,
|
|
31
33
|
RejectionRecord,
|
|
32
34
|
SchemaFloor,
|
|
33
35
|
SubscribeInput,
|
|
@@ -38,7 +40,15 @@ import type {
|
|
|
38
40
|
import type { SqlRow, SqlValue } from './database';
|
|
39
41
|
import { registerDevtools } from './devtools';
|
|
40
42
|
import { ClientSyncError } from './errors';
|
|
41
|
-
import {
|
|
43
|
+
import {
|
|
44
|
+
ChangeEmitter,
|
|
45
|
+
type ClientChangeListener,
|
|
46
|
+
InvalidationEmitter,
|
|
47
|
+
type InvalidationListener,
|
|
48
|
+
invalidationFromChange,
|
|
49
|
+
type LocalRevision,
|
|
50
|
+
type SyncStatusSnapshot,
|
|
51
|
+
} from './invalidation';
|
|
42
52
|
import {
|
|
43
53
|
type LeaderLease,
|
|
44
54
|
type LeaderLock,
|
|
@@ -89,7 +99,6 @@ export interface SyncClientHandleConfig {
|
|
|
89
99
|
readonly limits?: SyncClientLimits;
|
|
90
100
|
/** Worker-side host loop (§8.4); default true. */
|
|
91
101
|
readonly autoSync?: boolean;
|
|
92
|
-
readonly wakeJitterMs?: number;
|
|
93
102
|
/** Default: Web Locks when available, else single-owner. */
|
|
94
103
|
readonly leaderLock?: LeaderLock;
|
|
95
104
|
readonly lockName?: string;
|
|
@@ -107,7 +116,7 @@ export interface SyncClientHandleConfig {
|
|
|
107
116
|
readonly followerCallTimeoutMs?: number;
|
|
108
117
|
/** Fires when this handle's role changes (follower → leader on promotion). */
|
|
109
118
|
readonly onRoleChange?: (role: HandleRole) => void;
|
|
110
|
-
readonly onSyncNeeded?: (reason: 'hello' | WakeReason) => void;
|
|
119
|
+
readonly onSyncNeeded?: (reason: 'startup' | 'hello' | WakeReason) => void;
|
|
111
120
|
readonly onConflict?: (conflict: ConflictRecord) => void;
|
|
112
121
|
/** A worker-side autoSync round finished (or failed). */
|
|
113
122
|
readonly onSynced?: (result: {
|
|
@@ -171,6 +180,7 @@ export class SyncClientHandle {
|
|
|
171
180
|
#core: LeaderCore | undefined;
|
|
172
181
|
#follower: FollowerLink | undefined;
|
|
173
182
|
readonly #invalidation: InvalidationEmitter;
|
|
183
|
+
readonly #changes: ChangeEmitter;
|
|
174
184
|
readonly #presence: Set<(scopeKey: string) => void>;
|
|
175
185
|
readonly #roleListeners: Set<(role: HandleRole) => void>;
|
|
176
186
|
readonly #devtoolsUnregister: () => void;
|
|
@@ -183,6 +193,7 @@ export class SyncClientHandle {
|
|
|
183
193
|
core?: LeaderCore;
|
|
184
194
|
follower?: FollowerLink;
|
|
185
195
|
invalidation: InvalidationEmitter;
|
|
196
|
+
changes: ChangeEmitter;
|
|
186
197
|
presence: Set<(scopeKey: string) => void>;
|
|
187
198
|
roleListeners?: Set<(role: HandleRole) => void>;
|
|
188
199
|
}) {
|
|
@@ -191,6 +202,7 @@ export class SyncClientHandle {
|
|
|
191
202
|
this.#core = internals.core;
|
|
192
203
|
this.#follower = internals.follower;
|
|
193
204
|
this.#invalidation = internals.invalidation;
|
|
205
|
+
this.#changes = internals.changes;
|
|
194
206
|
this.#presence = internals.presence;
|
|
195
207
|
this.#roleListeners = internals.roleListeners ?? new Set();
|
|
196
208
|
// RFC 0002 §3.2: console introspection — a no-op outside a dev page.
|
|
@@ -235,8 +247,10 @@ export class SyncClientHandle {
|
|
|
235
247
|
/* a UI listener must never break event dispatch */
|
|
236
248
|
}
|
|
237
249
|
}
|
|
238
|
-
} else if (event.kind === '
|
|
239
|
-
this.#
|
|
250
|
+
} else if (event.kind === 'change') {
|
|
251
|
+
this.#changes.emit(event.batch);
|
|
252
|
+
const legacy = invalidationFromChange(event.batch);
|
|
253
|
+
if (legacy !== undefined) this.#invalidation.emit(legacy);
|
|
240
254
|
}
|
|
241
255
|
}
|
|
242
256
|
|
|
@@ -250,6 +264,10 @@ export class SyncClientHandle {
|
|
|
250
264
|
return this.#invalidation.on(listener);
|
|
251
265
|
}
|
|
252
266
|
|
|
267
|
+
onChange(listener: ClientChangeListener): () => void {
|
|
268
|
+
return this.#changes.on(listener);
|
|
269
|
+
}
|
|
270
|
+
|
|
253
271
|
/**
|
|
254
272
|
* §8.6: subscribe to presence changes — the identical surface as
|
|
255
273
|
* `SyncClient.onPresence`. Returns an unsubscribe function.
|
|
@@ -345,6 +363,20 @@ export class SyncClientHandle {
|
|
|
345
363
|
return this.#call('query', [sql, params]);
|
|
346
364
|
}
|
|
347
365
|
|
|
366
|
+
querySnapshot<Row = SqlRow>(
|
|
367
|
+
spec: QueryReadSpec,
|
|
368
|
+
): Promise<QuerySnapshot<Row>> {
|
|
369
|
+
return this.#call('querySnapshot', [spec]) as Promise<QuerySnapshot<Row>>;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
localRevision(): Promise<LocalRevision> {
|
|
373
|
+
return this.#call('localRevision', []);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
statusSnapshot(): Promise<SyncStatusSnapshot> {
|
|
377
|
+
return this.#call('statusSnapshot', []);
|
|
378
|
+
}
|
|
379
|
+
|
|
348
380
|
conflicts(): Promise<readonly ConflictRecord[]> {
|
|
349
381
|
return this.#call('conflicts', []);
|
|
350
382
|
}
|
|
@@ -574,9 +606,6 @@ function buildInitConfig(config: SyncClientHandleConfig): WorkerInitConfig {
|
|
|
574
606
|
...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
|
|
575
607
|
...(config.limits !== undefined ? { limits: config.limits } : {}),
|
|
576
608
|
...(config.autoSync !== undefined ? { autoSync: config.autoSync } : {}),
|
|
577
|
-
...(config.wakeJitterMs !== undefined
|
|
578
|
-
? { wakeJitterMs: config.wakeJitterMs }
|
|
579
|
-
: {}),
|
|
580
609
|
};
|
|
581
610
|
}
|
|
582
611
|
|
|
@@ -614,6 +643,7 @@ export async function createSyncClientHandle(
|
|
|
614
643
|
const lock = config.leaderLock ?? defaultLeaderLock();
|
|
615
644
|
const lockName = config.lockName ?? 'syncular-leader';
|
|
616
645
|
const invalidation = new InvalidationEmitter();
|
|
646
|
+
const changes = new ChangeEmitter();
|
|
617
647
|
const presence = new Set<(scopeKey: string) => void>();
|
|
618
648
|
const roleListeners = new Set<(role: HandleRole) => void>();
|
|
619
649
|
if (config.onRoleChange !== undefined) roleListeners.add(config.onRoleChange);
|
|
@@ -633,6 +663,7 @@ export async function createSyncClientHandle(
|
|
|
633
663
|
return await bootLeader(config, lockName, lease, {
|
|
634
664
|
epoch: 0,
|
|
635
665
|
invalidation,
|
|
666
|
+
changes,
|
|
636
667
|
presence,
|
|
637
668
|
roleListeners,
|
|
638
669
|
});
|
|
@@ -645,6 +676,7 @@ export async function createSyncClientHandle(
|
|
|
645
676
|
role: 'follower',
|
|
646
677
|
clientId: '',
|
|
647
678
|
invalidation,
|
|
679
|
+
changes,
|
|
648
680
|
presence,
|
|
649
681
|
roleListeners,
|
|
650
682
|
});
|
|
@@ -653,6 +685,7 @@ export async function createSyncClientHandle(
|
|
|
653
685
|
// ---- Follower: proxy to the leader; contest + promote on its close. ----
|
|
654
686
|
return await bootFollower(config, lockName, lock, {
|
|
655
687
|
invalidation,
|
|
688
|
+
changes,
|
|
656
689
|
presence,
|
|
657
690
|
roleListeners,
|
|
658
691
|
});
|
|
@@ -661,6 +694,7 @@ export async function createSyncClientHandle(
|
|
|
661
694
|
interface HandleParts {
|
|
662
695
|
epoch?: number;
|
|
663
696
|
invalidation: InvalidationEmitter;
|
|
697
|
+
changes: ChangeEmitter;
|
|
664
698
|
presence: Set<(scopeKey: string) => void>;
|
|
665
699
|
roleListeners: Set<(role: HandleRole) => void>;
|
|
666
700
|
}
|
|
@@ -712,6 +746,7 @@ async function bootLeader(
|
|
|
712
746
|
clientId: core.clientId,
|
|
713
747
|
core,
|
|
714
748
|
invalidation: parts.invalidation,
|
|
749
|
+
changes: parts.changes,
|
|
715
750
|
presence: parts.presence,
|
|
716
751
|
roleListeners: parts.roleListeners,
|
|
717
752
|
});
|
|
@@ -809,6 +844,7 @@ async function bootFollower(
|
|
|
809
844
|
clientId: '',
|
|
810
845
|
follower,
|
|
811
846
|
invalidation: parts.invalidation,
|
|
847
|
+
changes: parts.changes,
|
|
812
848
|
presence: parts.presence,
|
|
813
849
|
roleListeners: parts.roleListeners,
|
|
814
850
|
});
|
package/src/worker-protocol.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Scheduling note (SPEC §8.4): the sync-needed signal is host-driven,
|
|
14
14
|
* and in worker mode the HOST LOOP LIVES IN THE WORKER — wake-ups
|
|
15
|
-
* coalesce into
|
|
15
|
+
* coalesce into intent-driven `syncUntilIdle` rounds there (`autoSync`),
|
|
16
16
|
* so the UI thread never has to react to keep data flowing. Events are
|
|
17
17
|
* still forwarded to the handle for visibility.
|
|
18
18
|
*/
|
|
@@ -23,6 +23,8 @@ import type {
|
|
|
23
23
|
LeaseState,
|
|
24
24
|
MutationInput,
|
|
25
25
|
PresencePeer,
|
|
26
|
+
QueryReadSpec,
|
|
27
|
+
QuerySnapshot,
|
|
26
28
|
RejectionRecord,
|
|
27
29
|
SchemaFloor,
|
|
28
30
|
SubscribeInput,
|
|
@@ -31,7 +33,11 @@ import type {
|
|
|
31
33
|
WindowState,
|
|
32
34
|
} from './client';
|
|
33
35
|
import type { SqlRow, SqlValue } from './database';
|
|
34
|
-
import type {
|
|
36
|
+
import type {
|
|
37
|
+
ClientChangeBatch,
|
|
38
|
+
LocalRevision,
|
|
39
|
+
SyncStatusSnapshot,
|
|
40
|
+
} from './invalidation';
|
|
35
41
|
import type { OutboxCommit } from './outbox';
|
|
36
42
|
import type { ClientSchema } from './schema';
|
|
37
43
|
import type { SubscriptionRecord } from './state';
|
|
@@ -88,12 +94,11 @@ export interface WorkerInitConfig {
|
|
|
88
94
|
readonly clientId?: string;
|
|
89
95
|
readonly limits?: SyncClientLimits;
|
|
90
96
|
/**
|
|
91
|
-
* Worker-side host loop (§8.4): coalesce
|
|
97
|
+
* Worker-side host loop (§8.4): coalesce interactive work immediately and
|
|
98
|
+
* honor explicit background retry deadlines
|
|
92
99
|
* `syncUntilIdle` rounds inside the worker. Default true.
|
|
93
100
|
*/
|
|
94
101
|
readonly autoSync?: boolean;
|
|
95
|
-
/** Max jitter before a coalesced auto-sync round (default 200 ms). */
|
|
96
|
-
readonly wakeJitterMs?: number;
|
|
97
102
|
}
|
|
98
103
|
|
|
99
104
|
/** Successful init reply. */
|
|
@@ -123,6 +128,9 @@ export interface WorkerApi {
|
|
|
123
128
|
sync(): Promise<SyncSummary>;
|
|
124
129
|
syncUntilIdle(maxRounds?: number): Promise<SyncSummary>;
|
|
125
130
|
query(sql: string, params?: readonly SqlValue[]): SqlRow[];
|
|
131
|
+
querySnapshot(spec: QueryReadSpec): QuerySnapshot;
|
|
132
|
+
localRevision(): LocalRevision;
|
|
133
|
+
statusSnapshot(): SyncStatusSnapshot;
|
|
126
134
|
conflicts(): readonly ConflictRecord[];
|
|
127
135
|
rejections(): readonly RejectionRecord[];
|
|
128
136
|
schemaFloor(): SchemaFloor | undefined;
|
|
@@ -182,7 +190,10 @@ export interface WorkerErrorShape {
|
|
|
182
190
|
}
|
|
183
191
|
|
|
184
192
|
export type SyncWorkerEvent =
|
|
185
|
-
| {
|
|
193
|
+
| {
|
|
194
|
+
readonly kind: 'sync-needed';
|
|
195
|
+
readonly reason: 'startup' | 'hello' | WakeReason;
|
|
196
|
+
}
|
|
186
197
|
| { readonly kind: 'conflict'; readonly conflict: ConflictRecord }
|
|
187
198
|
| {
|
|
188
199
|
/** An autoSync round finished (or failed) inside the worker. */
|
|
@@ -201,13 +212,9 @@ export type SyncWorkerEvent =
|
|
|
201
212
|
readonly scopeKey: string;
|
|
202
213
|
}
|
|
203
214
|
| {
|
|
204
|
-
/**
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
* verbatim — the handle re-emits it to `onInvalidate` listeners.
|
|
208
|
-
*/
|
|
209
|
-
readonly kind: 'invalidate';
|
|
210
|
-
readonly event: InvalidationEvent;
|
|
215
|
+
/** Exact revisioned core transaction; Sets and bigint clone directly. */
|
|
216
|
+
readonly kind: 'change';
|
|
217
|
+
readonly batch: ClientChangeBatch;
|
|
211
218
|
};
|
|
212
219
|
|
|
213
220
|
export type WorkerToMainMessage =
|