-
Notifications
You must be signed in to change notification settings - Fork 10
streams and progress
Long-running mutations (imports, mass re-label, bulk re-pick, validate-and-import flows) should report progress to the user while they run instead of leaving the UI in an indeterminate "Wird ausgeführt..." spinner. The pattern is a stream command: the server declares the request as stream: true, returns a Stream<Progress, E, R>, and the client uses Command.withDefaultToastStream to render progress inline in the toast.
Companions:
-
command-pattern.md —
.fn()/CommandButtonbaseline. - query-shape-list-vs-get.md — shaping the emitted progress payload.
Use OperationProgress (api/src/models/Operations.ts) when the operation has a known total and processes items:
export class OperationProgress extends S.Opaque<OperationProgress, OperationProgress.Encoded>()(
S.Struct({
completed: S.NonNegativeInt,
total: S.NonNegativeInt
})
) {}Co-located with Operation (the persisted record) and the ImportOperationFailure / OperationSuccess tagged union used for terminal results.
Custom progress shapes are fine when the default { completed, total } doesn't fit (e.g. a phase-string + percentage). The client-side operationProgress helper only knows the default shape — if you emit a different shape, write a workflow-specific helper next to it.
// api/src/<workflow>/resources/Overview.ts
export class RetryLabel extends Req.Command<RetryLabel>()(
"RetryLabel",
{},
{ stream: true, success: OperationProgress },
(queryKey) => [
{ filters: { queryKey } },
{ filters: { queryKey: makeQueryKey(List) } }
]
) {}-
stream: trueflips the request from "single-response RPC" to "stream of responses". -
success: OperationProgressis the schema of each emitted value, not a single terminal result. The stream ends when the underlying work finishes. - The fourth argument is the query-invalidation map (see command-pattern.md § Query invalidation belongs in resources).
Two patterns cover almost every case:
Stream.unwrap + a long-running Effect that pushes through a queue. Use when the body that produces progress is already an Effect and only needs to surface progress at known checkpoints:
RetryLabel: () =>
Stream.unwrap(
CurrentUser.get.pipe(
Effect.flatMap((user) =>
orderRepo.queryAndSavePure(/* ... */)
),
Effect.tap(() => Invalidation.InvalidationSet.use((_) => _.add(makeQueryKey(List)))),
Effect.map((items) => /* return Stream<OperationProgress, E> */)
)
)Stream.callback for callback-style emission. Use when the work runs through an existing service that takes an onProgress callback:
const userImport = (streams: readonly { content: string | File; name: string }[]) => {
const id = StringId.make()
return Stream.callback<OperationProgress, InvalidStateError | …, CurrentSettings>((queue) =>
importStream(id, streams, (p) => Effect.sync(() => Queue.offerUnsafe(queue, p)))
.pipe(
Effect.tap(/* error post-processing */),
Effect.ensuring(publish),
importing.withPermits(1),
Effect.asVoid,
Effect.onExit(Exit.match({
onSuccess: () => Queue.end(queue),
onFailure: (cause) => Queue.failCause(queue, cause)
}))
)
)
}Key points:
- The work is
Effect.forkChild/withPermits(1)/forkDaemonReportso concurrent invocations are bounded. -
Effect.onExittranslates the underlying Exit intoQueue.end(success) /Queue.failCause(error). Without this the stream never terminates on failure. -
Effect.ensuring(publish)flushes any final state regardless of outcome.
A stream command's fiber dies when the client disconnects. For background work that must keep running (and that you want to recover after a reload), wrap it with Operations.run:
// api/src/services/Operations.ts
const op = yield* operations.run(
(opId) =>
importEffect(items, (progress) => operations.update(opId, progress))
.pipe(Effect.withSpan("Import")),
NonEmptyString2k("Import"),
importId
)
// op: { id: OperationId, fiber: Fiber<A, E> }Operations.run:
- Forks a daemon fiber via
RequestFiberSet.forkDaemonReportUnexpected(survives request lifetime). - Persists the operation row via
OperationsRepo(addOp). - On exit, writes a terminal
OperationSuccessorOperationFailurerow viafinishOp. - Cleans up rows older than 1 hour on a
Schedule.fixed(Duration.minutes(20))schedule.
operations.update(opId, progress) writes progress: OperationProgress to the Operation row mid-flight. Clients can either:
- Subscribe to the stream returned by the command for live updates, or
- Poll
operations.find(opId)if the page is re-entered after a refresh.
The stream + Operations.run are independent — many imports do both: emit progress on the stream and persist it so a tab refresh can pick up the in-progress operation.
const retryLabel = overviewClient.RetryLabel.mutate.wrap()(
Command.withDefaultToastStream({ progress: operationProgress })
)<CommandButton :command="retryLabel" />-
.mutate.wrap()is the right entry for passthrough streams (no extra side effects). For streams that need confirmation / navigation, use.fn()andyield* …mutateinside a generator — the generator returns the stream andCommand.withDefaultToastStreamconsumes it. -
Command.withDefaultToastStreamaccepts the same options asCommand.withDefaultToastplus aprogressmapper. -
progress: operationProgressreads the emitted value out of theAsyncResultand renders it as toast text + a progress bar.
// frontend/utils/operationProgress.ts
type Progress = string | { readonly text: string; readonly percentage: number }
export function operationProgress<A, E>(
result: AsyncResult.AsyncResult<A, E>
): Progress | undefined {
if (!AsyncResult.isSuccess(result) || !result.waiting) return undefined
const p = result.value
if (!isOperationProgress(p)) return undefined
const text = `${p.completed}/${p.total}`
return p.total === 0 ? text : { text, percentage: Math.round((p.completed / p.total) * 100) }
}- Returns
undefinedwhen the stream hasn't emitted yet or has completed — the toast falls back to the default waiting / success / error text. - Returns a string when
total === 0(unbounded progress) — toast shows the text without a bar. - Returns
{ text, percentage }when the total is known — toast shows a determinate bar.
For custom progress shapes, write a sibling helper with the same (result) => Progress | undefined signature.
The local Command.withDefaultToastStream wrapper in frontend/composables/client.ts adds auth-retry transparently for both the upstream Effect and each emitted stream value — use the re-exported Command from ~/composables/client, not the raw baseClient.Command. The wrapper is already in scope when you import Command at the top of a .vue.
Sometimes the stream needs to carry both intermediate progress events and a final payload that the client renders differently (a summary card, a CSV download link, a navigation target). Model the emitted value as a tagged union so progress and the terminal result are distinguishable on the wire.
Co-locate the progress / final tagged structs with the resource and combine them via S.TaggedUnion:
// api/src/<workflow>/resources/Overview.ts
export class ImportProgress extends S.Opaque<ImportProgress, ImportProgress.Encoded>()(
S.TaggedStruct("ImportProgress", {
completed: S.NonNegativeInt,
total: S.NonNegativeInt
})
) {}
export class ImportFinal extends S.Opaque<ImportFinal, ImportFinal.Encoded>()(
S.TaggedStruct("ImportFinal", {
importedCount: S.NonNegativeInt,
skippedCount: S.NonNegativeInt,
issues: S.Array(OperationResultImportIssue).withConstructorDefault,
operationId: OperationId
})
) {}
export const ImportEvent = S.TaggedUnion([ImportProgress, ImportFinal])
export type ImportEvent = S.Schema.Type<typeof ImportEvent>
export class Import extends Req.Command<Import>()(
"Import",
{ files: S.NonEmptyArray(FileInput) },
{ stream: true, success: ImportEvent },
(queryKey) => [{ filters: { queryKey } }, { filters: { queryKey: makeQueryKey(List) } }]
) {}Why S.TaggedUnion (not S.Union):
- Gives free
ImportEvent.guards.ImportProgress(event)/ImportEvent.guards.ImportFinal(event)type guards in templates (see vue-conventions.md § TaggedUnion type guards in templates). - Makes the discriminator a real field on the wire, not a positional assumption.
- Extending the union (a third event tag) is a local change — no
_tag === "..."chains to update.
Import: ({ files }) =>
Stream.callback<ImportEvent, ImportFailure | …, R>((queue) =>
Effect.gen(function*() {
const opId = yield* operations.addOp(StringId.make(), "Import", makeImportId(files))
let completed = 0
const total = files.reduce((sum, f) => sum + estimateRows(f), 0)
const { issues, importedCount, skippedCount } = yield* importFiles(files, {
onProgress: (delta) => Effect.sync(() => {
completed += delta
Queue.offerUnsafe(queue, ImportProgress.make({ completed, total }))
})
})
Queue.offerUnsafe(queue, ImportFinal.make({
importedCount,
skippedCount,
issues,
operationId: opId
}))
}).pipe(
Effect.onExit(Exit.match({
onSuccess: () => Queue.end(queue),
onFailure: (cause) => Queue.failCause(queue, cause)
}))
)
)Invariant: emit zero-or-more ImportProgress, then exactly one ImportFinal, then Queue.end. If the work fails, Queue.failCause short-circuits — don't emit a synthetic ImportFinal with an error count, surface the failure as a typed error on the stream's E channel instead.
Command.withDefaultToastStream only knows about progress shapes. For a union, write a small splitter so the toast keeps showing { completed, total } while a ref holds the final result for the page to render.
// frontend/utils/operationProgress.ts (or a sibling file co-located with the page)
export function importProgress<E>(
result: AsyncResult.AsyncResult<ImportEvent, E>
): Progress | undefined {
if (!AsyncResult.isSuccess(result) || !result.waiting) return undefined
const ev = result.value
if (!ImportEvent.guards.ImportProgress(ev)) return undefined // ignore the final on the toast
const text = `${ev.completed}/${ev.total}`
return ev.total === 0 ? text : { text, percentage: Math.round((ev.completed / ev.total) * 100) }
}Page-level wiring:
const importFinal = ref<ImportFinal | null>(null)
const importFiles = importClient.Import.fn(
function*(input: typeof importClient.Import.Input) {
yield* importClient.Import.mutate(input).pipe(
Stream.tap((ev) =>
ImportEvent.guards.ImportFinal(ev)
? Effect.sync(() => { importFinal.value = ev })
: Effect.void
),
Stream.runDrain
)
},
Command.withDefaultToastStream({ progress: importProgress })
)Template renders the final summary when present, using the TaggedUnion guard for the field-level type narrowing:
<CommandButton :command="importFiles" :input="{ files }" />
<v-card v-if="importFinal">
<div>Imported: {{ importFinal.importedCount }}</div>
<div>Skipped: {{ importFinal.skippedCount }}</div>
<ImportIssueList :issues="importFinal.issues" />
<v-btn :to="`/import/${importFinal.operationId}`">Details</v-btn>
</v-card>-
One final per run. If you find yourself wanting two terminal events, add a third tag and a state machine, don't fire two
ImportFinals. -
Don't conflate progress and final shape. Sharing fields between
ImportProgressandImportFinal(e.g. both carrycompleted) tempts consumers to read them generically — keep them disjoint so the type guard does its job. -
Errors on the
Echannel, not as a tag. AImportFailedmember of the union is wrong: the stream's failure channel already models that, and putting it in the success union breaks toast / error reporting inwithDefaultToastStream. -
Final ref lives on the page, not in the command. The command body uses
Stream.tapto write into a page-owned ref. Don't try to make the command'sresultcarry the final —resultreflects the whole stream, not the last emitted value.
- The work takes more than ~3 seconds and has a meaningful "progress" the user benefits from seeing.
- The work emits intermediate results the user reads (validation errors per row, import summaries per file).
- The work must run to completion even if the user navigates away — pair the stream with
Operations.runso the row persists.
For sub-second mutations, plain .fn() + Command.withDefaultToast() is fine — adding a stream just costs an extra round-trip and a flickering progress bar.
-
Polling
Operations.find(opId)instead of subscribing to the stream. The stream already pushes; polling adds latency and load. Only fall back to polling for "page re-opened after refresh, pick up where we left off". -
Stream that never ends on failure. If you
Stream.callback, you must wireEffect.onExittoQueue.failCause— otherwise the toast hangs forever on errors. -
Emitting raw counts as a
numberschema. UseOperationProgress({ completed, total }) so the client renders a determinate bar. Naked numbers force every consumer to invent its own formatter. -
Forgetting
Effect.ensuring(publish)/ cleanup hooks. Long imports hold permits (withPermits(1)); on failure / cancellation they need to release them or subsequent invocations deadlock. -
Mixing stream commands and non-stream commands in
Promise.all. Stream commands aren't promises; sequence them withyield*in a single command body if they must run together.
-
api/src/<workflow>/services/Import.ts— full import pipeline (Stream.callback+Operations.run+ emailer side channel for failures). -
api/src/services/PickImport.ts— pick-workflow validate-and-import (operations.run+ per-itemoperations.update). -
api/src/<workflow>/Overview.Controllers.ts(RetryLabel) —Stream.unwrapover a queryAndSave pipeline. -
frontend/workflows/<workflow>/components/Import.vue— client wiring withCommand.withDefaultToastStream({ progress: operationProgress }).
- Index
- Import Rules
- Resource & Controller Layout
- Command Pattern
- Command Input Validation
- Query Shape: List vs Get
- Database Query Guidelines
- List Layout
- Streams & Progress
- Vue Conventions
- E2E State Pattern
- E2E
- E2E Toast Wait Audit
- Flow Documentation
- (project-local — create
flows/when first workflow lands)