@sonamu-kit/tasks 0.2.0 → 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 -107
  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 +219 -213
  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 +37 -31
  30. package/dist/core/retry.d.ts.map +1 -1
  31. package/dist/core/retry.js +44 -51
  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 -577
  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 -59
  70. package/dist/execution.d.ts.map +1 -1
  71. package/dist/execution.js +175 -188
  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 -13
  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 -199
  84. package/dist/worker.js.map +1 -1
  85. package/dist/workflow.d.ts +26 -30
  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 +17 -19
  91. package/src/backend.ts +25 -9
  92. package/src/chaos.test.ts +3 -1
  93. package/src/client.test.ts +2 -0
  94. package/src/client.ts +30 -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 +3 -2
  103. package/src/core/retry.ts +1 -1
  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 +44 -40
  111. package/src/database/backend.ts +207 -25
  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 +2 -0
  121. package/src/execution.ts +49 -10
  122. package/src/internal.ts +15 -15
  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 +30 -9
  129. package/src/workflow.test.ts +1 -0
  130. package/src/workflow.ts +3 -3
  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 -198
  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 -1280
  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 -662
  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
@@ -0,0 +1,41 @@
1
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { describe, expect, test } from "vitest";
6
+
7
+ import { createMigrationSource } from "./base";
8
+
9
+ describe("createMigrationSource", () => {
10
+ test("preserves emitted migration filenames for knex identity", async () => {
11
+ const migrationsDir = await mkdtemp(path.join(tmpdir(), "sonamu-tasks-migrations-"));
12
+
13
+ try {
14
+ await Promise.all([
15
+ writeFile(
16
+ path.join(migrationsDir, "20251212000000_0_init.js"),
17
+ "export const up = async () => {}; export const down = async () => {};",
18
+ ),
19
+ writeFile(path.join(migrationsDir, "20251212000000_0_init.d.ts"), "export {};"),
20
+ writeFile(
21
+ path.join(migrationsDir, "20251212000000_1_tables.ts"),
22
+ "export const up = async () => {}; export const down = async () => {};",
23
+ ),
24
+ writeFile(path.join(migrationsDir, "20251212000000_2_fk.js.map"), "{}"),
25
+ ]);
26
+
27
+ const migrationSource = createMigrationSource(migrationsDir);
28
+ const migrations = await migrationSource.getMigrations([]);
29
+
30
+ expect(migrations.map((migration) => migration.fileName)).toStrictEqual([
31
+ "20251212000000_0_init.js",
32
+ "20251212000000_1_tables.ts",
33
+ ]);
34
+ expect(
35
+ migrations.map((migration) => migrationSource.getMigrationName(migration)),
36
+ ).toStrictEqual(["20251212000000_0_init.js", "20251212000000_1_tables.ts"]);
37
+ } finally {
38
+ await rm(migrationsDir, { recursive: true, force: true });
39
+ }
40
+ });
41
+ });
@@ -1,7 +1,55 @@
1
+ import { readdir } from "node:fs/promises";
1
2
  import path from "node:path";
2
- import knex, { type Knex } from "knex";
3
+ import { pathToFileURL } from "node:url";
4
+
5
+ import knex from "knex";
6
+ import { type Knex } from "knex";
3
7
 
4
8
  export const DEFAULT_SCHEMA = "sonamu_tasks";
