@syncular/client 0.4.1 → 0.5.1
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/dist/worker-entry.js
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
|
*/
|
|
@@ -80,34 +80,66 @@ export function startSyncWorker(overrides = {}) {
|
|
|
80
80
|
syncChain = next.then(() => undefined, () => undefined);
|
|
81
81
|
return next;
|
|
82
82
|
}
|
|
83
|
-
// -- §
|
|
83
|
+
// -- SPEC §7.5 host loop: event-driven intents, no interactive polling --
|
|
84
84
|
let autoSync = true;
|
|
85
|
-
let wakeJitterMs = 200;
|
|
86
85
|
let autoSyncScheduled = false;
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
let backgroundTimer;
|
|
87
|
+
let backgroundDue = Number.POSITIVE_INFINITY;
|
|
88
|
+
function runAutoSync() {
|
|
89
|
+
autoSyncScheduled = false;
|
|
90
|
+
if (closed || client === undefined)
|
|
91
|
+
return;
|
|
92
|
+
const running = client;
|
|
93
|
+
void serializedSync(() => running.syncUntilIdle())
|
|
94
|
+
.then((summary) => {
|
|
95
|
+
if (!closed)
|
|
96
|
+
post({ t: 'event', event: { kind: 'synced', summary } });
|
|
97
|
+
})
|
|
98
|
+
.catch((error) => {
|
|
99
|
+
if (!closed) {
|
|
100
|
+
post({
|
|
101
|
+
t: 'event',
|
|
102
|
+
event: { kind: 'synced', error: toErrorShape(error) },
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
function consumeSyncIntent(intent) {
|
|
108
|
+
if (!autoSync || closed || client === undefined || intent.kind === 'none') {
|
|
89
109
|
return;
|
|
90
110
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
111
|
+
if (intent.kind === 'background') {
|
|
112
|
+
if (autoSyncScheduled)
|
|
113
|
+
return;
|
|
114
|
+
const due = Date.now() + Math.max(0, intent.delayMs);
|
|
115
|
+
if (backgroundTimer !== undefined && due >= backgroundDue)
|
|
95
116
|
return;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
t: 'event',
|
|
106
|
-
event: { kind: 'synced', error: toErrorShape(error) },
|
|
107
|
-
});
|
|
117
|
+
if (backgroundTimer !== undefined)
|
|
118
|
+
clearTimeout(backgroundTimer);
|
|
119
|
+
backgroundDue = due;
|
|
120
|
+
backgroundTimer = setTimeout(() => {
|
|
121
|
+
backgroundTimer = undefined;
|
|
122
|
+
backgroundDue = Number.POSITIVE_INFINITY;
|
|
123
|
+
if (!autoSyncScheduled) {
|
|
124
|
+
autoSyncScheduled = true;
|
|
125
|
+
runAutoSync();
|
|
108
126
|
}
|
|
109
|
-
});
|
|
110
|
-
|
|
127
|
+
}, Math.max(0, due - Date.now()));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (backgroundTimer !== undefined) {
|
|
131
|
+
clearTimeout(backgroundTimer);
|
|
132
|
+
backgroundTimer = undefined;
|
|
133
|
+
backgroundDue = Number.POSITIVE_INFINITY;
|
|
134
|
+
}
|
|
135
|
+
if (!autoSync || autoSyncScheduled || closed || client === undefined) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
autoSyncScheduled = true;
|
|
139
|
+
queueMicrotask(runAutoSync);
|
|
140
|
+
}
|
|
141
|
+
function consumeEffects(effects) {
|
|
142
|
+
consumeSyncIntent(effects.sync);
|
|
111
143
|
}
|
|
112
144
|
function requireClient() {
|
|
113
145
|
if (client === undefined) {
|
|
@@ -145,7 +177,6 @@ export function startSyncWorker(overrides = {}) {
|
|
|
145
177
|
throw new ClientSyncError(WORKER_FAILED_CODE, 'the worker is already initialized');
|
|
146
178
|
}
|
|
147
179
|
autoSync = config.autoSync ?? true;
|
|
148
|
-
wakeJitterMs = config.wakeJitterMs ?? 200;
|
|
149
180
|
database =
|
|
150
181
|
overrides.openDatabase !== undefined
|
|
151
182
|
? await overrides.openDatabase(config)
|
|
@@ -187,8 +218,9 @@ export function startSyncWorker(overrides = {}) {
|
|
|
187
218
|
...(config.limits !== undefined ? { limits: config.limits } : {}),
|
|
188
219
|
onSyncNeeded: (reason) => {
|
|
189
220
|
post({ t: 'event', event: { kind: 'sync-needed', reason } });
|
|
190
|
-
|
|
221
|
+
consumeSyncIntent({ kind: 'interactive' });
|
|
191
222
|
},
|
|
223
|
+
onSyncIntent: consumeSyncIntent,
|
|
192
224
|
onConflict: (conflict) => {
|
|
193
225
|
post({ t: 'event', event: { kind: 'conflict', conflict } });
|
|
194
226
|
},
|
|
@@ -201,11 +233,9 @@ export function startSyncWorker(overrides = {}) {
|
|
|
201
233
|
post({ t: 'event', event: { kind: 'presence', scopeKey } });
|
|
202
234
|
},
|
|
203
235
|
});
|
|
204
|
-
|
|
205
|
-
// UI thread so `SyncClientHandle.onInvalidate` fires with worker parity.
|
|
206
|
-
started.onInvalidate((event) => {
|
|
236
|
+
started.onChange((batch) => {
|
|
207
237
|
if (!closed)
|
|
208
|
-
post({ t: 'event', event: { kind: '
|
|
238
|
+
post({ t: 'event', event: { kind: 'change', batch } });
|
|
209
239
|
});
|
|
210
240
|
await started.start();
|
|
211
241
|
realtimeConnector =
|
|
@@ -215,41 +245,32 @@ export function startSyncWorker(overrides = {}) {
|
|
|
215
245
|
? webSocketRealtimeConnector(config.endpoints.realtimeUrl.replace('{clientId}', encodeURIComponent(started.clientId)))
|
|
216
246
|
: undefined;
|
|
217
247
|
client = started;
|
|
248
|
+
// `start()` may discover persisted subscriptions/outbox work. Its callback
|
|
249
|
+
// fires before this worker publishes the initialized client, so consume
|
|
250
|
+
// the durable state once here as well; coalescing makes this a single task.
|
|
251
|
+
if (started.syncNeeded)
|
|
252
|
+
consumeSyncIntent({ kind: 'interactive' });
|
|
218
253
|
return { clientId: started.clientId };
|
|
219
254
|
}
|
|
220
255
|
const api = {
|
|
221
256
|
subscribe: (input) => requireClient().subscribe(input),
|
|
222
257
|
unsubscribe: (id) => requireClient().unsubscribe(id),
|
|
223
258
|
setWindow: async (base, units) => {
|
|
224
|
-
await requireClient().
|
|
225
|
-
|
|
226
|
-
// unit needs a bootstrap pull to become visible. In autoSync mode the
|
|
227
|
-
// host loop owns rounds, so schedule one now — otherwise the new unit
|
|
228
|
-
// waits for an unrelated server wake-up (§8.4). A no-op change still
|
|
229
|
-
// schedules a harmless idempotent round.
|
|
230
|
-
scheduleAutoSync();
|
|
259
|
+
const result = await requireClient().setWindowCommand(base, units);
|
|
260
|
+
consumeEffects(result.effects);
|
|
231
261
|
},
|
|
232
262
|
windowState: (base) => requireClient().windowState(base),
|
|
233
263
|
mutate: (mutations) => {
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
// to drain the outbox promptly. `mutate` is synchronous — its outbox
|
|
238
|
-
// write has committed before this line — and the round itself serializes
|
|
239
|
-
// on SyncClient's operation chain, so no ordering hack is needed here;
|
|
240
|
-
// the §8.4 jitter in `scheduleAutoSync` is the only deferral, and it
|
|
241
|
-
// coalesces bursts. (Historically this used setTimeout to dodge a
|
|
242
|
-
// nested-transaction flake; that race is fixed at the source now — the
|
|
243
|
-
// real cause was cross-operation interleaving, serialized in the core.)
|
|
244
|
-
scheduleAutoSync();
|
|
245
|
-
return id;
|
|
264
|
+
const result = requireClient().mutateCommand(mutations);
|
|
265
|
+
consumeEffects(result.effects);
|
|
266
|
+
return result.value;
|
|
246
267
|
},
|
|
247
268
|
patch: (table, rowId, partial, options) => {
|
|
248
269
|
// Same §8.4 rule as `mutate`: a local write must push without the app
|
|
249
|
-
// orchestrating sync, so
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
return
|
|
270
|
+
// orchestrating sync, so consume the core's immediate intent.
|
|
271
|
+
const result = requireClient().patchCommand(table, rowId, partial, options);
|
|
272
|
+
consumeEffects(result.effects);
|
|
273
|
+
return result.value;
|
|
253
274
|
},
|
|
254
275
|
sync: () => {
|
|
255
276
|
const running = requireClient();
|
|
@@ -260,6 +281,9 @@ export function startSyncWorker(overrides = {}) {
|
|
|
260
281
|
return serializedSync(() => running.syncUntilIdle(maxRounds));
|
|
261
282
|
},
|
|
262
283
|
query: (sql, params) => requireClient().query(sql, params),
|
|
284
|
+
querySnapshot: (spec) => requireClient().querySnapshot(spec),
|
|
285
|
+
localRevision: () => requireClient().localRevision,
|
|
286
|
+
statusSnapshot: () => requireClient().statusSnapshot(),
|
|
263
287
|
conflicts: () => requireClient().conflicts,
|
|
264
288
|
rejections: () => requireClient().rejections,
|
|
265
289
|
schemaFloor: () => requireClient().schemaFloor,
|
|
@@ -282,6 +306,8 @@ export function startSyncWorker(overrides = {}) {
|
|
|
282
306
|
},
|
|
283
307
|
close: async () => {
|
|
284
308
|
closed = true;
|
|
309
|
+
if (backgroundTimer !== undefined)
|
|
310
|
+
clearTimeout(backgroundTimer);
|
|
285
311
|
await client?.close();
|
|
286
312
|
database?.close();
|
|
287
313
|
client = undefined;
|
package/dist/worker-host.d.ts
CHANGED
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import type { WakeReason } from '@syncular/core';
|
|
25
25
|
import type { BlobRef, CachedBlob } from './blob.js';
|
|
26
|
-
import type { ConflictRecord, LeaseState, MutationInput, PresencePeer, RejectionRecord, SchemaFloor, SubscribeInput, SyncClientLimits, SyncSummary, WindowState } from './client.js';
|
|
26
|
+
import type { ConflictRecord, LeaseState, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SchemaFloor, SubscribeInput, SyncClientLimits, SyncSummary, WindowState } from './client.js';
|
|
27
27
|
import type { SqlRow, SqlValue } from './database.js';
|
|
28
|
-
import { InvalidationEmitter, type InvalidationListener } from './invalidation.js';
|
|
28
|
+
import { ChangeEmitter, type ClientChangeListener, InvalidationEmitter, type InvalidationListener, type LocalRevision, type SyncStatusSnapshot } from './invalidation.js';
|
|
29
29
|
import { type LeaderLease, type LeaderLock } from './leader-lock.js';
|
|
30
30
|
import { type CrossTabChannel, FollowerLink, LeaderBridge } from './multi-tab.js';
|
|
31
31
|
import type { OutboxCommit } from './outbox.js';
|
|
@@ -49,7 +49,6 @@ export interface SyncClientHandleConfig {
|
|
|
49
49
|
readonly limits?: SyncClientLimits;
|
|
50
50
|
/** Worker-side host loop (§8.4); default true. */
|
|
51
51
|
readonly autoSync?: boolean;
|
|
52
|
-
readonly wakeJitterMs?: number;
|
|
53
52
|
/** Default: Web Locks when available, else single-owner. */
|
|
54
53
|
readonly leaderLock?: LeaderLock;
|
|
55
54
|
readonly lockName?: string;
|
|
@@ -67,7 +66,7 @@ export interface SyncClientHandleConfig {
|
|
|
67
66
|
readonly followerCallTimeoutMs?: number;
|
|
68
67
|
/** Fires when this handle's role changes (follower → leader on promotion). */
|
|
69
68
|
readonly onRoleChange?: (role: HandleRole) => void;
|
|
70
|
-
readonly onSyncNeeded?: (reason: 'hello' | WakeReason) => void;
|
|
69
|
+
readonly onSyncNeeded?: (reason: 'startup' | 'hello' | WakeReason) => void;
|
|
71
70
|
readonly onConflict?: (conflict: ConflictRecord) => void;
|
|
72
71
|
/** A worker-side autoSync round finished (or failed). */
|
|
73
72
|
readonly onSynced?: (result: {
|
|
@@ -110,6 +109,7 @@ export declare class SyncClientHandle {
|
|
|
110
109
|
core?: LeaderCore;
|
|
111
110
|
follower?: FollowerLink;
|
|
112
111
|
invalidation: InvalidationEmitter;
|
|
112
|
+
changes: ChangeEmitter;
|
|
113
113
|
presence: Set<(scopeKey: string) => void>;
|
|
114
114
|
roleListeners?: Set<(role: HandleRole) => void>;
|
|
115
115
|
});
|
|
@@ -124,6 +124,7 @@ export declare class SyncClientHandle {
|
|
|
124
124
|
* unsubscribe function.
|
|
125
125
|
*/
|
|
126
126
|
onInvalidate(listener: InvalidationListener): () => void;
|
|
127
|
+
onChange(listener: ClientChangeListener): () => void;
|
|
127
128
|
/**
|
|
128
129
|
* §8.6: subscribe to presence changes — the identical surface as
|
|
129
130
|
* `SyncClient.onPresence`. Returns an unsubscribe function.
|
|
@@ -143,6 +144,9 @@ export declare class SyncClientHandle {
|
|
|
143
144
|
sync(): Promise<SyncSummary>;
|
|
144
145
|
syncUntilIdle(maxRounds?: number): Promise<SyncSummary>;
|
|
145
146
|
query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]>;
|
|
147
|
+
querySnapshot<Row = SqlRow>(spec: QueryReadSpec): Promise<QuerySnapshot<Row>>;
|
|
148
|
+
localRevision(): Promise<LocalRevision>;
|
|
149
|
+
statusSnapshot(): Promise<SyncStatusSnapshot>;
|
|
146
150
|
conflicts(): Promise<readonly ConflictRecord[]>;
|
|
147
151
|
rejections(): Promise<readonly RejectionRecord[]>;
|
|
148
152
|
schemaFloor(): Promise<SchemaFloor | undefined>;
|
package/dist/worker-host.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { registerDevtools } from './devtools.js';
|
|
2
2
|
import { ClientSyncError } from './errors.js';
|
|
3
|
-
import { InvalidationEmitter } from './invalidation.js';
|
|
3
|
+
import { ChangeEmitter, InvalidationEmitter, invalidationFromChange, } from './invalidation.js';
|
|
4
4
|
import { singleOwnerLock, webLocksLeaderLock, } from './leader-lock.js';
|
|
5
5
|
import { broadcastChannelFactory, FollowerLink, LeaderBridge, multiTabChannelName, newTabId, } from './multi-tab.js';
|
|
6
6
|
import { NOT_LEADER_CODE, WORKER_FAILED_CODE, } from './worker-protocol.js';
|
|
@@ -32,6 +32,7 @@ export class SyncClientHandle {
|
|
|
32
32
|
#core;
|
|
33
33
|
#follower;
|
|
34
34
|
#invalidation;
|
|
35
|
+
#changes;
|
|
35
36
|
#presence;
|
|
36
37
|
#roleListeners;
|
|
37
38
|
#devtoolsUnregister;
|
|
@@ -43,6 +44,7 @@ export class SyncClientHandle {
|
|
|
43
44
|
this.#core = internals.core;
|
|
44
45
|
this.#follower = internals.follower;
|
|
45
46
|
this.#invalidation = internals.invalidation;
|
|
47
|
+
this.#changes = internals.changes;
|
|
46
48
|
this.#presence = internals.presence;
|
|
47
49
|
this.#roleListeners = internals.roleListeners ?? new Set();
|
|
48
50
|
// RFC 0002 §3.2: console introspection — a no-op outside a dev page.
|
|
@@ -88,8 +90,11 @@ export class SyncClientHandle {
|
|
|
88
90
|
}
|
|
89
91
|
}
|
|
90
92
|
}
|
|
91
|
-
else if (event.kind === '
|
|
92
|
-
this.#
|
|
93
|
+
else if (event.kind === 'change') {
|
|
94
|
+
this.#changes.emit(event.batch);
|
|
95
|
+
const legacy = invalidationFromChange(event.batch);
|
|
96
|
+
if (legacy !== undefined)
|
|
97
|
+
this.#invalidation.emit(legacy);
|
|
93
98
|
}
|
|
94
99
|
}
|
|
95
100
|
/**
|
|
@@ -101,6 +106,9 @@ export class SyncClientHandle {
|
|
|
101
106
|
onInvalidate(listener) {
|
|
102
107
|
return this.#invalidation.on(listener);
|
|
103
108
|
}
|
|
109
|
+
onChange(listener) {
|
|
110
|
+
return this.#changes.on(listener);
|
|
111
|
+
}
|
|
104
112
|
/**
|
|
105
113
|
* §8.6: subscribe to presence changes — the identical surface as
|
|
106
114
|
* `SyncClient.onPresence`. Returns an unsubscribe function.
|
|
@@ -163,6 +171,15 @@ export class SyncClientHandle {
|
|
|
163
171
|
query(sql, params) {
|
|
164
172
|
return this.#call('query', [sql, params]);
|
|
165
173
|
}
|
|
174
|
+
querySnapshot(spec) {
|
|
175
|
+
return this.#call('querySnapshot', [spec]);
|
|
176
|
+
}
|
|
177
|
+
localRevision() {
|
|
178
|
+
return this.#call('localRevision', []);
|
|
179
|
+
}
|
|
180
|
+
statusSnapshot() {
|
|
181
|
+
return this.#call('statusSnapshot', []);
|
|
182
|
+
}
|
|
166
183
|
conflicts() {
|
|
167
184
|
return this.#call('conflicts', []);
|
|
168
185
|
}
|
|
@@ -344,9 +361,6 @@ function buildInitConfig(config) {
|
|
|
344
361
|
...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
|
|
345
362
|
...(config.limits !== undefined ? { limits: config.limits } : {}),
|
|
346
363
|
...(config.autoSync !== undefined ? { autoSync: config.autoSync } : {}),
|
|
347
|
-
...(config.wakeJitterMs !== undefined
|
|
348
|
-
? { wakeJitterMs: config.wakeJitterMs }
|
|
349
|
-
: {}),
|
|
350
364
|
};
|
|
351
365
|
}
|
|
352
366
|
/** Route a worker event to the config-level callbacks (leader visibility). */
|
|
@@ -381,6 +395,7 @@ export async function createSyncClientHandle(config) {
|
|
|
381
395
|
const lock = config.leaderLock ?? defaultLeaderLock();
|
|
382
396
|
const lockName = config.lockName ?? 'syncular-leader';
|
|
383
397
|
const invalidation = new InvalidationEmitter();
|
|
398
|
+
const changes = new ChangeEmitter();
|
|
384
399
|
const presence = new Set();
|
|
385
400
|
const roleListeners = new Set();
|
|
386
401
|
if (config.onRoleChange !== undefined)
|
|
@@ -398,6 +413,7 @@ export async function createSyncClientHandle(config) {
|
|
|
398
413
|
return await bootLeader(config, lockName, lease, {
|
|
399
414
|
epoch: 0,
|
|
400
415
|
invalidation,
|
|
416
|
+
changes,
|
|
401
417
|
presence,
|
|
402
418
|
roleListeners,
|
|
403
419
|
});
|
|
@@ -409,6 +425,7 @@ export async function createSyncClientHandle(config) {
|
|
|
409
425
|
role: 'follower',
|
|
410
426
|
clientId: '',
|
|
411
427
|
invalidation,
|
|
428
|
+
changes,
|
|
412
429
|
presence,
|
|
413
430
|
roleListeners,
|
|
414
431
|
});
|
|
@@ -416,6 +433,7 @@ export async function createSyncClientHandle(config) {
|
|
|
416
433
|
// ---- Follower: proxy to the leader; contest + promote on its close. ----
|
|
417
434
|
return await bootFollower(config, lockName, lock, {
|
|
418
435
|
invalidation,
|
|
436
|
+
changes,
|
|
419
437
|
presence,
|
|
420
438
|
roleListeners,
|
|
421
439
|
});
|
|
@@ -453,6 +471,7 @@ async function bootLeader(config, lockName, lease, parts) {
|
|
|
453
471
|
clientId: core.clientId,
|
|
454
472
|
core,
|
|
455
473
|
invalidation: parts.invalidation,
|
|
474
|
+
changes: parts.changes,
|
|
456
475
|
presence: parts.presence,
|
|
457
476
|
roleListeners: parts.roleListeners,
|
|
458
477
|
});
|
|
@@ -536,6 +555,7 @@ async function bootFollower(config, lockName, lock, parts) {
|
|
|
536
555
|
clientId: '',
|
|
537
556
|
follower,
|
|
538
557
|
invalidation: parts.invalidation,
|
|
558
|
+
changes: parts.changes,
|
|
539
559
|
presence: parts.presence,
|
|
540
560
|
roleListeners: parts.roleListeners,
|
|
541
561
|
});
|
|
@@ -12,15 +12,15 @@
|
|
|
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
|
*/
|
|
19
19
|
import type { WakeReason } from '@syncular/core';
|
|
20
20
|
import type { BlobRef, CachedBlob } from './blob.js';
|
|
21
|
-
import type { ConflictRecord, LeaseState, MutationInput, PresencePeer, RejectionRecord, SchemaFloor, SubscribeInput, SyncClientLimits, SyncSummary, WindowState } from './client.js';
|
|
21
|
+
import type { ConflictRecord, LeaseState, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SchemaFloor, SubscribeInput, SyncClientLimits, SyncSummary, WindowState } from './client.js';
|
|
22
22
|
import type { SqlRow, SqlValue } from './database.js';
|
|
23
|
-
import type {
|
|
23
|
+
import type { ClientChangeBatch, LocalRevision, SyncStatusSnapshot } from './invalidation.js';
|
|
24
24
|
import type { OutboxCommit } from './outbox.js';
|
|
25
25
|
import type { ClientSchema } from './schema.js';
|
|
26
26
|
import type { SubscriptionRecord } from './state.js';
|
|
@@ -63,12 +63,11 @@ export interface WorkerInitConfig {
|
|
|
63
63
|
readonly clientId?: string;
|
|
64
64
|
readonly limits?: SyncClientLimits;
|
|
65
65
|
/**
|
|
66
|
-
* Worker-side host loop (§8.4): coalesce
|
|
66
|
+
* Worker-side host loop (§8.4): coalesce interactive work immediately and
|
|
67
|
+
* honor explicit background retry deadlines
|
|
67
68
|
* `syncUntilIdle` rounds inside the worker. Default true.
|
|
68
69
|
*/
|
|
69
70
|
readonly autoSync?: boolean;
|
|
70
|
-
/** Max jitter before a coalesced auto-sync round (default 200 ms). */
|
|
71
|
-
readonly wakeJitterMs?: number;
|
|
72
71
|
}
|
|
73
72
|
/** Successful init reply. */
|
|
74
73
|
export interface WorkerInitResult {
|
|
@@ -89,6 +88,9 @@ export interface WorkerApi {
|
|
|
89
88
|
sync(): Promise<SyncSummary>;
|
|
90
89
|
syncUntilIdle(maxRounds?: number): Promise<SyncSummary>;
|
|
91
90
|
query(sql: string, params?: readonly SqlValue[]): SqlRow[];
|
|
91
|
+
querySnapshot(spec: QueryReadSpec): QuerySnapshot;
|
|
92
|
+
localRevision(): LocalRevision;
|
|
93
|
+
statusSnapshot(): SyncStatusSnapshot;
|
|
92
94
|
conflicts(): readonly ConflictRecord[];
|
|
93
95
|
rejections(): readonly RejectionRecord[];
|
|
94
96
|
schemaFloor(): SchemaFloor | undefined;
|
|
@@ -138,7 +140,7 @@ export interface WorkerErrorShape {
|
|
|
138
140
|
}
|
|
139
141
|
export type SyncWorkerEvent = {
|
|
140
142
|
readonly kind: 'sync-needed';
|
|
141
|
-
readonly reason: 'hello' | WakeReason;
|
|
143
|
+
readonly reason: 'startup' | 'hello' | WakeReason;
|
|
142
144
|
} | {
|
|
143
145
|
readonly kind: 'conflict';
|
|
144
146
|
readonly conflict: ConflictRecord;
|
|
@@ -156,13 +158,9 @@ export type SyncWorkerEvent = {
|
|
|
156
158
|
readonly kind: 'presence';
|
|
157
159
|
readonly scopeKey: string;
|
|
158
160
|
} | {
|
|
159
|
-
/**
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
* verbatim — the handle re-emits it to `onInvalidate` listeners.
|
|
163
|
-
*/
|
|
164
|
-
readonly kind: 'invalidate';
|
|
165
|
-
readonly event: InvalidationEvent;
|
|
161
|
+
/** Exact revisioned core transaction; Sets and bigint clone directly. */
|
|
162
|
+
readonly kind: 'change';
|
|
163
|
+
readonly batch: ClientChangeBatch;
|
|
166
164
|
};
|
|
167
165
|
export type WorkerToMainMessage = {
|
|
168
166
|
readonly t: 'ready';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
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.
|
|
84
|
+
"@syncular/core": "0.5.1"
|
|
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.
|
|
95
|
+
"@syncular/server": "0.5.1",
|
|
96
96
|
"@types/better-sqlite3": "^7.6.13",
|
|
97
97
|
"better-sqlite3": "^12.11.1"
|
|
98
98
|
}
|
package/src/apply.ts
CHANGED
|
@@ -22,6 +22,9 @@ import {
|
|
|
22
22
|
toSqlValue,
|
|
23
23
|
} from './schema';
|
|
24
24
|
|
|
25
|
+
/** Lets the client wrap the physical write in its revision transaction. */
|
|
26
|
+
export type ApplyTransaction = <T>(fn: () => T) => T;
|
|
27
|
+
|
|
25
28
|
function upsertSql(table: CompiledClientTable): string {
|
|
26
29
|
const names = [
|
|
27
30
|
...table.columns.map((column) => quoteIdent(column.name)),
|
|
@@ -68,6 +71,7 @@ export async function applyCommitFrame(
|
|
|
68
71
|
schema: CompiledClientSchema,
|
|
69
72
|
frame: CommitFrame,
|
|
70
73
|
encryption?: EncryptionConfig,
|
|
74
|
+
transaction: ApplyTransaction = (fn) => db.transaction(fn),
|
|
71
75
|
): Promise<void> {
|
|
72
76
|
type Resolved =
|
|
73
77
|
| { op: 'delete'; table: CompiledClientTable; rowId: string }
|
|
@@ -115,7 +119,7 @@ export async function applyCommitFrame(
|
|
|
115
119
|
rowVersion: change.rowVersion,
|
|
116
120
|
});
|
|
117
121
|
}
|
|
118
|
-
|
|
122
|
+
transaction(() => {
|
|
119
123
|
for (const change of resolved) {
|
|
120
124
|
if (change.op === 'delete') {
|
|
121
125
|
deleteLocalRow(db, change.table, change.rowId);
|
|
@@ -290,7 +294,11 @@ export function applySqliteSegment(
|
|
|
290
294
|
table: CompiledClientTable,
|
|
291
295
|
bytes: Uint8Array,
|
|
292
296
|
descriptor: SqliteSegmentDescriptor,
|
|
293
|
-
options: {
|
|
297
|
+
options: {
|
|
298
|
+
readonly clearFirst: boolean;
|
|
299
|
+
readonly effective: ScopeMap;
|
|
300
|
+
readonly transaction?: ApplyTransaction;
|
|
301
|
+
},
|
|
294
302
|
): number {
|
|
295
303
|
const withImage = db.withSqliteImage?.bind(db);
|
|
296
304
|
if (withImage === undefined) {
|
|
@@ -359,7 +367,7 @@ export function applySqliteSegment(
|
|
|
359
367
|
|
|
360
368
|
// 3. One transaction: fresh-bootstrap clear, then replace-or-upsert.
|
|
361
369
|
const names = table.columns.map((column) => quoteIdent(column.name));
|
|
362
|
-
return db.transaction(() => {
|
|
370
|
+
return (options.transaction ?? ((fn) => db.transaction(fn)))(() => {
|
|
363
371
|
if (options.clearFirst) {
|
|
364
372
|
deleteScopedRows(db, table, options.effective);
|
|
365
373
|
}
|
|
@@ -396,7 +404,11 @@ export async function applyRowsSegment(
|
|
|
396
404
|
schema: CompiledClientSchema,
|
|
397
405
|
table: CompiledClientTable,
|
|
398
406
|
segment: RowsSegment,
|
|
399
|
-
options: {
|
|
407
|
+
options: {
|
|
408
|
+
readonly clearFirst: boolean;
|
|
409
|
+
readonly effective: ScopeMap;
|
|
410
|
+
readonly transaction?: ApplyTransaction;
|
|
411
|
+
},
|
|
400
412
|
encryption?: EncryptionConfig,
|
|
401
413
|
): Promise<number> {
|
|
402
414
|
validateSegmentColumns(schema, table, segment);
|
|
@@ -421,7 +433,8 @@ export async function applyRowsSegment(
|
|
|
421
433
|
}
|
|
422
434
|
const clearThisBlock = first && options.clearFirst;
|
|
423
435
|
first = false;
|
|
424
|
-
|
|
436
|
+
if (!clearThisBlock && rows.length === 0) continue;
|
|
437
|
+
(options.transaction ?? ((fn) => db.transaction(fn)))(() => {
|
|
425
438
|
if (clearThisBlock) {
|
|
426
439
|
deleteScopedRows(db, table, options.effective);
|
|
427
440
|
}
|