@ecodrix/erix-client 1.0.1 → 1.0.3

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/index.d.ts CHANGED
@@ -24,7 +24,7 @@ export type JsonValue = string | number | boolean | null | JsonValue[] | {
24
24
  [key: string]: JsonValue;
25
25
  };
26
26
  export interface ErixClientOptions {
27
- /** Full URL of the deployed erix-store instance, e.g. https://erix-store.onrender.com */
27
+ /** Full URL of the deployed erix-store instance, e.g. https://api.erix.ecodrix.com */
28
28
  baseUrl: string;
29
29
  /** Shared secret generated at deploy time — must match ERIX_API_KEY on the server */
30
30
  apiKey: string;
@@ -61,6 +61,32 @@ export interface EnqueueOptionsV2 {
61
61
  clientCode?: string;
62
62
  metadata?: Record<string, JsonValue>;
63
63
  }
64
+ export interface CacheSetOptions {
65
+ /** TTL in milliseconds */
66
+ ttl?: number;
67
+ /** Tags for bulk invalidation */
68
+ tags?: string[];
69
+ /**
70
+ * Stale-While-Revalidate window in milliseconds.
71
+ * The server serves the old value for this duration after TTL expires
72
+ * while refreshing in the background.
73
+ */
74
+ staleFor?: number;
75
+ }
76
+ export interface SemanticGetResult<T = JsonValue> {
77
+ value: T;
78
+ key: string;
79
+ similarity: number;
80
+ isExact: boolean;
81
+ }
82
+ export interface QueueEventHandlers<T = JsonValue> {
83
+ onAdded?: (job: JobV2<T>) => void;
84
+ onActive?: (job: JobV2<T>) => void;
85
+ onCompleted?: (job: JobV2<T>) => void;
86
+ onFailed?: (job: JobV2<T>) => void;
87
+ onZombie?: (job: JobV2<T>) => void;
88
+ onError?: (err: Error) => void;
89
+ }
64
90
  export declare class ErixClient {
65
91
  private readonly baseUrl;
66
92
  private readonly headers;
@@ -105,7 +131,7 @@ export declare class ErixClient {
105
131
  /** Dequeue the oldest job from a named queue. Returns `null` if empty. */
106
132
  pop: <T = JsonValue>(name: string) => Promise<T | null>;
107
133
  };
108
- /** Advanced Queue (v2) — supports priority, delay, retries, and persistence */
134
+ /** Advanced Queue (v2) — supports priority, delay, retries, heartbeat, and SSE push */
109
135
  queueV2: {
110
136
  /** Enqueue a job into an advanced queue */
111
137
  push: <T = JsonValue>(queueName: string, data: T, options?: EnqueueOptionsV2) => Promise<JobV2<T>>;
@@ -117,12 +143,47 @@ export declare class ErixClient {
117
143
  fail: (jobId: string, error: string) => Promise<void>;
118
144
  /** Update job progress (0-100) */
119
145
  updateProgress: (jobId: string, progress: number) => Promise<void>;
146
+ /**
147
+ * Send a worker heartbeat to keep the job alive in the reaper.
148
+ * Call every 15–30 seconds from inside a long-running job handler.
149
+ * If silent for 60s the reaper will fail and requeue the job.
150
+ */
151
+ heartbeat: (jobId: string) => Promise<void>;
120
152
  /** Get job by ID */
121
153
  get: <T = JsonValue>(jobId: string) => Promise<JobV2<T> | null>;
154
+ /**
155
+ * Subscribe to queue events via Server-Sent Events.
156
+ * Returns an EventSource-like object — call `.close()` to unsubscribe.
157
+ *
158
+ * Workers should use this instead of polling `claim()` on an interval.
159
+ * On `job:added` or `job:active` events, call `claim()` to get the job.
160
+ *
161
+ * @example
162
+ * const sub = client.queueV2.subscribe('crm', {
163
+ * onAdded: () => processNextJob(),
164
+ * onError: (e) => console.error(e),
165
+ * });
166
+ * // later:
167
+ * sub.close();
168
+ */
169
+ subscribe: <T = JsonValue>(queueName: string, handlers: QueueEventHandlers<T>) => {
170
+ close: () => void;
171
+ };
122
172
  };
123
173
  pubsub: {
124
174
  /** Publish a message to a channel */
125
175
  publish: (channel: string, message: JsonValue) => Promise<void>;
176
+ /**
177
+ * Subscribe to a pub/sub channel via Server-Sent Events.
178
+ * Returns a controller with a `.close()` method.
179
+ *
180
+ * @example
181
+ * const sub = client.pubsub.subscribe('alerts', (msg) => console.log(msg));
182
+ * sub.close(); // unsubscribe
183
+ */
184
+ subscribe: (channel: string, onMessage: (message: JsonValue) => void, onError?: (err: Error) => void) => {
185
+ close: () => void;
186
+ };
126
187
  };
127
188
  /**
128
189
  * Check if an operation is within rate limits.
@@ -139,4 +200,100 @@ export declare class ErixClient {
139
200
  /** Get all members of a set */
140
201
  smembers: (key: string) => Promise<string[]>;
141
202
  };
203
+ cache: {
204
+ /** Get a cached value */
205
+ get: <T = JsonValue>(key: string) => Promise<T | null>;
206
+ /** Set a cached value with optional TTL, tags, and stale-while-revalidate */
207
+ set: (key: string, value: JsonValue, options?: CacheSetOptions) => Promise<void>;
208
+ /** Delete a cached key */
209
+ del: (key: string) => Promise<void>;
210
+ /** Invalidate all keys with a specific tag */
211
+ invalidateByTag: (tag: string) => Promise<number>;
212
+ /** Invalidate all keys matching multiple tags */
213
+ invalidateByTags: (tags: string[]) => Promise<number>;
214
+ /** Get cache statistics (hit rate, size, evictions) */
215
+ stats: () => Promise<unknown>;
216
+ };
217
+ semantic: {
218
+ /**
219
+ * Store a value with its embedding for similarity lookups.
220
+ * @param key Unique cache key
221
+ * @param text Text to embed (the query/question this entry answers)
222
+ * @param value The value to cache
223
+ * @param ttlMs Optional TTL in milliseconds
224
+ * @param tags Optional tags for bulk invalidation
225
+ */
226
+ set: (key: string, text: string, value: JsonValue, ttlMs?: number, tags?: string[]) => Promise<void>;
227
+ /**
228
+ * Exact key lookup in semantic cache.
229
+ */
230
+ get: <T = JsonValue>(key: string) => Promise<T | null>;
231
+ /**
232
+ * Find the most similar cached entry to a query string.
233
+ * Returns null if no entry exceeds the similarity threshold.
234
+ *
235
+ * @example
236
+ * // Stored: "What are your pricing plans?" → { starter: '$29', pro: '$99' }
237
+ * // Query: "How much does it cost?"
238
+ * const result = await client.semantic.search("How much does it cost?");
239
+ * // → { value: {...}, similarity: 0.97, isExact: false }
240
+ */
241
+ search: <T = JsonValue>(query: string, threshold?: number) => Promise<SemanticGetResult<T> | null>;
242
+ /** Invalidate all semantic cache entries with a specific tag */
243
+ invalidateByTag: (tag: string) => Promise<number>;
244
+ /** Delete a single semantic cache entry */
245
+ del: (key: string) => Promise<void>;
246
+ /** Get semantic cache stats */
247
+ stats: () => Promise<{
248
+ success: boolean;
249
+ size: number;
250
+ keys: string[];
251
+ }>;
252
+ };
253
+ analytics: {
254
+ /**
255
+ * Get live usage counts for this tenant (from in-memory buffer).
256
+ * Updated in real time — no waiting for the 10s DB flush.
257
+ */
258
+ usage: () => Promise<Record<string, number>>;
259
+ /**
260
+ * Get anomaly detector stats — mean, stddev, and sample count
261
+ * for each monitored metric of this tenant.
262
+ */
263
+ anomalies: () => Promise<{
264
+ success: boolean;
265
+ metrics: Array<{
266
+ metric: string;
267
+ mean: number;
268
+ stddev: number;
269
+ n: number;
270
+ }>;
271
+ }>;
272
+ /**
273
+ * Subscribe to real-time anomaly alerts via SSE.
274
+ * The server fires an event whenever a metric exceeds 3σ.
275
+ *
276
+ * @example
277
+ * const sub = client.analytics.subscribeAlerts((alert) => {
278
+ * console.log(`ALERT: ${alert.message}`);
279
+ * });
280
+ * sub.close();
281
+ */
282
+ subscribeAlerts: (onAlert: (alert: {
283
+ metric: string;
284
+ current: number;
285
+ mean: number;
286
+ zScore: number;
287
+ message: string;
288
+ }) => void, onError?: (err: Error) => void) => {
289
+ close: () => void;
290
+ };
291
+ };
292
+ /**
293
+ * Opens a persistent SSE connection and dispatches named events to a handler.
294
+ * Returns a controller with `.close()` to terminate the connection.
295
+ *
296
+ * Uses native fetch with a ReadableStream reader — works in Node 18+.
297
+ */
298
+ private openEventStream;
142
299
  }
package/dist/index.js CHANGED
@@ -90,7 +90,7 @@ export class ErixClient {
90
90
  return this.list.lpop(`q:${name}`);
91
91
  },
92
92
  };
