@bobtail.software/b-durable 1.0.3 → 1.0.4

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 ADDED
@@ -0,0 +1,217 @@
1
+
2
+ # `@bobtail.software/b-durable`: Composable, Type-Safe, Durable Workflows for TypeScript
3
+
4
+ [![npm version](https://badge.fury.io/js/@bobtail.software/b-durable.svg)](https://badge.fury.io/js/@bobtail.software/b-durable)
5
+ [![License: GPL-3.0](https://img.shields.io/badge/License-GPL--3.0-yellow.svg)](https://opensource.org/licenses/GPL-3.0)
6
+
7
+ `b-durable` is a powerful system that transforms standard `async` functions into **composable, interactive, durable, and resilient workflows**. It lets you write long-running business logic—spanning hours, days, or months—as simple, linear `async/await` code. The system handles state persistence, orchestration, external events, and crash recovery, allowing you to focus on your business logic.
8
+
9
+ ## The Problem
10
+
11
+ Standard `async/await` is great for short-lived operations, but it breaks down for complex, long-running processes:
12
+
13
+ 1. **Fragility**: If your server restarts mid-execution, all in-memory state is lost.
14
+ 2. **Inefficiency**: An operation like `await bSleep('7 days')` is impossible. It would hold a process hostage, consume resources, and wouldn't survive a single deployment.
15
+ 3. **Orchestration Complexity**: Coordinating processes that involve multiple services, human-in-the-loop steps (like approvals), or external system webhooks often leads to a tangled mess of state machines, queues, and database flags.
16
+
17
+ ## The `b-durable` Solution
18
+
19
+ `b-durable` allows you to express this complexity as a single, readable `async` function. The system automatically persists the workflow's state after each `await` step, ensuring it can resume from the exact point of interruption.
20
+
21
+ Imagine orchestrating an e-commerce order. With `b-durable`, the code is as clear as the business process itself:
22
+
23
+ ```typescript
24
+ // Define a reusable sub-workflow for handling payments
25
+ export const paymentWorkflow = bDurable({
26
+ workflow: async (input: { orderId: string; amount: number }, context) => {
27
+ // 1. Call a service to process the payment
28
+ const result = await processPayment({ orderId: input.orderId, amount: input.amount });
29
+ if (!result.success) {
30
+ throw new Error('Payment failed!');
31
+ }
32
+ // 2. Pause durably for fraud checks
33
+ await context.bSleep('30m');
34
+ return { transactionId: result.transactionId };
35
+ },
36
+ });
37
+
38
+ // Define the main order processing workflow
39
+ export const orderProcessingWorkflow = bDurable({
40
+ workflow: async (input: { orderId: string; items: Item[] }, context) => {
41
+ try {
42
+ // 1. Call another workflow and await its result
43
+ const payment = await context.bExecute(paymentWorkflow, { orderId: input.orderId, amount: 99.99 });
44
+ context.log(`Payment successful: ${payment.transactionId}`);
45
+
46
+ // 2. Pause and wait for an external event (e.g., from a UI or webhook)
47
+ const approval = await context.bWaitForEvent('order.approved');
48
+ context.log(`Order approved by ${approval.approverId}`);
49
+
50
+ // 3. Call the final service function
51
+ await shipOrder(input.orderId);
52
+
53
+ return { status: 'completed' };
54
+ } catch (error) {
55
+ // 4. Handle errors durably
56
+ await notifyCustomerOfFailure(input.orderId, error.message);
57
+ await cancelOrder(input.orderId);
58
+ return { status: 'failed', reason: error.message };
59
+ }
60
+ },
61
+ });
62
+ ```
63
+
64
+ ## Core Features
65
+
66
+ - **Composable Orchestration**: Workflows can call other workflows using `await context.bExecute()`, allowing you to build complex processes from smaller, reusable parts. Results and errors are propagated automatically.
67
+ - **Interactive & Event-Driven**: Pause a workflow indefinitely with `await context.bWaitForEvent()` until an external event is received, enabling human-in-the-loop patterns and webhook integrations.
68
+ - **Durable & Resilient**: Workflows survive server restarts, crashes, and deployments, resuming exactly where they left off.
69
+ - **Built-in Error Handling**: Use standard `try/catch` blocks to handle errors from tasks or sub-workflows. Your `catch` block will execute reliably, even if the failure occurs hours after the `try` block started.
70
+ - **Durable Timers**: Use `await context.bSleep('30 days')` to pause workflows for extended periods without consuming server resources.
71
+ - **Type Safety End-to-End**: Leverages TypeScript for type safety across steps, I/O, events, and workflow composition.
72
+ - **Compiler-Powered**: A smart CLI compiler transforms your workflows into a step-by-step executable format, preserving types and ensuring runtime correctness.
73
+
74
+ ## How It Works: Compiler + Runtime
75
+
76
+ 1. **The Smart Compiler (`b-durable-compiler`)**:
77
+ Analyzes your workflow files (`*.workflow.ts`). For each function wrapped in `bDurable(...)`, it:
78
+ - **Maps Control Flow**: Breaks the function into steps at each `await`, analyzing `if/else` and `try/catch` blocks to build a complete state machine.
79
+ - **Identifies Durable Calls**: Differentiates between a durable instruction (`context.bSleep`, `context.bExecute`) and a standard service task call.
80
+ - **Generates Durable Artifacts**: Produces compiled `.mts` files that the runtime can execute step-by-step.
81
+
82
+ 2. **The Durable Runtime**:
83
+ The engine that executes the compiled workflows.
84
+ - **State Persistence**: Uses Redis to store the state, step, and context of every workflow instance.
85
+ - **Orchestration Logic**: Manages parent/child workflow relationships, passing results and errors up the chain.
86
+ - **Event System**: Tracks which workflows are waiting for which events.
87
+ - **Task Queue & Scheduler**: Reliably executes service function calls and manages long-running timers.
88
+
89
+ ## Getting Started
90
+
91
+ ### 1. Installation
92
+
93
+ Install the core library and its peer dependencies. We also highly recommend the ESLint plugin for the best developer experience.
94
+
95
+ ```bash
96
+ pnpm add @bobtail.software/b-durable ioredis
97
+ pnpm add -D @bobtail.software/eslint-plugin-b-durable
98
+ ```
99
+
100
+ ### 2. Set Up ESLint (Highly Recommended)
101
+
102
+ Our ESLint plugin prevents common errors by flagging unsupported code constructs (like loops) inside your workflows.
103
+
104
+ **In `eslint.config.js` (Flat Config):**
105
+ ```javascript
106
+ import bDurablePlugin from '@bobtail.software/eslint-plugin-b-durable';
107
+
108
+ export default [
109
+ // ... your other configs
110
+ bDurablePlugin.configs.recommended,
111
+ ];
112
+ ```
113
+ For legacy `.eslintrc.js` setup, see the [plugin's documentation](link-to-your-eslint-plugin-readme).
114
+
115
+ ### 3. Define a Workflow
116
+
117
+ Create a file ending in `.workflow.ts`. The `(input, context)` signature gives you access to durable functions.
118
+
119
+ ```typescript
120
+ // src/workflows/onboarding.workflow.ts
121
+ import { bDurable, DurableContext } from '@bobtail.software/b-durable';
122
+ import { createUser, sendWelcomeEmail } from '../services';
123
+
124
+ interface OnboardingInput {
125
+ userId: string;
126
+ email: string;
127
+ }
128
+
129
+ export const userOnboardingWorkflow = bDurable({
130
+ workflow: async (input: OnboardingInput, context: DurableContext) => {
131
+ const user = await createUser({ id: input.userId, email: input.email });
132
+
133
+ await context.bSleep('10s');
134
+
135
+ await sendWelcomeEmail(user.email);
136
+
137
+ return { status: 'completed', userId: user.id };
138
+ },
139
+ });
140
+ ```
141
+
142
+ ### 4. Compile Workflows
143
+
144
+ Add a script to your `package.json` to run the compiler.
145
+
146
+ ```json
147
+ // package.json
148
+ "scripts": {
149
+ "compile-workflows": "b-durable-compiler --in src/workflows --out src/generated"
150
+ }
151
+ ```
152
+ Run `pnpm compile-workflows`. This generates the durable definitions in `src/generated`.
153
+
154
+ ### 5. Initialize the Runtime
155
+
156
+ In your application's entry point, initialize the system and start a workflow.
157
+
158
+ ```typescript
159
+ // src/main.ts
160
+ import { bDurableInitialize } from '@bobtail.software/b-durable';
161
+ import Redis from 'ioredis';
162
+ import durableFunctions, { userOnboardingWorkflow } from './generated';
163
+
164
+ async function main() {
165
+ const redis = new Redis();
166
+ const blockingRedis = new Redis(); // Required for reliable queue operations
167
+
168
+ const durableSystem = bDurableInitialize({
169
+ durableFunctions,
170
+ sourceRoot: process.cwd(),
171
+ redisClient: redis,
172
+ blockingRedisClient: blockingRedis,
173
+ });
174
+
175
+ console.log('Durable system ready. Starting workflow...');
176
+
177
+ const workflowId = await durableSystem.start(userOnboardingWorkflow, {
178
+ userId: `user-${Date.now()}`,
179
+ email: 'test.user@example.com',
180
+ });
181
+
182
+ console.log(`Workflow ${workflowId} started.`);
183
+ }
184
+
185
+ main().catch(console.error);
186
+ ```
187
+
188
+ ### 6. Run Your Application
189
+
190
+ Run your app (`node src/main.ts`). You'll see the workflow execute, pause, and resume, with all its state managed by `b-durable`.
191
+
192
+ ## Development Setup (for contributors)
193
+
194
+ The project is a `pnpm` monorepo.
195
+
196
+ 1. **Clone & Install**:
197
+ ```bash
198
+ git clone <repository-url>
199
+ cd b-durable-monorepo
200
+ pnpm install
201
+ ```
202
+
203
+ 2. **Run in Development Mode**:
204
+ This command builds the library, compiles example workflows, and starts the example app with hot-reloading.
205
+ ```bash
206
+ pnpm dev
207
+ ```
208
+
209
+ 3. **Run Tests**:
210
+ Tests use Vitest and a real Redis instance.
211
+ ```bash
212
+ pnpm --filter @bobtail.software/b-durable test
213
+ ```
214
+
215
+ ## License
216
+
217
+ This project is licensed under the GPL-3.0 License. See the [LICENSE](LICENSE) file for details.
@@ -1,53 +1,58 @@
1
1
  #!/usr/bin/env node
2
- import j from"path";import{existsSync as Z,mkdirSync as V,rmSync as ee}from"fs";import y from"path";import{Node as g,Project as te,SyntaxKind as $,VariableDeclarationKind as W}from"ts-morph";var ne="bDurable";async function K(e){console.log("Iniciando compilador de workflows duraderos...");let{inputDir:t,outputDir:n,packageName:o}=e,r=new te({tsConfigFilePath:y.resolve(process.cwd(),"tsconfig.json")}),a=r.addSourceFilesAtPaths(`${t}/**/*.ts`);Z(n)&&(console.log(`Limpiando directorio de salida: ${n}`),ee(n,{recursive:!0,force:!0})),V(n,{recursive:!0});let u=r.createDirectory(n);console.log(`Encontrados ${a.length} archivos de workflow para procesar.`);let i=[];for(let c of a){console.log(`
3
- Procesando archivo: ${c.getBaseName()}`);let s=c.getDescendantsOfKind($.CallExpression).filter(d=>d.getExpression().getText()===ne);if(s.length!==0)for(let d of s){let l=d.getParentIfKind($.VariableDeclaration);if(!l)continue;let x=l.getName();console.log(` -> Transformando workflow: ${x}`);let[p]=d.getArguments();if(!g.isArrowFunction(p))continue;let m=c.getBaseName().replace(/\.ts$/,".compiled.mts"),f=y.join(u.getPath(),m),T=r.createSourceFile(f,"",{overwrite:!0});oe(x,p,r,o,T),console.log(` -> Archivo generado: ${y.relative(process.cwd(),f)}`);let S=m;i.push({name:x,importPath:`./${S}`})}}if(i.length>0){let c=y.join(u.getPath(),"index.mts"),s=r.createSourceFile(c,"",{overwrite:!0});s.addStatements(`// Este archivo fue generado autom\xE1ticamente. NO EDITAR MANUALMENTE.
4
- `),s.addImportDeclaration({isTypeOnly:!0,moduleSpecifier:o,namedImports:["DurableFunction"]});for(let l of i)s.addImportDeclaration({moduleSpecifier:l.importPath,namedImports:[l.name]});s.addExportDeclaration({namedExports:i.map(l=>l.name)}),s.addStatements(`
5
- `),s.addVariableStatement({declarationKind:W.Const,declarations:[{name:"durableFunctions",type:"Map<string, DurableFunction<any, any>>",initializer:"new Map()"}]});let d=i.map(l=>`durableFunctions.set(${l.name}.name, ${l.name});`);s.addStatements(d),s.addStatements(`
6
- `),s.addExportAssignment({isExportEquals:!1,expression:"durableFunctions"}),console.log(`
7
- -> Archivo de \xEDndice generado: ${y.basename(c)}`)}await r.save(),console.log(`
8
- Compilaci\xF3n completada exitosamente.`)}function oe(e,t,n,o,r){let a=t.getBody();if(!g.isBlock(a))throw new Error(`El cuerpo del workflow '${e}' debe ser un bloque {}.`);let u=new Map,{clauses:i}=F(a.getStatements(),0,u,n,o,""),[c]=t.getParameters(),s=t.getParameters()[1],l=s&&s.getName()==="context"?"const { log, workflowId } = context;":"",x=`Awaited<${t.getReturnType().getText()}>`,p=new Set,E="{}",m="unknown",f=t.getSourceFile();if(f.getInterfaces().forEach(S=>{p.add(S.getFullText())}),f.getTypeAliases().forEach(S=>{p.add(S.getFullText())}),c){E=c.getNameNode().getText();let S=c.getTypeNode();S&&(m=S.getText())}r.addImportDeclaration({isTypeOnly:!0,moduleSpecifier:o,namedImports:["DurableFunction","WorkflowContext","Instruction"]}),p.size>0&&(r.addStatements(`
9
- `),r.addStatements(Array.from(p))),r.addStatements(`
2
+ import R from"path";import{existsSync as L,mkdirSync as K,rmSync as _}from"fs";import b from"path";import*as k from"prettier";import{Node as g,Project as z,SyntaxKind as E,ts as B,VariableDeclarationKind as M}from"ts-morph";var U="bDurable",A=B.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope|B.TypeFormatFlags.NoTruncation;async function H(e){let t=e.getFilePath(),s=e.getFullText(),n=await k.resolveConfig(t),a=await k.format(s,{...n,parser:"typescript"});e.replaceWithText(a)}async function O(e){console.log("Iniciando compilador de workflows duraderos...");let{inputDir:t,outputDir:s,packageName:n}=e,a=new z({tsConfigFilePath:b.resolve(process.cwd(),"tsconfig.json")}),i=a.addSourceFilesAtPaths(`${t}/**/*.ts`);L(s)&&(console.log(`Limpiando directorio de salida: ${s}`),_(s,{recursive:!0,force:!0})),K(s,{recursive:!0});let S=a.createDirectory(s);console.log(`Encontrados ${i.length} archivos de workflow para procesar.`);let o=[],c=[];for(let l of i){console.log(`
3
+ Procesando archivo: ${l.getBaseName()}`);let r=l.getDescendantsOfKind(E.CallExpression).filter(d=>d.getExpression().getText()===U);if(r.length!==0)for(let d of r){let p=d.getParentIfKind(E.VariableDeclaration);if(!p)continue;let u=p.getName();console.log(` -> Transformando workflow: ${u}`);let[f]=d.getArguments();if(!g.isObjectLiteralExpression(f))continue;let x=f.getProperty("workflow");if(!x||!g.isPropertyAssignment(x))continue;let m=x.getInitializer();if(!m||!g.isArrowFunction(m))continue;let P=l.getBaseName().replace(/\.ts$/,".compiled.mts"),$=b.join(S.getPath(),P),I=a.createSourceFile($,"",{overwrite:!0});c.push(I),q(u,m,d,I,n),console.log(` -> Archivo generado: ${b.relative(process.cwd(),$)}`);let y=P;o.push({name:u,importPath:`./${y}`})}}if(o.length>0){let l=b.join(S.getPath(),"index.mts"),r=a.createSourceFile(l,"",{overwrite:!0});c.push(r),r.addStatements(`// Este archivo fue generado autom\xE1ticamente. NO EDITAR MANUALMENTE.
4
+ `),r.addImportDeclaration({isTypeOnly:!0,moduleSpecifier:n,namedImports:["DurableFunction"]});for(let p of o)r.addImportDeclaration({moduleSpecifier:p.importPath,namedImports:[p.name]});r.addExportDeclaration({namedExports:o.map(p=>p.name)}),r.addStatements(`
5
+ `),r.addVariableStatement({declarationKind:M.Const,declarations:[{name:"durableFunctions",type:"Map<string, DurableFunction<any, any>>",initializer:"new Map()"}]});let d=o.map(p=>`durableFunctions.set(${p.name}.name, ${p.name});`);r.addStatements(d),r.addStatements(`
6
+ `),r.addExportAssignment({isExportEquals:!1,expression:"durableFunctions"}),console.log(`
7
+ -> Archivo de \xEDndice generado: ${b.basename(l)}`)}console.log(`
8
+ Formateando archivos generados con Prettier...`);for(let l of c)await H(l);await a.save(),console.log(`
9
+ Compilaci\xF3n completada exitosamente.`)}function q(e,t,s,n,a){let i=t.getBody();if(!g.isBlock(i))throw new Error(`El cuerpo del workflow '${e}' debe ser un bloque {}.`);let{clauses:S}=w(i.getStatements(),{step:0,persistedVariables:new Map}),o=t.getReturnType();o.getSymbol()?.getName()==="Promise"&&o.isObject()&&(o=o.getTypeArguments()[0]||o);let c=o.getText(void 0,A),l=new Set,r=t.getSourceFile(),d=s.getTypeArguments(),p=d.length>0?d[0].getText():"unknown";r.getImportDeclarations().forEach(m=>{if(m.getModuleSpecifierValue()===a)return;let h=m.getModuleSpecifierValue();if(h.includes(".workflow")){let y=b.parse(h),T=b.join(y.dir,y.base+".compiled.mts");!T.startsWith(".")&&!b.isAbsolute(T)&&(T="./"+T),h=T.replace(/\\/g,"/")}else h.startsWith(".")&&b.extname(h)===""&&(h+=".mjs");let P=[],$=[];m.getNamedImports().forEach(y=>{let T=y.getName(),F=y.getAliasNode()?.getText(),v=F?`${T} as ${F}`:T,V=(y.getNameNode().getSymbol()?.getAliasedSymbol()??y.getNameNode().getSymbol())?.getDeclarations()??[],j=V.some(D=>g.isEnumDeclaration(D));y.isTypeOnly()||!j&&V.every(D=>g.isInterfaceDeclaration(D)||g.isTypeAliasDeclaration(D))?$.push(v):P.push(v)}),P.length>0&&n.addImportDeclaration({moduleSpecifier:h,namedImports:P}),$.length>0&&n.addImportDeclaration({isTypeOnly:!0,moduleSpecifier:h,namedImports:$});let I=m.getDefaultImport();I&&n.addImportDeclaration({moduleSpecifier:h,defaultImport:I.getText()})}),r.getInterfaces().forEach(m=>{l.add(m.getText().startsWith("export")?m.getText():`export ${m.getText()}`)}),r.getTypeAliases().forEach(m=>{l.add(m.getText().startsWith("export")?m.getText():`export ${m.getText()}`)});let[u]=t.getParameters(),f="";if(u){let m=u.getNameNode().getText();m!=="input"&&(f=`const ${m} = input;`)}n.addImportDeclaration({isTypeOnly:!0,moduleSpecifier:a,namedImports:["DurableFunction","WorkflowContext","Instruction"]}),l.size>0&&(n.addStatements(`
10
+ `),n.addStatements(Array.from(l))),n.addStatements(`
10
11
  // Este archivo fue generado autom\xE1ticamente. NO EDITAR MANUALMENTE.
11
- `);let T=`{
12
+ `);let x=`{
12
13
  __isDurable: true,
13
14
  name: '${e}',
14
- async execute(context: WorkflowContext<${m}>): Promise<Instruction<${x}>> {
15
- const { input, state, result } = context;
16
- ${l}
17
- const ${E} = input;
18
- switch (context.step) {
19
- ${i.join(`
15
+ async execute(context: WorkflowContext<${p}>): Promise<Instruction<${c}>> {
16
+ const { input, state, result, log, workflowId } = context;
17
+ ${f}
18
+ while (true) {
19
+ switch (context.step) {
20
+ ${S.join(`
20
21
  `)}
21
- default:
22
- throw new Error(\`Paso desconocido: \${context.step}\`);
22
+ default:
23
+ throw new Error(\`Paso desconocido: \${context.step}\`);
24
+ }
25
+ }
26
+ }
27
+ }`;n.addVariableStatement({isExported:!0,declarationKind:M.Const,declarations:[{name:e,type:`DurableFunction<${p}, ${c}>`,initializer:x}]}),n.organizeImports()}function w(e,t){if(e.length===0){let f=[];if(t.pendingStateAssignment){let x=`case ${t.step}: {
28
+ ${t.pendingStateAssignment}
29
+ return { type: 'COMPLETE', result: undefined };
30
+ }`;f.push(x)}return{clauses:f,nextStep:t.step+1}}let{syncBlock:s,durableStatement:n,nextStatements:a}=te(e),{rewrittenSyncStatements:i,newlyPersistedVariables:S}=Y(s,n?[n,...a]:[],t.persistedVariables);t.pendingStateAssignment&&i.unshift(t.pendingStateAssignment);let o=new Map([...t.persistedVariables,...S]);if(!n){let f=i.join(`
31
+ `),m=s.length>0&&g.isReturnStatement(s[s.length-1])?"":`
32
+ return { type: 'COMPLETE', result: undefined };`;return{clauses:[`case ${t.step}: {
33
+ ${f}${m}
34
+ }`],nextStep:t.step+1}}if(g.isIfStatement(n))return G(n,a,{...t,persistedVariables:o},i);if(g.isTryStatement(n))return X(n,a,{...t,persistedVariables:o},i);let{instruction:c,nextPendingStateAssignment:l}=Q(n,o);i.push(c);let r=i.join(`
35
+ `),d=`case ${t.step}: {
36
+ ${r}
37
+ }`,p={step:t.step+1,persistedVariables:o,pendingStateAssignment:l},u=w(a,p);return{clauses:[d,...u.clauses],nextStep:u.nextStep}}function G(e,t,s,n){let a=C(e.getExpression(),s.persistedVariables),i=e.getThenStatement(),S=g.isBlock(i)?i.getStatements():[i],o=w(S,{step:s.step+1,persistedVariables:new Map(s.persistedVariables)}),c,l=e.getElseStatement();if(l){let x=g.isBlock(l)?l.getStatements():[l];c=w(x,{step:o.nextStep,persistedVariables:new Map(s.persistedVariables)})}let r=c?c.nextStep:o.nextStep,d=w(t,{step:r,persistedVariables:s.persistedVariables}),p=n.join(`
38
+ `),u=o.nextStep;return{clauses:[`
39
+ case ${s.step}: {
40
+ ${p}
41
+ if (${a}) {
42
+ context.step = ${s.step+1};
43
+ } else {
44
+ ${l?`context.step = ${u};`:`context.step = ${r};`}
23
45
  }
46
+ break;
47
+ }
48
+ `,...o.clauses,...c?c.clauses:[],...d.clauses],nextStep:d.nextStep}}function X(e,t,s,n){let{step:a,persistedVariables:i}=s,S=e.getTryBlock(),o=e.getCatchClause(),c=e.getFinallyBlock(),l=w(S.getStatements(),{step:a+1,persistedVariables:new Map(i)}),r,d,p=l.nextStep;if(o){let y=o.getBlock(),T=o.getVariableDeclaration();T&&(d=T.getName()),r=w(y.getStatements(),{step:p,persistedVariables:new Map(i)})}let u,f=r?r.nextStep:p;c&&(u=w(c.getStatements(),{step:f,persistedVariables:new Map(i)}));let x=u?u.nextStep:f,m=w(t,{step:x,persistedVariables:i}),h=`{ catchStep: ${o?p:"undefined"}, finallyStep: ${c?f:"undefined"} }`,P=`
49
+ case ${a}: {
50
+ ${n.join(`
51
+ `)}
52
+ state.tryCatchStack = state.tryCatchStack || [];
53
+ state.tryCatchStack.push(${h});
54
+ context.step = ${a+1}; // Salta al inicio del bloque try
55
+ break;
24
56
  }
25
- }`;r.addVariableStatement({isExported:!0,declarationKind:W.Const,declarations:[{name:e,type:`DurableFunction<${m}, ${x}>`,initializer:T}]})}function F(e,t,n,o,r,a){if(e.length===0)return{clauses:[],nextStep:t};let{syncBlock:u,durableStatement:i,nextStatements:c}=A(e),s=a+u.map(m=>h(m,n,o)).join(`
26
- `);if(!i)return{clauses:[`case ${t}: {
27
- ${s}
28
- return { type: 'COMPLETE', result: undefined };
29
- }`],nextStep:t+1};let d="",l=i.getFirstDescendantByKind($.VariableDeclaration);if(l){let m=l.getName(),f=l.getTypeNode()?.getText()??l.getInitializerOrThrow().getType().getText();n.set(m,{type:f}),d=`state.${m} = result;`}if(g.isIfStatement(i)){let m=h(i.getExpression(),n,o),f=`__branch_step_${t}`,T=L(i.getThenStatement()),S=i.getElseStatement(),I=S?L(S):[],{syncBlock:z,durableStatement:B,nextStatements:H}=A(T),{syncBlock:q,durableStatement:k,nextStatements:X}=A(I),G=z.map(D=>h(D,n,o)).join(`
30
- `),J=B?v(B,n,o,r):'return { type: "COMPLETE", result: undefined };',M=`if (${m}) {
31
- ${G}
32
- state.${f} = 0;
33
- ${J}
34
- }`;if(I.length>0){let D=q.map(N=>h(N,n,o)).join(`
35
- `),C=k?v(k,n,o,r):'return { type: "COMPLETE", result: undefined };';M+=` else {
36
- ${D}
37
- state.${f} = 1;
38
- ${C}
39
- }`}let Q=`case ${t}: {
40
- ${s}
41
- ${M}
42
- }`,O=F(H,t+1,new Map(n),o,r,d),_=F(X,t+1,new Map(n),o,r,d),P=[];if(O.clauses.length>0||_.clauses.length>0){let D=O.clauses.map(w=>w.replace(/^case \d+:\s*{/,"")).map(w=>w.replace(/}\s*$/,"")).join(`
43
- `),C=_.clauses.map(w=>w.replace(/^case \d+:\s*{/,"")).map(w=>w.replace(/}\s*$/,"")).join(`
44
- `),N=`case ${t+1}: {
45
- if (state.${f} === 0) {
46
- ${D}
47
- } else {
48
- ${C}
49
- }
50
- }`;P.push(N)}let Y=t+1+(P.length>0?1:0),R=F(c,Y,n,o,r,"");return{clauses:[Q,...P,...R.clauses],nextStep:R.nextStep}}let x=v(i,n,o,r),p=`case ${t}: {
51
- ${s}
52
- ${x}
53
- }`,E=F(c,t+1,n,o,r,d);return{clauses:[p,...E.clauses],nextStep:E.nextStep}}function b(e){if(e.getDescendantsOfKind($.AwaitExpression).length>0)return!0;if(g.isIfStatement(e)){let t=b(e.getThenStatement()),n=e.getElseStatement()?b(e.getElseStatement()):!1;return t||n}return g.isBlock(e)?e.getStatements().some(b):!1}function L(e){return g.isBlock(e)?e.getStatements():[e]}function A(e){for(let t=0;t<e.length;t++){let n=e[t];if(g.isReturnStatement(n)||b(n))return{syncBlock:e.slice(0,t),durableStatement:n,nextStatements:e.slice(t+1)}}return{syncBlock:e,durableStatement:null,nextStatements:[]}}function v(e,t,n,o){if(g.isReturnStatement(e))return`return { type: 'COMPLETE', result: ${e.getExpression()?h(e.getExpressionOrThrow(),t,n):"undefined"} };`;let r=e.getFirstDescendantByKind($.AwaitExpression);if(r){let a=r.getExpression();if(g.isCallExpression(a))return`return ${re(a,t,n,o)};`}throw new Error(`No se pudo generar una instrucci\xF3n para el statement: ${e.getText()}`)}function re(e,t,n,o){let r=e.getExpression(),a=r.getText(),u=e.getArguments().map(d=>h(d,t,n)).join(", "),i=r.getSymbol();if(!i)throw new Error(`S\xEDmbolo no encontrado para '${a}'.`);let c=i.getDeclarations()[0]?.asKind($.ImportSpecifier);if(!c)throw new Error(`'${a}' debe ser importada.`);if(c.getImportDeclaration().getModuleSpecifierValue()===o){if(a==="bSleep")return`{ type: 'SCHEDULE_SLEEP', duration: ${u} }`;if(a==="bWaitForEvent")return`{ type: 'WAIT_FOR_EVENT', eventName: ${u} }`;if(a==="bExecute"){let[d,l]=e.getArguments(),x=d.getText(),p=h(l,t,n);return`{ type: 'EXECUTE_SUBWORKFLOW', workflowName: '${x}', input: ${p} }`}throw new Error(`Funci\xF3n desconocida '${a}' importada desde '${o}'.`)}else{let d=c.getImportDeclaration().getModuleSpecifierSourceFileOrThrow();return`{ type: 'SCHEDULE_TASK', modulePath: '${y.relative(process.cwd(),d.getFilePath()).replace(/\\/g,"/")}', exportName: '${a}', args: [${u}] }`}}function h(e,t,n){let o=e.getProject().createSourceFile("temp_rewrite.ts",e.getText(),{overwrite:!0}).getChildAtIndex(0),r=[o,...o.getDescendants()];for(let u of r.reverse())if(g.isIdentifier(u)&&!u.wasForgotten()){let i=u.getText();if(t.has(i)){let c=t.get(i);if(c){let s=u.getParent();if(s&&g.isPropertyAccessExpression(s)&&s.getNameNode()===u||s&&g.isPropertyAssignment(s)&&s.getNameNode()===u)continue;u.replaceWithText(`(state.${i} as ${c.type})`)}}}let a=o.getSourceFile().getFullText();return o.getSourceFile().forget(),a}var U=e=>{let t=process.argv.indexOf(e);if(t!==-1&&process.argv.length>t+1)return process.argv[t+1]};async function se(){let e=U("--in"),t=U("--out");(!e||!t)&&(console.error("Uso: b-durable-compiler --in <directorio_entrada> --out <directorio_salida>"),process.exit(1));let n=j.resolve(process.cwd(),e),o=j.resolve(process.cwd(),t);await K({inputDir:n,outputDir:o,packageName:"@bobtail.software/b-durable"})}se().catch(e=>{console.error("Error durante la compilaci\xF3n:",e),process.exit(1)});
57
+ `,$=l.clauses.pop()||"",I=c?f:x;if(l.clauses.push($.replace(/return { type: 'COMPLETE'.* };/,`context.step = ${I}; break;`)),r){if(d){let T=r.clauses[0]||`case ${p}: {}`;r.clauses[0]=T.replace("{",`{
58
+ const ${d} = result as Error;`)}let y=r.clauses.pop()||"";r.clauses.push(y.replace(/return { type: 'COMPLETE'.* };/,`context.step = ${I}; break;`))}if(u){let y=u.clauses.pop()||"";u.clauses.push(y.replace(/return { type: 'COMPLETE'.* };/,`state.tryCatchStack?.pop(); context.step = ${x}; break;`))}return{clauses:[P,...l.clauses,...r?r.clauses:[],...u?u.clauses:[],...m.clauses],nextStep:m.nextStep}}function Y(e,t,s){let n=[],a=new Map,i=Z(t),S=new Map(s);for(let o of e){let c=!1;if(g.isVariableStatement(o))for(let l of o.getDeclarations()){let r=l.getInitializer();if(!r)continue;let d=J(l),p=d.filter(u=>i.has(u.name));if(p.length>0){let u=C(r,s);for(let{name:f,type:x}of p){a.set(f,{type:x}),S.set(f,{type:x});let m=d.length>1?`${u}.${f}`:u;n.push(`state.${f} = ${m};`)}p.length===d.length&&(c=!0)}}c||n.push(C(o,S))}return{rewrittenSyncStatements:n,newlyPersistedVariables:a}}function J(e){let t=e.getNameNode(),s=[];if(g.isIdentifier(t)){let n=e.getType().getText(e,A);s.push({name:t.getText(),type:n})}else if(g.isObjectBindingPattern(t))for(let n of t.getElements()){let a=n.getName(),i=n.getType().getText(n,A);s.push({name:a,type:i})}return s}function Q(e,t){if(g.isReturnStatement(e))return{instruction:`return { type: 'COMPLETE', result: ${e.getExpression()?C(e.getExpressionOrThrow(),t):"undefined"} };`,nextPendingStateAssignment:void 0};let s,n=e.getFirstDescendantByKind(E.VariableDeclaration);if(n){let i=n.getName(),S=n.getType().getText(n,A);t.set(i,{type:S}),s=`state.${i} = result;`}let a=e.getFirstDescendantByKind(E.AwaitExpression);if(a){let i=a.getExpression();if(g.isCallExpression(i))return{instruction:`return ${ee(i,t)};`,nextPendingStateAssignment:s}}return{instruction:C(e,t),nextPendingStateAssignment:s}}function Z(e){let t=new Set;for(let s of e)s.getDescendantsOfKind(E.Identifier).forEach(n=>{t.add(n.getText())});return t}function C(e,t){let s=e.getProject().createSourceFile(`temp_rewrite_${Math.random()}.ts`,`const temp = ${e.getText()};`,{overwrite:!0}),n=s.getVariableDeclarationOrThrow("temp").getInitializerOrThrow(),a=[n,...n.getDescendants()].reverse();for(let c of a)if(g.isIdentifier(c)&&!c.wasForgotten()&&t.has(c.getText())){let l=c.getText(),r=c.getParent(),d=g.isVariableDeclaration(r)&&r.getNameNode()===c,p=g.isPropertyAccessExpression(r)&&r.getNameNode()===c||g.isPropertyAssignment(r)&&r.getNameNode()===c,u=g.isBindingElement(r)&&r.getNameNode()===c;if(!d&&!p&&!u){let f=t.get(l);c.replaceWithText(`(state.${l} as ${f.type})`)}}let i=s.getFullText().trim();s.forget();let S="const temp = ",o=i;return o.startsWith(S)&&(o=o.substring(S.length)),o.endsWith(";")&&(o=o.slice(0,-1)),o}function ee(e,t){let s=e.getExpression(),n,a=!1;g.isPropertyAccessExpression(s)?(s.getExpression().getText()==="context"&&(a=!0),n=s.getName()):n=s.getText();let i=e.getArguments().map(r=>C(r,t)).join(", ");if(a){if(n==="bSleep")return`{ type: 'SCHEDULE_SLEEP', duration: ${i} }`;if(n==="bWaitForEvent")return`{ type: 'WAIT_FOR_EVENT', eventName: ${i} }`;if(n==="bExecute"){let[r,d]=e.getArguments(),p=r.getText(),u=d?C(d,t):"undefined";return`{ type: 'EXECUTE_SUBWORKFLOW', workflowName: ${p}.name, input: ${u} }`}throw new Error(`Funci\xF3n de contexto durable desconocida: '${n}'.`)}let S=s.getSymbol();if(!S)throw new Error(`S\xEDmbolo no encontrado para '${n}'.`);let o=S.getDeclarations()[0]?.asKind(E.ImportSpecifier);if(!o)throw new Error(`'${n}' debe ser importada.`);let c=o.getImportDeclaration().getModuleSpecifierSourceFileOrThrow();return`{ type: 'SCHEDULE_TASK', modulePath: '${b.relative(process.cwd(),c.getFilePath()).replace(/\\/g,"/")}', exportName: '${n}', args: [${i}] }`}function N(e){for(let t of e.getDescendantsOfKind(E.AwaitExpression)){let s=t.getExpressionIfKind(E.CallExpression);if(s){let n=s.getExpression();if(g.isPropertyAccessExpression(n)){let a=n.getName();if(n.getExpression().getText()==="context"&&(a==="bSleep"||a==="bWaitForEvent"||a==="bExecute")||n.getSymbol()?.getDeclarations()[0]?.isKind(E.ImportSpecifier))return!0}else if(n.getSymbol()?.getDeclarations()[0]?.isKind(E.ImportSpecifier))return!0}}if(g.isTryStatement(e)&&(N(e.getTryBlock())||e.getCatchClause()&&N(e.getCatchClause().getBlock())||e.getFinallyBlock()&&N(e.getFinallyBlock())))return!0;if(g.isIfStatement(e)){let t=N(e.getThenStatement()),s=e.getElseStatement()?N(e.getElseStatement()):!1;return t||s}return g.isBlock(e)?e.getStatements().some(N):!1}function te(e){for(let t=0;t<e.length;t++){let s=e[t];if(g.isReturnStatement(s)||N(s)||g.isTryStatement(s))return{syncBlock:e.slice(0,t),durableStatement:s,nextStatements:e.slice(t+1)}}return{syncBlock:e,durableStatement:null,nextStatements:[]}}var W=e=>{let t=process.argv.indexOf(e);if(t!==-1&&process.argv.length>t+1)return process.argv[t+1]};async function ne(){let e=W("--in"),t=W("--out");(!e||!t)&&(console.error("Uso: b-durable-compiler --in <directorio_entrada> --out <directorio_salida>"),process.exit(1));let s=R.resolve(process.cwd(),e),n=R.resolve(process.cwd(),t);await O({inputDir:s,outputDir:n,packageName:"@bobtail.software/b-durable"})}ne().catch(e=>{console.error("Error durante la compilaci\xF3n:",e),process.exit(1)});
package/dist/index.d.mts CHANGED
@@ -1,11 +1,18 @@
1
1
  import Redis from 'ioredis';
2
2
  import ms from 'ms';
3
3
 
4
+ interface WorkflowState {
5
+ tryCatchStack?: {
6
+ catchStep?: number;
7
+ finallyStep?: number;
8
+ }[];
9
+ [key: string]: unknown;
10
+ }
4
11
  interface WorkflowContext<TInput = unknown> {
5
12
  workflowId: string;
6
13
  step: number;
7
14
  input: TInput;
8
- state: Record<string, unknown>;
15
+ state: WorkflowState;
9
16
  result?: unknown;
10
17
  log: (message: string) => void;
11
18
  }
@@ -36,18 +43,21 @@ interface DurableFunction<TInput = unknown, TOutput = unknown> {
36
43
 
37
44
  declare class DurableRuntime {
38
45
  private durableFns;
39
- private isWorkerRunning;
40
- private isSchedulerRunning;
46
+ private repo;
47
+ private workerId;
48
+ private isRunning;
41
49
  private schedulerInterval;
42
50
  private readonly sourceRoot;
43
51
  constructor(options: {
44
52
  sourceRoot: string;
45
53
  });
46
- start<TInput, TOutput>(durableFn: DurableFunction<TInput, TOutput>, input: TInput, parentId?: string, predefinedWorkflowId?: string): Promise<string>;
47
- private executeStep;
54
+ start<TInput, TOutput>(durableFn: DurableFunction<TInput, TOutput>, input: TInput, parentId?: string): Promise<string>;
55
+ private scheduleExecution;
56
+ private _executeStep;
48
57
  private handleInstruction;
58
+ private handleFailure;
49
59
  private resumeParentWorkflow;
50
- private failParentWorkflow;
60
+ private propagateFailureToParent;
51
61
  sendEvent<T>(workflowId: string, eventName: string, payload: T): Promise<void>;
52
62
  private startScheduler;
53
63
  private startWorker;
@@ -55,42 +65,57 @@ declare class DurableRuntime {
55
65
  stop(): void;
56
66
  }
57
67
 
58
- type DurableWorkflowFn<TInput, TOutput> = (input: TInput, context: Pick<WorkflowContext<TInput>, 'log' | 'workflowId'>) => Promise<TOutput>;
59
- /**
60
- * Marcador para que el compilador identifique y transforme una función en un workflow durable.
61
- * Esta función es un passthrough en tiempo de ejecución, su único propósito es para el análisis estático.
62
- * @param fn La función async que define la lógica del workflow.
63
- */
64
- declare const bDurable: <TInput, TOutput>(fn: DurableWorkflowFn<TInput, TOutput>) => DurableWorkflowFn<TInput, TOutput>;
68
+ type DurableWorkflowFn$1<TInput, TOutput> = (input: TInput, ...args: any[]) => Promise<TOutput>;
65
69
 
66
70
  /**
67
- * Ejecuta un sub-workflow y espera de forma duradera su resultado.
68
- * @param workflow La función durable a ejecutar.
69
- * @param input La entrada para el sub-workflow.
70
- * @returns Una promesa que se resuelve con el resultado del sub-workflow.
71
+ * El contexto de ejecución proporcionado a cada workflow, con métodos de durabilidad tipados.
71
72
  */
72
- declare function bExecute<TInput, TOutput>(workflow: DurableWorkflowFn<TInput, TOutput>, input: TInput): Promise<TOutput>;
73
-
74
- /**
75
- * Pausa la ejecución del workflow de manera duradera.
76
- * Esta función es un marcador especial que el compilador `b-durable` transforma
77
- * en una instrucción para el runtime. No se ejecuta directamente.
78
- * @param duration Una cadena de tiempo como '2 days', '10h', '7s'.
79
- */
80
- declare function bSleep(duration: ms.StringValue): Promise<void>;
81
-
73
+ interface DurableContext<TEvents extends Record<string, any> = Record<string, never>> extends Pick<WorkflowContext, 'log' | 'workflowId'> {
74
+ /**
75
+ * Pausa la ejecución del workflow de manera duradera.
76
+ * @param duration Una cadena de tiempo como '2 days', '10h', '7s'.
77
+ */
78
+ bSleep(duration: ms.StringValue): Promise<void>;
79
+ /**
80
+ * Pausa la ejecución del workflow hasta que se reciba un evento externo.
81
+ * El tipo del payload retornado se infiere automáticamente del contrato de eventos del workflow.
82
+ * @param eventName El nombre único del evento que se está esperando.
83
+ * @returns Una promesa que se resuelve con el payload del evento recibido.
84
+ */
85
+ bWaitForEvent<K extends keyof TEvents>(eventName: K): Promise<TEvents[K]>;
86
+ /**
87
+ * Ejecuta un sub-workflow y espera de forma duradera su resultado.
88
+ * @param workflow La función durable a ejecutar.
89
+ * @param input La entrada para el sub-workflow.
90
+ * @returns Una promesa que se resuelve con el resultado del sub-workflow.
91
+ */
92
+ bExecute<TInput, TOutput>(workflow: DurableWorkflowFn$1<TInput, TOutput>, input: TInput): Promise<TOutput>;
93
+ }
94
+ type DurableWorkflowFn<TInput, TOutput, TEvents extends Record<string, any> = Record<string, never>> = (input: TInput, context: DurableContext<TEvents>) => Promise<TOutput>;
95
+ interface DurableWorkflowDef<TInput, TOutput, TEvents extends Record<string, any> = Record<string, never>> {
96
+ /**
97
+ * La función async que contiene la lógica del workflow.
98
+ */
99
+ workflow: DurableWorkflowFn<TInput, TOutput, TEvents>;
100
+ }
82
101
  /**
83
- * Pausa la ejecución del workflow hasta que se reciba un evento externo.
84
- * @param eventName El nombre único del evento que se está esperando.
85
- * @returns Una promesa que se resuelve con el payload del evento recibido.
102
+ * Marcador para que el compilador identifique y transforme una función en un workflow durable.
103
+ * Esta función es un passthrough en tiempo de ejecución, su único propósito es para el análisis estático.
86
104
  */
87
- declare function bWaitForEvent<T>(eventName: string): Promise<T>;
105
+ declare const bDurable: <TInput = unknown, TOutput = unknown, TEvents extends Record<string, any> = Record<string, never>>(def: DurableWorkflowDef<TInput, TOutput, TEvents>) => DurableWorkflowFn$1<TInput, TOutput>;
88
106
 
89
- interface BDurableAPI {
107
+ interface BDurableAPI<TEvents extends Record<string, any> = Record<string, never>> {
90
108
  start: <TInput, TOutput>(durableFn: DurableFunction<TInput, TOutput>, input: TInput) => Promise<string>;
91
109
  stop: () => void;
92
110
  runtime: DurableRuntime;
93
- sendEvent: <T>(workflowId: string, eventName: string, payload: T) => Promise<void>;
111
+ /**
112
+ * Envía un evento a un workflow en ejecución que está en pausa esperando dicho evento.
113
+ * Esta función es estrictamente tipada basada en el tipo de eventos globales proporcionado.
114
+ * @param workflowId El ID del workflow al que se le enviará el evento.
115
+ * @param eventName El nombre del evento. Será autocompletado por el editor.
116
+ * @param payload La carga útil del evento. El tipo debe coincidir con el definido para `eventName`.
117
+ */
118
+ sendEvent: <K extends keyof TEvents>(workflowId: string, eventName: K, payload: TEvents[K]) => Promise<void>;
94
119
  }
95
120
  interface InitializeOptions {
96
121
  durableFunctions: Map<string, DurableFunction<unknown, unknown>>;
@@ -98,6 +123,6 @@ interface InitializeOptions {
98
123
  redisClient: Redis;
99
124
  blockingRedisClient: Redis;
100
125
  }
101
- declare function bDurableInitialize(options: InitializeOptions): BDurableAPI;
126
+ declare function bDurableInitialize<TEvents extends Record<string, any>>(options: InitializeOptions): BDurableAPI<TEvents>;
102
127
 
103
- export { type BDurableAPI, type DurableFunction, type Instruction, type WorkflowContext, bDurable, bDurableInitialize, bExecute, bSleep, bWaitForEvent };
128
+ export { type BDurableAPI, type DurableFunction, type Instruction, type WorkflowContext, type WorkflowState, bDurable, bDurableInitialize };
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- var n,w;function k(i){if(n||w){console.warn("[Persistence] Los clientes de Redis ya han sido configurados. Omitiendo.");return}n=i.commandClient,w=i.blockingClient}import{randomUUID as g}from"crypto";import m from"ms";import{resolve as b}from"path";var f="queue:tasks",p="durable:sleepers";var u={RUNNING:"RUNNING",SLEEPING:"SLEEPING",COMPLETED:"COMPLETED",FAILED:"FAILED",AWAITING_EVENT:"AWAITING_EVENT",AWAITING_SUBWORKFLOW:"AWAITING_SUBWORKFLOW"};var d=class{durableFns=new Map;isWorkerRunning=!1;isSchedulerRunning=!1;schedulerInterval=null;sourceRoot;constructor(e){this.sourceRoot=e.sourceRoot}async start(e,s,r,t){let o=t||g();console.log(`[RUNTIME] Iniciando workflow '${e.name}' con ID: ${o}`);let a={workflowId:o,name:e.name,status:u.RUNNING,step:"0",input:JSON.stringify(s),state:JSON.stringify({})};return r&&(a.parentId=r),await n.hset(`workflow:${o}`,a),await this.executeStep(o,e),o}async executeStep(e,s,r){let t=await n.hgetall(`workflow:${e}`);if(!t||t.status!=="RUNNING")return;let o={workflowId:e,step:parseInt(t.step,10),input:JSON.parse(t.input),state:JSON.parse(t.state),result:r,log:a=>{console.log(`[WF:${e}] ${a}`)}};try{let a=await s.execute(o);await this.handleInstruction(a,o)}catch(a){console.error(`[RUNTIME] Error en workflow ${e} en el paso ${o.step}:`,a);let l=a instanceof Error?a:new Error(String(a)),c={status:u.FAILED,error:l.message};await n.hset(`workflow:${e}`,c),await this.failParentWorkflow(e,l)}}async handleInstruction(e,s){let{workflowId:r,state:t}=s;switch(await n.hset(`workflow:${r}`,"state",JSON.stringify(t)),e.type){case"SCHEDULE_TASK":{console.log(`[RUNTIME] Workflow ${r} agend\xF3 la tarea '${e.exportName}'.`);let o={workflowId:r,durableFunctionName:await n.hget(`workflow:${r}`,"name"),modulePath:e.modulePath,exportName:e.exportName,args:e.args};await n.lpush(f,JSON.stringify(o));break}case"SCHEDULE_SLEEP":{let o=m(e.duration),a=Date.now()+o;console.log(`[RUNTIME] Workflow ${r} en pausa por ${e.duration}. Despertar\xE1 en ${new Date(a).toISOString()}`),await n.hset(`workflow:${r}`,"status","SLEEPING"),await n.zadd(p,a.toString(),r);break}case"WAIT_FOR_EVENT":{console.log(`[RUNTIME] Workflow ${r} en pausa, esperando el evento '${e.eventName}'.`),await n.hset(`workflow:${r}`,{status:u.AWAITING_EVENT,awaitingEvent:e.eventName}),await n.sadd(`events:awaiting:${e.eventName}`,r);break}case"EXECUTE_SUBWORKFLOW":{console.log(`[RUNTIME] Workflow ${r} est\xE1 ejecutando el sub-workflow '${e.workflowName}'.`);let o=this.durableFns.get(e.workflowName);if(!o)throw new Error(`Sub-workflow '${e.workflowName}' no encontrado.`);let a=g();await n.hset(`workflow:${r}`,{status:u.AWAITING_SUBWORKFLOW,subWorkflowId:a}),await this.start(o,e.input,r,a);break}case"COMPLETE":{console.log(`[RUNTIME] Workflow ${r} completado.`);let o={status:"COMPLETED",result:JSON.stringify(e.result??null),step:(s.step+1).toString()};await n.hset(`workflow:${r}`,o),await this.resumeParentWorkflow(r);break}}}async resumeParentWorkflow(e){try{let s=`workflow:${e}`,r=await n.hgetall(s);if(!r||!r.parentId){console.log(`[RUNTIME-DEBUG] El workflow ${e} ha completado pero no tiene un parentId. No se reanudar\xE1 a nadie.`);return}let t=r.parentId,o=`workflow:${t}`,a=await n.hgetall(o);if(a.status===u.AWAITING_SUBWORKFLOW&&a.subWorkflowId===e){console.log(`[RUNTIME] Reanudando workflow padre ${t} tras la finalizaci\xF3n del sub-workflow ${e}.`);let l=this.durableFns.get(a.name);if(!l){console.error(`[RUNTIME-ERROR] No se pudo encontrar la definici\xF3n del workflow padre '${a.name}' para reanudar.`);return}await n.hset(o,"status",u.RUNNING),await n.hdel(o,"subWorkflowId"),await n.hincrby(o,"step",1);let c=r.result?JSON.parse(r.result):null;await this.executeStep(t,l,c)}else console.log(`[RUNTIME-DEBUG] El workflow ${e} ha completado, pero su padre ${t} no lo estaba esperando. Estado del padre:`,{expectedStatus:u.AWAITING_SUBWORKFLOW,actualStatus:a.status,expectedSubWorkflowId:e,actualSubWorkflowId:a.subWorkflowId})}catch(s){console.error(`[RUNTIME-ERROR] Error fatal al reanudar el workflow padre desde ${e}:`,s)}}async failParentWorkflow(e,s){let r=await n.hgetall(`workflow:${e}`);if(!r.parentId)return;let t=r.parentId;console.log(`[RUNTIME] Propagando fallo del sub-workflow ${e} al padre ${t}.`);let o={status:u.FAILED,error:`Sub-workflow ${e} fall\xF3: ${s.message}`};await n.hset(`workflow:${t}`,o)}async sendEvent(e,s,r){let t=`workflow:${e}`,o=await n.hgetall(t);if(!o.status){console.warn(`[RUNTIME] Intento de enviar evento a un workflow no existente: ${e}`);return}if(o.status!==u.AWAITING_EVENT||o.awaitingEvent!==s){console.warn(`[RUNTIME] El workflow ${e} no est\xE1 esperando el evento '${s}'. Estado actual: ${o.status}.`);return}console.log(`[RUNTIME] Evento '${s}' recibido para el workflow ${e}. Reanudando...`);let a=o.name,l=this.durableFns.get(a);l&&(await n.hset(t,"status",u.RUNNING),await n.hdel(t,"awaitingEvent"),await n.srem(`events:awaiting:${s}`,e),await n.hincrby(t,"step",1),await this.executeStep(e,l,r))}startScheduler(){if(this.isSchedulerRunning)return;this.isSchedulerRunning=!0,console.log("[SCHEDULER] Scheduler iniciado.");let e=async()=>{let s=Date.now(),r=await n.zrangebyscore(p,0,s);if(r.length>0){console.log(`[SCHEDULER] Despertando ${r.length} workflow(s)...`),await n.zrem(p,...r);for(let t of r){let o=await n.hget(`workflow:${t}`,"name");if(!o){console.error(`[SCHEDULER] No se pudo encontrar el nombre del workflow para el ID ${t}. Saltando.`);continue}let a=this.durableFns.get(o);if(!a){console.error(`[SCHEDULER] El workflow '${o}' (ID: ${t}) no est\xE1 registrado en este worker. Saltando.`);continue}console.log(`[SCHEDULER] Reanudando workflow ${t}`),await n.hset(`workflow:${t}`,"status","RUNNING"),await n.hincrby(`workflow:${t}`,"step",1),await this.executeStep(t,a,null)}}};this.schedulerInterval=setInterval(e,2e3)}startWorker(){if(this.isWorkerRunning)return;this.isWorkerRunning=!0,console.log("[WORKER] Worker iniciado, esperando tareas..."),(async()=>{for(;this.isWorkerRunning;)try{let s=await w.brpop(f,0);if(s){let[,r]=s,t=JSON.parse(r);console.log(`[WORKER] Tarea recibida: ${t.exportName}`);try{let o=t.modulePath.startsWith("virtual:")?t.modulePath:b(this.sourceRoot,t.modulePath);console.log(`[WORKER] Importando m\xF3dulo desde: ${o}`);let l=(await import(o))[t.exportName];if(typeof l!="function")throw new Error(`La exportaci\xF3n '${t.exportName}' no es una funci\xF3n en '${t.modulePath}'.`);let c=await l(...t.args),I=`workflow:${t.workflowId}`;await n.hincrby(I,"step",1);let E=this.durableFns.get(t.durableFunctionName);E&&await this.executeStep(t.workflowId,E,c)}catch(o){let a=o instanceof Error?o:new Error(String(o));console.error(`[WORKER] Falla en la tarea '${t.exportName}' para el workflow ${t.workflowId}. Marcando como FAILED.`);let l={status:u.FAILED,error:a.message};await n.hset(`workflow:${t.workflowId}`,l),await this.failParentWorkflow(t.workflowId,a)}}}catch(s){if(s instanceof Error&&s.message.includes("Connection is closed")){console.log("[WORKER] Conexi\xF3n de bloqueo cerrada.");break}console.error("[WORKER] Error de infraestructura:",s)}})()}run(e){this.durableFns=e,this.startWorker(),this.startScheduler()}stop(){this.isWorkerRunning=!1,this.isSchedulerRunning=!1,this.schedulerInterval&&clearInterval(this.schedulerInterval),console.log("[RUNTIME] Solicitando detenci\xF3n...")}};var h=i=>i;function R(i,e){throw new Error(`The "bExecute" function can only be called inside a "bDurable" workflow. Attempted to execute "${i.name}" with input ${JSON.stringify(e)}.`)}function T(i){throw new Error(`The "bSleep" function can only be called inside a "bDurable" workflow. ${i}`)}function N(i){throw new Error(`The "bWaitForEvent" function can only be called inside a "bDurable" workflow. Waiting for "${i}".`)}function M(i){console.log("--- Inicializando Sistema Durable ---"),k({commandClient:i.redisClient,blockingClient:i.blockingRedisClient});let e=new d({sourceRoot:i.sourceRoot});return e.run(i.durableFunctions),{start:e.start.bind(e),sendEvent:e.sendEvent.bind(e),stop:e.stop.bind(e),runtime:e}}export{h as bDurable,M as bDurableInitialize,R as bExecute,T as bSleep,N as bWaitForEvent};
1
+ var i,p;function E(c){if(i||p){console.warn("[Persistence] Los clientes de Redis ya han sido configurados. Omitiendo.");return}i=c.commandClient,p=c.blockingClient}import{randomUUID as m}from"crypto";import T from"ms";import{resolve as I}from"path";var d="queue:tasks",w="durable:sleepers";var u={RUNNING:"RUNNING",SLEEPING:"SLEEPING",COMPLETED:"COMPLETED",FAILED:"FAILED",AWAITING_EVENT:"AWAITING_EVENT",AWAITING_SUBWORKFLOW:"AWAITING_SUBWORKFLOW"};var f=class{getKey(t){return`workflow:${t}`}getLockKey(t){return`workflow:${t}:lock`}async acquireLock(t,e=10){let a=this.getLockKey(t);return await i.set(a,"locked","EX",e,"NX")==="OK"}async releaseLock(t){await i.del(this.getLockKey(t))}async get(t){let e=await i.hgetall(this.getKey(t));return!e||Object.keys(e).length===0?null:{workflowId:e.workflowId,name:e.name,status:e.status,step:parseInt(e.step,10),input:JSON.parse(e.input),state:JSON.parse(e.state),result:e.result?JSON.parse(e.result):void 0,error:e.error,parentId:e.parentId,subWorkflowId:e.subWorkflowId,awaitingEvent:e.awaitingEvent}}async create(t){let e={...t,step:0,state:{}},a=i.pipeline();a.hset(this.getKey(e.workflowId),{...e,input:JSON.stringify(e.input),state:JSON.stringify(e.state)}),await a.exec()}async updateState(t,e){await i.hset(this.getKey(t),"state",JSON.stringify(e))}async updateStatus(t,e,a={}){await i.hset(this.getKey(t),{status:e,...a})}async incrementStep(t){return i.hincrby(this.getKey(t),"step",1)}async complete(t,e){await i.hset(this.getKey(t),{status:u.COMPLETED,result:JSON.stringify(e??null)})}async fail(t,e){await i.hset(this.getKey(t),{status:u.FAILED,error:e.message})}async scheduleSleep(t,e){await this.updateStatus(t,u.SLEEPING),await i.zadd(w,e.toString(),t)}async getWorkflowsToWake(){let t=Date.now(),e=await i.zrangebyscore(w,0,t);return e.length>0&&await i.zrem(w,...e),e}async enqueueTask(t){await i.lpush(d,JSON.stringify(t))}async resumeForCatch(t,e,a){let n=this.getKey(t);await i.hset(n,{state:JSON.stringify(e),status:u.RUNNING,step:a.toString()})}},g=class{durableFns=new Map;repo=new f;workerId=m();isRunning=!1;schedulerInterval=null;sourceRoot;constructor(t){this.sourceRoot=t.sourceRoot}async start(t,e,a){let n=m();return console.log(`[RUNTIME] Iniciando workflow '${t.name}' con ID: ${n}`),await this.repo.create({workflowId:n,name:t.name,status:u.RUNNING,input:e,parentId:a}),this.scheduleExecution(n,t),n}async scheduleExecution(t,e,a,n){setImmediate(()=>{this._executeStep(t,e,a,n).catch(r=>{console.error(`[RUNTIME-FATAL] Error no manejado en la ejecuci\xF3n del workflow ${t}`,r)})})}async _executeStep(t,e,a,n){if(!await this.repo.acquireLock(t)){console.log(`[RUNTIME-LOCK] No se pudo adquirir el bloqueo para ${t}, otro proceso est\xE1 trabajando. Se omitir\xE1 este ciclo.`);return}let o=null;try{if(n)throw n;let s=await this.repo.get(t);if(!s)return;if(s.status!==u.RUNNING){console.log(`[RUNTIME] Se intent\xF3 ejecutar el workflow ${t} pero su estado es ${s.status}. Omitiendo.`);return}let l={workflowId:t,step:s.step,input:s.input,state:s.state,result:a,log:k=>console.log(`[WF:${t}] ${k}`)},h=await e.execute(l);await this.repo.updateState(t,l.state),await this.handleInstruction(h,l,s.name)}catch(s){let l=s instanceof Error?s:new Error(String(s));o=s instanceof Error?s:new Error(String(s)),console.error(`[RUNTIME] Error en workflow ${t}:`,o),await this.handleFailure(t,l,e)}finally{await this.repo.releaseLock(t)}o&&await this.handleFailure(t,o,e)}async handleInstruction(t,e,a){let{workflowId:n}=e;switch(t.type){case"SCHEDULE_TASK":{await this.repo.enqueueTask({workflowId:n,durableFunctionName:a,...t});break}case"SCHEDULE_SLEEP":{let r=T(t.duration),o=Date.now()+r;await this.repo.scheduleSleep(n,o);break}case"WAIT_FOR_EVENT":{await this.repo.updateStatus(n,u.AWAITING_EVENT,{awaitingEvent:t.eventName}),await i.sadd(`events:awaiting:${t.eventName}`,n);break}case"EXECUTE_SUBWORKFLOW":{let r=this.durableFns.get(t.workflowName);if(!r)throw new Error(`Sub-workflow '${t.workflowName}' no encontrado.`);let o=await this.start(r,t.input,n);await this.repo.updateStatus(n,u.AWAITING_SUBWORKFLOW,{subWorkflowId:o});break}case"COMPLETE":{await this.repo.complete(n,t.result),await this.resumeParentWorkflow(n);break}}}async handleFailure(t,e,a){if(!await this.repo.acquireLock(t,20)){console.warn(`[RUNTIME-FAIL] No se pudo adquirir lock para manejar fallo en ${t}. Reintentando m\xE1s tarde...`);return}try{let r=await this.repo.get(t);if(!r||r.status===u.FAILED)return;let o=r.state.tryCatchStack;if(o&&o.length>0){let l=o.pop()?.catchStep;if(l!==void 0){console.log(`[RUNTIME-FAIL] Excepci\xF3n capturada en ${t}. Saltando a la cl\xE1usula CATCH en el paso ${l}.`),await this.repo.resumeForCatch(t,r.state,l),this.scheduleExecution(t,a,{name:e.name,message:e.message,stack:e.stack});return}}console.error(`[RUNTIME] Error no capturado en workflow ${t}:`,e),await this.repo.fail(t,e),await this.propagateFailureToParent(t,e)}finally{await this.repo.releaseLock(t)}}async resumeParentWorkflow(t){let e=await this.repo.get(t);if(!e?.parentId)return;let a=e.parentId,n=await this.repo.get(a);if(!n||n.status!==u.AWAITING_SUBWORKFLOW||n.subWorkflowId!==t)return;console.log(`[RUNTIME] Reanudando workflow padre ${a}.`);let r=this.durableFns.get(n.name);if(!r){await this.repo.fail(a,new Error(`Definici\xF3n del workflow '${n.name}' no encontrada.`));return}await this.repo.updateStatus(a,u.RUNNING,{subWorkflowId:""}),await this.repo.incrementStep(a),this.scheduleExecution(a,r,e.result)}async propagateFailureToParent(t,e){let a=await this.repo.get(t);if(!a?.parentId)return;let n=a.parentId,r=await this.repo.get(n);if(!r||r.status!==u.AWAITING_SUBWORKFLOW||r.subWorkflowId!==t)return;console.log(`[RUNTIME] Propagando fallo del sub-workflow ${t} al padre ${n}.`);let o=this.durableFns.get(r.name);if(!o){await this.repo.fail(n,new Error(`Definici\xF3n del workflow '${r.name}' no encontrada al propagar fallo.`));return}await this.repo.updateStatus(n,u.RUNNING,{subWorkflowId:""});let s=new Error(`Sub-workflow '${a.name}' (${t}) fall\xF3: ${e.message}`);s.stack=e.stack,this.scheduleExecution(n,o,void 0,s)}async sendEvent(t,e,a){if(!await this.repo.acquireLock(t)){console.warn(`[RUNTIME-LOCK] No se pudo adquirir el bloqueo para sendEvent en ${t}. El evento podr\xEDa ser descartado o retrasado.`);return}try{let r=await this.repo.get(t);if(!r){console.warn(`[RUNTIME] Se intent\xF3 enviar un evento a un workflow no existente: ${t}`);return}if(r.status!==u.AWAITING_EVENT||r.awaitingEvent!==e){console.warn(`[RUNTIME] El workflow ${t} no est\xE1 esperando el evento '${e}'. Estado actual: ${r.status}, esperando: ${r.awaitingEvent}.`);return}console.log(`[RUNTIME] Evento '${e}' recibido para el workflow ${t}. Reanudando...`);let o=this.durableFns.get(r.name);if(!o){console.error(`[RUNTIME] La definici\xF3n de la funci\xF3n durable '${r.name}' no se encontr\xF3 para el workflow ${t}.`),await this.repo.fail(t,new Error(`Funci\xF3n durable '${r.name}' no encontrada.`));return}await this.repo.updateStatus(t,u.RUNNING,{awaitingEvent:""}),await i.srem(`events:awaiting:${e}`,t),await this.repo.incrementStep(t),this.scheduleExecution(t,o,a)}catch(r){console.error(`[RUNTIME] Error procesando el evento '${e}' para el workflow ${t}:`,r),await this.repo.fail(t,new Error(`Fallo al procesar el evento: ${r instanceof Error?r.message:String(r)}`))}finally{await this.repo.releaseLock(t)}}startScheduler(){if(this.schedulerInterval)return;console.log("[SCHEDULER] Scheduler iniciado.");let t=async()=>{let e=await this.repo.getWorkflowsToWake();for(let a of e){let n=await this.repo.get(a);if(n){let r=this.durableFns.get(n.name);r&&(console.log(`[SCHEDULER] Reanudando workflow ${a}`),await this.repo.updateStatus(a,u.RUNNING),await this.repo.incrementStep(a),this.scheduleExecution(a,r,null))}}};this.schedulerInterval=setInterval(t,2e3)}startWorker(){if(this.isRunning)return;this.isRunning=!0;let t=`${d}:processing:${this.workerId}`;console.log(`[WORKER] Worker ${this.workerId} iniciado, esperando tareas...`),(async()=>{for(;this.isRunning;)try{let a=await p.brpoplpush(d,t,0);if(!a)continue;let n=JSON.parse(a);console.log(`[WORKER] Tarea recibida: ${n.exportName}`);try{let r;n.modulePath.startsWith("virtual:")?r=await import(n.modulePath):r=await import(I(this.sourceRoot,n.modulePath));let o=r[n.exportName];if(typeof o!="function")throw new Error(`'${n.exportName}' no es una funci\xF3n.`);let s=await o(...n.args),l=this.durableFns.get(n.durableFunctionName);l&&(await this.repo.incrementStep(n.workflowId),this.scheduleExecution(n.workflowId,l,s)),await i.lrem(t,1,a)}catch(r){let o=r instanceof Error?r:new Error(String(r));console.error(`[WORKER] Falla en la tarea '${n.exportName}' para workflow ${n.workflowId}`,o);let s=this.durableFns.get(n.durableFunctionName);s?await this.handleFailure(n.workflowId,o,s):await this.repo.fail(n.workflowId,new Error(`Definici\xF3n de workflow ${n.durableFunctionName} no encontrada durante el manejo de fallos.`)),console.log(`[WORKER] Eliminando tarea procesada (con error manejado): ${n.exportName}`),await i.lrem(t,1,a)}}catch(a){if(!this.isRunning)break;console.error("[WORKER] Error de infraestructura:",a),await new Promise(n=>setTimeout(n,5e3))}})()}run(t){this.durableFns=t,this.startWorker(),this.startScheduler()}stop(){this.isRunning=!1,this.schedulerInterval&&clearInterval(this.schedulerInterval),console.log("[RUNTIME] Solicitando detenci\xF3n...")}};var b=c=>c.workflow;function D(c){console.log("--- Inicializando Sistema Durable ---"),E({commandClient:c.redisClient,blockingClient:c.blockingRedisClient});let t=new g({sourceRoot:c.sourceRoot});return t.run(c.durableFunctions),{start:t.start.bind(t),sendEvent:t.sendEvent.bind(t),stop:t.stop.bind(t),runtime:t}}export{b as bDurable,D as bDurableInitialize};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobtail.software/b-durable",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "main": "dist/index.mjs",
5
5
  "types": "dist/index.d.mts",
6
6
  "description": "A system for creating durable, resilient, and type-safe workflows in JavaScript/TypeScript.",
@@ -23,6 +23,7 @@
23
23
  "dependencies": {
24
24
  "ioredis": "^5.8.2",
25
25
  "ms": "^2.1.3",
26
+ "prettier": "^3.6.2",
26
27
  "ts-morph": "^27.0.2"
27
28
  },
28
29
  "devDependencies": {