@lotiai/composer 0.2.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/LICENSE +21 -0
- package/README.md +461 -0
- package/bin/composer.js +3 -0
- package/docker-compose.temporal.yml +144 -0
- package/package.json +99 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Loti, LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
# @lotiai/composer
|
|
2
|
+
|
|
3
|
+
[](https://github.com/lotillc/composer/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@lotiai/composer)
|
|
5
|
+
|
|
6
|
+
A framework for building type-safe, DAG-based workflows with optional Temporal integration.
|
|
7
|
+
|
|
8
|
+
Composer lets you define steps with explicit inputs and outputs, compose them into workflows with automatic dependency resolution and parallel execution, and run them synchronously (in-process) or asynchronously (via [Temporal](https://temporal.io/)).
|
|
9
|
+
|
|
10
|
+
> **Status:** pre-1.0. The public API may change between minor versions until 1.0.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pnpm add @lotiai/composer
|
|
16
|
+
# or
|
|
17
|
+
npm install @lotiai/composer
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Core Concepts
|
|
21
|
+
|
|
22
|
+
### Bag
|
|
23
|
+
|
|
24
|
+
The **bag** is a typed object that flows through a workflow. Each step declares which fields it reads from the bag (`needs`) and which fields it writes (`provides`). The bag type is defined once and shared across all steps and workflows.
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
interface MyBag {
|
|
28
|
+
userId: string;
|
|
29
|
+
user: { name: string; email: string };
|
|
30
|
+
greeting: string;
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Step
|
|
35
|
+
|
|
36
|
+
A **step** is a single unit of work. Steps declare their dependencies and outputs, and the framework validates them at both compile-time and runtime.
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
import { step } from "@lotiai/composer";
|
|
40
|
+
|
|
41
|
+
// The double function call pattern lets TypeScript infer Needs/Provides from the definition.
|
|
42
|
+
// The first call binds Bag and Context; the second binds the step shape.
|
|
43
|
+
export const fetchUser = step<MyBag, MyContext>()({
|
|
44
|
+
name: "fetchUser",
|
|
45
|
+
needs: ["userId"],
|
|
46
|
+
provides: ["user"],
|
|
47
|
+
run: async (ctx, bag) => {
|
|
48
|
+
const user = await ctx.db.findUser(bag.userId);
|
|
49
|
+
return { user };
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export const greet = step<MyBag, MyContext>()({
|
|
54
|
+
name: "greet",
|
|
55
|
+
needs: ["user"],
|
|
56
|
+
provides: ["greeting"],
|
|
57
|
+
run: async (_ctx, bag) => {
|
|
58
|
+
return { greeting: `Hello, ${bag.user.name}!` };
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**Type safety guarantees:**
|
|
64
|
+
|
|
65
|
+
- Steps can only access fields declared in `needs`
|
|
66
|
+
- Steps must return exactly the fields declared in `provides`
|
|
67
|
+
- Return values must be JSON-serializable (enforced at compile-time and runtime)
|
|
68
|
+
- Excess or missing return properties are caught at both compile-time and runtime
|
|
69
|
+
|
|
70
|
+
### Workflow
|
|
71
|
+
|
|
72
|
+
A **workflow** is a collection of steps with automatic dependency resolution. The framework builds a DAG, plans parallel execution batches, and validates all dependencies at compile-time.
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
import { createWorkflow } from "@lotiai/composer";
|
|
76
|
+
|
|
77
|
+
// Workflow without required initial data
|
|
78
|
+
const simpleWorkflow = createWorkflow<MyBag>("simple")
|
|
79
|
+
.build([fetchUser, greet]);
|
|
80
|
+
|
|
81
|
+
// Workflow requiring initial data at runtime
|
|
82
|
+
const greetWorkflow = createWorkflow<MyBag>("greet-user")
|
|
83
|
+
.requires("userId")
|
|
84
|
+
.build([fetchUser, greet]);
|
|
85
|
+
|
|
86
|
+
// Workflow with pre-configured values
|
|
87
|
+
const configuredWorkflow = createWorkflow<MyBag>("configured")
|
|
88
|
+
.configure({ userId: "default-user" })
|
|
89
|
+
.build([fetchUser, greet]);
|
|
90
|
+
|
|
91
|
+
// Configure + require (configure provides defaults, requires demands runtime values)
|
|
92
|
+
const hybridWorkflow = createWorkflow<MyBag>("hybrid")
|
|
93
|
+
.configure({ someDefault: "value" })
|
|
94
|
+
.requires("userId")
|
|
95
|
+
.build([fetchUser, greet]);
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**Compile-time dependency validation:** If a step needs a field that no prior step provides and it's not in initial/configured data, you get a clear compile error:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
"WORKFLOW ERROR: A step needs user but it is not available. Available fields: [userId]
|
|
102
|
+
-> FIX: Add a step that provides user before the step that needs it"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Workflow Composition
|
|
106
|
+
|
|
107
|
+
Workflows can be composed into other workflows using the `use()` helper. Child workflow steps are flattened into the parent's DAG, enabling maximum parallelization while maintaining logical boundaries for observability.
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
import { createWorkflow, use } from "@lotiai/composer";
|
|
111
|
+
|
|
112
|
+
const authWorkflow = createWorkflow<MyBag>("auth")
|
|
113
|
+
.requires("userId")
|
|
114
|
+
.build([validateUser, checkPermissions]);
|
|
115
|
+
|
|
116
|
+
const mainWorkflow = createWorkflow<MyBag>("main")
|
|
117
|
+
.requires("userId")
|
|
118
|
+
.build([
|
|
119
|
+
fetchData,
|
|
120
|
+
use(authWorkflow), // Steps are flattened into parent DAG
|
|
121
|
+
processRequest,
|
|
122
|
+
]);
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Composed workflows:
|
|
126
|
+
- Flatten steps for maximum parallelization across workflow boundaries
|
|
127
|
+
- Auto-namespace step names (e.g. `auth.validateUser`) to prevent conflicts
|
|
128
|
+
- Preserve observability via SubWorkflow spans in traces
|
|
129
|
+
- Support arbitrary nesting depth
|
|
130
|
+
|
|
131
|
+
### Execution Model
|
|
132
|
+
|
|
133
|
+
Workflows execute using batch-based parallelism:
|
|
134
|
+
|
|
135
|
+
1. **Dependency analysis** -- topological sort builds a DAG
|
|
136
|
+
2. **Batch formation** -- groups steps that can run simultaneously
|
|
137
|
+
3. **Parallel execution** -- each batch runs with `Promise.allSettled`
|
|
138
|
+
4. **Progressive merge** -- successful outputs merge into the bag even if sibling steps fail
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
Given: stepA(needs:[]) -> stepB(needs:["x"]) -> stepC(needs:["x"]) -> stepD(needs:["y","z"])
|
|
142
|
+
|
|
143
|
+
Batch 1: [stepA] -- sequential (no deps)
|
|
144
|
+
Batch 2: [stepB, stepC] -- parallel (both need only "x")
|
|
145
|
+
Batch 3: [stepD] -- sequential (needs "y" and "z")
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Usage
|
|
149
|
+
|
|
150
|
+
### Creating a Composer
|
|
151
|
+
|
|
152
|
+
The `createComposer` function creates a configured instance that manages context lifecycle and execution:
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
import { createComposer } from "@lotiai/composer";
|
|
156
|
+
|
|
157
|
+
// Sync-only composer (no Temporal config)
|
|
158
|
+
const syncComposer = createComposer({
|
|
159
|
+
contextProvider: {
|
|
160
|
+
beforeStep: async (stepName) => ({
|
|
161
|
+
db: await getDbConnection(),
|
|
162
|
+
}),
|
|
163
|
+
afterStep: async (ctx, error) => {
|
|
164
|
+
if (!error) await ctx.db.flush();
|
|
165
|
+
ctx.db.release();
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Full composer with Temporal support
|
|
171
|
+
const composer = createComposer({
|
|
172
|
+
contextProvider: myContextProvider,
|
|
173
|
+
temporal: {
|
|
174
|
+
serverAddress: "localhost:7233",
|
|
175
|
+
namespace: "my-namespace",
|
|
176
|
+
},
|
|
177
|
+
// Optional settings:
|
|
178
|
+
logger: myPinoLogger, // defaults to console
|
|
179
|
+
deepFreeze: true, // freeze step outputs for immutability protection
|
|
180
|
+
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Running Workflows
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
// Synchronous execution (in-process)
|
|
187
|
+
const { bag, error } = await composer.runSyncWorkflow(greetWorkflow, { userId: "u_123" });
|
|
188
|
+
|
|
189
|
+
if (error) {
|
|
190
|
+
console.error("Workflow failed:", error);
|
|
191
|
+
} else {
|
|
192
|
+
console.log(bag.greeting); // "Hello, Alice!"
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Asynchronous execution (via Temporal) -- requires temporal config
|
|
196
|
+
const { bag: asyncBag, error: asyncError } = await composer.runAsyncWorkflow(greetWorkflow, { userId: "u_123" });
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Workflows never throw. Errors are returned in `error`, and `bag` always contains any data produced by successful steps.
|
|
200
|
+
|
|
201
|
+
### Checkpoints (Async Only)
|
|
202
|
+
|
|
203
|
+
Checkpoints allow async workflows to return partial results early while continuing execution in the background:
|
|
204
|
+
|
|
205
|
+
```typescript
|
|
206
|
+
const workflow = createWorkflow<MyBag>("with-checkpoint")
|
|
207
|
+
.requires("userId")
|
|
208
|
+
.build([persistStep, processStep, finalizeStep])
|
|
209
|
+
.checkpoint("persisted", { afterStep: persistStep })
|
|
210
|
+
.checkpoint("processed", { afterStep: processStep, timeout: 60000 });
|
|
211
|
+
|
|
212
|
+
// Get partial result after persistStep completes
|
|
213
|
+
const { bag, error } = await composer.runAsyncWorkflow(workflow, data, {
|
|
214
|
+
awaitCheckpoint: "persisted",
|
|
215
|
+
});
|
|
216
|
+
// bag contains outputs up through the batch containing persistStep
|
|
217
|
+
// The workflow continues running in the background
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Checkpoint names are validated at compile-time -- using a name that doesn't exist on the workflow produces a type error.
|
|
221
|
+
|
|
222
|
+
### Error Handling
|
|
223
|
+
|
|
224
|
+
Attach an error handler to workflows for domain-specific error recovery:
|
|
225
|
+
|
|
226
|
+
```typescript
|
|
227
|
+
const workflow = createWorkflow<MyBag>("resilient")
|
|
228
|
+
.requires("userId")
|
|
229
|
+
.build([fetchUser, processData])
|
|
230
|
+
.onError(async (ctx, bag, error) => {
|
|
231
|
+
// Check error type
|
|
232
|
+
if (isExpectedError(error)) {
|
|
233
|
+
bag.result = getFallbackResult();
|
|
234
|
+
return undefined; // Error handled, error will be undefined in the result
|
|
235
|
+
}
|
|
236
|
+
return error; // Unknown error, propagate as-is
|
|
237
|
+
});
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Error handler return values:
|
|
241
|
+
- `undefined` -- error fully handled, `error` will be `undefined` in the result
|
|
242
|
+
- `Error` -- propagate or transform the error into the result's `error`
|
|
243
|
+
- Throwing -- `error` will be a `WorkflowErrorHandlerFailure` wrapping both the original error and the handler error
|
|
244
|
+
|
|
245
|
+
### Context Provider
|
|
246
|
+
|
|
247
|
+
The context provider manages per-step resources (e.g. database connections, loggers). It is configured once via `createComposer` and used automatically for every step execution.
|
|
248
|
+
|
|
249
|
+
```typescript
|
|
250
|
+
import { type StepContextProvider } from "@lotiai/composer";
|
|
251
|
+
|
|
252
|
+
interface MyContext {
|
|
253
|
+
em: SqlEntityManager;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const contextProvider: StepContextProvider<MyContext> = {
|
|
257
|
+
beforeStep: async (stepName) => ({
|
|
258
|
+
em: await DatabaseConnection.getInstance().getForkedEntityManager(),
|
|
259
|
+
}),
|
|
260
|
+
afterStep: async (ctx, error) => {
|
|
261
|
+
if (!error) await ctx.em.flush();
|
|
262
|
+
ctx.em.clear();
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### Custom Logger
|
|
268
|
+
|
|
269
|
+
Composer accepts any logger with `info`, `warn`, `error`, and `debug` methods. Compatible with `console`, `pino`, `winston`, and most logging libraries.
|
|
270
|
+
|
|
271
|
+
```typescript
|
|
272
|
+
import pino from "pino";
|
|
273
|
+
|
|
274
|
+
const composer = createComposer({
|
|
275
|
+
contextProvider,
|
|
276
|
+
logger: {
|
|
277
|
+
info: (msg, meta) => pino().info(meta, msg),
|
|
278
|
+
warn: (msg, meta) => pino().warn(meta, msg),
|
|
279
|
+
error: (msg, meta) => pino().error(meta, msg),
|
|
280
|
+
debug: (msg, meta) => pino().debug(meta, msg),
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
## CLI
|
|
286
|
+
|
|
287
|
+
The package includes a CLI for building and managing Temporal workflow/activity bundles. It reads configuration from a `composer.build-config.ts` file in your package root.
|
|
288
|
+
|
|
289
|
+
```bash
|
|
290
|
+
npx @lotiai/composer build # Build bundles (fast unversioned flow)
|
|
291
|
+
npx @lotiai/composer build --git-hash=abc # Versioned build (vendor bundle + version copies + manifest)
|
|
292
|
+
npx @lotiai/composer validate # Validate config and definitions
|
|
293
|
+
npx @lotiai/composer dev # Watch mode: compile, bundle, restart workers on changes
|
|
294
|
+
npx @lotiai/composer dev --git-hash=abc # Watch mode with versioned bundle flow
|
|
295
|
+
npx @lotiai/composer dev:clean # Remove versioned bundle artifacts
|
|
296
|
+
npx @lotiai/composer dev:up # Start Temporal dev server (PostgreSQL + Temporal + UI)
|
|
297
|
+
npx @lotiai/composer dev:down # Stop Temporal dev server
|
|
298
|
+
npx @lotiai/composer merge --git-hash=abc # Merge versioned bundles into combined bundles for workers
|
|
299
|
+
npx @lotiai/composer upload --git-hash=abc --type=versions # Upload version bundles to S3
|
|
300
|
+
npx @lotiai/composer upload --git-hash=abc --type=merged # Upload merged bundles to S3
|
|
301
|
+
npx @lotiai/composer cleanup # Remove old versioned bundles (retention policy)
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### Output Directory
|
|
305
|
+
|
|
306
|
+
The CLI resolves the output directory automatically from your `tsconfig.json`'s `compilerOptions.outDir`. If `outDir` is not set in the `tsconfig.json` (sibling file to `composer.build-config.ts`), it falls back to `"dist"`. This means bundle output (workflow.js, activity.js, vendor bundles, version copies) is placed alongside your compiled TypeScript output without any extra configuration.
|
|
307
|
+
|
|
308
|
+
### Build Configuration File
|
|
309
|
+
|
|
310
|
+
Create a `composer.build-config.ts` in your package root:
|
|
311
|
+
|
|
312
|
+
```typescript
|
|
313
|
+
import { defineBuildConfig } from "@lotiai/composer/build-config";
|
|
314
|
+
|
|
315
|
+
export default defineBuildConfig({
|
|
316
|
+
// Required: directories containing step and workflow definitions
|
|
317
|
+
stepsDir: "src/steps",
|
|
318
|
+
workflowsDir: "src/workflows",
|
|
319
|
+
|
|
320
|
+
// Optional: S3 config for bundle storage (required for upload/merge/cleanup commands)
|
|
321
|
+
s3: {
|
|
322
|
+
bucketName: "composer-bundles",
|
|
323
|
+
// Optional: provide a custom S3Client (e.g. for LocalStack or custom credentials)
|
|
324
|
+
// customClient: new S3Client({ region: "us-east-1", endpoint: "http://localhost:4566", forcePathStyle: true }),
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
// Optional: override worker profile defaults
|
|
328
|
+
workerProfiles: {
|
|
329
|
+
standard: {
|
|
330
|
+
cpu: 1024,
|
|
331
|
+
memory: 4096,
|
|
332
|
+
maxConcurrentActivities: 20,
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
|
|
336
|
+
// Optional: build settings
|
|
337
|
+
build: {
|
|
338
|
+
minify: true, // default: true
|
|
339
|
+
},
|
|
340
|
+
|
|
341
|
+
// Optional: dev command settings
|
|
342
|
+
dev: {
|
|
343
|
+
startAllWorkersJsScript: "dist/scripts/start-all-workers.js",
|
|
344
|
+
// watchPatterns: ["src/**/*.ts"], // default: ["src/**/*.ts"]
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Note: Temporal connection config (`serverAddress`, `namespace`) is passed to `createComposer()` at runtime, not in `composer.build-config.ts`. The config file is for build and tooling concerns only.
|
|
350
|
+
|
|
351
|
+
### Monorepo Consumers and Vendor Bundles
|
|
352
|
+
|
|
353
|
+
When using versioned builds (`--git-hash`), the CLI creates a **vendor bundle** containing shared npm dependencies. In a monorepo, you typically want to exclude your own workspace packages from the vendor bundle (they contain business logic, not shared vendor deps) while still including their transitive npm dependencies.
|
|
354
|
+
|
|
355
|
+
The CLI auto-detects which packages to exclude by looking for dependencies declared with pnpm's `workspace:` protocol in your `package.json`:
|
|
356
|
+
|
|
357
|
+
```json
|
|
358
|
+
{
|
|
359
|
+
"dependencies": {
|
|
360
|
+
"@myorg/shared-lib": "workspace:*",
|
|
361
|
+
"@myorg/db": "workspace:*",
|
|
362
|
+
"es-toolkit": "^1.43.0",
|
|
363
|
+
"uuid": "^11.1.0"
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
In this example, `@myorg/shared-lib` and `@myorg/db` are excluded from the vendor bundle, but their transitive npm dependencies are still discovered and included. `es-toolkit` and `uuid` are included directly. Dependencies using pnpm's `catalog:` protocol are treated as normal npm dependencies and included in the vendor bundle.
|
|
369
|
+
|
|
370
|
+
## Starting Temporal Workers
|
|
371
|
+
|
|
372
|
+
For production or local development with Temporal, start workers using the helper functions:
|
|
373
|
+
|
|
374
|
+
```typescript
|
|
375
|
+
import { startAllWorkers } from "@lotiai/composer";
|
|
376
|
+
import { composer } from "./my-app-composer";
|
|
377
|
+
|
|
378
|
+
// Start both workflow and activity workers in one process (local dev)
|
|
379
|
+
await startAllWorkers(composer, {
|
|
380
|
+
workflow: {
|
|
381
|
+
taskQueues: ["workflow-tasks"],
|
|
382
|
+
maxConcurrentWorkflowTaskExecutions: 100,
|
|
383
|
+
},
|
|
384
|
+
activity: {
|
|
385
|
+
taskQueues: ["standard-tasks"],
|
|
386
|
+
maxConcurrentActivityTaskExecutions: 15,
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
For production, run workflow and activity workers in separate processes for independent scaling:
|
|
392
|
+
|
|
393
|
+
```typescript
|
|
394
|
+
import { startWorkflowWorker, startActivityWorker } from "@lotiai/composer";
|
|
395
|
+
|
|
396
|
+
// In workflow worker process:
|
|
397
|
+
await startWorkflowWorker(composer, {
|
|
398
|
+
taskQueues: ["workflow-tasks"],
|
|
399
|
+
maxConcurrentWorkflowTaskExecutions: 100,
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// In activity worker process:
|
|
403
|
+
await startActivityWorker(composer, {
|
|
404
|
+
taskQueues: ["standard-tasks"],
|
|
405
|
+
maxConcurrentActivityTaskExecutions: 15,
|
|
406
|
+
});
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
## Worker Profiles
|
|
410
|
+
|
|
411
|
+
Steps can declare a `workerProfile` to control which Temporal worker pool they run on. Currently, there is a single `"standard"` profile:
|
|
412
|
+
|
|
413
|
+
| Profile | Task Queue | CPU | Memory | Concurrent Activities |
|
|
414
|
+
|------------|------------------|---------|--------|-----------------------|
|
|
415
|
+
| `standard` | standard-tasks | 0.5 vCPU| 2 GB | 15 |
|
|
416
|
+
|
|
417
|
+
```typescript
|
|
418
|
+
export const heavyStep = step<MyBag, MyContext>()({
|
|
419
|
+
name: "heavyComputation",
|
|
420
|
+
needs: ["input"],
|
|
421
|
+
provides: ["output"],
|
|
422
|
+
workerProfile: "standard", // default if omitted
|
|
423
|
+
run: async (ctx, bag) => { /* ... */ },
|
|
424
|
+
});
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
Defaults can be overridden per-deployment via `workerProfiles` in `composer.build-config.ts`.
|
|
428
|
+
|
|
429
|
+
## Exports
|
|
430
|
+
|
|
431
|
+
### Main entry point (`@lotiai/composer`)
|
|
432
|
+
|
|
433
|
+
| Export | Description |
|
|
434
|
+
|--------|-------------|
|
|
435
|
+
| `createComposer` | Create a configured Composer instance |
|
|
436
|
+
| `createWorkflow` | Create a workflow with compile-time dependency validation |
|
|
437
|
+
| `step` | Factory for creating type-safe steps |
|
|
438
|
+
| `use` | Compose a child workflow into a parent |
|
|
439
|
+
| `defineBuildConfig` | Type-safe build config helper (also available from `@lotiai/composer/build-config`) |
|
|
440
|
+
| `startAllWorkers` | Start both Temporal workers in one process |
|
|
441
|
+
| `startWorkflowWorker` | Start a Temporal workflow worker |
|
|
442
|
+
| `startActivityWorker` | Start a Temporal activity worker |
|
|
443
|
+
| `getAllTaskQueues` | Get all configured task queue names |
|
|
444
|
+
| `getTaskQueueForProfile` | Get the task queue for a worker profile |
|
|
445
|
+
| `WorkflowStepError` | Error class for step failures |
|
|
446
|
+
| `WorkflowBatchError` | Error class for batch failures |
|
|
447
|
+
| `WorkflowErrorHandlerFailure` | Error class for error handler failures |
|
|
448
|
+
|
|
449
|
+
### Build config entry point (`@lotiai/composer/build-config`)
|
|
450
|
+
|
|
451
|
+
Build configuration schema, loader, and `defineBuildConfig` helper for `composer.build-config.ts` files.
|
|
452
|
+
|
|
453
|
+
## Deep Freeze (Immutability Protection)
|
|
454
|
+
|
|
455
|
+
Enable `deepFreeze: true` in `createComposer` to freeze all step outputs before merging them into the bag. This catches mutation bugs that can cause non-deterministic behavior:
|
|
456
|
+
|
|
457
|
+
- Parallel batch mutations (two steps mutating shared references)
|
|
458
|
+
- Downstream mutations (a step modifying data from a previous step)
|
|
459
|
+
- Post-return mutations (keeping a reference and mutating it later)
|
|
460
|
+
|
|
461
|
+
Recommended for development and testing. The overhead (~0.5--10ms per output) is negligible for I/O-bound workflows.
|
package/bin/composer.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# Temporal Server for local development
|
|
2
|
+
#
|
|
3
|
+
# temporalio/auto-setup was removed after 1.24.x. Local dev now uses
|
|
4
|
+
# temporalio/admin-tools to initialise the schema, then starts the bare
|
|
5
|
+
# temporalio/server image -- the same split used in production (ECS).
|
|
6
|
+
#
|
|
7
|
+
# Usage (from any directory):
|
|
8
|
+
# docker compose -f <path-to>/docker-compose.temporal.yml up -d
|
|
9
|
+
|
|
10
|
+
services:
|
|
11
|
+
postgresql:
|
|
12
|
+
image: postgres:14
|
|
13
|
+
container_name: temporal-postgresql
|
|
14
|
+
environment:
|
|
15
|
+
POSTGRES_USER: temporal
|
|
16
|
+
POSTGRES_PASSWORD: temporal
|
|
17
|
+
POSTGRES_DB: temporal
|
|
18
|
+
ports:
|
|
19
|
+
# TEMPORAL_DB_PORT controls the host-side port only; containers always
|
|
20
|
+
# talk to postgresql:5432 over the Docker network.
|
|
21
|
+
- "${TEMPORAL_DB_PORT:-5432}:5432"
|
|
22
|
+
volumes:
|
|
23
|
+
- postgres-data:/var/lib/postgresql/data
|
|
24
|
+
networks:
|
|
25
|
+
- temporal-network
|
|
26
|
+
healthcheck:
|
|
27
|
+
test: ["CMD-SHELL", "pg_isready -U temporal"]
|
|
28
|
+
interval: 5s
|
|
29
|
+
timeout: 5s
|
|
30
|
+
retries: 5
|
|
31
|
+
|
|
32
|
+
# One-shot init container: creates the visibility database and runs
|
|
33
|
+
# schema setup for both the main and visibility databases, then exits.
|
|
34
|
+
temporal-schema-init:
|
|
35
|
+
image: temporalio/admin-tools:1.30.1
|
|
36
|
+
container_name: temporal-schema-init
|
|
37
|
+
depends_on:
|
|
38
|
+
postgresql:
|
|
39
|
+
condition: service_healthy
|
|
40
|
+
environment:
|
|
41
|
+
# temporal-sql-tool reads these env vars natively
|
|
42
|
+
- SQL_PLUGIN=postgres12
|
|
43
|
+
- SQL_HOST=postgresql
|
|
44
|
+
- SQL_PORT=5432
|
|
45
|
+
- SQL_USER=temporal
|
|
46
|
+
- SQL_PASSWORD=temporal
|
|
47
|
+
entrypoint: ["sh", "-c"]
|
|
48
|
+
command:
|
|
49
|
+
- |
|
|
50
|
+
set -eu
|
|
51
|
+
|
|
52
|
+
# Create the visibility database (the main 'temporal' db is created by PostgreSQL's POSTGRES_DB)
|
|
53
|
+
temporal-sql-tool --database temporal_visibility create-database || true
|
|
54
|
+
|
|
55
|
+
# Set up or update the main temporal database schema
|
|
56
|
+
temporal-sql-tool --database temporal setup-schema -v 0.0
|
|
57
|
+
temporal-sql-tool --database temporal \
|
|
58
|
+
update-schema -d /etc/temporal/schema/postgresql/v12/temporal/versioned
|
|
59
|
+
|
|
60
|
+
# Set up or update the visibility database schema
|
|
61
|
+
temporal-sql-tool --database temporal_visibility setup-schema -v 0.0
|
|
62
|
+
temporal-sql-tool --database temporal_visibility \
|
|
63
|
+
update-schema -d /etc/temporal/schema/postgresql/v12/visibility/versioned
|
|
64
|
+
|
|
65
|
+
echo "Temporal schema initialisation complete."
|
|
66
|
+
networks:
|
|
67
|
+
- temporal-network
|
|
68
|
+
|
|
69
|
+
temporal:
|
|
70
|
+
image: temporalio/server:1.30.1
|
|
71
|
+
container_name: temporal-dev
|
|
72
|
+
depends_on:
|
|
73
|
+
postgresql:
|
|
74
|
+
condition: service_healthy
|
|
75
|
+
temporal-schema-init:
|
|
76
|
+
condition: service_completed_successfully
|
|
77
|
+
ports:
|
|
78
|
+
- "7233:7233" # Temporal Server (gRPC)
|
|
79
|
+
environment:
|
|
80
|
+
- DB=postgres12
|
|
81
|
+
- DB_PORT=5432
|
|
82
|
+
- POSTGRES_USER=temporal
|
|
83
|
+
- POSTGRES_PWD=temporal
|
|
84
|
+
- POSTGRES_SEEDS=postgresql
|
|
85
|
+
# The bare server image does not ship a dynamic config file but requires
|
|
86
|
+
# one to exist. Run as root so we can create it, then exec the entrypoint.
|
|
87
|
+
user: root
|
|
88
|
+
entrypoint: ["sh", "-c"]
|
|
89
|
+
command:
|
|
90
|
+
- |
|
|
91
|
+
mkdir -p /etc/temporal/config/dynamicconfig
|
|
92
|
+
echo '{}' > /etc/temporal/config/dynamicconfig/docker.yaml
|
|
93
|
+
exec /etc/temporal/entrypoint.sh
|
|
94
|
+
networks:
|
|
95
|
+
- temporal-network
|
|
96
|
+
|
|
97
|
+
temporal-namespace-init:
|
|
98
|
+
image: temporalio/admin-tools:1.30.1
|
|
99
|
+
container_name: temporal-namespace-init
|
|
100
|
+
depends_on:
|
|
101
|
+
- temporal
|
|
102
|
+
entrypoint: ["sh", "-c"]
|
|
103
|
+
# Uses the `temporal` CLI; `tctl` was removed from temporalio/admin-tools and would block forever in the health-wait loop.
|
|
104
|
+
command:
|
|
105
|
+
- |
|
|
106
|
+
set -eu
|
|
107
|
+
|
|
108
|
+
until temporal operator cluster health --address temporal:7233 >/dev/null 2>&1; do
|
|
109
|
+
sleep 2
|
|
110
|
+
done
|
|
111
|
+
|
|
112
|
+
# Keep local namespaces aligned with the canonical shared constants.
|
|
113
|
+
for namespace in interchange outpost josu; do
|
|
114
|
+
if temporal operator namespace describe -n "$namespace" --address temporal:7233 >/dev/null 2>&1; then
|
|
115
|
+
echo "Temporal namespace '$namespace' already exists."
|
|
116
|
+
else
|
|
117
|
+
temporal operator namespace create -n "$namespace" --retention 90d --address temporal:7233
|
|
118
|
+
echo "Temporal namespace '$namespace' created."
|
|
119
|
+
fi
|
|
120
|
+
done
|
|
121
|
+
networks:
|
|
122
|
+
- temporal-network
|
|
123
|
+
|
|
124
|
+
temporal-ui:
|
|
125
|
+
image: temporalio/ui:2.47.3
|
|
126
|
+
container_name: temporal-ui
|
|
127
|
+
depends_on:
|
|
128
|
+
- temporal
|
|
129
|
+
- temporal-namespace-init
|
|
130
|
+
ports:
|
|
131
|
+
- "8080:8080" # Temporal Web UI
|
|
132
|
+
environment:
|
|
133
|
+
- TEMPORAL_ADDRESS=temporal:7233
|
|
134
|
+
- TEMPORAL_CORS_ORIGINS=http://localhost:3000
|
|
135
|
+
networks:
|
|
136
|
+
- temporal-network
|
|
137
|
+
|
|
138
|
+
volumes:
|
|
139
|
+
postgres-data:
|
|
140
|
+
driver: local
|
|
141
|
+
|
|
142
|
+
networks:
|
|
143
|
+
temporal-network:
|
|
144
|
+
driver: bridge
|
package/package.json
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lotiai/composer",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "A framework for building type-safe, DAG-based workflows with optional Temporal integration.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"temporal",
|
|
8
|
+
"workflow",
|
|
9
|
+
"dag",
|
|
10
|
+
"typescript",
|
|
11
|
+
"type-safe",
|
|
12
|
+
"orchestration"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/lotillc/composer#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/lotillc/composer/issues"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "https://github.com/lotillc/composer.git"
|
|
21
|
+
},
|
|
22
|
+
"main": "dist/index.js",
|
|
23
|
+
"types": "dist/index.d.ts",
|
|
24
|
+
"bin": {
|
|
25
|
+
"composer": "bin/composer.js"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"bin",
|
|
29
|
+
"dist",
|
|
30
|
+
"!dist/**/*.vitest.*",
|
|
31
|
+
"!dist/**/__tests__",
|
|
32
|
+
"docker-compose.temporal.yml"
|
|
33
|
+
],
|
|
34
|
+
"exports": {
|
|
35
|
+
".": {
|
|
36
|
+
"types": "./dist/index.d.ts",
|
|
37
|
+
"default": "./dist/index.js"
|
|
38
|
+
},
|
|
39
|
+
"./build-config": {
|
|
40
|
+
"types": "./dist/build-config/index.d.ts",
|
|
41
|
+
"default": "./dist/build-config/index.js"
|
|
42
|
+
},
|
|
43
|
+
"./temporal-naming": {
|
|
44
|
+
"types": "./dist/temporal-naming.d.ts",
|
|
45
|
+
"default": "./dist/temporal-naming.js"
|
|
46
|
+
},
|
|
47
|
+
"./schedule-sync": {
|
|
48
|
+
"types": "./dist/schedule-sync.d.ts",
|
|
49
|
+
"default": "./dist/schedule-sync.js"
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"publishConfig": {
|
|
53
|
+
"access": "public",
|
|
54
|
+
"registry": "https://registry.npmjs.org"
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"compile": "tsc --build",
|
|
58
|
+
"clean": "rm -rf dist *.tsbuildinfo",
|
|
59
|
+
"test": "pnpm test:vitest",
|
|
60
|
+
"test:vitest": "vitest run --config vitest.config.mts --typecheck",
|
|
61
|
+
"test:vitest:watch": "vitest --config vitest.config.mts",
|
|
62
|
+
"test:watch": "pnpm test:vitest:watch",
|
|
63
|
+
"test:coverage": "vitest run --config vitest.config.mts --coverage",
|
|
64
|
+
"dev:temporal:up": "docker-compose -f docker-compose.temporal.yml up -d",
|
|
65
|
+
"dev:temporal:down": "docker-compose -f docker-compose.temporal.yml down",
|
|
66
|
+
"dev:temporal:logs": "docker-compose -f docker-compose.temporal.yml logs -f",
|
|
67
|
+
"cli": "node dist/cli/index.js",
|
|
68
|
+
"cli:dev": "node dist/cli/index.js dev"
|
|
69
|
+
},
|
|
70
|
+
"dependencies": {
|
|
71
|
+
"@aws-sdk/client-cloudwatch": "^3.946.0",
|
|
72
|
+
"@aws-sdk/client-lambda": "^3.658.1",
|
|
73
|
+
"@lifeomic/attempt": "^3.0.0",
|
|
74
|
+
"@opentelemetry/api": "^1.9.0",
|
|
75
|
+
"@temporalio/activity": "^1.15.0",
|
|
76
|
+
"@temporalio/client": "^1.15.0",
|
|
77
|
+
"@temporalio/common": "^1.15.0",
|
|
78
|
+
"@temporalio/worker": "^1.15.0",
|
|
79
|
+
"@temporalio/workflow": "^1.15.0",
|
|
80
|
+
"chokidar": "^3.5.3",
|
|
81
|
+
"es-toolkit": "^1.43.0",
|
|
82
|
+
"glob": "^13.0.0",
|
|
83
|
+
"jiti": "^2.6.1",
|
|
84
|
+
"long": "^5.0.0",
|
|
85
|
+
"typescript": "^5.9.2",
|
|
86
|
+
"uuid": "^11.1.0",
|
|
87
|
+
"yargs": "^17.7.2",
|
|
88
|
+
"zod": "^4.1.13"
|
|
89
|
+
},
|
|
90
|
+
"devDependencies": {
|
|
91
|
+
"@types/yargs": "^17.0.24",
|
|
92
|
+
"@vitest/coverage-v8": "^4.1.4",
|
|
93
|
+
"vitest": "^4.1.4"
|
|
94
|
+
},
|
|
95
|
+
"engines": {
|
|
96
|
+
"node": ">=22"
|
|
97
|
+
},
|
|
98
|
+
"packageManager": "pnpm@10.28.2"
|
|
99
|
+
}
|