@sonamu-kit/tasks 0.1.3 → 0.3.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.
Files changed (215) hide show
  1. package/.oxlintrc.json +3 -0
  2. package/AGENTS.md +21 -0
  3. package/dist/backend.d.ts +126 -103
  4. package/dist/backend.d.ts.map +1 -1
  5. package/dist/backend.js +4 -1
  6. package/dist/backend.js.map +1 -1
  7. package/dist/client.d.ts +145 -132
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +220 -212
  10. package/dist/client.js.map +1 -1
  11. package/dist/config.d.ts +15 -8
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +22 -17
  14. package/dist/config.js.map +1 -1
  15. package/dist/core/duration.d.ts +5 -4
  16. package/dist/core/duration.d.ts.map +1 -1
  17. package/dist/core/duration.js +54 -59
  18. package/dist/core/duration.js.map +1 -1
  19. package/dist/core/error.d.ts +10 -7
  20. package/dist/core/error.d.ts.map +1 -1
  21. package/dist/core/error.js +21 -21
  22. package/dist/core/error.js.map +1 -1
  23. package/dist/core/json.d.ts +8 -3
  24. package/dist/core/json.d.ts.map +1 -1
  25. package/dist/core/result.d.ts +10 -14
  26. package/dist/core/result.d.ts.map +1 -1
  27. package/dist/core/result.js +21 -16
  28. package/dist/core/result.js.map +1 -1
  29. package/dist/core/retry.d.ts +42 -20
  30. package/dist/core/retry.d.ts.map +1 -1
  31. package/dist/core/retry.js +49 -20
  32. package/dist/core/retry.js.map +1 -1
  33. package/dist/core/schema.d.ts +57 -53
  34. package/dist/core/schema.d.ts.map +1 -1
  35. package/dist/core/step.d.ts +28 -78
  36. package/dist/core/step.d.ts.map +1 -1
  37. package/dist/core/step.js +53 -63
  38. package/dist/core/step.js.map +1 -1
  39. package/dist/core/workflow.d.ts +33 -61
  40. package/dist/core/workflow.d.ts.map +1 -1
  41. package/dist/core/workflow.js +31 -41
  42. package/dist/core/workflow.js.map +1 -1
  43. package/dist/database/backend.d.ts +53 -46
  44. package/dist/database/backend.d.ts.map +1 -1
  45. package/dist/database/backend.js +544 -545
  46. package/dist/database/backend.js.map +1 -1
  47. package/dist/database/base.js +48 -25
  48. package/dist/database/base.js.map +1 -1
  49. package/dist/database/migrations/20251212000000_0_init.d.ts +10 -0
  50. package/dist/database/migrations/20251212000000_0_init.d.ts.map +1 -0
  51. package/dist/database/migrations/20251212000000_0_init.js +8 -4
  52. package/dist/database/migrations/20251212000000_0_init.js.map +1 -1
  53. package/dist/database/migrations/20251212000000_1_tables.d.ts +10 -0
  54. package/dist/database/migrations/20251212000000_1_tables.d.ts.map +1 -0
  55. package/dist/database/migrations/20251212000000_1_tables.js +81 -83
  56. package/dist/database/migrations/20251212000000_1_tables.js.map +1 -1
  57. package/dist/database/migrations/20251212000000_2_fk.d.ts +10 -0
  58. package/dist/database/migrations/20251212000000_2_fk.d.ts.map +1 -0
  59. package/dist/database/migrations/20251212000000_2_fk.js +20 -43
  60. package/dist/database/migrations/20251212000000_2_fk.js.map +1 -1
  61. package/dist/database/migrations/20251212000000_3_indexes.d.ts +10 -0
  62. package/dist/database/migrations/20251212000000_3_indexes.d.ts.map +1 -0
  63. package/dist/database/migrations/20251212000000_3_indexes.js +88 -102
  64. package/dist/database/migrations/20251212000000_3_indexes.js.map +1 -1
  65. package/dist/database/pubsub.d.ts +7 -16
  66. package/dist/database/pubsub.d.ts.map +1 -1
  67. package/dist/database/pubsub.js +75 -73
  68. package/dist/database/pubsub.js.map +1 -1
  69. package/dist/execution.d.ts +20 -57
  70. package/dist/execution.d.ts.map +1 -1
  71. package/dist/execution.js +175 -174
  72. package/dist/execution.js.map +1 -1
  73. package/dist/index.d.ts +5 -8
  74. package/dist/index.js +5 -5
  75. package/dist/internal.d.ts +12 -12
  76. package/dist/internal.js +4 -4
  77. package/dist/registry.d.ts +33 -27
  78. package/dist/registry.d.ts.map +1 -1
  79. package/dist/registry.js +58 -49
  80. package/dist/registry.js.map +1 -1
  81. package/dist/worker.d.ts +57 -50
  82. package/dist/worker.d.ts.map +1 -1
  83. package/dist/worker.js +194 -198
  84. package/dist/worker.js.map +1 -1
  85. package/dist/workflow.d.ts +26 -27
  86. package/dist/workflow.d.ts.map +1 -1
  87. package/dist/workflow.js +20 -15
  88. package/dist/workflow.js.map +1 -1
  89. package/nodemon.json +1 -1
  90. package/package.json +18 -20
  91. package/src/backend.ts +28 -8
  92. package/src/chaos.test.ts +3 -1
  93. package/src/client.test.ts +2 -0
  94. package/src/client.ts +32 -8
  95. package/src/config.test.ts +1 -0
  96. package/src/config.ts +3 -2
  97. package/src/core/duration.test.ts +2 -1
  98. package/src/core/duration.ts +1 -1
  99. package/src/core/error.test.ts +1 -0
  100. package/src/core/error.ts +1 -1
  101. package/src/core/result.test.ts +1 -0
  102. package/src/core/retry.test.ts +181 -11
  103. package/src/core/retry.ts +95 -19
  104. package/src/core/schema.ts +2 -2
  105. package/src/core/step.test.ts +2 -1
  106. package/src/core/step.ts +4 -3
  107. package/src/core/workflow.test.ts +2 -1
  108. package/src/core/workflow.ts +4 -3
  109. package/src/database/backend.test.ts +1 -0
  110. package/src/database/backend.testsuite.ts +162 -39
  111. package/src/database/backend.ts +271 -35
  112. package/src/database/base.test.ts +41 -0
  113. package/src/database/base.ts +51 -2
  114. package/src/database/migrations/20251212000000_0_init.ts +2 -1
  115. package/src/database/migrations/20251212000000_1_tables.ts +2 -1
  116. package/src/database/migrations/20251212000000_2_fk.ts +2 -1
  117. package/src/database/migrations/20251212000000_3_indexes.ts +2 -1
  118. package/src/database/pubsub.test.ts +6 -3
  119. package/src/database/pubsub.ts +55 -33
  120. package/src/execution.test.ts +117 -0
  121. package/src/execution.ts +65 -10
  122. package/src/internal.ts +21 -1
  123. package/src/practices/01-remote-workflow.ts +1 -0
  124. package/src/registry.test.ts +1 -0
  125. package/src/registry.ts +1 -1
  126. package/src/testing/connection.ts +3 -1
  127. package/src/worker.test.ts +2 -0
  128. package/src/worker.ts +31 -9
  129. package/src/workflow.test.ts +1 -0
  130. package/src/workflow.ts +5 -2
  131. package/templates/openworkflow.config.ts +2 -1
  132. package/tsdown.config.ts +31 -0
  133. package/.swcrc +0 -17
  134. package/dist/chaos.test.d.ts +0 -2
  135. package/dist/chaos.test.d.ts.map +0 -1
  136. package/dist/chaos.test.js +0 -92
  137. package/dist/chaos.test.js.map +0 -1
  138. package/dist/client.test.d.ts +0 -2
  139. package/dist/client.test.d.ts.map +0 -1
  140. package/dist/client.test.js +0 -340
  141. package/dist/client.test.js.map +0 -1
  142. package/dist/config.test.d.ts +0 -2
  143. package/dist/config.test.d.ts.map +0 -1
  144. package/dist/config.test.js +0 -24
  145. package/dist/config.test.js.map +0 -1
  146. package/dist/core/duration.test.d.ts +0 -2
  147. package/dist/core/duration.test.d.ts.map +0 -1
  148. package/dist/core/duration.test.js +0 -265
  149. package/dist/core/duration.test.js.map +0 -1
  150. package/dist/core/error.test.d.ts +0 -2
  151. package/dist/core/error.test.d.ts.map +0 -1
  152. package/dist/core/error.test.js +0 -63
  153. package/dist/core/error.test.js.map +0 -1
  154. package/dist/core/json.js +0 -3
  155. package/dist/core/json.js.map +0 -1
  156. package/dist/core/result.test.d.ts +0 -2
  157. package/dist/core/result.test.d.ts.map +0 -1
  158. package/dist/core/result.test.js +0 -19
  159. package/dist/core/result.test.js.map +0 -1
  160. package/dist/core/retry.test.d.ts +0 -2
  161. package/dist/core/retry.test.d.ts.map +0 -1
  162. package/dist/core/retry.test.js +0 -37
  163. package/dist/core/retry.test.js.map +0 -1
  164. package/dist/core/schema.js +0 -4
  165. package/dist/core/schema.js.map +0 -1
  166. package/dist/core/step.test.d.ts +0 -2
  167. package/dist/core/step.test.d.ts.map +0 -1
  168. package/dist/core/step.test.js +0 -356
  169. package/dist/core/step.test.js.map +0 -1
  170. package/dist/core/workflow.test.d.ts +0 -2
  171. package/dist/core/workflow.test.d.ts.map +0 -1
  172. package/dist/core/workflow.test.js +0 -172
  173. package/dist/core/workflow.test.js.map +0 -1
  174. package/dist/database/backend.test.d.ts +0 -2
  175. package/dist/database/backend.test.d.ts.map +0 -1
  176. package/dist/database/backend.test.js +0 -19
  177. package/dist/database/backend.test.js.map +0 -1
  178. package/dist/database/backend.testsuite.d.ts +0 -20
  179. package/dist/database/backend.testsuite.d.ts.map +0 -1
  180. package/dist/database/backend.testsuite.js +0 -1174
  181. package/dist/database/backend.testsuite.js.map +0 -1
  182. package/dist/database/base.d.ts +0 -12
  183. package/dist/database/base.d.ts.map +0 -1
  184. package/dist/database/pubsub.test.d.ts +0 -2
  185. package/dist/database/pubsub.test.d.ts.map +0 -1
  186. package/dist/database/pubsub.test.js +0 -86
  187. package/dist/database/pubsub.test.js.map +0 -1
  188. package/dist/execution.test.d.ts +0 -2
  189. package/dist/execution.test.d.ts.map +0 -1
  190. package/dist/execution.test.js +0 -558
  191. package/dist/execution.test.js.map +0 -1
  192. package/dist/index.d.ts.map +0 -1
  193. package/dist/index.js.map +0 -1
  194. package/dist/internal.d.ts.map +0 -1
  195. package/dist/internal.js.map +0 -1
  196. package/dist/practices/01-remote-workflow.d.ts +0 -2
  197. package/dist/practices/01-remote-workflow.d.ts.map +0 -1
  198. package/dist/practices/01-remote-workflow.js +0 -70
  199. package/dist/practices/01-remote-workflow.js.map +0 -1
  200. package/dist/registry.test.d.ts +0 -2
  201. package/dist/registry.test.d.ts.map +0 -1
  202. package/dist/registry.test.js +0 -95
  203. package/dist/registry.test.js.map +0 -1
  204. package/dist/testing/connection.d.ts +0 -7
  205. package/dist/testing/connection.d.ts.map +0 -1
  206. package/dist/testing/connection.js +0 -39
  207. package/dist/testing/connection.js.map +0 -1
  208. package/dist/worker.test.d.ts +0 -2
  209. package/dist/worker.test.d.ts.map +0 -1
  210. package/dist/worker.test.js +0 -1164
  211. package/dist/worker.test.js.map +0 -1
  212. package/dist/workflow.test.d.ts +0 -2
  213. package/dist/workflow.test.d.ts.map +0 -1
  214. package/dist/workflow.test.js +0 -73
  215. package/dist/workflow.test.js.map +0 -1