9
+ const MIGRATION_FILE_PATTERN = /\.(?:[cm]?[jt]s)$/;
10
+ const TYPE_DECLARATION_FILE_PATTERN = /\.d\.[cm]?[jt]s$/;
11
+
12
+ type MigrationModule = {
13
+ up: (knex: Knex) => Promise<void>;
14
+ down: (knex: Knex) => Promise<void>;
15
+ };
16
+
17
+ type MigrationEntry = {
18
+ canonicalName: string;
19
+ fileName: string;
20
+ };
21
+
22
+ function toCanonicalMigrationName(fileName: string): string {
23
+ return fileName.replace(/\.(?:[cm]?[jt]s)$/, ".ts");
24
+ }
25
+
26
+ async function listMigrationEntries(directory: string): Promise<MigrationEntry[]> {
27
+ const dirents = await readdir(directory, { withFileTypes: true });
28
+
29
+ return dirents
30
+ .filter(
31
+ (dirent) =>
32
+ dirent.isFile() &&
33
+ MIGRATION_FILE_PATTERN.test(dirent.name) &&
34
+ !TYPE_DECLARATION_FILE_PATTERN.test(dirent.name),
35
+ )
36
+ .map((dirent) => ({
37
+ canonicalName: toCanonicalMigrationName(dirent.name),
38
+ fileName: dirent.name,
39
+ }))
40
+ .sort((left, right) => left.canonicalName.localeCompare(right.canonicalName));
41
+ }
42
+
43
+ export function createMigrationSource(directory: string): Knex.MigrationSource<MigrationEntry> {
44
+ return {
45
+ getMigrations: async (_loadExtensions) => listMigrationEntries(directory),
46
+ getMigrationName: (migration) => migration.fileName,
47
+ getMigration: async (migration): Promise<MigrationModule> => {
48
+ const migrationUrl = pathToFileURL(path.join(directory, migration.fileName)).href;
49
+ return import(migrationUrl) as Promise<MigrationModule>;
50
+ },
51
+ };
52
+ }
5
53
 
