@drakkar.software/starfish-client 1.3.1 → 1.4.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/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,39 @@ export class SyncManager {
34
41
  return this.lastCheckpoint;
35
42
  }
36
43
  async pull() {
37
- const result = await this.client.pull(this.pullPath, this.lastCheckpoint);
38
- if (this.encryptor) {
39
- const decrypted = await this.encryptor.decrypt(result.data);
40
- this.localData = decrypted;
41
- result.data = decrypted;
42
- }
43
- else if (this.lastCheckpoint > 0) {
44
- this.localData = deepMerge(this.localData, result.data);
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
+ }
56
+ else {
57
+ this.localData = result.data;
58
+ }
59
+ this.lastHash = result.hash;
60
+ this.lastCheckpoint = result.timestamp;
61
+ this.logger?.pullSuccess(this.loggerName, Math.round(performance.now() - start));
62
+ return result;
45
63
  }
46
- else {
47
- this.localData = result.data;
64
+ catch (err) {
65
+ this.logger?.pullError(this.loggerName, err.message);
66
+ throw err;
48
67
  }
49
- this.lastHash = result.hash;
50
- this.lastCheckpoint = result.timestamp;
51
- return result;
52
68
  }
53
69
  async push(data) {
70
+ if (this.validate) {
71
+ const result = this.validate(data);
72
+ if (result !== true)
73
+ throw new ValidationError(result);
74
+ }
75
+ this.logger?.pushStart(this.loggerName);
76
+ const start = performance.now();
54
77
  let attempt = 0;
55
78
  let pendingData = data;
56
79
  while (attempt <= this.maxRetries) {
@@ -65,12 +88,15 @@ export class SyncManager {
65
88
  this.lastHash = result.hash;
66
89
  this.lastCheckpoint = result.timestamp;
67
90
  this.localData = pendingData;
91
+ this.logger?.pushSuccess(this.loggerName, Math.round(performance.now() - start));
68
92
  return result;
69
93
  }
70
94
  catch (err) {
71
95
  if (!(err instanceof ConflictError) || attempt >= this.maxRetries) {
96
+ this.logger?.pushError(this.loggerName, err.message);
72
97
  throw err;
73
98
  }
99
+ this.logger?.conflict(this.loggerName, attempt + 1);
74
100
  const remote = await this.client.pull(this.pullPath);
75
101
  this.lastHash = remote.hash;
76
102
  this.lastCheckpoint = remote.timestamp;
@@ -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 {};
@@ -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;
@@ -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.1",
3
+ "version": "1.4.0",
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
- "zustand": ">=4.0.0",
43
+ "@legendapp/state": ">=2.0.0",
32
44
  "immer": ">=9.0.0",
33
- "@legendapp/state": ">=2.0.0"
45
+ "react": ">=18.0.0",
46
+ "zustand": ">=4.0.0"
34
47
  },
35
48
  "peerDependenciesMeta": {
36
49
  "zustand": {
@@ -41,25 +54,34 @@
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": "workspace:*"
48
- },
49
- "scripts": {
50
- "build": "tsc --build",
51
- "typecheck": "tsc --noEmit",
52
- "test": "vitest run"
63
+ "@drakkar.software/starfish-protocol": "1.4.0"
53
64
  },
54
65
  "devDependencies": {
55
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",
56
70
  "hono": "^4.12.7",
57
71
  "immer": "^11.1.4",
72
+ "jsdom": "^29.0.1",
73
+ "react": "^19.2.4",
74
+ "react-dom": "^19.2.4",
58
75
  "typescript": "^5.5.0",
59
76
  "vitest": "^3.0.0",
60
77
  "zustand": "^5.0.11"
61
78
  },
62
79
  "files": [
63
80
  "dist"
64
- ]
65
- }
81
+ ],
82
+ "scripts": {
83
+ "build": "tsc --build",
84
+ "typecheck": "tsc --noEmit",
85
+ "test": "vitest run"
86
+ }
87
+ }