@drakkar.software/starfish-client 1.4.1 → 1.6.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.
@@ -0,0 +1,72 @@
1
+ declare const WORDLIST: string[];
2
+ /** Credentials derived from a passphrase. Pass directly to SyncManager / StarfishClient. */
3
+ export interface DerivedCredentials {
4
+ /** Hex-encoded auth token. Use as `Bearer ${authToken}` in request headers. */
5
+ authToken: string;
6
+ /**
7
+ * Short identifier derived from the auth token (first 16 hex chars = 8 bytes).
8
+ * Used as the user/namespace segment in collection paths.
9
+ */
10
+ userId: string;
11
+ /**
12
+ * Hex-encoded key suitable as `encryptionSecret` for SyncManager.
13
+ * Combined with `encryptionSalt` to derive the AES-256-GCM key via HKDF.
14
+ */
15
+ encryptionSecret: string;
16
+ /**
17
+ * Value suitable as `encryptionSalt` for SyncManager. Equals `userId`.
18
+ * Using a per-identity salt ensures that even if two users share a passphrase,
19
+ * their encryption keys are different.
20
+ */
21
+ encryptionSalt: string;
22
+ }
23
+ /**
24
+ * Generates a cryptographically random passphrase from the built-in 256-word list.
25
+ *
26
+ * Each word represents one byte of entropy (256 words = one byte per word, zero modulo bias).
27
+ * A 12-word passphrase gives 96 bits of entropy — stronger than a random UUID.
28
+ *
29
+ * @param wordCount Number of words (default: 12).
30
+ * @param wordlist Custom word list (must have exactly 256 entries).
31
+ */
32
+ export declare function generatePassphrase(wordCount?: number, wordlist?: string[]): string;
33
+ /**
34
+ * Derives auth credentials from a passphrase.
35
+ *
36
+ * All derivations are deterministic — the same passphrase always produces the same credentials.
37
+ * Sharing the passphrase grants access on any device.
38
+ *
39
+ * The returned values map directly to Starfish options:
40
+ * ```ts
41
+ * const creds = await deriveCredentials(passphrase)
42
+ *
43
+ * const client = new StarfishClient({
44
+ * baseUrl: serverUrl,
45
+ * auth: () => ({ Authorization: `Bearer ${creds.authToken}` }),
46
+ * })
47
+ * const syncManager = new SyncManager({
48
+ * client,
49
+ * pullPath: `/pull/${creds.userId}/wedding`,
50
+ * pushPath: `/push/${creds.userId}/wedding`,
51
+ * encryptionSecret: creds.encryptionSecret,
52
+ * encryptionSalt: creds.encryptionSalt,
53
+ * })
54
+ * ```
55
+ */
56
+ export declare function deriveCredentials(passphrase: string): Promise<DerivedCredentials>;
57
+ /**
58
+ * Encodes an invite payload as a URL-safe token and appends it as `?t=<token>`.
59
+ *
60
+ * ```ts
61
+ * const url = buildInviteUrl("myapp://join", { name: "Alice & Bob", p: passphrase })
62
+ * // → "myapp://join?t=eyJuYW1lIjoiQWxpY2UgJiBCb2IifQ"
63
+ * ```
64
+ */
65
+ export declare function buildInviteUrl(baseUrl: string, payload: Record<string, unknown>): string;
66
+ /**
67
+ * Decodes an invite URL produced by `buildInviteUrl`.
68
+ *
69
+ * Returns the decoded payload, or `null` if the URL is missing or malformed.
70
+ */
71
+ export declare function parseInviteUrl(url: string): Record<string, unknown> | null;
72
+ export { WORDLIST as DEFAULT_WORDLIST };
@@ -0,0 +1,161 @@
1
+ import { getCrypto, getBase64 } from "@drakkar.software/starfish-protocol";
2
+ // ── Word list ─────────────────────────────────────────────────────────────────
3
+ // 256 common English words, one per index (0-255). One byte of entropy per word.
4
+ // 12 words = 96 bits of entropy (stronger than a random UUID).
5
+ const WORDLIST = [
6
+ "able", "acid", "aged", "also", "area", "army", "away", "back",
7
+ "ball", "band", "bank", "base", "bath", "bean", "bear", "beat",
8
+ "bell", "best", "bird", "blue", "boat", "bold", "bolt", "bone",
9
+ "born", "bowl", "burn", "calm", "call", "camp", "card", "care",
10
+ "cash", "cast", "cave", "city", "clam", "clay", "clip", "coal",
11
+ "coat", "coin", "cold", "cook", "cool", "corn", "cost", "cozy",
12
+ "dark", "data", "dawn", "dead", "deal", "deck", "deep", "dew",
13
+ "dish", "dome", "door", "down", "draw", "drop", "drum", "dusk",
14
+ "dust", "each", "earn", "east", "edge", "even", "ever", "face",
15
+ "fact", "fair", "fall", "fame", "farm", "fast", "felt", "file",
16
+ "fill", "fire", "fish", "fist", "flag", "flat", "flew", "flow",
17
+ "foam", "fold", "fond", "food", "foot", "form", "frog", "fuel",
18
+ "full", "gain", "game", "gate", "gave", "gaze", "gift", "glad",
19
+ "glow", "glue", "goal", "good", "grab", "gray", "grip", "grow",
20
+ "gulf", "gust", "half", "hall", "hand", "hard", "harm", "have",
21
+ "hawk", "head", "heal", "heap", "heat", "held", "helm", "help",
22
+ "herb", "here", "hero", "high", "hill", "hint", "hold", "hole",
23
+ "home", "hope", "horn", "hour", "huge", "hunt", "idea", "inch",
24
+ "into", "iris", "isle", "jade", "jail", "join", "jump", "just",
25
+ "keep", "kind", "king", "knot", "know", "lack", "lake", "land",
26
+ "lane", "last", "late", "lawn", "lead", "leaf", "lean", "leap",
27
+ "left", "lend", "less", "life", "lift", "like", "lime", "line",
28
+ "lion", "list", "live", "load", "lock", "loft", "long", "look",
29
+ "loop", "loud", "love", "luck", "lung", "made", "main", "mark",
30
+ "mast", "math", "maze", "meal", "meet", "melt", "mild", "mind",
31
+ "mint", "mist", "mode", "moon", "more", "most", "move", "much",
32
+ "must", "name", "near", "neck", "need", "next", "nice", "nine",
33
+ "none", "noon", "note", "noun", "oath", "once", "open", "oval",
34
+ "over", "pack", "page", "paid", "pain", "pale", "palm", "park",
35
+ "part", "path", "pave", "peak", "pier", "pile", "pine", "pipe",
36
+ "plan", "plum", "poem", "pole", "pool", "port", "pose", "post",
37
+ "pray", "prey", "pull", "pure", "push", "quit", "race", "rack",
38
+ ];
39
+ // ── Helpers ───────────────────────────────────────────────────────────────────
40
+ function bytesToHex(bytes) {
41
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
42
+ }
43
+ async function sha256Hex(input) {
44
+ const c = getCrypto();
45
+ const encoded = new TextEncoder().encode(input);
46
+ const hash = await c.subtle.digest("SHA-256", encoded);
47
+ return bytesToHex(new Uint8Array(hash));
48
+ }
49
+ // URL-safe base64 (RFC 4648 §5): replaces + → -, / → _, strips trailing =
50
+ function base64UrlEncode(data) {
51
+ const b64 = getBase64();
52
+ return b64
53
+ .encode(data)
54
+ .replace(/\+/g, "-")
55
+ .replace(/\//g, "_")
56
+ .replace(/=+$/, "");
57
+ }
58
+ function base64UrlDecode(encoded) {
59
+ const b64 = getBase64();
60
+ // Restore standard base64 padding
61
+ const padded = encoded.replace(/-/g, "+").replace(/_/g, "/");
62
+ const rem = padded.length % 4;
63
+ const padded2 = rem === 0 ? padded : padded + "=".repeat(4 - rem);
64
+ return b64.decode(padded2);
65
+ }
66
+ // ── Public API ────────────────────────────────────────────────────────────────
67
+ /**
68
+ * Generates a cryptographically random passphrase from the built-in 256-word list.
69
+ *
70
+ * Each word represents one byte of entropy (256 words = one byte per word, zero modulo bias).
71
+ * A 12-word passphrase gives 96 bits of entropy — stronger than a random UUID.
72
+ *
73
+ * @param wordCount Number of words (default: 12).
74
+ * @param wordlist Custom word list (must have exactly 256 entries).
75
+ */
76
+ export function generatePassphrase(wordCount = 12, wordlist = WORDLIST) {
77
+ if (wordlist.length !== 256) {
78
+ throw new Error(`Word list must have exactly 256 entries, got ${wordlist.length}`);
79
+ }
80
+ const c = getCrypto();
81
+ const bytes = c.getRandomValues(new Uint8Array(wordCount));
82
+ return Array.from(bytes, (b) => wordlist[b]).join(" ");
83
+ }
84
+ /**
85
+ * Derives auth credentials from a passphrase.
86
+ *
87
+ * All derivations are deterministic — the same passphrase always produces the same credentials.
88
+ * Sharing the passphrase grants access on any device.
89
+ *
90
+ * The returned values map directly to Starfish options:
91
+ * ```ts
92
+ * const creds = await deriveCredentials(passphrase)
93
+ *
94
+ * const client = new StarfishClient({
95
+ * baseUrl: serverUrl,
96
+ * auth: () => ({ Authorization: `Bearer ${creds.authToken}` }),
97
+ * })
98
+ * const syncManager = new SyncManager({
99
+ * client,
100
+ * pullPath: `/pull/${creds.userId}/wedding`,
101
+ * pushPath: `/push/${creds.userId}/wedding`,
102
+ * encryptionSecret: creds.encryptionSecret,
103
+ * encryptionSalt: creds.encryptionSalt,
104
+ * })
105
+ * ```
106
+ */
107
+ export async function deriveCredentials(passphrase) {
108
+ if (!passphrase.trim())
109
+ throw new Error("Passphrase must not be empty");
110
+ // authToken = SHA-256(passphrase) — used as Bearer token
111
+ const authToken = await sha256Hex(passphrase);
112
+ // userId = first 16 hex chars of authToken (8 bytes)
113
+ const userId = authToken.slice(0, 16);
114
+ // encryptionSecret = SHA-256(passphrase + ":" + userId)
115
+ // Domain separation from authToken ensures they cannot be recovered from each other.
116
+ const encryptionSecret = await sha256Hex(`${passphrase}:${userId}`);
117
+ return {
118
+ authToken,
119
+ userId,
120
+ encryptionSecret,
121
+ encryptionSalt: userId,
122
+ };
123
+ }
124
+ /**
125
+ * Encodes an invite payload as a URL-safe token and appends it as `?t=<token>`.
126
+ *
127
+ * ```ts
128
+ * const url = buildInviteUrl("myapp://join", { name: "Alice & Bob", p: passphrase })
129
+ * // → "myapp://join?t=eyJuYW1lIjoiQWxpY2UgJiBCb2IifQ"
130
+ * ```
131
+ */
132
+ export function buildInviteUrl(baseUrl, payload) {
133
+ const json = JSON.stringify(payload);
134
+ const bytes = new TextEncoder().encode(json);
135
+ const token = base64UrlEncode(bytes);
136
+ const separator = baseUrl.includes("?") ? "&" : "?";
137
+ return `${baseUrl}${separator}t=${token}`;
138
+ }
139
+ /**
140
+ * Decodes an invite URL produced by `buildInviteUrl`.
141
+ *
142
+ * Returns the decoded payload, or `null` if the URL is missing or malformed.
143
+ */
144
+ export function parseInviteUrl(url) {
145
+ try {
146
+ const tokenMatch = url.match(/[?&]t=([^&]+)/);
147
+ if (!tokenMatch?.[1])
148
+ return null;
149
+ const bytes = base64UrlDecode(tokenMatch[1]);
150
+ const json = new TextDecoder().decode(bytes);
151
+ const parsed = JSON.parse(json);
152
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
153
+ return null;
154
+ return parsed;
155
+ }
156
+ catch {
157
+ return null;
158
+ }
159
+ }
160
+ // Export word list for consumers that want to provide localized alternatives
161
+ export { WORDLIST as DEFAULT_WORDLIST };
package/dist/index.d.ts CHANGED
@@ -10,16 +10,31 @@ export { createEncryptor, ENCRYPTED_KEY } from "./crypto.js";
10
10
  export type { Encryptor } from "./crypto.js";
11
11
  export { ConflictError, StarfishHttpError, } from "./types.js";
12
12
  export type { StarfishClientOptions, AuthProvider, ConflictResolver, } from "./types.js";
13
- export { consoleSyncLogger, noopSyncLogger } from "./logger.js";
14
- export type { SyncLogger } from "./logger.js";
13
+ export { consoleSyncLogger, noopSyncLogger, createMetricsCollector } from "./logger.js";
14
+ export type { SyncLogger, SyncMetrics, MetricsCollector } from "./logger.js";
15
15
  export { createMigrator } from "./migrate.js";
16
16
  export type { MigrationFn, MigrationConfig } from "./migrate.js";
17
17
  export { ValidationError, createSchemaValidator } from "./validate.js";
18
18
  export type { Validator, ValidationResult } from "./validate.js";
19
19
  export { classifyError } from "./fetch.js";
20
20
  export type { ErrorCategory } from "./fetch.js";
21
- export { createUnionMerge, createSoftDeleteResolver, timestampWinner, pruneTombstones, } from "./resolvers.js";
21
+ export { createUnionMerge, createSoftDeleteResolver, timestampWinner, pruneTombstones, withConflictMeta, } from "./resolvers.js";
22
+ export type { ConflictMeta, ConflictResolverWithMeta } from "./resolvers.js";
22
23
  export { SnapshotHistory } from "./history.js";
23
24
  export type { Snapshot, SnapshotHistoryOptions } from "./history.js";
24
25
  export { startPolling, startAdaptivePolling } from "./polling.js";
25
26
  export type { PollableState, AdaptivePollingOptions, AdaptivePollingControls } from "./polling.js";
27
+ export { createDedupFetch } from "./dedup.js";
28
+ export { createIndexedDBStorage } from "./storage/indexeddb.js";
29
+ export type { IndexedDBStorageOptions, AsyncStateStorage } from "./storage/indexeddb.js";
30
+ export { exportData, importData, exportToBlob } from "./export.js";
31
+ export type { ExportOptions } from "./export.js";
32
+ export { isBackgroundSyncSupported, registerBackgroundSync } from "./background-sync.js";
33
+ export type { BackgroundSyncOptions } from "./background-sync.js";
34
+ export { isServiceWorkerSupported, registerServiceWorker, unregisterServiceWorkers } from "./service-worker.js";
35
+ export type { ServiceWorkerOptions } from "./service-worker.js";
36
+ export { createSuspenseResource } from "./bindings/suspense.js";
37
+ export { createDebouncedSync } from "./debounced-sync.js";
38
+ export type { DebouncedSyncOptions, DebouncedSync } from "./debounced-sync.js";
39
+ export { createMultiStoreSync } from "./multi-store.js";
40
+ export type { StoreSlice, BackupDocument, MultiStoreMigrationFn, MultiStoreSyncOptions, MultiStoreSync, } from "./multi-store.js";
package/dist/index.js CHANGED
@@ -4,10 +4,18 @@ 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";
7
+ export { consoleSyncLogger, noopSyncLogger, createMetricsCollector } from "./logger.js";
8
8
  export { createMigrator } from "./migrate.js";
9
9
  export { ValidationError, createSchemaValidator } from "./validate.js";
10
10
  export { classifyError } from "./fetch.js";
11
- export { createUnionMerge, createSoftDeleteResolver, timestampWinner, pruneTombstones, } from "./resolvers.js";
11
+ export { createUnionMerge, createSoftDeleteResolver, timestampWinner, pruneTombstones, withConflictMeta, } from "./resolvers.js";
12
12
  export { SnapshotHistory } from "./history.js";
13
13
  export { startPolling, startAdaptivePolling } from "./polling.js";
14
+ export { createDedupFetch } from "./dedup.js";
15
+ export { createIndexedDBStorage } from "./storage/indexeddb.js";
16
+ export { exportData, importData, exportToBlob } from "./export.js";
17
+ export { isBackgroundSyncSupported, registerBackgroundSync } from "./background-sync.js";
18
+ export { isServiceWorkerSupported, registerServiceWorker, unregisterServiceWorkers } from "./service-worker.js";
19
+ export { createSuspenseResource } from "./bindings/suspense.js";
20
+ export { createDebouncedSync } from "./debounced-sync.js";
21
+ export { createMultiStoreSync } from "./multi-store.js";
package/dist/logger.d.ts CHANGED
@@ -1,10 +1,18 @@
1
+ /** Extended metrics for sync operations. */
2
+ export interface SyncMetrics {
3
+ bytesTransferred?: number;
4
+ compressedSize?: number;
5
+ conflictCount?: number;
6
+ retryCount?: number;
7
+ cacheHit?: boolean;
8
+ }
1
9
  /** Structured logger for sync operations. */
2
10
  export interface SyncLogger {
3
11
  pullStart(store: string): void;
4
- pullSuccess(store: string, durationMs: number): void;
12
+ pullSuccess(store: string, durationMs: number, metrics?: SyncMetrics): void;
5
13
  pullError(store: string, error: string): void;
6
14
  pushStart(store: string): void;
7
- pushSuccess(store: string, durationMs: number): void;
15
+ pushSuccess(store: string, durationMs: number, metrics?: SyncMetrics): void;
8
16
  pushError(store: string, error: string): void;
9
17
  conflict(store: string, attempt: number): void;
10
18
  }
@@ -12,3 +20,19 @@ export interface SyncLogger {
12
20
  export declare const consoleSyncLogger: SyncLogger;
13
21
  /** Silent sync logger (no output). */
14
22
  export declare const noopSyncLogger: SyncLogger;
23
+ /** Collects sync metrics over time. */
24
+ export interface MetricsCollector {
25
+ recordPull(name: string, durationMs: number, metrics?: SyncMetrics): void;
26
+ recordPush(name: string, durationMs: number, metrics?: SyncMetrics): void;
27
+ recordConflict(name: string): void;
28
+ getSummary(): Record<string, {
29
+ totalPulls: number;
30
+ totalPushes: number;
31
+ avgDurationMs: number;
32
+ totalBytes: number;
33
+ totalConflicts: number;
34
+ }>;
35
+ reset(): void;
36
+ }
37
+ /** Create a metrics collector that accumulates sync statistics. */
38
+ export declare function createMetricsCollector(): MetricsCollector;
package/dist/logger.js CHANGED
@@ -1,10 +1,22 @@
1
1
  /** Console-based sync logger with structured output. */
2
2
  export const consoleSyncLogger = {
3
3
  pullStart: (s) => console.log(`[starfish:${s}] pull started`),
4
- pullSuccess: (s, ms) => console.log(`[starfish:${s}] pull OK (${ms}ms)`),
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
+ },
5
12
  pullError: (s, err) => console.error(`[starfish:${s}] pull failed: ${err}`),
6
13
  pushStart: (s) => console.log(`[starfish:${s}] push started`),
7
- pushSuccess: (s, ms) => console.log(`[starfish:${s}] push OK (${ms}ms)`),
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
+ },
8
20
  pushError: (s, err) => console.error(`[starfish:${s}] push failed: ${err}`),
9
21
  conflict: (s, n) => console.warn(`[starfish:${s}] conflict (attempt ${n})`),
10
22
  };
