diff --git a/.gitignore b/.gitignore index 9ce3561b..e9a6ba35 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,5 @@ packages/cloud/ # Local test artifacts /test/ /test_screenshots/ +third_party/ +packages/stem/workflow.sqlite* diff --git a/.site/docs/brokers/overview.md b/.site/docs/brokers/overview.md index b2d49cac..eac5318d 100644 --- a/.site/docs/brokers/overview.md +++ b/.site/docs/brokers/overview.md @@ -98,11 +98,12 @@ and limited fanout without SNS. - **Redis Streams** is the default. Enable persistence (AOF) and replicate to a hot standby for fault tolerance. Configure namespaces per environment with - ACLs. The `examples/redis_postgres_worker` sample pairs Redis with Postgres - for result storage. + ACLs. The `packages/stem/example/redis_postgres_worker` sample pairs Redis + with Postgres for result storage. - **Postgres** integrates tightly with the existing result backend for teams - already running Postgres. Leases are implemented via advisory locks; ensure - the connection pool matches expected concurrency. + already running Postgres. Delivery leases are tracked in queue rows (for + example via `locked_until`), so ensure the connection pool matches expected + concurrency. - **SQLite** is ideal for single-host development and demos. Use separate DB files for broker and backend; avoid producer writes to the backend. - **In-memory** adapters mirror the Redis API and are safe for smoke tests. diff --git a/.site/docs/comparisons/stem-vs-bullmq.md b/.site/docs/comparisons/stem-vs-bullmq.md index c95329a2..cf8fdde4 100644 --- a/.site/docs/comparisons/stem-vs-bullmq.md +++ b/.site/docs/comparisons/stem-vs-bullmq.md @@ -8,7 +8,7 @@ slug: /comparisons/stem-vs-bullmq This page is the canonical Stem comparison matrix for BullMQ-style features. It focuses on capability parity, not API-level compatibility. -**As of:** February 24, 2026 +**As of:** March 18, 2026 ## Status semantics @@ -27,7 +27,7 @@ It focuses on capability parity, not API-level compatibility. | Group Rate Limit | `✓` | Stem supports group-scoped rate limiting via `TaskOptions.groupRateLimit`, `groupRateKey`, and `groupRateKeyHeader`. See [Rate Limiting](../core-concepts/rate-limiting.md). | | Group Support | `✓` | Stem provides `Canvas.group` and `Canvas.chord` primitives. See [Canvas Patterns](../core-concepts/canvas.md). | | Batches Support | `✓` | Stem exposes first-class batch APIs (`submitBatch`, `inspectBatch`) with durable batch lifecycle status. See [Canvas Patterns](../core-concepts/canvas.md). | -| Parent/Child Dependencies | `✓` | Stem supports dependency composition through chains, groups/chords, and workflow steps. See [Canvas Patterns](../core-concepts/canvas.md) and [Workflows](../core-concepts/workflows.md). | +| Parent/Child Dependencies | `✓` | Stem supports dependency composition through chains, groups/chords, and durable workflow stages. See [Canvas Patterns](../core-concepts/canvas.md) and [Workflows](../workflows/index.md). | | Deduplication (Debouncing) | `~` | `TaskOptions.unique` prevents duplicate enqueue claims, but semantics are lock/TTL-based rather than BullMQ-native dedupe APIs. See [Uniqueness](../core-concepts/uniqueness.md). | | Deduplication (Throttling) | `~` | `uniqueFor` and lock TTL windows approximate throttling behavior, but are not a direct BullMQ equivalent. See [Uniqueness](../core-concepts/uniqueness.md). | | Priorities | `✓` | Stem supports task priority and queue priority ranges. See [Tasks](../core-concepts/tasks.md) and [Routing](../core-concepts/routing.md). | @@ -41,7 +41,7 @@ It focuses on capability parity, not API-level compatibility. | Atomic ops | `~` | Stem includes atomic behavior in specific stores/flows, but end-to-end transactional guarantees (for all enqueue/ack/result paths) are not universally built-in. See [Tasks idempotency guidance](../core-concepts/tasks.md#idempotency-checklist) and [Best Practices](../getting-started/best-practices.md). | | Persistence | `✓` | Stem persists task/workflow/schedule state through pluggable backends/stores. See [Persistence & Stores](../core-concepts/persistence.md). | | UI | `~` | Stem includes an experimental dashboard, not a fully mature operator UI parity target yet. See [Dashboard](../core-concepts/dashboard.md). | -| Optimized for | `~` | Stem is optimized for jobs/messages plus durable workflow orchestration, not only queue semantics. See [Core Concepts](../core-concepts/index.md) and [Workflows](../core-concepts/workflows.md). | +| Optimized for | `~` | Stem is optimized for jobs/messages plus durable workflow orchestration, not only queue semantics. See [Core Concepts](../core-concepts/index.md) and [Workflows](../workflows/index.md). | ## Update policy diff --git a/.site/docs/core-concepts/canvas.md b/.site/docs/core-concepts/canvas.md index 5caf079c..e4114661 100644 --- a/.site/docs/core-concepts/canvas.md +++ b/.site/docs/core-concepts/canvas.md @@ -9,7 +9,7 @@ This guide walks through Stem's task composition primitives—chains, groups, an chords—using in-memory brokers and backends. Each snippet references a runnable file under `packages/stem/example/docs_snippets/` so you can experiment locally with `dart run`. If you bootstrap with `StemApp`, use `app.canvas` to reuse the -same broker, backend, registry, and encoder registry. +same broker, backend, task handlers, and encoder registry. ## Chains diff --git a/.site/docs/core-concepts/cli-control.md b/.site/docs/core-concepts/cli-control.md index 155b0f51..e1fb99bb 100644 --- a/.site/docs/core-concepts/cli-control.md +++ b/.site/docs/core-concepts/cli-control.md @@ -109,17 +109,25 @@ stem worker stats --worker worker-a ``` -## Registry resolution +## Task registry resolution for CLI commands -Many CLI commands that reference task names need a registry. The default CLI -context does not load one automatically, so wire it via `runStemCli` with a +Many CLI commands that reference task names need task metadata. That is a CLI +concern, not the default application bootstrap path. The default CLI context +does not load a task registry automatically, so wire it via `runStemCli` with a `contextBuilder` that sets `CliContext.registry`. For multi-binary deployments, -ensure the CLI and workers share the same registry entrypoint so task names, -encoders, and routing rules stay consistent. +ensure the CLI and workers share the same task-definition entrypoint so task +names, encoders, and routing rules stay consistent. + +A common pattern is to build that CLI registry from the same shared task list +or generated `stemModule.tasks` your app uses, so task metadata stays consistent +without teaching registry-first bootstrap for normal services. If a command needs a registry and none is available, it will exit with an error or fall back to raw task metadata (depending on the subcommand). +For normal app bootstrap, prefer `tasks: [...]` or a generated `stemModule`. +See [Tasks](./tasks.md) and [stem_builder](./stem-builder.md). + ## List registered tasks ```bash diff --git a/.site/docs/core-concepts/index.md b/.site/docs/core-concepts/index.md index a6379c07..03385b5d 100644 --- a/.site/docs/core-concepts/index.md +++ b/.site/docs/core-concepts/index.md @@ -52,8 +52,9 @@ behavior before touching production. - **[Queue Events](./queue-events.md)** – Publish/listen to queue-scoped custom events. - **[Canvas Patterns](./canvas.md)** – Chains, groups, and chords for composing work. - **[Observability](./observability.md)** – Metrics, traces, logging, and lifecycle signals. -- **[Persistence & Stores](./persistence.md)** – Result backends, schedule/lock stores, and revocation storage. -- **[Workflows](./workflows.md)** – Durable Flow/Script runtimes with typed results, suspensions, and event watchers. +- **[Persistence & Stores](./persistence.md)** – Result backends, workflow stores, schedule/lock stores, and revocation storage. +- **[Workflows](../workflows/index.md)** – Durable workflow orchestration, suspensions, recovery, and annotated workflow generation. +- **[stem_builder](./stem-builder.md)** – Generate workflow/task helpers, manifests, workflow refs, and typed task helpers from annotations. - **[CLI & Control](./cli-control.md)** – Quickly inspect queues, workers, and health from the command line. Continue with the [Workers guide](../workers/index.md) for operational details. diff --git a/.site/docs/core-concepts/observability.md b/.site/docs/core-concepts/observability.md index a5b4d98d..308002c5 100644 --- a/.site/docs/core-concepts/observability.md +++ b/.site/docs/core-concepts/observability.md @@ -60,15 +60,16 @@ control-plane commands. ## Workflow Introspection -Workflow runtimes can emit step-level events (started/completed/failed/retrying) -through a `WorkflowIntrospectionSink`. Use it to publish step telemetry or -bridge to your own tracing/logging systems. +Workflow runtimes can emit execution events (started/completed/failed/retrying) +for both flow steps and script checkpoints through a +`WorkflowIntrospectionSink`. Use it to publish workflow telemetry or bridge to +your own tracing/logging systems. ```dart class LoggingWorkflowIntrospectionSink implements WorkflowIntrospectionSink { @override Future recordStepEvent(WorkflowStepEvent event) async { - stemLogger.info('workflow.step', { + stemLogger.info('workflow.execution', { 'run': event.runId, 'workflow': event.workflow, 'step': event.stepId, @@ -111,5 +112,6 @@ A minimal dashboard typically charts: - Scheduler drift (`StemSignals.onScheduleEntryDispatched` drift metrics). Exporters can be mixed—enable console during development and OTLP in staging/ -production. For local exploration, run the `examples/otel_metrics` stack to see -metrics in a collector + Jaeger pipeline. +production. For local exploration, run the +`packages/stem/example/otel_metrics` stack to see metrics in a collector + +Jaeger pipeline. diff --git a/.site/docs/core-concepts/persistence.md b/.site/docs/core-concepts/persistence.md index ce34cdc9..905fdd9e 100644 --- a/.site/docs/core-concepts/persistence.md +++ b/.site/docs/core-concepts/persistence.md @@ -5,9 +5,9 @@ sidebar_position: 7 slug: /core-concepts/persistence --- -Use persistence when you need durable task state, shared schedules, or -revocation storage. Stem ships with Redis, Postgres, and SQLite adapters plus -in-memory variants for local development. +Use persistence when you need durable task state, workflow state, shared +schedules, or revocation storage. Stem ships with Redis, Postgres, and SQLite +adapters plus in-memory variants for local development. import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; @@ -64,6 +64,19 @@ Handlers needing bespoke treatment can override `TaskMetadata.argsEncoder` and `TaskMetadata.resultEncoder`; the worker ensures only that task uses the custom encoder while the rest fall back to the global defaults. +## Workflow store + +Workflow stores persist: + +- workflow runs and status +- flow step results and script checkpoint results +- suspension/watcher records +- due-run scheduling metadata + +That store is what allows workflow resumes, run inspection, and recovery across +worker restarts. See the top-level [Workflows](../workflows/index.md) section +for the durable orchestration model and runtime behavior. + ## Schedule & lock stores ```dart title="lib/beat_bootstrap.dart" file=/../packages/stem/example/docs_snippets/lib/persistence.dart#persistence-beat-stores diff --git a/.site/docs/core-concepts/rate-limiting.md b/.site/docs/core-concepts/rate-limiting.md index 60e0598b..e3b29fea 100644 --- a/.site/docs/core-concepts/rate-limiting.md +++ b/.site/docs/core-concepts/rate-limiting.md @@ -79,7 +79,7 @@ import TabItem from '@theme/TabItem'; ``` - + ```dart title="lib/rate_limiting.dart" file=/../packages/stem/example/docs_snippets/lib/rate_limiting.dart#rate-limit-demo-registry @@ -157,7 +157,7 @@ Group rate limits share a limiter bucket across related tasks. ## Redis-backed limiter example -The `example/rate_limit_delay` demo ships a Redis fixed-window limiter. It: +The `packages/stem/example/rate_limit_delay` demo ships a Redis fixed-window limiter. It: - shares tokens across multiple workers, - logs when a token is granted or denied, diff --git a/.site/docs/core-concepts/routing.md b/.site/docs/core-concepts/routing.md index d0629897..c1e46ea5 100644 --- a/.site/docs/core-concepts/routing.md +++ b/.site/docs/core-concepts/routing.md @@ -3,6 +3,7 @@ title: Routing Configuration sidebar_label: Routing sidebar_position: 3 slug: /core-concepts/routing +description: Load routing config, build worker subscriptions, and resolve queue or broadcast targets in Stem. --- Stem workers and publishers resolve queue and broadcast targets from the routing diff --git a/.site/docs/core-concepts/signing.md b/.site/docs/core-concepts/signing.md index 07a09d92..219ce109 100644 --- a/.site/docs/core-concepts/signing.md +++ b/.site/docs/core-concepts/signing.md @@ -46,7 +46,7 @@ export STEM_SIGNING_ACTIVE_KEY=v1 2) Wire the signer into producers, workers, and schedulers. -These snippets come from the `example/microservice` project so you can see the +These snippets come from the `packages/stem/example/microservice` project so you can see the full context. @@ -144,7 +144,7 @@ export STEM_SIGNING_ACTIVE_KEY=primary 4) Remove the old key after the backlog drains. Example: producer logging the active key and enqueuing during rotation (from -`example/signing_key_rotation`): +`packages/stem/example/signing_key_rotation`): diff --git a/.site/docs/core-concepts/stem-builder.md b/.site/docs/core-concepts/stem-builder.md new file mode 100644 index 00000000..069fdb23 --- /dev/null +++ b/.site/docs/core-concepts/stem-builder.md @@ -0,0 +1,136 @@ +--- +title: stem_builder +sidebar_label: stem_builder +sidebar_position: 15 +slug: /core-concepts/stem-builder +--- + +`stem_builder` generates workflow/task definitions, manifests, helper output, +and typed workflow refs from annotations, so you can avoid stringly-typed +wiring. + +This page focuses on the generator itself. For the workflow authoring model and +durable runtime behavior, start with the top-level +[Workflows](../workflows/index.md) section, especially +[Annotated Workflows](../workflows/annotated-workflows.md). + +For script workflows, generated checkpoints are introspection metadata. The +actual execution plan still comes from `run(...)`. + +## Install + +```bash +dart pub add stem +dart pub add --dev build_runner stem_builder +``` + +## Define Annotated Workflows and Tasks + +```dart +import 'package:stem/stem.dart'; + +part 'workflow_defs.stem.g.dart'; + +@WorkflowDefn( + name: 'commerce.user_signup', + kind: WorkflowKind.script, + starterName: 'UserSignup', +) +class UserSignupWorkflow { + Future> run(String email) async { + final user = await createUser(email); + await sendWelcomeEmail(email); + return {'userId': user['id'], 'status': 'done'}; + } + + @WorkflowStep(name: 'create-user') + Future> createUser(String email) async { + return {'id': 'usr-$email'}; + } + + @WorkflowStep(name: 'send-welcome-email') + Future sendWelcomeEmail(String email) async {} +} + +@TaskDefn(name: 'commerce.audit.log', runInIsolate: false) +Future logAudit(TaskInvocationContext ctx, String event, String id) async { + ctx.progress(1.0, data: {'event': event, 'id': id}); +} +``` + +## Generate + +```bash +dart run build_runner build --delete-conflicting-outputs +``` + +Generated output (`workflow_defs.stem.g.dart`) includes: + +- `stemModule` +- typed workflow refs like `StemWorkflowDefinitions.userSignup` +- typed task definitions, enqueue helpers, and typed result wait helpers + +## Wire Into StemWorkflowApp + +Use the generated definitions/helpers directly with `StemWorkflowApp`: + +```dart +final workflowApp = await StemWorkflowApp.fromUrl( + 'memory://', + module: stemModule, +); + +await workflowApp.start(); +final result = await StemWorkflowDefinitions.userSignup + .call((email: 'user@example.com')) + .startAndWaitWithApp(workflowApp); +``` + +If you already manage a `StemApp` for a larger service, reuse it instead of +bootstrapping a second app: + +```dart +final stemApp = await StemApp.fromUrl( + 'redis://localhost:6379', + adapters: const [StemRedisAdapter()], + tasks: stemModule.tasks, +); + +final workflowApp = await StemWorkflowApp.create( + stemApp: stemApp, + module: stemModule, +); +``` + +If you already centralize broker/backend wiring in a `StemClient`, prefer the +shared-client path: + +```dart +final client = await StemClient.fromUrl( + 'redis://localhost:6379', + adapters: const [StemRedisAdapter()], +); + +final workflowApp = await client.createWorkflowApp(module: stemModule); +``` + +## Parameter and Signature Rules + +- Parameters after context must be required positional serializable values. +- Parameters after context must be required positional values that are either + serializable or codec-backed DTOs. +- Script workflow `run(...)` can be plain (no annotation required). +- `@WorkflowRun` is still supported for explicit run entrypoints. +- Step methods use `@WorkflowStep`. +- Plain `run(...)` is best when called step methods only need serializable + parameters. +- Use `@WorkflowRun()` plus `WorkflowScriptContext` when you need to enter a + context-aware script checkpoint that consumes `WorkflowScriptStepContext`. +- DTO classes are supported when they provide: + - `Map toJson()` + - `factory Type.fromJson(Map json)` or an equivalent named + `fromJson` constructor +- Typed task results can use the same DTO convention. +- Workflow inputs, checkpoint values, and final workflow results can use the + same DTO convention. The generated `PayloadCodec` persists the JSON form + while workflow code continues to work with typed objects. diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index b63e42de..eafaaf01 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -8,11 +8,12 @@ slug: /core-concepts/tasks import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; -Tasks are the units of work executed by Stem workers. Each task is represented by -a handler registered in a `TaskRegistry`. Handlers expose metadata through -`TaskOptions`, which control routing, retry behavior, timeouts, and isolation. +Tasks are the units of work executed by Stem workers. In the common path, you +provide handlers directly via `tasks: [...]` on `Stem`, `Worker`, `StemApp`, or +`StemClient`. Handlers expose metadata through `TaskOptions`, which control +routing, retry behavior, timeouts, and isolation. -## Registering Handlers +## Providing Handlers @@ -59,7 +60,7 @@ retry cadence by: - Tuning the broker connection (e.g. Redis `blockTime`, `claimInterval`, `defaultVisibilityTimeout`) so delayed messages are drained quickly. -See the `examples/retry_task` Compose demo for a runnable setup that prints +See the `packages/stem/example/retry_task` Compose demo for a runnable setup that prints every retry signal and shows how the strategy interacts with broker timings. ```dart title="lib/retry_backoff.dart" file=/../packages/stem/example/docs_snippets/lib/retry_backoff.dart#retry-backoff-strategy @@ -82,9 +83,9 @@ every retry signal and shows how the strategy interacts with broker timings. Use the context to build idempotent handlers. Re-enqueue work, cancel jobs, or store audit details in `context.meta`. -See the `example/task_context_mixed` demo for a runnable sample that exercises +See the `packages/stem/example/task_context_mixed` demo for a runnable sample that exercises inline + isolate enqueue, TaskRetryPolicy overrides, and enqueue options. -The `example/task_usage_patterns.dart` sample shows in-memory TaskContext and +The `packages/stem/example/task_usage_patterns.dart` sample shows in-memory TaskContext and TaskInvocationContext patterns without external dependencies. ### Enqueue from a running task @@ -177,4 +178,5 @@ metadata overrides: Because encoders are centrally registered inside the `TaskPayloadEncoderRegistry`, every producer/worker instance that shares the -registry can resolve encoder ids reliably—even across processes or languages. +same encoder configuration can resolve encoder ids reliably, even across +processes or languages. diff --git a/.site/docs/core-concepts/workflows.md b/.site/docs/core-concepts/workflows.md index dbfa267f..995c2cc0 100644 --- a/.site/docs/core-concepts/workflows.md +++ b/.site/docs/core-concepts/workflows.md @@ -7,11 +7,23 @@ slug: /core-concepts/workflows Stem Workflows let you orchestrate multi-step business processes with durable state, typed results, automatic retries, and event-driven resumes. The -`StemWorkflowApp` helper wires together a `Stem` instance, workflow store, -event bus, and runtime so you can start runs, monitor progress, and interact -with suspended steps from one place. +`StemWorkflowApp` helper wires together a `StemApp`, workflow store, event +bus, and runtime so you can start runs, monitor progress, and interact with +suspended workflow state from one place. -## Runtime Overview +This page is now the short orientation page. The full workflow manual lives in +the top-level [Workflows](../workflows/index.md) section. + +## Start there for the full workflow guide + +- [Getting Started](../workflows/getting-started.md) +- [Flows and Scripts](../workflows/flows-and-scripts.md) +- [Annotated Workflows](../workflows/annotated-workflows.md) +- [Context and Serialization](../workflows/context-and-serialization.md) +- [How It Works](../workflows/how-it-works.md) +- [Observability](../workflows/observability.md) + +## Runtime overview ```dart title="bin/workflows.dart" file=/../packages/stem/example/docs_snippets/lib/workflows.dart#workflows-app-create @@ -25,154 +37,23 @@ Start the runtime once the app is constructed: `StemWorkflowApp` exposes: -- `runtime` – registers `Flow`/`WorkflowScript` definitions and dequeues runs. +- `runtime` – registers workflow definitions and coordinates run execution and + resume logic. - `store` – persists checkpoints, suspension metadata, and results. -- `eventBus` – emits topics that resume waiting steps. +- `eventBus` – routes topics that resume waiting runs. - `app` – the underlying `StemApp` (broker + result backend + worker). -## StemClient Entrypoint - -`StemClient` is the shared entrypoint when you want a single object to own the -broker, result backend, and workflow helpers. It creates workflow apps and -workers with consistent configuration so you don't pass broker/backend handles -around. - -```dart title="bin/workflows_client.dart" file=/../packages/stem/example/docs_snippets/lib/workflows.dart#workflows-client - -``` - -## Declaring Typed Flows - -Flows use the declarative DSL (`FlowBuilder`) to capture ordered steps. Specify -`Flow` to document the completion type; generic metadata is preserved all the -way through `WorkflowResult`. - -```dart title="lib/workflows/approvals_flow.dart" file=/../packages/stem/example/docs_snippets/lib/workflows.dart#workflows-flow - -``` - -Steps re-run from the top after every suspension, so handlers must be -idempotent and rely on `FlowContext` helpers: `iteration`, `takeResumeData`, -`sleep`, `awaitEvent`, `idempotencyKey`, and persisted step outputs. - -## Workflow Scripts - -`WorkflowScript` offers a higher-level facade that feels like a regular async -function. You still get typed results and step-level durability, but the DSL -handles `ctx.step` registration automatically. - -```dart title="lib/workflows/retry_script.dart" file=/../packages/stem/example/docs_snippets/lib/workflows.dart#workflows-script - -``` - -Scripts can enable `autoVersion: true` inside `script.step` calls to track loop -iterations using the `stepName#iteration` naming convention. - -## Annotated Workflows (stem_builder) - -If you prefer decorators over the DSL, annotate workflow classes and tasks with -`@WorkflowDefn`, `@workflow.run`, `@workflow.step`, and `@TaskDefn`, then generate -the registry with `stem_builder`. - -```dart title="lib/workflows/annotated.dart" file=/../packages/stem/example/docs_snippets/lib/workflows.dart#workflows-annotated - -``` - -Build the registry (example): - -```bash -dart pub add --dev build_runner stem_builder -dart run build_runner build -``` - -## Starting & Awaiting Workflows - -```dart title="bin/run_workflow.dart" file=/../packages/stem/example/docs_snippets/lib/workflows.dart#workflows-run - -``` - -`waitForCompletion` returns a `WorkflowResult` that includes the decoded -value, original `RunState`, and a `timedOut` flag so callers can decide whether -to keep polling or surface status upstream. - -### Cancellation policies - -`WorkflowCancellationPolicy` guards long-running runs. Use it to auto-cancel -workflows that exceed a wall-clock budget or remain suspended longer than -allowed. - -## Suspension, Events, and Groups of Runs - -- `sleep(duration)` stores a wake-up timestamp; the runtime polls `dueRuns` and - resumes those runs by re-enqueuing the internal workflow task. -- `awaitEvent(topic, deadline: ...)` registers durable watchers so external - services can `emit(topic, payload)`. The payload becomes `resumeData` for the - awaiting step. -- `runsWaitingOn(topic)` exposes all runs suspended on a channel—useful for CLI - tooling or dashboards. After a topic resumes the runtime calls - `markResumed(runId, data: suspensionData)` so flows can inspect the payload. - -Because watchers and due runs are persisted in the `WorkflowStore`, you can -operate on *groups* of workflows (pause, resume, or inspect every run waiting on -a topic) even if no worker is currently online. - -## Run Leases & Multi-Worker Recovery - -Workflow runs are lease-based: a worker claims a run for a fixed duration, -renews the lease while executing, and releases it on completion. This prevents -two workers from executing the same run concurrently while still allowing -takeover after crashes. - -Operational guidance: - -- Keep `runLeaseDuration` **>=** the broker visibility timeout so redelivered - workflow tasks retry instead of being dropped before the lease expires. -- Ensure workers renew leases (`leaseExtension`) before either the workflow - lease or broker visibility timeout expires. -- Keep system clocks in sync (NTP) because lease expiry is time-based across - workers and the shared store. - -## Deterministic Tests with WorkflowClock - -Inject a `WorkflowClock` when you need deterministic timestamps (e.g. for lease -expiry or due run scheduling). The `FakeWorkflowClock` lets tests advance time -without waiting on real timers. - -```dart -final clock = FakeWorkflowClock(DateTime.utc(2024, 1, 1)); -final store = InMemoryWorkflowStore(clock: clock); -final runtime = WorkflowRuntime( - stem: stem, - store: store, - eventBus: InMemoryEventBus(store: store), - clock: clock, -); -``` - -## Payload Encoders in Workflow Apps - -Workflows execute on top of a `Stem` worker, so they inherit the same -`TaskPayloadEncoder` facilities as regular tasks. `StemWorkflowApp.create` -accepts either a shared `TaskPayloadEncoderRegistry` or explicit defaults: - -```dart title="lib/workflows/bootstrap.dart" file=/../packages/stem/example/docs_snippets/lib/workflows.dart#workflows-encoders - -``` +## What makes workflows different from tasks -Every workflow run task stores the result encoder id in `RunState.resultMeta`, -and the internal tasks dispatched by workflows reuse the same registry—so -typed steps can safely emit encrypted/binary payloads while workers decode them -exactly once. +- workflow runs persist durable state in a workflow store +- steps or checkpoints can suspend on time or external events +- resumption happens through persisted watchers and due-run scheduling +- admin tooling can inspect runs even after worker restarts -Need per-workflow overrides? Register custom encoders on individual task -handlers (via `TaskMetadata`) or attach a specialized encoder to a `Flow`/script -step that persists sensitive data in the workflow store. +## Flow versus script -## Tooling Tips +- flows: declared steps are the execution plan +- scripts: `run(...)` is the execution plan +- script checkpoints are durable replay boundaries plus manifest metadata -- Use `workflowApp.store.listRuns(...)` to filter by workflow/status when - building admin dashboards. -- `workflowApp.runtime.emit(topic, payload)` is the canonical way to resume - batches of runs waiting on external events. -- CLI integrations (see `stem workflow ...`) rely on the same store APIs, so - keeping the store tidy (expired runs, watchers) ensures responsive tooling. +The details now live in [Flows and Scripts](../workflows/flows-and-scripts.md). diff --git a/.site/docs/getting-started/developer-environment.md b/.site/docs/getting-started/developer-environment.md index 8dc96928..3e6a93d6 100644 --- a/.site/docs/getting-started/developer-environment.md +++ b/.site/docs/getting-started/developer-environment.md @@ -138,6 +138,6 @@ start emitting events. - Keep the infrastructure running and head to [Observe & Operate](./observability-and-ops.md) to enable telemetry, inspect heartbeats, replay DLQs, and issue remote control commands. -- Browse the runnable examples under `examples/` for Redis/Postgres, - mixed-cluster, autoscaling, scheduler observability, and signing-key rotation - drills you can adapt to your environment. +- Browse the runnable examples under `packages/stem/example/` for + Redis/Postgres, mixed-cluster, autoscaling, scheduler observability, and + signing-key rotation drills you can adapt to your environment. diff --git a/.site/docs/getting-started/first-steps.md b/.site/docs/getting-started/first-steps.md index 0d5b33a4..fbcfae25 100644 --- a/.site/docs/getting-started/first-steps.md +++ b/.site/docs/getting-started/first-steps.md @@ -61,36 +61,37 @@ Decision shortcuts: For more detail, see [Broker Overview](../brokers/overview.md) and [Persistence](../core-concepts/persistence.md). -## Install +## When you move past the in-memory demo - Install Stem and the CLI as shown in [Quick Start](./quick-start.md). - Ensure `stem --version` runs in your shell. -## App setup +## Reuse the same task definitions -- Register tasks and options via `StemApp` or a shared registry (see +- Register tasks and options via `StemApp` or a shared task list (see [Tasks & Retries](../core-concepts/tasks.md)). -- Wire producers with the same task list/registry (see +- Wire producers with the same task list (see [Producer API](../core-concepts/producer.md)). -## Run a worker +## Split producers and workers into separate processes -- Start a worker against your broker and queues (see +- Once you leave the in-memory app, start workers against your broker and + queues (see [Connect to Infrastructure](./developer-environment.md)). - Use [Worker Control CLI](../workers/worker-control.md) to confirm it is responding. -## Call a task +## Enqueue from apps or the CLI - Enqueue from your app or the CLI (see [Producer API](../core-concepts/producer.md)). -## Keeping results +## Add a durable result backend - Configure a result backend for stored task results and groups (see [Persistence](../core-concepts/persistence.md)). -## Configuration +## Add environment-based configuration - Use `STEM_*` environment variables for brokers, routing, scheduling, and signing (see [CLI & Control](../core-concepts/cli-control.md)). diff --git a/.site/docs/getting-started/index.md b/.site/docs/getting-started/index.md index 7e964ed9..eea5f407 100644 --- a/.site/docs/getting-started/index.md +++ b/.site/docs/getting-started/index.md @@ -13,7 +13,7 @@ want to explore further. - **[Introduction](./intro.md)** – Prerequisites, the feature tour, and how the onboarding journey is structured. - **[Quick Start](./quick-start.md)** – Create your first Stem tasks, enqueue with delays/priorities, and inspect results in memory. -- **[First Steps](./first-steps.md)** – Run a worker against Redis, enqueue from a producer, and read results. +- **[First Steps](./first-steps.md)** – Bootstrap an in-memory `StemApp`, enqueue from a producer, and read results. - **[Connect to Infrastructure](./developer-environment.md)** – Run Redis/Postgres locally, configure brokers/backends, experiment with routing and canvas patterns. - **[Observe & Operate](./observability-and-ops.md)** – Enable OpenTelemetry export, inspect workers/queues/DLQ via CLI, and wire lifecycle signals. - **[Prepare for Production](./production-checklist.md)** – Apply signing/TLS, deploy with systemd or CLI multi-process tooling, and run quality gates before launch. @@ -21,7 +21,8 @@ want to explore further. - **[Stem vs BullMQ](../comparisons/stem-vs-bullmq.md)** – Canonical feature mapping with `✓/~ /✗` parity semantics. Once you complete the journey, continue with the in-depth material under -[Core Concepts](../core-concepts/index.md) and [Workers](../workers/index.md). +[Workflows](../workflows/index.md), [Core Concepts](../core-concepts/index.md), +and [Workers](../workers/index.md). ## Preview: a full Stem pipeline in one file diff --git a/.site/docs/getting-started/intro.md b/.site/docs/getting-started/intro.md index b1ee9556..3d6693a9 100644 --- a/.site/docs/getting-started/intro.md +++ b/.site/docs/getting-started/intro.md @@ -80,7 +80,7 @@ keeps everything in a single file so you can see the moving parts together. ## Prerequisites -- Dart **3.3+** installed (`dart --version`). +- Dart **3.9.2+** installed (`dart --version`). - Access to the Dart pub cache (`dart pub ...`). - Optional but recommended: Docker Desktop or another container runtime for local Redis/Postgres instances. @@ -91,8 +91,8 @@ keeps everything in a single file so you can see the moving parts together. 1. **[Quick Start](./quick-start.md)** – Build and run your first Stem worker entirely in memory while you learn the task pipeline primitives. -2. **[First Steps](./first-steps.md)** – Use Redis to run producers and workers - in separate processes, then fetch results. +2. **[First Steps](./first-steps.md)** – Bootstrap an in-memory `StemApp`, + enqueue work from a producer, and fetch persisted results. 3. **[Connect to Infrastructure](./developer-environment.md)** – Point Stem at Redis/Postgres, run workers/Beat across processes, and try routing/canvas patterns. diff --git a/.site/docs/getting-started/next-steps.md b/.site/docs/getting-started/next-steps.md index 43f6ca38..f0887687 100644 --- a/.site/docs/getting-started/next-steps.md +++ b/.site/docs/getting-started/next-steps.md @@ -36,7 +36,7 @@ Use this page as a jump table once you’ve finished the first walkthroughs. ## Canvas/Workflows - [Canvas Patterns](../core-concepts/canvas.md) -- [Workflows](../core-concepts/workflows.md) +- [Workflows](../workflows/index.md) ```dart title="lib/canvas_chain.dart" file=/../packages/stem/example/docs_snippets/lib/canvas_chain.dart#canvas-chain diff --git a/.site/docs/getting-started/observability-and-ops.md b/.site/docs/getting-started/observability-and-ops.md index ac470f0d..a4af4ed1 100644 --- a/.site/docs/getting-started/observability-and-ops.md +++ b/.site/docs/getting-started/observability-and-ops.md @@ -12,11 +12,12 @@ channel—all the pieces you need to operate Stem confidently. ## 1. Enable OpenTelemetry Export Stem emits metrics, traces, and structured logs out of the box. Point it at an -OTLP endpoint (the repo ships a ready-made stack under `examples/otel_metrics/`): +OTLP endpoint (the repo ships a ready-made stack under +`packages/stem/example/otel_metrics/`): ```bash # Start the example collector, Prometheus, and Grafana stack. -docker compose -f examples/otel_metrics/docker-compose.yml up +docker compose -f packages/stem/example/otel_metrics/docker-compose.yml up # Export OTLP details for producers and workers. export STEM_OTLP_ENDPOINT=http://localhost:4318 @@ -163,5 +164,7 @@ checklists in [Prepare for Production](./production-checklist.md). If you want more hands-on drills: -- Run `example/ops_health_suite` to practice `stem health` and `stem observe` flows. -- Run `example/scheduler_observability` to watch drift metrics and schedule signals. +- Run `packages/stem/example/ops_health_suite` to practice `stem health` and + `stem observe` flows. +- Run `packages/stem/example/scheduler_observability` to watch drift metrics + and schedule signals. diff --git a/.site/docs/getting-started/production-checklist.md b/.site/docs/getting-started/production-checklist.md index 684acbac..405e2d74 100644 --- a/.site/docs/getting-started/production-checklist.md +++ b/.site/docs/getting-started/production-checklist.md @@ -39,7 +39,7 @@ In code, wire the signer into both producers and workers: ``` - + ```dart title="lib/production_checklist.dart" file=/../packages/stem/example/docs_snippets/lib/production_checklist.dart#production-signing-registry @@ -87,7 +87,7 @@ Use the repo’s helper script to generate local certificates or plug in the one issued by your platform: ```bash -scripts/security/generate_tls_assets.sh --out tmp/tls +packages/stem/scripts/security/generate_tls_assets.sh --out tmp/tls export STEM_TLS_CA_CERT=$PWD/tmp/tls/ca.pem export STEM_TLS_CLIENT_CERT=$PWD/tmp/tls/client.pem @@ -102,12 +102,12 @@ Update Redis/Postgres URLs to include TLS if required (for example, ## 3. Supervise Processes with Managed Services -Stem ships ready-to-use templates under `templates/systemd/` and -`templates/sysv/`. Drop in environment files with your Stem variables and -enable the services: +Stem ships ready-to-use templates under `packages/stem/templates/systemd/` and +`packages/stem/templates/sysv/`. Drop in environment files with your Stem +variables and enable the services: ```bash -sudo cp templates/systemd/stem-worker@.service /etc/systemd/system/ +sudo cp packages/stem/templates/systemd/stem-worker@.service /etc/systemd/system/ sudo systemctl enable stem-worker@default.service sudo systemctl start stem-worker@default.service @@ -139,7 +139,7 @@ stem worker diagnose --node web-1 \ Before every deployment run through these guardrails: -- **Quality gates** – run `example/quality_gates` (`just quality`) to execute +- **Quality gates** – run `packages/stem/example/quality_gates` (`just quality`) to execute format, analyze, unit/chaos/perf tests, and coverage targets. - **Observability** – confirm Grafana dashboards (task success rate, latency p95, queue depth) and OpenTelemetry exporters are healthy. @@ -150,8 +150,9 @@ Before every deployment run through these guardrails: `stem worker stats`) against staging to verify access. Document the results in your team’s runbook (see -`docs/process/observability-runbook.md` and `docs/process/scheduler-parity.md`) -so the production checklist stays auditable. +`packages/stem/doc/process/observability-runbook.md` and +`packages/stem/doc/process/scheduler-parity.md`) so the production checklist +stays auditable. ## 5. Where to Go Next diff --git a/.site/docs/getting-started/quick-start.md b/.site/docs/getting-started/quick-start.md index ef6b2387..aa988d05 100644 --- a/.site/docs/getting-started/quick-start.md +++ b/.site/docs/getting-started/quick-start.md @@ -20,7 +20,7 @@ cd stem_quickstart # Add Stem as a dependency and activate the CLI. dart pub add stem -dart pub global activate stem +dart pub global activate stem_cli ``` Add the Dart pub cache to your `PATH` so the `stem` CLI is reachable: @@ -62,7 +62,7 @@ Each task declares its name and retry/timeout options. Use `StemApp` to wire tasks, the in-memory broker/backend, and the worker: - + ```dart file=/../packages/stem/example/docs_snippets/lib/quick_start.dart#quickstart-bootstrap diff --git a/.site/docs/getting-started/troubleshooting.md b/.site/docs/getting-started/troubleshooting.md index 8eeadd45..263c6a05 100644 --- a/.site/docs/getting-started/troubleshooting.md +++ b/.site/docs/getting-started/troubleshooting.md @@ -63,7 +63,8 @@ Checklist: - Validate the routing file path and format (YAML/JSON). - Confirm `STEM_ROUTING_CONFIG` points at the file you expect. -- Confirm the registry matches the task names referenced in the file. +- Confirm the routing config or routing registry matches the task names + referenced in the file. - If you use queue priorities, ensure the broker supports them. @@ -74,7 +75,7 @@ Checklist: ``` - + ```dart title="lib/routing.dart" file=/../packages/stem/example/docs_snippets/lib/routing.dart#routing-inline diff --git a/.site/docs/scheduler/index.md b/.site/docs/scheduler/index.md index d38e59f9..2b49c6bf 100644 --- a/.site/docs/scheduler/index.md +++ b/.site/docs/scheduler/index.md @@ -76,7 +76,8 @@ Common scheduler CLI commands: Beat itself runs as a Dart process; see the Beat guide for entrypoints. - **[Beat Scheduler Guide](./beat-guide.md)** – Configure Beat, load schedules, and run it with in-memory, Redis, or Postgres stores. -- **Example:** `example/scheduler_observability` shows drift metrics, schedule signals, and CLI inspection. +- **Example:** `packages/stem/example/scheduler_observability` shows drift metrics, schedule signals, and CLI inspection. -Looking for locking and storage details? See the Postgres and Redis sections in -[Broker Overview](../brokers/overview.md). +Looking for locking and storage details? Start with the +[Beat Scheduler Guide](./beat-guide.md) and +[Persistence & Stores](../core-concepts/persistence.md). diff --git a/.site/docs/workers/daemonization.md b/.site/docs/workers/daemonization.md index a6dd7df0..d8da76cc 100644 --- a/.site/docs/workers/daemonization.md +++ b/.site/docs/workers/daemonization.md @@ -10,39 +10,40 @@ import TabItem from '@theme/TabItem'; Stem now ships opinionated service templates and CLI helpers so you can manage workers like you would with Celery’s `celery multi`. This guide mirrors -`docs/process/daemonization.md` and walks through real examples. +`packages/stem/doc/process/daemonization.md` and walks through real examples. ## Prerequisites - Create an unprivileged `stem` user/group. - Install the Stem CLI and your worker launcher binary/script (for example, `/usr/local/bin/stem-worker`). -- Copy templates from the repository (`templates/`) into your packaging step: +- Copy templates from `packages/stem/templates/` into your packaging step: systemd units, SysV scripts, and `/etc/default/stem`. ## Worker entrypoint The daemonization templates expect a worker launcher that runs until signaled. -This stub worker lives in `examples/daemonized_worker/bin/worker.dart`: +This stub worker lives in +`packages/stem/example/daemonized_worker/bin/worker.dart`: -```dart title="examples/daemonized_worker/bin/worker.dart" file=/../packages/stem/example/daemonized_worker/bin/worker.dart#daemonized-worker-entrypoint +```dart title="packages/stem/example/daemonized_worker/bin/worker.dart" file=/../packages/stem/example/daemonized_worker/bin/worker.dart#daemonized-worker-entrypoint ``` -```dart title="examples/daemonized_worker/bin/worker.dart" file=/../packages/stem/example/daemonized_worker/bin/worker.dart#daemonized-worker-signal-handlers +```dart title="packages/stem/example/daemonized_worker/bin/worker.dart" file=/../packages/stem/example/daemonized_worker/bin/worker.dart#daemonized-worker-signal-handlers ``` -```dart title="examples/daemonized_worker/bin/worker.dart" file=/../packages/stem/example/daemonized_worker/bin/worker.dart#daemonized-worker-loop +```dart title="packages/stem/example/daemonized_worker/bin/worker.dart" file=/../packages/stem/example/daemonized_worker/bin/worker.dart#daemonized-worker-loop ``` @@ -52,9 +53,9 @@ This stub worker lives in `examples/daemonized_worker/bin/worker.dart`: ## Systemd Example ```bash -sudo install -D templates/systemd/stem-worker@.service \ +sudo install -D packages/stem/templates/systemd/stem-worker@.service \ /etc/systemd/system/stem-worker@.service -sudo install -D templates/etc/default/stem /etc/stem/stem.env +sudo install -D packages/stem/templates/etc/default/stem /etc/stem/stem.env sudo install -d -o stem -g stem /var/lib/stem /var/log/stem /var/run/stem ``` @@ -80,8 +81,8 @@ logrotate snippet (for example, `/etc/logrotate.d/stem`) when journald is not us ## SysV Example ```bash -sudo install -D templates/sysv/init.d/stem-worker /etc/init.d/stem-worker -sudo install -D templates/etc/default/stem /etc/default/stem +sudo install -D packages/stem/templates/sysv/init.d/stem-worker /etc/init.d/stem-worker +sudo install -D packages/stem/templates/etc/default/stem /etc/default/stem sudo chmod 755 /etc/init.d/stem-worker sudo update-rc.d stem-worker defaults ``` @@ -108,12 +109,12 @@ Set `STEM_SCHEDULER_COMMAND` in the environment file and enable ## Docker Example -`examples/daemonized_worker/` contains a Dockerfile and entrypoint that run +`packages/stem/example/daemonized_worker/` contains a Dockerfile and entrypoint that run `stem worker multi` directly. Build and run from the repo root: ``` -docker build -f examples/daemonized_worker/Dockerfile -t stem-multi . -docker run --rm -e STEM_WORKER_COMMAND="dart run examples/daemonized_worker/bin/worker.dart" stem-multi +docker build -f packages/stem/example/daemonized_worker/Dockerfile -t stem-multi . +docker run --rm -e STEM_WORKER_COMMAND="dart run packages/stem/example/daemonized_worker/bin/worker.dart" stem-multi ``` Override `STEM_WORKER_*` environment variables to control nodes, PID/log diff --git a/.site/docs/workers/index.md b/.site/docs/workers/index.md index 288cf65b..cda70acc 100644 --- a/.site/docs/workers/index.md +++ b/.site/docs/workers/index.md @@ -53,7 +53,8 @@ Workers can subscribe to: fan-out, or dedicated lanes per workload). Queue subscriptions determine which stream shards the worker polls, so keep -queue names stable and document them alongside task registries. +queue names stable and document them alongside the shared task definitions your +service uses. ```dart title="routing.dart" file=/../packages/stem/example/docs_snippets/lib/routing.dart#routing-bootstrap @@ -94,5 +95,5 @@ so scaling does not starve queues. - **[Daemonization Guide](./daemonization.md)** – Run workers under systemd, launchd, or custom supervisors. -Looking for retry tuning or task registries? See the +Looking for retry tuning or task-definition guidance? See the [Core Concepts](../core-concepts/index.md). diff --git a/.site/docs/workers/programmatic-integration.md b/.site/docs/workers/programmatic-integration.md index 06cf9959..c02036fd 100644 --- a/.site/docs/workers/programmatic-integration.md +++ b/.site/docs/workers/programmatic-integration.md @@ -92,11 +92,17 @@ startup: Swap the in-memory adapters for Redis/Postgres when you deploy, keeping the API surface the same. +If your service wants a higher-level bootstrap that owns broker/backend/tasks +in one place, use `StemApp` or `StemClient` instead of wiring raw `Stem` and +`Worker` instances by hand. This page stays focused on the lower-level +embedding path. + ## Checklist - Reuse producer and worker objects—avoid per-request construction. -- Inject the `TaskRegistry` from a central module so producers and workers stay - in sync. +- Keep a shared `tasks` list/module so producers and workers stay in sync. +- Reach for a custom `TaskRegistry` only when you need advanced dynamic + registration behavior. - Capture task IDs returned by `Stem.enqueue` when you need to poll results or correlate with your own auditing. - Emit lifecycle signals (`StemSignals`) and wire logs/metrics early so diff --git a/.site/docs/workers/worker-control.md b/.site/docs/workers/worker-control.md index 37e88621..46169b8e 100644 --- a/.site/docs/workers/worker-control.md +++ b/.site/docs/workers/worker-control.md @@ -57,7 +57,7 @@ stem worker resume --worker worker-a --queue default ``` For a runnable lab that exercises ping/stats/revoke/shutdown against real -workers, see `example/worker_control_lab` in the repository. +workers, see `packages/stem/example/worker_control_lab` in the repository. ## Autoscaling Concurrency @@ -74,7 +74,7 @@ when to scale. Metrics expose the current setting via `stem.worker.concurrency`, and `stem worker stats --json` includes the live `activeConcurrency` value so dashboards can observe adjustments. -See `example/autoscaling_demo` for a queue-backlog scenario that triggers +See `packages/stem/example/autoscaling_demo` for a queue-backlog scenario that triggers scale-up and scale-down events. ## CLI Multi-Instance Management @@ -257,5 +257,5 @@ export STEM_REVOKE_STORE_URL=sqlite:///var/lib/stem/revokes.sqlite ## Additional Resources - `stem worker --help` – built-in CLI usage for each subcommand. -- The `examples/` directory in the Stem repository demonstrates control - commands alongside worker lifecycle signals. +- The `packages/stem/example/` directory in the Stem repository demonstrates + control commands alongside worker lifecycle signals. diff --git a/.site/docs/workflows/annotated-workflows.md b/.site/docs/workflows/annotated-workflows.md new file mode 100644 index 00000000..64d6b77c --- /dev/null +++ b/.site/docs/workflows/annotated-workflows.md @@ -0,0 +1,139 @@ +--- +title: Annotated Workflows +--- + +Use `stem_builder` when you want workflow authoring to look like normal Dart +methods instead of manual `Flow(...)` or `WorkflowScript(...)` objects. + +## What the generator gives you + +After adding `part '.stem.g.dart';` and running `build_runner`, the +generated file exposes: + +- `stemModule` +- `StemWorkflowDefinitions` +- `StemTaskDefinitions` +- typed workflow refs like `StemWorkflowDefinitions.userSignup` +- typed enqueue helpers like `enqueueSendEmailTyped(...)` +- typed result wait helpers like `waitForSendEmailTyped(...)` + +Wire the bundle directly into `StemWorkflowApp`: + +```dart +final workflowApp = await StemWorkflowApp.fromUrl( + 'memory://', + module: stemModule, +); +``` + +Use the generated workflow refs when you want a single typed handle for start +and wait operations: + +```dart +final result = await StemWorkflowDefinitions.userSignup + .call((email: 'user@example.com')) + .startAndWaitWithApp(workflowApp); +``` + +## Two script entry styles + +### Direct-call style + +Use a plain `run(...)` when your annotated checkpoints only need serializable +values or codec-backed DTO parameters: + +```dart +@WorkflowDefn(name: 'builder.example.user_signup', kind: WorkflowKind.script) +class UserSignupWorkflow { + Future> run(String email) async { + final user = await createUser(email); + await sendWelcomeEmail(email); + return {'userId': user['id'], 'status': 'done'}; + } + + @WorkflowStep(name: 'create-user') + Future> createUser(String email) async { + return {'id': 'user:$email'}; + } + + @WorkflowStep(name: 'send-welcome-email') + Future sendWelcomeEmail(String email) async {} +} +``` + +The generator rewrites those calls into durable checkpoint boundaries in the +generated proxy class. + +### Context-aware style + +Use `@WorkflowRun()` when you need to enter through `WorkflowScriptContext` so +the checkpoint body can receive `WorkflowScriptStepContext`: + +```dart +@WorkflowDefn(name: 'annotated.context_script', kind: WorkflowKind.script) +class AnnotatedContextScriptWorkflow { + @WorkflowRun() + Future> run( + WorkflowScriptContext script, + String email, + ) async { + return script.step>( + 'enter-context-step', + (ctx) => captureContext(ctx, email), + ); + } + + @WorkflowStep(name: 'capture-context') + Future> captureContext( + WorkflowScriptStepContext ctx, + String email, + ) async { + return { + 'workflow': ctx.workflow, + 'runId': ctx.runId, + 'stepName': ctx.stepName, + 'stepIndex': ctx.stepIndex, + }; + } +} +``` + +Context-aware checkpoint methods are not meant to be called directly from a +plain `run(String ...)` signature. If a called step needs +`WorkflowScriptStepContext`, enter it through `@WorkflowRun()` plus +`WorkflowScriptContext`; plain direct-call style is for steps that consume only +serializable business parameters. + +## Runnable example + +Use `packages/stem/example/annotated_workflows` when you want a verified +example that demonstrates: + +- `FlowContext` +- direct-call script checkpoints +- nested annotated checkpoint calls +- `WorkflowScriptContext` +- `WorkflowScriptStepContext` +- `TaskInvocationContext` +- codec-backed DTO workflow checkpoints and final workflow results +- typed task DTO input and result decoding + +## DTO rules + +Generated workflow/task entrypoints support required positional parameters that +are either: + +- serializable values (`String`, numbers, bools, `List`, `Map`) +- codec-backed DTO classes that provide: + - `Map toJson()` + - `factory Type.fromJson(Map json)` or an equivalent named + `fromJson` constructor + +Typed task results can use the same DTO convention. + +Workflow inputs, checkpoint values, and final workflow results can use the same +DTO convention. The generated `PayloadCodec` persists the JSON form while +workflow code continues to work with typed objects. + +For lower-level generator details, see +[`Core Concepts > stem_builder`](../core-concepts/stem-builder.md). diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md new file mode 100644 index 00000000..ee508c7d --- /dev/null +++ b/.site/docs/workflows/context-and-serialization.md @@ -0,0 +1,91 @@ +--- +title: Context and Serialization +--- + +Stem injects context objects at specific points in the workflow/task lifecycle. +Everything else that crosses a durable boundary must be serializable. + +## Supported context injection points + +- flow steps: `FlowContext` +- script runs: `WorkflowScriptContext` +- script checkpoints: `WorkflowScriptStepContext` +- tasks: `TaskInvocationContext` + +Those context objects are not part of the persisted payload shape. They are +injected by the runtime when the handler executes. + +## What context gives you + +Depending on the context type, you can access: + +- `workflow` +- `runId` +- `stepName` +- `stepIndex` +- `iteration` +- workflow params and previous results +- `takeResumeData()` for event-driven resumes +- `takeResumeValue(codec: ...)` for typed event-driven resumes +- `idempotencyKey(...)` +- task metadata like `id`, `attempt`, `meta` + +## Serializable parameter rules + +Supported shapes: + +- `String` +- `bool` +- `int` +- `double` +- `num` +- JSON-like scalar values (`Object?` only when the runtime value is itself + serializable) +- `List` where `T` is serializable +- `Map` where `T` is serializable + +Unsupported directly: + +- arbitrary Dart class instances +- non-string map keys +- annotated workflow/task method signatures with optional or named business + parameters + +If you have a domain object, prefer a codec-backed DTO: + +```dart +class OrderRequest { + const OrderRequest({required this.id, required this.customerId}); + + final String id; + final String customerId; + + Map toJson() => {'id': id, 'customerId': customerId}; + + factory OrderRequest.fromJson(Map json) { + return OrderRequest( + id: json['id'] as String, + customerId: json['customerId'] as String, + ); + } +} +``` + +Generated workflow refs and task definitions will persist the JSON form while +your workflow/task code keeps working with the typed object. The restriction +still applies to the annotated business method signatures that `stem_builder` +lowers into workflow/task definitions. + +The same rule applies to workflow resume events: `emitValue(...)` can take a +typed DTO plus a `PayloadCodec`, but the codec must still encode to a +`Map` because watcher persistence and event delivery are +map-based today. + +## Practical rule + +When you need context metadata, add the appropriate context parameter first. +When you need business input, make it a required positional serializable value +after the context parameter. + +The runnable `annotated_workflows` example demonstrates both the context-aware +and plain serializable forms. diff --git a/.site/docs/workflows/errors-retries-and-idempotency.md b/.site/docs/workflows/errors-retries-and-idempotency.md new file mode 100644 index 00000000..27bcde75 --- /dev/null +++ b/.site/docs/workflows/errors-retries-and-idempotency.md @@ -0,0 +1,47 @@ +--- +title: Errors, Retries, and Idempotency +--- + +Durable orchestration only works if replayed code is safe. In Stem, that means +understanding where retries happen and where you need idempotent boundaries. + +## Flow retries + +Flow steps are durable stage boundaries. A suspended flow step is re-entered by +the runtime after resume, and the step body must tolerate replay. + +Use: + +- `takeResumeData()` to branch on fresh resume payloads +- `idempotencyKey(...)` when a step talks to an external side-effecting system +- persisted previous results instead of in-memory state + +## Script checkpoint retries + +In script workflows, completed checkpoints are replay-safe boundaries. The +runtime restores completed checkpoint results and continues through the +remaining `script.step(...)` calls. + +The code between durable checkpoints should still avoid hidden side effects. + +## Task retries inside workflows + +If a workflow enqueues normal Stem tasks, those tasks still use the normal +`TaskOptions` retry policy. The workflow and the task are separate retry +surfaces. + +## Cancellation policies + +Use `WorkflowCancellationPolicy` when you need to cap: + +- overall run duration +- maximum suspension duration + +That turns unbounded waiting into an explicit terminal state. + +## Rules of thumb + +- treat external writes as idempotent operations +- never rely on process-local memory for workflow progress +- keep side effects behind task handlers or clearly named checkpoints +- encode enough metadata to safely detect duplicate execution attempts diff --git a/.site/docs/workflows/flows-and-scripts.md b/.site/docs/workflows/flows-and-scripts.md new file mode 100644 index 00000000..018be993 --- /dev/null +++ b/.site/docs/workflows/flows-and-scripts.md @@ -0,0 +1,55 @@ +--- +title: Flows and Scripts +--- + +Stem supports two workflow models. Both are durable. They differ in where the +execution plan lives. + +## The distinction + +| Model | Source of truth | Best for | +| --- | --- | --- | +| `Flow` | Declared steps | Explicit orchestration, clearer admin views, fixed step order | +| `WorkflowScript` | The Dart code in `run(...)` | Branching, loops, and function-style workflow authoring | + +The confusing part is that both models expose step-like metadata. The difference +is that for script workflows those are **checkpoints**, not the plan itself. + +- **Flow**: the runtime advances through the declared step list. +- **WorkflowScript**: the runtime re-enters `run(...)` and durable boundaries + are created when `script.step(...)` executes. +- **Script checkpoints** exist for replay boundaries, manifests, dashboards, + and tooling. + +## Flow example + +```dart title="lib/workflows/approvals_flow.dart" file=/../packages/stem/example/docs_snippets/lib/workflows.dart#workflows-flow + +``` + +Use `Flow` when: + +- the sequence of durable actions should be obvious from the definition +- each step maps cleanly to one business stage +- your operators care about a stable, declared step list + +## Script example + +```dart title="lib/workflows/retry_script.dart" file=/../packages/stem/example/docs_snippets/lib/workflows.dart#workflows-script + +``` + +Use `WorkflowScript` when: + +- you want normal Dart control flow to define the run +- the workflow has branching or repeated patterns +- you want a more function-like authoring model + +## Contexts in each model + +- flow steps receive `FlowContext` +- script runs may receive `WorkflowScriptContext` +- script checkpoints may receive `WorkflowScriptStepContext` + +The full injection and parameter rules are documented in +[Context and Serialization](./context-and-serialization.md). diff --git a/.site/docs/workflows/getting-started.md b/.site/docs/workflows/getting-started.md new file mode 100644 index 00000000..b83628d3 --- /dev/null +++ b/.site/docs/workflows/getting-started.md @@ -0,0 +1,61 @@ +--- +title: Getting Started +sidebar_position: 1 +--- + +This is the quickest path to a working durable workflow in Stem. + +## 1. Create a workflow app + +```dart title="bin/workflows.dart" file=/../packages/stem/example/docs_snippets/lib/workflows.dart#workflows-app-create + +``` + +Pass normal task handlers through `tasks:` if the workflow also needs to +enqueue regular Stem tasks. + +## 2. Start the managed worker + +```dart title="bin/workflows.dart" file=/../packages/stem/example/docs_snippets/lib/workflows.dart#workflows-app-start + +``` + +`StemWorkflowApp.start()` starts both the runtime and the underlying worker. +The managed worker subscribes to the workflow orchestration queue, so you do +not need to manually register the internal `stem.workflow.run` task. + +If you prefer a minimal example, `startWorkflow(...)` also lazy-starts the +runtime and managed worker on first use. Explicit `start()` is still the better +choice when you want deterministic application lifecycle control. + +## 3. Start a run and wait for the result + +```dart title="bin/run_workflow.dart" file=/../packages/stem/example/docs_snippets/lib/workflows.dart#workflows-run + +``` + +The returned `WorkflowResult` includes: + +- the decoded `value` +- the persisted `RunState` +- a `timedOut` flag when the caller stops waiting before the run finishes + +## 4. Reuse existing bootstrap when needed + +```dart title="bin/workflows_client.dart" file=/../packages/stem/example/docs_snippets/lib/workflows.dart#workflows-client + +``` + +Use `StemClient` when one service wants to own broker, backend, and workflow +setup in one place. The clean path there is `client.createWorkflowApp(...)`. + +If your service already owns a `StemApp`, layer workflows on top of it with +`StemWorkflowApp.create(stemApp: ..., flows: ..., scripts: ..., tasks: ...)`. + +## 5. Move to the right next page + +- If you need a mental model first, read [Flows and Scripts](./flows-and-scripts.md). +- If you want the decorator/codegen path, read + [Annotated Workflows](./annotated-workflows.md). +- If you need to suspend and resume runs, read + [Suspensions and Events](./suspensions-and-events.md). diff --git a/.site/docs/workflows/how-it-works.md b/.site/docs/workflows/how-it-works.md new file mode 100644 index 00000000..b8d077c3 --- /dev/null +++ b/.site/docs/workflows/how-it-works.md @@ -0,0 +1,70 @@ +--- +title: How It Works +--- + +Stem workflows are built on top of the regular Stem queue runtime, but they are +not just “tasks that sleep”. They add a workflow store, durable suspension +state, and orchestration-specific runtime metadata. + +## Runtime components + +`StemWorkflowApp` bundles: + +- `runtime`: workflow registration plus run execution/resume coordination once + the internal workflow task is invoked +- `store`: persisted runs, checkpoints, watchers, due runs, and results +- `eventBus`: topic-based resume channel +- `app`: the underlying `StemApp` with broker, backend, and worker + +## Internal execution task + +Each workflow run is executed by an internal task named `stem.workflow.run`. +That task is delivered on the orchestration queue, claimed by a worker, and +then delegated into the workflow runtime. + +This is why workflow logs show task lifecycle lines alongside workflow runtime +lines. + +## State model + +Stem keeps materialized workflow state in the workflow store: + +- run metadata and status +- flow step results and script checkpoint results +- suspension records +- due-run schedules +- topic watchers +- runtime metadata such as queue and serialization info + +That model is simpler to query from dashboards and store adapters, while still +allowing durable resume and recovery. + +## Manifests + +`WorkflowRuntime.workflowManifest()` exposes typed manifest entries for all +registered workflows. + +The manifest now distinguishes: + +- flow `steps` +- script `checkpoints` + +That distinction is reflected in the dashboard and generated metadata so script +workflows no longer look like they are driven by a declared step list. + +## Leases and recovery + +Workflow runs are lease-based. + +- a worker claims a run for a finite lease duration +- the lease is renewed while the run is active +- another worker can take over after lease expiry if the first worker crashes + +Operational guidance: + +- keep workflow lease duration at or above the broker visibility timeout +- renew leases before visibility or lease expiry +- keep clocks in sync across workers + +Those constraints determine whether recovery is clean under crashes or network +delays. diff --git a/.site/docs/workflows/index.md b/.site/docs/workflows/index.md new file mode 100644 index 00000000..ea4c9fc5 --- /dev/null +++ b/.site/docs/workflows/index.md @@ -0,0 +1,80 @@ +--- +title: Workflows +slug: /workflows +sidebar_position: 0 +--- + +Stem workflows are the durable orchestration layer on top of the normal Stem +task runtime. They let you model multi-step business processes, suspend on +time or external events, resume on another worker, and inspect the full run +state from the store, CLI, or dashboard. + +Use this section as the main workflow manual. The older +[`Core Concepts > Workflows`](../core-concepts/workflows.md) page now just +orients you and links back here. + +## Pick a workflow style + +- **Flow**: a declared sequence of durable steps. Use this when you want the + workflow structure to be the source of truth. +- **WorkflowScript**: a durable async function. Use this when normal Dart + control flow should define the execution plan. +- **Annotated workflows with `stem_builder`**: use annotations and generated + workflow refs when you want plain method signatures and less string-based + wiring. + +## Read this section in order + +- [Getting Started](./getting-started.md) shows the runtime bootstrap and the + basic start/wait lifecycle. +- [Flows and Scripts](./flows-and-scripts.md) explains the execution model + difference between declared steps and script checkpoints. +- [Starting and Waiting](./starting-and-waiting.md) covers named starts, + generated workflow refs, results, and cancellation policies. +- [Suspensions and Events](./suspensions-and-events.md) covers `sleep`, + `awaitEvent`, due runs, and external resume flows. +- [Annotated Workflows](./annotated-workflows.md) covers `stem_builder`, + context injection, and generated workflow refs. +- [Context and Serialization](./context-and-serialization.md) documents where + context objects are injected and what parameter shapes are supported. +- [Errors, Retries, and Idempotency](./errors-retries-and-idempotency.md) + explains replay expectations and how to keep durable code safe. +- [How It Works](./how-it-works.md) explains the runtime, store, leases, and + manifests. +- [Observability](./observability.md) covers logs, dashboard views, and store + inspection. +- [Troubleshooting](./troubleshooting.md) collects the workflow-specific + failure modes you are likely to hit first. + +## Runtime bootstrap + +```dart title="bin/workflows.dart" file=/../packages/stem/example/docs_snippets/lib/workflows.dart#workflows-app-create + +``` + +```dart title="bin/workflows.dart" file=/../packages/stem/example/docs_snippets/lib/workflows.dart#workflows-app-start + +``` + +`StemWorkflowApp` is the recommended entrypoint because it wires: + +- the underlying `StemApp` +- the workflow runtime +- the workflow store +- the workflow event bus +- the managed worker that executes the internal `stem.workflow.run` task + +If you already own a `StemClient`, you can attach workflow support through that +shared client instead of constructing a second app boundary: + +```dart +final client = await StemClient.fromUrl('memory://'); +final workflowApp = await client.createWorkflowApp( + flows: [ApprovalsFlow.flow], + scripts: [retryScript], +); +``` + +If your service already owns a `StemApp`, reuse it directly with +`StemWorkflowApp.create(stemApp: ..., flows: ..., scripts: ..., tasks: ...)` +rather than bootstrapping a second broker/backend/task boundary. diff --git a/.site/docs/workflows/observability.md b/.site/docs/workflows/observability.md new file mode 100644 index 00000000..6a7bf389 --- /dev/null +++ b/.site/docs/workflows/observability.md @@ -0,0 +1,54 @@ +--- +title: Observability +--- + +Workflow observability in Stem comes from three layers: + +- workflow-aware logs +- store-backed inspection APIs +- dashboard and CLI tooling + +## Logs + +Recent logging improvements add workflow context onto the internal +`stem.workflow.run` task lines. You can now correlate: + +- `workflow` +- `workflowRunId` +- `workflowId` +- `workflowChannel` +- `workflowReason` +- `workflowStep` / checkpoint metadata + +The runtime also emits lifecycle logs for enqueue, suspend, fail, and complete. + +## Store-backed inspection + +Use the workflow store for operational queries: + +- `listRuns(...)` +- `runsWaitingOn(topic)` +- `get(runId)` + +Use the runtime for definition-level inspection: + +- `workflowManifest()` + +## Dashboard and CLI + +The dashboard uses the same store/manifests to surface: + +- registered workflows +- run status +- current step/checkpoint +- worker health +- queue and DLQ state around workflow-driven tasks + +CLI integrations rely on the same persisted state, so keeping the workflow +store healthy is what keeps the tools responsive. + +## Encoders and result metadata + +Workflow runs inherit Stem payload encoder behavior. Result encoder ids are +persisted in `RunState.resultMeta`, which lets tooling decode stored outputs +consistently across workers. diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md new file mode 100644 index 00000000..67ec8862 --- /dev/null +++ b/.site/docs/workflows/starting-and-waiting.md @@ -0,0 +1,54 @@ +--- +title: Starting and Waiting +--- + +Workflow runs are started through the runtime, through `StemWorkflowApp`, or +through generated workflow refs. + +## Start by workflow name + +```dart title="bin/run_workflow.dart" file=/../packages/stem/example/docs_snippets/lib/workflows.dart#workflows-run + +``` + +Use `params:` to supply workflow input and +`WorkflowCancellationPolicy` to cap wall-clock runtime or maximum suspension +time. + +## Wait for completion + +`waitForCompletion` polls the store until the run finishes or the caller +times out. + +Use the returned `WorkflowResult` when you need: + +- `value` for a completed run +- `status` for partial progress +- `timedOut` to decide whether to keep polling + +## Start through generated workflow refs + +When you use `stem_builder`, generated workflow refs remove the raw +workflow-name strings and give you one typed handle for both start and wait: + +```dart +final result = await StemWorkflowDefinitions.userSignup + .call((email: 'user@example.com')) + .startAndWaitWithApp(workflowApp); +``` + +The same definitions work on `WorkflowRuntime` through +`.startWithRuntime(runtime)`. + +If you still need the run identifier for inspection or operator tooling, read +it from `result.runId`. + +## Parent runs and TTL + +`WorkflowRuntime.startWorkflow(...)` also supports: + +- `parentRunId` when one workflow needs to track provenance from another run +- `ttl` when you want run metadata to expire after a bounded retention period + +Those are advanced controls. Most applications only need `params:` and an +optional cancellation policy. diff --git a/.site/docs/workflows/suspensions-and-events.md b/.site/docs/workflows/suspensions-and-events.md new file mode 100644 index 00000000..f3cd9e70 --- /dev/null +++ b/.site/docs/workflows/suspensions-and-events.md @@ -0,0 +1,63 @@ +--- +title: Suspensions and Events +--- + +Suspension is where workflows differ from normal queue consumers. A workflow +can stop executing, persist its state, and resume later on the same worker or a +different worker. + +## Sleep + +`sleep(duration)` records a wake-up time in the workflow store. The runtime +periodically scans due runs and re-enqueues the internal workflow task when the +sleep expires. + +## Await external events + +`awaitEvent(topic, deadline: ...)` records a durable watcher. External code can +resume those runs through the runtime API by emitting a payload for the topic. + +Typical flow: + +1. a step calls `awaitEvent('orders.payment.confirmed')` +2. the run is marked suspended in the store +3. another process calls `WorkflowRuntime.emit(...)` / + `WorkflowRuntime.emitValue(...)` (or an app/service wrapper around it) with + a payload +4. the runtime resumes the run and exposes the payload through + `takeResumeData()` or `takeResumeValue(codec: ...)` + +## Emit resume events + +Use `WorkflowRuntime.emit(...)` / `WorkflowRuntime.emitValue(...)` (or the app +wrapper `workflowApp.emitValue(...)`) instead of hand-editing store state: + +```dart +await workflowApp.emitValue( + 'orders.payment.confirmed', + const PaymentConfirmed(paymentId: 'pay_42', approvedBy: 'gateway'), + codec: paymentConfirmedCodec, +); +``` + +Typed event payloads still serialize to the existing `Map` +wire format. `emitValue(...)` is a DTO/codec convenience layer, not a new +transport shape. + +## Inspect waiting runs + +The workflow store can tell you which runs are waiting on a topic: + +- `runsWaitingOn(topic)` +- `listRuns(...)` + +That is the foundation for dashboards, operational tooling, and bulk +inspection. + +## Group operations + +Because due runs and event watchers are persisted, you can: + +- resume batches of runs waiting on one topic +- inspect all suspended runs even with no active worker +- rebuild dashboard views after process restarts diff --git a/.site/docs/workflows/troubleshooting.md b/.site/docs/workflows/troubleshooting.md new file mode 100644 index 00000000..60769fb2 --- /dev/null +++ b/.site/docs/workflows/troubleshooting.md @@ -0,0 +1,50 @@ +--- +title: Troubleshooting +--- + +These are the workflow-specific issues you are most likely to hit first. + +## The workflow never starts + +Check: + +- the app was started with `await workflowApp.start()` +- a worker is subscribed to the workflow orchestration queue +- the workflow name is registered in `flows:` or `scripts:` + +## A normal task inside the workflow never runs + +The workflow worker may only be subscribed to the `workflow` queue. If the +workflow enqueues regular tasks, make sure some worker also consumes the target +task queue such as `default`. + +## Resume events do nothing + +Check: + +- the topic passed to `WorkflowRuntime.emit(...)` / `emitValue(...)` or + `workflowApp.emitValue(...)` matches the one passed to `awaitEvent(...)` +- the run is still waiting on that topic +- the payload encodes to a `Map` + +## Serialization failures + +Do not pass arbitrary Dart objects across workflow or task boundaries. Encode +domain objects as `Map` or `List` first. + +## Logs only show `stem.workflow.run` + +Upgrade to a build that includes the newer workflow log context. The logs +should include workflow name, run id, channel, and checkpoint metadata in +addition to the internal task name. + +## Leases or redelivery behave strangely + +Check the relationship between: + +- broker visibility timeout +- workflow run lease duration +- lease renewal cadence + +If the broker redelivers before the workflow lease model expects, another +worker can observe a task before the prior lease is considered stale. diff --git a/.site/sidebars.ts b/.site/sidebars.ts index ad93cfab..07030a06 100644 --- a/.site/sidebars.ts +++ b/.site/sidebars.ts @@ -25,6 +25,41 @@ const sidebars: SidebarsConfig = { "getting-started/developer-environment", ], }, + { + type: "category", + label: "Workflows", + link: { type: "doc", id: "workflows/index" }, + items: [ + "workflows/getting-started", + { + type: "category", + label: "Foundations", + items: [ + "workflows/flows-and-scripts", + "workflows/starting-and-waiting", + "workflows/suspensions-and-events", + "workflows/annotated-workflows", + "workflows/context-and-serialization", + "workflows/errors-retries-and-idempotency", + ], + }, + { + type: "category", + label: "How It Works", + items: ["workflows/how-it-works"], + }, + { + type: "category", + label: "Observability", + items: ["workflows/observability"], + }, + { + type: "category", + label: "Troubleshooting", + items: ["workflows/troubleshooting"], + }, + ], + }, { type: "category", label: "Guides", @@ -58,6 +93,7 @@ const sidebars: SidebarsConfig = { "core-concepts/observability", "core-concepts/dashboard", "core-concepts/persistence", + "core-concepts/stem-builder", "core-concepts/cli-control", ], }, @@ -79,8 +115,8 @@ const sidebars: SidebarsConfig = { }, { type: "category", - label: "Brokers & Backends", - items: ["brokers/overview", "brokers/caveats"], + label: "Brokers", + items: ["brokers/overview", "brokers/sqlite", "brokers/caveats"], }, ], }; diff --git a/.site/static/img/favicon.ico b/.site/static/img/favicon.ico index c01d54bc..765721fb 100644 Binary files a/.site/static/img/favicon.ico and b/.site/static/img/favicon.ico differ diff --git a/.site/static/img/stem-icon.png b/.site/static/img/stem-icon.png new file mode 100644 index 00000000..8f7e0ae3 Binary files /dev/null and b/.site/static/img/stem-icon.png differ diff --git a/Taskfile.yml b/Taskfile.yml index 82333e7b..5a0c3a53 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -135,3 +135,34 @@ tasks: - task: stem_postgres:coverage - task: stem_redis:coverage - task: stem_sqlite:coverage + + demo:annotated: + desc: Run the annotated workflows example. + dir: ./packages/stem/example/annotated_workflows + cmds: + - dart pub get + - dart run build_runner build --delete-conflicting-outputs + - dart run bin/main.dart + + demo:builder: + desc: Run the stem_builder example. + dir: ./packages/stem_builder/example + cmds: + - dart pub get + - dart run build_runner build --delete-conflicting-outputs + - dart run bin/main.dart + + demo:ecommerce:test: + desc: Run the ecommerce example test suite. + dir: ./packages/stem/example/ecommerce + cmds: + - dart pub get + - dart run build_runner build --delete-conflicting-outputs + - dart test + + demo:all: + desc: Run the healthy example demos from the repo root. + cmds: + - task: demo:annotated + - task: demo:builder + - task: demo:ecommerce:test diff --git a/packages/dashboard/CHANGELOG.md b/packages/dashboard/CHANGELOG.md index b9e5c2bf..a29b4b39 100644 --- a/packages/dashboard/CHANGELOG.md +++ b/packages/dashboard/CHANGELOG.md @@ -2,6 +2,7 @@ ## 0.1.0 +- Updated the dashboard data layer to use Ormed 0.2.0. - Reworked the dashboard into a richer operations console with dedicated views for tasks, jobs, workflows, workers, failures, audit, events, namespaces, and search. @@ -13,4 +14,6 @@ during page switches. - Expanded dashboard state/service/server models and test coverage to support the new views and metadata-rich rendering paths. +- Clarified workflow views by labeling script-runtime nodes as checkpoints + instead of steps. - Initial release of the `stem_dashboard` package. diff --git a/packages/dashboard/lib/src/services/models.dart b/packages/dashboard/lib/src/services/models.dart index 42681efd..28c888bb 100644 --- a/packages/dashboard/lib/src/services/models.dart +++ b/packages/dashboard/lib/src/services/models.dart @@ -572,9 +572,11 @@ List buildNamespaceSnapshots({ final runsByNamespace = >{}; for (final queue in queues) { - queueNamesByNamespace.putIfAbsent(defaultNamespace, () => {}).add( - queue.queue, - ); + queueNamesByNamespace + .putIfAbsent(defaultNamespace, () => {}) + .add( + queue.queue, + ); pendingByNamespace[defaultNamespace] = (pendingByNamespace[defaultNamespace] ?? 0) + queue.pending; inflightByNamespace[defaultNamespace] = @@ -602,9 +604,11 @@ List buildNamespaceSnapshots({ final namespace = task.namespace.trim().isEmpty ? defaultNamespace : task.namespace.trim(); - queueNamesByNamespace.putIfAbsent(namespace, () => {}).add( - task.queue, - ); + queueNamesByNamespace + .putIfAbsent(namespace, () => {}) + .add( + task.queue, + ); if (task.state == TaskState.running) { runningByNamespace[namespace] = (runningByNamespace[namespace] ?? 0) + 1; } @@ -622,22 +626,23 @@ List buildNamespaceSnapshots({ ...runningByNamespace.keys, ...failedByNamespace.keys, ...runsByNamespace.keys, - }.toList(growable: false) - ..sort(); - - return namespaces.map((namespace) { - return DashboardNamespaceSnapshot( - namespace: namespace, - queueCount: queueNamesByNamespace[namespace]?.length ?? 0, - workerCount: workerCountByNamespace[namespace] ?? 0, - pending: pendingByNamespace[namespace] ?? 0, - inflight: inflightByNamespace[namespace] ?? 0, - deadLetters: deadByNamespace[namespace] ?? 0, - runningTasks: runningByNamespace[namespace] ?? 0, - failedTasks: failedByNamespace[namespace] ?? 0, - workflowRuns: runsByNamespace[namespace]?.length ?? 0, - ); - }).toList(growable: false); + }.toList(growable: false)..sort(); + + return namespaces + .map((namespace) { + return DashboardNamespaceSnapshot( + namespace: namespace, + queueCount: queueNamesByNamespace[namespace]?.length ?? 0, + workerCount: workerCountByNamespace[namespace] ?? 0, + pending: pendingByNamespace[namespace] ?? 0, + inflight: inflightByNamespace[namespace] ?? 0, + deadLetters: deadByNamespace[namespace] ?? 0, + runningTasks: runningByNamespace[namespace] ?? 0, + failedTasks: failedByNamespace[namespace] ?? 0, + workflowRuns: runsByNamespace[namespace]?.length ?? 0, + ); + }) + .toList(growable: false); } /// Builds task/job summaries grouped by task name. @@ -756,9 +761,9 @@ class DashboardWorkflowRunSnapshot { final Object? result; } -/// Projection of a persisted workflow step checkpoint. +/// Projection of a persisted workflow checkpoint. class DashboardWorkflowStepSnapshot { - /// Creates a workflow step snapshot. + /// Creates a workflow checkpoint snapshot. const DashboardWorkflowStepSnapshot({ required this.name, required this.position, @@ -766,7 +771,7 @@ class DashboardWorkflowStepSnapshot { this.completedAt, }); - /// Builds a workflow step snapshot from [WorkflowStepEntry]. + /// Builds a workflow checkpoint snapshot from [WorkflowStepEntry]. factory DashboardWorkflowStepSnapshot.fromEntry(WorkflowStepEntry entry) { return DashboardWorkflowStepSnapshot( name: entry.name, @@ -776,10 +781,10 @@ class DashboardWorkflowStepSnapshot { ); } - /// Step name. + /// Checkpoint name. final String name; - /// Step ordering position. + /// Checkpoint ordering position. final int position; /// Persisted checkpoint value. @@ -879,9 +884,9 @@ class _DashboardJobSummaryBuilder { final sampleQueue = _queueHits.entries.isEmpty ? 'default' : (_queueHits.entries.toList() - ..sort((a, b) => b.value.compareTo(a.value))) - .first - .key; + ..sort((a, b) => b.value.compareTo(a.value))) + .first + .key; return DashboardJobSummary( taskName: taskName, sampleQueue: sampleQueue, diff --git a/packages/dashboard/lib/src/ui/task_detail.dart b/packages/dashboard/lib/src/ui/task_detail.dart index 7d1b7df4..e3d9f3c0 100644 --- a/packages/dashboard/lib/src/ui/task_detail.dart +++ b/packages/dashboard/lib/src/ui/task_detail.dart @@ -170,12 +170,12 @@ String buildWorkflowSection(
-

Workflow Steps

+

Workflow Checkpoints

- + diff --git a/packages/dashboard/lib/src/ui/workflows.dart b/packages/dashboard/lib/src/ui/workflows.dart index a147e95e..db3b97ea 100644 --- a/packages/dashboard/lib/src/ui/workflows.dart +++ b/packages/dashboard/lib/src/ui/workflows.dart @@ -13,17 +13,19 @@ String buildWorkflowsContent({ final runs = buildWorkflowRunSummaries(taskStatuses, limit: 400); final workflowFilter = options.workflow?.toLowerCase(); final runFilter = options.runId?.toLowerCase(); - final filtered = runs.where((entry) { - final matchesWorkflow = - workflowFilter == null || - workflowFilter.isEmpty || - entry.workflowName.toLowerCase().contains(workflowFilter); - final matchesRun = - runFilter == null || - runFilter.isEmpty || - entry.runId.toLowerCase().contains(runFilter); - return matchesWorkflow && matchesRun; - }).toList(growable: false); + final filtered = runs + .where((entry) { + final matchesWorkflow = + workflowFilter == null || + workflowFilter.isEmpty || + entry.workflowName.toLowerCase().contains(workflowFilter); + final matchesRun = + runFilter == null || + runFilter.isEmpty || + entry.runId.toLowerCase().contains(runFilter); + return matchesWorkflow && matchesRun; + }) + .toList(growable: false); final running = filtered.fold(0, (sum, entry) => sum + entry.running); final failed = filtered.fold(0, (sum, entry) => sum + entry.failed); @@ -33,15 +35,15 @@ String buildWorkflowsContent({
${buildMetricCard('Runs (sample)', formatInt(filtered.length), 'Distinct workflow run IDs currently visible in task status history.')} - ${buildMetricCard('Queued steps', formatInt(queued), 'Queued or retried statuses across sampled runs.')} - ${buildMetricCard('Running steps', formatInt(running), 'Statuses currently executing inside workflow runs.')} - ${buildMetricCard('Failed steps', formatInt(failed), 'Failed statuses mapped to workflow runs.')} + ${buildMetricCard('Queued checkpoints', formatInt(queued), 'Queued or retried workflow run tasks across sampled runs.')} + ${buildMetricCard('Running checkpoints', formatInt(running), 'Workflow run tasks currently executing inside sampled runs.')} + ${buildMetricCard('Failed checkpoints', formatInt(failed), 'Failed workflow run tasks mapped to sampled runs.')}
@@ -62,7 +64,7 @@ String buildWorkflowsContent({
- + diff --git a/packages/dashboard/pubspec.yaml b/packages/dashboard/pubspec.yaml index 083a814e..dc79877b 100644 --- a/packages/dashboard/pubspec.yaml +++ b/packages/dashboard/pubspec.yaml @@ -9,7 +9,7 @@ resolution: workspace dependencies: intl: ^0.20.2 meta: ^1.18.0 - ormed: ^0.1.0 + ormed: ^0.2.0 routed: ^0.3.2 routed_hotwire: ^0.1.2 stem: ^0.1.0 @@ -29,4 +29,3 @@ dev_dependencies: dependency_overrides: analyzer: ^10.0.1 artisanal: ^0.2.0 - diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index f9620a28..bc623da8 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,30 @@ ## 0.1.1 +- Added `StemModule`, typed `WorkflowRef`/`WorkflowStartCall` helpers, and bundle-first `StemWorkflowApp`/`StemClient` composition for generated workflow and task definitions. +- Added `PayloadCodec`, typed workflow resume helpers, codec-backed workflow checkpoint/result persistence, typed task result waiting, and typed workflow event emit helpers for DTO-shaped payloads. +- Added workflow manifests, runtime metadata views, and run/step drilldown APIs + for inspecting workflow definitions and persisted execution state. +- Clarified the workflow authoring model by distinguishing flow steps from + script checkpoints in manifests, docs, dashboard wording, and generated + workflow output. +- Improved workflow store contracts and runtime compatibility for caller- + supplied run ids and persisted runtime metadata attached to workflow params. +- Restored the deprecated `SimpleTaskRegistry` alias for source compatibility + and fixed workflow continuation routing to honor persisted queue metadata + when resuming suspended runs after runtime configuration changes. +- Added `tasks:`-first wiring across `Stem`, `Worker`, `Canvas`, and + `StemWorkflowApp`, removing the need for manual default-registry setup in + normal application code and examples. +- Renamed the default in-memory task registry surface to + `InMemoryTaskRegistry` and refreshed docs/examples to teach `tasks: [...]` + rather than explicit registry construction. +- Improved workflow logging with richer run/step context on worker lifecycle + lines plus enqueue/suspend/fail/complete runtime events. +- Exported logging types from `package:stem/stem.dart`, including `Level`, + `Logger`, and `Context`. +- Added an end-to-end ecommerce workflow example using mixed annotated/manual + workflows, `StemWorkflowApp`, and Ormed-backed SQLite models/migrations. - Expanded span attribution across enqueue/consume/execute with task identity, queue, worker, host, lineage, namespace, and workflow step metadata (`run_id`, `step`, `step_id`, `step_index`, `step_attempt`, `iteration`). diff --git a/packages/stem/README.md b/packages/stem/README.md index d05f9237..a8d0fbef 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -23,7 +23,7 @@ dart pub add stem # core runtime APIs dart pub add stem_redis # Redis broker + result backend dart pub add stem_postgres # (optional) Postgres broker + backend dart pub add stem_sqlite # (optional) SQLite broker + backend -dart pub add -d stem_builder # (optional) registry builder +dart pub add -d stem_builder # (optional) workflow/task code generator dart pub global activate stem_cli ``` @@ -129,12 +129,15 @@ class HelloTask implements TaskHandler { } Future main() async { - final registry = SimpleTaskRegistry()..register(HelloTask()); final broker = await RedisStreamsBroker.connect('redis://localhost:6379'); final backend = await RedisResultBackend.connect('redis://localhost:6379/1'); - final stem = Stem(broker: broker, registry: registry, backend: backend); - final worker = Worker(broker: broker, registry: registry, backend: backend); + final stem = Stem(broker: broker, backend: backend, tasks: [HelloTask()]); + final worker = Worker( + broker: broker, + backend: backend, + tasks: [HelloTask()], + ); unawaited(worker.start()); await stem.enqueue('demo.hello', args: {'name': 'Stem'}); @@ -179,12 +182,15 @@ class HelloArgs { } Future main() async { - final registry = SimpleTaskRegistry()..register(HelloTask()); final broker = await RedisStreamsBroker.connect('redis://localhost:6379'); final backend = await RedisResultBackend.connect('redis://localhost:6379/1'); - final stem = Stem(broker: broker, registry: registry, backend: backend); - final worker = Worker(broker: broker, registry: registry, backend: backend); + final stem = Stem(broker: broker, backend: backend, tasks: [HelloTask()]); + final worker = Worker( + broker: broker, + backend: backend, + tasks: [HelloTask()], + ); unawaited(worker.start()); await stem.enqueueCall( @@ -285,7 +291,7 @@ final app = await StemWorkflowApp.inMemory( }); await script.step('poll-shipment', (step) async { - final resume = step.takeResumeData(); + final resume = step.takeResumeValue(); if (resume != true) { await step.sleep(const Duration(seconds: 30)); return 'waiting'; @@ -316,8 +322,270 @@ Inside a script step you can access the same metadata as `FlowContext`: - `step.iteration` tracks the current auto-version suffix when `autoVersion: true` is set. - `step.idempotencyKey('scope')` builds stable outbound identifiers. -- `step.takeResumeData()` surfaces payloads from sleeps or awaited events so - you can branch on resume paths. +- `step.takeResumeData()` and `step.takeResumeValue(codec: ...)` surface + payloads from sleeps or awaited events so you can branch on resume paths. + +### Current workflow model + +Stem supports three workflow authoring styles today: + +1. `Flow` for explicit orchestration +2. `WorkflowScript` for function-style durable workflows +3. `stem_builder` for annotated workflows with generated workflow refs + +The runtime shape is the same in every case: + +- bootstrap a `StemWorkflowApp` +- pass `flows:`, `scripts:`, and `tasks:` directly +- start runs with `startWorkflow(...)` or generated workflow refs +- wait with `waitForCompletion(...)` + +You do not need to build task registries manually for normal workflow usage. + +#### Manual `Flow` + +Use `Flow` when you want explicit step orchestration and fine control over +resume behavior: + +```dart +final approvalsFlow = Flow( + name: 'approvals.flow', + build: (flow) { + flow.step('draft', (ctx) async { + final payload = ctx.params['draft'] as Map; + return payload['documentId']; + }); + + flow.step('manager-review', (ctx) async { + final resume = ctx.takeResumeValue>(); + if (resume == null) { + await ctx.awaitEvent('approvals.manager'); + return null; + } + return resume['approvedBy'] as String?; + }); + + flow.step('finalize', (ctx) async { + final approvedBy = ctx.previousResult as String?; + return 'approved-by:$approvedBy'; + }); + }, +); + +final app = await StemWorkflowApp.fromUrl( + 'memory://', + flows: [approvalsFlow], + tasks: const [], +); + +final runId = await app.startWorkflow( + 'approvals.flow', + params: { + 'draft': {'documentId': 'doc-42'}, + }, +); + +final result = await app.waitForCompletion(runId); +print(result?.value); +await app.close(); +``` + +#### Manual `WorkflowScript` + +Use `WorkflowScript` when you want your workflow to read like a normal async +function while still persisting durable checkpoints: + +```dart +final billingRetryScript = WorkflowScript( + name: 'billing.retry-script', + run: (script) async { + final chargeId = await script.step('charge', (ctx) async { + final resume = ctx.takeResumeValue>(); + if (resume == null) { + await ctx.awaitEvent('billing.charge.prepared'); + return 'pending'; + } + return resume['chargeId'] as String; + }); + + return script.step('confirm', (ctx) async { + ctx.idempotencyKey('confirm-$chargeId'); + return 'receipt-$chargeId'; + }); + }, +); + +final app = await StemWorkflowApp.inMemory( + scripts: [billingRetryScript], + tasks: const [], +); +``` + +#### Annotated workflows with `stem_builder` + +Use `stem_builder` when you want the best DX: plain method signatures, +generated manifests, and typed workflow refs. + +The important part of the model is that `run(...)` calls other annotated +methods directly. Those method calls are what become durable script checkpoints in +the generated proxy. + +The conceptual split is: + +- `Flow`: declared steps are the execution plan +- `WorkflowScript`: `run(...)` is the execution plan, and declared checkpoints + are manifest/introspection metadata + +```dart +import 'package:stem/stem.dart'; + +part 'definitions.stem.g.dart'; + +@WorkflowDefn(name: 'builder.example.user_signup', kind: WorkflowKind.script) +class BuilderUserSignupWorkflow { + Future> run(String email) async { + final user = await createUser(email); + await sendWelcomeEmail(email); + await sendOneWeekCheckInEmail(email); + return {'userId': user['id'], 'status': 'done'}; + } + + @WorkflowStep(name: 'create-user') + Future> createUser(String email) async { + return {'id': 'user:$email'}; + } + + @WorkflowStep(name: 'send-welcome-email') + Future sendWelcomeEmail(String email) async {} + + @WorkflowStep(name: 'send-one-week-check-in-email') + Future sendOneWeekCheckInEmail(String email) async {} +} + +@TaskDefn(name: 'builder.example.task') +Future builderExampleTask( + TaskInvocationContext context, + Map args, +) async {} +``` + +There are two supported script entry styles: + +- plain direct-call style: + - `Future run(String email, ...)` + - best when your annotated step methods only take serializable parameters +- context-aware style: + - `@WorkflowRun()` + - `Future run(WorkflowScriptContext script, String email, ...)` + - use this when you need to enter a step explicitly with `script.step(...)` + so the step body can receive `WorkflowScriptStepContext` + +Context injection works at every runtime layer: + +- flow steps can take `FlowContext` +- script runs can take `WorkflowScriptContext` +- script steps can take `WorkflowScriptStepContext` +- tasks can take `TaskInvocationContext` + +Serializable parameter rules for generated workflows and tasks are strict: + +- supported: + - `String`, `bool`, `int`, `double`, `num`, `Object?`, `null` + - `List` where `T` is serializable + - `Map` where `T` is serializable + - DTO classes with: + - `Map toJson()` + - `factory Type.fromJson(Map json)` or an equivalent + named `fromJson` constructor +- not supported directly: + - optional/named parameters on generated workflow/task entrypoints + +Typed task results can use the same DTO convention. + +Workflow inputs, checkpoint values, and final workflow results can use the same +DTO convention. The generated `PayloadCodec` persists the JSON form while +workflow code continues to work with typed objects. + +See the runnable example: + +- [example/annotated_workflows](example/annotated_workflows) + - `FlowContext` metadata + - plain proxy-driven script step calls + - `WorkflowScriptContext` + `WorkflowScriptStepContext` + - codec-backed workflow checkpoint values and workflow results + - typed `@TaskDefn` decoding scalar, `Map`, and `List` parameters + +Generate code: + +```bash +dart run build_runner build +``` + +Wire the generated bundle directly into `StemWorkflowApp`: + +```dart +final app = await StemWorkflowApp.fromUrl( + 'memory://', + module: stemModule, +); + +final result = await StemWorkflowDefinitions.userSignup + .call((email: 'user@example.com')) + .startAndWaitWithApp(app); +print(result?.value); +await app.close(); +``` + +Generated output gives you: + +- `stemModule` +- `StemWorkflowDefinitions` +- `StemTaskDefinitions` +- typed enqueue helpers on `TaskEnqueuer` +- typed result wait helpers on `Stem` + +If your service already owns a `StemApp`, reuse it: + +```dart +final client = await StemClient.fromUrl( + 'redis://localhost:6379', + adapters: const [StemRedisAdapter()], +); + +final workflowApp = await client.createWorkflowApp( + module: stemModule, +); +``` + +#### Mixing workflows and normal tasks + +A workflow can orchestrate durable steps and still enqueue ordinary Stem tasks +for side effects: + +```dart +flow.step('emit-side-effects', (ctx) async { + final order = ctx.previousResult as Map; + + await ctx.enqueuer!.enqueue( + 'ecommerce.audit.log', + args: { + 'event': 'order.checked_out', + 'entityId': order['id'], + 'detail': 'cart=${order['cartId']}', + }, + options: const TaskOptions(queue: 'default'), + ); + + return order; +}); +``` + +That split is the intended model: + +- workflows coordinate durable state transitions +- regular tasks handle side effects and background execution +- both are wired into the same app, and generated modules bundle the two + surfaces together ### Typed workflow completion @@ -339,6 +607,19 @@ if (result?.isCompleted == true) { } ``` +In the example above, these calls inside `run(...)`: + +```dart +final user = await createUser(email); +await sendWelcomeEmail(email); +await sendOneWeekCheckInEmail(email); +``` + +are transformed by generated code into durable `script.step(...)` calls. See +the generated proxy in +`packages/stem_builder/example/lib/definitions.stem.g.dart` for the concrete +lowering. + ### Typed task completion Producers can now wait for individual task results using `Stem.waitForTask` @@ -445,7 +726,7 @@ final client = await StemClient.inMemory( final canvas = Canvas( broker: broker, backend: backend, - registry: registry, + tasks: [SecretTask()], resultEncoder: const Base64ResultEncoder(), argsEncoder: const Base64ResultEncoder(), ); @@ -493,8 +774,8 @@ final unique = UniqueTaskCoordinator( final stem = Stem( broker: broker, - registry: registry, backend: backend, + tasks: [OrdersSyncTask()], uniqueTaskCoordinator: unique, ); ``` @@ -547,13 +828,14 @@ backend metadata under `stem.unique.duplicates`. - Sleeps persist wake timestamps. When a resumed step calls `sleep` again, the runtime skips re-suspending once the stored `resumeAt` is reached so loop handlers can simply call `sleep` without extra guards. -- Use `ctx.takeResumeData()` to detect whether a step is resuming. Call it at - the start of the handler and branch accordingly. +- Use `ctx.takeResumeData()` or `ctx.takeResumeValue(codec: ...)` to detect + whether a step is resuming. Call it at the start of the handler and branch + accordingly. - When you suspend, provide a marker in the `data` payload so the resumed step can distinguish the wake-up path. For example: ```dart - final resume = ctx.takeResumeData(); + final resume = ctx.takeResumeValue(); if (resume != true) { ctx.sleep(const Duration(milliseconds: 200)); return null; @@ -561,7 +843,11 @@ backend metadata under `stem.unique.duplicates`. ``` - Awaited events behave the same way: the emitted payload is delivered via - `takeResumeData()` when the run resumes. + `takeResumeData()` / `takeResumeValue(codec: ...)` when the run resumes. +- When you have a DTO event, emit it through `runtime.emitValue(...)` / + `workflowApp.emitValue(...)` with a `PayloadCodec` instead of hand-building + the payload map. Event payloads still serialize onto the existing + `Map` wire format. - Only return values you want persisted. If a handler returns `null`, the runtime treats it as "no result yet" and will run the step again on resume. - Derive outbound idempotency tokens with `ctx.idempotencyKey('charge')` so diff --git a/packages/stem/example/annotated_workflows/README.md b/packages/stem/example/annotated_workflows/README.md index 01ab7f39..615f0fc4 100644 --- a/packages/stem/example/annotated_workflows/README.md +++ b/packages/stem/example/annotated_workflows/README.md @@ -1,19 +1,78 @@ # Annotated Workflows Example This example shows how to use `@WorkflowDefn`, `@WorkflowStep`, and `@TaskDefn` -with the `stem_builder` registry generator. +with the `stem_builder` bundle generator. + +It now demonstrates the generated script-proxy behavior explicitly: +- a flow step using `FlowContext` +- `run(WelcomeRequest request)` calls annotated step methods directly +- `prepareWelcome(...)` calls other annotated steps +- `deliverWelcome(...)` calls another annotated step from inside an annotated + step +- a second script workflow uses `@WorkflowRun()` plus `WorkflowScriptStepContext` + to expose `runId`, `workflow`, `stepName`, `stepIndex`, and idempotency keys +- a plain script workflow that returns a codec-backed DTO result and persists a + codec-backed DTO checkpoint value +- a typed `@TaskDefn` using `TaskInvocationContext` plus codec-backed DTO + input/output types + +When you run the example, it prints: +- the flow result with `FlowContext` metadata +- the plain script result +- the persisted step order for the plain script workflow +- the persisted JSON form of the plain script DTO checkpoint and DTO result +- the context-aware script result with workflow metadata +- the persisted JSON form of the context-aware DTO result +- the persisted step order for the context-aware workflow +- the typed task result showing a decoded DTO result and task invocation + metadata + +The generated file exposes: + +- `stemModule` +- `StemWorkflowDefinitions` +- typed workflow refs for `StemWorkflowApp` and `WorkflowRuntime` +- typed task definitions, enqueue helpers, and typed result wait helpers + +## Serializable parameter rules + +For `stem_builder`, generated workflow/task entrypoints support required +positional parameters that are either serializable values or codec-backed DTO +types: + +- `String`, `bool`, `int`, `double`, `num`, `Object?`, `null` +- `List` where `T` is serializable +- `Map` where `T` is serializable +- Dart classes with: + - `Map toJson()` + - `factory Type.fromJson(Map json)` or an equivalent named + `fromJson` constructor + +Typed task results can use the same DTO convention. + +Workflow inputs, checkpoint values, and final workflow results can use the same +DTO convention. The generated `PayloadCodec` persists the JSON form while the +workflow continues to work with typed objects. ## Run ```bash cd packages/stem/example/annotated_workflows +dart pub get +dart run build_runner build --delete-conflicting-outputs dart run bin/main.dart ``` -## Regenerate the registry +From the repo root: + +```bash +task demo:annotated +``` + +## Regenerate the bundle ```bash -dart run build_runner build +dart run build_runner build --delete-conflicting-outputs ``` -The generated file is `lib/stem_registry.g.dart`. +The generated file is `lib/definitions.stem.g.dart`. diff --git a/packages/stem/example/annotated_workflows/bin/main.dart b/packages/stem/example/annotated_workflows/bin/main.dart index aa3b08bc..208b05e1 100644 --- a/packages/stem/example/annotated_workflows/bin/main.dart +++ b/packages/stem/example/annotated_workflows/bin/main.dart @@ -1,31 +1,83 @@ +import 'dart:convert'; + import 'package:stem/stem.dart'; -import 'package:stem_annotated_workflows/stem_registry.g.dart'; +import 'package:stem_annotated_workflows/definitions.dart'; Future main() async { final client = await StemClient.inMemory(); - registerStemDefinitions( - workflows: client.workflowRegistry, - tasks: client.taskRegistry, - ); final app = await client.createWorkflowApp( - flows: stemFlows, - scripts: stemScripts, + module: stemModule, + workerConfig: StemWorkerConfig( + queue: 'workflow', + subscription: RoutingSubscription(queues: ['workflow', 'default']), + ), ); await app.start(); - final flowRunId = await app.startWorkflow('annotated.flow'); - final flowResult = await app.waitForCompletion( + final flowRunId = await StemWorkflowDefinitions.flow + .call(const {}) + .startWithApp(app); + final flowResult = await StemWorkflowDefinitions.flow.waitFor( + app, flowRunId, timeout: const Duration(seconds: 2), ); - print('Flow result: ${flowResult?.value}'); + print('Flow result: ${jsonEncode(flowResult?.value)}'); + + final scriptCall = StemWorkflowDefinitions.script.call( + (request: const WelcomeRequest(email: ' SomeEmail@Example.com ')), + ); + final scriptResult = await scriptCall.startAndWaitWithApp( + app, + timeout: const Duration(seconds: 2), + ); + print('Script result: ${jsonEncode(scriptResult?.value?.toJson())}'); + + final scriptDetail = await app.runtime.viewRunDetail(scriptResult!.runId); + final scriptCheckpoints = scriptDetail?.steps + .map((step) => step.baseStepName) + .join(' -> '); + final persistedPreparation = scriptDetail?.steps + .firstWhere((step) => step.baseStepName == 'prepare-welcome') + .value; + print('Script checkpoints: $scriptCheckpoints'); + print( + 'Persisted prepare-welcome checkpoint: ${jsonEncode(persistedPreparation)}', + ); + print('Persisted script result: ${jsonEncode(scriptDetail?.run.result)}'); + print('Script detail: ${jsonEncode(scriptDetail?.toJson())}'); + + final contextCall = StemWorkflowDefinitions.contextScript.call( + (request: const WelcomeRequest(email: ' ContextEmail@Example.com ')), + ); + final contextResult = await contextCall.startAndWaitWithApp( + app, + timeout: const Duration(seconds: 2), + ); + print('Context script result: ${jsonEncode(contextResult?.value?.toJson())}'); + + final contextDetail = await app.runtime.viewRunDetail(contextResult!.runId); + final contextCheckpoints = contextDetail?.steps + .map((step) => step.baseStepName) + .join(' -> '); + print('Context script checkpoints: $contextCheckpoints'); + print('Persisted context result: ${jsonEncode(contextDetail?.run.result)}'); + print('Context script detail: ${jsonEncode(contextDetail?.toJson())}'); - final scriptRunId = await app.startWorkflow('annotated.script'); - final scriptResult = await app.waitForCompletion( - scriptRunId, + final typedTaskId = await app.app.stem.enqueueSendEmailTyped( + dispatch: const EmailDispatch( + email: 'typed@example.com', + subject: 'Welcome', + body: 'Codec-backed DTO payloads', + tags: ['welcome', 'transactional', 'annotated'], + ), + meta: const {'origin': 'annotated_workflows_example'}, + ); + final typedTaskResult = await app.app.stem.waitForSendEmailTyped( + typedTaskId, timeout: const Duration(seconds: 2), ); - print('Script result: ${scriptResult?.value}'); + print('Typed task result: ${jsonEncode(typedTaskResult?.value?.toJson())}'); await app.close(); await client.close(); diff --git a/packages/stem/example/annotated_workflows/lib/definitions.dart b/packages/stem/example/annotated_workflows/lib/definitions.dart index f12364eb..16938b64 100644 --- a/packages/stem/example/annotated_workflows/lib/definitions.dart +++ b/packages/stem/example/annotated_workflows/lib/definitions.dart @@ -1,29 +1,286 @@ import 'package:stem/stem.dart'; +part 'definitions.stem.g.dart'; + +class WelcomeRequest { + const WelcomeRequest({required this.email}); + + final String email; + + Map toJson() => {'email': email}; + + factory WelcomeRequest.fromJson(Map json) { + return WelcomeRequest(email: json['email'] as String); + } +} + +class EmailDispatch { + const EmailDispatch({ + required this.email, + required this.subject, + required this.body, + required this.tags, + }); + + final String email; + final String subject; + final String body; + final List tags; + + Map toJson() => { + 'email': email, + 'subject': subject, + 'body': body, + 'tags': tags, + }; + + factory EmailDispatch.fromJson(Map json) { + return EmailDispatch( + email: json['email'] as String, + subject: json['subject'] as String, + body: json['body'] as String, + tags: (json['tags'] as List).cast(), + ); + } +} + +class EmailDeliveryReceipt { + const EmailDeliveryReceipt({ + required this.taskId, + required this.attempt, + required this.email, + required this.subject, + required this.tags, + required this.meta, + }); + + final String taskId; + final int attempt; + final String email; + final String subject; + final List tags; + final Map meta; + + Map toJson() => { + 'taskId': taskId, + 'attempt': attempt, + 'email': email, + 'subject': subject, + 'tags': tags, + 'meta': meta, + }; + + factory EmailDeliveryReceipt.fromJson(Map json) { + return EmailDeliveryReceipt( + taskId: json['taskId'] as String, + attempt: json['attempt'] as int, + email: json['email'] as String, + subject: json['subject'] as String, + tags: (json['tags'] as List).cast(), + meta: Map.from(json['meta'] as Map), + ); + } +} + +class WelcomePreparation { + const WelcomePreparation({ + required this.normalizedEmail, + required this.subject, + }); + + final String normalizedEmail; + final String subject; + + Map toJson() => { + 'normalizedEmail': normalizedEmail, + 'subject': subject, + }; + + factory WelcomePreparation.fromJson(Map json) { + return WelcomePreparation( + normalizedEmail: json['normalizedEmail'] as String, + subject: json['subject'] as String, + ); + } +} + +class WelcomeWorkflowResult { + const WelcomeWorkflowResult({ + required this.normalizedEmail, + required this.subject, + required this.followUp, + }); + + final String normalizedEmail; + final String subject; + final String followUp; + + Map toJson() => { + 'normalizedEmail': normalizedEmail, + 'subject': subject, + 'followUp': followUp, + }; + + factory WelcomeWorkflowResult.fromJson(Map json) { + return WelcomeWorkflowResult( + normalizedEmail: json['normalizedEmail'] as String, + subject: json['subject'] as String, + followUp: json['followUp'] as String, + ); + } +} + +class ContextCaptureResult { + const ContextCaptureResult({ + required this.workflow, + required this.runId, + required this.stepName, + required this.stepIndex, + required this.iteration, + required this.idempotencyKey, + required this.normalizedEmail, + required this.subject, + }); + + final String workflow; + final String runId; + final String stepName; + final int stepIndex; + final int iteration; + final String idempotencyKey; + final String normalizedEmail; + final String subject; + + Map toJson() => { + 'workflow': workflow, + 'runId': runId, + 'stepName': stepName, + 'stepIndex': stepIndex, + 'iteration': iteration, + 'idempotencyKey': idempotencyKey, + 'normalizedEmail': normalizedEmail, + 'subject': subject, + }; + + factory ContextCaptureResult.fromJson(Map json) { + return ContextCaptureResult( + workflow: json['workflow'] as String, + runId: json['runId'] as String, + stepName: json['stepName'] as String, + stepIndex: json['stepIndex'] as int, + iteration: json['iteration'] as int, + idempotencyKey: json['idempotencyKey'] as String, + normalizedEmail: json['normalizedEmail'] as String, + subject: json['subject'] as String, + ); + } +} + @WorkflowDefn(name: 'annotated.flow') class AnnotatedFlowWorkflow { - @workflow.step - Future start(FlowContext ctx) async { + @WorkflowStep() + Future?> start(FlowContext ctx) async { final resume = ctx.takeResumeData(); if (resume == null) { ctx.sleep(const Duration(milliseconds: 50)); return null; } - return 'flow-complete'; + return { + 'workflow': ctx.workflow, + 'runId': ctx.runId, + 'stepName': ctx.stepName, + 'stepIndex': ctx.stepIndex, + 'iteration': ctx.iteration, + 'idempotencyKey': ctx.idempotencyKey(), + }; } } @WorkflowDefn(name: 'annotated.script', kind: WorkflowKind.script) class AnnotatedScriptWorkflow { + Future run(WelcomeRequest request) async { + final prepared = await prepareWelcome(request); + final normalizedEmail = prepared.normalizedEmail; + final subject = prepared.subject; + final followUp = await deliverWelcome(normalizedEmail, subject); + return WelcomeWorkflowResult( + normalizedEmail: normalizedEmail, + subject: subject, + followUp: followUp, + ); + } + + @WorkflowStep(name: 'prepare-welcome') + Future prepareWelcome(WelcomeRequest request) async { + final normalizedEmail = await normalizeEmail(request.email); + final subject = await buildWelcomeSubject(normalizedEmail); + return WelcomePreparation( + normalizedEmail: normalizedEmail, + subject: subject, + ); + } + + @WorkflowStep(name: 'normalize-email') + Future normalizeEmail(String email) async { + return email.trim().toLowerCase(); + } + + @WorkflowStep(name: 'build-welcome-subject') + Future buildWelcomeSubject(String normalizedEmail) async { + return 'welcome:$normalizedEmail'; + } + + @WorkflowStep(name: 'deliver-welcome') + Future deliverWelcome(String normalizedEmail, String subject) async { + return buildFollowUp(normalizedEmail, subject); + } + + @WorkflowStep(name: 'build-follow-up') + Future buildFollowUp(String normalizedEmail, String subject) async { + return '$subject|follow-up:$normalizedEmail'; + } +} + +@WorkflowDefn(name: 'annotated.context_script', kind: WorkflowKind.script) +class AnnotatedContextScriptWorkflow { @WorkflowRun() - Future run(WorkflowScriptContext script) async { - await script.step('sleep', (ctx) async { - final resume = ctx.takeResumeData(); - if (resume == null) { - await ctx.sleep(const Duration(milliseconds: 50)); - } - }); - return 'script-complete'; + Future run( + WorkflowScriptContext script, + WelcomeRequest request, + ) async { + return script.step( + 'enter-context-step', + (ctx) => captureContext(ctx, request), + ); + } + + @WorkflowStep(name: 'capture-context') + Future captureContext( + WorkflowScriptStepContext ctx, + WelcomeRequest request, + ) async { + final normalizedEmail = await normalizeEmail(request.email); + final subject = await buildWelcomeSubject(normalizedEmail); + return ContextCaptureResult( + workflow: ctx.workflow, + runId: ctx.runId, + stepName: ctx.stepName, + stepIndex: ctx.stepIndex, + iteration: ctx.iteration, + idempotencyKey: ctx.idempotencyKey('welcome'), + normalizedEmail: normalizedEmail, + subject: subject, + ); + } + + @WorkflowStep(name: 'normalize-email') + Future normalizeEmail(String email) async { + return email.trim().toLowerCase(); + } + + @WorkflowStep(name: 'build-welcome-subject') + Future buildWelcomeSubject(String normalizedEmail) async { + return 'welcome:$normalizedEmail'; } } @@ -34,3 +291,23 @@ Future sendEmail( ) async { // No-op task for example purposes. } + +@TaskDefn(name: 'send_email_typed', options: TaskOptions(maxRetries: 1)) +Future sendEmailTyped( + TaskInvocationContext ctx, + EmailDispatch dispatch, +) async { + ctx.heartbeat(); + await ctx.progress( + 100, + data: {'email': dispatch.email, 'tagCount': dispatch.tags.length}, + ); + return EmailDeliveryReceipt( + taskId: ctx.id, + attempt: ctx.attempt, + email: dispatch.email, + subject: dispatch.subject, + tags: dispatch.tags, + meta: ctx.meta, + ); +} diff --git a/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart b/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart new file mode 100644 index 00000000..42c0f107 --- /dev/null +++ b/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart @@ -0,0 +1,400 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: unused_element, unnecessary_lambdas, omit_local_variable_types, unused_import + +part of 'definitions.dart'; + +Map _stemPayloadMap(Object? value, String typeName) { + if (value is Map) { + return Map.from(value); + } + if (value is Map) { + final result = {}; + value.forEach((key, entry) { + if (key is! String) { + throw StateError('$typeName payload must use string keys.'); + } + result[key] = entry; + }); + return result; + } + throw StateError( + '$typeName payload must decode to Map, got ${value.runtimeType}.', + ); +} + +abstract final class StemPayloadCodecs { + static final PayloadCodec welcomeWorkflowResult = + PayloadCodec( + encode: (value) => value.toJson(), + decode: (payload) => WelcomeWorkflowResult.fromJson( + _stemPayloadMap(payload, "WelcomeWorkflowResult"), + ), + ); + static final PayloadCodec welcomeRequest = + PayloadCodec( + encode: (value) => value.toJson(), + decode: (payload) => + WelcomeRequest.fromJson(_stemPayloadMap(payload, "WelcomeRequest")), + ); + static final PayloadCodec welcomePreparation = + PayloadCodec( + encode: (value) => value.toJson(), + decode: (payload) => WelcomePreparation.fromJson( + _stemPayloadMap(payload, "WelcomePreparation"), + ), + ); + static final PayloadCodec contextCaptureResult = + PayloadCodec( + encode: (value) => value.toJson(), + decode: (payload) => ContextCaptureResult.fromJson( + _stemPayloadMap(payload, "ContextCaptureResult"), + ), + ); + static final PayloadCodec emailDispatch = + PayloadCodec( + encode: (value) => value.toJson(), + decode: (payload) => + EmailDispatch.fromJson(_stemPayloadMap(payload, "EmailDispatch")), + ); + static final PayloadCodec emailDeliveryReceipt = + PayloadCodec( + encode: (value) => value.toJson(), + decode: (payload) => EmailDeliveryReceipt.fromJson( + _stemPayloadMap(payload, "EmailDeliveryReceipt"), + ), + ); +} + +final List _stemFlows = [ + Flow( + name: "annotated.flow", + build: (flow) { + final impl = AnnotatedFlowWorkflow(); + flow.step?>( + "start", + (ctx) => impl.start(ctx), + kind: WorkflowStepKind.task, + taskNames: [], + ); + }, + ), +]; + +class _StemScriptProxy0 extends AnnotatedScriptWorkflow { + _StemScriptProxy0(this._script); + final WorkflowScriptContext _script; + @override + Future prepareWelcome(WelcomeRequest request) { + return _script.step( + "prepare-welcome", + (context) => super.prepareWelcome(request), + ); + } + + @override + Future normalizeEmail(String email) { + return _script.step( + "normalize-email", + (context) => super.normalizeEmail(email), + ); + } + + @override + Future buildWelcomeSubject(String normalizedEmail) { + return _script.step( + "build-welcome-subject", + (context) => super.buildWelcomeSubject(normalizedEmail), + ); + } + + @override + Future deliverWelcome(String normalizedEmail, String subject) { + return _script.step( + "deliver-welcome", + (context) => super.deliverWelcome(normalizedEmail, subject), + ); + } + + @override + Future buildFollowUp(String normalizedEmail, String subject) { + return _script.step( + "build-follow-up", + (context) => super.buildFollowUp(normalizedEmail, subject), + ); + } +} + +class _StemScriptProxy1 extends AnnotatedContextScriptWorkflow { + _StemScriptProxy1(this._script); + final WorkflowScriptContext _script; + @override + Future captureContext( + WorkflowScriptStepContext context, + WelcomeRequest request, + ) { + return _script.step( + "capture-context", + (context) => super.captureContext(context, request), + ); + } + + @override + Future normalizeEmail(String email) { + return _script.step( + "normalize-email", + (context) => super.normalizeEmail(email), + ); + } + + @override + Future buildWelcomeSubject(String normalizedEmail) { + return _script.step( + "build-welcome-subject", + (context) => super.buildWelcomeSubject(normalizedEmail), + ); + } +} + +final List _stemScripts = [ + WorkflowScript( + name: "annotated.script", + checkpoints: [ + FlowStep.typed( + name: "prepare-welcome", + handler: _stemScriptManifestStepNoop, + valueCodec: StemPayloadCodecs.welcomePreparation, + kind: WorkflowStepKind.task, + taskNames: [], + ), + FlowStep( + name: "normalize-email", + handler: _stemScriptManifestStepNoop, + kind: WorkflowStepKind.task, + taskNames: [], + ), + FlowStep( + name: "build-welcome-subject", + handler: _stemScriptManifestStepNoop, + kind: WorkflowStepKind.task, + taskNames: [], + ), + FlowStep( + name: "deliver-welcome", + handler: _stemScriptManifestStepNoop, + kind: WorkflowStepKind.task, + taskNames: [], + ), + FlowStep( + name: "build-follow-up", + handler: _stemScriptManifestStepNoop, + kind: WorkflowStepKind.task, + taskNames: [], + ), + ], + resultCodec: StemPayloadCodecs.welcomeWorkflowResult, + run: (script) => _StemScriptProxy0(script).run( + StemPayloadCodecs.welcomeRequest.decode( + _stemRequireArg(script.params, "request"), + ), + ), + ), + WorkflowScript( + name: "annotated.context_script", + checkpoints: [ + FlowStep.typed( + name: "capture-context", + handler: _stemScriptManifestStepNoop, + valueCodec: StemPayloadCodecs.contextCaptureResult, + kind: WorkflowStepKind.task, + taskNames: [], + ), + FlowStep( + name: "normalize-email", + handler: _stemScriptManifestStepNoop, + kind: WorkflowStepKind.task, + taskNames: [], + ), + FlowStep( + name: "build-welcome-subject", + handler: _stemScriptManifestStepNoop, + kind: WorkflowStepKind.task, + taskNames: [], + ), + ], + resultCodec: StemPayloadCodecs.contextCaptureResult, + run: (script) => _StemScriptProxy1(script).run( + script, + StemPayloadCodecs.welcomeRequest.decode( + _stemRequireArg(script.params, "request"), + ), + ), + ), +]; + +abstract final class StemWorkflowDefinitions { + static final WorkflowRef, Map?> flow = + WorkflowRef, Map?>( + name: "annotated.flow", + encodeParams: (params) => params, + ); + static final WorkflowRef<({WelcomeRequest request}), WelcomeWorkflowResult> + script = WorkflowRef<({WelcomeRequest request}), WelcomeWorkflowResult>( + name: "annotated.script", + encodeParams: (params) => { + "request": StemPayloadCodecs.welcomeRequest.encode(params.request), + }, + decodeResult: StemPayloadCodecs.welcomeWorkflowResult.decode, + ); + static final WorkflowRef<({WelcomeRequest request}), ContextCaptureResult> + contextScript = WorkflowRef<({WelcomeRequest request}), ContextCaptureResult>( + name: "annotated.context_script", + encodeParams: (params) => { + "request": StemPayloadCodecs.welcomeRequest.encode(params.request), + }, + decodeResult: StemPayloadCodecs.contextCaptureResult.decode, + ); +} + +Future _stemScriptManifestStepNoop(FlowContext context) async => null; + +Object? _stemRequireArg(Map args, String name) { + if (!args.containsKey(name)) { + throw ArgumentError('Missing required argument "$name".'); + } + return args[name]; +} + +Future _stemTaskAdapter0( + TaskInvocationContext context, + Map args, +) async { + return await Future.value( + sendEmailTyped( + context, + StemPayloadCodecs.emailDispatch.decode(_stemRequireArg(args, "dispatch")), + ), + ); +} + +abstract final class StemTaskDefinitions { + static final TaskDefinition, Object?> sendEmail = + TaskDefinition, Object?>( + name: "send_email", + encodeArgs: (args) => args, + defaultOptions: const TaskOptions(maxRetries: 1), + metadata: const TaskMetadata(), + ); + static final TaskDefinition<({EmailDispatch dispatch}), EmailDeliveryReceipt> + sendEmailTyped = + TaskDefinition<({EmailDispatch dispatch}), EmailDeliveryReceipt>( + name: "send_email_typed", + encodeArgs: (args) => { + "dispatch": StemPayloadCodecs.emailDispatch.encode(args.dispatch), + }, + defaultOptions: const TaskOptions(maxRetries: 1), + metadata: const TaskMetadata(), + decodeResult: StemPayloadCodecs.emailDeliveryReceipt.decode, + ); +} + +extension StemGeneratedTaskEnqueuer on TaskEnqueuer { + Future enqueueSendEmail({ + required Map args, + Map headers = const {}, + TaskOptions? options, + DateTime? notBefore, + Map? meta, + TaskEnqueueOptions? enqueueOptions, + }) { + return enqueueCall( + StemTaskDefinitions.sendEmail.call( + args, + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ), + ); + } + + Future enqueueSendEmailTyped({ + required EmailDispatch dispatch, + Map headers = const {}, + TaskOptions? options, + DateTime? notBefore, + Map? meta, + TaskEnqueueOptions? enqueueOptions, + }) { + return enqueueCall( + StemTaskDefinitions.sendEmailTyped.call( + (dispatch: dispatch), + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ), + ); + } +} + +extension StemGeneratedTaskResults on Stem { + Future?> waitForSendEmail( + String taskId, { + Duration? timeout, + }) { + return waitForTaskDefinition( + taskId, + StemTaskDefinitions.sendEmail, + timeout: timeout, + ); + } + + Future?> waitForSendEmailTyped( + String taskId, { + Duration? timeout, + }) { + return waitForTaskDefinition( + taskId, + StemTaskDefinitions.sendEmailTyped, + timeout: timeout, + ); + } +} + +final List> _stemTasks = >[ + FunctionTaskHandler( + name: "send_email", + entrypoint: sendEmail, + options: const TaskOptions(maxRetries: 1), + metadata: const TaskMetadata(), + ), + FunctionTaskHandler( + name: "send_email_typed", + entrypoint: _stemTaskAdapter0, + options: const TaskOptions(maxRetries: 1), + metadata: TaskMetadata( + tags: [], + idempotent: false, + attributes: {}, + resultEncoder: CodecTaskPayloadEncoder( + idValue: "stem.generated.send_email_typed.result", + codec: StemPayloadCodecs.emailDeliveryReceipt, + ), + ), + ), +]; + +final List _stemWorkflowManifest = + [ + ..._stemFlows.map((flow) => flow.definition.toManifestEntry()), + ..._stemScripts.map((script) => script.definition.toManifestEntry()), + ]; + +final StemModule stemModule = StemModule( + flows: _stemFlows, + scripts: _stemScripts, + tasks: _stemTasks, + workflowManifest: _stemWorkflowManifest, +); diff --git a/packages/stem/example/annotated_workflows/lib/stem_registry.g.dart b/packages/stem/example/annotated_workflows/lib/stem_registry.g.dart deleted file mode 100644 index 5484cd13..00000000 --- a/packages/stem/example/annotated_workflows/lib/stem_registry.g.dart +++ /dev/null @@ -1,47 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: unused_element, unnecessary_lambdas, omit_local_variable_types - -import 'package:stem/stem.dart'; -import 'package:stem_annotated_workflows/definitions.dart'; - -final List stemFlows = [ - Flow( - name: 'annotated.flow', - build: (flow) { - final impl = AnnotatedFlowWorkflow(); - flow.step( - 'start', - (ctx) => impl.start(ctx), - ); - }, - ), -]; - -final List stemScripts = [ - WorkflowScript( - name: 'annotated.script', - run: (script) => AnnotatedScriptWorkflow().run(script), - ), -]; - -final List> stemTasks = >[ - FunctionTaskHandler( - name: 'send_email', - entrypoint: sendEmail, - ), -]; - -void registerStemDefinitions({ - required WorkflowRegistry workflows, - required TaskRegistry tasks, -}) { - for (final flow in stemFlows) { - workflows.register(flow.definition); - } - for (final script in stemScripts) { - workflows.register(script.definition); - } - for (final handler in stemTasks) { - tasks.register(handler); - } -} diff --git a/packages/stem/example/annotated_workflows/pubspec.yaml b/packages/stem/example/annotated_workflows/pubspec.yaml index 59522544..ffc575ad 100644 --- a/packages/stem/example/annotated_workflows/pubspec.yaml +++ b/packages/stem/example/annotated_workflows/pubspec.yaml @@ -2,14 +2,21 @@ name: stem_annotated_workflows description: Example app using annotated Stem workflows and tasks. publish_to: 'none' version: 0.0.1 -resolution: workspace environment: sdk: ">=3.9.2 <4.0.0" dependencies: - stem: ^0.1.0 + stem: + path: ../.. dev_dependencies: - build_runner: ^2.10.4 - stem_builder: ^0.1.0 + build_runner: ^2.10.5 + stem_builder: + path: ../../../stem_builder + +dependency_overrides: + stem: + path: ../.. + stem_memory: + path: ../../../stem_memory diff --git a/packages/stem/example/autoscaling_demo/bin/producer.dart b/packages/stem/example/autoscaling_demo/bin/producer.dart index 9ebdd8cd..567e729a 100644 --- a/packages/stem/example/autoscaling_demo/bin/producer.dart +++ b/packages/stem/example/autoscaling_demo/bin/producer.dart @@ -8,7 +8,7 @@ Future main() async { final broker = await connectBroker(config.brokerUrl, tls: config.tls); final backendUrl = config.resultBackendUrl ?? config.brokerUrl; final backend = await connectBackend(backendUrl, tls: config.tls); - final registry = buildRegistry(); + final tasks = buildTasks(); final taskCount = _parseInt('TASKS', fallback: 48, min: 1); final burst = _parseInt('BURST', fallback: 12, min: 1); @@ -21,7 +21,7 @@ Future main() async { 'tasks=$taskCount burst=$burst pauseMs=$pauseMs durationMs=$durationMs', ); - final stem = Stem(broker: broker, registry: registry, backend: backend); + final stem = Stem(broker: broker, tasks: tasks, backend: backend); const options = TaskOptions(queue: autoscaleQueue); if (initialDelayMs > 0) { diff --git a/packages/stem/example/autoscaling_demo/bin/worker.dart b/packages/stem/example/autoscaling_demo/bin/worker.dart index 43133441..defe5750 100644 --- a/packages/stem/example/autoscaling_demo/bin/worker.dart +++ b/packages/stem/example/autoscaling_demo/bin/worker.dart @@ -10,7 +10,7 @@ Future main() async { final backendUrl = config.resultBackendUrl ?? config.brokerUrl; final backend = await connectBackend(backendUrl, tls: config.tls); - final registry = buildRegistry(); + final tasks = buildTasks(); final observability = ObservabilityConfig.fromEnvironment(); final workerName = @@ -44,7 +44,7 @@ Future main() async { final worker = Worker( broker: broker, - registry: registry, + tasks: tasks, backend: backend, queue: autoscaleQueue, subscription: RoutingSubscription.singleQueue(autoscaleQueue), diff --git a/packages/stem/example/autoscaling_demo/lib/shared.dart b/packages/stem/example/autoscaling_demo/lib/shared.dart index 6e208cd8..d2b1e1b7 100644 --- a/packages/stem/example/autoscaling_demo/lib/shared.dart +++ b/packages/stem/example/autoscaling_demo/lib/shared.dart @@ -14,17 +14,13 @@ Future connectBackend(String url, {TlsConfig? tls}) { return RedisResultBackend.connect(url, tls: tls); } -SimpleTaskRegistry buildRegistry() { - final registry = SimpleTaskRegistry(); - registry.register( - FunctionTaskHandler( - name: 'autoscale.work', - options: const TaskOptions(queue: autoscaleQueue), - entrypoint: _autoscaleEntrypoint, - ), - ); - return registry; -} +List> buildTasks() => [ + FunctionTaskHandler( + name: 'autoscale.work', + options: const TaskOptions(queue: autoscaleQueue), + entrypoint: _autoscaleEntrypoint, + ), + ]; FutureOr _autoscaleEntrypoint( TaskInvocationContext context, diff --git a/packages/stem/example/canvas_patterns/chain_example.dart b/packages/stem/example/canvas_patterns/chain_example.dart index 23ad24b8..67e49084 100644 --- a/packages/stem/example/canvas_patterns/chain_example.dart +++ b/packages/stem/example/canvas_patterns/chain_example.dart @@ -5,45 +5,39 @@ import 'package:stem/stem.dart'; Future main() async { final broker = InMemoryBroker(); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry() - ..register( - FunctionTaskHandler( - name: 'fetch.user', - entrypoint: (context, args) async => 'Ada', - ), - ) - ..register( - FunctionTaskHandler( - name: 'enrich.user', - entrypoint: (context, args) async { - final prev = context.meta['chainPrevResult'] as String? ?? 'Friend'; - return '$prev Lovelace'; - }, - ), - ) - ..register( - FunctionTaskHandler( - name: 'send.email', - entrypoint: (context, args) async { - final fullName = - context.meta['chainPrevResult'] as String? ?? 'Friend'; - print('Sending email to $fullName'); - return null; - }, - ), - ); + final tasks = >[ + FunctionTaskHandler( + name: 'fetch.user', + entrypoint: (context, args) async => 'Ada', + ), + FunctionTaskHandler( + name: 'enrich.user', + entrypoint: (context, args) async { + final prev = context.meta['chainPrevResult'] as String? ?? 'Friend'; + return '$prev Lovelace'; + }, + ), + FunctionTaskHandler( + name: 'send.email', + entrypoint: (context, args) async { + final fullName = context.meta['chainPrevResult'] as String? ?? 'Friend'; + print('Sending email to $fullName'); + return null; + }, + ), + ]; final worker = Worker( broker: broker, - registry: registry, backend: backend, + tasks: tasks, consumerName: 'chain-worker', concurrency: 1, prefetchMultiplier: 1, ); await worker.start(); - final canvas = Canvas(broker: broker, backend: backend, registry: registry); + final canvas = Canvas(broker: broker, backend: backend, tasks: tasks); final chainResult = await canvas.chain([ task('fetch.user'), task('enrich.user'), diff --git a/packages/stem/example/canvas_patterns/chord_example.dart b/packages/stem/example/canvas_patterns/chord_example.dart index fa86c604..4d9034a5 100644 --- a/packages/stem/example/canvas_patterns/chord_example.dart +++ b/packages/stem/example/canvas_patterns/chord_example.dart @@ -5,43 +5,40 @@ import 'package:stem/stem.dart'; Future main() async { final broker = InMemoryBroker(); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry() - ..register( - FunctionTaskHandler( - name: 'fetch.metric', - entrypoint: (context, args) async { - await Future.delayed(const Duration(milliseconds: 40)); - return args['value'] as int; - }, - ), - ) - ..register( - FunctionTaskHandler( - name: 'aggregate.metric', - entrypoint: (context, args) async { - final values = - (context.meta['chordResults'] as List?) - ?.whereType() - .toList() ?? - const []; - final sum = values.fold(0, (a, b) => a + b); - print('Aggregated result: $sum'); - return null; - }, - ), - ); + final tasks = >[ + FunctionTaskHandler( + name: 'fetch.metric', + entrypoint: (context, args) async { + await Future.delayed(const Duration(milliseconds: 40)); + return args['value'] as int; + }, + ), + FunctionTaskHandler( + name: 'aggregate.metric', + entrypoint: (context, args) async { + final values = + (context.meta['chordResults'] as List?) + ?.whereType() + .toList() ?? + const []; + final sum = values.fold(0, (a, b) => a + b); + print('Aggregated result: $sum'); + return null; + }, + ), + ]; final worker = Worker( broker: broker, - registry: registry, backend: backend, + tasks: tasks, consumerName: 'chord-worker', concurrency: 3, prefetchMultiplier: 1, ); await worker.start(); - final canvas = Canvas(broker: broker, backend: backend, registry: registry); + final canvas = Canvas(broker: broker, backend: backend, tasks: tasks); final chordResult = await canvas.chord( body: [ task('fetch.metric', args: {'value': 5}), diff --git a/packages/stem/example/canvas_patterns/group_example.dart b/packages/stem/example/canvas_patterns/group_example.dart index cd4e81d9..4ab069a8 100644 --- a/packages/stem/example/canvas_patterns/group_example.dart +++ b/packages/stem/example/canvas_patterns/group_example.dart @@ -3,29 +3,28 @@ import 'package:stem/stem.dart'; Future main() async { final broker = InMemoryBroker(); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry() - ..register( - FunctionTaskHandler( - name: 'square', - entrypoint: (context, args) async { - final value = args['value'] as int; - await Future.delayed(const Duration(milliseconds: 50)); - return value * value; - }, - ), - ); + final tasks = >[ + FunctionTaskHandler( + name: 'square', + entrypoint: (context, args) async { + final value = args['value'] as int; + await Future.delayed(const Duration(milliseconds: 50)); + return value * value; + }, + ), + ]; final worker = Worker( broker: broker, - registry: registry, backend: backend, + tasks: tasks, consumerName: 'group-worker', concurrency: 2, prefetchMultiplier: 1, ); await worker.start(); - final canvas = Canvas(broker: broker, backend: backend, registry: registry); + final canvas = Canvas(broker: broker, backend: backend, tasks: tasks); const groupHandle = 'squares-demo'; await backend.initGroup(GroupDescriptor(id: groupHandle, expected: 3)); final dispatch = await canvas.group([ diff --git a/packages/stem/example/dlq_sandbox/bin/producer.dart b/packages/stem/example/dlq_sandbox/bin/producer.dart index 33983ee4..f900c843 100644 --- a/packages/stem/example/dlq_sandbox/bin/producer.dart +++ b/packages/stem/example/dlq_sandbox/bin/producer.dart @@ -13,10 +13,10 @@ Future main() async { final broker = await connectBroker(brokerUrl); final backend = await connectBackend(backendUrl); - final registry = buildRegistry(); + final tasks = buildTasks(); final stem = buildStem( broker: broker, - registry: registry, + tasks: tasks, backend: backend, ); diff --git a/packages/stem/example/dlq_sandbox/bin/worker.dart b/packages/stem/example/dlq_sandbox/bin/worker.dart index b874b446..6378a5e4 100644 --- a/packages/stem/example/dlq_sandbox/bin/worker.dart +++ b/packages/stem/example/dlq_sandbox/bin/worker.dart @@ -13,12 +13,12 @@ Future main() async { final broker = await connectBroker(brokerUrl); final backend = await connectBackend(backendUrl); - final registry = buildRegistry(); + final tasks = buildTasks(); final subscriptions = attachSignalLogging(); final worker = Worker( broker: broker, - registry: registry, + tasks: tasks, backend: backend, queue: queueName(), consumerName: Platform.environment['WORKER_NAME'] ?? 'dlq-sandbox-worker', diff --git a/packages/stem/example/dlq_sandbox/lib/shared.dart b/packages/stem/example/dlq_sandbox/lib/shared.dart index 4f57dfc5..779787ff 100644 --- a/packages/stem/example/dlq_sandbox/lib/shared.dart +++ b/packages/stem/example/dlq_sandbox/lib/shared.dart @@ -8,9 +8,7 @@ import 'package:stem_redis/stem_redis.dart'; const _queueName = 'default'; const _taskName = 'billing.invoice.process'; -SimpleTaskRegistry buildRegistry() { - final registry = SimpleTaskRegistry() - ..register( +List> buildTasks() => [ FunctionTaskHandler( name: _taskName, options: const TaskOptions( @@ -20,18 +18,16 @@ SimpleTaskRegistry buildRegistry() { ), entrypoint: _invoiceEntrypoint, ), - ); - return registry; -} + ]; Stem buildStem({ required Broker broker, - required TaskRegistry registry, + required Iterable> tasks, ResultBackend? backend, }) { return Stem( broker: broker, - registry: registry, + tasks: tasks, backend: backend, ); } diff --git a/packages/stem/example/docs_snippets/lib/best_practices.dart b/packages/stem/example/docs_snippets/lib/best_practices.dart index af417648..84f16cc4 100644 --- a/packages/stem/example/docs_snippets/lib/best_practices.dart +++ b/packages/stem/example/docs_snippets/lib/best_practices.dart @@ -35,15 +35,15 @@ Future enqueueTyped(Stem stem) async { // #endregion best-practices-enqueue Future main() async { - final registry = SimpleTaskRegistry()..register(IdempotentTask()); final broker = InMemoryBroker(); final backend = InMemoryResultBackend(); - final stem = Stem(broker: broker, registry: registry, backend: backend); + final tasks = [IdempotentTask()]; + final stem = Stem(broker: broker, backend: backend, tasks: tasks); final worker = Worker( broker: broker, - registry: registry, backend: backend, + tasks: tasks, queue: 'default', ); unawaited(worker.start()); diff --git a/packages/stem/example/docs_snippets/lib/developer_environment.dart b/packages/stem/example/docs_snippets/lib/developer_environment.dart index 95c91f23..df2b60b2 100644 --- a/packages/stem/example/docs_snippets/lib/developer_environment.dart +++ b/packages/stem/example/docs_snippets/lib/developer_environment.dart @@ -7,7 +7,7 @@ import 'package:stem/stem.dart'; import 'package:stem_redis/stem_redis.dart'; // #region dev-env-bootstrap -Future bootstrapStem(SimpleTaskRegistry registry) async { +Future bootstrapStem(List> tasks) async { // #region dev-env-config final config = StemConfig.fromEnvironment(Platform.environment); // #endregion dev-env-config @@ -32,7 +32,7 @@ Future bootstrapStem(SimpleTaskRegistry registry) async { final stem = Stem( broker: broker, backend: backend, - registry: registry, + tasks: tasks, routing: routing, ); // #endregion dev-env-stem @@ -42,7 +42,7 @@ Future bootstrapStem(SimpleTaskRegistry registry) async { final worker = Worker( broker: broker, backend: backend, - registry: registry, + tasks: tasks, revokeStore: revokeStore, rateLimiter: rateLimiter, queue: config.defaultQueue, @@ -84,7 +84,7 @@ class Bootstrap { // #region dev-env-canvas Future runCanvasFlows( Bootstrap bootstrap, - SimpleTaskRegistry registry, + List> tasks, ) async { final canvas = Canvas( broker: bootstrap.stem.broker, @@ -95,7 +95,7 @@ Future runCanvasFlows( 1, ), ), - registry: registry, + tasks: tasks, ); final ids = await canvas.group([ @@ -187,43 +187,38 @@ Future main() async { return; } - final registry = SimpleTaskRegistry() - ..register( - FunctionTaskHandler( - name: 'media.resize', - entrypoint: (context, args) async { - final file = args['file'] as String? ?? 'asset.png'; - print('Resized $file'); - return file; - }, - ), - ) - ..register( - FunctionTaskHandler( - name: 'reports.render', - entrypoint: (context, args) async { - final week = args['week'] as String? ?? '2024-W01'; - print('Rendered report $week'); - return week; - }, - ), - ) - ..register( - FunctionTaskHandler( - name: 'billing.email-receipt', - entrypoint: (context, args) async { - final to = args['to'] as String? ?? 'ops@example.com'; - print('Queued receipt email to $to'); - return null; - }, - options: const TaskOptions(queue: 'emails'), - ), - ); + final tasks = >[ + FunctionTaskHandler( + name: 'media.resize', + entrypoint: (context, args) async { + final file = args['file'] as String? ?? 'asset.png'; + print('Resized $file'); + return file; + }, + ), + FunctionTaskHandler( + name: 'reports.render', + entrypoint: (context, args) async { + final week = args['week'] as String? ?? '2024-W01'; + print('Rendered report $week'); + return week; + }, + ), + FunctionTaskHandler( + name: 'billing.email-receipt', + entrypoint: (context, args) async { + final to = args['to'] as String? ?? 'ops@example.com'; + print('Queued receipt email to $to'); + return null; + }, + options: const TaskOptions(queue: 'emails'), + ), + ]; installSignalHandlers(); - final bootstrap = await bootstrapStem(registry); + final bootstrap = await bootstrapStem(tasks); await bootstrap.worker.start(); - await runCanvasFlows(bootstrap, registry); + await runCanvasFlows(bootstrap, tasks); await Future.delayed(const Duration(seconds: 1)); await bootstrap.worker.shutdown(); } diff --git a/packages/stem/example/docs_snippets/lib/namespaces.dart b/packages/stem/example/docs_snippets/lib/namespaces.dart index 1f1f176c..59c6943c 100644 --- a/packages/stem/example/docs_snippets/lib/namespaces.dart +++ b/packages/stem/example/docs_snippets/lib/namespaces.dart @@ -56,14 +56,13 @@ Future isolateNamespaces() async { // #region namespaces-worker Future configureWorkerNamespace() async { - final registry = SimpleTaskRegistry(); final broker = InMemoryBroker(namespace: 'prod-us-east'); final backend = InMemoryResultBackend(); final worker = Worker( broker: broker, - registry: registry, backend: backend, + tasks: const [], heartbeatNamespace: 'prod-us-east', ); diff --git a/packages/stem/example/docs_snippets/lib/observability.dart b/packages/stem/example/docs_snippets/lib/observability.dart index 2b9cdce8..b1d88e0b 100644 --- a/packages/stem/example/docs_snippets/lib/observability.dart +++ b/packages/stem/example/docs_snippets/lib/observability.dart @@ -14,14 +14,14 @@ void configureMetrics() { Stem buildTracedStem( Broker broker, ResultBackend backend, - TaskRegistry registry, + Iterable> tasks, ) { // Configure OpenTelemetry globally; StemTracer.instance reads from it. final _ = StemTracer.instance; return Stem( broker: broker, - registry: registry, backend: backend, + tasks: tasks, ); } // #endregion observability-tracing @@ -70,20 +70,19 @@ Future main() async { configureMetrics(); registerSignals(); - final registry = SimpleTaskRegistry() - ..register( - FunctionTaskHandler( - name: 'demo.trace', - entrypoint: (context, args) async { - print('Tracing demo task'); - return null; - }, - ), - ); + final tasks = [ + FunctionTaskHandler( + name: 'demo.trace', + entrypoint: (context, args) async { + print('Tracing demo task'); + return null; + }, + ), + ]; final broker = InMemoryBroker(); final backend = InMemoryResultBackend(); - final stem = buildTracedStem(broker, backend, registry); + final stem = buildTracedStem(broker, backend, tasks); logTaskStart( Envelope( diff --git a/packages/stem/example/docs_snippets/lib/persistence.dart b/packages/stem/example/docs_snippets/lib/persistence.dart index 76438061..557c54ce 100644 --- a/packages/stem/example/docs_snippets/lib/persistence.dart +++ b/packages/stem/example/docs_snippets/lib/persistence.dart @@ -9,16 +9,15 @@ import 'package:stem_postgres/stem_postgres.dart'; import 'package:stem_redis/stem_redis.dart'; import 'package:stem_sqlite/stem_sqlite.dart'; -final registry = SimpleTaskRegistry() - ..register( - FunctionTaskHandler( - name: 'demo', - entrypoint: (context, args) async { - print('Handled demo task'); - return null; - }, - ), - ); +final demoTasks = [ + FunctionTaskHandler( + name: 'demo', + entrypoint: (context, args) async { + print('Handled demo task'); + return null; + }, + ), +]; // #region persistence-backend-in-memory Future connectInMemoryBackend() async { @@ -26,8 +25,8 @@ Future connectInMemoryBackend() async { final backend = InMemoryResultBackend(); final stem = Stem( broker: broker, - registry: registry, backend: backend, + tasks: demoTasks, ); await stem.enqueue('demo', args: {}); await backend.close(); @@ -41,8 +40,8 @@ Future connectRedisBackend() async { final broker = await RedisStreamsBroker.connect('redis://localhost:6379'); final stem = Stem( broker: broker, - registry: registry, backend: backend, + tasks: demoTasks, ); await stem.enqueue('demo', args: {}); await backend.close(); @@ -58,8 +57,8 @@ Future connectPostgresBackend() async { final broker = await RedisStreamsBroker.connect('redis://localhost:6379'); final stem = Stem( broker: broker, - registry: registry, backend: backend, + tasks: demoTasks, ); await stem.enqueue('demo', args: {}); await backend.close(); @@ -73,8 +72,8 @@ Future connectSqliteBackend() async { final backend = await SqliteResultBackend.open(File('stem_backend.sqlite')); final stem = Stem( broker: broker, - registry: registry, backend: backend, + tasks: demoTasks, ); await stem.enqueue('demo', args: {}); await backend.close(); @@ -134,8 +133,8 @@ Future configurePostgresRevokeStore() async { ); final worker = Worker( broker: broker, - registry: registry, backend: backend, + tasks: demoTasks, revokeStore: revokeStore, ); @@ -156,8 +155,8 @@ Future configureSqliteRevokeStore() async { ); final worker = Worker( broker: broker, - registry: registry, backend: backend, + tasks: demoTasks, revokeStore: revokeStore, ); diff --git a/packages/stem/example/docs_snippets/lib/producer.dart b/packages/stem/example/docs_snippets/lib/producer.dart index a4a914c5..3105e629 100644 --- a/packages/stem/example/docs_snippets/lib/producer.dart +++ b/packages/stem/example/docs_snippets/lib/producer.dart @@ -38,22 +38,21 @@ Future enqueueWithRedis() async { final broker = await RedisStreamsBroker.connect(brokerUrl); final backend = await RedisResultBackend.connect('$brokerUrl/1'); - final registry = SimpleTaskRegistry() - ..register( - FunctionTaskHandler( - name: 'reports.generate', - entrypoint: (context, args) async { - final id = args['reportId'] as String? ?? 'unknown'; - print('Queued report $id'); - return null; - }, - ), - ); + final tasks = [ + FunctionTaskHandler( + name: 'reports.generate', + entrypoint: (context, args) async { + final id = args['reportId'] as String? ?? 'unknown'; + print('Queued report $id'); + return null; + }, + ), + ]; final stem = Stem( broker: broker, - registry: registry, backend: backend, + tasks: tasks, ); await stem.enqueue( @@ -75,21 +74,20 @@ Future enqueueWithSigning() async { tls: config.tls, ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry() - ..register( - FunctionTaskHandler( - name: 'billing.charge', - entrypoint: (context, args) async { - final customerId = args['customerId'] as String? ?? 'unknown'; - print('Queued charge for $customerId'); - return null; - }, - ), - ); + final tasks = [ + FunctionTaskHandler( + name: 'billing.charge', + entrypoint: (context, args) async { + final customerId = args['customerId'] as String? ?? 'unknown'; + print('Queued charge for $customerId'); + return null; + }, + ), + ]; final stem = Stem( broker: broker, - registry: registry, backend: backend, + tasks: tasks, signer: PayloadSigner.maybe(config.signing), ); diff --git a/packages/stem/example/docs_snippets/lib/production_checklist.dart b/packages/stem/example/docs_snippets/lib/production_checklist.dart index 7cef8810..a78e310d 100644 --- a/packages/stem/example/docs_snippets/lib/production_checklist.dart +++ b/packages/stem/example/docs_snippets/lib/production_checklist.dart @@ -17,16 +17,15 @@ Future configureSigning() async { // #region production-signing-registry final broker = InMemoryBroker(); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry() - ..register( - FunctionTaskHandler( - name: 'audit.log', - entrypoint: (context, args) async { - print('Audit log: ${args['message']}'); - return null; - }, - ), - ); + final tasks = [ + FunctionTaskHandler( + name: 'audit.log', + entrypoint: (context, args) async { + print('Audit log: ${args['message']}'); + return null; + }, + ), + ]; // #endregion production-signing-registry // #region production-signing-runtime @@ -34,7 +33,7 @@ Future configureSigning() async { final stem = Stem( broker: broker, backend: backend, - registry: registry, + tasks: tasks, signer: signer, ); // #endregion production-signing-stem @@ -43,7 +42,7 @@ Future configureSigning() async { final worker = Worker( broker: broker, backend: backend, - registry: registry, + tasks: tasks, signer: signer, ); // #endregion production-signing-worker diff --git a/packages/stem/example/docs_snippets/lib/routing.dart b/packages/stem/example/docs_snippets/lib/routing.dart index e2e77627..4a0e8181 100644 --- a/packages/stem/example/docs_snippets/lib/routing.dart +++ b/packages/stem/example/docs_snippets/lib/routing.dart @@ -43,7 +43,7 @@ final priorityRegistry = RoutingRegistry( // #region routing-bootstrap Future<(Stem, Worker)> bootstrapStem() async { final routing = await loadRouting(); - final registry = SimpleTaskRegistry()..register(EmailTask()); + final tasks = [EmailTask()]; final config = StemConfig.fromEnvironment(); final subscription = RoutingSubscription( queues: config.workerQueues.isEmpty @@ -54,15 +54,15 @@ Future<(Stem, Worker)> bootstrapStem() async { final stem = Stem( broker: await RedisStreamsBroker.connect('redis://localhost:6379'), - registry: registry, backend: InMemoryResultBackend(), + tasks: tasks, routing: routing, ); final worker = Worker( broker: await RedisStreamsBroker.connect('redis://localhost:6379'), - registry: registry, backend: InMemoryResultBackend(), + tasks: tasks, subscription: subscription, ); diff --git a/packages/stem/example/docs_snippets/lib/scheduler.dart b/packages/stem/example/docs_snippets/lib/scheduler.dart index 029931ad..0aa17799 100644 --- a/packages/stem/example/docs_snippets/lib/scheduler.dart +++ b/packages/stem/example/docs_snippets/lib/scheduler.dart @@ -24,7 +24,6 @@ Future loadSchedules() async { // #region beat-dev Future main() async { - final registry = SimpleTaskRegistry()..register(DemoTask()); final broker = InMemoryBroker(); final store = InMemoryScheduleStore(); final lockStore = InMemoryLockStore(); diff --git a/packages/stem/example/docs_snippets/lib/signing.dart b/packages/stem/example/docs_snippets/lib/signing.dart index 4eac0721..8a808bb3 100644 --- a/packages/stem/example/docs_snippets/lib/signing.dart +++ b/packages/stem/example/docs_snippets/lib/signing.dart @@ -30,13 +30,13 @@ PayloadSigner? buildSigningSigner() { Stem buildSignedProducer( Broker broker, ResultBackend backend, - TaskRegistry registry, + Iterable> tasks, PayloadSigner? signer, ) { return Stem( broker: broker, - registry: registry, backend: backend, + tasks: tasks, signer: signer, ); } @@ -53,13 +53,13 @@ PayloadSigner? buildWorkerSigner() { Worker buildSignedWorker( Broker broker, ResultBackend backend, - TaskRegistry registry, + Iterable> tasks, PayloadSigner? signer, ) { return Worker( broker: broker, - registry: registry, backend: backend, + tasks: tasks, signer: signer, queue: 'billing', consumerName: 'billing-worker', @@ -109,16 +109,16 @@ Future enqueueDuringRotation(Stem stem) async { Future main() async { final broker = InMemoryBroker(); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry()..register(BillingTask()); + final tasks = [BillingTask()]; final signer = buildSigningSigner(); - final stem = buildSignedProducer(broker, backend, registry, signer); + final stem = buildSignedProducer(broker, backend, tasks, signer); await stem.enqueue( 'billing.charge', args: {'customerId': 'cust_demo', 'amount': 2500}, ); - final worker = buildSignedWorker(broker, backend, registry, signer); + final worker = buildSignedWorker(broker, backend, tasks, signer); unawaited(worker.start()); await Future.delayed(const Duration(milliseconds: 200)); await worker.shutdown(); diff --git a/packages/stem/example/docs_snippets/lib/tasks.dart b/packages/stem/example/docs_snippets/lib/tasks.dart index be5530a2..29a289b7 100644 --- a/packages/stem/example/docs_snippets/lib/tasks.dart +++ b/packages/stem/example/docs_snippets/lib/tasks.dart @@ -20,7 +20,7 @@ class EmailTask extends TaskHandler { } } -final registry = SimpleTaskRegistry()..register(EmailTask()); +final inMemoryTasks = [EmailTask()]; // #endregion tasks-register-in-memory // #region tasks-register-redis @@ -43,7 +43,7 @@ class RedisEmailTask extends TaskHandler { } } -final redisRegistry = SimpleTaskRegistry()..register(RedisEmailTask()); +final redisTasks = [RedisEmailTask()]; // #endregion tasks-register-redis // #region tasks-typed-definition @@ -78,8 +78,8 @@ Future runTypedDefinitionExample() async { final backend = InMemoryResultBackend(); final stem = Stem( broker: broker, - registry: SimpleTaskRegistry()..register(PublishInvoiceTask()), backend: backend, + tasks: [PublishInvoiceTask()], ); final taskId = await stem.enqueueCall( diff --git a/packages/stem/example/docs_snippets/lib/worker_control.dart b/packages/stem/example/docs_snippets/lib/worker_control.dart index cf7322ec..b1ceafc8 100644 --- a/packages/stem/example/docs_snippets/lib/worker_control.dart +++ b/packages/stem/example/docs_snippets/lib/worker_control.dart @@ -9,8 +9,8 @@ final InMemoryResultBackend _autoscaleBackend = InMemoryResultBackend(); // #region worker-control-autoscale final worker = Worker( broker: _autoscaleBroker, - registry: SimpleTaskRegistry(), backend: _autoscaleBackend, + tasks: const [], queue: 'critical', concurrency: 12, autoscale: const WorkerAutoscaleConfig( @@ -97,8 +97,8 @@ final InMemoryResultBackend _lifecycleBackend = InMemoryResultBackend(); // #region worker-control-lifecycle final lifecycleWorker = Worker( broker: _lifecycleBroker, - registry: SimpleTaskRegistry(), backend: _lifecycleBackend, + tasks: const [], lifecycle: const WorkerLifecycleConfig( maxTasksPerIsolate: 500, maxMemoryPerIsolateBytes: 512 * 1024 * 1024, diff --git a/packages/stem/example/docs_snippets/lib/workers_programmatic.dart b/packages/stem/example/docs_snippets/lib/workers_programmatic.dart index 2636562e..d47dc4d8 100644 --- a/packages/stem/example/docs_snippets/lib/workers_programmatic.dart +++ b/packages/stem/example/docs_snippets/lib/workers_programmatic.dart @@ -9,24 +9,23 @@ import 'package:stem_redis/stem_redis.dart'; // #region workers-producer-minimal Future minimalProducer() async { - final registry = SimpleTaskRegistry() - ..register( - FunctionTaskHandler( - name: 'email.send', - entrypoint: (context, args) async { - final to = args['to'] as String? ?? 'friend'; - print('Queued email to $to'); - return null; - }, - ), - ); + final tasks = [ + FunctionTaskHandler( + name: 'email.send', + entrypoint: (context, args) async { + final to = args['to'] as String? ?? 'friend'; + print('Queued email to $to'); + return null; + }, + ), + ]; final broker = InMemoryBroker(); final backend = InMemoryResultBackend(); final stem = Stem( broker: broker, - registry: registry, backend: backend, + tasks: tasks, ); final taskId = await stem.enqueue( @@ -46,22 +45,21 @@ Future redisProducer() async { Platform.environment['STEM_BROKER_URL'] ?? 'redis://localhost:6379'; final broker = await RedisStreamsBroker.connect(brokerUrl); final backend = await RedisResultBackend.connect('$brokerUrl/1'); - final registry = SimpleTaskRegistry() - ..register( - FunctionTaskHandler( - name: 'report.generate', - entrypoint: (context, args) async { - final id = args['reportId'] as String? ?? 'unknown'; - print('Queued report $id'); - return null; - }, - ), - ); + final tasks = [ + FunctionTaskHandler( + name: 'report.generate', + entrypoint: (context, args) async { + final id = args['reportId'] as String? ?? 'unknown'; + print('Queued report $id'); + return null; + }, + ), + ]; final stem = Stem( broker: broker, - registry: registry, backend: backend, + tasks: tasks, ); await stem.enqueue( @@ -78,17 +76,16 @@ Future redisProducer() async { Future signedProducer() async { final config = StemConfig.fromEnvironment(); final signer = PayloadSigner.maybe(config.signing); - final registry = SimpleTaskRegistry() - ..register( - FunctionTaskHandler( - name: 'billing.charge', - entrypoint: (context, args) async { - final customerId = args['customerId'] as String? ?? 'unknown'; - print('Queued charge for $customerId'); - return null; - }, - ), - ); + final tasks = [ + FunctionTaskHandler( + name: 'billing.charge', + entrypoint: (context, args) async { + final customerId = args['customerId'] as String? ?? 'unknown'; + print('Queued charge for $customerId'); + return null; + }, + ), + ]; final broker = await RedisStreamsBroker.connect( config.brokerUrl, @@ -97,8 +94,8 @@ Future signedProducer() async { final backend = InMemoryResultBackend(); final stem = Stem( broker: broker, - registry: registry, backend: backend, + tasks: tasks, signer: signer, ); @@ -127,14 +124,13 @@ class EmailTask extends TaskHandler { } Future minimalWorker() async { - final registry = SimpleTaskRegistry()..register(EmailTask()); final broker = InMemoryBroker(); final backend = InMemoryResultBackend(); final worker = Worker( broker: broker, - registry: registry, backend: backend, + tasks: [EmailTask()], queue: 'default', ); @@ -146,12 +142,11 @@ Future minimalWorker() async { Future redisWorker() async { final brokerUrl = Platform.environment['STEM_BROKER_URL'] ?? 'redis://localhost:6379'; - final registry = SimpleTaskRegistry()..register(RedisEmailTask()); final worker = Worker( broker: await RedisStreamsBroker.connect(brokerUrl), - registry: registry, backend: await RedisResultBackend.connect('$brokerUrl/1'), + tasks: [RedisEmailTask()], queue: 'default', concurrency: Platform.numberOfProcessors, ); @@ -197,11 +192,10 @@ Future retryWorker() async { print('[retry] next run at: ${payload.nextRetryAt}'); }); - final registry = SimpleTaskRegistry()..register(FlakyTask()); final worker = Worker( broker: InMemoryBroker(), - registry: registry, backend: InMemoryResultBackend(), + tasks: [FlakyTask()], retryStrategy: ExponentialJitterRetryStrategy( base: const Duration(milliseconds: 200), max: const Duration(seconds: 1), @@ -214,9 +208,9 @@ Future retryWorker() async { // #region workers-bootstrap class StemRuntime { - StemRuntime({required this.registry, required this.brokerUrl}); + StemRuntime({required this.tasks, required this.brokerUrl}); - final TaskRegistry registry; + final List> tasks; final String brokerUrl; final InMemoryBroker _stemBroker = InMemoryBroker(); @@ -226,14 +220,14 @@ class StemRuntime { late final Stem stem = Stem( broker: _stemBroker, - registry: registry, backend: _stemBackend, + tasks: tasks, ); late final Worker worker = Worker( broker: _workerBroker, - registry: registry, backend: _workerBackend, + tasks: tasks, ); Future start() async { diff --git a/packages/stem/example/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index 6cb7cc79..224d68a5 100644 --- a/packages/stem/example/docs_snippets/lib/workflows.dart +++ b/packages/stem/example/docs_snippets/lib/workflows.dart @@ -31,7 +31,7 @@ Future bootstrapWorkflowRuntime() async { // #region workflows-client Future bootstrapWorkflowClient() async { final client = await StemClient.fromUrl('memory://'); - final app = await client.createWorkflowApp(flows: [ApprovalsFlow.flow]); + final app = await client.createWorkflowApp(module: stemModule); await app.start(); await app.close(); await client.close(); @@ -49,7 +49,7 @@ class ApprovalsFlow { }); flow.step('manager-review', (ctx) async { - final resume = ctx.takeResumeData() as Map?; + final resume = ctx.takeResumeValue>(); if (resume == null) { await ctx.awaitEvent('approvals.manager'); return null; @@ -75,7 +75,7 @@ final retryScript = WorkflowScript( name: 'billing.retry-script', run: (script) async { final chargeId = await script.step('charge', (ctx) async { - final resume = ctx.takeResumeData() as Map?; + final resume = ctx.takeResumeValue>(); if (resume == null) { await ctx.awaitEvent('billing.charge.prepared'); return 'pending'; @@ -142,7 +142,7 @@ Future configureWorkflowEncoders() async { // #region workflows-annotated @WorkflowDefn(name: 'approvals.flow') class ApprovalsAnnotatedWorkflow { - @workflow.step + @WorkflowStep() Future draft(FlowContext ctx) async { final payload = ctx.params['draft'] as Map; return payload['documentId'] as String; @@ -150,15 +150,15 @@ class ApprovalsAnnotatedWorkflow { @WorkflowStep(name: 'manager-review') Future managerReview(FlowContext ctx) async { - final resume = ctx.takeResumeData() as Map?; + final resume = ctx.takeResumeValue>(); if (resume == null) { - ctx.awaitEvent('approvals.manager'); + await ctx.awaitEvent('approvals.manager'); return null; } return resume['approvedBy'] as String?; } - @workflow.step + @WorkflowStep() Future finalize(FlowContext ctx) async { final approvedBy = ctx.previousResult as String?; return 'approved-by:$approvedBy'; @@ -167,10 +167,10 @@ class ApprovalsAnnotatedWorkflow { @WorkflowDefn(name: 'billing.retry-script', kind: WorkflowKind.script) class BillingRetryAnnotatedWorkflow { - @workflow.run + @WorkflowRun() Future run(WorkflowScriptContext script) async { final chargeId = await script.step('charge', (ctx) async { - final resume = ctx.takeResumeData() as Map?; + final resume = ctx.takeResumeValue>(); if (resume == null) { await ctx.awaitEvent('billing.charge.prepared'); return 'pending'; @@ -198,7 +198,7 @@ Future sendEmail( Future registerAnnotatedDefinitions(StemWorkflowApp app) async { // Generated by stem_builder. - registerStemDefinitions( + stemModule.registerInto( workflows: app.runtime.registry, tasks: app.app.registry, ); @@ -206,10 +206,7 @@ Future registerAnnotatedDefinitions(StemWorkflowApp app) async { // #endregion workflows-annotated // Stub for docs snippet; generated by stem_builder in real apps. -void registerStemDefinitions({ - required WorkflowRegistry workflows, - required TaskRegistry tasks, -}) {} +final StemModule stemModule = StemModule(); class Base64PayloadEncoder extends TaskPayloadEncoder { const Base64PayloadEncoder(); diff --git a/packages/stem/example/durable_watchers.dart b/packages/stem/example/durable_watchers.dart index 1abc011c..76114fe7 100644 --- a/packages/stem/example/durable_watchers.dart +++ b/packages/stem/example/durable_watchers.dart @@ -1,5 +1,10 @@ import 'package:stem/stem.dart'; +final shipmentReadyEventCodec = PayloadCodec<_ShipmentReadyEvent>( + encode: (value) => value.toJson(), + decode: _ShipmentReadyEvent.fromJson, +); + /// Runs a workflow that suspends on `awaitEvent` and resumes once a payload is /// emitted. The example also inspects watcher metadata before the resume. Future main() async { @@ -16,8 +21,10 @@ Future main() async { final trackingId = await script.step('wait-for-shipment', ( step, ) async { - final resume = step.takeResumeData(); - if (resume == null) { + final payload = step.takeResumeValue<_ShipmentReadyEvent>( + codec: shipmentReadyEventCodec, + ); + if (payload == null) { await step.awaitEvent( 'shipment.ready', deadline: DateTime.now().add(const Duration(minutes: 5)), @@ -25,9 +32,7 @@ Future main() async { ); return 'waiting'; } - - final payload = resume as Map; - return payload['trackingId']; + return payload.trackingId; }); return trackingId; @@ -52,7 +57,11 @@ Future main() async { print('Watcher metadata: ${watcher.data}'); } - await app.runtime.emit('shipment.ready', const {'trackingId': 'ZX-42'}); + await app.emitValue( + 'shipment.ready', + const _ShipmentReadyEvent(trackingId: 'ZX-42'), + codec: shipmentReadyEventCodec, + ); await app.runtime.executeRun(runId); @@ -61,3 +70,16 @@ Future main() async { await app.close(); } + +class _ShipmentReadyEvent { + const _ShipmentReadyEvent({required this.trackingId}); + + final String trackingId; + + Map toJson() => {'trackingId': trackingId}; + + static _ShipmentReadyEvent fromJson(Object? payload) { + final json = payload! as Map; + return _ShipmentReadyEvent(trackingId: json['trackingId'] as String); + } +} diff --git a/packages/stem/example/ecommerce/.gitignore b/packages/stem/example/ecommerce/.gitignore new file mode 100644 index 00000000..eaf4d47a --- /dev/null +++ b/packages/stem/example/ecommerce/.gitignore @@ -0,0 +1,2 @@ +.dart_tool/ +*.sqlite diff --git a/packages/stem/example/ecommerce/README.md b/packages/stem/example/ecommerce/README.md new file mode 100644 index 00000000..17432061 --- /dev/null +++ b/packages/stem/example/ecommerce/README.md @@ -0,0 +1,100 @@ +# Stem Ecommerce Example + +A small ecommerce API built with Shelf and Stem workflows. + +This example demonstrates: + +- mixed workflow styles: + - annotated script workflow (`ecommerce.cart.add_item`) via `stem_builder` + - manual flow workflow (`ecommerce.checkout`) +- SQLite persistence with Ormed models + migrations for store data +- Stem runtime on SQLite via `stem_sqlite` (also Ormed-backed) +- HTTP testing with `server_testing` + `server_testing_shelf` +- workflow steps reading/writing through a DB-backed repository + +## Run + +```bash +cd packages/stem/example/ecommerce +dart pub get +dart run build_runner build --delete-conflicting-outputs +dart run bin/server.dart +``` + +## Stem Builder Integration + +The annotated workflow/task definitions live in: + +- `lib/src/workflows/annotated_defs.dart` + +`stem_builder` generates: + +- `lib/src/workflows/annotated_defs.stem.g.dart` + +From those annotations, this example uses generated APIs: + +- `stemModule` (generated workflow/task bundle) +- `StemWorkflowDefinitions.addToCart` +- `StemTaskDefinitions.ecommerceAuditLog` +- `TaskEnqueuer.enqueueEcommerceAuditLog(...)` + +The server wires generated and manual tasks together in one place: + +```dart +final workflowApp = await StemWorkflowApp.fromUrl( + 'sqlite://$stemDatabasePath', + adapters: const [StemSqliteAdapter()], + module: stemModule, + flows: [buildCheckoutFlow(repository)], + tasks: [shipmentReserveTaskHandler], +); +``` + +This is why the run command always includes: + +```bash +dart run build_runner build --delete-conflicting-outputs +``` + +You can also use watch mode while iterating on annotated definitions: + +```bash +dart run build_runner watch --delete-conflicting-outputs +``` + +Database boot sequence on startup: + +- loads [`ormed.yaml`](/run/media/kingwill101/disk2/code/code/dart_packages/stem/packages/stem/example/ecommerce/ormed.yaml) +- opens the configured SQLite file +- applies pending migrations from + [`lib/src/database/migrations.dart`](/run/media/kingwill101/disk2/code/code/dart_packages/stem/packages/stem/example/ecommerce/lib/src/database/migrations.dart) +- seeds default catalog records if empty + +Optional CLI migration command (when your local `ormed_cli` dependency set is compatible): + +```bash +dart run ormed_cli:ormed migrate --config ormed.yaml +``` + +Server defaults: + +- `PORT=8085` +- `ECOMMERCE_DB_PATH=.dart_tool/ecommerce/ecommerce.sqlite` + +## API + +- `GET /health` +- `GET /catalog` +- `POST /carts` body: `{ "customerId": "cust-1" }` +- `GET /carts/` +- `POST /carts//items` body: `{ "sku": "sku_tee", "quantity": 2 }` +- `POST /checkout/` +- `GET /orders/` +- `GET /runs/` + +## Test + +```bash +cd packages/stem/example/ecommerce +dart test +``` diff --git a/packages/stem/example/ecommerce/analysis_options.yaml b/packages/stem/example/ecommerce/analysis_options.yaml new file mode 100644 index 00000000..572dd239 --- /dev/null +++ b/packages/stem/example/ecommerce/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml diff --git a/packages/stem/example/ecommerce/bin/server.dart b/packages/stem/example/ecommerce/bin/server.dart new file mode 100644 index 00000000..45fa5153 --- /dev/null +++ b/packages/stem/example/ecommerce/bin/server.dart @@ -0,0 +1,43 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:shelf/shelf_io.dart' as shelf_io; +import 'package:stem_ecommerce_example/ecommerce.dart'; + +Future main() async { + final port = int.tryParse(Platform.environment['PORT'] ?? '8085') ?? 8085; + final databasePath = Platform.environment['ECOMMERCE_DB_PATH']; + + final app = await EcommerceServer.create(databasePath: databasePath); + final server = await shelf_io.serve( + app.handler, + InternetAddress.anyIPv4, + port, + ); + + stdout.writeln( + 'Ecommerce API running on http://${server.address.address}:${server.port}', + ); + stdout.writeln('Database: ${app.repository.databasePath}'); + stdout.writeln('Endpoints:'); + stdout.writeln(' GET /health'); + stdout.writeln(' GET /catalog'); + stdout.writeln(' POST /carts'); + stdout.writeln(' GET /carts/'); + stdout.writeln(' POST /carts//items'); + stdout.writeln(' POST /checkout/'); + stdout.writeln(' GET /orders/'); + stdout.writeln(' GET /runs/'); + + Future shutdown([ProcessSignal? signal]) async { + if (signal != null) { + stdout.writeln('Received $signal, shutting down...'); + } + await server.close(force: true); + await app.close(); + exit(0); + } + + ProcessSignal.sigint.watch().listen(shutdown); + ProcessSignal.sigterm.watch().listen(shutdown); +} diff --git a/packages/stem/example/ecommerce/database/.gitkeep b/packages/stem/example/ecommerce/database/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/packages/stem/example/ecommerce/lib/ecommerce.dart b/packages/stem/example/ecommerce/lib/ecommerce.dart new file mode 100644 index 00000000..53f58766 --- /dev/null +++ b/packages/stem/example/ecommerce/lib/ecommerce.dart @@ -0,0 +1,3 @@ +library; + +export 'src/app.dart'; diff --git a/packages/stem/example/ecommerce/lib/src/app.dart b/packages/stem/example/ecommerce/lib/src/app.dart new file mode 100644 index 00000000..4343953a --- /dev/null +++ b/packages/stem/example/ecommerce/lib/src/app.dart @@ -0,0 +1,272 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:path/path.dart' as p; +import 'package:shelf/shelf.dart'; +import 'package:shelf_router/shelf_router.dart'; +import 'package:stem/stem.dart'; +import 'package:stem_sqlite/stem_sqlite.dart'; + +import 'domain/repository.dart'; +import 'tasks/manual_tasks.dart'; +import 'workflows/annotated_defs.dart'; +import 'workflows/checkout_flow.dart'; + +class EcommerceServer { + EcommerceServer._({ + required this.workflowApp, + required this.repository, + required Handler handler, + }) : _handler = handler; + + final StemWorkflowApp workflowApp; + final EcommerceRepository repository; + final Handler _handler; + + Handler get handler => _handler; + + static Future create({String? databasePath}) async { + final commerceDatabasePath = await _resolveDatabasePath(databasePath); + final stemDatabasePath = p.join( + p.dirname(commerceDatabasePath), + 'stem_runtime.sqlite', + ); + final repository = await EcommerceRepository.open(commerceDatabasePath); + bindAddToCartWorkflowRepository(repository); + + final workflowApp = await StemWorkflowApp.fromUrl( + 'sqlite://$stemDatabasePath', + adapters: const [StemSqliteAdapter()], + module: stemModule, + flows: [buildCheckoutFlow(repository)], + tasks: [shipmentReserveTaskHandler], + workerConfig: StemWorkerConfig( + queue: 'workflow', + consumerName: 'ecommerce-worker', + concurrency: 2, + subscription: RoutingSubscription( + queues: const ['workflow', 'default'], + ), + ), + ); + + await workflowApp.start(); + + final router = Router() + ..get('/health', (Request request) async { + return _json(200, { + 'status': 'ok', + 'databasePath': repository.databasePath, + 'stemDatabasePath': stemDatabasePath, + 'workflows': [ + StemWorkflowDefinitions.addToCart.name, + checkoutWorkflowName, + ], + }); + }) + ..get('/catalog', (Request request) async { + final catalog = await repository.listCatalog(); + return _json(200, {'items': catalog}); + }) + ..post('/carts', (Request request) async { + try { + final payload = await _readJsonMap(request); + final customerId = + payload['customerId']?.toString().trim().isNotEmpty == true + ? payload['customerId']!.toString().trim() + : 'guest'; + + final cart = await repository.createCart(customerId: customerId); + return _json(201, {'cart': cart}); + } on Object catch (error) { + return _error(400, 'Failed to create cart.', error); + } + }) + ..get('/carts/', (Request request, String cartId) async { + final cart = await repository.getCart(cartId); + if (cart == null) { + return _error(404, 'Cart not found.', {'cartId': cartId}); + } + return _json(200, {'cart': cart}); + }) + ..post('/carts//items', (Request request, String cartId) async { + try { + final payload = await _readJsonMap(request); + final sku = payload['sku']?.toString() ?? ''; + final quantity = _toInt(payload['quantity']); + + final runId = await StemWorkflowDefinitions.addToCart + .call((cartId: cartId, sku: sku, quantity: quantity)) + .startWithApp(workflowApp); + + final result = await StemWorkflowDefinitions.addToCart.waitFor( + workflowApp, + runId, + timeout: const Duration(seconds: 4), + ); + + if (result == null) { + return _error(500, 'Add-to-cart workflow run not found.', { + 'runId': runId, + }); + } + + if (result.status != WorkflowStatus.completed || + result.value == null) { + return _error(422, 'Add-to-cart workflow did not complete.', { + 'runId': runId, + 'status': result.status.name, + 'lastError': result.state.lastError, + }); + } + + final computed = result.value!; + final updatedCart = await repository.addItemToCart( + cartId: cartId, + sku: sku, + title: computed['title']?.toString() ?? sku, + quantity: quantity, + unitPriceCents: _toInt(computed['unitPriceCents']), + ); + + return _json(200, {'runId': runId, 'cart': updatedCart}); + } on Object catch (error) { + return _error(400, 'Failed to add item to cart.', error); + } + }) + ..post('/checkout/', (Request request, String cartId) async { + try { + final runId = await workflowApp.startWorkflow( + checkoutWorkflowName, + params: {'cartId': cartId}, + ); + + final result = await workflowApp + .waitForCompletion>( + runId, + timeout: const Duration(seconds: 6), + decode: _toMap, + ); + + if (result == null) { + return _error(500, 'Checkout workflow run not found.', { + 'runId': runId, + }); + } + + if (result.status != WorkflowStatus.completed || + result.value == null) { + return _error(409, 'Checkout workflow did not complete.', { + 'runId': runId, + 'status': result.status.name, + 'lastError': result.state.lastError, + }); + } + + return _json(200, {'runId': runId, 'order': result.value}); + } on Object catch (error) { + return _error(400, 'Failed to checkout cart.', error); + } + }) + ..get('/orders/', (Request request, String orderId) async { + final order = await repository.getOrder(orderId); + if (order == null) { + return _error(404, 'Order not found.', {'orderId': orderId}); + } + return _json(200, {'order': order}); + }) + ..get('/runs/', (Request request, String runId) async { + final detail = await workflowApp.runtime.viewRunDetail(runId); + if (detail == null) { + return _error(404, 'Workflow run not found.', {'runId': runId}); + } + return _json(200, {'detail': detail.toJson()}); + }); + + final handler = Pipeline() + .addMiddleware(logRequests()) + .addHandler(router.call); + + return EcommerceServer._( + workflowApp: workflowApp, + repository: repository, + handler: handler, + ); + } + + Future close() async { + try { + await workflowApp.shutdown(); + } finally { + try { + await repository.close(); + } finally { + unbindAddToCartWorkflowRepository(); + } + } + } +} + +Future _resolveDatabasePath(String? path) async { + if (path != null && path.trim().isNotEmpty) { + return p.normalize(path); + } + + final directory = Directory(p.join('.dart_tool', 'ecommerce')); + await directory.create(recursive: true); + return p.join(directory.path, 'ecommerce.sqlite'); +} + +Future> _readJsonMap(Request request) async { + final body = await request.readAsString(); + if (body.trim().isEmpty) { + return {}; + } + + final decoded = jsonDecode(body); + if (decoded is! Map) { + throw FormatException('Request payload must be a JSON object.'); + } + + return decoded.cast(); +} + +Response _json(int status, Map payload) { + return Response( + status, + body: jsonEncode(payload), + headers: const {'content-type': 'application/json; charset=utf-8'}, + ); +} + +Response _error(int status, String message, Object? error) { + return _json(status, {'error': message, 'details': _normalizeError(error)}); +} + +Map _toMap(Object? value) { + if (value is Map) { + return value; + } + if (value is Map) { + return value.cast(); + } + return {}; +} + +Object? _normalizeError(Object? error) { + if (error == null) { + return null; + } + if (error is Map) { + return error; + } + if (error is Map) { + return error.cast(); + } + return error.toString(); +} + +int _toInt(Object? value) { + if (value is int) return value; + if (value is num) return value.toInt(); + return int.tryParse(value?.toString() ?? '') ?? 0; +} diff --git a/packages/stem/example/ecommerce/lib/src/database/datasource.dart b/packages/stem/example/ecommerce/lib/src/database/datasource.dart new file mode 100644 index 00000000..46c38e5d --- /dev/null +++ b/packages/stem/example/ecommerce/lib/src/database/datasource.dart @@ -0,0 +1,34 @@ +import 'package:ormed/ormed.dart'; +import 'package:ormed_sqlite/ormed_sqlite.dart'; + +import 'migrations.dart'; +import 'orm_registry.g.dart'; + +Future openEcommerceDataSource({ + required String databasePath, +}) async { + final dataSource = bootstrapOrm().sqliteFileDataSource(path: databasePath); + + await dataSource.init(); + + final driver = dataSource.connection.driver; + if (driver is! SchemaDriver) { + throw StateError('Expected a schema driver for SQLite migrations.'); + } + final schemaDriver = driver as SchemaDriver; + + final ledger = SqlMigrationLedger(driver, tableName: 'orm_migrations'); + await ledger.ensureInitialized(); + + final runner = MigrationRunner( + schemaDriver: schemaDriver, + ledger: ledger, + migrations: buildMigrations(), + ); + await runner.applyAll(); + + await driver.executeRaw('PRAGMA journal_mode=WAL;'); + await driver.executeRaw('PRAGMA synchronous=NORMAL;'); + + return dataSource; +} diff --git a/packages/stem/example/ecommerce/lib/src/database/migrations.dart b/packages/stem/example/ecommerce/lib/src/database/migrations.dart new file mode 100644 index 00000000..ccf08b73 --- /dev/null +++ b/packages/stem/example/ecommerce/lib/src/database/migrations.dart @@ -0,0 +1,53 @@ +import 'dart:convert'; + +import 'package:ormed/migrations.dart'; + +import 'migrations/m_20260226010000_create_ecommerce_tables.dart'; + +final List _entries = [ + MigrationEntry( + id: MigrationId( + DateTime.utc(2026, 2, 26, 1), + 'm_20260226010000_create_ecommerce_tables', + ), + migration: const CreateEcommerceTables(), + ), +]; + +List buildMigrations() => + MigrationEntry.buildDescriptors(_entries); + +MigrationEntry? _findEntry(String rawId) { + for (final entry in _entries) { + if (entry.id.toString() == rawId) return entry; + } + return null; +} + +void main(List args) { + if (args.contains('--dump-json')) { + final payload = buildMigrations().map((m) => m.toJson()).toList(); + print(jsonEncode(payload)); + return; + } + + final planIndex = args.indexOf('--plan-json'); + if (planIndex != -1) { + final id = args[planIndex + 1]; + final entry = _findEntry(id); + if (entry == null) { + throw StateError('Unknown migration id $id.'); + } + final directionName = args[args.indexOf('--direction') + 1]; + final direction = MigrationDirection.values.byName(directionName); + final snapshotIndex = args.indexOf('--schema-snapshot'); + SchemaSnapshot? snapshot; + if (snapshotIndex != -1) { + final decoded = utf8.decode(base64.decode(args[snapshotIndex + 1])); + final payload = jsonDecode(decoded) as Map; + snapshot = SchemaSnapshot.fromJson(payload); + } + final plan = entry.migration.plan(direction, snapshot: snapshot); + print(jsonEncode(plan.toJson())); + } +} diff --git a/packages/stem/example/ecommerce/lib/src/database/migrations/m_20260226010000_create_ecommerce_tables.dart b/packages/stem/example/ecommerce/lib/src/database/migrations/m_20260226010000_create_ecommerce_tables.dart new file mode 100644 index 00000000..45ae709e --- /dev/null +++ b/packages/stem/example/ecommerce/lib/src/database/migrations/m_20260226010000_create_ecommerce_tables.dart @@ -0,0 +1,99 @@ +import 'package:ormed/migrations.dart'; + +class CreateEcommerceTables extends Migration { + const CreateEcommerceTables(); + + @override + void up(SchemaBuilder schema) { + schema + ..create('catalog_skus', (table) { + table.text('sku').primaryKey(); + table.text('title'); + table.integer('price_cents'); + table.integer('stock_available'); + table + ..timestampsTz() + ..index(['sku'], name: 'catalog_skus_sku_idx'); + }) + ..create('carts', (table) { + table.text('id').primaryKey(); + table.text('customer_id'); + table.text('status'); + table + ..timestampsTz() + ..index(['status'], name: 'carts_status_idx') + ..index(['customer_id'], name: 'carts_customer_id_idx'); + }) + ..create('cart_items', (table) { + table.text('id').primaryKey(); + table.text('cart_id'); + table.text('sku'); + table.text('title'); + table.integer('quantity'); + table.integer('unit_price_cents'); + table.integer('line_total_cents'); + table + ..timestampsTz() + ..unique(['cart_id', 'sku'], name: 'cart_items_cart_id_sku_unique') + ..index(['cart_id'], name: 'cart_items_cart_id_idx') + ..foreign( + ['cart_id'], + references: 'carts', + referencedColumns: ['id'], + onDelete: ReferenceAction.cascade, + ) + ..foreign( + ['sku'], + references: 'catalog_skus', + referencedColumns: ['sku'], + onDelete: ReferenceAction.restrict, + ); + }) + ..create('orders', (table) { + table.text('id').primaryKey(); + table.text('cart_id'); + table.text('customer_id'); + table.text('status'); + table.integer('total_cents'); + table.text('payment_reference'); + table + ..timestampsTz() + ..index(['cart_id'], name: 'orders_cart_id_idx') + ..index(['customer_id'], name: 'orders_customer_id_idx') + ..foreign( + ['cart_id'], + references: 'carts', + referencedColumns: ['id'], + onDelete: ReferenceAction.restrict, + ); + }) + ..create('order_items', (table) { + table.text('id').primaryKey(); + table.text('order_id'); + table.text('sku'); + table.text('title'); + table.integer('quantity'); + table.integer('unit_price_cents'); + table.integer('line_total_cents'); + table + ..timestampsTz() + ..index(['order_id'], name: 'order_items_order_id_idx') + ..foreign( + ['order_id'], + references: 'orders', + referencedColumns: ['id'], + onDelete: ReferenceAction.cascade, + ); + }); + } + + @override + void down(SchemaBuilder schema) { + schema + ..drop('order_items', ifExists: true) + ..drop('orders', ifExists: true) + ..drop('cart_items', ifExists: true) + ..drop('carts', ifExists: true) + ..drop('catalog_skus', ifExists: true); + } +} diff --git a/packages/stem/example/ecommerce/lib/src/database/models/cart.dart b/packages/stem/example/ecommerce/lib/src/database/models/cart.dart new file mode 100644 index 00000000..be15f5ed --- /dev/null +++ b/packages/stem/example/ecommerce/lib/src/database/models/cart.dart @@ -0,0 +1,20 @@ +import 'package:ormed/ormed.dart'; + +part 'cart.orm.dart'; + +@OrmModel(table: 'carts') +class CartModel extends Model with TimestampsTZ { + const CartModel({ + required this.id, + required this.customerId, + required this.status, + }); + + @OrmField(isPrimaryKey: true) + final String id; + + @OrmField(columnName: 'customer_id') + final String customerId; + + final String status; +} diff --git a/packages/stem/example/ecommerce/lib/src/database/models/cart.orm.dart b/packages/stem/example/ecommerce/lib/src/database/models/cart.orm.dart new file mode 100644 index 00000000..d128d764 --- /dev/null +++ b/packages/stem/example/ecommerce/lib/src/database/models/cart.orm.dart @@ -0,0 +1,577 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 + +part of 'cart.dart'; + +// ************************************************************************** +// OrmModelGenerator +// ************************************************************************** + +const FieldDefinition _$CartModelIdField = FieldDefinition( + name: 'id', + columnName: 'id', + dartType: 'String', + resolvedType: 'String', + isPrimaryKey: true, + isNullable: false, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +const FieldDefinition _$CartModelCustomerIdField = FieldDefinition( + name: 'customerId', + columnName: 'customer_id', + dartType: 'String', + resolvedType: 'String', + isPrimaryKey: false, + isNullable: false, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +const FieldDefinition _$CartModelStatusField = FieldDefinition( + name: 'status', + columnName: 'status', + dartType: 'String', + resolvedType: 'String', + isPrimaryKey: false, + isNullable: false, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +const FieldDefinition _$CartModelCreatedAtField = FieldDefinition( + name: 'createdAt', + columnName: 'created_at', + dartType: 'DateTime', + resolvedType: 'DateTime?', + isPrimaryKey: false, + isNullable: true, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +const FieldDefinition _$CartModelUpdatedAtField = FieldDefinition( + name: 'updatedAt', + columnName: 'updated_at', + dartType: 'DateTime', + resolvedType: 'DateTime?', + isPrimaryKey: false, + isNullable: true, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +Map _encodeCartModelUntracked( + Object model, + ValueCodecRegistry registry, +) { + final m = model as CartModel; + return { + 'id': registry.encodeField(_$CartModelIdField, m.id), + 'customer_id': registry.encodeField( + _$CartModelCustomerIdField, + m.customerId, + ), + 'status': registry.encodeField(_$CartModelStatusField, m.status), + }; +} + +final ModelDefinition<$CartModel> _$CartModelDefinition = ModelDefinition( + modelName: 'CartModel', + tableName: 'carts', + fields: const [ + _$CartModelIdField, + _$CartModelCustomerIdField, + _$CartModelStatusField, + _$CartModelCreatedAtField, + _$CartModelUpdatedAtField, + ], + relations: const [], + softDeleteColumn: 'deleted_at', + metadata: ModelAttributesMetadata( + hidden: const [], + visible: const [], + fillable: const [], + guarded: const [], + casts: const {}, + appends: const [], + touches: const [], + timestamps: true, + softDeletes: false, + softDeleteColumn: 'deleted_at', + ), + untrackedToMap: _encodeCartModelUntracked, + codec: _$CartModelCodec(), +); + +extension CartModelOrmDefinition on CartModel { + static ModelDefinition<$CartModel> get definition => _$CartModelDefinition; +} + +class CartModels { + const CartModels._(); + + /// Starts building a query for [$CartModel]. + /// + /// {@macro ormed.query} + static Query<$CartModel> query([String? connection]) => + Model.query<$CartModel>(connection: connection); + + static Future<$CartModel?> find(Object id, {String? connection}) => + Model.find<$CartModel>(id, connection: connection); + + static Future<$CartModel> findOrFail(Object id, {String? connection}) => + Model.findOrFail<$CartModel>(id, connection: connection); + + static Future> all({String? connection}) => + Model.all<$CartModel>(connection: connection); + + static Future count({String? connection}) => + Model.count<$CartModel>(connection: connection); + + static Future anyExist({String? connection}) => + Model.anyExist<$CartModel>(connection: connection); + + static Query<$CartModel> where( + String column, + String operator, + dynamic value, { + String? connection, + }) => + Model.where<$CartModel>(column, operator, value, connection: connection); + + static Query<$CartModel> whereIn( + String column, + List values, { + String? connection, + }) => Model.whereIn<$CartModel>(column, values, connection: connection); + + static Query<$CartModel> orderBy( + String column, { + String direction = "asc", + String? connection, + }) => Model.orderBy<$CartModel>( + column, + direction: direction, + connection: connection, + ); + + static Query<$CartModel> limit(int count, {String? connection}) => + Model.limit<$CartModel>(count, connection: connection); + + /// Creates a [Repository] for [$CartModel]. + /// + /// {@macro ormed.repository} + static Repository<$CartModel> repo([String? connection]) => + Model.repository<$CartModel>(connection: connection); + + /// Builds a tracked model from a column/value map. + static $CartModel fromMap( + Map data, { + ValueCodecRegistry? registry, + }) => _$CartModelDefinition.fromMap(data, registry: registry); + + /// Converts a tracked model to a column/value map. + static Map toMap( + $CartModel model, { + ValueCodecRegistry? registry, + }) => _$CartModelDefinition.toMap(model, registry: registry); +} + +class CartModelFactory { + const CartModelFactory._(); + + static ModelDefinition<$CartModel> get definition => _$CartModelDefinition; + + static ModelCodec<$CartModel> get codec => definition.codec; + + static CartModel fromMap( + Map data, { + ValueCodecRegistry? registry, + }) => definition.fromMap(data, registry: registry); + + static Map toMap( + CartModel model, { + ValueCodecRegistry? registry, + }) => definition.toMap(model.toTracked(), registry: registry); + + static void registerWith(ModelRegistry registry) => + registry.register(definition); + + static ModelFactoryConnection withConnection( + QueryContext context, + ) => ModelFactoryConnection( + definition: definition, + context: context, + ); + + static ModelFactoryBuilder factory({ + GeneratorProvider? generatorProvider, + }) => ModelFactoryRegistry.factoryFor( + generatorProvider: generatorProvider, + ); +} + +class _$CartModelCodec extends ModelCodec<$CartModel> { + const _$CartModelCodec(); + @override + Map encode($CartModel model, ValueCodecRegistry registry) { + return { + 'id': registry.encodeField(_$CartModelIdField, model.id), + 'customer_id': registry.encodeField( + _$CartModelCustomerIdField, + model.customerId, + ), + 'status': registry.encodeField(_$CartModelStatusField, model.status), + if (model.hasAttribute('created_at')) + 'created_at': registry.encodeField( + _$CartModelCreatedAtField, + model.getAttribute('created_at'), + ), + if (model.hasAttribute('updated_at')) + 'updated_at': registry.encodeField( + _$CartModelUpdatedAtField, + model.getAttribute('updated_at'), + ), + }; + } + + @override + $CartModel decode(Map data, ValueCodecRegistry registry) { + final String cartModelIdValue = + registry.decodeField(_$CartModelIdField, data['id']) ?? + (throw StateError('Field id on CartModel cannot be null.')); + final String cartModelCustomerIdValue = + registry.decodeField( + _$CartModelCustomerIdField, + data['customer_id'], + ) ?? + (throw StateError('Field customerId on CartModel cannot be null.')); + final String cartModelStatusValue = + registry.decodeField(_$CartModelStatusField, data['status']) ?? + (throw StateError('Field status on CartModel cannot be null.')); + final DateTime? cartModelCreatedAtValue = registry.decodeField( + _$CartModelCreatedAtField, + data['created_at'], + ); + final DateTime? cartModelUpdatedAtValue = registry.decodeField( + _$CartModelUpdatedAtField, + data['updated_at'], + ); + final model = $CartModel( + id: cartModelIdValue, + customerId: cartModelCustomerIdValue, + status: cartModelStatusValue, + ); + model._attachOrmRuntimeMetadata({ + 'id': cartModelIdValue, + 'customer_id': cartModelCustomerIdValue, + 'status': cartModelStatusValue, + if (data.containsKey('created_at')) 'created_at': cartModelCreatedAtValue, + if (data.containsKey('updated_at')) 'updated_at': cartModelUpdatedAtValue, + }); + return model; + } +} + +/// Insert DTO for [CartModel]. +/// +/// Auto-increment/DB-generated fields are omitted by default. +class CartModelInsertDto implements InsertDto<$CartModel> { + const CartModelInsertDto({this.id, this.customerId, this.status}); + final String? id; + final String? customerId; + final String? status; + + @override + Map toMap() { + return { + if (id != null) 'id': id, + if (customerId != null) 'customer_id': customerId, + if (status != null) 'status': status, + }; + } + + static const _CartModelInsertDtoCopyWithSentinel _copyWithSentinel = + _CartModelInsertDtoCopyWithSentinel(); + CartModelInsertDto copyWith({ + Object? id = _copyWithSentinel, + Object? customerId = _copyWithSentinel, + Object? status = _copyWithSentinel, + }) { + return CartModelInsertDto( + id: identical(id, _copyWithSentinel) ? this.id : id as String?, + customerId: identical(customerId, _copyWithSentinel) + ? this.customerId + : customerId as String?, + status: identical(status, _copyWithSentinel) + ? this.status + : status as String?, + ); + } +} + +class _CartModelInsertDtoCopyWithSentinel { + const _CartModelInsertDtoCopyWithSentinel(); +} + +/// Update DTO for [CartModel]. +/// +/// All fields are optional; only provided entries are used in SET clauses. +class CartModelUpdateDto implements UpdateDto<$CartModel> { + const CartModelUpdateDto({this.id, this.customerId, this.status}); + final String? id; + final String? customerId; + final String? status; + + @override + Map toMap() { + return { + if (id != null) 'id': id, + if (customerId != null) 'customer_id': customerId, + if (status != null) 'status': status, + }; + } + + static const _CartModelUpdateDtoCopyWithSentinel _copyWithSentinel = + _CartModelUpdateDtoCopyWithSentinel(); + CartModelUpdateDto copyWith({ + Object? id = _copyWithSentinel, + Object? customerId = _copyWithSentinel, + Object? status = _copyWithSentinel, + }) { + return CartModelUpdateDto( + id: identical(id, _copyWithSentinel) ? this.id : id as String?, + customerId: identical(customerId, _copyWithSentinel) + ? this.customerId + : customerId as String?, + status: identical(status, _copyWithSentinel) + ? this.status + : status as String?, + ); + } +} + +class _CartModelUpdateDtoCopyWithSentinel { + const _CartModelUpdateDtoCopyWithSentinel(); +} + +/// Partial projection for [CartModel]. +/// +/// All fields are nullable; intended for subset SELECTs. +class CartModelPartial implements PartialEntity<$CartModel> { + const CartModelPartial({this.id, this.customerId, this.status}); + + /// Creates a partial from a database row map. + /// + /// The [row] keys should be column names (snake_case). + /// Missing columns will result in null field values. + factory CartModelPartial.fromRow(Map row) { + return CartModelPartial( + id: row['id'] as String?, + customerId: row['customer_id'] as String?, + status: row['status'] as String?, + ); + } + + final String? id; + final String? customerId; + final String? status; + + @override + $CartModel toEntity() { + // Basic required-field check: non-nullable fields must be present. + final String? idValue = id; + if (idValue == null) { + throw StateError('Missing required field: id'); + } + final String? customerIdValue = customerId; + if (customerIdValue == null) { + throw StateError('Missing required field: customerId'); + } + final String? statusValue = status; + if (statusValue == null) { + throw StateError('Missing required field: status'); + } + return $CartModel( + id: idValue, + customerId: customerIdValue, + status: statusValue, + ); + } + + @override + Map toMap() { + return { + if (id != null) 'id': id, + if (customerId != null) 'customer_id': customerId, + if (status != null) 'status': status, + }; + } + + static const _CartModelPartialCopyWithSentinel _copyWithSentinel = + _CartModelPartialCopyWithSentinel(); + CartModelPartial copyWith({ + Object? id = _copyWithSentinel, + Object? customerId = _copyWithSentinel, + Object? status = _copyWithSentinel, + }) { + return CartModelPartial( + id: identical(id, _copyWithSentinel) ? this.id : id as String?, + customerId: identical(customerId, _copyWithSentinel) + ? this.customerId + : customerId as String?, + status: identical(status, _copyWithSentinel) + ? this.status + : status as String?, + ); + } +} + +class _CartModelPartialCopyWithSentinel { + const _CartModelPartialCopyWithSentinel(); +} + +/// Generated tracked model class for [CartModel]. +/// +/// This class extends the user-defined [CartModel] model and adds +/// attribute tracking, change detection, and relationship management. +/// Instances of this class are returned by queries and repositories. +/// +/// **Do not instantiate this class directly.** Use queries, repositories, +/// or model factories to create tracked model instances. +class $CartModel extends CartModel + with ModelAttributes, TimestampsTZImpl + implements OrmEntity { + /// Internal constructor for [$CartModel]. + $CartModel({ + required String id, + required String customerId, + required String status, + }) : super(id: id, customerId: customerId, status: status) { + _attachOrmRuntimeMetadata({ + 'id': id, + 'customer_id': customerId, + 'status': status, + }); + } + + /// Creates a tracked model instance from a user-defined model instance. + factory $CartModel.fromModel(CartModel model) { + return $CartModel( + id: model.id, + customerId: model.customerId, + status: model.status, + ); + } + + $CartModel copyWith({String? id, String? customerId, String? status}) { + return $CartModel( + id: id ?? this.id, + customerId: customerId ?? this.customerId, + status: status ?? this.status, + ); + } + + /// Builds a tracked model from a column/value map. + static $CartModel fromMap( + Map data, { + ValueCodecRegistry? registry, + }) => _$CartModelDefinition.fromMap(data, registry: registry); + + /// Converts this tracked model to a column/value map. + Map toMap({ValueCodecRegistry? registry}) => + _$CartModelDefinition.toMap(this, registry: registry); + + /// Tracked getter for [id]. + @override + String get id => getAttribute('id') ?? super.id; + + /// Tracked setter for [id]. + set id(String value) => setAttribute('id', value); + + /// Tracked getter for [customerId]. + @override + String get customerId => + getAttribute('customer_id') ?? super.customerId; + + /// Tracked setter for [customerId]. + set customerId(String value) => setAttribute('customer_id', value); + + /// Tracked getter for [status]. + @override + String get status => getAttribute('status') ?? super.status; + + /// Tracked setter for [status]. + set status(String value) => setAttribute('status', value); + + void _attachOrmRuntimeMetadata(Map values) { + replaceAttributes(values); + attachModelDefinition(_$CartModelDefinition); + } +} + +class _CartModelCopyWithSentinel { + const _CartModelCopyWithSentinel(); +} + +extension CartModelOrmExtension on CartModel { + static const _CartModelCopyWithSentinel _copyWithSentinel = + _CartModelCopyWithSentinel(); + CartModel copyWith({ + Object? id = _copyWithSentinel, + Object? customerId = _copyWithSentinel, + Object? status = _copyWithSentinel, + }) { + return CartModel( + id: identical(id, _copyWithSentinel) ? this.id : id as String, + customerId: identical(customerId, _copyWithSentinel) + ? this.customerId + : customerId as String, + status: identical(status, _copyWithSentinel) + ? this.status + : status as String, + ); + } + + /// Converts this model to a column/value map. + Map toMap({ValueCodecRegistry? registry}) => + _$CartModelDefinition.toMap(this, registry: registry); + + /// Builds a model from a column/value map. + static CartModel fromMap( + Map data, { + ValueCodecRegistry? registry, + }) => _$CartModelDefinition.fromMap(data, registry: registry); + + /// The Type of the generated ORM-managed model class. + /// Use this when you need to specify the tracked model type explicitly, + /// for example in generic type parameters. + static Type get trackedType => $CartModel; + + /// Converts this immutable model to a tracked ORM-managed model. + /// The tracked model supports attribute tracking, change detection, + /// and persistence operations like save() and touch(). + $CartModel toTracked() { + return $CartModel.fromModel(this); + } +} + +extension CartModelPredicateFields on PredicateBuilder { + PredicateField get id => + PredicateField(this, 'id'); + PredicateField get customerId => + PredicateField(this, 'customerId'); + PredicateField get status => + PredicateField(this, 'status'); +} + +void registerCartModelEventHandlers(EventBus bus) { + // No event handlers registered for CartModel. +} diff --git a/packages/stem/example/ecommerce/lib/src/database/models/cart_item.dart b/packages/stem/example/ecommerce/lib/src/database/models/cart_item.dart new file mode 100644 index 00000000..6f1e0d01 --- /dev/null +++ b/packages/stem/example/ecommerce/lib/src/database/models/cart_item.dart @@ -0,0 +1,34 @@ +import 'package:ormed/ormed.dart'; + +part 'cart_item.orm.dart'; + +@OrmModel(table: 'cart_items') +class CartItemModel extends Model with TimestampsTZ { + const CartItemModel({ + required this.id, + required this.cartId, + required this.sku, + required this.title, + required this.quantity, + required this.unitPriceCents, + required this.lineTotalCents, + }); + + @OrmField(isPrimaryKey: true) + final String id; + + @OrmField(columnName: 'cart_id') + final String cartId; + + final String sku; + + final String title; + + final int quantity; + + @OrmField(columnName: 'unit_price_cents') + final int unitPriceCents; + + @OrmField(columnName: 'line_total_cents') + final int lineTotalCents; +} diff --git a/packages/stem/example/ecommerce/lib/src/database/models/cart_item.orm.dart b/packages/stem/example/ecommerce/lib/src/database/models/cart_item.orm.dart new file mode 100644 index 00000000..39a7e2b0 --- /dev/null +++ b/packages/stem/example/ecommerce/lib/src/database/models/cart_item.orm.dart @@ -0,0 +1,894 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 + +part of 'cart_item.dart'; + +// ************************************************************************** +// OrmModelGenerator +// ************************************************************************** + +const FieldDefinition _$CartItemModelIdField = FieldDefinition( + name: 'id', + columnName: 'id', + dartType: 'String', + resolvedType: 'String', + isPrimaryKey: true, + isNullable: false, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +const FieldDefinition _$CartItemModelCartIdField = FieldDefinition( + name: 'cartId', + columnName: 'cart_id', + dartType: 'String', + resolvedType: 'String', + isPrimaryKey: false, + isNullable: false, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +const FieldDefinition _$CartItemModelSkuField = FieldDefinition( + name: 'sku', + columnName: 'sku', + dartType: 'String', + resolvedType: 'String', + isPrimaryKey: false, + isNullable: false, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +const FieldDefinition _$CartItemModelTitleField = FieldDefinition( + name: 'title', + columnName: 'title', + dartType: 'String', + resolvedType: 'String', + isPrimaryKey: false, + isNullable: false, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +const FieldDefinition _$CartItemModelQuantityField = FieldDefinition( + name: 'quantity', + columnName: 'quantity', + dartType: 'int', + resolvedType: 'int', + isPrimaryKey: false, + isNullable: false, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +const FieldDefinition _$CartItemModelUnitPriceCentsField = FieldDefinition( + name: 'unitPriceCents', + columnName: 'unit_price_cents', + dartType: 'int', + resolvedType: 'int', + isPrimaryKey: false, + isNullable: false, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +const FieldDefinition _$CartItemModelLineTotalCentsField = FieldDefinition( + name: 'lineTotalCents', + columnName: 'line_total_cents', + dartType: 'int', + resolvedType: 'int', + isPrimaryKey: false, + isNullable: false, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +const FieldDefinition _$CartItemModelCreatedAtField = FieldDefinition( + name: 'createdAt', + columnName: 'created_at', + dartType: 'DateTime', + resolvedType: 'DateTime?', + isPrimaryKey: false, + isNullable: true, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +const FieldDefinition _$CartItemModelUpdatedAtField = FieldDefinition( + name: 'updatedAt', + columnName: 'updated_at', + dartType: 'DateTime', + resolvedType: 'DateTime?', + isPrimaryKey: false, + isNullable: true, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +Map _encodeCartItemModelUntracked( + Object model, + ValueCodecRegistry registry, +) { + final m = model as CartItemModel; + return { + 'id': registry.encodeField(_$CartItemModelIdField, m.id), + 'cart_id': registry.encodeField(_$CartItemModelCartIdField, m.cartId), + 'sku': registry.encodeField(_$CartItemModelSkuField, m.sku), + 'title': registry.encodeField(_$CartItemModelTitleField, m.title), + 'quantity': registry.encodeField(_$CartItemModelQuantityField, m.quantity), + 'unit_price_cents': registry.encodeField( + _$CartItemModelUnitPriceCentsField, + m.unitPriceCents, + ), + 'line_total_cents': registry.encodeField( + _$CartItemModelLineTotalCentsField, + m.lineTotalCents, + ), + }; +} + +final ModelDefinition<$CartItemModel> _$CartItemModelDefinition = + ModelDefinition( + modelName: 'CartItemModel', + tableName: 'cart_items', + fields: const [ + _$CartItemModelIdField, + _$CartItemModelCartIdField, + _$CartItemModelSkuField, + _$CartItemModelTitleField, + _$CartItemModelQuantityField, + _$CartItemModelUnitPriceCentsField, + _$CartItemModelLineTotalCentsField, + _$CartItemModelCreatedAtField, + _$CartItemModelUpdatedAtField, + ], + relations: const [], + softDeleteColumn: 'deleted_at', + metadata: ModelAttributesMetadata( + hidden: const [], + visible: const [], + fillable: const [], + guarded: const [], + casts: const {}, + appends: const [], + touches: const [], + timestamps: true, + softDeletes: false, + softDeleteColumn: 'deleted_at', + ), + untrackedToMap: _encodeCartItemModelUntracked, + codec: _$CartItemModelCodec(), + ); + +extension CartItemModelOrmDefinition on CartItemModel { + static ModelDefinition<$CartItemModel> get definition => + _$CartItemModelDefinition; +} + +class CartItemModels { + const CartItemModels._(); + + /// Starts building a query for [$CartItemModel]. + /// + /// {@macro ormed.query} + static Query<$CartItemModel> query([String? connection]) => + Model.query<$CartItemModel>(connection: connection); + + static Future<$CartItemModel?> find(Object id, {String? connection}) => + Model.find<$CartItemModel>(id, connection: connection); + + static Future<$CartItemModel> findOrFail(Object id, {String? connection}) => + Model.findOrFail<$CartItemModel>(id, connection: connection); + + static Future> all({String? connection}) => + Model.all<$CartItemModel>(connection: connection); + + static Future count({String? connection}) => + Model.count<$CartItemModel>(connection: connection); + + static Future anyExist({String? connection}) => + Model.anyExist<$CartItemModel>(connection: connection); + + static Query<$CartItemModel> where( + String column, + String operator, + dynamic value, { + String? connection, + }) => Model.where<$CartItemModel>( + column, + operator, + value, + connection: connection, + ); + + static Query<$CartItemModel> whereIn( + String column, + List values, { + String? connection, + }) => Model.whereIn<$CartItemModel>(column, values, connection: connection); + + static Query<$CartItemModel> orderBy( + String column, { + String direction = "asc", + String? connection, + }) => Model.orderBy<$CartItemModel>( + column, + direction: direction, + connection: connection, + ); + + static Query<$CartItemModel> limit(int count, {String? connection}) => + Model.limit<$CartItemModel>(count, connection: connection); + + /// Creates a [Repository] for [$CartItemModel]. + /// + /// {@macro ormed.repository} + static Repository<$CartItemModel> repo([String? connection]) => + Model.repository<$CartItemModel>(connection: connection); + + /// Builds a tracked model from a column/value map. + static $CartItemModel fromMap( + Map data, { + ValueCodecRegistry? registry, + }) => _$CartItemModelDefinition.fromMap(data, registry: registry); + + /// Converts a tracked model to a column/value map. + static Map toMap( + $CartItemModel model, { + ValueCodecRegistry? registry, + }) => _$CartItemModelDefinition.toMap(model, registry: registry); +} + +class CartItemModelFactory { + const CartItemModelFactory._(); + + static ModelDefinition<$CartItemModel> get definition => + _$CartItemModelDefinition; + + static ModelCodec<$CartItemModel> get codec => definition.codec; + + static CartItemModel fromMap( + Map data, { + ValueCodecRegistry? registry, + }) => definition.fromMap(data, registry: registry); + + static Map toMap( + CartItemModel model, { + ValueCodecRegistry? registry, + }) => definition.toMap(model.toTracked(), registry: registry); + + static void registerWith(ModelRegistry registry) => + registry.register(definition); + + static ModelFactoryConnection withConnection( + QueryContext context, + ) => ModelFactoryConnection( + definition: definition, + context: context, + ); + + static ModelFactoryBuilder factory({ + GeneratorProvider? generatorProvider, + }) => ModelFactoryRegistry.factoryFor( + generatorProvider: generatorProvider, + ); +} + +class _$CartItemModelCodec extends ModelCodec<$CartItemModel> { + const _$CartItemModelCodec(); + @override + Map encode( + $CartItemModel model, + ValueCodecRegistry registry, + ) { + return { + 'id': registry.encodeField(_$CartItemModelIdField, model.id), + 'cart_id': registry.encodeField(_$CartItemModelCartIdField, model.cartId), + 'sku': registry.encodeField(_$CartItemModelSkuField, model.sku), + 'title': registry.encodeField(_$CartItemModelTitleField, model.title), + 'quantity': registry.encodeField( + _$CartItemModelQuantityField, + model.quantity, + ), + 'unit_price_cents': registry.encodeField( + _$CartItemModelUnitPriceCentsField, + model.unitPriceCents, + ), + 'line_total_cents': registry.encodeField( + _$CartItemModelLineTotalCentsField, + model.lineTotalCents, + ), + if (model.hasAttribute('created_at')) + 'created_at': registry.encodeField( + _$CartItemModelCreatedAtField, + model.getAttribute('created_at'), + ), + if (model.hasAttribute('updated_at')) + 'updated_at': registry.encodeField( + _$CartItemModelUpdatedAtField, + model.getAttribute('updated_at'), + ), + }; + } + + @override + $CartItemModel decode( + Map data, + ValueCodecRegistry registry, + ) { + final String cartItemModelIdValue = + registry.decodeField(_$CartItemModelIdField, data['id']) ?? + (throw StateError('Field id on CartItemModel cannot be null.')); + final String cartItemModelCartIdValue = + registry.decodeField( + _$CartItemModelCartIdField, + data['cart_id'], + ) ?? + (throw StateError('Field cartId on CartItemModel cannot be null.')); + final String cartItemModelSkuValue = + registry.decodeField(_$CartItemModelSkuField, data['sku']) ?? + (throw StateError('Field sku on CartItemModel cannot be null.')); + final String cartItemModelTitleValue = + registry.decodeField( + _$CartItemModelTitleField, + data['title'], + ) ?? + (throw StateError('Field title on CartItemModel cannot be null.')); + final int cartItemModelQuantityValue = + registry.decodeField( + _$CartItemModelQuantityField, + data['quantity'], + ) ?? + (throw StateError('Field quantity on CartItemModel cannot be null.')); + final int cartItemModelUnitPriceCentsValue = + registry.decodeField( + _$CartItemModelUnitPriceCentsField, + data['unit_price_cents'], + ) ?? + (throw StateError( + 'Field unitPriceCents on CartItemModel cannot be null.', + )); + final int cartItemModelLineTotalCentsValue = + registry.decodeField( + _$CartItemModelLineTotalCentsField, + data['line_total_cents'], + ) ?? + (throw StateError( + 'Field lineTotalCents on CartItemModel cannot be null.', + )); + final DateTime? cartItemModelCreatedAtValue = registry + .decodeField( + _$CartItemModelCreatedAtField, + data['created_at'], + ); + final DateTime? cartItemModelUpdatedAtValue = registry + .decodeField( + _$CartItemModelUpdatedAtField, + data['updated_at'], + ); + final model = $CartItemModel( + id: cartItemModelIdValue, + cartId: cartItemModelCartIdValue, + sku: cartItemModelSkuValue, + title: cartItemModelTitleValue, + quantity: cartItemModelQuantityValue, + unitPriceCents: cartItemModelUnitPriceCentsValue, + lineTotalCents: cartItemModelLineTotalCentsValue, + ); + model._attachOrmRuntimeMetadata({ + 'id': cartItemModelIdValue, + 'cart_id': cartItemModelCartIdValue, + 'sku': cartItemModelSkuValue, + 'title': cartItemModelTitleValue, + 'quantity': cartItemModelQuantityValue, + 'unit_price_cents': cartItemModelUnitPriceCentsValue, + 'line_total_cents': cartItemModelLineTotalCentsValue, + if (data.containsKey('created_at')) + 'created_at': cartItemModelCreatedAtValue, + if (data.containsKey('updated_at')) + 'updated_at': cartItemModelUpdatedAtValue, + }); + return model; + } +} + +/// Insert DTO for [CartItemModel]. +/// +/// Auto-increment/DB-generated fields are omitted by default. +class CartItemModelInsertDto implements InsertDto<$CartItemModel> { + const CartItemModelInsertDto({ + this.id, + this.cartId, + this.sku, + this.title, + this.quantity, + this.unitPriceCents, + this.lineTotalCents, + }); + final String? id; + final String? cartId; + final String? sku; + final String? title; + final int? quantity; + final int? unitPriceCents; + final int? lineTotalCents; + + @override + Map toMap() { + return { + if (id != null) 'id': id, + if (cartId != null) 'cart_id': cartId, + if (sku != null) 'sku': sku, + if (title != null) 'title': title, + if (quantity != null) 'quantity': quantity, + if (unitPriceCents != null) 'unit_price_cents': unitPriceCents, + if (lineTotalCents != null) 'line_total_cents': lineTotalCents, + }; + } + + static const _CartItemModelInsertDtoCopyWithSentinel _copyWithSentinel = + _CartItemModelInsertDtoCopyWithSentinel(); + CartItemModelInsertDto copyWith({ + Object? id = _copyWithSentinel, + Object? cartId = _copyWithSentinel, + Object? sku = _copyWithSentinel, + Object? title = _copyWithSentinel, + Object? quantity = _copyWithSentinel, + Object? unitPriceCents = _copyWithSentinel, + Object? lineTotalCents = _copyWithSentinel, + }) { + return CartItemModelInsertDto( + id: identical(id, _copyWithSentinel) ? this.id : id as String?, + cartId: identical(cartId, _copyWithSentinel) + ? this.cartId + : cartId as String?, + sku: identical(sku, _copyWithSentinel) ? this.sku : sku as String?, + title: identical(title, _copyWithSentinel) + ? this.title + : title as String?, + quantity: identical(quantity, _copyWithSentinel) + ? this.quantity + : quantity as int?, + unitPriceCents: identical(unitPriceCents, _copyWithSentinel) + ? this.unitPriceCents + : unitPriceCents as int?, + lineTotalCents: identical(lineTotalCents, _copyWithSentinel) + ? this.lineTotalCents + : lineTotalCents as int?, + ); + } +} + +class _CartItemModelInsertDtoCopyWithSentinel { + const _CartItemModelInsertDtoCopyWithSentinel(); +} + +/// Update DTO for [CartItemModel]. +/// +/// All fields are optional; only provided entries are used in SET clauses. +class CartItemModelUpdateDto implements UpdateDto<$CartItemModel> { + const CartItemModelUpdateDto({ + this.id, + this.cartId, + this.sku, + this.title, + this.quantity, + this.unitPriceCents, + this.lineTotalCents, + }); + final String? id; + final String? cartId; + final String? sku; + final String? title; + final int? quantity; + final int? unitPriceCents; + final int? lineTotalCents; + + @override + Map toMap() { + return { + if (id != null) 'id': id, + if (cartId != null) 'cart_id': cartId, + if (sku != null) 'sku': sku, + if (title != null) 'title': title, + if (quantity != null) 'quantity': quantity, + if (unitPriceCents != null) 'unit_price_cents': unitPriceCents, + if (lineTotalCents != null) 'line_total_cents': lineTotalCents, + }; + } + + static const _CartItemModelUpdateDtoCopyWithSentinel _copyWithSentinel = + _CartItemModelUpdateDtoCopyWithSentinel(); + CartItemModelUpdateDto copyWith({ + Object? id = _copyWithSentinel, + Object? cartId = _copyWithSentinel, + Object? sku = _copyWithSentinel, + Object? title = _copyWithSentinel, + Object? quantity = _copyWithSentinel, + Object? unitPriceCents = _copyWithSentinel, + Object? lineTotalCents = _copyWithSentinel, + }) { + return CartItemModelUpdateDto( + id: identical(id, _copyWithSentinel) ? this.id : id as String?, + cartId: identical(cartId, _copyWithSentinel) + ? this.cartId + : cartId as String?, + sku: identical(sku, _copyWithSentinel) ? this.sku : sku as String?, + title: identical(title, _copyWithSentinel) + ? this.title + : title as String?, + quantity: identical(quantity, _copyWithSentinel) + ? this.quantity + : quantity as int?, + unitPriceCents: identical(unitPriceCents, _copyWithSentinel) + ? this.unitPriceCents + : unitPriceCents as int?, + lineTotalCents: identical(lineTotalCents, _copyWithSentinel) + ? this.lineTotalCents + : lineTotalCents as int?, + ); + } +} + +class _CartItemModelUpdateDtoCopyWithSentinel { + const _CartItemModelUpdateDtoCopyWithSentinel(); +} + +/// Partial projection for [CartItemModel]. +/// +/// All fields are nullable; intended for subset SELECTs. +class CartItemModelPartial implements PartialEntity<$CartItemModel> { + const CartItemModelPartial({ + this.id, + this.cartId, + this.sku, + this.title, + this.quantity, + this.unitPriceCents, + this.lineTotalCents, + }); + + /// Creates a partial from a database row map. + /// + /// The [row] keys should be column names (snake_case). + /// Missing columns will result in null field values. + factory CartItemModelPartial.fromRow(Map row) { + return CartItemModelPartial( + id: row['id'] as String?, + cartId: row['cart_id'] as String?, + sku: row['sku'] as String?, + title: row['title'] as String?, + quantity: row['quantity'] as int?, + unitPriceCents: row['unit_price_cents'] as int?, + lineTotalCents: row['line_total_cents'] as int?, + ); + } + + final String? id; + final String? cartId; + final String? sku; + final String? title; + final int? quantity; + final int? unitPriceCents; + final int? lineTotalCents; + + @override + $CartItemModel toEntity() { + // Basic required-field check: non-nullable fields must be present. + final String? idValue = id; + if (idValue == null) { + throw StateError('Missing required field: id'); + } + final String? cartIdValue = cartId; + if (cartIdValue == null) { + throw StateError('Missing required field: cartId'); + } + final String? skuValue = sku; + if (skuValue == null) { + throw StateError('Missing required field: sku'); + } + final String? titleValue = title; + if (titleValue == null) { + throw StateError('Missing required field: title'); + } + final int? quantityValue = quantity; + if (quantityValue == null) { + throw StateError('Missing required field: quantity'); + } + final int? unitPriceCentsValue = unitPriceCents; + if (unitPriceCentsValue == null) { + throw StateError('Missing required field: unitPriceCents'); + } + final int? lineTotalCentsValue = lineTotalCents; + if (lineTotalCentsValue == null) { + throw StateError('Missing required field: lineTotalCents'); + } + return $CartItemModel( + id: idValue, + cartId: cartIdValue, + sku: skuValue, + title: titleValue, + quantity: quantityValue, + unitPriceCents: unitPriceCentsValue, + lineTotalCents: lineTotalCentsValue, + ); + } + + @override + Map toMap() { + return { + if (id != null) 'id': id, + if (cartId != null) 'cart_id': cartId, + if (sku != null) 'sku': sku, + if (title != null) 'title': title, + if (quantity != null) 'quantity': quantity, + if (unitPriceCents != null) 'unit_price_cents': unitPriceCents, + if (lineTotalCents != null) 'line_total_cents': lineTotalCents, + }; + } + + static const _CartItemModelPartialCopyWithSentinel _copyWithSentinel = + _CartItemModelPartialCopyWithSentinel(); + CartItemModelPartial copyWith({ + Object? id = _copyWithSentinel, + Object? cartId = _copyWithSentinel, + Object? sku = _copyWithSentinel, + Object? title = _copyWithSentinel, + Object? quantity = _copyWithSentinel, + Object? unitPriceCents = _copyWithSentinel, + Object? lineTotalCents = _copyWithSentinel, + }) { + return CartItemModelPartial( + id: identical(id, _copyWithSentinel) ? this.id : id as String?, + cartId: identical(cartId, _copyWithSentinel) + ? this.cartId + : cartId as String?, + sku: identical(sku, _copyWithSentinel) ? this.sku : sku as String?, + title: identical(title, _copyWithSentinel) + ? this.title + : title as String?, + quantity: identical(quantity, _copyWithSentinel) + ? this.quantity + : quantity as int?, + unitPriceCents: identical(unitPriceCents, _copyWithSentinel) + ? this.unitPriceCents + : unitPriceCents as int?, + lineTotalCents: identical(lineTotalCents, _copyWithSentinel) + ? this.lineTotalCents + : lineTotalCents as int?, + ); + } +} + +class _CartItemModelPartialCopyWithSentinel { + const _CartItemModelPartialCopyWithSentinel(); +} + +/// Generated tracked model class for [CartItemModel]. +/// +/// This class extends the user-defined [CartItemModel] model and adds +/// attribute tracking, change detection, and relationship management. +/// Instances of this class are returned by queries and repositories. +/// +/// **Do not instantiate this class directly.** Use queries, repositories, +/// or model factories to create tracked model instances. +class $CartItemModel extends CartItemModel + with ModelAttributes, TimestampsTZImpl + implements OrmEntity { + /// Internal constructor for [$CartItemModel]. + $CartItemModel({ + required String id, + required String cartId, + required String sku, + required String title, + required int quantity, + required int unitPriceCents, + required int lineTotalCents, + }) : super( + id: id, + cartId: cartId, + sku: sku, + title: title, + quantity: quantity, + unitPriceCents: unitPriceCents, + lineTotalCents: lineTotalCents, + ) { + _attachOrmRuntimeMetadata({ + 'id': id, + 'cart_id': cartId, + 'sku': sku, + 'title': title, + 'quantity': quantity, + 'unit_price_cents': unitPriceCents, + 'line_total_cents': lineTotalCents, + }); + } + + /// Creates a tracked model instance from a user-defined model instance. + factory $CartItemModel.fromModel(CartItemModel model) { + return $CartItemModel( + id: model.id, + cartId: model.cartId, + sku: model.sku, + title: model.title, + quantity: model.quantity, + unitPriceCents: model.unitPriceCents, + lineTotalCents: model.lineTotalCents, + ); + } + + $CartItemModel copyWith({ + String? id, + String? cartId, + String? sku, + String? title, + int? quantity, + int? unitPriceCents, + int? lineTotalCents, + }) { + return $CartItemModel( + id: id ?? this.id, + cartId: cartId ?? this.cartId, + sku: sku ?? this.sku, + title: title ?? this.title, + quantity: quantity ?? this.quantity, + unitPriceCents: unitPriceCents ?? this.unitPriceCents, + lineTotalCents: lineTotalCents ?? this.lineTotalCents, + ); + } + + /// Builds a tracked model from a column/value map. + static $CartItemModel fromMap( + Map data, { + ValueCodecRegistry? registry, + }) => _$CartItemModelDefinition.fromMap(data, registry: registry); + + /// Converts this tracked model to a column/value map. + Map toMap({ValueCodecRegistry? registry}) => + _$CartItemModelDefinition.toMap(this, registry: registry); + + /// Tracked getter for [id]. + @override + String get id => getAttribute('id') ?? super.id; + + /// Tracked setter for [id]. + set id(String value) => setAttribute('id', value); + + /// Tracked getter for [cartId]. + @override + String get cartId => getAttribute('cart_id') ?? super.cartId; + + /// Tracked setter for [cartId]. + set cartId(String value) => setAttribute('cart_id', value); + + /// Tracked getter for [sku]. + @override + String get sku => getAttribute('sku') ?? super.sku; + + /// Tracked setter for [sku]. + set sku(String value) => setAttribute('sku', value); + + /// Tracked getter for [title]. + @override + String get title => getAttribute('title') ?? super.title; + + /// Tracked setter for [title]. + set title(String value) => setAttribute('title', value); + + /// Tracked getter for [quantity]. + @override + int get quantity => getAttribute('quantity') ?? super.quantity; + + /// Tracked setter for [quantity]. + set quantity(int value) => setAttribute('quantity', value); + + /// Tracked getter for [unitPriceCents]. + @override + int get unitPriceCents => + getAttribute('unit_price_cents') ?? super.unitPriceCents; + + /// Tracked setter for [unitPriceCents]. + set unitPriceCents(int value) => setAttribute('unit_price_cents', value); + + /// Tracked getter for [lineTotalCents]. + @override + int get lineTotalCents => + getAttribute('line_total_cents') ?? super.lineTotalCents; + + /// Tracked setter for [lineTotalCents]. + set lineTotalCents(int value) => setAttribute('line_total_cents', value); + + void _attachOrmRuntimeMetadata(Map values) { + replaceAttributes(values); + attachModelDefinition(_$CartItemModelDefinition); + } +} + +class _CartItemModelCopyWithSentinel { + const _CartItemModelCopyWithSentinel(); +} + +extension CartItemModelOrmExtension on CartItemModel { + static const _CartItemModelCopyWithSentinel _copyWithSentinel = + _CartItemModelCopyWithSentinel(); + CartItemModel copyWith({ + Object? id = _copyWithSentinel, + Object? cartId = _copyWithSentinel, + Object? sku = _copyWithSentinel, + Object? title = _copyWithSentinel, + Object? quantity = _copyWithSentinel, + Object? unitPriceCents = _copyWithSentinel, + Object? lineTotalCents = _copyWithSentinel, + }) { + return CartItemModel( + id: identical(id, _copyWithSentinel) ? this.id : id as String, + cartId: identical(cartId, _copyWithSentinel) + ? this.cartId + : cartId as String, + sku: identical(sku, _copyWithSentinel) ? this.sku : sku as String, + title: identical(title, _copyWithSentinel) ? this.title : title as String, + quantity: identical(quantity, _copyWithSentinel) + ? this.quantity + : quantity as int, + unitPriceCents: identical(unitPriceCents, _copyWithSentinel) + ? this.unitPriceCents + : unitPriceCents as int, + lineTotalCents: identical(lineTotalCents, _copyWithSentinel) + ? this.lineTotalCents + : lineTotalCents as int, + ); + } + + /// Converts this model to a column/value map. + Map toMap({ValueCodecRegistry? registry}) => + _$CartItemModelDefinition.toMap(this, registry: registry); + + /// Builds a model from a column/value map. + static CartItemModel fromMap( + Map data, { + ValueCodecRegistry? registry, + }) => _$CartItemModelDefinition.fromMap(data, registry: registry); + + /// The Type of the generated ORM-managed model class. + /// Use this when you need to specify the tracked model type explicitly, + /// for example in generic type parameters. + static Type get trackedType => $CartItemModel; + + /// Converts this immutable model to a tracked ORM-managed model. + /// The tracked model supports attribute tracking, change detection, + /// and persistence operations like save() and touch(). + $CartItemModel toTracked() { + return $CartItemModel.fromModel(this); + } +} + +extension CartItemModelPredicateFields on PredicateBuilder { + PredicateField get id => + PredicateField(this, 'id'); + PredicateField get cartId => + PredicateField(this, 'cartId'); + PredicateField get sku => + PredicateField(this, 'sku'); + PredicateField get title => + PredicateField(this, 'title'); + PredicateField get quantity => + PredicateField(this, 'quantity'); + PredicateField get unitPriceCents => + PredicateField(this, 'unitPriceCents'); + PredicateField get lineTotalCents => + PredicateField(this, 'lineTotalCents'); +} + +void registerCartItemModelEventHandlers(EventBus bus) { + // No event handlers registered for CartItemModel. +} diff --git a/packages/stem/example/ecommerce/lib/src/database/models/catalog_sku.dart b/packages/stem/example/ecommerce/lib/src/database/models/catalog_sku.dart new file mode 100644 index 00000000..938ea362 --- /dev/null +++ b/packages/stem/example/ecommerce/lib/src/database/models/catalog_sku.dart @@ -0,0 +1,24 @@ +import 'package:ormed/ormed.dart'; + +part 'catalog_sku.orm.dart'; + +@OrmModel(table: 'catalog_skus') +class CatalogSkuModel extends Model with TimestampsTZ { + const CatalogSkuModel({ + required this.sku, + required this.title, + required this.priceCents, + required this.stockAvailable, + }); + + @OrmField(isPrimaryKey: true) + final String sku; + + final String title; + + @OrmField(columnName: 'price_cents') + final int priceCents; + + @OrmField(columnName: 'stock_available') + final int stockAvailable; +} diff --git a/packages/stem/example/ecommerce/lib/src/database/models/catalog_sku.orm.dart b/packages/stem/example/ecommerce/lib/src/database/models/catalog_sku.orm.dart new file mode 100644 index 00000000..e341015b --- /dev/null +++ b/packages/stem/example/ecommerce/lib/src/database/models/catalog_sku.orm.dart @@ -0,0 +1,694 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 + +part of 'catalog_sku.dart'; + +// ************************************************************************** +// OrmModelGenerator +// ************************************************************************** + +const FieldDefinition _$CatalogSkuModelSkuField = FieldDefinition( + name: 'sku', + columnName: 'sku', + dartType: 'String', + resolvedType: 'String', + isPrimaryKey: true, + isNullable: false, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +const FieldDefinition _$CatalogSkuModelTitleField = FieldDefinition( + name: 'title', + columnName: 'title', + dartType: 'String', + resolvedType: 'String', + isPrimaryKey: false, + isNullable: false, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +const FieldDefinition _$CatalogSkuModelPriceCentsField = FieldDefinition( + name: 'priceCents', + columnName: 'price_cents', + dartType: 'int', + resolvedType: 'int', + isPrimaryKey: false, + isNullable: false, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +const FieldDefinition _$CatalogSkuModelStockAvailableField = FieldDefinition( + name: 'stockAvailable', + columnName: 'stock_available', + dartType: 'int', + resolvedType: 'int', + isPrimaryKey: false, + isNullable: false, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +const FieldDefinition _$CatalogSkuModelCreatedAtField = FieldDefinition( + name: 'createdAt', + columnName: 'created_at', + dartType: 'DateTime', + resolvedType: 'DateTime?', + isPrimaryKey: false, + isNullable: true, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +const FieldDefinition _$CatalogSkuModelUpdatedAtField = FieldDefinition( + name: 'updatedAt', + columnName: 'updated_at', + dartType: 'DateTime', + resolvedType: 'DateTime?', + isPrimaryKey: false, + isNullable: true, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +Map _encodeCatalogSkuModelUntracked( + Object model, + ValueCodecRegistry registry, +) { + final m = model as CatalogSkuModel; + return { + 'sku': registry.encodeField(_$CatalogSkuModelSkuField, m.sku), + 'title': registry.encodeField(_$CatalogSkuModelTitleField, m.title), + 'price_cents': registry.encodeField( + _$CatalogSkuModelPriceCentsField, + m.priceCents, + ), + 'stock_available': registry.encodeField( + _$CatalogSkuModelStockAvailableField, + m.stockAvailable, + ), + }; +} + +final ModelDefinition<$CatalogSkuModel> _$CatalogSkuModelDefinition = + ModelDefinition( + modelName: 'CatalogSkuModel', + tableName: 'catalog_skus', + fields: const [ + _$CatalogSkuModelSkuField, + _$CatalogSkuModelTitleField, + _$CatalogSkuModelPriceCentsField, + _$CatalogSkuModelStockAvailableField, + _$CatalogSkuModelCreatedAtField, + _$CatalogSkuModelUpdatedAtField, + ], + relations: const [], + softDeleteColumn: 'deleted_at', + metadata: ModelAttributesMetadata( + hidden: const [], + visible: const [], + fillable: const [], + guarded: const [], + casts: const {}, + appends: const [], + touches: const [], + timestamps: true, + softDeletes: false, + softDeleteColumn: 'deleted_at', + ), + untrackedToMap: _encodeCatalogSkuModelUntracked, + codec: _$CatalogSkuModelCodec(), + ); + +extension CatalogSkuModelOrmDefinition on CatalogSkuModel { + static ModelDefinition<$CatalogSkuModel> get definition => + _$CatalogSkuModelDefinition; +} + +class CatalogSkuModels { + const CatalogSkuModels._(); + + /// Starts building a query for [$CatalogSkuModel]. + /// + /// {@macro ormed.query} + static Query<$CatalogSkuModel> query([String? connection]) => + Model.query<$CatalogSkuModel>(connection: connection); + + static Future<$CatalogSkuModel?> find(Object id, {String? connection}) => + Model.find<$CatalogSkuModel>(id, connection: connection); + + static Future<$CatalogSkuModel> findOrFail(Object id, {String? connection}) => + Model.findOrFail<$CatalogSkuModel>(id, connection: connection); + + static Future> all({String? connection}) => + Model.all<$CatalogSkuModel>(connection: connection); + + static Future count({String? connection}) => + Model.count<$CatalogSkuModel>(connection: connection); + + static Future anyExist({String? connection}) => + Model.anyExist<$CatalogSkuModel>(connection: connection); + + static Query<$CatalogSkuModel> where( + String column, + String operator, + dynamic value, { + String? connection, + }) => Model.where<$CatalogSkuModel>( + column, + operator, + value, + connection: connection, + ); + + static Query<$CatalogSkuModel> whereIn( + String column, + List values, { + String? connection, + }) => Model.whereIn<$CatalogSkuModel>(column, values, connection: connection); + + static Query<$CatalogSkuModel> orderBy( + String column, { + String direction = "asc", + String? connection, + }) => Model.orderBy<$CatalogSkuModel>( + column, + direction: direction, + connection: connection, + ); + + static Query<$CatalogSkuModel> limit(int count, {String? connection}) => + Model.limit<$CatalogSkuModel>(count, connection: connection); + + /// Creates a [Repository] for [$CatalogSkuModel]. + /// + /// {@macro ormed.repository} + static Repository<$CatalogSkuModel> repo([String? connection]) => + Model.repository<$CatalogSkuModel>(connection: connection); + + /// Builds a tracked model from a column/value map. + static $CatalogSkuModel fromMap( + Map data, { + ValueCodecRegistry? registry, + }) => _$CatalogSkuModelDefinition.fromMap(data, registry: registry); + + /// Converts a tracked model to a column/value map. + static Map toMap( + $CatalogSkuModel model, { + ValueCodecRegistry? registry, + }) => _$CatalogSkuModelDefinition.toMap(model, registry: registry); +} + +class CatalogSkuModelFactory { + const CatalogSkuModelFactory._(); + + static ModelDefinition<$CatalogSkuModel> get definition => + _$CatalogSkuModelDefinition; + + static ModelCodec<$CatalogSkuModel> get codec => definition.codec; + + static CatalogSkuModel fromMap( + Map data, { + ValueCodecRegistry? registry, + }) => definition.fromMap(data, registry: registry); + + static Map toMap( + CatalogSkuModel model, { + ValueCodecRegistry? registry, + }) => definition.toMap(model.toTracked(), registry: registry); + + static void registerWith(ModelRegistry registry) => + registry.register(definition); + + static ModelFactoryConnection withConnection( + QueryContext context, + ) => ModelFactoryConnection( + definition: definition, + context: context, + ); + + static ModelFactoryBuilder factory({ + GeneratorProvider? generatorProvider, + }) => ModelFactoryRegistry.factoryFor( + generatorProvider: generatorProvider, + ); +} + +class _$CatalogSkuModelCodec extends ModelCodec<$CatalogSkuModel> { + const _$CatalogSkuModelCodec(); + @override + Map encode( + $CatalogSkuModel model, + ValueCodecRegistry registry, + ) { + return { + 'sku': registry.encodeField(_$CatalogSkuModelSkuField, model.sku), + 'title': registry.encodeField(_$CatalogSkuModelTitleField, model.title), + 'price_cents': registry.encodeField( + _$CatalogSkuModelPriceCentsField, + model.priceCents, + ), + 'stock_available': registry.encodeField( + _$CatalogSkuModelStockAvailableField, + model.stockAvailable, + ), + if (model.hasAttribute('created_at')) + 'created_at': registry.encodeField( + _$CatalogSkuModelCreatedAtField, + model.getAttribute('created_at'), + ), + if (model.hasAttribute('updated_at')) + 'updated_at': registry.encodeField( + _$CatalogSkuModelUpdatedAtField, + model.getAttribute('updated_at'), + ), + }; + } + + @override + $CatalogSkuModel decode( + Map data, + ValueCodecRegistry registry, + ) { + final String catalogSkuModelSkuValue = + registry.decodeField(_$CatalogSkuModelSkuField, data['sku']) ?? + (throw StateError('Field sku on CatalogSkuModel cannot be null.')); + final String catalogSkuModelTitleValue = + registry.decodeField( + _$CatalogSkuModelTitleField, + data['title'], + ) ?? + (throw StateError('Field title on CatalogSkuModel cannot be null.')); + final int catalogSkuModelPriceCentsValue = + registry.decodeField( + _$CatalogSkuModelPriceCentsField, + data['price_cents'], + ) ?? + (throw StateError( + 'Field priceCents on CatalogSkuModel cannot be null.', + )); + final int catalogSkuModelStockAvailableValue = + registry.decodeField( + _$CatalogSkuModelStockAvailableField, + data['stock_available'], + ) ?? + (throw StateError( + 'Field stockAvailable on CatalogSkuModel cannot be null.', + )); + final DateTime? catalogSkuModelCreatedAtValue = registry + .decodeField( + _$CatalogSkuModelCreatedAtField, + data['created_at'], + ); + final DateTime? catalogSkuModelUpdatedAtValue = registry + .decodeField( + _$CatalogSkuModelUpdatedAtField, + data['updated_at'], + ); + final model = $CatalogSkuModel( + sku: catalogSkuModelSkuValue, + title: catalogSkuModelTitleValue, + priceCents: catalogSkuModelPriceCentsValue, + stockAvailable: catalogSkuModelStockAvailableValue, + ); + model._attachOrmRuntimeMetadata({ + 'sku': catalogSkuModelSkuValue, + 'title': catalogSkuModelTitleValue, + 'price_cents': catalogSkuModelPriceCentsValue, + 'stock_available': catalogSkuModelStockAvailableValue, + if (data.containsKey('created_at')) + 'created_at': catalogSkuModelCreatedAtValue, + if (data.containsKey('updated_at')) + 'updated_at': catalogSkuModelUpdatedAtValue, + }); + return model; + } +} + +/// Insert DTO for [CatalogSkuModel]. +/// +/// Auto-increment/DB-generated fields are omitted by default. +class CatalogSkuModelInsertDto implements InsertDto<$CatalogSkuModel> { + const CatalogSkuModelInsertDto({ + this.sku, + this.title, + this.priceCents, + this.stockAvailable, + }); + final String? sku; + final String? title; + final int? priceCents; + final int? stockAvailable; + + @override + Map toMap() { + return { + if (sku != null) 'sku': sku, + if (title != null) 'title': title, + if (priceCents != null) 'price_cents': priceCents, + if (stockAvailable != null) 'stock_available': stockAvailable, + }; + } + + static const _CatalogSkuModelInsertDtoCopyWithSentinel _copyWithSentinel = + _CatalogSkuModelInsertDtoCopyWithSentinel(); + CatalogSkuModelInsertDto copyWith({ + Object? sku = _copyWithSentinel, + Object? title = _copyWithSentinel, + Object? priceCents = _copyWithSentinel, + Object? stockAvailable = _copyWithSentinel, + }) { + return CatalogSkuModelInsertDto( + sku: identical(sku, _copyWithSentinel) ? this.sku : sku as String?, + title: identical(title, _copyWithSentinel) + ? this.title + : title as String?, + priceCents: identical(priceCents, _copyWithSentinel) + ? this.priceCents + : priceCents as int?, + stockAvailable: identical(stockAvailable, _copyWithSentinel) + ? this.stockAvailable + : stockAvailable as int?, + ); + } +} + +class _CatalogSkuModelInsertDtoCopyWithSentinel { + const _CatalogSkuModelInsertDtoCopyWithSentinel(); +} + +/// Update DTO for [CatalogSkuModel]. +/// +/// All fields are optional; only provided entries are used in SET clauses. +class CatalogSkuModelUpdateDto implements UpdateDto<$CatalogSkuModel> { + const CatalogSkuModelUpdateDto({ + this.sku, + this.title, + this.priceCents, + this.stockAvailable, + }); + final String? sku; + final String? title; + final int? priceCents; + final int? stockAvailable; + + @override + Map toMap() { + return { + if (sku != null) 'sku': sku, + if (title != null) 'title': title, + if (priceCents != null) 'price_cents': priceCents, + if (stockAvailable != null) 'stock_available': stockAvailable, + }; + } + + static const _CatalogSkuModelUpdateDtoCopyWithSentinel _copyWithSentinel = + _CatalogSkuModelUpdateDtoCopyWithSentinel(); + CatalogSkuModelUpdateDto copyWith({ + Object? sku = _copyWithSentinel, + Object? title = _copyWithSentinel, + Object? priceCents = _copyWithSentinel, + Object? stockAvailable = _copyWithSentinel, + }) { + return CatalogSkuModelUpdateDto( + sku: identical(sku, _copyWithSentinel) ? this.sku : sku as String?, + title: identical(title, _copyWithSentinel) + ? this.title + : title as String?, + priceCents: identical(priceCents, _copyWithSentinel) + ? this.priceCents + : priceCents as int?, + stockAvailable: identical(stockAvailable, _copyWithSentinel) + ? this.stockAvailable + : stockAvailable as int?, + ); + } +} + +class _CatalogSkuModelUpdateDtoCopyWithSentinel { + const _CatalogSkuModelUpdateDtoCopyWithSentinel(); +} + +/// Partial projection for [CatalogSkuModel]. +/// +/// All fields are nullable; intended for subset SELECTs. +class CatalogSkuModelPartial implements PartialEntity<$CatalogSkuModel> { + const CatalogSkuModelPartial({ + this.sku, + this.title, + this.priceCents, + this.stockAvailable, + }); + + /// Creates a partial from a database row map. + /// + /// The [row] keys should be column names (snake_case). + /// Missing columns will result in null field values. + factory CatalogSkuModelPartial.fromRow(Map row) { + return CatalogSkuModelPartial( + sku: row['sku'] as String?, + title: row['title'] as String?, + priceCents: row['price_cents'] as int?, + stockAvailable: row['stock_available'] as int?, + ); + } + + final String? sku; + final String? title; + final int? priceCents; + final int? stockAvailable; + + @override + $CatalogSkuModel toEntity() { + // Basic required-field check: non-nullable fields must be present. + final String? skuValue = sku; + if (skuValue == null) { + throw StateError('Missing required field: sku'); + } + final String? titleValue = title; + if (titleValue == null) { + throw StateError('Missing required field: title'); + } + final int? priceCentsValue = priceCents; + if (priceCentsValue == null) { + throw StateError('Missing required field: priceCents'); + } + final int? stockAvailableValue = stockAvailable; + if (stockAvailableValue == null) { + throw StateError('Missing required field: stockAvailable'); + } + return $CatalogSkuModel( + sku: skuValue, + title: titleValue, + priceCents: priceCentsValue, + stockAvailable: stockAvailableValue, + ); + } + + @override + Map toMap() { + return { + if (sku != null) 'sku': sku, + if (title != null) 'title': title, + if (priceCents != null) 'price_cents': priceCents, + if (stockAvailable != null) 'stock_available': stockAvailable, + }; + } + + static const _CatalogSkuModelPartialCopyWithSentinel _copyWithSentinel = + _CatalogSkuModelPartialCopyWithSentinel(); + CatalogSkuModelPartial copyWith({ + Object? sku = _copyWithSentinel, + Object? title = _copyWithSentinel, + Object? priceCents = _copyWithSentinel, + Object? stockAvailable = _copyWithSentinel, + }) { + return CatalogSkuModelPartial( + sku: identical(sku, _copyWithSentinel) ? this.sku : sku as String?, + title: identical(title, _copyWithSentinel) + ? this.title + : title as String?, + priceCents: identical(priceCents, _copyWithSentinel) + ? this.priceCents + : priceCents as int?, + stockAvailable: identical(stockAvailable, _copyWithSentinel) + ? this.stockAvailable + : stockAvailable as int?, + ); + } +} + +class _CatalogSkuModelPartialCopyWithSentinel { + const _CatalogSkuModelPartialCopyWithSentinel(); +} + +/// Generated tracked model class for [CatalogSkuModel]. +/// +/// This class extends the user-defined [CatalogSkuModel] model and adds +/// attribute tracking, change detection, and relationship management. +/// Instances of this class are returned by queries and repositories. +/// +/// **Do not instantiate this class directly.** Use queries, repositories, +/// or model factories to create tracked model instances. +class $CatalogSkuModel extends CatalogSkuModel + with ModelAttributes, TimestampsTZImpl + implements OrmEntity { + /// Internal constructor for [$CatalogSkuModel]. + $CatalogSkuModel({ + required String sku, + required String title, + required int priceCents, + required int stockAvailable, + }) : super( + sku: sku, + title: title, + priceCents: priceCents, + stockAvailable: stockAvailable, + ) { + _attachOrmRuntimeMetadata({ + 'sku': sku, + 'title': title, + 'price_cents': priceCents, + 'stock_available': stockAvailable, + }); + } + + /// Creates a tracked model instance from a user-defined model instance. + factory $CatalogSkuModel.fromModel(CatalogSkuModel model) { + return $CatalogSkuModel( + sku: model.sku, + title: model.title, + priceCents: model.priceCents, + stockAvailable: model.stockAvailable, + ); + } + + $CatalogSkuModel copyWith({ + String? sku, + String? title, + int? priceCents, + int? stockAvailable, + }) { + return $CatalogSkuModel( + sku: sku ?? this.sku, + title: title ?? this.title, + priceCents: priceCents ?? this.priceCents, + stockAvailable: stockAvailable ?? this.stockAvailable, + ); + } + + /// Builds a tracked model from a column/value map. + static $CatalogSkuModel fromMap( + Map data, { + ValueCodecRegistry? registry, + }) => _$CatalogSkuModelDefinition.fromMap(data, registry: registry); + + /// Converts this tracked model to a column/value map. + Map toMap({ValueCodecRegistry? registry}) => + _$CatalogSkuModelDefinition.toMap(this, registry: registry); + + /// Tracked getter for [sku]. + @override + String get sku => getAttribute('sku') ?? super.sku; + + /// Tracked setter for [sku]. + set sku(String value) => setAttribute('sku', value); + + /// Tracked getter for [title]. + @override + String get title => getAttribute('title') ?? super.title; + + /// Tracked setter for [title]. + set title(String value) => setAttribute('title', value); + + /// Tracked getter for [priceCents]. + @override + int get priceCents => getAttribute('price_cents') ?? super.priceCents; + + /// Tracked setter for [priceCents]. + set priceCents(int value) => setAttribute('price_cents', value); + + /// Tracked getter for [stockAvailable]. + @override + int get stockAvailable => + getAttribute('stock_available') ?? super.stockAvailable; + + /// Tracked setter for [stockAvailable]. + set stockAvailable(int value) => setAttribute('stock_available', value); + + void _attachOrmRuntimeMetadata(Map values) { + replaceAttributes(values); + attachModelDefinition(_$CatalogSkuModelDefinition); + } +} + +class _CatalogSkuModelCopyWithSentinel { + const _CatalogSkuModelCopyWithSentinel(); +} + +extension CatalogSkuModelOrmExtension on CatalogSkuModel { + static const _CatalogSkuModelCopyWithSentinel _copyWithSentinel = + _CatalogSkuModelCopyWithSentinel(); + CatalogSkuModel copyWith({ + Object? sku = _copyWithSentinel, + Object? title = _copyWithSentinel, + Object? priceCents = _copyWithSentinel, + Object? stockAvailable = _copyWithSentinel, + }) { + return CatalogSkuModel( + sku: identical(sku, _copyWithSentinel) ? this.sku : sku as String, + title: identical(title, _copyWithSentinel) ? this.title : title as String, + priceCents: identical(priceCents, _copyWithSentinel) + ? this.priceCents + : priceCents as int, + stockAvailable: identical(stockAvailable, _copyWithSentinel) + ? this.stockAvailable + : stockAvailable as int, + ); + } + + /// Converts this model to a column/value map. + Map toMap({ValueCodecRegistry? registry}) => + _$CatalogSkuModelDefinition.toMap(this, registry: registry); + + /// Builds a model from a column/value map. + static CatalogSkuModel fromMap( + Map data, { + ValueCodecRegistry? registry, + }) => _$CatalogSkuModelDefinition.fromMap(data, registry: registry); + + /// The Type of the generated ORM-managed model class. + /// Use this when you need to specify the tracked model type explicitly, + /// for example in generic type parameters. + static Type get trackedType => $CatalogSkuModel; + + /// Converts this immutable model to a tracked ORM-managed model. + /// The tracked model supports attribute tracking, change detection, + /// and persistence operations like save() and touch(). + $CatalogSkuModel toTracked() { + return $CatalogSkuModel.fromModel(this); + } +} + +extension CatalogSkuModelPredicateFields on PredicateBuilder { + PredicateField get sku => + PredicateField(this, 'sku'); + PredicateField get title => + PredicateField(this, 'title'); + PredicateField get priceCents => + PredicateField(this, 'priceCents'); + PredicateField get stockAvailable => + PredicateField(this, 'stockAvailable'); +} + +void registerCatalogSkuModelEventHandlers(EventBus bus) { + // No event handlers registered for CatalogSkuModel. +} diff --git a/packages/stem/example/ecommerce/lib/src/database/models/models.dart b/packages/stem/example/ecommerce/lib/src/database/models/models.dart new file mode 100644 index 00000000..aae10b17 --- /dev/null +++ b/packages/stem/example/ecommerce/lib/src/database/models/models.dart @@ -0,0 +1,5 @@ +export 'catalog_sku.dart'; +export 'cart.dart'; +export 'cart_item.dart'; +export 'order.dart'; +export 'order_item.dart'; diff --git a/packages/stem/example/ecommerce/lib/src/database/models/order.dart b/packages/stem/example/ecommerce/lib/src/database/models/order.dart new file mode 100644 index 00000000..30ee802c --- /dev/null +++ b/packages/stem/example/ecommerce/lib/src/database/models/order.dart @@ -0,0 +1,32 @@ +import 'package:ormed/ormed.dart'; + +part 'order.orm.dart'; + +@OrmModel(table: 'orders') +class OrderModel extends Model with TimestampsTZ { + const OrderModel({ + required this.id, + required this.cartId, + required this.customerId, + required this.status, + required this.totalCents, + required this.paymentReference, + }); + + @OrmField(isPrimaryKey: true) + final String id; + + @OrmField(columnName: 'cart_id') + final String cartId; + + @OrmField(columnName: 'customer_id') + final String customerId; + + final String status; + + @OrmField(columnName: 'total_cents') + final int totalCents; + + @OrmField(columnName: 'payment_reference') + final String paymentReference; +} diff --git a/packages/stem/example/ecommerce/lib/src/database/models/order.orm.dart b/packages/stem/example/ecommerce/lib/src/database/models/order.orm.dart new file mode 100644 index 00000000..46fcf2c8 --- /dev/null +++ b/packages/stem/example/ecommerce/lib/src/database/models/order.orm.dart @@ -0,0 +1,822 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 + +part of 'order.dart'; + +// ************************************************************************** +// OrmModelGenerator +// ************************************************************************** + +const FieldDefinition _$OrderModelIdField = FieldDefinition( + name: 'id', + columnName: 'id', + dartType: 'String', + resolvedType: 'String', + isPrimaryKey: true, + isNullable: false, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +const FieldDefinition _$OrderModelCartIdField = FieldDefinition( + name: 'cartId', + columnName: 'cart_id', + dartType: 'String', + resolvedType: 'String', + isPrimaryKey: false, + isNullable: false, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +const FieldDefinition _$OrderModelCustomerIdField = FieldDefinition( + name: 'customerId', + columnName: 'customer_id', + dartType: 'String', + resolvedType: 'String', + isPrimaryKey: false, + isNullable: false, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +const FieldDefinition _$OrderModelStatusField = FieldDefinition( + name: 'status', + columnName: 'status', + dartType: 'String', + resolvedType: 'String', + isPrimaryKey: false, + isNullable: false, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +const FieldDefinition _$OrderModelTotalCentsField = FieldDefinition( + name: 'totalCents', + columnName: 'total_cents', + dartType: 'int', + resolvedType: 'int', + isPrimaryKey: false, + isNullable: false, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +const FieldDefinition _$OrderModelPaymentReferenceField = FieldDefinition( + name: 'paymentReference', + columnName: 'payment_reference', + dartType: 'String', + resolvedType: 'String', + isPrimaryKey: false, + isNullable: false, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +const FieldDefinition _$OrderModelCreatedAtField = FieldDefinition( + name: 'createdAt', + columnName: 'created_at', + dartType: 'DateTime', + resolvedType: 'DateTime?', + isPrimaryKey: false, + isNullable: true, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +const FieldDefinition _$OrderModelUpdatedAtField = FieldDefinition( + name: 'updatedAt', + columnName: 'updated_at', + dartType: 'DateTime', + resolvedType: 'DateTime?', + isPrimaryKey: false, + isNullable: true, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +Map _encodeOrderModelUntracked( + Object model, + ValueCodecRegistry registry, +) { + final m = model as OrderModel; + return { + 'id': registry.encodeField(_$OrderModelIdField, m.id), + 'cart_id': registry.encodeField(_$OrderModelCartIdField, m.cartId), + 'customer_id': registry.encodeField( + _$OrderModelCustomerIdField, + m.customerId, + ), + 'status': registry.encodeField(_$OrderModelStatusField, m.status), + 'total_cents': registry.encodeField( + _$OrderModelTotalCentsField, + m.totalCents, + ), + 'payment_reference': registry.encodeField( + _$OrderModelPaymentReferenceField, + m.paymentReference, + ), + }; +} + +final ModelDefinition<$OrderModel> _$OrderModelDefinition = ModelDefinition( + modelName: 'OrderModel', + tableName: 'orders', + fields: const [ + _$OrderModelIdField, + _$OrderModelCartIdField, + _$OrderModelCustomerIdField, + _$OrderModelStatusField, + _$OrderModelTotalCentsField, + _$OrderModelPaymentReferenceField, + _$OrderModelCreatedAtField, + _$OrderModelUpdatedAtField, + ], + relations: const [], + softDeleteColumn: 'deleted_at', + metadata: ModelAttributesMetadata( + hidden: const [], + visible: const [], + fillable: const [], + guarded: const [], + casts: const {}, + appends: const [], + touches: const [], + timestamps: true, + softDeletes: false, + softDeleteColumn: 'deleted_at', + ), + untrackedToMap: _encodeOrderModelUntracked, + codec: _$OrderModelCodec(), +); + +extension OrderModelOrmDefinition on OrderModel { + static ModelDefinition<$OrderModel> get definition => _$OrderModelDefinition; +} + +class OrderModels { + const OrderModels._(); + + /// Starts building a query for [$OrderModel]. + /// + /// {@macro ormed.query} + static Query<$OrderModel> query([String? connection]) => + Model.query<$OrderModel>(connection: connection); + + static Future<$OrderModel?> find(Object id, {String? connection}) => + Model.find<$OrderModel>(id, connection: connection); + + static Future<$OrderModel> findOrFail(Object id, {String? connection}) => + Model.findOrFail<$OrderModel>(id, connection: connection); + + static Future> all({String? connection}) => + Model.all<$OrderModel>(connection: connection); + + static Future count({String? connection}) => + Model.count<$OrderModel>(connection: connection); + + static Future anyExist({String? connection}) => + Model.anyExist<$OrderModel>(connection: connection); + + static Query<$OrderModel> where( + String column, + String operator, + dynamic value, { + String? connection, + }) => + Model.where<$OrderModel>(column, operator, value, connection: connection); + + static Query<$OrderModel> whereIn( + String column, + List values, { + String? connection, + }) => Model.whereIn<$OrderModel>(column, values, connection: connection); + + static Query<$OrderModel> orderBy( + String column, { + String direction = "asc", + String? connection, + }) => Model.orderBy<$OrderModel>( + column, + direction: direction, + connection: connection, + ); + + static Query<$OrderModel> limit(int count, {String? connection}) => + Model.limit<$OrderModel>(count, connection: connection); + + /// Creates a [Repository] for [$OrderModel]. + /// + /// {@macro ormed.repository} + static Repository<$OrderModel> repo([String? connection]) => + Model.repository<$OrderModel>(connection: connection); + + /// Builds a tracked model from a column/value map. + static $OrderModel fromMap( + Map data, { + ValueCodecRegistry? registry, + }) => _$OrderModelDefinition.fromMap(data, registry: registry); + + /// Converts a tracked model to a column/value map. + static Map toMap( + $OrderModel model, { + ValueCodecRegistry? registry, + }) => _$OrderModelDefinition.toMap(model, registry: registry); +} + +class OrderModelFactory { + const OrderModelFactory._(); + + static ModelDefinition<$OrderModel> get definition => _$OrderModelDefinition; + + static ModelCodec<$OrderModel> get codec => definition.codec; + + static OrderModel fromMap( + Map data, { + ValueCodecRegistry? registry, + }) => definition.fromMap(data, registry: registry); + + static Map toMap( + OrderModel model, { + ValueCodecRegistry? registry, + }) => definition.toMap(model.toTracked(), registry: registry); + + static void registerWith(ModelRegistry registry) => + registry.register(definition); + + static ModelFactoryConnection withConnection( + QueryContext context, + ) => ModelFactoryConnection( + definition: definition, + context: context, + ); + + static ModelFactoryBuilder factory({ + GeneratorProvider? generatorProvider, + }) => ModelFactoryRegistry.factoryFor( + generatorProvider: generatorProvider, + ); +} + +class _$OrderModelCodec extends ModelCodec<$OrderModel> { + const _$OrderModelCodec(); + @override + Map encode($OrderModel model, ValueCodecRegistry registry) { + return { + 'id': registry.encodeField(_$OrderModelIdField, model.id), + 'cart_id': registry.encodeField(_$OrderModelCartIdField, model.cartId), + 'customer_id': registry.encodeField( + _$OrderModelCustomerIdField, + model.customerId, + ), + 'status': registry.encodeField(_$OrderModelStatusField, model.status), + 'total_cents': registry.encodeField( + _$OrderModelTotalCentsField, + model.totalCents, + ), + 'payment_reference': registry.encodeField( + _$OrderModelPaymentReferenceField, + model.paymentReference, + ), + if (model.hasAttribute('created_at')) + 'created_at': registry.encodeField( + _$OrderModelCreatedAtField, + model.getAttribute('created_at'), + ), + if (model.hasAttribute('updated_at')) + 'updated_at': registry.encodeField( + _$OrderModelUpdatedAtField, + model.getAttribute('updated_at'), + ), + }; + } + + @override + $OrderModel decode(Map data, ValueCodecRegistry registry) { + final String orderModelIdValue = + registry.decodeField(_$OrderModelIdField, data['id']) ?? + (throw StateError('Field id on OrderModel cannot be null.')); + final String orderModelCartIdValue = + registry.decodeField( + _$OrderModelCartIdField, + data['cart_id'], + ) ?? + (throw StateError('Field cartId on OrderModel cannot be null.')); + final String orderModelCustomerIdValue = + registry.decodeField( + _$OrderModelCustomerIdField, + data['customer_id'], + ) ?? + (throw StateError('Field customerId on OrderModel cannot be null.')); + final String orderModelStatusValue = + registry.decodeField(_$OrderModelStatusField, data['status']) ?? + (throw StateError('Field status on OrderModel cannot be null.')); + final int orderModelTotalCentsValue = + registry.decodeField( + _$OrderModelTotalCentsField, + data['total_cents'], + ) ?? + (throw StateError('Field totalCents on OrderModel cannot be null.')); + final String orderModelPaymentReferenceValue = + registry.decodeField( + _$OrderModelPaymentReferenceField, + data['payment_reference'], + ) ?? + (throw StateError( + 'Field paymentReference on OrderModel cannot be null.', + )); + final DateTime? orderModelCreatedAtValue = registry.decodeField( + _$OrderModelCreatedAtField, + data['created_at'], + ); + final DateTime? orderModelUpdatedAtValue = registry.decodeField( + _$OrderModelUpdatedAtField, + data['updated_at'], + ); + final model = $OrderModel( + id: orderModelIdValue, + cartId: orderModelCartIdValue, + customerId: orderModelCustomerIdValue, + status: orderModelStatusValue, + totalCents: orderModelTotalCentsValue, + paymentReference: orderModelPaymentReferenceValue, + ); + model._attachOrmRuntimeMetadata({ + 'id': orderModelIdValue, + 'cart_id': orderModelCartIdValue, + 'customer_id': orderModelCustomerIdValue, + 'status': orderModelStatusValue, + 'total_cents': orderModelTotalCentsValue, + 'payment_reference': orderModelPaymentReferenceValue, + if (data.containsKey('created_at')) + 'created_at': orderModelCreatedAtValue, + if (data.containsKey('updated_at')) + 'updated_at': orderModelUpdatedAtValue, + }); + return model; + } +} + +/// Insert DTO for [OrderModel]. +/// +/// Auto-increment/DB-generated fields are omitted by default. +class OrderModelInsertDto implements InsertDto<$OrderModel> { + const OrderModelInsertDto({ + this.id, + this.cartId, + this.customerId, + this.status, + this.totalCents, + this.paymentReference, + }); + final String? id; + final String? cartId; + final String? customerId; + final String? status; + final int? totalCents; + final String? paymentReference; + + @override + Map toMap() { + return { + if (id != null) 'id': id, + if (cartId != null) 'cart_id': cartId, + if (customerId != null) 'customer_id': customerId, + if (status != null) 'status': status, + if (totalCents != null) 'total_cents': totalCents, + if (paymentReference != null) 'payment_reference': paymentReference, + }; + } + + static const _OrderModelInsertDtoCopyWithSentinel _copyWithSentinel = + _OrderModelInsertDtoCopyWithSentinel(); + OrderModelInsertDto copyWith({ + Object? id = _copyWithSentinel, + Object? cartId = _copyWithSentinel, + Object? customerId = _copyWithSentinel, + Object? status = _copyWithSentinel, + Object? totalCents = _copyWithSentinel, + Object? paymentReference = _copyWithSentinel, + }) { + return OrderModelInsertDto( + id: identical(id, _copyWithSentinel) ? this.id : id as String?, + cartId: identical(cartId, _copyWithSentinel) + ? this.cartId + : cartId as String?, + customerId: identical(customerId, _copyWithSentinel) + ? this.customerId + : customerId as String?, + status: identical(status, _copyWithSentinel) + ? this.status + : status as String?, + totalCents: identical(totalCents, _copyWithSentinel) + ? this.totalCents + : totalCents as int?, + paymentReference: identical(paymentReference, _copyWithSentinel) + ? this.paymentReference + : paymentReference as String?, + ); + } +} + +class _OrderModelInsertDtoCopyWithSentinel { + const _OrderModelInsertDtoCopyWithSentinel(); +} + +/// Update DTO for [OrderModel]. +/// +/// All fields are optional; only provided entries are used in SET clauses. +class OrderModelUpdateDto implements UpdateDto<$OrderModel> { + const OrderModelUpdateDto({ + this.id, + this.cartId, + this.customerId, + this.status, + this.totalCents, + this.paymentReference, + }); + final String? id; + final String? cartId; + final String? customerId; + final String? status; + final int? totalCents; + final String? paymentReference; + + @override + Map toMap() { + return { + if (id != null) 'id': id, + if (cartId != null) 'cart_id': cartId, + if (customerId != null) 'customer_id': customerId, + if (status != null) 'status': status, + if (totalCents != null) 'total_cents': totalCents, + if (paymentReference != null) 'payment_reference': paymentReference, + }; + } + + static const _OrderModelUpdateDtoCopyWithSentinel _copyWithSentinel = + _OrderModelUpdateDtoCopyWithSentinel(); + OrderModelUpdateDto copyWith({ + Object? id = _copyWithSentinel, + Object? cartId = _copyWithSentinel, + Object? customerId = _copyWithSentinel, + Object? status = _copyWithSentinel, + Object? totalCents = _copyWithSentinel, + Object? paymentReference = _copyWithSentinel, + }) { + return OrderModelUpdateDto( + id: identical(id, _copyWithSentinel) ? this.id : id as String?, + cartId: identical(cartId, _copyWithSentinel) + ? this.cartId + : cartId as String?, + customerId: identical(customerId, _copyWithSentinel) + ? this.customerId + : customerId as String?, + status: identical(status, _copyWithSentinel) + ? this.status + : status as String?, + totalCents: identical(totalCents, _copyWithSentinel) + ? this.totalCents + : totalCents as int?, + paymentReference: identical(paymentReference, _copyWithSentinel) + ? this.paymentReference + : paymentReference as String?, + ); + } +} + +class _OrderModelUpdateDtoCopyWithSentinel { + const _OrderModelUpdateDtoCopyWithSentinel(); +} + +/// Partial projection for [OrderModel]. +/// +/// All fields are nullable; intended for subset SELECTs. +class OrderModelPartial implements PartialEntity<$OrderModel> { + const OrderModelPartial({ + this.id, + this.cartId, + this.customerId, + this.status, + this.totalCents, + this.paymentReference, + }); + + /// Creates a partial from a database row map. + /// + /// The [row] keys should be column names (snake_case). + /// Missing columns will result in null field values. + factory OrderModelPartial.fromRow(Map row) { + return OrderModelPartial( + id: row['id'] as String?, + cartId: row['cart_id'] as String?, + customerId: row['customer_id'] as String?, + status: row['status'] as String?, + totalCents: row['total_cents'] as int?, + paymentReference: row['payment_reference'] as String?, + ); + } + + final String? id; + final String? cartId; + final String? customerId; + final String? status; + final int? totalCents; + final String? paymentReference; + + @override + $OrderModel toEntity() { + // Basic required-field check: non-nullable fields must be present. + final String? idValue = id; + if (idValue == null) { + throw StateError('Missing required field: id'); + } + final String? cartIdValue = cartId; + if (cartIdValue == null) { + throw StateError('Missing required field: cartId'); + } + final String? customerIdValue = customerId; + if (customerIdValue == null) { + throw StateError('Missing required field: customerId'); + } + final String? statusValue = status; + if (statusValue == null) { + throw StateError('Missing required field: status'); + } + final int? totalCentsValue = totalCents; + if (totalCentsValue == null) { + throw StateError('Missing required field: totalCents'); + } + final String? paymentReferenceValue = paymentReference; + if (paymentReferenceValue == null) { + throw StateError('Missing required field: paymentReference'); + } + return $OrderModel( + id: idValue, + cartId: cartIdValue, + customerId: customerIdValue, + status: statusValue, + totalCents: totalCentsValue, + paymentReference: paymentReferenceValue, + ); + } + + @override + Map toMap() { + return { + if (id != null) 'id': id, + if (cartId != null) 'cart_id': cartId, + if (customerId != null) 'customer_id': customerId, + if (status != null) 'status': status, + if (totalCents != null) 'total_cents': totalCents, + if (paymentReference != null) 'payment_reference': paymentReference, + }; + } + + static const _OrderModelPartialCopyWithSentinel _copyWithSentinel = + _OrderModelPartialCopyWithSentinel(); + OrderModelPartial copyWith({ + Object? id = _copyWithSentinel, + Object? cartId = _copyWithSentinel, + Object? customerId = _copyWithSentinel, + Object? status = _copyWithSentinel, + Object? totalCents = _copyWithSentinel, + Object? paymentReference = _copyWithSentinel, + }) { + return OrderModelPartial( + id: identical(id, _copyWithSentinel) ? this.id : id as String?, + cartId: identical(cartId, _copyWithSentinel) + ? this.cartId + : cartId as String?, + customerId: identical(customerId, _copyWithSentinel) + ? this.customerId + : customerId as String?, + status: identical(status, _copyWithSentinel) + ? this.status + : status as String?, + totalCents: identical(totalCents, _copyWithSentinel) + ? this.totalCents + : totalCents as int?, + paymentReference: identical(paymentReference, _copyWithSentinel) + ? this.paymentReference + : paymentReference as String?, + ); + } +} + +class _OrderModelPartialCopyWithSentinel { + const _OrderModelPartialCopyWithSentinel(); +} + +/// Generated tracked model class for [OrderModel]. +/// +/// This class extends the user-defined [OrderModel] model and adds +/// attribute tracking, change detection, and relationship management. +/// Instances of this class are returned by queries and repositories. +/// +/// **Do not instantiate this class directly.** Use queries, repositories, +/// or model factories to create tracked model instances. +class $OrderModel extends OrderModel + with ModelAttributes, TimestampsTZImpl + implements OrmEntity { + /// Internal constructor for [$OrderModel]. + $OrderModel({ + required String id, + required String cartId, + required String customerId, + required String status, + required int totalCents, + required String paymentReference, + }) : super( + id: id, + cartId: cartId, + customerId: customerId, + status: status, + totalCents: totalCents, + paymentReference: paymentReference, + ) { + _attachOrmRuntimeMetadata({ + 'id': id, + 'cart_id': cartId, + 'customer_id': customerId, + 'status': status, + 'total_cents': totalCents, + 'payment_reference': paymentReference, + }); + } + + /// Creates a tracked model instance from a user-defined model instance. + factory $OrderModel.fromModel(OrderModel model) { + return $OrderModel( + id: model.id, + cartId: model.cartId, + customerId: model.customerId, + status: model.status, + totalCents: model.totalCents, + paymentReference: model.paymentReference, + ); + } + + $OrderModel copyWith({ + String? id, + String? cartId, + String? customerId, + String? status, + int? totalCents, + String? paymentReference, + }) { + return $OrderModel( + id: id ?? this.id, + cartId: cartId ?? this.cartId, + customerId: customerId ?? this.customerId, + status: status ?? this.status, + totalCents: totalCents ?? this.totalCents, + paymentReference: paymentReference ?? this.paymentReference, + ); + } + + /// Builds a tracked model from a column/value map. + static $OrderModel fromMap( + Map data, { + ValueCodecRegistry? registry, + }) => _$OrderModelDefinition.fromMap(data, registry: registry); + + /// Converts this tracked model to a column/value map. + Map toMap({ValueCodecRegistry? registry}) => + _$OrderModelDefinition.toMap(this, registry: registry); + + /// Tracked getter for [id]. + @override + String get id => getAttribute('id') ?? super.id; + + /// Tracked setter for [id]. + set id(String value) => setAttribute('id', value); + + /// Tracked getter for [cartId]. + @override + String get cartId => getAttribute('cart_id') ?? super.cartId; + + /// Tracked setter for [cartId]. + set cartId(String value) => setAttribute('cart_id', value); + + /// Tracked getter for [customerId]. + @override + String get customerId => + getAttribute('customer_id') ?? super.customerId; + + /// Tracked setter for [customerId]. + set customerId(String value) => setAttribute('customer_id', value); + + /// Tracked getter for [status]. + @override + String get status => getAttribute('status') ?? super.status; + + /// Tracked setter for [status]. + set status(String value) => setAttribute('status', value); + + /// Tracked getter for [totalCents]. + @override + int get totalCents => getAttribute('total_cents') ?? super.totalCents; + + /// Tracked setter for [totalCents]. + set totalCents(int value) => setAttribute('total_cents', value); + + /// Tracked getter for [paymentReference]. + @override + String get paymentReference => + getAttribute('payment_reference') ?? super.paymentReference; + + /// Tracked setter for [paymentReference]. + set paymentReference(String value) => + setAttribute('payment_reference', value); + + void _attachOrmRuntimeMetadata(Map values) { + replaceAttributes(values); + attachModelDefinition(_$OrderModelDefinition); + } +} + +class _OrderModelCopyWithSentinel { + const _OrderModelCopyWithSentinel(); +} + +extension OrderModelOrmExtension on OrderModel { + static const _OrderModelCopyWithSentinel _copyWithSentinel = + _OrderModelCopyWithSentinel(); + OrderModel copyWith({ + Object? id = _copyWithSentinel, + Object? cartId = _copyWithSentinel, + Object? customerId = _copyWithSentinel, + Object? status = _copyWithSentinel, + Object? totalCents = _copyWithSentinel, + Object? paymentReference = _copyWithSentinel, + }) { + return OrderModel( + id: identical(id, _copyWithSentinel) ? this.id : id as String, + cartId: identical(cartId, _copyWithSentinel) + ? this.cartId + : cartId as String, + customerId: identical(customerId, _copyWithSentinel) + ? this.customerId + : customerId as String, + status: identical(status, _copyWithSentinel) + ? this.status + : status as String, + totalCents: identical(totalCents, _copyWithSentinel) + ? this.totalCents + : totalCents as int, + paymentReference: identical(paymentReference, _copyWithSentinel) + ? this.paymentReference + : paymentReference as String, + ); + } + + /// Converts this model to a column/value map. + Map toMap({ValueCodecRegistry? registry}) => + _$OrderModelDefinition.toMap(this, registry: registry); + + /// Builds a model from a column/value map. + static OrderModel fromMap( + Map data, { + ValueCodecRegistry? registry, + }) => _$OrderModelDefinition.fromMap(data, registry: registry); + + /// The Type of the generated ORM-managed model class. + /// Use this when you need to specify the tracked model type explicitly, + /// for example in generic type parameters. + static Type get trackedType => $OrderModel; + + /// Converts this immutable model to a tracked ORM-managed model. + /// The tracked model supports attribute tracking, change detection, + /// and persistence operations like save() and touch(). + $OrderModel toTracked() { + return $OrderModel.fromModel(this); + } +} + +extension OrderModelPredicateFields on PredicateBuilder { + PredicateField get id => + PredicateField(this, 'id'); + PredicateField get cartId => + PredicateField(this, 'cartId'); + PredicateField get customerId => + PredicateField(this, 'customerId'); + PredicateField get status => + PredicateField(this, 'status'); + PredicateField get totalCents => + PredicateField(this, 'totalCents'); + PredicateField get paymentReference => + PredicateField(this, 'paymentReference'); +} + +void registerOrderModelEventHandlers(EventBus bus) { + // No event handlers registered for OrderModel. +} diff --git a/packages/stem/example/ecommerce/lib/src/database/models/order_item.dart b/packages/stem/example/ecommerce/lib/src/database/models/order_item.dart new file mode 100644 index 00000000..5aba07e9 --- /dev/null +++ b/packages/stem/example/ecommerce/lib/src/database/models/order_item.dart @@ -0,0 +1,34 @@ +import 'package:ormed/ormed.dart'; + +part 'order_item.orm.dart'; + +@OrmModel(table: 'order_items') +class OrderItemModel extends Model with TimestampsTZ { + const OrderItemModel({ + required this.id, + required this.orderId, + required this.sku, + required this.title, + required this.quantity, + required this.unitPriceCents, + required this.lineTotalCents, + }); + + @OrmField(isPrimaryKey: true) + final String id; + + @OrmField(columnName: 'order_id') + final String orderId; + + final String sku; + + final String title; + + final int quantity; + + @OrmField(columnName: 'unit_price_cents') + final int unitPriceCents; + + @OrmField(columnName: 'line_total_cents') + final int lineTotalCents; +} diff --git a/packages/stem/example/ecommerce/lib/src/database/models/order_item.orm.dart b/packages/stem/example/ecommerce/lib/src/database/models/order_item.orm.dart new file mode 100644 index 00000000..ec20053c --- /dev/null +++ b/packages/stem/example/ecommerce/lib/src/database/models/order_item.orm.dart @@ -0,0 +1,897 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 + +part of 'order_item.dart'; + +// ************************************************************************** +// OrmModelGenerator +// ************************************************************************** + +const FieldDefinition _$OrderItemModelIdField = FieldDefinition( + name: 'id', + columnName: 'id', + dartType: 'String', + resolvedType: 'String', + isPrimaryKey: true, + isNullable: false, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +const FieldDefinition _$OrderItemModelOrderIdField = FieldDefinition( + name: 'orderId', + columnName: 'order_id', + dartType: 'String', + resolvedType: 'String', + isPrimaryKey: false, + isNullable: false, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +const FieldDefinition _$OrderItemModelSkuField = FieldDefinition( + name: 'sku', + columnName: 'sku', + dartType: 'String', + resolvedType: 'String', + isPrimaryKey: false, + isNullable: false, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +const FieldDefinition _$OrderItemModelTitleField = FieldDefinition( + name: 'title', + columnName: 'title', + dartType: 'String', + resolvedType: 'String', + isPrimaryKey: false, + isNullable: false, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +const FieldDefinition _$OrderItemModelQuantityField = FieldDefinition( + name: 'quantity', + columnName: 'quantity', + dartType: 'int', + resolvedType: 'int', + isPrimaryKey: false, + isNullable: false, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +const FieldDefinition _$OrderItemModelUnitPriceCentsField = FieldDefinition( + name: 'unitPriceCents', + columnName: 'unit_price_cents', + dartType: 'int', + resolvedType: 'int', + isPrimaryKey: false, + isNullable: false, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +const FieldDefinition _$OrderItemModelLineTotalCentsField = FieldDefinition( + name: 'lineTotalCents', + columnName: 'line_total_cents', + dartType: 'int', + resolvedType: 'int', + isPrimaryKey: false, + isNullable: false, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +const FieldDefinition _$OrderItemModelCreatedAtField = FieldDefinition( + name: 'createdAt', + columnName: 'created_at', + dartType: 'DateTime', + resolvedType: 'DateTime?', + isPrimaryKey: false, + isNullable: true, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +const FieldDefinition _$OrderItemModelUpdatedAtField = FieldDefinition( + name: 'updatedAt', + columnName: 'updated_at', + dartType: 'DateTime', + resolvedType: 'DateTime?', + isPrimaryKey: false, + isNullable: true, + isUnique: false, + isIndexed: false, + autoIncrement: false, +); + +Map _encodeOrderItemModelUntracked( + Object model, + ValueCodecRegistry registry, +) { + final m = model as OrderItemModel; + return { + 'id': registry.encodeField(_$OrderItemModelIdField, m.id), + 'order_id': registry.encodeField(_$OrderItemModelOrderIdField, m.orderId), + 'sku': registry.encodeField(_$OrderItemModelSkuField, m.sku), + 'title': registry.encodeField(_$OrderItemModelTitleField, m.title), + 'quantity': registry.encodeField(_$OrderItemModelQuantityField, m.quantity), + 'unit_price_cents': registry.encodeField( + _$OrderItemModelUnitPriceCentsField, + m.unitPriceCents, + ), + 'line_total_cents': registry.encodeField( + _$OrderItemModelLineTotalCentsField, + m.lineTotalCents, + ), + }; +} + +final ModelDefinition<$OrderItemModel> _$OrderItemModelDefinition = + ModelDefinition( + modelName: 'OrderItemModel', + tableName: 'order_items', + fields: const [ + _$OrderItemModelIdField, + _$OrderItemModelOrderIdField, + _$OrderItemModelSkuField, + _$OrderItemModelTitleField, + _$OrderItemModelQuantityField, + _$OrderItemModelUnitPriceCentsField, + _$OrderItemModelLineTotalCentsField, + _$OrderItemModelCreatedAtField, + _$OrderItemModelUpdatedAtField, + ], + relations: const [], + softDeleteColumn: 'deleted_at', + metadata: ModelAttributesMetadata( + hidden: const [], + visible: const [], + fillable: const [], + guarded: const [], + casts: const {}, + appends: const [], + touches: const [], + timestamps: true, + softDeletes: false, + softDeleteColumn: 'deleted_at', + ), + untrackedToMap: _encodeOrderItemModelUntracked, + codec: _$OrderItemModelCodec(), + ); + +extension OrderItemModelOrmDefinition on OrderItemModel { + static ModelDefinition<$OrderItemModel> get definition => + _$OrderItemModelDefinition; +} + +class OrderItemModels { + const OrderItemModels._(); + + /// Starts building a query for [$OrderItemModel]. + /// + /// {@macro ormed.query} + static Query<$OrderItemModel> query([String? connection]) => + Model.query<$OrderItemModel>(connection: connection); + + static Future<$OrderItemModel?> find(Object id, {String? connection}) => + Model.find<$OrderItemModel>(id, connection: connection); + + static Future<$OrderItemModel> findOrFail(Object id, {String? connection}) => + Model.findOrFail<$OrderItemModel>(id, connection: connection); + + static Future> all({String? connection}) => + Model.all<$OrderItemModel>(connection: connection); + + static Future count({String? connection}) => + Model.count<$OrderItemModel>(connection: connection); + + static Future anyExist({String? connection}) => + Model.anyExist<$OrderItemModel>(connection: connection); + + static Query<$OrderItemModel> where( + String column, + String operator, + dynamic value, { + String? connection, + }) => Model.where<$OrderItemModel>( + column, + operator, + value, + connection: connection, + ); + + static Query<$OrderItemModel> whereIn( + String column, + List values, { + String? connection, + }) => Model.whereIn<$OrderItemModel>(column, values, connection: connection); + + static Query<$OrderItemModel> orderBy( + String column, { + String direction = "asc", + String? connection, + }) => Model.orderBy<$OrderItemModel>( + column, + direction: direction, + connection: connection, + ); + + static Query<$OrderItemModel> limit(int count, {String? connection}) => + Model.limit<$OrderItemModel>(count, connection: connection); + + /// Creates a [Repository] for [$OrderItemModel]. + /// + /// {@macro ormed.repository} + static Repository<$OrderItemModel> repo([String? connection]) => + Model.repository<$OrderItemModel>(connection: connection); + + /// Builds a tracked model from a column/value map. + static $OrderItemModel fromMap( + Map data, { + ValueCodecRegistry? registry, + }) => _$OrderItemModelDefinition.fromMap(data, registry: registry); + + /// Converts a tracked model to a column/value map. + static Map toMap( + $OrderItemModel model, { + ValueCodecRegistry? registry, + }) => _$OrderItemModelDefinition.toMap(model, registry: registry); +} + +class OrderItemModelFactory { + const OrderItemModelFactory._(); + + static ModelDefinition<$OrderItemModel> get definition => + _$OrderItemModelDefinition; + + static ModelCodec<$OrderItemModel> get codec => definition.codec; + + static OrderItemModel fromMap( + Map data, { + ValueCodecRegistry? registry, + }) => definition.fromMap(data, registry: registry); + + static Map toMap( + OrderItemModel model, { + ValueCodecRegistry? registry, + }) => definition.toMap(model.toTracked(), registry: registry); + + static void registerWith(ModelRegistry registry) => + registry.register(definition); + + static ModelFactoryConnection withConnection( + QueryContext context, + ) => ModelFactoryConnection( + definition: definition, + context: context, + ); + + static ModelFactoryBuilder factory({ + GeneratorProvider? generatorProvider, + }) => ModelFactoryRegistry.factoryFor( + generatorProvider: generatorProvider, + ); +} + +class _$OrderItemModelCodec extends ModelCodec<$OrderItemModel> { + const _$OrderItemModelCodec(); + @override + Map encode( + $OrderItemModel model, + ValueCodecRegistry registry, + ) { + return { + 'id': registry.encodeField(_$OrderItemModelIdField, model.id), + 'order_id': registry.encodeField( + _$OrderItemModelOrderIdField, + model.orderId, + ), + 'sku': registry.encodeField(_$OrderItemModelSkuField, model.sku), + 'title': registry.encodeField(_$OrderItemModelTitleField, model.title), + 'quantity': registry.encodeField( + _$OrderItemModelQuantityField, + model.quantity, + ), + 'unit_price_cents': registry.encodeField( + _$OrderItemModelUnitPriceCentsField, + model.unitPriceCents, + ), + 'line_total_cents': registry.encodeField( + _$OrderItemModelLineTotalCentsField, + model.lineTotalCents, + ), + if (model.hasAttribute('created_at')) + 'created_at': registry.encodeField( + _$OrderItemModelCreatedAtField, + model.getAttribute('created_at'), + ), + if (model.hasAttribute('updated_at')) + 'updated_at': registry.encodeField( + _$OrderItemModelUpdatedAtField, + model.getAttribute('updated_at'), + ), + }; + } + + @override + $OrderItemModel decode( + Map data, + ValueCodecRegistry registry, + ) { + final String orderItemModelIdValue = + registry.decodeField(_$OrderItemModelIdField, data['id']) ?? + (throw StateError('Field id on OrderItemModel cannot be null.')); + final String orderItemModelOrderIdValue = + registry.decodeField( + _$OrderItemModelOrderIdField, + data['order_id'], + ) ?? + (throw StateError('Field orderId on OrderItemModel cannot be null.')); + final String orderItemModelSkuValue = + registry.decodeField(_$OrderItemModelSkuField, data['sku']) ?? + (throw StateError('Field sku on OrderItemModel cannot be null.')); + final String orderItemModelTitleValue = + registry.decodeField( + _$OrderItemModelTitleField, + data['title'], + ) ?? + (throw StateError('Field title on OrderItemModel cannot be null.')); + final int orderItemModelQuantityValue = + registry.decodeField( + _$OrderItemModelQuantityField, + data['quantity'], + ) ?? + (throw StateError('Field quantity on OrderItemModel cannot be null.')); + final int orderItemModelUnitPriceCentsValue = + registry.decodeField( + _$OrderItemModelUnitPriceCentsField, + data['unit_price_cents'], + ) ?? + (throw StateError( + 'Field unitPriceCents on OrderItemModel cannot be null.', + )); + final int orderItemModelLineTotalCentsValue = + registry.decodeField( + _$OrderItemModelLineTotalCentsField, + data['line_total_cents'], + ) ?? + (throw StateError( + 'Field lineTotalCents on OrderItemModel cannot be null.', + )); + final DateTime? orderItemModelCreatedAtValue = registry + .decodeField( + _$OrderItemModelCreatedAtField, + data['created_at'], + ); + final DateTime? orderItemModelUpdatedAtValue = registry + .decodeField( + _$OrderItemModelUpdatedAtField, + data['updated_at'], + ); + final model = $OrderItemModel( + id: orderItemModelIdValue, + orderId: orderItemModelOrderIdValue, + sku: orderItemModelSkuValue, + title: orderItemModelTitleValue, + quantity: orderItemModelQuantityValue, + unitPriceCents: orderItemModelUnitPriceCentsValue, + lineTotalCents: orderItemModelLineTotalCentsValue, + ); + model._attachOrmRuntimeMetadata({ + 'id': orderItemModelIdValue, + 'order_id': orderItemModelOrderIdValue, + 'sku': orderItemModelSkuValue, + 'title': orderItemModelTitleValue, + 'quantity': orderItemModelQuantityValue, + 'unit_price_cents': orderItemModelUnitPriceCentsValue, + 'line_total_cents': orderItemModelLineTotalCentsValue, + if (data.containsKey('created_at')) + 'created_at': orderItemModelCreatedAtValue, + if (data.containsKey('updated_at')) + 'updated_at': orderItemModelUpdatedAtValue, + }); + return model; + } +} + +/// Insert DTO for [OrderItemModel]. +/// +/// Auto-increment/DB-generated fields are omitted by default. +class OrderItemModelInsertDto implements InsertDto<$OrderItemModel> { + const OrderItemModelInsertDto({ + this.id, + this.orderId, + this.sku, + this.title, + this.quantity, + this.unitPriceCents, + this.lineTotalCents, + }); + final String? id; + final String? orderId; + final String? sku; + final String? title; + final int? quantity; + final int? unitPriceCents; + final int? lineTotalCents; + + @override + Map toMap() { + return { + if (id != null) 'id': id, + if (orderId != null) 'order_id': orderId, + if (sku != null) 'sku': sku, + if (title != null) 'title': title, + if (quantity != null) 'quantity': quantity, + if (unitPriceCents != null) 'unit_price_cents': unitPriceCents, + if (lineTotalCents != null) 'line_total_cents': lineTotalCents, + }; + } + + static const _OrderItemModelInsertDtoCopyWithSentinel _copyWithSentinel = + _OrderItemModelInsertDtoCopyWithSentinel(); + OrderItemModelInsertDto copyWith({ + Object? id = _copyWithSentinel, + Object? orderId = _copyWithSentinel, + Object? sku = _copyWithSentinel, + Object? title = _copyWithSentinel, + Object? quantity = _copyWithSentinel, + Object? unitPriceCents = _copyWithSentinel, + Object? lineTotalCents = _copyWithSentinel, + }) { + return OrderItemModelInsertDto( + id: identical(id, _copyWithSentinel) ? this.id : id as String?, + orderId: identical(orderId, _copyWithSentinel) + ? this.orderId + : orderId as String?, + sku: identical(sku, _copyWithSentinel) ? this.sku : sku as String?, + title: identical(title, _copyWithSentinel) + ? this.title + : title as String?, + quantity: identical(quantity, _copyWithSentinel) + ? this.quantity + : quantity as int?, + unitPriceCents: identical(unitPriceCents, _copyWithSentinel) + ? this.unitPriceCents + : unitPriceCents as int?, + lineTotalCents: identical(lineTotalCents, _copyWithSentinel) + ? this.lineTotalCents + : lineTotalCents as int?, + ); + } +} + +class _OrderItemModelInsertDtoCopyWithSentinel { + const _OrderItemModelInsertDtoCopyWithSentinel(); +} + +/// Update DTO for [OrderItemModel]. +/// +/// All fields are optional; only provided entries are used in SET clauses. +class OrderItemModelUpdateDto implements UpdateDto<$OrderItemModel> { + const OrderItemModelUpdateDto({ + this.id, + this.orderId, + this.sku, + this.title, + this.quantity, + this.unitPriceCents, + this.lineTotalCents, + }); + final String? id; + final String? orderId; + final String? sku; + final String? title; + final int? quantity; + final int? unitPriceCents; + final int? lineTotalCents; + + @override + Map toMap() { + return { + if (id != null) 'id': id, + if (orderId != null) 'order_id': orderId, + if (sku != null) 'sku': sku, + if (title != null) 'title': title, + if (quantity != null) 'quantity': quantity, + if (unitPriceCents != null) 'unit_price_cents': unitPriceCents, + if (lineTotalCents != null) 'line_total_cents': lineTotalCents, + }; + } + + static const _OrderItemModelUpdateDtoCopyWithSentinel _copyWithSentinel = + _OrderItemModelUpdateDtoCopyWithSentinel(); + OrderItemModelUpdateDto copyWith({ + Object? id = _copyWithSentinel, + Object? orderId = _copyWithSentinel, + Object? sku = _copyWithSentinel, + Object? title = _copyWithSentinel, + Object? quantity = _copyWithSentinel, + Object? unitPriceCents = _copyWithSentinel, + Object? lineTotalCents = _copyWithSentinel, + }) { + return OrderItemModelUpdateDto( + id: identical(id, _copyWithSentinel) ? this.id : id as String?, + orderId: identical(orderId, _copyWithSentinel) + ? this.orderId + : orderId as String?, + sku: identical(sku, _copyWithSentinel) ? this.sku : sku as String?, + title: identical(title, _copyWithSentinel) + ? this.title + : title as String?, + quantity: identical(quantity, _copyWithSentinel) + ? this.quantity + : quantity as int?, + unitPriceCents: identical(unitPriceCents, _copyWithSentinel) + ? this.unitPriceCents + : unitPriceCents as int?, + lineTotalCents: identical(lineTotalCents, _copyWithSentinel) + ? this.lineTotalCents + : lineTotalCents as int?, + ); + } +} + +class _OrderItemModelUpdateDtoCopyWithSentinel { + const _OrderItemModelUpdateDtoCopyWithSentinel(); +} + +/// Partial projection for [OrderItemModel]. +/// +/// All fields are nullable; intended for subset SELECTs. +class OrderItemModelPartial implements PartialEntity<$OrderItemModel> { + const OrderItemModelPartial({ + this.id, + this.orderId, + this.sku, + this.title, + this.quantity, + this.unitPriceCents, + this.lineTotalCents, + }); + + /// Creates a partial from a database row map. + /// + /// The [row] keys should be column names (snake_case). + /// Missing columns will result in null field values. + factory OrderItemModelPartial.fromRow(Map row) { + return OrderItemModelPartial( + id: row['id'] as String?, + orderId: row['order_id'] as String?, + sku: row['sku'] as String?, + title: row['title'] as String?, + quantity: row['quantity'] as int?, + unitPriceCents: row['unit_price_cents'] as int?, + lineTotalCents: row['line_total_cents'] as int?, + ); + } + + final String? id; + final String? orderId; + final String? sku; + final String? title; + final int? quantity; + final int? unitPriceCents; + final int? lineTotalCents; + + @override + $OrderItemModel toEntity() { + // Basic required-field check: non-nullable fields must be present. + final String? idValue = id; + if (idValue == null) { + throw StateError('Missing required field: id'); + } + final String? orderIdValue = orderId; + if (orderIdValue == null) { + throw StateError('Missing required field: orderId'); + } + final String? skuValue = sku; + if (skuValue == null) { + throw StateError('Missing required field: sku'); + } + final String? titleValue = title; + if (titleValue == null) { + throw StateError('Missing required field: title'); + } + final int? quantityValue = quantity; + if (quantityValue == null) { + throw StateError('Missing required field: quantity'); + } + final int? unitPriceCentsValue = unitPriceCents; + if (unitPriceCentsValue == null) { + throw StateError('Missing required field: unitPriceCents'); + } + final int? lineTotalCentsValue = lineTotalCents; + if (lineTotalCentsValue == null) { + throw StateError('Missing required field: lineTotalCents'); + } + return $OrderItemModel( + id: idValue, + orderId: orderIdValue, + sku: skuValue, + title: titleValue, + quantity: quantityValue, + unitPriceCents: unitPriceCentsValue, + lineTotalCents: lineTotalCentsValue, + ); + } + + @override + Map toMap() { + return { + if (id != null) 'id': id, + if (orderId != null) 'order_id': orderId, + if (sku != null) 'sku': sku, + if (title != null) 'title': title, + if (quantity != null) 'quantity': quantity, + if (unitPriceCents != null) 'unit_price_cents': unitPriceCents, + if (lineTotalCents != null) 'line_total_cents': lineTotalCents, + }; + } + + static const _OrderItemModelPartialCopyWithSentinel _copyWithSentinel = + _OrderItemModelPartialCopyWithSentinel(); + OrderItemModelPartial copyWith({ + Object? id = _copyWithSentinel, + Object? orderId = _copyWithSentinel, + Object? sku = _copyWithSentinel, + Object? title = _copyWithSentinel, + Object? quantity = _copyWithSentinel, + Object? unitPriceCents = _copyWithSentinel, + Object? lineTotalCents = _copyWithSentinel, + }) { + return OrderItemModelPartial( + id: identical(id, _copyWithSentinel) ? this.id : id as String?, + orderId: identical(orderId, _copyWithSentinel) + ? this.orderId + : orderId as String?, + sku: identical(sku, _copyWithSentinel) ? this.sku : sku as String?, + title: identical(title, _copyWithSentinel) + ? this.title + : title as String?, + quantity: identical(quantity, _copyWithSentinel) + ? this.quantity + : quantity as int?, + unitPriceCents: identical(unitPriceCents, _copyWithSentinel) + ? this.unitPriceCents + : unitPriceCents as int?, + lineTotalCents: identical(lineTotalCents, _copyWithSentinel) + ? this.lineTotalCents + : lineTotalCents as int?, + ); + } +} + +class _OrderItemModelPartialCopyWithSentinel { + const _OrderItemModelPartialCopyWithSentinel(); +} + +/// Generated tracked model class for [OrderItemModel]. +/// +/// This class extends the user-defined [OrderItemModel] model and adds +/// attribute tracking, change detection, and relationship management. +/// Instances of this class are returned by queries and repositories. +/// +/// **Do not instantiate this class directly.** Use queries, repositories, +/// or model factories to create tracked model instances. +class $OrderItemModel extends OrderItemModel + with ModelAttributes, TimestampsTZImpl + implements OrmEntity { + /// Internal constructor for [$OrderItemModel]. + $OrderItemModel({ + required String id, + required String orderId, + required String sku, + required String title, + required int quantity, + required int unitPriceCents, + required int lineTotalCents, + }) : super( + id: id, + orderId: orderId, + sku: sku, + title: title, + quantity: quantity, + unitPriceCents: unitPriceCents, + lineTotalCents: lineTotalCents, + ) { + _attachOrmRuntimeMetadata({ + 'id': id, + 'order_id': orderId, + 'sku': sku, + 'title': title, + 'quantity': quantity, + 'unit_price_cents': unitPriceCents, + 'line_total_cents': lineTotalCents, + }); + } + + /// Creates a tracked model instance from a user-defined model instance. + factory $OrderItemModel.fromModel(OrderItemModel model) { + return $OrderItemModel( + id: model.id, + orderId: model.orderId, + sku: model.sku, + title: model.title, + quantity: model.quantity, + unitPriceCents: model.unitPriceCents, + lineTotalCents: model.lineTotalCents, + ); + } + + $OrderItemModel copyWith({ + String? id, + String? orderId, + String? sku, + String? title, + int? quantity, + int? unitPriceCents, + int? lineTotalCents, + }) { + return $OrderItemModel( + id: id ?? this.id, + orderId: orderId ?? this.orderId, + sku: sku ?? this.sku, + title: title ?? this.title, + quantity: quantity ?? this.quantity, + unitPriceCents: unitPriceCents ?? this.unitPriceCents, + lineTotalCents: lineTotalCents ?? this.lineTotalCents, + ); + } + + /// Builds a tracked model from a column/value map. + static $OrderItemModel fromMap( + Map data, { + ValueCodecRegistry? registry, + }) => _$OrderItemModelDefinition.fromMap(data, registry: registry); + + /// Converts this tracked model to a column/value map. + Map toMap({ValueCodecRegistry? registry}) => + _$OrderItemModelDefinition.toMap(this, registry: registry); + + /// Tracked getter for [id]. + @override + String get id => getAttribute('id') ?? super.id; + + /// Tracked setter for [id]. + set id(String value) => setAttribute('id', value); + + /// Tracked getter for [orderId]. + @override + String get orderId => getAttribute('order_id') ?? super.orderId; + + /// Tracked setter for [orderId]. + set orderId(String value) => setAttribute('order_id', value); + + /// Tracked getter for [sku]. + @override + String get sku => getAttribute('sku') ?? super.sku; + + /// Tracked setter for [sku]. + set sku(String value) => setAttribute('sku', value); + + /// Tracked getter for [title]. + @override + String get title => getAttribute('title') ?? super.title; + + /// Tracked setter for [title]. + set title(String value) => setAttribute('title', value); + + /// Tracked getter for [quantity]. + @override + int get quantity => getAttribute('quantity') ?? super.quantity; + + /// Tracked setter for [quantity]. + set quantity(int value) => setAttribute('quantity', value); + + /// Tracked getter for [unitPriceCents]. + @override + int get unitPriceCents => + getAttribute('unit_price_cents') ?? super.unitPriceCents; + + /// Tracked setter for [unitPriceCents]. + set unitPriceCents(int value) => setAttribute('unit_price_cents', value); + + /// Tracked getter for [lineTotalCents]. + @override + int get lineTotalCents => + getAttribute('line_total_cents') ?? super.lineTotalCents; + + /// Tracked setter for [lineTotalCents]. + set lineTotalCents(int value) => setAttribute('line_total_cents', value); + + void _attachOrmRuntimeMetadata(Map values) { + replaceAttributes(values); + attachModelDefinition(_$OrderItemModelDefinition); + } +} + +class _OrderItemModelCopyWithSentinel { + const _OrderItemModelCopyWithSentinel(); +} + +extension OrderItemModelOrmExtension on OrderItemModel { + static const _OrderItemModelCopyWithSentinel _copyWithSentinel = + _OrderItemModelCopyWithSentinel(); + OrderItemModel copyWith({ + Object? id = _copyWithSentinel, + Object? orderId = _copyWithSentinel, + Object? sku = _copyWithSentinel, + Object? title = _copyWithSentinel, + Object? quantity = _copyWithSentinel, + Object? unitPriceCents = _copyWithSentinel, + Object? lineTotalCents = _copyWithSentinel, + }) { + return OrderItemModel( + id: identical(id, _copyWithSentinel) ? this.id : id as String, + orderId: identical(orderId, _copyWithSentinel) + ? this.orderId + : orderId as String, + sku: identical(sku, _copyWithSentinel) ? this.sku : sku as String, + title: identical(title, _copyWithSentinel) ? this.title : title as String, + quantity: identical(quantity, _copyWithSentinel) + ? this.quantity + : quantity as int, + unitPriceCents: identical(unitPriceCents, _copyWithSentinel) + ? this.unitPriceCents + : unitPriceCents as int, + lineTotalCents: identical(lineTotalCents, _copyWithSentinel) + ? this.lineTotalCents + : lineTotalCents as int, + ); + } + + /// Converts this model to a column/value map. + Map toMap({ValueCodecRegistry? registry}) => + _$OrderItemModelDefinition.toMap(this, registry: registry); + + /// Builds a model from a column/value map. + static OrderItemModel fromMap( + Map data, { + ValueCodecRegistry? registry, + }) => _$OrderItemModelDefinition.fromMap(data, registry: registry); + + /// The Type of the generated ORM-managed model class. + /// Use this when you need to specify the tracked model type explicitly, + /// for example in generic type parameters. + static Type get trackedType => $OrderItemModel; + + /// Converts this immutable model to a tracked ORM-managed model. + /// The tracked model supports attribute tracking, change detection, + /// and persistence operations like save() and touch(). + $OrderItemModel toTracked() { + return $OrderItemModel.fromModel(this); + } +} + +extension OrderItemModelPredicateFields on PredicateBuilder { + PredicateField get id => + PredicateField(this, 'id'); + PredicateField get orderId => + PredicateField(this, 'orderId'); + PredicateField get sku => + PredicateField(this, 'sku'); + PredicateField get title => + PredicateField(this, 'title'); + PredicateField get quantity => + PredicateField(this, 'quantity'); + PredicateField get unitPriceCents => + PredicateField(this, 'unitPriceCents'); + PredicateField get lineTotalCents => + PredicateField(this, 'lineTotalCents'); +} + +void registerOrderItemModelEventHandlers(EventBus bus) { + // No event handlers registered for OrderItemModel. +} diff --git a/packages/stem/example/ecommerce/lib/src/database/orm_registry.g.dart b/packages/stem/example/ecommerce/lib/src/database/orm_registry.g.dart new file mode 100644 index 00000000..2296b040 --- /dev/null +++ b/packages/stem/example/ecommerce/lib/src/database/orm_registry.g.dart @@ -0,0 +1,80 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +import 'package:ormed/ormed.dart'; +import 'package:stem_ecommerce_example/src/database/models/cart_item.dart'; +import 'package:stem_ecommerce_example/src/database/models/cart.dart'; +import 'package:stem_ecommerce_example/src/database/models/catalog_sku.dart'; +import 'package:stem_ecommerce_example/src/database/models/order_item.dart'; +import 'package:stem_ecommerce_example/src/database/models/order.dart'; + +final List> _$ormModelDefinitions = [ + CartItemModelOrmDefinition.definition, + CartModelOrmDefinition.definition, + CatalogSkuModelOrmDefinition.definition, + OrderItemModelOrmDefinition.definition, + OrderModelOrmDefinition.definition, +]; + +ModelRegistry buildOrmRegistry() => ModelRegistry() + ..registerAll(_$ormModelDefinitions) + ..registerTypeAlias(_$ormModelDefinitions[0]) + ..registerTypeAlias(_$ormModelDefinitions[1]) + ..registerTypeAlias(_$ormModelDefinitions[2]) + ..registerTypeAlias(_$ormModelDefinitions[3]) + ..registerTypeAlias(_$ormModelDefinitions[4]) + ; + +List> get generatedOrmModelDefinitions => + List.unmodifiable(_$ormModelDefinitions); + +extension GeneratedOrmModels on ModelRegistry { + ModelRegistry registerGeneratedModels() { + registerAll(_$ormModelDefinitions); + registerTypeAlias(_$ormModelDefinitions[0]); + registerTypeAlias(_$ormModelDefinitions[1]); + registerTypeAlias(_$ormModelDefinitions[2]); + registerTypeAlias(_$ormModelDefinitions[3]); + registerTypeAlias(_$ormModelDefinitions[4]); + return this; + } +} + +/// Registers factory definitions for all models that have factory support. +/// Call this before using [Model.factory()] to ensure definitions are available. +void registerOrmFactories() { +} + +/// Combined setup: registers both model registry and factories. +/// Returns a ModelRegistry with all generated models registered. +ModelRegistry buildOrmRegistryWithFactories() { + registerOrmFactories(); + return buildOrmRegistry(); +} + +/// Registers generated model event handlers. +void registerModelEventHandlers({EventBus? bus}) { + // No model event handlers were generated. +} + +/// Registers generated model scopes into a [ScopeRegistry]. +void registerModelScopes({ScopeRegistry? scopeRegistry}) { + // No model scopes were generated. +} + +/// Bootstraps generated ORM pieces: registry, factories, event handlers, and scopes. +ModelRegistry bootstrapOrm({ModelRegistry? registry, EventBus? bus, ScopeRegistry? scopes, bool registerFactories = true, bool registerEventHandlers = true, bool registerScopes = true}) { + final reg = registry ?? buildOrmRegistry(); + if (registry != null) { + reg.registerGeneratedModels(); + } + if (registerFactories) { + registerOrmFactories(); + } + if (registerEventHandlers) { + registerModelEventHandlers(bus: bus); + } + if (registerScopes) { + registerModelScopes(scopeRegistry: scopes); + } + return reg; +} diff --git a/packages/stem/example/ecommerce/lib/src/domain/catalog.dart b/packages/stem/example/ecommerce/lib/src/domain/catalog.dart new file mode 100644 index 00000000..349a3869 --- /dev/null +++ b/packages/stem/example/ecommerce/lib/src/domain/catalog.dart @@ -0,0 +1,50 @@ +class CatalogSku { + const CatalogSku({ + required this.sku, + required this.title, + required this.priceCents, + required this.initialStock, + }); + + final String sku; + final String title; + final int priceCents; + final int initialStock; + + Map toJson() => { + 'sku': sku, + 'title': title, + 'priceCents': priceCents, + 'initialStock': initialStock, + }; +} + +const defaultCatalog = [ + CatalogSku( + sku: 'sku_tee', + title: 'Stem Tee', + priceCents: 2500, + initialStock: 250, + ), + CatalogSku( + sku: 'sku_mug', + title: 'Stem Mug', + priceCents: 1800, + initialStock: 150, + ), + CatalogSku( + sku: 'sku_sticker_pack', + title: 'Sticker Pack', + priceCents: 700, + initialStock: 500, + ), +]; + +CatalogSku? catalogSkuById(String sku) { + for (final entry in defaultCatalog) { + if (entry.sku == sku) { + return entry; + } + } + return null; +} diff --git a/packages/stem/example/ecommerce/lib/src/domain/repository.dart b/packages/stem/example/ecommerce/lib/src/domain/repository.dart new file mode 100644 index 00000000..087158d3 --- /dev/null +++ b/packages/stem/example/ecommerce/lib/src/domain/repository.dart @@ -0,0 +1,404 @@ +import 'dart:io'; + +import 'package:ormed/ormed.dart'; +import 'package:path/path.dart' as p; + +import '../database/datasource.dart'; +import '../database/models/models.dart'; +import 'catalog.dart'; + +class EcommerceRepository { + EcommerceRepository._({ + required this.databasePath, + required DataSource dataSource, + }) : _dataSource = dataSource; + + final String databasePath; + final DataSource _dataSource; + + int _idCounter = 0; + + static Future open(String databasePath) async { + final file = File(databasePath); + await file.parent.create(recursive: true); + + final normalizedPath = p.normalize(databasePath); + final dataSource = await openEcommerceDataSource( + databasePath: normalizedPath, + ); + + final repository = EcommerceRepository._( + databasePath: normalizedPath, + dataSource: dataSource, + ); + + await repository._seedCatalog(); + + return repository; + } + + Future close() => _dataSource.dispose(); + + Future>> listCatalog() async { + final items = await _dataSource.context + .query() + .orderBy('sku') + .get(); + + return items + .map( + (item) => { + 'sku': item.sku, + 'title': item.title, + 'priceCents': item.priceCents, + 'stockAvailable': item.stockAvailable, + 'updatedAt': item.updatedAt?.toIso8601String() ?? '', + }, + ) + .toList(growable: false); + } + + Future> resolveLineItemForCart({ + required String cartId, + required String sku, + required int quantity, + }) async { + if (quantity <= 0) { + throw ArgumentError.value(quantity, 'quantity', 'Quantity must be > 0.'); + } + + final cart = await _dataSource.context + .query() + .whereEquals('id', cartId) + .first(); + + if (cart == null) { + throw StateError('Cart $cartId not found.'); + } + + if (cart.status != 'open') { + throw StateError('Cart $cartId is not open for updates.'); + } + + final item = await _dataSource.context + .query() + .whereEquals('sku', sku) + .first(); + + if (item == null) { + throw StateError('Unknown SKU: $sku'); + } + + final stockAvailable = item.stockAvailable; + if (stockAvailable < quantity) { + throw StateError( + 'Insufficient stock for $sku. Requested $quantity, available $stockAvailable.', + ); + } + + final unitPriceCents = item.priceCents; + return { + 'title': item.title, + 'unitPriceCents': unitPriceCents, + 'lineTotalCents': unitPriceCents * quantity, + 'stockAvailable': stockAvailable, + }; + } + + Future> createCart({required String customerId}) async { + final cartId = _nextId('cart'); + + await _dataSource.context.repository().insert( + CartModelInsertDto(id: cartId, customerId: customerId, status: 'open'), + ); + + final cart = await getCart(cartId); + if (cart == null) { + throw StateError('Failed to create cart $cartId.'); + } + return cart; + } + + Future?> getCart(String cartId) async { + final cart = await _dataSource.context + .query() + .whereEquals('id', cartId) + .first(); + if (cart == null) return null; + + final itemRows = await _dataSource.context + .query() + .whereEquals('cartId', cartId) + .orderBy('createdAt') + .get(); + + var totalCents = 0; + final items = itemRows + .map((item) { + totalCents += item.lineTotalCents; + return { + 'id': item.id, + 'sku': item.sku, + 'title': item.title, + 'quantity': item.quantity, + 'unitPriceCents': item.unitPriceCents, + 'lineTotalCents': item.lineTotalCents, + }; + }) + .toList(growable: false); + + return { + 'id': cart.id, + 'customerId': cart.customerId, + 'status': cart.status, + 'itemCount': items.length, + 'totalCents': totalCents, + 'createdAt': cart.createdAt?.toIso8601String() ?? '', + 'updatedAt': cart.updatedAt?.toIso8601String() ?? '', + 'items': items, + }; + } + + Future> addItemToCart({ + required String cartId, + required String sku, + required String title, + required int quantity, + required int unitPriceCents, + }) async { + if (quantity <= 0) { + throw ArgumentError.value(quantity, 'quantity', 'Quantity must be > 0.'); + } + + final cart = await _dataSource.context + .query() + .whereEquals('id', cartId) + .first(); + if (cart == null) { + throw StateError('Cart $cartId not found.'); + } + if (cart.status != 'open') { + throw StateError('Cart $cartId is not open for updates.'); + } + + final itemRepository = _dataSource.context.repository(); + final existing = await _dataSource.context + .query() + .whereEquals('cartId', cartId) + .whereEquals('sku', sku) + .first(); + + if (existing == null) { + await itemRepository.insert( + CartItemModelInsertDto( + id: _nextId('cit'), + cartId: cartId, + sku: sku, + title: title, + quantity: quantity, + unitPriceCents: unitPriceCents, + lineTotalCents: quantity * unitPriceCents, + ), + ); + } else { + final nextQuantity = existing.quantity + quantity; + await itemRepository.update( + CartItemModelUpdateDto( + quantity: nextQuantity, + lineTotalCents: unitPriceCents * nextQuantity, + ), + where: CartItemModelPartial(id: existing.id), + ); + } + + // Touch the parent cart so updatedAt reflects item mutations. + await _dataSource.context.repository().update( + const CartModelUpdateDto(status: 'open'), + where: CartModelPartial(id: cartId), + ); + + final updated = await getCart(cartId); + if (updated == null) { + throw StateError('Cart $cartId was updated but could not be reloaded.'); + } + + return updated; + } + + Future> checkoutCart({ + required String cartId, + required String paymentReference, + }) async { + String? createdOrderId; + + await _dataSource.transaction(() async { + final cart = await _dataSource.context + .query() + .whereEquals('id', cartId) + .first(); + if (cart == null) { + throw StateError('Cart $cartId not found.'); + } + if (cart.status != 'open') { + throw StateError('Cart $cartId is not open for checkout.'); + } + + final items = await _dataSource.context + .query() + .whereEquals('cartId', cartId) + .orderBy('createdAt') + .get(); + if (items.isEmpty) { + throw StateError('Cart $cartId has no items to checkout.'); + } + + for (final item in items) { + await _reserveInventory(sku: item.sku, quantity: item.quantity); + } + + var totalCents = 0; + for (final item in items) { + totalCents += item.lineTotalCents; + } + + final orderId = _nextId('ord'); + await _dataSource.context.repository().insert( + OrderModelInsertDto( + id: orderId, + cartId: cartId, + customerId: cart.customerId, + status: 'confirmed', + totalCents: totalCents, + paymentReference: paymentReference, + ), + ); + + final orderItemRepository = _dataSource.context + .repository(); + for (final item in items) { + await orderItemRepository.insert( + OrderItemModelInsertDto( + id: _nextId('ori'), + orderId: orderId, + sku: item.sku, + title: item.title, + quantity: item.quantity, + unitPriceCents: item.unitPriceCents, + lineTotalCents: item.lineTotalCents, + ), + ); + } + + await _dataSource.context.repository().update( + const CartModelUpdateDto(status: 'checked_out'), + where: CartModelPartial(id: cartId), + ); + + createdOrderId = orderId; + }); + + final orderId = createdOrderId; + if (orderId == null) { + throw StateError('Checkout failed to create an order.'); + } + + final order = await getOrder(orderId); + if (order == null) { + throw StateError('Order $orderId was created but could not be loaded.'); + } + + return order; + } + + Future?> getOrder(String orderId) async { + final order = await _dataSource.context + .query() + .whereEquals('id', orderId) + .first(); + + if (order == null) return null; + + final itemRows = await _dataSource.context + .query() + .whereEquals('orderId', orderId) + .orderBy('createdAt') + .get(); + + final items = itemRows + .map( + (item) => { + 'id': item.id, + 'sku': item.sku, + 'title': item.title, + 'quantity': item.quantity, + 'unitPriceCents': item.unitPriceCents, + 'lineTotalCents': item.lineTotalCents, + }, + ) + .toList(growable: false); + + return { + 'id': order.id, + 'cartId': order.cartId, + 'customerId': order.customerId, + 'status': order.status, + 'totalCents': order.totalCents, + 'paymentReference': order.paymentReference, + 'createdAt': order.createdAt?.toIso8601String() ?? '', + 'updatedAt': order.updatedAt?.toIso8601String() ?? '', + 'items': items, + }; + } + + Future _reserveInventory({ + required String sku, + required int quantity, + }) async { + final model = await _dataSource.context + .query() + .whereEquals('sku', sku) + .first(); + + if (model == null) { + throw StateError('SKU $sku not found in catalog.'); + } + + final available = model.stockAvailable; + if (available < quantity) { + throw StateError( + 'Insufficient stock for $sku. Requested $quantity, available $available.', + ); + } + + await _dataSource.context.repository().update( + CatalogSkuModelUpdateDto(stockAvailable: available - quantity), + where: CatalogSkuModelPartial(sku: sku), + ); + } + + Future _seedCatalog() async { + final existing = await _dataSource.context + .query() + .limit(1) + .first(); + if (existing != null) return; + + final repository = _dataSource.context.repository(); + for (final sku in defaultCatalog) { + await repository.insert( + CatalogSkuModelInsertDto( + sku: sku.sku, + title: sku.title, + priceCents: sku.priceCents, + stockAvailable: sku.initialStock, + ), + ); + } + } + + String _nextId(String prefix) { + _idCounter += 1; + final micros = DateTime.now().toUtc().microsecondsSinceEpoch; + return '$prefix-$micros-$_idCounter'; + } +} diff --git a/packages/stem/example/ecommerce/lib/src/tasks/manual_tasks.dart b/packages/stem/example/ecommerce/lib/src/tasks/manual_tasks.dart new file mode 100644 index 00000000..fee3531a --- /dev/null +++ b/packages/stem/example/ecommerce/lib/src/tasks/manual_tasks.dart @@ -0,0 +1,33 @@ +import 'dart:async'; + +import 'package:stem/stem.dart'; + +FutureOr _reserveShipmentTask( + TaskInvocationContext context, + Map args, +) async { + final orderId = args['orderId']?.toString() ?? 'unknown'; + final carrier = args['carrier']?.toString() ?? 'acme-post'; + await Future.delayed(const Duration(milliseconds: 25)); + context.progress( + 1.0, + data: { + 'orderId': orderId, + 'carrier': carrier, + 'reservation': 'ship-$orderId', + }, + ); + return { + 'orderId': orderId, + 'carrier': carrier, + 'reservationId': 'ship-$orderId', + }; +} + +final TaskHandler shipmentReserveTaskHandler = + FunctionTaskHandler( + name: 'ecommerce.shipping.reserve', + entrypoint: _reserveShipmentTask, + options: const TaskOptions(queue: 'default'), + runInIsolate: false, + ); diff --git a/packages/stem/example/ecommerce/lib/src/workflows/annotated_defs.dart b/packages/stem/example/ecommerce/lib/src/workflows/annotated_defs.dart new file mode 100644 index 00000000..df3036a1 --- /dev/null +++ b/packages/stem/example/ecommerce/lib/src/workflows/annotated_defs.dart @@ -0,0 +1,99 @@ +import 'package:stem/stem.dart'; + +import '../domain/repository.dart'; + +part 'annotated_defs.stem.g.dart'; + +EcommerceRepository? _addToCartWorkflowRepository; + +void bindAddToCartWorkflowRepository(EcommerceRepository repository) { + _addToCartWorkflowRepository = repository; +} + +void unbindAddToCartWorkflowRepository() { + _addToCartWorkflowRepository = null; +} + +EcommerceRepository get _repository { + final repository = _addToCartWorkflowRepository; + if (repository == null) { + throw StateError( + 'AddToCartWorkflow repository is not bound. ' + 'Call bindAddToCartWorkflowRepository(...) during startup.', + ); + } + return repository; +} + +@WorkflowDefn( + name: 'ecommerce.cart.add_item', + kind: WorkflowKind.script, + starterName: 'AddToCart', + description: 'Validates cart item requests and computes durable pricing.', +) +class AddToCartWorkflow { + Future> run( + String cartId, + String sku, + int quantity, + ) async { + final validated = await validateInput(cartId, sku, quantity); + final priced = await priceLineItem(cartId, sku, quantity); + return {...validated, ...priced}; + } + + @WorkflowStep(name: 'validate-input') + Future> validateInput( + String cartId, + String sku, + int quantity, + ) async { + if (cartId.trim().isEmpty) { + throw ArgumentError.value(cartId, 'cartId', 'Cart ID must not be empty.'); + } + if (sku.trim().isEmpty) { + throw ArgumentError.value(sku, 'sku', 'SKU must not be empty.'); + } + if (quantity <= 0) { + throw ArgumentError.value(quantity, 'quantity', 'Quantity must be > 0.'); + } + return {'cartId': cartId, 'sku': sku, 'quantity': quantity}; + } + + @WorkflowStep(name: 'price-line-item') + Future> priceLineItem( + String cartId, + String sku, + int quantity, + ) async { + return _repository.resolveLineItemForCart( + cartId: cartId, + sku: sku, + quantity: quantity, + ); + } +} + +@TaskDefn( + name: 'ecommerce.audit.log', + options: TaskOptions(queue: 'default'), + runInIsolate: false, +) +Future> logAuditEvent( + TaskInvocationContext context, + String event, + String entityId, + String detail, +) async { + context.progress( + 1.0, + data: {'event': event, 'entityId': entityId, 'detail': detail}, + ); + + return { + 'event': event, + 'entityId': entityId, + 'detail': detail, + 'attempt': context.attempt, + }; +} diff --git a/packages/stem/example/ecommerce/lib/src/workflows/annotated_defs.stem.g.dart b/packages/stem/example/ecommerce/lib/src/workflows/annotated_defs.stem.g.dart new file mode 100644 index 00000000..65d4e4fa --- /dev/null +++ b/packages/stem/example/ecommerce/lib/src/workflows/annotated_defs.stem.g.dart @@ -0,0 +1,183 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: unused_element, unnecessary_lambdas, omit_local_variable_types, unused_import + +part of 'annotated_defs.dart'; + +final List _stemFlows = []; + +class _StemScriptProxy0 extends AddToCartWorkflow { + _StemScriptProxy0(this._script); + final WorkflowScriptContext _script; + @override + Future> validateInput( + String cartId, + String sku, + int quantity, + ) { + return _script.step>( + "validate-input", + (context) => super.validateInput(cartId, sku, quantity), + ); + } + + @override + Future> priceLineItem( + String cartId, + String sku, + int quantity, + ) { + return _script.step>( + "price-line-item", + (context) => super.priceLineItem(cartId, sku, quantity), + ); + } +} + +final List _stemScripts = [ + WorkflowScript( + name: "ecommerce.cart.add_item", + checkpoints: [ + FlowStep( + name: "validate-input", + handler: _stemScriptManifestStepNoop, + kind: WorkflowStepKind.task, + taskNames: [], + ), + FlowStep( + name: "price-line-item", + handler: _stemScriptManifestStepNoop, + kind: WorkflowStepKind.task, + taskNames: [], + ), + ], + description: "Validates cart item requests and computes durable pricing.", + run: (script) => _StemScriptProxy0(script).run( + (_stemRequireArg(script.params, "cartId") as String), + (_stemRequireArg(script.params, "sku") as String), + (_stemRequireArg(script.params, "quantity") as int), + ), + ), +]; + +abstract final class StemWorkflowDefinitions { + static final WorkflowRef< + ({String cartId, String sku, int quantity}), + Map + > + addToCart = + WorkflowRef< + ({String cartId, String sku, int quantity}), + Map + >( + name: "ecommerce.cart.add_item", + encodeParams: (params) => { + "cartId": params.cartId, + "sku": params.sku, + "quantity": params.quantity, + }, + ); +} + +Future _stemScriptManifestStepNoop(FlowContext context) async => null; + +Object? _stemRequireArg(Map args, String name) { + if (!args.containsKey(name)) { + throw ArgumentError('Missing required argument "$name".'); + } + return args[name]; +} + +Future _stemTaskAdapter0( + TaskInvocationContext context, + Map args, +) async { + return await Future.value( + logAuditEvent( + context, + (_stemRequireArg(args, "event") as String), + (_stemRequireArg(args, "entityId") as String), + (_stemRequireArg(args, "detail") as String), + ), + ); +} + +abstract final class StemTaskDefinitions { + static final TaskDefinition< + ({String event, String entityId, String detail}), + Map + > + ecommerceAuditLog = + TaskDefinition< + ({String event, String entityId, String detail}), + Map + >( + name: "ecommerce.audit.log", + encodeArgs: (args) => { + "event": args.event, + "entityId": args.entityId, + "detail": args.detail, + }, + defaultOptions: const TaskOptions(queue: "default"), + metadata: const TaskMetadata(), + ); +} + +extension StemGeneratedTaskEnqueuer on TaskEnqueuer { + Future enqueueEcommerceAuditLog({ + required String event, + required String entityId, + required String detail, + Map headers = const {}, + TaskOptions? options, + DateTime? notBefore, + Map? meta, + TaskEnqueueOptions? enqueueOptions, + }) { + return enqueueCall( + StemTaskDefinitions.ecommerceAuditLog.call( + (event: event, entityId: entityId, detail: detail), + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ), + ); + } +} + +extension StemGeneratedTaskResults on Stem { + Future>?> waitForEcommerceAuditLog( + String taskId, { + Duration? timeout, + }) { + return waitForTaskDefinition( + taskId, + StemTaskDefinitions.ecommerceAuditLog, + timeout: timeout, + ); + } +} + +final List> _stemTasks = >[ + FunctionTaskHandler( + name: "ecommerce.audit.log", + entrypoint: _stemTaskAdapter0, + options: const TaskOptions(queue: "default"), + metadata: const TaskMetadata(), + runInIsolate: false, + ), +]; + +final List _stemWorkflowManifest = + [ + ..._stemFlows.map((flow) => flow.definition.toManifestEntry()), + ..._stemScripts.map((script) => script.definition.toManifestEntry()), + ]; + +final StemModule stemModule = StemModule( + flows: _stemFlows, + scripts: _stemScripts, + tasks: _stemTasks, + workflowManifest: _stemWorkflowManifest, +); diff --git a/packages/stem/example/ecommerce/lib/src/workflows/checkout_flow.dart b/packages/stem/example/ecommerce/lib/src/workflows/checkout_flow.dart new file mode 100644 index 00000000..1c342b4f --- /dev/null +++ b/packages/stem/example/ecommerce/lib/src/workflows/checkout_flow.dart @@ -0,0 +1,107 @@ +import 'package:stem/stem.dart'; + +import '../domain/repository.dart'; + +const checkoutWorkflowName = 'ecommerce.checkout'; + +Flow> buildCheckoutFlow(EcommerceRepository repository) { + return Flow>( + name: checkoutWorkflowName, + description: 'Converts a cart into an order and emits operational tasks.', + metadata: const {'domain': 'commerce', 'surface': 'checkout'}, + build: (flow) { + flow.step('load-cart', (ctx) async { + final cartId = ctx.params['cartId']?.toString() ?? ''; + if (cartId.isEmpty) { + throw ArgumentError('Missing required cartId parameter.'); + } + + final cart = await repository.getCart(cartId); + if (cart == null) { + throw StateError('Cart $cartId not found.'); + } + return cart; + }); + + flow.step('capture-payment', (ctx) async { + final resume = ctx.takeResumeValue>(); + if (resume == null) { + ctx.sleep( + const Duration(milliseconds: 100), + data: { + 'phase': 'payment-authorization', + 'cartId': ctx.params['cartId'], + }, + ); + return null; + } + + final cartId = ctx.params['cartId']?.toString() ?? 'unknown-cart'; + return {'paymentReference': 'pay-$cartId'}; + }); + + flow.step('create-order', (ctx) async { + final cartId = ctx.params['cartId']?.toString() ?? ''; + final paymentPayload = _mapFromDynamic(ctx.previousResult); + final paymentReference = + paymentPayload['paymentReference']?.toString() ?? 'pay-$cartId'; + + final order = await repository.checkoutCart( + cartId: cartId, + paymentReference: paymentReference, + ); + return order; + }); + + flow.step('emit-side-effects', (ctx) async { + final order = _mapFromDynamic(ctx.previousResult); + if (order.isEmpty) { + throw StateError( + 'create-order step did not return an order payload.', + ); + } + + final orderId = order['id']?.toString() ?? ''; + final cartId = order['cartId']?.toString() ?? ''; + + if (ctx.enqueuer != null) { + await ctx.enqueuer!.enqueue( + 'ecommerce.audit.log', + args: { + 'event': 'order.checked_out', + 'entityId': orderId, + 'detail': 'cart=$cartId', + }, + options: const TaskOptions(queue: 'default'), + meta: { + 'workflow': checkoutWorkflowName, + 'step': 'emit-side-effects', + }, + ); + + await ctx.enqueuer!.enqueue( + 'ecommerce.shipping.reserve', + args: {'orderId': orderId, 'carrier': 'acme-post'}, + options: const TaskOptions(queue: 'default'), + meta: { + 'workflow': checkoutWorkflowName, + 'step': 'emit-side-effects', + }, + ); + } + + return order; + }); + }, + ); +} + +Map _mapFromDynamic(Object? value) { + if (value is Map) { + return value; + } + if (value is Map) { + return value.cast(); + } + return {}; +} diff --git a/packages/stem/example/ecommerce/ormed.yaml b/packages/stem/example/ecommerce/ormed.yaml new file mode 100644 index 00000000..8963e41f --- /dev/null +++ b/packages/stem/example/ecommerce/ormed.yaml @@ -0,0 +1,9 @@ +driver: + type: sqlite + options: + database: ${ECOMMERCE_DB_PATH:-database/ecommerce.sqlite} +migrations: + directory: lib/src/database/migrations + registry: lib/src/database/migrations.dart + ledger_table: orm_migrations + schema_dump: database/schema.sql diff --git a/packages/stem/example/ecommerce/pubspec.yaml b/packages/stem/example/ecommerce/pubspec.yaml new file mode 100644 index 00000000..975c540a --- /dev/null +++ b/packages/stem/example/ecommerce/pubspec.yaml @@ -0,0 +1,35 @@ +name: stem_ecommerce_example +description: Workflow-driven ecommerce Shelf app using Stem + SQLite + Ormed. +publish_to: 'none' +version: 0.0.1 + +environment: + sdk: ">=3.9.2 <4.0.0" + +dependencies: + ormed: ^0.2.0 + ormed_sqlite: ^0.2.0 + path: ^1.9.1 + shelf: ^1.4.2 + shelf_router: ^1.1.4 + stem: + path: ../.. + stem_sqlite: + path: ../../../stem_sqlite + +dev_dependencies: + build_runner: ^2.10.5 + ormed_cli: ^0.2.0 + lints: ^6.0.0 + server_testing: ^0.3.2 + server_testing_shelf: ^0.3.2 + stem_builder: + path: ../../../stem_builder + test: ^1.26.2 + +dependency_overrides: + artisanal: ^0.2.0 + stem: + path: ../.. + stem_memory: + path: ../../../stem_memory diff --git a/packages/stem/example/ecommerce/test/server_test.dart b/packages/stem/example/ecommerce/test/server_test.dart new file mode 100644 index 00000000..2dc0dee1 --- /dev/null +++ b/packages/stem/example/ecommerce/test/server_test.dart @@ -0,0 +1,151 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:server_testing/server_testing.dart'; +import 'package:server_testing_shelf/server_testing_shelf.dart'; +import 'package:stem_ecommerce_example/ecommerce.dart'; + +void main() { + Directory? tempDir; + EcommerceServer? app; + TestClient? client; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('stem-ecommerce-test-'); + app = await EcommerceServer.create( + databasePath: p.join(tempDir!.path, 'ecommerce.sqlite'), + ); + client = TestClient.inMemory(ShelfRequestHandler(app!.handler)); + }); + + tearDown(() async { + if (client != null) { + await client!.close(); + } + if (app != null) { + await app!.close(); + } + if (tempDir != null && tempDir!.existsSync()) { + await tempDir!.delete(recursive: true); + } + }); + + test('cart creation + add-to-cart workflow persists item', () async { + final createResponse = await client!.postJson('/carts', { + 'customerId': 'cust-123', + }); + createResponse + ..assertStatus(201) + ..assertJsonPath('cart.customerId', 'cust-123') + ..assertJsonPath('cart.itemCount', 0); + + final cartId = createResponse.json('cart.id') as String; + + final addResponse = await client!.postJson('/carts/$cartId/items', { + 'sku': 'sku_tee', + 'quantity': 2, + }); + addResponse + ..assertStatus(200) + ..assertJsonPath('cart.itemCount', 1) + ..assertJsonPath('cart.totalCents', 5000); + + final addedCart = addResponse.json('cart') as Map; + final addedItems = (addedCart['items'] as List).cast(); + expect(addedItems.first['sku'], 'sku_tee'); + + final runId = addResponse.json('runId') as String; + + final runResponse = await client!.get('/runs/$runId'); + runResponse + ..assertStatus(200) + ..assertJsonPath('detail.run.workflow', 'ecommerce.cart.add_item') + ..assertJsonPath('detail.run.status', 'completed'); + }); + + test('checkout flow creates order and exposes run detail', () async { + final createResponse = await client!.postJson('/carts', { + 'customerId': 'cust-checkout', + }); + createResponse.assertStatus(201); + final cartId = createResponse.json('cart.id') as String; + + final addOne = await client!.postJson('/carts/$cartId/items', { + 'sku': 'sku_mug', + 'quantity': 1, + }); + addOne.assertStatus(200); + + final addTwo = await client!.postJson('/carts/$cartId/items', { + 'sku': 'sku_sticker_pack', + 'quantity': 3, + }); + addTwo + ..assertStatus(200) + ..assertJsonPath('cart.itemCount', 2) + ..assertJsonPath('cart.totalCents', 3900); + + final checkoutResponse = await client!.postJson('/checkout/$cartId', {}); + checkoutResponse + ..assertStatus(200) + ..assertJsonPath('order.status', 'confirmed') + ..assertJsonPath('order.cartId', cartId) + ..assertJsonPath('order.totalCents', 3900); + + final runId = checkoutResponse.json('runId') as String; + final orderId = checkoutResponse.json('order.id') as String; + + final orderResponse = await client!.get('/orders/$orderId'); + orderResponse + ..assertStatus(200) + ..assertJsonPath('order.id', orderId); + + final orderPayload = orderResponse.json('order') as Map; + final orderItems = (orderPayload['items'] as List).cast(); + expect(orderItems.first['sku'], 'sku_mug'); + + final runResponse = await client!.get('/runs/$runId'); + runResponse + ..assertStatus(200) + ..assertJsonPath('detail.run.workflow', 'ecommerce.checkout') + ..assertJsonPath('detail.run.status', 'completed'); + }); + + test('ephemeral server mode works with Shelf adapter', () async { + final ephemeral = TestClient.ephemeralServer( + ShelfRequestHandler(app!.handler), + ); + addTearDown(ephemeral.close); + + final health = await ephemeral.get('/health'); + health + ..assertStatus(200) + ..assertJsonPath('status', 'ok'); + }); + + test( + 'add-to-cart workflow validates cart and catalog via database', + () async { + final missingCart = await client!.postJson('/carts/cart-missing/items', { + 'sku': 'sku_tee', + 'quantity': 1, + }); + missingCart + ..assertStatus(422) + ..assertJsonPath('error', 'Add-to-cart workflow did not complete.'); + + final createResponse = await client!.postJson('/carts', { + 'customerId': 'cust-db-checks', + }); + final cartId = createResponse.json('cart.id') as String; + + final unknownSku = await client!.postJson('/carts/$cartId/items', { + 'sku': 'sku_missing', + 'quantity': 1, + }); + unknownSku + ..assertStatus(422) + ..assertJsonPath('error', 'Add-to-cart workflow did not complete.'); + }, + ); +} diff --git a/packages/stem/example/email_service/bin/enqueuer.dart b/packages/stem/example/email_service/bin/enqueuer.dart index 2263bcac..a6169920 100644 --- a/packages/stem/example/email_service/bin/enqueuer.dart +++ b/packages/stem/example/email_service/bin/enqueuer.dart @@ -29,18 +29,17 @@ Future main(List args) async { exit(64); } - final registry = SimpleTaskRegistry() - ..register( - FunctionTaskHandler( - name: 'email.send', - entrypoint: _placeholderEntrypoint, - options: const TaskOptions(queue: 'emails', maxRetries: 3), - ), - ); + final tasks = >[ + FunctionTaskHandler( + name: 'email.send', + entrypoint: _placeholderEntrypoint, + options: const TaskOptions(queue: 'emails', maxRetries: 3), + ), + ]; final stem = Stem( broker: broker, - registry: registry, + tasks: tasks, backend: backend, signer: signer, ); diff --git a/packages/stem/example/email_service/bin/worker.dart b/packages/stem/example/email_service/bin/worker.dart index a6dc5885..7772ff72 100644 --- a/packages/stem/example/email_service/bin/worker.dart +++ b/packages/stem/example/email_service/bin/worker.dart @@ -41,18 +41,17 @@ Future main(List args) async { exit(64); } - final registry = SimpleTaskRegistry() - ..register( - FunctionTaskHandler( - name: 'email.send', - entrypoint: sendEmail, - options: TaskOptions(queue: config.defaultQueue, maxRetries: 3), - ), - ); + final tasks = >[ + FunctionTaskHandler( + name: 'email.send', + entrypoint: sendEmail, + options: TaskOptions(queue: config.defaultQueue, maxRetries: 3), + ), + ]; final worker = Worker( broker: broker, - registry: registry, + tasks: tasks, backend: backend, queue: config.defaultQueue, subscription: subscription, diff --git a/packages/stem/example/encrypted_payload/docker/main.dart b/packages/stem/example/encrypted_payload/docker/main.dart index c226ef27..a933fc25 100644 --- a/packages/stem/example/encrypted_payload/docker/main.dart +++ b/packages/stem/example/encrypted_payload/docker/main.dart @@ -15,16 +15,15 @@ Future main(List args) async { final broker = await RedisStreamsBroker.connect(config.brokerUrl); final backend = await RedisResultBackend.connect(config.resultBackendUrl!); - final registry = SimpleTaskRegistry() - ..register( - FunctionTaskHandler( - name: 'secure.report', - entrypoint: _noopEntrypoint, - options: TaskOptions(queue: config.defaultQueue), - ), - ); + final tasks = >[ + FunctionTaskHandler( + name: 'secure.report', + entrypoint: _noopEntrypoint, + options: TaskOptions(queue: config.defaultQueue), + ), + ]; - final stem = Stem(broker: broker, registry: registry, backend: backend); + final stem = Stem(broker: broker, tasks: tasks, backend: backend); final jobs = [ {'customerId': 'cust-1001', 'amount': 1250.75}, diff --git a/packages/stem/example/encrypted_payload/enqueuer/bin/enqueue.dart b/packages/stem/example/encrypted_payload/enqueuer/bin/enqueue.dart index ba9939b6..62238454 100644 --- a/packages/stem/example/encrypted_payload/enqueuer/bin/enqueue.dart +++ b/packages/stem/example/encrypted_payload/enqueuer/bin/enqueue.dart @@ -23,18 +23,17 @@ Future main(List args) async { } final backend = await RedisResultBackend.connect(backendUrl, tls: config.tls); - final registry = SimpleTaskRegistry() - ..register( - FunctionTaskHandler( - name: 'secure.report', - entrypoint: _noopEntrypoint, - options: TaskOptions(queue: config.defaultQueue), - ), - ); + final tasks = >[ + FunctionTaskHandler( + name: 'secure.report', + entrypoint: _noopEntrypoint, + options: TaskOptions(queue: config.defaultQueue), + ), + ]; final stem = Stem( broker: broker, - registry: registry, + tasks: tasks, backend: backend, signer: PayloadSigner.maybe(config.signing), ); diff --git a/packages/stem/example/encrypted_payload/worker/bin/worker.dart b/packages/stem/example/encrypted_payload/worker/bin/worker.dart index 0203decb..fc91f5ec 100644 --- a/packages/stem/example/encrypted_payload/worker/bin/worker.dart +++ b/packages/stem/example/encrypted_payload/worker/bin/worker.dart @@ -24,23 +24,22 @@ Future main(List args) async { ); final backend = await RedisResultBackend.connect(backendUrl, tls: config.tls); - final registry = SimpleTaskRegistry() - ..register( - FunctionTaskHandler( - name: 'secure.report', - entrypoint: (context, args) => - _encryptedEntrypoint(context, args, cipher, secretKey), - options: TaskOptions( - queue: config.defaultQueue, - maxRetries: 5, - softTimeLimit: const Duration(seconds: 5), - ), + final tasks = >[ + FunctionTaskHandler( + name: 'secure.report', + entrypoint: (context, args) => + _encryptedEntrypoint(context, args, cipher, secretKey), + options: TaskOptions( + queue: config.defaultQueue, + maxRetries: 5, + softTimeLimit: const Duration(seconds: 5), ), - ); + ), + ]; final worker = Worker( broker: broker, - registry: registry, + tasks: tasks, backend: backend, queue: config.defaultQueue, consumerName: 'encrypted-worker-1', diff --git a/packages/stem/example/image_processor/bin/api.dart b/packages/stem/example/image_processor/bin/api.dart index cd2cf8bb..c60b8df5 100644 --- a/packages/stem/example/image_processor/bin/api.dart +++ b/packages/stem/example/image_processor/bin/api.dart @@ -29,18 +29,17 @@ Future main(List args) async { exit(64); } - final registry = SimpleTaskRegistry() - ..register( - FunctionTaskHandler( - name: 'image.generate_thumbnail', - entrypoint: _placeholderEntrypoint, - options: const TaskOptions(queue: 'images', maxRetries: 2), - ), - ); + final tasks = >[ + FunctionTaskHandler( + name: 'image.generate_thumbnail', + entrypoint: _placeholderEntrypoint, + options: const TaskOptions(queue: 'images', maxRetries: 2), + ), + ]; final stem = Stem( broker: broker, - registry: registry, + tasks: tasks, backend: backend, signer: signer, ); diff --git a/packages/stem/example/image_processor/bin/worker.dart b/packages/stem/example/image_processor/bin/worker.dart index 96d9205e..67bb5fa9 100644 --- a/packages/stem/example/image_processor/bin/worker.dart +++ b/packages/stem/example/image_processor/bin/worker.dart @@ -28,18 +28,17 @@ Future main(List args) async { exit(64); } - final registry = SimpleTaskRegistry() - ..register( - FunctionTaskHandler( - name: 'image.generate_thumbnail', - entrypoint: generateThumbnail, - options: const TaskOptions(queue: 'images', maxRetries: 2), - ), - ); + final tasks = >[ + FunctionTaskHandler( + name: 'image.generate_thumbnail', + entrypoint: generateThumbnail, + options: const TaskOptions(queue: 'images', maxRetries: 2), + ), + ]; final worker = Worker( broker: broker, - registry: registry, + tasks: tasks, backend: backend, concurrency: 2, // Parallel processing signer: signer, diff --git a/packages/stem/example/microservice/enqueuer/bin/main.dart b/packages/stem/example/microservice/enqueuer/bin/main.dart index 20c4a2de..fb868f6d 100644 --- a/packages/stem/example/microservice/enqueuer/bin/main.dart +++ b/packages/stem/example/microservice/enqueuer/bin/main.dart @@ -107,21 +107,20 @@ Future main(List args) async { // #endregion signing-producer-signer final httpContext = _buildHttpSecurityContext(); - final registry = SimpleTaskRegistry(); - for (final spec in _demoTaskSpecs) { - registry.register( - FunctionTaskHandler( - name: spec.name, - entrypoint: _placeholderEntrypoint, - options: TaskOptions(queue: spec.queue, maxRetries: spec.maxRetries), - ), - ); - } + final tasks = _demoTaskSpecs + .map>( + (spec) => FunctionTaskHandler( + name: spec.name, + entrypoint: _placeholderEntrypoint, + options: TaskOptions(queue: spec.queue, maxRetries: spec.maxRetries), + ), + ) + .toList(growable: false); // #region signing-producer-stem final stem = Stem( broker: broker, - registry: registry, + tasks: tasks, backend: backend, signer: signer, ); @@ -129,7 +128,7 @@ Future main(List args) async { final canvas = Canvas( broker: broker, backend: backend, - registry: registry, + tasks: tasks, ); final autoFill = _AutoFillController( stem: stem, diff --git a/packages/stem/example/microservice/worker/bin/worker.dart b/packages/stem/example/microservice/worker/bin/worker.dart index 0f24a19a..ad46ef6f 100644 --- a/packages/stem/example/microservice/worker/bin/worker.dart +++ b/packages/stem/example/microservice/worker/bin/worker.dart @@ -76,25 +76,22 @@ Future main(List args) async { final signer = PayloadSigner.maybe(config.signing); // #endregion signing-worker-signer - final registry = SimpleTaskRegistry(); - for (final spec in _taskSpecs) { + final tasks = _taskSpecs.map>((spec) { final entrypoint = _taskEntrypoints[spec.name]; if (entrypoint == null) { throw StateError('Missing task entrypoint for ${spec.name}'); } - registry.register( - FunctionTaskHandler( - name: spec.name, - entrypoint: entrypoint, - options: TaskOptions( - queue: spec.queue, - maxRetries: spec.maxRetries, - softTimeLimit: spec.softLimit, - hardTimeLimit: spec.hardLimit, - ), + return FunctionTaskHandler( + name: spec.name, + entrypoint: entrypoint, + options: TaskOptions( + queue: spec.queue, + maxRetries: spec.maxRetries, + softTimeLimit: spec.softLimit, + hardTimeLimit: spec.hardLimit, ), ); - } + }).toList(growable: false); final observability = ObservabilityConfig.fromEnvironment(); final configuredWorkerName = Platform.environment['STEM_WORKER_NAME']?.trim(); @@ -110,7 +107,7 @@ Future main(List args) async { // #region signing-worker-wire final worker = Worker( broker: broker, - registry: registry, + tasks: tasks, backend: backend, queue: queue, consumerName: resolvedWorkerName, diff --git a/packages/stem/example/mixed_cluster/enqueuer/bin/enqueue.dart b/packages/stem/example/mixed_cluster/enqueuer/bin/enqueue.dart index 89ca51b2..e5c53258 100644 --- a/packages/stem/example/mixed_cluster/enqueuer/bin/enqueue.dart +++ b/packages/stem/example/mixed_cluster/enqueuer/bin/enqueue.dart @@ -63,18 +63,17 @@ Future _buildRedisStem(StemConfig config) async { } final backend = await RedisResultBackend.connect(backendUrl, tls: config.tls); - final registry = SimpleTaskRegistry() - ..register( - FunctionTaskHandler( - name: 'redis.only', - entrypoint: _noopEntrypoint, - options: TaskOptions(queue: config.defaultQueue), - ), - ); + final tasks = >[ + FunctionTaskHandler( + name: 'redis.only', + entrypoint: _noopEntrypoint, + options: TaskOptions(queue: config.defaultQueue), + ), + ]; return Stem( broker: broker, - registry: registry, + tasks: tasks, backend: backend, signer: PayloadSigner.maybe(config.signing), ); @@ -94,18 +93,17 @@ Future _buildPostgresStem(StemConfig config) async { connectionString: backendUrl, ); - final registry = SimpleTaskRegistry() - ..register( - FunctionTaskHandler( - name: 'postgres.only', - entrypoint: _noopEntrypoint, - options: TaskOptions(queue: config.defaultQueue), - ), - ); + final tasks = >[ + FunctionTaskHandler( + name: 'postgres.only', + entrypoint: _noopEntrypoint, + options: TaskOptions(queue: config.defaultQueue), + ), + ]; return Stem( broker: broker, - registry: registry, + tasks: tasks, backend: backend, signer: PayloadSigner.maybe(config.signing), ); diff --git a/packages/stem/example/mixed_cluster/postgres_worker/bin/worker.dart b/packages/stem/example/mixed_cluster/postgres_worker/bin/worker.dart index 90c403e6..789e4d9e 100644 --- a/packages/stem/example/mixed_cluster/postgres_worker/bin/worker.dart +++ b/packages/stem/example/mixed_cluster/postgres_worker/bin/worker.dart @@ -23,23 +23,22 @@ Future main(List args) async { connectionString: backendUrl, ); - final registry = SimpleTaskRegistry() - ..register( - FunctionTaskHandler( - name: 'postgres.only', - entrypoint: _postgresEntrypoint, - options: TaskOptions( - queue: config.defaultQueue, - maxRetries: 3, - softTimeLimit: const Duration(seconds: 5), - hardTimeLimit: const Duration(seconds: 12), - ), + final tasks = >[ + FunctionTaskHandler( + name: 'postgres.only', + entrypoint: _postgresEntrypoint, + options: TaskOptions( + queue: config.defaultQueue, + maxRetries: 3, + softTimeLimit: const Duration(seconds: 5), + hardTimeLimit: const Duration(seconds: 12), ), - ); + ), + ]; final worker = Worker( broker: broker, - registry: registry, + tasks: tasks, backend: backend, queue: config.defaultQueue, consumerName: 'postgres-worker-1', diff --git a/packages/stem/example/mixed_cluster/redis_worker/bin/worker.dart b/packages/stem/example/mixed_cluster/redis_worker/bin/worker.dart index 1796a68a..33078c2d 100644 --- a/packages/stem/example/mixed_cluster/redis_worker/bin/worker.dart +++ b/packages/stem/example/mixed_cluster/redis_worker/bin/worker.dart @@ -20,23 +20,22 @@ Future main(List args) async { ); final backend = await RedisResultBackend.connect(backendUrl, tls: config.tls); - final registry = SimpleTaskRegistry() - ..register( - FunctionTaskHandler( - name: 'redis.only', - entrypoint: _redisEntrypoint, - options: TaskOptions( - queue: config.defaultQueue, - maxRetries: 5, - softTimeLimit: const Duration(seconds: 5), - hardTimeLimit: const Duration(seconds: 10), - ), + final tasks = >[ + FunctionTaskHandler( + name: 'redis.only', + entrypoint: _redisEntrypoint, + options: TaskOptions( + queue: config.defaultQueue, + maxRetries: 5, + softTimeLimit: const Duration(seconds: 5), + hardTimeLimit: const Duration(seconds: 10), ), - ); + ), + ]; final worker = Worker( broker: broker, - registry: registry, + tasks: tasks, backend: backend, queue: config.defaultQueue, consumerName: 'redis-worker-1', diff --git a/packages/stem/example/monolith_service/bin/service.dart b/packages/stem/example/monolith_service/bin/service.dart index 229d4658..9d9698d4 100644 --- a/packages/stem/example/monolith_service/bin/service.dart +++ b/packages/stem/example/monolith_service/bin/service.dart @@ -11,28 +11,27 @@ Future main(List args) async { final port = int.tryParse(Platform.environment['PORT'] ?? '8080') ?? 8080; final broker = InMemoryBroker(); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry() - ..register( - FunctionTaskHandler( - name: 'greeting.send', - entrypoint: _greetingEntrypoint, - options: const TaskOptions( - maxRetries: 3, - softTimeLimit: Duration(seconds: 5), - hardTimeLimit: Duration(seconds: 8), - ), + final tasks = >[ + FunctionTaskHandler( + name: 'greeting.send', + entrypoint: _greetingEntrypoint, + options: const TaskOptions( + maxRetries: 3, + softTimeLimit: Duration(seconds: 5), + hardTimeLimit: Duration(seconds: 8), ), - ); + ), + ]; - final stem = Stem(broker: broker, registry: registry, backend: backend); + final stem = Stem(broker: broker, tasks: tasks, backend: backend); final canvas = Canvas( broker: broker, backend: backend, - registry: registry, + tasks: tasks, ); final worker = Worker( broker: broker, - registry: registry, + tasks: tasks, backend: backend, consumerName: 'monolith-worker', concurrency: 2, diff --git a/packages/stem/example/ops_health_suite/bin/producer.dart b/packages/stem/example/ops_health_suite/bin/producer.dart index 6a30bee9..8de24d63 100644 --- a/packages/stem/example/ops_health_suite/bin/producer.dart +++ b/packages/stem/example/ops_health_suite/bin/producer.dart @@ -8,7 +8,7 @@ Future main() async { final broker = await connectBroker(config.brokerUrl, tls: config.tls); final backendUrl = config.resultBackendUrl ?? config.brokerUrl; final backend = await connectBackend(backendUrl, tls: config.tls); - final registry = buildRegistry(); + final tasks = buildTasks(); final taskCount = _parseInt('TASKS', fallback: 6, min: 1); final delayMs = _parseInt('DELAY_MS', fallback: 400, min: 0); @@ -17,7 +17,7 @@ Future main() async { '[producer] broker=${config.brokerUrl} backend=$backendUrl tasks=$taskCount', ); - final stem = Stem(broker: broker, registry: registry, backend: backend); + final stem = Stem(broker: broker, tasks: tasks, backend: backend); const options = TaskOptions(queue: opsQueue); for (var i = 0; i < taskCount; i += 1) { diff --git a/packages/stem/example/ops_health_suite/bin/worker.dart b/packages/stem/example/ops_health_suite/bin/worker.dart index b14b63d8..bde7ed77 100644 --- a/packages/stem/example/ops_health_suite/bin/worker.dart +++ b/packages/stem/example/ops_health_suite/bin/worker.dart @@ -10,12 +10,12 @@ Future main() async { final backendUrl = config.resultBackendUrl ?? config.brokerUrl; final backend = await connectBackend(backendUrl, tls: config.tls); - final registry = buildRegistry(); + final tasks = buildTasks(); final observability = ObservabilityConfig.fromEnvironment(); final worker = Worker( broker: broker, - registry: registry, + tasks: tasks, backend: backend, queue: opsQueue, subscription: RoutingSubscription.singleQueue(opsQueue), diff --git a/packages/stem/example/ops_health_suite/lib/shared.dart b/packages/stem/example/ops_health_suite/lib/shared.dart index dc41520d..fed85c60 100644 --- a/packages/stem/example/ops_health_suite/lib/shared.dart +++ b/packages/stem/example/ops_health_suite/lib/shared.dart @@ -14,17 +14,13 @@ Future connectBackend(String url, {TlsConfig? tls}) { return RedisResultBackend.connect(url, tls: tls); } -SimpleTaskRegistry buildRegistry() { - final registry = SimpleTaskRegistry(); - registry.register( - FunctionTaskHandler( - name: 'ops.ping', - options: const TaskOptions(queue: opsQueue), - entrypoint: _opsEntrypoint, - ), - ); - return registry; -} +List> buildTasks() => [ + FunctionTaskHandler( + name: 'ops.ping', + options: const TaskOptions(queue: opsQueue), + entrypoint: _opsEntrypoint, + ), + ]; FutureOr _opsEntrypoint( TaskInvocationContext context, diff --git a/packages/stem/example/otel_metrics/bin/worker.dart b/packages/stem/example/otel_metrics/bin/worker.dart index f99db820..36974825 100644 --- a/packages/stem/example/otel_metrics/bin/worker.dart +++ b/packages/stem/example/otel_metrics/bin/worker.dart @@ -4,18 +4,17 @@ import 'dart:io'; import 'package:stem/stem.dart'; Future main() async { - final registry = SimpleTaskRegistry() - ..register( - FunctionTaskHandler( - name: 'metrics.ping', - entrypoint: (context, _) async { - // Simulate a bit of work. - await Future.delayed(const Duration(milliseconds: 150)); - context.progress(1.0); - return null; - }, - ), - ); + final tasks = >[ + FunctionTaskHandler( + name: 'metrics.ping', + entrypoint: (context, _) async { + // Simulate a bit of work. + await Future.delayed(const Duration(milliseconds: 150)); + context.progress(1.0); + return null; + }, + ), + ]; final broker = InMemoryBroker(); final backend = InMemoryResultBackend(); @@ -31,14 +30,14 @@ Future main() async { final worker = Worker( broker: broker, - registry: registry, + tasks: tasks, backend: backend, consumerName: 'otel-demo-worker', observability: observability, heartbeatTransport: const NoopHeartbeatTransport(), ); - final stem = Stem(broker: broker, registry: registry, backend: backend); + final stem = Stem(broker: broker, tasks: tasks, backend: backend); await worker.start(); print( diff --git a/packages/stem/example/postgres_tls/bin/enqueue.dart b/packages/stem/example/postgres_tls/bin/enqueue.dart index 7929194c..ac4284f1 100644 --- a/packages/stem/example/postgres_tls/bin/enqueue.dart +++ b/packages/stem/example/postgres_tls/bin/enqueue.dart @@ -24,18 +24,17 @@ Future main(List args) async { connectionString: backendUrl, ); - final registry = SimpleTaskRegistry() - ..register( - FunctionTaskHandler( - name: 'reports.generate', - entrypoint: _noop, - options: TaskOptions(queue: config.defaultQueue), - ), - ); + final tasks = >[ + FunctionTaskHandler( + name: 'reports.generate', + entrypoint: _noop, + options: TaskOptions(queue: config.defaultQueue), + ), + ]; final stem = Stem( broker: broker, - registry: registry, + tasks: tasks, backend: backend, signer: PayloadSigner.maybe(config.signing), ); diff --git a/packages/stem/example/postgres_tls/bin/worker.dart b/packages/stem/example/postgres_tls/bin/worker.dart index 7e76dddb..f59803fd 100644 --- a/packages/stem/example/postgres_tls/bin/worker.dart +++ b/packages/stem/example/postgres_tls/bin/worker.dart @@ -24,22 +24,21 @@ Future main(List args) async { connectionString: backendUrl, ); - final registry = SimpleTaskRegistry() - ..register( - FunctionTaskHandler( - name: 'reports.generate', - entrypoint: _reportEntrypoint, - options: TaskOptions( - queue: config.defaultQueue, - maxRetries: 3, - visibilityTimeout: const Duration(seconds: 30), - ), + final tasks = >[ + FunctionTaskHandler( + name: 'reports.generate', + entrypoint: _reportEntrypoint, + options: TaskOptions( + queue: config.defaultQueue, + maxRetries: 3, + visibilityTimeout: const Duration(seconds: 30), ), - ); + ), + ]; final worker = Worker( broker: broker, - registry: registry, + tasks: tasks, backend: backend, queue: config.defaultQueue, consumerName: 'postgres-tls-worker', diff --git a/packages/stem/example/postgres_worker/enqueuer/bin/enqueue.dart b/packages/stem/example/postgres_worker/enqueuer/bin/enqueue.dart index 6190013a..a15e9169 100644 --- a/packages/stem/example/postgres_worker/enqueuer/bin/enqueue.dart +++ b/packages/stem/example/postgres_worker/enqueuer/bin/enqueue.dart @@ -24,18 +24,17 @@ Future main(List args) async { connectionString: backendUrl, ); - final registry = SimpleTaskRegistry() - ..register( - FunctionTaskHandler( - name: 'report.generate', - entrypoint: _noopEntrypoint, - options: TaskOptions(queue: config.defaultQueue), - ), - ); + final tasks = >[ + FunctionTaskHandler( + name: 'report.generate', + entrypoint: _noopEntrypoint, + options: TaskOptions(queue: config.defaultQueue), + ), + ]; final stem = Stem( broker: broker, - registry: registry, + tasks: tasks, backend: backend, signer: PayloadSigner.maybe(config.signing), ); diff --git a/packages/stem/example/postgres_worker/worker/bin/worker.dart b/packages/stem/example/postgres_worker/worker/bin/worker.dart index dba1bca9..4d341fd9 100644 --- a/packages/stem/example/postgres_worker/worker/bin/worker.dart +++ b/packages/stem/example/postgres_worker/worker/bin/worker.dart @@ -24,23 +24,22 @@ Future main(List args) async { connectionString: backendUrl, ); - final registry = SimpleTaskRegistry() - ..register( - FunctionTaskHandler( - name: 'report.generate', - entrypoint: _reportEntrypoint, - options: TaskOptions( - queue: config.defaultQueue, - maxRetries: 3, - softTimeLimit: const Duration(seconds: 5), - hardTimeLimit: const Duration(seconds: 10), - ), + final tasks = >[ + FunctionTaskHandler( + name: 'report.generate', + entrypoint: _reportEntrypoint, + options: TaskOptions( + queue: config.defaultQueue, + maxRetries: 3, + softTimeLimit: const Duration(seconds: 5), + hardTimeLimit: const Duration(seconds: 10), ), - ); + ), + ]; final worker = Worker( broker: broker, - registry: registry, + tasks: tasks, backend: backend, queue: config.defaultQueue, consumerName: 'postgres-worker-1', diff --git a/packages/stem/example/progress_heartbeat/bin/producer.dart b/packages/stem/example/progress_heartbeat/bin/producer.dart index bee81327..7a235f09 100644 --- a/packages/stem/example/progress_heartbeat/bin/producer.dart +++ b/packages/stem/example/progress_heartbeat/bin/producer.dart @@ -19,11 +19,11 @@ Future main() async { final broker = await connectBroker(brokerUrl); final backend = await connectBackend(backendUrl); - final registry = buildRegistry(); + final tasks = buildTasks(); final stem = Stem( broker: broker, - registry: registry, + tasks: tasks, backend: backend, ); const taskOptions = TaskOptions(queue: progressQueue); diff --git a/packages/stem/example/progress_heartbeat/bin/worker.dart b/packages/stem/example/progress_heartbeat/bin/worker.dart index aa7aa330..1a7ade3f 100644 --- a/packages/stem/example/progress_heartbeat/bin/worker.dart +++ b/packages/stem/example/progress_heartbeat/bin/worker.dart @@ -15,12 +15,12 @@ Future main() async { final broker = await connectBroker(brokerUrl); final backend = await connectBackend(backendUrl); - final registry = buildRegistry(); + final tasks = buildTasks(); // #region reliability-heartbeat-worker final worker = Worker( broker: broker, - registry: registry, + tasks: tasks, backend: backend, queue: progressQueue, subscription: RoutingSubscription.singleQueue(progressQueue), diff --git a/packages/stem/example/progress_heartbeat/lib/shared.dart b/packages/stem/example/progress_heartbeat/lib/shared.dart index d3bf4f22..cd7508bb 100644 --- a/packages/stem/example/progress_heartbeat/lib/shared.dart +++ b/packages/stem/example/progress_heartbeat/lib/shared.dart @@ -14,11 +14,7 @@ Future connectBackend(String url) { return RedisResultBackend.connect(url); } -SimpleTaskRegistry buildRegistry() { - final registry = SimpleTaskRegistry(); - registry.register(ProgressTask()); - return registry; -} +List> buildTasks() => [ProgressTask()]; // #region reliability-worker-event-logging void attachWorkerEventLogging(Worker worker) { diff --git a/packages/stem/example/rate_limit_delay/bin/producer.dart b/packages/stem/example/rate_limit_delay/bin/producer.dart index baaa8608..0262338f 100644 --- a/packages/stem/example/rate_limit_delay/bin/producer.dart +++ b/packages/stem/example/rate_limit_delay/bin/producer.dart @@ -13,11 +13,11 @@ Future main() async { final broker = await connectBroker(brokerUrl); final backend = await connectBackend(backendUrl); - final registry = buildRegistry(); + final tasks = buildTasks(); final routing = buildRoutingRegistry(); final stem = buildStem( broker: broker, - registry: registry, + tasks: tasks, backend: backend, routing: routing, ); diff --git a/packages/stem/example/rate_limit_delay/bin/worker.dart b/packages/stem/example/rate_limit_delay/bin/worker.dart index 6c095042..90b0e3ae 100644 --- a/packages/stem/example/rate_limit_delay/bin/worker.dart +++ b/packages/stem/example/rate_limit_delay/bin/worker.dart @@ -17,13 +17,13 @@ Future main() async { final broker = await connectBroker(brokerUrl); final backend = await connectBackend(backendUrl); final rateLimiter = await connectRateLimiter(rateUrl); - final registry = buildRegistry(); + final tasks = buildTasks(); final subscriptions = attachSignalLogging(); // #region rate-limit-worker final worker = Worker( broker: broker, - registry: registry, + tasks: tasks, backend: backend, rateLimiter: rateLimiter, queue: 'throttled', diff --git a/packages/stem/example/rate_limit_delay/lib/shared.dart b/packages/stem/example/rate_limit_delay/lib/shared.dart index f4a43166..c7cfa765 100644 --- a/packages/stem/example/rate_limit_delay/lib/shared.dart +++ b/packages/stem/example/rate_limit_delay/lib/shared.dart @@ -9,23 +9,22 @@ import 'rate_limiter.dart'; const _taskName = 'demo.throttled.render'; -SimpleTaskRegistry buildRegistry() { +List> buildTasks() { // #region rate-limit-task-options - final registry = SimpleTaskRegistry() - ..register( - FunctionTaskHandler( - name: _taskName, - options: const TaskOptions( - queue: 'throttled', - maxRetries: 0, - visibilityTimeout: Duration(seconds: 60), - rateLimit: '3/s', - ), - entrypoint: _renderEntrypoint, + final tasks = >[ + FunctionTaskHandler( + name: _taskName, + options: const TaskOptions( + queue: 'throttled', + maxRetries: 0, + visibilityTimeout: Duration(seconds: 60), + rateLimit: '3/s', ), - ); + entrypoint: _renderEntrypoint, + ), + ]; // #endregion rate-limit-task-options - return registry; + return tasks; } RoutingRegistry buildRoutingRegistry() { @@ -42,13 +41,13 @@ RoutingRegistry buildRoutingRegistry() { Stem buildStem({ required Broker broker, - required TaskRegistry registry, + required Iterable> tasks, ResultBackend? backend, RoutingRegistry? routing, }) { return Stem( broker: broker, - registry: registry, + tasks: tasks, backend: backend, routing: routing, ); diff --git a/packages/stem/example/redis_postgres_worker/enqueuer/bin/enqueue.dart b/packages/stem/example/redis_postgres_worker/enqueuer/bin/enqueue.dart index d2923fd0..ef8ea10c 100644 --- a/packages/stem/example/redis_postgres_worker/enqueuer/bin/enqueue.dart +++ b/packages/stem/example/redis_postgres_worker/enqueuer/bin/enqueue.dart @@ -24,18 +24,17 @@ Future main(List args) async { connectionString: backendUrl, ); - final registry = SimpleTaskRegistry() - ..register( - FunctionTaskHandler( - name: 'hybrid.process', - entrypoint: _noop, - options: TaskOptions(queue: config.defaultQueue), - ), - ); + final tasks = >[ + FunctionTaskHandler( + name: 'hybrid.process', + entrypoint: _noop, + options: TaskOptions(queue: config.defaultQueue), + ), + ]; final stem = Stem( broker: broker, - registry: registry, + tasks: tasks, backend: backend, signer: PayloadSigner.maybe(config.signing), ); diff --git a/packages/stem/example/redis_postgres_worker/worker/bin/worker.dart b/packages/stem/example/redis_postgres_worker/worker/bin/worker.dart index 4bf29863..90258f29 100644 --- a/packages/stem/example/redis_postgres_worker/worker/bin/worker.dart +++ b/packages/stem/example/redis_postgres_worker/worker/bin/worker.dart @@ -24,23 +24,22 @@ Future main(List args) async { connectionString: backendUrl, ); - final registry = SimpleTaskRegistry() - ..register( - FunctionTaskHandler( - name: 'hybrid.process', - entrypoint: _hybridEntrypoint, - options: TaskOptions( - queue: config.defaultQueue, - maxRetries: 3, - softTimeLimit: const Duration(seconds: 5), - hardTimeLimit: const Duration(seconds: 10), - ), + final tasks = >[ + FunctionTaskHandler( + name: 'hybrid.process', + entrypoint: _hybridEntrypoint, + options: TaskOptions( + queue: config.defaultQueue, + maxRetries: 3, + softTimeLimit: const Duration(seconds: 5), + hardTimeLimit: const Duration(seconds: 10), ), - ); + ), + ]; final worker = Worker( broker: broker, - registry: registry, + tasks: tasks, backend: backend, queue: config.defaultQueue, consumerName: 'redis-postgres-worker-1', diff --git a/packages/stem/example/retry_task/bin/producer.dart b/packages/stem/example/retry_task/bin/producer.dart index 30329dd6..f05e6549 100644 --- a/packages/stem/example/retry_task/bin/producer.dart +++ b/packages/stem/example/retry_task/bin/producer.dart @@ -10,9 +10,9 @@ Future main() async { Platform.environment['STEM_BROKER_URL'] ?? 'redis://redis:6379/0'; final broker = await RedisStreamsBroker.connect(brokerUrl); - final registry = buildRegistry(); + final tasks = buildTasks(); final subscriptions = attachLogging('producer'); - final stem = Stem(broker: broker, registry: registry); + final stem = Stem(broker: broker, tasks: tasks); final taskId = await stem.enqueue( 'tasks.always_fail', diff --git a/packages/stem/example/retry_task/bin/worker.dart b/packages/stem/example/retry_task/bin/worker.dart index 812df0a9..c42c3a71 100644 --- a/packages/stem/example/retry_task/bin/worker.dart +++ b/packages/stem/example/retry_task/bin/worker.dart @@ -16,13 +16,13 @@ Future main() async { claimInterval: const Duration(milliseconds: 200), defaultVisibilityTimeout: const Duration(seconds: 2), ); - final registry = buildRegistry(); + final tasks = buildTasks(); final backend = InMemoryResultBackend(); // #region reliability-retry-worker final worker = Worker( broker: broker, - registry: registry, + tasks: tasks, backend: backend, queue: 'retry-demo', consumerName: workerName, diff --git a/packages/stem/example/retry_task/lib/shared.dart b/packages/stem/example/retry_task/lib/shared.dart index 3678bf51..dbfb67f1 100644 --- a/packages/stem/example/retry_task/lib/shared.dart +++ b/packages/stem/example/retry_task/lib/shared.dart @@ -4,17 +4,13 @@ import 'dart:convert'; import 'package:stem/stem.dart'; // #region reliability-retry-registry -SimpleTaskRegistry buildRegistry() { - final registry = SimpleTaskRegistry() - ..register( +List> buildTasks() => [ FunctionTaskHandler( name: 'tasks.always_fail', entrypoint: _alwaysFailEntrypoint, options: const TaskOptions(maxRetries: 2, queue: 'retry-demo'), ), - ); - return registry; -} + ]; // #endregion reliability-retry-registry // #region reliability-retry-signals diff --git a/packages/stem/example/routing_parity/bin/publisher.dart b/packages/stem/example/routing_parity/bin/publisher.dart index 11437155..d41f78aa 100644 --- a/packages/stem/example/routing_parity/bin/publisher.dart +++ b/packages/stem/example/routing_parity/bin/publisher.dart @@ -9,7 +9,7 @@ Future main() async { 'redis://localhost:6379/0'; final routing = buildRoutingRegistry(); - final registry = buildDemoTaskRegistry(); + final tasks = buildDemoTasks(); final broker = await RedisStreamsBroker.connect( redisUrl, @@ -18,7 +18,7 @@ Future main() async { final stem = Stem( broker: broker, - registry: registry, + tasks: tasks, routing: routing, ); diff --git a/packages/stem/example/routing_parity/bin/worker.dart b/packages/stem/example/routing_parity/bin/worker.dart index e67cd1e0..817a20d9 100644 --- a/packages/stem/example/routing_parity/bin/worker.dart +++ b/packages/stem/example/routing_parity/bin/worker.dart @@ -9,7 +9,7 @@ Future main() async { 'redis://localhost:6379/0'; final routing = buildRoutingRegistry(); - final registry = buildDemoTaskRegistry(); + final tasks = buildDemoTasks(); final broker = await RedisStreamsBroker.connect( redisUrl, @@ -24,7 +24,7 @@ Future main() async { final worker = Worker( broker: broker, - registry: registry, + tasks: tasks, backend: backend, queue: 'standard', subscription: subscription, diff --git a/packages/stem/example/routing_parity/lib/routing_demo.dart b/packages/stem/example/routing_parity/lib/routing_demo.dart index 394fdf4a..215107f2 100644 --- a/packages/stem/example/routing_parity/lib/routing_demo.dart +++ b/packages/stem/example/routing_parity/lib/routing_demo.dart @@ -34,30 +34,23 @@ routes: RoutingRegistry buildRoutingRegistry() => RoutingRegistry(RoutingConfig.fromYaml(_demoRoutingYaml)); -SimpleTaskRegistry buildDemoTaskRegistry() { - return SimpleTaskRegistry() - ..register( +List> buildDemoTasks() => [ FunctionTaskHandler( name: 'billing.invoice', entrypoint: _processInvoice, options: const TaskOptions(queue: 'standard', maxRetries: 2), ), - ) - ..register( FunctionTaskHandler( name: 'reports.generate', entrypoint: _processReport, options: const TaskOptions(queue: 'critical', maxRetries: 3), ), - ) - ..register( FunctionTaskHandler( name: 'ops.status', entrypoint: _handleBroadcast, options: const TaskOptions(queue: 'standard'), ), - ); -} + ]; Future _processInvoice( TaskInvocationContext context, diff --git a/packages/stem/example/scheduler_observability/bin/worker.dart b/packages/stem/example/scheduler_observability/bin/worker.dart index 8772811a..99bc708a 100644 --- a/packages/stem/example/scheduler_observability/bin/worker.dart +++ b/packages/stem/example/scheduler_observability/bin/worker.dart @@ -10,12 +10,12 @@ Future main() async { final backendUrl = config.resultBackendUrl ?? config.brokerUrl; final backend = await connectBackend(backendUrl, tls: config.tls); - final registry = buildRegistry(); + final tasks = buildTasks(); final observability = ObservabilityConfig.fromEnvironment(); final worker = Worker( broker: broker, - registry: registry, + tasks: tasks, backend: backend, queue: scheduleQueue, subscription: RoutingSubscription.singleQueue(scheduleQueue), diff --git a/packages/stem/example/scheduler_observability/lib/shared.dart b/packages/stem/example/scheduler_observability/lib/shared.dart index 53f44b70..9e1bd54f 100644 --- a/packages/stem/example/scheduler_observability/lib/shared.dart +++ b/packages/stem/example/scheduler_observability/lib/shared.dart @@ -14,17 +14,13 @@ Future connectBackend(String url, {TlsConfig? tls}) { return RedisResultBackend.connect(url, tls: tls); } -SimpleTaskRegistry buildRegistry() { - final registry = SimpleTaskRegistry(); - registry.register( - FunctionTaskHandler( - name: 'scheduler.demo', - options: const TaskOptions(queue: scheduleQueue), - entrypoint: _scheduledEntrypoint, - ), - ); - return registry; -} +List> buildTasks() => [ + FunctionTaskHandler( + name: 'scheduler.demo', + options: const TaskOptions(queue: scheduleQueue), + entrypoint: _scheduledEntrypoint, + ), + ]; FutureOr _scheduledEntrypoint( TaskInvocationContext context, diff --git a/packages/stem/example/signals_demo/bin/producer.dart b/packages/stem/example/signals_demo/bin/producer.dart index 1c3eb528..abd7ce9a 100644 --- a/packages/stem/example/signals_demo/bin/producer.dart +++ b/packages/stem/example/signals_demo/bin/producer.dart @@ -13,10 +13,10 @@ Future main() async { registerSignalLogging('producer'); final broker = await RedisStreamsBroker.connect(brokerUrl); - final registry = buildRegistry(); + final tasks = buildTasks(); final stem = Stem( broker: broker, - registry: registry, + tasks: tasks, backend: InMemoryResultBackend(), ); diff --git a/packages/stem/example/signals_demo/bin/worker.dart b/packages/stem/example/signals_demo/bin/worker.dart index 9bdab92f..58a3b3db 100644 --- a/packages/stem/example/signals_demo/bin/worker.dart +++ b/packages/stem/example/signals_demo/bin/worker.dart @@ -15,12 +15,12 @@ Future main() async { registerSignalLogging('worker'); final broker = await RedisStreamsBroker.connect(brokerUrl); - final registry = buildRegistry(); + final tasks = buildTasks(); final backend = InMemoryResultBackend(); final worker = Worker( broker: broker, - registry: registry, + tasks: tasks, backend: backend, queue: 'default', consumerName: workerName, diff --git a/packages/stem/example/signals_demo/lib/shared.dart b/packages/stem/example/signals_demo/lib/shared.dart index 2f308fa2..39b3c4db 100644 --- a/packages/stem/example/signals_demo/lib/shared.dart +++ b/packages/stem/example/signals_demo/lib/shared.dart @@ -3,31 +3,23 @@ import 'dart:convert'; import 'package:stem/stem.dart'; -SimpleTaskRegistry buildRegistry() { - final registry = SimpleTaskRegistry() - ..register( +List> buildTasks() => [ FunctionTaskHandler( name: 'tasks.hello', entrypoint: _helloEntrypoint, options: const TaskOptions(maxRetries: 0), ), - ) - ..register( FunctionTaskHandler( name: 'tasks.flaky', entrypoint: _flakyEntrypoint, options: const TaskOptions(maxRetries: 2), ), - ) - ..register( FunctionTaskHandler( name: 'tasks.always_fail', entrypoint: _alwaysFailEntrypoint, options: const TaskOptions(maxRetries: 1), ), - ); - return registry; -} + ]; List registerSignalLogging(String label) { String prefix(String event) => '[signals][$label][$event]'; diff --git a/packages/stem/example/signing_key_rotation/bin/producer.dart b/packages/stem/example/signing_key_rotation/bin/producer.dart index 2e545660..4f95af13 100644 --- a/packages/stem/example/signing_key_rotation/bin/producer.dart +++ b/packages/stem/example/signing_key_rotation/bin/producer.dart @@ -14,11 +14,11 @@ Future main() async { final signer = PayloadSigner.maybe(config.signing); // #endregion signing-rotation-producer-signer - final registry = buildRegistry(); + final tasks = buildTasks(); // #region signing-rotation-producer-stem final stem = Stem( broker: broker, - registry: registry, + tasks: tasks, backend: backend, signer: signer, ); diff --git a/packages/stem/example/signing_key_rotation/bin/worker.dart b/packages/stem/example/signing_key_rotation/bin/worker.dart index 892acf71..83d4fd83 100644 --- a/packages/stem/example/signing_key_rotation/bin/worker.dart +++ b/packages/stem/example/signing_key_rotation/bin/worker.dart @@ -15,12 +15,12 @@ Future main() async { final signer = PayloadSigner.maybe(config.signing); // #endregion signing-rotation-worker-signer - final registry = buildRegistry(); + final tasks = buildTasks(); // #region signing-rotation-worker-start final worker = Worker( broker: broker, - registry: registry, + tasks: tasks, backend: backend, queue: rotationQueue, subscription: RoutingSubscription.singleQueue(rotationQueue), diff --git a/packages/stem/example/signing_key_rotation/lib/shared.dart b/packages/stem/example/signing_key_rotation/lib/shared.dart index 764a0d68..f6a8082c 100644 --- a/packages/stem/example/signing_key_rotation/lib/shared.dart +++ b/packages/stem/example/signing_key_rotation/lib/shared.dart @@ -14,17 +14,13 @@ Future connectBackend(String url, {TlsConfig? tls}) { return RedisResultBackend.connect(url, tls: tls); } -SimpleTaskRegistry buildRegistry() { - final registry = SimpleTaskRegistry(); - registry.register( - FunctionTaskHandler( - name: 'rotation.demo', - options: const TaskOptions(queue: rotationQueue), - entrypoint: _rotationEntrypoint, - ), - ); - return registry; -} +List> buildTasks() => [ + FunctionTaskHandler( + name: 'rotation.demo', + options: const TaskOptions(queue: rotationQueue), + entrypoint: _rotationEntrypoint, + ), + ]; FutureOr _rotationEntrypoint( TaskInvocationContext context, diff --git a/packages/stem/example/stem_example.dart b/packages/stem/example/stem_example.dart index 3aece1b2..1edd6285 100644 --- a/packages/stem/example/stem_example.dart +++ b/packages/stem/example/stem_example.dart @@ -47,12 +47,15 @@ class HelloArgs { Future main() async { // #region getting-started-runtime-setup - final registry = SimpleTaskRegistry()..register(HelloTask()); final broker = await RedisStreamsBroker.connect('redis://localhost:6379'); final backend = await RedisResultBackend.connect('redis://localhost:6379/1'); - final stem = Stem(broker: broker, registry: registry, backend: backend); - final worker = Worker(broker: broker, registry: registry, backend: backend); + final stem = Stem(broker: broker, backend: backend, tasks: [HelloTask()]); + final worker = Worker( + broker: broker, + backend: backend, + tasks: [HelloTask()], + ); // #endregion getting-started-runtime-setup // #region getting-started-enqueue diff --git a/packages/stem/example/task_context_mixed/bin/enqueue.dart b/packages/stem/example/task_context_mixed/bin/enqueue.dart index f7e1c8dd..db625577 100644 --- a/packages/stem/example/task_context_mixed/bin/enqueue.dart +++ b/packages/stem/example/task_context_mixed/bin/enqueue.dart @@ -5,8 +5,8 @@ import 'package:stem_task_context_mixed_example/shared.dart'; Future main(List args) async { final broker = await connectBroker(); - final registry = buildRegistry(); - final stem = Stem(broker: broker, registry: registry); + final tasks = buildTasks(); + final stem = Stem(broker: broker, tasks: tasks); final forceFail = args.contains('--fail'); final overwrite = args.contains('--overwrite'); diff --git a/packages/stem/example/task_context_mixed/bin/worker.dart b/packages/stem/example/task_context_mixed/bin/worker.dart index 15e6a5d8..ade492a3 100644 --- a/packages/stem/example/task_context_mixed/bin/worker.dart +++ b/packages/stem/example/task_context_mixed/bin/worker.dart @@ -8,11 +8,11 @@ import 'package:stem_sqlite/stem_sqlite.dart'; Future main() async { final broker = await connectBroker(); final backend = await connectBackend(); - final registry = buildRegistry(); + final tasks = buildTasks(); final worker = Worker( broker: broker, - registry: registry, + tasks: tasks, backend: backend, queue: mixedQueue, consumerName: Platform.environment['WORKER_NAME'] ?? 'task-context-worker', diff --git a/packages/stem/example/task_context_mixed/lib/shared.dart b/packages/stem/example/task_context_mixed/lib/shared.dart index b7e370ad..55d28352 100644 --- a/packages/stem/example/task_context_mixed/lib/shared.dart +++ b/packages/stem/example/task_context_mixed/lib/shared.dart @@ -102,10 +102,8 @@ final linkErrorDefinition = TaskDefinition, void>( defaultOptions: const TaskOptions(queue: mixedQueue), ); -SimpleTaskRegistry buildRegistry() { - return SimpleTaskRegistry() - ..register(InlineCoordinatorTask()) - ..register( +List> buildTasks() => [ + InlineCoordinatorTask(), FunctionTaskHandler.inline( name: 'demo.inline_entrypoint', entrypoint: inlineEntrypoint, @@ -115,8 +113,6 @@ SimpleTaskRegistry buildRegistry() { tags: ['task-context', 'inline'], ), ), - ) - ..register( FunctionTaskHandler( name: 'demo.isolate_child', entrypoint: isolateChildEntrypoint, @@ -126,8 +122,6 @@ SimpleTaskRegistry buildRegistry() { tags: ['task-context', 'isolate'], ), ), - ) - ..register( FunctionTaskHandler( name: 'demo.flaky', entrypoint: flakyEntrypoint, @@ -146,32 +140,25 @@ SimpleTaskRegistry buildRegistry() { tags: ['task-context', 'retry'], ), ), - ) - ..register( FunctionTaskHandler.inline( name: auditDefinition.name, entrypoint: auditEntrypoint, options: auditDefinition.defaultOptions, metadata: auditDefinition.metadata, ), - ) - ..register( FunctionTaskHandler.inline( name: linkSuccessDefinition.name, entrypoint: linkSuccessEntrypoint, options: linkSuccessDefinition.defaultOptions, metadata: linkSuccessDefinition.metadata, ), - ) - ..register( FunctionTaskHandler.inline( name: linkErrorDefinition.name, entrypoint: linkErrorEntrypoint, options: linkErrorDefinition.defaultOptions, metadata: linkErrorDefinition.metadata, ), - ); -} + ]; class InlineCoordinatorTask extends TaskHandler { @override diff --git a/packages/stem/example/task_usage_patterns.dart b/packages/stem/example/task_usage_patterns.dart index b7b0175c..b7879ebc 100644 --- a/packages/stem/example/task_usage_patterns.dart +++ b/packages/stem/example/task_usage_patterns.dart @@ -69,33 +69,30 @@ FutureOr invocationParentEntrypoint( } Future main() async { - final registry = SimpleTaskRegistry() - ..register(ParentTask()) - ..register( - FunctionTaskHandler.inline( - name: childDefinition.name, - entrypoint: childEntrypoint, - options: const TaskOptions(queue: 'default'), - metadata: childDefinition.metadata, - ), - ) - ..register( - FunctionTaskHandler.inline( - name: 'tasks.invocation_parent', - entrypoint: invocationParentEntrypoint, - options: const TaskOptions(queue: 'default'), - ), - ); + final tasks = >[ + ParentTask(), + FunctionTaskHandler.inline( + name: childDefinition.name, + entrypoint: childEntrypoint, + options: const TaskOptions(queue: 'default'), + metadata: childDefinition.metadata, + ), + FunctionTaskHandler.inline( + name: 'tasks.invocation_parent', + entrypoint: invocationParentEntrypoint, + options: const TaskOptions(queue: 'default'), + ), + ]; final broker = InMemoryBroker(); final backend = InMemoryResultBackend(); final worker = Worker( broker: broker, - registry: registry, backend: backend, + tasks: tasks, consumerName: 'example-worker', ); - final stem = Stem(broker: broker, registry: registry, backend: backend); + final stem = Stem(broker: broker, backend: backend, tasks: tasks); unawaited(worker.start()); diff --git a/packages/stem/example/unique_tasks/unique_task_example.dart b/packages/stem/example/unique_tasks/unique_task_example.dart index 84e6a4be..d42012ac 100644 --- a/packages/stem/example/unique_tasks/unique_task_example.dart +++ b/packages/stem/example/unique_tasks/unique_task_example.dart @@ -56,19 +56,19 @@ Future main() async { ); // #endregion unique-task-coordinator - final registry = SimpleTaskRegistry()..register(SendDigestTask()); + final tasks = [SendDigestTask()]; // #region unique-task-stem-worker final stem = Stem( broker: broker, - registry: registry, backend: backend, + tasks: tasks, uniqueTaskCoordinator: coordinator, ); final worker = Worker( broker: broker, - registry: registry, backend: backend, + tasks: tasks, uniqueTaskCoordinator: coordinator, queue: 'email', consumerName: 'unique-worker', diff --git a/packages/stem/example/worker_control_lab/bin/producer.dart b/packages/stem/example/worker_control_lab/bin/producer.dart index 8fdbd94a..2f64a78c 100644 --- a/packages/stem/example/worker_control_lab/bin/producer.dart +++ b/packages/stem/example/worker_control_lab/bin/producer.dart @@ -21,11 +21,11 @@ Future main() async { final broker = await connectBroker(brokerUrl); final backend = await connectBackend(backendUrl); - final registry = buildRegistry(); + final tasks = buildTasks(); final stem = Stem( broker: broker, - registry: registry, + tasks: tasks, backend: backend, ); diff --git a/packages/stem/example/worker_control_lab/bin/worker.dart b/packages/stem/example/worker_control_lab/bin/worker.dart index a7a54d5d..ab8505e6 100644 --- a/packages/stem/example/worker_control_lab/bin/worker.dart +++ b/packages/stem/example/worker_control_lab/bin/worker.dart @@ -23,11 +23,11 @@ Future main() async { final broker = await connectBroker(brokerUrl); final backend = await connectBackend(backendUrl); final revokeStore = await connectRevokeStore(revokeUrl); - final registry = buildRegistry(); + final tasks = buildTasks(); final worker = Worker( broker: broker, - registry: registry, + tasks: tasks, backend: backend, revokeStore: revokeStore, queue: controlQueue, diff --git a/packages/stem/example/worker_control_lab/lib/shared.dart b/packages/stem/example/worker_control_lab/lib/shared.dart index 73c07079..2eafb49a 100644 --- a/packages/stem/example/worker_control_lab/lib/shared.dart +++ b/packages/stem/example/worker_control_lab/lib/shared.dart @@ -18,13 +18,8 @@ Future connectRevokeStore(String url) { return RedisRevokeStore.connect(url, namespace: 'stem'); } -SimpleTaskRegistry buildRegistry() { - final registry = SimpleTaskRegistry(); - registry - ..register(ControlLongTask()) - ..register(ControlQuickTask()); - return registry; -} +List> buildTasks() => + [ControlLongTask(), ControlQuickTask()]; class ControlLongTask extends TaskHandler { @override diff --git a/packages/stem/example/workflow_runtime_features_example.dart b/packages/stem/example/workflow_runtime_features_example.dart new file mode 100644 index 00000000..ffb87401 --- /dev/null +++ b/packages/stem/example/workflow_runtime_features_example.dart @@ -0,0 +1,3 @@ +import 'workflows/runtime_metadata_views.dart' as example; + +Future main() => example.main(); diff --git a/packages/stem/example/workflows/cancellation_policy.dart b/packages/stem/example/workflows/cancellation_policy.dart index bada3133..fe2a1267 100644 --- a/packages/stem/example/workflows/cancellation_policy.dart +++ b/packages/stem/example/workflows/cancellation_policy.dart @@ -15,7 +15,7 @@ Future main() async { name: 'reports.generate', build: (flow) { flow.step('poll-status', (ctx) async { - final resume = ctx.takeResumeData(); + final resume = ctx.takeResumeValue(); if (resume != true) { print('[workflow] polling external system…'); // Simulate a slow external service; the cancellation policy will diff --git a/packages/stem/example/workflows/runtime_metadata_views.dart b/packages/stem/example/workflows/runtime_metadata_views.dart new file mode 100644 index 00000000..f08ae774 --- /dev/null +++ b/packages/stem/example/workflows/runtime_metadata_views.dart @@ -0,0 +1,102 @@ +// Demonstrates workflow runtime metadata, channel markers, manifest output, +// and run/step drilldown views. +// Run with: dart run example/workflows/runtime_metadata_views.dart + +import 'dart:convert'; + +import 'package:stem/stem.dart'; + +Future main() async { + final app = await StemApp.create( + tasks: [ + FunctionTaskHandler.inline( + name: 'example.noop', + entrypoint: (context, args) async => null, + ), + ], + ); + final broker = app.broker as InMemoryBroker; + final stem = app.stem; + final store = InMemoryWorkflowStore(); + final runtime = WorkflowRuntime( + stem: stem, + store: store, + eventBus: InMemoryEventBus(store), + queue: 'workflow', + continuationQueue: 'workflow-continue', + executionQueue: 'workflow-step', + ); + + app.register(runtime.workflowRunnerHandler()); + + runtime.registerWorkflow( + Flow( + name: 'example.runtime.features', + build: (flow) { + flow.step('dispatch-task', (ctx) async { + await ctx.enqueuer!.enqueue( + 'example.noop', + args: const {'payload': true}, + meta: const {'origin': 'runtime_metadata_views'}, + ); + return 'done'; + }); + }, + ).definition, + ); + + try { + final runId = await runtime.startWorkflow( + 'example.runtime.features', + params: const {'tenant': 'acme', 'requestId': 'req-42'}, + ); + + final orchestrationDelivery = await broker + .consume(RoutingSubscription.singleQueue('workflow')) + .first + .timeout(const Duration(seconds: 1)); + + print('--- Orchestration task metadata ---'); + print( + const JsonEncoder.withIndent( + ' ', + ).convert(orchestrationDelivery.envelope.meta), + ); + + await runtime.executeRun(runId); + + final executionDelivery = await broker + .consume(RoutingSubscription.singleQueue('workflow-step')) + .first + .timeout(const Duration(seconds: 1)); + + print('\n--- Execution task metadata ---'); + print( + const JsonEncoder.withIndent( + ' ', + ).convert(executionDelivery.envelope.meta), + ); + + final runView = await runtime.viewRun(runId); + final runDetail = await runtime.viewRunDetail(runId); + + print('\n--- Workflow manifest ---'); + print( + const JsonEncoder.withIndent(' ').convert( + runtime + .workflowManifest() + .map((entry) => entry.toJson()) + .toList(growable: false), + ), + ); + + print('\n--- Run view ---'); + print(const JsonEncoder.withIndent(' ').convert(runView?.toJson())); + + print('\n--- Run detail view ---'); + print(const JsonEncoder.withIndent(' ').convert(runDetail?.toJson())); + } finally { + await runtime.dispose(); + await app.close(); + } +} diff --git a/packages/stem/example/workflows/sleep_and_event.dart b/packages/stem/example/workflows/sleep_and_event.dart index 3041e20b..a78ff94f 100644 --- a/packages/stem/example/workflows/sleep_and_event.dart +++ b/packages/stem/example/workflows/sleep_and_event.dart @@ -12,7 +12,7 @@ Future main() async { name: 'durable.sleep.event', build: (flow) { flow.step('initial', (ctx) async { - final resumePayload = ctx.takeResumeData(); + final resumePayload = ctx.takeResumeValue(); if (resumePayload != true) { ctx.sleep(const Duration(milliseconds: 200)); return null; @@ -21,12 +21,11 @@ Future main() async { }); flow.step('await-event', (ctx) async { - final resumeData = ctx.takeResumeData(); - if (resumeData == null) { + final payload = ctx.takeResumeValue>(); + if (payload == null) { ctx.awaitEvent('demo.event'); return null; } - final payload = resumeData as Map; return payload['message']; }); }, diff --git a/packages/stem/lib/src/bootstrap/stem_app.dart b/packages/stem/lib/src/bootstrap/stem_app.dart index d95c47a6..ec76be59 100644 --- a/packages/stem/lib/src/bootstrap/stem_app.dart +++ b/packages/stem/lib/src/bootstrap/stem_app.dart @@ -103,7 +103,7 @@ class StemApp { TaskPayloadEncoder argsEncoder = const JsonTaskPayloadEncoder(), Iterable additionalEncoders = const [], }) async { - final taskRegistry = registry ?? SimpleTaskRegistry(); + final taskRegistry = registry ?? InMemoryTaskRegistry(); tasks.forEach(taskRegistry.register); final brokerFactory = broker ?? StemBrokerFactory.inMemory(); diff --git a/packages/stem/lib/src/bootstrap/stem_client.dart b/packages/stem/lib/src/bootstrap/stem_client.dart index 9405882c..6a7fd57d 100644 --- a/packages/stem/lib/src/bootstrap/stem_client.dart +++ b/packages/stem/lib/src/bootstrap/stem_client.dart @@ -1,5 +1,6 @@ import 'package:stem/src/bootstrap/factories.dart'; import 'package:stem/src/bootstrap/stem_app.dart'; +import 'package:stem/src/bootstrap/stem_module.dart'; import 'package:stem/src/bootstrap/stem_stack.dart'; import 'package:stem/src/bootstrap/workflow_app.dart'; import 'package:stem/src/core/contracts.dart'; @@ -196,6 +197,7 @@ abstract class StemClient { /// Creates a workflow app using the shared client configuration. Future createWorkflowApp({ + StemModule? module, Iterable workflows = const [], Iterable flows = const [], Iterable scripts = const [], @@ -208,6 +210,7 @@ abstract class StemClient { }) { return StemWorkflowApp.fromClient( client: this, + module: module, workflows: workflows, flows: flows, scripts: scripts, @@ -271,7 +274,7 @@ class _DefaultStemClient extends StemClient { TaskPayloadEncoder argsEncoder = const JsonTaskPayloadEncoder(), Iterable additionalEncoders = const [], }) async { - final registry = taskRegistry ?? SimpleTaskRegistry(); + final registry = taskRegistry ?? InMemoryTaskRegistry(); tasks.forEach(registry.register); final workflows = workflowRegistry ?? InMemoryWorkflowRegistry(); diff --git a/packages/stem/lib/src/bootstrap/stem_module.dart b/packages/stem/lib/src/bootstrap/stem_module.dart new file mode 100644 index 00000000..4c6748cf --- /dev/null +++ b/packages/stem/lib/src/bootstrap/stem_module.dart @@ -0,0 +1,88 @@ +import 'package:stem/src/core/contracts.dart'; +import 'package:stem/src/workflow/core/flow.dart'; +import 'package:stem/src/workflow/core/workflow_definition.dart'; +import 'package:stem/src/workflow/core/workflow_script.dart'; +import 'package:stem/src/workflow/runtime/workflow_manifest.dart'; +import 'package:stem/src/workflow/runtime/workflow_registry.dart'; + +/// Generated or hand-authored bundle of tasks and workflow definitions. +/// +/// The intended use is to pass one module into bootstrap helpers rather than +/// threading separate task, flow, and script lists through every call site. +class StemModule { + /// Creates a bundled module of tasks and workflows. + StemModule({ + Iterable workflows = const [], + Iterable flows = const [], + Iterable scripts = const [], + Iterable> tasks = const [], + Iterable? workflowManifest, + }) : workflows = List.unmodifiable(workflows), + flows = List.unmodifiable(flows), + scripts = List.unmodifiable(scripts), + tasks = List.unmodifiable(tasks), + workflowManifest = List.unmodifiable( + workflowManifest ?? + _defaultManifest( + workflows: workflows, + flows: flows, + scripts: scripts, + ), + ); + + /// Raw workflow definitions that are not represented as [Flow] or + /// [WorkflowScript] instances. + final List workflows; + + /// Flow workflows in this module. + final List flows; + + /// Script workflows in this module. + final List scripts; + + /// Task handlers in this module. + final List> tasks; + + /// Workflow manifest entries exported by this module. + final List workflowManifest; + + /// All workflow definitions contained in the module. + Iterable get workflowDefinitions sync* { + yield* workflows; + for (final flow in flows) { + yield flow.definition; + } + for (final script in scripts) { + yield script.definition; + } + } + + /// Registers bundled definitions into the supplied registries. + void registerInto({ + WorkflowRegistry? workflows, + TaskRegistry? tasks, + }) { + if (workflows != null) { + workflowDefinitions.forEach(workflows.register); + } + if (tasks != null) { + this.tasks.forEach(tasks.register); + } + } + + static Iterable _defaultManifest({ + required Iterable workflows, + required Iterable flows, + required Iterable scripts, + }) sync* { + for (final workflow in workflows) { + yield workflow.toManifestEntry(); + } + for (final flow in flows) { + yield flow.definition.toManifestEntry(); + } + for (final script in scripts) { + yield script.definition.toManifestEntry(); + } + } +} diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index 50431cb3..c5254e23 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -1,9 +1,12 @@ import 'package:stem/src/bootstrap/factories.dart'; import 'package:stem/src/bootstrap/stem_app.dart'; import 'package:stem/src/bootstrap/stem_client.dart'; +import 'package:stem/src/bootstrap/stem_module.dart'; import 'package:stem/src/bootstrap/stem_stack.dart'; import 'package:stem/src/control/revoke_store.dart'; import 'package:stem/src/core/clock.dart'; +import 'package:stem/src/core/contracts.dart' show TaskHandler; +import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/core/task_payload_encoder.dart'; import 'package:stem/src/core/unique_task_coordinator.dart'; import 'package:stem/src/workflow/core/event_bus.dart'; @@ -11,6 +14,7 @@ import 'package:stem/src/workflow/core/flow.dart'; import 'package:stem/src/workflow/core/run_state.dart'; import 'package:stem/src/workflow/core/workflow_cancellation_policy.dart'; import 'package:stem/src/workflow/core/workflow_definition.dart'; +import 'package:stem/src/workflow/core/workflow_ref.dart'; import 'package:stem/src/workflow/core/workflow_result.dart'; import 'package:stem/src/workflow/core/workflow_script.dart'; import 'package:stem/src/workflow/core/workflow_status.dart'; @@ -112,6 +116,58 @@ class StemWorkflowApp { ); } + /// Schedules a workflow run from a typed [WorkflowRef]. + Future startWorkflowRef( + WorkflowRef definition, + TParams params, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + if (!_started) { + return start().then( + (_) => runtime.startWorkflowRef( + definition, + params, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ), + ); + } + return runtime.startWorkflowRef( + definition, + params, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } + + /// Schedules a workflow run from a prebuilt [WorkflowStartCall]. + Future startWorkflowCall( + WorkflowStartCall call, + ) { + return startWorkflowRef( + call.definition, + call.params, + parentRunId: call.parentRunId, + ttl: call.ttl, + cancellationPolicy: call.cancellationPolicy, + ); + } + + /// Emits a typed event to resume runs waiting on [topic]. + /// + /// This is a convenience wrapper over [WorkflowRuntime.emitValue]. + Future emitValue( + String topic, + T value, { + PayloadCodec? codec, + }) { + return runtime.emitValue(topic, value, codec: codec); + } + /// Returns the current [RunState] of a workflow run, or `null` if not found. /// /// Example: @@ -163,6 +219,24 @@ class StemWorkflowApp { } } + /// Waits for [runId] using the decoding rules from a [WorkflowRef]. + Future?> waitForWorkflowRef< + TParams, + TResult extends Object? + >( + String runId, + WorkflowRef definition, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return waitForCompletion( + runId, + pollInterval: pollInterval, + timeout: timeout, + decode: definition.decode, + ); + } + WorkflowResult _buildResult( RunState state, T Function(Object? payload)? decode, { @@ -225,9 +299,11 @@ class StemWorkflowApp { /// ); /// ``` static Future create({ + StemModule? module, Iterable workflows = const [], Iterable flows = const [], Iterable scripts = const [], + Iterable> tasks = const [], StemApp? stemApp, StemBrokerFactory? broker, StemBackendFactory? backend, @@ -243,6 +319,9 @@ class StemWorkflowApp { TaskPayloadEncoder argsEncoder = const JsonTaskPayloadEncoder(), Iterable additionalEncoders = const [], }) async { + final moduleTasks = module?.tasks ?? const >[]; + final moduleWorkflowDefinitions = + module?.workflowDefinitions ?? const []; final appInstance = stemApp ?? await StemApp.create( @@ -272,15 +351,15 @@ class StemWorkflowApp { introspectionSink: introspectionSink, ); + [...moduleTasks, ...tasks].forEach(appInstance.register); appInstance.register(runtime.workflowRunnerHandler()); - workflows.forEach(runtime.registerWorkflow); - for (final flow in flows) { - runtime.registerWorkflow(flow.definition); - } - for (final script in scripts) { - runtime.registerWorkflow(script.definition); - } + [ + ...moduleWorkflowDefinitions, + ...workflows, + ...flows.map((flow) => flow.definition), + ...scripts.map((script) => script.definition), + ].forEach(runtime.registerWorkflow); return StemWorkflowApp._( app: appInstance, @@ -303,9 +382,11 @@ class StemWorkflowApp { /// ); /// ``` static Future inMemory({ + StemModule? module, Iterable workflows = const [], Iterable flows = const [], Iterable scripts = const [], + Iterable> tasks = const [], StemWorkerConfig workerConfig = const StemWorkerConfig(queue: 'workflow'), Duration pollInterval = const Duration(milliseconds: 500), Duration leaseExtension = const Duration(seconds: 30), @@ -317,9 +398,11 @@ class StemWorkflowApp { Iterable additionalEncoders = const [], }) { return StemWorkflowApp.create( + module: module, workflows: workflows, flows: flows, scripts: scripts, + tasks: tasks, broker: StemBrokerFactory.inMemory(), backend: StemBackendFactory.inMemory(), storeFactory: WorkflowStoreFactory.inMemory(), @@ -342,9 +425,11 @@ class StemWorkflowApp { /// optional per-store overrides via [StemStack.fromUrl]. static Future fromUrl( String url, { + StemModule? module, Iterable workflows = const [], Iterable flows = const [], Iterable scripts = const [], + Iterable> tasks = const [], Iterable adapters = const [], StemStoreOverrides overrides = const StemStoreOverrides(), StemWorkerConfig workerConfig = const StemWorkerConfig(queue: 'workflow'), @@ -391,9 +476,11 @@ class StemWorkflowApp { try { return await create( + module: module, workflows: workflows, flows: flows, scripts: scripts, + tasks: tasks, stemApp: app, storeFactory: stack.workflowStore, eventBusFactory: eventBusFactory, @@ -418,9 +505,11 @@ class StemWorkflowApp { /// Creates a workflow app backed by a shared [StemClient]. static Future fromClient({ required StemClient client, + StemModule? module, Iterable workflows = const [], Iterable flows = const [], Iterable scripts = const [], + Iterable> tasks = const [], WorkflowStoreFactory? storeFactory, WorkflowEventBusFactory? eventBusFactory, StemWorkerConfig workerConfig = const StemWorkerConfig(queue: 'workflow'), @@ -433,6 +522,7 @@ class StemWorkflowApp { workerConfig: workerConfig, ); return StemWorkflowApp.create( + module: module, workflows: workflows, flows: flows, scripts: scripts, @@ -447,3 +537,51 @@ class StemWorkflowApp { ); } } + +/// Convenience helpers for typed workflow start calls. +extension WorkflowStartCallAppExtension + on WorkflowStartCall { + /// Starts this workflow call with [app]. + Future startWithApp(StemWorkflowApp app) { + return app.startWorkflowCall(this); + } + + /// Starts this workflow call with [app] and waits for the typed result. + Future?> startAndWaitWithApp( + StemWorkflowApp app, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) async { + final runId = await app.startWorkflowCall(this); + return definition.waitFor( + app, + runId, + pollInterval: pollInterval, + timeout: timeout, + ); + } + + /// Starts this workflow call with [runtime]. + Future startWithRuntime(WorkflowRuntime runtime) { + return runtime.startWorkflowCall(this); + } +} + +/// Convenience helpers for waiting on workflow results using a typed reference. +extension WorkflowRefAppExtension + on WorkflowRef { + /// Waits for [runId] using this workflow reference's decode rules. + Future?> waitFor( + StemWorkflowApp app, + String runId, { + Duration pollInterval = const Duration(milliseconds: 100), + Duration? timeout, + }) { + return app.waitForWorkflowRef( + runId, + this, + pollInterval: pollInterval, + timeout: timeout, + ); + } +} diff --git a/packages/stem/lib/src/canvas/canvas.dart b/packages/stem/lib/src/canvas/canvas.dart index 2eadc66f..c9dac1ce 100644 --- a/packages/stem/lib/src/canvas/canvas.dart +++ b/packages/stem/lib/src/canvas/canvas.dart @@ -255,16 +255,19 @@ class Canvas { /// Creates a [Canvas] that uses [broker] to publish messages and [backend] /// to persist task state and group metadata. /// - /// [registry] provides task lookups when needed. + /// [tasks] are registered automatically. [registry] can be provided for + /// advanced setups that need a custom task catalog. Canvas({ required this.broker, required ResultBackend backend, - required this.registry, + Iterable> tasks = const [], + TaskRegistry? registry, TaskPayloadEncoderRegistry? encoderRegistry, TaskPayloadEncoder resultEncoder = const JsonTaskPayloadEncoder(), TaskPayloadEncoder argsEncoder = const JsonTaskPayloadEncoder(), Iterable additionalEncoders = const [], - }) : payloadEncoders = ensureTaskPayloadEncoderRegistry( + }) : registry = _resolveTaskRegistry(registry, tasks), + payloadEncoders = ensureTaskPayloadEncoderRegistry( encoderRegistry, resultEncoder: resultEncoder, argsEncoder: argsEncoder, @@ -273,6 +276,15 @@ class Canvas { this.backend = withTaskPayloadEncoder(backend, payloadEncoders); } + static TaskRegistry _resolveTaskRegistry( + TaskRegistry? registry, + Iterable> tasks, + ) { + final resolved = registry ?? InMemoryTaskRegistry(); + tasks.forEach(resolved.register); + return resolved; + } + /// The message broker used to publish task envelopes. final Broker broker; diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index 515cfe55..4975f2a4 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -275,6 +275,101 @@ class TaskStatus { /// Worker id that reported this status, if available. String? get workerId => meta['worker']?.toString(); + /// Workflow name associated with this task status, if any. + String? get workflowName => _taskStatusString( + meta, + const ['stem.workflow.name', 'workflow.name', 'workflow'], + ); + + /// Workflow run identifier associated with this task status, if any. + String? get workflowRunId => _taskStatusString( + meta, + const ['stem.workflow.runId', 'workflow.runId'], + ); + + /// Stable workflow definition identifier, if provided. + String? get workflowId => _taskStatusString( + meta, + const ['stem.workflow.id', 'workflow.id'], + ); + + /// Workflow step name associated with this task status, if any. + String? get workflowStep => _taskStatusString( + meta, + const ['stem.workflow.step', 'workflow.step', 'step'], + ); + + /// Workflow step index associated with this task status, if any. + int? get workflowStepIndex => _taskStatusInt( + meta, + const ['stem.workflow.stepIndex', 'workflow.stepIndex'], + ); + + /// Workflow iteration associated with this task status, if any. + int? get workflowIteration => _taskStatusInt( + meta, + const ['stem.workflow.iteration', 'workflow.iteration'], + ); + + /// Workflow channel (`orchestration` or `execution`) for this status. + String? get workflowChannel => _taskStatusString( + meta, + const ['stem.workflow.channel', 'workflow.channel'], + ); + + /// Whether this status represents a continuation orchestration dispatch. + bool get workflowContinuation => + meta['stem.workflow.continuation'] == true || + meta['workflow.continuation'] == true; + + /// Continuation reason label when present. + String? get workflowContinuationReason => _taskStatusString( + meta, + const [ + 'stem.workflow.continuationReason', + 'workflow.continuationReason', + ], + ); + + /// Orchestration queue associated with the workflow runtime. + String? get workflowOrchestrationQueue => _taskStatusString( + meta, + const ['stem.workflow.orchestrationQueue', 'workflow.orchestrationQueue'], + ); + + /// Continuation queue associated with the workflow runtime. + String? get workflowContinuationQueue => _taskStatusString( + meta, + const ['stem.workflow.continuationQueue', 'workflow.continuationQueue'], + ); + + /// Execution queue associated with the workflow runtime. + String? get workflowExecutionQueue => _taskStatusString( + meta, + const ['stem.workflow.executionQueue', 'workflow.executionQueue'], + ); + + /// Serialization format used by the workflow run context. + String? get workflowSerializationFormat => _taskStatusString( + meta, + const ['stem.workflow.serialization.format', 'workflow.serialization'], + ); + + /// Serialization version used by the workflow run context. + String? get workflowSerializationVersion => _taskStatusString( + meta, + const [ + 'stem.workflow.serialization.version', + 'workflow.serialization.version', + ], + ); + + /// Per-run stream identifier used for framing metadata. + String? get workflowStreamId => _taskStatusString( + meta, + const ['stem.workflow.stream.id', 'workflow.stream.id'], + ); + /// Processing start timestamp recorded by the worker, if present. DateTime? get startedAt => _taskStatusDate(meta['startedAt']); @@ -329,6 +424,28 @@ DateTime? _taskStatusDate(Object? value) { return DateTime.tryParse(value.toString())?.toUtc(); } +String? _taskStatusString(Map meta, List keys) { + for (final key in keys) { + final value = meta[key]; + if (value == null) continue; + final text = value.toString().trim(); + if (text.isNotEmpty) return text; + } + return null; +} + +int? _taskStatusInt(Map meta, List keys) { + for (final key in keys) { + final value = meta[key]; + if (value == null) continue; + if (value is int) return value; + if (value is num) return value.toInt(); + final parsed = int.tryParse(value.toString()); + if (parsed != null) return parsed; + } + return null; +} + Duration? _taskStatusDuration(Object? value) { if (value is num) { return Duration(milliseconds: value.toInt()); @@ -983,7 +1100,7 @@ class TaskOptions { /// The rate limit for tasks with these options. final String? rateLimit; - /// Group-scoped rate limit shared by tasks that resolve to + /// Group-scoped rate limit shared by tasks that resolve to /// the same group key. final String? groupRateLimit; @@ -1769,7 +1886,7 @@ abstract class TaskRegistry { } /// Default in-memory registry implementation. -class SimpleTaskRegistry implements TaskRegistry { +class InMemoryTaskRegistry implements TaskRegistry { final Map> _handlers = {}; final StreamController _registerController = StreamController.broadcast(); @@ -1805,6 +1922,10 @@ class SimpleTaskRegistry implements TaskRegistry { Stream get onRegister => _registerController.stream; } +/// Backwards-compatible alias for the default in-memory registry. +@Deprecated('Use InMemoryTaskRegistry instead.') +typedef SimpleTaskRegistry = InMemoryTaskRegistry; + /// Optional task metadata for documentation and tooling. class TaskMetadata { /// Creates task metadata for documentation and tooling. diff --git a/packages/stem/lib/src/core/payload_codec.dart b/packages/stem/lib/src/core/payload_codec.dart new file mode 100644 index 00000000..813f6d64 --- /dev/null +++ b/packages/stem/lib/src/core/payload_codec.dart @@ -0,0 +1,53 @@ +import 'package:stem/src/core/task_payload_encoder.dart'; + +/// Encodes and decodes a strongly-typed payload value. +/// +/// This author-facing codec layer is used by generated workflow/task helpers to +/// lower richer Dart DTOs into the existing durable wire format. +class PayloadCodec { + /// Creates a payload codec from explicit encode/decode callbacks. + const PayloadCodec({required this.encode, required this.decode}); + + /// Converts a typed value into a durable payload representation. + final Object? Function(T value) encode; + + /// Reconstructs a typed value from a durable payload representation. + final T Function(Object? payload) decode; + + /// Converts an erased author-facing value into a durable payload. + Object? encodeDynamic(Object? value) { + if (value == null) return null; + return encode(value as T); + } + + /// Reconstructs an erased author-facing value from a durable payload. + Object? decodeDynamic(Object? payload) { + if (payload == null) return null; + return decode(payload); + } +} + +/// Bridges a [PayloadCodec] into the existing [TaskPayloadEncoder] contract. +class CodecTaskPayloadEncoder extends TaskPayloadEncoder { + /// Creates a task payload encoder backed by a typed [codec]. + const CodecTaskPayloadEncoder({required this.idValue, required this.codec}); + + /// Stable encoder identifier used across producer/worker boundaries. + final String idValue; + + /// Typed codec used to encode and decode payloads. + final PayloadCodec codec; + + @override + String get id => idValue; + + @override + Object? encode(Object? value) { + return codec.encodeDynamic(value); + } + + @override + Object? decode(Object? stored) { + return codec.decodeDynamic(stored); + } +} diff --git a/packages/stem/lib/src/core/stem.dart b/packages/stem/lib/src/core/stem.dart index 74f4f629..d363135a 100644 --- a/packages/stem/lib/src/core/stem.dart +++ b/packages/stem/lib/src/core/stem.dart @@ -79,8 +79,9 @@ class Stem implements TaskEnqueuer { /// Creates a Stem producer facade with the provided dependencies. Stem({ required this.broker, - required this.registry, + TaskRegistry? registry, this.backend, + Iterable> tasks = const [], this.uniqueTaskCoordinator, RetryStrategy? retryStrategy, List middleware = const [], @@ -90,7 +91,8 @@ class Stem implements TaskEnqueuer { TaskPayloadEncoder resultEncoder = const JsonTaskPayloadEncoder(), TaskPayloadEncoder argsEncoder = const JsonTaskPayloadEncoder(), Iterable additionalEncoders = const [], - }) : payloadEncoders = ensureTaskPayloadEncoderRegistry( + }) : registry = _resolveTaskRegistry(registry, tasks), + payloadEncoders = ensureTaskPayloadEncoderRegistry( encoderRegistry, resultEncoder: resultEncoder, argsEncoder: argsEncoder, @@ -100,6 +102,15 @@ class Stem implements TaskEnqueuer { retryStrategy = retryStrategy ?? ExponentialJitterRetryStrategy(), middleware = List.unmodifiable(middleware); + static TaskRegistry _resolveTaskRegistry( + TaskRegistry? registry, + Iterable> tasks, + ) { + final resolved = registry ?? InMemoryTaskRegistry(); + tasks.forEach(resolved.register); + return resolved; + } + /// Broker used to publish task envelopes. final Broker broker; @@ -460,6 +471,40 @@ class Stem implements TaskEnqueuer { return completer.future; } + /// Waits for [taskId] using the decoding rules from a [TaskDefinition]. + Future?> waitForTaskDefinition< + TArgs, + TResult extends Object? + >( + String taskId, + TaskDefinition definition, { + Duration? timeout, + }) { + return waitForTask( + taskId, + timeout: timeout, + decode: (payload) { + TResult? value; + try { + value = definition.decode(payload); + } on Object { + if (payload is TResult) { + value = payload; + } else { + rethrow; + } + } + if (value == null && null is! TResult) { + throw StateError( + 'Task definition "${definition.name}" decoded a null result ' + 'for non-nullable type $TResult.', + ); + } + return value as TResult; + }, + ); + } + /// Executes the enqueue middleware chain in order. Future _runEnqueueMiddleware( Envelope envelope, diff --git a/packages/stem/lib/src/worker/worker.dart b/packages/stem/lib/src/worker/worker.dart index d7ccc05c..8e58795d 100644 --- a/packages/stem/lib/src/worker/worker.dart +++ b/packages/stem/lib/src/worker/worker.dart @@ -46,15 +46,13 @@ /// ```dart /// // 1. Set up dependencies /// final broker = RedisBroker(); -/// final registry = TaskRegistry() -/// ..register('process_order', TaskHandler(processOrder)); /// final backend = RedisResultBackend(); /// /// // 2. Create and start worker /// final worker = Worker( /// broker: broker, -/// registry: registry, /// backend: backend, +/// tasks: [ProcessOrderTask()], /// concurrency: 8, /// ); /// @@ -199,11 +197,12 @@ enum WorkerShutdownMode { /// /// | Parameter | Required | Description | /// |-----------|----------|-------------| -/// | [broker] | Yes | Message broker for queue operations | -/// | [registry] | Yes | Task handler registry | -/// | [backend] | Yes | Result persistence backend | -/// | [concurrency] | No | Max parallel tasks (default: CPU count) | -/// | [queue] | No | Default queue name (default: 'default') | +/// | `broker` | Yes | Message broker for queue operations | +/// | `backend` | Yes | Result persistence backend | +/// | `tasks` | No | Task handlers to register automatically | +/// | `registry` | No | Custom task registry for advanced setups | +/// | `concurrency` | No | Max parallel tasks (default: CPU count) | +/// | `queue` | No | Default queue name (default: 'default') | /// | `autoscale` | No | Dynamic concurrency scaling config | /// | `lifecycle` | No | Shutdown and recycling config | /// @@ -212,8 +211,8 @@ enum WorkerShutdownMode { /// ```dart /// final worker = Worker( /// broker: RedisBroker(), -/// registry: registry, /// backend: RedisResultBackend(), +/// tasks: [ProcessOrderTask()], /// concurrency: 8, /// middleware: [LoggingMiddleware()], /// autoscale: WorkerAutoscaleConfig( @@ -261,13 +260,14 @@ class Worker { /// /// - [broker]: The message broker for consuming and acknowledging tasks. /// Must be connected before calling [start]. - /// - [registry]: Contains registered task handlers. Tasks without handlers - /// are dead-lettered with reason 'unregistered-task'. /// - [backend]: Stores task state and results. Used for task status tracking /// and result retrieval by callers. /// /// ## Optional Parameters /// + /// - [tasks]: Task handlers registered into the worker automatically. + /// - [registry]: Optional custom registry. When omitted an in-memory registry + /// is created and populated from [tasks]. /// - [enqueuer]: [Stem] instance for spawning child tasks from handlers. /// Created automatically if not provided. /// - [rateLimiter]: Enforces per-task rate limits. Rate limits are defined @@ -300,8 +300,9 @@ class Worker { /// - [additionalEncoders]: Additional payload encoders to register. Worker({ required Broker broker, - required TaskRegistry registry, required ResultBackend backend, + Iterable> tasks = const [], + TaskRegistry? registry, Stem? enqueuer, RateLimiter? rateLimiter, List middleware = const [], @@ -329,7 +330,7 @@ class Worker { }) : this._( broker: broker, enqueuer: enqueuer, - registry: registry, + registry: _resolveTaskRegistry(registry, tasks), backend: backend, rateLimiter: rateLimiter, middleware: middleware, @@ -475,6 +476,15 @@ class Worker { _signals = StemSignalEmitter(defaultSender: _workerIdentifier); } + static TaskRegistry _resolveTaskRegistry( + TaskRegistry? registry, + Iterable> tasks, + ) { + final resolved = registry ?? InMemoryTaskRegistry(); + tasks.forEach(resolved.register); + return resolved; + } + /// Broker used to consume and acknowledge deliveries. final Broker broker; @@ -909,14 +919,7 @@ class Worker { stemLogger.debug( 'Task {task} started', - Context( - _logContext({ - 'task': envelope.name, - 'id': envelope.id, - 'attempt': envelope.attempt, - 'queue': envelope.queue, - }), - ), + Context(_deliveryLogContext(envelope)), ); StemMetrics.instance.increment( 'stem.tasks.started', @@ -1036,15 +1039,7 @@ class Worker { _completedCount += 1; stemLogger.debug( 'Task {task} succeeded', - Context( - _logContext({ - 'task': envelope.name, - 'id': envelope.id, - 'attempt': envelope.attempt, - 'queue': envelope.queue, - 'worker': consumerName ?? 'unknown', - }), - ), + Context(_deliveryLogContext(envelope)), ); _events.add( WorkerEvent(type: WorkerEventType.completed, envelope: envelope), @@ -1939,14 +1934,13 @@ class Worker { stemLogger.error( 'Task {task} signature verification failed', Context( - _logContext({ - 'task': envelope.name, - 'id': envelope.id, - 'queue': envelope.queue, - 'worker': consumerName ?? 'unknown', - 'error': error.message, - if (error.keyId != null) 'keyId': error.keyId!, - }), + _deliveryLogContext( + envelope, + extra: { + 'error': error.message, + if (error.keyId != null) 'keyId': error.keyId!, + }, + ), ), ); @@ -2075,6 +2069,20 @@ class Worker { reason: error, nextRetryAt: nextRunAt, ); + stemLogger.debug( + 'Task {task} scheduled for retry', + Context( + _deliveryLogContext( + envelope, + extra: { + 'error': error.toString(), + 'retryAfterMs': delay.inMilliseconds, + 'nextAttempt': retryEnvelope.attempt, + 'nextRunAt': nextRunAt.toIso8601String(), + }, + ), + ), + ); return TaskState.retried; } else { final failureMeta = _statusMeta( @@ -2121,15 +2129,13 @@ class Worker { stemLogger.warning( 'Task {task} failed: {error}', Context( - _logContext({ - 'task': envelope.name, - 'id': envelope.id, - 'attempt': envelope.attempt, - 'queue': envelope.queue, - 'worker': consumerName ?? 'unknown', - 'error': error.toString(), - 'stack': stack.toString(), - }), + _deliveryLogContext( + envelope, + extra: { + 'error': error.toString(), + 'stack': stack.toString(), + }, + ), ), ); _events.add( @@ -2268,6 +2274,19 @@ class Worker { reason: request, nextRetryAt: notBefore, ); + stemLogger.debug( + 'Task {task} retry requested', + Context( + _deliveryLogContext( + envelope, + extra: { + 'retryAfterMs': delay.inMilliseconds, + 'nextAttempt': retryEnvelope.attempt, + 'nextRunAt': notBefore.toIso8601String(), + }, + ), + ), + ); return TaskState.retried; } @@ -2710,6 +2729,120 @@ class Worker { return {...context, ...traceFields}; } + Map _deliveryLogContext( + Envelope envelope, { + Map extra = const {}, + }) { + final fields = { + 'task': envelope.name, + 'id': envelope.id, + 'attempt': envelope.attempt, + 'queue': envelope.queue, + ...extra, + }; + _appendEnvelopeMetaLogFields(fields, envelope.meta); + return _logContext(fields.cast()); + } + + void _appendEnvelopeMetaLogFields( + Map fields, + Map meta, + ) { + final workflowChannel = _metaString(meta, const [ + 'stem.workflow.channel', + 'workflow.channel', + ]); + if (workflowChannel != null) { + fields.putIfAbsent('workflowChannel', () => workflowChannel); + } + + final workflowContinuation = _metaBool(meta, const [ + 'stem.workflow.continuation', + 'workflow.continuation', + ]); + if (workflowContinuation != null) { + fields.putIfAbsent('workflowContinuation', () => workflowContinuation); + } + + final workflowReason = _metaString(meta, const [ + 'stem.workflow.continuationReason', + 'workflow.continuationReason', + ]); + if (workflowReason != null) { + fields.putIfAbsent('workflowReason', () => workflowReason); + } + + final workflowRunId = _metaString(meta, const [ + 'stem.workflow.runId', + 'workflow.runId', + 'stem.workflow.run_id', + ]); + if (workflowRunId != null) { + fields.putIfAbsent('workflowRunId', () => workflowRunId); + } + + final workflowId = _metaString(meta, const [ + 'stem.workflow.id', + 'workflow.id', + ]); + if (workflowId != null) { + fields.putIfAbsent('workflowId', () => workflowId); + } + + final workflowName = _metaString(meta, const [ + 'stem.workflow.name', + 'workflow.name', + ]); + if (workflowName != null) { + fields.putIfAbsent('workflow', () => workflowName); + } + + final workflowStep = _metaString(meta, const [ + 'stem.workflow.step', + 'workflow.step', + 'stem.workflow.stepName', + 'workflow.stepName', + 'stepName', + 'step', + ]); + if (workflowStep != null) { + fields.putIfAbsent('workflowStep', () => workflowStep); + } + + final workflowStepId = _metaString(meta, const [ + 'stem.workflow.stepId', + 'workflow.stepId', + 'stepId', + ]); + if (workflowStepId != null) { + fields.putIfAbsent('workflowStepId', () => workflowStepId); + } + + final workflowStepIndex = _metaInt(meta, const [ + 'stem.workflow.stepIndex', + 'stem.workflow.step_index', + ]); + if (workflowStepIndex != null) { + fields.putIfAbsent('workflowStepIndex', () => workflowStepIndex); + } + + final workflowIteration = _metaInt(meta, const [ + 'stem.workflow.iteration', + ]); + if (workflowIteration != null) { + fields.putIfAbsent('workflowIteration', () => workflowIteration); + } + + final workflowStepAttempt = _metaInt(meta, const [ + 'stem.workflow.stepAttempt', + 'workflow.stepAttempt', + 'stepAttempt', + ]); + if (workflowStepAttempt != null) { + fields.putIfAbsent('workflowStepAttempt', () => workflowStepAttempt); + } + } + Map _deliverySpanAttributes(Envelope envelope) { final attributes = { 'stem.task': envelope.name, @@ -2851,6 +2984,25 @@ class Worker { return null; } + bool? _metaBool(Map meta, List keys) { + for (final key in keys) { + final value = meta[key]; + if (value is bool) { + return value; + } + if (value is String) { + final normalized = value.trim().toLowerCase(); + if (normalized == 'true') { + return true; + } + if (normalized == 'false') { + return false; + } + } + } + return null; + } + static String? _safeLocalHostname() { try { final hostname = Platform.localHostname.trim(); diff --git a/packages/stem/lib/src/workflow/annotations.dart b/packages/stem/lib/src/workflow/annotations.dart index 0cd008ed..728bb17b 100644 --- a/packages/stem/lib/src/workflow/annotations.dart +++ b/packages/stem/lib/src/workflow/annotations.dart @@ -32,6 +32,8 @@ class WorkflowDefn { this.version, this.description, this.metadata, + this.starterName, + this.nameField, }); /// Optional override for the workflow definition name. @@ -48,6 +50,18 @@ class WorkflowDefn { /// Optional metadata attached to the workflow definition. final Map? metadata; + + /// Optional override for the generated workflow ref symbol suffix. + /// + /// Example: `starterName: 'UserSignup'` contributes + /// `StemWorkflowDefinitions.userSignup`. + final String? starterName; + + /// Optional override for the generated workflow ref field name. + /// + /// Example: `nameField: 'userSignup'` generates + /// `StemWorkflowDefinitions.userSignup`. + final String? nameField; } /// Marks a workflow class method as the run entrypoint. diff --git a/packages/stem/lib/src/workflow/core/flow.dart b/packages/stem/lib/src/workflow/core/flow.dart index 98e0a629..5a5cdd97 100644 --- a/packages/stem/lib/src/workflow/core/flow.dart +++ b/packages/stem/lib/src/workflow/core/flow.dart @@ -1,3 +1,4 @@ +import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/workflow/core/workflow_definition.dart'; /// Convenience wrapper that builds a [WorkflowDefinition] using the declarative @@ -15,12 +16,14 @@ class Flow { String? version, String? description, Map? metadata, + PayloadCodec? resultCodec, }) : definition = WorkflowDefinition.flow( name: name, build: build, version: version, description: description, metadata: metadata, + resultCodec: resultCodec, ); /// The constructed workflow definition. diff --git a/packages/stem/lib/src/workflow/core/flow_step.dart b/packages/stem/lib/src/workflow/core/flow_step.dart index d30d9092..9413d0bd 100644 --- a/packages/stem/lib/src/workflow/core/flow_step.dart +++ b/packages/stem/lib/src/workflow/core/flow_step.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/workflow/core/flow.dart' show Flow; import 'package:stem/src/workflow/core/flow_context.dart'; import 'package:stem/src/workflow/workflow.dart' show Flow; @@ -38,13 +39,41 @@ class FlowStep { required this.handler, this.autoVersion = false, String? title, + Object? Function(Object? value)? valueEncoder, + Object? Function(Object? payload)? valueDecoder, this.kind = WorkflowStepKind.task, Iterable taskNames = const [], Map? metadata, }) : title = title ?? name, + _valueEncoder = valueEncoder, + _valueDecoder = valueDecoder, taskNames = List.unmodifiable(taskNames), metadata = metadata == null ? null : Map.unmodifiable(metadata); + /// Creates a step definition backed by a typed [valueCodec]. + static FlowStep typed({ + required String name, + required FutureOr Function(FlowContext context) handler, + required PayloadCodec valueCodec, + bool autoVersion = false, + String? title, + WorkflowStepKind kind = WorkflowStepKind.task, + Iterable taskNames = const [], + Map? metadata, + }) { + return FlowStep( + name: name, + handler: handler, + autoVersion: autoVersion, + title: title, + valueEncoder: valueCodec.encodeDynamic, + valueDecoder: valueCodec.decodeDynamic, + kind: kind, + taskNames: taskNames, + metadata: metadata, + ); + } + /// Rehydrates a flow step from serialized JSON. factory FlowStep.fromJson(Map json) { return FlowStep( @@ -67,6 +96,9 @@ class FlowStep { /// Step kind classification. final WorkflowStepKind kind; + final Object? Function(Object? value)? _valueEncoder; + final Object? Function(Object? payload)? _valueDecoder; + /// Task names associated with this step (for UI introspection). final List taskNames; @@ -90,6 +122,22 @@ class FlowStep { if (metadata != null) 'metadata': metadata, }; } + + /// Encodes a step value before it is persisted. + Object? encodeValue(Object? value) { + if (value == null) return null; + final encoder = _valueEncoder; + if (encoder == null) return value; + return encoder(value); + } + + /// Decodes a persisted step value back into the author-facing type. + Object? decodeValue(Object? payload) { + if (payload == null) return null; + final decoder = _valueDecoder; + if (decoder == null) return payload; + return decoder(payload); + } } WorkflowStepKind _kindFromJson(Object? value) { diff --git a/packages/stem/lib/src/workflow/core/run_state.dart b/packages/stem/lib/src/workflow/core/run_state.dart index fe8fcbf0..6b31b49c 100644 --- a/packages/stem/lib/src/workflow/core/run_state.dart +++ b/packages/stem/lib/src/workflow/core/run_state.dart @@ -1,5 +1,6 @@ import 'package:stem/src/core/clock.dart'; import 'package:stem/src/workflow/core/workflow_cancellation_policy.dart'; +import 'package:stem/src/workflow/core/workflow_runtime_metadata.dart'; import 'package:stem/src/workflow/core/workflow_status.dart'; import 'package:stem/src/workflow/core/workflow_store.dart' show WorkflowStore; import 'package:stem/src/workflow/workflow.dart' show WorkflowStore; @@ -70,6 +71,14 @@ class RunState { /// Parameters supplied at workflow start. final Map params; + /// Parameters visible to workflow handlers (internal runtime keys removed). + Map get workflowParams => + WorkflowRunRuntimeMetadata.stripFromParams(params); + + /// Run-scoped runtime metadata. + WorkflowRunRuntimeMetadata get runtimeMetadata => + WorkflowRunRuntimeMetadata.fromParams(params); + /// Timestamp when the workflow run was created. final DateTime createdAt; @@ -149,6 +158,36 @@ class RunState { DateTime? get suspensionDeliveredAt => _dateFromJson(suspensionData?['deliveredAt']); + /// Orchestration queue associated with this run. + String get orchestrationQueue => runtimeMetadata.orchestrationQueue; + + /// Continuation queue associated with this run. + String get continuationQueue => runtimeMetadata.continuationQueue; + + /// Execution queue associated with this run. + String get executionQueue => runtimeMetadata.executionQueue; + + /// Serialization format associated with this run. + String get serializationFormat => runtimeMetadata.serializationFormat; + + /// Serialization version associated with this run. + String get serializationVersion => runtimeMetadata.serializationVersion; + + /// Framing format associated with this run. + String get frameFormat => runtimeMetadata.frameFormat; + + /// Framing version associated with this run. + String get frameVersion => runtimeMetadata.frameVersion; + + /// Encryption scope associated with this run. + String get encryptionScope => runtimeMetadata.encryptionScope; + + /// Whether run payloads are expected to be encrypted. + bool get encryptionEnabled => runtimeMetadata.encryptionEnabled; + + /// Stream identifier associated with this run. + String? get streamId => runtimeMetadata.streamId; + /// Returns a copy of this run state with updated fields. RunState copyWith({ WorkflowStatus? status, diff --git a/packages/stem/lib/src/workflow/core/workflow_definition.dart b/packages/stem/lib/src/workflow/core/workflow_definition.dart index acab554e..814d422c 100644 --- a/packages/stem/lib/src/workflow/core/workflow_definition.dart +++ b/packages/stem/lib/src/workflow/core/workflow_definition.dart @@ -11,8 +11,8 @@ /// 1. **Flow**: A list of discrete `FlowStep`s that execute in order. This is /// the most common model and is easily visualized. /// 2. **Script**: A procedural Dart function that uses `context.step` to -/// wrap individual pieces of work. This allows for complex branching -/// logic and loops using standard Dart control flow. +/// create durable checkpoints around individual pieces of work. This allows +/// for complex branching logic and loops using standard Dart control flow. /// /// ## Versioning and Metadata /// @@ -38,7 +38,7 @@ /// ```dart /// final script = WorkflowDefinition.script( /// name: 'process_order', -/// body: (context) async { +/// run: (context) async { /// await context.step('validate_order', ...); /// if (isPremium) { /// await context.step('apply_discount', ...); @@ -54,7 +54,9 @@ library; import 'dart:async'; +import 'dart:convert'; +import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/workflow/core/flow.dart' show Flow; import 'package:stem/src/workflow/core/flow_context.dart'; import 'package:stem/src/workflow/core/flow_step.dart'; @@ -76,8 +78,9 @@ enum WorkflowDefinitionKind { } /// Declarative workflow definition built via [FlowBuilder] or a higher-level -/// script facade. The definition captures the ordered steps that the runtime -/// will execute along with optional script metadata used by the facade runner. +/// script facade. Flow definitions capture an ordered execution plan. Script +/// definitions capture a script body plus optional checkpoint metadata used for +/// introspection and tooling. class WorkflowDefinition { /// Internal constructor used by builders and script facades. WorkflowDefinition._({ @@ -89,9 +92,13 @@ class WorkflowDefinition { this.description, Map? metadata, this.scriptBody, + Object? Function(Object? value)? resultEncoder, + Object? Function(Object? payload)? resultDecoder, }) : _kind = kind, _steps = steps, _edges = edges, + _resultEncoder = resultEncoder, + _resultDecoder = resultDecoder, metadata = metadata == null ? null : Map.unmodifiable(metadata); /// Rehydrates a workflow definition from serialized JSON. @@ -111,7 +118,7 @@ class WorkflowDefinition { return WorkflowDefinition._( name: json['name']?.toString() ?? '', kind: kind, - steps: const [], + steps: steps, edges: edges, version: json['version']?.toString(), description: json['description']?.toString(), @@ -136,6 +143,7 @@ class WorkflowDefinition { String? version, String? description, Map? metadata, + PayloadCodec? resultCodec, }) { final steps = []; build(FlowBuilder(steps)); @@ -143,6 +151,16 @@ class WorkflowDefinition { for (var i = 0; i < steps.length - 1; i += 1) { edges.add(WorkflowEdge(from: steps[i].name, to: steps[i + 1].name)); } + Object? Function(Object?)? resultEncoder; + Object? Function(Object?)? resultDecoder; + if (resultCodec != null) { + resultEncoder = (Object? value) { + return resultCodec.encodeDynamic(value); + }; + resultDecoder = (Object? payload) { + return resultCodec.decodeDynamic(payload); + }; + } return WorkflowDefinition._( name: name, kind: WorkflowDefinitionKind.flow, @@ -151,6 +169,8 @@ class WorkflowDefinition { version: version, description: description, metadata: metadata, + resultEncoder: resultEncoder, + resultDecoder: resultDecoder, ); } @@ -158,18 +178,34 @@ class WorkflowDefinition { factory WorkflowDefinition.script({ required String name, required WorkflowScriptBody run, + Iterable steps = const [], + Iterable checkpoints = const [], String? version, String? description, Map? metadata, + PayloadCodec? resultCodec, }) { + final declaredCheckpoints = checkpoints.isNotEmpty ? checkpoints : steps; + Object? Function(Object?)? resultEncoder; + Object? Function(Object?)? resultDecoder; + if (resultCodec != null) { + resultEncoder = (Object? value) { + return resultCodec.encodeDynamic(value); + }; + resultDecoder = (Object? payload) { + return resultCodec.decodeDynamic(payload); + }; + } return WorkflowDefinition._( name: name, kind: WorkflowDefinitionKind.script, - steps: const [], + steps: List.unmodifiable(declaredCheckpoints), version: version, description: description, metadata: metadata, scriptBody: run, + resultEncoder: resultEncoder, + resultDecoder: resultDecoder, ); } @@ -191,6 +227,9 @@ class WorkflowDefinition { /// Optional script body when using the script facade. final WorkflowScriptBody? scriptBody; + final Object? Function(Object? value)? _resultEncoder; + final Object? Function(Object? payload)? _resultDecoder; + /// Ordered list of steps for flow-based workflows. List get steps => List.unmodifiable(_steps); @@ -200,6 +239,53 @@ class WorkflowDefinition { /// Whether this definition represents a script-based workflow. bool get isScript => _kind == WorkflowDefinitionKind.script; + /// Looks up a declared step/checkpoint by its base name. + FlowStep? stepByName(String name) { + for (final step in _steps) { + if (step.name == name) { + return step; + } + } + return null; + } + + /// Encodes a final workflow result before it is persisted. + Object? encodeResult(Object? value) { + if (value == null) return null; + final encoder = _resultEncoder; + if (encoder == null) return value; + return encoder(value); + } + + /// Decodes a persisted final workflow result. + Object? decodeResult(Object? payload) { + if (payload == null) return null; + final decoder = _resultDecoder; + if (decoder == null) return payload; + return decoder(payload); + } + + /// Stable identifier derived from immutable workflow definition fields. + String get stableId { + final basis = StringBuffer() + ..write(name) + ..write('|') + ..write(_kind.name) + ..write('|') + ..write(version ?? '') + ..write('|'); + for (final step in _steps) { + basis + ..write(step.name) + ..write(':') + ..write(step.kind.name) + ..write(':') + ..write(step.autoVersion ? '1' : '0') + ..write('|'); + } + return _stableHexDigest(basis.toString()); + } + /// Serialize the workflow definition for introspection. Map toJson() { final steps = >[]; @@ -220,6 +306,17 @@ class WorkflowDefinition { } } +String _stableHexDigest(String input) { + final bytes = utf8.encode(input); + var hash = 0xcbf29ce484222325; + const prime = 0x00000100000001B3; + for (final value in bytes) { + hash ^= value; + hash = (hash * prime) & 0xFFFFFFFFFFFFFFFF; + } + return hash.toRadixString(16).padLeft(16, '0'); +} + /// Describes a directed edge between workflow steps. class WorkflowEdge { /// Creates a workflow edge from [from] to [to]. @@ -283,25 +380,37 @@ class FlowBuilder { /// When [autoVersion] is `true`, the runtime stores checkpoints using a /// `name#iteration` convention so each execution is tracked separately and /// the handler receives the iteration number via [FlowContext.iteration]. - void step( + void step( String name, - FutureOr Function(FlowContext context) handler, { + FutureOr Function(FlowContext context) handler, { bool autoVersion = false, String? title, WorkflowStepKind kind = WorkflowStepKind.task, Iterable taskNames = const [], Map? metadata, + PayloadCodec? valueCodec, }) { _steps.add( - FlowStep( - name: name, - handler: handler, - autoVersion: autoVersion, - title: title, - kind: kind, - taskNames: taskNames, - metadata: metadata, - ), + valueCodec == null + ? FlowStep( + name: name, + handler: handler, + autoVersion: autoVersion, + title: title, + kind: kind, + taskNames: taskNames, + metadata: metadata, + ) + : FlowStep.typed( + name: name, + handler: handler, + valueCodec: valueCodec, + autoVersion: autoVersion, + title: title, + kind: kind, + taskNames: taskNames, + metadata: metadata, + ), ); } } diff --git a/packages/stem/lib/src/workflow/core/workflow_ref.dart b/packages/stem/lib/src/workflow/core/workflow_ref.dart new file mode 100644 index 00000000..c6fc2f70 --- /dev/null +++ b/packages/stem/lib/src/workflow/core/workflow_ref.dart @@ -0,0 +1,84 @@ +import 'package:stem/src/workflow/core/workflow_cancellation_policy.dart'; + +/// Typed producer-facing reference to a registered workflow. +/// +/// This mirrors the role `TaskDefinition` plays for tasks: it centralizes the +/// workflow name plus parameter/result encoding rules so producer code can work +/// with one typed handle instead of raw workflow-name strings. +class WorkflowRef { + /// Creates a typed workflow reference. + const WorkflowRef({ + required this.name, + required this.encodeParams, + this.decodeResult, + }); + + /// Registered workflow name. + final String name; + + /// Encodes typed workflow parameters into the persisted parameter map. + final Map Function(TParams params) encodeParams; + + /// Optional decoder for the final workflow result payload. + final TResult Function(Object? payload)? decodeResult; + + /// Builds a workflow start call from typed arguments. + WorkflowStartCall call( + TParams params, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return WorkflowStartCall._( + definition: this, + params: params, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } + + /// Decodes a final workflow result payload. + TResult decode(Object? payload) { + if (payload == null) { + return null as TResult; + } + final decoder = decodeResult; + if (decoder != null) { + return decoder(payload); + } + return payload as TResult; + } +} + +/// Typed start request built from a [WorkflowRef]. +class WorkflowStartCall { + const WorkflowStartCall._({ + required this.definition, + required this.params, + this.parentRunId, + this.ttl, + this.cancellationPolicy, + }); + + /// Reference used to build this start call. + final WorkflowRef definition; + + /// Typed workflow parameters. + final TParams params; + + /// Optional parent workflow run. + final String? parentRunId; + + /// Optional run TTL. + final Duration? ttl; + + /// Optional cancellation policy. + final WorkflowCancellationPolicy? cancellationPolicy; + + /// Workflow name derived from [definition]. + String get name => definition.name; + + /// Encodes typed parameters into the workflow parameter map. + Map encodeParams() => definition.encodeParams(params); +} diff --git a/packages/stem/lib/src/workflow/core/workflow_resume.dart b/packages/stem/lib/src/workflow/core/workflow_resume.dart new file mode 100644 index 00000000..8e2c7f18 --- /dev/null +++ b/packages/stem/lib/src/workflow/core/workflow_resume.dart @@ -0,0 +1,31 @@ +import 'package:stem/src/core/payload_codec.dart'; +import 'package:stem/src/workflow/core/flow_context.dart'; +import 'package:stem/src/workflow/core/workflow_script_context.dart'; + +/// Typed resume helpers for durable workflow suspensions. +extension FlowContextResumeValues on FlowContext { + /// Returns the next resume payload as [T] and consumes it. + /// + /// When [codec] is provided, the stored durable payload is decoded through + /// that codec before being returned. + T? takeResumeValue({PayloadCodec? codec}) { + final payload = takeResumeData(); + if (payload == null) return null; + if (codec != null) return codec.decodeDynamic(payload) as T; + return payload as T; + } +} + +/// Typed resume helpers for durable script checkpoints. +extension WorkflowScriptStepResumeValues on WorkflowScriptStepContext { + /// Returns the next resume payload as [T] and consumes it. + /// + /// When [codec] is provided, the stored durable payload is decoded through + /// that codec before being returned. + T? takeResumeValue({PayloadCodec? codec}) { + final payload = takeResumeData(); + if (payload == null) return null; + if (codec != null) return codec.decodeDynamic(payload) as T; + return payload as T; + } +} diff --git a/packages/stem/lib/src/workflow/core/workflow_runtime_metadata.dart b/packages/stem/lib/src/workflow/core/workflow_runtime_metadata.dart new file mode 100644 index 00000000..ff9e15a9 --- /dev/null +++ b/packages/stem/lib/src/workflow/core/workflow_runtime_metadata.dart @@ -0,0 +1,170 @@ +import 'dart:collection'; + +/// Reserved params key storing internal runtime metadata for workflow runs. +const String workflowRuntimeMetadataParamKey = '__stem.workflow.runtime'; + +/// Logical channel used by workflow-related task enqueues. +enum WorkflowChannelKind { + /// Orchestration channel used by workflow continuation tasks. + orchestration, + + /// Execution channel used by step-spawned task work. + execution, +} + +/// Why a workflow continuation task was enqueued. +enum WorkflowContinuationReason { + /// Initial run dispatch from `startWorkflow`. + start, + + /// Run resumed because a timer/sleep became due. + due, + + /// Run resumed because an awaited external event was delivered. + event, + + /// Run was re-enqueued manually. + manual, + + /// Run was re-enqueued as part of replay/rewind operations. + replay, +} + +/// Run-scoped runtime metadata persisted alongside workflow params. +class WorkflowRunRuntimeMetadata { + /// Creates immutable runtime metadata. + const WorkflowRunRuntimeMetadata({ + required this.workflowId, + required this.orchestrationQueue, + required this.continuationQueue, + required this.executionQueue, + this.serializationFormat = 'json', + this.serializationVersion = '1', + this.frameFormat = 'json-frame', + this.frameVersion = '1', + this.encryptionScope = 'none', + this.encryptionEnabled = false, + this.streamId, + }); + + /// Restores metadata from a JSON map. + factory WorkflowRunRuntimeMetadata.fromJson(Map json) { + return WorkflowRunRuntimeMetadata( + workflowId: json['workflowId']?.toString() ?? '', + orchestrationQueue: + json['orchestrationQueue']?.toString().trim().isNotEmpty == true + ? json['orchestrationQueue']!.toString().trim() + : 'workflow', + continuationQueue: + json['continuationQueue']?.toString().trim().isNotEmpty == true + ? json['continuationQueue']!.toString().trim() + : 'workflow', + executionQueue: + json['executionQueue']?.toString().trim().isNotEmpty == true + ? json['executionQueue']!.toString().trim() + : 'default', + serializationFormat: + json['serializationFormat']?.toString().trim().isNotEmpty == true + ? json['serializationFormat']!.toString().trim() + : 'json', + serializationVersion: + json['serializationVersion']?.toString().trim().isNotEmpty == true + ? json['serializationVersion']!.toString().trim() + : '1', + frameFormat: json['frameFormat']?.toString().trim().isNotEmpty == true + ? json['frameFormat']!.toString().trim() + : 'json-frame', + frameVersion: json['frameVersion']?.toString().trim().isNotEmpty == true + ? json['frameVersion']!.toString().trim() + : '1', + encryptionScope: + json['encryptionScope']?.toString().trim().isNotEmpty == true + ? json['encryptionScope']!.toString().trim() + : 'none', + encryptionEnabled: json['encryptionEnabled'] == true, + streamId: json['streamId']?.toString(), + ); + } + + /// Extracts metadata from [params], defaulting when absent. + factory WorkflowRunRuntimeMetadata.fromParams(Map params) { + final raw = params[workflowRuntimeMetadataParamKey]; + if (raw is Map) { + return WorkflowRunRuntimeMetadata.fromJson(raw.cast()); + } + return const WorkflowRunRuntimeMetadata( + workflowId: '', + orchestrationQueue: 'workflow', + continuationQueue: 'workflow', + executionQueue: 'default', + ); + } + + /// Stable identifier for the workflow definition. + final String workflowId; + + /// Queue used for initial orchestration tasks. + final String orchestrationQueue; + + /// Queue used for continuation orchestration tasks. + final String continuationQueue; + + /// Default queue used for execution channel tasks. + final String executionQueue; + + /// Serialization format label for run-scoped payload framing. + final String serializationFormat; + + /// Serialization schema/version identifier. + final String serializationVersion; + + /// Stream frame format identifier. + final String frameFormat; + + /// Stream frame version identifier. + final String frameVersion; + + /// Encryption scope identifier. + final String encryptionScope; + + /// Whether run payloads are expected to be encrypted. + final bool encryptionEnabled; + + /// Stable stream identifier for per-run framing. + final String? streamId; + + /// Converts metadata to a JSON-compatible map. + Map toJson() { + return { + 'workflowId': workflowId, + 'orchestrationQueue': orchestrationQueue, + 'continuationQueue': continuationQueue, + 'executionQueue': executionQueue, + 'serializationFormat': serializationFormat, + 'serializationVersion': serializationVersion, + 'frameFormat': frameFormat, + 'frameVersion': frameVersion, + 'encryptionScope': encryptionScope, + 'encryptionEnabled': encryptionEnabled, + if (streamId != null && streamId!.isNotEmpty) 'streamId': streamId, + }; + } + + /// Returns a new params map containing this metadata under the reserved key. + Map attachToParams(Map params) { + return Map.unmodifiable({ + ...params, + workflowRuntimeMetadataParamKey: toJson(), + }); + } + + /// Returns params without internal runtime metadata. + static Map stripFromParams(Map params) { + if (!params.containsKey(workflowRuntimeMetadataParamKey)) { + return Map.unmodifiable(params); + } + final copy = Map.from(params) + ..remove(workflowRuntimeMetadataParamKey); + return UnmodifiableMapView(copy); + } +} diff --git a/packages/stem/lib/src/workflow/core/workflow_script.dart b/packages/stem/lib/src/workflow/core/workflow_script.dart index d3fb41f4..467885de 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script.dart @@ -1,21 +1,33 @@ +import 'package:stem/src/core/payload_codec.dart'; +import 'package:stem/src/workflow/core/flow_step.dart'; import 'package:stem/src/workflow/core/workflow_definition.dart'; /// High-level workflow facade that allows scripts to be authored as a single /// async function using `step`, `sleep`, and `awaitEvent` helpers. +/// +/// In script workflows, the `run` function is the execution plan. Declared +/// checkpoints are optional metadata used for tooling, manifests, and +/// dashboards. class WorkflowScript { /// Creates a workflow script definition. WorkflowScript({ required String name, required WorkflowScriptBody run, + Iterable steps = const [], + Iterable checkpoints = const [], String? version, String? description, Map? metadata, + PayloadCodec? resultCodec, }) : definition = WorkflowDefinition.script( name: name, run: run, + steps: steps, + checkpoints: checkpoints, version: version, description: description, metadata: metadata, + resultCodec: resultCodec, ); /// The constructed workflow definition. diff --git a/packages/stem/lib/src/workflow/core/workflow_step_entry.dart b/packages/stem/lib/src/workflow/core/workflow_step_entry.dart index a8818579..250bb3cb 100644 --- a/packages/stem/lib/src/workflow/core/workflow_step_entry.dart +++ b/packages/stem/lib/src/workflow/core/workflow_step_entry.dart @@ -30,6 +30,20 @@ class WorkflowStepEntry { /// Optional timestamp when the checkpoint was recorded. final DateTime? completedAt; + /// Base step name without any auto-version suffix. + String get baseName { + final hashIndex = name.indexOf('#'); + if (hashIndex == -1) return name; + return name.substring(0, hashIndex); + } + + /// Parsed iteration suffix for auto-versioned checkpoints, if present. + int? get iteration { + final hashIndex = name.lastIndexOf('#'); + if (hashIndex == -1) return null; + return int.tryParse(name.substring(hashIndex + 1)); + } + /// Converts this entry to a JSON-compatible map. Map toJson() { return { diff --git a/packages/stem/lib/src/workflow/core/workflow_store.dart b/packages/stem/lib/src/workflow/core/workflow_store.dart index 609bdf36..029a7f2e 100644 --- a/packages/stem/lib/src/workflow/core/workflow_store.dart +++ b/packages/stem/lib/src/workflow/core/workflow_store.dart @@ -8,6 +8,7 @@ import 'package:stem/src/workflow/core/workflow_watcher.dart'; abstract class WorkflowStore { /// Creates a new workflow run record and returns its run id. Future createRun({ + String? runId, required String workflow, required Map params, String? parentRunId, diff --git a/packages/stem/lib/src/workflow/runtime/workflow_introspection.dart b/packages/stem/lib/src/workflow/runtime/workflow_introspection.dart index 223944ac..a5c61c66 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_introspection.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_introspection.dart @@ -15,6 +15,12 @@ enum WorkflowStepEventType { retrying, } +/// Runtime-level workflow events emitted by orchestration transitions. +enum WorkflowRuntimeEventType { + /// A continuation task was enqueued for a run. + continuationEnqueued, +} + /// Step-level execution event emitted by the workflow runtime. class WorkflowStepEvent implements StemEvent { /// Creates a workflow step execution event. @@ -75,10 +81,54 @@ class WorkflowStepEvent implements StemEvent { }; } +/// Runtime orchestration event emitted by the workflow runtime. +class WorkflowRuntimeEvent implements StemEvent { + /// Creates a runtime orchestration event. + WorkflowRuntimeEvent({ + required this.runId, + required this.workflow, + required this.type, + required this.timestamp, + this.metadata, + }); + + /// Workflow run identifier. + final String runId; + + /// Workflow name. + final String workflow; + + /// Runtime event type. + final WorkflowRuntimeEventType type; + + /// Event timestamp. + final DateTime timestamp; + + /// Additional event metadata. + final Map? metadata; + + @override + String get eventName => 'workflow.runtime.${type.name}'; + + @override + DateTime get occurredAt => timestamp; + + @override + Map get attributes => { + 'runId': runId, + 'workflow': workflow, + if (metadata != null) 'metadata': metadata, + }; +} + /// Sink for workflow step execution events. mixin WorkflowIntrospectionSink { /// Records a workflow step execution [event]. Future recordStepEvent(WorkflowStepEvent event); + + /// Records a workflow runtime [event]. Optional for sinks that only care + /// about step-level traces. + Future recordRuntimeEvent(WorkflowRuntimeEvent event) async {} } /// Default no-op sink for workflow step events. @@ -88,4 +138,7 @@ class NoopWorkflowIntrospectionSink implements WorkflowIntrospectionSink { @override Future recordStepEvent(WorkflowStepEvent event) async {} + + @override + Future recordRuntimeEvent(WorkflowRuntimeEvent event) async {} } diff --git a/packages/stem/lib/src/workflow/runtime/workflow_manifest.dart b/packages/stem/lib/src/workflow/runtime/workflow_manifest.dart new file mode 100644 index 00000000..091b4b32 --- /dev/null +++ b/packages/stem/lib/src/workflow/runtime/workflow_manifest.dart @@ -0,0 +1,180 @@ +import 'dart:convert'; + +import 'package:stem/src/workflow/core/flow_step.dart'; +import 'package:stem/src/workflow/core/workflow_definition.dart'; + +/// Distinguishes between declared flow steps and script checkpoints. +enum WorkflowManifestStepRole { + /// Step that belongs to a declarative flow execution plan. + flowStep, + + /// Checkpoint declared by a script workflow for tooling/introspection. + scriptCheckpoint, +} + +/// Immutable manifest entry describing a workflow definition. +class WorkflowManifestEntry { + /// Creates a workflow manifest entry. + const WorkflowManifestEntry({ + required this.id, + required this.name, + required this.kind, + this.version, + this.description, + this.metadata, + this.steps = const [], + }); + + /// Stable workflow identifier. + final String id; + + /// Workflow name. + final String name; + + /// Workflow definition kind. + final WorkflowDefinitionKind kind; + + /// Optional workflow version. + final String? version; + + /// Optional workflow description. + final String? description; + + /// Optional workflow metadata. + final Map? metadata; + + /// Declared flow steps or script checkpoints. + final List steps; + + /// Human-friendly label for the declared nodes on this workflow. + String get stepCollectionLabel => + kind == WorkflowDefinitionKind.script ? 'checkpoints' : 'steps'; + + /// Alias for [steps] when treating script nodes as checkpoints. + List get checkpoints => steps; + + /// Serializes this entry to a JSON-compatible map. + Map toJson() { + final serializedSteps = steps + .map((step) => step.toJson()) + .toList(growable: false); + return { + 'id': id, + 'name': name, + 'kind': kind.name, + if (version != null) 'version': version, + if (description != null) 'description': description, + if (metadata != null) 'metadata': metadata, + 'stepCollectionLabel': stepCollectionLabel, + 'steps': serializedSteps, + if (kind == WorkflowDefinitionKind.script) 'checkpoints': serializedSteps, + }; + } +} + +/// Immutable manifest entry describing a workflow step or script checkpoint. +class WorkflowManifestStep { + /// Creates a workflow step manifest entry. + const WorkflowManifestStep({ + required this.id, + required this.name, + required this.position, + required this.role, + required this.kind, + required this.autoVersion, + this.title, + this.taskNames = const [], + this.metadata, + }); + + /// Stable step identifier. + final String id; + + /// Step name. + final String name; + + /// Zero-based position in the workflow. + final int position; + + /// Whether this node is part of a flow plan or a script checkpoint list. + final WorkflowManifestStepRole role; + + /// Step kind. + final WorkflowStepKind kind; + + /// Whether this step auto-versions checkpoints. + final bool autoVersion; + + /// Optional title. + final String? title; + + /// Associated task names. + final List taskNames; + + /// Optional step metadata. + final Map? metadata; + + /// Serializes this entry to a JSON-compatible map. + Map toJson() { + return { + 'id': id, + 'name': name, + 'position': position, + 'role': role.name, + 'kind': kind.name, + 'autoVersion': autoVersion, + if (title != null) 'title': title, + if (taskNames.isNotEmpty) 'taskNames': taskNames, + if (metadata != null) 'metadata': metadata, + }; + } +} + +/// Converts workflow definitions into typed manifest entries. +extension WorkflowManifestDefinition on WorkflowDefinition { + /// Builds a manifest entry for this definition. + WorkflowManifestEntry toManifestEntry() { + final workflowId = stableId; + final stepEntries = []; + for (var index = 0; index < steps.length; index += 1) { + final step = steps[index]; + stepEntries.add( + WorkflowManifestStep( + id: _stableHexDigest('$workflowId:${step.name}:$index'), + name: step.name, + position: index, + role: isScript + ? WorkflowManifestStepRole.scriptCheckpoint + : WorkflowManifestStepRole.flowStep, + kind: step.kind, + autoVersion: step.autoVersion, + title: step.title, + taskNames: step.taskNames, + metadata: step.metadata, + ), + ); + } + return WorkflowManifestEntry( + id: workflowId, + name: name, + kind: isScript + ? WorkflowDefinitionKind.script + : WorkflowDefinitionKind.flow, + version: version, + description: description, + metadata: metadata, + steps: stepEntries, + ); + } +} + +String _stableHexDigest(String input) { + final bytes = utf8.encode(input); + var hash = 0xcbf29ce484222325; + const prime = 0x00000100000001B3; + for (final value in bytes) { + hash ^= value; + hash = (hash * prime) & 0xFFFFFFFFFFFFFFFF; + } + return hash.toRadixString(16).padLeft(16, '0'); +} diff --git a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart index 8e336bf1..18aa338b 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart @@ -28,9 +28,12 @@ library; import 'dart:async'; +import 'package:contextual/contextual.dart' show Context; import 'package:stem/src/core/contracts.dart'; +import 'package:stem/src/core/payload_codec.dart'; import 'package:stem/src/core/stem.dart'; import 'package:stem/src/core/task_invocation.dart'; +import 'package:stem/src/observability/logging.dart'; import 'package:stem/src/signals/emitter.dart'; import 'package:stem/src/signals/payloads.dart'; import 'package:stem/src/workflow/core/event_bus.dart'; @@ -40,11 +43,15 @@ import 'package:stem/src/workflow/core/run_state.dart'; import 'package:stem/src/workflow/core/workflow_cancellation_policy.dart'; import 'package:stem/src/workflow/core/workflow_clock.dart'; import 'package:stem/src/workflow/core/workflow_definition.dart'; +import 'package:stem/src/workflow/core/workflow_ref.dart'; +import 'package:stem/src/workflow/core/workflow_runtime_metadata.dart'; import 'package:stem/src/workflow/core/workflow_script_context.dart'; import 'package:stem/src/workflow/core/workflow_status.dart'; import 'package:stem/src/workflow/core/workflow_store.dart'; import 'package:stem/src/workflow/runtime/workflow_introspection.dart'; +import 'package:stem/src/workflow/runtime/workflow_manifest.dart'; import 'package:stem/src/workflow/runtime/workflow_registry.dart'; +import 'package:stem/src/workflow/runtime/workflow_views.dart'; import 'package:uuid/uuid.dart'; /// Task name used for workflow run execution tasks. @@ -71,6 +78,8 @@ class WorkflowRuntime { this.leaseExtension = const Duration(seconds: 30), this.runLeaseDuration = const Duration(seconds: 30), this.queue = 'workflow', + String? continuationQueue, + String? executionQueue, WorkflowRegistry? registry, WorkflowIntrospectionSink? introspectionSink, String? runtimeId, @@ -82,6 +91,14 @@ class WorkflowRuntime { _registry = registry ?? InMemoryWorkflowRegistry(), _introspection = introspectionSink ?? const NoopWorkflowIntrospectionSink(), + continuationQueue = _resolveQueueName( + continuationQueue, + fallback: queue, + ), + executionQueue = _resolveQueueName( + executionQueue, + fallback: 'default', + ), _runtimeId = runtimeId ?? _defaultRuntimeId(); final Stem _stem; @@ -100,6 +117,12 @@ class WorkflowRuntime { /// Queue name used to enqueue workflow run tasks. final String queue; + + /// Queue used for continuation tasks after suspension/event delivery. + final String continuationQueue; + + /// Default queue for step-spawned execution channel tasks. + final String executionQueue; final WorkflowClock _clock; final StemSignalEmitter _signals = const StemSignalEmitter( defaultSender: 'workflow', @@ -120,6 +143,13 @@ class WorkflowRuntime { _registry.register(definition); } + /// Returns typed manifest entries for all registered workflows. + List workflowManifest() { + return _registry.all + .map((definition) => definition.toManifestEntry()) + .toList(growable: false); + } + /// Persists a new workflow run and enqueues it for execution. /// /// Throws [ArgumentError] if the workflow name is unknown. The returned run @@ -138,9 +168,25 @@ class WorkflowRuntime { if (definition == null) { throw ArgumentError.value(name, 'name', 'Workflow is not registered'); } + final requestedRunId = const Uuid().v7(); + final runtimeMetadata = WorkflowRunRuntimeMetadata( + workflowId: definition.stableId, + orchestrationQueue: queue, + continuationQueue: continuationQueue, + executionQueue: executionQueue, + serializationFormat: _stem.payloadEncoders.defaultArgsEncoder.id, + serializationVersion: '1', + frameFormat: 'stem-envelope', + frameVersion: '1', + encryptionScope: _stem.signer != null ? 'signed-envelope' : 'none', + encryptionEnabled: _stem.signer != null, + streamId: '${name}_$requestedRunId', + ); + final persistedParams = runtimeMetadata.attachToParams(params); final runId = await _store.createRun( + runId: requestedRunId, workflow: name, - params: params, + params: persistedParams, parentRunId: parentRunId, ttl: ttl, cancellationPolicy: cancellationPolicy, @@ -152,10 +198,46 @@ class WorkflowRuntime { status: WorkflowRunStatus.running, ), ); - await _enqueueRun(runId, workflow: name); + await _enqueueRun( + runId, + workflow: name, + continuation: false, + reason: WorkflowContinuationReason.start, + runtimeMetadata: runtimeMetadata, + ); return runId; } + /// Starts a workflow from a typed [WorkflowRef]. + Future startWorkflowRef( + WorkflowRef definition, + TParams params, { + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return startWorkflow( + definition.name, + params: definition.encodeParams(params), + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } + + /// Starts a workflow from a prebuilt [WorkflowStartCall]. + Future startWorkflowCall( + WorkflowStartCall call, + ) { + return startWorkflowRef( + call.definition, + call.params, + parentRunId: call.parentRunId, + ttl: call.ttl, + cancellationPolicy: call.cancellationPolicy, + ); + } + /// Emits an external event and resumes all runs waiting on [topic]. /// /// Each resumed run receives the event as `resumeData` for the awaiting step @@ -180,7 +262,13 @@ class WorkflowRuntime { if (await _maybeCancelForPolicy(state, now: now)) { continue; } - await _enqueueRun(resolution.runId, workflow: state.workflow); + await _enqueueRun( + resolution.runId, + workflow: state.workflow, + continuation: true, + reason: WorkflowContinuationReason.event, + runtimeMetadata: state.runtimeMetadata, + ); } if (resolutions.length < batchSize) { break; @@ -188,6 +276,21 @@ class WorkflowRuntime { } } + /// Emits a typed external event that serializes to the existing map-based + /// workflow event transport. + /// + /// When [codec] is provided, [value] is encoded before being emitted. The + /// encoded value must be a `Map` because workflow watcher + /// resolution and event transport are currently map-shaped. + Future emitValue( + String topic, + T value, { + PayloadCodec? codec, + }) { + final encoded = codec != null ? codec.encodeDynamic(value) : value; + return emit(topic, _coerceEventPayload(topic, encoded)); + } + /// Starts periodic polling that resumes runs whose wake-up time has elapsed. Future start() async { if (_started) return; @@ -204,7 +307,13 @@ class WorkflowRuntime { continue; } await _store.markResumed(runId, data: state.suspensionData); - await _enqueueRun(runId, workflow: state.workflow); + await _enqueueRun( + runId, + workflow: state.workflow, + continuation: true, + reason: WorkflowContinuationReason.due, + runtimeMetadata: state.runtimeMetadata, + ); } }); } @@ -235,6 +344,53 @@ class WorkflowRuntime { TaskHandler workflowRunnerHandler() => _WorkflowRunTaskHandler(runtime: this); + /// Returns a uniform run view for dashboard/CLI drilldowns. + Future viewRun(String runId) async { + final state = await _store.get(runId); + if (state == null) return null; + return WorkflowRunView.fromState(state); + } + + /// Returns persisted step views for [runId]. + Future> viewSteps(String runId) async { + final state = await _store.get(runId); + if (state == null) return const []; + final steps = await _store.listSteps(runId); + return steps + .map( + (entry) => WorkflowStepView.fromEntry( + runId: runId, + workflow: state.workflow, + entry: entry, + ), + ) + .toList(growable: false); + } + + /// Returns combined run+step drilldown view for [runId]. + Future viewRunDetail(String runId) async { + final run = await viewRun(runId); + if (run == null) return null; + final steps = await viewSteps(runId); + return WorkflowRunDetailView(run: run, steps: steps); + } + + /// Returns uniform run views filtered by workflow/status. + Future> listRunViews({ + String? workflow, + WorkflowStatus? status, + int limit = 50, + int offset = 0, + }) async { + final runs = await _store.listRuns( + workflow: workflow, + status: status, + limit: limit, + offset: offset, + ); + return runs.map(WorkflowRunView.fromState).toList(growable: false); + } + /// Executes steps for [runId] until completion or the next suspension. /// /// Safe to invoke multiple times; if the run is already terminal the call is @@ -295,6 +451,14 @@ class WorkflowRuntime { final wasSuspended = runState.status == WorkflowStatus.suspended; await _store.markRunning(runId); if (wasSuspended) { + stemLogger.debug( + 'Workflow {workflow} resumed', + _runtimeLogContext( + workflow: runState.workflow, + runId: runId, + extra: {'runtimeId': _runtimeId}, + ), + ); await _signals.workflowRunResumed( WorkflowRunPayload( runId: runId, @@ -319,7 +483,9 @@ class WorkflowRuntime { final completedCount = completedIterations[prevStep.name] ?? 0; if (completedCount > 0) { final checkpoint = _checkpointName(prevStep, completedCount - 1); - previousResult = await _store.readStep(runId, checkpoint); + previousResult = prevStep.decodeValue( + await _store.readStep(runId, checkpoint), + ); } } var resumeData = suspensionData?['payload']; @@ -378,7 +544,7 @@ class WorkflowRuntime { final cached = await _store.readStep(runId, checkpointName); if (cached != null) { - previousResult = cached; + previousResult = step.decodeValue(cached); await _recordStepEvent( WorkflowStepEventType.completed, runState, @@ -402,7 +568,7 @@ class WorkflowRuntime { workflow: runState.workflow, runId: runId, stepName: step.name, - params: runState.params, + params: runState.workflowParams, previousResult: previousResult, stepIndex: cursor, iteration: iteration, @@ -411,6 +577,7 @@ class WorkflowRuntime { enqueuer: _stepEnqueuer( taskContext: taskContext, baseMeta: stepMeta, + targetExecutionQueue: runState.executionQueue, ), ); resumeData = null; @@ -424,6 +591,19 @@ class WorkflowRuntime { return; } catch (error, stack) { await _store.markFailed(runId, error, stack); + stemLogger.warning( + 'Workflow {workflow} failed', + _runtimeLogContext( + workflow: runState.workflow, + runId: runId, + step: step.name, + extra: { + 'error': error.toString(), + 'stack': stack.toString(), + 'runtimeId': _runtimeId, + }, + ), + ); await _recordStepEvent( WorkflowStepEventType.failed, runState, @@ -490,6 +670,20 @@ class WorkflowRuntime { }, ), ); + stemLogger.debug( + 'Workflow {workflow} suspended', + _runtimeLogContext( + workflow: runState.workflow, + runId: runId, + step: step.name, + extra: { + 'workflowSuspensionType': 'sleep', + 'resumeAt': resumeAt.toIso8601String(), + 'workflowIteration': iteration, + 'runtimeId': _runtimeId, + }, + ), + ); } else if (control.type == FlowControlType.waitForEvent) { metadata['type'] = 'event'; metadata['topic'] = control.topic; @@ -527,18 +721,34 @@ class WorkflowRuntime { }, ), ); + stemLogger.debug( + 'Workflow {workflow} suspended', + _runtimeLogContext( + workflow: runState.workflow, + runId: runId, + step: step.name, + extra: { + 'workflowSuspensionType': 'event', + 'topic': control.topic!, + 'workflowIteration': iteration, + if (deadline != null) 'deadline': deadline.toIso8601String(), + 'runtimeId': _runtimeId, + }, + ), + ); } return; } - await _store.saveStep(runId, checkpointName, result); + final storedResult = step.encodeValue(result); + await _store.saveStep(runId, checkpointName, storedResult); await _extendLeases(taskContext, runId); await _recordStepEvent( WorkflowStepEventType.completed, runState, step.name, iteration: iteration, - result: result, + result: storedResult, ); if (step.autoVersion) { completedIterations[step.name] = iteration + 1; @@ -549,14 +759,22 @@ class WorkflowRuntime { cursor += 1; } - await _store.markCompleted(runId, previousResult); - await _extendLeases(taskContext, runId); + final storedWorkflowResult = definition.encodeResult(previousResult); + await _store.markCompleted(runId, storedWorkflowResult); + stemLogger.debug( + 'Workflow {workflow} completed', + _runtimeLogContext( + workflow: runState.workflow, + runId: runId, + extra: {'runtimeId': _runtimeId}, + ), + ); await _signals.workflowRunCompleted( WorkflowRunPayload( runId: runId, workflow: runState.workflow, status: WorkflowRunStatus.completed, - metadata: {'result': previousResult}, + metadata: {'result': storedWorkflowResult}, ), ); } on _WorkflowLeaseLost { @@ -580,6 +798,14 @@ class WorkflowRuntime { final wasSuspended = runState.status == WorkflowStatus.suspended; await _store.markRunning(runId); if (wasSuspended) { + stemLogger.debug( + 'Workflow {workflow} resumed', + _runtimeLogContext( + workflow: runState.workflow, + runId: runId, + extra: {'runtimeId': _runtimeId}, + ), + ); await _signals.workflowRunResumed( WorkflowRunPayload( runId: runId, @@ -592,13 +818,17 @@ class WorkflowRuntime { final completedIterations = await _loadCompletedIterations(runId); Object? previousResult; if (steps.isNotEmpty) { - previousResult = steps.last.value; + previousResult = definition + .stepByName(steps.last.baseName) + ?.decodeValue(steps.last.value) ?? + steps.last.value; } final execution = _WorkflowScriptExecution( runtime: this, runState: runState, taskContext: taskContext, completedIterations: completedIterations, + definition: definition, previousResult: previousResult, initialStepIndex: steps.length, suspensionData: runState.suspensionData, @@ -610,14 +840,22 @@ class WorkflowRuntime { if (execution.wasSuspended) { return; } - await _store.markCompleted(runId, result); - await _extendLeases(taskContext, runId); + final storedWorkflowResult = definition.encodeResult(result); + await _store.markCompleted(runId, storedWorkflowResult); + stemLogger.debug( + 'Workflow {workflow} completed', + _runtimeLogContext( + workflow: runState.workflow, + runId: runId, + extra: {'runtimeId': _runtimeId}, + ), + ); await _signals.workflowRunCompleted( WorkflowRunPayload( runId: runId, workflow: runState.workflow, status: WorkflowRunStatus.completed, - metadata: {'result': result}, + metadata: {'result': storedWorkflowResult}, ), ); } on _WorkflowLeaseLost { @@ -626,6 +864,19 @@ class WorkflowRuntime { return; } catch (error, stack) { await _store.markFailed(runId, error, stack); + stemLogger.warning( + 'Workflow {workflow} failed', + _runtimeLogContext( + workflow: runState.workflow, + runId: runId, + step: execution.lastStepName, + extra: { + 'error': error.toString(), + 'stack': stack.toString(), + 'runtimeId': _runtimeId, + }, + ), + ); await _signals.workflowRunFailed( WorkflowRunPayload( runId: runId, @@ -668,6 +919,28 @@ class WorkflowRuntime { } } + /// Records a runtime orchestration event to the introspection sink. + Future _recordRuntimeEvent({ + required String runId, + required String workflow, + required WorkflowRuntimeEventType type, + Map? metadata, + }) async { + try { + await _introspection.recordRuntimeEvent( + WorkflowRuntimeEvent( + runId: runId, + workflow: workflow, + type: type, + timestamp: _clock.now(), + metadata: metadata == null ? null : Map.unmodifiable(metadata), + ), + ); + } on Object catch (_) { + // Ignore introspection failures to avoid impacting workflow execution. + } + } + /// Loads the latest iteration number for each step name. Future> _loadCompletedIterations(String runId) async { final entries = await _store.listSteps(runId); @@ -773,18 +1046,93 @@ class WorkflowRuntime { /// Generates a unique runtime identifier for workflow lease ownership. static String _defaultRuntimeId() => 'workflow-runtime-${const Uuid().v7()}'; + static String _resolveQueueName(String? raw, {required String fallback}) { + final trimmed = raw?.trim(); + if (trimmed == null || trimmed.isEmpty) return fallback; + return trimmed; + } + /// Enqueues a workflow run execution task. - Future _enqueueRun(String runId, {String? workflow}) async { + Future _enqueueRun( + String runId, { + String? workflow, + required bool continuation, + required WorkflowContinuationReason reason, + WorkflowRunRuntimeMetadata? runtimeMetadata, + }) async { + final metadata = + runtimeMetadata ?? + WorkflowRunRuntimeMetadata( + workflowId: '', + orchestrationQueue: queue, + continuationQueue: continuationQueue, + executionQueue: executionQueue, + ); + final orchestrationQueue = _resolveQueueName( + metadata.orchestrationQueue, + fallback: queue, + ); + final resolvedContinuationQueue = _resolveQueueName( + metadata.continuationQueue, + fallback: continuationQueue, + ); + final resolvedExecutionQueue = _resolveQueueName( + metadata.executionQueue, + fallback: executionQueue, + ); + final targetQueue = continuation + ? resolvedContinuationQueue + : orchestrationQueue; final meta = { + 'stem.workflow.channel': WorkflowChannelKind.orchestration.name, 'stem.workflow.runId': runId, + 'stem.workflow.continuation': continuation, + 'stem.workflow.continuationReason': reason.name, + 'stem.workflow.orchestrationQueue': orchestrationQueue, + 'stem.workflow.continuationQueue': resolvedContinuationQueue, + 'stem.workflow.executionQueue': resolvedExecutionQueue, + 'stem.workflow.serialization.format': metadata.serializationFormat, + 'stem.workflow.serialization.version': metadata.serializationVersion, + 'stem.workflow.frame.format': metadata.frameFormat, + 'stem.workflow.frame.version': metadata.frameVersion, + 'stem.workflow.encryption.scope': metadata.encryptionScope, + 'stem.workflow.encryption.enabled': metadata.encryptionEnabled, + if (metadata.streamId != null) + 'stem.workflow.stream.id': metadata.streamId, + if (metadata.workflowId.isNotEmpty) + 'stem.workflow.id': metadata.workflowId, if (workflow != null && workflow.isNotEmpty) 'stem.workflow.name': workflow, }; + await _recordRuntimeEvent( + runId: runId, + workflow: workflow ?? '', + type: WorkflowRuntimeEventType.continuationEnqueued, + metadata: { + 'continuation': continuation, + 'reason': reason.name, + 'queue': targetQueue, + }, + ); await _stem.enqueue( workflowRunTaskName, args: {'runId': runId}, meta: meta, - options: TaskOptions(queue: queue), + options: TaskOptions(queue: targetQueue), + ); + stemLogger.debug( + 'Workflow {workflow} enqueued', + _runtimeLogContext( + workflow: workflow ?? '', + runId: runId, + extra: { + 'workflowChannel': WorkflowChannelKind.orchestration.name, + 'workflowContinuation': continuation, + 'workflowReason': reason.name, + 'queue': targetQueue, + 'runtimeId': _runtimeId, + }, + ), ); } @@ -795,12 +1143,25 @@ class WorkflowRuntime { required int stepIndex, required int iteration, }) { + final runtime = runState.runtimeMetadata; return Map.unmodifiable({ + 'stem.workflow.channel': WorkflowChannelKind.execution.name, 'stem.workflow.name': runState.workflow, 'stem.workflow.runId': runState.id, 'stem.workflow.step': stepName, 'stem.workflow.stepIndex': stepIndex, 'stem.workflow.iteration': iteration, + 'stem.workflow.orchestrationQueue': runState.orchestrationQueue, + 'stem.workflow.continuationQueue': runState.continuationQueue, + 'stem.workflow.executionQueue': runState.executionQueue, + 'stem.workflow.serialization.format': runtime.serializationFormat, + 'stem.workflow.serialization.version': runtime.serializationVersion, + 'stem.workflow.frame.format': runtime.frameFormat, + 'stem.workflow.frame.version': runtime.frameVersion, + 'stem.workflow.encryption.scope': runtime.encryptionScope, + 'stem.workflow.encryption.enabled': runtime.encryptionEnabled, + if (runtime.streamId != null) 'stem.workflow.stream.id': runtime.streamId, + if (runtime.workflowId.isNotEmpty) 'stem.workflow.id': runtime.workflowId, }); } @@ -808,9 +1169,35 @@ class WorkflowRuntime { TaskEnqueuer _stepEnqueuer({ required Map baseMeta, TaskContext? taskContext, + String? targetExecutionQueue, }) { final delegate = taskContext ?? _stem; - return _WorkflowStepEnqueuer(delegate: delegate, baseMeta: baseMeta); + return _WorkflowStepEnqueuer( + delegate: delegate, + baseMeta: baseMeta, + executionQueue: _resolveQueueName( + targetExecutionQueue, + fallback: executionQueue, + ), + ); + } + + Context _runtimeLogContext({ + required String workflow, + required String runId, + String? step, + Map extra = const {}, + }) { + return stemLogContext( + component: 'stem', + subsystem: 'workflow', + fields: { + 'workflow': workflow, + 'workflowRunId': runId, + if (step != null && step.isNotEmpty) 'workflowStep': step, + ...extra, + }, + ); } /// Returns true when a cancellation policy triggers a terminal cancel. @@ -934,6 +1321,7 @@ class _WorkflowRunTaskHandler implements TaskHandler { class _WorkflowScriptExecution implements WorkflowScriptContext { _WorkflowScriptExecution({ required this.runtime, + required this.definition, required this.runState, required this.taskContext, required Map completedIterations, @@ -952,6 +1340,7 @@ class _WorkflowScriptExecution implements WorkflowScriptContext { clock = runtime.clock; final WorkflowRuntime runtime; + final WorkflowDefinition definition; final RunState runState; final TaskContext? taskContext; final Map _completedIterations; @@ -972,7 +1361,7 @@ class _WorkflowScriptExecution implements WorkflowScriptContext { String? get lastStepName => _lastStepName; @override - Map get params => runState.params; + Map get params => runState.workflowParams; @override String get runId => runState.id; @@ -1033,12 +1422,14 @@ class _WorkflowScriptExecution implements WorkflowScriptContext { ), ); + final declaredStep = definition.stepByName(name); final cached = await runtime._store.readStep( runId, checkpointName, ); if (cached != null) { - _previousResult = cached; + final decodedCached = declaredStep?.decodeValue(cached) ?? cached; + _previousResult = decodedCached; await runtime._recordStepEvent( WorkflowStepEventType.completed, runState, @@ -1054,7 +1445,7 @@ class _WorkflowScriptExecution implements WorkflowScriptContext { } _stepIndex += 1; await runtime._extendLeases(taskContext, runId); - return cached as T; + return decodedCached as T; } final resumeData = _takeResumePayload(name, autoVersion ? iteration : null); @@ -1073,6 +1464,7 @@ class _WorkflowScriptExecution implements WorkflowScriptContext { enqueuer: runtime._stepEnqueuer( taskContext: taskContext, baseMeta: stepMeta, + targetExecutionQueue: runState.executionQueue, ), ); T result; @@ -1100,14 +1492,15 @@ class _WorkflowScriptExecution implements WorkflowScriptContext { } } - await runtime._store.saveStep(runId, checkpointName, result); + final storedResult = declaredStep?.encodeValue(result) ?? result; + await runtime._store.saveStep(runId, checkpointName, storedResult); await runtime._extendLeases(taskContext, runId); await runtime._recordStepEvent( WorkflowStepEventType.completed, runState, name, iteration: iteration, - result: result, + result: storedResult, ); if (autoVersion) { _completedIterations[name] = iteration + 1; @@ -1195,6 +1588,20 @@ class _WorkflowScriptExecution implements WorkflowScriptContext { metadata: {'type': 'sleep', 'resumeAt': resumeAt.toIso8601String()}, ), ); + stemLogger.debug( + 'Workflow {workflow} suspended', + runtime._runtimeLogContext( + workflow: workflow, + runId: runId, + step: stepName, + extra: { + 'workflowSuspensionType': 'sleep', + 'resumeAt': resumeAt.toIso8601String(), + 'workflowIteration': iteration, + 'runtimeId': runtime._runtimeId, + }, + ), + ); } else if (control.type == _ScriptControlType.waitForEvent) { metadata['type'] = 'event'; metadata['topic'] = control.topic; @@ -1228,6 +1635,21 @@ class _WorkflowScriptExecution implements WorkflowScriptContext { }, ), ); + stemLogger.debug( + 'Workflow {workflow} suspended', + runtime._runtimeLogContext( + workflow: workflow, + runId: runId, + step: stepName, + extra: { + 'workflowSuspensionType': 'event', + 'topic': control.topic!, + 'workflowIteration': iteration, + if (deadline != null) 'deadline': deadline.toIso8601String(), + 'runtimeId': runtime._runtimeId, + }, + ), + ); } _wasSuspended = true; } @@ -1350,10 +1772,12 @@ class _WorkflowStepEnqueuer implements TaskEnqueuer { _WorkflowStepEnqueuer({ required this.delegate, required this.baseMeta, + required this.executionQueue, }); final TaskEnqueuer delegate; final Map baseMeta; + final String executionQueue; @override Future enqueue( @@ -1366,11 +1790,15 @@ class _WorkflowStepEnqueuer implements TaskEnqueuer { }) { /// Merges workflow metadata into task enqueue requests. final mergedMeta = Map.from(baseMeta)..addAll(meta); + final resolvedOptions = + (options.queue == 'default' && executionQueue != 'default') + ? options.copyWith(queue: executionQueue) + : options; return delegate.enqueue( name, args: args, headers: headers, - options: options, + options: resolvedOptions, meta: mergedMeta, enqueueOptions: enqueueOptions, ); @@ -1382,7 +1810,18 @@ class _WorkflowStepEnqueuer implements TaskEnqueuer { TaskEnqueueOptions? enqueueOptions, }) { final mergedMeta = Map.from(baseMeta)..addAll(call.meta); + TaskOptions? resolvedOptions = call.options; + if (resolvedOptions == null) { + final inherited = call.definition.defaultOptions; + if (inherited.queue == 'default' && executionQueue != 'default') { + resolvedOptions = inherited.copyWith(queue: executionQueue); + } + } else if (resolvedOptions.queue == 'default' && + executionQueue != 'default') { + resolvedOptions = resolvedOptions.copyWith(queue: executionQueue); + } final mergedCall = call.copyWith( + options: resolvedOptions, meta: Map.unmodifiable(mergedMeta), ); return delegate.enqueueCall( @@ -1392,6 +1831,32 @@ class _WorkflowStepEnqueuer implements TaskEnqueuer { } } +Map _coerceEventPayload(String topic, Object? payload) { + if (payload is Map) { + return Map.from(payload); + } + if (payload is Map) { + final encoded = {}; + for (final MapEntry(key: key, value: value) in payload.entries) { + if (key is! String) { + throw ArgumentError.value( + payload, + 'payload', + 'Workflow event payloads for topic "$topic" must use String keys.', + ); + } + encoded[key] = value; + } + return encoded; + } + throw ArgumentError.value( + payload, + 'payload', + 'Workflow event payloads for topic "$topic" must encode to ' + 'Map.', + ); +} + class _WorkflowScriptSuspended implements Exception { const _WorkflowScriptSuspended(); } diff --git a/packages/stem/lib/src/workflow/runtime/workflow_views.dart b/packages/stem/lib/src/workflow/runtime/workflow_views.dart new file mode 100644 index 00000000..1a4f1075 --- /dev/null +++ b/packages/stem/lib/src/workflow/runtime/workflow_views.dart @@ -0,0 +1,177 @@ +import 'package:stem/src/workflow/core/run_state.dart'; +import 'package:stem/src/workflow/core/workflow_status.dart'; +import 'package:stem/src/workflow/core/workflow_step_entry.dart'; + +/// Uniform workflow run view tailored for dashboard/CLI drilldowns. +class WorkflowRunView { + /// Creates an immutable workflow run view. + const WorkflowRunView({ + required this.runId, + required this.workflow, + required this.status, + required this.cursor, + required this.createdAt, + this.updatedAt, + this.result, + this.lastError, + required this.params, + required this.runtime, + this.suspensionData, + }); + + /// Creates a view from a persisted [RunState]. + factory WorkflowRunView.fromState(RunState state) { + return WorkflowRunView( + runId: state.id, + workflow: state.workflow, + status: state.status, + cursor: state.cursor, + createdAt: state.createdAt, + updatedAt: state.updatedAt, + result: state.result, + lastError: state.lastError, + params: state.workflowParams, + runtime: state.runtimeMetadata.toJson(), + suspensionData: state.suspensionData, + ); + } + + /// Run identifier. + final String runId; + + /// Workflow name. + final String workflow; + + /// Current lifecycle status. + final WorkflowStatus status; + + /// Current cursor position. + final int cursor; + + /// Creation timestamp. + final DateTime createdAt; + + /// Last update timestamp. + final DateTime? updatedAt; + + /// Final result payload when completed. + final Object? result; + + /// Last error payload, if present. + final Map? lastError; + + /// Public user-supplied workflow params. + final Map params; + + /// Run-scoped runtime metadata (queues/channel/serialization framing). + final Map runtime; + + /// Suspension payload, if run is suspended. + final Map? suspensionData; + + /// Serializes this view into JSON. + Map toJson() { + return { + 'runId': runId, + 'workflow': workflow, + 'status': status.name, + 'cursor': cursor, + 'createdAt': createdAt.toIso8601String(), + if (updatedAt != null) 'updatedAt': updatedAt!.toIso8601String(), + if (result != null) 'result': result, + if (lastError != null) 'lastError': lastError, + 'params': params, + 'runtime': runtime, + if (suspensionData != null) 'suspensionData': suspensionData, + }; + } +} + +/// Uniform workflow checkpoint view for dashboard/CLI step drilldowns. +class WorkflowStepView { + /// Creates an immutable step view. + const WorkflowStepView({ + required this.runId, + required this.workflow, + required this.stepName, + required this.baseStepName, + this.iteration, + required this.position, + this.completedAt, + this.value, + }); + + /// Creates a step view from a [WorkflowStepEntry]. + factory WorkflowStepView.fromEntry({ + required String runId, + required String workflow, + required WorkflowStepEntry entry, + }) { + return WorkflowStepView( + runId: runId, + workflow: workflow, + stepName: entry.name, + baseStepName: entry.baseName, + iteration: entry.iteration, + position: entry.position, + completedAt: entry.completedAt, + value: entry.value, + ); + } + + /// Run identifier. + final String runId; + + /// Workflow name. + final String workflow; + + /// Persisted checkpoint name. + final String stepName; + + /// Base step name without iteration suffix. + final String baseStepName; + + /// Optional iteration suffix. + final int? iteration; + + /// Zero-based checkpoint order. + final int position; + + /// Completion timestamp, if available. + final DateTime? completedAt; + + /// Persisted checkpoint value. + final Object? value; + + /// Serializes this view into JSON. + Map toJson() { + return { + 'runId': runId, + 'workflow': workflow, + 'stepName': stepName, + 'baseStepName': baseStepName, + if (iteration != null) 'iteration': iteration, + 'position': position, + if (completedAt != null) 'completedAt': completedAt!.toIso8601String(), + 'value': value, + }; + } +} + +/// Combined run + step drilldown view. +class WorkflowRunDetailView { + /// Creates an immutable run detail view. + const WorkflowRunDetailView({required this.run, required this.steps}); + + /// Run summary view. + final WorkflowRunView run; + + /// Persisted step views. + final List steps; + + /// Serializes this detail view into JSON. + Map toJson() => { + 'run': run.toJson(), + 'steps': steps.map((step) => step.toJson()).toList(), + }; +} diff --git a/packages/stem/lib/src/workflow/workflow.dart b/packages/stem/lib/src/workflow/workflow.dart index 5ea385a8..15d1f11c 100644 --- a/packages/stem/lib/src/workflow/workflow.dart +++ b/packages/stem/lib/src/workflow/workflow.dart @@ -9,7 +9,10 @@ export 'core/run_state.dart'; export 'core/workflow_cancellation_policy.dart'; export 'core/workflow_clock.dart'; export 'core/workflow_definition.dart'; +export 'core/workflow_ref.dart'; export 'core/workflow_result.dart'; +export 'core/workflow_runtime_metadata.dart'; +export 'core/workflow_resume.dart'; export 'core/workflow_script.dart'; export 'core/workflow_script_context.dart'; export 'core/workflow_status.dart'; @@ -17,5 +20,7 @@ export 'core/workflow_step_entry.dart'; export 'core/workflow_store.dart'; export 'core/workflow_watcher.dart'; export 'runtime/workflow_introspection.dart'; +export 'runtime/workflow_manifest.dart'; export 'runtime/workflow_registry.dart'; export 'runtime/workflow_runtime.dart'; +export 'runtime/workflow_views.dart'; diff --git a/packages/stem/lib/stem.dart b/packages/stem/lib/stem.dart index 3b0ceeff..7fd64d28 100644 --- a/packages/stem/lib/stem.dart +++ b/packages/stem/lib/stem.dart @@ -41,19 +41,18 @@ /// encodeArgs: (args) => args, /// ); /// -/// // 2. Setup the registry and handler -/// final registry = SimpleTaskRegistry() -/// ..register(FunctionTaskHandler( +/// // 2. Define the handler +/// final addHandler = FunctionTaskHandler( /// name: 'add_task', /// entrypoint: (context, args) async { /// return (args['a'] as int) + (args['b'] as int); /// }, -/// )); +/// ); /// /// // 3. Initialize Stem with a broker (e.g., In-Memory for testing) /// final stem = Stem( /// broker: InMemoryBroker(), -/// registry: registry, +/// tasks: [addHandler], /// ); /// /// // 4. Enqueue work and wait for the result @@ -68,6 +67,8 @@ /// ``` library; +export 'package:contextual/contextual.dart' show Context, Level, Logger; + import 'package:stem/src/core/contracts.dart'; import 'package:stem/src/core/stem.dart'; import 'package:stem/src/scheduler/beat.dart'; @@ -84,6 +85,7 @@ export 'src/backend/encoding_result_backend.dart'; export 'src/bootstrap/factories.dart'; export 'src/bootstrap/stem_app.dart'; export 'src/bootstrap/stem_client.dart'; +export 'src/bootstrap/stem_module.dart'; export 'src/bootstrap/stem_stack.dart'; export 'src/bootstrap/workflow_app.dart'; export 'src/canvas/canvas.dart'; @@ -97,6 +99,7 @@ export 'src/core/contracts.dart'; export 'src/core/encoder_keys.dart'; export 'src/core/envelope.dart'; export 'src/core/function_task_handler.dart'; +export 'src/core/payload_codec.dart'; export 'src/core/queue_events.dart'; export 'src/core/retry.dart'; export 'src/core/stem.dart'; diff --git a/packages/stem/spec.md b/packages/stem/spec.md index dd40090e..4b0f5627 100644 --- a/packages/stem/spec.md +++ b/packages/stem/spec.md @@ -944,7 +944,7 @@ abstract class TaskRegistry { Stream get onRegister; } -class SimpleTaskRegistry implements TaskRegistry { +class InMemoryTaskRegistry implements TaskRegistry { final Map _m = {}; final _onRegister = StreamController.broadcast(); @override @@ -1564,7 +1564,7 @@ Future main() async { final broker = RedisBroker(redis, namespace: 'stem'); final backend = RedisResultBackend(redis, namespace: 'stem'); - final reg = SimpleTaskRegistry()..register(SendEmailTask()); + final reg = InMemoryTaskRegistry()..register(SendEmailTask()); final w = Worker( broker: broker, diff --git a/packages/stem/test/bootstrap/stem_app_test.dart b/packages/stem/test/bootstrap/stem_app_test.dart index dda378f1..d6c977e9 100644 --- a/packages/stem/test/bootstrap/stem_app_test.dart +++ b/packages/stem/test/bootstrap/stem_app_test.dart @@ -399,6 +399,238 @@ void main() { } }); + test('fromUrl registers provided tasks', () async { + final helperTask = FunctionTaskHandler( + name: 'workflow.task.helper', + entrypoint: (context, args) async => null, + runInIsolate: false, + ); + final adapter = TestStoreAdapter( + scheme: 'test', + adapterName: 'bootstrap-test-adapter', + broker: StemBrokerFactory(create: () async => InMemoryBroker()), + backend: StemBackendFactory( + create: () async => InMemoryResultBackend(), + ), + workflow: WorkflowStoreFactory( + create: () async => InMemoryWorkflowStore(), + ), + ); + + final workflowApp = await StemWorkflowApp.fromUrl( + 'test://localhost', + adapters: [adapter], + tasks: [helperTask], + ); + try { + expect( + workflowApp.app.registry.resolve('workflow.task.helper'), + same(helperTask), + ); + } finally { + await workflowApp.shutdown(); + } + }); + + test('inMemory registers module tasks and workflows', () async { + final helperTask = FunctionTaskHandler( + name: 'workflow.module.helper', + entrypoint: (context, args) async => 'ok', + runInIsolate: false, + ); + final moduleFlow = Flow( + name: 'workflow.module.flow', + build: (builder) { + builder.step('hello', (ctx) async => 'from-module'); + }, + ); + final module = StemModule(flows: [moduleFlow], tasks: [helperTask]); + + final workflowApp = await StemWorkflowApp.inMemory(module: module); + try { + expect( + workflowApp.app.registry.resolve('workflow.module.helper'), + same(helperTask), + ); + + final runId = await workflowApp.startWorkflow('workflow.module.flow'); + final result = await workflowApp.waitForCompletion( + runId, + timeout: const Duration(seconds: 2), + ); + expect(result?.value, 'from-module'); + } finally { + await workflowApp.shutdown(); + } + }); + + test('workflow refs start and decode runs through app helpers', () async { + final moduleFlow = Flow( + name: 'workflow.ref.flow', + build: (builder) { + builder.step('hello', (ctx) async { + final name = ctx.params['name'] as String? ?? 'world'; + return 'hello $name'; + }); + }, + ); + final workflowRef = + WorkflowRef, String>( + name: 'workflow.ref.flow', + encodeParams: (params) => params, + ); + + final workflowApp = await StemWorkflowApp.inMemory(flows: [moduleFlow]); + try { + final runId = await workflowRef.call( + const {'name': 'stem'}, + ).startWithApp(workflowApp); + final result = await workflowRef.waitFor( + workflowApp, + runId, + timeout: const Duration(seconds: 2), + ); + + expect(result?.value, 'hello stem'); + } finally { + await workflowApp.shutdown(); + } + }); + + test( + 'workflow codecs persist encoded checkpoints and decode typed results', + () async { + final flow = Flow<_DemoPayload>( + name: 'workflow.codec.flow', + resultCodec: _demoPayloadCodec, + build: (builder) { + builder + ..step<_DemoPayload>( + 'build', + (ctx) async => const _DemoPayload('bar'), + valueCodec: _demoPayloadCodec, + ) + ..step<_DemoPayload>( + 'finish', + (ctx) async { + final previous = ctx.previousResult! as _DemoPayload; + return _DemoPayload('${previous.foo}-done'); + }, + valueCodec: _demoPayloadCodec, + ); + }, + ); + final workflowRef = + WorkflowRef, _DemoPayload>( + name: 'workflow.codec.flow', + encodeParams: (params) => params, + decodeResult: _demoPayloadCodec.decode, + ); + + final workflowApp = await StemWorkflowApp.inMemory(flows: [flow]); + try { + final runId = await workflowRef.call(const {}).startWithApp( + workflowApp, + ); + final result = await workflowRef.waitFor( + workflowApp, + runId, + timeout: const Duration(seconds: 2), + ); + + expect(result, isNotNull); + expect(result!.value?.foo, 'bar-done'); + expect(result.state.result, {'foo': 'bar-done'}); + expect( + await workflowApp.store.readStep>( + runId, + 'build', + ), + {'foo': 'bar'}, + ); + expect( + await workflowApp.store.readStep>( + runId, + 'finish', + ), + {'foo': 'bar-done'}, + ); + } finally { + await workflowApp.shutdown(); + } + }, + ); + + test( + 'script workflow codecs persist encoded checkpoints and decode typed results', + () async { + final script = WorkflowScript<_DemoPayload>( + name: 'workflow.codec.script', + resultCodec: _demoPayloadCodec, + checkpoints: [ + FlowStep.typed<_DemoPayload>( + name: 'build', + handler: (_) async => null, + valueCodec: _demoPayloadCodec, + ), + FlowStep.typed<_DemoPayload>( + name: 'finish', + handler: (_) async => null, + valueCodec: _demoPayloadCodec, + ), + ], + run: (script) async { + final built = await script.step<_DemoPayload>( + 'build', + (ctx) async => const _DemoPayload('bar'), + ); + return script.step<_DemoPayload>( + 'finish', + (ctx) async => _DemoPayload('${built.foo}-done'), + ); + }, + ); + final workflowRef = + WorkflowRef, _DemoPayload>( + name: 'workflow.codec.script', + encodeParams: (params) => params, + decodeResult: _demoPayloadCodec.decode, + ); + + final workflowApp = await StemWorkflowApp.inMemory(scripts: [script]); + try { + final runId = await workflowRef.call(const {}).startWithApp( + workflowApp, + ); + final result = await workflowRef.waitFor( + workflowApp, + runId, + timeout: const Duration(seconds: 2), + ); + + expect(result, isNotNull); + expect(result!.value?.foo, 'bar-done'); + expect(result.state.result, {'foo': 'bar-done'}); + expect( + await workflowApp.store.readStep>( + runId, + 'build', + ), + {'foo': 'bar'}, + ); + expect( + await workflowApp.store.readStep>( + runId, + 'finish', + ), + {'foo': 'bar-done'}, + ); + } finally { + await workflowApp.shutdown(); + } + }, + ); + test('fromUrl shuts down app when workflow bootstrap fails', () async { final createdLockStore = InMemoryLockStore(); final createdRevokeStore = InMemoryRevokeStore(); @@ -459,6 +691,17 @@ class _DemoPayload { final String foo; } +const _demoPayloadCodec = PayloadCodec<_DemoPayload>( + encode: _encodeDemoPayload, + decode: _decodeDemoPayload, +); + +Object? _encodeDemoPayload(_DemoPayload value) => {'foo': value.foo}; + +_DemoPayload _decodeDemoPayload(Object? payload) { + return _DemoPayload.fromJson(Map.from(payload! as Map)); +} + class _TestRateLimiter implements RateLimiter { @override Future acquire( diff --git a/packages/stem/test/bootstrap/stem_client_test.dart b/packages/stem/test/bootstrap/stem_client_test.dart index 9443252e..6d17758d 100644 --- a/packages/stem/test/bootstrap/stem_client_test.dart +++ b/packages/stem/test/bootstrap/stem_client_test.dart @@ -28,6 +28,101 @@ void main() { await client.close(); }); + test('StemClient createWorkflowApp registers module definitions', () async { + final client = await StemClient.inMemory(); + final moduleTask = FunctionTaskHandler( + name: 'client.module.task', + entrypoint: (context, args) async => 'task-ok', + runInIsolate: false, + ); + final moduleFlow = Flow( + name: 'client.module.workflow', + build: (builder) { + builder.step('hello', (ctx) async => 'module-ok'); + }, + ); + final module = StemModule(flows: [moduleFlow], tasks: [moduleTask]); + + final app = await client.createWorkflowApp(module: module); + await app.start(); + + expect(app.app.registry.resolve('client.module.task'), same(moduleTask)); + + final runId = await app.startWorkflow('client.module.workflow'); + final result = await app.waitForCompletion( + runId, + timeout: const Duration(seconds: 2), + ); + + expect(result?.value, 'module-ok'); + + await app.close(); + await client.close(); + }); + + test('StemClient workflow app supports typed workflow refs', () async { + final client = await StemClient.inMemory(); + final flow = Flow( + name: 'client.workflow.ref', + build: (builder) { + builder.step('hello', (ctx) async { + final name = ctx.params['name'] as String? ?? 'world'; + return 'ok:$name'; + }); + }, + ); + final workflowRef = WorkflowRef, String>( + name: 'client.workflow.ref', + encodeParams: (params) => params, + ); + + final app = await client.createWorkflowApp(flows: [flow]); + await app.start(); + + final runId = await app.startWorkflowCall( + workflowRef.call(const {'name': 'ref'}), + ); + final result = await app.waitForWorkflowRef( + runId, + workflowRef, + timeout: const Duration(seconds: 2), + ); + + expect(result?.value, 'ok:ref'); + + await app.close(); + await client.close(); + }); + + test('StemClient workflow app supports startAndWaitWithApp', () async { + final client = await StemClient.inMemory(); + final flow = Flow( + name: 'client.workflow.start-and-wait', + build: (builder) { + builder.step('hello', (ctx) async { + final name = ctx.params['name'] as String? ?? 'world'; + return 'ok:$name'; + }); + }, + ); + final workflowRef = WorkflowRef, String>( + name: 'client.workflow.start-and-wait', + encodeParams: (params) => params, + ); + + final app = await client.createWorkflowApp(flows: [flow]); + await app.start(); + + final result = await workflowRef.call( + const {'name': 'one-shot'}, + ).startAndWaitWithApp(app, timeout: const Duration(seconds: 2)); + + expect(result?.value, 'ok:one-shot'); + + await app.close(); + await client.close(); + }); + test('StemClient fromUrl resolves adapter-backed broker/backend', () async { final handler = FunctionTaskHandler( name: 'client.from-url', diff --git a/packages/stem/test/performance/throughput_test.dart b/packages/stem/test/performance/throughput_test.dart index ea0b2793..d393a72e 100644 --- a/packages/stem/test/performance/throughput_test.dart +++ b/packages/stem/test/performance/throughput_test.dart @@ -25,7 +25,7 @@ void main() { final backend = InMemoryResultBackend(); final completed = {}; - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( InlineTaskHandler( name: 'perf.echo', diff --git a/packages/stem/test/soak/soak_test.dart b/packages/stem/test/soak/soak_test.dart index eb812561..ba28dba5 100644 --- a/packages/stem/test/soak/soak_test.dart +++ b/packages/stem/test/soak/soak_test.dart @@ -25,7 +25,7 @@ void main() { final backend = InMemoryResultBackend(); final completed = {}; - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( InlineTaskHandler( name: 'soak.task', diff --git a/packages/stem/test/unit/canvas/canvas_test.dart b/packages/stem/test/unit/canvas/canvas_test.dart index 4c6ad49d..09b904da 100644 --- a/packages/stem/test/unit/canvas/canvas_test.dart +++ b/packages/stem/test/unit/canvas/canvas_test.dart @@ -7,7 +7,6 @@ void main() { group('Canvas', () { late InMemoryBroker broker; late InMemoryResultBackend backend; - late SimpleTaskRegistry registry; late Worker worker; late Canvas canvas; @@ -17,14 +16,15 @@ void main() { claimInterval: const Duration(milliseconds: 20), ); backend = InMemoryResultBackend(); - registry = SimpleTaskRegistry() - ..register(_EchoTask()) - ..register(_SumTask()); - canvas = Canvas(broker: broker, backend: backend, registry: registry); + canvas = Canvas( + broker: broker, + backend: backend, + tasks: [_EchoTask(), _SumTask()], + ); worker = Worker( broker: broker, - registry: registry, backend: backend, + tasks: [_EchoTask(), _SumTask()], consumerName: 'canvas-worker', concurrency: 1, prefetchMultiplier: 1, diff --git a/packages/stem/test/unit/core/contracts_test.dart b/packages/stem/test/unit/core/contracts_test.dart index d309db57..3e6c6a3a 100644 --- a/packages/stem/test/unit/core/contracts_test.dart +++ b/packages/stem/test/unit/core/contracts_test.dart @@ -103,6 +103,21 @@ void main() { 'stem.softTimeLimitMs': 750, 'stem.parentTaskId': 'parent-1', 'stem.rootTaskId': 'root-1', + 'stem.workflow.id': 'wf_def_01', + 'stem.workflow.name': 'invoice.flow', + 'stem.workflow.runId': 'run-123', + 'stem.workflow.step': 'charge', + 'stem.workflow.stepIndex': 2, + 'stem.workflow.iteration': 1, + 'stem.workflow.channel': 'execution', + 'stem.workflow.continuation': false, + 'stem.workflow.orchestrationQueue': 'workflow', + 'stem.workflow.continuationQueue': 'workflow', + 'stem.workflow.executionQueue': 'workflow-step', + 'stem.workflow.continuationReason': 'event', + 'stem.workflow.serialization.format': 'json', + 'stem.workflow.serialization.version': '1', + 'stem.workflow.stream.id': 'invoice_run-123', }, ); @@ -120,6 +135,21 @@ void main() { expect(status.softTimeLimit, equals(const Duration(milliseconds: 750))); expect(status.parentTaskId, equals('parent-1')); expect(status.rootTaskId, equals('root-1')); + expect(status.workflowId, equals('wf_def_01')); + expect(status.workflowName, equals('invoice.flow')); + expect(status.workflowRunId, equals('run-123')); + expect(status.workflowStep, equals('charge')); + expect(status.workflowStepIndex, equals(2)); + expect(status.workflowIteration, equals(1)); + expect(status.workflowChannel, equals('execution')); + expect(status.workflowContinuation, isFalse); + expect(status.workflowContinuationReason, equals('event')); + expect(status.workflowOrchestrationQueue, equals('workflow')); + expect(status.workflowContinuationQueue, equals('workflow')); + expect(status.workflowExecutionQueue, equals('workflow-step')); + expect(status.workflowSerializationFormat, equals('json')); + expect(status.workflowSerializationVersion, equals('1')); + expect(status.workflowStreamId, equals('invoice_run-123')); }); }); diff --git a/packages/stem/test/unit/core/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index c777442f..eda93ff9 100644 --- a/packages/stem/test/unit/core/stem_core_test.dart +++ b/packages/stem/test/unit/core/stem_core_test.dart @@ -60,9 +60,11 @@ void main() { test('publishes to broker and writes queued state', () async { final broker = _RecordingBroker(); final backend = _RecordingBackend(); - final registry = SimpleTaskRegistry()..register(_StubTaskHandler()); - - final stem = Stem(broker: broker, registry: registry, backend: backend); + final stem = Stem( + broker: broker, + backend: backend, + tasks: [_StubTaskHandler()], + ); final id = await stem.enqueue( 'sample.task', @@ -76,6 +78,107 @@ void main() { expect(backend.records.single.state, equals(TaskState.queued)); }); }); + + group('Stem.waitForTaskDefinition', () { + test('does not double decode codec-backed terminal results', () async { + final backend = _codecAwareBackend(); + final stem = _codecAwareStem(backend); + + await backend.set( + 'task-terminal', + TaskState.succeeded, + payload: const _CodecReceipt('receipt-terminal'), + meta: {stemResultEncoderMetaKey: _codecReceiptEncoder.id}, + ); + + final result = await stem.waitForTaskDefinition( + 'task-terminal', + _codecReceiptDefinition, + ); + + expect(result?.value?.id, 'receipt-terminal'); + expect(result?.rawPayload, isA<_CodecReceipt>()); + }); + + test('does not double decode codec-backed watched results', () async { + final backend = _codecAwareBackend(); + final stem = _codecAwareStem(backend); + + unawaited( + Future.delayed(const Duration(milliseconds: 20), () async { + await backend.set( + 'task-watched', + TaskState.succeeded, + payload: const _CodecReceipt('receipt-watched'), + meta: {stemResultEncoderMetaKey: _codecReceiptEncoder.id}, + ); + }), + ); + + final result = await stem.waitForTaskDefinition( + 'task-watched', + _codecReceiptDefinition, + timeout: const Duration(seconds: 1), + ); + + expect(result?.value?.id, 'receipt-watched'); + expect(result?.rawPayload, isA<_CodecReceipt>()); + }); + }); +} + +ResultBackend _codecAwareBackend() { + final registry = ensureTaskPayloadEncoderRegistry( + null, + additionalEncoders: [_codecReceiptEncoder], + ); + return withTaskPayloadEncoder(InMemoryResultBackend(), registry); +} + +Stem _codecAwareStem(ResultBackend backend) { + return Stem( + broker: _RecordingBroker(), + backend: backend, + encoderRegistry: ensureTaskPayloadEncoderRegistry( + null, + additionalEncoders: [_codecReceiptEncoder], + ), + ); +} + +class _CodecReceipt { + const _CodecReceipt(this.id); + + factory _CodecReceipt.fromJson(Map json) { + return _CodecReceipt(json['id']! as String); + } + + final String id; + + Map toJson() => {'id': id}; +} + +const _codecReceiptCodec = PayloadCodec<_CodecReceipt>( + encode: _encodeCodecReceipt, + decode: _decodeCodecReceipt, +); + +const _codecReceiptEncoder = CodecTaskPayloadEncoder<_CodecReceipt>( + idValue: 'test.codec.receipt', + codec: _codecReceiptCodec, +); + +final _codecReceiptDefinition = + TaskDefinition, _CodecReceipt>( + name: 'codec.receipt', + encodeArgs: (args) => args, + decodeResult: _codecReceiptCodec.decode, + ); + +Object? _encodeCodecReceipt(_CodecReceipt value) => value.toJson(); + +_CodecReceipt _decodeCodecReceipt(Object? payload) { + return _CodecReceipt.fromJson(Map.from(payload! as Map)); } class _StubTaskHandler implements TaskHandler { diff --git a/packages/stem/test/unit/core/stem_enqueue_options_test.dart b/packages/stem/test/unit/core/stem_enqueue_options_test.dart index 4990b091..1c91b647 100644 --- a/packages/stem/test/unit/core/stem_enqueue_options_test.dart +++ b/packages/stem/test/unit/core/stem_enqueue_options_test.dart @@ -16,7 +16,7 @@ void main() { group('Stem.enqueue options', () { test('applies countdown to notBefore', () async { final broker = _RecordingBroker(); - final registry = SimpleTaskRegistry()..register(_StubTaskHandler()); + final registry = InMemoryTaskRegistry()..register(_StubTaskHandler()); final stem = Stem(broker: broker, registry: registry); final start = DateTime.now(); @@ -38,7 +38,7 @@ void main() { test('applies eta to notBefore', () async { final broker = _RecordingBroker(); - final registry = SimpleTaskRegistry()..register(_StubTaskHandler()); + final registry = InMemoryTaskRegistry()..register(_StubTaskHandler()); final stem = Stem(broker: broker, registry: registry); final eta = DateTime.utc(2026, 01, 03, 12, 30); @@ -53,7 +53,7 @@ void main() { test('applies taskId override', () async { final broker = _RecordingBroker(); - final registry = SimpleTaskRegistry()..register(_StubTaskHandler()); + final registry = InMemoryTaskRegistry()..register(_StubTaskHandler()); final stem = Stem(broker: broker, registry: registry); await stem.enqueue( @@ -67,7 +67,7 @@ void main() { test('propagates routing overrides', () async { final broker = _RecordingBroker(); - final registry = SimpleTaskRegistry()..register(_StubTaskHandler()); + final registry = InMemoryTaskRegistry()..register(_StubTaskHandler()); final stem = Stem(broker: broker, registry: registry); await stem.enqueue( @@ -90,7 +90,7 @@ void main() { test('stores metadata options in envelope meta', () async { final broker = _RecordingBroker(); - final registry = SimpleTaskRegistry()..register(_StubTaskHandler()); + final registry = InMemoryTaskRegistry()..register(_StubTaskHandler()); final stem = Stem(broker: broker, registry: registry); await stem.enqueue( @@ -120,7 +120,7 @@ void main() { test('retries publish when retry enabled', () async { final broker = _FlakyPublishBroker(failures: 1); - final registry = SimpleTaskRegistry()..register(_StubTaskHandler()); + final registry = InMemoryTaskRegistry()..register(_StubTaskHandler()); final stem = Stem(broker: broker, registry: registry); await stem.enqueue( diff --git a/packages/stem/test/unit/core/stem_unique_task_test.dart b/packages/stem/test/unit/core/stem_unique_task_test.dart index 202e58f7..5aa51620 100644 --- a/packages/stem/test/unit/core/stem_unique_task_test.dart +++ b/packages/stem/test/unit/core/stem_unique_task_test.dart @@ -11,7 +11,7 @@ void main() { claimInterval: const Duration(milliseconds: 20), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( FunctionTaskHandler( name: 'demo.unique', diff --git a/packages/stem/test/unit/core/task_registry_test.dart b/packages/stem/test/unit/core/task_registry_test.dart index f54f3199..28fd54e6 100644 --- a/packages/stem/test/unit/core/task_registry_test.dart +++ b/packages/stem/test/unit/core/task_registry_test.dart @@ -115,9 +115,9 @@ class _Args { } void main() { - group('SimpleTaskRegistry', () { + group('InMemoryTaskRegistry', () { test('emits registration events', () async { - final registry = SimpleTaskRegistry(); + final registry = InMemoryTaskRegistry(); final events = []; final sub = registry.onRegister.listen(events.add); @@ -138,7 +138,7 @@ void main() { expect(events.last.handler, same(second)); }); test('throws when registering duplicate handler without override', () { - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register(_DuplicateHandler('sample.task')); expect( @@ -154,7 +154,7 @@ void main() { }); test('allows overriding when requested explicitly', () { - final registry = SimpleTaskRegistry(); + final registry = InMemoryTaskRegistry(); final original = _TestHandler('sample.task'); final replacement = _TestHandler('sample.task'); @@ -166,7 +166,7 @@ void main() { }); test('exposes registered handlers as read-only list', () { - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register(_TestHandler('first')) ..register(_TestHandler('second')); @@ -191,6 +191,18 @@ void main() { final handler = _TestHandler('meta', description: 'Example task'); expect(handler.metadata.description, 'Example task'); }); + + test('retains SimpleTaskRegistry as a compatibility alias', () { + // Compatibility coverage intentionally exercises the deprecated symbol. + // ignore: deprecated_member_use_from_same_package + final registry = SimpleTaskRegistry(); + // A single plain call is clearer here than forcing a one-off cascade. + // ignore: cascade_invocations + registry.register(_TestHandler('legacy.task')); + + expect(registry, isA()); + expect(registry.resolve('legacy.task')?.name, 'legacy.task'); + }); }); group('TaskDefinition', () { @@ -208,9 +220,10 @@ void main() { test('enqueues via Stem.enqueueCall', () async { final broker = _FakeBroker(); - final registry = SimpleTaskRegistry() - ..register(_TestHandler('demo.task')); - final stem = Stem(broker: broker, registry: registry); + final stem = Stem( + broker: broker, + tasks: [_TestHandler('demo.task')], + ); final definition = TaskDefinition<_Args, void>( name: 'demo.task', diff --git a/packages/stem/test/unit/observability/logging_test.dart b/packages/stem/test/unit/observability/logging_test.dart index 1851bba2..da513331 100644 --- a/packages/stem/test/unit/observability/logging_test.dart +++ b/packages/stem/test/unit/observability/logging_test.dart @@ -1,8 +1,21 @@ -import 'package:contextual/contextual.dart'; -import 'package:stem/src/observability/logging.dart'; +import 'package:stem/stem.dart'; import 'package:test/test.dart'; void main() { + test('package:stem exports logging types used by the public API', () { + void acceptsStemLogger(Logger logger, Level level) { + logger.setLevel(level); + } + + final context = stemLogContext( + component: 'stem', + subsystem: 'worker', + ); + + acceptsStemLogger(stemLogger, Level.critical); + expect(context, isA()); + }); + test('configureStemLogging updates logger level', () { configureStemLogging(level: Level.debug); configureStemLogging(level: Level.warning); diff --git a/packages/stem/test/unit/observability/metrics_integration_test.dart b/packages/stem/test/unit/observability/metrics_integration_test.dart index a29bd15f..29129e35 100644 --- a/packages/stem/test/unit/observability/metrics_integration_test.dart +++ b/packages/stem/test/unit/observability/metrics_integration_test.dart @@ -20,7 +20,7 @@ void main() { final broker = InMemoryBroker(); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( FunctionTaskHandler( name: 'metrics.test', diff --git a/packages/stem/test/unit/redis_components_test.dart b/packages/stem/test/unit/redis_components_test.dart index 566d0f5f..7ada06d0 100644 --- a/packages/stem/test/unit/redis_components_test.dart +++ b/packages/stem/test/unit/redis_components_test.dart @@ -327,7 +327,7 @@ void main() { claimInterval: const Duration(milliseconds: 30), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry()..register(_NoopTask()); + final registry = InMemoryTaskRegistry()..register(_NoopTask()); final stem = Stem(broker: broker, registry: registry, backend: backend); final taskId = await stem.enqueue('noop'); @@ -350,7 +350,7 @@ void main() { claimInterval: const Duration(milliseconds: 30), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry()..register(_NoopTask()); + final registry = InMemoryTaskRegistry()..register(_NoopTask()); final stem = Stem(broker: broker, registry: registry, backend: backend); final taskId = await stem.enqueue('noop'); diff --git a/packages/stem/test/unit/scheduler/beat_test.dart b/packages/stem/test/unit/scheduler/beat_test.dart index 55e23694..db62d90a 100644 --- a/packages/stem/test/unit/scheduler/beat_test.dart +++ b/packages/stem/test/unit/scheduler/beat_test.dart @@ -24,7 +24,7 @@ void main() { test('fires schedule once per interval', () async { final broker = InMemoryBroker(); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry()..register(_NoopTask()); + final registry = InMemoryTaskRegistry()..register(_NoopTask()); final store = InMemoryScheduleStore(); final beat = Beat( store: store, @@ -75,7 +75,7 @@ void main() { final broker = InMemoryBroker(); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry()..register(_NoopTask()); + final registry = InMemoryTaskRegistry()..register(_NoopTask()); final store = InMemoryScheduleStore(); final beat = Beat( store: store, @@ -126,7 +126,7 @@ void main() { test('disables one-shot schedules after execution', () async { final broker = InMemoryBroker(); - final registry = SimpleTaskRegistry()..register(_NoopTask()); + final registry = InMemoryTaskRegistry()..register(_NoopTask()); final backend = InMemoryResultBackend(); final store = InMemoryScheduleStore(); final beat = Beat( @@ -170,7 +170,7 @@ void main() { test('only one beat instance dispatches when locks used', () async { final broker = InMemoryBroker(); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry()..register(_NoopTask()); + final registry = InMemoryTaskRegistry()..register(_NoopTask()); final store = InMemoryScheduleStore(); final lockStore = InMemoryLockStore(); final beatA = Beat( diff --git a/packages/stem/test/unit/tracing/tracing_test.dart b/packages/stem/test/unit/tracing/tracing_test.dart index 4e91eec2..76c5218a 100644 --- a/packages/stem/test/unit/tracing/tracing_test.dart +++ b/packages/stem/test/unit/tracing/tracing_test.dart @@ -59,7 +59,7 @@ void main() { test('traces flow from enqueue to execution', () async { final broker = InMemoryBroker(); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( FunctionTaskHandler( name: 'trace.test', @@ -203,7 +203,7 @@ void main() { test('consume starts a new trace when trace headers are missing', () async { final broker = InMemoryBroker(); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( FunctionTaskHandler( name: 'trace.test', @@ -264,7 +264,7 @@ void main() { () async { final broker = InMemoryBroker(); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( FunctionTaskHandler( name: 'trace.parent', diff --git a/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart b/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart index 2b5c2159..4e442ba6 100644 --- a/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart +++ b/packages/stem/test/unit/worker/task_context_enqueue_integration_test.dart @@ -23,7 +23,7 @@ void main() { final backend = InMemoryResultBackend(); final childCompleted = Completer(); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( FunctionTaskHandler.inline( name: _childDefinition.name, @@ -61,7 +61,7 @@ void main() { test('enqueue + execute round-trip is stable', () async { final broker = InMemoryBroker(); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry()..register(_EchoTask()); + final registry = InMemoryTaskRegistry()..register(_EchoTask()); final worker = Worker(broker: broker, registry: registry, backend: backend); await worker.start(); @@ -105,7 +105,7 @@ void main() { encodeArgs: (args) => {'value': args.value}, ); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( FunctionTaskHandler.inline( name: 'tasks.primary.success', @@ -162,7 +162,7 @@ void main() { encodeArgs: (args) => {'value': args.value}, ); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( FunctionTaskHandler.inline( name: 'tasks.primary.fail', @@ -214,7 +214,7 @@ void main() { claimInterval: const Duration(milliseconds: 40), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( FunctionTaskHandler.inline( name: 'tasks.payload', @@ -260,7 +260,7 @@ void main() { ); final backend = InMemoryResultBackend(); var executed = false; - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( FunctionTaskHandler.inline( name: 'tasks.expiring', @@ -311,7 +311,7 @@ void main() { claimInterval: const Duration(milliseconds: 40), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( FunctionTaskHandler.inline( name: 'tasks.echo', diff --git a/packages/stem/test/unit/worker/task_retry_policy_test.dart b/packages/stem/test/unit/worker/task_retry_policy_test.dart index ab6bc5db..12542303 100644 --- a/packages/stem/test/unit/worker/task_retry_policy_test.dart +++ b/packages/stem/test/unit/worker/task_retry_policy_test.dart @@ -22,7 +22,7 @@ void main() { name: 'tasks.policy', options: const TaskOptions(maxRetries: 1, retryPolicy: policy), ); - final registry = SimpleTaskRegistry()..register(task); + final registry = InMemoryTaskRegistry()..register(task); final worker = Worker( broker: broker, registry: registry, @@ -74,7 +74,7 @@ void main() { jitter: false, defaultDelay: Duration(milliseconds: 15), ); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( _PolicyFlakyTask( name: 'tasks.override', @@ -133,7 +133,7 @@ void main() { autoRetryFor: [StateError], dontAutoRetryFor: [ArgumentError], ); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( _AlwaysErrorTask( name: 'tasks.filtered', @@ -179,7 +179,7 @@ void main() { claimInterval: const Duration(milliseconds: 40), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register(_ExplicitRetryTask('tasks.explicit')); final worker = Worker( @@ -220,7 +220,7 @@ void main() { test('retry semantics converge based on max retries', () async { final broker = InMemoryBroker(); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry()..register(_FlakyTask()); + final registry = InMemoryTaskRegistry()..register(_FlakyTask()); final worker = Worker( broker: broker, registry: registry, diff --git a/packages/stem/test/unit/worker/worker_test.dart b/packages/stem/test/unit/worker/worker_test.dart index cccad725..683afd03 100644 --- a/packages/stem/test/unit/worker/worker_test.dart +++ b/packages/stem/test/unit/worker/worker_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:isolate'; +import 'package:contextual/contextual.dart' show Level, LogDriver, LogEntry; import 'package:stem/stem.dart'; import 'package:test/test.dart'; @@ -13,11 +14,10 @@ void main() { claimInterval: const Duration(milliseconds: 40), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry()..register(_SuccessTask()); final worker = Worker( broker: broker, - registry: registry, backend: backend, + tasks: [_SuccessTask()], consumerName: 'worker-1', concurrency: 1, prefetchMultiplier: 1, @@ -28,7 +28,11 @@ void main() { await worker.start(); - final stem = Stem(broker: broker, registry: registry, backend: backend); + final stem = Stem( + broker: broker, + backend: backend, + tasks: [_SuccessTask()], + ); final taskId = await stem.enqueue('tasks.success'); await Future.delayed(const Duration(milliseconds: 50)); @@ -55,13 +59,87 @@ void main() { broker.dispose(); }); + test('includes workflow metadata in task lifecycle logs', () async { + final driver = _RecordingLogDriver(); + stemLogger + ..addChannel( + 'worker-log-test-${DateTime.now().microsecondsSinceEpoch}', + driver, + ) + ..setLevel(Level.debug); + + final broker = InMemoryBroker( + delayedInterval: const Duration(milliseconds: 10), + claimInterval: const Duration(milliseconds: 40), + ); + final backend = InMemoryResultBackend(); + final registry = InMemoryTaskRegistry()..register(_SuccessTask()); + final worker = Worker( + broker: broker, + registry: registry, + backend: backend, + consumerName: 'worker-log-metadata', + concurrency: 1, + prefetchMultiplier: 1, + ); + + await worker.start(); + + final stem = Stem(broker: broker, registry: registry, backend: backend); + final taskId = await stem.enqueue( + 'tasks.success', + meta: const { + 'stem.workflow.channel': 'orchestration', + 'stem.workflow.continuation': true, + 'stem.workflow.continuationReason': 'due', + 'stem.workflow.runId': 'run-123', + 'stem.workflow.id': 'wf-123', + 'stem.workflow.name': 'demo.workflow', + 'stem.workflow.step': 'wait', + 'stem.workflow.stepIndex': 2, + 'stem.workflow.iteration': 1, + }, + ); + + await _waitForTaskState(backend, taskId, TaskState.succeeded); + await Future.delayed(Duration.zero); + + LogEntry startedEntry() => driver.entries.firstWhere( + (entry) => + entry.record.message == 'Task {task} started' && + entry.record.context.all()['id'] == taskId, + ); + + LogEntry succeededEntry() => driver.entries.firstWhere( + (entry) => + entry.record.message == 'Task {task} succeeded' && + entry.record.context.all()['id'] == taskId, + ); + + for (final entry in [startedEntry(), succeededEntry()]) { + final context = entry.record.context.all(); + expect(context['workflowChannel'], equals('orchestration')); + expect(context['workflowContinuation'], isTrue); + expect(context['workflowReason'], equals('due')); + expect(context['workflowRunId'], equals('run-123')); + expect(context['workflowId'], equals('wf-123')); + expect(context['workflow'], equals('demo.workflow')); + expect(context['workflowStep'], equals('wait')); + expect(context['workflowStepIndex'], equals(2)); + expect(context['workflowIteration'], equals(1)); + } + + await worker.shutdown(); + broker.dispose(); + }); + test('dispatches chord callback when body completes', () async { final broker = InMemoryBroker( delayedInterval: const Duration(milliseconds: 5), claimInterval: const Duration(milliseconds: 20), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register(_ChordBodyTask()) ..register(_ChordCallbackTask()); final worker = Worker( @@ -105,7 +183,7 @@ void main() { claimInterval: const Duration(milliseconds: 20), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry()..register(_SuccessTask()); + final registry = InMemoryTaskRegistry()..register(_SuccessTask()); final coordinator = UniqueTaskCoordinator( lockStore: InMemoryLockStore(), defaultTtl: const Duration(seconds: 5), @@ -170,7 +248,7 @@ void main() { claimInterval: const Duration(milliseconds: 20), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry()..register(_SuccessTask()); + final registry = InMemoryTaskRegistry()..register(_SuccessTask()); final worker = Worker( broker: broker, registry: registry, @@ -242,7 +320,7 @@ void main() { claimInterval: const Duration(milliseconds: 20), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry()..register(_SuccessTask()); + final registry = InMemoryTaskRegistry()..register(_SuccessTask()); final worker = Worker( broker: broker, registry: registry, @@ -281,7 +359,7 @@ void main() { claimInterval: const Duration(milliseconds: 20), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( FunctionTaskHandler( name: 'tasks.autoscale', @@ -349,7 +427,7 @@ void main() { claimInterval: const Duration(milliseconds: 20), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry()..register(_SuccessTask()); + final registry = InMemoryTaskRegistry()..register(_SuccessTask()); final worker = Worker( broker: broker, registry: registry, @@ -422,7 +500,7 @@ void main() { claimInterval: const Duration(milliseconds: 20), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( FunctionTaskHandler( name: 'tasks.isolate', @@ -491,7 +569,7 @@ void main() { claimInterval: const Duration(milliseconds: 20), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry()..register(_SuccessTask()); + final registry = InMemoryTaskRegistry()..register(_SuccessTask()); final worker = Worker( broker: broker, registry: registry, @@ -564,7 +642,7 @@ void main() { claimInterval: const Duration(milliseconds: 20), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry()..register(_FlakyTask()); + final registry = InMemoryTaskRegistry()..register(_FlakyTask()); final worker = Worker( broker: broker, registry: registry, @@ -625,7 +703,7 @@ void main() { claimInterval: const Duration(milliseconds: 20), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( FunctionTaskHandler( name: 'tasks.default', @@ -700,7 +778,7 @@ void main() { claimInterval: const Duration(milliseconds: 20), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( FunctionTaskHandler( name: 'tasks.sleepy', @@ -750,7 +828,7 @@ void main() { claimInterval: const Duration(milliseconds: 20), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( FunctionTaskHandler( name: 'tasks.recycle', @@ -806,7 +884,7 @@ void main() { claimInterval: const Duration(milliseconds: 20), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( FunctionTaskHandler( name: 'tasks.memory-recycle', @@ -862,7 +940,7 @@ void main() { claimInterval: const Duration(milliseconds: 40), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry()..register(_SuccessTask()); + final registry = InMemoryTaskRegistry()..register(_SuccessTask()); final signingConfig = SigningConfig.fromEnvironment({ 'STEM_SIGNING_KEYS': @@ -919,7 +997,7 @@ void main() { claimInterval: const Duration(milliseconds: 40), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry()..register(_SuccessTask()); + final registry = InMemoryTaskRegistry()..register(_SuccessTask()); final signingConfig = SigningConfig.fromEnvironment({ 'STEM_SIGNING_KEYS': @@ -975,7 +1053,7 @@ void main() { claimInterval: const Duration(milliseconds: 40), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry()..register(_FlakyTask()); + final registry = InMemoryTaskRegistry()..register(_FlakyTask()); final worker = Worker( broker: broker, registry: registry, @@ -1033,7 +1111,7 @@ void main() { claimInterval: const Duration(milliseconds: 40), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry()..register(_FlakyTask()); + final registry = InMemoryTaskRegistry()..register(_FlakyTask()); final signingConfig = SigningConfig.fromEnvironment({ 'STEM_SIGNING_KEYS': @@ -1101,7 +1179,7 @@ void main() { claimInterval: const Duration(milliseconds: 40), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry()..register(_AlwaysFailTask()); + final registry = InMemoryTaskRegistry()..register(_AlwaysFailTask()); final worker = Worker( broker: broker, registry: registry, @@ -1168,7 +1246,7 @@ void main() { claimInterval: const Duration(milliseconds: 40), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( FunctionTaskHandler( name: 'tasks.isolate', @@ -1220,7 +1298,7 @@ void main() { claimInterval: const Duration(milliseconds: 40), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( FunctionTaskHandler( name: 'tasks.hard-limit', @@ -1288,7 +1366,7 @@ void main() { claimInterval: const Duration(milliseconds: 40), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry()..register(_SuccessTask()); + final registry = InMemoryTaskRegistry()..register(_SuccessTask()); final revokeStore = InMemoryRevokeStore(); final stem = Stem(broker: broker, registry: registry, backend: backend); @@ -1369,7 +1447,7 @@ void main() { } return const RateLimitDecision(allowed: true); }); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( FunctionTaskHandler( name: 'tasks.group.a', @@ -1451,7 +1529,7 @@ void main() { final limiter = _ScenarioRateLimiter((key, attempt) { throw StateError('limiter unavailable'); }); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( FunctionTaskHandler( name: 'tasks.group.failopen', @@ -1498,7 +1576,7 @@ void main() { throw StateError('limiter unavailable'); }); var executed = 0; - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( FunctionTaskHandler( name: 'tasks.group.failclosed', @@ -1548,7 +1626,7 @@ void main() { ); final backend = InMemoryResultBackend(); final revokeStore = InMemoryRevokeStore(); - final registry = SimpleTaskRegistry()..register(_SuccessTask()); + final registry = InMemoryTaskRegistry()..register(_SuccessTask()); final workerA = Worker( broker: broker, @@ -1630,7 +1708,7 @@ void main() { claimInterval: const Duration(milliseconds: 20), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry()..register(_SuccessTask()); + final registry = InMemoryTaskRegistry()..register(_SuccessTask()); final worker = Worker( broker: broker, @@ -1894,6 +1972,17 @@ class _FixedRetryStrategy implements RetryStrategy { Duration nextDelay(int attempt, Object error, StackTrace stackTrace) => delay; } +class _RecordingLogDriver extends LogDriver { + _RecordingLogDriver() : entries = [], super('recording'); + + final List entries; + + @override + Future log(LogEntry entry) async { + entries.add(entry); + } +} + class _FlakyTask implements TaskHandler { int _attempts = 0; diff --git a/packages/stem/test/unit/workflow/in_memory_event_bus_test.dart b/packages/stem/test/unit/workflow/in_memory_event_bus_test.dart index 4085e6d2..cbcaed1c 100644 --- a/packages/stem/test/unit/workflow/in_memory_event_bus_test.dart +++ b/packages/stem/test/unit/workflow/in_memory_event_bus_test.dart @@ -14,6 +14,7 @@ class _NoopWorkflowStore implements WorkflowStore { @override Future createRun({ + String? runId, required String workflow, required Map params, String? parentRunId, diff --git a/packages/stem/test/unit/workflow/workflow_manifest_test.dart b/packages/stem/test/unit/workflow/workflow_manifest_test.dart new file mode 100644 index 00000000..e1559327 --- /dev/null +++ b/packages/stem/test/unit/workflow/workflow_manifest_test.dart @@ -0,0 +1,78 @@ +import 'package:stem/stem.dart'; +import 'package:test/test.dart'; + +void main() { + test('workflow definitions expose stable ids and manifest entries', () { + final definition = Flow( + name: 'manifest.flow', + version: '1.0.0', + build: (flow) { + flow + ..step('first', (context) async => 'ok') + ..step('second', (context) async => context.previousResult); + }, + ).definition; + + final firstId = definition.stableId; + final secondId = definition.stableId; + expect(firstId, equals(secondId)); + + final manifest = definition.toManifestEntry(); + expect(manifest.id, equals(firstId)); + expect(manifest.name, equals('manifest.flow')); + expect(manifest.kind, equals(WorkflowDefinitionKind.flow)); + expect(manifest.stepCollectionLabel, equals('steps')); + expect(manifest.checkpoints, hasLength(2)); + expect(manifest.steps, hasLength(2)); + expect(manifest.steps.first.position, equals(0)); + expect(manifest.steps.first.name, equals('first')); + expect( + manifest.steps.first.role, + equals(WorkflowManifestStepRole.flowStep), + ); + expect(manifest.steps.first.id, isNotEmpty); + expect(manifest.steps.first.id, isNot(equals(manifest.steps.last.id))); + }); + + test('script workflows can publish declared checkpoint metadata', () { + final definition = WorkflowScript>( + name: 'manifest.script', + run: (script) async { + final email = script.params['email'] as String; + return {'email': email, 'status': 'done'}; + }, + checkpoints: [ + FlowStep( + name: 'create-user', + title: 'Create user', + kind: WorkflowStepKind.task, + taskNames: const ['user.create'], + handler: (context) async => {'id': '1'}, + ), + FlowStep( + name: 'send-welcome-email', + title: 'Send welcome email', + kind: WorkflowStepKind.task, + taskNames: const ['email.send'], + handler: (context) async => null, + ), + ], + ).definition; + + final manifest = definition.toManifestEntry(); + expect(manifest.kind, equals(WorkflowDefinitionKind.script)); + expect(manifest.stepCollectionLabel, equals('checkpoints')); + expect(manifest.checkpoints, hasLength(2)); + expect(manifest.steps, hasLength(2)); + expect(manifest.steps.first.name, equals('create-user')); + expect(manifest.steps.first.position, equals(0)); + expect( + manifest.steps.first.role, + equals(WorkflowManifestStepRole.scriptCheckpoint), + ); + expect(manifest.steps.first.taskNames, equals(const ['user.create'])); + expect(manifest.steps.last.name, equals('send-welcome-email')); + expect(manifest.steps.last.position, equals(1)); + expect(manifest.steps.last.taskNames, equals(const ['email.send'])); + }); +} diff --git a/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart b/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart index 4cf11f9e..2302703f 100644 --- a/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart +++ b/packages/stem/test/unit/workflow/workflow_metadata_views_test.dart @@ -50,6 +50,44 @@ void main() { equals(const {'invoiceId': 'inv-1'}), ); }); + + test('exposes runtime queue and serialization metadata', () { + final state = RunState( + id: 'run-2', + workflow: 'invoice', + status: WorkflowStatus.running, + cursor: 1, + params: const { + 'tenant': 'acme', + '__stem.workflow.runtime': { + 'workflowId': 'abc123', + 'orchestrationQueue': 'workflow', + 'continuationQueue': 'workflow-continue', + 'executionQueue': 'workflow-step', + 'serializationFormat': 'json', + 'serializationVersion': '1', + 'frameFormat': 'stem-envelope', + 'frameVersion': '1', + 'encryptionScope': 'signed-envelope', + 'encryptionEnabled': true, + 'streamId': 'invoice_run-2', + }, + }, + createdAt: DateTime.utc(2026, 2, 25), + ); + + expect(state.workflowParams, equals(const {'tenant': 'acme'})); + expect(state.orchestrationQueue, equals('workflow')); + expect(state.continuationQueue, equals('workflow-continue')); + expect(state.executionQueue, equals('workflow-step')); + expect(state.serializationFormat, equals('json')); + expect(state.serializationVersion, equals('1')); + expect(state.frameFormat, equals('stem-envelope')); + expect(state.frameVersion, equals('1')); + expect(state.encryptionScope, equals('signed-envelope')); + expect(state.encryptionEnabled, isTrue); + expect(state.streamId, equals('invoice_run-2')); + }); }); group('Workflow watcher metadata getters', () { @@ -107,4 +145,20 @@ void main() { ); }); }); + + group('WorkflowStepEntry metadata getters', () { + test('parses base name and iteration suffix', () { + const step = WorkflowStepEntry( + name: 'approval#3', + value: 'ok', + position: 2, + ); + const plain = WorkflowStepEntry(name: 'finalize', value: null, position: 3); + + expect(step.baseName, equals('approval')); + expect(step.iteration, equals(3)); + expect(plain.baseName, equals('finalize')); + expect(plain.iteration, isNull); + }); + }); } diff --git a/packages/stem/test/unit/workflow/workflow_resume_test.dart b/packages/stem/test/unit/workflow/workflow_resume_test.dart new file mode 100644 index 00000000..6c8c3dcd --- /dev/null +++ b/packages/stem/test/unit/workflow/workflow_resume_test.dart @@ -0,0 +1,120 @@ +import 'package:stem/src/core/contracts.dart'; +import 'package:stem/src/core/payload_codec.dart'; +import 'package:stem/src/workflow/core/flow_context.dart'; +import 'package:stem/src/workflow/core/workflow_resume.dart'; +import 'package:stem/src/workflow/core/workflow_script_context.dart'; +import 'package:test/test.dart'; + +void main() { + test('FlowContext.takeResumeValue decodes codec-backed DTO payloads', () { + final context = FlowContext( + workflow: 'demo', + runId: 'run-1', + stepName: 'wait', + params: const {}, + previousResult: null, + stepIndex: 0, + resumeData: const {'message': 'approved'}, + ); + + final value = context.takeResumeValue<_ResumePayload>( + codec: _resumePayloadCodec, + ); + + expect(value, isNotNull); + expect(value!.message, 'approved'); + expect( + context.takeResumeValue<_ResumePayload>(codec: _resumePayloadCodec), + isNull, + ); + }); + + test('WorkflowScriptStepContext.takeResumeValue casts plain payloads', () { + final context = _FakeWorkflowScriptStepContext( + resumeData: const {'approvedBy': 'gateway'}, + ); + + final value = context.takeResumeValue>(); + + expect(value, isNotNull); + expect(value!['approvedBy'], 'gateway'); + expect(context.takeResumeValue>(), isNull); + }); +} + +class _ResumePayload { + const _ResumePayload({required this.message}); + + factory _ResumePayload.fromJson(Map json) { + return _ResumePayload(message: json['message'] as String); + } + + final String message; + + Map toJson() => {'message': message}; +} + +const _resumePayloadCodec = PayloadCodec<_ResumePayload>( + encode: _encodeResumePayload, + decode: _decodeResumePayload, +); + +Object? _encodeResumePayload(_ResumePayload value) => value.toJson(); + +_ResumePayload _decodeResumePayload(Object? payload) { + return _ResumePayload.fromJson( + Map.from(payload! as Map), + ); +} + +class _FakeWorkflowScriptStepContext implements WorkflowScriptStepContext { + _FakeWorkflowScriptStepContext({Object? resumeData}) + : _resumeData = resumeData; + + Object? _resumeData; + + @override + TaskEnqueuer? get enqueuer => null; + + @override + int get iteration => 0; + + @override + Map get params => const {}; + + @override + Object? get previousResult => null; + + @override + String get runId => 'run-1'; + + @override + String get stepName => 'step'; + + @override + int get stepIndex => 0; + + @override + String get workflow => 'demo.workflow'; + + @override + Future awaitEvent( + String topic, { + DateTime? deadline, + Map? data, + }) async {} + + @override + String idempotencyKey([String? scope]) => + 'demo.workflow/run-1/${scope ?? stepName}'; + + @override + Future sleep(Duration duration, {Map? data}) async {} + + @override + Object? takeResumeData() { + final value = _resumeData; + _resumeData = null; + return value; + } +} diff --git a/packages/stem/test/workflow/workflow_runtime_test.dart b/packages/stem/test/workflow/workflow_runtime_test.dart index 7cff2c47..c04b1486 100644 --- a/packages/stem/test/workflow/workflow_runtime_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_test.dart @@ -1,10 +1,11 @@ +import 'package:contextual/contextual.dart' show Level, LogDriver, LogEntry; import 'package:stem/stem.dart'; import 'package:test/test.dart'; void main() { late InMemoryBroker broker; late InMemoryResultBackend backend; - late SimpleTaskRegistry registry; + late InMemoryTaskRegistry registry; late Stem stem; late InMemoryWorkflowStore store; late WorkflowRuntime runtime; @@ -14,7 +15,7 @@ void main() { setUp(() { broker = InMemoryBroker(); backend = InMemoryResultBackend(); - registry = SimpleTaskRegistry(); + registry = InMemoryTaskRegistry(); stem = Stem(broker: broker, registry: registry, backend: backend); clock = FakeWorkflowClock(DateTime.utc(2024)); store = InMemoryWorkflowStore(clock: clock); @@ -61,6 +62,85 @@ void main() { expect(await store.readStep(runId, 'finish'), 'ready-done'); }); + test( + 'startWorkflow persists runtime metadata and strips internal params', + () async { + runtime.registerWorkflow( + Flow( + name: 'metadata.workflow', + build: (flow) { + flow.step('inspect', (context) async => context.params['tenant']); + }, + ).definition, + ); + + final runId = await runtime.startWorkflow( + 'metadata.workflow', + params: const {'tenant': 'acme'}, + ); + + final state = await store.get(runId); + expect(state, isNotNull); + expect( + state!.params.containsKey(workflowRuntimeMetadataParamKey), + isTrue, + ); + expect(state.workflowParams, equals(const {'tenant': 'acme'})); + expect(state.orchestrationQueue, equals(runtime.queue)); + expect(state.executionQueue, equals(runtime.executionQueue)); + expect( + state.workflowParams.containsKey(workflowRuntimeMetadataParamKey), + isFalse, + ); + expect(introspection.runtimeEvents, isNotEmpty); + expect( + introspection.runtimeEvents.last.type, + equals(WorkflowRuntimeEventType.continuationEnqueued), + ); + }, + ); + + test('viewRunDetail exposes uniform run and step views', () async { + runtime.registerWorkflow( + Flow( + name: 'views.workflow', + build: (flow) { + flow.step('only', (context) async => 'done'); + }, + ).definition, + ); + + final runId = await runtime.startWorkflow('views.workflow'); + await runtime.executeRun(runId); + + final detail = await runtime.viewRunDetail(runId); + expect(detail, isNotNull); + expect(detail!.run.runId, equals(runId)); + expect(detail.run.workflow, equals('views.workflow')); + expect(detail.steps, hasLength(1)); + expect(detail.steps.first.baseStepName, equals('only')); + expect(detail.steps.first.stepName, equals('only')); + }); + + test('workflowManifest exposes typed manifest entries', () { + runtime.registerWorkflow( + Flow( + name: 'manifest.runtime.workflow', + build: (flow) { + flow.step('only', (context) async => 'done'); + }, + ).definition, + ); + + final manifest = runtime.workflowManifest(); + final entry = manifest.firstWhere( + (item) => item.name == 'manifest.runtime.workflow', + ); + expect(entry.id, isNotEmpty); + expect(entry.steps, hasLength(1)); + expect(entry.steps.first.name, equals('only')); + }); + test('extends lease when checkpoints persist', () async { runtime.registerWorkflow( Flow( @@ -243,6 +323,51 @@ void main() { expect(observedPayload, 'user-123'); }); + test('emitValue resumes flows with codec-backed DTO payloads', () async { + _UserUpdatedEvent? observedPayload; + + runtime.registerWorkflow( + Flow( + name: 'event.typed.workflow', + build: (flow) { + flow.step( + 'wait', + (context) async { + final resume = context.takeResumeValue<_UserUpdatedEvent>( + codec: _userUpdatedEventCodec, + ); + if (resume == null) { + context.awaitEvent('user.updated.typed'); + return null; + } + observedPayload = resume; + return resume.id; + }, + ); + }, + ).definition, + ); + + final runId = await runtime.startWorkflow('event.typed.workflow'); + await runtime.executeRun(runId); + + final suspended = await store.get(runId); + expect(suspended?.status, WorkflowStatus.suspended); + expect(suspended?.waitTopic, 'user.updated.typed'); + + await runtime.emitValue( + 'user.updated.typed', + const _UserUpdatedEvent(id: 'user-typed-1'), + codec: _userUpdatedEventCodec, + ); + await runtime.executeRun(runId); + + final completed = await store.get(runId); + expect(completed?.status, WorkflowStatus.completed); + expect(observedPayload?.id, 'user-typed-1'); + expect(completed?.result, 'user-typed-1'); + }); + test('emit persists payload before worker resumes execution', () async { runtime.registerWorkflow( Flow( @@ -865,6 +990,87 @@ void main() { expect(meta['origin'], 'direct'); }); + test( + 'emits workflow lifecycle logs for enqueue, suspension, and completion', + () async { + final driver = _RecordingLogDriver(); + stemLogger + ..addChannel( + 'workflow-runtime-log-test-${DateTime.now().microsecondsSinceEpoch}', + driver, + ) + ..setLevel(Level.debug); + + runtime.registerWorkflow( + Flow( + name: 'logging.suspend.workflow', + build: (flow) { + flow.step('wait', (context) async { + context.sleep(const Duration(milliseconds: 20)); + return null; + }); + }, + ).definition, + ); + runtime.registerWorkflow( + Flow( + name: 'logging.complete.workflow', + build: (flow) { + flow.step('finish', (context) async => 'done'); + }, + ).definition, + ); + + final suspendedRunId = await runtime.startWorkflow( + 'logging.suspend.workflow', + ); + await runtime.executeRun(suspendedRunId); + + final completedRunId = await runtime.startWorkflow( + 'logging.complete.workflow', + ); + await runtime.executeRun(completedRunId); + + LogEntry findEntry(String runId, String message) => + driver.entries.firstWhere( + (entry) => + entry.record.message == message && + entry.record.context.all()['workflowRunId'] == runId, + ); + + final enqueued = driver.entries.firstWhere( + (entry) => + entry.record.message == 'Workflow {workflow} enqueued' && + entry.record.context.all()['workflowRunId'] == suspendedRunId && + entry.record.context.all()['workflowReason'] == 'start', + ); + expect( + enqueued.record.context.all()['workflow'], + equals('logging.suspend.workflow'), + ); + expect(enqueued.record.context.all()['workflowReason'], equals('start')); + + final suspended = findEntry( + suspendedRunId, + 'Workflow {workflow} suspended', + ); + expect(suspended.record.context.all()['workflowStep'], equals('wait')); + expect( + suspended.record.context.all()['workflowSuspensionType'], + equals('sleep'), + ); + + final completed = findEntry( + completedRunId, + 'Workflow {workflow} completed', + ); + expect( + completed.record.context.all()['workflow'], + equals('logging.complete.workflow'), + ); + }, + ); + test('enqueue builder in steps includes workflow metadata', () async { const taskName = 'tasks.meta.builder'; registry.register( @@ -918,9 +1124,44 @@ void main() { class _RecordingWorkflowIntrospectionSink implements WorkflowIntrospectionSink { final List events = []; + final List runtimeEvents = []; @override Future recordStepEvent(WorkflowStepEvent event) async { events.add(event); } + + @override + Future recordRuntimeEvent(WorkflowRuntimeEvent event) async { + runtimeEvents.add(event); + } +} + +class _RecordingLogDriver extends LogDriver { + _RecordingLogDriver() : entries = [], super('recording'); + + final List entries; + + @override + Future log(LogEntry entry) async { + entries.add(entry); + } +} + +final _userUpdatedEventCodec = PayloadCodec<_UserUpdatedEvent>( + encode: (value) => value.toJson(), + decode: _UserUpdatedEvent.fromJson, +); + +class _UserUpdatedEvent { + const _UserUpdatedEvent({required this.id}); + + final String id; + + Map toJson() => {'id': id}; + + static _UserUpdatedEvent fromJson(Object? payload) { + final json = payload! as Map; + return _UserUpdatedEvent(id: json['id'] as String); + } } diff --git a/packages/stem/tool/proxy_runtime_check.dart b/packages/stem/tool/proxy_runtime_check.dart new file mode 100644 index 00000000..c33db2b8 --- /dev/null +++ b/packages/stem/tool/proxy_runtime_check.dart @@ -0,0 +1,62 @@ +import 'dart:io'; + +import 'package:stem/stem.dart'; + +class ScriptDef { + Future run(WorkflowScriptContext script) async { + return sendEmail('user@example.com'); + } + + Future sendEmail(String email) async { + return email; + } +} + +class ScriptProxy extends ScriptDef { + ScriptProxy(this._script); + final WorkflowScriptContext _script; + + @override + Future sendEmail(String email) { + return _script.step( + 'send-email', + (context) => super.sendEmail(email), + ); + } +} + +Future main() async { + final broker = InMemoryBroker(); + final backend = InMemoryResultBackend(); + final registry = InMemoryTaskRegistry(); + final stem = Stem(broker: broker, registry: registry, backend: backend); + final store = InMemoryWorkflowStore(); + final runtime = WorkflowRuntime( + stem: stem, + store: store, + eventBus: InMemoryEventBus(store), + continuationQueue: 'workflow-continue', + ); + + registry.register(runtime.workflowRunnerHandler()); + runtime.registerWorkflow( + WorkflowScript( + name: 'proxy.script', + run: (script) => ScriptProxy(script).run(script), + ).definition, + ); + + final runId = await runtime.startWorkflow('proxy.script'); + await runtime.executeRun(runId); + final detail = await runtime.viewRunDetail(runId); + stdout.writeln( + 'result=${detail?.run.result} checkpoints=${detail?.steps.length}', + ); + if ((detail?.steps.length ?? 0) > 0) { + stdout.writeln('checkpointName=${detail!.steps.first.stepName}'); + } + + await runtime.dispose(); + await backend.close(); + broker.dispose(); +} diff --git a/packages/stem_adapter_tests/CHANGELOG.md b/packages/stem_adapter_tests/CHANGELOG.md index 48cb9d8f..c39cd1f5 100644 --- a/packages/stem_adapter_tests/CHANGELOG.md +++ b/packages/stem_adapter_tests/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.1.1 +- Added workflow-store contract coverage for caller-provided run ids and + persisted workflow runtime metadata introspection. +- Added regression coverage for duplicate caller-provided run ids and + continuation re-enqueue routing using persisted workflow queue metadata. - Expanded adapter contract documentation with a capability matrix, explicit skip semantics, and recipe-style setup examples. - Scoped the binary payload round-trip contract test to the Base64 encoder diff --git a/packages/stem_adapter_tests/lib/src/workflow_script_facade_suite.dart b/packages/stem_adapter_tests/lib/src/workflow_script_facade_suite.dart index f2feaf15..35ce0656 100644 --- a/packages/stem_adapter_tests/lib/src/workflow_script_facade_suite.dart +++ b/packages/stem_adapter_tests/lib/src/workflow_script_facade_suite.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:stem/stem.dart'; import 'package:stem_adapter_tests/src/workflow_store_contract_suite.dart'; import 'package:test/test.dart'; @@ -11,7 +13,6 @@ void runWorkflowScriptFacadeTests({ WorkflowStore? store; InMemoryBroker? broker; InMemoryResultBackend? backend; - SimpleTaskRegistry? registry; Stem? stem; WorkflowRuntime? runtime; late FakeWorkflowClock clock; @@ -21,8 +22,8 @@ void runWorkflowScriptFacadeTests({ store = await factory.create(clock); broker = InMemoryBroker(); backend = InMemoryResultBackend(); - registry = SimpleTaskRegistry(); - stem = Stem(broker: broker!, registry: registry!, backend: backend); + final currentRegistry = InMemoryTaskRegistry(); + stem = Stem(broker: broker!, registry: currentRegistry, backend: backend); runtime = WorkflowRuntime( stem: stem!, store: store!, @@ -30,7 +31,7 @@ void runWorkflowScriptFacadeTests({ clock: clock, pollInterval: const Duration(milliseconds: 50), ); - registry!.register(runtime!.workflowRunnerHandler()); + currentRegistry.register(runtime!.workflowRunnerHandler()); }); tearDown(() async { @@ -44,7 +45,6 @@ void runWorkflowScriptFacadeTests({ store = null; broker = null; backend = null; - registry = null; stem = null; runtime = null; }); @@ -192,5 +192,109 @@ void runWorkflowScriptFacadeTests({ expect(observed?['value'], 'resumed'); expect(completed?.result, 'resumed'); }); + + test( + 'event resumptions enqueue continuations onto the ' + 'persisted queue metadata', + () async { + final currentStem = stem!; + final currentStore = store!; + final currentBroker = broker!; + final runtimeA = WorkflowRuntime( + stem: currentStem, + store: currentStore, + eventBus: InMemoryEventBus(currentStore), + clock: clock, + queue: 'workflow-a', + continuationQueue: 'workflow-a-cont', + executionQueue: 'workflow-a-exec', + ); + final runtimeB = WorkflowRuntime( + stem: currentStem, + store: currentStore, + eventBus: InMemoryEventBus(currentStore), + clock: clock, + queue: 'workflow-b', + continuationQueue: 'workflow-b-cont', + executionQueue: 'workflow-b-exec', + ); + + final definition = WorkflowScript( + name: 'script.contract.queue-routing', + run: (script) async { + final value = await script.step('wait', (step) async { + final resume = step.takeResumeData(); + if (resume == null) { + await step.awaitEvent('contract.queue-routing'); + return 'waiting'; + } + final payload = resume as Map; + return payload['value']?.toString() ?? 'missing'; + }); + return value; + }, + ).definition; + runtimeA.registerWorkflow(definition); + runtimeB.registerWorkflow(definition); + + Future nextDelivery(String queue) async { + try { + return await currentBroker + .consume(RoutingSubscription.singleQueue(queue)) + .first + .timeout(const Duration(milliseconds: 250)); + } on TimeoutException { + return null; + } + } + + try { + final runId = await runtimeA.startWorkflow( + 'script.contract.queue-routing', + ); + await runtimeA.executeRun(runId); + + final suspended = await currentStore.get(runId); + expect(suspended?.status, WorkflowStatus.suspended); + expect(suspended?.waitTopic, 'contract.queue-routing'); + expect(suspended?.continuationQueue, 'workflow-a-cont'); + expect( + suspended?.executionQueue, + 'workflow-a-exec', + ); + + final deliveryAFuture = nextDelivery('workflow-a-cont'); + final deliveryBFuture = nextDelivery('workflow-b-cont'); + + await runtimeB.emit('contract.queue-routing', const {'value': 'ok'}); + + final deliveryA = await deliveryAFuture; + final deliveryB = await deliveryBFuture; + + expect(deliveryA, isNotNull); + expect(deliveryB, isNull); + expect(deliveryA!.envelope.queue, 'workflow-a-cont'); + expect( + deliveryA.envelope.meta['stem.workflow.orchestrationQueue'], + 'workflow-a', + ); + expect( + deliveryA.envelope.meta['stem.workflow.continuationQueue'], + 'workflow-a-cont', + ); + expect( + deliveryA.envelope.meta['stem.workflow.executionQueue'], + 'workflow-a-exec', + ); + expect( + deliveryA.envelope.meta['stem.workflow.continuationReason'], + WorkflowContinuationReason.event.name, + ); + } finally { + await runtimeA.dispose(); + await runtimeB.dispose(); + } + }, + ); }); } diff --git a/packages/stem_adapter_tests/lib/src/workflow_store_contract_suite.dart b/packages/stem_adapter_tests/lib/src/workflow_store_contract_suite.dart index 0216da18..142c87e8 100644 --- a/packages/stem_adapter_tests/lib/src/workflow_store_contract_suite.dart +++ b/packages/stem_adapter_tests/lib/src/workflow_store_contract_suite.dart @@ -4,6 +4,14 @@ import 'package:stem/stem.dart'; import 'package:stem_adapter_tests/src/contract_capabilities.dart'; import 'package:test/test.dart'; +int _workflowStoreContractRunIdCounter = 0; +final int _workflowStoreContractRunIdSeed = + DateTime.now().microsecondsSinceEpoch; + +String _nextRequestedRunId() => + 'contract-run-id-$_workflowStoreContractRunIdSeed-' + '${_workflowStoreContractRunIdCounter++}'; + /// Settings that tune the workflow store contract suite. class WorkflowStoreContractSettings { /// Creates workflow store contract settings. @@ -66,6 +74,109 @@ void runWorkflowStoreContractTests({ expect(state.params['user'], 1); }); + test('createRun honors caller-provided runId when supplied', () async { + final current = store!; + final requestedRunId = _nextRequestedRunId(); + final runId = await current.createRun( + runId: requestedRunId, + workflow: 'contract.workflow', + params: const {'seed': 'value'}, + ); + + expect(runId, requestedRunId); + final state = await current.get(requestedRunId); + expect(state, isNotNull); + expect(state!.id, requestedRunId); + expect(state.params['seed'], 'value'); + }); + + test('createRun rejects duplicate caller-provided runId', () async { + final current = store!; + final requestedRunId = _nextRequestedRunId(); + await current.createRun( + runId: requestedRunId, + workflow: 'contract.workflow.original', + params: const {'seed': 'value'}, + ); + await current.saveStep(requestedRunId, 'checkpoint', 'persisted'); + + await expectLater( + () => current.createRun( + runId: requestedRunId, + workflow: 'contract.workflow.duplicate', + params: const {'seed': 'other'}, + ), + throwsA(anything), + ); + + final state = await current.get(requestedRunId); + expect(state, isNotNull); + expect(state!.workflow, 'contract.workflow.original'); + expect(state.params['seed'], 'value'); + expect( + await current.readStep(requestedRunId, 'checkpoint'), + 'persisted', + ); + }); + + test( + 'createRun persists runtime metadata and workflowParams strips internals', + () async { + final current = store!; + const runtimeMetadata = WorkflowRunRuntimeMetadata( + workflowId: 'wf_contract_01', + orchestrationQueue: 'workflow', + continuationQueue: 'workflow', + executionQueue: 'workflow-step', + serializationFormat: 'json', + serializationVersion: '1', + frameFormat: 'stem-envelope', + frameVersion: '1', + encryptionScope: 'signed-envelope', + encryptionEnabled: true, + streamId: 'contract_stream_01', + ); + final params = runtimeMetadata.attachToParams(const { + 'tenant': 'acme', + 'jobType': 'sync', + }); + + final runId = await current.createRun( + workflow: 'contract.runtime.meta', + params: params, + ); + + final state = await current.get(runId); + expect(state, isNotNull); + expect( + state!.params.containsKey(workflowRuntimeMetadataParamKey), + isTrue, + ); + expect( + state.params[workflowRuntimeMetadataParamKey], + runtimeMetadata.toJson(), + ); + expect( + state.workflowParams, + equals(const {'tenant': 'acme', 'jobType': 'sync'}), + ); + expect( + state.workflowParams.containsKey(workflowRuntimeMetadataParamKey), + isFalse, + ); + expect(state.orchestrationQueue, 'workflow'); + expect(state.continuationQueue, 'workflow'); + expect(state.executionQueue, 'workflow-step'); + expect(state.serializationFormat, 'json'); + expect(state.serializationVersion, '1'); + expect(state.frameFormat, 'stem-envelope'); + expect(state.frameVersion, '1'); + expect(state.encryptionScope, 'signed-envelope'); + expect(state.encryptionEnabled, isTrue); + expect(state.streamId, 'contract_stream_01'); + }, + ); + test('saveStep/readStep/rewind maintain checkpoints', () async { final current = store!; final runId = await current.createRun( diff --git a/packages/stem_adapter_tests/pubspec.yaml b/packages/stem_adapter_tests/pubspec.yaml index c64904cf..0819484c 100644 --- a/packages/stem_adapter_tests/pubspec.yaml +++ b/packages/stem_adapter_tests/pubspec.yaml @@ -7,7 +7,7 @@ environment: sdk: ">=3.9.2 <4.0.0" dependencies: - stem: ^0.1.0 + stem: ^0.1.1 test: ^1.29.0 dev_dependencies: diff --git a/packages/stem_builder/CHANGELOG.md b/packages/stem_builder/CHANGELOG.md index e7dcb657..aab805a1 100644 --- a/packages/stem_builder/CHANGELOG.md +++ b/packages/stem_builder/CHANGELOG.md @@ -2,6 +2,19 @@ ## 0.1.0 +- Switched generated output to a bundle-first surface with `stemModule`, `StemWorkflowDefinitions`, `StemTaskDefinitions`, generated typed wait helpers, and payload codec generation for DTO-backed workflow/task APIs. +- Added builder diagnostics for duplicate or conflicting annotated workflow checkpoint names and refreshed generated examples around typed workflow refs. +- Added typed workflow starter generation and app helper output for annotated + workflow/task definitions. +- Switched generated output to per-file `part` generation using `.stem.g.dart` + files instead of a shared standalone registry file. +- Added support for plain `@WorkflowRun` entrypoints and configurable starter + naming in generated APIs. +- Refreshed the builder README, example package, and annotated workflow demos + to match the generated `tasks:`-first runtime wiring. +- Switched generated script metadata from `steps:` to `checkpoints:` and + expanded docs/examples around direct step calls, context injection, and + serializable parameter rules. - Initial builder for annotated Stem workflow/task registries. - Expanded the registry builder implementation and hardened generation output. - Added build configuration, analysis options, and tests for registry builds. diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index a2da43fe..c4a1797a 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -9,7 +9,7 @@ [![License](https://img.shields.io/badge/license-MIT-purple.svg)](https://github.com/kingwill101/stem/blob/main/LICENSE) [![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-support-yellow.svg)](https://www.buymeacoffee.com/kingwill101) -Build-time registry generator for annotated Stem workflows and tasks. +Build-time code generator for annotated Stem workflows and tasks. ## Install @@ -30,28 +30,208 @@ Annotate workflows and tasks: ```dart import 'package:stem/stem.dart'; -@workflow.defn(name: 'hello.flow') +part 'workflows.stem.g.dart'; + +@WorkflowDefn(name: 'hello.flow') class HelloFlow { - @workflow.step() - Future greet(FlowContext context) async { + @WorkflowStep() + Future greet(String email) async { // ... } } +@WorkflowDefn(name: 'hello.script', kind: WorkflowKind.script) +class HelloScript { + Future run(String email) async { + await sendEmail(email); + } + + @WorkflowStep() + Future sendEmail(String email) async { + // builder routes this through durable script.step(...) + } +} + @TaskDefn(name: 'hello.task') Future helloTask( TaskInvocationContext context, - Map args, + String email, ) async { // ... } ``` -Run build_runner to generate `lib/stem_registry.g.dart`: +Script workflows can use a plain `run(...)` method (no extra annotation +required). `@WorkflowRun` is still supported for backward compatibility. +`run(...)` may optionally take `WorkflowScriptContext` as its first parameter, +followed by required positional serializable parameters. + +The intended usage is to call annotated step methods directly from `run(...)`: + +```dart +Future> run(String email) async { + final user = await createUser(email); + await sendWelcomeEmail(email); + await sendOneWeekCheckInEmail(email); + return {'userId': user['id'], 'status': 'done'}; +} +``` + +`stem_builder` generates a proxy subclass that rewrites those calls into +durable `script.step(...)` executions. The source method bodies stay readable, +while the generated part handles the workflow runtime plumbing. + +Conceptually: + +- `Flow`: declared steps are the execution plan +- script workflows: `run(...)` is the execution plan, and declared checkpoints + are metadata for manifests/tooling + +Choose the entry shape based on whether you need step context: + +- plain direct-call style + - `Future run(String email, ...)` + - use when annotated step methods only need serializable parameters +- context-aware style + - `@WorkflowRun()` + - `Future run(WorkflowScriptContext script, String email, ...)` + - use when you need to enter through `script.step(...)` so the step body can + receive `WorkflowScriptStepContext` + +Supported context injection points: + +- flow steps: `FlowContext` +- script runs: `WorkflowScriptContext` +- script steps: `WorkflowScriptStepContext` +- tasks: `TaskInvocationContext` + +Serializable parameter rules are enforced by the generator: + +- supported: + - `String`, `bool`, `int`, `double`, `num`, `Object?`, `null` + - `List` where `T` is serializable + - `Map` where `T` is serializable +- supported DTOs: + - Dart classes with `toJson()` plus a named `fromJson(...)` constructor + taking `Map` +- unsupported directly: + - optional/named parameters on generated workflow/task entrypoints + +Typed task results can use the same DTO convention. + +Workflow inputs, checkpoint values, and final workflow results can use the same +DTO convention. The generated `PayloadCodec` persists the JSON form while +workflow code continues to work with typed objects. + +The intended DX is: + +- define annotated workflows and tasks in one file +- add `part '.stem.g.dart';` +- run `build_runner` +- pass generated `stemModule` into `StemWorkflowApp` or `StemClient` +- start workflows through generated workflow refs instead of raw + workflow-name strings +- enqueue annotated tasks through generated `enqueueXxx(...)` helpers instead + of raw task-name strings + +You can customize generated workflow ref names via `@WorkflowDefn`: + +```dart +@WorkflowDefn( + name: 'billing.daily_sync', + starterName: 'DailyBilling', + nameField: 'dailyBilling', + kind: WorkflowKind.script, +) +class BillingWorkflow { + Future run(String tenant) async {} +} +``` + +Run build_runner to generate `*.stem.g.dart` part files: ```bash dart run build_runner build ``` -The generated registry exports `registerStemDefinitions` to register annotated -flows, scripts, and tasks with your `WorkflowRegistry` and `TaskRegistry`. +The generated part exports a bundle plus typed helpers so you can avoid raw +workflow-name and task-name strings (for example +`StemWorkflowDefinitions.userSignup.call((email: 'user@example.com'))` or +`stem.enqueueBuilderExampleTask(args: {...})`). + +Generated output includes: + +- `stemModule` +- `StemWorkflowDefinitions` +- `StemTaskDefinitions` +- typed enqueue helpers on `TaskEnqueuer` +- typed result wait helpers on `Stem` + +## Wiring Into StemWorkflowApp + +For the common case, pass the generated bundle directly to `StemWorkflowApp`: + +```dart +final workflowApp = await StemWorkflowApp.fromUrl( + 'redis://localhost:6379', + module: stemModule, +); + +final result = await StemWorkflowDefinitions.userSignup + .call((email: 'user@example.com')) + .startAndWaitWithApp(workflowApp); +``` + +If your application already owns a `StemApp`, reuse it: + +```dart +final stemApp = await StemApp.fromUrl( + 'redis://localhost:6379', + adapters: const [StemRedisAdapter()], + tasks: stemModule.tasks, +); + +final workflowApp = await StemWorkflowApp.create( + stemApp: stemApp, + module: stemModule, +); +``` + +If you already centralize wiring in a `StemClient`, prefer the shared-client +path: + +```dart +final client = await StemClient.fromUrl( + 'redis://localhost:6379', + adapters: const [StemRedisAdapter()], +); + +final workflowApp = await client.createWorkflowApp(module: stemModule); +``` + +The generated workflow refs work on `WorkflowRuntime` too: + +```dart +final runtime = workflowApp.runtime; +final runId = await StemWorkflowDefinitions.userSignup + .call((email: 'user@example.com')) + .startWithRuntime(runtime); +await runtime.executeRun(runId); +``` + +Annotated tasks also get generated definitions and enqueue helpers: + +```dart +final taskId = await workflowApp.app.stem.enqueueBuilderExampleTask( + args: const {'kind': 'welcome'}, +); +``` + +## Examples + +See [`example/README.md`](example/README.md) for runnable examples, including: + +- Generated registration + execution with `StemWorkflowApp` +- Runtime manifest + run detail views with `WorkflowRuntime` +- Plain direct-call script steps and context-aware script steps +- Typed `@TaskDefn` parameters with `TaskInvocationContext` diff --git a/packages/stem_builder/build.yaml b/packages/stem_builder/build.yaml index 91feb16a..9c27c9c3 100644 --- a/packages/stem_builder/build.yaml +++ b/packages/stem_builder/build.yaml @@ -2,6 +2,6 @@ builders: stem_registry_builder: import: "package:stem_builder/stem_builder.dart" builder_factories: ["stemRegistryBuilder"] - build_extensions: {"lib/$lib$": ["lib/stem_registry.g.dart"]} + build_extensions: {"lib/{{}}.dart": ["lib/{{}}.stem.g.dart"]} auto_apply: dependents build_to: source diff --git a/packages/stem_builder/example/README.md b/packages/stem_builder/example/README.md new file mode 100644 index 00000000..afca9303 --- /dev/null +++ b/packages/stem_builder/example/README.md @@ -0,0 +1,38 @@ +# stem_builder example + +This example demonstrates: + +- Annotated workflow/task definitions +- Generated `stemModule` +- Generated typed workflow refs (no manual workflow-name strings): + - `StemWorkflowDefinitions.flow.call(...).startWithRuntime(runtime)` + - `StemWorkflowDefinitions.userSignup.call(...).startWithRuntime(runtime)` +- Generated typed task definitions, enqueue helpers, and typed result wait helpers +- Generated workflow manifest via `stemModule.workflowManifest` +- Running generated definitions through `StemWorkflowApp` +- Runtime manifest + run/step metadata views via `WorkflowRuntime` + +## Run + +```bash +cd packages/stem_builder/example + +dart pub get + +dart run build_runner build + +dart run bin/main.dart + +dart run bin/runtime_metadata_views.dart +``` + +The checked-in `lib/definitions.stem.g.dart` is only a starter snapshot; rerun +`build_runner` after changing annotations. + + +The generated bundle is the default integration surface: + +- `StemWorkflowApp.inMemory(module: stemModule)` +- `StemWorkflowApp.fromUrl(..., module: stemModule)` +- `StemWorkflowApp.create(stemApp: ..., module: stemModule)` +- `StemClient.createWorkflowApp(module: stemModule)` diff --git a/packages/stem_builder/example/bin/main.dart b/packages/stem_builder/example/bin/main.dart new file mode 100644 index 00000000..0d14ada8 --- /dev/null +++ b/packages/stem_builder/example/bin/main.dart @@ -0,0 +1,42 @@ +import 'dart:convert'; + +import 'package:stem/stem.dart'; +import 'package:stem_builder_example/definitions.dart'; + +Future main() async { + print('Registered workflows:'); + for (final entry in stemModule.workflowManifest) { + print(' - ${entry.name} (id=${entry.id})'); + } + + print('\nGenerated workflow manifest:'); + print( + const JsonEncoder.withIndent( + ' ', + ).convert(stemModule.workflowManifest.map((entry) => entry.toJson()).toList()), + ); + + final app = await StemWorkflowApp.inMemory(module: stemModule); + try { + final runtime = app.runtime; + final runtimeManifest = runtime + .workflowManifest() + .map((entry) => entry.toJson()) + .toList(growable: false); + print('\nRuntime manifest:'); + print(const JsonEncoder.withIndent(' ').convert(runtimeManifest)); + + final runId = await StemWorkflowDefinitions.flow + .call(const {'name': 'Stem Builder'}) + .startWithRuntime(runtime); + await runtime.executeRun(runId); + final result = await StemWorkflowDefinitions.flow.waitFor( + app, + runId, + timeout: const Duration(seconds: 2), + ); + print('\nFlow result: ${result?.value}'); + } finally { + await app.close(); + } +} diff --git a/packages/stem_builder/example/bin/runtime_metadata_views.dart b/packages/stem_builder/example/bin/runtime_metadata_views.dart new file mode 100644 index 00000000..1aacdee4 --- /dev/null +++ b/packages/stem_builder/example/bin/runtime_metadata_views.dart @@ -0,0 +1,57 @@ +import 'dart:convert'; + +import 'package:stem/stem.dart'; +import 'package:stem_builder_example/definitions.dart'; + +Future main() async { + final app = await StemWorkflowApp.inMemory(module: stemModule); + final runtime = app.runtime; + + try { + print('--- Generated manifest (builder output) ---'); + print( + const JsonEncoder.withIndent( + ' ', + ).convert(stemModule.workflowManifest.map((entry) => entry.toJson()).toList()), + ); + + print('\n--- Runtime manifest (registered definitions) ---'); + print( + const JsonEncoder.withIndent(' ').convert( + runtime + .workflowManifest() + .map((entry) => entry.toJson()) + .toList(growable: false), + ), + ); + + final flowRunId = await StemWorkflowDefinitions.flow + .call(const {'name': 'runtime metadata'}) + .startWithRuntime(runtime); + await runtime.executeRun(flowRunId); + + final scriptRunId = await StemWorkflowDefinitions.userSignup + .call((email: 'dev@stem.dev')) + .startWithRuntime(runtime); + await runtime.executeRun(scriptRunId); + + final runViews = await runtime.listRunViews(limit: 10); + print('\n--- Run views ---'); + print( + const JsonEncoder.withIndent( + ' ', + ).convert(runViews.map((view) => view.toJson()).toList()), + ); + + final flowDetail = await runtime.viewRunDetail(flowRunId); + final scriptDetail = await runtime.viewRunDetail(scriptRunId); + + print('\n--- Flow run detail ---'); + print(const JsonEncoder.withIndent(' ').convert(flowDetail?.toJson())); + + print('\n--- Script run detail ---'); + print(const JsonEncoder.withIndent(' ').convert(scriptDetail?.toJson())); + } finally { + await app.close(); + } +} diff --git a/packages/stem_builder/example/lib/definitions.dart b/packages/stem_builder/example/lib/definitions.dart new file mode 100644 index 00000000..b8980ede --- /dev/null +++ b/packages/stem_builder/example/lib/definitions.dart @@ -0,0 +1,38 @@ +import 'package:stem/stem.dart'; + +part 'definitions.stem.g.dart'; + +@WorkflowDefn(name: 'builder.example.flow') +class BuilderExampleFlow { + @WorkflowStep(name: 'greet') + Future greet(String name) async { + return 'hello $name'; + } +} + +@WorkflowDefn(name: 'builder.example.user_signup', kind: WorkflowKind.script) +class BuilderUserSignupWorkflow { + Future> run(String email) async { + final user = await createUser(email); + await sendWelcomeEmail(email); + await sendOneWeekCheckInEmail(email); + return {'userId': user['id'], 'status': 'done'}; + } + + @WorkflowStep(name: 'create-user') + Future> createUser(String email) async { + return {'id': 'user:$email'}; + } + + @WorkflowStep(name: 'send-welcome-email') + Future sendWelcomeEmail(String email) async {} + + @WorkflowStep(name: 'send-one-week-check-in-email') + Future sendOneWeekCheckInEmail(String email) async {} +} + +@TaskDefn(name: 'builder.example.task') +Future builderExampleTask( + TaskInvocationContext context, + Map args, +) async {} diff --git a/packages/stem_builder/example/lib/definitions.stem.g.dart b/packages/stem_builder/example/lib/definitions.stem.g.dart new file mode 100644 index 00000000..2f87ebb1 --- /dev/null +++ b/packages/stem_builder/example/lib/definitions.stem.g.dart @@ -0,0 +1,165 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: unused_element, unnecessary_lambdas, omit_local_variable_types, unused_import + +part of 'definitions.dart'; + +final List _stemFlows = [ + Flow( + name: "builder.example.flow", + build: (flow) { + final impl = BuilderExampleFlow(); + flow.step( + "greet", + (ctx) => impl.greet((_stemRequireArg(ctx.params, "name") as String)), + kind: WorkflowStepKind.task, + taskNames: [], + ); + }, + ), +]; + +class _StemScriptProxy0 extends BuilderUserSignupWorkflow { + _StemScriptProxy0(this._script); + final WorkflowScriptContext _script; + @override + Future> createUser(String email) { + return _script.step>( + "create-user", + (context) => super.createUser(email), + ); + } + + @override + Future sendWelcomeEmail(String email) { + return _script.step( + "send-welcome-email", + (context) => super.sendWelcomeEmail(email), + ); + } + + @override + Future sendOneWeekCheckInEmail(String email) { + return _script.step( + "send-one-week-check-in-email", + (context) => super.sendOneWeekCheckInEmail(email), + ); + } +} + +final List _stemScripts = [ + WorkflowScript( + name: "builder.example.user_signup", + checkpoints: [ + FlowStep( + name: "create-user", + handler: _stemScriptManifestStepNoop, + kind: WorkflowStepKind.task, + taskNames: [], + ), + FlowStep( + name: "send-welcome-email", + handler: _stemScriptManifestStepNoop, + kind: WorkflowStepKind.task, + taskNames: [], + ), + FlowStep( + name: "send-one-week-check-in-email", + handler: _stemScriptManifestStepNoop, + kind: WorkflowStepKind.task, + taskNames: [], + ), + ], + run: (script) => _StemScriptProxy0( + script, + ).run((_stemRequireArg(script.params, "email") as String)), + ), +]; + +abstract final class StemWorkflowDefinitions { + static final WorkflowRef, String> flow = + WorkflowRef, String>( + name: "builder.example.flow", + encodeParams: (params) => params, + ); + static final WorkflowRef<({String email}), Map> userSignup = + WorkflowRef<({String email}), Map>( + name: "builder.example.user_signup", + encodeParams: (params) => {"email": params.email}, + ); +} + +Future _stemScriptManifestStepNoop(FlowContext context) async => null; + +Object? _stemRequireArg(Map args, String name) { + if (!args.containsKey(name)) { + throw ArgumentError('Missing required argument "$name".'); + } + return args[name]; +} + +abstract final class StemTaskDefinitions { + static final TaskDefinition, Object?> + builderExampleTask = TaskDefinition, Object?>( + name: "builder.example.task", + encodeArgs: (args) => args, + defaultOptions: const TaskOptions(), + metadata: const TaskMetadata(), + ); +} + +extension StemGeneratedTaskEnqueuer on TaskEnqueuer { + Future enqueueBuilderExampleTask({ + required Map args, + Map headers = const {}, + TaskOptions? options, + DateTime? notBefore, + Map? meta, + TaskEnqueueOptions? enqueueOptions, + }) { + return enqueueCall( + StemTaskDefinitions.builderExampleTask.call( + args, + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ), + ); + } +} + +extension StemGeneratedTaskResults on Stem { + Future?> waitForBuilderExampleTask( + String taskId, { + Duration? timeout, + }) { + return waitForTaskDefinition( + taskId, + StemTaskDefinitions.builderExampleTask, + timeout: timeout, + ); + } +} + +final List> _stemTasks = >[ + FunctionTaskHandler( + name: "builder.example.task", + entrypoint: builderExampleTask, + options: const TaskOptions(), + metadata: const TaskMetadata(), + ), +]; + +final List _stemWorkflowManifest = + [ + ..._stemFlows.map((flow) => flow.definition.toManifestEntry()), + ..._stemScripts.map((script) => script.definition.toManifestEntry()), + ]; + +final StemModule stemModule = StemModule( + flows: _stemFlows, + scripts: _stemScripts, + tasks: _stemTasks, + workflowManifest: _stemWorkflowManifest, +); diff --git a/packages/stem_builder/example/pubspec.yaml b/packages/stem_builder/example/pubspec.yaml new file mode 100644 index 00000000..6e202d58 --- /dev/null +++ b/packages/stem_builder/example/pubspec.yaml @@ -0,0 +1,22 @@ +name: stem_builder_example +description: Example app showing stem_builder generated registry and manifest usage. +version: 0.0.1 +publish_to: none + +environment: + sdk: ">=3.9.2 <4.0.0" + +dependencies: + stem: + path: ../../stem + +dev_dependencies: + stem_builder: + path: .. + build_runner: ^2.10.5 + +dependency_overrides: + stem: + path: ../../stem + stem_memory: + path: ../../stem_memory diff --git a/packages/stem_builder/lib/src/stem_registry_builder.dart b/packages/stem_builder/lib/src/stem_registry_builder.dart index 1ad05c9f..1b715cbd 100644 --- a/packages/stem_builder/lib/src/stem_registry_builder.dart +++ b/packages/stem_builder/lib/src/stem_registry_builder.dart @@ -3,13 +3,14 @@ import 'dart:convert'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; import 'package:analyzer/dart/constant/value.dart'; import 'package:analyzer/dart/element/element.dart'; import 'package:analyzer/dart/element/nullability_suffix.dart'; import 'package:analyzer/dart/element/type.dart'; import 'package:build/build.dart'; import 'package:dart_style/dart_style.dart'; -import 'package:glob/glob.dart'; import 'package:source_gen/source_gen.dart'; import 'package:stem/stem.dart'; @@ -20,7 +21,7 @@ class StemRegistryBuilder implements Builder { @override Map> get buildExtensions => const { - r'lib/$lib$': ['lib/stem_registry.g.dart'], + 'lib/{{}}.dart': ['lib/{{}}.stem.g.dart'], }; @override @@ -47,168 +48,136 @@ class StemRegistryBuilder implements Builder { WorkflowScriptContext, inPackage: 'stem', ); + const scriptStepContextChecker = TypeChecker.typeNamed( + WorkflowScriptStepContext, + inPackage: 'stem', + ); const taskContextChecker = TypeChecker.typeNamed( TaskInvocationContext, inPackage: 'stem', ); const mapChecker = TypeChecker.typeNamed(Map, inSdk: true); + final input = buildStep.inputId; + if (!input.path.startsWith('lib/') || + input.path.endsWith('.g.dart') || + input.path.endsWith('.stem.g.dart')) { + return; + } + final workflows = <_WorkflowInfo>[]; final tasks = <_TaskInfo>[]; - final importAliases = {}; - var importIndex = 0; + var taskAdapterIndex = 0; - String importAliasFor(String importPath) { - return importAliases.putIfAbsent( - importPath, - () => 'stemLib${importIndex++}', - ); + if (!await buildStep.resolver.isLibrary(input)) { + return; } - final assets = []; - await for (final input in buildStep.findAssets(Glob('lib/**.dart'))) { - if (input.path.endsWith('.g.dart') || input.path.contains('.g.')) { + final library = await buildStep.resolver.libraryFor(input); + for (final classElement in library.classes) { + final annotation = workflowDefnChecker.firstAnnotationOfExact( + classElement, + throwOnUnresolved: false, + ); + if (annotation == null) { continue; } - assets.add(input); - } - assets.sort((a, b) => a.path.compareTo(b.path)); - for (final input in assets) { - if (!await buildStep.resolver.isLibrary(input)) { - continue; + if (classElement.isPrivate) { + throw InvalidGenerationSourceError( + 'Workflow class ${classElement.displayName} must be public.', + element: classElement, + ); } - final library = await buildStep.resolver.libraryFor(input); - final hasWorkflow = library.classes.any( - (element) => workflowDefnChecker.hasAnnotationOfExact(element), - ); - final hasTask = library.topLevelFunctions.any( - (element) => taskDefnChecker.hasAnnotationOfExact(element), - ); - if (!hasWorkflow && !hasTask) { - continue; + if (classElement.isAbstract) { + throw InvalidGenerationSourceError( + 'Workflow class ${classElement.displayName} must not be abstract.', + element: classElement, + ); } - - final importPath = _importForAsset(input); - final importAlias = importAliasFor(importPath); - - for (final classElement in library.classes) { - final annotation = workflowDefnChecker.firstAnnotationOfExact( - classElement, - throwOnUnresolved: false, + final constructor = classElement.unnamedConstructor; + if (constructor == null || constructor.isPrivate) { + throw InvalidGenerationSourceError( + 'Workflow class ${classElement.displayName} needs a public default constructor.', + element: classElement, ); - if (annotation == null) { - continue; - } - if (classElement.isPrivate) { - throw InvalidGenerationSourceError( - 'Workflow class ${classElement.displayName} must be public.', - element: classElement, - ); - } - if (classElement.isAbstract) { - throw InvalidGenerationSourceError( - 'Workflow class ${classElement.displayName} must not be abstract.', - element: classElement, - ); - } - final constructor = classElement.unnamedConstructor; - if (constructor == null || constructor.isPrivate) { - throw InvalidGenerationSourceError( - 'Workflow class ${classElement.displayName} needs a public default constructor.', - element: classElement, - ); - } - if (constructor.formalParameters.any( - (p) => p.isRequiredNamed || p.isRequiredPositional, - )) { - throw InvalidGenerationSourceError( - 'Workflow class ${classElement.displayName} default constructor must have no required parameters.', - element: classElement, - ); - } + } + if (constructor.formalParameters.any( + (p) => p.isRequiredNamed || p.isRequiredPositional, + )) { + throw InvalidGenerationSourceError( + 'Workflow class ${classElement.displayName} default constructor must have no required parameters.', + element: classElement, + ); + } - final readerAnnotation = ConstantReader(annotation); - final workflowName = - _stringOrNull(readerAnnotation.peek('name')) ?? - classElement.displayName; - final version = _stringOrNull(readerAnnotation.peek('version')); - final description = _stringOrNull(readerAnnotation.peek('description')); - final metadata = _objectOrNull(readerAnnotation.peek('metadata')); - final kind = _readWorkflowKind(readerAnnotation); - - final runMethods = classElement.methods - .where( - (method) => - workflowRunChecker.hasAnnotationOfExact(method) && - !method.isStatic, - ) - .toList(growable: false); - final stepMethods = - classElement.methods - .where( - (method) => - workflowStepChecker.hasAnnotationOfExact(method) && - !method.isStatic, - ) - .toList(growable: false) - ..sort((a, b) { - final aOffset = - a.firstFragment.nameOffset ?? a.firstFragment.offset; - final bOffset = - b.firstFragment.nameOffset ?? b.firstFragment.offset; - return aOffset.compareTo(bOffset); - }); - - if (kind == WorkflowKind.script) { - if (runMethods.isEmpty) { - throw InvalidGenerationSourceError( - 'Workflow ${classElement.displayName} is marked as script but has no @workflow.run method.', - element: classElement, - ); - } - if (stepMethods.isNotEmpty) { - throw InvalidGenerationSourceError( - 'Workflow ${classElement.displayName} is marked as script but has @workflow.step methods.', - element: classElement, - ); - } - if (runMethods.length > 1) { - throw InvalidGenerationSourceError( - 'Workflow ${classElement.displayName} has multiple @workflow.run methods.', - element: classElement, - ); - } - final runMethod = runMethods.single; - _validateRunMethod(runMethod, scriptContextChecker); - workflows.add( - _WorkflowInfo.script( - name: workflowName, - importAlias: importAlias, - className: classElement.displayName, - runMethod: runMethod.displayName, - version: version, - description: description, - metadata: metadata, - ), - ); - continue; - } + final readerAnnotation = ConstantReader(annotation); + final workflowName = + _stringOrNull(readerAnnotation.peek('name')) ?? + classElement.displayName; + final version = _stringOrNull(readerAnnotation.peek('version')); + final description = _stringOrNull(readerAnnotation.peek('description')); + final metadata = _objectOrNull(readerAnnotation.peek('metadata')); + final starterName = _stringOrNull(readerAnnotation.peek('starterName')); + final nameField = _stringOrNull(readerAnnotation.peek('nameField')); + final kind = _readWorkflowKind(readerAnnotation); + + final annotatedRunMethods = classElement.methods + .where( + (method) => + workflowRunChecker.hasAnnotationOfExact(method) && + !method.isStatic, + ) + .toList(growable: false); + final inferredRunMethods = classElement.methods + .where( + (method) => method.displayName == 'run' && !method.isStatic, + ) + .toList(growable: false); + final runMethods = kind == WorkflowKind.script + ? (annotatedRunMethods.isNotEmpty + ? annotatedRunMethods + : inferredRunMethods) + : annotatedRunMethods; + final stepMethods = + classElement.methods + .where( + (method) => + workflowStepChecker.hasAnnotationOfExact(method) && + !method.isStatic, + ) + .toList(growable: false) + ..sort((a, b) { + final aOffset = + a.firstFragment.nameOffset ?? a.firstFragment.offset; + final bOffset = + b.firstFragment.nameOffset ?? b.firstFragment.offset; + return aOffset.compareTo(bOffset); + }); - if (runMethods.isNotEmpty) { + if (kind == WorkflowKind.script) { + if (runMethods.isEmpty) { throw InvalidGenerationSourceError( - 'Workflow ${classElement.displayName} has @workflow.run but is not marked as script.', + 'Workflow ${classElement.displayName} is marked as script but has no run entry method. Add @WorkflowRun or define a public run(...) method.', element: classElement, ); } - if (stepMethods.isEmpty) { + if (runMethods.length > 1) { throw InvalidGenerationSourceError( - 'Workflow ${classElement.displayName} has no @workflow.step methods.', + 'Workflow ${classElement.displayName} has multiple @workflow.run methods.', element: classElement, ); } - final steps = <_WorkflowStepInfo>[]; + final runMethod = runMethods.single; + final runBinding = _validateRunMethod( + runMethod, + scriptContextChecker, + ); + final scriptSteps = <_WorkflowStepInfo>[]; for (final method in stepMethods) { - _validateFlowStepMethod(method, flowContextChecker); + final stepBinding = _validateScriptStepMethod( + method, + scriptStepContextChecker, + ); final stepAnnotation = workflowStepChecker.firstAnnotationOfExact( method, throwOnUnresolved: false, @@ -227,10 +196,17 @@ class StemRegistryBuilder implements Builder { final kindValue = _objectOrNull(stepReader.peek('kind')); final taskNames = _objectOrNull(stepReader.peek('taskNames')); final stepMetadata = _objectOrNull(stepReader.peek('metadata')); - steps.add( + scriptSteps.add( _WorkflowStepInfo( name: stepName, method: method.displayName, + acceptsFlowContext: false, + acceptsScriptStepContext: stepBinding.acceptsContext, + valueParameters: stepBinding.valueParameters, + returnTypeCode: stepBinding.returnTypeCode, + stepValueTypeCode: stepBinding.stepValueTypeCode, + stepValuePayloadCodecTypeCode: + stepBinding.stepValuePayloadCodecTypeCode, autoVersion: autoVersion, title: title, kind: kindValue, @@ -239,75 +215,200 @@ class StemRegistryBuilder implements Builder { ), ); } + _ensureUniqueWorkflowStepNames( + classElement, + scriptSteps, + label: 'checkpoint', + ); + await _diagnoseScriptCheckpointPatterns( + buildStep, + classElement, + runMethod, + scriptSteps, + runAcceptsScriptContext: runBinding.acceptsContext, + ); workflows.add( - _WorkflowInfo.flow( + _WorkflowInfo.script( name: workflowName, - importAlias: importAlias, + importAlias: '', className: classElement.displayName, - steps: steps, + steps: scriptSteps, + runMethod: runMethod.displayName, + runAcceptsScriptContext: runBinding.acceptsContext, + runValueParameters: runBinding.valueParameters, + resultTypeCode: runBinding.resultTypeCode, + resultPayloadCodecTypeCode: runBinding.resultPayloadCodecTypeCode, version: version, description: description, metadata: metadata, + starterNameOverride: starterName, + nameFieldOverride: nameField, ), ); + continue; } - for (final function in library.topLevelFunctions) { - final annotation = taskDefnChecker.firstAnnotationOfExact( - function, + if (runMethods.isNotEmpty) { + throw InvalidGenerationSourceError( + 'Workflow ${classElement.displayName} has @workflow.run but is not marked as script.', + element: classElement, + ); + } + if (stepMethods.isEmpty) { + throw InvalidGenerationSourceError( + 'Workflow ${classElement.displayName} has no @workflow.step methods.', + element: classElement, + ); + } + final steps = <_WorkflowStepInfo>[]; + for (final method in stepMethods) { + final stepBinding = _validateFlowStepMethod( + method, + flowContextChecker, + ); + final stepAnnotation = workflowStepChecker.firstAnnotationOfExact( + method, throwOnUnresolved: false, ); - if (annotation == null) { + if (stepAnnotation == null) { continue; } - if (function.isPrivate) { - throw InvalidGenerationSourceError( - 'Task function ${function.displayName} must be public.', - element: function, - ); - } - _validateTaskFunction(function, taskContextChecker, mapChecker); - final readerAnnotation = ConstantReader(annotation); - final taskName = - _stringOrNull(readerAnnotation.peek('name')) ?? - function.displayName; - final options = _objectOrNull(readerAnnotation.peek('options')); - final metadata = _objectOrNull(readerAnnotation.peek('metadata')); - final runInIsolate = _boolOrDefault( - readerAnnotation.peek('runInIsolate'), - true, - ); - - tasks.add( - _TaskInfo( - name: taskName, - importAlias: importAlias, - function: function.displayName, - options: options, - metadata: metadata, - runInIsolate: runInIsolate, + final stepReader = ConstantReader(stepAnnotation); + final stepName = + _stringOrNull(stepReader.peek('name')) ?? method.displayName; + final autoVersion = _boolOrDefault( + stepReader.peek('autoVersion'), + false, + ); + final title = _stringOrNull(stepReader.peek('title')); + final kindValue = _objectOrNull(stepReader.peek('kind')); + final taskNames = _objectOrNull(stepReader.peek('taskNames')); + final stepMetadata = _objectOrNull(stepReader.peek('metadata')); + steps.add( + _WorkflowStepInfo( + name: stepName, + method: method.displayName, + acceptsFlowContext: stepBinding.acceptsContext, + acceptsScriptStepContext: false, + valueParameters: stepBinding.valueParameters, + returnTypeCode: null, + stepValueTypeCode: stepBinding.stepValueTypeCode, + stepValuePayloadCodecTypeCode: + stepBinding.stepValuePayloadCodecTypeCode, + autoVersion: autoVersion, + title: title, + kind: kindValue, + taskNames: taskNames, + metadata: stepMetadata, ), ); } + _ensureUniqueWorkflowStepNames(classElement, steps, label: 'step'); + workflows.add( + _WorkflowInfo.flow( + name: workflowName, + importAlias: '', + className: classElement.displayName, + steps: steps, + resultTypeCode: + steps.isEmpty ? 'Object?' : (steps.last.stepValueTypeCode ?? 'Object?'), + resultPayloadCodecTypeCode: steps.isEmpty + ? null + : steps.last.stepValuePayloadCodecTypeCode, + version: version, + description: description, + metadata: metadata, + starterNameOverride: starterName, + nameFieldOverride: nameField, + ), + ); + } + + for (final function in library.topLevelFunctions) { + final annotation = taskDefnChecker.firstAnnotationOfExact( + function, + throwOnUnresolved: false, + ); + if (annotation == null) { + continue; + } + if (function.isPrivate) { + throw InvalidGenerationSourceError( + 'Task function ${function.displayName} must be public.', + element: function, + ); + } + final taskBinding = _validateTaskFunction( + function, + taskContextChecker, + mapChecker, + ); + final readerAnnotation = ConstantReader(annotation); + final taskName = + _stringOrNull(readerAnnotation.peek('name')) ?? function.displayName; + final options = _objectOrNull(readerAnnotation.peek('options')); + final metadata = _objectOrNull(readerAnnotation.peek('metadata')); + final metadataReader = readerAnnotation.peek('metadata'); + final metadataResultEncoder = metadataReader?.peek('resultEncoder'); + if (taskBinding.resultPayloadCodecTypeCode != null && + metadataResultEncoder != null && + !metadataResultEncoder.isNull) { + throw InvalidGenerationSourceError( + '@TaskDefn function ${function.displayName} defines a codec-backed DTO result and an explicit metadata.resultEncoder. Choose one encoding path.', + element: function, + ); + } + final runInIsolate = _boolOrDefault( + readerAnnotation.peek('runInIsolate'), + true, + ); + + tasks.add( + _TaskInfo( + name: taskName, + importAlias: '', + function: function.displayName, + adapterName: taskBinding.usesLegacyMapArgs + ? null + : '_stemTaskAdapter${taskAdapterIndex++}', + acceptsTaskContext: taskBinding.acceptsContext, + valueParameters: taskBinding.valueParameters, + usesLegacyMapArgs: taskBinding.usesLegacyMapArgs, + resultTypeCode: taskBinding.resultTypeCode, + resultPayloadCodecTypeCode: taskBinding.resultPayloadCodecTypeCode, + options: options, + metadata: metadata, + runInIsolate: runInIsolate, + ), + ); } final outputId = buildStep.allowedOutputs.single; + final fileName = input.pathSegments.last; + final generatedFileName = fileName.replaceFirst('.dart', '.stem.g.dart'); + final source = await buildStep.readAsString(input); + final declaresGeneratedPart = + source.contains("part '$generatedFileName';") || + source.contains('part "$generatedFileName";'); + if (workflows.isEmpty && tasks.isEmpty) { + if (!declaresGeneratedPart) { + return; + } + await buildStep.writeAsString( + outputId, + _format(_RegistryEmitter.emptyPart(fileName: fileName)), + ); + return; + } + final registryCode = _RegistryEmitter( workflows: workflows, tasks: tasks, - imports: importAliases, - ).emit(); + ).emit(partOfFile: fileName); final formatted = _format(registryCode); await buildStep.writeAsString(outputId, formatted); } - static String _importForAsset(AssetId asset) { - if (asset.path.startsWith('lib/')) { - return 'package:${asset.package}/${asset.path.substring(4)}'; - } - return asset.uri.toString(); - } - static WorkflowKind _readWorkflowKind(ConstantReader reader) { final kind = reader.peek('kind'); if (kind == null || kind.isNull) return WorkflowKind.flow; @@ -319,7 +420,7 @@ class StemRegistryBuilder implements Builder { ); } - static void _validateRunMethod( + static _RunBinding _validateRunMethod( MethodElement method, TypeChecker scriptContextChecker, ) { @@ -329,22 +430,45 @@ class StemRegistryBuilder implements Builder { element: method, ); } - if (method.formalParameters.length != 1) { - throw InvalidGenerationSourceError( - '@workflow.run method ${method.displayName} must accept a single WorkflowScriptContext argument.', - element: method, - ); + + final parameters = method.formalParameters; + var acceptsContext = false; + var startIndex = 0; + if (parameters.isNotEmpty && + scriptContextChecker.isAssignableFromType(parameters.first.type)) { + acceptsContext = true; + startIndex = 1; } - final param = method.formalParameters.first; - if (!scriptContextChecker.isAssignableFromType(param.type)) { - throw InvalidGenerationSourceError( - '@workflow.run method ${method.displayName} must accept WorkflowScriptContext.', - element: method, - ); + + final valueParameters = <_ValueParameterInfo>[]; + for (final parameter in parameters.skip(startIndex)) { + if (!parameter.isRequiredPositional) { + throw InvalidGenerationSourceError( + '@workflow.run method ${method.displayName} only supports required positional serializable or codec-backed parameters after WorkflowScriptContext.', + element: method, + ); + } + final valueParameter = _createValueParameterInfo(parameter); + if (valueParameter == null) { + throw InvalidGenerationSourceError( + '@workflow.run method ${method.displayName} parameter "${parameter.displayName}" must use a serializable or codec-backed DTO type.', + element: method, + ); + } + valueParameters.add(valueParameter); } + + return _RunBinding( + acceptsContext: acceptsContext, + valueParameters: valueParameters, + resultTypeCode: _workflowResultTypeCode(method.returnType), + resultPayloadCodecTypeCode: _workflowResultPayloadCodecTypeCode( + method.returnType, + ), + ); } - static void _validateFlowStepMethod( + static _FlowStepBinding _validateFlowStepMethod( MethodElement method, TypeChecker flowContextChecker, ) { @@ -354,52 +478,219 @@ class StemRegistryBuilder implements Builder { element: method, ); } - if (method.formalParameters.length != 1) { + final parameters = method.formalParameters; + var acceptsContext = false; + var startIndex = 0; + if (parameters.isNotEmpty && + flowContextChecker.isAssignableFromType(parameters.first.type)) { + acceptsContext = true; + startIndex = 1; + } + + final valueParameters = <_ValueParameterInfo>[]; + for (final parameter in parameters.skip(startIndex)) { + if (!parameter.isRequiredPositional) { + throw InvalidGenerationSourceError( + '@workflow.step method ${method.displayName} only supports required positional serializable or codec-backed parameters after FlowContext.', + element: method, + ); + } + final valueParameter = _createValueParameterInfo(parameter); + if (valueParameter == null) { + throw InvalidGenerationSourceError( + '@workflow.step method ${method.displayName} parameter "${parameter.displayName}" must use a serializable or codec-backed DTO type.', + element: method, + ); + } + valueParameters.add(valueParameter); + } + + return _FlowStepBinding( + acceptsContext: acceptsContext, + valueParameters: valueParameters, + stepValueTypeCode: _workflowResultTypeCode(method.returnType), + stepValuePayloadCodecTypeCode: _workflowResultPayloadCodecTypeCode( + method.returnType, + ), + ); + } + + static _ScriptStepBinding _validateScriptStepMethod( + MethodElement method, + TypeChecker scriptStepContextChecker, + ) { + if (method.isPrivate) { throw InvalidGenerationSourceError( - '@workflow.step method ${method.displayName} must accept a single FlowContext argument.', + '@workflow.step method ${method.displayName} must be public.', element: method, ); } - final param = method.formalParameters.first; - if (!flowContextChecker.isAssignableFromType(param.type)) { + final returnType = method.returnType; + final isFutureLike = + returnType.isDartAsyncFuture || returnType.isDartAsyncFutureOr; + if (!isFutureLike) { throw InvalidGenerationSourceError( - '@workflow.step method ${method.displayName} must accept FlowContext.', + '@workflow.step method ${method.displayName} in script workflows must return Future or FutureOr.', element: method, ); } + final stepValueType = _extractStepValueType(returnType); + + final parameters = method.formalParameters; + var acceptsContext = false; + var startIndex = 0; + if (parameters.isNotEmpty && + scriptStepContextChecker.isAssignableFromType(parameters.first.type)) { + acceptsContext = true; + startIndex = 1; + } + + final valueParameters = <_ValueParameterInfo>[]; + for (final parameter in parameters.skip(startIndex)) { + if (!parameter.isRequiredPositional) { + throw InvalidGenerationSourceError( + '@workflow.step method ${method.displayName} only supports required positional serializable or codec-backed parameters after WorkflowScriptStepContext.', + element: method, + ); + } + final valueParameter = _createValueParameterInfo(parameter); + if (valueParameter == null) { + throw InvalidGenerationSourceError( + '@workflow.step method ${method.displayName} parameter "${parameter.displayName}" must use a serializable or codec-backed DTO type.', + element: method, + ); + } + valueParameters.add(valueParameter); + } + + return _ScriptStepBinding( + acceptsContext: acceptsContext, + valueParameters: valueParameters, + returnTypeCode: _typeCode(returnType), + stepValueTypeCode: _typeCode(stepValueType), + stepValuePayloadCodecTypeCode: _payloadCodecTypeCode(stepValueType), + ); } - static void _validateTaskFunction( + static _TaskBinding _validateTaskFunction( TopLevelFunctionElement function, TypeChecker taskContextChecker, TypeChecker mapChecker, ) { - if (function.formalParameters.length != 2) { - throw InvalidGenerationSourceError( - '@TaskDefn function ${function.displayName} must accept (TaskInvocationContext, Map).', - element: function, - ); + final parameters = function.formalParameters; + var acceptsContext = false; + var startIndex = 0; + if (parameters.isNotEmpty && + taskContextChecker.isAssignableFromType(parameters.first.type)) { + acceptsContext = true; + startIndex = 1; } - final context = function.formalParameters[0]; - final args = function.formalParameters[1]; - if (!taskContextChecker.isAssignableFromType(context.type)) { - throw InvalidGenerationSourceError( - '@TaskDefn function ${function.displayName} must accept TaskInvocationContext as first parameter.', - element: function, + + final remaining = parameters.skip(startIndex).toList(growable: false); + final legacyMapSignature = + acceptsContext && + remaining.length == 1 && + mapChecker.isAssignableFromType(remaining.first.type) && + _isStringObjectMap(remaining.first.type) && + remaining.first.isRequiredPositional; + if (legacyMapSignature) { + return _TaskBinding( + acceptsContext: true, + valueParameters: [], + usesLegacyMapArgs: true, + resultTypeCode: _taskResultTypeCode(function.returnType), + resultPayloadCodecTypeCode: _taskResultPayloadCodecTypeCode( + function.returnType, + ), ); } - if (!mapChecker.isAssignableFromType(args.type)) { - throw InvalidGenerationSourceError( - '@TaskDefn function ${function.displayName} must accept Map as second parameter.', - element: function, - ); + + final valueParameters = <_ValueParameterInfo>[]; + for (final parameter in remaining) { + if (!parameter.isRequiredPositional) { + throw InvalidGenerationSourceError( + '@TaskDefn function ${function.displayName} only supports required positional serializable or codec-backed parameters after TaskInvocationContext.', + element: function, + ); + } + final valueParameter = _createValueParameterInfo(parameter); + if (valueParameter == null) { + throw InvalidGenerationSourceError( + '@TaskDefn function ${function.displayName} parameter "${parameter.displayName}" must use a serializable or codec-backed DTO type.', + element: function, + ); + } + valueParameters.add(valueParameter); } - if (!_isStringObjectMap(args.type)) { - throw InvalidGenerationSourceError( - '@TaskDefn function ${function.displayName} must accept Map as second parameter.', - element: function, - ); + + return _TaskBinding( + acceptsContext: acceptsContext, + valueParameters: valueParameters, + usesLegacyMapArgs: false, + resultTypeCode: _taskResultTypeCode(function.returnType), + resultPayloadCodecTypeCode: _taskResultPayloadCodecTypeCode( + function.returnType, + ), + ); + } + + static _ValueParameterInfo? _createValueParameterInfo( + FormalParameterElement parameter, + ) { + final type = parameter.type; + final codecTypeCode = _payloadCodecTypeCode(type); + if (!_isSerializableValueType(type) && codecTypeCode == null) { + return null; + } + return _ValueParameterInfo( + name: parameter.displayName, + typeCode: _typeCode(type), + payloadCodecTypeCode: codecTypeCode, + ); + } + + static String _taskResultTypeCode(DartType returnType) { + final valueType = _extractAsyncValueType(returnType); + if (valueType is VoidType || valueType is NeverType) { + return 'Object?'; + } + if (valueType.isDartCoreNull) { + return 'Object?'; + } + return _typeCode(valueType); + } + + static String _workflowResultTypeCode(DartType returnType) { + final valueType = _extractAsyncValueType(returnType); + if (valueType is VoidType || valueType is NeverType) { + return 'Object?'; + } + if (valueType.isDartCoreNull) { + return 'Object?'; + } + return _typeCode(valueType); + } + + static String? _taskResultPayloadCodecTypeCode(DartType returnType) { + final valueType = _extractAsyncValueType(returnType); + if (valueType is VoidType || valueType is NeverType) { + return null; } + if (valueType.isDartCoreNull) { + return null; + } + return _payloadCodecTypeCode(valueType); + } + + static String? _workflowResultPayloadCodecTypeCode(DartType returnType) { + final valueType = _extractAsyncValueType(returnType); + if (valueType is VoidType || valueType is NeverType) { + return null; + } + if (valueType.isDartCoreNull) { + return null; + } + return _payloadCodecTypeCode(valueType); } static bool _isStringObjectMap(DartType type) { @@ -413,6 +704,86 @@ class StemRegistryBuilder implements Builder { return valueType.nullabilitySuffix == NullabilitySuffix.question; } + static bool _isSerializableValueType(DartType type) { + if (type is DynamicType) return false; + if (type is VoidType) return false; + if (type is NeverType) return false; + if (type.isDartCoreString || + type.isDartCoreBool || + type.isDartCoreInt || + type.isDartCoreDouble || + type.isDartCoreNum || + type.isDartCoreObject || + type.isDartCoreNull) { + return true; + } + if (type is! InterfaceType) return false; + if (type.isDartCoreList) { + if (type.typeArguments.length != 1) return false; + return _isSerializableValueType(type.typeArguments.first); + } + if (type.isDartCoreMap) { + if (type.typeArguments.length != 2) return false; + final keyType = type.typeArguments[0]; + final valueType = type.typeArguments[1]; + if (!keyType.isDartCoreString) return false; + return _isSerializableValueType(valueType); + } + return false; + } + + static String? _payloadCodecTypeCode(DartType type) { + if (type is! InterfaceType) return null; + if (type.isDartCoreMap || type.isDartCoreList || type.isDartCoreSet) { + return null; + } + if (type.element.typeParameters.isNotEmpty) { + return null; + } + final toJson = type.element.methods.where( + (method) => + method.name == 'toJson' && + !method.isStatic && + method.formalParameters.isEmpty && + _isStringKeyedMapLike(method.returnType), + ); + if (toJson.isEmpty) { + return null; + } + final fromJsonConstructor = type.element.constructors.where( + (constructor) => + constructor.name == 'fromJson' && + constructor.formalParameters.length == 1 && + constructor.formalParameters.first.isRequiredPositional && + _isStringKeyedMapLike(constructor.formalParameters.first.type), + ); + if (fromJsonConstructor.isEmpty) { + return null; + } + return _typeCode(type); + } + + static bool _isStringKeyedMapLike(DartType type) { + if (type is! InterfaceType) return false; + if (!type.isDartCoreMap) return false; + if (type.typeArguments.length != 2) return false; + final keyType = type.typeArguments[0]; + return keyType.isDartCoreString; + } + + static String _typeCode(DartType type) => type.getDisplayString(); + + static DartType _extractStepValueType(DartType returnType) { + return _extractAsyncValueType(returnType); + } + + static DartType _extractAsyncValueType(DartType returnType) { + if (returnType is InterfaceType && returnType.typeArguments.isNotEmpty) { + return returnType.typeArguments.first; + } + return returnType; + } + static String? _stringOrNull(ConstantReader? reader) { if (reader == null || reader.isNull) return null; return reader.stringValue; @@ -445,29 +816,47 @@ class _WorkflowInfo { required this.importAlias, required this.className, required this.steps, + required this.resultTypeCode, + required this.resultPayloadCodecTypeCode, + this.starterNameOverride, + this.nameFieldOverride, this.version, this.description, this.metadata, }) : kind = WorkflowKind.flow, - runMethod = null; + runMethod = null, + runAcceptsScriptContext = false, + runValueParameters = const []; _WorkflowInfo.script({ required this.name, required this.importAlias, required this.className, + required this.steps, required this.runMethod, + required this.runAcceptsScriptContext, + required this.runValueParameters, + required this.resultTypeCode, + required this.resultPayloadCodecTypeCode, + this.starterNameOverride, + this.nameFieldOverride, this.version, this.description, this.metadata, - }) : kind = WorkflowKind.script, - steps = const []; + }) : kind = WorkflowKind.script; final String name; final WorkflowKind kind; final String importAlias; final String className; final List<_WorkflowStepInfo> steps; + final String resultTypeCode; + final String? resultPayloadCodecTypeCode; final String? runMethod; + final bool runAcceptsScriptContext; + final List<_ValueParameterInfo> runValueParameters; + final String? starterNameOverride; + final String? nameFieldOverride; final String? version; final String? description; final DartObject? metadata; @@ -477,6 +866,12 @@ class _WorkflowStepInfo { const _WorkflowStepInfo({ required this.name, required this.method, + required this.acceptsFlowContext, + required this.acceptsScriptStepContext, + required this.valueParameters, + required this.returnTypeCode, + required this.stepValueTypeCode, + required this.stepValuePayloadCodecTypeCode, required this.autoVersion, required this.title, required this.kind, @@ -486,6 +881,12 @@ class _WorkflowStepInfo { final String name; final String method; + final bool acceptsFlowContext; + final bool acceptsScriptStepContext; + final List<_ValueParameterInfo> valueParameters; + final String? returnTypeCode; + final String? stepValueTypeCode; + final String? stepValuePayloadCodecTypeCode; final bool autoVersion; final String? title; final DartObject? kind; @@ -493,11 +894,164 @@ class _WorkflowStepInfo { final DartObject? metadata; } +void _ensureUniqueWorkflowStepNames( + ClassElement classElement, + Iterable<_WorkflowStepInfo> steps, { + required String label, +}) { + final namesByMethod = >{}; + for (final step in steps) { + namesByMethod.putIfAbsent(step.name, () => []).add(step.method); + } + + final duplicates = namesByMethod.entries + .where((entry) => entry.value.length > 1) + .toList(growable: false); + if (duplicates.isEmpty) { + return; + } + + final details = duplicates + .map((entry) => '"${entry.key}" from ${entry.value.join(', ')}') + .join('; '); + throw InvalidGenerationSourceError( + 'Workflow ${classElement.displayName} defines duplicate $label names: ' + '$details.', + element: classElement, + ); +} + +Future _diagnoseScriptCheckpointPatterns( + BuildStep buildStep, + ClassElement classElement, + MethodElement runMethod, + List<_WorkflowStepInfo> steps, { + required bool runAcceptsScriptContext, +}) async { + if (!runAcceptsScriptContext || steps.isEmpty) { + return; + } + + final astNode = await buildStep.resolver.astNodeFor( + runMethod.firstFragment, + resolve: true, + ); + if (astNode is! MethodDeclaration) { + return; + } + + final stepsByMethod = {for (final step in steps) step.method: step}; + final manualSteps = _ManualScriptStepVisitor(stepsByMethod.keys.toSet()) + ..visitMethodDeclaration(astNode); + + for (final invocation in manualSteps.invocations) { + final duplicateStep = invocation.stepName == null + ? null + : _findStepByName(steps, invocation.stepName!); + if (duplicateStep != null) { + throw InvalidGenerationSourceError( + 'Workflow ${classElement.displayName} defines manual checkpoint ' + '"${invocation.stepName}" that conflicts with annotated checkpoint ' + '"${duplicateStep.name}" on ${duplicateStep.method}.', + element: runMethod, + ); + } + + for (final methodName in invocation.annotatedMethodCalls) { + final step = stepsByMethod[methodName]; + if (step == null || step.acceptsScriptStepContext) { + continue; + } + final wrapperName = invocation.stepName ?? ''; + log.warning( + 'Workflow ${classElement.displayName} wraps annotated checkpoint ' + '"${step.name}" inside manual script.step("$wrapperName"). ' + 'Call ${step.method}(...) directly from run(...) to avoid nested ' + 'checkpoints.', + ); + } + } +} + +_WorkflowStepInfo? _findStepByName( + Iterable<_WorkflowStepInfo> steps, + String stepName, +) { + for (final step in steps) { + if (step.name == stepName) { + return step; + } + } + return null; +} + +class _ManualScriptStepVisitor extends RecursiveAstVisitor { + _ManualScriptStepVisitor(this.annotatedMethodNames); + + final Set annotatedMethodNames; + final List<_ManualScriptInvocation> invocations = []; + + @override + void visitMethodInvocation(MethodInvocation node) { + if (node.methodName.name == 'step' && node.argumentList.arguments.length >= 2) { + final nameArg = node.argumentList.arguments.first; + final callbackArg = node.argumentList.arguments[1]; + final callback = callbackArg is FunctionExpression ? callbackArg : null; + if (callback != null) { + final collector = _AnnotatedMethodCallCollector(annotatedMethodNames); + callback.body.accept(collector); + invocations.add( + _ManualScriptInvocation( + stepName: nameArg is StringLiteral ? nameArg.stringValue : null, + annotatedMethodCalls: collector.calls, + ), + ); + } + } + super.visitMethodInvocation(node); + } +} + +class _AnnotatedMethodCallCollector extends RecursiveAstVisitor { + _AnnotatedMethodCallCollector(this.annotatedMethodNames); + + final Set annotatedMethodNames; + final Set calls = {}; + + @override + void visitMethodInvocation(MethodInvocation node) { + final target = node.target; + final isWorkflowMethodTarget = + target == null || target is ThisExpression || target is SuperExpression; + if (isWorkflowMethodTarget && + annotatedMethodNames.contains(node.methodName.name)) { + calls.add(node.methodName.name); + } + super.visitMethodInvocation(node); + } +} + +class _ManualScriptInvocation { + const _ManualScriptInvocation({ + required this.stepName, + required this.annotatedMethodCalls, + }); + + final String? stepName; + final Set annotatedMethodCalls; +} + class _TaskInfo { const _TaskInfo({ required this.name, required this.importAlias, required this.function, + required this.adapterName, + required this.acceptsTaskContext, + required this.valueParameters, + required this.usesLegacyMapArgs, + required this.resultTypeCode, + required this.resultPayloadCodecTypeCode, required this.options, required this.metadata, required this.runInIsolate, @@ -506,59 +1060,231 @@ class _TaskInfo { final String name; final String importAlias; final String function; + final String? adapterName; + final bool acceptsTaskContext; + final List<_ValueParameterInfo> valueParameters; + final bool usesLegacyMapArgs; + final String resultTypeCode; + final String? resultPayloadCodecTypeCode; final DartObject? options; final DartObject? metadata; final bool runInIsolate; } -class _RegistryEmitter { - _RegistryEmitter({ - required this.workflows, - required this.tasks, - required this.imports, +class _FlowStepBinding { + const _FlowStepBinding({ + required this.acceptsContext, + required this.valueParameters, + required this.stepValueTypeCode, + required this.stepValuePayloadCodecTypeCode, }); - final List<_WorkflowInfo> workflows; - final List<_TaskInfo> tasks; - final Map imports; + final bool acceptsContext; + final List<_ValueParameterInfo> valueParameters; + final String stepValueTypeCode; + final String? stepValuePayloadCodecTypeCode; +} - String emit() { - final buffer = StringBuffer(); - buffer.writeln('// GENERATED CODE - DO NOT MODIFY BY HAND'); - buffer.writeln( - '// ignore_for_file: unused_element, unnecessary_lambdas, omit_local_variable_types', - ); - buffer.writeln(); - buffer.writeln("import 'package:stem/stem.dart';"); - final sortedImports = imports.entries.toList() - ..sort((a, b) => a.key.compareTo(b.key)); - for (final entry in sortedImports) { - buffer.writeln("import '${entry.key}' as ${entry.value};"); - } - buffer.writeln(); +class _RunBinding { + const _RunBinding({ + required this.acceptsContext, + required this.valueParameters, + required this.resultTypeCode, + required this.resultPayloadCodecTypeCode, + }); - _emitWorkflows(buffer); - _emitTasks(buffer); + final bool acceptsContext; + final List<_ValueParameterInfo> valueParameters; + final String resultTypeCode; + final String? resultPayloadCodecTypeCode; +} - buffer.writeln('void registerStemDefinitions({'); - buffer.writeln(' required WorkflowRegistry workflows,'); - buffer.writeln(' required TaskRegistry tasks,'); - buffer.writeln('}) {'); - buffer.writeln(' for (final flow in stemFlows) {'); - buffer.writeln(' workflows.register(flow.definition);'); - buffer.writeln(' }'); - buffer.writeln(' for (final script in stemScripts) {'); - buffer.writeln(' workflows.register(script.definition);'); +class _ScriptStepBinding { + const _ScriptStepBinding({ + required this.acceptsContext, + required this.valueParameters, + required this.returnTypeCode, + required this.stepValueTypeCode, + required this.stepValuePayloadCodecTypeCode, + }); + + final bool acceptsContext; + final List<_ValueParameterInfo> valueParameters; + final String returnTypeCode; + final String stepValueTypeCode; + final String? stepValuePayloadCodecTypeCode; +} + +class _TaskBinding { + const _TaskBinding({ + required this.acceptsContext, + required this.valueParameters, + required this.usesLegacyMapArgs, + required this.resultTypeCode, + required this.resultPayloadCodecTypeCode, + }); + + final bool acceptsContext; + final List<_ValueParameterInfo> valueParameters; + final bool usesLegacyMapArgs; + final String resultTypeCode; + final String? resultPayloadCodecTypeCode; +} + +class _ValueParameterInfo { + const _ValueParameterInfo({ + required this.name, + required this.typeCode, + required this.payloadCodecTypeCode, + }); + + final String name; + final String typeCode; + final String? payloadCodecTypeCode; +} + +class _RegistryEmitter { + _RegistryEmitter({ + required this.workflows, + required this.tasks, + }) : payloadCodecSymbols = _payloadCodecSymbolsFor(workflows, tasks); + + final List<_WorkflowInfo> workflows; + final List<_TaskInfo> tasks; + final Map payloadCodecSymbols; + + static String emptyPart({required String fileName}) { + final buffer = StringBuffer(); + buffer.writeln('// GENERATED CODE - DO NOT MODIFY BY HAND'); + buffer.writeln( + '// ignore_for_file: unused_element, unnecessary_lambdas, omit_local_variable_types, unused_import', + ); + buffer.writeln(); + buffer.writeln("part of '$fileName';"); + return buffer.toString(); + } + + static Map _payloadCodecSymbolsFor( + List<_WorkflowInfo> workflows, + List<_TaskInfo> tasks, + ) { + final orderedTypes = []; + void addType(String? typeCode) { + if (typeCode == null || orderedTypes.contains(typeCode)) return; + orderedTypes.add(typeCode); + } + + for (final workflow in workflows) { + addType(workflow.resultPayloadCodecTypeCode); + for (final parameter in workflow.runValueParameters) { + addType(parameter.payloadCodecTypeCode); + } + for (final step in workflow.steps) { + addType(step.stepValuePayloadCodecTypeCode); + for (final parameter in step.valueParameters) { + addType(parameter.payloadCodecTypeCode); + } + } + } + for (final task in tasks) { + for (final parameter in task.valueParameters) { + addType(parameter.payloadCodecTypeCode); + } + addType(task.resultPayloadCodecTypeCode); + } + + final result = {}; + final used = {}; + for (final typeCode in orderedTypes) { + var candidate = _lowerCamelStatic(_pascalIdentifierStatic(typeCode)); + if (candidate.isEmpty) { + candidate = 'payloadCodec'; + } + if (used.contains(candidate)) { + final base = candidate; + var suffix = 2; + while (used.contains(candidate)) { + candidate = '$base$suffix'; + suffix += 1; + } + } + used.add(candidate); + result[typeCode] = candidate; + } + return result; + } + + String emit({required String partOfFile}) { + final buffer = StringBuffer(); + buffer.writeln('// GENERATED CODE - DO NOT MODIFY BY HAND'); + buffer.writeln( + '// ignore_for_file: unused_element, unnecessary_lambdas, omit_local_variable_types, unused_import', + ); + buffer.writeln(); + buffer.writeln("part of '$partOfFile';"); + buffer.writeln(); + + _emitPayloadCodecs(buffer); + _emitWorkflows(buffer); + _emitWorkflowStartHelpers(buffer); + _emitGeneratedHelpers(buffer); + _emitTaskAdapters(buffer); + _emitTaskDefinitions(buffer); + _emitTasks(buffer); + _emitManifest(buffer); + _emitModule(buffer); + return buffer.toString(); + } + + void _emitPayloadCodecs(StringBuffer buffer) { + if (payloadCodecSymbols.isEmpty) { + return; + } + buffer.writeln('Map _stemPayloadMap('); + buffer.writeln(' Object? value,'); + buffer.writeln(' String typeName,'); + buffer.writeln(') {'); + buffer.writeln(' if (value is Map) {'); + buffer.writeln(' return Map.from(value);'); buffer.writeln(' }'); - buffer.writeln(' for (final handler in stemTasks) {'); - buffer.writeln(' tasks.register(handler);'); + buffer.writeln(' if (value is Map) {'); + buffer.writeln(' final result = {};'); + buffer.writeln(' value.forEach((key, entry) {'); + buffer.writeln(' if (key is! String) {'); + buffer.writeln( + r" throw StateError('$typeName payload must use string keys.');", + ); + buffer.writeln(' }'); + buffer.writeln(' result[key] = entry;'); + buffer.writeln(' });'); + buffer.writeln(' return result;'); buffer.writeln(' }'); + buffer.writeln( + r" throw StateError('$typeName payload must decode to Map, got ${value.runtimeType}.');", + ); buffer.writeln('}'); - return buffer.toString(); + buffer.writeln(); + + buffer.writeln('abstract final class StemPayloadCodecs {'); + for (final entry in payloadCodecSymbols.entries) { + final typeCode = entry.key; + final symbol = entry.value; + buffer.writeln(' static final PayloadCodec<$typeCode> $symbol ='); + buffer.writeln(' PayloadCodec<$typeCode>('); + buffer.writeln(' encode: (value) => value.toJson(),'); + buffer.writeln( + ' decode: (payload) => $typeCode.fromJson(' + ' _stemPayloadMap(payload, ${_string(typeCode)}),' + ' ),', + ); + buffer.writeln(' );'); + } + buffer.writeln('}'); + buffer.writeln(); } void _emitWorkflows(StringBuffer buffer) { - buffer.writeln('final List stemFlows = ['); + buffer.writeln('final List _stemFlows = ['); for (final workflow in workflows.where( (w) => w.kind == WorkflowKind.flow, )) { @@ -575,17 +1301,36 @@ class _RegistryEmitter { ' metadata: ${_dartObjectToCode(workflow.metadata!)},', ); } + if (workflow.resultPayloadCodecTypeCode != null) { + final codecField = + payloadCodecSymbols[workflow.resultPayloadCodecTypeCode]!; + buffer.writeln(' resultCodec: StemPayloadCodecs.$codecField,'); + } buffer.writeln(' build: (flow) {'); buffer.writeln( - ' final impl = ${workflow.importAlias}.${workflow.className}();', + ' final impl = ${_qualify(workflow.importAlias, workflow.className)}();', ); for (final step in workflow.steps) { - buffer.writeln(' flow.step('); + final stepArgs = step.valueParameters + .map((param) => _decodeArg('ctx.params', param)) + .join(', '); + final invocationArgs = [ + if (step.acceptsFlowContext) 'ctx', + if (stepArgs.isNotEmpty) stepArgs, + ].join(', '); + buffer.writeln(' flow.step<${step.stepValueTypeCode}>('); buffer.writeln(' ${_string(step.name)},'); - buffer.writeln(' (ctx) => impl.${step.method}(ctx),'); + buffer.writeln( + ' (ctx) => impl.${step.method}($invocationArgs),', + ); if (step.autoVersion) { buffer.writeln(' autoVersion: true,'); } + if (step.stepValuePayloadCodecTypeCode != null) { + final codecField = + payloadCodecSymbols[step.stepValuePayloadCodecTypeCode]!; + buffer.writeln(' valueCodec: StemPayloadCodecs.$codecField,'); + } if (step.title != null) { buffer.writeln(' title: ${_string(step.title!)},'); } @@ -610,14 +1355,102 @@ class _RegistryEmitter { buffer.writeln('];'); buffer.writeln(); + final scriptWorkflows = workflows + .where((workflow) => workflow.kind == WorkflowKind.script) + .toList(growable: false); + final scriptProxyClassNames = <_WorkflowInfo, String>{}; + var scriptProxyIndex = 0; + for (final workflow in scriptWorkflows) { + if (workflow.steps.isEmpty) { + continue; + } + final proxyClassName = '_StemScriptProxy${scriptProxyIndex++}'; + scriptProxyClassNames[workflow] = proxyClassName; + buffer.writeln( + 'class $proxyClassName extends ${_qualify(workflow.importAlias, workflow.className)} {', + ); + buffer.writeln(' $proxyClassName(this._script);'); + buffer.writeln(' final WorkflowScriptContext _script;'); + for (final step in workflow.steps) { + final signatureParts = [ + if (step.acceptsScriptStepContext) + 'WorkflowScriptStepContext context', + ...step.valueParameters.map( + (parameter) => '${parameter.typeCode} ${parameter.name}', + ), + ]; + final invocationArgs = [ + if (step.acceptsScriptStepContext) 'context', + ...step.valueParameters.map((parameter) => parameter.name), + ]; + buffer.writeln(' @override'); + buffer.writeln( + ' ${step.returnTypeCode} ${step.method}(${signatureParts.join(', ')}) {', + ); + buffer.writeln(' return _script.step<${step.stepValueTypeCode}>('); + buffer.writeln(' ${_string(step.name)},'); + buffer.writeln( + ' (context) => super.${step.method}(${invocationArgs.join(', ')}),', + ); + if (step.autoVersion) { + buffer.writeln(' autoVersion: true,'); + } + buffer.writeln(' );'); + buffer.writeln(' }'); + } + buffer.writeln('}'); + buffer.writeln(); + } + buffer.writeln( - 'final List stemScripts = [', + 'final List _stemScripts = [', ); - for (final workflow in workflows.where( - (w) => w.kind == WorkflowKind.script, - )) { + for (final workflow in scriptWorkflows) { + final proxyClass = scriptProxyClassNames[workflow]; buffer.writeln(' WorkflowScript('); buffer.writeln(' name: ${_string(workflow.name)},'); + if (workflow.steps.isNotEmpty) { + buffer.writeln(' checkpoints: ['); + for (final step in workflow.steps) { + if (step.stepValuePayloadCodecTypeCode != null) { + buffer.writeln(' FlowStep.typed<${step.stepValueTypeCode}>('); + } else { + buffer.writeln(' FlowStep('); + } + buffer.writeln(' name: ${_string(step.name)},'); + buffer.writeln( + ' handler: _stemScriptManifestStepNoop,', + ); + if (step.autoVersion) { + buffer.writeln(' autoVersion: true,'); + } + if (step.stepValuePayloadCodecTypeCode != null) { + final codecField = + payloadCodecSymbols[step.stepValuePayloadCodecTypeCode]!; + buffer.writeln( + ' valueCodec: StemPayloadCodecs.$codecField,', + ); + } + if (step.title != null) { + buffer.writeln(' title: ${_string(step.title!)},'); + } + if (step.kind != null) { + buffer.writeln(' kind: ${_dartObjectToCode(step.kind!)},'); + } + if (step.taskNames != null) { + buffer.writeln( + ' taskNames: ${_dartObjectToCode(step.taskNames!)},', + ); + } + if (step.metadata != null) { + buffer.writeln( + ' metadata: ${_dartObjectToCode(step.metadata!)},', + ); + } + buffer.writeln(' ),'); + } + buffer.writeln(' ],'); + } if (workflow.version != null) { buffer.writeln(' version: ${_string(workflow.version!)},'); } @@ -629,28 +1462,275 @@ class _RegistryEmitter { ' metadata: ${_dartObjectToCode(workflow.metadata!)},', ); } - buffer.writeln( - ' run: (script) => ${workflow.importAlias}.${workflow.className}().${workflow.runMethod}(script),', - ); + if (workflow.resultPayloadCodecTypeCode != null) { + final codecField = + payloadCodecSymbols[workflow.resultPayloadCodecTypeCode]!; + buffer.writeln(' resultCodec: StemPayloadCodecs.$codecField,'); + } + if (proxyClass != null) { + final runArgs = [ + if (workflow.runAcceptsScriptContext) 'script', + ...workflow.runValueParameters.map( + (parameter) => _decodeArg('script.params', parameter), + ), + ].join(', '); + buffer.writeln( + ' run: (script) => $proxyClass(script).${workflow.runMethod}($runArgs),', + ); + } else { + final runArgs = [ + if (workflow.runAcceptsScriptContext) 'script', + ...workflow.runValueParameters.map( + (parameter) => _decodeArg('script.params', parameter), + ), + ].join(', '); + buffer.writeln( + ' run: (script) => ${_qualify(workflow.importAlias, workflow.className)}().${workflow.runMethod}($runArgs),', + ); + } buffer.writeln(' ),'); } buffer.writeln('];'); buffer.writeln(); } + void _emitWorkflowStartHelpers(StringBuffer buffer) { + if (workflows.isEmpty) { + return; + } + final fieldNames = _fieldNamesForWorkflows( + workflows, + _symbolNamesForWorkflows(workflows), + ); + + buffer.writeln('abstract final class StemWorkflowDefinitions {'); + for (final workflow in workflows) { + final fieldName = fieldNames[workflow]!; + final argsTypeCode = _workflowArgsTypeCode(workflow); + buffer.writeln( + ' static final WorkflowRef<$argsTypeCode, ${workflow.resultTypeCode}> ' + '$fieldName = WorkflowRef<$argsTypeCode, ${workflow.resultTypeCode}>(', + ); + buffer.writeln(' name: ${_string(workflow.name)},'); + if (workflow.kind == WorkflowKind.script) { + if (workflow.runValueParameters.isEmpty) { + buffer.writeln( + ' encodeParams: (_) => const {},', + ); + } else { + buffer.writeln(' encodeParams: (params) => {'); + for (final parameter in workflow.runValueParameters) { + buffer.writeln( + ' ${_string(parameter.name)}: ' + '${_encodeValueExpression('params.${parameter.name}', parameter)},', + ); + } + buffer.writeln(' },'); + } + } else { + buffer.writeln(' encodeParams: (params) => params,'); + } + if (workflow.resultPayloadCodecTypeCode != null) { + final codecField = + payloadCodecSymbols[workflow.resultPayloadCodecTypeCode]!; + buffer.writeln( + ' decodeResult: StemPayloadCodecs.$codecField.decode,', + ); + } + buffer.writeln(' );'); + } + buffer.writeln('}'); + buffer.writeln(); + } + + Map<_WorkflowInfo, String> _symbolNamesForWorkflows( + List<_WorkflowInfo> values, + ) { + final result = <_WorkflowInfo, String>{}; + final used = {}; + for (final workflow in values) { + final candidates = _workflowSymbolCandidates( + workflowName: workflow.name, + starterNameOverride: workflow.starterNameOverride, + ); + var chosen = candidates.firstWhere( + (candidate) => !used.contains(candidate), + orElse: () => candidates.last, + ); + if (used.contains(chosen)) { + final base = chosen; + var suffix = 2; + while (used.contains(chosen)) { + chosen = '$base$suffix'; + suffix += 1; + } + } + used.add(chosen); + result[workflow] = chosen; + } + return result; + } + + Map<_WorkflowInfo, String> _fieldNamesForWorkflows( + List<_WorkflowInfo> values, + Map<_WorkflowInfo, String> symbolNames, + ) { + final result = <_WorkflowInfo, String>{}; + final used = {}; + for (final workflow in values) { + final candidateList = [ + if (workflow.nameFieldOverride != null && + workflow.nameFieldOverride!.trim().isNotEmpty) + _lowerCamelIdentifier(workflow.nameFieldOverride!), + _lowerCamel(symbolNames[workflow]!), + ]; + var chosen = candidateList.firstWhere( + (candidate) => candidate.isNotEmpty && !used.contains(candidate), + orElse: () => candidateList.last, + ); + if (used.contains(chosen)) { + final base = chosen; + var suffix = 2; + while (used.contains(chosen)) { + chosen = '$base$suffix'; + suffix += 1; + } + } + used.add(chosen); + result[workflow] = chosen; + } + return result; + } + + Map<_TaskInfo, String> _symbolNamesForTasks(List<_TaskInfo> values) { + final result = <_TaskInfo, String>{}; + final used = {}; + for (final task in values) { + final candidates = _taskSymbolCandidates(task); + var chosen = candidates.firstWhere( + (candidate) => !used.contains(candidate), + orElse: () => candidates.last, + ); + if (used.contains(chosen)) { + final base = chosen; + var suffix = 2; + while (used.contains(chosen)) { + chosen = '$base$suffix'; + suffix += 1; + } + } + used.add(chosen); + result[task] = chosen; + } + return result; + } + + List _taskSymbolCandidates(_TaskInfo task) { + final byName = task.name + .split('.') + .map(_pascalIdentifier) + .where((value) => value.isNotEmpty) + .toList(growable: false); + if (byName.isNotEmpty) { + return [ + byName.join(), + _pascalIdentifier(task.function), + ]; + } + return [_pascalIdentifier(task.function)]; + } + + List _workflowSymbolCandidates({ + required String workflowName, + String? starterNameOverride, + }) { + if (starterNameOverride != null && starterNameOverride.trim().isNotEmpty) { + final override = _starterSuffix(starterNameOverride); + if (override.isNotEmpty) { + return [override]; + } + } + final segments = workflowName + .split('.') + .map(_pascalIdentifier) + .where((value) => value.isNotEmpty) + .toList(growable: false); + if (segments.isEmpty) { + return const ['Workflow']; + } + final candidates = []; + for (var take = 1; take <= segments.length; take += 1) { + candidates.add( + segments.sublist(segments.length - take).join(), + ); + } + return candidates; + } + + String _starterSuffix(String value) { + final trimmed = value.trim(); + final match = RegExp( + '^start(?=[A-Z0-9_])', + caseSensitive: false, + ).firstMatch(trimmed); + final stripped = match == null ? trimmed : trimmed.substring(match.end); + return _pascalIdentifier(stripped); + } + + String _pascalIdentifier(String value) { + return _pascalIdentifierStatic(value); + } + + static String _pascalIdentifierStatic(String value) { + final parts = value + .split(RegExp('[^A-Za-z0-9]+')) + .where((part) => part.isNotEmpty) + .toList(growable: false); + if (parts.isEmpty) return 'Workflow'; + final buffer = StringBuffer(); + for (final part in parts) { + buffer + ..write(part[0].toUpperCase()) + ..write(part.substring(1)); + } + var result = buffer.toString(); + if (RegExp('^[0-9]').hasMatch(result)) { + result = 'Workflow$result'; + } + return result; + } + + String _lowerCamel(String value) { + return _lowerCamelStatic(value); + } + + static String _lowerCamelStatic(String value) { + if (value.isEmpty) return value; + return '${value[0].toLowerCase()}${value.substring(1)}'; + } + + String _lowerCamelIdentifier(String value) { + final pascal = _pascalIdentifier(value); + return _lowerCamel(pascal); + } + void _emitTasks(StringBuffer buffer) { buffer.writeln( - 'final List> stemTasks = >[', + 'final List> _stemTasks = >[', ); for (final task in tasks) { + final entrypoint = task.usesLegacyMapArgs + ? _qualify(task.importAlias, task.function) + : task.adapterName!; + final metadataCode = _taskMetadataCode(task); buffer.writeln(' FunctionTaskHandler('); buffer.writeln(' name: ${_string(task.name)},'); - buffer.writeln(' entrypoint: ${task.importAlias}.${task.function},'); + buffer.writeln(' entrypoint: $entrypoint,'); if (task.options != null) { buffer.writeln(' options: ${_dartObjectToCode(task.options!)},'); } - if (task.metadata != null) { - buffer.writeln(' metadata: ${_dartObjectToCode(task.metadata!)},'); + if (metadataCode != null) { + buffer.writeln(' metadata: $metadataCode,'); } if (!task.runInIsolate) { buffer.writeln(' runInIsolate: false,'); @@ -660,6 +1740,311 @@ class _RegistryEmitter { buffer.writeln('];'); buffer.writeln(); } + + void _emitTaskDefinitions(StringBuffer buffer) { + if (tasks.isEmpty) { + return; + } + final symbolNames = _symbolNamesForTasks(tasks); + buffer.writeln('abstract final class StemTaskDefinitions {'); + for (final task in tasks) { + final symbol = _lowerCamel(symbolNames[task]!); + final argsTypeCode = _taskArgsTypeCode(task); + buffer.writeln( + ' static final TaskDefinition<$argsTypeCode, ${task.resultTypeCode}> $symbol = TaskDefinition<$argsTypeCode, ${task.resultTypeCode}>(', + ); + buffer.writeln(' name: ${_string(task.name)},'); + if (task.usesLegacyMapArgs) { + buffer.writeln(' encodeArgs: (args) => args,'); + } else if (task.valueParameters.isEmpty) { + buffer.writeln(' encodeArgs: (args) => const {},'); + } else { + buffer.writeln(' encodeArgs: (args) => {'); + for (final parameter in task.valueParameters) { + buffer.writeln( + ' ${_string(parameter.name)}: ' + '${_encodeValueExpression('args.${parameter.name}', parameter)},', + ); + } + buffer.writeln(' },'); + } + if (task.options != null) { + buffer.writeln(' defaultOptions: ${_dartObjectToCode(task.options!)},'); + } + if (task.metadata != null) { + buffer.writeln(' metadata: ${_dartObjectToCode(task.metadata!)},'); + } + if (task.resultPayloadCodecTypeCode != null) { + final codecField = + payloadCodecSymbols[task.resultPayloadCodecTypeCode]!; + buffer.writeln( + ' decodeResult: StemPayloadCodecs.$codecField.decode,', + ); + } + buffer.writeln(' );'); + } + buffer.writeln('}'); + buffer.writeln(); + + buffer.writeln('extension StemGeneratedTaskEnqueuer on TaskEnqueuer {'); + for (final task in tasks) { + final symbol = symbolNames[task]!; + final fieldName = _lowerCamel(symbol); + buffer.writeln(' Future enqueue$symbol({'); + if (task.usesLegacyMapArgs) { + buffer.writeln(' required Map args,'); + } else { + for (final parameter in task.valueParameters) { + buffer.writeln( + ' required ${parameter.typeCode} ${parameter.name},', + ); + } + } + buffer.writeln(' Map headers = const {},'); + buffer.writeln(' TaskOptions? options,'); + buffer.writeln(' DateTime? notBefore,'); + buffer.writeln(' Map? meta,'); + buffer.writeln(' TaskEnqueueOptions? enqueueOptions,'); + buffer.writeln(' }) {'); + final callArgs = task.usesLegacyMapArgs + ? 'args' + : task.valueParameters.isEmpty + ? '()' + : '(${task.valueParameters.map((parameter) => '${parameter.name}: ${parameter.name}').join(', ')})'; + buffer.writeln(' return enqueueCall('); + buffer.writeln(' StemTaskDefinitions.$fieldName.call('); + buffer.writeln(' $callArgs,'); + buffer.writeln(' headers: headers,'); + buffer.writeln(' options: options,'); + buffer.writeln(' notBefore: notBefore,'); + buffer.writeln(' meta: meta,'); + buffer.writeln(' enqueueOptions: enqueueOptions,'); + buffer.writeln(' ),'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + } + buffer.writeln('}'); + buffer.writeln(); + + buffer.writeln('extension StemGeneratedTaskResults on Stem {'); + for (final task in tasks) { + final symbol = symbolNames[task]!; + final fieldName = _lowerCamel(symbol); + buffer.writeln( + ' Future?> waitFor$symbol(', + ); + buffer.writeln(' String taskId, {'); + buffer.writeln(' Duration? timeout,'); + buffer.writeln(' }) {'); + buffer.writeln(' return waitForTaskDefinition('); + buffer.writeln(' taskId,'); + buffer.writeln(' StemTaskDefinitions.$fieldName,'); + buffer.writeln(' timeout: timeout,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + } + buffer.writeln('}'); + buffer.writeln(); + } + + void _emitTaskAdapters(StringBuffer buffer) { + final typedTasks = tasks.where((task) => !task.usesLegacyMapArgs).toList(); + if (typedTasks.isEmpty) { + return; + } + for (final task in typedTasks) { + final adapterName = task.adapterName!; + final callArgs = [ + if (task.acceptsTaskContext) 'context', + ...task.valueParameters.map((param) => _decodeArg('args', param)), + ].join(', '); + buffer.writeln( + 'Future $adapterName(TaskInvocationContext context, Map args) async {', + ); + buffer.writeln( + ' return await Future.value(${_qualify(task.importAlias, task.function)}($callArgs));', + ); + buffer.writeln('}'); + buffer.writeln(); + } + } + + void _emitGeneratedHelpers(StringBuffer buffer) { + final needsScriptStepNoop = workflows.any( + (workflow) => + workflow.kind == WorkflowKind.script && workflow.steps.isNotEmpty, + ); + if (needsScriptStepNoop) { + buffer.writeln( + 'Future _stemScriptManifestStepNoop(FlowContext context) async => null;', + ); + buffer.writeln(); + } + + final needsArgHelper = + tasks.any((task) => !task.usesLegacyMapArgs) || + workflows.any( + (workflow) => + workflow.runValueParameters.isNotEmpty || + workflow.steps.any((step) => step.valueParameters.isNotEmpty), + ); + if (!needsArgHelper) { + return; + } + buffer.writeln('Object? _stemRequireArg('); + buffer.writeln(' Map args,'); + buffer.writeln(' String name,'); + buffer.writeln(') {'); + buffer.writeln(' if (!args.containsKey(name)) {'); + buffer.writeln( + " throw ArgumentError('Missing required argument \"\$name\".');", + ); + buffer.writeln(' }'); + buffer.writeln(' return args[name];'); + buffer.writeln('}'); + buffer.writeln(); + } + + void _emitManifest(StringBuffer buffer) { + buffer.writeln( + 'final List _stemWorkflowManifest = [', + ); + buffer.writeln( + ' ..._stemFlows.map((flow) => flow.definition.toManifestEntry()),', + ); + buffer.writeln( + ' ..._stemScripts.map((script) => script.definition.toManifestEntry()),', + ); + buffer.writeln('];'); + buffer.writeln(); + } + + void _emitModule(StringBuffer buffer) { + buffer.writeln('final StemModule stemModule = StemModule('); + buffer.writeln(' flows: _stemFlows,'); + buffer.writeln(' scripts: _stemScripts,'); + buffer.writeln(' tasks: _stemTasks,'); + buffer.writeln(' workflowManifest: _stemWorkflowManifest,'); + buffer.writeln(');'); + buffer.writeln(); + } + + String? _taskMetadataCode(_TaskInfo task) { + final resultCodecTypeCode = task.resultPayloadCodecTypeCode; + if (task.metadata == null && resultCodecTypeCode == null) { + return null; + } + if (resultCodecTypeCode == null) { + return _dartObjectToCode(task.metadata!); + } + final codecField = payloadCodecSymbols[resultCodecTypeCode]!; + final metadata = task.metadata; + if (metadata == null) { + return [ + 'TaskMetadata(', + 'resultEncoder: CodecTaskPayloadEncoder<${task.resultTypeCode}>(', + 'idValue: ${_string('stem.generated.${task.name}.result')}, ', + 'codec: StemPayloadCodecs.$codecField, ', + '), ', + ')', + ].join(); + } + final reader = ConstantReader(metadata); + final fields = []; + final description = StemRegistryBuilder._stringOrNull( + reader.peek('description'), + ); + if (description != null) { + fields.add('description: ${_string(description)}'); + } + final tags = StemRegistryBuilder._objectOrNull(reader.peek('tags')); + if (tags != null) { + fields.add('tags: ${_dartObjectToCode(tags)}'); + } + final idempotentReader = reader.peek('idempotent'); + if (idempotentReader != null && !idempotentReader.isNull) { + fields.add('idempotent: ${idempotentReader.boolValue}'); + } + final attributes = StemRegistryBuilder._objectOrNull( + reader.peek('attributes'), + ); + if (attributes != null) { + fields.add('attributes: ${_dartObjectToCode(attributes)}'); + } + final argsEncoder = StemRegistryBuilder._objectOrNull( + reader.peek('argsEncoder'), + ); + if (argsEncoder != null) { + fields.add('argsEncoder: ${_dartObjectToCode(argsEncoder)}'); + } + fields.add( + [ + 'resultEncoder: CodecTaskPayloadEncoder<${task.resultTypeCode}>(', + 'idValue: ${_string('stem.generated.${task.name}.result')}, ', + 'codec: StemPayloadCodecs.$codecField, ', + ')', + ].join(), + ); + return 'TaskMetadata(${fields.join(', ')})'; + } + + String _decodeArg(String sourceMap, _ValueParameterInfo parameter) { + final codecTypeCode = parameter.payloadCodecTypeCode; + if (codecTypeCode != null) { + final codecField = payloadCodecSymbols[codecTypeCode]!; + return [ + 'StemPayloadCodecs.$codecField.decode(', + '_stemRequireArg($sourceMap, ${_string(parameter.name)}),', + ')', + ].join(); + } + return '(_stemRequireArg($sourceMap, ${_string(parameter.name)}) ' + 'as ${parameter.typeCode})'; + } + + String _encodeValueExpression(String expression, _ValueParameterInfo parameter) { + final codecTypeCode = parameter.payloadCodecTypeCode; + if (codecTypeCode == null) { + return expression; + } + final codecField = payloadCodecSymbols[codecTypeCode]!; + return 'StemPayloadCodecs.$codecField.encode($expression)'; + } + + String _taskArgsTypeCode( + _TaskInfo task, + ) { + if (task.usesLegacyMapArgs) { + return 'Map'; + } + if (task.valueParameters.isEmpty) { + return '()'; + } + final fields = task.valueParameters + .map((parameter) => '${parameter.typeCode} ${parameter.name}') + .join(', '); + return '({$fields})'; + } + + String _workflowArgsTypeCode(_WorkflowInfo workflow) { + if (workflow.kind != WorkflowKind.script) { + return 'Map'; + } + if (workflow.runValueParameters.isEmpty) { + return '()'; + } + final fields = workflow.runValueParameters + .map((parameter) => '${parameter.typeCode} ${parameter.name}') + .join(', '); + return '({$fields})'; + } + + String _qualify(String alias, String symbol) { + if (alias.isEmpty) return symbol; + return '$alias.$symbol'; + } } String _dartObjectToCode(DartObject object) { diff --git a/packages/stem_builder/lib/stem_builder.dart b/packages/stem_builder/lib/stem_builder.dart index 3eff4e97..c866911d 100644 --- a/packages/stem_builder/lib/stem_builder.dart +++ b/packages/stem_builder/lib/stem_builder.dart @@ -2,5 +2,5 @@ import 'package:build/build.dart'; import 'package:stem_builder/src/stem_registry_builder.dart'; -/// Creates the builder that generates `stem_registry.g.dart`. +/// Creates the builder that generates per-library `*.stem.g.dart` part files. Builder stemRegistryBuilder(BuilderOptions options) => StemRegistryBuilder(); diff --git a/packages/stem_builder/pubspec.yaml b/packages/stem_builder/pubspec.yaml index ee2aa843..0868c198 100644 --- a/packages/stem_builder/pubspec.yaml +++ b/packages/stem_builder/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: dart_style: ^3.1.4 glob: ^2.1.3 source_gen: ^4.1.2 - stem: ^0.1.0 + stem: ^0.1.1 dev_dependencies: build_runner: ^2.10.5 diff --git a/packages/stem_builder/test/stem_registry_builder_test.dart b/packages/stem_builder/test/stem_registry_builder_test.dart index 8ae3d992..4ec8967f 100644 --- a/packages/stem_builder/test/stem_registry_builder_test.dart +++ b/packages/stem_builder/test/stem_registry_builder_test.dart @@ -7,7 +7,44 @@ const stubStem = ''' library stem; class FlowContext {} -class WorkflowScriptContext {} +typedef _FlowStepHandler = Future Function(FlowContext context); + +enum WorkflowStepKind { task, choice, parallel, wait, custom } + +class PayloadCodec { + const PayloadCodec({required this.encode, required this.decode}); + final Object? Function(T value) encode; + final T Function(Object? payload) decode; +} + +class FlowStep { + FlowStep({ + required this.name, + required this.handler, + this.autoVersion = false, + this.valueCodec, + this.title, + this.kind = WorkflowStepKind.task, + this.taskNames = const [], + this.metadata, + }); + final String name; + final _FlowStepHandler handler; + final bool autoVersion; + final PayloadCodec? valueCodec; + final String? title; + final WorkflowStepKind kind; + final List taskNames; + final Map? metadata; +} +class WorkflowScriptContext { + Future step( + String name, + dynamic handler, { + bool autoVersion = false, + }) async => throw UnimplementedError(); +} +class WorkflowScriptStepContext {} class TaskInvocationContext {} class TaskOptions { @@ -20,9 +57,16 @@ class TaskMetadata { } class WorkflowDefn { - const WorkflowDefn({this.name, this.kind = WorkflowKind.flow}); + const WorkflowDefn({ + this.name, + this.kind = WorkflowKind.flow, + this.starterName, + this.nameField, + }); final String? name; final WorkflowKind kind; + final String? starterName; + final String? nameField; } class WorkflowRun { @@ -52,11 +96,21 @@ class WorkflowAnnotations { const workflow = WorkflowAnnotations(); class Flow { - Flow({required String name, required void Function(dynamic) build}); + Flow({ + required String name, + required void Function(dynamic) build, + PayloadCodec? resultCodec, + }); } class WorkflowScript { - WorkflowScript({required String name, required dynamic run}); + WorkflowScript({ + required String name, + required dynamic run, + List steps = const [], + List checkpoints = const [], + PayloadCodec? resultCodec, + }); } class TaskHandler {} @@ -65,6 +119,18 @@ class FunctionTaskHandler implements TaskHandler { FunctionTaskHandler({required String name, required dynamic entrypoint}); } +class Stem { + Future?> waitForTaskDefinition( + String taskId, + TaskDefinition definition, { + Duration? timeout, + }) async => null; +} + +class TaskResult { + const TaskResult(); +} + abstract class WorkflowRegistry { void register(dynamic definition); } @@ -79,6 +145,8 @@ void main() { const input = ''' import 'package:stem/stem.dart'; +part 'workflows.stem.g.dart'; + @WorkflowDefn(name: 'hello.flow') class HelloWorkflow { @WorkflowStep(name: 'step-1') @@ -107,19 +175,23 @@ Future sendEmail( AssetId('stem', 'lib/stem.dart'), stubStem, ), - outputs: { - 'stem_builder|lib/stem_registry.g.dart': decodedMatches( - allOf([ - contains('registerStemDefinitions'), - contains('Flow('), - contains('WorkflowScript('), - contains('FunctionTaskHandler'), - contains( - "import 'package:stem_builder/workflows.dart' as stemLib0;", - ), - ]), - ), - }, + outputs: { + 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( + allOf([ + contains('StemWorkflowDefinitions'), + contains('StemTaskDefinitions'), + contains('StemGeneratedTaskEnqueuer'), + contains('StemGeneratedTaskResults'), + contains('waitForSendEmail('), + contains('WorkflowRef, String>'), + contains('Flow('), + contains('WorkflowScript('), + contains('stemModule = StemModule('), + contains('FunctionTaskHandler'), + contains("part of 'workflows.dart';"), + ]), + ), + }, ); }); @@ -127,6 +199,8 @@ Future sendEmail( const input = ''' import 'package:stem/stem.dart'; +part 'workflows.stem.g.dart'; + @WorkflowDefn() class BadWorkflow { @WorkflowRun() @@ -148,17 +222,204 @@ class BadWorkflow { expect(result.errors.join('\n'), contains('@workflow.run')); }); - test('rejects script workflow with steps', () async { + test( + 'honors workflow starter/name field overrides from annotations', + () async { + const input = ''' +import 'package:stem/stem.dart'; + +part 'workflows.stem.g.dart'; + +@WorkflowDefn( + name: 'hello.flow', + starterName: 'LaunchHello', + nameField: 'helloFlow', +) +class HelloWorkflow { + @WorkflowStep() + Future stepOne() async {} +} + +@WorkflowDefn( + name: 'billing.daily_sync', + kind: WorkflowKind.script, + starterName: 'startDailyBilling', + nameField: 'dailyBilling', +) +class DailyBillingWorkflow { + @WorkflowRun() + Future run(String tenant) async {} +} +'''; + + await testBuilder( + stemRegistryBuilder(BuilderOptions.empty), + {'stem_builder|lib/workflows.dart': input}, + rootPackage: 'stem_builder', + readerWriter: TestReaderWriter(rootPackage: 'stem_builder') + ..testing.writeString( + AssetId('stem', 'lib/stem.dart'), + stubStem, + ), + outputs: { + 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( + allOf([ + contains( + 'static final WorkflowRef, Object?> ' + 'helloFlow =', + ), + contains( + 'static final WorkflowRef<({String tenant}), Object?> ' + 'dailyBilling =', + ), + ]), + ), + }, + ); + }, + ); + + test( + 'generates script workflow step proxies for direct method calls', + () async { + const input = ''' +import 'package:stem/stem.dart'; + +part 'workflows.stem.g.dart'; + +@WorkflowDefn(kind: WorkflowKind.script) +class ScriptWithStepsWorkflow { + @WorkflowRun() + Future run(WorkflowScriptContext script) async { + return sendEmail('user@example.com'); + } + + @WorkflowStep() + Future sendEmail(String email) async => email; +} +'''; + + await testBuilder( + stemRegistryBuilder(BuilderOptions.empty), + {'stem_builder|lib/workflows.dart': input}, + rootPackage: 'stem_builder', + readerWriter: TestReaderWriter(rootPackage: 'stem_builder') + ..testing.writeString( + AssetId('stem', 'lib/stem.dart'), + stubStem, + ), + outputs: { + 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( + allOf([ + contains( + 'class _StemScriptProxy0 extends ScriptWithStepsWorkflow', + ), + contains('return _script.step('), + contains('(context) => super.sendEmail(email)'), + contains( + 'run: (script) => _StemScriptProxy0(script).run(script)', + ), + ]), + ), + }, + ); + }, + ); + + test( + 'supports script workflows with plain run(...) and no @WorkflowRun', + () async { + const input = ''' +import 'package:stem/stem.dart'; + +part 'workflows.stem.g.dart'; + +@WorkflowDefn(kind: WorkflowKind.script) +class ScriptWorkflow { + Future run(String email) async => sendEmail(email); + + @WorkflowStep() + Future sendEmail(String email) async => email; +} +'''; + + await testBuilder( + stemRegistryBuilder(BuilderOptions.empty), + {'stem_builder|lib/workflows.dart': input}, + rootPackage: 'stem_builder', + readerWriter: TestReaderWriter(rootPackage: 'stem_builder') + ..testing.writeString( + AssetId('stem', 'lib/stem.dart'), + stubStem, + ), + outputs: { + 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( + allOf([ + contains('class _StemScriptProxy0 extends ScriptWorkflow'), + contains( + 'run: (script) => _StemScriptProxy0(', + ), + contains('_stemRequireArg(script.params, "email") as String'), + ]), + ), + }, + ); + }, + ); + + test('rejects script workflow steps that are not async', () async { const input = ''' import 'package:stem/stem.dart'; +part 'workflows.stem.g.dart'; + @WorkflowDefn(kind: WorkflowKind.script) -class BadWorkflow { +class BadScriptWorkflow { @WorkflowRun() - Future run(WorkflowScriptContext script) async => 'done'; + Future run(WorkflowScriptContext script) async { + return sendEmail('user@example.com'); + } @WorkflowStep() - Future step(FlowContext ctx) async => 'ok'; + String sendEmail(String email) => email; +} +'''; + + final result = await testBuilder( + stemRegistryBuilder(BuilderOptions.empty), + {'stem_builder|lib/workflows.dart': input}, + rootPackage: 'stem_builder', + readerWriter: TestReaderWriter(rootPackage: 'stem_builder') + ..testing.writeString( + AssetId('stem', 'lib/stem.dart'), + stubStem, + ), + ); + expect(result.succeeded, isFalse); + expect( + result.errors.join('\n'), + contains('must return Future or FutureOr'), + ); + }); + + test('rejects duplicate script checkpoint names', () async { + const input = ''' +import 'package:stem/stem.dart'; + +part 'workflows.stem.g.dart'; + +@WorkflowDefn(kind: WorkflowKind.script) +class DuplicateCheckpointWorkflow { + Future run() async { + await first(); + await second(); + } + + @WorkflowStep(name: 'shared') + Future first() async {} + + @WorkflowStep(name: 'shared') + Future second() async {} } '''; @@ -173,13 +434,181 @@ class BadWorkflow { ), ); expect(result.succeeded, isFalse); - expect(result.errors.join('\n'), contains('@workflow.step')); + expect(result.errors.join('\n'), contains('duplicate checkpoint names')); + expect(result.errors.join('\n'), contains('"shared" from first, second')); + }); + + test( + 'rejects manual checkpoint names that conflict with annotated ones', + () async { + const input = ''' +import 'package:stem/stem.dart'; + +part 'workflows.stem.g.dart'; + +@WorkflowDefn(kind: WorkflowKind.script) +class DuplicateManualCheckpointWorkflow { + @WorkflowRun() + Future run(WorkflowScriptContext script) async { + await script.step('send-email', (ctx) => sendEmail('user@example.com')); + } + + @WorkflowStep(name: 'send-email') + Future sendEmail(String email) async {} +} +'''; + + final result = await testBuilder( + stemRegistryBuilder(BuilderOptions.empty), + {'stem_builder|lib/workflows.dart': input}, + rootPackage: 'stem_builder', + readerWriter: TestReaderWriter(rootPackage: 'stem_builder') + ..testing.writeString( + AssetId('stem', 'lib/stem.dart'), + stubStem, + ), + ); + expect(result.succeeded, isFalse); + expect(result.errors.join('\n'), contains('manual checkpoint')); + expect( + result.errors.join('\n'), + contains('conflicts with annotated checkpoint'), + ); + }); + + test( + 'decodes serializable @workflow.run parameters from script params', + () async { + const input = ''' +import 'package:stem/stem.dart'; + +part 'workflows.stem.g.dart'; + +@WorkflowDefn(kind: WorkflowKind.script) +class SignupWorkflow { + @WorkflowRun() + Future> run(String email) async { + await sendWelcomeEmail(email); + return {'email': email}; + } + + @WorkflowStep() + Future sendWelcomeEmail(String email) async {} +} +'''; + + await testBuilder( + stemRegistryBuilder(BuilderOptions.empty), + {'stem_builder|lib/workflows.dart': input}, + rootPackage: 'stem_builder', + readerWriter: TestReaderWriter(rootPackage: 'stem_builder') + ..testing.writeString( + AssetId('stem', 'lib/stem.dart'), + stubStem, + ), + outputs: { + 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( + allOf([ + contains( + 'run: (script) => _StemScriptProxy0(', + ), + contains( + ').run((_stemRequireArg(script.params, "email") as String))', + ), + contains('_stemRequireArg(script.params, "email") as String'), + contains('abstract final class StemWorkflowDefinitions'), + contains( + 'signupWorkflow = WorkflowRef<({String email}), ' + 'Map>(', + ), + isNot(contains('extraParams')), + ]), + ), + }, + ); + }, + ); + + test( + 'supports @workflow.run with WorkflowScriptContext plus typed parameters', + () async { + const input = ''' +import 'package:stem/stem.dart'; + +part 'workflows.stem.g.dart'; + +@WorkflowDefn(kind: WorkflowKind.script) +class SignupWorkflow { + @WorkflowRun() + Future run(WorkflowScriptContext script, String email) async { + await sendWelcomeEmail(email); + } + + @WorkflowStep() + Future sendWelcomeEmail(String email) async {} +} +'''; + + await testBuilder( + stemRegistryBuilder(BuilderOptions.empty), + {'stem_builder|lib/workflows.dart': input}, + rootPackage: 'stem_builder', + readerWriter: TestReaderWriter(rootPackage: 'stem_builder') + ..testing.writeString( + AssetId('stem', 'lib/stem.dart'), + stubStem, + ), + outputs: { + 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( + allOf([ + contains('run: (script) => _StemScriptProxy0('), + contains( + ').run(script, (_stemRequireArg(script.params, "email") as ' + 'String))', + ), + ]), + ), + }, + ); + }, + ); + + test('rejects non-serializable @workflow.run parameter types', () async { + const input = ''' +import 'package:stem/stem.dart'; + +part 'workflows.stem.g.dart'; + +@WorkflowDefn(kind: WorkflowKind.script) +class BadScriptWorkflow { + @WorkflowRun() + Future run(DateTime when) async {} +} +'''; + + final result = await testBuilder( + stemRegistryBuilder(BuilderOptions.empty), + {'stem_builder|lib/workflows.dart': input}, + rootPackage: 'stem_builder', + readerWriter: TestReaderWriter(rootPackage: 'stem_builder') + ..testing.writeString( + AssetId('stem', 'lib/stem.dart'), + stubStem, + ), + ); + expect(result.succeeded, isFalse); + expect( + result.errors.join('\n'), + contains('serializable or codec-backed DTO type'), + ); }); test('rejects task args that are not Map', () async { const input = ''' import 'package:stem/stem.dart'; +part 'workflows.stem.g.dart'; + @TaskDefn() Future badTask( TaskInvocationContext ctx, @@ -198,6 +627,164 @@ Future badTask( ), ); expect(result.succeeded, isFalse); - expect(result.errors.join('\n'), contains('Map')); + expect( + result.errors.join('\n'), + contains('serializable or codec-backed DTO type'), + ); + }); + + test('generates adapters for typed workflow and task parameters', () async { + const input = ''' +import 'package:stem/stem.dart'; + +part 'workflows.stem.g.dart'; + +@WorkflowDefn(name: 'typed.flow') +class TypedWorkflow { + @WorkflowStep(name: 'send-email') + Future sendEmail(String email, int retries) async {} +} + +@TaskDefn(name: 'typed.task') +Future typedTask( + TaskInvocationContext context, + String email, + int retries, +) async {} +'''; + + await testBuilder( + stemRegistryBuilder(BuilderOptions.empty), + {'stem_builder|lib/workflows.dart': input}, + rootPackage: 'stem_builder', + readerWriter: TestReaderWriter(rootPackage: 'stem_builder') + ..testing.writeString( + AssetId('stem', 'lib/stem.dart'), + stubStem, + ), + outputs: { + 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( + allOf([ + contains('_stemRequireArg(ctx.params, "email") as String'), + contains('_stemRequireArg(ctx.params, "retries") as int'), + contains('Future _stemTaskAdapter0('), + contains('_stemRequireArg(args, "email") as String'), + contains('_stemRequireArg(args, "retries") as int'), + contains('entrypoint: _stemTaskAdapter0'), + ]), + ), + }, + ); + }); + + test( + 'generates codec-backed DTO helpers for workflow and task types', + () async { + const input = ''' +import 'package:stem/stem.dart'; + +part 'workflows.stem.g.dart'; + +class EmailRequest { + const EmailRequest({required this.email, required this.retries}); + + final String email; + final int retries; + + Map toJson() => { + 'email': email, + 'retries': retries, + }; + + factory EmailRequest.fromJson(Map json) => EmailRequest( + email: json['email'] as String, + retries: json['retries'] as int, + ); +} + +@WorkflowDefn(name: 'dto.script', kind: WorkflowKind.script) +class DtoWorkflow { + Future run(EmailRequest request) async => send(request); + + @WorkflowStep(name: 'send') + Future send(EmailRequest request) async => request; +} + +@TaskDefn(name: 'dto.task') +Future dtoTask( + TaskInvocationContext context, + EmailRequest request, +) async => request; +'''; + + await testBuilder( + stemRegistryBuilder(BuilderOptions.empty), + {'stem_builder|lib/workflows.dart': input}, + rootPackage: 'stem_builder', + readerWriter: TestReaderWriter(rootPackage: 'stem_builder') + ..testing.writeString( + AssetId('stem', 'lib/stem.dart'), + stubStem, + ), + outputs: { + 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( + allOf([ + contains('abstract final class StemPayloadCodecs'), + contains('PayloadCodec emailRequest ='), + contains( + 'WorkflowRef<({EmailRequest request}), EmailRequest> script =', + ), + contains('encode: (value) => value.toJson(),'), + contains('EmailRequest.fromJson('), + contains( + 'StemPayloadCodecs.emailRequest.encode(params.request)', + ), + contains('StemPayloadCodecs.emailRequest.decode('), + contains( + '_stemRequireArg(script.params, "request"),', + ), + contains( + 'StemPayloadCodecs.emailRequest.decode(' + '_stemRequireArg(args, "request"))', + ), + contains('decodeResult: StemPayloadCodecs.emailRequest.decode,'), + contains('CodecTaskPayloadEncoder('), + contains('valueCodec: StemPayloadCodecs.emailRequest,'), + contains('resultCodec: StemPayloadCodecs.emailRequest,'), + ]), + ), + }, + ); + }); + + test('rejects non-serializable workflow step parameter types', () async { + const input = ''' +import 'package:stem/stem.dart'; + +part 'workflows.stem.g.dart'; + +@WorkflowDefn(name: 'bad.flow') +class BadWorkflow { + @WorkflowStep() + Future bad(DateTime when) async {} +} +'''; + + final result = await testBuilder( + stemRegistryBuilder(BuilderOptions.empty), + {'stem_builder|lib/workflows.dart': input}, + rootPackage: 'stem_builder', + readerWriter: TestReaderWriter(rootPackage: 'stem_builder') + ..testing.writeString( + AssetId('stem', 'lib/stem.dart'), + stubStem, + ), + ); + + expect(result.succeeded, isFalse); + expect( + result.errors.join('\n'), + contains('serializable or codec-backed DTO type'), + ); }); } diff --git a/packages/stem_cli/lib/src/cli/utilities.dart b/packages/stem_cli/lib/src/cli/utilities.dart index 68438705..043f2815 100644 --- a/packages/stem_cli/lib/src/cli/utilities.dart +++ b/packages/stem_cli/lib/src/cli/utilities.dart @@ -129,7 +129,7 @@ Future createDefaultWorkflowContext({ final env = environment ?? Platform.environment; final config = StemConfig.fromEnvironment(env); final cliContext = await createDefaultContext(environment: env); - final registry = cliContext.registry ?? SimpleTaskRegistry(); + final registry = cliContext.registry ?? InMemoryTaskRegistry(); final stem = Stem( broker: cliContext.broker, registry: registry, diff --git a/packages/stem_cli/test/unit/cli/cli_tasks_test.dart b/packages/stem_cli/test/unit/cli/cli_tasks_test.dart index e0530520..dcf5821c 100644 --- a/packages/stem_cli/test/unit/cli/cli_tasks_test.dart +++ b/packages/stem_cli/test/unit/cli/cli_tasks_test.dart @@ -8,7 +8,7 @@ void main() { group('stem tasks', () { test('lists tasks with metadata', () async { final broker = InMemoryBroker(); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( FunctionTaskHandler( name: 'task.one', @@ -52,7 +52,7 @@ void main() { test('emits json when requested', () async { final broker = InMemoryBroker(); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( FunctionTaskHandler( name: 'task.two', diff --git a/packages/stem_cli/test/unit/cli/cli_worker_stats_test.dart b/packages/stem_cli/test/unit/cli/cli_worker_stats_test.dart index 17330fa6..5c759544 100644 --- a/packages/stem_cli/test/unit/cli/cli_worker_stats_test.dart +++ b/packages/stem_cli/test/unit/cli/cli_worker_stats_test.dart @@ -9,7 +9,7 @@ void main() { test('prints snapshot for idle worker', () async { final broker = InMemoryBroker(); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry(); + final registry = InMemoryTaskRegistry(); final worker = Worker( broker: broker, registry: registry, @@ -34,7 +34,7 @@ void main() { backend: backend, routing: RoutingRegistry(RoutingConfig.legacy()), dispose: () async {}, - registry: SimpleTaskRegistry(), + registry: InMemoryTaskRegistry(), ), ); @@ -55,7 +55,7 @@ void main() { final started = Completer(); final broker = InMemoryBroker(); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register(_BlockingTask(started, release)); final worker = Worker( @@ -95,7 +95,7 @@ void main() { revokeStore: InMemoryRevokeStore(), routing: RoutingRegistry(RoutingConfig.legacy()), dispose: () async {}, - registry: SimpleTaskRegistry(), + registry: InMemoryTaskRegistry(), ), ); @@ -114,7 +114,7 @@ void main() { final revokeStore = InMemoryRevokeStore(); final started = Completer(); final release = Completer(); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register(_BlockingTask(started, release)); final worker = Worker( @@ -194,7 +194,7 @@ void main() { final backend = InMemoryResultBackend(); final started = Completer(); final release = Completer(); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register(_BlockingTask(started, release)); final worker = Worker( @@ -255,7 +255,7 @@ void main() { final revokeStore = InMemoryRevokeStore(); final started = Completer(); final release = Completer(); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register(_BlockingTask(started, release)); final worker = Worker( @@ -307,7 +307,7 @@ void main() { final broker = InMemoryBroker(); final backend = InMemoryResultBackend(); final revokeStore = InMemoryRevokeStore(); - final registry = SimpleTaskRegistry(); + final registry = InMemoryTaskRegistry(); final worker = Worker( broker: broker, @@ -353,7 +353,7 @@ void main() { final backend = InMemoryResultBackend(); final revokeStore = InMemoryRevokeStore(); final started = Completer(); - final registry = SimpleTaskRegistry()..register(_LoopingTask(started)); + final registry = InMemoryTaskRegistry()..register(_LoopingTask(started)); final worker = Worker( broker: broker, diff --git a/packages/stem_cli/test/unit/cli/cli_worker_status_test.dart b/packages/stem_cli/test/unit/cli/cli_worker_status_test.dart index f645f5d7..3061e291 100644 --- a/packages/stem_cli/test/unit/cli/cli_worker_status_test.dart +++ b/packages/stem_cli/test/unit/cli/cli_worker_status_test.dart @@ -31,7 +31,7 @@ void main() { dispose: () async { broker.dispose(); }, - registry: SimpleTaskRegistry(), + registry: InMemoryTaskRegistry(), ), ); @@ -68,7 +68,7 @@ void main() { dispose: () async { broker.dispose(); }, - registry: SimpleTaskRegistry(), + registry: InMemoryTaskRegistry(), ), ); diff --git a/packages/stem_cli/test/unit/cli/cli_workflow_agent_help_test.dart b/packages/stem_cli/test/unit/cli/cli_workflow_agent_help_test.dart index 78909f18..3d8da3bd 100644 --- a/packages/stem_cli/test/unit/cli/cli_workflow_agent_help_test.dart +++ b/packages/stem_cli/test/unit/cli/cli_workflow_agent_help_test.dart @@ -20,7 +20,7 @@ StemCommandDependencies _deps(StringBuffer out, StringBuffer err) { dispose: () async { broker.dispose(); }, - registry: SimpleTaskRegistry(), + registry: InMemoryTaskRegistry(), ); }, ); @@ -45,7 +45,7 @@ void main() { dispose: () async { broker.dispose(); }, - registry: SimpleTaskRegistry(), + registry: InMemoryTaskRegistry(), ); }, ); @@ -77,7 +77,7 @@ void main() { dispose: () async { broker.dispose(); }, - registry: SimpleTaskRegistry(), + registry: InMemoryTaskRegistry(), ); }, ); diff --git a/packages/stem_cli/test/unit/cli/cli_workflow_test.dart b/packages/stem_cli/test/unit/cli/cli_workflow_test.dart index 5830a523..afee1eb0 100644 --- a/packages/stem_cli/test/unit/cli/cli_workflow_test.dart +++ b/packages/stem_cli/test/unit/cli/cli_workflow_test.dart @@ -23,7 +23,7 @@ void main() { Future _buildWorkflowContext() async { final broker = InMemoryBroker(); - final registry = SimpleTaskRegistry(); + final registry = InMemoryTaskRegistry(); final stem = Stem(broker: broker, registry: registry, backend: null); final runtime = WorkflowRuntime( stem: stem, @@ -52,7 +52,7 @@ void main() { dispose: () async { broker.dispose(); }, - registry: SimpleTaskRegistry(), + registry: InMemoryTaskRegistry(), ); } diff --git a/packages/stem_cli/test/unit/cli/dlq_cli_test.dart b/packages/stem_cli/test/unit/cli/dlq_cli_test.dart index b3648c45..2bfd6c8b 100644 --- a/packages/stem_cli/test/unit/cli/dlq_cli_test.dart +++ b/packages/stem_cli/test/unit/cli/dlq_cli_test.dart @@ -32,7 +32,7 @@ void main() { backend: backend, routing: RoutingRegistry(RoutingConfig.legacy()), dispose: () async {}, - registry: SimpleTaskRegistry(), + registry: InMemoryTaskRegistry(), ), ); @@ -60,7 +60,7 @@ void main() { backend: backend, routing: RoutingRegistry(RoutingConfig.legacy()), dispose: () async {}, - registry: SimpleTaskRegistry(), + registry: InMemoryTaskRegistry(), ), ); @@ -91,7 +91,7 @@ void main() { backend: backend, routing: RoutingRegistry(RoutingConfig.legacy()), dispose: () async {}, - registry: SimpleTaskRegistry(), + registry: InMemoryTaskRegistry(), ), ); @@ -115,7 +115,7 @@ void main() { backend: backend, routing: RoutingRegistry(RoutingConfig.legacy()), dispose: () async {}, - registry: SimpleTaskRegistry(), + registry: InMemoryTaskRegistry(), ), ); diff --git a/packages/stem_memory/CHANGELOG.md b/packages/stem_memory/CHANGELOG.md index 208e435a..d696229b 100644 --- a/packages/stem_memory/CHANGELOG.md +++ b/packages/stem_memory/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.1.0 +- Updated the in-memory workflow store to honor caller-provided run ids, + aligning it with workflow runtime metadata views and manifest tooling. +- Rejected duplicate caller-provided workflow run ids instead of overwriting + existing run/checkpoint state. - Renamed `memoryBackendFactory` to `memoryResultBackendFactory` for adapter factory naming consistency. - Updated docs and exports to use `StemClient`-first examples and the renamed diff --git a/packages/stem_memory/lib/src/workflow/store/in_memory_workflow_store.dart b/packages/stem_memory/lib/src/workflow/store/in_memory_workflow_store.dart index 6b9bbe9a..f6ff4509 100644 --- a/packages/stem_memory/lib/src/workflow/store/in_memory_workflow_store.dart +++ b/packages/stem_memory/lib/src/workflow/store/in_memory_workflow_store.dart @@ -87,6 +87,7 @@ class InMemoryWorkflowStore implements WorkflowStore { @override /// Creates a new workflow run and returns its generated id. Future createRun({ + String? runId, required String workflow, required Map params, String? parentRunId, @@ -94,7 +95,12 @@ class InMemoryWorkflowStore implements WorkflowStore { WorkflowCancellationPolicy? cancellationPolicy, }) async { final now = _clock.now(); - final id = 'wf-${now.microsecondsSinceEpoch}-${_counter++}'; + final id = (runId != null && runId.trim().isNotEmpty) + ? runId.trim() + : 'wf-${now.microsecondsSinceEpoch}-${_counter++}'; + if (_runs.containsKey(id)) { + throw StateError('Workflow run "$id" already exists.'); + } _runs[id] = RunState( id: id, workflow: workflow, diff --git a/packages/stem_memory/pubspec.yaml b/packages/stem_memory/pubspec.yaml index a34b6652..f8aa99d8 100644 --- a/packages/stem_memory/pubspec.yaml +++ b/packages/stem_memory/pubspec.yaml @@ -8,11 +8,11 @@ environment: dependencies: collection: ^1.19.1 - stem: ^0.1.0 + stem: ^0.1.1 uuid: ^4.5.2 dev_dependencies: coverage: ^1.15.0 - stem_adapter_tests: ^0.1.0 + stem_adapter_tests: ^0.1.1 test: ^1.29.0 very_good_analysis: ^10.0.0 diff --git a/packages/stem_postgres/CHANGELOG.md b/packages/stem_postgres/CHANGELOG.md index c7c3539c..99b7ba1a 100644 --- a/packages/stem_postgres/CHANGELOG.md +++ b/packages/stem_postgres/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.1.1 + +- Updated Ormed dependencies to 0.2.0 for the Postgres adapter stack. +- Simplified explicit Postgres URL datasource bootstrapping to use the new + Ormed code-first datasource helper path. +- Removed explicit `ensurePostgresDriverRegistration()` calls from Stem + Postgres runtime and seed paths by routing config-driven datasource creation + through the new helper-based bootstrap code. +- Updated Postgres workflow stores to honor caller-provided run ids, keeping + adapter behavior aligned with workflow runtime metadata/manifests and the + shared workflow-store contract suite. + ## 0.1.0 - Normalized `postgresResultBackendFactory` to accept a positional `uri` diff --git a/packages/stem_postgres/lib/src/connection.dart b/packages/stem_postgres/lib/src/connection.dart index 804963a4..8b65d06b 100644 --- a/packages/stem_postgres/lib/src/connection.dart +++ b/packages/stem_postgres/lib/src/connection.dart @@ -1,5 +1,4 @@ import 'package:ormed/ormed.dart'; -import 'package:ormed_postgres/ormed_postgres.dart'; import 'package:stem_postgres/src/database/datasource.dart'; import 'package:stem_postgres/src/database/migrations.dart'; @@ -133,7 +132,6 @@ Future _openDataSource(String? connectionString) async { } Future _runMigrationsForDataSource(DataSource dataSource) async { - ensurePostgresDriverRegistration(); final driver = dataSource.connection.driver; if (driver is! SchemaDriver) { throw StateError('Expected a SchemaDriver for Postgres migrations.'); diff --git a/packages/stem_postgres/lib/src/database/datasource.dart b/packages/stem_postgres/lib/src/database/datasource.dart index f6deedce..0414c342 100644 --- a/packages/stem_postgres/lib/src/database/datasource.dart +++ b/packages/stem_postgres/lib/src/database/datasource.dart @@ -10,49 +10,79 @@ DataSource createDataSource({ bool logging = false, contextual.Logger? logger, }) { - ensurePostgresDriverRegistration(); - - var config = (connectionString != null && connectionString.isNotEmpty) - ? OrmProjectConfig( - connections: { - 'default': ConnectionDefinition( - name: 'default', - driver: DriverConfig( - type: 'postgres', - options: { - 'url': connectionString, - if (logging) 'logging': true, - }, - ), - migrations: MigrationSection( - directory: 'lib/src/database/migrations', - registry: 'lib/src/database/migrations.dart', - ledgerTable: 'orm_migrations', - schemaDump: 'database/schema.sql', - ), - seeds: SeedSection( - directory: 'lib/src/database/seeders', - registry: 'lib/src/database/seeders.dart', - ), - ), - }, - activeConnectionName: 'default', + if (connectionString != null && connectionString.isNotEmpty) { + final options = bootstrapOrm() + .postgresDataSourceOptionsFromEnv( + environment: {'DATABASE_URL': connectionString}, + logging: logging, ) - : loadOrmConfig(); + .copyWith(logger: logger ?? stemLogger); + return DataSource(options); + } - if (connectionString == null || connectionString.isEmpty) { - if (logging) { - config = config.updateActiveConnection( - driver: config.driver.copyWith( - options: {...config.driver.options, 'logging': true}, - ), - ); - } + var config = loadOrmConfig(); + + if (logging) { + config = config.updateActiveConnection( + driver: config.driver.copyWith( + options: {...config.driver.options, 'logging': true}, + ), + ); } - return DataSource.fromConfig( - config, - registry: bootstrapOrm(), - logger: logger ?? stemLogger, - ); + return createDataSourceFromConfig(config, logger: logger ?? stemLogger); +} + +/// Creates a new DataSource instance using a resolved ORM project config. +DataSource createDataSourceFromConfig( + OrmProjectConfig config, { + contextual.Logger? logger, +}) { + final registry = bootstrapOrm(); + final options = Map.from(config.driver.options); + final url = options['url']?.toString(); + final dataSourceOptions = (url != null && url.isNotEmpty) + ? registry.postgresDataSourceOptionsFromEnv( + environment: { + 'DATABASE_URL': url, + if (options['sslmode'] case final Object sslmode) + 'DB_SSLMODE': sslmode.toString(), + if (options['timezone'] case final Object timezone) + 'DB_TIMEZONE': timezone.toString(), + if (options['applicationName'] case final Object appName) + 'DB_APP_NAME': appName.toString(), + }, + name: config.activeConnectionName, + logging: options['logging'] == true, + tablePrefix: options['table_prefix']?.toString() ?? '', + defaultSchema: + options['default_schema']?.toString() ?? + options['schema']?.toString() ?? + 'public', + ) + : registry.postgresDataSourceOptions( + host: options['host']?.toString() ?? 'localhost', + port: switch (options['port']) { + final int value => value, + final String value => int.tryParse(value) ?? 5432, + _ => 5432, + }, + database: options['database']?.toString() ?? 'postgres', + username: + options['username']?.toString() ?? + options['user']?.toString() ?? + 'postgres', + password: options['password']?.toString(), + sslmode: options['sslmode']?.toString() ?? 'disable', + timezone: options['timezone']?.toString() ?? 'UTC', + applicationName: options['applicationName']?.toString(), + name: config.activeConnectionName, + logging: options['logging'] == true, + tablePrefix: options['table_prefix']?.toString() ?? '', + defaultSchema: + options['default_schema']?.toString() ?? + options['schema']?.toString() ?? + 'public', + ); + return DataSource(dataSourceOptions.copyWith(logger: logger)); } diff --git a/packages/stem_postgres/lib/src/database/seed_runtime.dart b/packages/stem_postgres/lib/src/database/seed_runtime.dart index c12b5633..794bdfcd 100644 --- a/packages/stem_postgres/lib/src/database/seed_runtime.dart +++ b/packages/stem_postgres/lib/src/database/seed_runtime.dart @@ -4,9 +4,10 @@ import 'dart:io'; import 'package:artisanal/args.dart'; import 'package:ormed/ormed.dart'; -import 'package:ormed_postgres/ormed_postgres.dart'; import 'package:stem/stem.dart' show stemLogger; +import 'package:stem_postgres/src/database/datasource.dart'; + /// Runs the registered seeders using an existing ORM connection. Future runSeedRegistryOnConnection( OrmConnection connection, @@ -103,8 +104,7 @@ Future runSeedRegistryEntrypoint({ ); } - ensurePostgresDriverRegistration(); - final dataSource = DataSource.fromConfig(config, logger: stemLogger); + final dataSource = createDataSourceFromConfig(config, logger: stemLogger); await dataSource.init(); try { final requested = diff --git a/packages/stem_postgres/lib/src/workflow/postgres_workflow_store.dart b/packages/stem_postgres/lib/src/workflow/postgres_workflow_store.dart index 041e2e7b..bf9c5bf6 100644 --- a/packages/stem_postgres/lib/src/workflow/postgres_workflow_store.dart +++ b/packages/stem_postgres/lib/src/workflow/postgres_workflow_store.dart @@ -99,13 +99,17 @@ class PostgresWorkflowStore implements WorkflowStore { @override Future createRun({ + String? runId, required String workflow, required Map params, String? parentRunId, Duration? ttl, WorkflowCancellationPolicy? cancellationPolicy, }) async { - final id = _uuid.v7(); + final id = + (runId != null && runId.trim().isNotEmpty) + ? runId.trim() + : _uuid.v7(); final now = _clock.now().toUtc(); final workflowName = workflow; diff --git a/packages/stem_postgres/lib/src/workflow/postgres_workflow_store_new.dart b/packages/stem_postgres/lib/src/workflow/postgres_workflow_store_new.dart index ac88fec5..262c467b 100644 --- a/packages/stem_postgres/lib/src/workflow/postgres_workflow_store_new.dart +++ b/packages/stem_postgres/lib/src/workflow/postgres_workflow_store_new.dart @@ -74,13 +74,17 @@ class PostgresWorkflowStore implements WorkflowStore { @override Future createRun({ + String? runId, required String workflow, required Map params, String? parentRunId, Duration? ttl, WorkflowCancellationPolicy? cancellationPolicy, }) async { - final id = _uuid.v7(); + final id = + (runId != null && runId.trim().isNotEmpty) + ? runId.trim() + : _uuid.v7(); final now = _clock.now().toUtc(); await _connections.runInTransaction((ctx) async { diff --git a/packages/stem_postgres/pubspec.yaml b/packages/stem_postgres/pubspec.yaml index 5cc37d18..742045af 100644 --- a/packages/stem_postgres/pubspec.yaml +++ b/packages/stem_postgres/pubspec.yaml @@ -1,6 +1,6 @@ name: stem_postgres description: Postgres broker, result backend, and scheduler utilities for Stem. -version: 0.1.0 +version: 0.1.1 repository: https://github.com/kingwill101/stem resolution: workspace environment: @@ -10,17 +10,17 @@ dependencies: artisanal: ^0.2.0 collection: ^1.19.1 contextual: ^2.2.0 - ormed: ^0.1.0 - ormed_postgres: ^0.1.0 + ormed: ^0.2.0 + ormed_postgres: ^0.2.0 path: ^1.9.1 postgres: ^3.5.9 - stem: ^0.1.0 + stem: ^0.1.1 uuid: ^4.5.2 dev_dependencies: build_runner: ^2.10.5 coverage: ^1.15.0 lints: ^6.0.0 - stem_adapter_tests: ^0.1.0 + stem_adapter_tests: ^0.1.1 test: ^1.29.0 very_good_analysis: ^10.0.0 diff --git a/packages/stem_postgres/test/support/postgres_test_harness.dart b/packages/stem_postgres/test/support/postgres_test_harness.dart index a03c1f4d..a2b4af25 100644 --- a/packages/stem_postgres/test/support/postgres_test_harness.dart +++ b/packages/stem_postgres/test/support/postgres_test_harness.dart @@ -34,8 +34,6 @@ Future createStemPostgresTestHarness({ required String connectionString, bool? logging, }) async { - ensurePostgresDriverRegistration(); - final enableLogging = logging ?? Platform.environment['STEM_TEST_POSTGRES_LOGGING'] == 'true'; final dataSource = createDataSource( diff --git a/packages/stem_redis/CHANGELOG.md b/packages/stem_redis/CHANGELOG.md index 48f65074..6024c30f 100644 --- a/packages/stem_redis/CHANGELOG.md +++ b/packages/stem_redis/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.1.1 +- Updated the Redis workflow store to honor caller-provided run ids, matching + the runtime metadata/manifests contract used by the core workflow views. +- Rejected duplicate caller-provided workflow run ids atomically so existing + run and checkpoint state is preserved on collisions. - Enabled broadcast fan-out broker contract coverage in Redis integration tests by wiring additional broker instances for shared-namespace fan-out checks. diff --git a/packages/stem_redis/lib/src/workflow/redis_workflow_store.dart b/packages/stem_redis/lib/src/workflow/redis_workflow_store.dart index f101cff9..7069ded3 100644 --- a/packages/stem_redis/lib/src/workflow/redis_workflow_store.dart +++ b/packages/stem_redis/lib/src/workflow/redis_workflow_store.dart @@ -234,6 +234,32 @@ if watcher['topicSetKey'] then redis.call('SREM', watcher['topicSetKey'], runId) end redis.call('ZREM', dueKey, runId) +return 1 +'''; + + static const _luaCreateRun = ''' +local runKey = KEYS[1] +local stepsKey = KEYS[2] +local orderKey = KEYS[3] + +if redis.call('EXISTS', runKey) == 1 then + return 0 +end + +redis.call('DEL', stepsKey, orderKey) +redis.call('HSET', runKey, + 'workflow', ARGV[1], + 'status', ARGV[2], + 'params', ARGV[3], + 'created_at', ARGV[4], + 'updated_at', ARGV[5], + 'owner_id', ARGV[6], + 'lease_expires_at', ARGV[7]) + +if ARGV[8] ~= '' then + redis.call('HSET', runKey, 'cancellation_policy', ARGV[8]) +end + return 1 '''; @@ -322,6 +348,7 @@ return 1 @override Future createRun({ + String? runId, required String workflow, required Map params, String? parentRunId, @@ -330,32 +357,31 @@ return 1 }) async { final now = _clock.now(); final nowIso = now.toIso8601String(); - final id = 'wf-${now.microsecondsSinceEpoch}-${_idCounter++}'; - final command = [ - 'HSET', + final id = + (runId != null && runId.trim().isNotEmpty) + ? runId.trim() + : 'wf-${now.microsecondsSinceEpoch}-${_idCounter++}'; + final result = await _send([ + 'EVAL', + _luaCreateRun, + '3', _runKey(id), - 'workflow', + _stepsKey(id), + _orderKey(id), workflow, - 'status', WorkflowStatus.running.name, - 'params', jsonEncode(params), - 'created_at', nowIso, - 'updated_at', nowIso, - 'owner_id', '', - 'lease_expires_at', '', - ]; - if (cancellationPolicy != null && !cancellationPolicy.isEmpty) { - command - ..add('cancellation_policy') - ..add(jsonEncode(cancellationPolicy.toJson())); + cancellationPolicy != null && !cancellationPolicy.isEmpty + ? jsonEncode(cancellationPolicy.toJson()) + : '', + ]); + if (result != 1 && result != '1') { + throw StateError('Workflow run "$id" already exists.'); } - await _send(command); - await _send(['DEL', _stepsKey(id), _orderKey(id)]); return id; } diff --git a/packages/stem_redis/pubspec.yaml b/packages/stem_redis/pubspec.yaml index 47dbc6fb..e2847326 100644 --- a/packages/stem_redis/pubspec.yaml +++ b/packages/stem_redis/pubspec.yaml @@ -10,12 +10,12 @@ dependencies: async: ^2.13.0 collection: ^1.19.1 redis: ^4.0.0 - stem: ^0.1.0 + stem: ^0.1.1 uuid: ^4.5.2 dev_dependencies: coverage: ^1.15.0 lints: ^6.0.0 - stem_adapter_tests: ^0.1.0 + stem_adapter_tests: ^0.1.1 test: ^1.29.0 very_good_analysis: ^10.0.0 diff --git a/packages/stem_redis/test/chaos/worker_resilience_test.dart b/packages/stem_redis/test/chaos/worker_resilience_test.dart index f6da6574..10d17f24 100644 --- a/packages/stem_redis/test/chaos/worker_resilience_test.dart +++ b/packages/stem_redis/test/chaos/worker_resilience_test.dart @@ -17,7 +17,7 @@ void main() { final succeeded = Completer(); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( InlineTaskHandler( name: 'chaos.resilience', diff --git a/packages/stem_sqlite/CHANGELOG.md b/packages/stem_sqlite/CHANGELOG.md index 238fa2a4..649766dd 100644 --- a/packages/stem_sqlite/CHANGELOG.md +++ b/packages/stem_sqlite/CHANGELOG.md @@ -2,6 +2,16 @@ ## 0.1.1 +- Updated Ormed dependencies to 0.2.0, including the new split + `ormed_sqlite_core` runtime dependency. +- Simplified SQLite datasource bootstrapping and migration tests to use the new + Ormed SQLite code-first datasource helpers. +- Removed explicit `ensureSqliteDriverRegistration()` calls from Stem SQLite + runtime and seed paths by routing config-driven datasource creation through + the new helper-based bootstrap code. +- Updated the SQLite workflow store to honor caller-provided run ids, keeping + local workflow runtime metadata/manifests behavior aligned with the shared + store contract suite. - Added broker broadcast fan-out support for SQLite routing subscriptions with broadcast channels. - Enabled broadcast fan-out broker contract coverage for the SQLite adapter. diff --git a/packages/stem_sqlite/lib/src/connection.dart b/packages/stem_sqlite/lib/src/connection.dart index e4c0e2ea..6e51ce71 100644 --- a/packages/stem_sqlite/lib/src/connection.dart +++ b/packages/stem_sqlite/lib/src/connection.dart @@ -75,14 +75,10 @@ Future _openDataSource(File file, {required bool readOnly}) async { file.parent.createSync(recursive: true); } - ensureSqliteDriverRegistration(); - final driver = SqliteDriverAdapter.file(file.path); - final registry = buildOrmRegistry(); - final dataSource = DataSource( - DataSourceOptions(driver: driver, registry: registry, database: file.path), - ); + final dataSource = buildOrmRegistry().sqliteFileDataSource(path: file.path); await dataSource.init(); if (!readOnly) { + final driver = dataSource.connection.driver; await driver.executeRaw('PRAGMA journal_mode=WAL;'); await driver.executeRaw('PRAGMA synchronous=NORMAL;'); } @@ -94,7 +90,6 @@ Future _runMigrations(File file) async { file.parent.createSync(recursive: true); } - ensureSqliteDriverRegistration(); final adapter = SqliteDriverAdapter.file(file.path); try { final ledger = SqlMigrationLedger(adapter, tableName: 'orm_migrations'); @@ -112,7 +107,6 @@ Future _runMigrations(File file) async { } Future _runMigrationsForDataSource(DataSource dataSource) async { - ensureSqliteDriverRegistration(); if (!dataSource.isInitialized) { await dataSource.init(); } diff --git a/packages/stem_sqlite/lib/src/database/datasource.dart b/packages/stem_sqlite/lib/src/database/datasource.dart index af1a1d90..30a70ff7 100644 --- a/packages/stem_sqlite/lib/src/database/datasource.dart +++ b/packages/stem_sqlite/lib/src/database/datasource.dart @@ -9,8 +9,6 @@ DataSource createDataSource({ bool logging = false, contextual.Logger? logger, }) { - ensureSqliteDriverRegistration(); - var config = loadOrmConfig(); if (logging) { config = config.updateActiveConnection( @@ -19,9 +17,33 @@ DataSource createDataSource({ ), ); } - return DataSource.fromConfig( - config, - registry: bootstrapOrm(), - logger: logger ?? stemLogger, - ); + return createDataSourceFromConfig(config, logger: logger ?? stemLogger); +} + +/// Creates a new DataSource instance using a resolved ORM project config. +DataSource createDataSourceFromConfig( + OrmProjectConfig config, { + contextual.Logger? logger, +}) { + final registry = bootstrapOrm(); + final options = Map.from(config.driver.options); + final database = + options['database']?.toString() ?? + options['path']?.toString() ?? + 'database.sqlite'; + final dataSourceOptions = database == ':memory:' + ? registry.sqliteInMemoryDataSourceOptions( + name: config.activeConnectionName, + logging: options['logging'] == true, + tablePrefix: options['table_prefix']?.toString() ?? '', + defaultSchema: options['default_schema']?.toString(), + ) + : registry.sqliteFileDataSourceOptions( + path: database, + name: config.activeConnectionName, + logging: options['logging'] == true, + tablePrefix: options['table_prefix']?.toString() ?? '', + defaultSchema: options['default_schema']?.toString(), + ); + return DataSource(dataSourceOptions.copyWith(logger: logger)); } diff --git a/packages/stem_sqlite/lib/src/database/seed_runtime.dart b/packages/stem_sqlite/lib/src/database/seed_runtime.dart index 4d8f1c83..90abb196 100644 --- a/packages/stem_sqlite/lib/src/database/seed_runtime.dart +++ b/packages/stem_sqlite/lib/src/database/seed_runtime.dart @@ -4,9 +4,10 @@ import 'dart:io'; import 'package:artisanal/args.dart'; import 'package:ormed/ormed.dart'; -import 'package:ormed_sqlite/ormed_sqlite.dart'; import 'package:stem/stem.dart' show stemLogger; +import 'package:stem_sqlite/src/database/datasource.dart'; + /// Runs the registered seeders using an existing ORM connection. Future runSeedRegistryOnConnection( OrmConnection connection, @@ -103,8 +104,7 @@ Future runSeedRegistryEntrypoint({ ); } - ensureSqliteDriverRegistration(); - final dataSource = DataSource.fromConfig(config, logger: stemLogger); + final dataSource = createDataSourceFromConfig(config, logger: stemLogger); await dataSource.init(); try { final requested = diff --git a/packages/stem_sqlite/lib/src/workflow/sqlite_workflow_store.dart b/packages/stem_sqlite/lib/src/workflow/sqlite_workflow_store.dart index 3fa9e001..02ed45d4 100644 --- a/packages/stem_sqlite/lib/src/workflow/sqlite_workflow_store.dart +++ b/packages/stem_sqlite/lib/src/workflow/sqlite_workflow_store.dart @@ -82,6 +82,7 @@ class SqliteWorkflowStore implements WorkflowStore { @override Future createRun({ + String? runId, required String workflow, required Map params, String? parentRunId, @@ -89,7 +90,10 @@ class SqliteWorkflowStore implements WorkflowStore { WorkflowCancellationPolicy? cancellationPolicy, }) async { final now = _clock.now().toUtc(); - final id = 'wf-${now.microsecondsSinceEpoch}-${_idCounter++}'; + final id = + (runId != null && runId.trim().isNotEmpty) + ? runId.trim() + : 'wf-${now.microsecondsSinceEpoch}-${_idCounter++}'; final policyJson = cancellationPolicy == null || cancellationPolicy.isEmpty ? null : jsonEncode(cancellationPolicy.toJson()); diff --git a/packages/stem_sqlite/pubspec.yaml b/packages/stem_sqlite/pubspec.yaml index 2b00ebd6..cad34eb3 100644 --- a/packages/stem_sqlite/pubspec.yaml +++ b/packages/stem_sqlite/pubspec.yaml @@ -11,16 +11,16 @@ dependencies: collection: ^1.19.1 contextual: ^2.2.0 meta: ^1.18.0 - ormed: ^0.1.0 - ormed_sqlite: ^0.1.0 + ormed: ^0.2.0 + ormed_sqlite: ^0.2.0 path: ^1.9.1 - stem: ^0.1.0 + stem: ^0.1.1 uuid: ^4.5.2 dev_dependencies: build_runner: ^2.10.5 coverage: ^1.15.0 lints: ^6.0.0 - stem_adapter_tests: ^0.1.0 + stem_adapter_tests: ^0.1.1 test: ^1.29.0 very_good_analysis: ^10.0.0 diff --git a/packages/stem_sqlite/test/backend/sqlite_result_backend_test.dart b/packages/stem_sqlite/test/backend/sqlite_result_backend_test.dart index e759c848..c384643c 100644 --- a/packages/stem_sqlite/test/backend/sqlite_result_backend_test.dart +++ b/packages/stem_sqlite/test/backend/sqlite_result_backend_test.dart @@ -1,6 +1,5 @@ import 'dart:io'; -import 'package:ormed/ormed.dart'; import 'package:ormed_sqlite/ormed_sqlite.dart'; import 'package:stem/stem.dart'; import 'package:stem_adapter_tests/stem_adapter_tests.dart'; @@ -49,13 +48,8 @@ void main() { ); test('fromDataSource runs migrations', () async { - ensureSqliteDriverRegistration(); - final dataSource = DataSource( - DataSourceOptions( - driver: SqliteDriverAdapter.file(dbFile.path), - registry: buildOrmRegistry(), - database: dbFile.path, - ), + final dataSource = buildOrmRegistry().sqliteFileDataSource( + path: dbFile.path, ); final backend = await SqliteResultBackend.fromDataSource( dataSource, diff --git a/packages/stem_sqlite/test/broker/sqlite_broker_test.dart b/packages/stem_sqlite/test/broker/sqlite_broker_test.dart index f2b2f2dd..3f4e63e6 100644 --- a/packages/stem_sqlite/test/broker/sqlite_broker_test.dart +++ b/packages/stem_sqlite/test/broker/sqlite_broker_test.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:io'; -import 'package:ormed/ormed.dart'; import 'package:ormed_sqlite/ormed_sqlite.dart'; import 'package:stem/stem.dart'; import 'package:stem_adapter_tests/stem_adapter_tests.dart'; @@ -73,13 +72,8 @@ void main() { ); test('fromDataSource runs migrations', () async { - ensureSqliteDriverRegistration(); - final dataSource = DataSource( - DataSourceOptions( - driver: SqliteDriverAdapter.file(dbFile.path), - registry: buildOrmRegistry(), - database: dbFile.path, - ), + final dataSource = buildOrmRegistry().sqliteFileDataSource( + path: dbFile.path, ); final broker = await SqliteBroker.fromDataSource( dataSource, diff --git a/packages/stem_sqlite/test/control/sqlite_revoke_store_test.dart b/packages/stem_sqlite/test/control/sqlite_revoke_store_test.dart index d59030b5..359cfbd2 100644 --- a/packages/stem_sqlite/test/control/sqlite_revoke_store_test.dart +++ b/packages/stem_sqlite/test/control/sqlite_revoke_store_test.dart @@ -1,6 +1,5 @@ import 'dart:io'; -import 'package:ormed/ormed.dart'; import 'package:ormed_sqlite/ormed_sqlite.dart'; import 'package:stem/stem.dart'; import 'package:stem_adapter_tests/stem_adapter_tests.dart'; @@ -29,13 +28,8 @@ void main() { ); test('fromDataSource runs migrations', () async { - ensureSqliteDriverRegistration(); - final dataSource = DataSource( - DataSourceOptions( - driver: SqliteDriverAdapter.file(dbFile.path), - registry: buildOrmRegistry(), - database: dbFile.path, - ), + final dataSource = buildOrmRegistry().sqliteFileDataSource( + path: dbFile.path, ); final store = await SqliteRevokeStore.fromDataSource(dataSource); try { diff --git a/packages/stem_sqlite/test/workflow/sqlite_workflow_store_test.dart b/packages/stem_sqlite/test/workflow/sqlite_workflow_store_test.dart index 7a0f8da4..2be20231 100644 --- a/packages/stem_sqlite/test/workflow/sqlite_workflow_store_test.dart +++ b/packages/stem_sqlite/test/workflow/sqlite_workflow_store_test.dart @@ -1,6 +1,5 @@ import 'dart:io'; -import 'package:ormed/ormed.dart'; import 'package:ormed_sqlite/ormed_sqlite.dart'; import 'package:stem/stem.dart'; import 'package:stem_sqlite/stem_sqlite.dart'; @@ -25,13 +24,8 @@ void main() { }); test('fromDataSource runs migrations', () async { - ensureSqliteDriverRegistration(); - final dataSource = DataSource( - DataSourceOptions( - driver: SqliteDriverAdapter.file(dbFile.path), - registry: buildOrmRegistry(), - database: dbFile.path, - ), + final dataSource = buildOrmRegistry().sqliteFileDataSource( + path: dbFile.path, ); final store = await SqliteWorkflowStore.fromDataSource(dataSource); try {
StepCheckpoint Position Completed Value
Run ID WorkflowLast stepLast checkpoint Queued Running Succeeded