6
54
  /**
7
55
  * migrate applies pending migrations to the database. Does nothing if the
@@ -10,9 +58,10 @@ export const DEFAULT_SCHEMA = "sonamu_tasks";
10
58
  export async function migrate(config: Knex.Config, schema: string) {
11
59
  const instance = knex({ ...config, pool: { min: 1, max: 1 } });
12
60
  try {
61
+ const migrationDirectory = path.join(import.meta.dirname, "migrations");
13
62
  await instance.schema.createSchemaIfNotExists(schema);
14
63
  await instance.migrate.latest({
15
- directory: path.join(import.meta.dirname, "migrations"),
64
+ migrationSource: createMigrationSource(migrationDirectory),
16
65
  schemaName: schema,
17
66
  });
18
67
  } finally {
@@ -1,4 +1,5 @@
1
- import type { Knex } from "knex";
1
+ import { type Knex } from "knex";
2
+
2
3
  import { DEFAULT_SCHEMA } from "../base";
3
4
 
4
5
  export async function up(knex: Knex): Promise<void> {
@@ -1,4 +1,5 @@
1
- import type { Knex } from "knex";
1
+ import { type Knex } from "knex";
2
+
2
3
  import { DEFAULT_SCHEMA } from "../base";
3
4
 
4
5
  export async function up(knex: Knex): Promise<void> {
@@ -1,4 +1,5 @@
1
- import type { Knex } from "knex";
1
+ import { type Knex } from "knex";
2
+
2
3
  import { DEFAULT_SCHEMA } from "../base";
3
4
 
4
5
  export async function up(knex: Knex): Promise<void> {
@@ -1,4 +1,5 @@
1
- import type { Knex } from "knex";
1
+ import { type Knex } from "knex";
2
+
2
3
  import { DEFAULT_SCHEMA } from "../base";
3
4
 
4
5
  export async function up(knex: Knex): Promise<void> {
@@ -1,8 +1,11 @@
1
- import knex, { type Knex } from "knex";
1
+ import knex from "knex";
2
+ import { type Knex } from "knex";
2
3
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
3
- import type { Result } from "../core/result";
4
+
5
+ import { type Result } from "../core/result";
4
6
  import { KNEX_GLOBAL_CONFIG } from "../testing/connection";
5
- import { type OnSubscribed, PostgresPubSub } from "./pubsub";
7
+ import { PostgresPubSub } from "./pubsub";
8
+ import { type OnSubscribed } from "./pubsub";
6
9
 
7
10
  describe("PostgresPubSub", () => {
8
11
  let knexInstance: Knex;
@@ -1,15 +1,21 @@
1
1
  import assert from "assert";
2
- import type { Knex } from "knex";
3
- import { err, ok, type Result } from "../core/result";
2
+
3
+ import { type Knex } from "knex";
4
+
5
+ import { err, ok } from "../core/result";
6
+ import { type Result } from "../core/result";
4
7
 
5
8
  export type OnSubscribed = (result: Result<string | null>) => void | Promise<void>;
6
9
 
7
10
  export class PostgresPubSub {
8
11
  private _destroyed = false;
12
+ private _connecting = false;
9
13
  private _onClosed: () => Promise<void>;
14
+ private _onNotification: (msg: { channel: string; payload: unknown }) => Promise<void>;
15
+ private _onError: (error: Error) => Promise<void>;
10
16
  private _listeners = new Map<string, Set<OnSubscribed>>();
11
17
 
12
- // biome-ignore lint/suspicious/noExplicitAny: Knex exposes a connection as any
18
+ // oxlint-disable-next-line @typescript-eslint/no-explicit-any -- Knex exposes a connection as any
13
19
  private _connection: any | null = null;
14
20
 
15
21
  private constructor(private readonly knex: Knex) {
@@ -21,46 +27,60 @@ export class PostgresPubSub {
21
27
 
22
28
  await this.connect();
23
29
  }).bind(this);
24
- }
25
30
 
26
- get destroyed() {
27
- return this._destroyed;
28
- }
31
+ this._onNotification = (async ({
32
+ channel,
33
+ payload: rawPayload,
34
+ }: {
35
+ channel: string;
36
+ payload: unknown;
37
+ }) => {
38
+ const payload = typeof rawPayload === "string" && rawPayload.length !== 0 ? rawPayload : null;
39
+ const listeners = this._listeners.get(channel);
40
+ if (!listeners) {
41
+ return;
42
+ }
29
43
 
30
- // acquire new raw connection and set up listeners
31
- async connect() {
32
- const connection = await this.knex.client.acquireRawConnection();
33
- connection.on("close", this._onClosed);
34
- connection.on(
35
- "notification",
36
- async ({ channel, payload: rawPayload }: { channel: string; payload: unknown }) => {
37
- const payload =
38
- typeof rawPayload === "string" && rawPayload.length !== 0 ? rawPayload : null;
39
- const listeners = this._listeners.get(channel);
40
- if (!listeners) {
41
- return;
42
- }
43
-
44
- const result = ok(payload);
45
- await Promise.allSettled(
46
- Array.from(listeners.values()).map((listener) => Promise.resolve(listener(result))),
47
- );
48
- },
49
- );
50
- connection.on("error", async (error: Error) => {
44
+ const result = ok(payload);
45
+ await Promise.allSettled(
46
+ Array.from(listeners.values()).map((listener) => Promise.resolve(listener(result))),
47
+ );
48
+ }).bind(this);
49
+
50
+ this._onError = (async (error: Error) => {
51
51
  const result = err(error);
52
52
  await Promise.allSettled(
53
53
  Array.from(this._listeners.values())
54
54
  .flatMap((listeners) => Array.from(listeners))
55
55
  .map((listener) => Promise.resolve(listener(result))),
56
56
  );
57
- });
57
+ }).bind(this);
58
+ }
58
59
 
59
- for (const channel of this._listeners.keys()) {
60
- connection.query(`LISTEN ${channel}`);
61
- }
60
+ get destroyed() {
61
+ return this._destroyed;
62
+ }
63
+
64
+ // acquire new raw connection and set up listeners
65
+ async connect() {
66
+ // 동시 재연결 시도로 인한 연결 누수를 방지합니다.
67
+ if (this._connecting) return;
68
+ this._connecting = true;
69
+
70
+ try {
71
+ const connection = await this.knex.client.acquireRawConnection();
72
+ connection.on("close", this._onClosed);
73
+ connection.on("notification", this._onNotification);
74
+ connection.on("error", this._onError);
62
75
 
63
- this._connection = connection;
76
+ for (const channel of this._listeners.keys()) {
77
+ connection.query(`LISTEN ${channel}`);
78
+ }
79
+
80
+ this._connection = connection;
81
+ } finally {
82
+ this._connecting = false;
83
+ }
64
84
  }
65
85
 
66
86
  // destroy the listener and close the connection, do not destroy the knex connection
@@ -70,6 +90,8 @@ export class PostgresPubSub {
70
90
  }
71
91
  try {
72
92
  this._connection.off("close", this._onClosed);
93
+ this._connection.off("notification", this._onNotification);
94
+ this._connection.off("error", this._onError);
73
95
  await this.knex.client.destroyRawConnection(this._connection);
74
96
  } finally {
75
97
  this._destroyed = true;
@@ -1,5 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
+
2
3
  import { afterAll, beforeAll, describe, expect, test } from "vitest";
4
+
3
5
  import { BackendPostgres } from ".";
4
6
  import { OpenWorkflow } from "./client";
5
7
  import { KNEX_GLOBAL_CONFIG } from "./testing/connection";
package/src/execution.ts CHANGED
@@ -1,9 +1,10 @@
1
- import type { Backend } from "./backend";
2
- import type { DurationString } from "./core/duration";
1
+ import { type Backend } from "./backend";
2
+ import { type DurationString } from "./core/duration";
3
3
  import { serializeError } from "./core/error";
4
- import type { JsonValue } from "./core/json";
5
- import { isDynamicRetryPolicy, type RetryPolicy } from "./core/retry";
6
- import type { StepAttempt, StepAttemptCache } from "./core/step";
4
+ import { type JsonValue } from "./core/json";
5
+ import { isDynamicRetryPolicy } from "./core/retry";
6
+ import { type RetryPolicy } from "./core/retry";
7
+ import { type StepAttempt, type StepAttemptCache } from "./core/step";
7
8
  import {
8
9
  addToStepAttemptCache,
9
10
  calculateSleepResumeAt,
@@ -12,7 +13,7 @@ import {
12
13
  getCachedStepAttempt,
13
14
  normalizeStepOutput,
14
15
  } from "./core/step";
15
- import type { WorkflowRun } from "./core/workflow";
16
+ import { type WorkflowRun } from "./core/workflow";
16
17
 
17
18
  /**
18
19
  * Config for an individual step defined with `step.run()`.
@@ -70,6 +71,16 @@ class SleepSignal extends Error {
70
71
  }
71
72
  }
72
73
 
74
+ /**
75
+ * 외부에서 workflow 상태가 변경되었을 때 실행을 안전하게 중단하기 위한 에러입니다.
76
+ */
77
+ class WorkflowAbortedError extends Error {
78
+ constructor() {
79
+ super("Workflow execution aborted");
80
+ this.name = "WorkflowAbortedError";
81
+ }
82
+ }
83
+
73
84
  /**
74
85
  * Configures the options for a StepExecutor.
75
86
  */