93
- /** Advanced Queue (v2) — supports priority, delay, retries, and persistence */
93
+ /** Advanced Queue (v2) — supports priority, delay, retries, heartbeat, and SSE push */
94
94
  this.queueV2 = {
95
95
  /** Enqueue a job into an advanced queue */
96
96
  push: async (queueName, data, options = {}) => {
@@ -114,11 +114,49 @@ export class ErixClient {
114
114
  updateProgress: async (jobId, progress) => {
115
115
  await this.req("PATCH", `/queue/v2/jobs/${jobId}/progress`, { progress });
116
116
  },
117
+ /**
118
+ * Send a worker heartbeat to keep the job alive in the reaper.
119
+ * Call every 15–30 seconds from inside a long-running job handler.
120
+ * If silent for 60s the reaper will fail and requeue the job.
121
+ */
122
+ heartbeat: async (jobId) => {
123
+ await this.req("PATCH", `/queue/v2/jobs/${jobId}/heartbeat`);
124
+ },
117
125
  /** Get job by ID */
118
126
  get: async (jobId) => {
119
127
  const res = await this.req("GET", `/queue/v2/jobs/${jobId}`);
120
128
  return res.job;
121
129
  },
130
+ /**
131
+ * Subscribe to queue events via Server-Sent Events.
132
+ * Returns an EventSource-like object — call `.close()` to unsubscribe.
133
+ *
134
+ * Workers should use this instead of polling `claim()` on an interval.
135
+ * On `job:added` or `job:active` events, call `claim()` to get the job.
136
+ *
137
+ * @example
138
+ * const sub = client.queueV2.subscribe('crm', {
139
+ * onAdded: () => processNextJob(),
140
+ * onError: (e) => console.error(e),
141
+ * });
142
+ * // later:
143
+ * sub.close();
144
+ */
145
+ subscribe: (queueName, handlers) => {
146
+ return this.openEventStream(`/queue/v2/${queueName}/events`, (event, data) => {
147
+ const job = data;
148
+ if (event === "job:added")
149
+ handlers.onAdded?.(job);
150
+ else if (event === "job:active")
151
+ handlers.onActive?.(job);
152
+ else if (event === "job:completed")
153
+ handlers.onCompleted?.(job);
154
+ else if (event === "job:failed")
155
+ handlers.onFailed?.(job);
156
+ else if (event === "job:zombie")
157
+ handlers.onZombie?.(job);
158
+ }, handlers.onError);
159
+ },
122
160
  };
123
161
  // ─── PubSub ───────────────────────────────────────────────────────────
124
162
  this.pubsub = {
@@ -126,6 +164,17 @@ export class ErixClient {
126
164
  publish: async (channel, message) => {
127
165
  await this.req("POST", "/pubsub/publish", { channel, message });
128
166
  },
167
+ /**
168
+ * Subscribe to a pub/sub channel via Server-Sent Events.
169
+ * Returns a controller with a `.close()` method.
170
+ *
171
+ * @example
172
+ * const sub = client.pubsub.subscribe('alerts', (msg) => console.log(msg));
173
+ * sub.close(); // unsubscribe
174
+ */
175
+ subscribe: (channel, onMessage, onError) => {
176
+ return this.openEventStream(`/pubsub/${encodeURIComponent(channel)}/stream`, (_event, data) => onMessage(data), onError);
177
+ },
129
178
  };
130
179
  // ─── Set ──────────────────────────────────────────────────────────────
131
180
  this.set_ = {
@@ -139,6 +188,139 @@ export class ErixClient {
139
188
  return data.members;
140
189
  },
141
190
  };
191
+ // ─── Cache (advanced) ────────────────────────────────────────────────
192
+ this.cache = {
193
+ /** Get a cached value */
194
+ get: async (key) => {
195
+ try {
196
+ const res = await this.req("GET", `/cache/${encodeURIComponent(key)}`);
197
+ return res.value ?? null;
198
+ }
199
+ catch {
200
+ return null;
201
+ }
202
+ },
203
+ /** Set a cached value with optional TTL, tags, and stale-while-revalidate */
204
+ set: async (key, value, options = {}) => {
205
+ await this.req("POST", `/cache/${encodeURIComponent(key)}`, {
206
+ value,
207
+ ...options,
208
+ });
209
+ },
210
+ /** Delete a cached key */
211
+ del: async (key) => {
212
+ await this.req("DELETE", `/cache/${encodeURIComponent(key)}`);
213
+ },
214
+ /** Invalidate all keys with a specific tag */
215
+ invalidateByTag: async (tag) => {
216
+ const res = await this.req("DELETE", `/cache/tags/${encodeURIComponent(tag)}`);
217
+ return res.count;
218
+ },
219
+ /** Invalidate all keys matching multiple tags */
220
+ invalidateByTags: async (tags) => {
221
+ const res = await this.req("POST", "/cache/tags/invalidate", { tags });
222
+ return res.count;
223
+ },
224
+ /** Get cache statistics (hit rate, size, evictions) */
225
+ stats: async () => {
226
+ const res = await this.req("GET", "/cache/_stats");
227
+ return res.stats;
228
+ },
229
+ };
230
+ // ─── Semantic Cache (AI layer) ────────────────────────────────────────
231
+ this.semantic = {
232
+ /**
233
+ * Store a value with its embedding for similarity lookups.
234
+ * @param key Unique cache key
235
+ * @param text Text to embed (the query/question this entry answers)
236
+ * @param value The value to cache
237
+ * @param ttlMs Optional TTL in milliseconds
238
+ * @param tags Optional tags for bulk invalidation
239
+ */
240
+ set: async (key, text, value, ttlMs, tags) => {
241
+ await this.req("POST", `/semantic/${encodeURIComponent(key)}`, {
242
+ text,
243
+ value,
244
+ ttlMs,
245
+ tags,
246
+ });
247
+ },
248
+ /**
249
+ * Exact key lookup in semantic cache.
250
+ */
251
+ get: async (key) => {
252
+ try {
253
+ const res = await this.req("GET", `/semantic/${encodeURIComponent(key)}`);
254
+ return res.value ?? null;
255
+ }
256
+ catch {
257
+ return null;
258
+ }
259
+ },
260
+ /**
261
+ * Find the most similar cached entry to a query string.
262
+ * Returns null if no entry exceeds the similarity threshold.
263
+ *
264
+ * @example
265
+ * // Stored: "What are your pricing plans?" → { starter: '$29', pro: '$99' }
266
+ * // Query: "How much does it cost?"
267
+ * const result = await client.semantic.search("How much does it cost?");
268
+ * // → { value: {...}, similarity: 0.97, isExact: false }
269
+ */
270
+ search: async (query, threshold) => {
271
+ try {
272
+ const res = await this.req("POST", "/semantic/search", { query, threshold });
273
+ return res;
274
+ }
275
+ catch {
276
+ return null;
277
+ }
278
+ },
279
+ /** Invalidate all semantic cache entries with a specific tag */
280
+ invalidateByTag: async (tag) => {
281
+ const res = await this.req("DELETE", `/semantic/tags/${encodeURIComponent(tag)}`);
282
+ return res.count;
283
+ },
284
+ /** Delete a single semantic cache entry */
285
+ del: async (key) => {
286
+ await this.req("DELETE", `/semantic/${encodeURIComponent(key)}`);
287
+ },
288
+ /** Get semantic cache stats */
289
+ stats: async () => {
290
+ return this.req("GET", "/semantic/_stats");
291
+ },
292
+ };
293
+ // ─── Analytics ────────────────────────────────────────────────────────
294
+ this.analytics = {
295
+ /**
296
+ * Get live usage counts for this tenant (from in-memory buffer).
297
+ * Updated in real time — no waiting for the 10s DB flush.
298
+ */
299
+ usage: async () => {
300
+ const res = await this.req("GET", "/analytics/usage");
301
+ return res.usage;
302
+ },
303
+ /**
304
+ * Get anomaly detector stats — mean, stddev, and sample count
305
+ * for each monitored metric of this tenant.
306
+ */
307
+ anomalies: async () => {
308
+ return this.req("GET", "/analytics/anomalies");
309
+ },
310
+ /**
311
+ * Subscribe to real-time anomaly alerts via SSE.
312
+ * The server fires an event whenever a metric exceeds 3σ.
313
+ *
314
+ * @example
315
+ * const sub = client.analytics.subscribeAlerts((alert) => {
316
+ * console.log(`ALERT: ${alert.message}`);
317
+ * });
318
+ * sub.close();
319
+ */
320
+ subscribeAlerts: (onAlert, onError) => {
321
+ return this.openEventStream("/analytics/anomalies/stream", (_event, data) => onAlert(data), onError);
322
+ },
323
+ };
142
324
  this.baseUrl = options.baseUrl.replace(/\/$/, ""); // strip trailing slash
143
325
  this.timeoutMs = options.timeoutMs ?? 5000;
144
326
  this.headers = {
@@ -223,4 +405,60 @@ export class ErixClient {
223
405
  window,
224
406
  });
225
407
  }
408
+ // ─── Internal SSE helper ─────────────────────────────────────────────
409
+ /**
410
+ * Opens a persistent SSE connection and dispatches named events to a handler.
411
+ * Returns a controller with `.close()` to terminate the connection.
412
+ *
413
+ * Uses native fetch with a ReadableStream reader — works in Node 18+.
414
+ */
415
+ openEventStream(path, onEvent, onError) {
416
+ const controller = new AbortController();
417
+ const url = `${this.baseUrl}${path}`;
418
+ const run = async () => {
419
+ try {
420
+ const res = await fetch(url, {
421
+ headers: this.headers,
422
+ signal: controller.signal,
423
+ });
424
+ if (!res.ok || !res.body) {
425
+ throw new Error(`[erix-store] SSE ${path} → ${res.status}: ${res.statusText}`);
426
+ }
427
+ const reader = res.body.getReader();
428
+ const decoder = new TextDecoder();
429
+ let buffer = "";
430
+ let currentEvent = "message";
431
+ while (true) {
432
+ const { done, value } = await reader.read();
433
+ if (done)
434
+ break;
435
+ buffer += decoder.decode(value, { stream: true });
436
+ const lines = buffer.split("\n");
437
+ buffer = lines.pop() ?? "";
438
+ for (const line of lines) {
439
+ if (line.startsWith("event:")) {
440
+ currentEvent = line.slice(6).trim();
441
+ }
442
+ else if (line.startsWith("data:")) {
443
+ try {
444
+ const data = JSON.parse(line.slice(5).trim());
445
+ onEvent(currentEvent, data);
446
+ }
447
+ catch {
448
+ // non-JSON data line, skip
449
+ }
450
+ currentEvent = "message";
451
+ }
452
+ }
453
+ }
454
+ }
455
+ catch (err) {
456
+ if (!controller.signal.aborted) {
457
+ onError?.(err instanceof Error ? err : new Error(String(err)));
458
+ }
459
+ }
460
+ };
461
+ run();
462
+ return { close: () => controller.abort() };
463
+ }
226
464
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecodrix/erix-client",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Type-safe HTTP client for the erix-store in-memory data service. Zero dependencies — uses native fetch.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",