@nestjs/workflows 0.0.1

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 (265) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +50 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +8 -0
  5. package/dist/core/codecs/aes-gcm-payload.codec.d.ts +42 -0
  6. package/dist/core/codecs/aes-gcm-payload.codec.js +119 -0
  7. package/dist/core/codecs/index.d.ts +2 -0
  8. package/dist/core/codecs/index.js +2 -0
  9. package/dist/core/codecs/payload-codecs.d.ts +59 -0
  10. package/dist/core/codecs/payload-codecs.js +130 -0
  11. package/dist/core/index.d.ts +16 -0
  12. package/dist/core/index.js +21 -0
  13. package/dist/core/interfaces/clock.interface.d.ts +18 -0
  14. package/dist/core/interfaces/clock.interface.js +1 -0
  15. package/dist/core/interfaces/index.d.ts +9 -0
  16. package/dist/core/interfaces/index.js +9 -0
  17. package/dist/core/interfaces/leased-worker-options.interface.d.ts +94 -0
  18. package/dist/core/interfaces/leased-worker-options.interface.js +1 -0
  19. package/dist/core/interfaces/limits.interface.d.ts +92 -0
  20. package/dist/core/interfaces/limits.interface.js +1 -0
  21. package/dist/core/interfaces/payload-codec.interface.d.ts +64 -0
  22. package/dist/core/interfaces/payload-codec.interface.js +1 -0
  23. package/dist/core/interfaces/result-waiter-options.interface.d.ts +52 -0
  24. package/dist/core/interfaces/result-waiter-options.interface.js +1 -0
  25. package/dist/core/interfaces/retry-settings.interface.d.ts +71 -0
  26. package/dist/core/interfaces/retry-settings.interface.js +1 -0
  27. package/dist/core/interfaces/schedule-store.interface.d.ts +143 -0
  28. package/dist/core/interfaces/schedule-store.interface.js +1 -0
  29. package/dist/core/interfaces/schedule.interface.d.ts +106 -0
  30. package/dist/core/interfaces/schedule.interface.js +1 -0
  31. package/dist/core/interfaces/scheduler-options.interface.d.ts +152 -0
  32. package/dist/core/interfaces/scheduler-options.interface.js +1 -0
  33. package/dist/core/limits/index.d.ts +1 -0
  34. package/dist/core/limits/index.js +1 -0
  35. package/dist/core/limits/limits.d.ts +61 -0
  36. package/dist/core/limits/limits.js +130 -0
  37. package/dist/core/results/index.d.ts +1 -0
  38. package/dist/core/results/index.js +1 -0
  39. package/dist/core/results/result-waiter.d.ts +54 -0
  40. package/dist/core/results/result-waiter.js +140 -0
  41. package/dist/core/retries/index.d.ts +1 -0
  42. package/dist/core/retries/index.js +1 -0
  43. package/dist/core/retries/retry.d.ts +60 -0
  44. package/dist/core/retries/retry.js +107 -0
  45. package/dist/core/scheduling/cron.util.d.ts +52 -0
  46. package/dist/core/scheduling/cron.util.js +262 -0
  47. package/dist/core/scheduling/encoded-schedule.store.d.ts +26 -0
  48. package/dist/core/scheduling/encoded-schedule.store.js +54 -0
  49. package/dist/core/scheduling/in-memory-schedule.store.d.ts +20 -0
  50. package/dist/core/scheduling/in-memory-schedule.store.js +90 -0
  51. package/dist/core/scheduling/index.d.ts +3 -0
  52. package/dist/core/scheduling/index.js +3 -0
  53. package/dist/core/scheduling/rrule.util.d.ts +55 -0
  54. package/dist/core/scheduling/rrule.util.js +371 -0
  55. package/dist/core/scheduling/schedule-spec.d.ts +57 -0
  56. package/dist/core/scheduling/schedule-spec.js +233 -0
  57. package/dist/core/scheduling/scheduler.d.ts +134 -0
  58. package/dist/core/scheduling/scheduler.js +450 -0
  59. package/dist/core/scheduling/time-zone.util.d.ts +18 -0
  60. package/dist/core/scheduling/time-zone.util.js +88 -0
  61. package/dist/core/time/clock.d.ts +28 -0
  62. package/dist/core/time/clock.js +35 -0
  63. package/dist/core/time/duration.d.ts +21 -0
  64. package/dist/core/time/duration.js +30 -0
  65. package/dist/core/time/index.d.ts +2 -0
  66. package/dist/core/time/index.js +2 -0
  67. package/dist/core/utils/canonical.util.d.ts +2 -0
  68. package/dist/core/utils/canonical.util.js +4 -0
  69. package/dist/core/utils/maybe.util.d.ts +8 -0
  70. package/dist/core/utils/maybe.util.js +14 -0
  71. package/dist/core/utils/payload-place.util.d.ts +10 -0
  72. package/dist/core/utils/payload-place.util.js +32 -0
  73. package/dist/core/workers/index.d.ts +1 -0
  74. package/dist/core/workers/index.js +1 -0
  75. package/dist/core/workers/leased-worker.d.ts +86 -0
  76. package/dist/core/workers/leased-worker.js +280 -0
  77. package/dist/cqrs/decorators/index.d.ts +2 -0
  78. package/dist/cqrs/decorators/index.js +2 -0
  79. package/dist/cqrs/decorators/signal-on.decorator.d.ts +18 -0
  80. package/dist/cqrs/decorators/signal-on.decorator.js +34 -0
  81. package/dist/cqrs/decorators/start-on.decorator.d.ts +18 -0
  82. package/dist/cqrs/decorators/start-on.decorator.js +49 -0
  83. package/dist/cqrs/index.d.ts +3 -0
  84. package/dist/cqrs/index.js +5 -0
  85. package/dist/cqrs/interfaces/index.d.ts +4 -0
  86. package/dist/cqrs/interfaces/index.js +4 -0
  87. package/dist/cqrs/interfaces/signal-on-options.interface.d.ts +22 -0
  88. package/dist/cqrs/interfaces/signal-on-options.interface.js +1 -0
  89. package/dist/cqrs/interfaces/start-on-options.interface.d.ts +23 -0
  90. package/dist/cqrs/interfaces/start-on-options.interface.js +1 -0
  91. package/dist/cqrs/interfaces/workflow-dispatcher-context.interface.d.ts +10 -0
  92. package/dist/cqrs/interfaces/workflow-dispatcher-context.interface.js +1 -0
  93. package/dist/cqrs/interfaces/workflow-event-route.interface.d.ts +20 -0
  94. package/dist/cqrs/interfaces/workflow-event-route.interface.js +1 -0
  95. package/dist/cqrs/utils/event-routes.util.d.ts +7 -0
  96. package/dist/cqrs/utils/event-routes.util.js +22 -0
  97. package/dist/cqrs/workflow-event.publisher.d.ts +52 -0
  98. package/dist/cqrs/workflow-event.publisher.js +257 -0
  99. package/dist/cqrs/workflow-events.explorer.d.ts +27 -0
  100. package/dist/cqrs/workflow-events.explorer.js +92 -0
  101. package/dist/cqrs/workflows-cqrs.module.d.ts +10 -0
  102. package/dist/cqrs/workflows-cqrs.module.js +27 -0
  103. package/dist/decorators/index.d.ts +1 -0
  104. package/dist/decorators/index.js +1 -0
  105. package/dist/decorators/workflow.decorator.d.ts +7 -0
  106. package/dist/decorators/workflow.decorator.js +52 -0
  107. package/dist/errors/child-workflow-failed.error.d.ts +13 -0
  108. package/dist/errors/child-workflow-failed.error.js +14 -0
  109. package/dist/errors/index.d.ts +13 -0
  110. package/dist/errors/index.js +13 -0
  111. package/dist/errors/non-retryable-step.error.d.ts +4 -0
  112. package/dist/errors/non-retryable-step.error.js +7 -0
  113. package/dist/errors/step-failed.error.d.ts +15 -0
  114. package/dist/errors/step-failed.error.js +16 -0
  115. package/dist/errors/step-timeout.error.d.ts +5 -0
  116. package/dist/errors/step-timeout.error.js +8 -0
  117. package/dist/errors/workflow-definition.error.d.ts +10 -0
  118. package/dist/errors/workflow-definition.error.js +13 -0
  119. package/dist/errors/workflow-failed.error.d.ts +20 -0
  120. package/dist/errors/workflow-failed.error.js +17 -0
  121. package/dist/errors/workflow-id-conflict.error.d.ts +6 -0
  122. package/dist/errors/workflow-id-conflict.error.js +9 -0
  123. package/dist/errors/workflow-interrupt.error.d.ts +32 -0
  124. package/dist/errors/workflow-interrupt.error.js +19 -0
  125. package/dist/errors/workflow-non-determinism.error.d.ts +4 -0
  126. package/dist/errors/workflow-non-determinism.error.js +7 -0
  127. package/dist/errors/workflow-not-found.error.d.ts +10 -0
  128. package/dist/errors/workflow-not-found.error.js +13 -0
  129. package/dist/errors/workflow-result-timeout.error.d.ts +11 -0
  130. package/dist/errors/workflow-result-timeout.error.js +13 -0
  131. package/dist/errors/workflow-state.error.d.ts +11 -0
  132. package/dist/errors/workflow-state.error.js +14 -0
  133. package/dist/errors/workflow.error.d.ts +7 -0
  134. package/dist/errors/workflow.error.js +7 -0
  135. package/dist/events/index.d.ts +3 -0
  136. package/dist/events/index.js +3 -0
  137. package/dist/events/workflow-events.interface.d.ts +145 -0
  138. package/dist/events/workflow-events.interface.js +1 -0
  139. package/dist/events/workflow-events.service.d.ts +17 -0
  140. package/dist/events/workflow-events.service.js +38 -0
  141. package/dist/events/workflows.channels.d.ts +3 -0
  142. package/dist/events/workflows.channels.js +9 -0
  143. package/dist/index.d.ts +20 -0
  144. package/dist/index.js +23 -0
  145. package/dist/interfaces/index.d.ts +13 -0
  146. package/dist/interfaces/index.js +13 -0
  147. package/dist/interfaces/serialized-workflow-error.interface.d.ts +6 -0
  148. package/dist/interfaces/serialized-workflow-error.interface.js +1 -0
  149. package/dist/interfaces/workflow-client.interface.d.ts +156 -0
  150. package/dist/interfaces/workflow-client.interface.js +1 -0
  151. package/dist/interfaces/workflow-clock.interface.d.ts +7 -0
  152. package/dist/interfaces/workflow-clock.interface.js +1 -0
  153. package/dist/interfaces/workflow-context.interface.d.ts +241 -0
  154. package/dist/interfaces/workflow-context.interface.js +1 -0
  155. package/dist/interfaces/workflow-decorator-options.interface.d.ts +101 -0
  156. package/dist/interfaces/workflow-decorator-options.interface.js +1 -0
  157. package/dist/interfaces/workflow-instance.interface.d.ts +83 -0
  158. package/dist/interfaces/workflow-instance.interface.js +1 -0
  159. package/dist/interfaces/workflow-payload-codec.interface.d.ts +32 -0
  160. package/dist/interfaces/workflow-payload-codec.interface.js +1 -0
  161. package/dist/interfaces/workflow-retry-options.interface.d.ts +9 -0
  162. package/dist/interfaces/workflow-retry-options.interface.js +1 -0
  163. package/dist/interfaces/workflow-runner.interface.d.ts +11 -0
  164. package/dist/interfaces/workflow-runner.interface.js +1 -0
  165. package/dist/interfaces/workflow-schedule.interface.d.ts +104 -0
  166. package/dist/interfaces/workflow-schedule.interface.js +1 -0
  167. package/dist/interfaces/workflow-storage.interface.d.ts +4 -0
  168. package/dist/interfaces/workflow-storage.interface.js +1 -0
  169. package/dist/interfaces/workflow-store.interface.d.ts +488 -0
  170. package/dist/interfaces/workflow-store.interface.js +1 -0
  171. package/dist/interfaces/workflows-module-options.interface.d.ts +80 -0
  172. package/dist/interfaces/workflows-module-options.interface.js +1 -0
  173. package/dist/mysql/index.d.ts +5 -0
  174. package/dist/mysql/index.js +8 -0
  175. package/dist/mysql/interfaces/index.d.ts +1 -0
  176. package/dist/mysql/interfaces/index.js +1 -0
  177. package/dist/mysql/interfaces/mysql-workflow-store-options.interface.d.ts +25 -0
  178. package/dist/mysql/interfaces/mysql-workflow-store-options.interface.js +1 -0
  179. package/dist/mysql/key-limits.d.ts +30 -0
  180. package/dist/mysql/key-limits.js +41 -0
  181. package/dist/mysql/migrations/index.d.ts +7 -0
  182. package/dist/mysql/migrations/index.js +16 -0
  183. package/dist/mysql/migrations/initial.migration.d.ts +9 -0
  184. package/dist/mysql/migrations/initial.migration.js +104 -0
  185. package/dist/mysql/mysql-workflow.store.d.ts +197 -0
  186. package/dist/mysql/mysql-workflow.store.js +828 -0
  187. package/dist/postgres/errors/index.d.ts +1 -0
  188. package/dist/postgres/errors/index.js +2 -0
  189. package/dist/postgres/index.d.ts +5 -0
  190. package/dist/postgres/index.js +8 -0
  191. package/dist/postgres/interfaces/index.d.ts +1 -0
  192. package/dist/postgres/interfaces/index.js +1 -0
  193. package/dist/postgres/interfaces/postgres-workflow-store-options.interface.d.ts +22 -0
  194. package/dist/postgres/interfaces/postgres-workflow-store-options.interface.js +1 -0
  195. package/dist/postgres/migrations/index.d.ts +6 -0
  196. package/dist/postgres/migrations/index.js +15 -0
  197. package/dist/postgres/migrations/initial.migration.d.ts +6 -0
  198. package/dist/postgres/migrations/initial.migration.js +106 -0
  199. package/dist/postgres/postgres-workflow.store.d.ts +132 -0
  200. package/dist/postgres/postgres-workflow.store.js +586 -0
  201. package/dist/services/index.d.ts +6 -0
  202. package/dist/services/index.js +6 -0
  203. package/dist/services/workflow-client.service.d.ts +146 -0
  204. package/dist/services/workflow-client.service.js +436 -0
  205. package/dist/services/workflow-execution.service.d.ts +267 -0
  206. package/dist/services/workflow-execution.service.js +1076 -0
  207. package/dist/services/workflow-registry.service.d.ts +56 -0
  208. package/dist/services/workflow-registry.service.js +172 -0
  209. package/dist/services/workflow-scheduler.service.d.ts +54 -0
  210. package/dist/services/workflow-scheduler.service.js +173 -0
  211. package/dist/services/workflow-schedules.service.d.ts +60 -0
  212. package/dist/services/workflow-schedules.service.js +193 -0
  213. package/dist/services/workflow-worker.service.d.ts +92 -0
  214. package/dist/services/workflow-worker.service.js +468 -0
  215. package/dist/signals/index.d.ts +1 -0
  216. package/dist/signals/index.js +1 -0
  217. package/dist/signals/workflow.signal.d.ts +17 -0
  218. package/dist/signals/workflow.signal.js +28 -0
  219. package/dist/sql/rate-windows.d.ts +41 -0
  220. package/dist/sql/rate-windows.js +53 -0
  221. package/dist/sql/workflow-rows.d.ts +16 -0
  222. package/dist/sql/workflow-rows.js +102 -0
  223. package/dist/sql/workflow-schema.error.d.ts +21 -0
  224. package/dist/sql/workflow-schema.error.js +15 -0
  225. package/dist/storage/encoded-workflow.store.d.ts +71 -0
  226. package/dist/storage/encoded-workflow.store.js +201 -0
  227. package/dist/storage/index.d.ts +1 -0
  228. package/dist/storage/index.js +1 -0
  229. package/dist/storage/workflow.storage.d.ts +55 -0
  230. package/dist/storage/workflow.storage.js +159 -0
  231. package/dist/stores/in-memory-workflow.store.d.ts +75 -0
  232. package/dist/stores/in-memory-workflow.store.js +380 -0
  233. package/dist/stores/index.d.ts +1 -0
  234. package/dist/stores/index.js +1 -0
  235. package/dist/testing/assertions.d.ts +10 -0
  236. package/dist/testing/assertions.js +72 -0
  237. package/dist/testing/index.d.ts +38 -0
  238. package/dist/testing/index.js +1089 -0
  239. package/dist/testing/schedule-store.contract.d.ts +33 -0
  240. package/dist/testing/schedule-store.contract.js +161 -0
  241. package/dist/utils/index.d.ts +7 -0
  242. package/dist/utils/index.js +7 -0
  243. package/dist/utils/journal-limits.util.d.ts +6 -0
  244. package/dist/utils/journal-limits.util.js +25 -0
  245. package/dist/utils/new-instance.util.d.ts +26 -0
  246. package/dist/utils/new-instance.util.js +87 -0
  247. package/dist/utils/normalize.util.d.ts +5 -0
  248. package/dist/utils/normalize.util.js +11 -0
  249. package/dist/utils/run-timeout.util.d.ts +3 -0
  250. package/dist/utils/run-timeout.util.js +15 -0
  251. package/dist/utils/schedule-spec.util.d.ts +16 -0
  252. package/dist/utils/schedule-spec.util.js +9 -0
  253. package/dist/utils/schedule-store.util.d.ts +15 -0
  254. package/dist/utils/schedule-store.util.js +44 -0
  255. package/dist/utils/serialize-error.util.d.ts +7 -0
  256. package/dist/utils/serialize-error.util.js +22 -0
  257. package/dist/utils/step-scope.util.d.ts +16 -0
  258. package/dist/utils/step-scope.util.js +38 -0
  259. package/dist/workflows.constants.d.ts +10 -0
  260. package/dist/workflows.constants.js +10 -0
  261. package/dist/workflows.module-definition.d.ts +4 -0
  262. package/dist/workflows.module-definition.js +6 -0
  263. package/dist/workflows.module.d.ts +24 -0
  264. package/dist/workflows.module.js +68 -0
  265. package/package.json +123 -0
