@syncular/client 0.32.1 → 0.34.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/dist/client.d.ts +8 -0
- package/dist/client.js +47 -1
- package/dist/http.js +16 -11
- package/dist/sync-scheduler.d.ts +4 -2
- package/dist/sync-scheduler.js +3 -2
- package/dist/worker-entry.js +8 -2
- package/package.json +3 -3
- package/src/client.ts +52 -1
- package/src/http.ts +20 -23
- package/src/sync-scheduler.ts +7 -4
- package/src/worker-entry.ts +8 -2
package/dist/client.d.ts
CHANGED
|
@@ -639,6 +639,14 @@ export declare class SyncClient {
|
|
|
639
639
|
/**
|
|
640
640
|
* Pull repeatedly until quiescent: no commits delivered, no bootstrap
|
|
641
641
|
* pages pending, no resets to recover (§4.5 "pull again" SHOULD).
|
|
642
|
+
*
|
|
643
|
+
* §8.4: every host trigger drives this same client — the scheduler, the
|
|
644
|
+
* realtime supervisor's connect-time catch-up, and app code — so a call
|
|
645
|
+
* that arrives while a drive is in flight waits for that drive and then
|
|
646
|
+
* runs one follow-up drive. The follow-up is required, not defensive: the
|
|
647
|
+
* caller may have mutated the outbox after the in-flight drive captured it.
|
|
648
|
+
* Callers that arrive during the same drive with the same budget share that
|
|
649
|
+
* single follow-up. `sync()` keeps rejecting a concurrent `sync()`.
|
|
642
650
|
*/
|
|
643
651
|
syncUntilIdle(maxRounds?: number): Promise<SyncIdleResult>;
|
|
644
652
|
connectRealtime(): Promise<void>;
|
package/dist/client.js
CHANGED
|
@@ -178,6 +178,14 @@ export class SyncClient {
|
|
|
178
178
|
* (the fast-bail the delta path reads).
|
|
179
179
|
*/
|
|
180
180
|
#syncOutstanding = false;
|
|
181
|
+
/**
|
|
182
|
+
* Idle drives run one after another (§8.4): `#syncIdleTail` settles when
|
|
183
|
+
* the last scheduled drive settles, and `#syncIdlePending` holds the
|
|
184
|
+
* scheduled drives that have not started, keyed by round budget, so a
|
|
185
|
+
* caller joins a waiting drive with its budget instead of queueing another.
|
|
186
|
+
*/
|
|
187
|
+
#syncIdleTail;
|
|
188
|
+
#syncIdlePending = new Map();
|
|
181
189
|
/**
|
|
182
190
|
* Bumped by every local projection reset (`rebootstrapLocalData`). A sync
|
|
183
191
|
* round captures this epoch together with its subscription state; a
|
|
@@ -1829,7 +1837,12 @@ export class SyncClient {
|
|
|
1829
1837
|
if (!sameIntent) {
|
|
1830
1838
|
throw new ClientSyncError('client.subscription_intent_mismatch', 'subscription id already names different table, scopes, or params');
|
|
1831
1839
|
}
|
|
1832
|
-
|
|
1840
|
+
// §3.3: a revoked registration keeps its id and intent but no progress.
|
|
1841
|
+
// Subscribing again with the same intent re-arms it, so the next pull
|
|
1842
|
+
// resolves its scopes from scratch. A `failed` registration is a
|
|
1843
|
+
// configuration error and stays put.
|
|
1844
|
+
if (existing.status !== 'revoked')
|
|
1845
|
+
return;
|
|
1833
1846
|
}
|
|
1834
1847
|
saveSubscription(this.#db, definedObject({
|
|
1835
1848
|
id: input.id,
|
|
@@ -2918,6 +2931,14 @@ export class SyncClient {
|
|
|
2918
2931
|
/**
|
|
2919
2932
|
* Pull repeatedly until quiescent: no commits delivered, no bootstrap
|
|
2920
2933
|
* pages pending, no resets to recover (§4.5 "pull again" SHOULD).
|
|
2934
|
+
*
|
|
2935
|
+
* §8.4: every host trigger drives this same client — the scheduler, the
|
|
2936
|
+
* realtime supervisor's connect-time catch-up, and app code — so a call
|
|
2937
|
+
* that arrives while a drive is in flight waits for that drive and then
|
|
2938
|
+
* runs one follow-up drive. The follow-up is required, not defensive: the
|
|
2939
|
+
* caller may have mutated the outbox after the in-flight drive captured it.
|
|
2940
|
+
* Callers that arrive during the same drive with the same budget share that
|
|
2941
|
+
* single follow-up. `sync()` keeps rejecting a concurrent `sync()`.
|
|
2921
2942
|
*/
|
|
2922
2943
|
async syncUntilIdle(maxRounds) {
|
|
2923
2944
|
// §7.7: an explicit budget is a strict positive round count. Zero,
|
|
@@ -2925,6 +2946,31 @@ export class SyncClient {
|
|
|
2925
2946
|
if (maxRounds !== undefined &&
|
|
2926
2947
|
(!Number.isInteger(maxRounds) || maxRounds < 1 || maxRounds > 0xffffffff))
|
|
2927
2948
|
throw invalidRequest('maxRounds must be a positive integer');
|
|
2949
|
+
const pending = this.#syncIdlePending.get(maxRounds);
|
|
2950
|
+
if (pending !== undefined)
|
|
2951
|
+
return pending;
|
|
2952
|
+
const previous = this.#syncIdleTail;
|
|
2953
|
+
let drive;
|
|
2954
|
+
if (previous === undefined) {
|
|
2955
|
+
drive = this.#syncIdleRounds(maxRounds);
|
|
2956
|
+
}
|
|
2957
|
+
else {
|
|
2958
|
+
drive = previous.then(() => {
|
|
2959
|
+
this.#syncIdlePending.delete(maxRounds);
|
|
2960
|
+
return this.#syncIdleRounds(maxRounds);
|
|
2961
|
+
});
|
|
2962
|
+
this.#syncIdlePending.set(maxRounds, drive);
|
|
2963
|
+
}
|
|
2964
|
+
const tail = drive.then(() => undefined, () => undefined);
|
|
2965
|
+
this.#syncIdleTail = tail;
|
|
2966
|
+
void tail.then(() => {
|
|
2967
|
+
if (this.#syncIdleTail === tail)
|
|
2968
|
+
this.#syncIdleTail = undefined;
|
|
2969
|
+
});
|
|
2970
|
+
return drive;
|
|
2971
|
+
}
|
|
2972
|
+
/** The rounds loop of one idle drive; `syncUntilIdle` owns coalescing. */
|
|
2973
|
+
async #syncIdleRounds(maxRounds) {
|
|
2928
2974
|
const budget = maxRounds ?? 20;
|
|
2929
2975
|
const aggregate = {
|
|
2930
2976
|
pushed: 0,
|
package/dist/http.js
CHANGED
|
@@ -229,25 +229,30 @@ export function httpBlobTransport(blobsBaseUrl, options) {
|
|
|
229
229
|
: response.status === 401
|
|
230
230
|
? 'sync.auth_required'
|
|
231
231
|
: 'sync.transport_failed');
|
|
232
|
-
// §5.9.5 always-issue: a JSON body with `url` means presigned delivery;
|
|
233
|
-
//
|
|
232
|
+
// §5.9.5 always-issue: a JSON body with a `url` means presigned delivery;
|
|
233
|
+
// any other body is inline bytes. The body is read exactly once as bytes,
|
|
234
|
+
// so an inline blob stored as `application/json` stays readable.
|
|
234
235
|
const contentType = response.headers.get('content-type') ?? '';
|
|
236
|
+
const bytes = new Uint8Array(await transfer('blob download body read failed', () => response.arrayBuffer(), response.status));
|
|
235
237
|
if (contentType.includes('application/json')) {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
+
let envelope = null;
|
|
239
|
+
try {
|
|
240
|
+
envelope = JSON.parse(new TextDecoder().decode(bytes));
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
// Not JSON: inline bytes.
|
|
244
|
+
}
|
|
245
|
+
if (typeof envelope?.url === 'string') {
|
|
238
246
|
return {
|
|
239
247
|
kind: 'url',
|
|
240
|
-
url:
|
|
241
|
-
...(typeof
|
|
242
|
-
? { urlExpiresAtMs:
|
|
248
|
+
url: envelope.url,
|
|
249
|
+
...(typeof envelope.urlExpiresAtMs === 'number'
|
|
250
|
+
? { urlExpiresAtMs: envelope.urlExpiresAtMs }
|
|
243
251
|
: {}),
|
|
244
252
|
};
|
|
245
253
|
}
|
|
246
254
|
}
|
|
247
|
-
return {
|
|
248
|
-
kind: 'bytes',
|
|
249
|
-
bytes: new Uint8Array(await transfer('blob download body read failed', () => response.arrayBuffer(), response.status)),
|
|
250
|
-
};
|
|
255
|
+
return { kind: 'bytes', bytes };
|
|
251
256
|
},
|
|
252
257
|
// §5.9.5: bare GET of the signed URL — no host auth (the URL is the grant).
|
|
253
258
|
fetchUrl: async (url) => {
|
package/dist/sync-scheduler.d.ts
CHANGED
|
@@ -2,8 +2,10 @@ import type { WakeReason } from '@syncular/core';
|
|
|
2
2
|
import type { SecurityLifecycle } from './client.js';
|
|
3
3
|
import type { SyncIntent } from './invalidation.js';
|
|
4
4
|
export interface SyncSchedulerClient {
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
statusSnapshot(): {
|
|
6
|
+
readonly syncNeeded: boolean;
|
|
7
|
+
};
|
|
8
|
+
securityLifecycle(): SecurityLifecycle;
|
|
7
9
|
syncUntilIdle(maxRounds?: number): Promise<unknown>;
|
|
8
10
|
onSyncNeeded(listener: (reason: 'startup' | 'hello' | WakeReason) => void): () => void;
|
|
9
11
|
onSyncIntent(listener: (intent: SyncIntent) => void): () => void;
|
package/dist/sync-scheduler.js
CHANGED
|
@@ -47,7 +47,7 @@ export function installSyncScheduler(client, options = {}) {
|
|
|
47
47
|
const run = () => {
|
|
48
48
|
if (stopped || running)
|
|
49
49
|
return;
|
|
50
|
-
if (client.securityLifecycle === 'preflight') {
|
|
50
|
+
if (client.securityLifecycle() === 'preflight') {
|
|
51
51
|
immediatePending = false;
|
|
52
52
|
return;
|
|
53
53
|
}
|
|
@@ -110,7 +110,8 @@ export function installSyncScheduler(client, options = {}) {
|
|
|
110
110
|
consume({ kind: 'interactive' });
|
|
111
111
|
});
|
|
112
112
|
const unsubscribeIntent = client.onSyncIntent(consume);
|
|
113
|
-
if (client.syncNeeded &&
|
|
113
|
+
if (client.statusSnapshot().syncNeeded &&
|
|
114
|
+
client.securityLifecycle() === 'active') {
|
|
114
115
|
consume({ kind: 'interactive' });
|
|
115
116
|
}
|
|
116
117
|
return {
|
package/dist/worker-entry.js
CHANGED
|
@@ -76,15 +76,21 @@ export function startSyncWorker(overrides = {}) {
|
|
|
76
76
|
let backgroundTimer;
|
|
77
77
|
let backgroundDue = Number.POSITIVE_INFINITY;
|
|
78
78
|
function runAutoSync() {
|
|
79
|
-
autoSyncScheduled = false;
|
|
80
79
|
if (closed ||
|
|
81
80
|
client === undefined ||
|
|
82
81
|
offline ||
|
|
83
82
|
client.securityLifecycle() === 'preflight') {
|
|
83
|
+
autoSyncScheduled = false;
|
|
84
84
|
return;
|
|
85
85
|
}
|
|
86
86
|
const running = client;
|
|
87
|
-
void serializedSync(() =>
|
|
87
|
+
void serializedSync(() => {
|
|
88
|
+
// The queued round owns the flag from here. Clearing it at entry would
|
|
89
|
+
// let every wake-up that arrives while this round waits behind the
|
|
90
|
+
// running one queue its own full drive (§8.4 coalescing).
|
|
91
|
+
autoSyncScheduled = false;
|
|
92
|
+
return running.syncUntilIdle();
|
|
93
|
+
})
|
|
88
94
|
.then((summary) => {
|
|
89
95
|
if (!closed)
|
|
90
96
|
post({ t: 'event', event: { kind: 'synced', summary } });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
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",
|
|
@@ -97,10 +97,10 @@
|
|
|
97
97
|
},
|
|
98
98
|
"dependencies": {
|
|
99
99
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
100
|
-
"@syncular/core": "0.
|
|
100
|
+
"@syncular/core": "0.34.0"
|
|
101
101
|
},
|
|
102
102
|
"devDependencies": {
|
|
103
|
-
"@syncular/server": "0.
|
|
103
|
+
"@syncular/server": "0.34.0",
|
|
104
104
|
"playwright": "1.63.0",
|
|
105
105
|
"@electric-sql/pglite": "^0.5.4"
|
|
106
106
|
}
|
package/src/client.ts
CHANGED
|
@@ -893,6 +893,17 @@ export class SyncClient {
|
|
|
893
893
|
* (the fast-bail the delta path reads).
|
|
894
894
|
*/
|
|
895
895
|
#syncOutstanding = false;
|
|
896
|
+
/**
|
|
897
|
+
* Idle drives run one after another (§8.4): `#syncIdleTail` settles when
|
|
898
|
+
* the last scheduled drive settles, and `#syncIdlePending` holds the
|
|
899
|
+
* scheduled drives that have not started, keyed by round budget, so a
|
|
900
|
+
* caller joins a waiting drive with its budget instead of queueing another.
|
|
901
|
+
*/
|
|
902
|
+
#syncIdleTail: Promise<void> | undefined;
|
|
903
|
+
readonly #syncIdlePending = new Map<
|
|
904
|
+
number | undefined,
|
|
905
|
+
Promise<SyncIdleResult>
|
|
906
|
+
>();
|
|
896
907
|
/**
|
|
897
908
|
* Bumped by every local projection reset (`rebootstrapLocalData`). A sync
|
|
898
909
|
* round captures this epoch together with its subscription state; a
|
|
@@ -2903,7 +2914,11 @@ export class SyncClient {
|
|
|
2903
2914
|
'subscription id already names different table, scopes, or params',
|
|
2904
2915
|
);
|
|
2905
2916
|
}
|
|
2906
|
-
|
|
2917
|
+
// §3.3: a revoked registration keeps its id and intent but no progress.
|
|
2918
|
+
// Subscribing again with the same intent re-arms it, so the next pull
|
|
2919
|
+
// resolves its scopes from scratch. A `failed` registration is a
|
|
2920
|
+
// configuration error and stays put.
|
|
2921
|
+
if (existing.status !== 'revoked') return;
|
|
2907
2922
|
}
|
|
2908
2923
|
saveSubscription(
|
|
2909
2924
|
this.#db,
|
|
@@ -4227,6 +4242,14 @@ export class SyncClient {
|
|
|
4227
4242
|
/**
|
|
4228
4243
|
* Pull repeatedly until quiescent: no commits delivered, no bootstrap
|
|
4229
4244
|
* pages pending, no resets to recover (§4.5 "pull again" SHOULD).
|
|
4245
|
+
*
|
|
4246
|
+
* §8.4: every host trigger drives this same client — the scheduler, the
|
|
4247
|
+
* realtime supervisor's connect-time catch-up, and app code — so a call
|
|
4248
|
+
* that arrives while a drive is in flight waits for that drive and then
|
|
4249
|
+
* runs one follow-up drive. The follow-up is required, not defensive: the
|
|
4250
|
+
* caller may have mutated the outbox after the in-flight drive captured it.
|
|
4251
|
+
* Callers that arrive during the same drive with the same budget share that
|
|
4252
|
+
* single follow-up. `sync()` keeps rejecting a concurrent `sync()`.
|
|
4230
4253
|
*/
|
|
4231
4254
|
async syncUntilIdle(maxRounds?: number): Promise<SyncIdleResult> {
|
|
4232
4255
|
// §7.7: an explicit budget is a strict positive round count. Zero,
|
|
@@ -4236,6 +4259,34 @@ export class SyncClient {
|
|
|
4236
4259
|
(!Number.isInteger(maxRounds) || maxRounds < 1 || maxRounds > 0xffffffff)
|
|
4237
4260
|
)
|
|
4238
4261
|
throw invalidRequest('maxRounds must be a positive integer');
|
|
4262
|
+
const pending = this.#syncIdlePending.get(maxRounds);
|
|
4263
|
+
if (pending !== undefined) return pending;
|
|
4264
|
+
const previous = this.#syncIdleTail;
|
|
4265
|
+
let drive: Promise<SyncIdleResult>;
|
|
4266
|
+
if (previous === undefined) {
|
|
4267
|
+
drive = this.#syncIdleRounds(maxRounds);
|
|
4268
|
+
} else {
|
|
4269
|
+
drive = previous.then(() => {
|
|
4270
|
+
this.#syncIdlePending.delete(maxRounds);
|
|
4271
|
+
return this.#syncIdleRounds(maxRounds);
|
|
4272
|
+
});
|
|
4273
|
+
this.#syncIdlePending.set(maxRounds, drive);
|
|
4274
|
+
}
|
|
4275
|
+
const tail = drive.then(
|
|
4276
|
+
() => undefined,
|
|
4277
|
+
() => undefined,
|
|
4278
|
+
);
|
|
4279
|
+
this.#syncIdleTail = tail;
|
|
4280
|
+
void tail.then(() => {
|
|
4281
|
+
if (this.#syncIdleTail === tail) this.#syncIdleTail = undefined;
|
|
4282
|
+
});
|
|
4283
|
+
return drive;
|
|
4284
|
+
}
|
|
4285
|
+
|
|
4286
|
+
/** The rounds loop of one idle drive; `syncUntilIdle` owns coalescing. */
|
|
4287
|
+
async #syncIdleRounds(
|
|
4288
|
+
maxRounds: number | undefined,
|
|
4289
|
+
): Promise<SyncIdleResult> {
|
|
4239
4290
|
const budget = maxRounds ?? 20;
|
|
4240
4291
|
const aggregate = {
|
|
4241
4292
|
pushed: 0,
|
package/src/http.ts
CHANGED
|
@@ -355,38 +355,35 @@ export function httpBlobTransport(
|
|
|
355
355
|
? 'sync.auth_required'
|
|
356
356
|
: 'sync.transport_failed',
|
|
357
357
|
);
|
|
358
|
-
// §5.9.5 always-issue: a JSON body with `url` means presigned delivery;
|
|
359
|
-
//
|
|
358
|
+
// §5.9.5 always-issue: a JSON body with a `url` means presigned delivery;
|
|
359
|
+
// any other body is inline bytes. The body is read exactly once as bytes,
|
|
360
|
+
// so an inline blob stored as `application/json` stays readable.
|
|
360
361
|
const contentType = response.headers.get('content-type') ?? '';
|
|
361
|
-
|
|
362
|
-
|
|
362
|
+
const bytes = new Uint8Array(
|
|
363
|
+
await transfer(
|
|
363
364
|
'blob download body read failed',
|
|
364
|
-
() => response.
|
|
365
|
+
() => response.arrayBuffer(),
|
|
365
366
|
response.status,
|
|
366
|
-
)
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
};
|
|
370
|
-
|
|
367
|
+
),
|
|
368
|
+
);
|
|
369
|
+
if (contentType.includes('application/json')) {
|
|
370
|
+
let envelope: { url?: unknown; urlExpiresAtMs?: unknown } | null = null;
|
|
371
|
+
try {
|
|
372
|
+
envelope = JSON.parse(new TextDecoder().decode(bytes));
|
|
373
|
+
} catch {
|
|
374
|
+
// Not JSON: inline bytes.
|
|
375
|
+
}
|
|
376
|
+
if (typeof envelope?.url === 'string') {
|
|
371
377
|
return {
|
|
372
378
|
kind: 'url',
|
|
373
|
-
url:
|
|
374
|
-
...(typeof
|
|
375
|
-
? { urlExpiresAtMs:
|
|
379
|
+
url: envelope.url,
|
|
380
|
+
...(typeof envelope.urlExpiresAtMs === 'number'
|
|
381
|
+
? { urlExpiresAtMs: envelope.urlExpiresAtMs }
|
|
376
382
|
: {}),
|
|
377
383
|
};
|
|
378
384
|
}
|
|
379
385
|
}
|
|
380
|
-
return {
|
|
381
|
-
kind: 'bytes',
|
|
382
|
-
bytes: new Uint8Array(
|
|
383
|
-
await transfer(
|
|
384
|
-
'blob download body read failed',
|
|
385
|
-
() => response.arrayBuffer(),
|
|
386
|
-
response.status,
|
|
387
|
-
),
|
|
388
|
-
),
|
|
389
|
-
};
|
|
386
|
+
return { kind: 'bytes', bytes };
|
|
390
387
|
},
|
|
391
388
|
// §5.9.5: bare GET of the signed URL — no host auth (the URL is the grant).
|
|
392
389
|
fetchUrl: async (url) => {
|
package/src/sync-scheduler.ts
CHANGED
|
@@ -3,8 +3,8 @@ import type { SecurityLifecycle } from './client';
|
|
|
3
3
|
import type { SyncIntent } from './invalidation';
|
|
4
4
|
|
|
5
5
|
export interface SyncSchedulerClient {
|
|
6
|
-
readonly syncNeeded: boolean;
|
|
7
|
-
|
|
6
|
+
statusSnapshot(): { readonly syncNeeded: boolean };
|
|
7
|
+
securityLifecycle(): SecurityLifecycle;
|
|
8
8
|
syncUntilIdle(maxRounds?: number): Promise<unknown>;
|
|
9
9
|
onSyncNeeded(
|
|
10
10
|
listener: (reason: 'startup' | 'hello' | WakeReason) => void,
|
|
@@ -79,7 +79,7 @@ export function installSyncScheduler(
|
|
|
79
79
|
|
|
80
80
|
const run = (): void => {
|
|
81
81
|
if (stopped || running) return;
|
|
82
|
-
if (client.securityLifecycle === 'preflight') {
|
|
82
|
+
if (client.securityLifecycle() === 'preflight') {
|
|
83
83
|
immediatePending = false;
|
|
84
84
|
return;
|
|
85
85
|
}
|
|
@@ -144,7 +144,10 @@ export function installSyncScheduler(
|
|
|
144
144
|
consume({ kind: 'interactive' });
|
|
145
145
|
});
|
|
146
146
|
const unsubscribeIntent = client.onSyncIntent(consume);
|
|
147
|
-
if (
|
|
147
|
+
if (
|
|
148
|
+
client.statusSnapshot().syncNeeded &&
|
|
149
|
+
client.securityLifecycle() === 'active'
|
|
150
|
+
) {
|
|
148
151
|
consume({ kind: 'interactive' });
|
|
149
152
|
}
|
|
150
153
|
|
package/src/worker-entry.ts
CHANGED
|
@@ -164,17 +164,23 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
164
164
|
let backgroundDue = Number.POSITIVE_INFINITY;
|
|
165
165
|
|
|
166
166
|
function runAutoSync(): void {
|
|
167
|
-
autoSyncScheduled = false;
|
|
168
167
|
if (
|
|
169
168
|
closed ||
|
|
170
169
|
client === undefined ||
|
|
171
170
|
offline ||
|
|
172
171
|
client.securityLifecycle() === 'preflight'
|
|
173
172
|
) {
|
|
173
|
+
autoSyncScheduled = false;
|
|
174
174
|
return;
|
|
175
175
|
}
|
|
176
176
|
const running = client;
|
|
177
|
-
void serializedSync(() =>
|
|
177
|
+
void serializedSync(() => {
|
|
178
|
+
// The queued round owns the flag from here. Clearing it at entry would
|
|
179
|
+
// let every wake-up that arrives while this round waits behind the
|
|
180
|
+
// running one queue its own full drive (§8.4 coalescing).
|
|
181
|
+
autoSyncScheduled = false;
|
|
182
|
+
return running.syncUntilIdle();
|
|
183
|
+
})
|
|
178
184
|
.then((summary) => {
|
|
179
185
|
if (!closed) post({ t: 'event', event: { kind: 'synced', summary } });
|
|
180
186
|
if (summary.budgetExhausted) consumeSyncIntent({ kind: 'interactive' });
|