@drakkar.software/starfish-client 3.0.0-alpha.4 → 3.0.0-alpha.40
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 +59 -0
- package/dist/append-log.d.ts +228 -0
- package/dist/append-log.js +267 -0
- package/dist/background-sync.js +29 -0
- package/dist/bindings/legend.d.ts +23 -0
- package/dist/bindings/legend.js +32 -0
- package/dist/bindings/legend.js.map +2 -2
- package/dist/bindings/suspense.js +49 -0
- package/dist/bindings/zustand.d.ts +167 -2
- package/dist/bindings/zustand.js +963 -87
- package/dist/bindings/zustand.js.map +4 -4
- package/dist/client.d.ts +283 -5
- package/dist/client.js +391 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +18 -0
- package/dist/debounced-sync.js +120 -0
- package/dist/dedup.js +35 -0
- package/dist/events.d.ts +150 -0
- package/dist/events.js +116 -0
- package/dist/events.js.map +7 -0
- package/dist/export.js +115 -0
- package/dist/fetch.d.ts +40 -0
- package/dist/fetch.js +51 -14
- package/dist/fetch.js.map +2 -2
- package/dist/history.js +61 -0
- package/dist/index.d.ts +14 -7
- package/dist/index.js +1025 -99
- package/dist/index.js.map +4 -4
- package/dist/kv-cache.d.ts +63 -0
- package/dist/logger.d.ts +3 -0
- package/dist/logger.js +80 -0
- package/dist/migrate.js +38 -0
- package/dist/mobile-lifecycle.d.ts +28 -1
- package/dist/mobile-lifecycle.js +94 -0
- package/dist/multi-store.js +92 -0
- package/dist/mutate.d.ts +39 -0
- package/dist/polling.js +52 -0
- package/dist/resolvers.js +223 -0
- package/dist/service-worker.js +55 -0
- package/dist/storage/indexeddb.js +59 -0
- package/dist/sync.d.ts +83 -0
- package/dist/sync.js +181 -0
- package/dist/types.d.ts +115 -9
- package/dist/types.js +18 -0
- package/dist/validate.js +28 -0
- package/package.json +12 -3
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KV-backed {@link PullCache} factory.
|
|
3
|
+
*
|
|
4
|
+
* {@link createKvPullCache} adapts any {@link AsyncStateStorage} (or any
|
|
5
|
+
* object with `getItem`/`setItem`) into a {@link PullCache} that the
|
|
6
|
+
* `StarfishClient` can use as its offline read-through cache.
|
|
7
|
+
*
|
|
8
|
+
* Why this matters: the client's `cache` option is ciphertext-at-rest by
|
|
9
|
+
* construction — it stores the raw sealed server response and only decrypts
|
|
10
|
+
* in memory on read. Backing the cache with the platform's own KV (AsyncStorage
|
|
11
|
+
* on React Native, IndexedDB via `createIndexedDBStorage`, a custom adapter)
|
|
12
|
+
* gives offline-first reads without exposing plaintext to the OS storage layer.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* import AsyncStorage from "@react-native-async-storage/async-storage"
|
|
17
|
+
* import { StarfishClient } from "@drakkar.software/starfish-client"
|
|
18
|
+
* import { createKvPullCache } from "@drakkar.software/starfish-client"
|
|
19
|
+
*
|
|
20
|
+
* const client = new StarfishClient({
|
|
21
|
+
* baseUrl: "https://api.example.com",
|
|
22
|
+
* cache: createKvPullCache(AsyncStorage, { prefix: "sf:", maxAgeMs: 30 * 24 * 60 * 60 * 1000 }),
|
|
23
|
+
* })
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
import type { PullCache } from "./types.js";
|
|
27
|
+
/** A storage backend that `createKvPullCache` can wrap. */
|
|
28
|
+
export interface KvStore {
|
|
29
|
+
getItem(key: string): Promise<string | null> | string | null;
|
|
30
|
+
setItem(key: string, value: string): Promise<unknown> | unknown;
|
|
31
|
+
removeItem?: (key: string) => Promise<unknown> | unknown;
|
|
32
|
+
}
|
|
33
|
+
/** Options for {@link createKvPullCache}. */
|
|
34
|
+
export interface KvPullCacheOptions {
|
|
35
|
+
/**
|
|
36
|
+
* Key prefix for cache entries (default `"starfish.pullcache."`). Change
|
|
37
|
+
* when sharing a KV store with other data to avoid key collisions.
|
|
38
|
+
*/
|
|
39
|
+
prefix?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Maximum age in milliseconds for a cached snapshot. When set, an entry
|
|
42
|
+
* older than `maxAgeMs` is returned as `null` (a cache MISS) so the next
|
|
43
|
+
* pull goes to the network rather than serving arbitrarily stale data.
|
|
44
|
+
*
|
|
45
|
+
* Each entry stores a `_cachedAt` wall-clock timestamp; expiry is checked on
|
|
46
|
+
* every `get`. Omit (default) for entries that never expire — recommended
|
|
47
|
+
* for offline-first apps where any last-synced data beats none.
|
|
48
|
+
*/
|
|
49
|
+
maxAgeMs?: number;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Adapt a KV store into a {@link PullCache} for `StarfishClient`.
|
|
53
|
+
*
|
|
54
|
+
* The adapter serialises the cached pull payload (itself a JSON string) into
|
|
55
|
+
* an outer JSON envelope that tracks the wall-clock write time for optional
|
|
56
|
+
* max-age expiry. Reading a legacy entry without `_cachedAt` treats it as
|
|
57
|
+
* fresh (backward-compatible with plain-string caches).
|
|
58
|
+
*
|
|
59
|
+
* All `get`/`set` errors are swallowed (the {@link PullCache} contract
|
|
60
|
+
* requires implementations not to throw) — a failing KV store simply
|
|
61
|
+
* degrades to "no cache" without crashing the app.
|
|
62
|
+
*/
|
|
63
|
+
export declare function createKvPullCache(kv: KvStore, opts?: KvPullCacheOptions): PullCache;
|
package/dist/logger.d.ts
CHANGED
|
@@ -5,6 +5,9 @@ export interface SyncMetrics {
|
|
|
5
5
|
conflictCount?: number;
|
|
6
6
|
retryCount?: number;
|
|
7
7
|
cacheHit?: boolean;
|
|
8
|
+
/** Elements an append-log pull dropped under `onElementError: "skip"`
|
|
9
|
+
* (failed verification/decryption). Omitted when none were skipped. */
|
|
10
|
+
skippedCount?: number;
|
|
8
11
|
}
|
|
9
12
|
/** Structured logger for sync operations. */
|
|
10
13
|
export interface SyncLogger {
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
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, m) => {
|
|
5
|
+
let msg = `[starfish:${s}] pull OK (${ms}ms)`;
|
|
6
|
+
if (m?.bytesTransferred)
|
|
7
|
+
msg += ` ${m.bytesTransferred}B`;
|
|
8
|
+
if (m?.cacheHit)
|
|
9
|
+
msg += ` (cache hit)`;
|
|
10
|
+
console.log(msg);
|
|
11
|
+
},
|
|
12
|
+
pullError: (s, err) => console.error(`[starfish:${s}] pull failed: ${err}`),
|
|
13
|
+
pushStart: (s) => console.log(`[starfish:${s}] push started`),
|
|
14
|
+
pushSuccess: (s, ms, m) => {
|
|
15
|
+
let msg = `[starfish:${s}] push OK (${ms}ms)`;
|
|
16
|
+
if (m?.bytesTransferred)
|
|
17
|
+
msg += ` ${m.bytesTransferred}B`;
|
|
18
|
+
console.log(msg);
|
|
19
|
+
},
|
|
20
|
+
pushError: (s, err) => console.error(`[starfish:${s}] push failed: ${err}`),
|
|
21
|
+
conflict: (s, n) => console.warn(`[starfish:${s}] conflict (attempt ${n})`),
|
|
22
|
+
};
|
|
23
|
+
/** Silent sync logger (no output). */
|
|
24
|
+
export const noopSyncLogger = {
|
|
25
|
+
pullStart: () => { },
|
|
26
|
+
pullSuccess: () => { },
|
|
27
|
+
pullError: () => { },
|
|
28
|
+
pushStart: () => { },
|
|
29
|
+
pushSuccess: () => { },
|
|
30
|
+
pushError: () => { },
|
|
31
|
+
conflict: () => { },
|
|
32
|
+
};
|
|
33
|
+
/** Create a metrics collector that accumulates sync statistics. */
|
|
34
|
+
export function createMetricsCollector() {
|
|
35
|
+
const stores = new Map();
|
|
36
|
+
function ensureStore(name) {
|
|
37
|
+
let s = stores.get(name);
|
|
38
|
+
if (!s) {
|
|
39
|
+
s = { totalPulls: 0, totalPushes: 0, totalDurationMs: 0, totalBytes: 0, totalConflicts: 0 };
|
|
40
|
+
stores.set(name, s);
|
|
41
|
+
}
|
|
42
|
+
return s;
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
recordPull(name, durationMs, metrics) {
|
|
46
|
+
const s = ensureStore(name);
|
|
47
|
+
s.totalPulls++;
|
|
48
|
+
s.totalDurationMs += durationMs;
|
|
49
|
+
if (metrics?.bytesTransferred)
|
|
50
|
+
s.totalBytes += metrics.bytesTransferred;
|
|
51
|
+
},
|
|
52
|
+
recordPush(name, durationMs, metrics) {
|
|
53
|
+
const s = ensureStore(name);
|
|
54
|
+
s.totalPushes++;
|
|
55
|
+
s.totalDurationMs += durationMs;
|
|
56
|
+
if (metrics?.bytesTransferred)
|
|
57
|
+
s.totalBytes += metrics.bytesTransferred;
|
|
58
|
+
},
|
|
59
|
+
recordConflict(name) {
|
|
60
|
+
ensureStore(name).totalConflicts++;
|
|
61
|
+
},
|
|
62
|
+
getSummary() {
|
|
63
|
+
const result = {};
|
|
64
|
+
for (const [name, s] of stores) {
|
|
65
|
+
const totalOps = s.totalPulls + s.totalPushes;
|
|
66
|
+
result[name] = {
|
|
67
|
+
totalPulls: s.totalPulls,
|
|
68
|
+
totalPushes: s.totalPushes,
|
|
69
|
+
avgDurationMs: totalOps > 0 ? Math.round(s.totalDurationMs / totalOps) : 0,
|
|
70
|
+
totalBytes: s.totalBytes,
|
|
71
|
+
totalConflicts: s.totalConflicts,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
},
|
|
76
|
+
reset() {
|
|
77
|
+
stores.clear();
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
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
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { StoreApi } from "zustand/vanilla";
|
|
2
|
-
import type { StarfishStore } from "./bindings/zustand.js";
|
|
2
|
+
import type { StarfishStore, StarfishLogStore } from "./bindings/zustand.js";
|
|
3
3
|
/**
|
|
4
4
|
* Minimal interface matching React Native's `AppState` module.
|
|
5
5
|
* Pass `AppState` from `react-native` directly.
|
|
@@ -69,3 +69,30 @@ export interface MobileLifecycleOptions {
|
|
|
69
69
|
* @returns A cleanup function that removes all event listeners.
|
|
70
70
|
*/
|
|
71
71
|
export declare function createMobileLifecycle(store: StoreApi<StarfishStore>, deps: MobileLifecycleDeps, options?: MobileLifecycleOptions): () => void;
|
|
72
|
+
export interface AppendLogLifecycleOptions {
|
|
73
|
+
/**
|
|
74
|
+
* Pull new elements when the app returns to the foreground.
|
|
75
|
+
* Only pulls if the store is online and not already loading.
|
|
76
|
+
* Default: `true`.
|
|
77
|
+
*/
|
|
78
|
+
pullOnForeground?: boolean;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Wires React Native app lifecycle events to an append-log store
|
|
82
|
+
* (`createStarfishLog`). A log is read-only, so this only pulls on foreground
|
|
83
|
+
* (there is nothing to flush on background). NetInfo connectivity changes are
|
|
84
|
+
* forwarded to `store.getState().setOnline()`.
|
|
85
|
+
*
|
|
86
|
+
* ```ts
|
|
87
|
+
* import { AppState } from "react-native"
|
|
88
|
+
* import NetInfo from "@react-native-community/netinfo"
|
|
89
|
+
* import { createStarfishLog, createAppendLogMobileLifecycle } from "@drakkar.software/starfish-client"
|
|
90
|
+
*
|
|
91
|
+
* const store = createStarfishLog({ cursor })
|
|
92
|
+
* const cleanup = createAppendLogMobileLifecycle(store, { appState: AppState, netInfo: NetInfo })
|
|
93
|
+
* useEffect(() => cleanup, [])
|
|
94
|
+
* ```
|
|
95
|
+
*
|
|
96
|
+
* @returns A cleanup function that removes all event listeners.
|
|
97
|
+
*/
|
|
98
|
+
export declare function createAppendLogMobileLifecycle(store: StoreApi<StarfishLogStore>, deps: MobileLifecycleDeps, options?: AppendLogLifecycleOptions): () => void;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// ── Implementation ────────────────────────────────────────────────────────────
|
|
2
|
+
/**
|
|
3
|
+
* Wires React Native app lifecycle events to a Starfish store.
|
|
4
|
+
*
|
|
5
|
+
* - **Background**: flushes pending changes before the OS suspends the app.
|
|
6
|
+
* - **Foreground**: pulls remote changes when the user returns to the app.
|
|
7
|
+
* - **NetInfo**: forwards connectivity changes to `store.getState().setOnline()`.
|
|
8
|
+
*
|
|
9
|
+
* Uses dependency injection so no `react-native` or `netinfo` imports are needed
|
|
10
|
+
* in this package. Pass the modules directly:
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* import { AppState } from "react-native"
|
|
14
|
+
* import NetInfo from "@react-native-community/netinfo"
|
|
15
|
+
* import { createMobileLifecycle } from "@drakkar.software/starfish-client"
|
|
16
|
+
*
|
|
17
|
+
* // Call once, after the store is created:
|
|
18
|
+
* const cleanup = createMobileLifecycle(
|
|
19
|
+
* store,
|
|
20
|
+
* { appState: AppState, netInfo: NetInfo },
|
|
21
|
+
* )
|
|
22
|
+
*
|
|
23
|
+
* // In a React component (e.g. root layout):
|
|
24
|
+
* useEffect(() => cleanup, [])
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* @returns A cleanup function that removes all event listeners.
|
|
28
|
+
*/
|
|
29
|
+
export function createMobileLifecycle(store, deps, options = {}) {
|
|
30
|
+
const { pullOnForeground = true, flushOnBackground = true } = options;
|
|
31
|
+
const appSub = deps.appState.addEventListener("change", (appState) => {
|
|
32
|
+
if (appState === "background" && flushOnBackground) {
|
|
33
|
+
if (store.getState().dirty) {
|
|
34
|
+
store.getState().flush().catch((err) => { console.error("[Starfish] background flush failed:", err); });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
else if (appState === "active" && pullOnForeground) {
|
|
38
|
+
const { online, syncing } = store.getState();
|
|
39
|
+
if (online && !syncing) {
|
|
40
|
+
store.getState().pull().catch((err) => { console.error("[Starfish] foreground pull failed:", err); });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// "inactive" (iOS transition) and other states are intentionally ignored
|
|
44
|
+
});
|
|
45
|
+
let netUnsub = null;
|
|
46
|
+
if (deps.netInfo) {
|
|
47
|
+
netUnsub = deps.netInfo.addEventListener(({ isConnected }) => {
|
|
48
|
+
store.getState().setOnline(!!isConnected);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return () => {
|
|
52
|
+
appSub.remove();
|
|
53
|
+
netUnsub?.();
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Wires React Native app lifecycle events to an append-log store
|
|
58
|
+
* (`createStarfishLog`). A log is read-only, so this only pulls on foreground
|
|
59
|
+
* (there is nothing to flush on background). NetInfo connectivity changes are
|
|
60
|
+
* forwarded to `store.getState().setOnline()`.
|
|
61
|
+
*
|
|
62
|
+
* ```ts
|
|
63
|
+
* import { AppState } from "react-native"
|
|
64
|
+
* import NetInfo from "@react-native-community/netinfo"
|
|
65
|
+
* import { createStarfishLog, createAppendLogMobileLifecycle } from "@drakkar.software/starfish-client"
|
|
66
|
+
*
|
|
67
|
+
* const store = createStarfishLog({ cursor })
|
|
68
|
+
* const cleanup = createAppendLogMobileLifecycle(store, { appState: AppState, netInfo: NetInfo })
|
|
69
|
+
* useEffect(() => cleanup, [])
|
|
70
|
+
* ```
|
|
71
|
+
*
|
|
72
|
+
* @returns A cleanup function that removes all event listeners.
|
|
73
|
+
*/
|
|
74
|
+
export function createAppendLogMobileLifecycle(store, deps, options = {}) {
|
|
75
|
+
const { pullOnForeground = true } = options;
|
|
76
|
+
const appSub = deps.appState.addEventListener("change", (appState) => {
|
|
77
|
+
if (appState === "active" && pullOnForeground) {
|
|
78
|
+
const { online, loading } = store.getState();
|
|
79
|
+
if (online && !loading) {
|
|
80
|
+
store.getState().pull().catch((err) => { console.error("[Starfish] foreground log pull failed:", err); });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
let netUnsub = null;
|
|
85
|
+
if (deps.netInfo) {
|
|
86
|
+
netUnsub = deps.netInfo.addEventListener(({ isConnected }) => {
|
|
87
|
+
store.getState().setOnline(!!isConnected);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return () => {
|
|
91
|
+
appSub.remove();
|
|
92
|
+
netUnsub?.();
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
2
|
+
// ── Implementation ────────────────────────────────────────────────────────────
|
|
3
|
+
/**
|
|
4
|
+
* Creates a multi-store sync coordinator.
|
|
5
|
+
*
|
|
6
|
+
* Collects multiple application stores into a single Starfish sync document,
|
|
7
|
+
* with versioned schema migrations for backward compatibility.
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* const multiSync = createMultiStoreSync({
|
|
11
|
+
* slices: {
|
|
12
|
+
* tasks: {
|
|
13
|
+
* serialize: () => taskStore.getState().tasks,
|
|
14
|
+
* restore: (tasks) => taskStore.setState({ tasks }),
|
|
15
|
+
* },
|
|
16
|
+
* settings: {
|
|
17
|
+
* serialize: () => settingsStore.getState().settings,
|
|
18
|
+
* restore: (settings) => settingsStore.setState({ settings }),
|
|
19
|
+
* },
|
|
20
|
+
* },
|
|
21
|
+
* version: 2,
|
|
22
|
+
* migrations: {
|
|
23
|
+
* // data from version 1 → upgrade to version 2
|
|
24
|
+
* 1: (data) => ({ ...data, settings: { ...(data.settings as object), darkMode: false } }),
|
|
25
|
+
* },
|
|
26
|
+
* })
|
|
27
|
+
*
|
|
28
|
+
* // Push:
|
|
29
|
+
* starfishStore.getState().set(() => multiSync.serialize())
|
|
30
|
+
*
|
|
31
|
+
* // Restore on pull (pass as onRemoteUpdate to createStarfishStore):
|
|
32
|
+
* createStarfishStore({
|
|
33
|
+
* name: "app",
|
|
34
|
+
* syncManager,
|
|
35
|
+
* onRemoteUpdate: (doc) => multiSync.restore(doc as BackupDocument),
|
|
36
|
+
* })
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export function createMultiStoreSync(options) {
|
|
40
|
+
const { slices, version, migrations = {} } = options;
|
|
41
|
+
// Validate migration chain at construction time (fail fast)
|
|
42
|
+
for (const fromVersion of Object.keys(migrations)) {
|
|
43
|
+
const v = Number(fromVersion);
|
|
44
|
+
if (isNaN(v) || v < 1) {
|
|
45
|
+
throw new Error(`Migration key must be a positive integer, got: "${fromVersion}"`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function serialize() {
|
|
49
|
+
const data = {};
|
|
50
|
+
for (const key of Object.keys(slices)) {
|
|
51
|
+
data[key] = slices[key].serialize();
|
|
52
|
+
}
|
|
53
|
+
return { version, timestamp: Date.now(), data };
|
|
54
|
+
}
|
|
55
|
+
function restore(doc) {
|
|
56
|
+
if (typeof doc !== "object" || doc === null) {
|
|
57
|
+
throw new Error("restore: expected a BackupDocument object");
|
|
58
|
+
}
|
|
59
|
+
const docVersion = doc.version ?? 1;
|
|
60
|
+
if (typeof docVersion !== "number" || !Number.isInteger(docVersion) || docVersion < 1) {
|
|
61
|
+
throw new Error(`restore: invalid document version: ${String(doc.version)}`);
|
|
62
|
+
}
|
|
63
|
+
if (docVersion > version) {
|
|
64
|
+
throw new Error(`restore: document version ${docVersion} is newer than current version ${version}. ` +
|
|
65
|
+
`Update the app to restore this backup.`);
|
|
66
|
+
}
|
|
67
|
+
// Run migrations sequentially from docVersion up to current version
|
|
68
|
+
let data = typeof doc.data === "object" && doc.data !== null
|
|
69
|
+
? { ...doc.data }
|
|
70
|
+
: {};
|
|
71
|
+
for (let v = docVersion; v < version; v++) {
|
|
72
|
+
const migration = migrations[v];
|
|
73
|
+
if (!migration)
|
|
74
|
+
continue;
|
|
75
|
+
try {
|
|
76
|
+
data = migration(data);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
80
|
+
throw new Error(`restore: migration from version ${v} to ${v + 1} failed: ${msg}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Restore each slice
|
|
84
|
+
for (const key of Object.keys(slices)) {
|
|
85
|
+
const sliceData = data[key];
|
|
86
|
+
if (sliceData !== undefined) {
|
|
87
|
+
slices[key].restore(sliceData);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { serialize, restore, version };
|
|
92
|
+
}
|
package/dist/mutate.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-modify-write a document with hash-CAS conflict retry.
|
|
3
|
+
*
|
|
4
|
+
* The everyday way to atomically edit a synced document: pull the current
|
|
5
|
+
* version, apply a pure `mutator` to its data, push the result with the read
|
|
6
|
+
* hash, and retry on a {@link ConflictError} (a concurrent writer moved the hash)
|
|
7
|
+
* by re-reading FRESH server state and re-applying the mutator. A missing
|
|
8
|
+
* document (404) is surfaced to the mutator as `{ data: null, hash: null }` so it
|
|
9
|
+
* can create the doc on first write.
|
|
10
|
+
*
|
|
11
|
+
* This replaces the ad-hoc `for (attempt…) { pull; mutate; try push catch
|
|
12
|
+
* ConflictError }` loop that applications otherwise hand-roll around every
|
|
13
|
+
* editable doc. The `mutator` MUST be idempotent — it re-runs on each retry — and
|
|
14
|
+
* returns `null` to signal a no-op (nothing changed; skip the write).
|
|
15
|
+
*/
|
|
16
|
+
import { StarfishClient } from "./client.js";
|
|
17
|
+
/** The current state handed to a {@link DocMutator}: the document data (or `null`
|
|
18
|
+
* when the doc does not exist yet) and the hash to base the next push on. */
|
|
19
|
+
export interface DocState<T> {
|
|
20
|
+
data: T | null;
|
|
21
|
+
hash: string | null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Pure transform from the current document to the next. Return the full next
|
|
25
|
+
* document body to write, or `null` for a no-op (the write is skipped). Runs once
|
|
26
|
+
* per attempt on freshly-pulled state, so it must be idempotent.
|
|
27
|
+
*/
|
|
28
|
+
export type DocMutator<T> = (cur: DocState<T>) => T | null;
|
|
29
|
+
export interface MutateDocOptions {
|
|
30
|
+
/** Max push attempts before a persistent conflict propagates. Default 3. */
|
|
31
|
+
maxAttempts?: number;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Atomically read-modify-write the document at `path`. Returns the document that
|
|
35
|
+
* was written, or `null` if the mutator signalled a no-op. Throws the underlying
|
|
36
|
+
* error on a non-conflict failure, or a {@link ConflictError} if every attempt
|
|
37
|
+
* raced and lost.
|
|
38
|
+
*/
|
|
39
|
+
export declare function mutateDoc<T extends Record<string, unknown> = Record<string, unknown>>(client: StarfishClient, path: string, mutator: DocMutator<T>, options?: MutateDocOptions): Promise<T | null>;
|
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((err) => { console.error("[Starfish] poll failed:", err); });
|
|
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((err) => { console.error("[Starfish] adaptive poll failed:", err); });
|
|
46
|
+
}, intervalMs);
|
|
47
|
+
return {
|
|
48
|
+
pause: () => { paused = true; },
|
|
49
|
+
resume: () => { paused = false; },
|
|
50
|
+
stop: () => clearInterval(timer),
|
|
51
|
+
};
|
|
52
|
+
}
|