@@ -0,0 +1,586 @@
1
+ import { Logger } from '@nestjs/common';
2
+ import { advisoryLock, assertReadCommittedTransaction, columns, quoteSchema, SqlParams, toBool, toInt, toJson, toText, } from '@nestjs/store-kit/postgres';
3
+ import { RateWindowClaim } from '../sql/rate-windows.js';
4
+ import { CANCELLABLE_STATUSES, INSTANCE_COLUMNS, RUNNABLE_STATUSES, SCHEDULE_COLUMNS, toInstance, toSchedule } from '../sql/workflow-rows.js';
5
+ import { workflowStoreSchema } from './migrations/index.js';
6
+ /** A statement that waited for a lock sees what the lock's holder committed. */
7
+ const READ_COMMITTED = { isolationLevel: 'read committed' };
8
+ const RUNNABLE = `(${RUNNABLE_STATUSES.map((status) => `'${status}'`).join(', ')})`;
9
+ const CANCELLABLE = `(${CANCELLABLE_STATUSES.map((status) => `'${status}'`).join(', ')})`;
10
+ /**
11
+ * The first-party `WorkflowStore` on PostgreSQL, through the client the application already has (`fromPg()`,
12
+ * `fromDrizzle()`, `fromTypeOrm()`, `fromPrisma()`, `fromKysely()`). It keeps its tables in a schema of its own
13
+ * (`nest_workflows` by default), which its migrations create and bring up to date, and it joins the application's
14
+ * transactions for `start()` and `signal()` with `{ transaction }`.
15
+ *
16
+ * ```ts
17
+ * @Module({
18
+ * imports: [WorkflowsModule.forRoot()],
19
+ * providers: [
20
+ * {
21
+ * provide: PostgresWorkflowStore,
22
+ * inject: [getDrizzleToken(), WorkflowStorage],
23
+ * useFactory: (db: Database, storage: WorkflowStorage) => new PostgresWorkflowStore({ executor: fromDrizzle(db) }, storage),
24
+ * },
25
+ * ],
26
+ * })
27
+ * export class AppModule {}
28
+ * ```
29
+ *
30
+ * Given `storage`, it registers itself (`storage.registerSource(this)`), as every store does. It checks its schema,
31
+ * or migrates it (`migrate`), in `onModuleInit` (so before the worker starts), or at its first call outside Nest.
32
+ */
33
+ export class PostgresWorkflowStore {
34
+ /**
35
+ * The SQL of the store's migrations, for teams that apply migrations with their own tool (drizzle-kit, TypeORM,
36
+ * Prisma Migrate, Flyway...) and run the store with `migrate: false`: from version `from` (default `0`, a new
37
+ * database) to `to` (default: the version this version of the package needs), with the bookkeeping that tells the
38
+ * store which versions a schema has. A schema's version is `SELECT max(version) FROM <schema>.migrations`.
39
+ * Downgrades aren't supported. From version 0 it starts with `CREATE SCHEMA IF NOT EXISTS`, which needs the CREATE
40
+ * privilege on the database even when the schema exists: drop that statement if someone created the schema for you.
41
+ * With `statementBreakpoints`, drizzle-kit's `--> statement-breakpoint` separates the statements, for a custom
42
+ * drizzle-kit migration: its migrator then runs them one at a time, which PGlite needs.
43
+ *
44
+ * ```ts
45
+ * // drizzle/0003_workflows.sql, created empty by `npx drizzle-kit generate --custom --name=workflows`
46
+ * writeFileSync('drizzle/0003_workflows.sql', PostgresWorkflowStore.migrationSql({ statementBreakpoints: true }));
47
+ * ```
48
+ */
49
+ static migrationSql(options = {}) {
50
+ return workflowStoreSchema.sql(options);
51
+ }
52
+ constructor(options, storage) {
53
+ this.logger = new Logger('WorkflowsModule');
54
+ const resolved = workflowStoreSchema.resolveOptions(options);
55
+ this.executor = resolved.executor;
56
+ this.schema = resolved.schema;
57
+ const s = quoteSchema(this.schema, 'PostgresWorkflowStore');
58
+ this.t = {
59
+ instances: `${s}.instances`,
60
+ journal: `${s}.journal`,
61
+ waits: `${s}.waits`,
62
+ signals: `${s}.signals`,
63
+ schedules: `${s}.schedules`,
64
+ rateLimits: `${s}.rate_limits`,
65
+ };
66
+ this.signalLock = `@nestjs/workflows:${this.schema}:signals`;
67
+ this.readiness = workflowStoreSchema.readiness({ ...resolved, logger: this.logger });
68
+ storage?.registerSource(this);
69
+ }
70
+ /** Migrates the schema (`migrate`) or checks it, before the worker starts: startup fails if it can't serve. */
71
+ async onModuleInit() {
72
+ await this.readiness.ready();
73
+ }
74
+ /**
75
+ * Applies the migrations the schema hasn't had yet, whatever `migrate` says, in one transaction under an advisory
76
+ * lock: of processes that migrate together, one applies them. Resolves to the versions it applied (`[]`: none were
77
+ * pending).
78
+ */
79
+ migrate() {
80
+ return this.readiness.migrate();
81
+ }
82
+ // ---------------------------------------------------------------- instances
83
+ async create(instance) {
84
+ await this.readiness.ready();
85
+ return this.insertInstance(this.executor, instance);
86
+ }
87
+ /** `start(..., { transaction })`: the instance commits or rolls back with the application's rows. */
88
+ async createInTransaction(transaction, instance) {
89
+ const tx = this.executor.wrapTransaction(transaction);
90
+ await this.readiness.readyIn(tx);
91
+ return this.insertInstance(tx, instance);
92
+ }
93
+ async get(id, options = {}) {
94
+ await this.readiness.ready();
95
+ const p = new SqlParams();
96
+ const journal = options.journal
97
+ ? `, (SELECT coalesce(jsonb_agg(j.entry ORDER BY j.seq), '[]')::text FROM ${this.t.journal} j WHERE j.instance_id = i.id) AS journal`
98
+ : '';
99
+ const [row] = await this.executor.query(`SELECT ${columns(INSTANCE_COLUMNS, 'i')},
100
+ (SELECT coalesce(json_agg(json_build_object('signal', w.signal, 'key', w.key) ORDER BY w.position), '[]')::text FROM ${this.t.waits} w WHERE w.instance_id = i.id) AS waits${journal}
101
+ FROM ${this.t.instances} i
102
+ WHERE i.id = ${p.text(id)}`, p.values);
103
+ if (!row) {
104
+ return null;
105
+ }
106
+ const details = { ...toInstance(row), waits: toJson(row.waits) };
107
+ return options.journal ? { ...details, journal: toJson(row.journal) } : details;
108
+ }
109
+ async list(query) {
110
+ await this.readiness.ready();
111
+ const p = new SqlParams();
112
+ const where = [
113
+ ...(query.status ? [p.in('status', query.status)] : []),
114
+ ...(query.workflow !== undefined ? [`workflow = ${p.text(query.workflow)}`] : []),
115
+ ...(query.version !== undefined ? [`version = ${p.int(query.version)}`] : []),
116
+ ...(query.parentId !== undefined ? [`parent_id = ${p.text(query.parentId)}`] : []),
117
+ ...(query.scheduleId !== undefined ? [`schedule_id = ${p.text(query.scheduleId)}`] : []),
118
+ ];
119
+ const rows = await this.executor.query(`SELECT ${columns(INSTANCE_COLUMNS)} FROM ${this.t.instances} i${where.length > 0 ? ` WHERE ${where.join(' AND ')}` : ''}
120
+ ORDER BY i.created_at, i.id LIMIT ${p.int(query.limit)} OFFSET ${p.int(query.offset)}`, p.values);
121
+ return rows.map(toInstance);
122
+ }
123
+ async requestCancel(id, { reason, now, terminate }) {
124
+ await this.readiness.ready();
125
+ // A terminate also stops a compensating instance, and follows a cancel.
126
+ const applies = terminate ? `status IN ${RUNNABLE} AND NOT terminate_requested` : `status IN ${CANCELLABLE} AND NOT cancel_requested`;
127
+ const p = new SqlParams();
128
+ const at = p.bigint(now);
129
+ const accepted = await this.executor.query(`UPDATE ${this.t.instances}
130
+ SET cancel_requested = true,${terminate ? ' terminate_requested = true,' : ''} cancel_reason = ${p.text(reason)}, updated_at = ${at}, wake_at = least(coalesce(wake_at, ${at}), ${at})
131
+ WHERE id = ${p.text(id)} AND ${applies}
132
+ RETURNING id`, p.values);
133
+ return accepted.length === 1;
134
+ }
135
+ /** `WorkflowClient.retry()`: one conditional update, then the journal, in one transaction. */
136
+ async reopen(id, reopen) {
137
+ await this.readiness.ready();
138
+ return this.executor.transaction(async (tx) => {
139
+ // The update locks the row; of two concurrent retries, the second finds it changed.
140
+ const p = new SqlParams();
141
+ const now = p.bigint(reopen.now);
142
+ const deadline = reopen.deadline === undefined ? '' : `, deadline = ${p.bigint(reopen.deadline)}`;
143
+ const reopened = await tx.query(`UPDATE ${this.t.instances}
144
+ SET status = ${p.text(reopen.status)}, error = ${p.json(reopen.error)}${deadline}, wake_at = ${now}, updated_at = ${now}
145
+ WHERE id = ${p.text(id)} AND lease_token IS NULL AND status = ${p.text(reopen.expect.status)} AND runs = ${p.int(reopen.expect.runs)}
146
+ RETURNING id`, p.values);
147
+ if (reopened.length === 0) {
148
+ return false;
149
+ }
150
+ if (reopen.entries.length > 0) {
151
+ await this.upsertEntries(tx, id, reopen.entries);
152
+ }
153
+ return true;
154
+ }, READ_COMMITTED);
155
+ }
156
+ /** `WorkflowClient.delete()`: its journal and waits go with it (ON DELETE CASCADE). */
157
+ async delete(id, statuses) {
158
+ await this.readiness.ready();
159
+ const p = new SqlParams();
160
+ const deleted = await this.executor.query(`DELETE FROM ${this.t.instances} WHERE id = ${p.text(id)} AND ${p.in('status', statuses)} RETURNING id`, p.values);
161
+ return deleted.length === 1;
162
+ }
163
+ // ---------------------------------------------------------------- signals
164
+ async signal(signal) {
165
+ await this.readiness.ready();
166
+ return this.executor.transaction((tx) => this.insertSignal(tx, signal), READ_COMMITTED);
167
+ }
168
+ /** `signal(..., { transaction })`: the signal and its wake-ups commit with the application's rows. */
169
+ async signalInTransaction(transaction, signal) {
170
+ const tx = this.executor.wrapTransaction(transaction);
171
+ await this.readiness.readyIn(tx);
172
+ await assertReadCommittedTransaction(tx, {
173
+ operation: 'signal() with { transaction }',
174
+ reason: 'its wake-ups would miss the waits committed after its snapshot',
175
+ });
176
+ return this.insertSignal(tx, signal);
177
+ }
178
+ async signals(query) {
179
+ await this.readiness.ready();
180
+ const p = new SqlParams();
181
+ const rows = await this.executor.query(`SELECT id::text AS id, name, key, payload::text AS payload, created_at::text AS created_at FROM ${this.t.signals} s
182
+ WHERE name = ${p.text(query.name)} AND ${p.equals('key', query.key)} AND id > ${p.bigint(query.afterId)} AND id <= ${p.bigint(query.upToId)}
183
+ ORDER BY s.id`, p.values);
184
+ return rows.map((row) => ({ id: toInt(row.id), name: row.name, key: row.key, payload: toJson(row.payload), createdAt: toInt(row.created_at) }));
185
+ }
186
+ // ---------------------------------------------------------------- retention
187
+ async purge(query) {
188
+ await this.readiness.ready();
189
+ const count = async (statement, p) => toInt((await this.executor.query(statement, p.values))[0]?.n) ?? 0;
190
+ // The condition again on the deleted rows: an instance reopened since the subquery read it stays. Its journal and
191
+ // waits go with it (ON DELETE CASCADE).
192
+ const p1 = new SqlParams();
193
+ const finished = `${p1.in('status', query.statuses)} AND updated_at < ${p1.bigint(query.before)}`;
194
+ const instances = await count(`WITH purged AS (
195
+ DELETE FROM ${this.t.instances}
196
+ WHERE id IN (SELECT id FROM ${this.t.instances} WHERE ${finished} ORDER BY updated_at, id LIMIT ${p1.int(query.limit)}) AND ${finished}
197
+ RETURNING 1
198
+ )
199
+ SELECT count(*)::text AS n FROM purged`, p1);
200
+ // Signals no instance can take: at or below every unfinished instance's cursor (new ones start at the newest
201
+ // signal), old enough, and never the newest, so the last signal id never goes back.
202
+ const p2 = new SqlParams();
203
+ const newest = `(SELECT max(id) FROM ${this.t.signals})`;
204
+ const signals = await count(`WITH pruned AS (
205
+ DELETE FROM ${this.t.signals}
206
+ WHERE id IN (
207
+ SELECT id FROM ${this.t.signals}
208
+ WHERE created_at < ${p2.bigint(query.before)} AND id < ${newest}
209
+ AND id <= coalesce((SELECT min(signal_cursor) FROM ${this.t.instances} WHERE status IN ${RUNNABLE}), ${newest})
210
+ ORDER BY id LIMIT ${p2.int(query.limit)}
211
+ )
212
+ RETURNING 1
213
+ )
214
+ SELECT count(*)::text AS n FROM pruned`, p2);
215
+ // Rate-limit windows that ended: again on the deleted rows, as a claim may have opened a new one meanwhile. Rows a
216
+ // claim is locking are skipped: waiting for them, in another order than the claim's, could deadlock.
217
+ const p3 = new SqlParams();
218
+ const ended = `window_end < ${p3.bigint(query.before)}`;
219
+ const rateLimits = await count(`WITH ended AS (
220
+ DELETE FROM ${this.t.rateLimits}
221
+ WHERE (workflow, key) IN (
222
+ SELECT workflow, key FROM ${this.t.rateLimits} WHERE ${ended} ORDER BY window_end, workflow, key LIMIT ${p3.int(query.limit)} FOR UPDATE SKIP LOCKED
223
+ ) AND ${ended}
224
+ RETURNING 1
225
+ )
226
+ SELECT count(*)::text AS n FROM ended`, p3);
227
+ return { instances, signals, rateLimits };
228
+ }
229
+ // ---------------------------------------------------------------- schedules
230
+ async saveSchedule(save) {
231
+ await this.readiness.ready();
232
+ const p = new SqlParams();
233
+ const now = p.bigint(save.now);
234
+ const fields = {
235
+ workflow: p.text(save.workflow),
236
+ declared: p.bool(save.declared),
237
+ spec: p.json(save.spec),
238
+ input: p.json(save.input),
239
+ paused: p.bool(save.paused),
240
+ wake_at: p.bigint(save.wakeAt),
241
+ state: p.json(save.state),
242
+ };
243
+ if (save.expectRevision === null) {
244
+ const [created] = await this.executor.query(`INSERT INTO ${this.t.schedules} (id, ${Object.keys(fields).join(', ')}, revision, created_at, updated_at)
245
+ VALUES (${p.text(save.id)}, ${Object.values(fields).join(', ')}, 1, ${now}, ${now})
246
+ ON CONFLICT (id) DO NOTHING
247
+ RETURNING ${columns(SCHEDULE_COLUMNS)}`, p.values);
248
+ return created ? toSchedule(created) : null;
249
+ }
250
+ // One conditional update: of two saves that read the same revision, the second finds it changed.
251
+ const set = Object.entries(fields).map(([column, value]) => `${column} = ${value}`);
252
+ const [saved] = await this.executor.query(`UPDATE ${this.t.schedules}
253
+ SET ${set.join(', ')}, updated_at = ${now}, revision = revision + 1${save.releaseLease ? ', lease_token = NULL, lease_until = NULL' : ''}
254
+ WHERE id = ${p.text(save.id)} AND revision = ${p.int(save.expectRevision)}
255
+ RETURNING ${columns(SCHEDULE_COLUMNS)}`, p.values);
256
+ return saved ? toSchedule(saved) : null;
257
+ }
258
+ async getSchedule(id) {
259
+ await this.readiness.ready();
260
+ const p = new SqlParams();
261
+ const [row] = await this.executor.query(`SELECT ${columns(SCHEDULE_COLUMNS)} FROM ${this.t.schedules} WHERE id = ${p.text(id)}`, p.values);
262
+ return row ? toSchedule(row) : null;
263
+ }
264
+ async listSchedules(query) {
265
+ await this.readiness.ready();
266
+ const p = new SqlParams();
267
+ const where = [
268
+ ...(query.workflow !== undefined ? [`workflow = ${p.text(query.workflow)}`] : []),
269
+ ...(query.declared !== undefined ? [`declared = ${p.bool(query.declared)}`] : []),
270
+ ];
271
+ const rows = await this.executor.query(`SELECT ${columns(SCHEDULE_COLUMNS)} FROM ${this.t.schedules} s${where.length > 0 ? ` WHERE ${where.join(' AND ')}` : ''}
272
+ ORDER BY s.id LIMIT ${p.int(query.limit)} OFFSET ${p.int(query.offset)}`, p.values);
273
+ return rows.map(toSchedule);
274
+ }
275
+ async deleteSchedule(id, revision) {
276
+ await this.readiness.ready();
277
+ const p = new SqlParams();
278
+ const deleted = await this.executor.query(`DELETE FROM ${this.t.schedules} WHERE id = ${p.text(id)}${revision !== undefined ? ` AND revision = ${p.int(revision)}` : ''} RETURNING id`, p.values);
279
+ return deleted.length === 1;
280
+ }
281
+ async claimSchedules(request) {
282
+ await this.readiness.ready();
283
+ // Locked; schedules another claim is locking right now are skipped instead of waited for.
284
+ const p = new SqlParams();
285
+ const now = p.bigint(request.now);
286
+ const rows = await this.executor.query(`WITH claimed AS (
287
+ UPDATE ${this.t.schedules}
288
+ SET lease_token = ${p.text(request.token)}, lease_owner = ${p.text(request.owner)}, lease_until = ${p.bigint(request.leaseUntil)}
289
+ WHERE id IN (
290
+ SELECT id FROM ${this.t.schedules}
291
+ WHERE NOT paused AND wake_at IS NOT NULL AND wake_at <= ${now} AND (lease_until IS NULL OR lease_until < ${now}) AND ${p.in('workflow', request.workflows)}
292
+ ORDER BY wake_at, id LIMIT ${p.int(request.limit)}
293
+ FOR UPDATE SKIP LOCKED
294
+ )
295
+ RETURNING *
296
+ )
297
+ SELECT ${columns(SCHEDULE_COLUMNS)} FROM claimed ORDER BY claimed.wake_at, claimed.id`, p.values);
298
+ return rows.map(toSchedule);
299
+ }
300
+ async writeSchedule(id, token, write) {
301
+ await this.readiness.ready();
302
+ const p = new SqlParams();
303
+ const written = await this.executor.query(`UPDATE ${this.t.schedules}
304
+ SET state = ${p.json(write.state)}, wake_at = ${p.bigint(write.wakeAt)}, revision = revision + 1, updated_at = ${p.bigint(write.now)}${write.release ? ', lease_token = NULL, lease_until = NULL' : ''}
305
+ WHERE id = ${p.text(id)} AND lease_token = ${p.text(token)}
306
+ RETURNING id`, p.values);
307
+ return written.length === 1;
308
+ }
309
+ // ---------------------------------------------------------------- the worker
310
+ async claim(request) {
311
+ await this.readiness.ready();
312
+ if (request.limits?.length || request.rateLimits?.length) {
313
+ return this.executor.transaction((tx) => this.claimWithin(tx, request), READ_COMMITTED);
314
+ }
315
+ // Locked; rows another claim is locking right now are skipped instead of waited for.
316
+ const p = new SqlParams();
317
+ const due = `SELECT i.id FROM ${this.t.instances} i WHERE ${this.isDue(p, request, 'i')}
318
+ ORDER BY i.priority, i.wake_at, i.created_at, i.id LIMIT ${p.int(request.limit)}
319
+ FOR UPDATE SKIP LOCKED`;
320
+ return { instances: await this.lease(this.executor, request, due, p), lastSignalId: await this.lastSignalId(this.executor) };
321
+ }
322
+ /**
323
+ * A claim under concurrency or rate limits. Claims of a workflow with a concurrency limit take its lock first (in
324
+ * name order, so two claims never wait for each other's), so they count the slots live leases hold and lease the
325
+ * instances that fit one after the other: two never both take the last slot. Rate-limit windows are counted again
326
+ * under their rows' locks, once the instances are picked (see `takeRoom()`).
327
+ */
328
+ async claimWithin(tx, request) {
329
+ const limits = request.limits ?? [];
330
+ const rates = request.rateLimits ?? [];
331
+ await advisoryLock(tx, limits.map((limit) => `@nestjs/workflows:${this.schema}:concurrency:${limit.workflow}`));
332
+ // Candidates with no room at all are passed over; then each concurrency key's first, as many as its free slots; of
333
+ // those, each rate key's, as many as its window has room for; of those, each workflow's, as many as both its free
334
+ // slots and its window allow. A full key is passed over, not waited behind. The windows read here are a snapshot,
335
+ // re-counted under their locks in takeRoom().
336
+ const p = new SqlParams();
337
+ const now = p.bigint(request.now);
338
+ const limitRows = limits.map((l) => ({ workflow: l.workflow, total: l.limit, per_key: l.perKey }));
339
+ const rateRows = rates.map((r) => ({ workflow: r.workflow, total: r.limit?.max ?? null, per_key: r.perKey?.max ?? null }));
340
+ const rows = await tx.query(`WITH limits AS (
341
+ SELECT * FROM jsonb_to_recordset(${p.json(limitRows)}) AS l(workflow text, total int, per_key int)
342
+ ),
343
+ rates AS (
344
+ SELECT * FROM jsonb_to_recordset(${p.json(rateRows)}) AS r(workflow text, total int, per_key int)
345
+ ),
346
+ held AS (
347
+ SELECT i.workflow AS workflow, i.concurrency_key AS key, count(*)::int AS n
348
+ FROM ${this.t.instances} i
349
+ WHERE i.lease_until >= ${now} AND i.workflow IN (SELECT workflow FROM limits)
350
+ GROUP BY 1, 2
351
+ ),
352
+ used AS (
353
+ SELECT rl.workflow AS workflow, rl.key AS key, rl.count AS n
354
+ FROM ${this.t.rateLimits} rl
355
+ WHERE rl.window_end > ${now} AND rl.workflow IN (SELECT workflow FROM rates)
356
+ ),
357
+ due AS (
358
+ SELECT i.id AS id, i.workflow AS workflow, i.concurrency_key AS key, i.rate_limit_key AS rate_key, i.priority AS priority,
359
+ i.wake_at AS wake_at, i.created_at AS created_at,
360
+ row_number() OVER (PARTITION BY i.workflow, i.concurrency_key ORDER BY i.priority, i.wake_at, i.created_at, i.id) AS key_rank
361
+ FROM ${this.t.instances} i
362
+ LEFT JOIN limits l ON l.workflow = i.workflow
363
+ LEFT JOIN rates r ON r.workflow = i.workflow
364
+ WHERE ${this.isDue(p, request, 'i')}
365
+ AND (l.total IS NULL OR coalesce((SELECT sum(n) FROM held h WHERE h.workflow = l.workflow), 0) < l.total)
366
+ AND (l.per_key IS NULL OR i.concurrency_key IS NULL
367
+ OR coalesce((SELECT n FROM held h WHERE h.workflow = l.workflow AND h.key = i.concurrency_key), 0) < l.per_key)
368
+ AND (r.total IS NULL OR coalesce((SELECT n FROM used u WHERE u.workflow = r.workflow AND u.key = ''), 0) < r.total)
369
+ AND (r.per_key IS NULL OR i.rate_limit_key IS NULL
370
+ OR coalesce((SELECT n FROM used u WHERE u.workflow = r.workflow AND u.key = i.rate_limit_key), 0) < r.per_key)
371
+ ),
372
+ fits_key AS (
373
+ SELECT d.*, row_number() OVER (PARTITION BY d.workflow, d.rate_key ORDER BY d.priority, d.wake_at, d.created_at, d.id) AS rate_key_rank
374
+ FROM due d
375
+ LEFT JOIN limits l ON l.workflow = d.workflow
376
+ WHERE l.per_key IS NULL OR d.key IS NULL
377
+ OR d.key_rank <= l.per_key - coalesce((SELECT n FROM held h WHERE h.workflow = d.workflow AND h.key = d.key), 0)
378
+ ),
379
+ fits_rate_key AS (
380
+ SELECT f.*, row_number() OVER (PARTITION BY f.workflow ORDER BY f.priority, f.wake_at, f.created_at, f.id) AS workflow_rank
381
+ FROM fits_key f
382
+ LEFT JOIN rates r ON r.workflow = f.workflow
383
+ WHERE r.per_key IS NULL OR f.rate_key IS NULL
384
+ OR f.rate_key_rank <= r.per_key - coalesce((SELECT n FROM used u WHERE u.workflow = f.workflow AND u.key = f.rate_key), 0)
385
+ )
386
+ SELECT f.id FROM fits_rate_key f
387
+ LEFT JOIN limits l ON l.workflow = f.workflow
388
+ LEFT JOIN rates r ON r.workflow = f.workflow
389
+ WHERE (l.total IS NULL OR f.workflow_rank <= l.total - coalesce((SELECT sum(n)::int FROM held h WHERE h.workflow = f.workflow), 0))
390
+ AND (r.total IS NULL OR f.workflow_rank <= r.total - coalesce((SELECT n FROM used u WHERE u.workflow = f.workflow AND u.key = ''), 0))
391
+ ORDER BY f.priority, f.wake_at, f.created_at, f.id
392
+ LIMIT ${p.int(request.limit)}`, p.values);
393
+ if (rows.length === 0) {
394
+ return { instances: [], lastSignalId: await this.lastSignalId(tx) };
395
+ }
396
+ const lock = new SqlParams();
397
+ const picked = await tx.query(`SELECT i.id, i.workflow, i.rate_limit_key FROM ${this.t.instances} i
398
+ WHERE ${lock.in('i.id', rows.map((row) => row.id))} AND ${this.isDue(lock, request, 'i')}
399
+ ORDER BY i.priority, i.wake_at, i.created_at, i.id
400
+ FOR UPDATE SKIP LOCKED`, lock.values);
401
+ const granted = await this.takeRoom(tx, request, picked.map((row) => ({ id: row.id, workflow: row.workflow, rateLimitKey: row.rate_limit_key })));
402
+ if (granted.length === 0) {
403
+ return { instances: [], lastSignalId: await this.lastSignalId(tx) };
404
+ }
405
+ const p2 = new SqlParams();
406
+ const instances = await this.lease(tx, request, `SELECT id FROM ${this.t.instances} WHERE ${p2.in('id', granted)}`, p2);
407
+ return { instances, lastSignalId: await this.lastSignalId(tx) };
408
+ }
409
+ /**
410
+ * Of `picked` (in claim order), the instances their rate-limit windows have room for, recorded in the windows. Each
411
+ * window's row is inserted or locked first, in a fixed order: the count read under the lock is exact (a concurrent
412
+ * claim of the same window waits for this one), and a purge can't delete the row in between.
413
+ */
414
+ async takeRoom(tx, request, picked) {
415
+ const claim = new RateWindowClaim(request, picked);
416
+ if (claim.windows.length === 0) {
417
+ return picked.map((instance) => instance.id);
418
+ }
419
+ const p = new SqlParams();
420
+ const locked = await tx.query(`INSERT INTO ${this.t.rateLimits} AS rl (workflow, key, window_end, count)
421
+ VALUES ${claim.windows.map(({ workflow, key }) => `(${p.text(workflow)}, ${p.text(key)}, 0, 0)`).join(', ')}
422
+ ON CONFLICT (workflow, key) DO UPDATE SET count = rl.count
423
+ RETURNING rl.workflow, rl.key, rl.window_end::text AS window_end, rl.count::text AS count`, p.values);
424
+ const { granted, changed } = claim.grant(locked.map((row) => ({ workflow: row.workflow, key: row.key, windowEnd: toInt(row.window_end), count: toInt(row.count) })));
425
+ if (changed.length > 0) {
426
+ const u = new SqlParams();
427
+ const values = changed.map((w) => ({ workflow: w.workflow, key: w.key, window_end: w.windowEnd, count: w.count }));
428
+ await tx.query(`UPDATE ${this.t.rateLimits} rl SET window_end = v.window_end, count = v.count
429
+ FROM jsonb_to_recordset(${u.json(values)}) AS v(workflow text, key text, window_end bigint, count int)
430
+ WHERE rl.workflow = v.workflow AND rl.key = v.key`, u.values);
431
+ }
432
+ return granted;
433
+ }
434
+ /** Leases the instances `due` (a subquery of `p`'s statement) selects and locks. */
435
+ async lease(db, request, due, p) {
436
+ const rows = await db.query(`WITH claimed AS (
437
+ UPDATE ${this.t.instances}
438
+ SET lease_token = ${p.text(request.token)}, lease_owner = ${p.text(request.owner)}, lease_until = ${p.bigint(request.leaseUntil)},
439
+ runs = runs + 1, updated_at = ${p.bigint(request.now)}, status = CASE WHEN status = 'compensating' THEN status ELSE 'running' END
440
+ WHERE id IN (${due})
441
+ RETURNING *
442
+ )
443
+ SELECT ${columns(INSTANCE_COLUMNS)} FROM claimed ORDER BY claimed.priority, claimed.wake_at, claimed.created_at, claimed.id`, p.values);
444
+ return rows.map(toInstance);
445
+ }
446
+ async renew(id, token, leaseUntil) {
447
+ await this.readiness.ready();
448
+ const p = new SqlParams();
449
+ const [row] = await this.executor.query(`UPDATE ${this.t.instances} SET lease_until = ${p.bigint(leaseUntil)} WHERE id = ${p.text(id)} AND lease_token = ${p.text(token)}
450
+ RETURNING cancel_requested::text AS cancel_requested, terminate_requested::text AS terminate_requested`, p.values);
451
+ return row ? { cancelRequested: toBool(row.cancel_requested), terminateRequested: toBool(row.terminate_requested) } : null;
452
+ }
453
+ async write(id, token, write) {
454
+ await this.readiness.ready();
455
+ return this.executor.transaction(async (tx) => {
456
+ const release = write.release;
457
+ if (write.signal) {
458
+ await advisoryLock(tx, this.signalLock);
459
+ }
460
+ else if (release && release.waits.length > 0) {
461
+ await advisoryLock(tx, this.signalLock, { shared: true });
462
+ }
463
+ // The fence: only the lease holder writes, and the row stays locked until commit.
464
+ const fence = new SqlParams();
465
+ const [fenced] = await tx.query(`SELECT cancel_requested::text AS cancel_requested FROM ${this.t.instances} WHERE id = ${fence.text(id)} AND lease_token = ${fence.text(token)} FOR UPDATE`, fence.values);
466
+ if (!fenced) {
467
+ return false;
468
+ }
469
+ if (write.entries.length > 0) {
470
+ await this.upsertEntries(tx, id, write.entries);
471
+ }
472
+ if (write.signal) {
473
+ await this.insertSignal(tx, write.signal);
474
+ }
475
+ // Fields the write leaves out (undefined) keep their value.
476
+ const p = new SqlParams();
477
+ const set = [
478
+ ...(write.status !== undefined ? [`status = ${p.text(write.status)}`] : []),
479
+ ...(write.output !== undefined ? [`output = ${p.json(write.output)}`] : []),
480
+ ...(write.error !== undefined ? [`error = ${p.json(write.error)}`] : []),
481
+ ...(write.customStatus !== undefined ? [`custom_status = ${p.json(write.customStatus)}`] : []),
482
+ ];
483
+ if (release) {
484
+ const missed = await this.replaceWaits(tx, id, release);
485
+ const wakeNow = missed || (toBool(fenced.cancel_requested) && write.status === 'suspended');
486
+ set.push('lease_token = NULL', 'lease_until = NULL', `wake_at = ${p.bigint(wakeNow ? write.now : release.wakeAt)}`);
487
+ }
488
+ if (set.length > 0) {
489
+ await tx.query(`UPDATE ${this.t.instances} SET ${set.join(', ')}, updated_at = ${p.bigint(write.now)} WHERE id = ${p.text(id)}`, p.values);
490
+ }
491
+ return true;
492
+ }, READ_COMMITTED);
493
+ }
494
+ // ---------------------------------------------------------------- internals
495
+ async insertInstance(db, i) {
496
+ // An instance deleted between the insert that met it and the read of it is created again.
497
+ for (let attempt = 1;; attempt++) {
498
+ const p = new SqlParams();
499
+ const now = p.bigint(i.now);
500
+ const [created] = await db.query(`INSERT INTO ${this.t.instances} (id, workflow, version, parent_id, parent_close, concurrency_key, rate_limit_key, priority, schedule_id,
501
+ scheduled_at, status, input, deadline, wake_at, signal_cursor, created_at, updated_at)
502
+ VALUES (${p.text(i.id)}, ${p.text(i.workflow)}, ${p.int(i.version)}, ${p.text(i.parentId ?? null)}, ${p.text(i.parentClose ?? null)},
503
+ ${p.text(i.concurrencyKey ?? null)}, ${p.text(i.rateLimitKey ?? null)}, ${p.int(i.priority ?? 0)}, ${p.text(i.scheduleId ?? null)},
504
+ ${p.bigint(i.scheduledAt ?? null)}, 'pending', ${p.json(i.input)}, ${p.bigint(i.deadline)}, ${now},
505
+ (SELECT coalesce(max(id), 0) FROM ${this.t.signals}), ${now}, ${now})
506
+ ON CONFLICT (id) DO NOTHING
507
+ RETURNING ${columns(INSTANCE_COLUMNS)}`, p.values);
508
+ if (created) {
509
+ return { instance: toInstance(created), created: true };
510
+ }
511
+ const read = new SqlParams();
512
+ const [existing] = await db.query(`SELECT ${columns(INSTANCE_COLUMNS)} FROM ${this.t.instances} WHERE id = ${read.text(i.id)}`, read.values);
513
+ if (existing) {
514
+ return { instance: toInstance(existing), created: false };
515
+ }
516
+ if (attempt === 3) {
517
+ throw new Error(`PostgresWorkflowStore: instance "${i.id}" was deleted each time its creation met it.`);
518
+ }
519
+ }
520
+ }
521
+ async insertSignal(tx, s) {
522
+ await advisoryLock(tx, this.signalLock);
523
+ // A name and dedupe id stored before make this a no-op: the unique constraint decides, with nothing to catch,
524
+ // because an error would abort the application's transaction.
525
+ const p = new SqlParams();
526
+ const [signal] = await tx.query(`INSERT INTO ${this.t.signals} (name, key, dedupe_id, payload, created_at)
527
+ VALUES (${p.text(s.name)}, ${p.text(s.key)}, ${p.text(s.dedupeId)}, ${p.json(s.payload)}, ${p.bigint(s.now)})
528
+ ON CONFLICT (name, dedupe_id) DO NOTHING
529
+ RETURNING id::text AS id`, p.values);
530
+ if (!signal) {
531
+ const read = new SqlParams();
532
+ const [earlier] = await tx.query(`SELECT id::text AS id, key FROM ${this.t.signals} WHERE name = ${read.text(s.name)} AND dedupe_id = ${read.text(s.dedupeId)}`, read.values);
533
+ return { id: toInt(earlier.id), woken: 0, created: false, key: toText(earlier.key) };
534
+ }
535
+ const w = new SqlParams();
536
+ const now = w.bigint(s.now);
537
+ const [woken] = await tx.query(`WITH woken AS (
538
+ UPDATE ${this.t.instances} SET wake_at = ${now}, updated_at = ${now}
539
+ WHERE status = 'suspended' AND (wake_at IS NULL OR wake_at > ${now})
540
+ AND id IN (SELECT instance_id FROM ${this.t.waits} WHERE signal = ${w.text(s.name)} AND ${w.equals('key', s.key)})
541
+ RETURNING 1
542
+ )
543
+ SELECT count(*)::text AS n FROM woken`, w.values);
544
+ return { id: toInt(signal.id), woken: toInt(woken?.n) ?? 0, created: true, key: s.key };
545
+ }
546
+ /**
547
+ * Replaces the instance's waits, and says whether a signal committed after the execution read its cursor matches
548
+ * one of the new ones: then the instance stays due instead of losing it.
549
+ */
550
+ async replaceWaits(tx, id, release) {
551
+ const d = new SqlParams();
552
+ await tx.query(`DELETE FROM ${this.t.waits} WHERE instance_id = ${d.text(id)}`, d.values);
553
+ if (release.waits.length === 0) {
554
+ return false;
555
+ }
556
+ const p = new SqlParams();
557
+ const instance = p.text(id);
558
+ await tx.query(`INSERT INTO ${this.t.waits} (instance_id, position, signal, key)
559
+ VALUES ${release.waits.map((wait, position) => `(${instance}, ${position}, ${p.text(wait.signal)}, ${p.text(wait.key)})`).join(', ')}`, p.values);
560
+ const m = new SqlParams();
561
+ const matches = release.waits.map((wait) => `(name = ${m.text(wait.signal)} AND ${m.equals('key', wait.key)})`);
562
+ const [hit] = await tx.query(`SELECT id::text AS id FROM ${this.t.signals} WHERE id > ${m.bigint(release.signalCursor)} AND (${matches.join(' OR ')}) LIMIT 1`, m.values);
563
+ return hit !== undefined;
564
+ }
565
+ /** Journal entries by name: a new name goes last (`seq`), a known one is replaced in place. */
566
+ async upsertEntries(tx, id, entries) {
567
+ const p = new SqlParams();
568
+ const instance = p.text(id);
569
+ await tx.query(`INSERT INTO ${this.t.journal} (instance_id, name, entry)
570
+ VALUES ${entries.map((entry) => `(${instance}, ${p.text(entry.name)}, ${p.json(entry)})`).join(', ')}
571
+ ON CONFLICT (instance_id, name) DO UPDATE SET entry = excluded.entry`, p.values);
572
+ }
573
+ /** Due, unleased instances of the versions the worker runs, as `alias` in a statement of `p`. */
574
+ isDue(p, request, alias) {
575
+ const now = p.bigint(request.now);
576
+ const versions = request.workflows.map((w) => `(${p.text(w.name)}, ${p.int(w.version)})`).join(', ');
577
+ return `${alias}.wake_at IS NOT NULL AND ${alias}.wake_at <= ${now} AND ${alias}.status IN ${RUNNABLE}
578
+ AND (${alias}.lease_until IS NULL OR ${alias}.lease_until < ${now}) AND (${alias}.workflow, ${alias}.version) IN (VALUES ${versions})`;
579
+ }
580
+ async lastSignalId(db) {
581
+ const [row] = await db.query(`SELECT coalesce(max(id), 0)::text AS id FROM ${this.t.signals}`);
582
+ return toInt(row?.id) ?? 0;
583
+ }
584
+ }
585
+ /** The schema version this version of the package needs: its last migration. */
586
+ PostgresWorkflowStore.schemaVersion = workflowStoreSchema.latest;
@@ -0,0 +1,6 @@
1
+ export * from './workflow-client.service.js';
2
+ export * from './workflow-execution.service.js';
3
+ export * from './workflow-registry.service.js';
4
+ export * from './workflow-scheduler.service.js';
5
+ export * from './workflow-schedules.service.js';
6
+ export * from './workflow-worker.service.js';
@@ -0,0 +1,6 @@
1
+ export * from './workflow-client.service.js';
2
+ export * from './workflow-execution.service.js';
3
+ export * from './workflow-registry.service.js';
4
+ export * from './workflow-scheduler.service.js';
5
+ export * from './workflow-schedules.service.js';
6
+ export * from './workflow-worker.service.js';