@@ -78,6 +89,7 @@ export interface StepExecutorOptions {
78
89
  workflowRunId: string;
79
90
  workerId: string;
80
91
  attempts: StepAttempt[];
92
+ signal?: AbortSignal;
81
93
  }
82
94
 
83
95
  /**
@@ -88,12 +100,14 @@ export class StepExecutor implements StepApi {
88
100
  private readonly backend: Backend;
89
101
  private readonly workflowRunId: string;
90
102
  private readonly workerId: string;
103
+ private readonly signal?: AbortSignal;
91
104
  private cache: StepAttemptCache;
92
105
 
93
106
  constructor(options: Readonly<StepExecutorOptions>) {
94
107
  this.backend = options.backend;
95
108
  this.workflowRunId = options.workflowRunId;
96
109
  this.workerId = options.workerId;
110
+ this.signal = options.signal;
97
111
 
98
112
  this.cache = createStepAttemptCacheFromAttempts(options.attempts);
99
113
  }
@@ -103,6 +117,9 @@ export class StepExecutor implements StepApi {
103
117
  fn: StepFunction<Output>,
104
118
  ): Promise<Output> {
105
119
  const { name } = config;
120
+ if (this.signal?.aborted) {
121
+ throw new WorkflowAbortedError();
122
+ }
106
123
 
107
124
  // return cached result if available
108
125
  const existingAttempt = getCachedStepAttempt(this.cache, name);
@@ -125,31 +142,42 @@ export class StepExecutor implements StepApi {
125
142
  const result = await fn();
126
143
  const output = normalizeStepOutput(result);
127
144
 
128
- // mark success
145
+ // mark success — null이면 외부에서 워크플로우 상태가 변경된 것입니다(pause/cancel).
129
146
  const savedAttempt = await this.backend.completeStepAttempt({
130
147
  workflowRunId: this.workflowRunId,
131
148
  stepAttemptId: attempt.id,
132
149
  workerId: this.workerId,
133
150
  output,
134
151
  });
152
+ if (!savedAttempt) {
153
+ throw new WorkflowAbortedError();
154
+ }
135
155
 
136
156
  // cache result
137
157
  this.cache = addToStepAttemptCache(this.cache, savedAttempt);
138
158
 
139
159
  return savedAttempt.output as Output;
140
160
  } catch (error) {
141
- // mark failure
142
- await this.backend.failStepAttempt({
161
+ // mark failure — null이면 외부에서 워크플로우 상태가 변경된 것입니다(pause/cancel).
162
+ const failed = await this.backend.failStepAttempt({
143
163
  workflowRunId: this.workflowRunId,
144
164
  stepAttemptId: attempt.id,
145
165
  workerId: this.workerId,
146
166
  error: serializeError(error),
147
167
  });
168
+ if (!failed) {
169
+ throw new WorkflowAbortedError();
170
+ }
171
+
148
172
  throw error;
149
173
  }
150
174
  }
151
175
 
152
176
  async sleep(name: string, duration: DurationString): Promise<void> {
177
+ if (this.signal?.aborted) {
178
+ throw new WorkflowAbortedError();
179
+ }
180
+
153
181
  // return cached result if this sleep already completed
154
182
  const existingAttempt = getCachedStepAttempt(this.cache, name);
155
183
  if (existingAttempt) return;
@@ -188,6 +216,7 @@ export interface ExecuteWorkflowParams {
188
216
  workflowVersion: string | null;
189
217
  workerId: string;
190
218
  retryPolicy?: RetryPolicy;
219
+ signal?: AbortSignal;
191
220
  }
192
221
 
193
222
  /**
@@ -200,7 +229,8 @@ export interface ExecuteWorkflowParams {
200
229
  * @param params - The execution parameters
201
230
  */
