@drakkar.software/starfish-client 1.4.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  }
@@ -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
- onDataRef.current?.(data);
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; log if logger provided
226
- newStore.getState().pull().catch((err) => {
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
- lastReceivedData = event.data.data;
11
- store.setState({ data: event.data.data, dirty: event.data.dirty });
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: string;
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/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
- const stream = new Blob([bodyText]).stream().pipeThrough(new CompressionStream("gzip"));
119
- const compressed = await new Response(stream).arrayBuffer();
120
- const normalized = Object.fromEntries(new Headers(init.headers).entries());
121
- normalized["content-encoding"] = "gzip";
122
- return baseFetch(input, {
123
- ...init,
124
- body: compressed,
125
- headers: normalized,
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
- throw new Error(`Circuit breaker is open (${breaker.getState()}, cooldown ${breakerOptions?.cooldownMs ?? 30_000}ms)`);
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.ok) {
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/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
- this.snapshots = JSON.parse(raw);
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
- return JSON.parse(snapshot.data);
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/migrate.js CHANGED
@@ -25,7 +25,12 @@ export function createMigrator(config) {
25
25
  if (!fn) {
26
26
  throw new Error(`Missing migration for version ${v} -> ${v + 1}`);
27
27
  }
28
- result = fn(result);
28
+ try {
29
+ result = fn(result);
30
+ }
31
+ catch (err) {
32
+ throw new Error(`Migration from version ${v} to ${v + 1} failed: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
33
+ }
29
34
  }
30
35
  result._schemaVersion = config.currentVersion;
31
36
  return result;
package/dist/polling.js CHANGED
@@ -14,7 +14,7 @@ export function startPolling(pullFn, getState, intervalMs = 30_000) {
14
14
  const timer = setInterval(() => {
15
15
  const { online, syncing } = getState();
16
16
  if (online && !syncing)
17
- pullFn();
17
+ pullFn().catch(() => { });
18
18
  }, intervalMs);
19
19
  return () => clearInterval(timer);
20
20
  }
@@ -42,7 +42,7 @@ export function startAdaptivePolling(pullFn, getState, options) {
42
42
  return;
43
43
  const { online, syncing } = getState();
44
44
  if (online && !syncing)
45
- pullFn();
45
+ pullFn().catch(() => { });
46
46
  }, intervalMs);
47
47
  return {
48
48
  pause: () => { paused = true; },
package/dist/resolvers.js CHANGED
@@ -99,9 +99,9 @@ export function createSoftDeleteResolver(options) {
99
99
  const rec = item;
100
100
  const id = rec[idKey];
101
101
  const deletedAt = rec[deletedAtKey];
102
- if (typeof deletedAt === "number") {
103
- const existing = tombstones.get(id) ?? 0;
104
- if (deletedAt > existing)
102
+ if (typeof deletedAt === "number" || typeof deletedAt === "string") {
103
+ const existing = tombstones.get(id);
104
+ if (existing == null || compareTimestamps(deletedAt, existing))
105
105
  tombstones.set(id, deletedAt);
106
106
  }
107
107
  }
@@ -157,6 +157,10 @@ export function pruneTombstones(items, ttlMs = 30 * 24 * 60 * 60 * 1000, deleted
157
157
  const deletedAt = item[deletedAtKey];
158
158
  if (deletedAt == null)
159
159
  return true;
160
- return typeof deletedAt === "number" && deletedAt > cutoff;
160
+ if (typeof deletedAt === "number")
161
+ return deletedAt > cutoff;
162
+ if (typeof deletedAt === "string")
163
+ return new Date(deletedAt).getTime() > cutoff;
164
+ return false;
161
165
  });
162
166
  }
package/dist/sync.js CHANGED
@@ -52,6 +52,7 @@ export class SyncManager {
52
52
  }
53
53
  else if (this.lastCheckpoint > 0) {
54
54
  this.localData = deepMerge(this.localData, result.data);
55
+ result.data = this.localData;
55
56
  }
56
57
  else {
57
58
  this.localData = result.data;
@@ -62,7 +63,7 @@ export class SyncManager {
62
63
  return result;
63
64
  }
64
65
  catch (err) {
65
- this.logger?.pullError(this.loggerName, err.message);
66
+ this.logger?.pullError(this.loggerName, err instanceof Error ? err.message : String(err));
66
67
  throw err;
67
68
  }
68
69
  }
@@ -93,17 +94,24 @@ export class SyncManager {
93
94
  }
94
95
  catch (err) {
95
96
  if (!(err instanceof ConflictError) || attempt >= this.maxRetries) {
96
- this.logger?.pushError(this.loggerName, err.message);
97
+ this.logger?.pushError(this.loggerName, err instanceof Error ? err.message : String(err));
97
98
  throw err;
98
99
  }
99
100
  this.logger?.conflict(this.loggerName, attempt + 1);
100
- const remote = await this.client.pull(this.pullPath);
101
- this.lastHash = remote.hash;
102
- this.lastCheckpoint = remote.timestamp;
103
- const remoteData = this.encryptor
104
- ? await this.encryptor.decrypt(remote.data)
105
- : remote.data;
106
- pendingData = this.onConflict(pendingData, remoteData);
101
+ try {
102
+ const remote = await this.client.pull(this.pullPath);
103
+ const remoteData = this.encryptor
104
+ ? await this.encryptor.decrypt(remote.data)
105
+ : remote.data;
106
+ this.lastHash = remote.hash;
107
+ this.lastCheckpoint = remote.timestamp;
108
+ pendingData = this.onConflict(pendingData, remoteData);
109
+ }
110
+ catch (resolveErr) {
111
+ const msg = resolveErr instanceof Error ? resolveErr.message : String(resolveErr);
112
+ this.logger?.pushError(this.loggerName, `Conflict resolution failed (attempt ${attempt + 1}): ${msg}`);
113
+ throw resolveErr;
114
+ }
107
115
  await new Promise(resolve => setTimeout(resolve, Math.min(100 * Math.pow(2, attempt), 2000) + Math.random() * 100));
108
116
  attempt++;
109
117
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakkar.software/starfish-client",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
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.0"
63
+ "@drakkar.software/starfish-protocol": "1.4.1"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@legendapp/state": "^2.0.0",