@drakkar.software/starfish-client 1.3.2 → 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/broadcast.d.ts +19 -0
- package/dist/bindings/broadcast.js +65 -0
- package/dist/bindings/legend.js +5 -5
- package/dist/bindings/react.d.ts +12 -0
- package/dist/bindings/react.js +25 -0
- package/dist/bindings/zustand.d.ts +53 -1
- package/dist/bindings/zustand.js +184 -5
- package/dist/broadcast.d.ts +36 -0
- package/dist/broadcast.js +83 -0
- package/dist/client.d.ts +21 -0
- package/dist/client.js +42 -0
- package/dist/fetch.d.ts +53 -0
- package/dist/fetch.js +166 -0
- package/dist/history.d.ts +29 -0
- package/dist/history.js +61 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +7 -0
- package/dist/logger.d.ts +14 -0
- package/dist/logger.js +20 -0
- package/dist/migrate.d.ts +16 -0
- package/dist/migrate.js +38 -0
- package/dist/polling.d.ts +28 -0
- package/dist/polling.js +52 -0
- package/dist/resolvers.d.ts +50 -0
- package/dist/resolvers.js +166 -0
- package/dist/sync.d.ts +11 -0
- package/dist/sync.js +54 -20
- package/dist/testing.d.ts +47 -0
- package/dist/testing.js +86 -0
- package/dist/validate.d.ts +27 -0
- package/dist/validate.js +28 -0
- package/package.json +26 -4
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/** Compare two timestamp values. Handles both numeric (epoch) and string (ISO-8601) timestamps. */
|
|
2
|
+
function compareTimestamps(a, b) {
|
|
3
|
+
if (typeof a === "number" && typeof b === "number")
|
|
4
|
+
return a >= b;
|
|
5
|
+
return String(a ?? "") >= String(b ?? "");
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Creates a conflict resolver that merges arrays by ID with per-item
|
|
9
|
+
* timestamp comparison, and uses document-level timestamp for scalars.
|
|
10
|
+
*
|
|
11
|
+
* For arrays: builds a union of both sets keyed by `idKey`. When both
|
|
12
|
+
* sides have the same item, the one with the newer `timestampKey` wins.
|
|
13
|
+
* For scalars: the document with the newer `documentTimestampKey` wins.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* const merge = createUnionMerge()
|
|
18
|
+
* const sync = new SyncManager({ ..., onConflict: merge })
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export function createUnionMerge(options) {
|
|
22
|
+
const idKey = options?.idKey ?? "id";
|
|
23
|
+
const tsKey = options?.timestampKey ?? "updatedAt";
|
|
24
|
+
const docTsKey = options?.documentTimestampKey ?? "timestamp";
|
|
25
|
+
return (local, remote) => {
|
|
26
|
+
const result = {};
|
|
27
|
+
const localNewer = compareTimestamps(local[docTsKey], remote[docTsKey]);
|
|
28
|
+
const allKeys = new Set([...Object.keys(local), ...Object.keys(remote)]);
|
|
29
|
+
for (const key of allKeys) {
|
|
30
|
+
const lv = local[key];
|
|
31
|
+
const rv = remote[key];
|
|
32
|
+
// Both sides have arrays — attempt ID-based union
|
|
33
|
+
if (Array.isArray(lv) && Array.isArray(rv)) {
|
|
34
|
+
const map = new Map();
|
|
35
|
+
// Seed with remote items
|
|
36
|
+
for (const item of rv) {
|
|
37
|
+
if (item && typeof item === "object" && idKey in item) {
|
|
38
|
+
map.set(item[idKey], item);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
map.set(Symbol(), item);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Overlay local items (per-item timestamp wins)
|
|
45
|
+
for (const item of lv) {
|
|
46
|
+
if (item && typeof item === "object" && idKey in item) {
|
|
47
|
+
const localItem = item;
|
|
48
|
+
const id = localItem[idKey];
|
|
49
|
+
const remoteItem = map.get(id);
|
|
50
|
+
if (!remoteItem) {
|
|
51
|
+
map.set(id, localItem);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
if (compareTimestamps(localItem[tsKey], remoteItem[tsKey])) {
|
|
55
|
+
map.set(id, localItem);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
map.set(Symbol(), item);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
result[key] = [...map.values()];
|
|
64
|
+
}
|
|
65
|
+
else if (lv !== undefined && rv !== undefined) {
|
|
66
|
+
// Scalar: document-level timestamp wins
|
|
67
|
+
result[key] = localNewer ? lv : rv;
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
// Only one side has the key
|
|
71
|
+
result[key] = lv ?? rv;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Creates a conflict resolver that handles soft-deleted items (tombstones).
|
|
79
|
+
* Extends union merge with tombstone awareness: if an item exists on one side
|
|
80
|
+
* with a `deletedAtKey` set, that deletion is respected even if the other side
|
|
81
|
+
* still has the item alive — as long as the deletion timestamp is newer.
|
|
82
|
+
*/
|
|
83
|
+
export function createSoftDeleteResolver(options) {
|
|
84
|
+
const idKey = options?.idKey ?? "id";
|
|
85
|
+
const tsKey = options?.timestampKey ?? "updatedAt";
|
|
86
|
+
const deletedAtKey = options?.deletedAtKey ?? "_deletedAt";
|
|
87
|
+
const baseMerge = createUnionMerge(options);
|
|
88
|
+
return (local, remote) => {
|
|
89
|
+
const merged = baseMerge(local, remote);
|
|
90
|
+
// Build a tombstone map from both sides: id → deletedAt timestamp
|
|
91
|
+
const tombstones = new Map();
|
|
92
|
+
for (const source of [local, remote]) {
|
|
93
|
+
for (const key of Object.keys(source)) {
|
|
94
|
+
const arr = source[key];
|
|
95
|
+
if (!Array.isArray(arr))
|
|
96
|
+
continue;
|
|
97
|
+
for (const item of arr) {
|
|
98
|
+
if (item && typeof item === "object" && idKey in item && deletedAtKey in item) {
|
|
99
|
+
const rec = item;
|
|
100
|
+
const id = rec[idKey];
|
|
101
|
+
const deletedAt = rec[deletedAtKey];
|
|
102
|
+
if (typeof deletedAt === "number" || typeof deletedAt === "string") {
|
|
103
|
+
const existing = tombstones.get(id);
|
|
104
|
+
if (existing == null || compareTimestamps(deletedAt, existing))
|
|
105
|
+
tombstones.set(id, deletedAt);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// For merged arrays, ensure tombstoned items stay deleted
|
|
112
|
+
// (don't resurrect an item if its tombstone is newer than its updatedAt)
|
|
113
|
+
for (const key of Object.keys(merged)) {
|
|
114
|
+
const value = merged[key];
|
|
115
|
+
if (!Array.isArray(value))
|
|
116
|
+
continue;
|
|
117
|
+
merged[key] = value.filter((item) => {
|
|
118
|
+
if (!item || typeof item !== "object" || !(idKey in item))
|
|
119
|
+
return true;
|
|
120
|
+
const rec = item;
|
|
121
|
+
const id = rec[idKey];
|
|
122
|
+
const deletedAt = tombstones.get(id);
|
|
123
|
+
if (deletedAt == null)
|
|
124
|
+
return true;
|
|
125
|
+
// Keep the item if it has a deletedAt (it's the tombstone itself)
|
|
126
|
+
if (rec[deletedAtKey] != null)
|
|
127
|
+
return true;
|
|
128
|
+
// Filter out alive items that have a newer tombstone
|
|
129
|
+
return compareTimestamps(rec[tsKey], deletedAt) && rec[tsKey] !== deletedAt;
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return merged;
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Simple resolver: the document with the newer timestamp wins entirely.
|
|
137
|
+
* No per-field or per-item merging.
|
|
138
|
+
*/
|
|
139
|
+
export function timestampWinner(timestampKey = "timestamp") {
|
|
140
|
+
return (local, remote) => {
|
|
141
|
+
return compareTimestamps(local[timestampKey], remote[timestampKey])
|
|
142
|
+
? local
|
|
143
|
+
: remote;
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Remove expired tombstones from an array of items.
|
|
148
|
+
* Items with a `deletedAtKey` older than `ttlMs` are pruned.
|
|
149
|
+
*
|
|
150
|
+
* @param items - Array of items, some with a deletedAt timestamp
|
|
151
|
+
* @param ttlMs - Time-to-live in ms for tombstones (default: 30 days)
|
|
152
|
+
* @param deletedAtKey - Key marking deletion timestamp (default: "_deletedAt")
|
|
153
|
+
*/
|
|
154
|
+
export function pruneTombstones(items, ttlMs = 30 * 24 * 60 * 60 * 1000, deletedAtKey = "_deletedAt") {
|
|
155
|
+
const cutoff = Date.now() - ttlMs;
|
|
156
|
+
return items.filter((item) => {
|
|
157
|
+
const deletedAt = item[deletedAtKey];
|
|
158
|
+
if (deletedAt == null)
|
|
159
|
+
return true;
|
|
160
|
+
if (typeof deletedAt === "number")
|
|
161
|
+
return deletedAt > cutoff;
|
|
162
|
+
if (typeof deletedAt === "string")
|
|
163
|
+
return new Date(deletedAt).getTime() > cutoff;
|
|
164
|
+
return false;
|
|
165
|
+
});
|
|
166
|
+
}
|
package/dist/sync.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { PullResult } from "@drakkar.software/starfish-protocol";
|
|
2
2
|
import type { ConflictResolver } from "./types.js";
|
|
3
3
|
import { StarfishClient } from "./client.js";
|
|
4
|
+
import type { SyncLogger } from "./logger.js";
|
|
5
|
+
import type { Validator } from "./validate.js";
|
|
4
6
|
export interface SyncManagerOptions {
|
|
5
7
|
client: StarfishClient;
|
|
6
8
|
pullPath: string;
|
|
@@ -13,6 +15,12 @@ export interface SyncManagerOptions {
|
|
|
13
15
|
encryptionSalt?: string;
|
|
14
16
|
encryptionInfo?: string;
|
|
15
17
|
signData?: (data: string) => Promise<string>;
|
|
18
|
+
/** Structured logger for sync events. */
|
|
19
|
+
logger?: SyncLogger;
|
|
20
|
+
/** Name passed to logger methods (default: derived from pullPath). */
|
|
21
|
+
loggerName?: string;
|
|
22
|
+
/** Validate data before push. Throws ValidationError on failure. */
|
|
23
|
+
validate?: Validator;
|
|
16
24
|
}
|
|
17
25
|
export declare class SyncManager {
|
|
18
26
|
private readonly client;
|
|
@@ -22,6 +30,9 @@ export declare class SyncManager {
|
|
|
22
30
|
private readonly maxRetries;
|
|
23
31
|
private readonly encryptor;
|
|
24
32
|
private readonly signData?;
|
|
33
|
+
private readonly logger?;
|
|
34
|
+
private readonly loggerName;
|
|
35
|
+
private readonly validate?;
|
|
25
36
|
private lastHash;
|
|
26
37
|
private lastCheckpoint;
|
|
27
38
|
private localData;
|
package/dist/sync.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { deepMerge, stableStringify } from "@drakkar.software/starfish-protocol";
|
|
2
2
|
import { ConflictError } from "./types.js";
|
|
3
3
|
import { createEncryptor } from "./crypto.js";
|
|
4
|
+
import { ValidationError } from "./validate.js";
|
|
4
5
|
export class SyncManager {
|
|
5
6
|
client;
|
|
6
7
|
pullPath;
|
|
@@ -9,6 +10,9 @@ export class SyncManager {
|
|
|
9
10
|
maxRetries;
|
|
10
11
|
encryptor;
|
|
11
12
|
signData;
|
|
13
|
+
logger;
|
|
14
|
+
loggerName;
|
|
15
|
+
validate;
|
|
12
16
|
lastHash = null;
|
|
13
17
|
lastCheckpoint = 0;
|
|
14
18
|
localData = {};
|
|
@@ -19,6 +23,9 @@ export class SyncManager {
|
|
|
19
23
|
this.onConflict = options.onConflict ?? deepMerge;
|
|
20
24
|
this.maxRetries = options.maxRetries ?? 3;
|
|
21
25
|
this.signData = options.signData;
|
|
26
|
+
this.logger = options.logger;
|
|
27
|
+
this.loggerName = options.loggerName ?? options.pullPath.split("/").filter(Boolean).pop() ?? options.pullPath;
|
|
28
|
+
this.validate = options.validate;
|
|
22
29
|
this.encryptor =
|
|
23
30
|
options.encryptionSecret && options.encryptionSalt
|
|
24
31
|
? createEncryptor(options.encryptionSecret, options.encryptionSalt, options.encryptionInfo)
|
|
@@ -34,23 +41,40 @@ export class SyncManager {
|
|
|
34
41
|
return this.lastCheckpoint;
|
|
35
42
|
}
|
|
36
43
|
async pull() {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
this.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
this.logger?.pullStart(this.loggerName);
|
|
45
|
+
const start = performance.now();
|
|
46
|
+
try {
|
|
47
|
+
const result = await this.client.pull(this.pullPath, this.lastCheckpoint);
|
|
48
|
+
if (this.encryptor) {
|
|
49
|
+
const decrypted = await this.encryptor.decrypt(result.data);
|
|
50
|
+
this.localData = decrypted;
|
|
51
|
+
result.data = decrypted;
|
|
52
|
+
}
|
|
53
|
+
else if (this.lastCheckpoint > 0) {
|
|
54
|
+
this.localData = deepMerge(this.localData, result.data);
|
|
55
|
+
result.data = this.localData;
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
this.localData = result.data;
|
|
59
|
+
}
|
|
60
|
+
this.lastHash = result.hash;
|
|
61
|
+
this.lastCheckpoint = result.timestamp;
|
|
62
|
+
this.logger?.pullSuccess(this.loggerName, Math.round(performance.now() - start));
|
|
63
|
+
return result;
|
|
45
64
|
}
|
|
46
|
-
|
|
47
|
-
this.
|
|
65
|
+
catch (err) {
|
|
66
|
+
this.logger?.pullError(this.loggerName, err instanceof Error ? err.message : String(err));
|
|
67
|
+
throw err;
|
|
48
68
|
}
|
|
49
|
-
this.lastHash = result.hash;
|
|
50
|
-
this.lastCheckpoint = result.timestamp;
|
|
51
|
-
return result;
|
|
52
69
|
}
|
|
53
70
|
async push(data) {
|
|
71
|
+
if (this.validate) {
|
|
72
|
+
const result = this.validate(data);
|
|
73
|
+
if (result !== true)
|
|
74
|
+
throw new ValidationError(result);
|
|
75
|
+
}
|
|
76
|
+
this.logger?.pushStart(this.loggerName);
|
|
77
|
+
const start = performance.now();
|
|
54
78
|
let attempt = 0;
|
|
55
79
|
let pendingData = data;
|
|
56
80
|
while (attempt <= this.maxRetries) {
|
|
@@ -65,19 +89,29 @@ export class SyncManager {
|
|
|
65
89
|
this.lastHash = result.hash;
|
|
66
90
|
this.lastCheckpoint = result.timestamp;
|
|
67
91
|
this.localData = pendingData;
|
|
92
|
+
this.logger?.pushSuccess(this.loggerName, Math.round(performance.now() - start));
|
|
68
93
|
return result;
|
|
69
94
|
}
|
|
70
95
|
catch (err) {
|
|
71
96
|
if (!(err instanceof ConflictError) || attempt >= this.maxRetries) {
|
|
97
|
+
this.logger?.pushError(this.loggerName, err instanceof Error ? err.message : String(err));
|
|
72
98
|
throw err;
|
|
73
99
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
100
|
+
this.logger?.conflict(this.loggerName, attempt + 1);
|
|
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
|
+
}
|
|
81
115
|
await new Promise(resolve => setTimeout(resolve, Math.min(100 * Math.pow(2, attempt), 2000) + Math.random() * 100));
|
|
82
116
|
attempt++;
|
|
83
117
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { PullResult, PushSuccess } from "@drakkar.software/starfish-protocol";
|
|
2
|
+
import { StarfishClient } from "./client.js";
|
|
3
|
+
type PullFn = (path: string, checkpoint?: number) => Promise<PullResult>;
|
|
4
|
+
type PushFn = (path: string, data: Record<string, unknown>, baseHash: string | null, sig?: string) => Promise<PushSuccess>;
|
|
5
|
+
/**
|
|
6
|
+
* Creates a mock StarfishClient for testing.
|
|
7
|
+
* Override individual methods or use the defaults (returns static data).
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* const client = createMockClient({
|
|
12
|
+
* pull: async () => ({ data: { key: "value" }, hash: "h1", timestamp: 100 }),
|
|
13
|
+
* })
|
|
14
|
+
* const sync = new SyncManager({ client, pullPath: "/pull/test", pushPath: "/push/test" })
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export declare function createMockClient(overrides?: {
|
|
18
|
+
pull?: PullFn;
|
|
19
|
+
push?: PushFn;
|
|
20
|
+
}): StarfishClient & {
|
|
21
|
+
pullCalls: Array<{
|
|
22
|
+
path: string;
|
|
23
|
+
checkpoint?: number;
|
|
24
|
+
}>;
|
|
25
|
+
pushCalls: Array<{
|
|
26
|
+
path: string;
|
|
27
|
+
data: Record<string, unknown>;
|
|
28
|
+
baseHash: string | null;
|
|
29
|
+
}>;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Creates a mock fetch that returns predefined responses.
|
|
33
|
+
* Useful for testing StarfishClient directly.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```ts
|
|
37
|
+
* const fetch = createMockFetch({ data: { key: "value" }, hash: "h1", timestamp: 100 })
|
|
38
|
+
* const client = new StarfishClient({ baseUrl: "https://example.com", fetch })
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export declare function createMockFetch(pullResponse?: PullResult, pushResponse?: PushSuccess): typeof globalThis.fetch;
|
|
42
|
+
/**
|
|
43
|
+
* Creates a mock fetch that simulates a conflict (409) on the first push,
|
|
44
|
+
* then succeeds on retry. Useful for testing conflict resolution.
|
|
45
|
+
*/
|
|
46
|
+
export declare function createConflictFetch(conflictPullResponse: PullResult, successPushResponse?: PushSuccess): typeof globalThis.fetch;
|
|
47
|
+
export {};
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates a mock StarfishClient for testing.
|
|
3
|
+
* Override individual methods or use the defaults (returns static data).
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* ```ts
|
|
7
|
+
* const client = createMockClient({
|
|
8
|
+
* pull: async () => ({ data: { key: "value" }, hash: "h1", timestamp: 100 }),
|
|
9
|
+
* })
|
|
10
|
+
* const sync = new SyncManager({ client, pullPath: "/pull/test", pushPath: "/push/test" })
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
export function createMockClient(overrides) {
|
|
14
|
+
const pullCalls = [];
|
|
15
|
+
const pushCalls = [];
|
|
16
|
+
const pull = overrides?.pull ?? (async () => ({
|
|
17
|
+
data: {},
|
|
18
|
+
hash: "mock-hash",
|
|
19
|
+
timestamp: Date.now(),
|
|
20
|
+
}));
|
|
21
|
+
const push = overrides?.push ?? (async () => ({
|
|
22
|
+
hash: "mock-push-hash",
|
|
23
|
+
timestamp: Date.now(),
|
|
24
|
+
}));
|
|
25
|
+
return {
|
|
26
|
+
pull: async (path, checkpoint) => {
|
|
27
|
+
pullCalls.push({ path, checkpoint });
|
|
28
|
+
return pull(path, checkpoint);
|
|
29
|
+
},
|
|
30
|
+
push: async (path, data, baseHash, sig) => {
|
|
31
|
+
pushCalls.push({ path, data, baseHash });
|
|
32
|
+
return push(path, data, baseHash, sig);
|
|
33
|
+
},
|
|
34
|
+
pullCalls,
|
|
35
|
+
pushCalls,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Creates a mock fetch that returns predefined responses.
|
|
40
|
+
* Useful for testing StarfishClient directly.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```ts
|
|
44
|
+
* const fetch = createMockFetch({ data: { key: "value" }, hash: "h1", timestamp: 100 })
|
|
45
|
+
* const client = new StarfishClient({ baseUrl: "https://example.com", fetch })
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function createMockFetch(pullResponse, pushResponse) {
|
|
49
|
+
return async (input) => {
|
|
50
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
51
|
+
if (url.includes("/pull/")) {
|
|
52
|
+
return new Response(JSON.stringify(pullResponse ?? { data: {}, hash: "h", timestamp: 1 }), {
|
|
53
|
+
status: 200,
|
|
54
|
+
headers: { "Content-Type": "application/json" },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return new Response(JSON.stringify(pushResponse ?? { hash: "h", timestamp: 1 }), {
|
|
58
|
+
status: 200,
|
|
59
|
+
headers: { "Content-Type": "application/json" },
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Creates a mock fetch that simulates a conflict (409) on the first push,
|
|
65
|
+
* then succeeds on retry. Useful for testing conflict resolution.
|
|
66
|
+
*/
|
|
67
|
+
export function createConflictFetch(conflictPullResponse, successPushResponse) {
|
|
68
|
+
let pushCount = 0;
|
|
69
|
+
return async (input, init) => {
|
|
70
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
71
|
+
if (url.includes("/pull/")) {
|
|
72
|
+
return new Response(JSON.stringify(conflictPullResponse), {
|
|
73
|
+
status: 200,
|
|
74
|
+
headers: { "Content-Type": "application/json" },
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
pushCount++;
|
|
78
|
+
if (pushCount === 1) {
|
|
79
|
+
return new Response(JSON.stringify({ error: "hash_mismatch" }), { status: 409 });
|
|
80
|
+
}
|
|
81
|
+
return new Response(JSON.stringify(successPushResponse ?? { hash: "resolved", timestamp: Date.now() }), {
|
|
82
|
+
status: 200,
|
|
83
|
+
headers: { "Content-Type": "application/json" },
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Validation result: true if valid, or an array of error messages. */
|
|
2
|
+
export type ValidationResult = true | string[];
|
|
3
|
+
/** A function that validates data before push. */
|
|
4
|
+
export type Validator = (data: Record<string, unknown>) => ValidationResult;
|
|
5
|
+
/** Error thrown when pre-push validation fails. */
|
|
6
|
+
export declare class ValidationError extends Error {
|
|
7
|
+
readonly errors: string[];
|
|
8
|
+
constructor(errors: string[]);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Creates a validator from a JSON Schema object.
|
|
12
|
+
* Requires an Ajv-compatible validate function.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* import Ajv from "ajv"
|
|
17
|
+
* const ajv = new Ajv()
|
|
18
|
+
* const validator = createSchemaValidator(ajv, mySchema)
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export declare function createSchemaValidator(ajv: {
|
|
22
|
+
compile: (schema: object) => {
|
|
23
|
+
(data: unknown): boolean;
|
|
24
|
+
errors?: unknown;
|
|
25
|
+
};
|
|
26
|
+
errorsText: (errors?: unknown) => string;
|
|
27
|
+
}, schema: object): Validator;
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Error thrown when pre-push validation fails. */
|
|
2
|
+
export class ValidationError extends Error {
|
|
3
|
+
errors;
|
|
4
|
+
constructor(errors) {
|
|
5
|
+
super(`Validation failed: ${errors.join("; ")}`);
|
|
6
|
+
this.errors = errors;
|
|
7
|
+
this.name = "ValidationError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Creates a validator from a JSON Schema object.
|
|
12
|
+
* Requires an Ajv-compatible validate function.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* import Ajv from "ajv"
|
|
17
|
+
* const ajv = new Ajv()
|
|
18
|
+
* const validator = createSchemaValidator(ajv, mySchema)
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export function createSchemaValidator(ajv, schema) {
|
|
22
|
+
const validate = ajv.compile(schema);
|
|
23
|
+
return (data) => {
|
|
24
|
+
if (validate(data))
|
|
25
|
+
return true;
|
|
26
|
+
return [ajv.errorsText(validate.errors)];
|
|
27
|
+
};
|
|
28
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakkar.software/starfish-client",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/Drakkar-Software/starfish.git",
|
|
@@ -25,12 +25,25 @@
|
|
|
25
25
|
"./legend": {
|
|
26
26
|
"types": "./dist/bindings/legend.d.ts",
|
|
27
27
|
"import": "./dist/bindings/legend.js"
|
|
28
|
+
},
|
|
29
|
+
"./fetch": {
|
|
30
|
+
"types": "./dist/fetch.d.ts",
|
|
31
|
+
"import": "./dist/fetch.js"
|
|
32
|
+
},
|
|
33
|
+
"./broadcast": {
|
|
34
|
+
"types": "./dist/broadcast.d.ts",
|
|
35
|
+
"import": "./dist/broadcast.js"
|
|
36
|
+
},
|
|
37
|
+
"./testing": {
|
|
38
|
+
"types": "./dist/testing.d.ts",
|
|
39
|
+
"import": "./dist/testing.js"
|
|
28
40
|
}
|
|
29
41
|
},
|
|
30
42
|
"peerDependencies": {
|
|
31
|
-
"
|
|
43
|
+
"@legendapp/state": ">=2.0.0",
|
|
32
44
|
"immer": ">=9.0.0",
|
|
33
|
-
"
|
|
45
|
+
"react": ">=18.0.0",
|
|
46
|
+
"zustand": ">=4.0.0"
|
|
34
47
|
},
|
|
35
48
|
"peerDependenciesMeta": {
|
|
36
49
|
"zustand": {
|
|
@@ -41,15 +54,24 @@
|
|
|
41
54
|
},
|
|
42
55
|
"@legendapp/state": {
|
|
43
56
|
"optional": true
|
|
57
|
+
},
|
|
58
|
+
"react": {
|
|
59
|
+
"optional": true
|
|
44
60
|
}
|
|
45
61
|
},
|
|
46
62
|
"dependencies": {
|
|
47
|
-
"@drakkar.software/starfish-protocol": "1.
|
|
63
|
+
"@drakkar.software/starfish-protocol": "1.4.1"
|
|
48
64
|
},
|
|
49
65
|
"devDependencies": {
|
|
50
66
|
"@legendapp/state": "^2.0.0",
|
|
67
|
+
"@testing-library/react": "^16.3.2",
|
|
68
|
+
"@types/react": "^19.2.14",
|
|
69
|
+
"@types/react-dom": "^19.2.3",
|
|
51
70
|
"hono": "^4.12.7",
|
|
52
71
|
"immer": "^11.1.4",
|
|
72
|
+
"jsdom": "^29.0.1",
|
|
73
|
+
"react": "^19.2.4",
|
|
74
|
+
"react-dom": "^19.2.4",
|
|
53
75
|
"typescript": "^5.5.0",
|
|
54
76
|
"vitest": "^3.0.0",
|
|
55
77
|
"zustand": "^5.0.11"
|