202
231
  export async function executeWorkflow(params: Readonly<ExecuteWorkflowParams>): Promise<void> {
203
- const { backend, workflowRun, workflowFn, workflowVersion, workerId, retryPolicy } = params;
232
+ const { backend, workflowRun, workflowFn, workflowVersion, workerId, retryPolicy, signal } =
233
+ params;
204
234
 
205
235
  try {
206
236
  // load all pages of step history
@@ -244,6 +274,9 @@ export async function executeWorkflow(params: Readonly<ExecuteWorkflowParams>):
244
274
  workerId,
245
275
  output: null,
246
276
  });
277
+ if (!completed) {
278
+ throw new WorkflowAbortedError();
279
+ }
247
280
 
248
281
  // update cache w/ completed attempt
249
282
  attempts[i] = completed;
@@ -256,6 +289,7 @@ export async function executeWorkflow(params: Readonly<ExecuteWorkflowParams>):
256
289
  workflowRunId: workflowRun.id,
257
290
  workerId,
258
291
  attempts,
292
+ signal,
259
293
  });
260
294
 
261
295
  // execute workflow
@@ -283,6 +317,11 @@ export async function executeWorkflow(params: Readonly<ExecuteWorkflowParams>):
283
317
  return;
284
318
  }
285
319
 
320
+ // heartbeat 실패로 abort된 경우, failWorkflowRun을 호출하지 않고 조용히 종료합니다.
321
+ if (error instanceof WorkflowAbortedError || signal?.aborted) {
322
+ return;
323
+ }
324
+
286
325
  // claimWorkflowRun에서 이미 attempts가 증가된 상태입니다.
287
326
  let forceComplete = false;
288
327
  let customDelayMs: number | undefined;
package/src/internal.ts CHANGED
@@ -3,26 +3,26 @@ export type * from "./client";
3
3
  export { loadConfig } from "./config";
4
4
  export type { DurationString } from "./core/duration";
5
5
  export type { JsonValue } from "./core/json";
