@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.
- package/dist/bindings/legend.js +5 -5
- 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/fetch.js +26 -16
- package/dist/history.d.ts +1 -1
- package/dist/history.js +12 -4
- package/dist/migrate.js +6 -1
- package/dist/polling.js +2 -2
- package/dist/resolvers.js +8 -4
- package/dist/sync.js +17 -9
- package/package.json +2 -2
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
|
}
|
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/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/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/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
|
-
|
|
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)
|
|
104
|
-
if (deletedAt
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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.
|
|
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.
|
|
63
|
+
"@drakkar.software/starfish-protocol": "1.4.1"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
66
|
"@legendapp/state": "^2.0.0",
|