package/package.json CHANGED
@@ -1,8 +1,13 @@
1
1
  {
2
2
  "name": "@sonamu-kit/tasks",
3
- "version": "0.1.3",
4
- "type": "module",
3
+ "version": "0.3.0",
5
4
  "description": "Sonamu Task - Simple & Distributed Task Queue",
5
+ "author": "CartaNova <dev@cartanova.ai>",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/cartanova-ai/sonamu.git"
9
+ },
10
+ "type": "module",
6
11
  "main": "./dist/index.js",
7
12
  "exports": {
8
13
  ".": {
@@ -16,12 +21,8 @@
16
21
  "development": "./src/internal.ts"
17
22
  }
18
23
  },
19
- "author": "CartaNova <dev@cartanova.ai>",
20
- "repository": {
21
- "type": "git",
22
- "url": "https://github.com/cartanova-ai/sonamu.git"
23
- },
24
24
  "dependencies": {
25
+ "c12": "^3.3.2",
25
26
  "date-fns": "^4.1.0",
26
27
  "date-fns-tz": "^3.2.0",
27
28
  "inflection": "^3.0.2",
@@ -30,33 +31,30 @@
30
31
  "pg": "^8.16.3",
31
32
  "rou3": "^0.7.10",
32
33
  "uuid": "^13.0.0",
33
- "zod": "^4.1.12",
34
- "c12": "^3.3.2"
34
+ "zod": "^4.3.6"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/inflection": "^2.0.0",
38
- "@biomejs/biome": "^2.3.10",
39
- "@swc/cli": "^0.7.8",
40
- "@swc/core": "^1.13.5",
41
- "@types/node": "25.0.3",
38
+ "@types/node": "25.0.7",
42
39
  "nodemon": "^3.1.10",
40
+ "tsdown": "^0.12.5",
43
41
  "tsx": "^4.20.6",
44
- "typescript": "^5.9.3",
45
- "vitest": "^4.0.10"
42
+ "typescript": "^6.0.0",
43
+ "vitest": "^4.1.2"
46
44
  },