6
+ export type {
7
+ DynamicRetryPolicy,
8
+ MergedDynamicRetryPolicy,
9
+ MergedRetryPolicy,
10
+ MergedStaticRetryPolicy,
11
+ RetryDecision,
12
+ RetryDecisionFn,
13
+ RetryPolicy,
14
+ SerializableRetryPolicy,
15
+ StaticRetryPolicy,
16
+ } from "./core/retry";
6
17
  export {
18
+ calculateRetryDelayMs,
7
19
  DEFAULT_RETRY_POLICY,
20
+ isDynamicRetryPolicy,
21
+ isStaticRetryPolicy,
8
22
  mergeRetryPolicy,
9
23
  serializeRetryPolicy,
10
- shouldRetryByPolicy,
11
- calculateRetryDelayMs,
12
24
  shouldRetry,
13
- isDynamicRetryPolicy,
14
- isStaticRetryPolicy,
15
- } from "./core/retry";
16
- export type {
17
- RetryPolicy,
18
- StaticRetryPolicy,
19
- DynamicRetryPolicy,
20
- SerializableRetryPolicy,
21
- RetryDecision,
22
- RetryDecisionFn,
23
- MergedStaticRetryPolicy,
24
- MergedDynamicRetryPolicy,
25
- MergedRetryPolicy,
25
+ shouldRetryByPolicy,
26
26
  } from "./core/retry";
27
27
  export type { StandardSchemaV1 } from "./core/schema";
28
28
  export type { StepAttempt } from "./core/step";
@@ -1,5 +1,6 @@
1
1
  import assert from "node:assert";
2
2
  import { randomUUID } from "node:crypto";
3
+
3
4
  import { BackendPostgres, OpenWorkflow } from "../";
4
5
  import { KNEX_GLOBAL_CONFIG } from "../testing/connection";
5
6
 
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "vitest";
2
+
2
3
  import { WorkflowRegistry } from "./registry";
3
4
  import { defineWorkflow } from "./workflow";
4
5
 
package/src/registry.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Workflow } from "./workflow";
1
+ import { type Workflow } from "./workflow";
2
2
 
3
3
  /**
4
4
  * A registry for storing and retrieving workflows by name and version.
@@ -1,5 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import type { Knex } from "knex";
2
+
3
+ import { type Knex } from "knex";
4
+
3
5
  import { BackendPostgres } from "../database/backend";
4
6
  import { migrate as baseMigrate, DEFAULT_SCHEMA } from "../database/base";
5
7
 
@@ -1,5 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
+
2
3
  import { afterEach, beforeEach, describe, expect, test } from "vitest";
4
+
3
5
  import { declareWorkflow, OpenWorkflow } from "./client";
4
6
  import { BackendPostgres } from "./database/backend";
5
7
  import { KNEX_GLOBAL_CONFIG } from "./testing/connection";
package/src/worker.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import type { Backend } from "./backend";
3
- import type { WorkflowRun } from "./core/workflow";
2
+
3
+ import { type Backend } from "./backend";
4
+ import { type WorkflowRun } from "./core/workflow";
4
5
  import { executeWorkflow } from "./execution";
5
- import type { WorkflowRegistry } from "./registry";
6
- import type { Workflow } from "./workflow";
6
+ import { type WorkflowRegistry } from "./registry";
7
+ import { type Workflow } from "./workflow";
7
8
 
8
9
  const DEFAULT_LEASE_DURATION_MS = 30 * 1000; // 30s
9
10
  const DEFAULT_POLL_INTERVAL_MS = 100; // 100ms
@@ -32,6 +33,7 @@ export class Worker {
32
33
  private readonly activeExecutions = new Set<WorkflowExecution>();
33
34
  private running = false;
34
35
  private loopPromise: Promise<void> | null = null;
36
+ private subscribed = false;
35
37
 
36
38
  private usePubSub: boolean;
37
39
  private listenDelay: number;
@@ -72,6 +74,8 @@ export class Worker {
72
74
 
73
75
  // wait for all active executions to finish
74
76
  while (this.activeExecutions.size > 0) await sleep(100);
77
+
78
+ this.subscribed = false;
75
79
  }
76
80
 
77
81
  /**
@@ -108,9 +112,10 @@ export class Worker {
108
112
  * Only sleeps when no work was claimed to avoid busy-waiting.
109
113
  */
