@drakkar.software/starfish-client 1.3.2 → 1.4.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/dist/bindings/broadcast.d.ts +19 -0
- package/dist/bindings/broadcast.js +65 -0
- package/dist/bindings/legend.js +5 -5
- package/dist/bindings/react.d.ts +12 -0
- package/dist/bindings/react.js +25 -0
- package/dist/bindings/zustand.d.ts +53 -1
- package/dist/bindings/zustand.js +184 -5
- package/dist/broadcast.d.ts +36 -0
- package/dist/broadcast.js +83 -0
- package/dist/client.d.ts +21 -0
- package/dist/client.js +42 -0
- package/dist/fetch.d.ts +53 -0
- package/dist/fetch.js +166 -0
- package/dist/history.d.ts +29 -0
- package/dist/history.js +61 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +7 -0
- package/dist/logger.d.ts +14 -0
- package/dist/logger.js +20 -0
- package/dist/migrate.d.ts +16 -0
- package/dist/migrate.js +38 -0
- package/dist/polling.d.ts +28 -0
- package/dist/polling.js +52 -0
- package/dist/resolvers.d.ts +50 -0
- package/dist/resolvers.js +166 -0
- package/dist/sync.d.ts +11 -0
- package/dist/sync.js +54 -20
- package/dist/testing.d.ts +47 -0
- package/dist/testing.js +86 -0
- package/dist/validate.d.ts +27 -0
- package/dist/validate.js +28 -0
- package/package.json +26 -4
package/dist/fetch.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** Error category returned by classifyError. */
|
|
2
|
+
export type ErrorCategory = "network" | "auth" | "conflict" | "rate-limited" | "server" | "client" | "unknown";
|
|
3
|
+
/** Classify an error from a fetch response or network failure. */
|
|
4
|
+
export declare function classifyError(err: unknown): ErrorCategory;
|
|
5
|
+
export interface RetryOptions {
|
|
6
|
+
/** Max number of retries (default: 3). */
|
|
7
|
+
maxRetries?: number;
|
|
8
|
+
/** Initial delay in ms before first retry (default: 500). */
|
|
9
|
+
initialDelayMs?: number;
|
|
10
|
+
/** Maximum delay in ms (default: 10000). */
|
|
11
|
+
maxDelayMs?: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Wraps a fetch function with automatic retry for retriable errors
|
|
15
|
+
* (network failures, 429, 5xx). Respects Retry-After headers.
|
|
16
|
+
*/
|
|
17
|
+
export declare function createRetryFetch(options?: RetryOptions): typeof globalThis.fetch;
|
|
18
|
+
type BreakerState = "closed" | "open" | "half-open";
|
|
19
|
+
export interface CircuitBreakerOptions {
|
|
20
|
+
/** Number of consecutive failures to open the circuit (default: 5). */
|
|
21
|
+
threshold?: number;
|
|
22
|
+
/** Cooldown in ms before transitioning from open to half-open (default: 30000). */
|
|
23
|
+
cooldownMs?: number;
|
|
24
|
+
}
|
|
25
|
+
/** Circuit breaker that prevents requests when the backend is unavailable. */
|
|
26
|
+
export declare class CircuitBreaker {
|
|
27
|
+
private state;
|
|
28
|
+
private failures;
|
|
29
|
+
private openedAt;
|
|
30
|
+
private readonly threshold;
|
|
31
|
+
private readonly cooldownMs;
|
|
32
|
+
constructor(options?: CircuitBreakerOptions);
|
|
33
|
+
getState(): BreakerState;
|
|
34
|
+
isOpen(): boolean;
|
|
35
|
+
recordSuccess(): void;
|
|
36
|
+
recordFailure(): void;
|
|
37
|
+
private maybeTransition;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Wraps fetch to gzip-compress string request bodies using the CompressionStream API.
|
|
41
|
+
* Adds Content-Encoding: gzip header. Non-string bodies (ArrayBuffer, Blob, etc.)
|
|
42
|
+
* are passed through uncompressed. Requires CompressionStream (browsers, Node.js 18+, Deno).
|
|
43
|
+
*/
|
|
44
|
+
export declare function createCompressedFetch(inner?: typeof globalThis.fetch): typeof globalThis.fetch;
|
|
45
|
+
/**
|
|
46
|
+
* Combines retry and circuit breaker into a single resilient fetch wrapper.
|
|
47
|
+
* Rejects immediately when the circuit is open.
|
|
48
|
+
*/
|
|
49
|
+
export declare function createResilientFetch(retryOptions?: RetryOptions, breakerOptions?: CircuitBreakerOptions): {
|
|
50
|
+
fetch: typeof globalThis.fetch;
|
|
51
|
+
breaker: CircuitBreaker;
|
|
52
|
+
};
|
|
53
|
+
export {};
|
package/dist/fetch.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/** Classify an error from a fetch response or network failure. */
|
|
2
|
+
export function classifyError(err) {
|
|
3
|
+
if (err instanceof Response || (err && typeof err === "object" && "status" in err)) {
|
|
4
|
+
const status = err.status;
|
|
5
|
+
if (typeof status !== "number" || isNaN(status))
|
|
6
|
+
return "unknown";
|
|
7
|
+
if (status === 0)
|
|
8
|
+
return "network";
|
|
9
|
+
if (status === 401 || status === 403)
|
|
10
|
+
return "auth";
|
|
11
|
+
if (status === 409)
|
|
12
|
+
return "conflict";
|
|
13
|
+
if (status === 429)
|
|
14
|
+
return "rate-limited";
|
|
15
|
+
if (status >= 500)
|
|
16
|
+
return "server";
|
|
17
|
+
if (status >= 400)
|
|
18
|
+
return "client";
|
|
19
|
+
}
|
|
20
|
+
if (err instanceof Error && /failed to fetch|fetch failed|network|load failed|ECONNREFUSED|ENOTFOUND/i.test(err.message))
|
|
21
|
+
return "network";
|
|
22
|
+
return "unknown";
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Wraps a fetch function with automatic retry for retriable errors
|
|
26
|
+
* (network failures, 429, 5xx). Respects Retry-After headers.
|
|
27
|
+
*/
|
|
28
|
+
export function createRetryFetch(options) {
|
|
29
|
+
const maxRetries = Math.max(0, options?.maxRetries ?? 3);
|
|
30
|
+
const initialDelay = options?.initialDelayMs ?? 500;
|
|
31
|
+
const maxDelay = options?.maxDelayMs ?? 10_000;
|
|
32
|
+
return async (input, init) => {
|
|
33
|
+
let attempt = 0;
|
|
34
|
+
while (true) {
|
|
35
|
+
try {
|
|
36
|
+
const res = await globalThis.fetch(input, init);
|
|
37
|
+
if (res.ok || attempt >= maxRetries)
|
|
38
|
+
return res;
|
|
39
|
+
const category = classifyError(res);
|
|
40
|
+
if (category !== "rate-limited" && category !== "server")
|
|
41
|
+
return res;
|
|
42
|
+
const retryAfter = res.headers.get("Retry-After")?.trim();
|
|
43
|
+
let delay;
|
|
44
|
+
if (retryAfter) {
|
|
45
|
+
const seconds = Number(retryAfter);
|
|
46
|
+
if (retryAfter !== "" && !isNaN(seconds)) {
|
|
47
|
+
delay = Math.min(seconds * 1000, maxDelay);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
const date = Date.parse(retryAfter);
|
|
51
|
+
delay = isNaN(date) ? initialDelay : Math.min(Math.max(date - Date.now(), 0), maxDelay);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
delay = Math.min(initialDelay * Math.pow(2, attempt), maxDelay);
|
|
56
|
+
}
|
|
57
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
58
|
+
attempt++;
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
if (attempt >= maxRetries)
|
|
62
|
+
throw err;
|
|
63
|
+
const category = classifyError(err);
|
|
64
|
+
if (category !== "network")
|
|
65
|
+
throw err;
|
|
66
|
+
const delay = Math.min(initialDelay * Math.pow(2, attempt), maxDelay);
|
|
67
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
68
|
+
attempt++;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** Circuit breaker that prevents requests when the backend is unavailable. */
|
|
74
|
+
export class CircuitBreaker {
|
|
75
|
+
state = "closed";
|
|
76
|
+
failures = 0;
|
|
77
|
+
openedAt = 0;
|
|
78
|
+
threshold;
|
|
79
|
+
cooldownMs;
|
|
80
|
+
constructor(options) {
|
|
81
|
+
this.threshold = options?.threshold ?? 5;
|
|
82
|
+
this.cooldownMs = options?.cooldownMs ?? 30_000;
|
|
83
|
+
}
|
|
84
|
+
getState() {
|
|
85
|
+
this.maybeTransition();
|
|
86
|
+
return this.state;
|
|
87
|
+
}
|
|
88
|
+
isOpen() {
|
|
89
|
+
return this.getState() === "open";
|
|
90
|
+
}
|
|
91
|
+
recordSuccess() {
|
|
92
|
+
this.failures = 0;
|
|
93
|
+
this.state = "closed";
|
|
94
|
+
}
|
|
95
|
+
recordFailure() {
|
|
96
|
+
this.failures++;
|
|
97
|
+
if (this.state === "half-open" || this.failures >= this.threshold) {
|
|
98
|
+
this.state = "open";
|
|
99
|
+
this.openedAt = Date.now();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
maybeTransition() {
|
|
103
|
+
if (this.state === "open" && Date.now() - this.openedAt >= this.cooldownMs) {
|
|
104
|
+
this.state = "half-open";
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Wraps fetch to gzip-compress string request bodies using the CompressionStream API.
|
|
110
|
+
* Adds Content-Encoding: gzip header. Non-string bodies (ArrayBuffer, Blob, etc.)
|
|
111
|
+
* are passed through uncompressed. Requires CompressionStream (browsers, Node.js 18+, Deno).
|
|
112
|
+
*/
|
|
113
|
+
export function createCompressedFetch(inner) {
|
|
114
|
+
const baseFetch = inner ?? globalThis.fetch.bind(globalThis);
|
|
115
|
+
return async (input, init) => {
|
|
116
|
+
if (!init?.body || typeof CompressionStream === "undefined") {
|
|
117
|
+
return baseFetch(input, init);
|
|
118
|
+
}
|
|
119
|
+
const bodyText = typeof init.body === "string" ? init.body : null;
|
|
120
|
+
if (!bodyText)
|
|
121
|
+
return baseFetch(input, init);
|
|
122
|
+
try {
|
|
123
|
+
const stream = new Blob([bodyText]).stream().pipeThrough(new CompressionStream("gzip"));
|
|
124
|
+
const compressed = await new Response(stream).arrayBuffer();
|
|
125
|
+
const normalized = Object.fromEntries(new Headers(init.headers).entries());
|
|
126
|
+
normalized["content-encoding"] = "gzip";
|
|
127
|
+
return baseFetch(input, {
|
|
128
|
+
...init,
|
|
129
|
+
body: compressed,
|
|
130
|
+
headers: normalized,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return baseFetch(input, init);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Combines retry and circuit breaker into a single resilient fetch wrapper.
|
|
140
|
+
* Rejects immediately when the circuit is open.
|
|
141
|
+
*/
|
|
142
|
+
export function createResilientFetch(retryOptions, breakerOptions) {
|
|
143
|
+
const breaker = new CircuitBreaker(breakerOptions);
|
|
144
|
+
const retryFetch = createRetryFetch(retryOptions);
|
|
145
|
+
const resilientFetch = async (input, init) => {
|
|
146
|
+
if (breaker.isOpen()) {
|
|
147
|
+
const cooldown = Math.ceil((breakerOptions?.cooldownMs ?? 30_000) / 1000);
|
|
148
|
+
throw new Error(`Request blocked: too many consecutive failures. Retry in ${cooldown}s.`);
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const res = await retryFetch(input, init);
|
|
152
|
+
if (res.status >= 500) {
|
|
153
|
+
breaker.recordFailure();
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
breaker.recordSuccess();
|
|
157
|
+
}
|
|
158
|
+
return res;
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
breaker.recordFailure();
|
|
162
|
+
throw err;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
return { fetch: resilientFetch, breaker };
|
|
166
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface Snapshot {
|
|
2
|
+
timestamp: number;
|
|
3
|
+
label: string;
|
|
4
|
+
data: string;
|
|
5
|
+
}
|
|
6
|
+
export interface SnapshotHistoryOptions {
|
|
7
|
+
/** Maximum number of snapshots to retain. Oldest are trimmed first. Default: 20. */
|
|
8
|
+
maxSnapshots?: number;
|
|
9
|
+
/** localStorage key for persistence. Pass to enable auto-save/load. */
|
|
10
|
+
storageKey?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare class SnapshotHistory {
|
|
13
|
+
private snapshots;
|
|
14
|
+
private readonly maxSnapshots;
|
|
15
|
+
private readonly storageKey;
|
|
16
|
+
constructor(options?: SnapshotHistoryOptions);
|
|
17
|
+
/** Take a labeled snapshot of the given data. */
|
|
18
|
+
take(label: string, data: Record<string, unknown>): void;
|
|
19
|
+
/** Restore data from a snapshot at the given index. Returns undefined if index is invalid or data is corrupt. */
|
|
20
|
+
restore(index: number): Record<string, unknown> | undefined;
|
|
21
|
+
/** List available snapshots (metadata only, no data payload). */
|
|
22
|
+
list(): Array<{
|
|
23
|
+
timestamp: number;
|
|
24
|
+
label: string;
|
|
25
|
+
}>;
|
|
26
|
+
/** Clear all snapshots. */
|
|
27
|
+
clear(): void;
|
|
28
|
+
private persist;
|
|
29
|
+
}
|
package/dist/history.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export class SnapshotHistory {
|
|
2
|
+
snapshots = [];
|
|
3
|
+
maxSnapshots;
|
|
4
|
+
storageKey;
|
|
5
|
+
constructor(options) {
|
|
6
|
+
this.maxSnapshots = options?.maxSnapshots ?? 20;
|
|
7
|
+
this.storageKey = options?.storageKey;
|
|
8
|
+
if (this.storageKey) {
|
|
9
|
+
try {
|
|
10
|
+
const raw = localStorage.getItem(this.storageKey);
|
|
11
|
+
if (raw) {
|
|
12
|
+
const parsed = JSON.parse(raw);
|
|
13
|
+
if (Array.isArray(parsed))
|
|
14
|
+
this.snapshots = parsed;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
catch { /* corrupted or unavailable — start fresh */ }
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Take a labeled snapshot of the given data. */
|
|
21
|
+
take(label, data) {
|
|
22
|
+
this.snapshots.push({
|
|
23
|
+
timestamp: Date.now(),
|
|
24
|
+
label,
|
|
25
|
+
data: JSON.stringify(data),
|
|
26
|
+
});
|
|
27
|
+
if (this.snapshots.length > this.maxSnapshots) {
|
|
28
|
+
this.snapshots = this.snapshots.slice(-this.maxSnapshots);
|
|
29
|
+
}
|
|
30
|
+
this.persist();
|
|
31
|
+
}
|
|
32
|
+
/** Restore data from a snapshot at the given index. Returns undefined if index is invalid or data is corrupt. */
|
|
33
|
+
restore(index) {
|
|
34
|
+
const snapshot = this.snapshots[index];
|
|
35
|
+
if (!snapshot)
|
|
36
|
+
return undefined;
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(snapshot.data);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** List available snapshots (metadata only, no data payload). */
|
|
45
|
+
list() {
|
|
46
|
+
return this.snapshots.map(({ timestamp, label }) => ({ timestamp, label }));
|
|
47
|
+
}
|
|
48
|
+
/** Clear all snapshots. */
|
|
49
|
+
clear() {
|
|
50
|
+
this.snapshots = [];
|
|
51
|
+
this.persist();
|
|
52
|
+
}
|
|
53
|
+
persist() {
|
|
54
|
+
if (!this.storageKey)
|
|
55
|
+
return;
|
|
56
|
+
try {
|
|
57
|
+
localStorage.setItem(this.storageKey, JSON.stringify(this.snapshots));
|
|
58
|
+
}
|
|
59
|
+
catch { /* quota exceeded — skip silently */ }
|
|
60
|
+
}
|
|
61
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,9 +3,23 @@ export type { CryptoProvider, Base64Provider, PlatformConfig } from "@drakkar.so
|
|
|
3
3
|
export { stableStringify, computeHash } from "@drakkar.software/starfish-protocol";
|
|
4
4
|
export type { PullResult, PushSuccess } from "@drakkar.software/starfish-protocol";
|
|
5
5
|
export { StarfishClient } from "./client.js";
|
|
6
|
+
export type { BlobPullResult, BlobPushResult } from "./client.js";
|
|
6
7
|
export { SyncManager } from "./sync.js";
|
|
7
8
|
export type { SyncManagerOptions } from "./sync.js";
|
|
8
9
|
export { createEncryptor, ENCRYPTED_KEY } from "./crypto.js";
|
|
9
10
|
export type { Encryptor } from "./crypto.js";
|
|
10
11
|
export { ConflictError, StarfishHttpError, } from "./types.js";
|
|
11
12
|
export type { StarfishClientOptions, AuthProvider, ConflictResolver, } from "./types.js";
|
|
13
|
+
export { consoleSyncLogger, noopSyncLogger } from "./logger.js";
|
|
14
|
+
export type { SyncLogger } from "./logger.js";
|
|
15
|
+
export { createMigrator } from "./migrate.js";
|
|
16
|
+
export type { MigrationFn, MigrationConfig } from "./migrate.js";
|
|
17
|
+
export { ValidationError, createSchemaValidator } from "./validate.js";
|
|
18
|
+
export type { Validator, ValidationResult } from "./validate.js";
|
|
19
|
+
export { classifyError } from "./fetch.js";
|
|
20
|
+
export type { ErrorCategory } from "./fetch.js";
|
|
21
|
+
export { createUnionMerge, createSoftDeleteResolver, timestampWinner, pruneTombstones, } from "./resolvers.js";
|
|
22
|
+
export { SnapshotHistory } from "./history.js";
|
|
23
|
+
export type { Snapshot, SnapshotHistoryOptions } from "./history.js";
|
|
24
|
+
export { startPolling, startAdaptivePolling } from "./polling.js";
|
|
25
|
+
export type { PollableState, AdaptivePollingOptions, AdaptivePollingControls } from "./polling.js";
|
package/dist/index.js
CHANGED
|
@@ -4,3 +4,10 @@ export { StarfishClient } from "./client.js";
|
|
|
4
4
|
export { SyncManager } from "./sync.js";
|
|
5
5
|
export { createEncryptor, ENCRYPTED_KEY } from "./crypto.js";
|
|
6
6
|
export { ConflictError, StarfishHttpError, } from "./types.js";
|
|
7
|
+
export { consoleSyncLogger, noopSyncLogger } from "./logger.js";
|
|
8
|
+
export { createMigrator } from "./migrate.js";
|
|
9
|
+
export { ValidationError, createSchemaValidator } from "./validate.js";
|
|
10
|
+
export { classifyError } from "./fetch.js";
|
|
11
|
+
export { createUnionMerge, createSoftDeleteResolver, timestampWinner, pruneTombstones, } from "./resolvers.js";
|
|
12
|
+
export { SnapshotHistory } from "./history.js";
|
|
13
|
+
export { startPolling, startAdaptivePolling } from "./polling.js";
|
package/dist/logger.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Structured logger for sync operations. */
|
|
2
|
+
export interface SyncLogger {
|
|
3
|
+
pullStart(store: string): void;
|
|
4
|
+
pullSuccess(store: string, durationMs: number): void;
|
|
5
|
+
pullError(store: string, error: string): void;
|
|
6
|
+
pushStart(store: string): void;
|
|
7
|
+
pushSuccess(store: string, durationMs: number): void;
|
|
8
|
+
pushError(store: string, error: string): void;
|
|
9
|
+
conflict(store: string, attempt: number): void;
|
|
10
|
+
}
|
|
11
|
+
/** Console-based sync logger with structured output. */
|
|
12
|
+
export declare const consoleSyncLogger: SyncLogger;
|
|
13
|
+
/** Silent sync logger (no output). */
|
|
14
|
+
export declare const noopSyncLogger: SyncLogger;
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Console-based sync logger with structured output. */
|
|
2
|
+
export const consoleSyncLogger = {
|
|
3
|
+
pullStart: (s) => console.log(`[starfish:${s}] pull started`),
|
|
4
|
+
pullSuccess: (s, ms) => console.log(`[starfish:${s}] pull OK (${ms}ms)`),
|
|
5
|
+
pullError: (s, err) => console.error(`[starfish:${s}] pull failed: ${err}`),
|
|
6
|
+
pushStart: (s) => console.log(`[starfish:${s}] push started`),
|
|
7
|
+
pushSuccess: (s, ms) => console.log(`[starfish:${s}] push OK (${ms}ms)`),
|
|
8
|
+
pushError: (s, err) => console.error(`[starfish:${s}] push failed: ${err}`),
|
|
9
|
+
conflict: (s, n) => console.warn(`[starfish:${s}] conflict (attempt ${n})`),
|
|
10
|
+
};
|
|
11
|
+
/** Silent sync logger (no output). */
|
|
12
|
+
export const noopSyncLogger = {
|
|
13
|
+
pullStart: () => { },
|
|
14
|
+
pullSuccess: () => { },
|
|
15
|
+
pullError: () => { },
|
|
16
|
+
pushStart: () => { },
|
|
17
|
+
pushSuccess: () => { },
|
|
18
|
+
pushError: () => { },
|
|
19
|
+
conflict: () => { },
|
|
20
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** A function that migrates data from one schema version to the next. */
|
|
2
|
+
export type MigrationFn = (data: Record<string, unknown>) => Record<string, unknown>;
|
|
3
|
+
export interface MigrationConfig {
|
|
4
|
+
/** The current schema version of the application. */
|
|
5
|
+
currentVersion: number;
|
|
6
|
+
/** Map of version number to the migration that upgrades FROM that version. */
|
|
7
|
+
migrations: Record<number, MigrationFn>;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Creates a migration runner that upgrades documents to the current schema version.
|
|
11
|
+
*
|
|
12
|
+
* Given a document with `_schemaVersion`, applies each migration in sequence
|
|
13
|
+
* until the document reaches `currentVersion`. Throws if the document version
|
|
14
|
+
* is ahead of the app (forward compatibility guard).
|
|
15
|
+
*/
|
|
16
|
+
export declare function createMigrator(config: MigrationConfig): (data: Record<string, unknown>) => Record<string, unknown>;
|
package/dist/migrate.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates a migration runner that upgrades documents to the current schema version.
|
|
3
|
+
*
|
|
4
|
+
* Given a document with `_schemaVersion`, applies each migration in sequence
|
|
5
|
+
* until the document reaches `currentVersion`. Throws if the document version
|
|
6
|
+
* is ahead of the app (forward compatibility guard).
|
|
7
|
+
*/
|
|
8
|
+
export function createMigrator(config) {
|
|
9
|
+
// Eagerly validate the migration chain
|
|
10
|
+
for (let v = 1; v < config.currentVersion; v++) {
|
|
11
|
+
if (!config.migrations[v]) {
|
|
12
|
+
throw new Error(`Missing migration for version ${v} -> ${v + 1}`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return (data) => {
|
|
16
|
+
const version = typeof data._schemaVersion === "number" ? data._schemaVersion : 1;
|
|
17
|
+
if (version > config.currentVersion) {
|
|
18
|
+
throw new Error(`Document schema version ${version} is newer than app version ${config.currentVersion}. Update the app.`);
|
|
19
|
+
}
|
|
20
|
+
if (version === config.currentVersion)
|
|
21
|
+
return data;
|
|
22
|
+
let result = { ...data };
|
|
23
|
+
for (let v = version; v < config.currentVersion; v++) {
|
|
24
|
+
const fn = config.migrations[v];
|
|
25
|
+
if (!fn) {
|
|
26
|
+
throw new Error(`Missing migration for version ${v} -> ${v + 1}`);
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
result = fn(result);
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
throw new Error(`Migration from version ${v} to ${v + 1} failed: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
result._schemaVersion = config.currentVersion;
|
|
36
|
+
return result;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Minimal state needed by polling utilities. */
|
|
2
|
+
export interface PollableState {
|
|
3
|
+
online: boolean;
|
|
4
|
+
syncing: boolean;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Start periodic pulling at a fixed interval.
|
|
8
|
+
* Skips pulls when offline or already syncing.
|
|
9
|
+
* Returns a cleanup function that stops polling.
|
|
10
|
+
*/
|
|
11
|
+
export declare function startPolling(pullFn: () => Promise<void>, getState: () => PollableState, intervalMs?: number): () => void;
|
|
12
|
+
export interface AdaptivePollingOptions {
|
|
13
|
+
/** Override the base interval in ms. If set, skips network quality detection. */
|
|
14
|
+
intervalMs?: number;
|
|
15
|
+
/** Custom mapping from effectiveType to interval in ms. */
|
|
16
|
+
intervals?: Record<string, number>;
|
|
17
|
+
}
|
|
18
|
+
export interface AdaptivePollingControls {
|
|
19
|
+
pause: () => void;
|
|
20
|
+
resume: () => void;
|
|
21
|
+
stop: () => void;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Start polling with adaptive intervals based on network quality.
|
|
25
|
+
* Uses the Network Information API (`navigator.connection.effectiveType`) when available.
|
|
26
|
+
* Returns controls to pause, resume, or stop polling.
|
|
27
|
+
*/
|
|
28
|
+
export declare function startAdaptivePolling(pullFn: () => Promise<void>, getState: () => PollableState, options?: AdaptivePollingOptions): AdaptivePollingControls;
|
package/dist/polling.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const DEFAULT_INTERVALS = {
|
|
2
|
+
"slow-2g": 120_000,
|
|
3
|
+
"2g": 60_000,
|
|
4
|
+
"3g": 30_000,
|
|
5
|
+
"4g": 10_000,
|
|
6
|
+
};
|
|
7
|
+
const DEFAULT_FALLBACK_MS = 15_000;
|
|
8
|
+
/**
|
|
9
|
+
* Start periodic pulling at a fixed interval.
|
|
10
|
+
* Skips pulls when offline or already syncing.
|
|
11
|
+
* Returns a cleanup function that stops polling.
|
|
12
|
+
*/
|
|
13
|
+
export function startPolling(pullFn, getState, intervalMs = 30_000) {
|
|
14
|
+
const timer = setInterval(() => {
|
|
15
|
+
const { online, syncing } = getState();
|
|
16
|
+
if (online && !syncing)
|
|
17
|
+
pullFn().catch(() => { });
|
|
18
|
+
}, intervalMs);
|
|
19
|
+
return () => clearInterval(timer);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Start polling with adaptive intervals based on network quality.
|
|
23
|
+
* Uses the Network Information API (`navigator.connection.effectiveType`) when available.
|
|
24
|
+
* Returns controls to pause, resume, or stop polling.
|
|
25
|
+
*/
|
|
26
|
+
export function startAdaptivePolling(pullFn, getState, options) {
|
|
27
|
+
let intervalMs;
|
|
28
|
+
if (options?.intervalMs != null) {
|
|
29
|
+
intervalMs = options.intervalMs;
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
const intervals = options?.intervals ?? DEFAULT_INTERVALS;
|
|
33
|
+
let effectiveType;
|
|
34
|
+
if (typeof navigator !== "undefined" && "connection" in navigator) {
|
|
35
|
+
effectiveType = navigator.connection.effectiveType;
|
|
36
|
+
}
|
|
37
|
+
intervalMs = (effectiveType != null ? intervals[effectiveType] : undefined) ?? DEFAULT_FALLBACK_MS;
|
|
38
|
+
}
|
|
39
|
+
let paused = false;
|
|
40
|
+
const timer = setInterval(() => {
|
|
41
|
+
if (paused)
|
|
42
|
+
return;
|
|
43
|
+
const { online, syncing } = getState();
|
|
44
|
+
if (online && !syncing)
|
|
45
|
+
pullFn().catch(() => { });
|
|
46
|
+
}, intervalMs);
|
|
47
|
+
return {
|
|
48
|
+
pause: () => { paused = true; },
|
|
49
|
+
resume: () => { paused = false; },
|
|
50
|
+
stop: () => clearInterval(timer),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ConflictResolver } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Creates a conflict resolver that merges arrays by ID with per-item
|
|
4
|
+
* timestamp comparison, and uses document-level timestamp for scalars.
|
|
5
|
+
*
|
|
6
|
+
* For arrays: builds a union of both sets keyed by `idKey`. When both
|
|
7
|
+
* sides have the same item, the one with the newer `timestampKey` wins.
|
|
8
|
+
* For scalars: the document with the newer `documentTimestampKey` wins.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* const merge = createUnionMerge()
|
|
13
|
+
* const sync = new SyncManager({ ..., onConflict: merge })
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export declare function createUnionMerge(options?: {
|
|
17
|
+
/** Key used to identify items in arrays (default: "id"). */
|
|
18
|
+
idKey?: string;
|
|
19
|
+
/** Key used for per-item timestamp comparison (default: "updatedAt"). */
|
|
20
|
+
timestampKey?: string;
|
|
21
|
+
/** Key used for document-level timestamp comparison (default: "timestamp"). */
|
|
22
|
+
documentTimestampKey?: string;
|
|
23
|
+
}): ConflictResolver;
|
|
24
|
+
/**
|
|
25
|
+
* Creates a conflict resolver that handles soft-deleted items (tombstones).
|
|
26
|
+
* Extends union merge with tombstone awareness: if an item exists on one side
|
|
27
|
+
* with a `deletedAtKey` set, that deletion is respected even if the other side
|
|
28
|
+
* still has the item alive — as long as the deletion timestamp is newer.
|
|
29
|
+
*/
|
|
30
|
+
export declare function createSoftDeleteResolver(options?: {
|
|
31
|
+
idKey?: string;
|
|
32
|
+
timestampKey?: string;
|
|
33
|
+
documentTimestampKey?: string;
|
|
34
|
+
/** Key marking an item as deleted (default: "_deletedAt"). */
|
|
35
|
+
deletedAtKey?: string;
|
|
36
|
+
}): ConflictResolver;
|
|
37
|
+
/**
|
|
38
|
+
* Simple resolver: the document with the newer timestamp wins entirely.
|
|
39
|
+
* No per-field or per-item merging.
|
|
40
|
+
*/
|
|
41
|
+
export declare function timestampWinner(timestampKey?: string): ConflictResolver;
|
|
42
|
+
/**
|
|
43
|
+
* Remove expired tombstones from an array of items.
|
|
44
|
+
* Items with a `deletedAtKey` older than `ttlMs` are pruned.
|
|
45
|
+
*
|
|
46
|
+
* @param items - Array of items, some with a deletedAt timestamp
|
|
47
|
+
* @param ttlMs - Time-to-live in ms for tombstones (default: 30 days)
|
|
48
|
+
* @param deletedAtKey - Key marking deletion timestamp (default: "_deletedAt")
|
|
49
|
+
*/
|
|
50
|
+
export declare function pruneTombstones<T extends Record<string, unknown>>(items: T[], ttlMs?: number, deletedAtKey?: string): T[];
|