@aikirun/client 0.9.2 → 0.10.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/index.d.ts CHANGED
@@ -44,9 +44,15 @@ export { AdaptivePollingSubscriberStrategy, ApiClient, Client, ClientParams, Log
44
44
  */
45
45
  declare function client<AppContext = null>(params: ClientParams<AppContext>): Promise<Client<AppContext>>;
46
46
 
47
+ type LogLevel = "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR";
48
+ interface ConsoleLoggerOptions {
49
+ level?: LogLevel;
50
+ context?: Record<string, unknown>;
51
+ }
47
52
  declare class ConsoleLogger implements Logger {
53
+ private readonly level;
48
54
  private readonly context;
49
- constructor(context?: Record<string, unknown>);
55
+ constructor(options?: ConsoleLoggerOptions);
50
56
  trace(message: string, metadata?: Record<string, unknown>): void;
51
57
  debug(message: string, metadata?: Record<string, unknown>): void;
52
58
  info(message: string, metadata?: Record<string, unknown>): void;
package/dist/index.js CHANGED
@@ -5,50 +5,84 @@ import { RPCLink } from "@orpc/client/fetch";
5
5
  import { Redis } from "ioredis";
6
6
 
7
7
  // logger/console-logger.ts
8
+ var colors = {
9
+ reset: "\x1B[0m",
10
+ dim: "\x1B[2m",
11
+ bold: "\x1B[1m",
12
+ gray: "\x1B[90m",
13
+ blue: "\x1B[94m",
14
+ cyan: "\x1B[36m",
15
+ green: "\x1B[32m",
16
+ yellow: "\x1B[33m",
17
+ red: "\x1B[31m",
18
+ magenta: "\x1B[35m"
19
+ };
20
+ var logLevelConfig = {
21
+ TRACE: { level: 10, color: colors.gray },
22
+ DEBUG: { level: 20, color: colors.blue },
23
+ INFO: { level: 30, color: colors.green },
24
+ WARN: { level: 40, color: colors.yellow },
25
+ ERROR: { level: 50, color: colors.red }
26
+ };
8
27
  var ConsoleLogger = class _ConsoleLogger {
9
- constructor(context = {}) {
10
- this.context = context;
28
+ level;
29
+ context;
30
+ constructor(options = {}) {
31
+ this.level = logLevelConfig[options.level ?? "INFO"].level;
32
+ this.context = options.context ?? {};
11
33
  }
12
34
  trace(message, metadata) {
13
- console.debug(this.format("TRACE", message, metadata));
35
+ if (this.level <= logLevelConfig.TRACE.level) {
36
+ console.debug(this.format("TRACE", message, metadata));
37
+ }
14
38
  }
15
39
  debug(message, metadata) {
16
- console.debug(this.format("DEBUG", message, metadata));
40
+ if (this.level <= logLevelConfig.DEBUG.level) {
41
+ console.debug(this.format("DEBUG", message, metadata));
42
+ }
17
43
  }
18
44
  info(message, metadata) {
19
- console.info(this.format("INFO", message, metadata));
45
+ if (this.level <= logLevelConfig.INFO.level) {
46
+ console.info(this.format("INFO", message, metadata));
47
+ }
20
48
  }
21
49
  warn(message, metadata) {
22
- console.warn(this.format("WARN", message, metadata));
50
+ if (this.level <= logLevelConfig.WARN.level) {
51
+ console.warn(this.format("WARN", message, metadata));
52
+ }
23
53
  }
24
54
  error(message, metadata) {
25
- console.error(this.format("ERROR", message, metadata));
55
+ if (this.level <= logLevelConfig.ERROR.level) {
56
+ console.error(this.format("ERROR", message, metadata));
57
+ }
26
58
  }
27
59
  child(bindings) {
28
- return new _ConsoleLogger({ ...this.context, ...bindings });
60
+ return new _ConsoleLogger({
61
+ level: Object.entries(logLevelConfig).find(([, v]) => v.level === this.level)?.[0],
62
+ context: { ...this.context, ...bindings }
63
+ });
29
64
  }
30
65
  format(level, message, metadata) {
31
66
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
32
67
  const mergedContext = { ...this.context, ...metadata };
33
- const contextStr = Object.keys(mergedContext).length > 0 ? ` ${JSON.stringify(mergedContext)}` : "";
34
- return `[${timestamp}] ${level}: ${message}${contextStr}`;
68
+ const levelColor = logLevelConfig[level].color ?? colors.reset;
69
+ const timestampStr = `${colors.dim}${timestamp}${colors.reset}`;
70
+ const levelStr = `${levelColor}${colors.bold}${level.padEnd(5)}${colors.reset}`;
71
+ const messageStr = `${colors.cyan}${message}${colors.reset}`;
72
+ let output = `${timestampStr} ${levelStr} ${messageStr}`;
73
+ if (Object.keys(mergedContext).length > 0) {
74
+ const entries = Object.entries(mergedContext).map(([key, value]) => {
75
+ const valueStr = typeof value === "object" ? JSON.stringify(value) : String(value);
76
+ return `${colors.magenta}${key}:${colors.reset} ${valueStr}`;
77
+ }).join("\n ");
78
+ output += `
79
+ ${entries}`;
80
+ }
81
+ return output;
35
82
  }
36
83
  };
37
84
 
38
85
  // ../../lib/array/utils.ts
39
- function groupBy(items, unwrap) {
40
- const result = /* @__PURE__ */ new Map();
41
- for (const item of items) {
42
- const [key, value] = unwrap(item);
43
- const valuesWithSameKey = result.get(key);
44
- if (valuesWithSameKey === void 0) {
45
- result.set(key, [value]);
46
- } else {
47
- valuesWithSameKey.push(value);
48
- }
49
- }
50
- return result;
51
- }
52
86
  function isNonEmptyArray(value) {
53
87
  return value.length > 0;
54
88
  }
@@ -127,13 +161,9 @@ function getRetryParams(attempts, strategy) {
127
161
 
128
162
  // subscribers/redis-streams.ts
129
163
  import { INTERNAL } from "@aikirun/types/symbols";
130
- import { z } from "zod";
131
- var WorkflowRunReadyMessageDataSchema = z.object({
132
- type: z.literal("workflow_run_ready"),
133
- workflowRunId: z.string().transform((id) => id)
134
- });
135
- var RedisMessageDataSchema = z.discriminatedUnion("type", [WorkflowRunReadyMessageDataSchema]);
136
- var RedisMessageRawDataSchema = z.array(z.unknown()).transform((rawFields) => {
164
+ import { type } from "arktype";
165
+ var streamEntriesSchema = type(["string", type(["string", "unknown[]"]).array()]).array();
166
+ var rawStreamMessageFieldsToRecord = (rawFields) => {
137
167
  const data = {};
138
168
  for (let i = 0; i < rawFields.length; i += 2) {
139
169
  if (i + 1 < rawFields.length) {
@@ -144,20 +174,12 @@ var RedisMessageRawDataSchema = z.array(z.unknown()).transform((rawFields) => {
144
174
  }
145
175
  }
146
176
  return data;
177
+ };
178
+ var streamMessageDataSchema = type({
179
+ type: "'workflow_run_ready'",
180
+ workflowRunId: "string > 0"
147
181
  });
148
- var RedisStreamMessageSchema = z.tuple([
149
- z.string(),
150
- // message-id
151
- RedisMessageRawDataSchema
152
- ]);
153
- var RedisStreamEntrySchema = z.tuple([
154
- z.string(),
155
- // stream
156
- z.array(RedisStreamMessageSchema)
157
- ]);
158
- var RedisStreamEntriesSchema = z.array(RedisStreamEntrySchema);
159
- var RedisStreamPendingMessageSchema = z.tuple([z.string(), z.string(), z.number(), z.number()]);
160
- var RedisStreamPendingMessagesSchema = z.array(RedisStreamPendingMessageSchema);
182
+ var streamPendingMessagesSchema = type(["string", "string", "number", "number"]).array();
161
183
  function createRedisStreamsStrategy(client2, strategy, workflows, workerShards) {
162
184
  const redis = client2[INTERNAL].redis.getConnection();
163
185
  const logger = client2.logger.child({
@@ -169,7 +191,7 @@ function createRedisStreamsStrategy(client2, strategy, workflows, workerShards)
169
191
  const maxRetryIntervalMs = strategy.maxRetryIntervalMs ?? 3e4;
170
192
  const atCapacityIntervalMs = strategy.atCapacityIntervalMs ?? 50;
171
193
  const blockTimeMs = strategy.blockTimeMs ?? 1e3;
172
- const claimMinIdleTimeMs = strategy.claimMinIdleTimeMs ?? 18e4;
194
+ const claimMinIdleTimeMs = strategy.claimMinIdleTimeMs ?? 9e4;
173
195
  const getNextDelay = (params) => {
174
196
  switch (params.type) {
175
197
  case "polled":
@@ -197,31 +219,40 @@ function createRedisStreamsStrategy(client2, strategy, workflows, workerShards)
197
219
  try {
198
220
  await redis.xclaim(meta.stream, meta.consumerGroup, workerId, 0, meta.messageId, "JUSTID");
199
221
  logger.debug("Heartbeat sent", {
222
+ "aiki.workerId": workerId,
200
223
  "aiki.workflowRunId": workflowRunId,
201
224
  "aiki.messageId": meta.messageId
202
225
  });
203
226
  } catch (error) {
204
227
  logger.warn("Heartbeat failed", {
228
+ "aiki.workerId": workerId,
205
229
  "aiki.workflowRunId": workflowRunId,
206
230
  "aiki.error": error instanceof Error ? error.message : String(error)
207
231
  });
208
232
  }
209
233
  };
210
- const acknowledge = async (workflowRunId, meta) => {
234
+ const acknowledge = async (workerId, workflowRunId, meta) => {
211
235
  try {
212
236
  const result = await redis.xack(meta.stream, meta.consumerGroup, meta.messageId);
213
237
  if (result === 0) {
214
238
  logger.warn("Message already acknowledged", {
239
+ "aiki.workerId": workerId,
215
240
  "aiki.workflowRunId": workflowRunId,
216
241
  "aiki.messageId": meta.messageId
217
242
  });
218
243
  } else {
244
+ logger.debug("Message acknowledged", {
245
+ "aiki.workerId": workerId,
246
+ "aiki.workflowRunId": workflowRunId,
247
+ "aiki.messageId": meta.messageId
248
+ });
219
249
  }
220
250
  } catch (error) {
221
251
  logger.error("Failed to acknowledge message", {
222
- "aiki.error": error instanceof Error ? error.message : String(error),
252
+ "aiki.workerId": workerId,
223
253
  "aiki.workflowRunId": workflowRunId,
224
- "aiki.messageId": meta.messageId
254
+ "aiki.messageId": meta.messageId,
255
+ "aiki.error": error instanceof Error ? error.message : String(error)
225
256
  });
226
257
  throw error;
227
258
  }
@@ -242,7 +273,7 @@ function createRedisStreamsStrategy(client2, strategy, workflows, workerShards)
242
273
  getNextDelay,
243
274
  getNextBatch: (size) => fetchRedisStreamMessages(
244
275
  redis,
245
- logger,
276
+ logger.child({ "aiki.workerId": workerId }),
246
277
  streams,
247
278
  streamConsumerGroupMap,
248
279
  workerId,
@@ -278,9 +309,10 @@ async function fetchRedisStreamMessages(redis, logger, streams, streamConsumerGr
278
309
  if (!isNonEmptyArray(streams)) {
279
310
  return [];
280
311
  }
312
+ const perStreamBlockTimeMs = Math.max(50, Math.floor(blockTimeMs / streams.length));
281
313
  const batchSizePerStream = distributeRoundRobin(size, streams.length);
282
314
  const shuffledStreams = shuffleArray(streams);
283
- const readPromises = [];
315
+ const streamEntries = [];
284
316
  for (let i = 0; i < shuffledStreams.length; i++) {
285
317
  const stream = shuffledStreams[i];
286
318
  if (!stream) {
@@ -294,25 +326,27 @@ async function fetchRedisStreamMessages(redis, logger, streams, streamConsumerGr
294
326
  if (!consumerGroup) {
295
327
  continue;
296
328
  }
297
- const readPromise = redis.xreadgroup(
298
- "GROUP",
299
- consumerGroup,
300
- workerId,
301
- "COUNT",
302
- streamBatchSize,
303
- "BLOCK",
304
- blockTimeMs,
305
- "STREAMS",
306
- stream,
307
- ">"
308
- );
309
- readPromises.push(readPromise);
310
- }
311
- const readResults = await Promise.allSettled(readPromises);
312
- const streamEntries = [];
313
- for (const result of readResults) {
314
- if (result.status === "fulfilled" && result.value) {
315
- streamEntries.push(result.value);
329
+ try {
330
+ const result = await redis.xreadgroup(
331
+ "GROUP",
332
+ consumerGroup,
333
+ workerId,
334
+ "COUNT",
335
+ streamBatchSize,
336
+ "BLOCK",
337
+ perStreamBlockTimeMs,
338
+ "STREAMS",
339
+ stream,
340
+ ">"
341
+ );
342
+ if (result) {
343
+ streamEntries.push(result);
344
+ }
345
+ } catch (error) {
346
+ logger.error("XREADGROUP failed", {
347
+ "aiki.stream": stream,
348
+ "aiki.error": error instanceof Error ? error.message : String(error)
349
+ });
316
350
  }
317
351
  }
318
352
  const workflowRuns = isNonEmptyArray(streamEntries) ? await processRedisStreamMessages(redis, logger, streamConsumerGroupMap, streamEntries) : [];
@@ -336,14 +370,15 @@ async function fetchRedisStreamMessages(redis, logger, streams, streamConsumerGr
336
370
  async function processRedisStreamMessages(redis, logger, streamConsumerGroupMap, streamsEntries) {
337
371
  const workflowRuns = [];
338
372
  for (const streamEntriesRaw of streamsEntries) {
339
- const streamEntriesResult = RedisStreamEntriesSchema.safeParse(streamEntriesRaw);
340
- if (!streamEntriesResult.success) {
373
+ logger.debug("Raw stream entries", { "aiki.entries": streamEntriesRaw });
374
+ const streamEntriesResult = streamEntriesSchema(streamEntriesRaw);
375
+ if (streamEntriesResult instanceof type.errors) {
341
376
  logger.error("Invalid stream entries format", {
342
- "aiki.error": z.treeifyError(streamEntriesResult.error)
377
+ "aiki.error": streamEntriesResult.summary
343
378
  });
344
379
  continue;
345
380
  }
346
- for (const streamEntry of streamEntriesResult.data) {
381
+ for (const streamEntry of streamEntriesResult) {
347
382
  const [stream, messages] = streamEntry;
348
383
  const consumerGroup = streamConsumerGroupMap.get(stream);
349
384
  if (!consumerGroup) {
@@ -352,20 +387,21 @@ async function processRedisStreamMessages(redis, logger, streamConsumerGroupMap,
352
387
  });
353
388
  continue;
354
389
  }
355
- for (const [messageId, rawMessageData] of messages) {
356
- const messageData = RedisMessageDataSchema.safeParse(rawMessageData);
357
- if (!messageData.success) {
390
+ for (const [messageId, rawFields] of messages) {
391
+ const rawMessageData = rawStreamMessageFieldsToRecord(rawFields);
392
+ const messageData = streamMessageDataSchema(rawMessageData);
393
+ if (messageData instanceof type.errors) {
358
394
  logger.warn("Invalid message structure", {
359
395
  "aiki.stream": stream,
360
396
  "aiki.messageId": messageId,
361
- "aiki.error": z.treeifyError(messageData.error)
397
+ "aiki.error": messageData.summary
362
398
  });
363
399
  await redis.xack(stream, consumerGroup, messageId);
364
400
  continue;
365
401
  }
366
- switch (messageData.data.type) {
402
+ switch (messageData.type) {
367
403
  case "workflow_run_ready": {
368
- const { workflowRunId } = messageData.data;
404
+ const workflowRunId = messageData.workflowRunId;
369
405
  workflowRuns.push({
370
406
  data: { workflowRunId },
371
407
  meta: {
@@ -377,7 +413,7 @@ async function processRedisStreamMessages(redis, logger, streamConsumerGroupMap,
377
413
  break;
378
414
  }
379
415
  default:
380
- messageData.data.type;
416
+ messageData.type;
381
417
  continue;
382
418
  }
383
419
  }
@@ -389,7 +425,7 @@ async function claimStuckRedisStreamMessages(redis, logger, shuffledStreams, str
389
425
  if (maxClaim <= 0 || minIdleMs <= 0) {
390
426
  return [];
391
427
  }
392
- const claimableMessages = await findClaimableRedisStreamMessages(
428
+ const claimaibleMessagesByStream = await findClaimableMessagesByStream(
393
429
  redis,
394
430
  logger,
395
431
  shuffledStreams,
@@ -398,16 +434,17 @@ async function claimStuckRedisStreamMessages(redis, logger, shuffledStreams, str
398
434
  maxClaim,
399
435
  minIdleMs
400
436
  );
401
- if (!isNonEmptyArray(claimableMessages)) {
437
+ if (!claimaibleMessagesByStream.size) {
402
438
  return [];
403
439
  }
404
- const claimaibleMessagesByStream = groupBy(claimableMessages, (message) => [message.stream, message]);
405
- const claimPromises = Array.from(claimaibleMessagesByStream.entries()).map(async ([stream, messages]) => {
440
+ const claimPromises = Array.from(claimaibleMessagesByStream.entries()).map(async ([stream, messageIds]) => {
441
+ if (!messageIds.length) {
442
+ return null;
443
+ }
406
444
  const consumerGroup = streamConsumerGroupMap.get(stream);
407
445
  if (!consumerGroup) {
408
446
  return null;
409
447
  }
410
- const messageIds = messages.map((message) => message.messageId);
411
448
  try {
412
449
  const claimedMessages = await redis.xclaim(stream, consumerGroup, workerId, minIdleMs, ...messageIds);
413
450
  return { stream, claimedMessages };
@@ -415,23 +452,25 @@ async function claimStuckRedisStreamMessages(redis, logger, shuffledStreams, str
415
452
  const errorMessage = error instanceof Error ? error.message : String(error);
416
453
  if (errorMessage.includes("NOGROUP")) {
417
454
  logger.warn("Consumer group does not exist for stream, skipping claim operation", {
418
- "aiki.stream": stream
455
+ "aiki.stream": stream,
456
+ "aiki.consumerGroup": consumerGroup
419
457
  });
420
458
  } else if (errorMessage.includes("BUSYGROUP")) {
421
459
  logger.warn("Consumer group busy for stream, skipping claim operation", {
422
- "aiki.stream": stream
460
+ "aiki.stream": stream,
461
+ "aiki.consumerGroup": consumerGroup
423
462
  });
424
463
  } else if (errorMessage.includes("NOSCRIPT")) {
425
464
  logger.warn("Redis script not loaded for stream, skipping claim operation", {
426
- "aiki.stream": stream
465
+ "aiki.stream": stream,
466
+ "aiki.consumerGroup": consumerGroup
427
467
  });
428
468
  } else {
429
469
  logger.error("Failed to claim messages from stream", {
430
- "aiki.error": errorMessage,
431
- "aiki.messageIds": messageIds.length,
432
- "aiki.workerId": workerId,
470
+ "aiki.stream": stream,
433
471
  "aiki.consumerGroup": consumerGroup,
434
- "aiki.stream": stream
472
+ "aiki.messageIds": messageIds.length,
473
+ "aiki.error": errorMessage
435
474
  });
436
475
  }
437
476
  return null;
@@ -448,10 +487,9 @@ async function claimStuckRedisStreamMessages(redis, logger, shuffledStreams, str
448
487
  if (!isNonEmptyArray(claimedStreamEntries)) {
449
488
  return [];
450
489
  }
451
- return processRedisStreamMessages(redis, logger, streamConsumerGroupMap, claimedStreamEntries);
490
+ return processRedisStreamMessages(redis, logger, streamConsumerGroupMap, [claimedStreamEntries]);
452
491
  }
453
- async function findClaimableRedisStreamMessages(redis, logger, shuffledStreams, streamConsumerGroupMap, workerId, maxClaim, minIdleMs) {
454
- const claimableMessages = [];
492
+ async function findClaimableMessagesByStream(redis, logger, shuffledStreams, streamConsumerGroupMap, workerId, maxClaim, minIdleMs) {
455
493
  const claimSizePerStream = distributeRoundRobin(maxClaim, shuffledStreams.length);
456
494
  const pendingPromises = [];
457
495
  for (let i = 0; i < shuffledStreams.length; i++) {
@@ -467,31 +505,35 @@ async function findClaimableRedisStreamMessages(redis, logger, shuffledStreams,
467
505
  if (!consumerGroup) {
468
506
  continue;
469
507
  }
470
- const pendingPromise = redis.xpending(stream, consumerGroup, "IDLE", minIdleMs, "-", "+", claimSize).then((result) => ({ stream, result }));
471
- pendingPromises.push(pendingPromise);
508
+ const readPromise = redis.xpending(stream, consumerGroup, "IDLE", minIdleMs, "-", "+", claimSize).then((result) => ({ stream, result }));
509
+ pendingPromises.push(readPromise);
472
510
  }
473
511
  const pendingResults = await Promise.allSettled(pendingPromises);
512
+ const claimableMessagesByStream = /* @__PURE__ */ new Map();
474
513
  for (const pendingResult of pendingResults) {
475
514
  if (pendingResult.status !== "fulfilled") {
476
515
  continue;
477
516
  }
478
517
  const { stream, result } = pendingResult.value;
479
- const parsedResult = RedisStreamPendingMessagesSchema.safeParse(result);
480
- if (!parsedResult.success) {
518
+ const parsedResult = streamPendingMessagesSchema(result);
519
+ if (parsedResult instanceof type.errors) {
481
520
  logger.error("Invalid XPENDING response", {
482
521
  "aiki.stream": stream,
483
- "aiki.error": z.treeifyError(parsedResult.error)
522
+ "aiki.error": parsedResult.summary
484
523
  });
485
524
  continue;
486
525
  }
487
- for (const [messageId, consumerName, _idleTimeMs, _deliveryCount] of parsedResult.data) {
488
- if (consumerName === workerId) {
489
- continue;
526
+ const claimableStreamMessages = claimableMessagesByStream.get(stream) ?? [];
527
+ for (const [messageId, consumerName, _idleTimeMs, _deliveryCount] of parsedResult) {
528
+ if (consumerName !== workerId) {
529
+ claimableStreamMessages.push(messageId);
490
530
  }
491
- claimableMessages.push({ stream, messageId });
531
+ }
532
+ if (claimableStreamMessages.length) {
533
+ claimableMessagesByStream.set(stream, claimableStreamMessages);
492
534
  }
493
535
  }
494
- return claimableMessages;
536
+ return claimableMessagesByStream;
495
537
  }
496
538
 
497
539
  // subscribers/strategy-resolver.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aikirun/client",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "description": "Client SDK for Aiki - connect to the server, start workflows, and manage execution",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -18,10 +18,10 @@
18
18
  "build": "tsup"
19
19
  },
20
20
  "dependencies": {
21
- "@aikirun/types": "0.9.2",
21
+ "@aikirun/types": "0.10.0",
22
22
  "@orpc/client": "^1.9.3",
23
23
  "ioredis": "^5.4.1",
24
- "zod": "^4.1.12"
24
+ "arktype": "^2.1.29"
25
25
  },
26
26
  "publishConfig": {
27
27
  "access": "public"