110
114
  private async runLoop(): Promise<void> {
111
- if (this.usePubSub) {
115
+ if (this.usePubSub && !this.subscribed) {
116
+ this.subscribed = true;
112
117
  this.backend.subscribe(async (result) => {
113
- if (!result.ok) {
118
+ if (!result.ok || !this.running) {
114
119
  return;
115
120
  }
116
121
 
@@ -134,7 +139,7 @@ export class Worker {
134
139
  }
135
140
 
136
141
  /*
137
- * Cclaim and process a workflow run for the given worker ID. Do not await the
142
+ * Claim and process a workflow run for the given worker ID. Do not await the
138
143
  * processing here to avoid blocking the caller.
139
144
  * Returns the claimed workflow run, or null if none was available.
140
145
  */
@@ -203,8 +208,13 @@ export class Worker {
203
208
  workflowVersion: execution.workflowRun.version,
204
209
  workerId: execution.workerId,
205
210
  retryPolicy: workflow.spec.retryPolicy,
211
+ signal: execution.signal,
206
212
  });
207
213
  } catch (error) {
214
+ if (execution.signal.aborted) {
215
+ return;
216
+ }
217
+
208
218
  // specifically for unexpected errors in the execution wrapper itself, not
209
219
  // for business logic errors (those are handled inside executeWorkflow)
210
220
  console.error(
@@ -233,6 +243,7 @@ class WorkflowExecution {
233
243
  workflowRun: WorkflowRun;
234
244
  workerId: string;
235
245
  private heartbeatTimer: NodeJS.Timeout | null = null;
246
+ private abortController = new AbortController();
236
247
 
237
248
  constructor(options: WorkflowExecutionOptions) {
238
249
  this.backend = options.backend;
@@ -240,6 +251,14 @@ class WorkflowExecution {
240
251
  this.workerId = options.workerId;
241
252
  }
242
253
 
254
+ /**
255
+ * 외부에서 abort 여부를 확인할 수 있는 signal입니다.
256
+ * heartbeat 실패 시 abort됩니다.
257
+ */
258
+ get signal(): AbortSignal {
259
+ return this.abortController.signal;
260
+ }
261
+
243
262
  /**
244
263
  * Start the heartbeat loop for this execution, heartbeating at half the lease
245
264
  * duration.
@@ -255,8 +274,10 @@ class WorkflowExecution {
255
274
  workerId: this.workerId,
256
275
  leaseDurationMs,
257
276
  })
258
- .catch((error: unknown) => {
259
- console.error("Heartbeat failed:", error);
277
+ .catch((_error: unknown) => {
278
+ // lease 연장 실패에는 heartbeat를 중단하고 abort 신호를 보냅니다.
279
+ this.abortController.abort();
280
+ this.stopHeartbeat();
260
281
  });
261
282
  }, heartbeatIntervalMs);
262
283
  }
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "vitest";
2
+
2
3
  import { defineWorkflow, defineWorkflowSpec } from "./workflow";
3
4
 
4
5
  describe("defineWorkflowSpec", () => {
package/src/workflow.ts CHANGED
@@ -1,6 +1,6 @@
1
- import type { RetryPolicy } from "./core/retry";
2
- import type { StandardSchemaV1 } from "./core/schema";
3
- import type { WorkflowFunction } from "./execution";
1
+ import { type RetryPolicy } from "./core/retry";
2
+ import { type StandardSchemaV1 } from "./core/schema";
3
+ import { type WorkflowFunction } from "./execution";
4
4
 
5
5
  export interface WorkflowSpec<Input, Output, RawInput> {
6
6
  /** The name of the workflow. */
@@ -1,4 +1,5 @@
1
- import type { Knex } from "knex";
1
+ import { type Knex } from "knex";
2
+
2
3
  import { BackendPostgres, defineConfig } from "../src/index";
3
4
 
4
5
  const config: Knex.Config = {