@iworkflow/core 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,11 +33,11 @@ try {
33
33
  }
34
34
  ```
35
35
 
36
- Ещё: `ctx.startChild` (без ожидания), `ChildOptions.timeoutMs` и `onExisting`, `ctx.startedAt`,
37
- `ctx.parentId`, `AppDefinition.pools`, `handle.describe()` и `handle.terminate()`.
36
+ Ещё: `ctx.startChild` (без ожидания, `reuseClosedId`), `ChildOptions.timeoutMs` и `onExisting`, `ctx.startedAt`,
37
+ `ctx.parentId`, `AppDefinition.pools` и `maxConcurrentSteps`, `StartOptions.onExisting`, `handle.describe()` и `handle.terminate()`.
38
38
 
39
39
  Приоритет: `client.start(..., { priority })` (1 — первым, 5 — последним) → `ctx.priority`; шаги и
40
40
  дети его наследуют, `StepOptions.priority` — свой. Срок повтора из отказа: `new Failure(msg, { type,
41
- retryAfterMs })`; `benign: true` — штатный отказ, Temporal не пишет его ошибкой. Номер попытки в шаге — `stepAttempt()`. Время в процессе — `await ctx.now()`.
41
+ retryAfterMs })`; `benign: true` — штатный отказ, Temporal не пишет его ошибкой. Номер попытки в шаге — `stepAttempt()`, процесс и запуск — `stepInfo()`, отмена попытки — `stepSignal()`. Журнал без повторов — `ctx.log`. Время в процессе — `await ctx.now()`.
42
42
  `client.signalWithStart(target, args, opts, topic, value)` — сигнал идущему или запуск с сигналом.
43
43
  Контракт обоих движков — `lab/test/contract.test.ts` репозитория.
package/dist/errors.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { AppDefinition } from "./types";
1
2
  /**
2
3
  * Отказ, который бросает код шага или процесса. `type` — код для разбора (`FlowError.type`),
3
4
  * `nonRetryable` — шаг не повторять. Отказ процесса не повторяется никогда.
@@ -47,3 +48,7 @@ export declare const isFlowError: (e: unknown) => e is FlowError;
47
48
  /** Приоритет порта — целое 1…5 (1 — первым); иное отвергается одинаково на обоих движках. */
48
49
  export declare function checkPriority(p: number | undefined): number | undefined;
49
50
  export declare const DEFAULT_PRIORITY = 3;
51
+ /** Пределы модуля — целые ≥ 1: иначе DBOS молча вешает шаги, а Temporal падает на старте. */
52
+ export declare function checkLimits(def: AppDefinition): void;
53
+ /** attrs журнала — в JSON-данные: BigInt строкой, Error — name, message и свои поля; непереносимый ключ выпадает один. */
54
+ export declare function plainAttrs(attrs: Record<string, unknown> | undefined): Record<string, unknown> | undefined;
@@ -3,6 +3,14 @@ export interface StepRuntime {
3
3
  lastHeartbeat(): Promise<unknown>;
4
4
  /** Номер попытки шага, с 1. */
5
5
  attempt(): number;
6
+ /** Процесс шага, запуск и шаг в нём. */
7
+ info(): {
8
+ workflowId: string;
9
+ runId: string;
10
+ stepId: string;
11
+ };
12
+ /** Отмена попытки. Temporal — срок и отмена процесса, узнаёт через heartbeat; DBOS — только срок попытки. */
13
+ signal(): AbortSignal;
6
14
  }
7
15
  /** Область шага: какой рантайм у шага, что идёт сейчас. Самый внутренний шаг — свой, без угадывания движка. */