47
45
  "peerDependencies": {
48
- "knex": "^3.1.0",
49
- "@logtape/logtape": "1.3.5"
46
+ "@logtape/logtape": "2.0.0",
47
+ "knex": "^3.1.0"
50
48
  },
51
49
  "optionalDependencies": {
52
50
  "pg-native": "^3.5.2"
53
51
  },
54
52
  "scripts": {
55
53
  "dev": "nodemon exec",
56
- "build": "swc src --config-file .swcrc -d dist --strip-leading-paths && tsc --emitDeclarationOnly",
57
- "check": "tsc --noEmit && biome format --write ./src/**/*.ts",
54
+ "build": "tsdown",
55
+ "check": "tsc --noEmit && oxlint src && oxfmt --check src",
58
56
  "check:type": "tsc --noEmit",
59
- "check:format": "biome format --write ./src/**/*.ts",
57
+ "check:format": "oxfmt src",
60
58
  "pretest": "tsx scripts/migrate.ts",
61
59
  "test": "vitest run",
62
60
  "test:watch": "vitest watch --standalone",
package/src/backend.ts CHANGED
@@ -1,8 +1,9 @@
1
- import type { SerializedError } from "./core/error";
2
- import type { JsonValue } from "./core/json";
3
- import type { StepAttempt, StepAttemptContext, StepKind } from "./core/step";
4
- import type { WorkflowRun } from "./core/workflow";
5
- import type { OnSubscribed } from "./database/pubsub";
1
+ import { type SerializedError } from "./core/error";
2
+ import { type JsonValue } from "./core/json";
3
+ import { type SerializableRetryPolicy } from "./core/retry";
4
+ import { type StepAttempt, type StepAttemptContext, type StepKind } from "./core/step";
5
+ import { type WorkflowRun } from "./core/workflow";
6
+ import { type OnSubscribed } from "./database/pubsub";
6
7
 
7
8
  export const DEFAULT_NAMESPACE_ID = "default";
8
9
 
@@ -26,6 +27,8 @@ export interface Backend {
26
27
  completeWorkflowRun(params: Readonly<CompleteWorkflowRunParams>): Promise<WorkflowRun>;
27
28
  failWorkflowRun(params: Readonly<FailWorkflowRunParams>): Promise<WorkflowRun>;
28
29
  cancelWorkflowRun(params: Readonly<CancelWorkflowRunParams>): Promise<WorkflowRun>;
30
+ pauseWorkflowRun(params: Readonly<PauseWorkflowRunParams>): Promise<WorkflowRun>;
31
+ resumeWorkflowRun(params: Readonly<ResumeWorkflowRunParams>): Promise<WorkflowRun>;
29
32
 
30
33
  // Step Attempts
31
34
  createStepAttempt(params: Readonly<CreateStepAttemptParams>): Promise<StepAttempt>;
@@ -33,8 +36,8 @@ export interface Backend {
33
36
  listStepAttempts(
34
37
  params: Readonly<ListStepAttemptsParams>,
35
38
  ): Promise<PaginatedResponse<StepAttempt>>;
36
- completeStepAttempt(params: Readonly<CompleteStepAttemptParams>): Promise<StepAttempt>;
37
- failStepAttempt(params: Readonly<FailStepAttemptParams>): Promise<StepAttempt>;
39
+ completeStepAttempt(params: Readonly<CompleteStepAttemptParams>): Promise<StepAttempt | null>;
40
+ failStepAttempt(params: Readonly<FailStepAttemptParams>): Promise<StepAttempt | null>;
38
41
  }
39
42
 
40
43
  export interface CreateWorkflowRunParams {
@@ -46,13 +49,19 @@ export interface CreateWorkflowRunParams {
46
49
  input: JsonValue | null;
47
50
  availableAt: Date | null; // null = immediately
48
51
  deadlineAt: Date | null; // null = no deadline
52
+ retryPolicy?: SerializableRetryPolicy;
49
53
  }
50
54
 
51
55
  export interface GetWorkflowRunParams {
52
56
  workflowRunId: string;
53
57
  }
54
58
 
55
- export type ListWorkflowRunsParams = PaginationOptions;
59
+ export interface ListWorkflowRunsParams extends PaginationOptions {
60
+ status?: string[];
61
+ workflowName?: string;
62
+ createdAfter?: Date;
63
+ createdBefore?: Date;
64
+ }
56
65
 
57
66
  export interface ClaimWorkflowRunParams {
58
67
  workerId: string;
@@ -81,12 +90,22 @@ export interface FailWorkflowRunParams {
81
90
  workflowRunId: string;
82
91
  workerId: string;
83
92
  error: SerializedError;
93
+ forceComplete?: boolean;
94
+ customDelayMs?: number;
84
95
  }
85
96
 
86
97
  export interface CancelWorkflowRunParams {
87
98
  workflowRunId: string;
88
99
  }
89
100
 
101
+ export interface PauseWorkflowRunParams {
102
+ workflowRunId: string;
103
+ }
104
+
105
+ export interface ResumeWorkflowRunParams {
106
+ workflowRunId: string;
107
+ }
108
+
90
109
  export interface CreateStepAttemptParams {
91
110
  workflowRunId: string;
92
111
  workerId: string;
@@ -122,6 +141,7 @@ export interface PaginationOptions {
122
141
  limit?: number;
123
142
  after?: string;
124
143
  before?: string;
144
+ order?: "asc" | "desc";
125
145
  }
126
146
 
127
147
  export interface PaginatedResponse<T> {
package/src/chaos.test.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { randomInt } from "node:crypto";
2
+
2
3
  import { describe, expect, test } from "vitest";
4
+
3
5
  import { OpenWorkflow } from "./client";
4
6
  import { createBackend } from "./testing/connection";
5
- import type { Worker } from "./worker";
7
+ import { type Worker } from "./worker";
6
8
 
7
9
  const TOTAL_STEPS = 50;
8
10
  const WORKER_COUNT = 3;
@@ -1,6 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
+
2
3
  import { afterEach, beforeEach, describe, expect, test } from "vitest";
3
4
  import { z } from "zod";
5
+
4
6
  import { BackendPostgres } from ".";
5
7
  import { declareWorkflow, OpenWorkflow } from "./client";
6
8
  import { KNEX_GLOBAL_CONFIG } from "./testing/connection";
package/src/client.ts CHANGED
@@ -1,11 +1,14 @@
1
- import type { Backend } from "./backend";
2
- import type { StandardSchemaV1 } from "./core/schema";
3
- import type { SchemaInput, SchemaOutput, WorkflowRun } from "./core/workflow";
1
+ import { type Backend } from "./backend";
2
+ import { serializeRetryPolicy } from "./core/retry";
3
+ import { type StandardSchemaV1 } from "./core/schema";
4
+ import { type SchemaInput, type SchemaOutput, type WorkflowRun } from "./core/workflow";
4
5
  import { validateInput } from "./core/workflow";
5
- import type { WorkflowFunction } from "./execution";
6
+ import { type WorkflowFunction } from "./execution";
6
7
  import { WorkflowRegistry } from "./registry";
7
- import { Worker, type WorkerOptions } from "./worker";
8
- import { defineWorkflow, defineWorkflowSpec, type Workflow, type WorkflowSpec } from "./workflow";
8
+ import { Worker } from "./worker";
9
+ import { type WorkerOptions } from "./worker";
10
+ import { defineWorkflow, defineWorkflowSpec } from "./workflow";
11
+ import { type Workflow, type WorkflowSpec } from "./workflow";
9
12
 
10
13
  const DEFAULT_RESULT_POLL_INTERVAL_MS = 1000; // 1s
11
14
  const DEFAULT_RESULT_TIMEOUT_MS = 5 * 60 * 1000; // 5m
@@ -101,6 +104,7 @@ export class OpenWorkflow {
101
104
  input: parsedInput ?? null,
102
105
  availableAt: null,
103
106
  deadlineAt: options?.deadlineAt ?? null,
107
+ retryPolicy: spec.retryPolicy ? serializeRetryPolicy(spec.retryPolicy) : undefined,
104
108
  });
105
109
 
106
110
  if (options?.publishToChannel) {
@@ -319,12 +323,32 @@ export class WorkflowRunHandle<Output> {
319
323
  }
320
324
 
321
325
  /**
322
- * Cancels the workflow run. Only workflows in pending, running, or sleeping
323
- * status can be canceled.
326
+ * Cancels the workflow run. Only workflows in pending, running, sleeping,
327
+ * or paused status can be canceled.
324
328
  */
325
329
  async cancel(): Promise<void> {
326
330
  await this.backend.cancelWorkflowRun({
327
331
  workflowRunId: this.workflowRun.id,
328
332
  });
329
333
  }
334
+
335
+ /**
336
+ * Pauses the workflow run. Only workflows in pending, running, or sleeping
337
+ * status can be paused.
338
+ */
339
+ async pause(): Promise<void> {
340
+ await this.backend.pauseWorkflowRun({
341
+ workflowRunId: this.workflowRun.id,
342
+ });
343
+ }
344
+
345
+ /**
346
+ * Resumes a paused workflow run. Sets the status back to pending so that
347
+ * a worker can reclaim it.
348
+ */
349
+ async resume(): Promise<void> {
350
+ await this.backend.resumeWorkflowRun({
351
+ workflowRunId: this.workflowRun.id,
352
+ });
353
+ }
330
354
  }
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "vitest";
2
+
2
3
  import { defineConfig } from "./config";
3
4
  import { createBackend } from "./testing/connection";
4
5
 
package/src/config.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { loadConfig as loadC12Config } from "c12";
2
- import type { Backend } from "./backend";
3
- import type { WorkerOptions } from "./worker";
2
+
3
+ import { type Backend } from "./backend";
4
+ import { type WorkerOptions } from "./worker";
4
5
 
5
6
  export interface OpenWorkflowConfig {
6
7
  backend: Backend;
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "vitest";
2
- import type { DurationString } from "./duration";
2
+
3
+ import { type DurationString } from "./duration";
3
4
  import { parseDuration } from "./duration";
4
5
  import { err, ok } from "./result";
5
6
 
@@ -1,4 +1,4 @@
1
- import type { Result } from "./result";
1
+ import { type Result } from "./result";
2
2
  import { err, ok } from "./result";
3
3
 
4
4
  type Years = "years" | "year" | "yrs" | "yr" | "y";
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "vitest";
2
+
2
3
  import { serializeError } from "./error";
3
4
 
4
5
  describe("serializeError", () => {
package/src/core/error.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { JsonValue } from "./json";
1
+ import { type JsonValue } from "./json";
2
2
 
3
3
  export type SerializedError = {
4
4
  name?: string;
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "vitest";
2
+
2
3
  import { err, ok } from "./result";
3
4
 
4
5
  describe("Result helpers", () => {
@@ -1,5 +1,17 @@
1
1
  import { describe, expect, test } from "vitest";
2
- import { calculateRetryDelayMs, DEFAULT_RETRY_POLICY, shouldRetry } from "./retry";
2
+
3
+ import { type SerializedError } from "./error";
4
+ import { type DynamicRetryPolicy, type StaticRetryPolicy } from "./retry";
5
+ import {
6
+ calculateRetryDelayMs,
7
+ DEFAULT_RETRY_POLICY,
8
+ isDynamicRetryPolicy,
9
+ isStaticRetryPolicy,
10
+ mergeRetryPolicy,
11
+ serializeRetryPolicy,
12
+ shouldRetry,
13
+ shouldRetryByPolicy,
14
+ } from "./retry";
3
15
 
4
16
  describe("calculateRetryDelayMs", () => {
5
17
  test("calculates exponential backoff correctly", () => {
@@ -9,14 +21,15 @@ describe("calculateRetryDelayMs", () => {
9
21
  expect(calculateRetryDelayMs(4)).toBe(8000);
10
22
  expect(calculateRetryDelayMs(5)).toBe(16_000);
11
23
  expect(calculateRetryDelayMs(6)).toBe(32_000);
12
- expect(calculateRetryDelayMs(7)).toBe(64_000);
24
+ // attempt 7: 1s * 2^6 = 64s = 64000ms, capped at 60000ms (max)
25
+ expect(calculateRetryDelayMs(7)).toBe(60_000);
13
26
  });
14
27
 
15
28
  test("caps delay at maximum interval", () => {
16
29
  const { maximumIntervalMs } = DEFAULT_RETRY_POLICY;
17
30
 
18
- // attempt 8: 1s * 2^7 = 128s = 128000ms, but capped at 100000ms (max)
19
- expect(calculateRetryDelayMs(8)).toBe(maximumIntervalMs);
31
+ // attempt 7: 1s * 2^6 = 64s = 64000ms, capped at 60000ms (max)
32
+ expect(calculateRetryDelayMs(7)).toBe(maximumIntervalMs);
20
33
 
21
34
  // attempts 10 & 100: should still be capped
22
35
  expect(calculateRetryDelayMs(10)).toBe(maximumIntervalMs);
@@ -26,16 +39,173 @@ describe("calculateRetryDelayMs", () => {
26
39
  test("handles edge cases", () => {
27
40
  // attempt 0: 1s * 2^-1 = 0.5s = 500ms
28
41
  expect(calculateRetryDelayMs(0)).toBe(500);
29
- expect(calculateRetryDelayMs(Infinity)).toBe(100_000);
42
+ expect(calculateRetryDelayMs(Infinity)).toBe(60_000);
30
43
  });
31
44
  });
32
45
 
33
46
  describe("shouldRetry", () => {
34
- test("always returns true with default policy (infinite retries)", () => {
35
- const retryPolicy = DEFAULT_RETRY_POLICY;
36
- expect(shouldRetry(retryPolicy, 1)).toBe(true);
37
- expect(shouldRetry(retryPolicy, 10)).toBe(true);
38
- expect(shouldRetry(retryPolicy, 100)).toBe(true);
39
- expect(shouldRetry(retryPolicy, 1000)).toBe(true);
47
+ test("returns false when attempt reaches maxAttempts", () => {
48
+ // 기본 정책: maxAttempts = 5
49
+ expect(shouldRetry(DEFAULT_RETRY_POLICY, 1)).toBe(true);
50
+ expect(shouldRetry(DEFAULT_RETRY_POLICY, 4)).toBe(true);
51
+ expect(shouldRetry(DEFAULT_RETRY_POLICY, 5)).toBe(false);
52
+ expect(shouldRetry(DEFAULT_RETRY_POLICY, 10)).toBe(false);
53
+ });
54
+ });
55
+
56
+ describe("shouldRetryByPolicy", () => {
57
+ test("respects maxAttempts from policy", () => {
58
+ expect(shouldRetryByPolicy({ maxAttempts: 3 }, 1)).toBe(true);
59
+ expect(shouldRetryByPolicy({ maxAttempts: 3 }, 2)).toBe(true);
60
+ expect(shouldRetryByPolicy({ maxAttempts: 3 }, 3)).toBe(false);
61
+ expect(shouldRetryByPolicy({ maxAttempts: 3 }, 4)).toBe(false);
62
+ });
63
+
64
+ test("uses default maxAttempts when not specified", () => {
65
+ expect(shouldRetryByPolicy({}, 1)).toBe(true);
66
+ expect(shouldRetryByPolicy({}, 4)).toBe(true);
67
+ expect(shouldRetryByPolicy({}, 5)).toBe(false);
68
+ });
69
+ });
70
+
71
+ describe("isDynamicRetryPolicy", () => {
72
+ test("returns true for policy with shouldRetry function", () => {
73
+ const dynamicPolicy: DynamicRetryPolicy = {
74
+ maxAttempts: 3,
75
+ shouldRetry: () => ({ shouldRetry: true, delayMs: 1000 }),
76
+ };
77
+ expect(isDynamicRetryPolicy(dynamicPolicy)).toBe(true);
78
+ });
79
+
80
+ test("returns false for static policy without shouldRetry", () => {
81
+ const staticPolicy: StaticRetryPolicy = {
82
+ maxAttempts: 5,
83
+ initialIntervalMs: 1000,
84
+ };
85
+ expect(isDynamicRetryPolicy(staticPolicy)).toBe(false);
86
+ });
87
+
88
+ test("returns false for empty policy", () => {
89
+ expect(isDynamicRetryPolicy({})).toBe(false);
90
+ });
91
+ });
92
+
93
+ describe("isStaticRetryPolicy", () => {
94
+ test("returns true for static policy without shouldRetry", () => {
95
+ const staticPolicy: StaticRetryPolicy = {
96
+ maxAttempts: 5,
97
+ initialIntervalMs: 1000,
98
+ };
99
+ expect(isStaticRetryPolicy(staticPolicy)).toBe(true);
100
+ });
101
+
102
+ test("returns true for empty policy", () => {
103
+ expect(isStaticRetryPolicy({})).toBe(true);
104
+ });
105
+
106
+ test("returns false for dynamic policy", () => {
107
+ const dynamicPolicy: DynamicRetryPolicy = {
108
+ maxAttempts: 3,
109
+ shouldRetry: () => ({ shouldRetry: true, delayMs: 1000 }),
110
+ };
111
+ expect(isStaticRetryPolicy(dynamicPolicy)).toBe(false);
112
+ });
113
+ });
114
+
115
+ describe("mergeRetryPolicy", () => {
116
+ test("returns default values when policy is undefined", () => {
117
+ const merged = mergeRetryPolicy(undefined);
118
+ expect(merged.maxAttempts).toBe(5);
119
+ expect(merged.initialIntervalMs).toBe(1000);
120
+ expect(merged.backoffCoefficient).toBe(2);
121
+ expect(merged.maximumIntervalMs).toBe(60_000);
122
+ });
123
+
124
+ test("uses provided values and fills missing with defaults for static policy", () => {
125
+ const merged = mergeRetryPolicy({ maxAttempts: 10, initialIntervalMs: 500 });
126
+ expect(merged.maxAttempts).toBe(10);
127
+ expect(merged.initialIntervalMs).toBe(500);
128
+ expect(merged.backoffCoefficient).toBe(2);
129
+ expect(merged.maximumIntervalMs).toBe(60_000);
130
+ });
131
+
132
+ test("returns only maxAttempts and shouldRetry for dynamic policy", () => {
133
+ const customFn = (_error: SerializedError, _attempt: number) => ({
134
+ shouldRetry: false,
135
+ delayMs: 5000,
136
+ });
137
+ const dynamicPolicy: DynamicRetryPolicy = {
138
+ maxAttempts: 3,
139
+ shouldRetry: customFn,
140
+ };
141
+ const merged = mergeRetryPolicy(dynamicPolicy);
142
+
143
+ expect(merged.maxAttempts).toBe(3);
144
+ expect(merged.shouldRetry).toBe(customFn);
145
+ // 동적 정책에서는 backoff 필드들이 없어야 합니다.
146
+ expect("initialIntervalMs" in merged).toBe(false);
147
+ expect("backoffCoefficient" in merged).toBe(false);
148
+ expect("maximumIntervalMs" in merged).toBe(false);
149
+ });
150
+
151
+ test("uses default maxAttempts for dynamic policy when not specified", () => {
152
+ const customFn = () => ({ shouldRetry: true, delayMs: 1000 });
153
+ const dynamicPolicy: DynamicRetryPolicy = {
154
+ shouldRetry: customFn,
155
+ };
156
+ const merged = mergeRetryPolicy(dynamicPolicy);
157
+
158
+ expect(merged.maxAttempts).toBe(5); // 기본값
159
+ expect(merged.shouldRetry).toBe(customFn);
160
+ });
161
+ });
162
+
163
+ describe("serializeRetryPolicy", () => {
164
+ test("returns empty object with hasDynamicPolicy=false for undefined", () => {
165
+ const serialized = serializeRetryPolicy(undefined);
166
+ expect(serialized.hasDynamicPolicy).toBe(false);
167
+ expect(serialized.maxAttempts).toBeUndefined();
168
+ });
169
+
170
+ test("serializes static fields for static policy", () => {
171
+ const serialized = serializeRetryPolicy({
172
+ maxAttempts: 10,
173
+ initialIntervalMs: 2000,
174
+ });
175
+ expect(serialized.maxAttempts).toBe(10);
176
+ expect(serialized.initialIntervalMs).toBe(2000);
177
+ expect(serialized.hasDynamicPolicy).toBe(false);
178
+ expect("shouldRetry" in serialized).toBe(false);
179
+ });
180
+
181
+ test("excludes backoff fields for dynamic policy", () => {
182
+ const dynamicPolicy: DynamicRetryPolicy = {
183
+ maxAttempts: 3,
184
+ shouldRetry: () => ({ shouldRetry: true, delayMs: 1000 }),
185
+ };
186
+ const serialized = serializeRetryPolicy(dynamicPolicy);
187
+
188
+ expect(serialized.maxAttempts).toBe(3);
189
+ expect(serialized.hasDynamicPolicy).toBe(true);
190
+ // 동적 정책에서는 backoff 필드들이 없어야 합니다.
191
+ expect(serialized.initialIntervalMs).toBeUndefined();
192
+ expect(serialized.backoffCoefficient).toBeUndefined();
193
+ expect(serialized.maximumIntervalMs).toBeUndefined();
194
+ });
195
+
196
+ test("includes backoff fields for static policy", () => {
197
+ const staticPolicy: StaticRetryPolicy = {
198
+ maxAttempts: 5,
199
+ initialIntervalMs: 2000,
200
+ backoffCoefficient: 3,
201
+ maximumIntervalMs: 30000,
202
+ };
203
+ const serialized = serializeRetryPolicy(staticPolicy);
204
+
205
+ expect(serialized.maxAttempts).toBe(5);
206
+ expect(serialized.initialIntervalMs).toBe(2000);
207
+ expect(serialized.backoffCoefficient).toBe(3);
208
+ expect(serialized.maximumIntervalMs).toBe(30000);
209
+ expect(serialized.hasDynamicPolicy).toBe(false);
40
210
  });
41
211
  });
package/src/core/retry.ts CHANGED
@@ -1,29 +1,105 @@
1
- export const DEFAULT_RETRY_POLICY = {
2
- initialIntervalMs: 1000, // 1s
1
+ import { type SerializedError } from "./error";
2
+
3
+ export interface RetryDecision {
4
+ shouldRetry: boolean;
5
+ delayMs: number;
6
+ }
7
+
8
+ export type RetryDecisionFn = (error: SerializedError, attempt: number) => RetryDecision;
9
+
10
+ export interface StaticRetryPolicy {
11
+ maxAttempts?: number;
12
+ initialIntervalMs?: number;
13
+ backoffCoefficient?: number;
14
+ maximumIntervalMs?: number;
15
+ }
16
+
17
+ export interface DynamicRetryPolicy {
18
+ maxAttempts?: number;
19
+ shouldRetry: RetryDecisionFn;
20
+ }
21
+
22
+ export type RetryPolicy = StaticRetryPolicy | DynamicRetryPolicy;
23
+
24
+ export interface SerializableRetryPolicy extends StaticRetryPolicy {
25
+ hasDynamicPolicy?: boolean;
26
+ }
27
+
28
+ export type MergedStaticRetryPolicy = Required<StaticRetryPolicy>;
29
+
30
+ export interface MergedDynamicRetryPolicy {
31
+ maxAttempts: number;
32
+ shouldRetry: RetryDecisionFn;
33
+ }
34
+
35
+ export type MergedRetryPolicy = MergedStaticRetryPolicy | MergedDynamicRetryPolicy;
36
+
37
+ export const DEFAULT_RETRY_POLICY: Required<StaticRetryPolicy> = {
38
+ maxAttempts: 5,
39
+ initialIntervalMs: 1000,
3
40
  backoffCoefficient: 2,
4
- maximumIntervalMs: 100 * 1000, // 100s
5
- maximumAttempts: Infinity, // unlimited
6
- } as const;
41
+ maximumIntervalMs: 60_000,
42
+ };
7
43
 
8
- export type RetryPolicy = typeof DEFAULT_RETRY_POLICY;
44
+ export function isDynamicRetryPolicy(policy: RetryPolicy): policy is DynamicRetryPolicy {
45
+ return "shouldRetry" in policy && typeof policy.shouldRetry === "function";
46
+ }
47
+
48
+ export function isStaticRetryPolicy(policy: RetryPolicy): policy is StaticRetryPolicy {
49
+ return !isDynamicRetryPolicy(policy);
50
+ }
9
51
 
10
- /**
11
- * Calculate the next retry delay using exponential backoff.
12
- * @param attemptNumber - Attempt number (1-based)
13
- * @returns Delay in milliseconds
14
- */
15
52
  export function calculateRetryDelayMs(attemptNumber: number): number {
16
53
  const { initialIntervalMs, backoffCoefficient, maximumIntervalMs } = DEFAULT_RETRY_POLICY;
17
54
  const backoffMs = initialIntervalMs * backoffCoefficient ** (attemptNumber - 1);
18
55
  return Math.min(backoffMs, maximumIntervalMs);
19
56
  }
20
57
 
21
- /**
22
- * Check if an operation should be retried based on the retry policy.
23
- * @param retryPolicy - Retry policy
24
- * @param attemptNumber - Attempt number (1-based)
25
- * @returns True if another attempt should be made
26
- */
27
- export function shouldRetry(retryPolicy: RetryPolicy, attemptNumber: number): boolean {
28
- return attemptNumber < retryPolicy.maximumAttempts;
58
+ export function shouldRetry(retryPolicy: StaticRetryPolicy, attemptNumber: number): boolean {
59
+ const maxAttempts = retryPolicy.maxAttempts ?? DEFAULT_RETRY_POLICY.maxAttempts;
60
+ return attemptNumber < maxAttempts;
61
+ }
62
+
63
+ export function shouldRetryByPolicy(policy: StaticRetryPolicy, attemptNumber: number): boolean {
64
+ const maxAttempts = policy.maxAttempts ?? DEFAULT_RETRY_POLICY.maxAttempts;
65
+ return attemptNumber < maxAttempts;
66
+ }
67
+
68
+ export function mergeRetryPolicy(policy: StaticRetryPolicy | undefined): MergedStaticRetryPolicy;
69
+ export function mergeRetryPolicy(policy: DynamicRetryPolicy): MergedDynamicRetryPolicy;
70
+ export function mergeRetryPolicy(policy?: RetryPolicy): MergedRetryPolicy;
71
+ export function mergeRetryPolicy(policy?: RetryPolicy): MergedRetryPolicy {
72
+ if (policy && isDynamicRetryPolicy(policy)) {
73
+ return {
74
+ maxAttempts: policy.maxAttempts ?? DEFAULT_RETRY_POLICY.maxAttempts,
75
+ shouldRetry: policy.shouldRetry,
76
+ };
77
+ }
78
+ return {
79
+ maxAttempts: policy?.maxAttempts ?? DEFAULT_RETRY_POLICY.maxAttempts,
80
+ initialIntervalMs: policy?.initialIntervalMs ?? DEFAULT_RETRY_POLICY.initialIntervalMs,
81
+ backoffCoefficient: policy?.backoffCoefficient ?? DEFAULT_RETRY_POLICY.backoffCoefficient,
82
+ maximumIntervalMs: policy?.maximumIntervalMs ?? DEFAULT_RETRY_POLICY.maximumIntervalMs,
83
+ };
84
+ }
85
+
86
+ export function serializeRetryPolicy(policy?: RetryPolicy): SerializableRetryPolicy {
87
+ if (!policy) {
88
+ return { hasDynamicPolicy: false };
89
+ }
90
+
91
+ if (isDynamicRetryPolicy(policy)) {
92
+ return {
93
+ maxAttempts: policy.maxAttempts,
94
+ hasDynamicPolicy: true,
95
+ };
96
+ }
97
+
98
+ return {
99
+ maxAttempts: policy.maxAttempts,
100
+ initialIntervalMs: policy.initialIntervalMs,
101
+ backoffCoefficient: policy.backoffCoefficient,
102
+ maximumIntervalMs: policy.maximumIntervalMs,
103
+ hasDynamicPolicy: false,
104
+ };
29
105
  }
@@ -68,7 +68,7 @@ export declare namespace StandardSchemaV1 {
68
68
  Schema["~standard"]["types"]
69
69
  >["output"];
70
70
 
71
- // eslint-disable-next-line unicorn/require-module-specifiers
72
- // biome-ignore lint/complexity/noUselessEmptyExport: needed for granular visibility control of TS namespace
71
+ // needed for granular visibility control of TS namespace
72
+ // oxlint-disable-next-line
73
73
  export {};
74
74
  }
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from "vitest";
2
+
2
3
  import { ok } from "./result";
3
- import type { StepAttempt, StepAttemptCache } from "./step";
4
+ import { type StepAttempt, type StepAttemptCache } from "./step";
4
5
  import {
5
6
  addToStepAttemptCache,
6
7
  calculateSleepResumeAt,