@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
@@ -1,6 +1,9 @@
1
1
  import { getLogger } from "@logtape/logtape";
2
2
  import { camelize } from "inflection";
3
- import knex, { type Knex } from "knex";
3
+ import knex from "knex";
4
+ import { type Knex } from "knex";
5
+
6
+ import { DEFAULT_NAMESPACE_ID } from "../backend";
4
7
  import {
5
8
  type Backend,
6
9
  type CancelWorkflowRunParams,
@@ -9,7 +12,6 @@ import {
9
12
  type CompleteWorkflowRunParams,
10
13
  type CreateStepAttemptParams,
11
14
  type CreateWorkflowRunParams,
12
- DEFAULT_NAMESPACE_ID,
13
15
  type ExtendWorkflowRunLeaseParams,
14
16
  type FailStepAttemptParams,
15
17
  type FailWorkflowRunParams,
@@ -18,13 +20,17 @@ import {
18
20
  type ListStepAttemptsParams,
19
21
  type ListWorkflowRunsParams,
20
22
  type PaginatedResponse,
23
+ type PauseWorkflowRunParams,
24
+ type ResumeWorkflowRunParams,
21
25
  type SleepWorkflowRunParams,
22
26
  } from "../backend";
23
- import { DEFAULT_RETRY_POLICY } from "../core/retry";
24
- import type { StepAttempt } from "../core/step";
25
- import type { WorkflowRun } from "../core/workflow";
27
+ import { mergeRetryPolicy } from "../core/retry";
28
+ import { type SerializableRetryPolicy } from "../core/retry";
29
+ import { type StepAttempt } from "../core/step";
30
+ import { type WorkflowRun } from "../core/workflow";
26
31
  import { DEFAULT_SCHEMA, migrate } from "./base";
27
- import { type OnSubscribed, PostgresPubSub } from "./pubsub";
32
+ import { PostgresPubSub } from "./pubsub";
33
+ import { type OnSubscribed } from "./pubsub";
28
34
 
29
35
  export const DEFAULT_LISTEN_CHANNEL = "new_tasks" as const;
30
36
  const DEFAULT_PAGINATION_PAGE_SIZE = 100 as const;
@@ -155,6 +161,8 @@ export class BackendPostgres implements Backend {
155
161
  await this.pubsub?.destroy();
156
162
  this.pubsub = null;
157
163
  await this.knex.destroy();
164
+ this._knex = null;
165
+ this.initialized = false;
158
166
  }
159
167
 
160
168
  async createWorkflowRun(params: CreateWorkflowRunParams): Promise<WorkflowRun> {
@@ -167,6 +175,12 @@ export class BackendPostgres implements Backend {
167
175
  version: params.version,
168
176
  });
169
177
 
178
+ // config에 retryPolicy를 포함시킵니다.
179
+ const configWithRetryPolicy = {
180
+ ...(typeof params.config === "object" && params.config !== null ? params.config : {}),
181
+ retryPolicy: params.retryPolicy ?? undefined,
182
+ };
183
+
170
184
  const qb = this.knex
171
185
  .withSchema(DEFAULT_SCHEMA)
172
186
  .table("workflow_runs")
@@ -177,7 +191,7 @@ export class BackendPostgres implements Backend {
177
191
  version: params.version,
178
192
  status: "pending",
179
193
  idempotency_key: params.idempotencyKey,
180
- config: params.config,
194
+ config: JSON.stringify(configWithRetryPolicy),
181
195
  context: params.context,
182
196
  input: params.input,
183
197
  attempts: 0,
@@ -247,6 +261,8 @@ export class BackendPostgres implements Backend {
247
261
  });
248
262
  const limit = params.limit ?? DEFAULT_PAGINATION_PAGE_SIZE;
249
263
  const { after, before } = params;
264
+ const order = params.order ?? "asc";
265
+ const reverseOrder = order === "asc" ? "desc" : "asc";
250
266
 
251
267
  let cursor: Cursor | null = null;
252
268
  if (after) {
@@ -255,10 +271,10 @@ export class BackendPostgres implements Backend {
255
271
  cursor = decodeCursor(before);
256
272
  }
257
273
 
258
- const qb = this.buildListWorkflowRunsWhere(params, cursor);
274
+ const qb = this.buildListWorkflowRunsWhere(params, cursor, order);
259
275
  const rows = await qb
260
- .orderBy("created_at", before ? "desc" : "asc")
261
- .orderBy("id", before ? "desc" : "asc")
276
+ .orderBy("created_at", before ? reverseOrder : order)
277
+ .orderBy("id", before ? reverseOrder : order)
262
278
  .limit(limit + 1);
263
279
 
264
280
  return this.processPaginationResults(
@@ -269,7 +285,11 @@ export class BackendPostgres implements Backend {
269
285
  );
270
286
  }
271
287
 
272
- private buildListWorkflowRunsWhere(params: ListWorkflowRunsParams, cursor: Cursor | null) {
288
+ private buildListWorkflowRunsWhere(
289
+ params: ListWorkflowRunsParams,
290
+ cursor: Cursor | null,
291
+ order: "asc" | "desc",
292
+ ) {
273
293
  const { after } = params;
274
294
  const qb = this.knex
275
295
  .withSchema(DEFAULT_SCHEMA)
@@ -277,13 +297,28 @@ export class BackendPostgres implements Backend {
277
297
  .where("namespace_id", this.namespaceId);
278
298
 
279
299
  if (cursor) {
280
- const operator = after ? ">" : "<";
281
- return qb.whereRaw(`("created_at", "id") ${operator} (?, ?)`, [
300
+ // asc: after ">", before "<"
301
+ // desc: after → "<", before → ">"
302
+ const operator = (order === "asc") === !!after ? ">" : "<";
303
+ qb.whereRaw(`("created_at", "id") ${operator} (?, ?)`, [
282
304
  cursor.createdAt.toISOString(),
283
305
  cursor.id,
284
306
  ]);
285
307
  }
286
308
 
309
+ if (params.status && params.status.length > 0) {
310
+ qb.whereIn("status", params.status);
311
+ }
312
+ if (params.workflowName) {
313
+ qb.where("workflow_name", params.workflowName);
314
+ }
315
+ if (params.createdAfter) {
316
+ qb.where("created_at", ">=", params.createdAfter);
317
+ }
318
+ if (params.createdBefore) {
319
+ qb.where("created_at", "<=", params.createdBefore);
320
+ }
321
+
287
322
  return qb;
288
323
  }
289
324
 
@@ -375,6 +410,11 @@ export class BackendPostgres implements Backend {
375
410
  .returning("*");
376
411
 
377
412
  if (!updated) {
413
+ const wr = await this.getWorkflowRun({ workflowRunId: params.workflowRunId });
414
+ if (wr && (wr.status === "paused" || wr.status === "canceled")) {
415
+ throw new Error("Workflow run is paused or canceled");
416
+ }
417
+
378
418
  logger.error("Failed to extend lease for workflow run: {params}", { params });
379
419
  throw new Error("Failed to extend lease for workflow run");
380
420
  }
@@ -459,8 +499,60 @@ export class BackendPostgres implements Backend {
459
499
  throw new Error("Backend not initialized");
460
500
  }
461
501
 
462
- const { workflowRunId, error } = params;
463
- const { initialIntervalMs, backoffCoefficient, maximumIntervalMs } = DEFAULT_RETRY_POLICY;
502
+ const { workflowRunId, error, forceComplete, customDelayMs } = params;
503
+
504
+ logger.info("Failing workflow run: {workflowRunId}, {workerId}, {error}", {
505
+ workflowRunId: params.workflowRunId,
506
+ workerId: params.workerId,
507
+ error: params.error,
508
+ });
509
+
510
+ const workflowRun = await this.knex
511
+ .withSchema(DEFAULT_SCHEMA)
512
+ .table("workflow_runs")
513
+ .where("namespace_id", this.namespaceId)
514
+ .where("id", workflowRunId)
515
+ .first();
516
+
517
+ if (!workflowRun) {
518
+ throw new Error("Workflow run not found");
519
+ }
520
+
521
+ const config =
522
+ typeof workflowRun.config === "string" ? JSON.parse(workflowRun.config) : workflowRun.config;
523
+ const savedRetryPolicy: SerializableRetryPolicy | undefined = config?.retryPolicy;
524
+ const retryPolicy = mergeRetryPolicy(savedRetryPolicy);
525
+
526
+ const { initialIntervalMs, backoffCoefficient, maximumIntervalMs, maxAttempts } = retryPolicy;
527
+
528
+ const currentAttempts = workflowRun.attempts ?? 0;
529
+ const shouldForceComplete = forceComplete || currentAttempts >= maxAttempts;
530
+
531
+ if (shouldForceComplete) {
532
+ const [updated] = await this.knex
533
+ .withSchema(DEFAULT_SCHEMA)
534
+ .table("workflow_runs")
535
+ .where("namespace_id", this.namespaceId)
536
+ .where("id", workflowRunId)
537
+ .where("status", "running")
538
+ .where("worker_id", params.workerId)
539
+ .update({
540
+ status: "failed",
541
+ available_at: null,
542
+ finished_at: this.knex.fn.now(),
543
+ error: JSON.stringify(error),
544
+ worker_id: null,
545
+ started_at: null,
546
+ updated_at: this.knex.fn.now(),
547
+ })
548
+ .returning("*");
549
+
550
+ if (!updated) {
551
+ logger.error("Failed to mark workflow run failed: {params}", { params });
552
+ throw new Error("Failed to mark workflow run failed");
553
+ }
554
+ return updated;
555
+ }
464
556
 
465
557
  // this beefy query updates a workflow's status, available_at, and
466
558
  // finished_at based on the workflow's deadline and retry policy
@@ -468,15 +560,11 @@ export class BackendPostgres implements Backend {
468
560
  // if the next retry would exceed the deadline, the run is marked as
469
561
  // 'failed' and finalized, otherwise, the run is rescheduled with an updated
470
562
  // 'available_at' timestamp for the next retry
471
- const retryIntervalExpr = `LEAST(${initialIntervalMs} * POWER(${backoffCoefficient}, "attempts" - 1), ${maximumIntervalMs}) * INTERVAL '1 millisecond'`;
563
+ const retryIntervalExpr = customDelayMs
564
+ ? `${customDelayMs} * INTERVAL '1 millisecond'`
565
+ : `LEAST(${initialIntervalMs} * POWER(${backoffCoefficient}, "attempts" - 1), ${maximumIntervalMs}) * INTERVAL '1 millisecond'`;
472
566
  const deadlineExceededCondition = `"deadline_at" IS NOT NULL AND NOW() + (${retryIntervalExpr}) >= "deadline_at"`;
473
567
 
474
- logger.info("Failing workflow run: {workflowRunId}, {workerId}, {error}", {
475
- workflowRunId: params.workflowRunId,
476
- workerId: params.workerId,
477
- error: params.error,
478
- });
479
-
480
568
  const [updated] = await this.knex
481
569
  .withSchema(DEFAULT_SCHEMA)
482
570
  .table("workflow_runs")
@@ -521,7 +609,7 @@ export class BackendPostgres implements Backend {
521
609
  .table("workflow_runs")
522
610
  .where("namespace_id", this.namespaceId)
523
611
  .where("id", params.workflowRunId)
524
- .whereIn("status", ["pending", "running", "sleeping"])
612
+ .whereIn("status", ["pending", "running", "sleeping", "paused"])
525
613
  .update({
526
614
  status: "canceled",
527
615
  worker_id: null,
@@ -564,6 +652,111 @@ export class BackendPostgres implements Backend {
564
652
  return updated;
565
653
  }
566
654
 
655
+ async pauseWorkflowRun(params: PauseWorkflowRunParams): Promise<WorkflowRun> {
656
+ if (!this.initialized) {
657
+ throw new Error("Backend not initialized");
658
+ }
659
+
660
+ logger.info("Pausing workflow run: {workflowRunId}", { workflowRunId: params.workflowRunId });
661
+
662
+ const [updated] = await this.knex
663
+ .withSchema(DEFAULT_SCHEMA)
664
+ .table("workflow_runs")
665
+ .where("namespace_id", this.namespaceId)
666
+ .where("id", params.workflowRunId)
667
+ .whereIn("status", ["pending", "running", "sleeping"])
668
+ .update({
669
+ status: "paused",
670
+ worker_id: null,
671
+ available_at: null,
672
+ updated_at: this.knex.fn.now(),
673
+ })
674
+ .returning("*");
675
+
676
+ if (!updated) {
677
+ const existing = await this.getWorkflowRun({
678
+ workflowRunId: params.workflowRunId,
679
+ });
680
+ if (!existing) {
681
+ throw new Error(`Workflow run ${params.workflowRunId} does not exist`);
682
+ }
683
+
684
+ // 이미 paused이면 멱등하게 반환합니다.
685
+ if (existing.status === "paused") {
686
+ return existing;
687
+ }
688
+
689
+ // 터미널 상태에서는 pause할 수 없습니다.
690
+ // 'succeeded' status is deprecated
691
+ if (["succeeded", "completed", "failed", "canceled"].includes(existing.status)) {
692
+ logger.error("Cannot pause workflow run: {params} with status {status}", {
693
+ params,
694
+ status: existing.status,
695
+ });
696
+ throw new Error(
697
+ `Cannot pause workflow run ${params.workflowRunId} with status ${existing.status}`,
698
+ );
699
+ }
700
+
701
+ logger.error("Failed to pause workflow run: {params}", { params });
702
+ throw new Error("Failed to pause workflow run");
703
+ }
704
+
705
+ return updated;
706
+ }
707
+
708
+ async resumeWorkflowRun(params: ResumeWorkflowRunParams): Promise<WorkflowRun> {
709
+ if (!this.initialized) {
710
+ throw new Error("Backend not initialized");
711
+ }
712
+
713
+ logger.info("Resuming workflow run: {workflowRunId}", { workflowRunId: params.workflowRunId });
714
+
715
+ const [updated] = await this.knex
716
+ .withSchema(DEFAULT_SCHEMA)
717
+ .table("workflow_runs")
718
+ .where("namespace_id", this.namespaceId)
719
+ .where("id", params.workflowRunId)
720
+ .where("status", "paused")
721
+ .update({
722
+ status: "pending",
723
+ available_at: this.knex.fn.now(),
724
+ updated_at: this.knex.fn.now(),
725
+ })
726
+ .returning("*");
727
+
728
+ if (!updated) {
729
+ const existing = await this.getWorkflowRun({
730
+ workflowRunId: params.workflowRunId,
731
+ });
732
+ if (!existing) {
733
+ throw new Error(`Workflow run ${params.workflowRunId} does not exist`);
734
+ }
735
+
736
+ // 이미 pending/running이면 멱등하게 반환합니다.
737
+ if (existing.status === "pending" || existing.status === "running") {
738
+ return existing;
739
+ }
740
+
741
+ // 터미널 상태에서는 resume할 수 없습니다.
742
+ // 'succeeded' status is deprecated
743
+ if (["succeeded", "completed", "failed", "canceled"].includes(existing.status)) {
744
+ logger.error("Cannot resume workflow run: {params} with status {status}", {
745
+ params,
746
+ status: existing.status,
747
+ });
748
+ throw new Error(
749
+ `Cannot resume workflow run ${params.workflowRunId} with status ${existing.status}`,
750
+ );
751
+ }
752
+
753
+ logger.error("Failed to resume workflow run: {params}", { params });
754
+ throw new Error("Failed to resume workflow run");
755
+ }
756
+
757
+ return updated;
758
+ }
759
+
567
760
  async createStepAttempt(params: CreateStepAttemptParams): Promise<StepAttempt> {
568
761
  if (!this.initialized) {
569
762
  throw new Error("Backend not initialized");
@@ -631,6 +824,8 @@ export class BackendPostgres implements Backend {
631
824
 
632
825
  const limit = params.limit ?? DEFAULT_PAGINATION_PAGE_SIZE;
633
826
  const { after, before } = params;
827
+ const order = params.order ?? "asc";
828
+ const reverseOrder = order === "asc" ? "desc" : "asc";
634
829
 
635
830
  let cursor: Cursor | null = null;
636
831
  if (after) {
@@ -639,10 +834,10 @@ export class BackendPostgres implements Backend {
639
834
  cursor = decodeCursor(before);
640
835
  }
641
836
 
642
- const qb = this.buildListStepAttemptsWhere(params, cursor);
837
+ const qb = this.buildListStepAttemptsWhere(params, cursor, order);
643
838
  const rows = await qb
644
- .orderBy("created_at", before ? "desc" : "asc")
645
- .orderBy("id", before ? "desc" : "asc")
839
+ .orderBy("created_at", before ? reverseOrder : order)
840
+ .orderBy("id", before ? reverseOrder : order)
646
841
  .limit(limit + 1);
647
842
 
648
843
  return this.processPaginationResults(
@@ -653,7 +848,11 @@ export class BackendPostgres implements Backend {
653
848
  );
654
849
  }
655
850
 
656
- private buildListStepAttemptsWhere(params: ListStepAttemptsParams, cursor: Cursor | null) {
851
+ private buildListStepAttemptsWhere(
852
+ params: ListStepAttemptsParams,
853
+ cursor: Cursor | null,
854
+ order: "asc" | "desc",
855
+ ) {
657
856
  const { after } = params;
658
857
  const qb = this.knex
659
858
  .withSchema(DEFAULT_SCHEMA)
@@ -662,7 +861,9 @@ export class BackendPostgres implements Backend {
662
861
  .where("workflow_run_id", params.workflowRunId);
663
862
 
664
863
  if (cursor) {
665
- const operator = after ? ">" : "<";
864
+ // asc: after ">", before "<"
865
+ // desc: after → "<", before → ">"
866
+ const operator = (order === "asc") === !!after ? ">" : "<";
666
867
  return qb.whereRaw(`("created_at", "id") ${operator} (?, ?)`, [
667
868
  cursor.createdAt.toISOString(),
668
869
  cursor.id,
@@ -713,8 +914,10 @@ export class BackendPostgres implements Backend {
713
914
  };
714
915
  }
715
916
 
716
- // NOTE: 실제 서비스에서 이게 되는 것 같은데, 쿼리 등을 체크할 필요가 있음.
717
- async completeStepAttempt(params: CompleteStepAttemptParams): Promise<StepAttempt> {
917
+ // WHERE 조건에 wr.status='running', sa.status='running'이 포함되어 있어,
918
+ // 외부에서 워크플로우 상태가 변경된 경우(pause/cancel) null을 반환합니다.
919
+ // 예상하지 못한 이유로 실패한 경우에는 에러를 로깅합니다.
920
+ async completeStepAttempt(params: CompleteStepAttemptParams): Promise<StepAttempt | null> {
718
921
  if (!this.initialized) {
719
922
  throw new Error("Backend not initialized");
720
923
  }
@@ -747,14 +950,13 @@ export class BackendPostgres implements Backend {
747
950
  .returning("sa.*");
748
951
 
749
952
  if (!updated) {
750
- logger.error("Failed to mark step attempt completed: {params}", { params });
751
- throw new Error("Failed to mark step attempt completed");
953
+ return this.handleStepAttemptUpdateMiss("completed", params);
752
954
  }
753
955
 
754
956
  return updated;
755
957
  }
756
958
 
757
- async failStepAttempt(params: FailStepAttemptParams): Promise<StepAttempt> {
959
+ async failStepAttempt(params: FailStepAttemptParams): Promise<StepAttempt | null> {
758
960
  if (!this.initialized) {
759
961
  throw new Error("Backend not initialized");
760
962
  }
@@ -788,12 +990,46 @@ export class BackendPostgres implements Backend {
788
990
  .returning("sa.*");
789
991
 
790
992
  if (!updated) {
791
- logger.error("Failed to mark step attempt failed: {params}", { params });
792
- throw new Error("Failed to mark step attempt failed");
993
+ return this.handleStepAttemptUpdateMiss("failed", params);
793
994
  }
794
995
 
795
996
  return updated;
796
997
  }
998
+
999
+ /**
1000
+ * completeStepAttempt/failStepAttempt에서 UPDATE가 0건일 때,
1001
+ * 외부 상태 변경(pause/cancel)에 의한 것인지 판단합니다.
1002
+ * - 외부 상태 변경이면 해당 step의 상태도 워크플로우와 동일하게 맞추고 null을 반환합니다.
1003
+ * - 그 외에는 예상하지 못한 상황이므로 에러를 throw합니다.
1004
+ */
1005
+ private async handleStepAttemptUpdateMiss(
1006
+ method: string,
1007
+ params: { workflowRunId: string; stepAttemptId: string; workerId: string },
1008
+ ): Promise<null> {
1009
+ const wr = await this.getWorkflowRun({ workflowRunId: params.workflowRunId });
1010
+
1011
+ // 워크플로우가 외부에서 paused/canceled된 경우 → step 상태도 동일하게 갱신하고 null 반환
1012
+ if (wr && (wr.status === "paused" || wr.status === "canceled")) {
1013
+ await this.knex
1014
+ .withSchema(DEFAULT_SCHEMA)
1015
+ .table("step_attempts")
1016
+ .where("namespace_id", this.namespaceId)
1017
+ .where("id", params.stepAttemptId)
1018
+ .whereIn("status", ["running", "paused"])
1019
+ .update({
1020
+ status: wr.status,
1021
+ updated_at: this.knex.fn.now(),
1022
+ });
1023
+ return null;
1024
+ }
1025
+
1026
+ // 그 외(워크플로우가 여전히 running인데 UPDATE가 안 된 경우 등) → 예상 못한 상황
1027
+ logger.error("Failed to mark step attempt {method}: {params}", {
1028
+ method,
1029
+ params,
1030
+ });
1031
+ throw new Error(`Failed to mark step attempt ${method}`);
1032
+ }
797
1033
  }
798
1034
 
799
1035
  /**
@@ -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;