@syncular/client 0.15.46 → 0.15.48
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 +43 -43
- package/dist/bun-database.d.ts +5 -0
- package/dist/bun-database.js +5 -0
- package/dist/client.d.ts +4 -0
- package/dist/client.js +127 -17
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/node-database.d.ts +6 -29
- package/dist/node-database.js +7 -69
- package/dist/query-guard.d.ts +2 -2
- package/dist/query-guard.js +2 -2
- package/dist/remote.d.ts +2 -0
- package/dist/remote.js +10 -2
- package/dist/sqlite-bun.d.ts +2 -0
- package/dist/sqlite-bun.js +4 -0
- package/dist/sqlite-node.d.ts +2 -0
- package/dist/sqlite-node.js +4 -0
- package/dist/sync-scheduler.d.ts +23 -0
- package/dist/sync-scheduler.js +122 -0
- package/dist/window.d.ts +5 -0
- package/dist/window.js +39 -0
- package/dist/worker-entry.js +2 -9
- package/package.json +12 -14
- package/src/bun-database.ts +11 -0
- package/src/client.ts +144 -16
- package/src/index.ts +2 -1
- package/src/node-database.ts +11 -108
- package/src/query-guard.ts +2 -2
- package/src/remote.ts +11 -2
- package/src/sqlite-bun.ts +6 -0
- package/src/sqlite-node.ts +6 -0
- package/src/sync-scheduler.ts +154 -0
- package/src/window.ts +65 -0
- package/src/worker-entry.ts +3 -11
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { WakeReason } from '@syncular/core';
|
|
2
|
+
import type { SecurityLifecycle } from './client';
|
|
3
|
+
import type { SyncIntent } from './invalidation';
|
|
4
|
+
|
|
5
|
+
export interface SyncSchedulerClient {
|
|
6
|
+
readonly syncNeeded: boolean;
|
|
7
|
+
readonly securityLifecycle: SecurityLifecycle;
|
|
8
|
+
syncUntilIdle(maxRounds?: number): Promise<unknown>;
|
|
9
|
+
onSyncNeeded(
|
|
10
|
+
listener: (reason: 'startup' | 'hello' | WakeReason) => void,
|
|
11
|
+
): () => void;
|
|
12
|
+
onSyncIntent(listener: (intent: SyncIntent) => void): () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SyncSchedulerOptions {
|
|
16
|
+
readonly maxRounds?: number;
|
|
17
|
+
readonly onError?: (error: unknown) => void;
|
|
18
|
+
readonly now?: () => number;
|
|
19
|
+
readonly queueMicrotask?: (callback: () => void) => void;
|
|
20
|
+
readonly schedule?: (callback: () => void, delayMs: number) => () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SyncScheduler {
|
|
24
|
+
readonly stopped: boolean;
|
|
25
|
+
stop(): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Install the event-driven single-flight host loop for a direct client. */
|
|
29
|
+
export function installSyncScheduler(
|
|
30
|
+
client: SyncSchedulerClient,
|
|
31
|
+
options: SyncSchedulerOptions = {},
|
|
32
|
+
): SyncScheduler {
|
|
33
|
+
const now = options.now ?? Date.now;
|
|
34
|
+
const enqueue = options.queueMicrotask ?? globalThis.queueMicrotask;
|
|
35
|
+
const schedule =
|
|
36
|
+
options.schedule ??
|
|
37
|
+
((callback: () => void, delayMs: number): (() => void) => {
|
|
38
|
+
const timer = globalThis.setTimeout(callback, delayMs);
|
|
39
|
+
return () => globalThis.clearTimeout(timer);
|
|
40
|
+
});
|
|
41
|
+
let stopped = false;
|
|
42
|
+
let running = false;
|
|
43
|
+
let immediatePending = false;
|
|
44
|
+
let immediateQueued = false;
|
|
45
|
+
let backgroundReady = false;
|
|
46
|
+
let backgroundDue = Number.POSITIVE_INFINITY;
|
|
47
|
+
let cancelBackground: (() => void) | undefined;
|
|
48
|
+
|
|
49
|
+
const report = (error: unknown): void => {
|
|
50
|
+
if (options.onError !== undefined) {
|
|
51
|
+
options.onError(error);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const root: typeof globalThis & {
|
|
55
|
+
reportError?: (error: unknown) => void;
|
|
56
|
+
} = globalThis;
|
|
57
|
+
if (root.reportError !== undefined) root.reportError(error);
|
|
58
|
+
else console.error(error);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const clearBackground = (): void => {
|
|
62
|
+
cancelBackground?.();
|
|
63
|
+
cancelBackground = undefined;
|
|
64
|
+
backgroundReady = false;
|
|
65
|
+
backgroundDue = Number.POSITIVE_INFINITY;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const queueImmediate = (): void => {
|
|
69
|
+
immediatePending = true;
|
|
70
|
+
if (stopped || running || immediateQueued) return;
|
|
71
|
+
immediateQueued = true;
|
|
72
|
+
enqueue(() => {
|
|
73
|
+
immediateQueued = false;
|
|
74
|
+
if (stopped || !immediatePending) return;
|
|
75
|
+
immediatePending = false;
|
|
76
|
+
run();
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const run = (): void => {
|
|
81
|
+
if (stopped || running) return;
|
|
82
|
+
if (client.securityLifecycle === 'preflight') {
|
|
83
|
+
immediatePending = false;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
running = true;
|
|
87
|
+
backgroundReady = false;
|
|
88
|
+
void client
|
|
89
|
+
.syncUntilIdle(options.maxRounds)
|
|
90
|
+
.catch((error: unknown) => {
|
|
91
|
+
if (!stopped) report(error);
|
|
92
|
+
})
|
|
93
|
+
.finally(() => {
|
|
94
|
+
running = false;
|
|
95
|
+
if (stopped) return;
|
|
96
|
+
if (immediatePending || backgroundReady) {
|
|
97
|
+
queueImmediate();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (cancelBackground !== undefined && backgroundDue <= now()) {
|
|
101
|
+
clearBackground();
|
|
102
|
+
queueImmediate();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const consume = (intent: SyncIntent): void => {
|
|
108
|
+
if (stopped) return;
|
|
109
|
+
if (intent.kind === 'none') {
|
|
110
|
+
clearBackground();
|
|
111
|
+
immediatePending = false;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (intent.kind === 'interactive') {
|
|
115
|
+
clearBackground();
|
|
116
|
+
queueImmediate();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (immediatePending || immediateQueued) return;
|
|
120
|
+
clearBackground();
|
|
121
|
+
backgroundDue = now() + Math.max(0, intent.delayMs);
|
|
122
|
+
cancelBackground = schedule(
|
|
123
|
+
() => {
|
|
124
|
+
cancelBackground = undefined;
|
|
125
|
+
backgroundDue = Number.POSITIVE_INFINITY;
|
|
126
|
+
backgroundReady = true;
|
|
127
|
+
if (!running) queueImmediate();
|
|
128
|
+
},
|
|
129
|
+
Math.max(0, intent.delayMs),
|
|
130
|
+
);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const unsubscribeNeeded = client.onSyncNeeded(() => {
|
|
134
|
+
consume({ kind: 'interactive' });
|
|
135
|
+
});
|
|
136
|
+
const unsubscribeIntent = client.onSyncIntent(consume);
|
|
137
|
+
if (client.syncNeeded && client.securityLifecycle === 'active') {
|
|
138
|
+
consume({ kind: 'interactive' });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
get stopped() {
|
|
143
|
+
return stopped;
|
|
144
|
+
},
|
|
145
|
+
stop() {
|
|
146
|
+
if (stopped) return;
|
|
147
|
+
stopped = true;
|
|
148
|
+
clearBackground();
|
|
149
|
+
immediatePending = false;
|
|
150
|
+
unsubscribeNeeded();
|
|
151
|
+
unsubscribeIntent();
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
package/src/window.ts
CHANGED
|
@@ -14,6 +14,71 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { canonicalScopeJson, type ScopeMap } from '@syncular/core';
|
|
16
16
|
import type { ClientDatabase } from './database';
|
|
17
|
+
import { ClientSyncError } from './errors';
|
|
18
|
+
|
|
19
|
+
export type TimeBucketUnit = 'month';
|
|
20
|
+
|
|
21
|
+
const MAX_TIME_BUCKET_MS = 253_402_300_799_999;
|
|
22
|
+
|
|
23
|
+
function monthBucket(year: number, month: number): string {
|
|
24
|
+
return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Derive the immutable UTC scope value stored when a row is created. */
|
|
28
|
+
export function creationTimeBucket(
|
|
29
|
+
createdAtMs: number,
|
|
30
|
+
unit: TimeBucketUnit,
|
|
31
|
+
): string {
|
|
32
|
+
if (
|
|
33
|
+
unit !== 'month' ||
|
|
34
|
+
!Number.isSafeInteger(createdAtMs) ||
|
|
35
|
+
createdAtMs < 0 ||
|
|
36
|
+
createdAtMs > MAX_TIME_BUCKET_MS
|
|
37
|
+
) {
|
|
38
|
+
throw new ClientSyncError(
|
|
39
|
+
'sync.invalid_request',
|
|
40
|
+
'creationTimeBucket requires a supported unit and a UTC timestamp from 1970 through 9999',
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
const date = new Date(createdAtMs);
|
|
44
|
+
return monthBucket(date.getUTCFullYear(), date.getUTCMonth() + 1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Return a rolling UTC month window ordered from oldest to newest. */
|
|
48
|
+
export function last(
|
|
49
|
+
count: number,
|
|
50
|
+
unit: TimeBucketUnit,
|
|
51
|
+
nowMs = Date.now(),
|
|
52
|
+
): string[] {
|
|
53
|
+
if (
|
|
54
|
+
unit !== 'month' ||
|
|
55
|
+
!Number.isSafeInteger(count) ||
|
|
56
|
+
count < 1 ||
|
|
57
|
+
count > 1_200 ||
|
|
58
|
+
!Number.isSafeInteger(nowMs) ||
|
|
59
|
+
nowMs < 0 ||
|
|
60
|
+
nowMs > MAX_TIME_BUCKET_MS
|
|
61
|
+
) {
|
|
62
|
+
throw new ClientSyncError(
|
|
63
|
+
'sync.invalid_request',
|
|
64
|
+
'last requires a supported unit, a count from 1 through 1200, and a UTC timestamp from 1970 through 9999',
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
const date = new Date(nowMs);
|
|
68
|
+
const current = date.getUTCFullYear() * 12 + date.getUTCMonth();
|
|
69
|
+
if (current - (count - 1) < 1970 * 12) {
|
|
70
|
+
throw new ClientSyncError(
|
|
71
|
+
'sync.invalid_request',
|
|
72
|
+
'last requires every returned UTC month to fall from 1970 through 9999',
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const units: string[] = [];
|
|
76
|
+
for (let offset = count - 1; offset >= 0; offset -= 1) {
|
|
77
|
+
const value = current - offset;
|
|
78
|
+
units.push(monthBucket(Math.floor(value / 12), (value % 12) + 1));
|
|
79
|
+
}
|
|
80
|
+
return units;
|
|
81
|
+
}
|
|
17
82
|
|
|
18
83
|
/**
|
|
19
84
|
* A window base: one table, one variable whose values are the window
|
package/src/worker-entry.ts
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
httpSyncTransport,
|
|
28
28
|
webSocketRealtimeConnector,
|
|
29
29
|
} from './http';
|
|
30
|
-
import type {
|
|
30
|
+
import type { SyncIntent } from './invalidation';
|
|
31
31
|
import type {
|
|
32
32
|
RealtimeConnector,
|
|
33
33
|
SegmentDownloader,
|
|
@@ -211,10 +211,6 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
211
211
|
queueMicrotask(runAutoSync);
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
-
function consumeEffects(effects: CommandEffects): void {
|
|
215
|
-
consumeSyncIntent(effects.sync);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
214
|
function requireClient(): SyncClient {
|
|
219
215
|
if (client === undefined) {
|
|
220
216
|
throw new ClientSyncError(
|
|
@@ -398,14 +394,11 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
398
394
|
subscribe: (input) => requireClient().subscribe(input),
|
|
399
395
|
unsubscribe: (id) => requireClient().unsubscribe(id),
|
|
400
396
|
setWindow: async (base, units) => {
|
|
401
|
-
|
|
402
|
-
consumeEffects(result.effects);
|
|
397
|
+
await requireClient().setWindowCommand(base, units);
|
|
403
398
|
},
|
|
404
399
|
windowState: (base) => requireClient().windowState(base),
|
|
405
400
|
mutate: (mutations) => {
|
|
406
|
-
|
|
407
|
-
consumeEffects(result.effects);
|
|
408
|
-
return result.value;
|
|
401
|
+
return requireClient().mutateCommand(mutations).value;
|
|
409
402
|
},
|
|
410
403
|
patch: (table, rowId, partial, options) => {
|
|
411
404
|
// Same §8.4 rule as `mutate`: a local write must push without the app
|
|
@@ -416,7 +409,6 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
416
409
|
partial,
|
|
417
410
|
options,
|
|
418
411
|
);
|
|
419
|
-
consumeEffects(result.effects);
|
|
420
412
|
return result.value;
|
|
421
413
|
},
|
|
422
414
|
purgeLocalData: (input) => requireClient().purgeLocalData(input),
|