@nsky/sync 0.1.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 +280 -0
- package/dist/core/index.cjs +277 -0
- package/dist/core/index.d.cts +2 -0
- package/dist/core/index.d.mts +2 -0
- package/dist/core/index.mjs +272 -0
- package/dist/core/index.mjs.map +1 -0
- package/dist/http/index.cjs +184 -0
- package/dist/http/index.d.cts +37 -0
- package/dist/http/index.d.cts.map +1 -0
- package/dist/http/index.d.mts +37 -0
- package/dist/http/index.d.mts.map +1 -0
- package/dist/http/index.mjs +184 -0
- package/dist/http/index.mjs.map +1 -0
- package/dist/index.cjs +23 -0
- package/dist/index.d.cts +56 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +56 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +5 -0
- package/dist/index2.d.cts +168 -0
- package/dist/index2.d.cts.map +1 -0
- package/dist/index2.d.mts +168 -0
- package/dist/index2.d.mts.map +1 -0
- package/dist/index3.d.cts +5 -0
- package/dist/index3.d.mts +5 -0
- package/dist/sql/index.cjs +149 -0
- package/dist/sql/index.d.cts +57 -0
- package/dist/sql/index.d.cts.map +1 -0
- package/dist/sql/index.d.mts +57 -0
- package/dist/sql/index.d.mts.map +1 -0
- package/dist/sql/index.mjs +126 -0
- package/dist/sql/index.mjs.map +1 -0
- package/dist/utils/index.cjs +148 -0
- package/dist/utils/index.d.cts +2 -0
- package/dist/utils/index.d.mts +2 -0
- package/dist/utils/index.mjs +143 -0
- package/dist/utils/index.mjs.map +1 -0
- package/package.json +90 -0
package/README.md
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
# @nsky/sync
|
|
2
|
+
|
|
3
|
+
Headless local-first sync primitives for applications that keep UI reads and writes local, then synchronize modeled changes in the background.
|
|
4
|
+
|
|
5
|
+
The package follows the `SQLite + Operation Log + HLC + Incremental Sync` design in this workspace. It does not sync database files. It syncs application-level `Change` records through storage and transport adapters.
|
|
6
|
+
|
|
7
|
+
## What It Provides
|
|
8
|
+
|
|
9
|
+
- `SyncEngine`: runs push, pull, cursor persistence, and remote apply cycles.
|
|
10
|
+
- `TypedSyncEventBus`: reports sync lifecycle events without binding to any UI framework.
|
|
11
|
+
- `LWWResolver`: resolves conflicts with HLC-based last-write-wins semantics and tombstone priority.
|
|
12
|
+
- `HLCClock`: creates monotonic hybrid logical timestamps for change versions.
|
|
13
|
+
- `MemoryDatabaseAdapter`: in-memory database adapter for tests and local prototypes.
|
|
14
|
+
- `DexieDatabaseAdapter`: IndexedDB-backed adapter for browser and extension contexts.
|
|
15
|
+
- `FetchSyncTransport`: HTTP transport for `/push` and `/pull` endpoints.
|
|
16
|
+
- `WebSocketSyncTransport`: request/response transport with real-time change notifications.
|
|
17
|
+
- `DefaultSyncScheduler`: interval, foreground, network-recovery, retry, and tombstone cleanup scheduling.
|
|
18
|
+
- `ExponentialBackoffPolicy`: reusable retry policy for transports and scheduling.
|
|
19
|
+
|
|
20
|
+
## Import Paths
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { SyncEngine } from "@nsky/sync/core";
|
|
24
|
+
import { FetchSyncTransport, WebSocketSyncTransport } from "@nsky/sync/http";
|
|
25
|
+
import { DexieDatabaseAdapter, MemoryDatabaseAdapter } from "@nsky/sync/sql";
|
|
26
|
+
import { HLCClock, createChangeId, createDeviceId } from "@nsky/sync/utils";
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The root export also re-exports all modules:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { SyncEngine, FetchSyncTransport, HLCClock } from "@nsky/sync";
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quick Start
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { SyncEngine } from "@nsky/sync/core";
|
|
39
|
+
import { FetchSyncTransport } from "@nsky/sync/http";
|
|
40
|
+
import { MemoryDatabaseAdapter } from "@nsky/sync/sql";
|
|
41
|
+
import { HLCClock, createChangeId, createDeviceId } from "@nsky/sync/utils";
|
|
42
|
+
|
|
43
|
+
const deviceId = createDeviceId();
|
|
44
|
+
const clock = new HLCClock(deviceId);
|
|
45
|
+
|
|
46
|
+
const database = new MemoryDatabaseAdapter({
|
|
47
|
+
pendingChanges: [
|
|
48
|
+
{
|
|
49
|
+
id: createChangeId(),
|
|
50
|
+
table: "bookmarks",
|
|
51
|
+
entityId: "bookmark-1",
|
|
52
|
+
action: "update",
|
|
53
|
+
payload: { title: "Local-first sync" },
|
|
54
|
+
version: clock.now(),
|
|
55
|
+
deviceId,
|
|
56
|
+
createdAt: Date.now(),
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const transport = new FetchSyncTransport({
|
|
62
|
+
baseUrl: "https://api.example.com/sync",
|
|
63
|
+
headers: {
|
|
64
|
+
Authorization: "Bearer <token>",
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const engine = new SyncEngine({
|
|
69
|
+
database,
|
|
70
|
+
transport,
|
|
71
|
+
deviceId,
|
|
72
|
+
batchSize: 100,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
engine.events.on("statusChange", (status) => {
|
|
76
|
+
console.log("sync status:", status);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
engine.events.on("error", (error) => {
|
|
80
|
+
console.error("sync failed:", error.code, error.message);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const result = await engine.sync();
|
|
84
|
+
console.log(result);
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Browser IndexedDB Adapter
|
|
88
|
+
|
|
89
|
+
Use `DexieDatabaseAdapter` when the app runs in a browser, extension, or other IndexedDB-capable runtime:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
import { DexieDatabaseAdapter } from "@nsky/sync/sql";
|
|
93
|
+
|
|
94
|
+
const database = new DexieDatabaseAdapter({
|
|
95
|
+
databaseName: "my-app-sync",
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
await database.recordLocalChange({
|
|
99
|
+
id: createChangeId(),
|
|
100
|
+
table: "bookmarks",
|
|
101
|
+
entityId: "bookmark-1",
|
|
102
|
+
action: "update",
|
|
103
|
+
payload: { title: "Updated locally" },
|
|
104
|
+
version: clock.now(),
|
|
105
|
+
deviceId,
|
|
106
|
+
createdAt: Date.now(),
|
|
107
|
+
});
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`recordLocalChange()` is the hook your Repository layer should call in the same user action that updates local business data. The adapter stores pending changes, remote row snapshots, cursors, and tombstones; it intentionally does not know your app-specific tables.
|
|
111
|
+
|
|
112
|
+
## WebSocket Transport
|
|
113
|
+
|
|
114
|
+
`WebSocketSyncTransport` implements the same `SyncTransport` contract as HTTP and can also wake the engine when the server announces new changes:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
import { WebSocketSyncTransport } from "@nsky/sync/http";
|
|
118
|
+
|
|
119
|
+
const transport = new WebSocketSyncTransport({
|
|
120
|
+
url: "wss://api.example.com/sync",
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Client-to-server messages use generated `id` values:
|
|
125
|
+
|
|
126
|
+
- `{ id, type: "push", payload }`
|
|
127
|
+
- `{ id, type: "pull", cursor, limit }`
|
|
128
|
+
|
|
129
|
+
Server responses should echo the matching `id`:
|
|
130
|
+
|
|
131
|
+
- `{ id, type: "pushResult" }`
|
|
132
|
+
- `{ id, type: "pullResult", result }`
|
|
133
|
+
- `{ type: "changes", cursor }` for real-time notifications.
|
|
134
|
+
|
|
135
|
+
## Scheduler
|
|
136
|
+
|
|
137
|
+
Wrap an engine with `DefaultSyncScheduler` when you want background sync behavior:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
import { DefaultSyncScheduler, SyncEngine } from "@nsky/sync/core";
|
|
141
|
+
import { ExponentialBackoffPolicy } from "@nsky/sync/utils";
|
|
142
|
+
|
|
143
|
+
const engine = new SyncEngine({ database, transport, deviceId });
|
|
144
|
+
const scheduler = new DefaultSyncScheduler({
|
|
145
|
+
engine,
|
|
146
|
+
database,
|
|
147
|
+
retryPolicy: new ExponentialBackoffPolicy({
|
|
148
|
+
maxAttempts: 5,
|
|
149
|
+
baseDelayMs: 1_000,
|
|
150
|
+
maxDelayMs: 60_000,
|
|
151
|
+
}),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
scheduler.start({
|
|
155
|
+
intervalMs: 30_000,
|
|
156
|
+
syncOnNetworkRecover: true,
|
|
157
|
+
syncOnForeground: true,
|
|
158
|
+
tombstoneCleanupIntervalMs: 7 * 24 * 60 * 60 * 1_000,
|
|
159
|
+
tombstoneMaxAgeMs: 30 * 24 * 60 * 60 * 1_000,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await scheduler.triggerSync();
|
|
163
|
+
scheduler.stop();
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The scheduler prevents overlapping `sync()` calls. Failed sync results are passed through the configured `RetryPolicy`; successful sync resets the retry state.
|
|
167
|
+
|
|
168
|
+
## Sync Flow
|
|
169
|
+
|
|
170
|
+
`SyncEngine.sync()` performs one complete cycle:
|
|
171
|
+
|
|
172
|
+
1. Read pending local changes from `DatabaseAdapter.getPendingChanges()`.
|
|
173
|
+
2. Push them with `SyncTransport.push()`.
|
|
174
|
+
3. Mark pushed changes as synced.
|
|
175
|
+
4. Load the local cursor.
|
|
176
|
+
5. Pull remote pages with `SyncTransport.pull(cursor, limit)`.
|
|
177
|
+
6. Apply each page and save its cursor inside `runInTransaction()`.
|
|
178
|
+
7. Emit lifecycle events and return a `SyncResult`.
|
|
179
|
+
|
|
180
|
+
Concurrent `sync()` calls share the same in-flight promise, so one engine instance does not run overlapping sync cycles.
|
|
181
|
+
|
|
182
|
+
## Change Model
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
type ChangeAction = "insert" | "update" | "delete";
|
|
186
|
+
|
|
187
|
+
interface Change<TPayload = unknown> {
|
|
188
|
+
readonly id: string;
|
|
189
|
+
readonly table: string;
|
|
190
|
+
readonly entityId: string;
|
|
191
|
+
readonly action: ChangeAction;
|
|
192
|
+
readonly payload: TPayload;
|
|
193
|
+
readonly version: string;
|
|
194
|
+
readonly deviceId: string;
|
|
195
|
+
readonly createdAt: number;
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Use HLC timestamps for `version` so devices can compare writes consistently even when wall clocks drift.
|
|
200
|
+
|
|
201
|
+
## Adapter Contracts
|
|
202
|
+
|
|
203
|
+
Production apps should provide a durable `DatabaseAdapter`, usually backed by SQLite or IndexedDB. The adapter must make `runInTransaction()` atomic.
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
interface DatabaseAdapter {
|
|
207
|
+
runInTransaction<T>(workload: () => Promise<T>): Promise<T>;
|
|
208
|
+
getLocalCursor(): Promise<SyncCursor | null>;
|
|
209
|
+
saveLocalCursor(cursor: SyncCursor): Promise<void>;
|
|
210
|
+
getPendingChanges(limit?: number): Promise<Change[]>;
|
|
211
|
+
markAsSynced(changeIds: string[]): Promise<void>;
|
|
212
|
+
applyRemoteChanges(changes: Change[]): Promise<void>;
|
|
213
|
+
clearTombstones(beforeTimestamp: number): Promise<number>;
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Transport implementations push local batches and pull ordered remote pages:
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
interface SyncTransport {
|
|
221
|
+
readonly type: "http" | "websocket" | "webrtc" | "grpc";
|
|
222
|
+
push(payload: PushPayload): Promise<void>;
|
|
223
|
+
pull(cursor: string | null, limit?: number): Promise<PullResult>;
|
|
224
|
+
subscribe?: (onChanges: (cursor: string) => void) => () => void;
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`FetchSyncTransport` and `WebSocketSyncTransport` both accept a `retryPolicy`. By default, `ExponentialBackoffPolicy` retries recoverable network, transport, server, and unknown errors; it does not retry non-recoverable errors such as auth failures.
|
|
229
|
+
|
|
230
|
+
## HTTP Endpoint Shape
|
|
231
|
+
|
|
232
|
+
`FetchSyncTransport` expects these endpoints relative to `baseUrl`:
|
|
233
|
+
|
|
234
|
+
- `POST /push` with body `{ changes, deviceId }`; any 2xx response is accepted.
|
|
235
|
+
- `GET /pull?cursor=<cursor>&limit=<limit>` returning:
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
{
|
|
239
|
+
cursor: { value: "opaque-server-cursor", lastSyncedAt: 1710000000000 },
|
|
240
|
+
changes: [],
|
|
241
|
+
hasMore: false,
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
HTTP failures are converted to sync errors:
|
|
246
|
+
|
|
247
|
+
- `401` or `403` -> `AUTH_FAILED`
|
|
248
|
+
- `410` -> `CURSOR_EXPIRED`
|
|
249
|
+
- `5xx` -> `SERVER_ERROR`
|
|
250
|
+
- other non-2xx statuses -> `TRANSPORT_ERROR`
|
|
251
|
+
|
|
252
|
+
## Conflict Behavior
|
|
253
|
+
|
|
254
|
+
The default resolver is `LWWResolver`:
|
|
255
|
+
|
|
256
|
+
- delete changes win over non-delete changes for the same entity.
|
|
257
|
+
- otherwise, the higher HLC `version` wins.
|
|
258
|
+
- equal versions pick the remote change for deterministic convergence.
|
|
259
|
+
|
|
260
|
+
Pass a custom `ConflictResolver` to `SyncEngine` when a domain needs field-level merge, CRDT behavior, or manual conflict queues.
|
|
261
|
+
|
|
262
|
+
## Testing Helpers
|
|
263
|
+
|
|
264
|
+
`MemoryDatabaseAdapter` is intended for tests and prototypes:
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
import { MemoryDatabaseAdapter } from "@nsky/sync/sql";
|
|
268
|
+
|
|
269
|
+
const database = new MemoryDatabaseAdapter({
|
|
270
|
+
pendingChanges: [],
|
|
271
|
+
rows: [],
|
|
272
|
+
cursor: null,
|
|
273
|
+
});
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
It implements the same adapter contract as a durable database adapter, including rollback behavior for failed `runInTransaction()` workloads.
|
|
277
|
+
|
|
278
|
+
## Current Scope
|
|
279
|
+
|
|
280
|
+
This package provides the headless engine, core contracts, in-memory and IndexedDB storage adapters, HTTP and WebSocket transports, scheduler, HLC utilities, and retry policy primitives. Platform-specific durable adapters such as SQLite are expected to implement the exported `DatabaseAdapter` interface.
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_utils_index = require("../utils/index.cjs");
|
|
3
|
+
//#region src/core/conflict.ts
|
|
4
|
+
var LWWResolver = class {
|
|
5
|
+
resolve(context) {
|
|
6
|
+
const { local, remote } = context;
|
|
7
|
+
if (local.action === "delete" && remote.action !== "delete") return {
|
|
8
|
+
winner: local,
|
|
9
|
+
strategy: "Tombstone wins over non-delete change"
|
|
10
|
+
};
|
|
11
|
+
if (remote.action === "delete" && local.action !== "delete") return {
|
|
12
|
+
winner: remote,
|
|
13
|
+
strategy: "Tombstone wins over non-delete change"
|
|
14
|
+
};
|
|
15
|
+
const order = require_utils_index.compareHLC(remote.version, local.version);
|
|
16
|
+
if (order > 0) return {
|
|
17
|
+
winner: remote,
|
|
18
|
+
strategy: "LWW: remote version newer"
|
|
19
|
+
};
|
|
20
|
+
if (order < 0) return {
|
|
21
|
+
winner: local,
|
|
22
|
+
strategy: "LWW: local version newer"
|
|
23
|
+
};
|
|
24
|
+
return {
|
|
25
|
+
winner: remote,
|
|
26
|
+
strategy: "LWW: equal version, remote wins"
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
//#endregion
|
|
31
|
+
//#region src/core/errors.ts
|
|
32
|
+
function createSyncError(code, message, cause) {
|
|
33
|
+
return {
|
|
34
|
+
code,
|
|
35
|
+
message,
|
|
36
|
+
cause,
|
|
37
|
+
occurredAt: Date.now()
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function isSyncError(error) {
|
|
41
|
+
return typeof error === "object" && error !== null && "code" in error && "message" in error && "occurredAt" in error;
|
|
42
|
+
}
|
|
43
|
+
function normalizeSyncError(error) {
|
|
44
|
+
if (isSyncError(error)) return error;
|
|
45
|
+
if (error instanceof Error) return createSyncError("UNKNOWN", error.message, error);
|
|
46
|
+
return createSyncError("UNKNOWN", "Unknown sync error.", error);
|
|
47
|
+
}
|
|
48
|
+
//#endregion
|
|
49
|
+
//#region src/core/event-bus.ts
|
|
50
|
+
var TypedSyncEventBus = class {
|
|
51
|
+
#listeners = /* @__PURE__ */ new Map();
|
|
52
|
+
on(event, listener) {
|
|
53
|
+
const listeners = this.#listeners.get(event) ?? /* @__PURE__ */ new Set();
|
|
54
|
+
listeners.add(listener);
|
|
55
|
+
this.#listeners.set(event, listeners);
|
|
56
|
+
return () => this.off(event, listener);
|
|
57
|
+
}
|
|
58
|
+
once(event, listener) {
|
|
59
|
+
const wrapped = ((payload) => {
|
|
60
|
+
this.off(event, wrapped);
|
|
61
|
+
if (payload === void 0) listener();
|
|
62
|
+
else listener(payload);
|
|
63
|
+
});
|
|
64
|
+
this.on(event, wrapped);
|
|
65
|
+
}
|
|
66
|
+
off(event, listener) {
|
|
67
|
+
this.#listeners.get(event)?.delete(listener);
|
|
68
|
+
}
|
|
69
|
+
emit(event, ...args) {
|
|
70
|
+
const listeners = [...this.#listeners.get(event) ?? []];
|
|
71
|
+
for (const listener of listeners) if (args.length === 0) listener();
|
|
72
|
+
else listener(args[0]);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
//#endregion
|
|
76
|
+
//#region src/core/engine.ts
|
|
77
|
+
var SyncEngine = class {
|
|
78
|
+
#database;
|
|
79
|
+
#transport;
|
|
80
|
+
#deviceId;
|
|
81
|
+
#batchSize;
|
|
82
|
+
#unsubscribe;
|
|
83
|
+
#status = "idle";
|
|
84
|
+
#inFlight = null;
|
|
85
|
+
resolver;
|
|
86
|
+
events = new TypedSyncEventBus();
|
|
87
|
+
constructor(options) {
|
|
88
|
+
this.#database = options.database;
|
|
89
|
+
this.#transport = options.transport;
|
|
90
|
+
this.#deviceId = options.deviceId;
|
|
91
|
+
this.#batchSize = options.batchSize ?? 100;
|
|
92
|
+
this.resolver = options.resolver ?? new LWWResolver();
|
|
93
|
+
this.#unsubscribe = this.#transport.subscribe?.(() => {
|
|
94
|
+
this.sync();
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
get status() {
|
|
98
|
+
return this.#status;
|
|
99
|
+
}
|
|
100
|
+
sync() {
|
|
101
|
+
if (this.#inFlight !== null) return this.#inFlight;
|
|
102
|
+
this.#inFlight = this.#syncOnce().finally(() => {
|
|
103
|
+
this.#inFlight = null;
|
|
104
|
+
});
|
|
105
|
+
return this.#inFlight;
|
|
106
|
+
}
|
|
107
|
+
async dispose() {
|
|
108
|
+
this.#unsubscribe?.();
|
|
109
|
+
}
|
|
110
|
+
async #syncOnce() {
|
|
111
|
+
const startedAt = Date.now();
|
|
112
|
+
let uploadedCount = 0;
|
|
113
|
+
let downloadedCount = 0;
|
|
114
|
+
this.events.emit("syncStart");
|
|
115
|
+
try {
|
|
116
|
+
this.#setStatus("uploading");
|
|
117
|
+
const pendingChanges = await this.#database.getPendingChanges(this.#batchSize);
|
|
118
|
+
if (pendingChanges.length > 0) {
|
|
119
|
+
await this.#transport.push({
|
|
120
|
+
changes: pendingChanges,
|
|
121
|
+
deviceId: this.#deviceId
|
|
122
|
+
});
|
|
123
|
+
await this.#database.markAsSynced(pendingChanges.map((change) => change.id));
|
|
124
|
+
uploadedCount = pendingChanges.length;
|
|
125
|
+
}
|
|
126
|
+
this.events.emit("uploadComplete", { uploadedCount });
|
|
127
|
+
let cursor = await this.#database.getLocalCursor();
|
|
128
|
+
let hasMore = true;
|
|
129
|
+
while (hasMore) {
|
|
130
|
+
this.#setStatus("downloading");
|
|
131
|
+
const pulled = await this.#transport.pull(cursor?.value ?? null, this.#batchSize);
|
|
132
|
+
this.events.emit("downloadComplete", { downloadedCount: pulled.changes.length });
|
|
133
|
+
this.#setStatus("applying");
|
|
134
|
+
await this.#database.runInTransaction(async () => {
|
|
135
|
+
await this.#database.applyRemoteChanges([...pulled.changes]);
|
|
136
|
+
await this.#database.saveLocalCursor(pulled.cursor);
|
|
137
|
+
});
|
|
138
|
+
downloadedCount += pulled.changes.length;
|
|
139
|
+
cursor = pulled.cursor;
|
|
140
|
+
hasMore = pulled.hasMore;
|
|
141
|
+
}
|
|
142
|
+
this.#setStatus("idle");
|
|
143
|
+
const result = {
|
|
144
|
+
success: true,
|
|
145
|
+
uploadedCount,
|
|
146
|
+
downloadedCount,
|
|
147
|
+
durationMs: Date.now() - startedAt
|
|
148
|
+
};
|
|
149
|
+
this.events.emit("syncFinish", result);
|
|
150
|
+
return result;
|
|
151
|
+
} catch (cause) {
|
|
152
|
+
const error = normalizeSyncError(cause);
|
|
153
|
+
const result = {
|
|
154
|
+
success: false,
|
|
155
|
+
uploadedCount,
|
|
156
|
+
downloadedCount,
|
|
157
|
+
durationMs: Date.now() - startedAt,
|
|
158
|
+
error
|
|
159
|
+
};
|
|
160
|
+
this.events.emit("error", error);
|
|
161
|
+
this.#setStatus("idle");
|
|
162
|
+
this.events.emit("syncFinish", result);
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
#setStatus(status) {
|
|
167
|
+
this.#status = status;
|
|
168
|
+
this.events.emit("statusChange", status);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
//#endregion
|
|
172
|
+
//#region src/core/scheduler.ts
|
|
173
|
+
const DEFAULT_INTERVAL_MS = 3e4;
|
|
174
|
+
const DEFAULT_TOMBSTONE_CLEANUP_INTERVAL_MS = 10080 * 60 * 1e3;
|
|
175
|
+
const DEFAULT_TOMBSTONE_MAX_AGE_MS = 720 * 60 * 60 * 1e3;
|
|
176
|
+
var DefaultSyncScheduler = class {
|
|
177
|
+
#engine;
|
|
178
|
+
#database;
|
|
179
|
+
#defaultRetryPolicy;
|
|
180
|
+
#window;
|
|
181
|
+
#document;
|
|
182
|
+
#syncTimer = null;
|
|
183
|
+
#retryTimer = null;
|
|
184
|
+
#tombstoneTimer = null;
|
|
185
|
+
#inFlight = null;
|
|
186
|
+
#retryAttempt = 0;
|
|
187
|
+
#firstFailedAt = 0;
|
|
188
|
+
#retryPolicy;
|
|
189
|
+
#handleOnline = () => {
|
|
190
|
+
this.triggerSync();
|
|
191
|
+
};
|
|
192
|
+
#handleVisibilityChange = () => {
|
|
193
|
+
if (this.#document?.visibilityState === "visible") this.triggerSync();
|
|
194
|
+
};
|
|
195
|
+
constructor(options) {
|
|
196
|
+
this.#engine = options.engine;
|
|
197
|
+
this.#database = options.database;
|
|
198
|
+
this.#defaultRetryPolicy = options.retryPolicy ?? new require_utils_index.ExponentialBackoffPolicy();
|
|
199
|
+
this.#retryPolicy = this.#defaultRetryPolicy;
|
|
200
|
+
this.#window = options.window ?? globalThis.window;
|
|
201
|
+
this.#document = options.document ?? globalThis.document;
|
|
202
|
+
}
|
|
203
|
+
start(options = {}) {
|
|
204
|
+
this.stop();
|
|
205
|
+
this.#retryPolicy = options.retryPolicy ?? this.#defaultRetryPolicy;
|
|
206
|
+
const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
207
|
+
if (intervalMs > 0) this.#syncTimer = setInterval(() => {
|
|
208
|
+
this.triggerSync();
|
|
209
|
+
}, intervalMs);
|
|
210
|
+
if (options.syncOnNetworkRecover ?? true) this.#window?.addEventListener("online", this.#handleOnline);
|
|
211
|
+
if (options.syncOnForeground ?? true) this.#document?.addEventListener("visibilitychange", this.#handleVisibilityChange);
|
|
212
|
+
const tombstoneCleanupIntervalMs = options.tombstoneCleanupIntervalMs ?? DEFAULT_TOMBSTONE_CLEANUP_INTERVAL_MS;
|
|
213
|
+
if (this.#database !== void 0 && tombstoneCleanupIntervalMs > 0) {
|
|
214
|
+
const tombstoneMaxAgeMs = options.tombstoneMaxAgeMs ?? DEFAULT_TOMBSTONE_MAX_AGE_MS;
|
|
215
|
+
this.#tombstoneTimer = setInterval(() => {
|
|
216
|
+
this.#database?.clearTombstones(Date.now() - tombstoneMaxAgeMs);
|
|
217
|
+
}, tombstoneCleanupIntervalMs);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
stop() {
|
|
221
|
+
if (this.#syncTimer !== null) {
|
|
222
|
+
clearInterval(this.#syncTimer);
|
|
223
|
+
this.#syncTimer = null;
|
|
224
|
+
}
|
|
225
|
+
if (this.#retryTimer !== null) {
|
|
226
|
+
clearTimeout(this.#retryTimer);
|
|
227
|
+
this.#retryTimer = null;
|
|
228
|
+
}
|
|
229
|
+
if (this.#tombstoneTimer !== null) {
|
|
230
|
+
clearInterval(this.#tombstoneTimer);
|
|
231
|
+
this.#tombstoneTimer = null;
|
|
232
|
+
}
|
|
233
|
+
this.#window?.removeEventListener("online", this.#handleOnline);
|
|
234
|
+
this.#document?.removeEventListener("visibilitychange", this.#handleVisibilityChange);
|
|
235
|
+
}
|
|
236
|
+
async triggerSync() {
|
|
237
|
+
if (this.#inFlight !== null) return await this.#inFlight;
|
|
238
|
+
this.#inFlight = this.#engine.sync().then((result) => {
|
|
239
|
+
this.#handleResult(result);
|
|
240
|
+
return result;
|
|
241
|
+
});
|
|
242
|
+
try {
|
|
243
|
+
return await this.#inFlight;
|
|
244
|
+
} finally {
|
|
245
|
+
this.#inFlight = null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
#handleResult(result) {
|
|
249
|
+
if (result.success) {
|
|
250
|
+
this.#retryAttempt = 0;
|
|
251
|
+
this.#firstFailedAt = 0;
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (result.error === void 0) return;
|
|
255
|
+
this.#firstFailedAt = this.#firstFailedAt === 0 ? result.error.occurredAt : this.#firstFailedAt;
|
|
256
|
+
const decision = this.#retryPolicy.decide({
|
|
257
|
+
attempt: this.#retryAttempt,
|
|
258
|
+
firstFailedAt: this.#firstFailedAt,
|
|
259
|
+
lastError: result.error
|
|
260
|
+
});
|
|
261
|
+
this.#retryAttempt += 1;
|
|
262
|
+
if (decision.action === "abort") return;
|
|
263
|
+
if (this.#retryTimer !== null) clearTimeout(this.#retryTimer);
|
|
264
|
+
this.#retryTimer = setTimeout(() => {
|
|
265
|
+
this.#retryTimer = null;
|
|
266
|
+
this.triggerSync();
|
|
267
|
+
}, decision.delayMs);
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
//#endregion
|
|
271
|
+
exports.DefaultSyncScheduler = DefaultSyncScheduler;
|
|
272
|
+
exports.LWWResolver = LWWResolver;
|
|
273
|
+
exports.SyncEngine = SyncEngine;
|
|
274
|
+
exports.TypedSyncEventBus = TypedSyncEventBus;
|
|
275
|
+
exports.createSyncError = createSyncError;
|
|
276
|
+
exports.isSyncError = isSyncError;
|
|
277
|
+
exports.normalizeSyncError = normalizeSyncError;
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { A as SyncStatus, C as DeviceId, D as SyncError, E as SyncCursor, O as SyncErrorCode, S as DatabaseAdapter, T as PushPayload, _ as ConflictResolution, a as SyncSchedulerDatabase, b as Change, c as isSyncError, d as SyncEngineOptions, f as SyncEventBus, g as ConflictContext, h as TypedSyncEventBus, i as SyncScheduler, j as SyncTransport, k as SyncResult, l as normalizeSyncError, m as SyncEventMap, n as DefaultSyncSchedulerOptions, o as SyncSchedulerEngine, p as SyncEventListener, r as SchedulerOptions, s as createSyncError, t as DefaultSyncScheduler, u as SyncEngine, v as ConflictResolver, w as PullResult, x as ChangeAction, y as LWWResolver } from "../index2.cjs";
|
|
2
|
+
export { Change, ChangeAction, ConflictContext, ConflictResolution, ConflictResolver, DatabaseAdapter, DefaultSyncScheduler, DefaultSyncSchedulerOptions, DeviceId, LWWResolver, PullResult, PushPayload, SchedulerOptions, SyncCursor, SyncEngine, SyncEngineOptions, SyncError, SyncErrorCode, SyncEventBus, SyncEventListener, SyncEventMap, SyncResult, SyncScheduler, SyncSchedulerDatabase, SyncSchedulerEngine, SyncStatus, SyncTransport, TypedSyncEventBus, createSyncError, isSyncError, normalizeSyncError };
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { A as SyncStatus, C as DeviceId, D as SyncError, E as SyncCursor, O as SyncErrorCode, S as DatabaseAdapter, T as PushPayload, _ as ConflictResolution, a as SyncSchedulerDatabase, b as Change, c as isSyncError, d as SyncEngineOptions, f as SyncEventBus, g as ConflictContext, h as TypedSyncEventBus, i as SyncScheduler, j as SyncTransport, k as SyncResult, l as normalizeSyncError, m as SyncEventMap, n as DefaultSyncSchedulerOptions, o as SyncSchedulerEngine, p as SyncEventListener, r as SchedulerOptions, s as createSyncError, t as DefaultSyncScheduler, u as SyncEngine, v as ConflictResolver, w as PullResult, x as ChangeAction, y as LWWResolver } from "../index2.mjs";
|
|
2
|
+
export { Change, ChangeAction, ConflictContext, ConflictResolution, ConflictResolver, DatabaseAdapter, DefaultSyncScheduler, DefaultSyncSchedulerOptions, DeviceId, LWWResolver, PullResult, PushPayload, SchedulerOptions, SyncCursor, SyncEngine, SyncEngineOptions, SyncError, SyncErrorCode, SyncEventBus, SyncEventListener, SyncEventMap, SyncResult, SyncScheduler, SyncSchedulerDatabase, SyncSchedulerEngine, SyncStatus, SyncTransport, TypedSyncEventBus, createSyncError, isSyncError, normalizeSyncError };
|