From 889d791d15debe50397fa3f2da27272cf3e17ad2 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 26 Feb 2026 17:34:17 -0500 Subject: [PATCH 01/23] feat(stem): add workflow runtime metadata views and manifests --- .../workflow_runtime_features_example.dart | 3 + .../workflows/runtime_metadata_views.dart | 104 ++++++++ packages/stem/lib/src/core/contracts.dart | 117 +++++++++ .../stem/lib/src/workflow/core/run_state.dart | 39 +++ .../workflow/core/workflow_definition.dart | 38 ++- .../core/workflow_runtime_metadata.dart | 170 +++++++++++++ .../src/workflow/core/workflow_script.dart | 3 + .../workflow/core/workflow_step_entry.dart | 14 ++ .../lib/src/workflow/core/workflow_store.dart | 1 + .../runtime/workflow_introspection.dart | 53 ++++ .../workflow/runtime/workflow_manifest.dart | 151 +++++++++++ .../workflow/runtime/workflow_runtime.dart | 235 +++++++++++++++++- .../src/workflow/runtime/workflow_views.dart | 177 +++++++++++++ packages/stem/lib/src/workflow/workflow.dart | 3 + .../stem/test/unit/core/contracts_test.dart | 30 +++ .../workflow/in_memory_event_bus_test.dart | 1 + .../unit/workflow/workflow_manifest_test.dart | 66 +++++ .../workflow_metadata_views_test.dart | 54 ++++ .../test/workflow/workflow_runtime_test.dart | 76 ++++++ .../src/workflow_store_contract_suite.dart | 66 +++++ .../store/in_memory_workflow_store.dart | 6 +- .../src/workflow/postgres_workflow_store.dart | 6 +- .../workflow/postgres_workflow_store_new.dart | 6 +- .../src/workflow/redis_workflow_store.dart | 6 +- .../src/workflow/sqlite_workflow_store.dart | 6 +- 25 files changed, 1414 insertions(+), 17 deletions(-) create mode 100644 packages/stem/example/workflow_runtime_features_example.dart create mode 100644 packages/stem/example/workflows/runtime_metadata_views.dart create mode 100644 packages/stem/lib/src/workflow/core/workflow_runtime_metadata.dart create mode 100644 packages/stem/lib/src/workflow/runtime/workflow_manifest.dart create mode 100644 packages/stem/lib/src/workflow/runtime/workflow_views.dart create mode 100644 packages/stem/test/unit/workflow/workflow_manifest_test.dart 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/runtime_metadata_views.dart b/packages/stem/example/workflows/runtime_metadata_views.dart new file mode 100644 index 00000000..9e098ae7 --- /dev/null +++ b/packages/stem/example/workflows/runtime_metadata_views.dart @@ -0,0 +1,104 @@ +// 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 broker = InMemoryBroker(); + final backend = InMemoryResultBackend(); + final registry = SimpleTaskRegistry(); + final stem = Stem(broker: broker, registry: registry, backend: backend); + final store = InMemoryWorkflowStore(); + final runtime = WorkflowRuntime( + stem: stem, + store: store, + eventBus: InMemoryEventBus(store), + queue: 'workflow', + continuationQueue: 'workflow-continue', + executionQueue: 'workflow-step', + ); + + registry + ..register(runtime.workflowRunnerHandler()) + ..register( + FunctionTaskHandler.inline( + name: 'example.noop', + entrypoint: (context, args) async => null, + ), + ); + + 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 backend.close(); + broker.dispose(); + } +} diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index 515cfe55..c7a713d5 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()); 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..2eba6e48 100644 --- a/packages/stem/lib/src/workflow/core/workflow_definition.dart +++ b/packages/stem/lib/src/workflow/core/workflow_definition.dart @@ -54,6 +54,7 @@ library; import 'dart:async'; +import 'dart:convert'; import 'package:stem/src/workflow/core/flow.dart' show Flow; import 'package:stem/src/workflow/core/flow_context.dart'; @@ -111,7 +112,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(), @@ -158,6 +159,7 @@ class WorkflowDefinition { factory WorkflowDefinition.script({ required String name, required WorkflowScriptBody run, + Iterable steps = const [], String? version, String? description, Map? metadata, @@ -165,7 +167,7 @@ class WorkflowDefinition { return WorkflowDefinition._( name: name, kind: WorkflowDefinitionKind.script, - steps: const [], + steps: List.unmodifiable(steps), version: version, description: description, metadata: metadata, @@ -200,6 +202,27 @@ class WorkflowDefinition { /// Whether this definition represents a script-based workflow. bool get isScript => _kind == WorkflowDefinitionKind.script; + /// 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 +243,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]. 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..4bf36546 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script.dart @@ -1,4 +1,5 @@ import 'package:stem/src/workflow/core/workflow_definition.dart'; +import 'package:stem/src/workflow/core/flow_step.dart'; /// High-level workflow facade that allows scripts to be authored as a single /// async function using `step`, `sleep`, and `awaitEvent` helpers. @@ -7,12 +8,14 @@ class WorkflowScript { WorkflowScript({ required String name, required WorkflowScriptBody run, + Iterable steps = const [], String? version, String? description, Map? metadata, }) : definition = WorkflowDefinition.script( name: name, run: run, + steps: steps, version: version, description: description, metadata: metadata, 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..b7c700d9 --- /dev/null +++ b/packages/stem/lib/src/workflow/runtime/workflow_manifest.dart @@ -0,0 +1,151 @@ +import 'dart:convert'; + +import 'package:stem/src/workflow/core/flow_step.dart'; +import 'package:stem/src/workflow/core/workflow_definition.dart'; + +/// 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; + + /// Step manifest entries (flow workflows only). + final List steps; + + /// Serializes this entry to a JSON-compatible map. + Map toJson() { + return { + 'id': id, + 'name': name, + 'kind': kind.name, + if (version != null) 'version': version, + if (description != null) 'description': description, + if (metadata != null) 'metadata': metadata, + 'steps': steps.map((step) => step.toJson()).toList(growable: false), + }; + } +} + +/// Immutable manifest entry describing a workflow step. +class WorkflowManifestStep { + /// Creates a workflow step manifest entry. + const WorkflowManifestStep({ + required this.id, + required this.name, + required this.position, + 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; + + /// 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, + '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, + 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..456078d4 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart @@ -40,11 +40,14 @@ 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_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 +74,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 +87,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 +113,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 +139,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 +164,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,7 +194,13 @@ class WorkflowRuntime { status: WorkflowRunStatus.running, ), ); - await _enqueueRun(runId, workflow: name); + await _enqueueRun( + runId, + workflow: name, + continuation: false, + reason: WorkflowContinuationReason.start, + runtimeMetadata: runtimeMetadata, + ); return runId; } @@ -180,7 +228,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; @@ -204,7 +258,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 +295,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 @@ -402,7 +509,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 +518,7 @@ class WorkflowRuntime { enqueuer: _stepEnqueuer( taskContext: taskContext, baseMeta: stepMeta, + targetExecutionQueue: runState.executionQueue, ), ); resumeData = null; @@ -668,6 +776,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 +903,65 @@ 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 targetQueue = continuation ? continuationQueue : queue; final meta = { + 'stem.workflow.channel': WorkflowChannelKind.orchestration.name, 'stem.workflow.runId': runId, + 'stem.workflow.continuation': continuation, + 'stem.workflow.continuationReason': reason.name, + 'stem.workflow.orchestrationQueue': queue, + 'stem.workflow.continuationQueue': continuationQueue, + 'stem.workflow.executionQueue': executionQueue, + '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), ); } @@ -795,12 +972,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 +998,17 @@ 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, + ), + ); } /// Returns true when a cancellation policy triggers a terminal cancel. @@ -972,7 +1170,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; @@ -1073,6 +1271,7 @@ class _WorkflowScriptExecution implements WorkflowScriptContext { enqueuer: runtime._stepEnqueuer( taskContext: taskContext, baseMeta: stepMeta, + targetExecutionQueue: runState.executionQueue, ), ); T result; @@ -1350,10 +1549,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 +1567,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 +1587,17 @@ 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( 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..bc016527 100644 --- a/packages/stem/lib/src/workflow/workflow.dart +++ b/packages/stem/lib/src/workflow/workflow.dart @@ -10,6 +10,7 @@ export 'core/workflow_cancellation_policy.dart'; export 'core/workflow_clock.dart'; export 'core/workflow_definition.dart'; export 'core/workflow_result.dart'; +export 'core/workflow_runtime_metadata.dart'; export 'core/workflow_script.dart'; export 'core/workflow_script_context.dart'; export 'core/workflow_status.dart'; @@ -17,5 +18,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/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/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..e5fcba45 --- /dev/null +++ b/packages/stem/test/unit/workflow/workflow_manifest_test.dart @@ -0,0 +1,66 @@ +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.steps, hasLength(2)); + expect(manifest.steps.first.position, equals(0)); + expect(manifest.steps.first.name, equals('first')); + expect(manifest.steps.first.id, isNotEmpty); + expect(manifest.steps.first.id, isNot(equals(manifest.steps.last.id))); + }); + + test('script workflows can publish declared step metadata', () { + final definition = WorkflowScript>( + name: 'manifest.script', + run: (script) async { + final email = script.params['email'] as String; + return {'email': email, 'status': 'done'}; + }, + steps: [ + 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.steps, hasLength(2)); + expect(manifest.steps.first.name, equals('create-user')); + expect(manifest.steps.first.position, equals(0)); + 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/workflow/workflow_runtime_test.dart b/packages/stem/test/workflow/workflow_runtime_test.dart index 7cff2c47..07527c36 100644 --- a/packages/stem/test/workflow/workflow_runtime_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_test.dart @@ -61,6 +61,76 @@ 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( @@ -918,9 +988,15 @@ 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); + } } 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..3ecf7f7e 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 @@ -66,6 +66,72 @@ void runWorkflowStoreContractTests({ expect(state.params['user'], 1); }); + test('createRun honors caller-provided runId when supplied', () async { + final current = store!; + const requestedRunId = 'contract-run-id'; + 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 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_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..1ab591f3 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,10 @@ 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++}'; _runs[id] = RunState( id: id, workflow: workflow, 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_redis/lib/src/workflow/redis_workflow_store.dart b/packages/stem_redis/lib/src/workflow/redis_workflow_store.dart index f101cff9..266d0a60 100644 --- a/packages/stem_redis/lib/src/workflow/redis_workflow_store.dart +++ b/packages/stem_redis/lib/src/workflow/redis_workflow_store.dart @@ -322,6 +322,7 @@ return 1 @override Future createRun({ + String? runId, required String workflow, required Map params, String? parentRunId, @@ -330,7 +331,10 @@ return 1 }) async { final now = _clock.now(); final nowIso = now.toIso8601String(); - final id = 'wf-${now.microsecondsSinceEpoch}-${_idCounter++}'; + final id = + (runId != null && runId.trim().isNotEmpty) + ? runId.trim() + : 'wf-${now.microsecondsSinceEpoch}-${_idCounter++}'; final command = [ 'HSET', _runKey(id), 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()); From aa6b9ed238e14b14fb9946b4c4d62037ded4be1f Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 26 Feb 2026 17:34:46 -0500 Subject: [PATCH 02/23] feat(stem_builder): generate typed workflow starters and app helpers --- packages/stem_builder/README.md | 33 +- packages/stem_builder/example/README.md | 32 + packages/stem_builder/example/bin/main.dart | 37 + .../example/bin/runtime_metadata_views.dart | 56 ++ .../stem_builder/example/lib/definitions.dart | 37 + .../example/lib/stem_registry.g.dart | 220 +++++ packages/stem_builder/example/pubspec.yaml | 22 + .../lib/src/stem_registry_builder.dart | 812 ++++++++++++++++-- .../test/stem_registry_builder_test.dart | 291 ++++++- 9 files changed, 1468 insertions(+), 72 deletions(-) create mode 100644 packages/stem_builder/example/README.md create mode 100644 packages/stem_builder/example/bin/main.dart create mode 100644 packages/stem_builder/example/bin/runtime_metadata_views.dart create mode 100644 packages/stem_builder/example/lib/definitions.dart create mode 100644 packages/stem_builder/example/lib/stem_registry.g.dart create mode 100644 packages/stem_builder/example/pubspec.yaml diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index a2da43fe..8407260a 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -30,23 +30,39 @@ Annotate workflows and tasks: ```dart import 'package:stem/stem.dart'; -@workflow.defn(name: 'hello.flow') +@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 { + @WorkflowRun() + 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 { // ... } ``` +`@WorkflowRun` may optionally take `WorkflowScriptContext` as its first +parameter, followed by required positional serializable parameters. + Run build_runner to generate `lib/stem_registry.g.dart`: ```bash @@ -55,3 +71,12 @@ dart run build_runner build The generated registry exports `registerStemDefinitions` to register annotated flows, scripts, and tasks with your `WorkflowRegistry` and `TaskRegistry`. +It also emits typed starters so you can avoid raw workflow-name strings, for +example `runtime.startHelloScript(email: 'user@example.com')`. + +## Examples + +See [`example/README.md`](example/README.md) for runnable examples, including: + +- Generated registration + execution with `StemWorkflowApp` +- Runtime manifest + run detail views with `WorkflowRuntime` diff --git a/packages/stem_builder/example/README.md b/packages/stem_builder/example/README.md new file mode 100644 index 00000000..8557680c --- /dev/null +++ b/packages/stem_builder/example/README.md @@ -0,0 +1,32 @@ +# stem_builder example + +This example demonstrates: + +- Annotated workflow/task definitions +- Generated `registerStemDefinitions(...)` +- Generated app bootstrap helpers: + - `createStemGeneratedInMemoryApp()` + - `createStemGeneratedWorkflowApp(stemApp: ...)` +- Generated typed workflow starters (no manual workflow-name strings): + - `runtime.startBuilderExampleFlow(...)` + - `runtime.startBuilderExampleUserSignup(email: ...)` +- Generated `stemWorkflowManifest` +- 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/stem_registry.g.dart` is only a starter snapshot; rerun +`build_runner` after changing annotations. diff --git a/packages/stem_builder/example/bin/main.dart b/packages/stem_builder/example/bin/main.dart new file mode 100644 index 00000000..6941475a --- /dev/null +++ b/packages/stem_builder/example/bin/main.dart @@ -0,0 +1,37 @@ +import 'dart:convert'; + +import 'package:stem_builder_example/stem_registry.g.dart'; + +Future main() async { + print('Registered workflows:'); + for (final entry in stemWorkflowManifest) { + print(' - ${entry.name} (id=${entry.id})'); + } + + print('\nGenerated workflow manifest:'); + print( + const JsonEncoder.withIndent( + ' ', + ).convert(stemWorkflowManifest.map((entry) => entry.toJson()).toList()), + ); + + final app = await createStemGeneratedInMemoryApp(); + 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 runtime.startBuilderExampleFlow( + params: const {'name': 'Stem Builder'}, + ); + await runtime.executeRun(runId); + final result = await runtime.viewRun(runId); + print('\nFlow result: ${result?.result}'); + } 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..8e5e658a --- /dev/null +++ b/packages/stem_builder/example/bin/runtime_metadata_views.dart @@ -0,0 +1,56 @@ +import 'dart:convert'; + +import 'package:stem_builder_example/stem_registry.g.dart'; + +Future main() async { + final app = await createStemGeneratedInMemoryApp(); + final runtime = app.runtime; + + try { + print('--- Generated manifest (builder output) ---'); + print( + const JsonEncoder.withIndent( + ' ', + ).convert(stemWorkflowManifest.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 runtime.startBuilderExampleFlow( + params: const {'name': 'runtime metadata'}, + ); + await runtime.executeRun(flowRunId); + + final scriptRunId = await runtime.startBuilderExampleUserSignup( + email: 'dev@stem.dev', + ); + 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..0fa210bf --- /dev/null +++ b/packages/stem_builder/example/lib/definitions.dart @@ -0,0 +1,37 @@ +import 'package:stem/stem.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 { + @WorkflowRun() + 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/stem_registry.g.dart b/packages/stem_builder/example/lib/stem_registry.g.dart new file mode 100644 index 00000000..6be3c64b --- /dev/null +++ b/packages/stem_builder/example/lib/stem_registry.g.dart @@ -0,0 +1,220 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: unused_element, unnecessary_lambdas, omit_local_variable_types, unused_import + +import 'dart:async'; +import 'package:stem/stem.dart'; +import 'package:stem_builder_example/definitions.dart' as stemLib0; + +final List stemFlows = [ + Flow( + name: "builder.example.flow", + build: (flow) { + final impl = stemLib0.BuilderExampleFlow(); + flow.step( + "greet", + (ctx) => impl.greet((_stemRequireArg(ctx.params, "name") as String)), + kind: WorkflowStepKind.task, + taskNames: [], + ); + }, + ), +]; + +class _StemScriptProxy0 extends stemLib0.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", + steps: [ + 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 StemWorkflowNames { + static const String builderExampleFlow = "builder.example.flow"; + static const String builderExampleUserSignup = "builder.example.user_signup"; +} + +extension StemGeneratedWorkflowAppStarters on StemWorkflowApp { + Future startBuilderExampleFlow({ + Map params = const {}, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return startWorkflow( + StemWorkflowNames.builderExampleFlow, + params: params, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } + + Future startBuilderExampleUserSignup({ + required String email, + Map extraParams = const {}, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + final params = {...extraParams, "email": email}; + return startWorkflow( + StemWorkflowNames.builderExampleUserSignup, + params: params, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } +} + +extension StemGeneratedWorkflowRuntimeStarters on WorkflowRuntime { + Future startBuilderExampleFlow({ + Map params = const {}, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return startWorkflow( + StemWorkflowNames.builderExampleFlow, + params: params, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } + + Future startBuilderExampleUserSignup({ + required String email, + Map extraParams = const {}, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + final params = {...extraParams, "email": email}; + return startWorkflow( + StemWorkflowNames.builderExampleUserSignup, + params: params, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } +} + +final List stemWorkflowManifest = + [ + ...stemFlows.map((flow) => flow.definition.toManifestEntry()), + ...stemScripts.map((script) => script.definition.toManifestEntry()), + ]; + +FutureOr _stemScriptManifestStepNoop(FlowContext context) async => + null; + +Object? _stemRequireArg(Map args, String name) { + if (!args.containsKey(name)) { + throw ArgumentError('Missing required argument "$name".'); + } + return args[name]; +} + +final List> stemTasks = >[ + FunctionTaskHandler( + name: "builder.example.task", + entrypoint: stemLib0.builderExampleTask, + options: const TaskOptions(), + metadata: const TaskMetadata(), + ), +]; + +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); + } +} + +Future createStemGeneratedWorkflowApp({ + required StemApp stemApp, + bool registerTasks = true, + Duration pollInterval = const Duration(milliseconds: 500), + Duration leaseExtension = const Duration(seconds: 30), + WorkflowRegistry? workflowRegistry, + WorkflowIntrospectionSink? introspectionSink, +}) async { + if (registerTasks) { + for (final handler in stemTasks) { + stemApp.register(handler); + } + } + return StemWorkflowApp.create( + stemApp: stemApp, + flows: stemFlows, + scripts: stemScripts, + pollInterval: pollInterval, + leaseExtension: leaseExtension, + workflowRegistry: workflowRegistry, + introspectionSink: introspectionSink, + ); +} + +Future createStemGeneratedInMemoryApp() async { + final stemApp = await StemApp.inMemory(tasks: stemTasks); + return createStemGeneratedWorkflowApp(stemApp: stemApp, registerTasks: false); +} 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..0002b0ab 100644 --- a/packages/stem_builder/lib/src/stem_registry_builder.dart +++ b/packages/stem_builder/lib/src/stem_registry_builder.dart @@ -47,6 +47,10 @@ class StemRegistryBuilder implements Builder { WorkflowScriptContext, inPackage: 'stem', ); + const scriptStepContextChecker = TypeChecker.typeNamed( + WorkflowScriptStepContext, + inPackage: 'stem', + ); const taskContextChecker = TypeChecker.typeNamed( TaskInvocationContext, inPackage: 'stem', @@ -57,6 +61,7 @@ class StemRegistryBuilder implements Builder { final tasks = <_TaskInfo>[]; final importAliases = {}; var importIndex = 0; + var taskAdapterIndex = 0; String importAliasFor(String importPath) { return importAliases.putIfAbsent( @@ -166,12 +171,6 @@ class StemRegistryBuilder implements Builder { 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.', @@ -179,13 +178,60 @@ class StemRegistryBuilder implements Builder { ); } final runMethod = runMethods.single; - _validateRunMethod(runMethod, scriptContextChecker); + final runBinding = _validateRunMethod( + runMethod, + scriptContextChecker, + ); + final scriptSteps = <_WorkflowStepInfo>[]; + for (final method in stepMethods) { + final stepBinding = _validateScriptStepMethod( + method, + scriptStepContextChecker, + ); + final stepAnnotation = workflowStepChecker.firstAnnotationOfExact( + method, + throwOnUnresolved: false, + ); + if (stepAnnotation == null) { + continue; + } + 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')); + scriptSteps.add( + _WorkflowStepInfo( + name: stepName, + method: method.displayName, + acceptsFlowContext: false, + acceptsScriptStepContext: stepBinding.acceptsContext, + valueParameters: stepBinding.valueParameters, + returnTypeCode: stepBinding.returnTypeCode, + stepValueTypeCode: stepBinding.stepValueTypeCode, + autoVersion: autoVersion, + title: title, + kind: kindValue, + taskNames: taskNames, + metadata: stepMetadata, + ), + ); + } workflows.add( _WorkflowInfo.script( name: workflowName, importAlias: importAlias, className: classElement.displayName, + steps: scriptSteps, runMethod: runMethod.displayName, + runAcceptsScriptContext: runBinding.acceptsContext, + runValueParameters: runBinding.valueParameters, version: version, description: description, metadata: metadata, @@ -208,7 +254,10 @@ class StemRegistryBuilder implements Builder { } final steps = <_WorkflowStepInfo>[]; for (final method in stepMethods) { - _validateFlowStepMethod(method, flowContextChecker); + final stepBinding = _validateFlowStepMethod( + method, + flowContextChecker, + ); final stepAnnotation = workflowStepChecker.firstAnnotationOfExact( method, throwOnUnresolved: false, @@ -231,6 +280,11 @@ class StemRegistryBuilder implements Builder { _WorkflowStepInfo( name: stepName, method: method.displayName, + acceptsFlowContext: stepBinding.acceptsContext, + acceptsScriptStepContext: false, + valueParameters: stepBinding.valueParameters, + returnTypeCode: null, + stepValueTypeCode: null, autoVersion: autoVersion, title: title, kind: kindValue, @@ -266,7 +320,11 @@ class StemRegistryBuilder implements Builder { element: function, ); } - _validateTaskFunction(function, taskContextChecker, mapChecker); + final taskBinding = _validateTaskFunction( + function, + taskContextChecker, + mapChecker, + ); final readerAnnotation = ConstantReader(annotation); final taskName = _stringOrNull(readerAnnotation.peek('name')) ?? @@ -283,6 +341,12 @@ class StemRegistryBuilder implements Builder { name: taskName, importAlias: importAlias, function: function.displayName, + adapterName: taskBinding.usesLegacyMapArgs + ? null + : '_stemTaskAdapter${taskAdapterIndex++}', + acceptsTaskContext: taskBinding.acceptsContext, + valueParameters: taskBinding.valueParameters, + usesLegacyMapArgs: taskBinding.usesLegacyMapArgs, options: options, metadata: metadata, runInIsolate: runInIsolate, @@ -319,7 +383,7 @@ class StemRegistryBuilder implements Builder { ); } - static void _validateRunMethod( + static _RunBinding _validateRunMethod( MethodElement method, TypeChecker scriptContextChecker, ) { @@ -329,22 +393,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 parameters after WorkflowScriptContext.', + element: method, + ); + } + if (!_isSerializableValueType(parameter.type)) { + throw InvalidGenerationSourceError( + '@workflow.run method ${method.displayName} parameter "${parameter.displayName}" must use a serializable type.', + element: method, + ); + } + valueParameters.add( + _ValueParameterInfo( + name: parameter.displayName, + typeCode: _typeCode(parameter.type), + ), ); } + + return _RunBinding( + acceptsContext: acceptsContext, + valueParameters: valueParameters, + ); } - static void _validateFlowStepMethod( + static _FlowStepBinding _validateFlowStepMethod( MethodElement method, TypeChecker flowContextChecker, ) { @@ -354,52 +441,159 @@ 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 parameters after FlowContext.', + element: method, + ); + } + if (!_isSerializableValueType(parameter.type)) { + throw InvalidGenerationSourceError( + '@workflow.step method ${method.displayName} parameter "${parameter.displayName}" must use a serializable type.', + element: method, + ); + } + valueParameters.add( + _ValueParameterInfo( + name: parameter.displayName, + typeCode: _typeCode(parameter.type), + ), + ); + } + + return _FlowStepBinding( + acceptsContext: acceptsContext, + valueParameters: valueParameters, + ); + } + + 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 parameters after WorkflowScriptStepContext.', + element: method, + ); + } + if (!_isSerializableValueType(parameter.type)) { + throw InvalidGenerationSourceError( + '@workflow.step method ${method.displayName} parameter "${parameter.displayName}" must use a serializable type.', + element: method, + ); + } + valueParameters.add( + _ValueParameterInfo( + name: parameter.displayName, + typeCode: _typeCode(parameter.type), + ), + ); + } + + return _ScriptStepBinding( + acceptsContext: acceptsContext, + valueParameters: valueParameters, + returnTypeCode: _typeCode(returnType), + stepValueTypeCode: _typeCode(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, - ); - } - if (!mapChecker.isAssignableFromType(args.type)) { - throw InvalidGenerationSourceError( - '@TaskDefn function ${function.displayName} must accept Map as second 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 const _TaskBinding( + acceptsContext: true, + valueParameters: [], + usesLegacyMapArgs: true, ); } - if (!_isStringObjectMap(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 parameters after TaskInvocationContext.', + element: function, + ); + } + if (!_isSerializableValueType(parameter.type)) { + throw InvalidGenerationSourceError( + '@TaskDefn function ${function.displayName} parameter "${parameter.displayName}" must use a serializable type.', + element: function, + ); + } + valueParameters.add( + _ValueParameterInfo( + name: parameter.displayName, + typeCode: _typeCode(parameter.type), + ), ); } + + return _TaskBinding( + acceptsContext: acceptsContext, + valueParameters: valueParameters, + usesLegacyMapArgs: false, + ); } static bool _isStringObjectMap(DartType type) { @@ -413,6 +607,43 @@ 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 _typeCode(DartType type) => type.getDisplayString(); + + static DartType _extractStepValueType(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; @@ -449,18 +680,22 @@ class _WorkflowInfo { 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, this.version, this.description, this.metadata, - }) : kind = WorkflowKind.script, - steps = const []; + }) : kind = WorkflowKind.script; final String name; final WorkflowKind kind; @@ -468,6 +703,8 @@ class _WorkflowInfo { final String className; final List<_WorkflowStepInfo> steps; final String? runMethod; + final bool runAcceptsScriptContext; + final List<_ValueParameterInfo> runValueParameters; final String? version; final String? description; final DartObject? metadata; @@ -477,6 +714,11 @@ 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.autoVersion, required this.title, required this.kind, @@ -486,6 +728,11 @@ 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 bool autoVersion; final String? title; final DartObject? kind; @@ -498,6 +745,10 @@ class _TaskInfo { required this.name, required this.importAlias, required this.function, + required this.adapterName, + required this.acceptsTaskContext, + required this.valueParameters, + required this.usesLegacyMapArgs, required this.options, required this.metadata, required this.runInIsolate, @@ -506,11 +757,71 @@ 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 DartObject? options; final DartObject? metadata; final bool runInIsolate; } +class _FlowStepBinding { + const _FlowStepBinding({ + required this.acceptsContext, + required this.valueParameters, + }); + + final bool acceptsContext; + final List<_ValueParameterInfo> valueParameters; +} + +class _RunBinding { + const _RunBinding({ + required this.acceptsContext, + required this.valueParameters, + }); + + final bool acceptsContext; + final List<_ValueParameterInfo> valueParameters; +} + +class _ScriptStepBinding { + const _ScriptStepBinding({ + required this.acceptsContext, + required this.valueParameters, + required this.returnTypeCode, + required this.stepValueTypeCode, + }); + + final bool acceptsContext; + final List<_ValueParameterInfo> valueParameters; + final String returnTypeCode; + final String stepValueTypeCode; +} + +class _TaskBinding { + const _TaskBinding({ + required this.acceptsContext, + required this.valueParameters, + required this.usesLegacyMapArgs, + }); + + final bool acceptsContext; + final List<_ValueParameterInfo> valueParameters; + final bool usesLegacyMapArgs; +} + +class _ValueParameterInfo { + const _ValueParameterInfo({ + required this.name, + required this.typeCode, + }); + + final String name; + final String typeCode; +} + class _RegistryEmitter { _RegistryEmitter({ required this.workflows, @@ -526,9 +837,10 @@ class _RegistryEmitter { 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', + '// ignore_for_file: unused_element, unnecessary_lambdas, omit_local_variable_types, unused_import', ); buffer.writeln(); + buffer.writeln("import 'dart:async';"); buffer.writeln("import 'package:stem/stem.dart';"); final sortedImports = imports.entries.toList() ..sort((a, b) => a.key.compareTo(b.key)); @@ -538,6 +850,10 @@ class _RegistryEmitter { buffer.writeln(); _emitWorkflows(buffer); + _emitWorkflowStartHelpers(buffer); + _emitManifest(buffer); + _emitGeneratedHelpers(buffer); + _emitTaskAdapters(buffer); _emitTasks(buffer); buffer.writeln('void registerStemDefinitions({'); @@ -554,6 +870,46 @@ class _RegistryEmitter { buffer.writeln(' tasks.register(handler);'); buffer.writeln(' }'); buffer.writeln('}'); + buffer.writeln(); + buffer.writeln('Future createStemGeneratedWorkflowApp({'); + buffer.writeln(' required StemApp stemApp,'); + buffer.writeln(' bool registerTasks = true,'); + buffer.writeln( + ' Duration pollInterval = const Duration(milliseconds: 500),', + ); + buffer.writeln( + ' Duration leaseExtension = const Duration(seconds: 30),', + ); + buffer.writeln(' WorkflowRegistry? workflowRegistry,'); + buffer.writeln(' WorkflowIntrospectionSink? introspectionSink,'); + buffer.writeln('}) async {'); + buffer.writeln(' if (registerTasks) {'); + buffer.writeln(' for (final handler in stemTasks) {'); + buffer.writeln(' stemApp.register(handler);'); + buffer.writeln(' }'); + buffer.writeln(' }'); + buffer.writeln(' return StemWorkflowApp.create('); + buffer.writeln(' stemApp: stemApp,'); + buffer.writeln(' flows: stemFlows,'); + buffer.writeln(' scripts: stemScripts,'); + buffer.writeln(' pollInterval: pollInterval,'); + buffer.writeln(' leaseExtension: leaseExtension,'); + buffer.writeln(' workflowRegistry: workflowRegistry,'); + buffer.writeln(' introspectionSink: introspectionSink,'); + buffer.writeln(' );'); + buffer.writeln('}'); + buffer.writeln(); + buffer.writeln( + 'Future createStemGeneratedInMemoryApp() async {', + ); + buffer.writeln( + ' final stemApp = await StemApp.inMemory(tasks: stemTasks);', + ); + buffer.writeln(' return createStemGeneratedWorkflowApp('); + buffer.writeln(' stemApp: stemApp,'); + buffer.writeln(' registerTasks: false,'); + buffer.writeln(' );'); + buffer.writeln('}'); return buffer.toString(); } @@ -580,9 +936,18 @@ class _RegistryEmitter { ' final impl = ${workflow.importAlias}.${workflow.className}();', ); for (final step in workflow.steps) { + 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('); 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,'); } @@ -610,14 +975,91 @@ 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 ${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 = [', ); - 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(' steps: ['); + for (final step in workflow.steps) { + buffer.writeln(' FlowStep('); + buffer.writeln(' name: ${_string(step.name)},'); + buffer.writeln( + ' handler: _stemScriptManifestStepNoop,', + ); + if (step.autoVersion) { + buffer.writeln(' autoVersion: true,'); + } + 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,23 +1071,196 @@ class _RegistryEmitter { ' metadata: ${_dartObjectToCode(workflow.metadata!)},', ); } - buffer.writeln( - ' run: (script) => ${workflow.importAlias}.${workflow.className}().${workflow.runMethod}(script),', - ); + 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) => ${workflow.importAlias}.${workflow.className}().${workflow.runMethod}($runArgs),', + ); + } buffer.writeln(' ),'); } buffer.writeln('];'); buffer.writeln(); } + void _emitWorkflowStartHelpers(StringBuffer buffer) { + if (workflows.isEmpty) { + return; + } + final symbolNames = _symbolNamesForWorkflows(workflows); + final fieldNames = <_WorkflowInfo, String>{}; + final usedFields = {}; + for (final workflow in workflows) { + final base = _lowerCamel(symbolNames[workflow]!); + var candidate = base; + var suffix = 2; + while (usedFields.contains(candidate)) { + candidate = '$base$suffix'; + suffix += 1; + } + usedFields.add(candidate); + fieldNames[workflow] = candidate; + } + + buffer.writeln('abstract final class StemWorkflowNames {'); + for (final workflow in workflows) { + buffer.writeln( + ' static const String ${fieldNames[workflow]} = ${_string(workflow.name)};', + ); + } + buffer.writeln('}'); + buffer.writeln(); + + _emitWorkflowStarterExtension( + buffer, + extensionName: 'StemGeneratedWorkflowAppStarters', + targetType: 'StemWorkflowApp', + symbolNames: symbolNames, + fieldNames: fieldNames, + ); + _emitWorkflowStarterExtension( + buffer, + extensionName: 'StemGeneratedWorkflowRuntimeStarters', + targetType: 'WorkflowRuntime', + symbolNames: symbolNames, + fieldNames: fieldNames, + ); + } + + void _emitWorkflowStarterExtension( + StringBuffer buffer, { + required String extensionName, + required String targetType, + required Map<_WorkflowInfo, String> symbolNames, + required Map<_WorkflowInfo, String> fieldNames, + }) { + buffer.writeln('extension $extensionName on $targetType {'); + for (final workflow in workflows) { + final methodName = 'start${symbolNames[workflow]}'; + if (workflow.kind == WorkflowKind.script && + workflow.runValueParameters.isNotEmpty) { + buffer.writeln(' Future $methodName({'); + for (final parameter in workflow.runValueParameters) { + buffer.writeln( + ' required ${parameter.typeCode} ${parameter.name},', + ); + } + buffer.writeln(' Map extraParams = const {},'); + buffer.writeln(' String? parentRunId,'); + buffer.writeln(' Duration? ttl,'); + buffer.writeln( + ' WorkflowCancellationPolicy? cancellationPolicy,', + ); + buffer.writeln(' }) {'); + buffer.writeln(' final params = {'); + buffer.writeln(' ...extraParams,'); + for (final parameter in workflow.runValueParameters) { + buffer.writeln( + ' ${_string(parameter.name)}: ${parameter.name},', + ); + } + buffer.writeln(' };'); + buffer.writeln(' return startWorkflow('); + buffer.writeln(' StemWorkflowNames.${fieldNames[workflow]},'); + buffer.writeln(' params: params,'); + buffer.writeln(' parentRunId: parentRunId,'); + buffer.writeln(' ttl: ttl,'); + buffer.writeln(' cancellationPolicy: cancellationPolicy,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + } else { + buffer.writeln(' Future $methodName({'); + buffer.writeln(' Map params = const {},'); + buffer.writeln(' String? parentRunId,'); + buffer.writeln(' Duration? ttl,'); + buffer.writeln( + ' WorkflowCancellationPolicy? cancellationPolicy,', + ); + buffer.writeln(' }) {'); + buffer.writeln(' return startWorkflow('); + buffer.writeln(' StemWorkflowNames.${fieldNames[workflow]},'); + buffer.writeln(' params: params,'); + buffer.writeln(' parentRunId: parentRunId,'); + buffer.writeln(' ttl: ttl,'); + buffer.writeln(' cancellationPolicy: cancellationPolicy,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + } + 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 base = _pascalIdentifier(workflow.name); + var candidate = base; + var suffix = 2; + while (used.contains(candidate)) { + candidate = '$base$suffix'; + suffix += 1; + } + used.add(candidate); + result[workflow] = candidate; + } + return result; + } + + String _pascalIdentifier(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) { + if (value.isEmpty) return value; + return '${value[0].toLowerCase()}${value.substring(1)}'; + } + void _emitTasks(StringBuffer buffer) { buffer.writeln( 'final List> stemTasks = >[', ); for (final task in tasks) { + final entrypoint = task.usesLegacyMapArgs + ? '${task.importAlias}.${task.function}' + : task.adapterName!; 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!)},'); } @@ -660,6 +1275,83 @@ class _RegistryEmitter { 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( + 'FutureOr $adapterName(TaskInvocationContext context, Map args) {', + ); + buffer.writeln( + ' return ${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( + 'FutureOr _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(); + } + + String _decodeArg(String sourceMap, _ValueParameterInfo parameter) { + return '(_stemRequireArg($sourceMap, ${_string(parameter.name)}) ' + 'as ${parameter.typeCode})'; + } } String _dartObjectToCode(DartObject object) { diff --git a/packages/stem_builder/test/stem_registry_builder_test.dart b/packages/stem_builder/test/stem_registry_builder_test.dart index 8ae3d992..200e98c9 100644 --- a/packages/stem_builder/test/stem_registry_builder_test.dart +++ b/packages/stem_builder/test/stem_registry_builder_test.dart @@ -7,7 +7,36 @@ const stubStem = ''' library stem; class FlowContext {} -class WorkflowScriptContext {} +typedef _FlowStepHandler = Future Function(FlowContext context); + +enum WorkflowStepKind { task, choice, parallel, wait, custom } + +class FlowStep { + FlowStep({ + required this.name, + required this.handler, + this.autoVersion = false, + this.title, + this.kind = WorkflowStepKind.task, + this.taskNames = const [], + this.metadata, + }); + final String name; + final _FlowStepHandler handler; + final bool autoVersion; + 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 { @@ -56,7 +85,11 @@ class Flow { } class WorkflowScript { - WorkflowScript({required String name, required dynamic run}); + WorkflowScript({ + required String name, + required dynamic run, + List steps = const [], + }); } class TaskHandler {} @@ -111,8 +144,16 @@ Future sendEmail( 'stem_builder|lib/stem_registry.g.dart': decodedMatches( allOf([ contains('registerStemDefinitions'), + contains('StemWorkflowNames'), + contains('StemGeneratedWorkflowAppStarters'), + contains('StemGeneratedWorkflowRuntimeStarters'), + contains('startHelloFlow'), + contains('startScriptWorkflow'), + contains('createStemGeneratedWorkflowApp'), + contains('createStemGeneratedInMemoryApp'), contains('Flow('), contains('WorkflowScript('), + contains('stemWorkflowManifest'), contains('FunctionTaskHandler'), contains( "import 'package:stem_builder/workflows.dart' as stemLib0;", @@ -148,17 +189,65 @@ class BadWorkflow { expect(result.errors.join('\n'), contains('@workflow.run')); }); - test('rejects script workflow with steps', () async { + test( + 'generates script workflow step proxies for direct method calls', + () async { + const input = ''' +import 'package:stem/stem.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/stem_registry.g.dart': decodedMatches( + allOf([ + contains( + 'class _StemScriptProxy0 extends ' + 'stemLib0.ScriptWithStepsWorkflow', + ), + contains('return _script.step('), + contains('(context) => super.sendEmail(email)'), + contains( + 'run: (script) => _StemScriptProxy0(script).run(script)', + ), + ]), + ), + }, + ); + }, + ); + + test('rejects script workflow steps that are not async', () async { const input = ''' import 'package:stem/stem.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; } '''; @@ -173,7 +262,125 @@ class BadWorkflow { ), ); expect(result.succeeded, isFalse); - expect(result.errors.join('\n'), contains('@workflow.step')); + expect( + result.errors.join('\n'), + contains('must return Future or FutureOr'), + ); + }); + + test( + 'decodes serializable @workflow.run parameters from script params', + () async { + const input = ''' +import 'package:stem/stem.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/stem_registry.g.dart': decodedMatches( + allOf([ + contains( + 'run: (script) => _StemScriptProxy0(', + ), + contains( + ').run((_stemRequireArg(script.params, "email") as String))', + ), + contains('_stemRequireArg(script.params, "email") as String'), + contains('Future startSignupWorkflow({'), + contains('required String email,'), + contains('Map extraParams = const {},'), + ]), + ), + }, + ); + }, + ); + + test( + 'supports @workflow.run with WorkflowScriptContext plus typed parameters', + () async { + const input = ''' +import 'package:stem/stem.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/stem_registry.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'; + +@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 type')); }); test('rejects task args that are not Map', () async { @@ -198,6 +405,74 @@ Future badTask( ), ); expect(result.succeeded, isFalse); - expect(result.errors.join('\n'), contains('Map')); + expect(result.errors.join('\n'), contains('serializable type')); + }); + + test('generates adapters for typed workflow and task parameters', () async { + const input = ''' +import 'package:stem/stem.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/stem_registry.g.dart': decodedMatches( + allOf([ + contains('_stemRequireArg(ctx.params, "email") as String'), + contains('_stemRequireArg(ctx.params, "retries") as int'), + contains('FutureOr _stemTaskAdapter0('), + contains('_stemRequireArg(args, "email") as String'), + contains('_stemRequireArg(args, "retries") as int'), + contains('entrypoint: _stemTaskAdapter0'), + ]), + ), + }, + ); + }); + + test('rejects non-serializable workflow step parameter types', () async { + const input = ''' +import 'package:stem/stem.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 type')); }); } From 0c9cede58d04df374839257788d11bc130c9e603 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 26 Feb 2026 17:57:04 -0500 Subject: [PATCH 03/23] feat(stem_builder): switch to per-file part generation and concise starter names --- packages/stem_builder/README.md | 12 +- packages/stem_builder/build.yaml | 2 +- packages/stem_builder/example/README.md | 6 +- packages/stem_builder/example/bin/main.dart | 4 +- .../example/bin/runtime_metadata_views.dart | 8 +- .../stem_builder/example/lib/definitions.dart | 2 + ...egistry.g.dart => definitions.stem.g.dart} | 33 +- .../lib/src/stem_registry_builder.dart | 535 +++++++++--------- packages/stem_builder/lib/stem_builder.dart | 2 +- .../test/stem_registry_builder_test.dart | 43 +- 10 files changed, 339 insertions(+), 308 deletions(-) rename packages/stem_builder/example/lib/{stem_registry.g.dart => definitions.stem.g.dart} (85%) diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index 8407260a..19318cc1 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -30,6 +30,8 @@ Annotate workflows and tasks: ```dart import 'package:stem/stem.dart'; +part 'workflows.stem.g.dart'; + @WorkflowDefn(name: 'hello.flow') class HelloFlow { @WorkflowStep() @@ -63,16 +65,16 @@ Future helloTask( `@WorkflowRun` may optionally take `WorkflowScriptContext` as its first parameter, followed by required positional serializable parameters. -Run build_runner to generate `lib/stem_registry.g.dart`: +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`. -It also emits typed starters so you can avoid raw workflow-name strings, for -example `runtime.startHelloScript(email: 'user@example.com')`. +The generated part exports helpers like `registerStemDefinitions`, +`createStemGeneratedWorkflowApp`, `createStemGeneratedInMemoryApp`, and typed +starters so you can avoid raw workflow-name strings (for example +`runtime.startScript(email: 'user@example.com')`). ## Examples 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 index 8557680c..3a952799 100644 --- a/packages/stem_builder/example/README.md +++ b/packages/stem_builder/example/README.md @@ -8,8 +8,8 @@ This example demonstrates: - `createStemGeneratedInMemoryApp()` - `createStemGeneratedWorkflowApp(stemApp: ...)` - Generated typed workflow starters (no manual workflow-name strings): - - `runtime.startBuilderExampleFlow(...)` - - `runtime.startBuilderExampleUserSignup(email: ...)` + - `runtime.startFlow(...)` + - `runtime.startUserSignup(email: ...)` - Generated `stemWorkflowManifest` - Running generated definitions through `StemWorkflowApp` - Runtime manifest + run/step metadata views via `WorkflowRuntime` @@ -28,5 +28,5 @@ dart run bin/main.dart dart run bin/runtime_metadata_views.dart ``` -The checked-in `lib/stem_registry.g.dart` is only a starter snapshot; rerun +The checked-in `lib/definitions.stem.g.dart` is only a starter snapshot; rerun `build_runner` after changing annotations. diff --git a/packages/stem_builder/example/bin/main.dart b/packages/stem_builder/example/bin/main.dart index 6941475a..c9f877dd 100644 --- a/packages/stem_builder/example/bin/main.dart +++ b/packages/stem_builder/example/bin/main.dart @@ -1,6 +1,6 @@ import 'dart:convert'; -import 'package:stem_builder_example/stem_registry.g.dart'; +import 'package:stem_builder_example/definitions.dart'; Future main() async { print('Registered workflows:'); @@ -25,7 +25,7 @@ Future main() async { print('\nRuntime manifest:'); print(const JsonEncoder.withIndent(' ').convert(runtimeManifest)); - final runId = await runtime.startBuilderExampleFlow( + final runId = await runtime.startFlow( params: const {'name': 'Stem Builder'}, ); await runtime.executeRun(runId); diff --git a/packages/stem_builder/example/bin/runtime_metadata_views.dart b/packages/stem_builder/example/bin/runtime_metadata_views.dart index 8e5e658a..85ceadaf 100644 --- a/packages/stem_builder/example/bin/runtime_metadata_views.dart +++ b/packages/stem_builder/example/bin/runtime_metadata_views.dart @@ -1,6 +1,6 @@ import 'dart:convert'; -import 'package:stem_builder_example/stem_registry.g.dart'; +import 'package:stem_builder_example/definitions.dart'; Future main() async { final app = await createStemGeneratedInMemoryApp(); @@ -24,14 +24,12 @@ Future main() async { ), ); - final flowRunId = await runtime.startBuilderExampleFlow( + final flowRunId = await runtime.startFlow( params: const {'name': 'runtime metadata'}, ); await runtime.executeRun(flowRunId); - final scriptRunId = await runtime.startBuilderExampleUserSignup( - email: 'dev@stem.dev', - ); + final scriptRunId = await runtime.startUserSignup(email: 'dev@stem.dev'); await runtime.executeRun(scriptRunId); final runViews = await runtime.listRunViews(limit: 10); diff --git a/packages/stem_builder/example/lib/definitions.dart b/packages/stem_builder/example/lib/definitions.dart index 0fa210bf..29351064 100644 --- a/packages/stem_builder/example/lib/definitions.dart +++ b/packages/stem_builder/example/lib/definitions.dart @@ -1,5 +1,7 @@ import 'package:stem/stem.dart'; +part 'definitions.stem.g.dart'; + @WorkflowDefn(name: 'builder.example.flow') class BuilderExampleFlow { @WorkflowStep(name: 'greet') diff --git a/packages/stem_builder/example/lib/stem_registry.g.dart b/packages/stem_builder/example/lib/definitions.stem.g.dart similarity index 85% rename from packages/stem_builder/example/lib/stem_registry.g.dart rename to packages/stem_builder/example/lib/definitions.stem.g.dart index 6be3c64b..5130f1ab 100644 --- a/packages/stem_builder/example/lib/stem_registry.g.dart +++ b/packages/stem_builder/example/lib/definitions.stem.g.dart @@ -1,15 +1,13 @@ // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: unused_element, unnecessary_lambdas, omit_local_variable_types, unused_import -import 'dart:async'; -import 'package:stem/stem.dart'; -import 'package:stem_builder_example/definitions.dart' as stemLib0; +part of 'definitions.dart'; final List stemFlows = [ Flow( name: "builder.example.flow", build: (flow) { - final impl = stemLib0.BuilderExampleFlow(); + final impl = BuilderExampleFlow(); flow.step( "greet", (ctx) => impl.greet((_stemRequireArg(ctx.params, "name") as String)), @@ -20,7 +18,7 @@ final List stemFlows = [ ), ]; -class _StemScriptProxy0 extends stemLib0.BuilderUserSignupWorkflow { +class _StemScriptProxy0 extends BuilderUserSignupWorkflow { _StemScriptProxy0(this._script); final WorkflowScriptContext _script; @override @@ -78,19 +76,19 @@ final List stemScripts = [ ]; abstract final class StemWorkflowNames { - static const String builderExampleFlow = "builder.example.flow"; - static const String builderExampleUserSignup = "builder.example.user_signup"; + static const String flow = "builder.example.flow"; + static const String userSignup = "builder.example.user_signup"; } extension StemGeneratedWorkflowAppStarters on StemWorkflowApp { - Future startBuilderExampleFlow({ + Future startFlow({ Map params = const {}, String? parentRunId, Duration? ttl, WorkflowCancellationPolicy? cancellationPolicy, }) { return startWorkflow( - StemWorkflowNames.builderExampleFlow, + StemWorkflowNames.flow, params: params, parentRunId: parentRunId, ttl: ttl, @@ -98,7 +96,7 @@ extension StemGeneratedWorkflowAppStarters on StemWorkflowApp { ); } - Future startBuilderExampleUserSignup({ + Future startUserSignup({ required String email, Map extraParams = const {}, String? parentRunId, @@ -107,7 +105,7 @@ extension StemGeneratedWorkflowAppStarters on StemWorkflowApp { }) { final params = {...extraParams, "email": email}; return startWorkflow( - StemWorkflowNames.builderExampleUserSignup, + StemWorkflowNames.userSignup, params: params, parentRunId: parentRunId, ttl: ttl, @@ -117,14 +115,14 @@ extension StemGeneratedWorkflowAppStarters on StemWorkflowApp { } extension StemGeneratedWorkflowRuntimeStarters on WorkflowRuntime { - Future startBuilderExampleFlow({ + Future startFlow({ Map params = const {}, String? parentRunId, Duration? ttl, WorkflowCancellationPolicy? cancellationPolicy, }) { return startWorkflow( - StemWorkflowNames.builderExampleFlow, + StemWorkflowNames.flow, params: params, parentRunId: parentRunId, ttl: ttl, @@ -132,7 +130,7 @@ extension StemGeneratedWorkflowRuntimeStarters on WorkflowRuntime { ); } - Future startBuilderExampleUserSignup({ + Future startUserSignup({ required String email, Map extraParams = const {}, String? parentRunId, @@ -141,7 +139,7 @@ extension StemGeneratedWorkflowRuntimeStarters on WorkflowRuntime { }) { final params = {...extraParams, "email": email}; return startWorkflow( - StemWorkflowNames.builderExampleUserSignup, + StemWorkflowNames.userSignup, params: params, parentRunId: parentRunId, ttl: ttl, @@ -156,8 +154,7 @@ final List stemWorkflowManifest = ...stemScripts.map((script) => script.definition.toManifestEntry()), ]; -FutureOr _stemScriptManifestStepNoop(FlowContext context) async => - null; +Future _stemScriptManifestStepNoop(FlowContext context) async => null; Object? _stemRequireArg(Map args, String name) { if (!args.containsKey(name)) { @@ -169,7 +166,7 @@ Object? _stemRequireArg(Map args, String name) { final List> stemTasks = >[ FunctionTaskHandler( name: "builder.example.task", - entrypoint: stemLib0.builderExampleTask, + entrypoint: builderExampleTask, options: const TaskOptions(), metadata: const TaskMetadata(), ), diff --git a/packages/stem_builder/lib/src/stem_registry_builder.dart b/packages/stem_builder/lib/src/stem_registry_builder.dart index 0002b0ab..8e958cf1 100644 --- a/packages/stem_builder/lib/src/stem_registry_builder.dart +++ b/packages/stem_builder/lib/src/stem_registry_builder.dart @@ -9,7 +9,6 @@ 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 +19,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 @@ -57,206 +56,113 @@ class StemRegistryBuilder implements Builder { ); 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, - ); - } - - 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 (runMethods.length > 1) { - throw InvalidGenerationSourceError( - 'Workflow ${classElement.displayName} has multiple @workflow.run methods.', - element: classElement, - ); - } - final runMethod = runMethods.single; - final runBinding = _validateRunMethod( - runMethod, - scriptContextChecker, - ); - final scriptSteps = <_WorkflowStepInfo>[]; - for (final method in stepMethods) { - final stepBinding = _validateScriptStepMethod( - method, - scriptStepContextChecker, - ); - final stepAnnotation = workflowStepChecker.firstAnnotationOfExact( - method, - throwOnUnresolved: false, - ); - if (stepAnnotation == null) { - continue; - } - 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')); - scriptSteps.add( - _WorkflowStepInfo( - name: stepName, - method: method.displayName, - acceptsFlowContext: false, - acceptsScriptStepContext: stepBinding.acceptsContext, - valueParameters: stepBinding.valueParameters, - returnTypeCode: stepBinding.returnTypeCode, - stepValueTypeCode: stepBinding.stepValueTypeCode, - autoVersion: autoVersion, - title: title, - kind: kindValue, - taskNames: taskNames, - metadata: stepMetadata, - ), - ); - } - workflows.add( - _WorkflowInfo.script( - name: workflowName, - importAlias: importAlias, - className: classElement.displayName, - steps: scriptSteps, - runMethod: runMethod.displayName, - runAcceptsScriptContext: runBinding.acceptsContext, - runValueParameters: runBinding.valueParameters, - version: version, - description: description, - metadata: metadata, - ), - ); - continue; - } + } + 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 (runMethods.isNotEmpty) { + 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} has @workflow.run but is not marked as script.', + 'Workflow ${classElement.displayName} is marked as script but has no @workflow.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) { - final stepBinding = _validateFlowStepMethod( + final stepBinding = _validateScriptStepMethod( method, - flowContextChecker, + scriptStepContextChecker, ); final stepAnnotation = workflowStepChecker.firstAnnotationOfExact( method, @@ -276,15 +182,15 @@ 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: stepBinding.acceptsContext, - acceptsScriptStepContext: false, + acceptsFlowContext: false, + acceptsScriptStepContext: stepBinding.acceptsContext, valueParameters: stepBinding.valueParameters, - returnTypeCode: null, - stepValueTypeCode: null, + returnTypeCode: stepBinding.returnTypeCode, + stepValueTypeCode: stepBinding.stepValueTypeCode, autoVersion: autoVersion, title: title, kind: kindValue, @@ -294,84 +200,161 @@ class StemRegistryBuilder implements Builder { ); } 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, version: version, description: description, metadata: metadata, ), ); + 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, - ); - } - final taskBinding = _validateTaskFunction( - function, - taskContextChecker, - mapChecker, + final stepReader = ConstantReader(stepAnnotation); + final stepName = + _stringOrNull(stepReader.peek('name')) ?? method.displayName; + final autoVersion = _boolOrDefault( + stepReader.peek('autoVersion'), + false, ); - 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, + 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: null, + autoVersion: autoVersion, + title: title, + kind: kindValue, + taskNames: taskNames, + metadata: stepMetadata, + ), ); + } + workflows.add( + _WorkflowInfo.flow( + name: workflowName, + importAlias: '', + className: classElement.displayName, + steps: steps, + version: version, + description: description, + metadata: metadata, + ), + ); + } - tasks.add( - _TaskInfo( - name: taskName, - importAlias: importAlias, - function: function.displayName, - adapterName: taskBinding.usesLegacyMapArgs - ? null - : '_stemTaskAdapter${taskAdapterIndex++}', - acceptsTaskContext: taskBinding.acceptsContext, - valueParameters: taskBinding.valueParameters, - usesLegacyMapArgs: taskBinding.usesLegacyMapArgs, - options: options, - metadata: metadata, - runInIsolate: runInIsolate, - ), + 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 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, + 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; @@ -826,27 +809,30 @@ class _RegistryEmitter { _RegistryEmitter({ required this.workflows, required this.tasks, - required this.imports, }); final List<_WorkflowInfo> workflows; final List<_TaskInfo> tasks; - final Map imports; - String emit() { + 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("import 'dart:async';"); - 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("part of '$fileName';"); + return buffer.toString(); + } + + 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(); _emitWorkflows(buffer); @@ -933,7 +919,7 @@ class _RegistryEmitter { } 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) { final stepArgs = step.valueParameters @@ -987,7 +973,7 @@ class _RegistryEmitter { final proxyClassName = '_StemScriptProxy${scriptProxyIndex++}'; scriptProxyClassNames[workflow] = proxyClassName; buffer.writeln( - 'class $proxyClassName extends ${workflow.importAlias}.${workflow.className} {', + 'class $proxyClassName extends ${_qualify(workflow.importAlias, workflow.className)} {', ); buffer.writeln(' $proxyClassName(this._script);'); buffer.writeln(' final WorkflowScriptContext _script;'); @@ -1089,7 +1075,7 @@ class _RegistryEmitter { ), ].join(', '); buffer.writeln( - ' run: (script) => ${workflow.importAlias}.${workflow.className}().${workflow.runMethod}($runArgs),', + ' run: (script) => ${_qualify(workflow.importAlias, workflow.className)}().${workflow.runMethod}($runArgs),', ); } buffer.writeln(' ),'); @@ -1213,19 +1199,43 @@ class _RegistryEmitter { final result = <_WorkflowInfo, String>{}; final used = {}; for (final workflow in values) { - final base = _pascalIdentifier(workflow.name); - var candidate = base; - var suffix = 2; - while (used.contains(candidate)) { - candidate = '$base$suffix'; - suffix += 1; + final candidates = _workflowSymbolCandidates(workflow.name); + 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(candidate); - result[workflow] = candidate; + used.add(chosen); + result[workflow] = chosen; } return result; } + List _workflowSymbolCandidates(String workflowName) { + 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 _pascalIdentifier(String value) { final parts = value .split(RegExp('[^A-Za-z0-9]+')) @@ -1256,7 +1266,7 @@ class _RegistryEmitter { ); for (final task in tasks) { final entrypoint = task.usesLegacyMapArgs - ? '${task.importAlias}.${task.function}' + ? _qualify(task.importAlias, task.function) : task.adapterName!; buffer.writeln(' FunctionTaskHandler('); buffer.writeln(' name: ${_string(task.name)},'); @@ -1288,10 +1298,10 @@ class _RegistryEmitter { ...task.valueParameters.map((param) => _decodeArg('args', param)), ].join(', '); buffer.writeln( - 'FutureOr $adapterName(TaskInvocationContext context, Map args) {', + 'Future $adapterName(TaskInvocationContext context, Map args) async {', ); buffer.writeln( - ' return ${task.importAlias}.${task.function}($callArgs);', + ' return await Future.value(${_qualify(task.importAlias, task.function)}($callArgs));', ); buffer.writeln('}'); buffer.writeln(); @@ -1305,7 +1315,7 @@ class _RegistryEmitter { ); if (needsScriptStepNoop) { buffer.writeln( - 'FutureOr _stemScriptManifestStepNoop(FlowContext context) async => null;', + 'Future _stemScriptManifestStepNoop(FlowContext context) async => null;', ); buffer.writeln(); } @@ -1352,6 +1362,11 @@ class _RegistryEmitter { return '(_stemRequireArg($sourceMap, ${_string(parameter.name)}) ' 'as ${parameter.typeCode})'; } + + 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/test/stem_registry_builder_test.dart b/packages/stem_builder/test/stem_registry_builder_test.dart index 200e98c9..087359be 100644 --- a/packages/stem_builder/test/stem_registry_builder_test.dart +++ b/packages/stem_builder/test/stem_registry_builder_test.dart @@ -112,6 +112,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') @@ -141,23 +143,21 @@ Future sendEmail( stubStem, ), outputs: { - 'stem_builder|lib/stem_registry.g.dart': decodedMatches( + 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( allOf([ contains('registerStemDefinitions'), contains('StemWorkflowNames'), contains('StemGeneratedWorkflowAppStarters'), contains('StemGeneratedWorkflowRuntimeStarters'), - contains('startHelloFlow'), - contains('startScriptWorkflow'), + contains('startFlow'), + contains('startWorkflow'), contains('createStemGeneratedWorkflowApp'), contains('createStemGeneratedInMemoryApp'), contains('Flow('), contains('WorkflowScript('), contains('stemWorkflowManifest'), contains('FunctionTaskHandler'), - contains( - "import 'package:stem_builder/workflows.dart' as stemLib0;", - ), + contains("part of 'workflows.dart';"), ]), ), }, @@ -168,6 +168,8 @@ Future sendEmail( const input = ''' import 'package:stem/stem.dart'; +part 'workflows.stem.g.dart'; + @WorkflowDefn() class BadWorkflow { @WorkflowRun() @@ -195,6 +197,8 @@ class BadWorkflow { const input = ''' import 'package:stem/stem.dart'; +part 'workflows.stem.g.dart'; + @WorkflowDefn(kind: WorkflowKind.script) class ScriptWithStepsWorkflow { @WorkflowRun() @@ -217,11 +221,10 @@ class ScriptWithStepsWorkflow { stubStem, ), outputs: { - 'stem_builder|lib/stem_registry.g.dart': decodedMatches( + 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( allOf([ contains( - 'class _StemScriptProxy0 extends ' - 'stemLib0.ScriptWithStepsWorkflow', + 'class _StemScriptProxy0 extends ScriptWithStepsWorkflow', ), contains('return _script.step('), contains('(context) => super.sendEmail(email)'), @@ -239,6 +242,8 @@ class ScriptWithStepsWorkflow { const input = ''' import 'package:stem/stem.dart'; +part 'workflows.stem.g.dart'; + @WorkflowDefn(kind: WorkflowKind.script) class BadScriptWorkflow { @WorkflowRun() @@ -274,6 +279,8 @@ class BadScriptWorkflow { const input = ''' import 'package:stem/stem.dart'; +part 'workflows.stem.g.dart'; + @WorkflowDefn(kind: WorkflowKind.script) class SignupWorkflow { @WorkflowRun() @@ -297,7 +304,7 @@ class SignupWorkflow { stubStem, ), outputs: { - 'stem_builder|lib/stem_registry.g.dart': decodedMatches( + 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( allOf([ contains( 'run: (script) => _StemScriptProxy0(', @@ -322,6 +329,8 @@ class SignupWorkflow { const input = ''' import 'package:stem/stem.dart'; +part 'workflows.stem.g.dart'; + @WorkflowDefn(kind: WorkflowKind.script) class SignupWorkflow { @WorkflowRun() @@ -344,7 +353,7 @@ class SignupWorkflow { stubStem, ), outputs: { - 'stem_builder|lib/stem_registry.g.dart': decodedMatches( + 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( allOf([ contains('run: (script) => _StemScriptProxy0('), contains( @@ -362,6 +371,8 @@ class SignupWorkflow { const input = ''' import 'package:stem/stem.dart'; +part 'workflows.stem.g.dart'; + @WorkflowDefn(kind: WorkflowKind.script) class BadScriptWorkflow { @WorkflowRun() @@ -387,6 +398,8 @@ class BadScriptWorkflow { const input = ''' import 'package:stem/stem.dart'; +part 'workflows.stem.g.dart'; + @TaskDefn() Future badTask( TaskInvocationContext ctx, @@ -412,6 +425,8 @@ Future badTask( const input = ''' import 'package:stem/stem.dart'; +part 'workflows.stem.g.dart'; + @WorkflowDefn(name: 'typed.flow') class TypedWorkflow { @WorkflowStep(name: 'send-email') @@ -436,11 +451,11 @@ Future typedTask( stubStem, ), outputs: { - 'stem_builder|lib/stem_registry.g.dart': decodedMatches( + 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( allOf([ contains('_stemRequireArg(ctx.params, "email") as String'), contains('_stemRequireArg(ctx.params, "retries") as int'), - contains('FutureOr _stemTaskAdapter0('), + contains('Future _stemTaskAdapter0('), contains('_stemRequireArg(args, "email") as String'), contains('_stemRequireArg(args, "retries") as int'), contains('entrypoint: _stemTaskAdapter0'), @@ -454,6 +469,8 @@ Future typedTask( const input = ''' import 'package:stem/stem.dart'; +part 'workflows.stem.g.dart'; + @WorkflowDefn(name: 'bad.flow') class BadWorkflow { @WorkflowStep() From 818833c610eddf68fd9f71a8b33f8cde8833ae05 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 26 Feb 2026 18:02:36 -0500 Subject: [PATCH 04/23] feat(stem_builder): support plain run entrypoint and configurable starter names --- .../stem/lib/src/workflow/annotations.dart | 13 +++ packages/stem_builder/README.md | 21 +++- .../stem_builder/example/lib/definitions.dart | 1 - .../lib/src/stem_registry_builder.dart | 102 ++++++++++++++--- .../test/stem_registry_builder_test.dart | 107 +++++++++++++++++- 5 files changed, 222 insertions(+), 22 deletions(-) diff --git a/packages/stem/lib/src/workflow/annotations.dart b/packages/stem/lib/src/workflow/annotations.dart index 0cd008ed..60e271cc 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,17 @@ class WorkflowDefn { /// Optional metadata attached to the workflow definition. final Map? metadata; + + /// Optional override for generated starter method suffix. + /// + /// Example: `starterName: 'UserSignup'` generates `startUserSignup(...)`. + final String? starterName; + + /// Optional override for `StemWorkflowNames` field name. + /// + /// Example: `nameField: 'userSignup'` generates + /// `StemWorkflowNames.userSignup`. + final String? nameField; } /// Marks a workflow class method as the run entrypoint. diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index 19318cc1..7c1546ed 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -42,7 +42,6 @@ class HelloFlow { @WorkflowDefn(name: 'hello.script', kind: WorkflowKind.script) class HelloScript { - @WorkflowRun() Future run(String email) async { await sendEmail(email); } @@ -62,8 +61,24 @@ Future helloTask( } ``` -`@WorkflowRun` may optionally take `WorkflowScriptContext` as its first -parameter, followed by required positional serializable parameters. +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. + +You can customize generated starter 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: diff --git a/packages/stem_builder/example/lib/definitions.dart b/packages/stem_builder/example/lib/definitions.dart index 29351064..b8980ede 100644 --- a/packages/stem_builder/example/lib/definitions.dart +++ b/packages/stem_builder/example/lib/definitions.dart @@ -12,7 +12,6 @@ class BuilderExampleFlow { @WorkflowDefn(name: 'builder.example.user_signup', kind: WorkflowKind.script) class BuilderUserSignupWorkflow { - @WorkflowRun() Future> run(String email) async { final user = await createUser(email); await sendWelcomeEmail(email); diff --git a/packages/stem_builder/lib/src/stem_registry_builder.dart b/packages/stem_builder/lib/src/stem_registry_builder.dart index 8e958cf1..255103d3 100644 --- a/packages/stem_builder/lib/src/stem_registry_builder.dart +++ b/packages/stem_builder/lib/src/stem_registry_builder.dart @@ -115,15 +115,27 @@ class StemRegistryBuilder implements Builder { 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 runMethods = classElement.methods + 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( @@ -143,7 +155,7 @@ class StemRegistryBuilder implements Builder { if (kind == WorkflowKind.script) { if (runMethods.isEmpty) { throw InvalidGenerationSourceError( - 'Workflow ${classElement.displayName} is marked as script but has no @workflow.run method.', + 'Workflow ${classElement.displayName} is marked as script but has no run entry method. Add @WorkflowRun or define a public run(...) method.', element: classElement, ); } @@ -211,6 +223,8 @@ class StemRegistryBuilder implements Builder { version: version, description: description, metadata: metadata, + starterNameOverride: starterName, + nameFieldOverride: nameField, ), ); continue; @@ -278,6 +292,8 @@ class StemRegistryBuilder implements Builder { version: version, description: description, metadata: metadata, + starterNameOverride: starterName, + nameFieldOverride: nameField, ), ); } @@ -659,6 +675,8 @@ class _WorkflowInfo { required this.importAlias, required this.className, required this.steps, + this.starterNameOverride, + this.nameFieldOverride, this.version, this.description, this.metadata, @@ -675,6 +693,8 @@ class _WorkflowInfo { required this.runMethod, required this.runAcceptsScriptContext, required this.runValueParameters, + this.starterNameOverride, + this.nameFieldOverride, this.version, this.description, this.metadata, @@ -688,6 +708,8 @@ class _WorkflowInfo { 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; @@ -1089,19 +1111,7 @@ class _RegistryEmitter { return; } final symbolNames = _symbolNamesForWorkflows(workflows); - final fieldNames = <_WorkflowInfo, String>{}; - final usedFields = {}; - for (final workflow in workflows) { - final base = _lowerCamel(symbolNames[workflow]!); - var candidate = base; - var suffix = 2; - while (usedFields.contains(candidate)) { - candidate = '$base$suffix'; - suffix += 1; - } - usedFields.add(candidate); - fieldNames[workflow] = candidate; - } + final fieldNames = _fieldNamesForWorkflows(workflows, symbolNames); buffer.writeln('abstract final class StemWorkflowNames {'); for (final workflow in workflows) { @@ -1199,7 +1209,10 @@ class _RegistryEmitter { final result = <_WorkflowInfo, String>{}; final used = {}; for (final workflow in values) { - final candidates = _workflowSymbolCandidates(workflow.name); + final candidates = _workflowSymbolCandidates( + workflowName: workflow.name, + starterNameOverride: workflow.starterNameOverride, + ); var chosen = candidates.firstWhere( (candidate) => !used.contains(candidate), orElse: () => candidates.last, @@ -1218,7 +1231,47 @@ class _RegistryEmitter { return result; } - List _workflowSymbolCandidates(String workflowName) { + 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; + } + + 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) @@ -1236,6 +1289,16 @@ class _RegistryEmitter { 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) { final parts = value .split(RegExp('[^A-Za-z0-9]+')) @@ -1260,6 +1323,11 @@ class _RegistryEmitter { 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 = >[', diff --git a/packages/stem_builder/test/stem_registry_builder_test.dart b/packages/stem_builder/test/stem_registry_builder_test.dart index 087359be..35963d28 100644 --- a/packages/stem_builder/test/stem_registry_builder_test.dart +++ b/packages/stem_builder/test/stem_registry_builder_test.dart @@ -49,9 +49,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 { @@ -191,6 +198,63 @@ class BadWorkflow { expect(result.errors.join('\n'), contains('@workflow.run')); }); + 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 const String helloFlow = "hello.flow";'), + contains( + 'static const String dailyBilling = "billing.daily_sync";', + ), + contains('Future startLaunchHello({'), + contains('Future startDailyBilling({'), + contains('StemWorkflowNames.helloFlow'), + contains('StemWorkflowNames.dailyBilling'), + ]), + ), + }, + ); + }, + ); + test( 'generates script workflow step proxies for direct method calls', () async { @@ -238,6 +302,47 @@ class ScriptWithStepsWorkflow { }, ); + 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'; From 31ac17c4b268919ca6b7af8753c4aede28a42910 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Tue, 17 Mar 2026 10:01:29 -0500 Subject: [PATCH 05/23] Add task registration to StemWorkflowApp --- .../stem/lib/src/bootstrap/workflow_app.dart | 9 +++++ .../stem/test/bootstrap/stem_app_test.dart | 33 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/packages/stem/lib/src/bootstrap/workflow_app.dart b/packages/stem/lib/src/bootstrap/workflow_app.dart index 50431cb3..8645f970 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -4,6 +4,7 @@ import 'package:stem/src/bootstrap/stem_client.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/task_payload_encoder.dart'; import 'package:stem/src/core/unique_task_coordinator.dart'; import 'package:stem/src/workflow/core/event_bus.dart'; @@ -228,6 +229,7 @@ class StemWorkflowApp { Iterable workflows = const [], Iterable flows = const [], Iterable scripts = const [], + Iterable> tasks = const [], StemApp? stemApp, StemBrokerFactory? broker, StemBackendFactory? backend, @@ -272,6 +274,7 @@ class StemWorkflowApp { introspectionSink: introspectionSink, ); + tasks.forEach(appInstance.register); appInstance.register(runtime.workflowRunnerHandler()); workflows.forEach(runtime.registerWorkflow); @@ -306,6 +309,7 @@ class StemWorkflowApp { 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), @@ -320,6 +324,7 @@ class StemWorkflowApp { workflows: workflows, flows: flows, scripts: scripts, + tasks: tasks, broker: StemBrokerFactory.inMemory(), backend: StemBackendFactory.inMemory(), storeFactory: WorkflowStoreFactory.inMemory(), @@ -345,6 +350,7 @@ class StemWorkflowApp { 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'), @@ -394,6 +400,7 @@ class StemWorkflowApp { workflows: workflows, flows: flows, scripts: scripts, + tasks: tasks, stemApp: app, storeFactory: stack.workflowStore, eventBusFactory: eventBusFactory, @@ -421,6 +428,7 @@ class StemWorkflowApp { Iterable workflows = const [], Iterable flows = const [], Iterable scripts = const [], + Iterable> tasks = const [], WorkflowStoreFactory? storeFactory, WorkflowEventBusFactory? eventBusFactory, StemWorkerConfig workerConfig = const StemWorkerConfig(queue: 'workflow'), @@ -436,6 +444,7 @@ class StemWorkflowApp { workflows: workflows, flows: flows, scripts: scripts, + tasks: tasks, stemApp: appInstance, storeFactory: storeFactory, eventBusFactory: eventBusFactory, diff --git a/packages/stem/test/bootstrap/stem_app_test.dart b/packages/stem/test/bootstrap/stem_app_test.dart index dda378f1..4328f395 100644 --- a/packages/stem/test/bootstrap/stem_app_test.dart +++ b/packages/stem/test/bootstrap/stem_app_test.dart @@ -399,6 +399,39 @@ void main() { } }); + test('fromUrl registers provided tasks', () async { + final helperTask = FunctionTaskHandler( + name: 'workflow.task.helper', + entrypoint: (context, args) async {}, + 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('fromUrl shuts down app when workflow bootstrap fails', () async { final createdLockStore = InMemoryLockStore(); final createdRevokeStore = InMemoryRevokeStore(); From d3c403e759c11ecfd0e623c13bd28ec25804151e Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Tue, 17 Mar 2026 10:01:50 -0500 Subject: [PATCH 06/23] Refresh stem_builder docs and demos --- .site/docs/core-concepts/index.md | 1 + .site/docs/core-concepts/stem-builder.md | 106 ++++++++++++ .site/docs/core-concepts/workflows.md | 7 +- .site/sidebars.ts | 2 + Taskfile.yml | 31 ++++ .../example/annotated_workflows/README.md | 12 +- .../example/annotated_workflows/bin/main.dart | 2 +- .../annotated_workflows/lib/definitions.dart | 4 +- .../lib/definitions.stem.g.dart | 154 ++++++++++++++++++ .../lib/stem_registry.g.dart | 47 ------ .../example/annotated_workflows/pubspec.yaml | 15 +- .../example/docs_snippets/lib/workflows.dart | 8 +- packages/stem_builder/README.md | 34 ++++ packages/stem_builder/example/README.md | 6 + 14 files changed, 368 insertions(+), 61 deletions(-) create mode 100644 .site/docs/core-concepts/stem-builder.md create mode 100644 packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart delete mode 100644 packages/stem/example/annotated_workflows/lib/stem_registry.g.dart diff --git a/.site/docs/core-concepts/index.md b/.site/docs/core-concepts/index.md index a6379c07..1cb5bc38 100644 --- a/.site/docs/core-concepts/index.md +++ b/.site/docs/core-concepts/index.md @@ -54,6 +54,7 @@ behavior before touching production. - **[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. +- **[stem_builder](./stem-builder.md)** – Generate workflow/task registries and typed starters 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/stem-builder.md b/.site/docs/core-concepts/stem-builder.md new file mode 100644 index 00000000..8e14288d --- /dev/null +++ b/.site/docs/core-concepts/stem-builder.md @@ -0,0 +1,106 @@ +--- +title: stem_builder +sidebar_label: stem_builder +sidebar_position: 15 +slug: /core-concepts/stem-builder +--- + +`stem_builder` generates workflow/task registries and typed workflow starters +from annotations, so you can avoid stringly-typed wiring. + +## 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: + +- `stemScripts`, `stemFlows`, `stemTasks` +- `registerStemDefinitions(...)` +- typed starters like `workflowApp.startUserSignup(...)` +- `StemWorkflowNames` constants +- convenience helpers such as `createStemGeneratedWorkflowApp(...)` + +## Wire Into StemWorkflowApp + +Use the generated registries directly with `StemWorkflowApp`: + +```dart +final workflowApp = await StemWorkflowApp.fromUrl( + 'memory://', + scripts: stemScripts, + flows: stemFlows, + tasks: stemTasks, +); + +await workflowApp.start(); +final runId = await workflowApp.startUserSignup(email: 'user@example.com'); +``` + +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: stemTasks, +); + +final workflowApp = await StemWorkflowApp.create( + stemApp: stemApp, + scripts: stemScripts, + flows: stemFlows, + tasks: stemTasks, +); +``` + +## Parameter and Signature Rules + +- Parameters after context must be required positional serializable values. +- Script workflow `run(...)` can be plain (no annotation required). +- `@WorkflowRun` is still supported for explicit run entrypoints. +- Step methods use `@WorkflowStep`. + diff --git a/.site/docs/core-concepts/workflows.md b/.site/docs/core-concepts/workflows.md index dbfa267f..26acf003 100644 --- a/.site/docs/core-concepts/workflows.md +++ b/.site/docs/core-concepts/workflows.md @@ -71,8 +71,8 @@ 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`. +`@WorkflowDefn`, `@WorkflowStep`, optional `@WorkflowRun`, 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 @@ -85,6 +85,9 @@ dart pub add --dev build_runner stem_builder dart run build_runner build ``` +For full setup and generated API details, see +[stem_builder](./stem-builder.md). + ## Starting & Awaiting Workflows ```dart title="bin/run_workflow.dart" file=/../packages/stem/example/docs_snippets/lib/workflows.dart#workflows-run diff --git a/.site/sidebars.ts b/.site/sidebars.ts index ad93cfab..20c680a7 100644 --- a/.site/sidebars.ts +++ b/.site/sidebars.ts @@ -58,6 +58,8 @@ const sidebars: SidebarsConfig = { "core-concepts/observability", "core-concepts/dashboard", "core-concepts/persistence", + "core-concepts/workflows", + "core-concepts/stem-builder", "core-concepts/cli-control", ], }, 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/stem/example/annotated_workflows/README.md b/packages/stem/example/annotated_workflows/README.md index 01ab7f39..73880850 100644 --- a/packages/stem/example/annotated_workflows/README.md +++ b/packages/stem/example/annotated_workflows/README.md @@ -7,13 +7,21 @@ with the `stem_builder` registry generator. ```bash cd packages/stem/example/annotated_workflows +dart pub get +dart run build_runner build --delete-conflicting-outputs dart run bin/main.dart ``` +From the repo root: + +```bash +task demo:annotated +``` + ## Regenerate the registry ```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..b08be130 100644 --- a/packages/stem/example/annotated_workflows/bin/main.dart +++ b/packages/stem/example/annotated_workflows/bin/main.dart @@ -1,5 +1,5 @@ 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(); diff --git a/packages/stem/example/annotated_workflows/lib/definitions.dart b/packages/stem/example/annotated_workflows/lib/definitions.dart index f12364eb..e86a2f2c 100644 --- a/packages/stem/example/annotated_workflows/lib/definitions.dart +++ b/packages/stem/example/annotated_workflows/lib/definitions.dart @@ -1,8 +1,10 @@ import 'package:stem/stem.dart'; +part 'definitions.stem.g.dart'; + @WorkflowDefn(name: 'annotated.flow') class AnnotatedFlowWorkflow { - @workflow.step + @WorkflowStep() Future start(FlowContext ctx) async { final resume = ctx.takeResumeData(); if (resume == null) { 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..0bff0452 --- /dev/null +++ b/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart @@ -0,0 +1,154 @@ +// 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: "annotated.flow", + build: (flow) { + final impl = AnnotatedFlowWorkflow(); + flow.step( + "start", + (ctx) => impl.start(ctx), + kind: WorkflowStepKind.task, + taskNames: [], + ); + }, + ), +]; + +final List stemScripts = [ + WorkflowScript( + name: "annotated.script", + run: (script) => AnnotatedScriptWorkflow().run(script), + ), +]; + +abstract final class StemWorkflowNames { + static const String flow = "annotated.flow"; + static const String script = "annotated.script"; +} + +extension StemGeneratedWorkflowAppStarters on StemWorkflowApp { + Future startFlow({ + Map params = const {}, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return startWorkflow( + StemWorkflowNames.flow, + params: params, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } + + Future startScript({ + Map params = const {}, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return startWorkflow( + StemWorkflowNames.script, + params: params, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } +} + +extension StemGeneratedWorkflowRuntimeStarters on WorkflowRuntime { + Future startFlow({ + Map params = const {}, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return startWorkflow( + StemWorkflowNames.flow, + params: params, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } + + Future startScript({ + Map params = const {}, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + return startWorkflow( + StemWorkflowNames.script, + params: params, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } +} + +final List stemWorkflowManifest = + [ + ...stemFlows.map((flow) => flow.definition.toManifestEntry()), + ...stemScripts.map((script) => script.definition.toManifestEntry()), + ]; + +final List> stemTasks = >[ + FunctionTaskHandler( + name: "send_email", + entrypoint: sendEmail, + options: const TaskOptions(maxRetries: 1), + metadata: const TaskMetadata(), + ), +]; + +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); + } +} + +Future createStemGeneratedWorkflowApp({ + required StemApp stemApp, + bool registerTasks = true, + Duration pollInterval = const Duration(milliseconds: 500), + Duration leaseExtension = const Duration(seconds: 30), + WorkflowRegistry? workflowRegistry, + WorkflowIntrospectionSink? introspectionSink, +}) async { + if (registerTasks) { + for (final handler in stemTasks) { + stemApp.register(handler); + } + } + return StemWorkflowApp.create( + stemApp: stemApp, + flows: stemFlows, + scripts: stemScripts, + pollInterval: pollInterval, + leaseExtension: leaseExtension, + workflowRegistry: workflowRegistry, + introspectionSink: introspectionSink, + ); +} + +Future createStemGeneratedInMemoryApp() async { + final stemApp = await StemApp.inMemory(tasks: stemTasks); + return createStemGeneratedWorkflowApp(stemApp: stemApp, registerTasks: false); +} 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/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index 6cb7cc79..a438321f 100644 --- a/packages/stem/example/docs_snippets/lib/workflows.dart +++ b/packages/stem/example/docs_snippets/lib/workflows.dart @@ -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; @@ -152,13 +152,13 @@ class ApprovalsAnnotatedWorkflow { Future managerReview(FlowContext ctx) async { final resume = ctx.takeResumeData() as Map?; 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,7 +167,7 @@ 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?; diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index 7c1546ed..e0adca3b 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -91,6 +91,40 @@ The generated part exports helpers like `registerStemDefinitions`, starters so you can avoid raw workflow-name strings (for example `runtime.startScript(email: 'user@example.com')`). +## Wiring Into StemWorkflowApp + +For the common case, pass generated tasks and workflows directly to +`StemWorkflowApp`: + +```dart +final workflowApp = await StemWorkflowApp.fromUrl( + 'redis://localhost:6379', + scripts: stemScripts, + flows: stemFlows, + tasks: stemTasks, +); +``` + +If your application already owns a `StemApp`, reuse it: + +```dart +final stemApp = await StemApp.fromUrl( + 'redis://localhost:6379', + adapters: const [StemRedisAdapter()], + tasks: stemTasks, +); + +final workflowApp = await StemWorkflowApp.create( + stemApp: stemApp, + scripts: stemScripts, + flows: stemFlows, + tasks: stemTasks, +); +``` + +You only need `registerStemDefinitions(...)` when you want to register against +existing `WorkflowRegistry` and `TaskRegistry` instances manually. + ## Examples See [`example/README.md`](example/README.md) for runnable examples, including: diff --git a/packages/stem_builder/example/README.md b/packages/stem_builder/example/README.md index 3a952799..b4737ba2 100644 --- a/packages/stem_builder/example/README.md +++ b/packages/stem_builder/example/README.md @@ -30,3 +30,9 @@ 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 helper APIs are convenience wrappers. The underlying public +`StemWorkflowApp` API also accepts `scripts`, `flows`, and `tasks` directly, so +you can wire generated definitions into a larger app without manual task +registration loops. From 8e7a75366dee55785730d90cfef5c94d9fab74f3 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Tue, 17 Mar 2026 10:02:02 -0500 Subject: [PATCH 07/23] Add ecommerce workflow example --- packages/stem/example/ecommerce/.gitignore | 2 + packages/stem/example/ecommerce/README.md | 100 ++ .../example/ecommerce/analysis_options.yaml | 1 + .../stem/example/ecommerce/bin/server.dart | 43 + .../stem/example/ecommerce/database/.gitkeep | 0 .../stem/example/ecommerce/lib/ecommerce.dart | 3 + .../example/ecommerce/lib/orm_registry.g.dart | 80 ++ .../stem/example/ecommerce/lib/src/app.dart | 289 ++++++ .../lib/src/database/datasource.dart | 50 + .../lib/src/database/migrations.dart | 53 ++ ...0260226010000_create_ecommerce_tables.dart | 99 ++ .../lib/src/database/models/cart.dart | 20 + .../lib/src/database/models/cart.orm.dart | 577 +++++++++++ .../lib/src/database/models/cart_item.dart | 34 + .../src/database/models/cart_item.orm.dart | 894 +++++++++++++++++ .../lib/src/database/models/catalog_sku.dart | 24 + .../src/database/models/catalog_sku.orm.dart | 694 ++++++++++++++ .../lib/src/database/models/models.dart | 5 + .../lib/src/database/models/order.dart | 32 + .../lib/src/database/models/order.orm.dart | 822 ++++++++++++++++ .../lib/src/database/models/order_item.dart | 34 + .../src/database/models/order_item.orm.dart | 897 ++++++++++++++++++ .../ecommerce/lib/src/domain/catalog.dart | 50 + .../ecommerce/lib/src/domain/repository.dart | 408 ++++++++ .../ecommerce/lib/src/tasks/manual_tasks.dart | 33 + .../lib/src/workflows/annotated_defs.dart | 99 ++ .../src/workflows/annotated_defs.stem.g.dart | 199 ++++ .../lib/src/workflows/checkout_flow.dart | 107 +++ packages/stem/example/ecommerce/ormed.yaml | 9 + packages/stem/example/ecommerce/pubspec.yaml | 35 + .../example/ecommerce/test/server_test.dart | 151 +++ 31 files changed, 5844 insertions(+) create mode 100644 packages/stem/example/ecommerce/.gitignore create mode 100644 packages/stem/example/ecommerce/README.md create mode 100644 packages/stem/example/ecommerce/analysis_options.yaml create mode 100644 packages/stem/example/ecommerce/bin/server.dart create mode 100644 packages/stem/example/ecommerce/database/.gitkeep create mode 100644 packages/stem/example/ecommerce/lib/ecommerce.dart create mode 100644 packages/stem/example/ecommerce/lib/orm_registry.g.dart create mode 100644 packages/stem/example/ecommerce/lib/src/app.dart create mode 100644 packages/stem/example/ecommerce/lib/src/database/datasource.dart create mode 100644 packages/stem/example/ecommerce/lib/src/database/migrations.dart create mode 100644 packages/stem/example/ecommerce/lib/src/database/migrations/m_20260226010000_create_ecommerce_tables.dart create mode 100644 packages/stem/example/ecommerce/lib/src/database/models/cart.dart create mode 100644 packages/stem/example/ecommerce/lib/src/database/models/cart.orm.dart create mode 100644 packages/stem/example/ecommerce/lib/src/database/models/cart_item.dart create mode 100644 packages/stem/example/ecommerce/lib/src/database/models/cart_item.orm.dart create mode 100644 packages/stem/example/ecommerce/lib/src/database/models/catalog_sku.dart create mode 100644 packages/stem/example/ecommerce/lib/src/database/models/catalog_sku.orm.dart create mode 100644 packages/stem/example/ecommerce/lib/src/database/models/models.dart create mode 100644 packages/stem/example/ecommerce/lib/src/database/models/order.dart create mode 100644 packages/stem/example/ecommerce/lib/src/database/models/order.orm.dart create mode 100644 packages/stem/example/ecommerce/lib/src/database/models/order_item.dart create mode 100644 packages/stem/example/ecommerce/lib/src/database/models/order_item.orm.dart create mode 100644 packages/stem/example/ecommerce/lib/src/domain/catalog.dart create mode 100644 packages/stem/example/ecommerce/lib/src/domain/repository.dart create mode 100644 packages/stem/example/ecommerce/lib/src/tasks/manual_tasks.dart create mode 100644 packages/stem/example/ecommerce/lib/src/workflows/annotated_defs.dart create mode 100644 packages/stem/example/ecommerce/lib/src/workflows/annotated_defs.stem.g.dart create mode 100644 packages/stem/example/ecommerce/lib/src/workflows/checkout_flow.dart create mode 100644 packages/stem/example/ecommerce/ormed.yaml create mode 100644 packages/stem/example/ecommerce/pubspec.yaml create mode 100644 packages/stem/example/ecommerce/test/server_test.dart 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..ad2b87a1 --- /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: + +- `stemScripts` (workflow script registration) +- `stemTasks` (task handler registration, passed into `StemWorkflowApp`) +- `workflowApp.startAddToCart(...)` (typed starter extension) +- `StemWorkflowNames.addToCart` (stable workflow name constant) + +The server wires generated and manual tasks together in one place: + +```dart +final workflowApp = await StemWorkflowApp.fromUrl( + 'sqlite://$stemDatabasePath', + adapters: const [StemSqliteAdapter()], + scripts: stemScripts, + flows: [buildCheckoutFlow(repository)], + tasks: [...stemTasks, 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/orm_registry.g.dart b/packages/stem/example/ecommerce/lib/orm_registry.g.dart new file mode 100644 index 00000000..32e7a6da --- /dev/null +++ b/packages/stem/example/ecommerce/lib/orm_registry.g.dart @@ -0,0 +1,80 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +import 'package:ormed/ormed.dart'; +import 'src/database/models/cart_item.dart'; +import 'src/database/models/cart.dart'; +import 'src/database/models/catalog_sku.dart'; +import 'src/database/models/order_item.dart'; +import '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/app.dart b/packages/stem/example/ecommerce/lib/src/app.dart new file mode 100644 index 00000000..663230d2 --- /dev/null +++ b/packages/stem/example/ecommerce/lib/src/app.dart @@ -0,0 +1,289 @@ +import 'dart:convert'; +import 'dart:isolate'; +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 packageRoot = await _resolvePackageRoot(); + final ormConfigPath = p.join(packageRoot, 'ormed.yaml'); + final stemDatabasePath = p.join( + p.dirname(commerceDatabasePath), + 'stem_runtime.sqlite', + ); + final repository = await EcommerceRepository.open( + commerceDatabasePath, + ormConfigPath: ormConfigPath, + ); + bindAddToCartWorkflowRepository(repository); + + final workflowApp = await StemWorkflowApp.fromUrl( + 'sqlite://$stemDatabasePath', + adapters: const [StemSqliteAdapter()], + scripts: stemScripts, + flows: [buildCheckoutFlow(repository)], + tasks: [...stemTasks, 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': [StemWorkflowNames.addToCart, 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 workflowApp.startAddToCart( + cartId: cartId, + sku: sku, + quantity: quantity, + ); + + final result = await workflowApp + .waitForCompletion>( + runId, + timeout: const Duration(seconds: 4), + decode: _toMap, + ); + + 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 _resolvePackageRoot() async { + final packageUri = await Isolate.resolvePackageUri( + Uri.parse('package:stem_ecommerce_example/ecommerce.dart'), + ); + if (packageUri == null || packageUri.scheme != 'file') { + throw StateError('Unable to resolve package root for ecommerce example.'); + } + final packageFilePath = packageUri.toFilePath(); + return p.dirname(p.dirname(packageFilePath)); +} + +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..234cbf63 --- /dev/null +++ b/packages/stem/example/ecommerce/lib/src/database/datasource.dart @@ -0,0 +1,50 @@ +import 'dart:io'; + +import 'package:ormed/ormed.dart'; +import 'package:ormed_sqlite/ormed_sqlite.dart'; + +import '../../orm_registry.g.dart'; +import 'migrations.dart'; + +Future openEcommerceDataSource({ + required String databasePath, + required String ormConfigPath, +}) async { + final configFile = File(ormConfigPath); + final baseConfig = loadOrmProjectConfig(configFile); + final config = baseConfig.updateActiveConnection( + driver: baseConfig.driver.copyWith( + options: {...baseConfig.driver.options, 'database': databasePath}, + ), + ); + + ensureSqliteDriverRegistration(); + + final dataSource = DataSource.fromConfig(config, registry: bootstrapOrm()); + + 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: config.migrations.ledgerTable, + ); + 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/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..30052bbb --- /dev/null +++ b/packages/stem/example/ecommerce/lib/src/domain/repository.dart @@ -0,0 +1,408 @@ +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, { + required String ormConfigPath, + }) async { + final file = File(databasePath); + await file.parent.create(recursive: true); + + final normalizedPath = p.normalize(databasePath); + final dataSource = await openEcommerceDataSource( + databasePath: normalizedPath, + ormConfigPath: ormConfigPath, + ); + + 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..686f61e4 --- /dev/null +++ b/packages/stem/example/ecommerce/lib/src/workflows/annotated_defs.stem.g.dart @@ -0,0 +1,199 @@ +// 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", + steps: [ + 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 StemWorkflowNames { + static const String addToCart = "ecommerce.cart.add_item"; +} + +extension StemGeneratedWorkflowAppStarters on StemWorkflowApp { + Future startAddToCart({ + required String cartId, + required String sku, + required int quantity, + Map extraParams = const {}, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + final params = { + ...extraParams, + "cartId": cartId, + "sku": sku, + "quantity": quantity, + }; + return startWorkflow( + StemWorkflowNames.addToCart, + params: params, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } +} + +extension StemGeneratedWorkflowRuntimeStarters on WorkflowRuntime { + Future startAddToCart({ + required String cartId, + required String sku, + required int quantity, + Map extraParams = const {}, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + final params = { + ...extraParams, + "cartId": cartId, + "sku": sku, + "quantity": quantity, + }; + return startWorkflow( + StemWorkflowNames.addToCart, + params: params, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } +} + +final List stemWorkflowManifest = + [ + ...stemFlows.map((flow) => flow.definition.toManifestEntry()), + ...stemScripts.map((script) => script.definition.toManifestEntry()), + ]; + +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), + ), + ); +} + +final List> stemTasks = >[ + FunctionTaskHandler( + name: "ecommerce.audit.log", + entrypoint: _stemTaskAdapter0, + options: const TaskOptions(queue: "default"), + metadata: const TaskMetadata(), + runInIsolate: false, + ), +]; + +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); + } +} + +Future createStemGeneratedWorkflowApp({ + required StemApp stemApp, + bool registerTasks = true, + Duration pollInterval = const Duration(milliseconds: 500), + Duration leaseExtension = const Duration(seconds: 30), + WorkflowRegistry? workflowRegistry, + WorkflowIntrospectionSink? introspectionSink, +}) async { + if (registerTasks) { + for (final handler in stemTasks) { + stemApp.register(handler); + } + } + return StemWorkflowApp.create( + stemApp: stemApp, + flows: stemFlows, + scripts: stemScripts, + pollInterval: pollInterval, + leaseExtension: leaseExtension, + workflowRegistry: workflowRegistry, + introspectionSink: introspectionSink, + ); +} + +Future createStemGeneratedInMemoryApp() async { + final stemApp = await StemApp.inMemory(tasks: stemTasks); + return createStemGeneratedWorkflowApp(stemApp: stemApp, registerTasks: false); +} 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..803d94ab --- /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.takeResumeData(); + 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..da9f9cdc --- /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.1.0 + ormed_sqlite: ^0.1.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: any + 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.'); + }, + ); +} From 544c5124be3ee3d790670e83fc3fc9e5da054862 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Tue, 17 Mar 2026 10:32:39 -0500 Subject: [PATCH 08/23] Improve workflow logging context --- packages/stem/lib/src/worker/worker.dart | 210 +++++++++++++++--- .../workflow/runtime/workflow_runtime.dart | 155 ++++++++++++- .../stem/test/unit/worker/worker_test.dart | 86 +++++++ .../test/workflow/workflow_runtime_test.dart | 158 ++++++++++--- 4 files changed, 544 insertions(+), 65 deletions(-) diff --git a/packages/stem/lib/src/worker/worker.dart b/packages/stem/lib/src/worker/worker.dart index d7ccc05c..fc1b8781 100644 --- a/packages/stem/lib/src/worker/worker.dart +++ b/packages/stem/lib/src/worker/worker.dart @@ -909,14 +909,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 +1029,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 +1924,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 +2059,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 +2119,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 +2264,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 +2719,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 +2974,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/runtime/workflow_runtime.dart b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart index 456078d4..2af82fe8 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart @@ -28,7 +28,9 @@ library; import 'dart:async'; +import 'package:contextual/contextual.dart' show Context; import 'package:stem/src/core/contracts.dart'; +import 'package:stem/src/observability/logging.dart'; import 'package:stem/src/core/stem.dart'; import 'package:stem/src/core/task_invocation.dart'; import 'package:stem/src/signals/emitter.dart'; @@ -402,6 +404,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, @@ -532,6 +542,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, @@ -598,6 +621,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; @@ -635,6 +672,21 @@ 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; } @@ -658,7 +710,14 @@ class WorkflowRuntime { } await _store.markCompleted(runId, previousResult); - await _extendLeases(taskContext, runId); + stemLogger.debug( + 'Workflow {workflow} completed', + _runtimeLogContext( + workflow: runState.workflow, + runId: runId, + extra: {'runtimeId': _runtimeId}, + ), + ); await _signals.workflowRunCompleted( WorkflowRunPayload( runId: runId, @@ -688,6 +747,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, @@ -719,7 +786,14 @@ class WorkflowRuntime { return; } await _store.markCompleted(runId, result); - await _extendLeases(taskContext, runId); + stemLogger.debug( + 'Workflow {workflow} completed', + _runtimeLogContext( + workflow: runState.workflow, + runId: runId, + extra: {'runtimeId': _runtimeId}, + ), + ); await _signals.workflowRunCompleted( WorkflowRunPayload( runId: runId, @@ -734,6 +808,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, @@ -963,6 +1050,20 @@ class WorkflowRuntime { meta: meta, 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, + }, + ), + ); } /// Builds workflow metadata injected into step task enqueues. @@ -1011,6 +1112,24 @@ class WorkflowRuntime { ); } + 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. Future _maybeCancelForPolicy( RunState state, { @@ -1394,6 +1513,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; @@ -1427,6 +1560,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; } @@ -1593,7 +1741,8 @@ class _WorkflowStepEnqueuer implements TaskEnqueuer { if (inherited.queue == 'default' && executionQueue != 'default') { resolvedOptions = inherited.copyWith(queue: executionQueue); } - } else if (resolvedOptions.queue == 'default' && executionQueue != 'default') { + } else if (resolvedOptions.queue == 'default' && + executionQueue != 'default') { resolvedOptions = resolvedOptions.copyWith(queue: executionQueue); } final mergedCall = call.copyWith( diff --git a/packages/stem/test/unit/worker/worker_test.dart b/packages/stem/test/unit/worker/worker_test.dart index cccad725..6706a652 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'; @@ -55,6 +56,80 @@ 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 = SimpleTaskRegistry()..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), @@ -1894,6 +1969,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/workflow/workflow_runtime_test.dart b/packages/stem/test/workflow/workflow_runtime_test.dart index 07527c36..6719493c 100644 --- a/packages/stem/test/workflow/workflow_runtime_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_test.dart @@ -1,3 +1,4 @@ +import 'package:contextual/contextual.dart' show Level, LogDriver, LogEntry; import 'package:stem/stem.dart'; import 'package:test/test.dart'; @@ -61,34 +62,43 @@ 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( + '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( @@ -935,6 +945,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( @@ -1000,3 +1091,14 @@ class _RecordingWorkflowIntrospectionSink implements WorkflowIntrospectionSink { runtimeEvents.add(event); } } + +class _RecordingLogDriver extends LogDriver { + _RecordingLogDriver() : entries = [], super('recording'); + + final List entries; + + @override + Future log(LogEntry entry) async { + entries.add(entry); + } +} From f289196cd64062b2a9f653d24ed62cab9b4a9e7a Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Tue, 17 Mar 2026 10:51:45 -0500 Subject: [PATCH 09/23] Export logging types from stem --- packages/stem/lib/stem.dart | 2 ++ .../test/unit/observability/logging_test.dart | 17 +++++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/stem/lib/stem.dart b/packages/stem/lib/stem.dart index 3b0ceeff..7021abcd 100644 --- a/packages/stem/lib/stem.dart +++ b/packages/stem/lib/stem.dart @@ -68,6 +68,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'; 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); From bf345ec5ca1d916eb11fcc582f59b3492248c084 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Tue, 17 Mar 2026 16:20:56 -0500 Subject: [PATCH 10/23] Adopt tasks-first wiring across Stem APIs and examples --- .site/docs/core-concepts/canvas.md | 2 +- .site/docs/core-concepts/tasks.md | 12 +-- .site/docs/core-concepts/workflows.md | 7 +- .site/docs/getting-started/first-steps.md | 4 +- .../getting-started/production-checklist.md | 2 +- .site/docs/getting-started/quick-start.md | 2 +- .../docs/workers/programmatic-integration.md | 5 +- packages/stem/README.md | 24 ++++-- .../autoscaling_demo/bin/producer.dart | 4 +- .../example/autoscaling_demo/bin/worker.dart | 4 +- .../example/autoscaling_demo/lib/shared.dart | 18 ++-- .../canvas_patterns/chain_example.dart | 52 +++++------ .../canvas_patterns/chord_example.dart | 51 ++++++----- .../canvas_patterns/group_example.dart | 25 +++--- .../example/dlq_sandbox/bin/producer.dart | 4 +- .../stem/example/dlq_sandbox/bin/worker.dart | 4 +- .../stem/example/dlq_sandbox/lib/shared.dart | 12 +-- .../docs_snippets/lib/best_practices.dart | 6 +- .../lib/developer_environment.dart | 73 ++++++++-------- .../example/docs_snippets/lib/namespaces.dart | 3 +- .../docs_snippets/lib/observability.dart | 25 +++--- .../docs_snippets/lib/persistence.dart | 31 ++++--- .../example/docs_snippets/lib/producer.dart | 46 +++++----- .../lib/production_checklist.dart | 23 +++-- .../example/docs_snippets/lib/routing.dart | 6 +- .../example/docs_snippets/lib/scheduler.dart | 1 - .../example/docs_snippets/lib/signing.dart | 14 +-- .../stem/example/docs_snippets/lib/tasks.dart | 6 +- .../docs_snippets/lib/worker_control.dart | 4 +- .../lib/workers_programmatic.dart | 86 +++++++++---------- .../example/email_service/bin/enqueuer.dart | 17 ++-- .../example/email_service/bin/worker.dart | 17 ++-- .../encrypted_payload/docker/main.dart | 17 ++-- .../enqueuer/bin/enqueue.dart | 17 ++-- .../encrypted_payload/worker/bin/worker.dart | 25 +++--- .../stem/example/image_processor/bin/api.dart | 17 ++-- .../example/image_processor/bin/worker.dart | 17 ++-- .../microservice/enqueuer/bin/main.dart | 23 +++-- .../microservice/worker/bin/worker.dart | 25 +++--- .../mixed_cluster/enqueuer/bin/enqueue.dart | 34 ++++---- .../postgres_worker/bin/worker.dart | 25 +++--- .../redis_worker/bin/worker.dart | 25 +++--- .../example/monolith_service/bin/service.dart | 27 +++--- .../ops_health_suite/bin/producer.dart | 4 +- .../example/ops_health_suite/bin/worker.dart | 4 +- .../example/ops_health_suite/lib/shared.dart | 18 ++-- .../stem/example/otel_metrics/bin/worker.dart | 27 +++--- .../example/postgres_tls/bin/enqueue.dart | 17 ++-- .../stem/example/postgres_tls/bin/worker.dart | 23 +++-- .../postgres_worker/enqueuer/bin/enqueue.dart | 17 ++-- .../postgres_worker/worker/bin/worker.dart | 25 +++--- .../progress_heartbeat/bin/producer.dart | 4 +- .../progress_heartbeat/bin/worker.dart | 4 +- .../progress_heartbeat/lib/shared.dart | 6 +- .../rate_limit_delay/bin/producer.dart | 4 +- .../example/rate_limit_delay/bin/worker.dart | 4 +- .../example/rate_limit_delay/lib/shared.dart | 31 ++++--- .../enqueuer/bin/enqueue.dart | 17 ++-- .../worker/bin/worker.dart | 25 +++--- .../stem/example/retry_task/bin/producer.dart | 4 +- .../stem/example/retry_task/bin/worker.dart | 4 +- .../stem/example/retry_task/lib/shared.dart | 8 +- .../example/routing_parity/bin/publisher.dart | 4 +- .../example/routing_parity/bin/worker.dart | 4 +- .../routing_parity/lib/routing_demo.dart | 11 +-- .../scheduler_observability/bin/worker.dart | 4 +- .../scheduler_observability/lib/shared.dart | 18 ++-- .../example/signals_demo/bin/producer.dart | 4 +- .../stem/example/signals_demo/bin/worker.dart | 4 +- .../stem/example/signals_demo/lib/shared.dart | 12 +-- .../signing_key_rotation/bin/producer.dart | 4 +- .../signing_key_rotation/bin/worker.dart | 4 +- .../signing_key_rotation/lib/shared.dart | 18 ++-- packages/stem/example/stem_example.dart | 9 +- .../task_context_mixed/bin/enqueue.dart | 4 +- .../task_context_mixed/bin/worker.dart | 4 +- .../task_context_mixed/lib/shared.dart | 19 +--- .../stem/example/task_usage_patterns.dart | 35 ++++---- .../unique_tasks/unique_task_example.dart | 6 +- .../worker_control_lab/bin/producer.dart | 4 +- .../worker_control_lab/bin/worker.dart | 4 +- .../worker_control_lab/lib/shared.dart | 9 +- .../workflows/runtime_metadata_views.dart | 26 +++--- packages/stem/lib/src/bootstrap/stem_app.dart | 2 +- .../stem/lib/src/bootstrap/stem_client.dart | 2 +- packages/stem/lib/src/canvas/canvas.dart | 18 +++- packages/stem/lib/src/core/contracts.dart | 4 +- packages/stem/lib/src/core/stem.dart | 15 +++- packages/stem/lib/src/worker/worker.dart | 36 +++++--- packages/stem/lib/stem.dart | 9 +- packages/stem/spec.md | 4 +- .../stem/test/bootstrap/stem_app_test.dart | 2 +- .../test/performance/throughput_test.dart | 2 +- packages/stem/test/soak/soak_test.dart | 2 +- .../stem/test/unit/canvas/canvas_test.dart | 12 +-- .../stem/test/unit/core/stem_core_test.dart | 8 +- .../unit/core/stem_enqueue_options_test.dart | 12 +-- .../test/unit/core/stem_unique_task_test.dart | 2 +- .../test/unit/core/task_registry_test.dart | 17 ++-- .../metrics_integration_test.dart | 2 +- .../stem/test/unit/redis_components_test.dart | 4 +- .../stem/test/unit/scheduler/beat_test.dart | 8 +- .../stem/test/unit/tracing/tracing_test.dart | 6 +- ...task_context_enqueue_integration_test.dart | 14 +-- .../unit/worker/task_retry_policy_test.dart | 10 +-- .../stem/test/unit/worker/worker_test.dart | 63 +++++++------- .../test/workflow/workflow_runtime_test.dart | 4 +- packages/stem_builder/README.md | 6 +- 108 files changed, 746 insertions(+), 813 deletions(-) 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/tasks.md b/.site/docs/core-concepts/tasks.md index b63e42de..98a108c0 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 @@ -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 26acf003..77a0a575 100644 --- a/.site/docs/core-concepts/workflows.md +++ b/.site/docs/core-concepts/workflows.md @@ -72,13 +72,13 @@ iterations using the `stepName#iteration` naming convention. If you prefer decorators over the DSL, annotate workflow classes and tasks with `@WorkflowDefn`, `@WorkflowStep`, optional `@WorkflowRun`, and `@TaskDefn`, -then generate the registry with `stem_builder`. +then generate the workflow/task helpers with `stem_builder`. ```dart title="lib/workflows/annotated.dart" file=/../packages/stem/example/docs_snippets/lib/workflows.dart#workflows-annotated ``` -Build the registry (example): +Generate the helpers (example): ```bash dart pub add --dev build_runner stem_builder @@ -163,7 +163,8 @@ accepts either a shared `TaskPayloadEncoderRegistry` or explicit defaults: ``` Every workflow run task stores the result encoder id in `RunState.resultMeta`, -and the internal tasks dispatched by workflows reuse the same registry—so +and the internal tasks dispatched by workflows reuse the same encoder +configuration—so typed steps can safely emit encrypted/binary payloads while workers decode them exactly once. diff --git a/.site/docs/getting-started/first-steps.md b/.site/docs/getting-started/first-steps.md index 0d5b33a4..199f96b9 100644 --- a/.site/docs/getting-started/first-steps.md +++ b/.site/docs/getting-started/first-steps.md @@ -68,9 +68,9 @@ For more detail, see [Broker Overview](../brokers/overview.md) and ## App setup -- 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 diff --git a/.site/docs/getting-started/production-checklist.md b/.site/docs/getting-started/production-checklist.md index 684acbac..3211a901 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 diff --git a/.site/docs/getting-started/quick-start.md b/.site/docs/getting-started/quick-start.md index ef6b2387..35ec3030 100644 --- a/.site/docs/getting-started/quick-start.md +++ b/.site/docs/getting-started/quick-start.md @@ -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/workers/programmatic-integration.md b/.site/docs/workers/programmatic-integration.md index 06cf9959..701678b9 100644 --- a/.site/docs/workers/programmatic-integration.md +++ b/.site/docs/workers/programmatic-integration.md @@ -95,8 +95,9 @@ surface the same. ## 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/packages/stem/README.md b/packages/stem/README.md index d05f9237..c205bf79 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( @@ -445,7 +451,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 +499,8 @@ final unique = UniqueTaskCoordinator( final stem = Stem( broker: broker, - registry: registry, backend: backend, + tasks: [OrdersSyncTask()], uniqueTaskCoordinator: unique, ); ``` 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/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/workflows/runtime_metadata_views.dart b/packages/stem/example/workflows/runtime_metadata_views.dart index 9e098ae7..f08ae774 100644 --- a/packages/stem/example/workflows/runtime_metadata_views.dart +++ b/packages/stem/example/workflows/runtime_metadata_views.dart @@ -7,10 +7,16 @@ import 'dart:convert'; import 'package:stem/stem.dart'; Future main() async { - final broker = InMemoryBroker(); - final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry(); - final stem = Stem(broker: broker, registry: registry, backend: backend); + 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, @@ -21,14 +27,7 @@ Future main() async { executionQueue: 'workflow-step', ); - registry - ..register(runtime.workflowRunnerHandler()) - ..register( - FunctionTaskHandler.inline( - name: 'example.noop', - entrypoint: (context, args) async => null, - ), - ); + app.register(runtime.workflowRunnerHandler()); runtime.registerWorkflow( Flow( @@ -98,7 +97,6 @@ Future main() async { print(const JsonEncoder.withIndent(' ').convert(runDetail?.toJson())); } finally { await runtime.dispose(); - await backend.close(); - broker.dispose(); + await app.close(); } } 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..e5c6404d 100644 --- a/packages/stem/lib/src/bootstrap/stem_client.dart +++ b/packages/stem/lib/src/bootstrap/stem_client.dart @@ -271,7 +271,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/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 c7a713d5..aaffc22d 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -1100,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; @@ -1886,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(); diff --git a/packages/stem/lib/src/core/stem.dart b/packages/stem/lib/src/core/stem.dart index 74f4f629..10f870b6 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; diff --git a/packages/stem/lib/src/worker/worker.dart b/packages/stem/lib/src/worker/worker.dart index fc1b8781..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; diff --git a/packages/stem/lib/stem.dart b/packages/stem/lib/stem.dart index 7021abcd..97b8f81c 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 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 4328f395..c7679aac 100644 --- a/packages/stem/test/bootstrap/stem_app_test.dart +++ b/packages/stem/test/bootstrap/stem_app_test.dart @@ -402,7 +402,7 @@ void main() { test('fromUrl registers provided tasks', () async { final helperTask = FunctionTaskHandler( name: 'workflow.task.helper', - entrypoint: (context, args) async {}, + entrypoint: (context, args) async => null, runInIsolate: false, ); final adapter = TestStoreAdapter( 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/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index c777442f..a68024f0 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', 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..54f4e22e 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')); @@ -208,9 +208,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/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 6706a652..683afd03 100644 --- a/packages/stem/test/unit/worker/worker_test.dart +++ b/packages/stem/test/unit/worker/worker_test.dart @@ -14,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, @@ -29,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)); @@ -70,7 +73,7 @@ void main() { claimInterval: const Duration(milliseconds: 40), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry()..register(_SuccessTask()); + final registry = InMemoryTaskRegistry()..register(_SuccessTask()); final worker = Worker( broker: broker, registry: registry, @@ -136,7 +139,7 @@ void main() { claimInterval: const Duration(milliseconds: 20), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register(_ChordBodyTask()) ..register(_ChordCallbackTask()); final worker = Worker( @@ -180,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), @@ -245,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, @@ -317,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, @@ -356,7 +359,7 @@ void main() { claimInterval: const Duration(milliseconds: 20), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( FunctionTaskHandler( name: 'tasks.autoscale', @@ -424,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, @@ -497,7 +500,7 @@ void main() { claimInterval: const Duration(milliseconds: 20), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( FunctionTaskHandler( name: 'tasks.isolate', @@ -566,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, @@ -639,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, @@ -700,7 +703,7 @@ void main() { claimInterval: const Duration(milliseconds: 20), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( FunctionTaskHandler( name: 'tasks.default', @@ -775,7 +778,7 @@ void main() { claimInterval: const Duration(milliseconds: 20), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( FunctionTaskHandler( name: 'tasks.sleepy', @@ -825,7 +828,7 @@ void main() { claimInterval: const Duration(milliseconds: 20), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( FunctionTaskHandler( name: 'tasks.recycle', @@ -881,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', @@ -937,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': @@ -994,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': @@ -1050,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, @@ -1108,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': @@ -1176,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, @@ -1243,7 +1246,7 @@ void main() { claimInterval: const Duration(milliseconds: 40), ); final backend = InMemoryResultBackend(); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( FunctionTaskHandler( name: 'tasks.isolate', @@ -1295,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', @@ -1363,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); @@ -1444,7 +1447,7 @@ void main() { } return const RateLimitDecision(allowed: true); }); - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( FunctionTaskHandler( name: 'tasks.group.a', @@ -1526,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', @@ -1573,7 +1576,7 @@ void main() { throw StateError('limiter unavailable'); }); var executed = 0; - final registry = SimpleTaskRegistry() + final registry = InMemoryTaskRegistry() ..register( FunctionTaskHandler( name: 'tasks.group.failclosed', @@ -1623,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, @@ -1705,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, diff --git a/packages/stem/test/workflow/workflow_runtime_test.dart b/packages/stem/test/workflow/workflow_runtime_test.dart index 6719493c..fe5428bf 100644 --- a/packages/stem/test/workflow/workflow_runtime_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_test.dart @@ -5,7 +5,7 @@ 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; @@ -15,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); diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index e0adca3b..3eafed23 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 @@ -122,8 +122,8 @@ final workflowApp = await StemWorkflowApp.create( ); ``` -You only need `registerStemDefinitions(...)` when you want to register against -existing `WorkflowRegistry` and `TaskRegistry` instances manually. +You only need `registerStemDefinitions(...)` when you are integrating with +existing custom `WorkflowRegistry` and `TaskRegistry` instances manually. ## Examples From ba8794a1b92aa36bb82ca470b73e3c9e3859beaf Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Wed, 18 Mar 2026 07:19:14 -0500 Subject: [PATCH 11/23] Prepare adapters for Ormed 0.2.0 release --- packages/dashboard/CHANGELOG.md | 1 + packages/dashboard/pubspec.yaml | 3 +- packages/stem/CHANGELOG.md | 16 ++++++ .../stem/example/ecommerce/lib/src/app.dart | 19 +------ .../lib/src/database/datasource.dart | 19 +------ .../ecommerce/lib/src/domain/repository.dart | 6 +-- packages/stem/example/ecommerce/pubspec.yaml | 6 +-- packages/stem_adapter_tests/CHANGELOG.md | 2 + .../lib/src/workflow_script_facade_suite.dart | 8 ++- packages/stem_adapter_tests/pubspec.yaml | 2 +- packages/stem_builder/CHANGELOG.md | 8 +++ packages/stem_builder/pubspec.yaml | 2 +- packages/stem_cli/lib/src/cli/utilities.dart | 2 +- .../test/unit/cli/cli_tasks_test.dart | 4 +- .../test/unit/cli/cli_worker_stats_test.dart | 18 +++---- .../test/unit/cli/cli_worker_status_test.dart | 4 +- .../cli/cli_workflow_agent_help_test.dart | 6 +-- .../test/unit/cli/cli_workflow_test.dart | 4 +- .../stem_cli/test/unit/cli/dlq_cli_test.dart | 8 +-- packages/stem_memory/CHANGELOG.md | 2 + packages/stem_memory/pubspec.yaml | 4 +- packages/stem_postgres/CHANGELOG.md | 9 ++++ .../lib/src/database/datasource.dart | 53 +++++++------------ packages/stem_postgres/pubspec.yaml | 10 ++-- packages/stem_redis/CHANGELOG.md | 2 + packages/stem_redis/pubspec.yaml | 4 +- .../test/chaos/worker_resilience_test.dart | 2 +- packages/stem_sqlite/CHANGELOG.md | 7 +++ packages/stem_sqlite/lib/src/connection.dart | 7 +-- packages/stem_sqlite/pubspec.yaml | 8 +-- .../backend/sqlite_result_backend_test.dart | 9 +--- .../test/broker/sqlite_broker_test.dart | 9 +--- .../control/sqlite_revoke_store_test.dart | 9 +--- .../workflow/sqlite_workflow_store_test.dart | 9 +--- 34 files changed, 125 insertions(+), 157 deletions(-) diff --git a/packages/dashboard/CHANGELOG.md b/packages/dashboard/CHANGELOG.md index b9e5c2bf..59faf731 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. 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..6ec61d2b 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,22 @@ ## 0.1.1 +- Added workflow manifests, runtime metadata views, and run/step drilldown APIs + for inspecting workflow definitions and persisted execution state. +- Improved workflow store contracts and runtime compatibility for caller- + supplied run ids and persisted runtime metadata attached to workflow params. +- 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/example/ecommerce/lib/src/app.dart b/packages/stem/example/ecommerce/lib/src/app.dart index 663230d2..6bbad20d 100644 --- a/packages/stem/example/ecommerce/lib/src/app.dart +++ b/packages/stem/example/ecommerce/lib/src/app.dart @@ -1,5 +1,4 @@ import 'dart:convert'; -import 'dart:isolate'; import 'dart:io'; import 'package:path/path.dart' as p; import 'package:shelf/shelf.dart'; @@ -27,16 +26,11 @@ class EcommerceServer { static Future create({String? databasePath}) async { final commerceDatabasePath = await _resolveDatabasePath(databasePath); - final packageRoot = await _resolvePackageRoot(); - final ormConfigPath = p.join(packageRoot, 'ormed.yaml'); final stemDatabasePath = p.join( p.dirname(commerceDatabasePath), 'stem_runtime.sqlite', ); - final repository = await EcommerceRepository.open( - commerceDatabasePath, - ormConfigPath: ormConfigPath, - ); + final repository = await EcommerceRepository.open(commerceDatabasePath); bindAddToCartWorkflowRepository(repository); final workflowApp = await StemWorkflowApp.fromUrl( @@ -222,17 +216,6 @@ Future _resolveDatabasePath(String? path) async { return p.join(directory.path, 'ecommerce.sqlite'); } -Future _resolvePackageRoot() async { - final packageUri = await Isolate.resolvePackageUri( - Uri.parse('package:stem_ecommerce_example/ecommerce.dart'), - ); - if (packageUri == null || packageUri.scheme != 'file') { - throw StateError('Unable to resolve package root for ecommerce example.'); - } - final packageFilePath = packageUri.toFilePath(); - return p.dirname(p.dirname(packageFilePath)); -} - Future> _readJsonMap(Request request) async { final body = await request.readAsString(); if (body.trim().isEmpty) { diff --git a/packages/stem/example/ecommerce/lib/src/database/datasource.dart b/packages/stem/example/ecommerce/lib/src/database/datasource.dart index 234cbf63..11194892 100644 --- a/packages/stem/example/ecommerce/lib/src/database/datasource.dart +++ b/packages/stem/example/ecommerce/lib/src/database/datasource.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:ormed/ormed.dart'; import 'package:ormed_sqlite/ormed_sqlite.dart'; @@ -8,19 +6,9 @@ import 'migrations.dart'; Future openEcommerceDataSource({ required String databasePath, - required String ormConfigPath, }) async { - final configFile = File(ormConfigPath); - final baseConfig = loadOrmProjectConfig(configFile); - final config = baseConfig.updateActiveConnection( - driver: baseConfig.driver.copyWith( - options: {...baseConfig.driver.options, 'database': databasePath}, - ), - ); - ensureSqliteDriverRegistration(); - - final dataSource = DataSource.fromConfig(config, registry: bootstrapOrm()); + final dataSource = bootstrapOrm().sqliteFileDataSource(path: databasePath); await dataSource.init(); @@ -30,10 +18,7 @@ Future openEcommerceDataSource({ } final schemaDriver = driver as SchemaDriver; - final ledger = SqlMigrationLedger( - driver, - tableName: config.migrations.ledgerTable, - ); + final ledger = SqlMigrationLedger(driver, tableName: 'orm_migrations'); await ledger.ensureInitialized(); final runner = MigrationRunner( diff --git a/packages/stem/example/ecommerce/lib/src/domain/repository.dart b/packages/stem/example/ecommerce/lib/src/domain/repository.dart index 30052bbb..087158d3 100644 --- a/packages/stem/example/ecommerce/lib/src/domain/repository.dart +++ b/packages/stem/example/ecommerce/lib/src/domain/repository.dart @@ -18,17 +18,13 @@ class EcommerceRepository { int _idCounter = 0; - static Future open( - String databasePath, { - required String ormConfigPath, - }) async { + 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, - ormConfigPath: ormConfigPath, ); final repository = EcommerceRepository._( diff --git a/packages/stem/example/ecommerce/pubspec.yaml b/packages/stem/example/ecommerce/pubspec.yaml index da9f9cdc..975c540a 100644 --- a/packages/stem/example/ecommerce/pubspec.yaml +++ b/packages/stem/example/ecommerce/pubspec.yaml @@ -7,8 +7,8 @@ environment: sdk: ">=3.9.2 <4.0.0" dependencies: - ormed: ^0.1.0 - ormed_sqlite: ^0.1.0 + ormed: ^0.2.0 + ormed_sqlite: ^0.2.0 path: ^1.9.1 shelf: ^1.4.2 shelf_router: ^1.1.4 @@ -19,7 +19,7 @@ dependencies: dev_dependencies: build_runner: ^2.10.5 - ormed_cli: any + ormed_cli: ^0.2.0 lints: ^6.0.0 server_testing: ^0.3.2 server_testing_shelf: ^0.3.2 diff --git a/packages/stem_adapter_tests/CHANGELOG.md b/packages/stem_adapter_tests/CHANGELOG.md index 48cb9d8f..7728d21f 100644 --- a/packages/stem_adapter_tests/CHANGELOG.md +++ b/packages/stem_adapter_tests/CHANGELOG.md @@ -2,6 +2,8 @@ ## 0.1.1 +- Added workflow-store contract coverage for caller-provided run ids and + persisted workflow runtime metadata introspection. - 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..3f0e340c 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 @@ -11,7 +11,6 @@ void runWorkflowScriptFacadeTests({ WorkflowStore? store; InMemoryBroker? broker; InMemoryResultBackend? backend; - SimpleTaskRegistry? registry; Stem? stem; WorkflowRuntime? runtime; late FakeWorkflowClock clock; @@ -21,8 +20,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 +29,7 @@ void runWorkflowScriptFacadeTests({ clock: clock, pollInterval: const Duration(milliseconds: 50), ); - registry!.register(runtime!.workflowRunnerHandler()); + currentRegistry.register(runtime!.workflowRunnerHandler()); }); tearDown(() async { @@ -44,7 +43,6 @@ void runWorkflowScriptFacadeTests({ store = null; broker = null; backend = null; - registry = null; stem = null; runtime = null; }); 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..695e09cc 100644 --- a/packages/stem_builder/CHANGELOG.md +++ b/packages/stem_builder/CHANGELOG.md @@ -2,6 +2,14 @@ ## 0.1.0 +- 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. - 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/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_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..fa6da5eb 100644 --- a/packages/stem_memory/CHANGELOG.md +++ b/packages/stem_memory/CHANGELOG.md @@ -2,6 +2,8 @@ ## 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. - 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/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..ce304969 100644 --- a/packages/stem_postgres/CHANGELOG.md +++ b/packages/stem_postgres/CHANGELOG.md @@ -1,5 +1,14 @@ # 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. +- 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/database/datasource.dart b/packages/stem_postgres/lib/src/database/datasource.dart index f6deedce..4d4b3cbb 100644 --- a/packages/stem_postgres/lib/src/database/datasource.dart +++ b/packages/stem_postgres/lib/src/database/datasource.dart @@ -11,48 +11,31 @@ DataSource createDataSource({ contextual.Logger? logger, }) { ensurePostgresDriverRegistration(); + final registry = bootstrapOrm(); - 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 = registry + .postgresDataSourceOptionsFromEnv( + environment: {'DATABASE_URL': connectionString}, + logging: logging, ) - : loadOrmConfig(); + .copyWith(logger: logger ?? stemLogger); + return DataSource(options); + } + + var config = loadOrmConfig(); - if (connectionString == null || connectionString.isEmpty) { - if (logging) { - config = config.updateActiveConnection( - driver: config.driver.copyWith( - options: {...config.driver.options, 'logging': true}, - ), - ); - } + if (logging) { + config = config.updateActiveConnection( + driver: config.driver.copyWith( + options: {...config.driver.options, 'logging': true}, + ), + ); } return DataSource.fromConfig( config, - registry: bootstrapOrm(), + registry: registry, logger: logger ?? stemLogger, ); } 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_redis/CHANGELOG.md b/packages/stem_redis/CHANGELOG.md index 48f65074..3ab1f07b 100644 --- a/packages/stem_redis/CHANGELOG.md +++ b/packages/stem_redis/CHANGELOG.md @@ -2,6 +2,8 @@ ## 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. - 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/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..5bdfa3dc 100644 --- a/packages/stem_sqlite/CHANGELOG.md +++ b/packages/stem_sqlite/CHANGELOG.md @@ -2,6 +2,13 @@ ## 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. +- 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..d4fe4f75 100644 --- a/packages/stem_sqlite/lib/src/connection.dart +++ b/packages/stem_sqlite/lib/src/connection.dart @@ -76,13 +76,10 @@ Future _openDataSource(File file, {required bool readOnly}) async { } 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;'); } 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..883566e2 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'; @@ -50,12 +49,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..6337b162 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'; @@ -74,12 +73,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..810717a6 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'; @@ -30,12 +29,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..376eecd1 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'; @@ -26,12 +25,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 { From 7fe3835074acbcd08b50dc1a9da4b4d2b6a53180 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Wed, 18 Mar 2026 07:39:06 -0500 Subject: [PATCH 12/23] Remove explicit adapter registration calls --- .../lib/src/database/datasource.dart | 1 - packages/stem_postgres/CHANGELOG.md | 3 + .../stem_postgres/lib/src/connection.dart | 2 - .../lib/src/database/datasource.dart | 65 ++++++++++++++++--- .../lib/src/database/seed_runtime.dart | 6 +- .../test/support/postgres_test_harness.dart | 2 - packages/stem_sqlite/CHANGELOG.md | 3 + packages/stem_sqlite/lib/src/connection.dart | 3 - .../lib/src/database/datasource.dart | 36 ++++++++-- .../lib/src/database/seed_runtime.dart | 6 +- .../backend/sqlite_result_backend_test.dart | 1 - .../test/broker/sqlite_broker_test.dart | 1 - .../control/sqlite_revoke_store_test.dart | 1 - .../workflow/sqlite_workflow_store_test.dart | 1 - 14 files changed, 97 insertions(+), 34 deletions(-) diff --git a/packages/stem/example/ecommerce/lib/src/database/datasource.dart b/packages/stem/example/ecommerce/lib/src/database/datasource.dart index 11194892..5dc1f899 100644 --- a/packages/stem/example/ecommerce/lib/src/database/datasource.dart +++ b/packages/stem/example/ecommerce/lib/src/database/datasource.dart @@ -7,7 +7,6 @@ import 'migrations.dart'; Future openEcommerceDataSource({ required String databasePath, }) async { - ensureSqliteDriverRegistration(); final dataSource = bootstrapOrm().sqliteFileDataSource(path: databasePath); await dataSource.init(); diff --git a/packages/stem_postgres/CHANGELOG.md b/packages/stem_postgres/CHANGELOG.md index ce304969..99b7ba1a 100644 --- a/packages/stem_postgres/CHANGELOG.md +++ b/packages/stem_postgres/CHANGELOG.md @@ -5,6 +5,9 @@ - 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. 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 4d4b3cbb..0414c342 100644 --- a/packages/stem_postgres/lib/src/database/datasource.dart +++ b/packages/stem_postgres/lib/src/database/datasource.dart @@ -10,11 +10,8 @@ DataSource createDataSource({ bool logging = false, contextual.Logger? logger, }) { - ensurePostgresDriverRegistration(); - final registry = bootstrapOrm(); - if (connectionString != null && connectionString.isNotEmpty) { - final options = registry + final options = bootstrapOrm() .postgresDataSourceOptionsFromEnv( environment: {'DATABASE_URL': connectionString}, logging: logging, @@ -33,9 +30,59 @@ DataSource createDataSource({ ); } - return DataSource.fromConfig( - config, - registry: registry, - 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/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_sqlite/CHANGELOG.md b/packages/stem_sqlite/CHANGELOG.md index 5bdfa3dc..649766dd 100644 --- a/packages/stem_sqlite/CHANGELOG.md +++ b/packages/stem_sqlite/CHANGELOG.md @@ -6,6 +6,9 @@ `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. diff --git a/packages/stem_sqlite/lib/src/connection.dart b/packages/stem_sqlite/lib/src/connection.dart index d4fe4f75..6e51ce71 100644 --- a/packages/stem_sqlite/lib/src/connection.dart +++ b/packages/stem_sqlite/lib/src/connection.dart @@ -75,7 +75,6 @@ Future _openDataSource(File file, {required bool readOnly}) async { file.parent.createSync(recursive: true); } - ensureSqliteDriverRegistration(); final dataSource = buildOrmRegistry().sqliteFileDataSource(path: file.path); await dataSource.init(); if (!readOnly) { @@ -91,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'); @@ -109,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/test/backend/sqlite_result_backend_test.dart b/packages/stem_sqlite/test/backend/sqlite_result_backend_test.dart index 883566e2..c384643c 100644 --- a/packages/stem_sqlite/test/backend/sqlite_result_backend_test.dart +++ b/packages/stem_sqlite/test/backend/sqlite_result_backend_test.dart @@ -48,7 +48,6 @@ void main() { ); test('fromDataSource runs migrations', () async { - ensureSqliteDriverRegistration(); final dataSource = buildOrmRegistry().sqliteFileDataSource( path: dbFile.path, ); diff --git a/packages/stem_sqlite/test/broker/sqlite_broker_test.dart b/packages/stem_sqlite/test/broker/sqlite_broker_test.dart index 6337b162..3f4e63e6 100644 --- a/packages/stem_sqlite/test/broker/sqlite_broker_test.dart +++ b/packages/stem_sqlite/test/broker/sqlite_broker_test.dart @@ -72,7 +72,6 @@ void main() { ); test('fromDataSource runs migrations', () async { - ensureSqliteDriverRegistration(); final dataSource = buildOrmRegistry().sqliteFileDataSource( path: dbFile.path, ); 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 810717a6..359cfbd2 100644 --- a/packages/stem_sqlite/test/control/sqlite_revoke_store_test.dart +++ b/packages/stem_sqlite/test/control/sqlite_revoke_store_test.dart @@ -28,7 +28,6 @@ void main() { ); test('fromDataSource runs migrations', () async { - ensureSqliteDriverRegistration(); final dataSource = buildOrmRegistry().sqliteFileDataSource( path: dbFile.path, ); 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 376eecd1..2be20231 100644 --- a/packages/stem_sqlite/test/workflow/sqlite_workflow_store_test.dart +++ b/packages/stem_sqlite/test/workflow/sqlite_workflow_store_test.dart @@ -24,7 +24,6 @@ void main() { }); test('fromDataSource runs migrations', () async { - ensureSqliteDriverRegistration(); final dataSource = buildOrmRegistry().sqliteFileDataSource( path: dbFile.path, ); From 7f1ba4eda8aa0209de751b50941f9aa1b41348da Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Wed, 18 Mar 2026 19:29:59 -0500 Subject: [PATCH 13/23] Fix workflow runtime and store regressions --- packages/stem/CHANGELOG.md | 3 + packages/stem/lib/src/core/contracts.dart | 4 + .../workflow/runtime/workflow_runtime.dart | 22 ++- .../test/unit/core/task_registry_test.dart | 12 ++ packages/stem_adapter_tests/CHANGELOG.md | 2 + .../lib/src/workflow_script_facade_suite.dart | 106 ++++++++++++++ .../src/workflow_store_contract_suite.dart | 129 ++++++++++++------ packages/stem_memory/CHANGELOG.md | 2 + .../store/in_memory_workflow_store.dart | 6 +- packages/stem_redis/CHANGELOG.md | 2 + .../src/workflow/redis_workflow_store.dart | 54 +++++--- 11 files changed, 278 insertions(+), 64 deletions(-) diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 6ec61d2b..08033397 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -6,6 +6,9 @@ for inspecting workflow definitions and persisted execution state. - 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. diff --git a/packages/stem/lib/src/core/contracts.dart b/packages/stem/lib/src/core/contracts.dart index aaffc22d..4975f2a4 100644 --- a/packages/stem/lib/src/core/contracts.dart +++ b/packages/stem/lib/src/core/contracts.dart @@ -1922,6 +1922,10 @@ class InMemoryTaskRegistry 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/workflow/runtime/workflow_runtime.dart b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart index 2af82fe8..0e01e5a3 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart @@ -1012,15 +1012,29 @@ class WorkflowRuntime { continuationQueue: continuationQueue, executionQueue: executionQueue, ); - final targetQueue = continuation ? continuationQueue : queue; + 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': queue, - 'stem.workflow.continuationQueue': continuationQueue, - 'stem.workflow.executionQueue': executionQueue, + '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, diff --git a/packages/stem/test/unit/core/task_registry_test.dart b/packages/stem/test/unit/core/task_registry_test.dart index 54f4e22e..28fd54e6 100644 --- a/packages/stem/test/unit/core/task_registry_test.dart +++ b/packages/stem/test/unit/core/task_registry_test.dart @@ -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', () { diff --git a/packages/stem_adapter_tests/CHANGELOG.md b/packages/stem_adapter_tests/CHANGELOG.md index 7728d21f..c39cd1f5 100644 --- a/packages/stem_adapter_tests/CHANGELOG.md +++ b/packages/stem_adapter_tests/CHANGELOG.md @@ -4,6 +4,8 @@ - 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 3f0e340c..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'; @@ -190,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 3ecf7f7e..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. @@ -68,7 +76,7 @@ void runWorkflowStoreContractTests({ test('createRun honors caller-provided runId when supplied', () async { final current = store!; - const requestedRunId = 'contract-run-id'; + final requestedRunId = _nextRequestedRunId(); final runId = await current.createRun( runId: requestedRunId, workflow: 'contract.workflow', @@ -82,56 +90,93 @@ void runWorkflowStoreContractTests({ expect(state.params['seed'], 'value'); }); - test('createRun persists runtime metadata and workflowParams strips internals', - () async { + test('createRun rejects duplicate caller-provided runId', () 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 requestedRunId = _nextRequestedRunId(); + await current.createRun( + runId: requestedRunId, + workflow: 'contract.workflow.original', + params: const {'seed': 'value'}, ); - final params = runtimeMetadata.attachToParams(const { - 'tenant': 'acme', - 'jobType': 'sync', - }); - - final runId = await current.createRun( - workflow: 'contract.runtime.meta', - params: params, + 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(runId); + final state = await current.get(requestedRunId); expect(state, isNotNull); - expect(state!.params.containsKey(workflowRuntimeMetadataParamKey), isTrue); - expect( - state.params[workflowRuntimeMetadataParamKey], - runtimeMetadata.toJson(), - ); + expect(state!.workflow, 'contract.workflow.original'); + expect(state.params['seed'], 'value'); expect( - state.workflowParams, - equals(const {'tenant': 'acme', 'jobType': 'sync'}), + await current.readStep(requestedRunId, 'checkpoint'), + 'persisted', ); - 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( + '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_memory/CHANGELOG.md b/packages/stem_memory/CHANGELOG.md index fa6da5eb..d696229b 100644 --- a/packages/stem_memory/CHANGELOG.md +++ b/packages/stem_memory/CHANGELOG.md @@ -4,6 +4,8 @@ - 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 1ab591f3..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 @@ -95,10 +95,12 @@ class InMemoryWorkflowStore implements WorkflowStore { WorkflowCancellationPolicy? cancellationPolicy, }) async { final now = _clock.now(); - final id = - (runId != null && runId.trim().isNotEmpty) + 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_redis/CHANGELOG.md b/packages/stem_redis/CHANGELOG.md index 3ab1f07b..6024c30f 100644 --- a/packages/stem_redis/CHANGELOG.md +++ b/packages/stem_redis/CHANGELOG.md @@ -4,6 +4,8 @@ - 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 266d0a60..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 '''; @@ -335,31 +361,27 @@ return 1 (runId != null && runId.trim().isNotEmpty) ? runId.trim() : 'wf-${now.microsecondsSinceEpoch}-${_idCounter++}'; - final command = [ - 'HSET', + 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; } From 4a9a2909ea2c16333a24ac24b6eeeb8939eb49b3 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Wed, 18 Mar 2026 19:31:34 -0500 Subject: [PATCH 14/23] Clarify workflow checkpoint terminology --- packages/dashboard/CHANGELOG.md | 2 + .../dashboard/lib/src/services/models.dart | 65 +++-- .../dashboard/lib/src/ui/task_detail.dart | 4 +- packages/dashboard/lib/src/ui/workflows.dart | 34 +-- packages/stem/CHANGELOG.md | 3 + packages/stem/README.md | 272 ++++++++++++++++++ .../example/annotated_workflows/README.md | 33 +++ .../example/annotated_workflows/bin/main.dart | 61 +++- .../annotated_workflows/lib/definitions.dart | 117 +++++++- .../lib/definitions.stem.g.dart | 209 +++++++++++++- .../src/workflows/annotated_defs.stem.g.dart | 2 +- .../workflow/core/workflow_definition.dart | 15 +- .../src/workflow/core/workflow_script.dart | 6 + .../workflow/runtime/workflow_manifest.dart | 35 ++- .../unit/workflow/workflow_manifest_test.dart | 16 +- packages/stem_builder/CHANGELOG.md | 3 + packages/stem_builder/README.md | 85 ++++++ .../example/lib/definitions.stem.g.dart | 2 +- .../lib/src/stem_registry_builder.dart | 2 +- 19 files changed, 885 insertions(+), 81 deletions(-) diff --git a/packages/dashboard/CHANGELOG.md b/packages/dashboard/CHANGELOG.md index 59faf731..a29b4b39 100644 --- a/packages/dashboard/CHANGELOG.md +++ b/packages/dashboard/CHANGELOG.md @@ -14,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/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 08033397..949bcbd0 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -4,6 +4,9 @@ - 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 diff --git a/packages/stem/README.md b/packages/stem/README.md index c205bf79..7f723d96 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -325,6 +325,265 @@ Inside a script step you can access the same metadata as `FlowContext`: - `step.takeResumeData()` surfaces 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 starters + +The runtime shape is the same in every case: + +- bootstrap a `StemWorkflowApp` +- pass `flows:`, `scripts:`, and `tasks:` directly +- start runs with `startWorkflow(...)` or generated `startXxx(...)` helpers +- 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.takeResumeData() as Map?; + 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.takeResumeData() as Map?; + 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 starter helpers. + +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 +- not supported directly: + - arbitrary Dart class instances + - optional/named parameters on generated workflow/task entrypoints + +If you want to pass a domain object, encode it into a serializable map first +and decode it inside the workflow or task body. + +See the runnable example: + +- [example/annotated_workflows](example/annotated_workflows) + - `FlowContext` metadata + - plain proxy-driven script step calls + - `WorkflowScriptContext` + `WorkflowScriptStepContext` + - typed `@TaskDefn` decoding scalar, `Map`, and `List` parameters + +Generate code: + +```bash +dart run build_runner build +``` + +Wire the generated definitions directly into `StemWorkflowApp`: + +```dart +final app = await StemWorkflowApp.fromUrl( + 'memory://', + flows: stemFlows, + scripts: stemScripts, + tasks: stemTasks, +); + +final runId = await app.startUserSignup(email: 'user@example.com'); +final result = await app.waitForCompletion>(runId); +print(result?.value); +await app.close(); +``` + +Generated output gives you: + +- `stemFlows` +- `stemScripts` +- `stemTasks` +- `StemWorkflowNames` +- typed starter helpers on `StemWorkflowApp` and `WorkflowRuntime` + +If your service already owns a `StemApp`, reuse it: + +```dart +final stemApp = await StemApp.fromUrl( + 'redis://localhost:6379', + adapters: const [StemRedisAdapter()], + tasks: stemTasks, +); + +final workflowApp = await StemWorkflowApp.create( + stemApp: stemApp, + flows: stemFlows, + scripts: stemScripts, + tasks: stemTasks, +); +``` + +#### 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 with `tasks:` + ### Typed workflow completion All workflow definitions (flows and scripts) accept an optional type argument @@ -345,6 +604,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` diff --git a/packages/stem/example/annotated_workflows/README.md b/packages/stem/example/annotated_workflows/README.md index 73880850..22363f94 100644 --- a/packages/stem/example/annotated_workflows/README.md +++ b/packages/stem/example/annotated_workflows/README.md @@ -3,6 +3,39 @@ This example shows how to use `@WorkflowDefn`, `@WorkflowStep`, and `@TaskDefn` with the `stem_builder` registry generator. +It now demonstrates the generated script-proxy behavior explicitly: +- a flow step using `FlowContext` +- `run(String email)` 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 typed `@TaskDefn` using `TaskInvocationContext` plus serializable scalar, + `Map`, and `List` parameters + +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 context-aware script result with workflow metadata +- the persisted step order for the context-aware workflow +- the typed task result showing decoded serializable parameters and task + invocation metadata + +## Serializable parameter rules + +For `stem_builder`, generated workflow/task entrypoints only support required +positional parameters that are serializable: + +- `String`, `bool`, `int`, `double`, `num`, `Object?`, `null` +- `List` where `T` is serializable +- `Map` where `T` is serializable + +Arbitrary Dart class instances are not supported directly. Encode them into a +serializable map first, then decode them inside your workflow or task if you +want a richer domain model. + ## Run ```bash diff --git a/packages/stem/example/annotated_workflows/bin/main.dart b/packages/stem/example/annotated_workflows/bin/main.dart index b08be130..4b2f68e8 100644 --- a/packages/stem/example/annotated_workflows/bin/main.dart +++ b/packages/stem/example/annotated_workflows/bin/main.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:stem/stem.dart'; import 'package:stem_annotated_workflows/definitions.dart'; @@ -10,22 +12,69 @@ Future main() async { final app = await client.createWorkflowApp( flows: stemFlows, scripts: stemScripts, + 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 app.startFlow(); + final flowResult = await app.waitForCompletion>( flowRunId, timeout: const Duration(seconds: 2), ); - print('Flow result: ${flowResult?.value}'); + print('Flow result: ${jsonEncode(flowResult?.value)}'); - final scriptRunId = await app.startWorkflow('annotated.script'); - final scriptResult = await app.waitForCompletion( + final scriptRunId = await app.startScript(email: ' SomeEmail@Example.com '); + final scriptResult = await app.waitForCompletion>( scriptRunId, timeout: const Duration(seconds: 2), ); - print('Script result: ${scriptResult?.value}'); + print('Script result: ${jsonEncode(scriptResult?.value)}'); + + final scriptDetail = await app.runtime.viewRunDetail(scriptRunId); + final scriptCheckpoints = scriptDetail?.steps + .map((step) => step.baseStepName) + .join(' -> '); + print('Script checkpoints: $scriptCheckpoints'); + print('Script detail: ${jsonEncode(scriptDetail?.toJson())}'); + + final contextRunId = await app.startContextScript( + email: ' ContextEmail@Example.com ', + ); + final contextResult = await app.waitForCompletion>( + contextRunId, + timeout: const Duration(seconds: 2), + ); + print('Context script result: ${jsonEncode(contextResult?.value)}'); + + final contextDetail = await app.runtime.viewRunDetail(contextRunId); + final contextCheckpoints = contextDetail?.steps + .map((step) => step.baseStepName) + .join(' -> '); + print('Context script checkpoints: $contextCheckpoints'); + print('Context script detail: ${jsonEncode(contextDetail?.toJson())}'); + + final typedTaskId = await app.app.stem.enqueue( + 'send_email_typed', + args: { + 'email': 'typed@example.com', + 'message': {'subject': 'Welcome', 'body': 'Serializable payloads only'}, + 'tags': [ + 'welcome', + 1, + true, + {'channel': 'email'}, + ], + }, + meta: const {'origin': 'annotated_workflows_example'}, + ); + final typedTaskResult = await app.app.stem.waitForTask>( + typedTaskId, + timeout: const Duration(seconds: 2), + ); + print('Typed task result: ${jsonEncode(typedTaskResult?.value)}'); 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 e86a2f2c..fff73813 100644 --- a/packages/stem/example/annotated_workflows/lib/definitions.dart +++ b/packages/stem/example/annotated_workflows/lib/definitions.dart @@ -5,27 +5,105 @@ part 'definitions.stem.g.dart'; @WorkflowDefn(name: 'annotated.flow') class AnnotatedFlowWorkflow { @WorkflowStep() - Future start(FlowContext ctx) async { + 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(String email) async { + final prepared = await prepareWelcome(email); + final normalizedEmail = prepared['normalizedEmail'] as String; + final subject = prepared['subject'] as String; + final followUp = await deliverWelcome(normalizedEmail, subject); + return { + 'normalizedEmail': normalizedEmail, + 'subject': subject, + 'followUp': followUp, + }; + } + + @WorkflowStep(name: 'prepare-welcome') + Future> prepareWelcome(String email) async { + final normalizedEmail = await normalizeEmail(email); + final subject = await buildWelcomeSubject(normalizedEmail); + return {'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, + String email, + ) async { + return script.step>( + 'enter-context-step', + (ctx) => captureContext(ctx, email), + ); + } + + @WorkflowStep(name: 'capture-context') + Future> captureContext( + WorkflowScriptStepContext ctx, + String email, + ) async { + final normalizedEmail = await normalizeEmail(email); + final subject = await buildWelcomeSubject(normalizedEmail); + return { + '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'; } } @@ -36,3 +114,22 @@ Future sendEmail( ) async { // No-op task for example purposes. } + +@TaskDefn(name: 'send_email_typed', options: TaskOptions(maxRetries: 1)) +Future> sendEmailTyped( + TaskInvocationContext ctx, + String email, + Map message, + List tags, +) async { + ctx.heartbeat(); + await ctx.progress(100, data: {'email': email, 'tagCount': tags.length}); + return { + 'taskId': ctx.id, + 'attempt': ctx.attempt, + 'email': email, + 'subject': message['subject'], + 'tags': 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 index 0bff0452..9f54362d 100644 --- a/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart +++ b/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart @@ -18,16 +18,152 @@ final List stemFlows = [ ), ]; +class _StemScriptProxy0 extends AnnotatedScriptWorkflow { + _StemScriptProxy0(this._script); + final WorkflowScriptContext _script; + @override + Future> prepareWelcome(String email) { + return _script.step>( + "prepare-welcome", + (context) => super.prepareWelcome(email), + ); + } + + @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, + String email, + ) { + return _script.step>( + "capture-context", + (context) => super.captureContext(context, email), + ); + } + + @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", - run: (script) => AnnotatedScriptWorkflow().run(script), + checkpoints: [ + FlowStep( + name: "prepare-welcome", + handler: _stemScriptManifestStepNoop, + 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: [], + ), + ], + run: (script) => _StemScriptProxy0( + script, + ).run((_stemRequireArg(script.params, "email") as String)), + ), + WorkflowScript( + name: "annotated.context_script", + checkpoints: [ + FlowStep( + name: "capture-context", + handler: _stemScriptManifestStepNoop, + 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: [], + ), + ], + run: (script) => _StemScriptProxy1( + script, + ).run(script, (_stemRequireArg(script.params, "email") as String)), ), ]; abstract final class StemWorkflowNames { static const String flow = "annotated.flow"; static const String script = "annotated.script"; + static const String contextScript = "annotated.context_script"; } extension StemGeneratedWorkflowAppStarters on StemWorkflowApp { @@ -47,11 +183,13 @@ extension StemGeneratedWorkflowAppStarters on StemWorkflowApp { } Future startScript({ - Map params = const {}, + required String email, + Map extraParams = const {}, String? parentRunId, Duration? ttl, WorkflowCancellationPolicy? cancellationPolicy, }) { + final params = {...extraParams, "email": email}; return startWorkflow( StemWorkflowNames.script, params: params, @@ -60,6 +198,23 @@ extension StemGeneratedWorkflowAppStarters on StemWorkflowApp { cancellationPolicy: cancellationPolicy, ); } + + Future startContextScript({ + required String email, + Map extraParams = const {}, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + final params = {...extraParams, "email": email}; + return startWorkflow( + StemWorkflowNames.contextScript, + params: params, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } } extension StemGeneratedWorkflowRuntimeStarters on WorkflowRuntime { @@ -79,11 +234,13 @@ extension StemGeneratedWorkflowRuntimeStarters on WorkflowRuntime { } Future startScript({ - Map params = const {}, + required String email, + Map extraParams = const {}, String? parentRunId, Duration? ttl, WorkflowCancellationPolicy? cancellationPolicy, }) { + final params = {...extraParams, "email": email}; return startWorkflow( StemWorkflowNames.script, params: params, @@ -92,6 +249,23 @@ extension StemGeneratedWorkflowRuntimeStarters on WorkflowRuntime { cancellationPolicy: cancellationPolicy, ); } + + Future startContextScript({ + required String email, + Map extraParams = const {}, + String? parentRunId, + Duration? ttl, + WorkflowCancellationPolicy? cancellationPolicy, + }) { + final params = {...extraParams, "email": email}; + return startWorkflow( + StemWorkflowNames.contextScript, + params: params, + parentRunId: parentRunId, + ttl: ttl, + cancellationPolicy: cancellationPolicy, + ); + } } final List stemWorkflowManifest = @@ -100,6 +274,29 @@ final List stemWorkflowManifest = ...stemScripts.map((script) => script.definition.toManifestEntry()), ]; +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, + (_stemRequireArg(args, "email") as String), + (_stemRequireArg(args, "message") as Map), + (_stemRequireArg(args, "tags") as List), + ), + ); +} + final List> stemTasks = >[ FunctionTaskHandler( name: "send_email", @@ -107,6 +304,12 @@ final List> stemTasks = >[ options: const TaskOptions(maxRetries: 1), metadata: const TaskMetadata(), ), + FunctionTaskHandler( + name: "send_email_typed", + entrypoint: _stemTaskAdapter0, + options: const TaskOptions(maxRetries: 1), + metadata: const TaskMetadata(), + ), ]; void registerStemDefinitions({ 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 index 686f61e4..8eac2f04 100644 --- 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 @@ -36,7 +36,7 @@ class _StemScriptProxy0 extends AddToCartWorkflow { final List stemScripts = [ WorkflowScript( name: "ecommerce.cart.add_item", - steps: [ + checkpoints: [ FlowStep( name: "validate-input", handler: _stemScriptManifestStepNoop, diff --git a/packages/stem/lib/src/workflow/core/workflow_definition.dart b/packages/stem/lib/src/workflow/core/workflow_definition.dart index 2eba6e48..6302f3de 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', ...); @@ -77,8 +77,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._({ @@ -160,14 +161,16 @@ class WorkflowDefinition { required String name, required WorkflowScriptBody run, Iterable steps = const [], + Iterable checkpoints = const [], String? version, String? description, Map? metadata, }) { + final declaredCheckpoints = checkpoints.isNotEmpty ? checkpoints : steps; return WorkflowDefinition._( name: name, kind: WorkflowDefinitionKind.script, - steps: List.unmodifiable(steps), + steps: List.unmodifiable(declaredCheckpoints), version: version, description: description, metadata: metadata, diff --git a/packages/stem/lib/src/workflow/core/workflow_script.dart b/packages/stem/lib/src/workflow/core/workflow_script.dart index 4bf36546..242d05dc 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script.dart @@ -3,12 +3,17 @@ import 'package:stem/src/workflow/core/flow_step.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, @@ -16,6 +21,7 @@ class WorkflowScript { name: name, run: run, steps: steps, + checkpoints: checkpoints, version: version, description: description, metadata: metadata, diff --git a/packages/stem/lib/src/workflow/runtime/workflow_manifest.dart b/packages/stem/lib/src/workflow/runtime/workflow_manifest.dart index b7c700d9..091b4b32 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_manifest.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_manifest.dart @@ -3,6 +3,15 @@ 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. @@ -34,11 +43,21 @@ class WorkflowManifestEntry { /// Optional workflow metadata. final Map? metadata; - /// Step manifest entries (flow workflows only). + /// 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, @@ -46,18 +65,21 @@ class WorkflowManifestEntry { if (version != null) 'version': version, if (description != null) 'description': description, if (metadata != null) 'metadata': metadata, - 'steps': steps.map((step) => step.toJson()).toList(growable: false), + 'stepCollectionLabel': stepCollectionLabel, + 'steps': serializedSteps, + if (kind == WorkflowDefinitionKind.script) 'checkpoints': serializedSteps, }; } } -/// Immutable manifest entry describing a workflow step. +/// 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, @@ -74,6 +96,9 @@ class WorkflowManifestStep { /// 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; @@ -95,6 +120,7 @@ class WorkflowManifestStep { 'id': id, 'name': name, 'position': position, + 'role': role.name, 'kind': kind.name, 'autoVersion': autoVersion, if (title != null) 'title': title, @@ -117,6 +143,9 @@ extension WorkflowManifestDefinition on WorkflowDefinition { 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, diff --git a/packages/stem/test/unit/workflow/workflow_manifest_test.dart b/packages/stem/test/unit/workflow/workflow_manifest_test.dart index e5fcba45..e1559327 100644 --- a/packages/stem/test/unit/workflow/workflow_manifest_test.dart +++ b/packages/stem/test/unit/workflow/workflow_manifest_test.dart @@ -21,21 +21,27 @@ void main() { 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 step metadata', () { + 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'}; }, - steps: [ + checkpoints: [ FlowStep( name: 'create-user', title: 'Create user', @@ -55,9 +61,15 @@ void main() { 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)); diff --git a/packages/stem_builder/CHANGELOG.md b/packages/stem_builder/CHANGELOG.md index 695e09cc..c9d7b228 100644 --- a/packages/stem_builder/CHANGELOG.md +++ b/packages/stem_builder/CHANGELOG.md @@ -10,6 +10,9 @@ 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 3eafed23..fb1ea694 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -66,6 +66,68 @@ 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 +- unsupported directly: + - arbitrary Dart class instances + - optional/named parameters on generated workflow/task entrypoints + +If you need to pass a richer domain object, encode it as +`Map` first and decode it inside the workflow or task body. + +The intended DX is: + +- define annotated workflows and tasks in one file +- add `part '.stem.g.dart';` +- run `build_runner` +- pass generated `stemFlows`, `stemScripts`, and `stemTasks` into + `StemWorkflowApp` +- start workflows through generated `startXxx(...)` helpers instead of raw + workflow-name strings + You can customize generated starter names via `@WorkflowDefn`: ```dart @@ -91,6 +153,16 @@ The generated part exports helpers like `registerStemDefinitions`, starters so you can avoid raw workflow-name strings (for example `runtime.startScript(email: 'user@example.com')`). +Generated output includes: + +- `stemFlows` +- `stemScripts` +- `stemTasks` +- `StemWorkflowNames` +- starter extensions on both `StemWorkflowApp` and `WorkflowRuntime` +- convenience helpers for creating generated apps in memory or on top of an + existing `StemApp` + ## Wiring Into StemWorkflowApp For the common case, pass generated tasks and workflows directly to @@ -103,6 +175,9 @@ final workflowApp = await StemWorkflowApp.fromUrl( flows: stemFlows, tasks: stemTasks, ); + +final runId = await workflowApp.startUserSignup(email: 'user@example.com'); +final result = await workflowApp.waitForCompletion>(runId); ``` If your application already owns a `StemApp`, reuse it: @@ -122,6 +197,14 @@ final workflowApp = await StemWorkflowApp.create( ); ``` +The generated helpers work on `WorkflowRuntime` too: + +```dart +final runtime = workflowApp.runtime; +final runId = await runtime.startUserSignup(email: 'user@example.com'); +await runtime.executeRun(runId); +``` + You only need `registerStemDefinitions(...)` when you are integrating with existing custom `WorkflowRegistry` and `TaskRegistry` instances manually. @@ -131,3 +214,5 @@ 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/example/lib/definitions.stem.g.dart b/packages/stem_builder/example/lib/definitions.stem.g.dart index 5130f1ab..8b74ae78 100644 --- a/packages/stem_builder/example/lib/definitions.stem.g.dart +++ b/packages/stem_builder/example/lib/definitions.stem.g.dart @@ -49,7 +49,7 @@ class _StemScriptProxy0 extends BuilderUserSignupWorkflow { final List stemScripts = [ WorkflowScript( name: "builder.example.user_signup", - steps: [ + checkpoints: [ FlowStep( name: "create-user", handler: _stemScriptManifestStepNoop, diff --git a/packages/stem_builder/lib/src/stem_registry_builder.dart b/packages/stem_builder/lib/src/stem_registry_builder.dart index 255103d3..c08d4862 100644 --- a/packages/stem_builder/lib/src/stem_registry_builder.dart +++ b/packages/stem_builder/lib/src/stem_registry_builder.dart @@ -1038,7 +1038,7 @@ class _RegistryEmitter { buffer.writeln(' WorkflowScript('); buffer.writeln(' name: ${_string(workflow.name)},'); if (workflow.steps.isNotEmpty) { - buffer.writeln(' steps: ['); + buffer.writeln(' checkpoints: ['); for (final step in workflow.steps) { buffer.writeln(' FlowStep('); buffer.writeln(' name: ${_string(step.name)},'); From 8c441cb95ccdb2d3631bd53fbb518611ed1d8b5c Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Wed, 18 Mar 2026 19:32:35 -0500 Subject: [PATCH 15/23] Add dedicated workflows docs section --- .site/docs/comparisons/stem-vs-bullmq.md | 4 +- .site/docs/core-concepts/index.md | 2 +- .site/docs/core-concepts/stem-builder.md | 15 +- .site/docs/core-concepts/workflows.md | 170 +++--------------- .site/docs/getting-started/index.md | 3 +- .site/docs/getting-started/next-steps.md | 2 +- .site/docs/workflows/annotated-workflows.md | 107 +++++++++++ .../workflows/context-and-serialization.md | 70 ++++++++ .../errors-retries-and-idempotency.md | 47 +++++ .site/docs/workflows/flows-and-scripts.md | 55 ++++++ .site/docs/workflows/getting-started.md | 54 ++++++ .site/docs/workflows/how-it-works.md | 69 +++++++ .site/docs/workflows/index.md | 67 +++++++ .site/docs/workflows/observability.md | 54 ++++++ .site/docs/workflows/starting-and-waiting.md | 49 +++++ .../docs/workflows/suspensions-and-events.md | 55 ++++++ .site/docs/workflows/troubleshooting.md | 49 +++++ .site/sidebars.ts | 36 +++- .site/static/img/favicon.ico | Bin 3626 -> 115753 bytes .site/static/img/stem-icon.png | Bin 0 -> 51668 bytes 20 files changed, 754 insertions(+), 154 deletions(-) create mode 100644 .site/docs/workflows/annotated-workflows.md create mode 100644 .site/docs/workflows/context-and-serialization.md create mode 100644 .site/docs/workflows/errors-retries-and-idempotency.md create mode 100644 .site/docs/workflows/flows-and-scripts.md create mode 100644 .site/docs/workflows/getting-started.md create mode 100644 .site/docs/workflows/how-it-works.md create mode 100644 .site/docs/workflows/index.md create mode 100644 .site/docs/workflows/observability.md create mode 100644 .site/docs/workflows/starting-and-waiting.md create mode 100644 .site/docs/workflows/suspensions-and-events.md create mode 100644 .site/docs/workflows/troubleshooting.md create mode 100644 .site/static/img/stem-icon.png diff --git a/.site/docs/comparisons/stem-vs-bullmq.md b/.site/docs/comparisons/stem-vs-bullmq.md index c95329a2..5a065297 100644 --- a/.site/docs/comparisons/stem-vs-bullmq.md +++ b/.site/docs/comparisons/stem-vs-bullmq.md @@ -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/index.md b/.site/docs/core-concepts/index.md index 1cb5bc38..27cfb56e 100644 --- a/.site/docs/core-concepts/index.md +++ b/.site/docs/core-concepts/index.md @@ -53,7 +53,7 @@ behavior before touching production. - **[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. +- **[Workflows](../workflows/index.md)** – Durable workflow orchestration, suspensions, recovery, and annotated workflow generation. - **[stem_builder](./stem-builder.md)** – Generate workflow/task registries and typed starters from annotations. - **[CLI & Control](./cli-control.md)** – Quickly inspect queues, workers, and health from the command line. diff --git a/.site/docs/core-concepts/stem-builder.md b/.site/docs/core-concepts/stem-builder.md index 8e14288d..2730911b 100644 --- a/.site/docs/core-concepts/stem-builder.md +++ b/.site/docs/core-concepts/stem-builder.md @@ -8,6 +8,14 @@ slug: /core-concepts/stem-builder `stem_builder` generates workflow/task registries and typed workflow starters 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 @@ -103,4 +111,9 @@ final workflowApp = await StemWorkflowApp.create( - 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`. +- Arbitrary Dart class instances are not supported directly; encode them into + `Map` first. diff --git a/.site/docs/core-concepts/workflows.md b/.site/docs/core-concepts/workflows.md index 77a0a575..87749b60 100644 --- a/.site/docs/core-concepts/workflows.md +++ b/.site/docs/core-concepts/workflows.md @@ -11,7 +11,19 @@ state, typed results, automatic retries, and event-driven resumes. The event bus, and runtime so you can start runs, monitor progress, and interact with suspended steps 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 @@ -30,153 +42,17 @@ Start the runtime once the app is constructed: - `eventBus` – emits topics that resume waiting steps. - `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`, `@WorkflowStep`, optional `@WorkflowRun`, and `@TaskDefn`, -then generate the workflow/task helpers with `stem_builder`. - -```dart title="lib/workflows/annotated.dart" file=/../packages/stem/example/docs_snippets/lib/workflows.dart#workflows-annotated - -``` - -Generate the helpers (example): - -```bash -dart pub add --dev build_runner stem_builder -dart run build_runner build -``` - -For full setup and generated API details, see -[stem_builder](./stem-builder.md). - -## 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 encoder -configuration—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/index.md b/.site/docs/getting-started/index.md index 7e964ed9..521982cb 100644 --- a/.site/docs/getting-started/index.md +++ b/.site/docs/getting-started/index.md @@ -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/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/workflows/annotated-workflows.md b/.site/docs/workflows/annotated-workflows.md new file mode 100644 index 00000000..03568504 --- /dev/null +++ b/.site/docs/workflows/annotated-workflows.md @@ -0,0 +1,107 @@ +--- +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: + +- `stemFlows` +- `stemScripts` +- `stemTasks` +- `StemWorkflowNames` +- typed starter helpers like `startUserSignup(...)` + +Wire those directly into `StemWorkflowApp`: + +```dart +final workflowApp = await StemWorkflowApp.fromUrl( + 'memory://', + flows: stemFlows, + scripts: stemScripts, + tasks: stemTasks, +); +``` + +## Two script entry styles + +### Direct-call style + +Use a plain `run(...)` when your annotated checkpoints only need serializable +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, + }; + } +} +``` + +## 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` +- typed task parameter decoding + +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..f6f2b763 --- /dev/null +++ b/.site/docs/workflows/context-and-serialization.md @@ -0,0 +1,70 @@ +--- +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 +- `idempotencyKey(...)` +- task metadata like `id`, `attempt`, `meta` + +## Serializable parameter rules + +Supported shapes: + +- `String` +- `bool` +- `int` +- `double` +- `num` +- `Object?` +- `List` where `T` is serializable +- `Map` where `T` is serializable + +Unsupported directly: + +- arbitrary Dart class instances +- non-string map keys +- generated workflow/task entrypoints with optional or named parameters + +If you have a domain object, encode it first: + +```dart +final order = { + 'id': 'ord_42', + 'customerId': 'cus_7', + 'totalCents': 1250, +}; +``` + +Decode it inside the workflow or task body, not at the durable boundary. + +## 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..90898687 --- /dev/null +++ b/.site/docs/workflows/getting-started.md @@ -0,0 +1,54 @@ +--- +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. + +## 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. Share bootstrap through StemClient 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. + +## 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..ce7cd15c --- /dev/null +++ b/.site/docs/workflows/how-it-works.md @@ -0,0 +1,69 @@ +--- +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, run scheduling, and resume logic +- `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 +- checkpoint/step 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..38c6de89 --- /dev/null +++ b/.site/docs/workflows/index.md @@ -0,0 +1,67 @@ +--- +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 + starters 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 starters, 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 starters. +- [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. 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..c127231c --- /dev/null +++ b/.site/docs/workflows/starting-and-waiting.md @@ -0,0 +1,49 @@ +--- +title: Starting and Waiting +--- + +Workflow runs are started through the runtime or through `StemWorkflowApp`. + +## 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 helpers + +When you use `stem_builder`, generated extension methods remove the raw +workflow-name strings: + +```dart +final runId = await workflowApp.startUserSignup(email: 'user@example.com'); +final result = await workflowApp.waitForCompletion>(runId); +``` + +These starters also exist on `WorkflowRuntime` when you want to work below the +`StemWorkflowApp` abstraction. + +## 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..9d2cee65 --- /dev/null +++ b/.site/docs/workflows/suspensions-and-events.md @@ -0,0 +1,55 @@ +--- +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 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 emits the topic with a payload +4. the runtime resumes the run and exposes the payload through + `takeResumeData()` + +## Emit resume events + +Use the runtime event bus instead of hand-editing store state: + +```dart +await workflowApp.runtime.emit('orders.payment.confirmed', { + 'paymentId': 'pay_42', + 'approvedBy': 'gateway', +}); +``` + +## 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..0caa80e1 --- /dev/null +++ b/.site/docs/workflows/troubleshooting.md @@ -0,0 +1,49 @@ +--- +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 `emit(...)` matches the one passed to `awaitEvent(...)` +- the run is still waiting on that topic +- the payload is 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 20c680a7..fb2207f5 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,7 +93,6 @@ const sidebars: SidebarsConfig = { "core-concepts/observability", "core-concepts/dashboard", "core-concepts/persistence", - "core-concepts/workflows", "core-concepts/stem-builder", "core-concepts/cli-control", ], diff --git a/.site/static/img/favicon.ico b/.site/static/img/favicon.ico index c01d54bcd39a5f853428f3cd5aa0f383d963c484..dcce62c370e49246895fbfd8d4589d9efd99830d 100644 GIT binary patch literal 115753 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 literal 3626 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%0A1YvReMEGKt|t7)pmB_MG(47b%6W3 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 literal 0 HcmV?d00001 From c077f3515646d85f83a06a37c0f75b4a97145eea Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Wed, 18 Mar 2026 19:32:54 -0500 Subject: [PATCH 16/23] Move ecommerce ORM registry next to database code --- .../example/ecommerce/lib/src/database/datasource.dart | 2 +- .../lib/{ => src/database}/orm_registry.g.dart | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) rename packages/stem/example/ecommerce/lib/{ => src/database}/orm_registry.g.dart (87%) diff --git a/packages/stem/example/ecommerce/lib/src/database/datasource.dart b/packages/stem/example/ecommerce/lib/src/database/datasource.dart index 5dc1f899..46c38e5d 100644 --- a/packages/stem/example/ecommerce/lib/src/database/datasource.dart +++ b/packages/stem/example/ecommerce/lib/src/database/datasource.dart @@ -1,8 +1,8 @@ import 'package:ormed/ormed.dart'; import 'package:ormed_sqlite/ormed_sqlite.dart'; -import '../../orm_registry.g.dart'; import 'migrations.dart'; +import 'orm_registry.g.dart'; Future openEcommerceDataSource({ required String databasePath, diff --git a/packages/stem/example/ecommerce/lib/orm_registry.g.dart b/packages/stem/example/ecommerce/lib/src/database/orm_registry.g.dart similarity index 87% rename from packages/stem/example/ecommerce/lib/orm_registry.g.dart rename to packages/stem/example/ecommerce/lib/src/database/orm_registry.g.dart index 32e7a6da..2296b040 100644 --- a/packages/stem/example/ecommerce/lib/orm_registry.g.dart +++ b/packages/stem/example/ecommerce/lib/src/database/orm_registry.g.dart @@ -1,11 +1,11 @@ // GENERATED CODE - DO NOT MODIFY BY HAND // coverage:ignore-file import 'package:ormed/ormed.dart'; -import 'src/database/models/cart_item.dart'; -import 'src/database/models/cart.dart'; -import 'src/database/models/catalog_sku.dart'; -import 'src/database/models/order_item.dart'; -import 'src/database/models/order.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, From 267dc2baa658a59798f0899de8fd2cd9b51a517f Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Wed, 18 Mar 2026 20:38:02 -0500 Subject: [PATCH 17/23] Refresh .site docs for current APIs --- .site/docs/brokers/overview.md | 9 ++++--- .site/docs/comparisons/stem-vs-bullmq.md | 2 +- .site/docs/core-concepts/cli-control.md | 18 +++++++++---- .site/docs/core-concepts/index.md | 4 +-- .site/docs/core-concepts/observability.md | 14 +++++----- .site/docs/core-concepts/persistence.md | 19 ++++++++++--- .site/docs/core-concepts/rate-limiting.md | 2 +- .site/docs/core-concepts/routing.md | 1 + .site/docs/core-concepts/stem-builder.md | 26 +++++++++++++++--- .site/docs/core-concepts/tasks.md | 6 ++--- .site/docs/core-concepts/workflows.md | 11 ++++---- .../getting-started/developer-environment.md | 6 ++--- .site/docs/getting-started/first-steps.md | 15 ++++++----- .site/docs/getting-started/index.md | 2 +- .site/docs/getting-started/intro.md | 6 ++--- .../getting-started/observability-and-ops.md | 11 +++++--- .../getting-started/production-checklist.md | 19 ++++++------- .site/docs/getting-started/quick-start.md | 2 +- .site/docs/getting-started/troubleshooting.md | 5 ++-- .site/docs/scheduler/index.md | 5 ++-- .site/docs/workers/daemonization.md | 27 ++++++++++--------- .site/docs/workers/index.md | 5 ++-- .../docs/workers/programmatic-integration.md | 5 ++++ .site/docs/workers/worker-control.md | 6 ++--- .site/docs/workflows/annotated-workflows.md | 6 +++++ .../workflows/context-and-serialization.md | 10 +++++-- .site/docs/workflows/getting-started.md | 11 ++++++-- .site/docs/workflows/how-it-works.md | 5 ++-- .site/docs/workflows/index.md | 14 +++++++++- .../docs/workflows/suspensions-and-events.md | 8 +++--- .site/docs/workflows/troubleshooting.md | 3 ++- .site/sidebars.ts | 4 +-- 32 files changed, 190 insertions(+), 97 deletions(-) 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 5a065297..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 diff --git a/.site/docs/core-concepts/cli-control.md b/.site/docs/core-concepts/cli-control.md index 155b0f51..3f4a5450 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 `stemTasks` 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 generated `stemTasks`. 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 27cfb56e..d16a0854 100644 --- a/.site/docs/core-concepts/index.md +++ b/.site/docs/core-concepts/index.md @@ -52,9 +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. +- **[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 registries and typed starters from annotations. +- **[stem_builder](./stem-builder.md)** – Generate workflow/task helpers, manifests, and typed starters 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..e47c3e51 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 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/stem-builder.md b/.site/docs/core-concepts/stem-builder.md index 2730911b..88215c26 100644 --- a/.site/docs/core-concepts/stem-builder.md +++ b/.site/docs/core-concepts/stem-builder.md @@ -5,8 +5,9 @@ sidebar_position: 15 slug: /core-concepts/stem-builder --- -`stem_builder` generates workflow/task registries and typed workflow starters -from annotations, so you can avoid stringly-typed wiring. +`stem_builder` generates workflow/task definitions, manifests, helper output, +and typed workflow starters 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 @@ -66,14 +67,15 @@ dart run build_runner build --delete-conflicting-outputs Generated output (`workflow_defs.stem.g.dart`) includes: - `stemScripts`, `stemFlows`, `stemTasks` -- `registerStemDefinitions(...)` - typed starters like `workflowApp.startUserSignup(...)` - `StemWorkflowNames` constants - convenience helpers such as `createStemGeneratedWorkflowApp(...)` +- `registerStemDefinitions(...)` for advanced/manual integrations that still + need explicit registries ## Wire Into StemWorkflowApp -Use the generated registries directly with `StemWorkflowApp`: +Use the generated definitions/helpers directly with `StemWorkflowApp`: ```dart final workflowApp = await StemWorkflowApp.fromUrl( @@ -105,6 +107,22 @@ final workflowApp = await StemWorkflowApp.create( ); ``` +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()], + tasks: stemTasks, +); + +final workflowApp = await client.createWorkflowApp( + scripts: stemScripts, + flows: stemFlows, +); +``` + ## Parameter and Signature Rules - Parameters after context must be required positional serializable values. diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md index 98a108c0..eafaaf01 100644 --- a/.site/docs/core-concepts/tasks.md +++ b/.site/docs/core-concepts/tasks.md @@ -60,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 @@ -83,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 diff --git a/.site/docs/core-concepts/workflows.md b/.site/docs/core-concepts/workflows.md index 87749b60..995c2cc0 100644 --- a/.site/docs/core-concepts/workflows.md +++ b/.site/docs/core-concepts/workflows.md @@ -7,9 +7,9 @@ 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. This page is now the short orientation page. The full workflow manual lives in the top-level [Workflows](../workflows/index.md) section. @@ -37,9 +37,10 @@ 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). ## What makes workflows different from tasks 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 199f96b9..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 task list (see [Tasks & Retries](../core-concepts/tasks.md)). - 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 521982cb..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. 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/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 3211a901..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 35ec3030..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: 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..7b68362a 100644 --- a/.site/docs/scheduler/index.md +++ b/.site/docs/scheduler/index.md @@ -78,5 +78,6 @@ 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. -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 701678b9..c02036fd 100644 --- a/.site/docs/workers/programmatic-integration.md +++ b/.site/docs/workers/programmatic-integration.md @@ -92,6 +92,11 @@ 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. diff --git a/.site/docs/workers/worker-control.md b/.site/docs/workers/worker-control.md index 37e88621..df74fea7 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 @@ -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 index 03568504..0ba75471 100644 --- a/.site/docs/workflows/annotated-workflows.md +++ b/.site/docs/workflows/annotated-workflows.md @@ -90,6 +90,12 @@ class AnnotatedContextScriptWorkflow { } ``` +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 diff --git a/.site/docs/workflows/context-and-serialization.md b/.site/docs/workflows/context-and-serialization.md index f6f2b763..bfc27f77 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -38,7 +38,8 @@ Supported shapes: - `int` - `double` - `num` -- `Object?` +- JSON-like scalar values (`Object?` only when the runtime value is itself + serializable) - `List` where `T` is serializable - `Map` where `T` is serializable @@ -46,7 +47,8 @@ Unsupported directly: - arbitrary Dart class instances - non-string map keys -- generated workflow/task entrypoints with optional or named parameters +- annotated workflow/task method signatures with optional or named business + parameters If you have a domain object, encode it first: @@ -60,6 +62,10 @@ final order = { Decode it inside the workflow or task body, not at the durable boundary. +Generated starter helpers may still expose named parameters as a wrapper over +the serialized params map. The restriction applies to the annotated business +method signatures that `stem_builder` lowers into workflow/task definitions. + ## Practical rule When you need context metadata, add the appropriate context parameter first. diff --git a/.site/docs/workflows/getting-started.md b/.site/docs/workflows/getting-started.md index 90898687..b83628d3 100644 --- a/.site/docs/workflows/getting-started.md +++ b/.site/docs/workflows/getting-started.md @@ -24,6 +24,10 @@ enqueue regular Stem tasks. 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 @@ -36,14 +40,17 @@ The returned `WorkflowResult` includes: - the persisted `RunState` - a `timedOut` flag when the caller stops waiting before the run finishes -## 4. Share bootstrap through StemClient when needed +## 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. +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 diff --git a/.site/docs/workflows/how-it-works.md b/.site/docs/workflows/how-it-works.md index ce7cd15c..b8d077c3 100644 --- a/.site/docs/workflows/how-it-works.md +++ b/.site/docs/workflows/how-it-works.md @@ -10,7 +10,8 @@ state, and orchestration-specific runtime metadata. `StemWorkflowApp` bundles: -- `runtime`: workflow registration, run scheduling, and resume logic +- `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 @@ -29,7 +30,7 @@ lines. Stem keeps materialized workflow state in the workflow store: - run metadata and status -- checkpoint/step results +- flow step results and script checkpoint results - suspension records - due-run schedules - topic watchers diff --git a/.site/docs/workflows/index.md b/.site/docs/workflows/index.md index 38c6de89..7e434031 100644 --- a/.site/docs/workflows/index.md +++ b/.site/docs/workflows/index.md @@ -64,4 +64,16 @@ orients you and links back here. - 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. +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/suspensions-and-events.md b/.site/docs/workflows/suspensions-and-events.md index 9d2cee65..15485b4b 100644 --- a/.site/docs/workflows/suspensions-and-events.md +++ b/.site/docs/workflows/suspensions-and-events.md @@ -15,19 +15,21 @@ sleep expires. ## Await external events `awaitEvent(topic, deadline: ...)` records a durable watcher. External code can -resume those runs by emitting a payload for the topic. +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 emits the topic with a payload +3. another process calls `WorkflowRuntime.emit(...)` (or an app/service wrapper + around it) with a payload 4. the runtime resumes the run and exposes the payload through `takeResumeData()` ## Emit resume events -Use the runtime event bus instead of hand-editing store state: +Use `WorkflowRuntime.emit(...)` / `workflowApp.runtime.emit(...)` instead of +hand-editing store state: ```dart await workflowApp.runtime.emit('orders.payment.confirmed', { diff --git a/.site/docs/workflows/troubleshooting.md b/.site/docs/workflows/troubleshooting.md index 0caa80e1..3b8db907 100644 --- a/.site/docs/workflows/troubleshooting.md +++ b/.site/docs/workflows/troubleshooting.md @@ -22,7 +22,8 @@ task queue such as `default`. Check: -- the topic passed to `emit(...)` matches the one passed to `awaitEvent(...)` +- the topic passed to `WorkflowRuntime.emit(...)` or + `workflowApp.runtime.emit(...)` matches the one passed to `awaitEvent(...)` - the run is still waiting on that topic - the payload is a `Map` diff --git a/.site/sidebars.ts b/.site/sidebars.ts index fb2207f5..07030a06 100644 --- a/.site/sidebars.ts +++ b/.site/sidebars.ts @@ -115,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"], }, ], }; From 7e497e297afe81621731b8ad874cd7163ba5a6b5 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Wed, 18 Mar 2026 20:48:50 -0500 Subject: [PATCH 18/23] Fix stale example paths in docs --- .site/docs/core-concepts/rate-limiting.md | 2 +- .site/docs/core-concepts/signing.md | 4 ++-- .site/docs/scheduler/index.md | 2 +- .site/docs/workers/worker-control.md | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.site/docs/core-concepts/rate-limiting.md b/.site/docs/core-concepts/rate-limiting.md index e47c3e51..e3b29fea 100644 --- a/.site/docs/core-concepts/rate-limiting.md +++ b/.site/docs/core-concepts/rate-limiting.md @@ -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/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/scheduler/index.md b/.site/docs/scheduler/index.md index 7b68362a..2b49c6bf 100644 --- a/.site/docs/scheduler/index.md +++ b/.site/docs/scheduler/index.md @@ -76,7 +76,7 @@ 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? Start with the [Beat Scheduler Guide](./beat-guide.md) and diff --git a/.site/docs/workers/worker-control.md b/.site/docs/workers/worker-control.md index df74fea7..46169b8e 100644 --- a/.site/docs/workers/worker-control.md +++ b/.site/docs/workers/worker-control.md @@ -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 From de5eb4ad9303f72b8da27e9bb7891f1f2bdb2bee Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Wed, 18 Mar 2026 20:49:02 -0500 Subject: [PATCH 19/23] Ignore local workflow and third-party artifacts --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) 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* From d47056c3ecca414f7c8ec34af13a6f459e2ab46a Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Wed, 18 Mar 2026 20:50:18 -0500 Subject: [PATCH 20/23] Add proxy workflow runtime probe --- packages/stem/tool/proxy_runtime_check.dart | 62 +++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 packages/stem/tool/proxy_runtime_check.dart 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(); +} From acf985d17a7ae2ac18e94c210142ce4d234bc312 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Wed, 18 Mar 2026 21:23:10 -0500 Subject: [PATCH 21/23] Update docs favicon to Stem icon --- .site/static/img/favicon.ico | Bin 115753 -> 25921 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/.site/static/img/favicon.ico b/.site/static/img/favicon.ico index dcce62c370e49246895fbfd8d4589d9efd99830d..765721fbf6005206df7803862e8c254e78632c83 100644 GIT binary patch literal 25921 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