8
16
  export interface StepScope {
@@ -20,3 +28,12 @@ export declare const heartbeat: (details?: unknown) => Promise<void>;
20
28
  export declare const lastHeartbeat: <T>() => Promise<T | undefined>;
21
29
  /** Номер попытки этого шага, с 1. DBOS считает заново после рестарта процесса, Temporal — нет. */
22
30
  export declare const stepAttempt: () => number;
31
+ /** Ключ идемпотентности внешней записи — runId + stepId: id переиспользуется (reuseClosedId), runId нет. */
32
+ export declare const stepInfo: () => {
33
+ workflowId: string;
34
+ runId: string;
35
+ stepId: string;
36
+ attempt: number;
37
+ };
38
+ /** Сигнал отмены попытки: передать в fetch и т. п.; aborted — итог попытки отброшен, не оборачивать в Failure. */
39
+ export declare const stepSignal: () => AbortSignal;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export * from "./types";
2
- export { heartbeat, lastHeartbeat, stepAttempt, stepScope, type StepRuntime, type StepScope } from "./heartbeat";
2
+ export { heartbeat, lastHeartbeat, stepAttempt, stepInfo, stepScope, stepSignal, type StepRuntime, type StepScope } from "./heartbeat";
3
3
  export * from "./errors";
4
4
  export { exitSignal, type ExitSignal } from "./exit";
package/dist/index.js CHANGED
@@ -3,15 +3,20 @@ var KEY = Symbol.for("@iworkflow/core.step-scope");
3
3
  function stepScope(make) {
4
4
  return globalThis[KEY] ??= make();
5
5
  }
6
- function current() {
6
+ function current(name = "heartbeat") {
7
7
  const rt = globalThis[KEY]?.current();
8
8
  if (!rt)
9
- throw new Error("heartbeat вне шага: шаг движка процесса сейчас не идёт");
9
+ throw new Error(`${name} вне шага: шаг движка процесса сейчас не идёт`);
10
10
  return rt;
11
11
  }
12
12
  var heartbeat = (details) => current().heartbeat(details);
13
13
  var lastHeartbeat = async () => await current().lastHeartbeat() ?? undefined;
14
- var stepAttempt = () => current().attempt();
14
+ var stepAttempt = () => current("stepAttempt").attempt();
15
+ var stepInfo = () => {
16
+ const rt = current("stepInfo");
17
+ return { ...rt.info(), attempt: rt.attempt() };
18
+ };
19
+ var stepSignal = () => current("stepSignal").signal();
15
20
  // src/errors.ts
16
21
  class Failure extends Error {
17
22
  type;
@@ -55,6 +60,29 @@ function checkPriority(p) {
55
60
  return p;
56
61
  }
57
62
  var DEFAULT_PRIORITY = 3;
63
+ function checkLimits(def) {
64
+ const check = (name, n) => {
65
+ if (n !== undefined && (!Number.isInteger(n) || n < 1))
66
+ throw new Error(`${def.app}: ${name} = ${n}, нужно целое ≥ 1`);
67
+ };
68
+ check("maxConcurrentSteps", def.maxConcurrentSteps);
69
+ for (const [p, n] of Object.entries(def.pools ?? {}))
70
+ check(`pools.${p}`, n);
71
+ }
72
+ function plainAttrs(attrs) {
73
+ if (!attrs)
74
+ return;
75
+ const plain = (_, v) => typeof v === "bigint" ? String(v) : v instanceof Error ? { ...v, name: v.name, message: v.message } : v;
76
+ const out = {};
77
+ for (const [k, v] of Object.entries(attrs)) {
78
+ try {
79
+ const json = JSON.stringify(v, plain);
80
+ if (json !== undefined)
81
+ out[k] = JSON.parse(json);
82
+ } catch {}
83
+ }
84
+ return out;
85
+ }
58
86
  // src/exit.ts
59
87
  function exitSignal() {
60
88
  let d = Promise.withResolvers();
@@ -83,12 +111,16 @@ export {
83
111
  DEFAULT_PRIORITY,
84
112
  Failure,
85
113
  FlowError,
114
+ checkLimits,
86
115
  checkPriority,
87
116
  exitSignal,
88
117
  heartbeat,
89
118
  isFlowError,
90
119
  lastHeartbeat,
91
120
  nonRetryable,
121
+ plainAttrs,
92
122
  stepAttempt,
93
- stepScope
123
+ stepInfo,
124
+ stepScope,
125
+ stepSignal
94
126
  };
package/dist/types.d.ts CHANGED
@@ -34,6 +34,13 @@ export interface ChildOptions {
34
34
  */
35
35
  onExisting?: "reject";
36
36
  }
37
+ export interface StartChildOptions extends Omit<ChildOptions, "onExisting"> {
38
+ /**
39
+ * Id завершённого процесса — новый запуск под ним (окно сбора на чат). Только Temporal: DBOS id не
40
+ * переиспользует — already-started. Идущий процесс с этим id — already-started всегда.
41
+ */
42
+ reuseClosedId?: boolean;
43
+ }
37
44
  /** Дочерний процесс, запущенный без ожидания. */
38
45
  export interface ChildHandle<T> {
39
46
  id: string;
@@ -58,12 +65,14 @@ export interface FlowContext {
58
65
  * Дочерний процесс без ожидания: живёт и после завершения родителя. Id уже идёт или занят —
59
66
  * FlowError reason "already-started".
60
67
  */
61
- startChild<T>(target: FlowRef, args: unknown[], opts: Omit<ChildOptions, "onExisting">): Promise<ChildHandle<T>>;
68
+ startChild<T>(target: FlowRef, args: unknown[], opts: StartChildOptions): Promise<ChildHandle<T>>;
62
69
  sleep(ms: number): Promise<void>;
63
- /** Ждать сигнал по теме; null — вышло время. Сигнал, пришедший раньше, не теряется. */
70
+ /** Ждать сигнал по теме; null — вышло время, при timeoutMs 0 — сигнала нет сейчас (Temporal: таймер 1 мс). Пришедший раньше не теряется. */
64
71
  recv<T>(topic: string, timeoutMs: number): Promise<T | null>;
65
72
  /** Сигнал другому процессу по id — в том числе в другом модуле. Процесса нет или он завершён — FlowError "not-found". */
66
73
  signal(workflowId: string, topic: string, value: unknown): Promise<void>;
74
+ /** Журнал из процесса: Temporal не повторяет строку при переигрывании, DBOS может повторить после рестарта. */
75
+ log(level: "info" | "warn" | "error", message: string, attrs?: Record<string, unknown>): Promise<void>;
67
76
  /** Опубликовать значение для query снаружи; читается и после завершения процесса. */
68
77
  setState(key: string, value: unknown): Promise<void>;
69
78
  /**
@@ -92,6 +101,8 @@ export interface AppDefinition {
92
101
  patches?: string[];
93
102
  /** Пулы шагов: имя → сколько шагов пула идёт одновременно в этом процессе. */
94
103
  pools?: Record<string, number>;
104
+ /** Сколько шагов вне пулов идёт одновременно в этом процессе (бережёт пул БД). Не задано — у DBOS без предела, у Temporal 100. */
105
+ maxConcurrentSteps?: number;
95
106
  }
96
107
  export type FlowStatus = "running" | "completed" | "failed" | "terminated" | "timed-out";
97
108
  export interface FlowDescription {
@@ -124,16 +135,23 @@ export interface StartOptions {
124
135
  timeoutMs?: number;
125
136
  /** 1 — первым, 5 — последним; шаги процесса наследуют его. */
126
137
  priority?: number;
138
+ /** Id уже занят (идёт или завершён; Temporal помнит завершённый в пределах хранения namespace) — FlowError "already-started". */
139
+ onExisting?: "reject";
140
+ /**
141
+ * Отказ, только пока процесс идёт; завершённый id — новый запуск (ночные задачи с постоянным id).
142
+ * Включает onExisting "reject". Только Temporal: DBOS id не переиспользует — already-started.
143
+ */
144
+ reuseClosedId?: boolean;
127
145
  }
128
146
  /** Сторона вызывающего вне процесса (HTTP, CLI, тест). */
129
147
  export interface EngineClient {
130
- /** Id уже идёт — handle к нему. timeoutMs — срок процесса. */
148
+ /** Id уже идёт — handle к нему (с onExisting "reject" — already-started). timeoutMs — срок процесса. */
131
149
  start<T>(target: FlowRef, args: unknown[], opts: StartOptions): Promise<FlowHandle<T>>;
132
150
  /**
133
151
  * Сигнал процессу; нет идущего с этим id — запустить и отдать сигнал ему. Temporal заводит новый
134
152
  * запуск и под id завершённого; DBOS id не переиспользует — сигнал уйдёт завершённому и пропадёт.
135
153
  */
136
- signalWithStart<T>(target: FlowRef, args: unknown[], opts: StartOptions, topic: string, value: unknown): Promise<FlowHandle<T>>;
154
+ signalWithStart<T>(target: FlowRef, args: unknown[], opts: Omit<StartOptions, "onExisting" | "reuseClosedId">, topic: string, value: unknown): Promise<FlowHandle<T>>;
137
155
  /** Уже запущенный процесс по id. */
138
156
  handle<T>(id: string): FlowHandle<T>;
139
157
  close(): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iworkflow/core",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
4
4
  "description": "Engine-neutral durable workflow port: flows, steps, child workflows, signals, queries, patches, heartbeat",
5
5
  "keywords": [
6
6
  "workflow",
package/src/errors.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  // Ошибки порта: одинаковые на обоих движках. Без импортов рантайма — их читает песочница Temporal.
2
+ import type { AppDefinition } from "./types";
2
3
 
3
4
  /**
4
5
  * Отказ, который бросает код шага или процесса. `type` — код для разбора (`FlowError.type`),
@@ -75,3 +76,29 @@ export function checkPriority(p: number | undefined): number | undefined {
75
76
  }
76
77
 
77
78
  export const DEFAULT_PRIORITY = 3;
79
+
80
+ /** Пределы модуля — целые ≥ 1: иначе DBOS молча вешает шаги, а Temporal падает на старте. */
81
+ export function checkLimits(def: AppDefinition): void {
82
+ const check = (name: string, n: number | undefined) => {
83
+ if (n !== undefined && (!Number.isInteger(n) || n < 1)) throw new Error(`${def.app}: ${name} = ${n}, нужно целое ≥ 1`);
84
+ };
85
+ check("maxConcurrentSteps", def.maxConcurrentSteps);
86
+ for (const [p, n] of Object.entries(def.pools ?? {})) check(`pools.${p}`, n);
87
+ }
88
+
89
+ /** attrs журнала — в JSON-данные: BigInt строкой, Error — name, message и свои поля; непереносимый ключ выпадает один. */
90
+ export function plainAttrs(attrs: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
91
+ if (!attrs) return undefined;
92
+ const plain = (_: string, v: unknown) =>
93
+ typeof v === "bigint" ? String(v) : v instanceof Error ? { ...v, name: v.name, message: v.message } : v;
94
+ const out: Record<string, unknown> = {};
95
+ for (const [k, v] of Object.entries(attrs)) {
96
+ try {
97
+ const json = JSON.stringify(v, plain);
98
+ if (json !== undefined) out[k] = JSON.parse(json);
99
+ } catch {
100
+ // цикл или бросающий геттер — только этот ключ
101
+ }
102
+ }
103
+ return out;
104
+ }
package/src/heartbeat.ts CHANGED
@@ -6,6 +6,10 @@ export interface StepRuntime {
6
6
  lastHeartbeat(): Promise<unknown>;
7
7
  /** Номер попытки шага, с 1. */
8
8
  attempt(): number;
9
+ /** Процесс шага, запуск и шаг в нём. */
10
+ info(): { workflowId: string; runId: string; stepId: string };
11
+ /** Отмена попытки. Temporal — срок и отмена процесса, узнаёт через heartbeat; DBOS — только срок попытки. */
12
+ signal(): AbortSignal;
9
13
  }
10
14
 
11
15
  /** Область шага: какой рантайм у шага, что идёт сейчас. Самый внутренний шаг — свой, без угадывания движка. */
@@ -25,9 +29,9 @@ export function stepScope(make: () => StepScope): StepScope {
25
29
  return ((globalThis as Slot)[KEY] ??= make());
26
30
  }
27
31
 
28
- function current(): StepRuntime {
32
+ function current(name = "heartbeat"): StepRuntime {
29
33
  const rt = (globalThis as Slot)[KEY]?.current();
30
- if (!rt) throw new Error("heartbeat вне шага: шаг движка процесса сейчас не идёт");
34
+ if (!rt) throw new Error(`${name} вне шага: шаг движка процесса сейчас не идёт`);
31
35
  return rt;
32
36
  }
33
37
 
@@ -39,4 +43,14 @@ export const lastHeartbeat = async <T>(): Promise<T | undefined> =>
39
43
  ((await current().lastHeartbeat()) ?? undefined) as T | undefined;
40
44
 
41
45
  /** Номер попытки этого шага, с 1. DBOS считает заново после рестарта процесса, Temporal — нет. */
42
- export const stepAttempt = (): number => current().attempt();
46
+ export const stepAttempt = (): number => current("stepAttempt").attempt();
47
+
48
+ /** Ключ идемпотентности внешней записи — runId + stepId: id переиспользуется (reuseClosedId), runId нет. */
49
+ // У DBOS runId = workflowId: id не переиспользуется.
50
+ export const stepInfo = (): { workflowId: string; runId: string; stepId: string; attempt: number } => {
51
+ const rt = current("stepInfo");
52
+ return { ...rt.info(), attempt: rt.attempt() };
53
+ };
54
+
55
+ /** Сигнал отмены попытки: передать в fetch и т. п.; aborted — итог попытки отброшен, не оборачивать в Failure. */
56
+ export const stepSignal = (): AbortSignal => current("stepSignal").signal();
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export * from "./types";
2
- export { heartbeat, lastHeartbeat, stepAttempt, stepScope, type StepRuntime, type StepScope } from "./heartbeat";
2
+ export { heartbeat, lastHeartbeat, stepAttempt, stepInfo, stepScope, stepSignal, type StepRuntime, type StepScope } from "./heartbeat";
3
3
  export * from "./errors";
4
4
  export { exitSignal, type ExitSignal } from "./exit";
package/src/types.ts CHANGED
@@ -40,6 +40,14 @@ export interface ChildOptions {
40
40
  onExisting?: "reject";
41
41
  }
42
42
 
43
+ export interface StartChildOptions extends Omit<ChildOptions, "onExisting"> {
44
+ /**
45
+ * Id завершённого процесса — новый запуск под ним (окно сбора на чат). Только Temporal: DBOS id не
46
+ * переиспользует — already-started. Идущий процесс с этим id — already-started всегда.
47
+ */
48
+ reuseClosedId?: boolean;
49
+ }
50
+
43
51
  /** Дочерний процесс, запущенный без ожидания. */
44
52
  export interface ChildHandle<T> {
45
53
  id: string;
@@ -65,12 +73,14 @@ export interface FlowContext {
65
73
  * Дочерний процесс без ожидания: живёт и после завершения родителя. Id уже идёт или занят —
66
74
  * FlowError reason "already-started".
67
75
  */
68
- startChild<T>(target: FlowRef, args: unknown[], opts: Omit<ChildOptions, "onExisting">): Promise<ChildHandle<T>>;
76
+ startChild<T>(target: FlowRef, args: unknown[], opts: StartChildOptions): Promise<ChildHandle<T>>;
69
77
  sleep(ms: number): Promise<void>;
70
- /** Ждать сигнал по теме; null — вышло время. Сигнал, пришедший раньше, не теряется. */
78
+ /** Ждать сигнал по теме; null — вышло время, при timeoutMs 0 — сигнала нет сейчас (Temporal: таймер 1 мс). Пришедший раньше не теряется. */
71
79
  recv<T>(topic: string, timeoutMs: number): Promise<T | null>;
72
80
  /** Сигнал другому процессу по id — в том числе в другом модуле. Процесса нет или он завершён — FlowError "not-found". */
73
81
  signal(workflowId: string, topic: string, value: unknown): Promise<void>;
82
+ /** Журнал из процесса: Temporal не повторяет строку при переигрывании, DBOS может повторить после рестарта. */
83
+ log(level: "info" | "warn" | "error", message: string, attrs?: Record<string, unknown>): Promise<void>;
74
84
  /** Опубликовать значение для query снаружи; читается и после завершения процесса. */
75
85
  setState(key: string, value: unknown): Promise<void>;
76
86
  /**
@@ -102,6 +112,8 @@ export interface AppDefinition {
102
112
  patches?: string[];
103
113
  /** Пулы шагов: имя → сколько шагов пула идёт одновременно в этом процессе. */
104
114
  pools?: Record<string, number>;
115
+ /** Сколько шагов вне пулов идёт одновременно в этом процессе (бережёт пул БД). Не задано — у DBOS без предела, у Temporal 100. */
116
+ maxConcurrentSteps?: number;
105
117
  }
106
118
 
107
119
  export type FlowStatus = "running" | "completed" | "failed" | "terminated" | "timed-out";
@@ -139,17 +151,24 @@ export interface StartOptions {
139
151
  timeoutMs?: number;
140
152
  /** 1 — первым, 5 — последним; шаги процесса наследуют его. */
141
153
  priority?: number;
154
+ /** Id уже занят (идёт или завершён; Temporal помнит завершённый в пределах хранения namespace) — FlowError "already-started". */
155
+ onExisting?: "reject";
156
+ /**
157
+ * Отказ, только пока процесс идёт; завершённый id — новый запуск (ночные задачи с постоянным id).
158
+ * Включает onExisting "reject". Только Temporal: DBOS id не переиспользует — already-started.
159
+ */
160
+ reuseClosedId?: boolean;
142
161
  }
143
162
 
144
163
  /** Сторона вызывающего вне процесса (HTTP, CLI, тест). */
145
164
  export interface EngineClient {
146
- /** Id уже идёт — handle к нему. timeoutMs — срок процесса. */
165
+ /** Id уже идёт — handle к нему (с onExisting "reject" — already-started). timeoutMs — срок процесса. */
147
166
  start<T>(target: FlowRef, args: unknown[], opts: StartOptions): Promise<FlowHandle<T>>;
148
167
  /**
149
168
  * Сигнал процессу; нет идущего с этим id — запустить и отдать сигнал ему. Temporal заводит новый
150
169
  * запуск и под id завершённого; DBOS id не переиспользует — сигнал уйдёт завершённому и пропадёт.
151
170
  */
152
- signalWithStart<T>(target: FlowRef, args: unknown[], opts: StartOptions, topic: string, value: unknown): Promise<FlowHandle<T>>;
171
+ signalWithStart<T>(target: FlowRef, args: unknown[], opts: Omit<StartOptions, "onExisting" | "reuseClosedId">, topic: string, value: unknown): Promise<FlowHandle<T>>;
153
172
  /** Уже запущенный процесс по id. */
154
173
  handle<T>(id: string): FlowHandle<T>;
155
174
  close(): Promise<void>;