@drakkar.software/starfish-client 1.4.1 → 1.5.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,16 @@
1
+ /**
2
+ * Background Sync API integration for pending changes.
3
+ * Uses the Web Background Sync API to retry failed sync operations
4
+ * when connectivity is restored, even if the app is closed.
5
+ */
6
+ export interface BackgroundSyncOptions {
7
+ /** Sync event tag. Default: "starfish-sync" */
8
+ tag?: string;
9
+ }
10
+ /** Check if the Background Sync API is supported in the current environment. */
11
+ export declare function isBackgroundSyncSupported(): boolean;
12
+ /**
13
+ * Register a background sync event with the active service worker.
14
+ * Returns true if registration succeeded, false if not supported or no active SW.
15
+ */
16
+ export declare function registerBackgroundSync(opts?: BackgroundSyncOptions): Promise<boolean>;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Background Sync API integration for pending changes.
3
+ * Uses the Web Background Sync API to retry failed sync operations
4
+ * when connectivity is restored, even if the app is closed.
5
+ */
6
+ /** Check if the Background Sync API is supported in the current environment. */
7
+ export function isBackgroundSyncSupported() {
8
+ return (typeof navigator !== "undefined" &&
9
+ "serviceWorker" in navigator &&
10
+ "SyncManager" in globalThis);
11
+ }
12
+ /**
13
+ * Register a background sync event with the active service worker.
14
+ * Returns true if registration succeeded, false if not supported or no active SW.
15
+ */
16
+ export async function registerBackgroundSync(opts) {
17
+ if (!isBackgroundSyncSupported())
18
+ return false;
19
+ const tag = opts?.tag ?? "starfish-sync";
20
+ try {
21
+ const registration = await navigator.serviceWorker.ready;
22
+ // @ts-expect-error - SyncManager types may not be available
23
+ await registration.sync.register(tag);
24
+ return true;
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * React Suspense integration for Starfish sync data.
3
+ * Creates resources that throw Promises while loading (Suspense protocol).
4
+ */
5
+ interface SuspenseResource<T> {
6
+ /** Read the resource value. Throws a Promise while pending (Suspense protocol). */
7
+ read(): T;
8
+ }
9
+ /**
10
+ * Create a Suspense-compatible resource from an async fetcher.
11
+ * The first call to `read()` triggers the fetch. While loading, `read()` throws
12
+ * a Promise (which React Suspense catches to show a fallback). Once resolved,
13
+ * `read()` returns the value synchronously.
14
+ *
15
+ * @example
16
+ * ```tsx
17
+ * const resource = createSuspenseResource(() => syncManager.pull())
18
+ * function MyComponent() {
19
+ * const data = resource.read() // throws while loading, returns data when ready
20
+ * return <div>{JSON.stringify(data)}</div>
21
+ * }
22
+ * ```
23
+ */
24
+ export declare function createSuspenseResource<T>(fetcher: () => Promise<T>): SuspenseResource<T>;
25
+ export {};
@@ -0,0 +1,49 @@
1
+ /**
2
+ * React Suspense integration for Starfish sync data.
3
+ * Creates resources that throw Promises while loading (Suspense protocol).
4
+ */
5
+ /**
6
+ * Create a Suspense-compatible resource from an async fetcher.
7
+ * The first call to `read()` triggers the fetch. While loading, `read()` throws
8
+ * a Promise (which React Suspense catches to show a fallback). Once resolved,
9
+ * `read()` returns the value synchronously.
10
+ *
11
+ * @example
12
+ * ```tsx
13
+ * const resource = createSuspenseResource(() => syncManager.pull())
14
+ * function MyComponent() {
15
+ * const data = resource.read() // throws while loading, returns data when ready
16
+ * return <div>{JSON.stringify(data)}</div>
17
+ * }
18
+ * ```
19
+ */
20
+ export function createSuspenseResource(fetcher) {
21
+ let status = "pending";
22
+ let result;
23
+ let error;
24
+ let promise = null;
25
+ function init() {
26
+ if (promise)
27
+ return promise;
28
+ promise = fetcher().then((value) => {
29
+ status = "resolved";
30
+ result = value;
31
+ }, (err) => {
32
+ status = "rejected";
33
+ error = err;
34
+ });
35
+ return promise;
36
+ }
37
+ return {
38
+ read() {
39
+ switch (status) {
40
+ case "pending":
41
+ throw init();
42
+ case "resolved":
43
+ return result;
44
+ case "rejected":
45
+ throw error;
46
+ }
47
+ },
48
+ };
49
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Request deduplication: prevents multiple concurrent identical GET requests.
3
+ * If a GET request is in-flight for a URL, subsequent identical GET requests
4
+ * return the same Promise. POST/PUT/DELETE/PATCH are never deduped.
5
+ */
6
+ export declare function createDedupFetch(baseFetch?: typeof globalThis.fetch): typeof globalThis.fetch;
package/dist/dedup.js ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Request deduplication: prevents multiple concurrent identical GET requests.
3
+ * If a GET request is in-flight for a URL, subsequent identical GET requests
4
+ * return the same Promise. POST/PUT/DELETE/PATCH are never deduped.
5
+ */
6
+ export function createDedupFetch(baseFetch = globalThis.fetch.bind(globalThis)) {
7
+ const inflightGets = new Map();
8
+ return (async (input, init) => {
9
+ const method = (init?.method ?? "GET").toUpperCase();
10
+ // Only dedup GET requests
11
+ if (method !== "GET") {
12
+ return baseFetch(input, init);
13
+ }
14
+ const url = typeof input === "string"
15
+ ? input
16
+ : input instanceof URL
17
+ ? input.toString()
18
+ : input.url;
19
+ const existing = inflightGets.get(url);
20
+ if (existing) {
21
+ // Return a clone — the original is reserved for cloning only
22
+ return existing.then((res) => res.clone());
23
+ }
24
+ // Store a promise that resolves to a response we keep solely for cloning.
25
+ // The first caller also gets a clone, ensuring the "master" body is never consumed.
26
+ const promise = baseFetch(input, init)
27
+ .then((res) => res)
28
+ .finally(() => {
29
+ inflightGets.delete(url);
30
+ });
31
+ inflightGets.set(url, promise);
32
+ // First caller also gets a clone so the cached response body stays unconsumed
33
+ return promise.then((res) => res.clone());
34
+ });
35
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Data export/import helpers for Starfish sync data.
3
+ * Supports JSON and CSV formats.
4
+ */
5
+ export interface ExportOptions {
6
+ /** Output format. Default: "json" */
7
+ format?: "json" | "csv";
8
+ /** Pretty-print JSON output. Default: false */
9
+ pretty?: boolean;
10
+ }
11
+ /**
12
+ * Export data to a string representation.
13
+ * JSON: serializes the full object.
14
+ * CSV: flattens top-level keys into columns. Array values are JSON-encoded.
15
+ */
16
+ export declare function exportData(data: Record<string, unknown>, opts?: ExportOptions): string;
17
+ /**
18
+ * Import data from a string representation.
19
+ */
20
+ export declare function importData(raw: string, format?: "json" | "csv"): Record<string, unknown>;
21
+ /**
22
+ * Export data to a Blob suitable for download.
23
+ */
24
+ export declare function exportToBlob(data: Record<string, unknown>, opts?: ExportOptions): Blob;
package/dist/export.js ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Data export/import helpers for Starfish sync data.
3
+ * Supports JSON and CSV formats.
4
+ */
5
+ /**
6
+ * Export data to a string representation.
7
+ * JSON: serializes the full object.
8
+ * CSV: flattens top-level keys into columns. Array values are JSON-encoded.
9
+ */
10
+ export function exportData(data, opts) {
11
+ const format = opts?.format ?? "json";
12
+ if (format === "json") {
13
+ return opts?.pretty
14
+ ? JSON.stringify(data, null, 2)
15
+ : JSON.stringify(data);
16
+ }
17
+ // CSV export: each top-level key becomes a column
18
+ return toCsv(data);
19
+ }
20
+ /**
21
+ * Import data from a string representation.
22
+ */
23
+ export function importData(raw, format = "json") {
24
+ if (format === "json") {
25
+ const parsed = JSON.parse(raw);
26
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
27
+ throw new Error("Expected a JSON object");
28
+ }
29
+ return parsed;
30
+ }
31
+ return fromCsv(raw);
32
+ }
33
+ /**
34
+ * Export data to a Blob suitable for download.
35
+ */
36
+ export function exportToBlob(data, opts) {
37
+ const format = opts?.format ?? "json";
38
+ const content = exportData(data, opts);
39
+ const mimeType = format === "csv" ? "text/csv;charset=utf-8" : "application/json;charset=utf-8";
40
+ return new Blob([content], { type: mimeType });
41
+ }
42
+ function toCsv(data) {
43
+ const keys = Object.keys(data);
44
+ const header = keys.map(escapeCsvField).join(",");
45
+ const values = keys.map((k) => {
46
+ const v = data[k];
47
+ if (v === null || v === undefined)
48
+ return "";
49
+ if (typeof v === "object")
50
+ return escapeCsvField(JSON.stringify(v));
51
+ return escapeCsvField(String(v));
52
+ });
53
+ return `${header}\n${values.join(",")}`;
54
+ }
55
+ function fromCsv(raw) {
56
+ const lines = raw.trim().split("\n");
57
+ if (lines.length < 2) {
58
+ throw new Error("CSV must have at least a header row and a data row");
59
+ }
60
+ const headers = parseCsvLine(lines[0]);
61
+ const values = parseCsvLine(lines[1]);
62
+ const result = {};
63
+ for (let i = 0; i < headers.length; i++) {
64
+ const key = headers[i];
65
+ const val = values[i] ?? "";
66
+ // Try to parse JSON values
67
+ try {
68
+ result[key] = JSON.parse(val);
69
+ }
70
+ catch {
71
+ result[key] = val;
72
+ }
73
+ }
74
+ return result;
75
+ }
76
+ function escapeCsvField(field) {
77
+ if (field.includes(",") || field.includes('"') || field.includes("\n")) {
78
+ return `"${field.replace(/"/g, '""')}"`;
79
+ }
80
+ return field;
81
+ }
82
+ function parseCsvLine(line) {
83
+ const result = [];
84
+ let current = "";
85
+ let inQuotes = false;
86
+ for (let i = 0; i < line.length; i++) {
87
+ const ch = line[i];
88
+ if (inQuotes) {
89
+ if (ch === '"' && line[i + 1] === '"') {
90
+ current += '"';
91
+ i++;
92
+ }
93
+ else if (ch === '"') {
94
+ inQuotes = false;
95
+ }
96
+ else {
97
+ current += ch;
98
+ }
99
+ }
100
+ else {
101
+ if (ch === '"') {
102
+ inQuotes = true;
103
+ }
104
+ else if (ch === ",") {
105
+ result.push(current);
106
+ current = "";
107
+ }
108
+ else {
109
+ current += ch;
110
+ }
111
+ }
112
+ }
113
+ result.push(current);
114
+ return result;
115
+ }
package/dist/hash.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Deterministic JSON serialization with sorted keys (recursive).
3
+ * Must produce identical output to the server's stableStringify.
4
+ */
5
+ export declare function stableStringify(value: unknown): string;
6
+ /**
7
+ * Compute SHA-256 hex digest of the stable-stringified data.
8
+ * Works in both browser (crypto.subtle) and Node.js environments.
9
+ */
10
+ export declare function computeHash(data: Record<string, unknown>): Promise<string>;
package/dist/hash.js ADDED
@@ -0,0 +1,34 @@
1
+ import { getCrypto } from "./platform.js";
2
+ /**
3
+ * Deterministic JSON serialization with sorted keys (recursive).
4
+ * Must produce identical output to the server's stableStringify.
5
+ */
6
+ export function stableStringify(value) {
7
+ if (value === null || value === undefined)
8
+ return "null";
9
+ if (typeof value === "boolean" || typeof value === "number")
10
+ return JSON.stringify(value);
11
+ if (typeof value === "string")
12
+ return JSON.stringify(value);
13
+ if (Array.isArray(value)) {
14
+ return "[" + value.map(v => stableStringify(v)).join(",") + "]";
15
+ }
16
+ if (typeof value === "object") {
17
+ const obj = value;
18
+ const keys = Object.keys(obj).sort();
19
+ const pairs = keys.map(k => JSON.stringify(k) + ":" + stableStringify(obj[k]));
20
+ return "{" + pairs.join(",") + "}";
21
+ }
22
+ return "null";
23
+ }
24
+ /**
25
+ * Compute SHA-256 hex digest of the stable-stringified data.
26
+ * Works in both browser (crypto.subtle) and Node.js environments.
27
+ */
28
+ export async function computeHash(data) {
29
+ const encoded = new TextEncoder().encode(stableStringify(data));
30
+ const buf = await getCrypto().subtle.digest("SHA-256", encoded);
31
+ return Array.from(new Uint8Array(buf))
32
+ .map(b => b.toString(16).padStart(2, "0"))
33
+ .join("");
34
+ }
package/dist/index.d.ts CHANGED
@@ -10,16 +10,27 @@ 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";
package/dist/index.js CHANGED
@@ -4,10 +4,16 @@ 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";
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,52 @@
1
+ /**
2
+ * Platform abstraction for crypto and base64 operations.
3
+ *
4
+ * Browser and Node.js >= 15 work with zero configuration (globalThis.crypto).
5
+ * React Native users must call configurePlatform() before using the SDK.
6
+ */
7
+ /** Minimal crypto interface required by the SDK (subset of Web Crypto API). */
8
+ export interface CryptoProvider {
9
+ subtle: {
10
+ digest(algorithm: string, data: BufferSource): Promise<ArrayBuffer>;
11
+ importKey(format: "raw", keyData: BufferSource, algorithm: string | Algorithm, extractable: boolean, keyUsages: KeyUsage[]): Promise<CryptoKey>;
12
+ deriveKey(algorithm: HkdfParams, baseKey: CryptoKey, derivedKeyType: AesKeyGenParams, extractable: boolean, keyUsages: KeyUsage[]): Promise<CryptoKey>;
13
+ encrypt(algorithm: AesGcmParams, key: CryptoKey, data: BufferSource): Promise<ArrayBuffer>;
14
+ decrypt(algorithm: AesGcmParams, key: CryptoKey, data: BufferSource): Promise<ArrayBuffer>;
15
+ };
16
+ getRandomValues<T extends ArrayBufferView>(array: T): T;
17
+ }
18
+ /** Base64 encode/decode for Uint8Array <-> string. */
19
+ export interface Base64Provider {
20
+ encode(data: Uint8Array): string;
21
+ decode(encoded: string): Uint8Array;
22
+ }
23
+ export interface PlatformConfig {
24
+ crypto?: CryptoProvider;
25
+ base64?: Base64Provider;
26
+ }
27
+ /**
28
+ * Configure platform-specific providers for environments
29
+ * that lack the Web Crypto API (e.g., React Native).
30
+ *
31
+ * Call once at app startup, before using any SDK functions.
32
+ * Not needed for browser or Node.js >= 15.
33
+ *
34
+ * @example
35
+ * ```ts
36
+ * import { configurePlatform } from "@satellite/client"
37
+ * import QuickCrypto from "react-native-quick-crypto"
38
+ *
39
+ * configurePlatform({
40
+ * crypto: QuickCrypto,
41
+ * base64: {
42
+ * encode: (data) => Buffer.from(data).toString("base64"),
43
+ * decode: (str) => new Uint8Array(Buffer.from(str, "base64")),
44
+ * },
45
+ * })
46
+ * ```
47
+ */
48
+ export declare function configurePlatform(config: PlatformConfig): void;
49
+ /** Resolve the active crypto provider. */
50
+ export declare function getCrypto(): CryptoProvider;
51
+ /** Resolve the active base64 provider. */
52
+ export declare function getBase64(): Base64Provider;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Platform abstraction for crypto and base64 operations.
3
+ *
4
+ * Browser and Node.js >= 15 work with zero configuration (globalThis.crypto).
5
+ * React Native users must call configurePlatform() before using the SDK.
6
+ */
7
+ let _crypto;
8
+ let _base64;
9
+ /**
10
+ * Configure platform-specific providers for environments
11
+ * that lack the Web Crypto API (e.g., React Native).
12
+ *
13
+ * Call once at app startup, before using any SDK functions.
14
+ * Not needed for browser or Node.js >= 15.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * import { configurePlatform } from "@satellite/client"
19
+ * import QuickCrypto from "react-native-quick-crypto"
20
+ *
21
+ * configurePlatform({
22
+ * crypto: QuickCrypto,
23
+ * base64: {
24
+ * encode: (data) => Buffer.from(data).toString("base64"),
25
+ * decode: (str) => new Uint8Array(Buffer.from(str, "base64")),
26
+ * },
27
+ * })
28
+ * ```
29
+ */
30
+ export function configurePlatform(config) {
31
+ if (config.crypto)
32
+ _crypto = config.crypto;
33
+ if (config.base64)
34
+ _base64 = config.base64;
35
+ }
36
+ /** Resolve the active crypto provider. */
37
+ export function getCrypto() {
38
+ if (_crypto)
39
+ return _crypto;
40
+ if (typeof globalThis !== "undefined" && globalThis.crypto?.subtle) {
41
+ return globalThis.crypto;
42
+ }
43
+ throw new Error("@satellite/client: No crypto provider available. " +
44
+ "In React Native, call configurePlatform({ crypto: ... }) before using the SDK.");
45
+ }
46
+ /** Resolve the active base64 provider. */
47
+ export function getBase64() {
48
+ if (_base64)
49
+ return _base64;
50
+ if (typeof globalThis !== "undefined" && typeof globalThis.btoa === "function") {
51
+ return {
52
+ encode(data) {
53
+ return btoa(String.fromCharCode(...data));
54
+ },
55
+ decode(encoded) {
56
+ return Uint8Array.from(atob(encoded), (c) => c.charCodeAt(0));
57
+ },
58
+ };
59
+ }
60
+ throw new Error("@satellite/client: No base64 provider available. " +
61
+ "In React Native, call configurePlatform({ base64: ... }) before using the SDK.");
62
+ }
@@ -1,4 +1,23 @@
1
1
  import type { ConflictResolver } from "./types.js";
2
+ /** Metadata about which fields were affected during conflict resolution. */
3
+ export interface ConflictMeta {
4
+ /** Field names that differed between local and remote. */
5
+ conflictedFields: string[];
6
+ /** How the conflict was resolved. */
7
+ resolvedBy: "local" | "remote" | "merged";
8
+ /** Timestamp of resolution. */
9
+ timestamp: number;
10
+ }
11
+ /** Conflict resolver that also returns metadata about the resolution. */
12
+ export type ConflictResolverWithMeta = (local: Record<string, unknown>, remote: Record<string, unknown>) => {
13
+ data: Record<string, unknown>;
14
+ meta: ConflictMeta;
15
+ };
16
+ /**
17
+ * Wrap a standard ConflictResolver to also return metadata about which fields conflicted.
18
+ * Compares local and remote keys to detect differing fields.
19
+ */
20
+ export declare function withConflictMeta(resolver: ConflictResolver): ConflictResolverWithMeta;
2
21
  /**
3
22
  * Creates a conflict resolver that merges arrays by ID with per-item
4
23
  * timestamp comparison, and uses document-level timestamp for scalars.
package/dist/resolvers.js CHANGED
@@ -1,3 +1,60 @@
1
+ /** Shallow structural comparison of two values. Handles objects, arrays, and primitives. */
2
+ function shallowEqual(a, b) {
3
+ if (a === b)
4
+ return true;
5
+ if (a == null || b == null)
6
+ return a === b;
7
+ if (typeof a !== typeof b)
8
+ return false;
9
+ if (typeof a !== "object")
10
+ return false;
11
+ if (Array.isArray(a) !== Array.isArray(b))
12
+ return false;
13
+ if (Array.isArray(a) && Array.isArray(b)) {
14
+ if (a.length !== b.length)
15
+ return false;
16
+ return a.every((v, i) => shallowEqual(v, b[i]));
17
+ }
18
+ const aObj = a;
19
+ const bObj = b;
20
+ const aKeys = Object.keys(aObj);
21
+ const bKeys = Object.keys(bObj);
22
+ if (aKeys.length !== bKeys.length)
23
+ return false;
24
+ return aKeys.every((k) => shallowEqual(aObj[k], bObj[k]));
25
+ }
26
+ /**
27
+ * Wrap a standard ConflictResolver to also return metadata about which fields conflicted.
28
+ * Compares local and remote keys to detect differing fields.
29
+ */
30
+ export function withConflictMeta(resolver) {
31
+ return (local, remote) => {
32
+ const conflictedFields = [];
33
+ const allKeys = new Set([...Object.keys(local), ...Object.keys(remote)]);
34
+ for (const key of allKeys) {
35
+ const lv = local[key];
36
+ const rv = remote[key];
37
+ if (!shallowEqual(lv, rv)) {
38
+ conflictedFields.push(key);
39
+ }
40
+ }
41
+ const data = resolver(local, remote);
42
+ // Determine how it was resolved using structural comparison
43
+ let resolvedBy = "merged";
44
+ if (shallowEqual(data, local))
45
+ resolvedBy = "local";
46
+ else if (shallowEqual(data, remote))
47
+ resolvedBy = "remote";
48
+ return {
49
+ data,
50
+ meta: {
51
+ conflictedFields,
52
+ resolvedBy,
53
+ timestamp: Date.now(),
54
+ },
55
+ };
56
+ };
57
+ }
1
58
  /** Compare two timestamp values. Handles both numeric (epoch) and string (ISO-8601) timestamps. */
2
59
  function compareTimestamps(a, b) {
3
60
  if (typeof a === "number" && typeof b === "number")
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Service Worker utilities for offline support and PWA functionality.
3
+ */
4
+ export interface ServiceWorkerOptions {
5
+ /** Scope for the service worker registration. */
6
+ scope?: string;
7
+ /** Called when an updated service worker is available. */
8
+ onUpdate?: (registration: ServiceWorkerRegistration) => void;
9
+ }
10
+ /** Check if service workers are supported in the current environment. */
11
+ export declare function isServiceWorkerSupported(): boolean;
12
+ /**
13
+ * Register a service worker for offline support.
14
+ * Returns the registration, or null if not supported.
15
+ */
16
+ export declare function registerServiceWorker(scriptUrl: string, opts?: ServiceWorkerOptions): Promise<ServiceWorkerRegistration | null>;
17
+ /** Unregister all service worker registrations. Returns true if any were unregistered. */
18
+ export declare function unregisterServiceWorkers(): Promise<boolean>;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Service Worker utilities for offline support and PWA functionality.
3
+ */
4
+ /** Check if service workers are supported in the current environment. */
5
+ export function isServiceWorkerSupported() {
6
+ return typeof navigator !== "undefined" && "serviceWorker" in navigator;
7
+ }
8
+ /**
9
+ * Register a service worker for offline support.
10
+ * Returns the registration, or null if not supported.
11
+ */
12
+ export async function registerServiceWorker(scriptUrl, opts) {
13
+ if (!isServiceWorkerSupported())
14
+ return null;
15
+ try {
16
+ const registration = await navigator.serviceWorker.register(scriptUrl, {
17
+ scope: opts?.scope,
18
+ });
19
+ if (opts?.onUpdate) {
20
+ registration.onupdatefound = () => {
21
+ const installingWorker = registration.installing;
22
+ if (installingWorker) {
23
+ installingWorker.onstatechange = () => {
24
+ if (installingWorker.state === "installed" &&
25
+ navigator.serviceWorker.controller) {
26
+ opts.onUpdate(registration);
27
+ }
28
+ };
29
+ }
30
+ };
31
+ }
32
+ return registration;
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ /** Unregister all service worker registrations. Returns true if any were unregistered. */
39
+ export async function unregisterServiceWorkers() {
40
+ if (!isServiceWorkerSupported())
41
+ return false;
42
+ try {
43
+ const registrations = await navigator.serviceWorker.getRegistrations();
44
+ let unregistered = false;
45
+ for (const registration of registrations) {
46
+ const result = await registration.unregister();
47
+ if (result)
48
+ unregistered = true;
49
+ }
50
+ return unregistered;
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * IndexedDB-based storage adapter for Zustand persistence.
3
+ * Implements the same interface as Zustand's StateStorage (getItem/setItem/removeItem).
4
+ * Supports larger data than localStorage (typically 50MB+).
5
+ */
6
+ export interface IndexedDBStorageOptions {
7
+ /** Database name. Default: "starfish" */
8
+ dbName?: string;
9
+ /** Object store name. Default: "state" */
10
+ storeName?: string;
11
+ }
12
+ export interface AsyncStateStorage {
13
+ getItem: (name: string) => Promise<string | null>;
14
+ setItem: (name: string, value: string) => Promise<void>;
15
+ removeItem: (name: string) => Promise<void>;
16
+ }
17
+ export declare function createIndexedDBStorage(opts?: IndexedDBStorageOptions): AsyncStateStorage;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * IndexedDB-based storage adapter for Zustand persistence.
3
+ * Implements the same interface as Zustand's StateStorage (getItem/setItem/removeItem).
4
+ * Supports larger data than localStorage (typically 50MB+).
5
+ */
6
+ function openDB(dbName, storeName) {
7
+ return new Promise((resolve, reject) => {
8
+ const request = indexedDB.open(dbName, 1);
9
+ request.onupgradeneeded = () => {
10
+ const db = request.result;
11
+ if (!db.objectStoreNames.contains(storeName)) {
12
+ db.createObjectStore(storeName);
13
+ }
14
+ };
15
+ request.onsuccess = () => resolve(request.result);
16
+ request.onerror = () => reject(request.error);
17
+ });
18
+ }
19
+ function idbRequest(request) {
20
+ return new Promise((resolve, reject) => {
21
+ request.onsuccess = () => resolve(request.result);
22
+ request.onerror = () => reject(request.error);
23
+ });
24
+ }
25
+ export function createIndexedDBStorage(opts) {
26
+ const dbName = opts?.dbName ?? "starfish";
27
+ const storeName = opts?.storeName ?? "state";
28
+ let dbPromise = null;
29
+ function getDB() {
30
+ if (!dbPromise) {
31
+ dbPromise = openDB(dbName, storeName).catch((err) => {
32
+ dbPromise = null; // Reset so next call retries
33
+ throw err;
34
+ });
35
+ }
36
+ return dbPromise;
37
+ }
38
+ return {
39
+ async getItem(name) {
40
+ const db = await getDB();
41
+ const tx = db.transaction(storeName, "readonly");
42
+ const store = tx.objectStore(storeName);
43
+ const result = await idbRequest(store.get(name));
44
+ return result ?? null;
45
+ },
46
+ async setItem(name, value) {
47
+ const db = await getDB();
48
+ const tx = db.transaction(storeName, "readwrite");
49
+ const store = tx.objectStore(storeName);
50
+ await idbRequest(store.put(value, name));
51
+ },
52
+ async removeItem(name) {
53
+ const db = await getDB();
54
+ const tx = db.transaction(storeName, "readwrite");
55
+ const store = tx.objectStore(storeName);
56
+ await idbRequest(store.delete(name));
57
+ },
58
+ };
59
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakkar.software/starfish-client",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/Drakkar-Software/starfish.git",
@@ -60,7 +60,7 @@
60
60
  }
61
61
  },
62
62
  "dependencies": {
63
- "@drakkar.software/starfish-protocol": "1.4.1"
63
+ "@drakkar.software/starfish-protocol": "1.5.0"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@legendapp/state": "^2.0.0",
@@ -1,19 +0,0 @@
1
- import type { StoreApi } from "zustand/vanilla";
2
- import type { StarfishStore } from "./zustand.js";
3
- /**
4
- * Syncs a Zustand Starfish store across browser tabs using BroadcastChannel.
5
- * Returns a cleanup function that closes the channel.
6
- */
7
- export declare function setupBroadcastSync(store: StoreApi<StarfishStore>, name: string): () => void;
8
- /**
9
- * Syncs a Zustand Starfish store across browser tabs using storage events.
10
- * Fallback for environments without BroadcastChannel.
11
- * Returns a cleanup function.
12
- */
13
- export declare function setupStorageFallback(store: StoreApi<StarfishStore>, name: string): () => void;
14
- /**
15
- * Auto-detects the best cross-tab sync mechanism and sets it up.
16
- * Uses BroadcastChannel when available, falls back to storage events.
17
- * Returns a cleanup function.
18
- */
19
- export declare function setupCrossTabSync(store: StoreApi<StarfishStore>, name: string): () => void;
@@ -1,65 +0,0 @@
1
- /**
2
- * Syncs a Zustand Starfish store across browser tabs using BroadcastChannel.
3
- * Returns a cleanup function that closes the channel.
4
- */
5
- export function setupBroadcastSync(store, name) {
6
- const channel = new BroadcastChannel(`starfish-${name}`);
7
- let lastReceivedData = null;
8
- channel.onmessage = (event) => {
9
- lastReceivedData = event.data.data;
10
- store.setState({ data: event.data.data, dirty: event.data.dirty });
11
- };
12
- const unsub = store.subscribe((state, prev) => {
13
- if (state.data === lastReceivedData)
14
- return;
15
- if (state.data !== prev.data || state.dirty !== prev.dirty) {
16
- channel.postMessage({ data: state.data, dirty: state.dirty });
17
- }
18
- });
19
- return () => {
20
- unsub();
21
- channel.close();
22
- };
23
- }
24
- /**
25
- * Syncs a Zustand Starfish store across browser tabs using storage events.
26
- * Fallback for environments without BroadcastChannel.
27
- * Returns a cleanup function.
28
- */
29
- export function setupStorageFallback(store, name) {
30
- const storageKey = `starfish-broadcast-${name}`;
31
- let lastReceivedData = null;
32
- const onStorage = (e) => {
33
- if (e.key !== storageKey || !e.newValue)
34
- return;
35
- const payload = JSON.parse(e.newValue);
36
- lastReceivedData = payload.data;
37
- store.setState({ data: payload.data, dirty: payload.dirty });
38
- };
39
- globalThis.addEventListener("storage", onStorage);
40
- const unsub = store.subscribe((state, prev) => {
41
- if (state.data === lastReceivedData)
42
- return;
43
- if (state.data !== prev.data || state.dirty !== prev.dirty) {
44
- localStorage.setItem(storageKey, JSON.stringify({ data: state.data, dirty: state.dirty }));
45
- }
46
- });
47
- return () => {
48
- unsub();
49
- globalThis.removeEventListener("storage", onStorage);
50
- };
51
- }
52
- /**
53
- * Auto-detects the best cross-tab sync mechanism and sets it up.
54
- * Uses BroadcastChannel when available, falls back to storage events.
55
- * Returns a cleanup function.
56
- */
57
- export function setupCrossTabSync(store, name) {
58
- if (typeof BroadcastChannel !== "undefined") {
59
- return setupBroadcastSync(store, name);
60
- }
61
- if (typeof globalThis.addEventListener === "function" && typeof localStorage !== "undefined") {
62
- return setupStorageFallback(store, name);
63
- }
64
- return () => { };
65
- }
@@ -1,12 +0,0 @@
1
- import type { StoreApi } from "zustand/vanilla";
2
- import type { StarfishStore, StarfishState } from "./zustand.js";
3
- /** Derived sync status for UI display. */
4
- export type SyncStatus = "synced" | "syncing" | "pending" | "error" | "offline";
5
- /** Derive a single sync status from store state. */
6
- export declare function deriveSyncStatus(state: StarfishState): SyncStatus;
7
- /** Use the full Starfish store state and actions. */
8
- export declare function useStarfish(store: StoreApi<StarfishStore>): StarfishStore;
9
- /** Use only the synced data, with an optional selector for fine-grained subscriptions. */
10
- export declare function useStarfishData<T = Record<string, unknown>>(store: StoreApi<StarfishStore>, selector?: (data: Record<string, unknown>) => T): T;
11
- /** Use the derived sync status (synced | syncing | pending | error | offline). */
12
- export declare function useSyncStatus(store: StoreApi<StarfishStore>): SyncStatus;
@@ -1,25 +0,0 @@
1
- import { useStore } from "zustand";
2
- /** Derive a single sync status from store state. */
3
- export function deriveSyncStatus(state) {
4
- if (!state.online)
5
- return "offline";
6
- if (state.error)
7
- return "error";
8
- if (state.syncing)
9
- return "syncing";
10
- if (state.dirty)
11
- return "pending";
12
- return "synced";
13
- }
14
- /** Use the full Starfish store state and actions. */
15
- export function useStarfish(store) {
16
- return useStore(store);
17
- }
18
- /** Use only the synced data, with an optional selector for fine-grained subscriptions. */
19
- export function useStarfishData(store, selector) {
20
- return useStore(store, (state) => selector ? selector(state.data) : state.data);
21
- }
22
- /** Use the derived sync status (synced | syncing | pending | error | offline). */
23
- export function useSyncStatus(store) {
24
- return useStore(store, deriveSyncStatus);
25
- }