Tbgz 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 N&;Pqc;Qy}L_#e)FA6x(c literal 115753 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 From d6928646a23de09976828643f424ba7e8a946329 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 11:40:30 -0500 Subject: [PATCH 22/23] Add typed workflow and task definition APIs --- packages/stem/CHANGELOG.md | 2 + .../example/annotated_workflows/bin/main.dart | 69 +- .../annotated_workflows/lib/definitions.dart | 262 ++++- .../lib/definitions.stem.g.dart | 395 ++++---- packages/stem/example/durable_watchers.dart | 34 +- .../stem/example/ecommerce/lib/src/app.dart | 28 +- .../src/workflows/annotated_defs.stem.g.dart | 194 ++-- .../lib/src/workflows/checkout_flow.dart | 2 +- .../workflows/cancellation_policy.dart | 2 +- .../example/workflows/sleep_and_event.dart | 7 +- .../stem/lib/src/bootstrap/stem_client.dart | 3 + .../stem/lib/src/bootstrap/stem_module.dart | 88 ++ .../stem/lib/src/bootstrap/workflow_app.dart | 147 ++- packages/stem/lib/src/core/payload_codec.dart | 53 + packages/stem/lib/src/core/stem.dart | 34 + .../stem/lib/src/workflow/annotations.dart | 9 +- packages/stem/lib/src/workflow/core/flow.dart | 3 + .../stem/lib/src/workflow/core/flow_step.dart | 48 + .../workflow/core/workflow_definition.dart | 94 +- .../lib/src/workflow/core/workflow_ref.dart | 84 ++ .../src/workflow/core/workflow_resume.dart | 31 + .../src/workflow/core/workflow_script.dart | 5 +- .../workflow/runtime/workflow_runtime.dart | 115 ++- packages/stem/lib/src/workflow/workflow.dart | 2 + packages/stem/lib/stem.dart | 2 + .../stem/test/bootstrap/stem_app_test.dart | 210 ++++ .../stem/test/bootstrap/stem_client_test.dart | 95 ++ .../stem/test/unit/core/stem_core_test.dart | 101 ++ .../unit/workflow/workflow_resume_test.dart | 120 +++ .../test/workflow/workflow_runtime_test.dart | 63 ++ packages/stem_builder/CHANGELOG.md | 2 + packages/stem_builder/example/bin/main.dart | 21 +- .../example/bin/runtime_metadata_views.dart | 15 +- .../example/lib/definitions.stem.g.dart | 184 ++-- .../lib/src/stem_registry_builder.dart | 958 ++++++++++++++---- .../test/stem_registry_builder_test.dart | 254 ++++- 36 files changed, 2977 insertions(+), 759 deletions(-) create mode 100644 packages/stem/lib/src/bootstrap/stem_module.dart create mode 100644 packages/stem/lib/src/core/payload_codec.dart create mode 100644 packages/stem/lib/src/workflow/core/workflow_ref.dart create mode 100644 packages/stem/lib/src/workflow/core/workflow_resume.dart create mode 100644 packages/stem/test/unit/workflow/workflow_resume_test.dart diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md index 949bcbd0..bc623da8 100644 --- a/packages/stem/CHANGELOG.md +++ b/packages/stem/CHANGELOG.md @@ -2,6 +2,8 @@ ## 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 diff --git a/packages/stem/example/annotated_workflows/bin/main.dart b/packages/stem/example/annotated_workflows/bin/main.dart index 4b2f68e8..208b05e1 100644 --- a/packages/stem/example/annotated_workflows/bin/main.dart +++ b/packages/stem/example/annotated_workflows/bin/main.dart @@ -5,13 +5,8 @@ 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']), @@ -19,62 +14,70 @@ Future main() async { ); await app.start(); - final flowRunId = await app.startFlow(); - 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: ${jsonEncode(flowResult?.value)}'); - final scriptRunId = await app.startScript(email: ' SomeEmail@Example.com '); - final scriptResult = await app.waitForCompletion>( - scriptRunId, + 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)}'); + print('Script result: ${jsonEncode(scriptResult?.value?.toJson())}'); - final scriptDetail = await app.runtime.viewRunDetail(scriptRunId); + 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 contextRunId = await app.startContextScript( - email: ' ContextEmail@Example.com ', + final contextCall = StemWorkflowDefinitions.contextScript.call( + (request: const WelcomeRequest(email: ' ContextEmail@Example.com ')), ); - final contextResult = await app.waitForCompletion>( - contextRunId, + final contextResult = await contextCall.startAndWaitWithApp( + app, timeout: const Duration(seconds: 2), ); - print('Context script result: ${jsonEncode(contextResult?.value)}'); + print('Context script result: ${jsonEncode(contextResult?.value?.toJson())}'); - final contextDetail = await app.runtime.viewRunDetail(contextRunId); + 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 typedTaskId = await app.app.stem.enqueue( - 'send_email_typed', - args: { - 'email': 'typed@example.com', - 'message': {'subject': 'Welcome', 'body': 'Serializable payloads only'}, - 'tags': [ - 'welcome', - 1, - true, - {'channel': 'email'}, - ], - }, + 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.waitForTask>( + final typedTaskResult = await app.app.stem.waitForSendEmailTyped( typedTaskId, timeout: const Duration(seconds: 2), ); - print('Typed task result: ${jsonEncode(typedTaskResult?.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 fff73813..16938b64 100644 --- a/packages/stem/example/annotated_workflows/lib/definitions.dart +++ b/packages/stem/example/annotated_workflows/lib/definitions.dart @@ -2,6 +2,180 @@ 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 { @WorkflowStep() @@ -24,23 +198,26 @@ class AnnotatedFlowWorkflow { @WorkflowDefn(name: 'annotated.script', kind: WorkflowKind.script) class AnnotatedScriptWorkflow { - Future> run(String email) async { - final prepared = await prepareWelcome(email); - final normalizedEmail = prepared['normalizedEmail'] as String; - final subject = prepared['subject'] as String; + 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 { - 'normalizedEmail': normalizedEmail, - 'subject': subject, - 'followUp': followUp, - }; + return WelcomeWorkflowResult( + normalizedEmail: normalizedEmail, + subject: subject, + followUp: followUp, + ); } @WorkflowStep(name: 'prepare-welcome') - Future> prepareWelcome(String email) async { - final normalizedEmail = await normalizeEmail(email); + Future prepareWelcome(WelcomeRequest request) async { + final normalizedEmail = await normalizeEmail(request.email); final subject = await buildWelcomeSubject(normalizedEmail); - return {'normalizedEmail': normalizedEmail, 'subject': subject}; + return WelcomePreparation( + normalizedEmail: normalizedEmail, + subject: subject, + ); } @WorkflowStep(name: 'normalize-email') @@ -67,33 +244,33 @@ class AnnotatedScriptWorkflow { @WorkflowDefn(name: 'annotated.context_script', kind: WorkflowKind.script) class AnnotatedContextScriptWorkflow { @WorkflowRun() - Future> run( + Future run( WorkflowScriptContext script, - String email, + WelcomeRequest request, ) async { - return script.step>( + return script.step( 'enter-context-step', - (ctx) => captureContext(ctx, email), + (ctx) => captureContext(ctx, request), ); } @WorkflowStep(name: 'capture-context') - Future> captureContext( + Future captureContext( WorkflowScriptStepContext ctx, - String email, + WelcomeRequest request, ) async { - final normalizedEmail = await normalizeEmail(email); + final normalizedEmail = await normalizeEmail(request.email); final subject = await buildWelcomeSubject(normalizedEmail); - return { - 'workflow': ctx.workflow, - 'runId': ctx.runId, - 'stepName': ctx.stepName, - 'stepIndex': ctx.stepIndex, - 'iteration': ctx.iteration, - 'idempotencyKey': ctx.idempotencyKey('welcome'), - 'normalizedEmail': normalizedEmail, - 'subject': subject, - }; + 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') @@ -116,20 +293,21 @@ Future sendEmail( } @TaskDefn(name: 'send_email_typed', options: TaskOptions(maxRetries: 1)) -Future> sendEmailTyped( +Future sendEmailTyped( TaskInvocationContext ctx, - String email, - Map message, - List tags, + EmailDispatch dispatch, ) async { ctx.heartbeat(); - await ctx.progress(100, data: {'email': email, 'tagCount': tags.length}); - return { - 'taskId': ctx.id, - 'attempt': ctx.attempt, - 'email': email, - 'subject': message['subject'], - 'tags': tags, - 'meta': ctx.meta, - }; + 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 index 9f54362d..42c0f107 100644 --- a/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart +++ b/packages/stem/example/annotated_workflows/lib/definitions.stem.g.dart @@ -3,12 +3,74 @@ part of 'definitions.dart'; -final List stemFlows = [ +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( + flow.step?>( "start", (ctx) => impl.start(ctx), kind: WorkflowStepKind.task, @@ -22,10 +84,10 @@ class _StemScriptProxy0 extends AnnotatedScriptWorkflow { _StemScriptProxy0(this._script); final WorkflowScriptContext _script; @override - Future> prepareWelcome(String email) { - return _script.step>( + Future prepareWelcome(WelcomeRequest request) { + return _script.step( "prepare-welcome", - (context) => super.prepareWelcome(email), + (context) => super.prepareWelcome(request), ); } @@ -66,13 +128,13 @@ class _StemScriptProxy1 extends AnnotatedContextScriptWorkflow { _StemScriptProxy1(this._script); final WorkflowScriptContext _script; @override - Future> captureContext( + Future captureContext( WorkflowScriptStepContext context, - String email, + WelcomeRequest request, ) { - return _script.step>( + return _script.step( "capture-context", - (context) => super.captureContext(context, email), + (context) => super.captureContext(context, request), ); } @@ -93,13 +155,14 @@ class _StemScriptProxy1 extends AnnotatedContextScriptWorkflow { } } -final List stemScripts = [ +final List _stemScripts = [ WorkflowScript( name: "annotated.script", checkpoints: [ - FlowStep( + FlowStep.typed( name: "prepare-welcome", handler: _stemScriptManifestStepNoop, + valueCodec: StemPayloadCodecs.welcomePreparation, kind: WorkflowStepKind.task, taskNames: [], ), @@ -128,16 +191,20 @@ final List stemScripts = [ taskNames: [], ), ], - run: (script) => _StemScriptProxy0( - script, - ).run((_stemRequireArg(script.params, "email") as String)), + resultCodec: StemPayloadCodecs.welcomeWorkflowResult, + run: (script) => _StemScriptProxy0(script).run( + StemPayloadCodecs.welcomeRequest.decode( + _stemRequireArg(script.params, "request"), + ), + ), ), WorkflowScript( name: "annotated.context_script", checkpoints: [ - FlowStep( + FlowStep.typed( name: "capture-context", handler: _stemScriptManifestStepNoop, + valueCodec: StemPayloadCodecs.contextCaptureResult, kind: WorkflowStepKind.task, taskNames: [], ), @@ -154,126 +221,40 @@ final List stemScripts = [ taskNames: [], ), ], - run: (script) => _StemScriptProxy1( + resultCodec: StemPayloadCodecs.contextCaptureResult, + run: (script) => _StemScriptProxy1(script).run( script, - ).run(script, (_stemRequireArg(script.params, "email") as String)), + StemPayloadCodecs.welcomeRequest.decode( + _stemRequireArg(script.params, "request"), + ), + ), ), ]; -abstract final class StemWorkflowNames { - static const String flow = "annotated.flow"; - static const String script = "annotated.script"; - static const String contextScript = "annotated.context_script"; -} - -extension StemGeneratedWorkflowAppStarters on StemWorkflowApp { - Future startFlow({ - Map params = const {}, - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - return startWorkflow( - StemWorkflowNames.flow, - params: params, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ); - } - - Future startScript({ - required String email, - Map extraParams = const {}, - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - final params = {...extraParams, "email": email}; - return startWorkflow( - StemWorkflowNames.script, - params: params, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ); - } - - Future startContextScript({ - required String email, - Map extraParams = const {}, - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - final params = {...extraParams, "email": email}; - return startWorkflow( - StemWorkflowNames.contextScript, - params: params, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ); - } -} - -extension StemGeneratedWorkflowRuntimeStarters on WorkflowRuntime { - Future startFlow({ - Map params = const {}, - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - return startWorkflow( - StemWorkflowNames.flow, - params: params, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ); - } - - Future startScript({ - required String email, - Map extraParams = const {}, - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - final params = {...extraParams, "email": email}; - return startWorkflow( - StemWorkflowNames.script, - params: params, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ); - } - - Future startContextScript({ - required String email, - Map extraParams = const {}, - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - final params = {...extraParams, "email": email}; - return startWorkflow( - StemWorkflowNames.contextScript, - params: params, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ); - } +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, + ); } -final List stemWorkflowManifest = - [ - ...stemFlows.map((flow) => flow.definition.toManifestEntry()), - ...stemScripts.map((script) => script.definition.toManifestEntry()), - ]; - Future _stemScriptManifestStepNoop(FlowContext context) async => null; Object? _stemRequireArg(Map args, String name) { @@ -290,14 +271,99 @@ Future _stemTaskAdapter0( return await Future.value( sendEmailTyped( context, - (_stemRequireArg(args, "email") as String), - (_stemRequireArg(args, "message") as Map), - (_stemRequireArg(args, "tags") as List), + StemPayloadCodecs.emailDispatch.decode(_stemRequireArg(args, "dispatch")), ), ); } -final List> stemTasks = >[ +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, @@ -308,50 +374,27 @@ final List> stemTasks = >[ name: "send_email_typed", entrypoint: _stemTaskAdapter0, options: const TaskOptions(maxRetries: 1), - metadata: const TaskMetadata(), + metadata: TaskMetadata( + tags: [], + idempotent: false, + attributes: {}, + resultEncoder: CodecTaskPayloadEncoder( + idValue: "stem.generated.send_email_typed.result", + codec: StemPayloadCodecs.emailDeliveryReceipt, + ), + ), ), ]; -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); - } -} - -Future createStemGeneratedWorkflowApp({ - required StemApp stemApp, - bool registerTasks = true, - Duration pollInterval = const Duration(milliseconds: 500), - Duration leaseExtension = const Duration(seconds: 30), - WorkflowRegistry? workflowRegistry, - WorkflowIntrospectionSink? introspectionSink, -}) async { - if (registerTasks) { - for (final handler in stemTasks) { - stemApp.register(handler); - } - } - return StemWorkflowApp.create( - stemApp: stemApp, - flows: stemFlows, - scripts: stemScripts, - pollInterval: pollInterval, - leaseExtension: leaseExtension, - workflowRegistry: workflowRegistry, - introspectionSink: introspectionSink, - ); -} +final List _stemWorkflowManifest = + [ + ..._stemFlows.map((flow) => flow.definition.toManifestEntry()), + ..._stemScripts.map((script) => script.definition.toManifestEntry()), + ]; -Future createStemGeneratedInMemoryApp() async { - final stemApp = await StemApp.inMemory(tasks: stemTasks); - return createStemGeneratedWorkflowApp(stemApp: stemApp, registerTasks: false); -} +final StemModule stemModule = StemModule( + flows: _stemFlows, + scripts: _stemScripts, + tasks: _stemTasks, + workflowManifest: _stemWorkflowManifest, +); 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/lib/src/app.dart b/packages/stem/example/ecommerce/lib/src/app.dart index 6bbad20d..4343953a 100644 --- a/packages/stem/example/ecommerce/lib/src/app.dart +++ b/packages/stem/example/ecommerce/lib/src/app.dart @@ -36,9 +36,9 @@ class EcommerceServer { final workflowApp = await StemWorkflowApp.fromUrl( 'sqlite://$stemDatabasePath', adapters: const [StemSqliteAdapter()], - scripts: stemScripts, + module: stemModule, flows: [buildCheckoutFlow(repository)], - tasks: [...stemTasks, shipmentReserveTaskHandler], + tasks: [shipmentReserveTaskHandler], workerConfig: StemWorkerConfig( queue: 'workflow', consumerName: 'ecommerce-worker', @@ -57,7 +57,10 @@ class EcommerceServer { 'status': 'ok', 'databasePath': repository.databasePath, 'stemDatabasePath': stemDatabasePath, - 'workflows': [StemWorkflowNames.addToCart, checkoutWorkflowName], + 'workflows': [ + StemWorkflowDefinitions.addToCart.name, + checkoutWorkflowName, + ], }); }) ..get('/catalog', (Request request) async { @@ -91,18 +94,15 @@ class EcommerceServer { final sku = payload['sku']?.toString() ?? ''; final quantity = _toInt(payload['quantity']); - final runId = await workflowApp.startAddToCart( - cartId: cartId, - sku: sku, - quantity: quantity, - ); + final runId = await StemWorkflowDefinitions.addToCart + .call((cartId: cartId, sku: sku, quantity: quantity)) + .startWithApp(workflowApp); - final result = await workflowApp - .waitForCompletion>( - runId, - timeout: const Duration(seconds: 4), - decode: _toMap, - ); + 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.', { 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 index 8eac2f04..65d4e4fa 100644 --- 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 @@ -3,7 +3,7 @@ part of 'annotated_defs.dart'; -final List stemFlows = []; +final List _stemFlows = []; class _StemScriptProxy0 extends AddToCartWorkflow { _StemScriptProxy0(this._script); @@ -33,7 +33,7 @@ class _StemScriptProxy0 extends AddToCartWorkflow { } } -final List stemScripts = [ +final List _stemScripts = [ WorkflowScript( name: "ecommerce.cart.add_item", checkpoints: [ @@ -59,68 +59,25 @@ final List stemScripts = [ ), ]; -abstract final class StemWorkflowNames { - static const String addToCart = "ecommerce.cart.add_item"; +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, + }, + ); } -extension StemGeneratedWorkflowAppStarters on StemWorkflowApp { - Future startAddToCart({ - required String cartId, - required String sku, - required int quantity, - Map extraParams = const {}, - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - final params = { - ...extraParams, - "cartId": cartId, - "sku": sku, - "quantity": quantity, - }; - return startWorkflow( - StemWorkflowNames.addToCart, - params: params, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ); - } -} - -extension StemGeneratedWorkflowRuntimeStarters on WorkflowRuntime { - Future startAddToCart({ - required String cartId, - required String sku, - required int quantity, - Map extraParams = const {}, - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - final params = { - ...extraParams, - "cartId": cartId, - "sku": sku, - "quantity": quantity, - }; - return startWorkflow( - StemWorkflowNames.addToCart, - params: params, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ); - } -} - -final List stemWorkflowManifest = - [ - ...stemFlows.map((flow) => flow.definition.toManifestEntry()), - ...stemScripts.map((script) => script.definition.toManifestEntry()), - ]; - Future _stemScriptManifestStepNoop(FlowContext context) async => null; Object? _stemRequireArg(Map args, String name) { @@ -144,7 +101,65 @@ Future _stemTaskAdapter0( ); } -final List> stemTasks = >[ +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, @@ -154,46 +169,15 @@ final List> stemTasks = >[ ), ]; -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); - } -} - -Future createStemGeneratedWorkflowApp({ - required StemApp stemApp, - bool registerTasks = true, - Duration pollInterval = const Duration(milliseconds: 500), - Duration leaseExtension = const Duration(seconds: 30), - WorkflowRegistry? workflowRegistry, - WorkflowIntrospectionSink? introspectionSink, -}) async { - if (registerTasks) { - for (final handler in stemTasks) { - stemApp.register(handler); - } - } - return StemWorkflowApp.create( - stemApp: stemApp, - flows: stemFlows, - scripts: stemScripts, - pollInterval: pollInterval, - leaseExtension: leaseExtension, - workflowRegistry: workflowRegistry, - introspectionSink: introspectionSink, - ); -} +final List _stemWorkflowManifest = + [ + ..._stemFlows.map((flow) => flow.definition.toManifestEntry()), + ..._stemScripts.map((script) => script.definition.toManifestEntry()), + ]; -Future createStemGeneratedInMemoryApp() async { - final stemApp = await StemApp.inMemory(tasks: stemTasks); - return createStemGeneratedWorkflowApp(stemApp: stemApp, registerTasks: false); -} +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 index 803d94ab..1c342b4f 100644 --- a/packages/stem/example/ecommerce/lib/src/workflows/checkout_flow.dart +++ b/packages/stem/example/ecommerce/lib/src/workflows/checkout_flow.dart @@ -24,7 +24,7 @@ Flow> buildCheckoutFlow(EcommerceRepository repository) { }); flow.step('capture-payment', (ctx) async { - final resume = ctx.takeResumeData(); + final resume = ctx.takeResumeValue>(); if (resume == null) { ctx.sleep( const Duration(milliseconds: 100), 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/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_client.dart b/packages/stem/lib/src/bootstrap/stem_client.dart index e5c6404d..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, 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 8645f970..c5254e23 100644 --- a/packages/stem/lib/src/bootstrap/workflow_app.dart +++ b/packages/stem/lib/src/bootstrap/workflow_app.dart @@ -1,10 +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'; @@ -12,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'; @@ -113,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: @@ -164,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, { @@ -226,6 +299,7 @@ class StemWorkflowApp { /// ); /// ``` static Future create({ + StemModule? module, Iterable workflows = const [], Iterable flows = const [], Iterable scripts = const [], @@ -245,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( @@ -274,16 +351,15 @@ class StemWorkflowApp { introspectionSink: introspectionSink, ); - tasks.forEach(appInstance.register); + [...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, @@ -306,6 +382,7 @@ class StemWorkflowApp { /// ); /// ``` static Future inMemory({ + StemModule? module, Iterable workflows = const [], Iterable flows = const [], Iterable scripts = const [], @@ -321,6 +398,7 @@ class StemWorkflowApp { Iterable additionalEncoders = const [], }) { return StemWorkflowApp.create( + module: module, workflows: workflows, flows: flows, scripts: scripts, @@ -347,6 +425,7 @@ 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 [], @@ -397,6 +476,7 @@ class StemWorkflowApp { try { return await create( + module: module, workflows: workflows, flows: flows, scripts: scripts, @@ -425,6 +505,7 @@ 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 [], @@ -441,10 +522,10 @@ class StemWorkflowApp { workerConfig: workerConfig, ); return StemWorkflowApp.create( + module: module, workflows: workflows, flows: flows, scripts: scripts, - tasks: tasks, stemApp: appInstance, storeFactory: storeFactory, eventBusFactory: eventBusFactory, @@ -456,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/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 10f870b6..d363135a 100644 --- a/packages/stem/lib/src/core/stem.dart +++ b/packages/stem/lib/src/core/stem.dart @@ -471,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/workflow/annotations.dart b/packages/stem/lib/src/workflow/annotations.dart index 60e271cc..728bb17b 100644 --- a/packages/stem/lib/src/workflow/annotations.dart +++ b/packages/stem/lib/src/workflow/annotations.dart @@ -51,15 +51,16 @@ class WorkflowDefn { /// Optional metadata attached to the workflow definition. final Map? metadata; - /// Optional override for generated starter method suffix. + /// Optional override for the generated workflow ref symbol suffix. /// - /// Example: `starterName: 'UserSignup'` generates `startUserSignup(...)`. + /// Example: `starterName: 'UserSignup'` contributes + /// `StemWorkflowDefinitions.userSignup`. final String? starterName; - /// Optional override for `StemWorkflowNames` field name. + /// Optional override for the generated workflow ref field name. /// /// Example: `nameField: 'userSignup'` generates - /// `StemWorkflowNames.userSignup`. + /// `StemWorkflowDefinitions.userSignup`. final String? nameField; } 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/workflow_definition.dart b/packages/stem/lib/src/workflow/core/workflow_definition.dart index 6302f3de..814d422c 100644 --- a/packages/stem/lib/src/workflow/core/workflow_definition.dart +++ b/packages/stem/lib/src/workflow/core/workflow_definition.dart @@ -56,6 +56,7 @@ 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'; @@ -91,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. @@ -138,6 +143,7 @@ class WorkflowDefinition { String? version, String? description, Map? metadata, + PayloadCodec? resultCodec, }) { final steps = []; build(FlowBuilder(steps)); @@ -145,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, @@ -153,6 +169,8 @@ class WorkflowDefinition { version: version, description: description, metadata: metadata, + resultEncoder: resultEncoder, + resultDecoder: resultDecoder, ); } @@ -165,8 +183,19 @@ class WorkflowDefinition { 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, @@ -175,6 +204,8 @@ class WorkflowDefinition { description: description, metadata: metadata, scriptBody: run, + resultEncoder: resultEncoder, + resultDecoder: resultDecoder, ); } @@ -196,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); @@ -205,6 +239,32 @@ 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() @@ -320,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_script.dart b/packages/stem/lib/src/workflow/core/workflow_script.dart index 242d05dc..467885de 100644 --- a/packages/stem/lib/src/workflow/core/workflow_script.dart +++ b/packages/stem/lib/src/workflow/core/workflow_script.dart @@ -1,5 +1,6 @@ -import 'package:stem/src/workflow/core/workflow_definition.dart'; +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. @@ -17,6 +18,7 @@ class WorkflowScript { String? version, String? description, Map? metadata, + PayloadCodec? resultCodec, }) : definition = WorkflowDefinition.script( name: name, run: run, @@ -25,6 +27,7 @@ class WorkflowScript { version: version, description: description, metadata: metadata, + resultCodec: resultCodec, ); /// The constructed workflow definition. diff --git a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart index 0e01e5a3..18aa338b 100644 --- a/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart +++ b/packages/stem/lib/src/workflow/runtime/workflow_runtime.dart @@ -30,9 +30,10 @@ import 'dart:async'; import 'package:contextual/contextual.dart' show Context; import 'package:stem/src/core/contracts.dart'; -import 'package:stem/src/observability/logging.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'; @@ -42,6 +43,7 @@ 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'; @@ -206,6 +208,36 @@ class WorkflowRuntime { 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 @@ -244,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; @@ -436,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']; @@ -495,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, @@ -691,14 +740,15 @@ class WorkflowRuntime { 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; @@ -709,7 +759,8 @@ class WorkflowRuntime { cursor += 1; } - await _store.markCompleted(runId, previousResult); + final storedWorkflowResult = definition.encodeResult(previousResult); + await _store.markCompleted(runId, storedWorkflowResult); stemLogger.debug( 'Workflow {workflow} completed', _runtimeLogContext( @@ -723,7 +774,7 @@ class WorkflowRuntime { runId: runId, workflow: runState.workflow, status: WorkflowRunStatus.completed, - metadata: {'result': previousResult}, + metadata: {'result': storedWorkflowResult}, ), ); } on _WorkflowLeaseLost { @@ -767,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, @@ -785,7 +840,8 @@ class WorkflowRuntime { if (execution.wasSuspended) { return; } - await _store.markCompleted(runId, result); + final storedWorkflowResult = definition.encodeResult(result); + await _store.markCompleted(runId, storedWorkflowResult); stemLogger.debug( 'Workflow {workflow} completed', _runtimeLogContext( @@ -799,7 +855,7 @@ class WorkflowRuntime { runId: runId, workflow: runState.workflow, status: WorkflowRunStatus.completed, - metadata: {'result': result}, + metadata: {'result': storedWorkflowResult}, ), ); } on _WorkflowLeaseLost { @@ -1265,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, @@ -1283,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; @@ -1364,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, @@ -1385,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); @@ -1432,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; @@ -1770,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/workflow.dart b/packages/stem/lib/src/workflow/workflow.dart index bc016527..15d1f11c 100644 --- a/packages/stem/lib/src/workflow/workflow.dart +++ b/packages/stem/lib/src/workflow/workflow.dart @@ -9,8 +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'; diff --git a/packages/stem/lib/stem.dart b/packages/stem/lib/stem.dart index 97b8f81c..7fd64d28 100644 --- a/packages/stem/lib/stem.dart +++ b/packages/stem/lib/stem.dart @@ -85,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'; @@ -98,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/test/bootstrap/stem_app_test.dart b/packages/stem/test/bootstrap/stem_app_test.dart index c7679aac..d6c977e9 100644 --- a/packages/stem/test/bootstrap/stem_app_test.dart +++ b/packages/stem/test/bootstrap/stem_app_test.dart @@ -432,6 +432,205 @@ void main() { } }); + 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(); @@ -492,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/unit/core/stem_core_test.dart b/packages/stem/test/unit/core/stem_core_test.dart index a68024f0..eda93ff9 100644 --- a/packages/stem/test/unit/core/stem_core_test.dart +++ b/packages/stem/test/unit/core/stem_core_test.dart @@ -78,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/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 fe5428bf..c04b1486 100644 --- a/packages/stem/test/workflow/workflow_runtime_test.dart +++ b/packages/stem/test/workflow/workflow_runtime_test.dart @@ -323,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( @@ -1102,3 +1147,21 @@ class _RecordingLogDriver extends LogDriver { 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_builder/CHANGELOG.md b/packages/stem_builder/CHANGELOG.md index c9d7b228..aab805a1 100644 --- a/packages/stem_builder/CHANGELOG.md +++ b/packages/stem_builder/CHANGELOG.md @@ -2,6 +2,8 @@ ## 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` diff --git a/packages/stem_builder/example/bin/main.dart b/packages/stem_builder/example/bin/main.dart index c9f877dd..0d14ada8 100644 --- a/packages/stem_builder/example/bin/main.dart +++ b/packages/stem_builder/example/bin/main.dart @@ -1,10 +1,11 @@ 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 stemWorkflowManifest) { + for (final entry in stemModule.workflowManifest) { print(' - ${entry.name} (id=${entry.id})'); } @@ -12,10 +13,10 @@ Future main() async { print( const JsonEncoder.withIndent( ' ', - ).convert(stemWorkflowManifest.map((entry) => entry.toJson()).toList()), + ).convert(stemModule.workflowManifest.map((entry) => entry.toJson()).toList()), ); - final app = await createStemGeneratedInMemoryApp(); + final app = await StemWorkflowApp.inMemory(module: stemModule); try { final runtime = app.runtime; final runtimeManifest = runtime @@ -25,12 +26,16 @@ Future main() async { print('\nRuntime manifest:'); print(const JsonEncoder.withIndent(' ').convert(runtimeManifest)); - final runId = await runtime.startFlow( - params: const {'name': 'Stem Builder'}, - ); + final runId = await StemWorkflowDefinitions.flow + .call(const {'name': 'Stem Builder'}) + .startWithRuntime(runtime); await runtime.executeRun(runId); - final result = await runtime.viewRun(runId); - print('\nFlow result: ${result?.result}'); + 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 index 85ceadaf..1aacdee4 100644 --- a/packages/stem_builder/example/bin/runtime_metadata_views.dart +++ b/packages/stem_builder/example/bin/runtime_metadata_views.dart @@ -1,9 +1,10 @@ import 'dart:convert'; +import 'package:stem/stem.dart'; import 'package:stem_builder_example/definitions.dart'; Future main() async { - final app = await createStemGeneratedInMemoryApp(); + final app = await StemWorkflowApp.inMemory(module: stemModule); final runtime = app.runtime; try { @@ -11,7 +12,7 @@ Future main() async { print( const JsonEncoder.withIndent( ' ', - ).convert(stemWorkflowManifest.map((entry) => entry.toJson()).toList()), + ).convert(stemModule.workflowManifest.map((entry) => entry.toJson()).toList()), ); print('\n--- Runtime manifest (registered definitions) ---'); @@ -24,12 +25,14 @@ Future main() async { ), ); - final flowRunId = await runtime.startFlow( - params: const {'name': 'runtime metadata'}, - ); + final flowRunId = await StemWorkflowDefinitions.flow + .call(const {'name': 'runtime metadata'}) + .startWithRuntime(runtime); await runtime.executeRun(flowRunId); - final scriptRunId = await runtime.startUserSignup(email: 'dev@stem.dev'); + final scriptRunId = await StemWorkflowDefinitions.userSignup + .call((email: 'dev@stem.dev')) + .startWithRuntime(runtime); await runtime.executeRun(scriptRunId); final runViews = await runtime.listRunViews(limit: 10); diff --git a/packages/stem_builder/example/lib/definitions.stem.g.dart b/packages/stem_builder/example/lib/definitions.stem.g.dart index 8b74ae78..2f87ebb1 100644 --- a/packages/stem_builder/example/lib/definitions.stem.g.dart +++ b/packages/stem_builder/example/lib/definitions.stem.g.dart @@ -3,7 +3,7 @@ part of 'definitions.dart'; -final List stemFlows = [ +final List _stemFlows = [ Flow( name: "builder.example.flow", build: (flow) { @@ -46,7 +46,7 @@ class _StemScriptProxy0 extends BuilderUserSignupWorkflow { } } -final List stemScripts = [ +final List _stemScripts = [ WorkflowScript( name: "builder.example.user_signup", checkpoints: [ @@ -75,95 +75,74 @@ final List stemScripts = [ ), ]; -abstract final class StemWorkflowNames { - static const String flow = "builder.example.flow"; - static const String userSignup = "builder.example.user_signup"; +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}, + ); } -extension StemGeneratedWorkflowAppStarters on StemWorkflowApp { - Future startFlow({ - Map params = const {}, - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - return startWorkflow( - StemWorkflowNames.flow, - params: params, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ); - } +Future _stemScriptManifestStepNoop(FlowContext context) async => null; - Future startUserSignup({ - required String email, - Map extraParams = const {}, - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - final params = {...extraParams, "email": email}; - return startWorkflow( - StemWorkflowNames.userSignup, - params: params, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ); +Object? _stemRequireArg(Map args, String name) { + if (!args.containsKey(name)) { + throw ArgumentError('Missing required argument "$name".'); } + return args[name]; } -extension StemGeneratedWorkflowRuntimeStarters on WorkflowRuntime { - Future startFlow({ - Map params = const {}, - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, - }) { - return startWorkflow( - StemWorkflowNames.flow, - params: params, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, - ); - } +abstract final class StemTaskDefinitions { + static final TaskDefinition, Object?> + builderExampleTask = TaskDefinition, Object?>( + name: "builder.example.task", + encodeArgs: (args) => args, + defaultOptions: const TaskOptions(), + metadata: const TaskMetadata(), + ); +} - Future startUserSignup({ - required String email, - Map extraParams = const {}, - String? parentRunId, - Duration? ttl, - WorkflowCancellationPolicy? cancellationPolicy, +extension StemGeneratedTaskEnqueuer on TaskEnqueuer { + Future enqueueBuilderExampleTask({ + required Map args, + Map headers = const {}, + TaskOptions? options, + DateTime? notBefore, + Map? meta, + TaskEnqueueOptions? enqueueOptions, }) { - final params = {...extraParams, "email": email}; - return startWorkflow( - StemWorkflowNames.userSignup, - params: params, - parentRunId: parentRunId, - ttl: ttl, - cancellationPolicy: cancellationPolicy, + return enqueueCall( + StemTaskDefinitions.builderExampleTask.call( + args, + headers: headers, + options: options, + notBefore: notBefore, + meta: meta, + enqueueOptions: enqueueOptions, + ), ); } } -final List stemWorkflowManifest = - [ - ...stemFlows.map((flow) => flow.definition.toManifestEntry()), - ...stemScripts.map((script) => script.definition.toManifestEntry()), - ]; - -Future _stemScriptManifestStepNoop(FlowContext context) async => null; - -Object? _stemRequireArg(Map args, String name) { - if (!args.containsKey(name)) { - throw ArgumentError('Missing required argument "$name".'); +extension StemGeneratedTaskResults on Stem { + Future?> waitForBuilderExampleTask( + String taskId, { + Duration? timeout, + }) { + return waitForTaskDefinition( + taskId, + StemTaskDefinitions.builderExampleTask, + timeout: timeout, + ); } - return args[name]; } -final List> stemTasks = >[ +final List> _stemTasks = >[ FunctionTaskHandler( name: "builder.example.task", entrypoint: builderExampleTask, @@ -172,46 +151,15 @@ final List> stemTasks = >[ ), ]; -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); - } -} - -Future createStemGeneratedWorkflowApp({ - required StemApp stemApp, - bool registerTasks = true, - Duration pollInterval = const Duration(milliseconds: 500), - Duration leaseExtension = const Duration(seconds: 30), - WorkflowRegistry? workflowRegistry, - WorkflowIntrospectionSink? introspectionSink, -}) async { - if (registerTasks) { - for (final handler in stemTasks) { - stemApp.register(handler); - } - } - return StemWorkflowApp.create( - stemApp: stemApp, - flows: stemFlows, - scripts: stemScripts, - pollInterval: pollInterval, - leaseExtension: leaseExtension, - workflowRegistry: workflowRegistry, - introspectionSink: introspectionSink, - ); -} +final List _stemWorkflowManifest = + [ + ..._stemFlows.map((flow) => flow.definition.toManifestEntry()), + ..._stemScripts.map((script) => script.definition.toManifestEntry()), + ]; -Future createStemGeneratedInMemoryApp() async { - final stemApp = await StemApp.inMemory(tasks: stemTasks); - return createStemGeneratedWorkflowApp(stemApp: stemApp, registerTasks: false); -} +final StemModule stemModule = StemModule( + flows: _stemFlows, + scripts: _stemScripts, + tasks: _stemTasks, + workflowManifest: _stemWorkflowManifest, +); diff --git a/packages/stem_builder/lib/src/stem_registry_builder.dart b/packages/stem_builder/lib/src/stem_registry_builder.dart index c08d4862..1b715cbd 100644 --- a/packages/stem_builder/lib/src/stem_registry_builder.dart +++ b/packages/stem_builder/lib/src/stem_registry_builder.dart @@ -3,6 +3,8 @@ 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'; @@ -203,6 +205,8 @@ class StemRegistryBuilder implements Builder { valueParameters: stepBinding.valueParameters, returnTypeCode: stepBinding.returnTypeCode, stepValueTypeCode: stepBinding.stepValueTypeCode, + stepValuePayloadCodecTypeCode: + stepBinding.stepValuePayloadCodecTypeCode, autoVersion: autoVersion, title: title, kind: kindValue, @@ -211,6 +215,18 @@ class StemRegistryBuilder implements Builder { ), ); } + _ensureUniqueWorkflowStepNames( + classElement, + scriptSteps, + label: 'checkpoint', + ); + await _diagnoseScriptCheckpointPatterns( + buildStep, + classElement, + runMethod, + scriptSteps, + runAcceptsScriptContext: runBinding.acceptsContext, + ); workflows.add( _WorkflowInfo.script( name: workflowName, @@ -220,6 +236,8 @@ class StemRegistryBuilder implements Builder { runMethod: runMethod.displayName, runAcceptsScriptContext: runBinding.acceptsContext, runValueParameters: runBinding.valueParameters, + resultTypeCode: runBinding.resultTypeCode, + resultPayloadCodecTypeCode: runBinding.resultPayloadCodecTypeCode, version: version, description: description, metadata: metadata, @@ -274,7 +292,9 @@ class StemRegistryBuilder implements Builder { acceptsScriptStepContext: false, valueParameters: stepBinding.valueParameters, returnTypeCode: null, - stepValueTypeCode: null, + stepValueTypeCode: stepBinding.stepValueTypeCode, + stepValuePayloadCodecTypeCode: + stepBinding.stepValuePayloadCodecTypeCode, autoVersion: autoVersion, title: title, kind: kindValue, @@ -283,12 +303,18 @@ class StemRegistryBuilder implements Builder { ), ); } + _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, @@ -322,6 +348,16 @@ class StemRegistryBuilder implements Builder { _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, @@ -338,6 +374,8 @@ class StemRegistryBuilder implements Builder { acceptsTaskContext: taskBinding.acceptsContext, valueParameters: taskBinding.valueParameters, usesLegacyMapArgs: taskBinding.usesLegacyMapArgs, + resultTypeCode: taskBinding.resultTypeCode, + resultPayloadCodecTypeCode: taskBinding.resultPayloadCodecTypeCode, options: options, metadata: metadata, runInIsolate: runInIsolate, @@ -406,27 +444,27 @@ class StemRegistryBuilder implements Builder { for (final parameter in parameters.skip(startIndex)) { if (!parameter.isRequiredPositional) { throw InvalidGenerationSourceError( - '@workflow.run method ${method.displayName} only supports required positional serializable parameters after WorkflowScriptContext.', + '@workflow.run method ${method.displayName} only supports required positional serializable or codec-backed parameters after WorkflowScriptContext.', element: method, ); } - if (!_isSerializableValueType(parameter.type)) { + final valueParameter = _createValueParameterInfo(parameter); + if (valueParameter == null) { throw InvalidGenerationSourceError( - '@workflow.run method ${method.displayName} parameter "${parameter.displayName}" must use a serializable type.', + '@workflow.run method ${method.displayName} parameter "${parameter.displayName}" must use a serializable or codec-backed DTO type.', element: method, ); } - valueParameters.add( - _ValueParameterInfo( - name: parameter.displayName, - typeCode: _typeCode(parameter.type), - ), - ); + valueParameters.add(valueParameter); } return _RunBinding( acceptsContext: acceptsContext, valueParameters: valueParameters, + resultTypeCode: _workflowResultTypeCode(method.returnType), + resultPayloadCodecTypeCode: _workflowResultPayloadCodecTypeCode( + method.returnType, + ), ); } @@ -453,27 +491,27 @@ class StemRegistryBuilder implements Builder { for (final parameter in parameters.skip(startIndex)) { if (!parameter.isRequiredPositional) { throw InvalidGenerationSourceError( - '@workflow.step method ${method.displayName} only supports required positional serializable parameters after FlowContext.', + '@workflow.step method ${method.displayName} only supports required positional serializable or codec-backed parameters after FlowContext.', element: method, ); } - if (!_isSerializableValueType(parameter.type)) { + final valueParameter = _createValueParameterInfo(parameter); + if (valueParameter == null) { throw InvalidGenerationSourceError( - '@workflow.step method ${method.displayName} parameter "${parameter.displayName}" must use a serializable type.', + '@workflow.step method ${method.displayName} parameter "${parameter.displayName}" must use a serializable or codec-backed DTO type.', element: method, ); } - valueParameters.add( - _ValueParameterInfo( - name: parameter.displayName, - typeCode: _typeCode(parameter.type), - ), - ); + valueParameters.add(valueParameter); } return _FlowStepBinding( acceptsContext: acceptsContext, valueParameters: valueParameters, + stepValueTypeCode: _workflowResultTypeCode(method.returnType), + stepValuePayloadCodecTypeCode: _workflowResultPayloadCodecTypeCode( + method.returnType, + ), ); } @@ -511,22 +549,18 @@ class StemRegistryBuilder implements Builder { for (final parameter in parameters.skip(startIndex)) { if (!parameter.isRequiredPositional) { throw InvalidGenerationSourceError( - '@workflow.step method ${method.displayName} only supports required positional serializable parameters after WorkflowScriptStepContext.', + '@workflow.step method ${method.displayName} only supports required positional serializable or codec-backed parameters after WorkflowScriptStepContext.', element: method, ); } - if (!_isSerializableValueType(parameter.type)) { + final valueParameter = _createValueParameterInfo(parameter); + if (valueParameter == null) { throw InvalidGenerationSourceError( - '@workflow.step method ${method.displayName} parameter "${parameter.displayName}" must use a serializable type.', + '@workflow.step method ${method.displayName} parameter "${parameter.displayName}" must use a serializable or codec-backed DTO type.', element: method, ); } - valueParameters.add( - _ValueParameterInfo( - name: parameter.displayName, - typeCode: _typeCode(parameter.type), - ), - ); + valueParameters.add(valueParameter); } return _ScriptStepBinding( @@ -534,6 +568,7 @@ class StemRegistryBuilder implements Builder { valueParameters: valueParameters, returnTypeCode: _typeCode(returnType), stepValueTypeCode: _typeCode(stepValueType), + stepValuePayloadCodecTypeCode: _payloadCodecTypeCode(stepValueType), ); } @@ -559,10 +594,14 @@ class StemRegistryBuilder implements Builder { _isStringObjectMap(remaining.first.type) && remaining.first.isRequiredPositional; if (legacyMapSignature) { - return const _TaskBinding( + return _TaskBinding( acceptsContext: true, valueParameters: [], usesLegacyMapArgs: true, + resultTypeCode: _taskResultTypeCode(function.returnType), + resultPayloadCodecTypeCode: _taskResultPayloadCodecTypeCode( + function.returnType, + ), ); } @@ -570,31 +609,90 @@ class StemRegistryBuilder implements Builder { for (final parameter in remaining) { if (!parameter.isRequiredPositional) { throw InvalidGenerationSourceError( - '@TaskDefn function ${function.displayName} only supports required positional serializable parameters after TaskInvocationContext.', + '@TaskDefn function ${function.displayName} only supports required positional serializable or codec-backed parameters after TaskInvocationContext.', element: function, ); } - if (!_isSerializableValueType(parameter.type)) { + final valueParameter = _createValueParameterInfo(parameter); + if (valueParameter == null) { throw InvalidGenerationSourceError( - '@TaskDefn function ${function.displayName} parameter "${parameter.displayName}" must use a serializable type.', + '@TaskDefn function ${function.displayName} parameter "${parameter.displayName}" must use a serializable or codec-backed DTO type.', element: function, ); } - valueParameters.add( - _ValueParameterInfo( - name: parameter.displayName, - typeCode: _typeCode(parameter.type), - ), - ); + valueParameters.add(valueParameter); } 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) { if (type is! InterfaceType) return false; if (!type.isDartCoreMap) return false; @@ -634,9 +732,52 @@ class StemRegistryBuilder implements Builder { 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; } @@ -675,6 +816,8 @@ class _WorkflowInfo { required this.importAlias, required this.className, required this.steps, + required this.resultTypeCode, + required this.resultPayloadCodecTypeCode, this.starterNameOverride, this.nameFieldOverride, this.version, @@ -693,6 +836,8 @@ class _WorkflowInfo { required this.runMethod, required this.runAcceptsScriptContext, required this.runValueParameters, + required this.resultTypeCode, + required this.resultPayloadCodecTypeCode, this.starterNameOverride, this.nameFieldOverride, this.version, @@ -705,6 +850,8 @@ class _WorkflowInfo { 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; @@ -724,6 +871,7 @@ class _WorkflowStepInfo { required this.valueParameters, required this.returnTypeCode, required this.stepValueTypeCode, + required this.stepValuePayloadCodecTypeCode, required this.autoVersion, required this.title, required this.kind, @@ -738,6 +886,7 @@ class _WorkflowStepInfo { final List<_ValueParameterInfo> valueParameters; final String? returnTypeCode; final String? stepValueTypeCode; + final String? stepValuePayloadCodecTypeCode; final bool autoVersion; final String? title; final DartObject? kind; @@ -745,6 +894,153 @@ 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, @@ -754,6 +1050,8 @@ class _TaskInfo { required this.acceptsTaskContext, required this.valueParameters, required this.usesLegacyMapArgs, + required this.resultTypeCode, + required this.resultPayloadCodecTypeCode, required this.options, required this.metadata, required this.runInIsolate, @@ -766,6 +1064,8 @@ class _TaskInfo { 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; @@ -775,20 +1075,28 @@ class _FlowStepBinding { const _FlowStepBinding({ required this.acceptsContext, required this.valueParameters, + required this.stepValueTypeCode, + required this.stepValuePayloadCodecTypeCode, }); final bool acceptsContext; final List<_ValueParameterInfo> valueParameters; + final String stepValueTypeCode; + final String? stepValuePayloadCodecTypeCode; } class _RunBinding { const _RunBinding({ required this.acceptsContext, required this.valueParameters, + required this.resultTypeCode, + required this.resultPayloadCodecTypeCode, }); final bool acceptsContext; final List<_ValueParameterInfo> valueParameters; + final String resultTypeCode; + final String? resultPayloadCodecTypeCode; } class _ScriptStepBinding { @@ -797,12 +1105,14 @@ class _ScriptStepBinding { 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 { @@ -810,31 +1120,38 @@ class _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(); @@ -847,6 +1164,56 @@ class _RegistryEmitter { 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'); @@ -857,72 +1224,67 @@ class _RegistryEmitter { buffer.writeln("part of '$partOfFile';"); buffer.writeln(); + _emitPayloadCodecs(buffer); _emitWorkflows(buffer); _emitWorkflowStartHelpers(buffer); - _emitManifest(buffer); _emitGeneratedHelpers(buffer); _emitTaskAdapters(buffer); + _emitTaskDefinitions(buffer); _emitTasks(buffer); + _emitManifest(buffer); + _emitModule(buffer); + return buffer.toString(); + } - 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);'); - buffer.writeln(' }'); - buffer.writeln(' for (final handler in stemTasks) {'); - buffer.writeln(' tasks.register(handler);'); + 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('}'); - buffer.writeln(); - buffer.writeln('Future createStemGeneratedWorkflowApp({'); - buffer.writeln(' required StemApp stemApp,'); - buffer.writeln(' bool registerTasks = true,'); + buffer.writeln(' if (value is Map) {'); + buffer.writeln(' final result = {};'); + buffer.writeln(' value.forEach((key, entry) {'); + buffer.writeln(' if (key is! String) {'); buffer.writeln( - ' Duration pollInterval = const Duration(milliseconds: 500),', + 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( - ' Duration leaseExtension = const Duration(seconds: 30),', + r" throw StateError('$typeName payload must decode to Map, got ${value.runtimeType}.');", ); - buffer.writeln(' WorkflowRegistry? workflowRegistry,'); - buffer.writeln(' WorkflowIntrospectionSink? introspectionSink,'); - buffer.writeln('}) async {'); - buffer.writeln(' if (registerTasks) {'); - buffer.writeln(' for (final handler in stemTasks) {'); - buffer.writeln(' stemApp.register(handler);'); - buffer.writeln(' }'); - buffer.writeln(' }'); - buffer.writeln(' return StemWorkflowApp.create('); - buffer.writeln(' stemApp: stemApp,'); - buffer.writeln(' flows: stemFlows,'); - buffer.writeln(' scripts: stemScripts,'); - buffer.writeln(' pollInterval: pollInterval,'); - buffer.writeln(' leaseExtension: leaseExtension,'); - buffer.writeln(' workflowRegistry: workflowRegistry,'); - buffer.writeln(' introspectionSink: introspectionSink,'); - buffer.writeln(' );'); buffer.writeln('}'); buffer.writeln(); - buffer.writeln( - 'Future createStemGeneratedInMemoryApp() async {', - ); - buffer.writeln( - ' final stemApp = await StemApp.inMemory(tasks: stemTasks);', - ); - buffer.writeln(' return createStemGeneratedWorkflowApp('); - buffer.writeln(' stemApp: stemApp,'); - buffer.writeln(' registerTasks: false,'); - 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('}'); - return buffer.toString(); + 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, )) { @@ -939,6 +1301,11 @@ 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 = ${_qualify(workflow.importAlias, workflow.className)}();', @@ -951,7 +1318,7 @@ class _RegistryEmitter { if (step.acceptsFlowContext) 'ctx', if (stepArgs.isNotEmpty) stepArgs, ].join(', '); - buffer.writeln(' flow.step('); + buffer.writeln(' flow.step<${step.stepValueTypeCode}>('); buffer.writeln(' ${_string(step.name)},'); buffer.writeln( ' (ctx) => impl.${step.method}($invocationArgs),', @@ -959,6 +1326,11 @@ class _RegistryEmitter { 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!)},'); } @@ -1031,7 +1403,7 @@ class _RegistryEmitter { } buffer.writeln( - 'final List stemScripts = [', + 'final List _stemScripts = [', ); for (final workflow in scriptWorkflows) { final proxyClass = scriptProxyClassNames[workflow]; @@ -1040,7 +1412,11 @@ class _RegistryEmitter { if (workflow.steps.isNotEmpty) { buffer.writeln(' checkpoints: ['); for (final step in workflow.steps) { - buffer.writeln(' FlowStep('); + if (step.stepValuePayloadCodecTypeCode != null) { + buffer.writeln(' FlowStep.typed<${step.stepValueTypeCode}>('); + } else { + buffer.writeln(' FlowStep('); + } buffer.writeln(' name: ${_string(step.name)},'); buffer.writeln( ' handler: _stemScriptManifestStepNoop,', @@ -1048,6 +1424,13 @@ class _RegistryEmitter { 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!)},'); } @@ -1079,6 +1462,11 @@ class _RegistryEmitter { ' metadata: ${_dartObjectToCode(workflow.metadata!)},', ); } + if (workflow.resultPayloadCodecTypeCode != null) { + final codecField = + payloadCodecSymbols[workflow.resultPayloadCodecTypeCode]!; + buffer.writeln(' resultCodec: StemPayloadCodecs.$codecField,'); + } if (proxyClass != null) { final runArgs = [ if (workflow.runAcceptsScriptContext) 'script', @@ -1110,94 +1498,46 @@ class _RegistryEmitter { if (workflows.isEmpty) { return; } - final symbolNames = _symbolNamesForWorkflows(workflows); - final fieldNames = _fieldNamesForWorkflows(workflows, symbolNames); + final fieldNames = _fieldNamesForWorkflows( + workflows, + _symbolNamesForWorkflows(workflows), + ); - buffer.writeln('abstract final class StemWorkflowNames {'); + buffer.writeln('abstract final class StemWorkflowDefinitions {'); for (final workflow in workflows) { + final fieldName = fieldNames[workflow]!; + final argsTypeCode = _workflowArgsTypeCode(workflow); buffer.writeln( - ' static const String ${fieldNames[workflow]} = ${_string(workflow.name)};', + ' static final WorkflowRef<$argsTypeCode, ${workflow.resultTypeCode}> ' + '$fieldName = WorkflowRef<$argsTypeCode, ${workflow.resultTypeCode}>(', ); - } - buffer.writeln('}'); - buffer.writeln(); - - _emitWorkflowStarterExtension( - buffer, - extensionName: 'StemGeneratedWorkflowAppStarters', - targetType: 'StemWorkflowApp', - symbolNames: symbolNames, - fieldNames: fieldNames, - ); - _emitWorkflowStarterExtension( - buffer, - extensionName: 'StemGeneratedWorkflowRuntimeStarters', - targetType: 'WorkflowRuntime', - symbolNames: symbolNames, - fieldNames: fieldNames, - ); - } - - void _emitWorkflowStarterExtension( - StringBuffer buffer, { - required String extensionName, - required String targetType, - required Map<_WorkflowInfo, String> symbolNames, - required Map<_WorkflowInfo, String> fieldNames, - }) { - buffer.writeln('extension $extensionName on $targetType {'); - for (final workflow in workflows) { - final methodName = 'start${symbolNames[workflow]}'; - if (workflow.kind == WorkflowKind.script && - workflow.runValueParameters.isNotEmpty) { - buffer.writeln(' Future $methodName({'); - for (final parameter in workflow.runValueParameters) { - buffer.writeln( - ' required ${parameter.typeCode} ${parameter.name},', - ); - } - buffer.writeln(' Map extraParams = const {},'); - buffer.writeln(' String? parentRunId,'); - buffer.writeln(' Duration? ttl,'); - buffer.writeln( - ' WorkflowCancellationPolicy? cancellationPolicy,', - ); - buffer.writeln(' }) {'); - buffer.writeln(' final params = {'); - buffer.writeln(' ...extraParams,'); - for (final parameter in workflow.runValueParameters) { + buffer.writeln(' name: ${_string(workflow.name)},'); + if (workflow.kind == WorkflowKind.script) { + if (workflow.runValueParameters.isEmpty) { buffer.writeln( - ' ${_string(parameter.name)}: ${parameter.name},', + ' 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(' },'); } - buffer.writeln(' };'); - buffer.writeln(' return startWorkflow('); - buffer.writeln(' StemWorkflowNames.${fieldNames[workflow]},'); - buffer.writeln(' params: params,'); - buffer.writeln(' parentRunId: parentRunId,'); - buffer.writeln(' ttl: ttl,'); - buffer.writeln(' cancellationPolicy: cancellationPolicy,'); - buffer.writeln(' );'); - buffer.writeln(' }'); } else { - buffer.writeln(' Future $methodName({'); - buffer.writeln(' Map params = const {},'); - buffer.writeln(' String? parentRunId,'); - buffer.writeln(' Duration? ttl,'); + buffer.writeln(' encodeParams: (params) => params,'); + } + if (workflow.resultPayloadCodecTypeCode != null) { + final codecField = + payloadCodecSymbols[workflow.resultPayloadCodecTypeCode]!; buffer.writeln( - ' WorkflowCancellationPolicy? cancellationPolicy,', + ' decodeResult: StemPayloadCodecs.$codecField.decode,', ); - buffer.writeln(' }) {'); - buffer.writeln(' return startWorkflow('); - buffer.writeln(' StemWorkflowNames.${fieldNames[workflow]},'); - buffer.writeln(' params: params,'); - buffer.writeln(' parentRunId: parentRunId,'); - buffer.writeln(' ttl: ttl,'); - buffer.writeln(' cancellationPolicy: cancellationPolicy,'); - buffer.writeln(' );'); - buffer.writeln(' }'); } - buffer.writeln(); + buffer.writeln(' );'); } buffer.writeln('}'); buffer.writeln(); @@ -1262,6 +1602,44 @@ class _RegistryEmitter { 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, @@ -1300,6 +1678,10 @@ class _RegistryEmitter { } 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) @@ -1319,6 +1701,10 @@ class _RegistryEmitter { } String _lowerCamel(String value) { + return _lowerCamelStatic(value); + } + + static String _lowerCamelStatic(String value) { if (value.isEmpty) return value; return '${value[0].toLowerCase()}${value.substring(1)}'; } @@ -1330,20 +1716,21 @@ class _RegistryEmitter { 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: $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,'); @@ -1354,6 +1741,114 @@ class _RegistryEmitter { 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) { @@ -1414,23 +1909,138 @@ class _RegistryEmitter { void _emitManifest(StringBuffer buffer) { buffer.writeln( - 'final List stemWorkflowManifest = [', + 'final List _stemWorkflowManifest = [', ); buffer.writeln( - ' ...stemFlows.map((flow) => flow.definition.toManifestEntry()),', + ' ..._stemFlows.map((flow) => flow.definition.toManifestEntry()),', ); buffer.writeln( - ' ...stemScripts.map((script) => script.definition.toManifestEntry()),', + ' ..._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'; diff --git a/packages/stem_builder/test/stem_registry_builder_test.dart b/packages/stem_builder/test/stem_registry_builder_test.dart index 35963d28..4ec8967f 100644 --- a/packages/stem_builder/test/stem_registry_builder_test.dart +++ b/packages/stem_builder/test/stem_registry_builder_test.dart @@ -11,11 +11,18 @@ 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 [], @@ -24,6 +31,7 @@ class FlowStep { final String name; final _FlowStepHandler handler; final bool autoVersion; + final PayloadCodec? valueCodec; final String? title; final WorkflowStepKind kind; final List taskNames; @@ -88,7 +96,11 @@ 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 { @@ -96,6 +108,8 @@ class WorkflowScript { required String name, required dynamic run, List steps = const [], + List checkpoints = const [], + PayloadCodec? resultCodec, }); } @@ -105,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); } @@ -149,25 +175,23 @@ Future sendEmail( AssetId('stem', 'lib/stem.dart'), stubStem, ), - outputs: { - 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( - allOf([ - contains('registerStemDefinitions'), - contains('StemWorkflowNames'), - contains('StemGeneratedWorkflowAppStarters'), - contains('StemGeneratedWorkflowRuntimeStarters'), - contains('startFlow'), - contains('startWorkflow'), - contains('createStemGeneratedWorkflowApp'), - contains('createStemGeneratedInMemoryApp'), - contains('Flow('), - contains('WorkflowScript('), - contains('stemWorkflowManifest'), - contains('FunctionTaskHandler'), - contains("part of 'workflows.dart';"), - ]), - ), - }, + 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';"), + ]), + ), + }, ); }); @@ -240,14 +264,14 @@ class DailyBillingWorkflow { outputs: { 'stem_builder|lib/workflows.stem.g.dart': decodedMatches( allOf([ - contains('static const String helloFlow = "hello.flow";'), contains( - 'static const String dailyBilling = "billing.daily_sync";', + 'static final WorkflowRef, Object?> ' + 'helloFlow =', + ), + contains( + 'static final WorkflowRef<({String tenant}), Object?> ' + 'dailyBilling =', ), - contains('Future startLaunchHello({'), - contains('Future startDailyBilling({'), - contains('StemWorkflowNames.helloFlow'), - contains('StemWorkflowNames.dailyBilling'), ]), ), }, @@ -378,6 +402,80 @@ class BadScriptWorkflow { ); }); + 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 {} +} +'''; + + 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('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 { @@ -418,9 +516,12 @@ class SignupWorkflow { ').run((_stemRequireArg(script.params, "email") as String))', ), contains('_stemRequireArg(script.params, "email") as String'), - contains('Future startSignupWorkflow({'), - contains('required String email,'), - contains('Map extraParams = const {},'), + contains('abstract final class StemWorkflowDefinitions'), + contains( + 'signupWorkflow = WorkflowRef<({String email}), ' + 'Map>(', + ), + isNot(contains('extraParams')), ]), ), }, @@ -496,7 +597,10 @@ class BadScriptWorkflow { ), ); expect(result.succeeded, isFalse); - expect(result.errors.join('\n'), contains('serializable type')); + expect( + result.errors.join('\n'), + contains('serializable or codec-backed DTO type'), + ); }); test('rejects task args that are not Map', () async { @@ -523,7 +627,10 @@ Future badTask( ), ); expect(result.succeeded, isFalse); - expect(result.errors.join('\n'), contains('serializable type')); + expect( + result.errors.join('\n'), + contains('serializable or codec-backed DTO type'), + ); }); test('generates adapters for typed workflow and task parameters', () async { @@ -570,6 +677,86 @@ Future typedTask( ); }); + 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'; @@ -595,6 +782,9 @@ class BadWorkflow { ); expect(result.succeeded, isFalse); - expect(result.errors.join('\n'), contains('serializable type')); + expect( + result.errors.join('\n'), + contains('serializable or codec-backed DTO type'), + ); }); } From afc51c1f1fa461a23e521161b3715549d76a7489 Mon Sep 17 00:00:00 2001 From: kingwill101 Date: Thu, 19 Mar 2026 11:41:13 -0500 Subject: [PATCH 23/23] Document typed workflow and task APIs --- .site/docs/core-concepts/cli-control.md | 6 +- .site/docs/core-concepts/index.md | 2 +- .site/docs/core-concepts/stem-builder.md | 43 +++++----- .site/docs/workflows/annotated-workflows.md | 48 ++++++++--- .../workflows/context-and-serialization.md | 35 +++++--- .site/docs/workflows/index.md | 7 +- .site/docs/workflows/starting-and-waiting.md | 21 +++-- .../docs/workflows/suspensions-and-events.md | 24 ++++-- .site/docs/workflows/troubleshooting.md | 6 +- packages/stem/README.md | 76 +++++++++-------- .../example/annotated_workflows/README.md | 42 ++++++--- .../example/docs_snippets/lib/workflows.dart | 17 ++-- packages/stem/example/ecommerce/README.md | 12 +-- packages/stem_builder/README.md | 85 ++++++++++++------- packages/stem_builder/example/README.md | 24 +++--- 15 files changed, 271 insertions(+), 177 deletions(-) diff --git a/.site/docs/core-concepts/cli-control.md b/.site/docs/core-concepts/cli-control.md index 3f4a5450..e1fb99bb 100644 --- a/.site/docs/core-concepts/cli-control.md +++ b/.site/docs/core-concepts/cli-control.md @@ -119,14 +119,14 @@ 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 `stemTasks` your app uses, so task metadata stays consistent +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 generated `stemTasks`. See -[Tasks](./tasks.md) and [stem_builder](./stem-builder.md). +For normal app bootstrap, prefer `tasks: [...]` or a generated `stemModule`. +See [Tasks](./tasks.md) and [stem_builder](./stem-builder.md). ## List registered tasks diff --git a/.site/docs/core-concepts/index.md b/.site/docs/core-concepts/index.md index d16a0854..03385b5d 100644 --- a/.site/docs/core-concepts/index.md +++ b/.site/docs/core-concepts/index.md @@ -54,7 +54,7 @@ behavior before touching production. - **[Observability](./observability.md)** – Metrics, traces, logging, and lifecycle signals. - **[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, and typed starters from annotations. +- **[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/stem-builder.md b/.site/docs/core-concepts/stem-builder.md index 88215c26..069fdb23 100644 --- a/.site/docs/core-concepts/stem-builder.md +++ b/.site/docs/core-concepts/stem-builder.md @@ -6,7 +6,7 @@ slug: /core-concepts/stem-builder --- `stem_builder` generates workflow/task definitions, manifests, helper output, -and typed workflow starters from annotations, so you can avoid stringly-typed +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 @@ -66,12 +66,9 @@ dart run build_runner build --delete-conflicting-outputs Generated output (`workflow_defs.stem.g.dart`) includes: -- `stemScripts`, `stemFlows`, `stemTasks` -- typed starters like `workflowApp.startUserSignup(...)` -- `StemWorkflowNames` constants -- convenience helpers such as `createStemGeneratedWorkflowApp(...)` -- `registerStemDefinitions(...)` for advanced/manual integrations that still - need explicit registries +- `stemModule` +- typed workflow refs like `StemWorkflowDefinitions.userSignup` +- typed task definitions, enqueue helpers, and typed result wait helpers ## Wire Into StemWorkflowApp @@ -80,13 +77,13 @@ Use the generated definitions/helpers directly with `StemWorkflowApp`: ```dart final workflowApp = await StemWorkflowApp.fromUrl( 'memory://', - scripts: stemScripts, - flows: stemFlows, - tasks: stemTasks, + module: stemModule, ); await workflowApp.start(); -final runId = await workflowApp.startUserSignup(email: 'user@example.com'); +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 @@ -96,14 +93,12 @@ bootstrapping a second app: final stemApp = await StemApp.fromUrl( 'redis://localhost:6379', adapters: const [StemRedisAdapter()], - tasks: stemTasks, + tasks: stemModule.tasks, ); final workflowApp = await StemWorkflowApp.create( stemApp: stemApp, - scripts: stemScripts, - flows: stemFlows, - tasks: stemTasks, + module: stemModule, ); ``` @@ -114,18 +109,16 @@ shared-client path: final client = await StemClient.fromUrl( 'redis://localhost:6379', adapters: const [StemRedisAdapter()], - tasks: stemTasks, ); -final workflowApp = await client.createWorkflowApp( - scripts: stemScripts, - flows: stemFlows, -); +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`. @@ -133,5 +126,11 @@ final workflowApp = await client.createWorkflowApp( parameters. - Use `@WorkflowRun()` plus `WorkflowScriptContext` when you need to enter a context-aware script checkpoint that consumes `WorkflowScriptStepContext`. -- Arbitrary Dart class instances are not supported directly; encode them into - `Map` first. +- 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/workflows/annotated-workflows.md b/.site/docs/workflows/annotated-workflows.md index 0ba75471..64d6b77c 100644 --- a/.site/docs/workflows/annotated-workflows.md +++ b/.site/docs/workflows/annotated-workflows.md @@ -10,29 +10,37 @@ methods instead of manual `Flow(...)` or `WorkflowScript(...)` objects. After adding `part '.stem.g.dart';` and running `build_runner`, the generated file exposes: -- `stemFlows` -- `stemScripts` -- `stemTasks` -- `StemWorkflowNames` -- typed starter helpers like `startUserSignup(...)` +- `stemModule` +- `StemWorkflowDefinitions` +- `StemTaskDefinitions` +- typed workflow refs like `StemWorkflowDefinitions.userSignup` +- typed enqueue helpers like `enqueueSendEmailTyped(...)` +- typed result wait helpers like `waitForSendEmailTyped(...)` -Wire those directly into `StemWorkflowApp`: +Wire the bundle directly into `StemWorkflowApp`: ```dart final workflowApp = await StemWorkflowApp.fromUrl( 'memory://', - flows: stemFlows, - scripts: stemScripts, - tasks: stemTasks, + 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 -parameters: +values or codec-backed DTO parameters: ```dart @WorkflowDefn(name: 'builder.example.user_signup', kind: WorkflowKind.script) @@ -107,7 +115,25 @@ example that demonstrates: - `WorkflowScriptContext` - `WorkflowScriptStepContext` - `TaskInvocationContext` -- typed task parameter decoding +- 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 index bfc27f77..ee508c7d 100644 --- a/.site/docs/workflows/context-and-serialization.md +++ b/.site/docs/workflows/context-and-serialization.md @@ -26,6 +26,7 @@ Depending on the context type, you can access: - `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` @@ -50,21 +51,35 @@ Unsupported directly: - annotated workflow/task method signatures with optional or named business parameters -If you have a domain object, encode it first: +If you have a domain object, prefer a codec-backed DTO: ```dart -final order = { - 'id': 'ord_42', - 'customerId': 'cus_7', - 'totalCents': 1250, -}; +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, + ); + } +} ``` -Decode it inside the workflow or task body, not at the durable boundary. +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. -Generated starter helpers may still expose named parameters as a wrapper over -the serialized params map. The restriction 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 diff --git a/.site/docs/workflows/index.md b/.site/docs/workflows/index.md index 7e434031..ea4c9fc5 100644 --- a/.site/docs/workflows/index.md +++ b/.site/docs/workflows/index.md @@ -20,7 +20,8 @@ orients you and links back here. - **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 - starters when you want plain method signatures and less string-based wiring. + workflow refs when you want plain method signatures and less string-based + wiring. ## Read this section in order @@ -29,11 +30,11 @@ orients you and links back here. - [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 starters, results, and cancellation policies. + 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 starters. + 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) diff --git a/.site/docs/workflows/starting-and-waiting.md b/.site/docs/workflows/starting-and-waiting.md index c127231c..67ec8862 100644 --- a/.site/docs/workflows/starting-and-waiting.md +++ b/.site/docs/workflows/starting-and-waiting.md @@ -2,7 +2,8 @@ title: Starting and Waiting --- -Workflow runs are started through the runtime or through `StemWorkflowApp`. +Workflow runs are started through the runtime, through `StemWorkflowApp`, or +through generated workflow refs. ## Start by workflow name @@ -25,18 +26,22 @@ Use the returned `WorkflowResult` when you need: - `status` for partial progress - `timedOut` to decide whether to keep polling -## Start through generated helpers +## Start through generated workflow refs -When you use `stem_builder`, generated extension methods remove the raw -workflow-name strings: +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 runId = await workflowApp.startUserSignup(email: 'user@example.com'); -final result = await workflowApp.waitForCompletion>(runId); +final result = await StemWorkflowDefinitions.userSignup + .call((email: 'user@example.com')) + .startAndWaitWithApp(workflowApp); ``` -These starters also exist on `WorkflowRuntime` when you want to work below the -`StemWorkflowApp` abstraction. +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 diff --git a/.site/docs/workflows/suspensions-and-events.md b/.site/docs/workflows/suspensions-and-events.md index 15485b4b..f3cd9e70 100644 --- a/.site/docs/workflows/suspensions-and-events.md +++ b/.site/docs/workflows/suspensions-and-events.md @@ -21,23 +21,29 @@ 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(...)` (or an app/service wrapper - around it) with a payload +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()` + `takeResumeData()` or `takeResumeValue(codec: ...)` ## Emit resume events -Use `WorkflowRuntime.emit(...)` / `workflowApp.runtime.emit(...)` instead of -hand-editing store state: +Use `WorkflowRuntime.emit(...)` / `WorkflowRuntime.emitValue(...)` (or the app +wrapper `workflowApp.emitValue(...)`) instead of hand-editing store state: ```dart -await workflowApp.runtime.emit('orders.payment.confirmed', { - 'paymentId': 'pay_42', - 'approvedBy': 'gateway', -}); +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: diff --git a/.site/docs/workflows/troubleshooting.md b/.site/docs/workflows/troubleshooting.md index 3b8db907..60769fb2 100644 --- a/.site/docs/workflows/troubleshooting.md +++ b/.site/docs/workflows/troubleshooting.md @@ -22,10 +22,10 @@ task queue such as `default`. Check: -- the topic passed to `WorkflowRuntime.emit(...)` or - `workflowApp.runtime.emit(...)` matches the one passed to `awaitEvent(...)` +- 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 is a `Map` +- the payload encodes to a `Map` ## Serialization failures diff --git a/packages/stem/README.md b/packages/stem/README.md index 7f723d96..a8d0fbef 100644 --- a/packages/stem/README.md +++ b/packages/stem/README.md @@ -291,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'; @@ -322,8 +322,8 @@ 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 @@ -331,13 +331,13 @@ 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 starters +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 `startXxx(...)` helpers +- start runs with `startWorkflow(...)` or generated workflow refs - wait with `waitForCompletion(...)` You do not need to build task registries manually for normal workflow usage. @@ -357,7 +357,7 @@ final approvalsFlow = Flow( }); 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; @@ -400,7 +400,7 @@ final billingRetryScript = 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'; @@ -424,7 +424,7 @@ final app = await StemWorkflowApp.inMemory( #### Annotated workflows with `stem_builder` Use `stem_builder` when you want the best DX: plain method signatures, -generated manifests, and typed starter helpers. +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 @@ -493,12 +493,18 @@ Serializable parameter rules for generated workflows and tasks are strict: - `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: - - arbitrary Dart class instances - optional/named parameters on generated workflow/task entrypoints -If you want to pass a domain object, encode it into a serializable map first -and decode it inside the workflow or task body. +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: @@ -506,6 +512,7 @@ See the runnable example: - `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: @@ -514,44 +521,39 @@ Generate code: dart run build_runner build ``` -Wire the generated definitions directly into `StemWorkflowApp`: +Wire the generated bundle directly into `StemWorkflowApp`: ```dart final app = await StemWorkflowApp.fromUrl( 'memory://', - flows: stemFlows, - scripts: stemScripts, - tasks: stemTasks, + module: stemModule, ); -final runId = await app.startUserSignup(email: 'user@example.com'); -final result = await app.waitForCompletion>(runId); +final result = await StemWorkflowDefinitions.userSignup + .call((email: 'user@example.com')) + .startAndWaitWithApp(app); print(result?.value); await app.close(); ``` Generated output gives you: -- `stemFlows` -- `stemScripts` -- `stemTasks` -- `StemWorkflowNames` -- typed starter helpers on `StemWorkflowApp` and `WorkflowRuntime` +- `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 stemApp = await StemApp.fromUrl( +final client = await StemClient.fromUrl( 'redis://localhost:6379', adapters: const [StemRedisAdapter()], - tasks: stemTasks, ); -final workflowApp = await StemWorkflowApp.create( - stemApp: stemApp, - flows: stemFlows, - scripts: stemScripts, - tasks: stemTasks, +final workflowApp = await client.createWorkflowApp( + module: stemModule, ); ``` @@ -582,7 +584,8 @@ 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 with `tasks:` +- both are wired into the same app, and generated modules bundle the two + surfaces together ### Typed workflow completion @@ -825,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; @@ -839,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 22363f94..615f0fc4 100644 --- a/packages/stem/example/annotated_workflows/README.md +++ b/packages/stem/example/annotated_workflows/README.md @@ -1,40 +1,58 @@ # 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(String email)` calls annotated step methods directly +- `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 typed `@TaskDefn` using `TaskInvocationContext` plus serializable scalar, - `Map`, and `List` parameters +- 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 decoded serializable parameters and task - invocation metadata +- 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 only support required -positional parameters that are serializable: +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. -Arbitrary Dart class instances are not supported directly. Encode them into a -serializable map first, then decode them inside your workflow or task if you -want a richer domain model. +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 @@ -51,7 +69,7 @@ From the repo root: task demo:annotated ``` -## Regenerate the registry +## Regenerate the bundle ```bash dart run build_runner build --delete-conflicting-outputs diff --git a/packages/stem/example/docs_snippets/lib/workflows.dart b/packages/stem/example/docs_snippets/lib/workflows.dart index a438321f..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'; @@ -150,7 +150,7 @@ class ApprovalsAnnotatedWorkflow { @WorkflowStep(name: 'manager-review') Future managerReview(FlowContext ctx) async { - final resume = ctx.takeResumeData() as Map?; + final resume = ctx.takeResumeValue>(); if (resume == null) { await ctx.awaitEvent('approvals.manager'); return null; @@ -170,7 +170,7 @@ class BillingRetryAnnotatedWorkflow { @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/ecommerce/README.md b/packages/stem/example/ecommerce/README.md index ad2b87a1..17432061 100644 --- a/packages/stem/example/ecommerce/README.md +++ b/packages/stem/example/ecommerce/README.md @@ -33,10 +33,10 @@ The annotated workflow/task definitions live in: From those annotations, this example uses generated APIs: -- `stemScripts` (workflow script registration) -- `stemTasks` (task handler registration, passed into `StemWorkflowApp`) -- `workflowApp.startAddToCart(...)` (typed starter extension) -- `StemWorkflowNames.addToCart` (stable workflow name constant) +- `stemModule` (generated workflow/task bundle) +- `StemWorkflowDefinitions.addToCart` +- `StemTaskDefinitions.ecommerceAuditLog` +- `TaskEnqueuer.enqueueEcommerceAuditLog(...)` The server wires generated and manual tasks together in one place: @@ -44,9 +44,9 @@ The server wires generated and manual tasks together in one place: final workflowApp = await StemWorkflowApp.fromUrl( 'sqlite://$stemDatabasePath', adapters: const [StemSqliteAdapter()], - scripts: stemScripts, + module: stemModule, flows: [buildCheckoutFlow(repository)], - tasks: [...stemTasks, shipmentReserveTaskHandler], + tasks: [shipmentReserveTaskHandler], ); ``` diff --git a/packages/stem_builder/README.md b/packages/stem_builder/README.md index fb1ea694..c4a1797a 100644 --- a/packages/stem_builder/README.md +++ b/packages/stem_builder/README.md @@ -111,24 +111,30 @@ Serializable parameter rules are enforced by the generator: - `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: - - arbitrary Dart class instances - optional/named parameters on generated workflow/task entrypoints -If you need to pass a richer domain object, encode it as -`Map` first and decode it inside the workflow or task body. +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 `stemFlows`, `stemScripts`, and `stemTasks` into - `StemWorkflowApp` -- start workflows through generated `startXxx(...)` helpers instead of raw +- 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 starter names via `@WorkflowDefn`: +You can customize generated workflow ref names via `@WorkflowDefn`: ```dart @WorkflowDefn( @@ -148,36 +154,32 @@ Run build_runner to generate `*.stem.g.dart` part files: dart run build_runner build ``` -The generated part exports helpers like `registerStemDefinitions`, -`createStemGeneratedWorkflowApp`, `createStemGeneratedInMemoryApp`, and typed -starters so you can avoid raw workflow-name strings (for example -`runtime.startScript(email: 'user@example.com')`). +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: -- `stemFlows` -- `stemScripts` -- `stemTasks` -- `StemWorkflowNames` -- starter extensions on both `StemWorkflowApp` and `WorkflowRuntime` -- convenience helpers for creating generated apps in memory or on top of an - existing `StemApp` +- `stemModule` +- `StemWorkflowDefinitions` +- `StemTaskDefinitions` +- typed enqueue helpers on `TaskEnqueuer` +- typed result wait helpers on `Stem` ## Wiring Into StemWorkflowApp -For the common case, pass generated tasks and workflows directly to -`StemWorkflowApp`: +For the common case, pass the generated bundle directly to `StemWorkflowApp`: ```dart final workflowApp = await StemWorkflowApp.fromUrl( 'redis://localhost:6379', - scripts: stemScripts, - flows: stemFlows, - tasks: stemTasks, + module: stemModule, ); -final runId = await workflowApp.startUserSignup(email: 'user@example.com'); -final result = await workflowApp.waitForCompletion>(runId); +final result = await StemWorkflowDefinitions.userSignup + .call((email: 'user@example.com')) + .startAndWaitWithApp(workflowApp); ``` If your application already owns a `StemApp`, reuse it: @@ -186,27 +188,44 @@ If your application already owns a `StemApp`, reuse it: final stemApp = await StemApp.fromUrl( 'redis://localhost:6379', adapters: const [StemRedisAdapter()], - tasks: stemTasks, + tasks: stemModule.tasks, ); final workflowApp = await StemWorkflowApp.create( stemApp: stemApp, - scripts: stemScripts, - flows: stemFlows, - tasks: stemTasks, + module: stemModule, ); ``` -The generated helpers work on `WorkflowRuntime` too: +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 runtime.startUserSignup(email: 'user@example.com'); +final runId = await StemWorkflowDefinitions.userSignup + .call((email: 'user@example.com')) + .startWithRuntime(runtime); await runtime.executeRun(runId); ``` -You only need `registerStemDefinitions(...)` when you are integrating with -existing custom `WorkflowRegistry` and `TaskRegistry` instances manually. +Annotated tasks also get generated definitions and enqueue helpers: + +```dart +final taskId = await workflowApp.app.stem.enqueueBuilderExampleTask( + args: const {'kind': 'welcome'}, +); +``` ## Examples diff --git a/packages/stem_builder/example/README.md b/packages/stem_builder/example/README.md index b4737ba2..afca9303 100644 --- a/packages/stem_builder/example/README.md +++ b/packages/stem_builder/example/README.md @@ -3,14 +3,12 @@ This example demonstrates: - Annotated workflow/task definitions -- Generated `registerStemDefinitions(...)` -- Generated app bootstrap helpers: - - `createStemGeneratedInMemoryApp()` - - `createStemGeneratedWorkflowApp(stemApp: ...)` -- Generated typed workflow starters (no manual workflow-name strings): - - `runtime.startFlow(...)` - - `runtime.startUserSignup(email: ...)` -- Generated `stemWorkflowManifest` +- 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` @@ -32,7 +30,9 @@ The checked-in `lib/definitions.stem.g.dart` is only a starter snapshot; rerun `build_runner` after changing annotations. -The generated helper APIs are convenience wrappers. The underlying public -`StemWorkflowApp` API also accepts `scripts`, `flows`, and `tasks` directly, so -you can wire generated definitions into a larger app without manual task -registration loops. +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)`

StepCheckpoint Position Completed Value
Run ID WorkflowLast stepLast checkpoint Queued Running Succeeded