@@ -18,3 +30,51 @@ export const noopSyncLogger = {
18
30
  pushError: () => { },
19
31
  conflict: () => { },
20
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
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Serializer/deserializer pair for one slice of application state.
3
+ *
4
+ * `serialize` snapshots the current state into a plain object.
5
+ * `restore` applies a snapshot (potentially from a different app version after migration).
6
+ */
7
+ export interface StoreSlice<T = unknown> {
8
+ /**
9
+ * Snapshot the current state of this slice into a serializable value.
10
+ * Called during `serialize()`.
11
+ */
12
+ serialize: () => T;
13
+ /**
14
+ * Apply a snapshot to this slice.
15
+ * Called during `restore()` — data may be from an older schema version after migration.
16
+ */
17
+ restore: (data: T) => void;
18
+ }
19
+ /**
20
+ * A versioned backup document produced by `MultiStoreSync.serialize()`.
21
+ * Safe to pass to `store.set()` as the Starfish sync document.
22
+ */
23
+ export interface BackupDocument<T = Record<string, unknown>> {
24
+ /** Schema version declared in `createMultiStoreSync`. */
25
+ version: number;
26
+ /** Unix timestamp (ms) when this backup was created. */
27
+ timestamp: number;
28
+ /** Serialized slice data, keyed by slice name. */
29
+ data: T;
30
+ }
31
+ /**
32
+ * A migration function that transforms data from one version to the next.
33
+ * Receives the full `data` object and must return an updated `data` object.
34
+ * Only the `data` field is passed; `version` and `timestamp` are managed automatically.
35
+ */
36
+ export type MultiStoreMigrationFn = (data: Record<string, unknown>) => Record<string, unknown>;
37
+ export interface MultiStoreSyncOptions<T extends Record<string, unknown>> {
38
+ /**
39
+ * Named slices to include in the backup document.
40
+ * Each slice provides `serialize()` and `restore()` methods.
41
+ *
42
+ * @example
43
+ * ```ts
44
+ * slices: {
45
+ * tasks: {
46
+ * serialize: () => taskStore.getState().tasks,
47
+ * restore: (data) => taskStore.setState({ tasks: data }),
48
+ * },
49
+ * settings: {
50
+ * serialize: () => settingsStore.getState().settings,
51
+ * restore: (data) => settingsStore.setState({ settings: data }),
52
+ * },
53
+ * }
54
+ * ```
55
+ */
56
+ slices: {
57
+ [K in keyof T]: StoreSlice<T[K]>;
58
+ };
59
+ /**
60
+ * Current schema version. Increment when slices are added, renamed, or their shape changes.
61
+ * Used to detect forward-incompatible documents from future app versions.
62
+ */
63
+ version: number;
64
+ /**
65
+ * Optional migration chain. Key is the version number that produced the data;
66
+ * value is a function that upgrades it to the next version.
67
+ *
68
+ * Migrations run sequentially from the document version up to the current version.
69
+ *
70
+ * @example
71
+ * ```ts
72
+ * migrations: {
73
+ * 1: (data) => ({ ...data, settings: { ...data.settings, theme: "light" } }),
74
+ * 2: (data) => ({ ...data, tasks: data.todos, todos: undefined }),
75
+ * }
76
+ * ```
77
+ */
78
+ migrations?: Record<number, MultiStoreMigrationFn>;
79
+ }
80
+ /**
81
+ * Returned by `createMultiStoreSync`. Serialize and restore coordinated multi-store state.
82
+ */
83
+ export interface MultiStoreSync<T extends Record<string, unknown>> {
84
+ /**
85
+ * Snapshot all slices into a `BackupDocument`.
86
+ * Pass the result to `starfishStore.getState().set(() => multiSync.serialize())`.
87
+ */
88
+ serialize: () => BackupDocument<T>;
89
+ /**
90
+ * Apply a `BackupDocument` to all slices, running migrations as needed.
91
+ *
92
+ * Throws if the document version is newer than the current version (forward-incompatible).
93
+ * Silently migrates older documents.
94
+ */
95
+ restore: (doc: BackupDocument) => void;
96
+ /** Current schema version as declared in options. */
97
+ readonly version: number;
98
+ }
99
+ /**
100
+ * Creates a multi-store sync coordinator.
101
+ *
102
+ * Collects multiple application stores into a single Starfish sync document,
103
+ * with versioned schema migrations for backward compatibility.
104
+ *
105
+ * ```ts
106
+ * const multiSync = createMultiStoreSync({
107
+ * slices: {
108
+ * tasks: {
109
+ * serialize: () => taskStore.getState().tasks,
110
+ * restore: (tasks) => taskStore.setState({ tasks }),
111
+ * },
112
+ * settings: {
113
+ * serialize: () => settingsStore.getState().settings,
114
+ * restore: (settings) => settingsStore.setState({ settings }),
115
+ * },
116
+ * },
117
+ * version: 2,
118
+ * migrations: {
119
+ * // data from version 1 → upgrade to version 2
120
+ * 1: (data) => ({ ...data, settings: { ...(data.settings as object), darkMode: false } }),
121
+ * },
122
+ * })
123
+ *
124
+ * // Push:
125
+ * starfishStore.getState().set(() => multiSync.serialize())
126
+ *
127
+ * // Restore on pull (pass as onRemoteUpdate to createStarfishStore):
128
+ * createStarfishStore({
129
+ * name: "app",
130
+ * syncManager,
131
+ * onRemoteUpdate: (doc) => multiSync.restore(doc as BackupDocument),
132
+ * })
133
+ * ```
134
+ */
135
+ export declare function createMultiStoreSync<T extends Record<string, unknown>>(options: MultiStoreSyncOptions<T>): MultiStoreSync<T>;
@@ -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
+ }