@drakkar.software/starfish-client 1.4.0 → 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.
- package/dist/background-sync.d.ts +16 -0
- package/dist/background-sync.js +29 -0
- package/dist/bindings/legend.js +5 -5
- package/dist/bindings/suspense.d.ts +25 -0
- package/dist/bindings/suspense.js +49 -0
- package/dist/bindings/zustand.js +19 -11
- package/dist/broadcast.js +8 -3
- package/dist/client.d.ts +2 -1
- package/dist/client.js +1 -1
- package/dist/dedup.d.ts +6 -0
- package/dist/dedup.js +35 -0
- package/dist/export.d.ts +24 -0
- package/dist/export.js +115 -0
- package/dist/fetch.js +26 -16
- package/dist/hash.d.ts +10 -0
- package/dist/hash.js +34 -0
- package/dist/history.d.ts +1 -1
- package/dist/history.js +12 -4
- package/dist/index.d.ts +14 -3
- package/dist/index.js +8 -2
- package/dist/logger.d.ts +26 -2
- package/dist/logger.js +62 -2
- package/dist/migrate.js +6 -1
- package/dist/platform.d.ts +52 -0
- package/dist/platform.js +62 -0
- package/dist/polling.js +2 -2
- package/dist/resolvers.d.ts +19 -0
- package/dist/resolvers.js +65 -4
- package/dist/service-worker.d.ts +18 -0
- package/dist/service-worker.js +55 -0
- package/dist/storage/indexeddb.d.ts +17 -0
- package/dist/storage/indexeddb.js +59 -0
- package/dist/sync.js +17 -9
- package/package.json +2 -2
- package/dist/bindings/broadcast.d.ts +0 -19
- package/dist/bindings/broadcast.js +0 -65
- package/dist/bindings/react.d.ts +0 -12
- package/dist/bindings/react.js +0 -25
|
@@ -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
|
+
}
|
package/dist/bindings/legend.js
CHANGED
|
@@ -18,7 +18,7 @@ export function createStarfishObservable(options) {
|
|
|
18
18
|
state.dirty.set(false);
|
|
19
19
|
}
|
|
20
20
|
catch (err) {
|
|
21
|
-
state.error.set(err.message);
|
|
21
|
+
state.error.set(err instanceof Error ? err.message : String(err));
|
|
22
22
|
}
|
|
23
23
|
finally {
|
|
24
24
|
state.syncing.set(false);
|
|
@@ -32,7 +32,7 @@ export function createStarfishObservable(options) {
|
|
|
32
32
|
state.data.set(options.syncManager.getData());
|
|
33
33
|
}
|
|
34
34
|
catch (err) {
|
|
35
|
-
state.error.set(err.message);
|
|
35
|
+
state.error.set(err instanceof Error ? err.message : String(err));
|
|
36
36
|
}
|
|
37
37
|
finally {
|
|
38
38
|
state.syncing.set(false);
|
|
@@ -48,16 +48,16 @@ export function createStarfishObservable(options) {
|
|
|
48
48
|
state.dirty.set(true);
|
|
49
49
|
state.error.set(null);
|
|
50
50
|
if (state.online.get())
|
|
51
|
-
flush();
|
|
51
|
+
flush().catch(() => { });
|
|
52
52
|
}
|
|
53
53
|
catch (err) {
|
|
54
|
-
state.error.set(err.message);
|
|
54
|
+
state.error.set(err instanceof Error ? err.message : String(err));
|
|
55
55
|
}
|
|
56
56
|
};
|
|
57
57
|
const setOnline = (online) => {
|
|
58
58
|
state.online.set(online);
|
|
59
59
|
if (online && state.dirty.get())
|
|
60
|
-
flush();
|
|
60
|
+
flush().catch(() => { });
|
|
61
61
|
};
|
|
62
62
|
return { state, pull, set, flush, setOnline };
|
|
63
63
|
}
|
|
@@ -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
|
+
}
|
package/dist/bindings/zustand.js
CHANGED
|
@@ -22,7 +22,7 @@ export function createStarfishStore(options) {
|
|
|
22
22
|
set({ data: syncManager.getData(), syncing: false }, false, "pull/success");
|
|
23
23
|
}
|
|
24
24
|
catch (err) {
|
|
25
|
-
set({ syncing: false, error: err.message }, false, "pull/error");
|
|
25
|
+
set({ syncing: false, error: err instanceof Error ? err.message : String(err) }, false, "pull/error");
|
|
26
26
|
}
|
|
27
27
|
},
|
|
28
28
|
set: (modifier) => {
|
|
@@ -32,10 +32,10 @@ export function createStarfishStore(options) {
|
|
|
32
32
|
: modifier(get().data);
|
|
33
33
|
set({ data: next, dirty: true, error: null }, false, "set");
|
|
34
34
|
if (get().online)
|
|
35
|
-
get().flush();
|
|
35
|
+
get().flush().catch(() => { });
|
|
36
36
|
}
|
|
37
37
|
catch (err) {
|
|
38
|
-
set({ error: err.message }, false, "set/error");
|
|
38
|
+
set({ error: err instanceof Error ? err.message : String(err) }, false, "set/error");
|
|
39
39
|
}
|
|
40
40
|
},
|
|
41
41
|
restore: (data) => {
|
|
@@ -50,13 +50,13 @@ export function createStarfishStore(options) {
|
|
|
50
50
|
set({ data: syncManager.getData(), syncing: false, dirty: false }, false, "flush/success");
|
|
51
51
|
}
|
|
52
52
|
catch (err) {
|
|
53
|
-
set({ syncing: false, error: err.message }, false, "flush/error");
|
|
53
|
+
set({ syncing: false, error: err instanceof Error ? err.message : String(err) }, false, "flush/error");
|
|
54
54
|
}
|
|
55
55
|
},
|
|
56
56
|
setOnline: (online) => {
|
|
57
57
|
set({ online }, false, "setOnline");
|
|
58
58
|
if (online && get().dirty)
|
|
59
|
-
get().flush();
|
|
59
|
+
get().flush().catch(() => { });
|
|
60
60
|
},
|
|
61
61
|
};
|
|
62
62
|
};
|
|
@@ -217,15 +217,23 @@ export function useSyncInit(config) {
|
|
|
217
217
|
// Only call onData when data changes and store is not dirty
|
|
218
218
|
// (dirty = false means data came from a pull, not a local set)
|
|
219
219
|
if (data !== lastDataRef && !state.dirty) {
|
|
220
|
-
|
|
220
|
+
lastDataRef = data;
|
|
221
|
+
try {
|
|
222
|
+
onDataRef.current?.(data);
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
newStore.setState({
|
|
226
|
+
error: `onData failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
lastDataRef = data;
|
|
221
232
|
}
|
|
222
|
-
lastDataRef = data;
|
|
223
233
|
});
|
|
224
234
|
setStore(newStore);
|
|
225
|
-
// Initial pull — errors are stored in state.error
|
|
226
|
-
newStore.getState().pull().catch((
|
|
227
|
-
config.logger?.pullError(config.storeName ?? "sync", err.message);
|
|
228
|
-
});
|
|
235
|
+
// Initial pull — errors are stored in state.error by the pull() action
|
|
236
|
+
newStore.getState().pull().catch(() => { });
|
|
229
237
|
return () => {
|
|
230
238
|
unsub();
|
|
231
239
|
setStore(null);
|
package/dist/broadcast.js
CHANGED
|
@@ -7,8 +7,11 @@ export function setupBroadcastSync(store, name) {
|
|
|
7
7
|
const channel = new BroadcastChannel(`starfish-${name}`);
|
|
8
8
|
let lastReceivedData = null;
|
|
9
9
|
channel.onmessage = (event) => {
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
const payload = event.data;
|
|
11
|
+
if (!payload || typeof payload !== "object" || !payload.data || typeof payload.data !== "object")
|
|
12
|
+
return;
|
|
13
|
+
lastReceivedData = payload.data;
|
|
14
|
+
store.setState({ data: payload.data, dirty: !!payload.dirty });
|
|
12
15
|
};
|
|
13
16
|
const unsub = store.subscribe((state, prev) => {
|
|
14
17
|
if (state.data === lastReceivedData)
|
|
@@ -43,8 +46,10 @@ export function setupStorageFallback(store, name) {
|
|
|
43
46
|
catch {
|
|
44
47
|
return;
|
|
45
48
|
}
|
|
49
|
+
if (!payload || typeof payload !== "object" || !payload.data || typeof payload.data !== "object")
|
|
50
|
+
return;
|
|
46
51
|
lastReceivedData = payload.data;
|
|
47
|
-
store.setState({ data: payload.data, dirty: payload.dirty });
|
|
52
|
+
store.setState({ data: payload.data, dirty: !!payload.dirty });
|
|
48
53
|
};
|
|
49
54
|
globalThis.addEventListener("storage", onStorage);
|
|
50
55
|
const unsub = store.subscribe((state, prev) => {
|
package/dist/client.d.ts
CHANGED
|
@@ -3,7 +3,8 @@ import type { StarfishClientOptions } from "./types.js";
|
|
|
3
3
|
/** Result of pulling a binary blob from the server. */
|
|
4
4
|
export interface BlobPullResult {
|
|
5
5
|
data: ArrayBuffer;
|
|
6
|
-
hash
|
|
6
|
+
/** Content hash from the ETag header. Null if the server didn't include an ETag. */
|
|
7
|
+
hash: string | null;
|
|
7
8
|
contentType: string;
|
|
8
9
|
}
|
|
9
10
|
/** Result of pushing a binary blob to the server. */
|
package/dist/client.js
CHANGED
|
@@ -82,7 +82,7 @@ export class StarfishClient {
|
|
|
82
82
|
if (!res.ok) {
|
|
83
83
|
throw new StarfishHttpError(res.status, await res.text());
|
|
84
84
|
}
|
|
85
|
-
const etag = res.headers.get("ETag")?.replace(/"/g, "") ??
|
|
85
|
+
const etag = res.headers.get("ETag")?.replace(/"/g, "") ?? null;
|
|
86
86
|
const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
|
|
87
87
|
const data = await res.arrayBuffer();
|
|
88
88
|
return { data, hash: etag, contentType };
|
package/dist/dedup.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/export.d.ts
ADDED
|
@@ -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/fetch.js
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
export function classifyError(err) {
|
|
3
3
|
if (err instanceof Response || (err && typeof err === "object" && "status" in err)) {
|
|
4
4
|
const status = err.status;
|
|
5
|
+
if (typeof status !== "number" || isNaN(status))
|
|
6
|
+
return "unknown";
|
|
7
|
+
if (status === 0)
|
|
8
|
+
return "network";
|
|
5
9
|
if (status === 401 || status === 403)
|
|
6
10
|
return "auth";
|
|
7
11
|
if (status === 409)
|
|
@@ -35,11 +39,11 @@ export function createRetryFetch(options) {
|
|
|
35
39
|
const category = classifyError(res);
|
|
36
40
|
if (category !== "rate-limited" && category !== "server")
|
|
37
41
|
return res;
|
|
38
|
-
const retryAfter = res.headers.get("Retry-After");
|
|
42
|
+
const retryAfter = res.headers.get("Retry-After")?.trim();
|
|
39
43
|
let delay;
|
|
40
44
|
if (retryAfter) {
|
|
41
45
|
const seconds = Number(retryAfter);
|
|
42
|
-
if (!isNaN(seconds)) {
|
|
46
|
+
if (retryAfter !== "" && !isNaN(seconds)) {
|
|
43
47
|
delay = Math.min(seconds * 1000, maxDelay);
|
|
44
48
|
}
|
|
45
49
|
else {
|
|
@@ -115,15 +119,20 @@ export function createCompressedFetch(inner) {
|
|
|
115
119
|
const bodyText = typeof init.body === "string" ? init.body : null;
|
|
116
120
|
if (!bodyText)
|
|
117
121
|
return baseFetch(input, init);
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
122
|
+
try {
|
|
123
|
+
const stream = new Blob([bodyText]).stream().pipeThrough(new CompressionStream("gzip"));
|
|
124
|
+
const compressed = await new Response(stream).arrayBuffer();
|
|
125
|
+
const normalized = Object.fromEntries(new Headers(init.headers).entries());
|
|
126
|
+
normalized["content-encoding"] = "gzip";
|
|
127
|
+
return baseFetch(input, {
|
|
128
|
+
...init,
|
|
129
|
+
body: compressed,
|
|
130
|
+
headers: normalized,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return baseFetch(input, init);
|
|
135
|
+
}
|
|
127
136
|
};
|
|
128
137
|
}
|
|
129
138
|
/**
|
|
@@ -135,16 +144,17 @@ export function createResilientFetch(retryOptions, breakerOptions) {
|
|
|
135
144
|
const retryFetch = createRetryFetch(retryOptions);
|
|
136
145
|
const resilientFetch = async (input, init) => {
|
|
137
146
|
if (breaker.isOpen()) {
|
|
138
|
-
|
|
147
|
+
const cooldown = Math.ceil((breakerOptions?.cooldownMs ?? 30_000) / 1000);
|
|
148
|
+
throw new Error(`Request blocked: too many consecutive failures. Retry in ${cooldown}s.`);
|
|
139
149
|
}
|
|
140
150
|
try {
|
|
141
151
|
const res = await retryFetch(input, init);
|
|
142
|
-
if (res.
|
|
143
|
-
breaker.recordSuccess();
|
|
144
|
-
}
|
|
145
|
-
else if (res.status >= 500) {
|
|
152
|
+
if (res.status >= 500) {
|
|
146
153
|
breaker.recordFailure();
|
|
147
154
|
}
|
|
155
|
+
else {
|
|
156
|
+
breaker.recordSuccess();
|
|
157
|
+
}
|
|
148
158
|
return res;
|
|
149
159
|
}
|
|
150
160
|
catch (err) {
|
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/history.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ export declare class SnapshotHistory {
|
|
|
16
16
|
constructor(options?: SnapshotHistoryOptions);
|
|
17
17
|
/** Take a labeled snapshot of the given data. */
|
|
18
18
|
take(label: string, data: Record<string, unknown>): void;
|
|
19
|
-
/** Restore data from a snapshot at the given index. Returns undefined if index is invalid. */
|
|
19
|
+
/** Restore data from a snapshot at the given index. Returns undefined if index is invalid or data is corrupt. */
|
|
20
20
|
restore(index: number): Record<string, unknown> | undefined;
|
|
21
21
|
/** List available snapshots (metadata only, no data payload). */
|
|
22
22
|
list(): Array<{
|
package/dist/history.js
CHANGED
|
@@ -8,8 +8,11 @@ export class SnapshotHistory {
|
|
|
8
8
|
if (this.storageKey) {
|
|
9
9
|
try {
|
|
10
10
|
const raw = localStorage.getItem(this.storageKey);
|
|
11
|
-
if (raw)
|
|
12
|
-
|
|
11
|
+
if (raw) {
|
|
12
|
+
const parsed = JSON.parse(raw);
|
|
13
|
+
if (Array.isArray(parsed))
|
|
14
|
+
this.snapshots = parsed;
|
|
15
|
+
}
|
|
13
16
|
}
|
|
14
17
|
catch { /* corrupted or unavailable — start fresh */ }
|
|
15
18
|
}
|
|
@@ -26,12 +29,17 @@ export class SnapshotHistory {
|
|
|
26
29
|
}
|
|
27
30
|
this.persist();
|
|
28
31
|
}
|
|
29
|
-
/** Restore data from a snapshot at the given index. Returns undefined if index is invalid. */
|
|
32
|
+
/** Restore data from a snapshot at the given index. Returns undefined if index is invalid or data is corrupt. */
|
|
30
33
|
restore(index) {
|
|
31
34
|
const snapshot = this.snapshots[index];
|
|
32
35
|
if (!snapshot)
|
|
33
36
|
return undefined;
|
|
34
|
-
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(snapshot.data);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
35
43
|
}
|
|
36
44
|
/** List available snapshots (metadata only, no data payload). */
|
|
37
45
|
list() {
|
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";
|