From ca077654ce0ecf517d7835cadcea505f1ad15d3f Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Mon, 6 Apr 2026 20:17:28 -0700 Subject: [PATCH 01/75] Add pub/sub contrib module for cross-language interop TypeScript implementation of temporalio.contrib.pubsub, matching the Python SDK's wire protocol exactly for cross-language interoperability. Workflow side (mixin.ts): - initPubSub() returns a PubSubHandle for publish/drain/getState - Signal __pubsub_publish with publisher_id + sequence dedup - Update __pubsub_poll with long-poll via condition() + drain validator - Query __pubsub_offset for global offset - Continue-as-new via PubSubState serialization Client side (client.ts): - PubSubClient with batching (start/stop lifecycle) - Flush with asyncio lock equivalent + buffer swap + dedup sequence - subscribe() async generator with CAN following via describe() - Configurable poll interval (default 0.1s) - forWorkflow() factory for CAN-aware handles Wire format (types.ts): - data fields use number[] (not Uint8Array) to match Python's JSON serialization of bytes as numeric arrays - snake_case field names match Python dataclass field names - toWireBytes()/fromWireBytes() helpers for string conversion - All handler names match Python: __pubsub_publish, __pubsub_poll, __pubsub_offset Verified via cross-language interop test: TypeScript client successfully publishes to, subscribes from, queries, and dedup-tests a Python PubSubMixin workflow. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/contrib-pubsub/package.json | 32 +++++ packages/contrib-pubsub/src/client.ts | 170 ++++++++++++++++++++++++++ packages/contrib-pubsub/src/index.ts | 18 +++ packages/contrib-pubsub/src/mixin.ts | 117 ++++++++++++++++++ packages/contrib-pubsub/src/types.ts | 61 +++++++++ packages/contrib-pubsub/tsconfig.json | 9 ++ pnpm-workspace.yaml | 1 + 7 files changed, 408 insertions(+) create mode 100644 packages/contrib-pubsub/package.json create mode 100644 packages/contrib-pubsub/src/client.ts create mode 100644 packages/contrib-pubsub/src/index.ts create mode 100644 packages/contrib-pubsub/src/mixin.ts create mode 100644 packages/contrib-pubsub/src/types.ts create mode 100644 packages/contrib-pubsub/tsconfig.json diff --git a/packages/contrib-pubsub/package.json b/packages/contrib-pubsub/package.json new file mode 100644 index 000000000..68cfc964f --- /dev/null +++ b/packages/contrib-pubsub/package.json @@ -0,0 +1,32 @@ +{ + "name": "@temporalio/contrib-pubsub", + "version": "1.15.0", + "description": "Temporal.io SDK Pub/Sub contrib module", + "main": "lib/index.js", + "types": "./lib/index.d.ts", + "scripts": { + "build": "tsc --build" + }, + "keywords": [ + "temporal", + "pubsub", + "streaming" + ], + "author": "Temporal Technologies Inc. ", + "license": "MIT", + "dependencies": { + "@temporalio/client": "workspace:*", + "@temporalio/common": "workspace:*", + "@temporalio/workflow": "workspace:*" + }, + "engines": { + "node": ">= 20.0.0" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "src", + "lib" + ] +} diff --git a/packages/contrib-pubsub/src/client.ts b/packages/contrib-pubsub/src/client.ts new file mode 100644 index 000000000..5f3ac8ebd --- /dev/null +++ b/packages/contrib-pubsub/src/client.ts @@ -0,0 +1,170 @@ +/** + * External-side pub/sub client. + * + * Used by activities, starters, and any code with a workflow handle to publish + * messages and subscribe to topics on a pub/sub workflow. + */ + +import { randomUUID } from 'crypto'; +import { Client, WorkflowHandle, WorkflowUpdateRPCTimeoutOrCancelledError } from '@temporalio/client'; +import type { PollInput, PollResult, PubSubItem, PublishEntry, PublishInput } from './types'; + +export interface PubSubClientOptions { + /** Seconds between automatic flushes. Default: 2.0 */ + batchInterval?: number; + /** Auto-flush when buffer reaches this size. */ + maxBatchSize?: number; +} + +export class PubSubClient { + private handle: WorkflowHandle; + private readonly client: Client | undefined; + private readonly workflowId: string; + private readonly batchInterval: number; + private readonly maxBatchSize: number | undefined; + private buffer: PublishEntry[] = []; + private flushTimer: ReturnType | undefined; + private flushPromise: Promise | undefined; + private publisherId: string = randomUUID().replace(/-/g, ''); + private sequence: number = 0; + + constructor(handle: WorkflowHandle, options?: PubSubClientOptions) { + this.handle = handle; + this.workflowId = handle.workflowId; + this.batchInterval = options?.batchInterval ?? 2.0; + this.maxBatchSize = options?.maxBatchSize; + } + + /** + * Create a PubSubClient from a Temporal client and workflow ID. + * Preferred constructor — enables continue-as-new following in subscribe(). + */ + static forWorkflow(client: Client, workflowId: string, options?: PubSubClientOptions): PubSubClient { + const handle = client.workflow.getHandle(workflowId); + const instance = new PubSubClient(handle, options); + (instance as unknown as { client: Client | undefined }).client = client; + return instance; + } + + /** Start the background flush timer. Call before publishing. */ + start(): void { + if (this.flushTimer) return; + this.flushTimer = setInterval(() => { + void this.flush(); + }, this.batchInterval * 1000); + } + + /** Stop the flush timer and flush remaining items. */ + async stop(): Promise { + if (this.flushTimer) { + clearInterval(this.flushTimer); + this.flushTimer = undefined; + } + await this.flush(); + } + + /** Buffer a message for publishing. */ + publish(topic: string, data: number[], priority = false): void { + this.buffer.push({ topic, data }); + if (priority || (this.maxBatchSize !== undefined && this.buffer.length >= this.maxBatchSize)) { + void this.flush(); + } + } + + /** Send all buffered messages to the workflow via signal. */ + async flush(): Promise { + // Simple serialization: wait for any in-progress flush + if (this.flushPromise) { + await this.flushPromise; + } + if (this.buffer.length === 0) return; + + this.sequence += 1; + const batch = this.buffer; + this.buffer = []; + + const doFlush = async (): Promise => { + try { + await this.handle.signal<[PublishInput]>('__pubsub_publish', { + items: batch, + publisher_id: this.publisherId, + sequence: this.sequence, + }); + } catch (err) { + // Restore items for retry, but keep advanced sequence to avoid + // dedup-dropping newly buffered items merged with retry batch + this.buffer = [...batch, ...this.buffer]; + throw err; + } + }; + + this.flushPromise = doFlush(); + try { + await this.flushPromise; + } finally { + this.flushPromise = undefined; + } + } + + /** + * Async generator that polls for new items. + * Automatically follows continue-as-new chains when created via forWorkflow(). + */ + async *subscribe( + topics?: string[], + fromOffset = 0, + options?: { pollInterval?: number } + ): AsyncGenerator { + const pollInterval = options?.pollInterval ?? 0.1; + let offset = fromOffset; + + while (true) { + let result: PollResult; + try { + result = await this.handle.executeUpdate('__pubsub_poll', { + args: [{ topics: topics ?? [], from_offset: offset, timeout: 300.0 }], + }); + } catch (err) { + if (err instanceof WorkflowUpdateRPCTimeoutOrCancelledError) { + if (await this.followContinueAsNew()) { + continue; + } + return; + } + throw err; + } + + for (const item of result.items) { + yield item; + } + offset = result.next_offset; + + if (pollInterval > 0) { + await new Promise((resolve) => setTimeout(resolve, pollInterval * 1000)); + } + } + } + + /** Query the current global offset. */ + async getOffset(): Promise { + return this.handle.query('__pubsub_offset'); + } + + /** + * Check if the workflow continued-as-new and re-target the handle. + * Returns true if the handle was updated (caller should retry). + */ + private async followContinueAsNew(): Promise { + if (!this.client) return false; + try { + const desc = await this.handle.describe(); + if (desc.status.name === 'CONTINUED_AS_NEW') { + this.handle = this.client.workflow.getHandle(this.workflowId); + return true; + } + } catch { + return false; + } + return false; + } +} diff --git a/packages/contrib-pubsub/src/index.ts b/packages/contrib-pubsub/src/index.ts new file mode 100644 index 000000000..2a6c7de84 --- /dev/null +++ b/packages/contrib-pubsub/src/index.ts @@ -0,0 +1,18 @@ +/** + * Pub/sub support for Temporal workflows. + * + * This module provides a reusable pub/sub pattern where a workflow acts as a + * message broker. External clients (activities, starters, other services) + * publish and subscribe through the workflow handle using Temporal primitives. + * + * Payloads are opaque byte strings for cross-language compatibility. + * + * @module + */ + +export type { PubSubItem, PublishEntry, PublishInput, PollInput, PollResult, PubSubState } from './types'; +export { toWireBytes, fromWireBytes } from './types'; +export { initPubSub, pubsubPublishSignal, pubsubPollUpdate, pubsubOffsetQuery } from './mixin'; +export type { PubSubHandle } from './mixin'; +export { PubSubClient } from './client'; +export type { PubSubClientOptions } from './client'; diff --git a/packages/contrib-pubsub/src/mixin.ts b/packages/contrib-pubsub/src/mixin.ts new file mode 100644 index 000000000..d026b42c6 --- /dev/null +++ b/packages/contrib-pubsub/src/mixin.ts @@ -0,0 +1,117 @@ +/** + * Workflow-side pub/sub mixin. + * + * TypeScript workflows are functions, not classes, so the "mixin" is a set of + * functions that initialize and manage pub/sub state within workflow scope. + * + * Call `initPubSub()` at the start of your workflow function. Use the returned + * handle to publish, drain, and get state for continue-as-new. + */ + +import { condition, defineSignal, defineUpdate, defineQuery, setHandler } from '@temporalio/workflow'; +import type { PollInput, PollResult, PubSubItem, PubSubState, PublishInput } from './types'; + +// Fixed handler names for cross-language interop +export const pubsubPublishSignal = defineSignal<[PublishInput]>('__pubsub_publish'); +export const pubsubPollUpdate = defineUpdate('__pubsub_poll'); +export const pubsubOffsetQuery = defineQuery('__pubsub_offset'); + +/** Handle returned by initPubSub for interacting with pub/sub state. */ +export interface PubSubHandle { + /** Publish an item from within workflow code. Deterministic — just appends. */ + publish(topic: string, data: number[]): void; + + /** Unblock all waiting poll handlers and reject new polls for CAN. */ + drain(): void; + + /** Return a serializable snapshot of pub/sub state for continue-as-new. */ + getState(): PubSubState; +} + +/** + * Initialize pub/sub state and register signal/update/query handlers. + * + * Call at the start of your workflow function. For continue-as-new, pass + * the prior state from `getState()`. + * + * @param priorState - State from a previous run via getState(). Pass undefined on first run. + * @returns A handle for publishing, draining, and getting state. + */ +export function initPubSub(priorState?: PubSubState): PubSubHandle { + const log: PubSubItem[] = priorState?.log ? [...priorState.log] : []; + const baseOffset: number = priorState?.base_offset ?? 0; + const publisherSequences: Record = priorState?.publisher_sequences + ? { ...priorState.publisher_sequences } + : {}; + let draining = false; + + // Signal handler: receive publications from external clients with dedup + setHandler(pubsubPublishSignal, (input: PublishInput) => { + if (input.publisher_id) { + const lastSeq = publisherSequences[input.publisher_id] ?? 0; + if (input.sequence <= lastSeq) { + return; // duplicate — skip + } + publisherSequences[input.publisher_id] = input.sequence; + } + for (const entry of input.items) { + log.push({ topic: entry.topic, data: entry.data }); + } + }); + + // Update handler: long-poll subscription + setHandler( + pubsubPollUpdate, + async (input: PollInput): Promise => { + const logOffset = input.from_offset - baseOffset; + if (logOffset < 0) { + throw new Error( + `Requested offset ${input.from_offset} is before base offset ${baseOffset} (log has been truncated)` + ); + } + await condition( + () => log.length > logOffset || draining, + input.timeout * 1000, // convert seconds to ms + ); + const allNew = log.slice(logOffset); + const nextOffset = baseOffset + log.length; + let filtered: PubSubItem[]; + if (input.topics.length > 0) { + const topicSet = new Set(input.topics); + filtered = allNew.filter((item) => topicSet.has(item.topic)); + } else { + filtered = [...allNew]; + } + return { items: filtered, next_offset: nextOffset }; + }, + { + // Validator: reject new polls when draining for continue-as-new + validator(_input: PollInput): void { + if (draining) { + throw new Error('Workflow is draining for continue-as-new'); + } + }, + } + ); + + // Query handler: current global offset + setHandler(pubsubOffsetQuery, () => baseOffset + log.length); + + return { + publish(topic: string, data: number[]): void { + log.push({ topic, data }); + }, + + drain(): void { + draining = true; + }, + + getState(): PubSubState { + return { + log: [...log], + base_offset: baseOffset, + publisher_sequences: { ...publisherSequences }, + }; + }, + }; +} diff --git a/packages/contrib-pubsub/src/types.ts b/packages/contrib-pubsub/src/types.ts new file mode 100644 index 000000000..d2936204a --- /dev/null +++ b/packages/contrib-pubsub/src/types.ts @@ -0,0 +1,61 @@ +/** + * Shared data types for the pub/sub contrib module. + * + * These types are serialized as JSON through Temporal's default data converter + * and must match the Python dataclass wire format for cross-language interop. + * + * IMPORTANT: Python's default JSON converter serializes `bytes` fields as + * numeric arrays (e.g., [104, 101, 108, 108, 111]), NOT base64 strings. + * The `data` field uses `number[]` to match this wire format. + */ + +/** A single item in the pub/sub log. */ +export interface PubSubItem { + topic: string; + /** Opaque payload. Wire format: JSON numeric array matching Python bytes. */ + data: number[]; +} + +/** A single entry to publish (used in batch signals). */ +export interface PublishEntry { + topic: string; + /** Opaque payload. Wire format: JSON numeric array matching Python bytes. */ + data: number[]; +} + +/** Signal payload: batch of entries to publish with dedup fields. */ +export interface PublishInput { + items: PublishEntry[]; + publisher_id: string; + sequence: number; +} + +/** Update payload: request to poll for new items. */ +export interface PollInput { + topics: string[]; + from_offset: number; + timeout: number; +} + +/** Update response: items matching the poll request. */ +export interface PollResult { + items: PubSubItem[]; + next_offset: number; +} + +/** Serializable snapshot of pub/sub state for continue-as-new. */ +export interface PubSubState { + log: PubSubItem[]; + base_offset: number; + publisher_sequences: Record; +} + +/** Convert a string to wire format (number[] matching Python bytes). */ +export function toWireBytes(s: string): number[] { + return Array.from(new TextEncoder().encode(s)); +} + +/** Convert wire format (number[] from Python bytes) to a string. */ +export function fromWireBytes(data: number[]): string { + return new TextDecoder().decode(new Uint8Array(data)); +} diff --git a/packages/contrib-pubsub/tsconfig.json b/packages/contrib-pubsub/tsconfig.json new file mode 100644 index 000000000..868a22f96 --- /dev/null +++ b/packages/contrib-pubsub/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./lib", + "rootDir": "./src", + "skipLibCheck": true + }, + "include": ["./src/**/*.ts"] +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 62362712b..00d431e93 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -17,6 +17,7 @@ packages: - packages/testing - packages/worker - packages/workflow + - packages/contrib-pubsub - scripts ignoreScripts: true From 3732492613910c5810777886a0428cf3af4d1f69 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Mon, 6 Apr 2026 22:28:45 -0700 Subject: [PATCH 02/75] TLA+-verified dedup rewrite, TTL pruning, truncation, API improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror all Python pub/sub changes to TypeScript: - Dedup rewrite: separate pending batch, retry with same sequence - TTL pruning in getState() with legacy state preservation - max_retry_duration (default 600s) with FlushTimeoutError - truncate() method on PubSubHandle - publisher_last_seen timestamps via Date.now() - forWorkflow→create, flush removed, pollInterval→pollCooldown - Publisher ID shortened to 16 hex chars Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/contrib-pubsub/src/client.ts | 103 ++++++++++++++++++++------ packages/contrib-pubsub/src/index.ts | 2 +- packages/contrib-pubsub/src/mixin.ts | 51 +++++++++++-- packages/contrib-pubsub/src/types.ts | 2 + 4 files changed, 130 insertions(+), 28 deletions(-) diff --git a/packages/contrib-pubsub/src/client.ts b/packages/contrib-pubsub/src/client.ts index 5f3ac8ebd..57eb3745f 100644 --- a/packages/contrib-pubsub/src/client.ts +++ b/packages/contrib-pubsub/src/client.ts @@ -9,11 +9,25 @@ import { randomUUID } from 'crypto'; import { Client, WorkflowHandle, WorkflowUpdateRPCTimeoutOrCancelledError } from '@temporalio/client'; import type { PollInput, PollResult, PubSubItem, PublishEntry, PublishInput } from './types'; +/** Thrown when a flush retry exceeds maxRetryDuration. */ +export class FlushTimeoutError extends Error { + constructor(message: string) { + super(message); + this.name = 'FlushTimeoutError'; + } +} + export interface PubSubClientOptions { /** Seconds between automatic flushes. Default: 2.0 */ batchInterval?: number; /** Auto-flush when buffer reaches this size. */ maxBatchSize?: number; + /** + * Maximum seconds to retry a failed flush before throwing. + * Must be less than the workflow's publisherTtl (default 900s) to preserve + * exactly-once delivery. Default: 600. + */ + maxRetryDuration?: number; } export class PubSubClient { @@ -22,24 +36,29 @@ export class PubSubClient { private readonly workflowId: string; private readonly batchInterval: number; private readonly maxBatchSize: number | undefined; + private readonly maxRetryDuration: number; private buffer: PublishEntry[] = []; + private pending: PublishEntry[] | null = null; + private pendingSeq = 0; + private pendingStartedAt: number | null = null; private flushTimer: ReturnType | undefined; private flushPromise: Promise | undefined; - private publisherId: string = randomUUID().replace(/-/g, ''); - private sequence: number = 0; + private publisherId: string = randomUUID().replace(/-/g, '').slice(0, 16); + private sequence = 0; constructor(handle: WorkflowHandle, options?: PubSubClientOptions) { this.handle = handle; this.workflowId = handle.workflowId; this.batchInterval = options?.batchInterval ?? 2.0; this.maxBatchSize = options?.maxBatchSize; + this.maxRetryDuration = options?.maxRetryDuration ?? 600; } /** * Create a PubSubClient from a Temporal client and workflow ID. * Preferred constructor — enables continue-as-new following in subscribe(). */ - static forWorkflow(client: Client, workflowId: string, options?: PubSubClientOptions): PubSubClient { + static create(client: Client, workflowId: string, options?: PubSubClientOptions): PubSubClient { const handle = client.workflow.getHandle(workflowId); const instance = new PubSubClient(handle, options); (instance as unknown as { client: Client | undefined }).client = client; @@ -50,7 +69,7 @@ export class PubSubClient { start(): void { if (this.flushTimer) return; this.flushTimer = setInterval(() => { - void this.flush(); + void this._flush(); }, this.batchInterval * 1000); } @@ -60,40 +79,80 @@ export class PubSubClient { clearInterval(this.flushTimer); this.flushTimer = undefined; } - await this.flush(); + await this._flush(); } - /** Buffer a message for publishing. */ + /** + * Buffer a message for publishing. + * @param priority - If true, triggers immediate flush (fire-and-forget). + */ publish(topic: string, data: number[], priority = false): void { this.buffer.push({ topic, data }); if (priority || (this.maxBatchSize !== undefined && this.buffer.length >= this.maxBatchSize)) { - void this.flush(); + void this._flush(); } } - /** Send all buffered messages to the workflow via signal. */ - async flush(): Promise { + /** + * Send pending or buffered messages to the workflow via signal. + * + * Implements the TLA+-verified dedup algorithm (see verification/PROOF.md): + * 1. If pending batch exists (from prior failure), retry with same sequence. + * 2. Otherwise, swap buffer into pending with new sequence. + * 3. On success: advance confirmed sequence, clear pending. + * 4. On failure: pending stays for retry. + */ + private async _flush(): Promise { // Simple serialization: wait for any in-progress flush if (this.flushPromise) { await this.flushPromise; } - if (this.buffer.length === 0) return; - this.sequence += 1; - const batch = this.buffer; - this.buffer = []; + let batch: PublishEntry[]; + let seq: number; + + if (this.pending !== null) { + // Retry path: check max_retry_duration + if ( + this.pendingStartedAt !== null && + (Date.now() - this.pendingStartedAt) / 1000 > this.maxRetryDuration + ) { + this.pending = null; + this.pendingSeq = 0; + this.pendingStartedAt = null; + throw new FlushTimeoutError( + `Flush retry exceeded maxRetryDuration (${this.maxRetryDuration}s). ` + + 'Pending batch dropped.' + ); + } + batch = this.pending; + seq = this.pendingSeq; + } else if (this.buffer.length > 0) { + // New batch path + seq = this.sequence + 1; + batch = this.buffer; + this.buffer = []; + this.pending = batch; + this.pendingSeq = seq; + this.pendingStartedAt = Date.now(); + } else { + return; + } const doFlush = async (): Promise => { try { await this.handle.signal<[PublishInput]>('__pubsub_publish', { items: batch, publisher_id: this.publisherId, - sequence: this.sequence, + sequence: seq, }); + // Success: advance confirmed sequence, clear pending + this.sequence = seq; + this.pending = null; + this.pendingSeq = 0; + this.pendingStartedAt = null; } catch (err) { - // Restore items for retry, but keep advanced sequence to avoid - // dedup-dropping newly buffered items merged with retry batch - this.buffer = [...batch, ...this.buffer]; + // Pending stays set for retry on next _flush() call throw err; } }; @@ -108,14 +167,14 @@ export class PubSubClient { /** * Async generator that polls for new items. - * Automatically follows continue-as-new chains when created via forWorkflow(). + * Automatically follows continue-as-new chains when created via create(). */ async *subscribe( topics?: string[], fromOffset = 0, - options?: { pollInterval?: number } + options?: { pollCooldown?: number } ): AsyncGenerator { - const pollInterval = options?.pollInterval ?? 0.1; + const pollCooldown = options?.pollCooldown ?? 0.1; let offset = fromOffset; while (true) { @@ -139,8 +198,8 @@ export class PubSubClient { } offset = result.next_offset; - if (pollInterval > 0) { - await new Promise((resolve) => setTimeout(resolve, pollInterval * 1000)); + if (pollCooldown > 0) { + await new Promise((resolve) => setTimeout(resolve, pollCooldown * 1000)); } } } diff --git a/packages/contrib-pubsub/src/index.ts b/packages/contrib-pubsub/src/index.ts index 2a6c7de84..f3fe7449b 100644 --- a/packages/contrib-pubsub/src/index.ts +++ b/packages/contrib-pubsub/src/index.ts @@ -14,5 +14,5 @@ export type { PubSubItem, PublishEntry, PublishInput, PollInput, PollResult, Pub export { toWireBytes, fromWireBytes } from './types'; export { initPubSub, pubsubPublishSignal, pubsubPollUpdate, pubsubOffsetQuery } from './mixin'; export type { PubSubHandle } from './mixin'; -export { PubSubClient } from './client'; +export { PubSubClient, FlushTimeoutError } from './client'; export type { PubSubClientOptions } from './client'; diff --git a/packages/contrib-pubsub/src/mixin.ts b/packages/contrib-pubsub/src/mixin.ts index d026b42c6..4c9074832 100644 --- a/packages/contrib-pubsub/src/mixin.ts +++ b/packages/contrib-pubsub/src/mixin.ts @@ -24,8 +24,18 @@ export interface PubSubHandle { /** Unblock all waiting poll handlers and reject new polls for CAN. */ drain(): void; - /** Return a serializable snapshot of pub/sub state for continue-as-new. */ - getState(): PubSubState; + /** + * Return a serializable snapshot of pub/sub state for continue-as-new. + * Prunes publisher dedup entries older than publisherTtl seconds. + */ + getState(publisherTtl?: number): PubSubState; + + /** + * Discard log entries before upToOffset. + * After truncation, polls requesting an offset before the new base + * will receive an error. + */ + truncate(upToOffset: number): void; } /** @@ -39,10 +49,13 @@ export interface PubSubHandle { */ export function initPubSub(priorState?: PubSubState): PubSubHandle { const log: PubSubItem[] = priorState?.log ? [...priorState.log] : []; - const baseOffset: number = priorState?.base_offset ?? 0; + let baseOffset: number = priorState?.base_offset ?? 0; const publisherSequences: Record = priorState?.publisher_sequences ? { ...priorState.publisher_sequences } : {}; + const publisherLastSeen: Record = priorState?.publisher_last_seen + ? { ...priorState.publisher_last_seen } + : {}; let draining = false; // Signal handler: receive publications from external clients with dedup @@ -53,6 +66,7 @@ export function initPubSub(priorState?: PubSubState): PubSubHandle { return; // duplicate — skip } publisherSequences[input.publisher_id] = input.sequence; + publisherLastSeen[input.publisher_id] = Date.now() / 1000; // seconds } for (const entry of input.items) { log.push({ topic: entry.topic, data: entry.data }); @@ -106,12 +120,39 @@ export function initPubSub(priorState?: PubSubState): PubSubHandle { draining = true; }, - getState(): PubSubState { + getState(publisherTtl = 900): PubSubState { + const now = Date.now() / 1000; + const activeSeqs: Record = {}; + const activeSeen: Record = {}; + // Iterate over publisher_sequences (not publisher_last_seen) to + // preserve legacy entries that predate the last_seen field. + for (const pid of Object.keys(publisherSequences)) { + const ts = publisherLastSeen[pid]; + if (ts === undefined || now - ts < publisherTtl) { + activeSeqs[pid] = publisherSequences[pid] ?? 0; + if (ts !== undefined) { + activeSeen[pid] = ts; + } + } + } return { log: [...log], base_offset: baseOffset, - publisher_sequences: { ...publisherSequences }, + publisher_sequences: activeSeqs, + publisher_last_seen: activeSeen, }; }, + + truncate(upToOffset: number): void { + const logIndex = upToOffset - baseOffset; + if (logIndex <= 0) return; + if (logIndex > log.length) { + throw new Error( + `Cannot truncate to offset ${upToOffset}: only ${baseOffset + log.length} items exist` + ); + } + log.splice(0, logIndex); + baseOffset = upToOffset; + }, }; } diff --git a/packages/contrib-pubsub/src/types.ts b/packages/contrib-pubsub/src/types.ts index d2936204a..ab6e2a7c3 100644 --- a/packages/contrib-pubsub/src/types.ts +++ b/packages/contrib-pubsub/src/types.ts @@ -48,6 +48,8 @@ export interface PubSubState { log: PubSubItem[]; base_offset: number; publisher_sequences: Record; + /** Per-publisher last-seen timestamps for TTL pruning. */ + publisher_last_seen?: Record; } /** Convert a string to wire format (number[] matching Python bytes). */ From 4a3f7c06d7743f15fdb3fdefbeb556c7d1119326 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Mon, 6 Apr 2026 22:32:58 -0700 Subject: [PATCH 03/75] Fix ESLint: remove useless try/catch in _flush Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/contrib-pubsub/src/client.ts | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/packages/contrib-pubsub/src/client.ts b/packages/contrib-pubsub/src/client.ts index 57eb3745f..a11e952cf 100644 --- a/packages/contrib-pubsub/src/client.ts +++ b/packages/contrib-pubsub/src/client.ts @@ -140,21 +140,17 @@ export class PubSubClient { } const doFlush = async (): Promise => { - try { - await this.handle.signal<[PublishInput]>('__pubsub_publish', { - items: batch, - publisher_id: this.publisherId, - sequence: seq, - }); - // Success: advance confirmed sequence, clear pending - this.sequence = seq; - this.pending = null; - this.pendingSeq = 0; - this.pendingStartedAt = null; - } catch (err) { - // Pending stays set for retry on next _flush() call - throw err; - } + // On failure, the signal throws and pending stays set for retry. + // On success, we advance confirmed sequence and clear pending. + await this.handle.signal<[PublishInput]>('__pubsub_publish', { + items: batch, + publisher_id: this.publisherId, + sequence: seq, + }); + this.sequence = seq; + this.pending = null; + this.pendingSeq = 0; + this.pendingStartedAt = null; }; this.flushPromise = doFlush(); From 9f25e5ccf0f36027f3fc1c09f92b3a73ce575b46 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Mon, 6 Apr 2026 22:34:36 -0700 Subject: [PATCH 04/75] Remove TLA+ proof references from implementation code Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/contrib-pubsub/src/client.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/contrib-pubsub/src/client.ts b/packages/contrib-pubsub/src/client.ts index a11e952cf..2b5eb483b 100644 --- a/packages/contrib-pubsub/src/client.ts +++ b/packages/contrib-pubsub/src/client.ts @@ -96,11 +96,8 @@ export class PubSubClient { /** * Send pending or buffered messages to the workflow via signal. * - * Implements the TLA+-verified dedup algorithm (see verification/PROOF.md): - * 1. If pending batch exists (from prior failure), retry with same sequence. - * 2. Otherwise, swap buffer into pending with new sequence. - * 3. On success: advance confirmed sequence, clear pending. - * 4. On failure: pending stays for retry. + * On failure, the pending batch and sequence are kept for retry. + * Only advances the confirmed sequence on success. */ private async _flush(): Promise { // Simple serialization: wait for any in-progress flush From 2f68b15bdf925a4d31baae76a74add267cb56dee Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Mon, 6 Apr 2026 22:37:40 -0700 Subject: [PATCH 05/75] Update pnpm-lock.yaml Co-Authored-By: Claude Opus 4.6 (1M context) --- pnpm-lock.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60ddfc954..2f66b9808 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -258,6 +258,18 @@ importers: specifier: ^7.2.5 version: 7.5.1 + packages/contrib-pubsub: + dependencies: + '@temporalio/client': + specifier: workspace:* + version: link:../client + '@temporalio/common': + specifier: workspace:* + version: link:../common + '@temporalio/workflow': + specifier: workspace:* + version: link:../workflow + packages/core-bridge: dependencies: '@grpc/grpc-js': From efb2820a94c165d40078d0997c9c92857c63688c Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Tue, 7 Apr 2026 10:15:49 -0700 Subject: [PATCH 06/75] pubsub: use base64 wire format with native Uint8Array API Wire types (PublishEntry, _WireItem, PollResult, PubSubState) encode data as base64 strings for cross-language compatibility. User-facing PubSubItem uses Uint8Array. Base64 encode/decode uses pure JS (no Buffer dependency) for workflow sandbox compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/contrib-pubsub/src/client.ts | 13 ++-- packages/contrib-pubsub/src/index.ts | 5 +- packages/contrib-pubsub/src/mixin.ts | 26 +++++--- packages/contrib-pubsub/src/types.ts | 90 +++++++++++++++++++++------ 4 files changed, 100 insertions(+), 34 deletions(-) diff --git a/packages/contrib-pubsub/src/client.ts b/packages/contrib-pubsub/src/client.ts index 2b5eb483b..02f43ac05 100644 --- a/packages/contrib-pubsub/src/client.ts +++ b/packages/contrib-pubsub/src/client.ts @@ -8,6 +8,7 @@ import { randomUUID } from 'crypto'; import { Client, WorkflowHandle, WorkflowUpdateRPCTimeoutOrCancelledError } from '@temporalio/client'; import type { PollInput, PollResult, PubSubItem, PublishEntry, PublishInput } from './types'; +import { encodeData, decodeData } from './types'; /** Thrown when a flush retry exceeds maxRetryDuration. */ export class FlushTimeoutError extends Error { @@ -84,10 +85,11 @@ export class PubSubClient { /** * Buffer a message for publishing. + * @param data - Opaque byte payload. * @param priority - If true, triggers immediate flush (fire-and-forget). */ - publish(topic: string, data: number[], priority = false): void { - this.buffer.push({ topic, data }); + publish(topic: string, data: Uint8Array, priority = false): void { + this.buffer.push({ topic, data: encodeData(data) }); if (priority || (this.maxBatchSize !== undefined && this.buffer.length >= this.maxBatchSize)) { void this._flush(); } @@ -186,8 +188,11 @@ export class PubSubClient { throw err; } - for (const item of result.items) { - yield item; + for (const wireItem of result.items) { + yield { + topic: wireItem.topic, + data: decodeData(wireItem.data), + }; } offset = result.next_offset; diff --git a/packages/contrib-pubsub/src/index.ts b/packages/contrib-pubsub/src/index.ts index f3fe7449b..eab9b5985 100644 --- a/packages/contrib-pubsub/src/index.ts +++ b/packages/contrib-pubsub/src/index.ts @@ -5,13 +5,14 @@ * message broker. External clients (activities, starters, other services) * publish and subscribe through the workflow handle using Temporal primitives. * - * Payloads are opaque byte strings for cross-language compatibility. + * Payloads are opaque bytes. Base64 encoding is used on the wire for + * cross-language compatibility, but users work with native Uint8Array. * * @module */ export type { PubSubItem, PublishEntry, PublishInput, PollInput, PollResult, PubSubState } from './types'; -export { toWireBytes, fromWireBytes } from './types'; +export { toWireBytes, fromWireBytes, encodeData, decodeData } from './types'; export { initPubSub, pubsubPublishSignal, pubsubPollUpdate, pubsubOffsetQuery } from './mixin'; export type { PubSubHandle } from './mixin'; export { PubSubClient, FlushTimeoutError } from './client'; diff --git a/packages/contrib-pubsub/src/mixin.ts b/packages/contrib-pubsub/src/mixin.ts index 4c9074832..9499cfb37 100644 --- a/packages/contrib-pubsub/src/mixin.ts +++ b/packages/contrib-pubsub/src/mixin.ts @@ -9,7 +9,8 @@ */ import { condition, defineSignal, defineUpdate, defineQuery, setHandler } from '@temporalio/workflow'; -import type { PollInput, PollResult, PubSubItem, PubSubState, PublishInput } from './types'; +import type { PollInput, PollResult, PubSubItem, PubSubState, PublishInput, _WireItem } from './types'; +import { encodeData, decodeData } from './types'; // Fixed handler names for cross-language interop export const pubsubPublishSignal = defineSignal<[PublishInput]>('__pubsub_publish'); @@ -19,7 +20,7 @@ export const pubsubOffsetQuery = defineQuery('__pubsub_offset'); /** Handle returned by initPubSub for interacting with pub/sub state. */ export interface PubSubHandle { /** Publish an item from within workflow code. Deterministic — just appends. */ - publish(topic: string, data: number[]): void; + publish(topic: string, data: Uint8Array): void; /** Unblock all waiting poll handlers and reject new polls for CAN. */ drain(): void; @@ -48,7 +49,10 @@ export interface PubSubHandle { * @returns A handle for publishing, draining, and getting state. */ export function initPubSub(priorState?: PubSubState): PubSubHandle { - const log: PubSubItem[] = priorState?.log ? [...priorState.log] : []; + // Decode wire items (base64) to in-memory items (Uint8Array) + const log: PubSubItem[] = priorState?.log + ? priorState.log.map((item) => ({ topic: item.topic, data: decodeData(item.data) })) + : []; let baseOffset: number = priorState?.base_offset ?? 0; const publisherSequences: Record = priorState?.publisher_sequences ? { ...priorState.publisher_sequences } @@ -69,7 +73,8 @@ export function initPubSub(priorState?: PubSubState): PubSubHandle { publisherLastSeen[input.publisher_id] = Date.now() / 1000; // seconds } for (const entry of input.items) { - log.push({ topic: entry.topic, data: entry.data }); + // Decode base64 wire data to Uint8Array for in-memory storage + log.push({ topic: entry.topic, data: decodeData(entry.data) }); } }); @@ -96,7 +101,11 @@ export function initPubSub(priorState?: PubSubState): PubSubHandle { } else { filtered = [...allNew]; } - return { items: filtered, next_offset: nextOffset }; + // Encode Uint8Array to base64 for wire response + return { + items: filtered.map((item) => ({ topic: item.topic, data: encodeData(item.data) })), + next_offset: nextOffset, + }; }, { // Validator: reject new polls when draining for continue-as-new @@ -112,7 +121,7 @@ export function initPubSub(priorState?: PubSubState): PubSubHandle { setHandler(pubsubOffsetQuery, () => baseOffset + log.length); return { - publish(topic: string, data: number[]): void { + publish(topic: string, data: Uint8Array): void { log.push({ topic, data }); }, @@ -124,8 +133,6 @@ export function initPubSub(priorState?: PubSubState): PubSubHandle { const now = Date.now() / 1000; const activeSeqs: Record = {}; const activeSeen: Record = {}; - // Iterate over publisher_sequences (not publisher_last_seen) to - // preserve legacy entries that predate the last_seen field. for (const pid of Object.keys(publisherSequences)) { const ts = publisherLastSeen[pid]; if (ts === undefined || now - ts < publisherTtl) { @@ -136,7 +143,8 @@ export function initPubSub(priorState?: PubSubState): PubSubHandle { } } return { - log: [...log], + // Encode Uint8Array to base64 for serializable state + log: log.map((item) => ({ topic: item.topic, data: encodeData(item.data) })), base_offset: baseOffset, publisher_sequences: activeSeqs, publisher_last_seen: activeSeen, diff --git a/packages/contrib-pubsub/src/types.ts b/packages/contrib-pubsub/src/types.ts index ab6e2a7c3..d2865c1d1 100644 --- a/packages/contrib-pubsub/src/types.ts +++ b/packages/contrib-pubsub/src/types.ts @@ -2,25 +2,31 @@ * Shared data types for the pub/sub contrib module. * * These types are serialized as JSON through Temporal's default data converter - * and must match the Python dataclass wire format for cross-language interop. + * and must match the wire format across all SDK languages. * - * IMPORTANT: Python's default JSON converter serializes `bytes` fields as - * numeric arrays (e.g., [104, 101, 108, 108, 111]), NOT base64 strings. - * The `data` field uses `number[]` to match this wire format. + * Wire types (PublishEntry, _WireItem, PollResult) use base64 strings for the + * data field. User-facing types (PubSubItem) use Uint8Array. */ -/** A single item in the pub/sub log. */ +/** A single item in the pub/sub log (user-facing). */ export interface PubSubItem { topic: string; - /** Opaque payload. Wire format: JSON numeric array matching Python bytes. */ - data: number[]; + /** Opaque byte payload. */ + data: Uint8Array; } -/** A single entry to publish (used in batch signals). */ +/** A single entry to publish via signal (wire type). */ export interface PublishEntry { topic: string; - /** Opaque payload. Wire format: JSON numeric array matching Python bytes. */ - data: number[]; + /** Base64-encoded byte payload. */ + data: string; +} + +/** Wire representation of a PubSubItem (base64 data). */ +export interface _WireItem { + topic: string; + /** Base64-encoded byte payload. */ + data: string; } /** Signal payload: batch of entries to publish with dedup fields. */ @@ -37,27 +43,73 @@ export interface PollInput { timeout: number; } -/** Update response: items matching the poll request. */ +/** Update response: items matching the poll request (wire type). */ export interface PollResult { - items: PubSubItem[]; + items: _WireItem[]; next_offset: number; } /** Serializable snapshot of pub/sub state for continue-as-new. */ export interface PubSubState { - log: PubSubItem[]; + log: _WireItem[]; base_offset: number; publisher_sequences: Record; /** Per-publisher last-seen timestamps for TTL pruning. */ publisher_last_seen?: Record; } -/** Convert a string to wire format (number[] matching Python bytes). */ -export function toWireBytes(s: string): number[] { - return Array.from(new TextEncoder().encode(s)); +// --- Base64 helpers (no Buffer dependency for workflow sandbox compat) --- + +// Standard base64 alphabet +const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +/** Encode bytes to base64 string for wire format. */ +export function encodeData(data: Uint8Array): string { + let result = ''; + for (let i = 0; i < data.length; i += 3) { + const b0 = data[i]!; + const b1 = i + 1 < data.length ? data[i + 1]! : 0; + const b2 = i + 2 < data.length ? data[i + 2]! : 0; + result += B64[(b0 >> 2) & 0x3f]; + result += B64[((b0 << 4) | (b1 >> 4)) & 0x3f]; + result += i + 1 < data.length ? B64[((b1 << 2) | (b2 >> 6)) & 0x3f] : '='; + result += i + 2 < data.length ? B64[b2 & 0x3f] : '='; + } + return result; +} + +/** Decode base64 string from wire format to bytes. */ +export function decodeData(data: string): Uint8Array { + const clean = data.replace(/=+$/, ''); + const len = (clean.length * 3) >> 2; + const out = new Uint8Array(len); + let j = 0; + for (let i = 0; i < clean.length; i += 4) { + const a = B64.indexOf(clean.charAt(i)); + const b = i + 1 < clean.length ? B64.indexOf(clean.charAt(i + 1)) : 0; + const c = i + 2 < clean.length ? B64.indexOf(clean.charAt(i + 2)) : 0; + const d = i + 3 < clean.length ? B64.indexOf(clean.charAt(i + 3)) : 0; + out[j++] = (a << 2) | (b >> 4); + if (j < len) out[j++] = ((b << 4) | (c >> 2)) & 0xff; + if (j < len) out[j++] = ((c << 6) | d) & 0xff; + } + return out; } -/** Convert wire format (number[] from Python bytes) to a string. */ -export function fromWireBytes(data: number[]): string { - return new TextDecoder().decode(new Uint8Array(data)); +/** + * Encode a UTF-8 string to base64 wire format. + * + * Convenience wrapper: encodes the string as UTF-8 bytes, then base64. + */ +export function toWireBytes(s: string): string { + return encodeData(new TextEncoder().encode(s)); +} + +/** + * Decode a base64 wire format string back to a UTF-8 string. + * + * Convenience wrapper: decodes base64 to bytes, then interprets as UTF-8. + */ +export function fromWireBytes(data: string): string { + return new TextDecoder().decode(decodeData(data)); } From 1f374366dc2e7ef8cbb5226ac8b1ff59c0934a27 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Tue, 7 Apr 2026 20:12:03 -0700 Subject: [PATCH 07/75] pubsub: remove poll timeout and trim trailing whitespace Remove the bounded poll wait from PubSubMixin and fix minor whitespace in client and types. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/contrib-pubsub/src/client.ts | 2 +- packages/contrib-pubsub/src/mixin.ts | 5 +---- packages/contrib-pubsub/src/types.ts | 1 - 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/contrib-pubsub/src/client.ts b/packages/contrib-pubsub/src/client.ts index 02f43ac05..4f7aae301 100644 --- a/packages/contrib-pubsub/src/client.ts +++ b/packages/contrib-pubsub/src/client.ts @@ -176,7 +176,7 @@ export class PubSubClient { let result: PollResult; try { result = await this.handle.executeUpdate('__pubsub_poll', { - args: [{ topics: topics ?? [], from_offset: offset, timeout: 300.0 }], + args: [{ topics: topics ?? [], from_offset: offset }], }); } catch (err) { if (err instanceof WorkflowUpdateRPCTimeoutOrCancelledError) { diff --git a/packages/contrib-pubsub/src/mixin.ts b/packages/contrib-pubsub/src/mixin.ts index 9499cfb37..0895f21dd 100644 --- a/packages/contrib-pubsub/src/mixin.ts +++ b/packages/contrib-pubsub/src/mixin.ts @@ -88,10 +88,7 @@ export function initPubSub(priorState?: PubSubState): PubSubHandle { `Requested offset ${input.from_offset} is before base offset ${baseOffset} (log has been truncated)` ); } - await condition( - () => log.length > logOffset || draining, - input.timeout * 1000, // convert seconds to ms - ); + await condition(() => log.length > logOffset || draining); const allNew = log.slice(logOffset); const nextOffset = baseOffset + log.length; let filtered: PubSubItem[]; diff --git a/packages/contrib-pubsub/src/types.ts b/packages/contrib-pubsub/src/types.ts index d2865c1d1..7a2572e27 100644 --- a/packages/contrib-pubsub/src/types.ts +++ b/packages/contrib-pubsub/src/types.ts @@ -40,7 +40,6 @@ export interface PublishInput { export interface PollInput { topics: string[]; from_offset: number; - timeout: number; } /** Update response: items matching the poll request (wire type). */ From ca800ab39497dfdac6654e29aa7527c23a5a0077 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Tue, 7 Apr 2026 20:12:15 -0700 Subject: [PATCH 08/75] Add token-level streaming to Vercel AI SDK Temporal plugin Add opt-in streaming to the AI SDK integration. When enabled, the invokeModelStreaming activity calls model.doStream(), publishes TEXT_DELTA/THINKING_DELTA/TOOL_CALL_START events via PubSubClient, and returns the accumulated LanguageModelV3GenerateResult. - New invokeModelStreaming activity in createActivities() - doStream() implemented on TemporalLanguageModel (returns replay stream) - streaming option on TemporalProviderOptions.languageModel - temporalClient option on AiSdkPluginOptions (required for pubsub) - Validates prerequisites before making LLM call - Unique part IDs via incrementing counter in replay stream Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/ai-sdk/package.json | 3 + packages/ai-sdk/src/activities.ts | 165 +++++++++++++++++++++++++++++- packages/ai-sdk/src/plugin.ts | 11 +- packages/ai-sdk/src/provider.ts | 70 +++++++++++-- 4 files changed, 241 insertions(+), 8 deletions(-) diff --git a/packages/ai-sdk/package.json b/packages/ai-sdk/package.json index 72228a52f..a9a40549b 100644 --- a/packages/ai-sdk/package.json +++ b/packages/ai-sdk/package.json @@ -15,7 +15,10 @@ "author": "Temporal Technologies Inc. ", "license": "MIT", "dependencies": { + "@temporalio/activity": "workspace:*", + "@temporalio/client": "workspace:*", "@temporalio/common": "workspace:*", + "@temporalio/contrib-pubsub": "workspace:*", "@temporalio/plugin": "workspace:*", "@temporalio/workflow": "workspace:*", "@ungap/structured-clone": "^1.3.0", diff --git a/packages/ai-sdk/src/activities.ts b/packages/ai-sdk/src/activities.ts index 87ea3631e..b7d1d5856 100644 --- a/packages/ai-sdk/src/activities.ts +++ b/packages/ai-sdk/src/activities.ts @@ -1,15 +1,29 @@ import type { LanguageModelV3CallOptions, + LanguageModelV3Content, + LanguageModelV3FinishReason, LanguageModelV3GenerateResult, + LanguageModelV3Usage, EmbeddingModelV3Result, SharedV3ProviderOptions, SharedV3Headers, + SharedV3Warning, ProviderV3, } from '@ai-sdk/provider'; import { asSchema, type Schema, type ToolExecutionOptions } from 'ai'; import { ApplicationFailure } from '@temporalio/common'; +import type { Client } from '@temporalio/client'; +import { Context } from '@temporalio/activity'; +import { PubSubClient } from '@temporalio/contrib-pubsub'; import type { McpClientFactories, McpClientFactory } from './mcp'; +const EVENTS_TOPIC = 'events'; +const encoder = new TextEncoder(); + +function makeEvent(type: string, data: Record = {}): Uint8Array { + return encoder.encode(JSON.stringify({ type, timestamp: new Date().toISOString(), data })); +} + /** * Arguments for invoking a language model activity. */ @@ -58,6 +72,14 @@ export interface CallToolArgs { options: ToolExecutionOptions; } +/** + * Options for creating activities with streaming support. + */ +export interface CreateActivitiesOptions { + /** Temporal client, required for streaming (PubSubClient needs it). */ + temporalClient?: Client; +} + /** * Creates Temporal activities for AI model invocation using the provided AI SDK provider. * These activities allow workflows to call AI models while maintaining Temporal's @@ -65,16 +87,157 @@ export interface CallToolArgs { * * @param provider The AI SDK provider to use for model invocations * @param mcpClientFactories A mapping of server names to functions to create mcp clients + * @param options Additional options (e.g. temporalClient for streaming) * @returns An object containing the activity functions * * @experimental The AI SDK integration is an experimental feature; APIs may change without notice. */ -export function createActivities(provider: ProviderV3, mcpClientFactories?: McpClientFactories): object { +export function createActivities( + provider: ProviderV3, + mcpClientFactories?: McpClientFactories, + options?: CreateActivitiesOptions +): object { let activities = { async invokeModel(args: InvokeModelArgs): Promise { const model = provider.languageModel(args.modelId); return await model.doGenerate(args.options); }, + + async invokeModelStreaming(args: InvokeModelArgs): Promise { + // Validate prerequisites before making the LLM call + const info = Context.current().info; + const workflowId = info.workflowExecution?.workflowId; + if (!workflowId || !options?.temporalClient) { + throw ApplicationFailure.nonRetryable( + 'Streaming requires temporalClient in plugin options and activity must be called from a workflow.' + ); + } + + const pubsub = PubSubClient.create(options.temporalClient, workflowId, { + batchInterval: 0.1, + }); + pubsub.start(); + + const model = provider.languageModel(args.modelId); + const streamResult = await model.doStream(args.options); + + const content: LanguageModelV3Content[] = []; + let finishReason: LanguageModelV3FinishReason = { unified: 'other', raw: undefined }; + let usage: LanguageModelV3Usage = { + inputTokens: { total: undefined, noCache: undefined, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: undefined, text: undefined, reasoning: undefined }, + }; + const warnings: SharedV3Warning[] = []; + let responseMetadata: Record | undefined; + + let currentText = ''; + let currentReasoning = ''; + + try { + pubsub.publish(EVENTS_TOPIC, makeEvent('LLM_CALL_START'), true); + + const reader = streamResult.stream.getReader(); + // eslint-disable-next-line no-constant-condition + while (true) { + const { done, value: part } = await reader.read(); + if (done) break; + + Context.current().heartbeat(); + + switch (part.type) { + case 'stream-start': + warnings.push(...part.warnings); + break; + case 'text-start': + currentText = ''; + break; + case 'text-delta': + currentText += part.delta; + pubsub.publish(EVENTS_TOPIC, makeEvent('TEXT_DELTA', { delta: part.delta })); + break; + case 'text-end': + content.push({ + type: 'text', + text: currentText, + providerMetadata: part.providerMetadata, + }); + pubsub.publish(EVENTS_TOPIC, makeEvent('TEXT_COMPLETE', { text: currentText }), true); + break; + case 'reasoning-start': + currentReasoning = ''; + pubsub.publish(EVENTS_TOPIC, makeEvent('THINKING_START')); + break; + case 'reasoning-delta': + currentReasoning += part.delta; + pubsub.publish(EVENTS_TOPIC, makeEvent('THINKING_DELTA', { delta: part.delta })); + break; + case 'reasoning-end': + content.push({ + type: 'reasoning', + text: currentReasoning, + providerMetadata: part.providerMetadata, + }); + pubsub.publish( + EVENTS_TOPIC, + makeEvent('THINKING_COMPLETE', { content: currentReasoning }), + true + ); + break; + case 'tool-input-start': + pubsub.publish( + EVENTS_TOPIC, + makeEvent('TOOL_CALL_START', { tool_name: part.toolName }) + ); + break; + case 'tool-input-delta': + pubsub.publish( + EVENTS_TOPIC, + makeEvent('TOOL_INPUT_DELTA', { delta: part.delta }) + ); + break; + case 'response-metadata': + responseMetadata = { + id: part.id, + timestamp: part.timestamp, + modelId: part.modelId, + }; + break; + case 'finish': + finishReason = part.finishReason; + usage = part.usage; + break; + default: + // tool-call, tool-result, file, source — collect as content + if ( + 'type' in part && + (part.type === 'tool-call' || + part.type === 'tool-result' || + part.type === 'file' || + part.type === 'source') + ) { + content.push(part as LanguageModelV3Content); + } + break; + } + } + + pubsub.publish(EVENTS_TOPIC, makeEvent('LLM_CALL_COMPLETE'), true); + } finally { + await pubsub.stop(); + } + + return { + content, + finishReason, + usage, + warnings, + request: streamResult.request, + response: responseMetadata + ? { ...responseMetadata, ...streamResult.response } + : streamResult.response, + } as InvokeModelResult; + }, + async invokeEmbeddingModel(args: InvokeEmbeddingModelArgs): Promise { const model = provider.embeddingModel(args.modelId); return await model.doEmbed({ diff --git a/packages/ai-sdk/src/plugin.ts b/packages/ai-sdk/src/plugin.ts index 7c3c8f2a6..f89232409 100644 --- a/packages/ai-sdk/src/plugin.ts +++ b/packages/ai-sdk/src/plugin.ts @@ -1,4 +1,5 @@ import type { ProviderV3 } from '@ai-sdk/provider'; +import type { Client } from '@temporalio/client'; import { SimplePlugin } from '@temporalio/plugin'; import { createActivities } from './activities'; import type { McpClientFactories } from './mcp'; @@ -16,6 +17,12 @@ export interface AiSdkPluginOptions { * Any TemporalMCPClient used in a workflow should have its associated servername listed in this object. */ mcpClientFactories?: McpClientFactories; + + /** + * Temporal client, required for streaming support. The streaming activity + * uses this to create a PubSubClient for publishing token events. + */ + temporalClient?: Client; } /** @@ -28,7 +35,9 @@ export class AiSdkPlugin extends SimplePlugin { constructor(options: AiSdkPluginOptions) { super({ name: 'AiSDKPlugin', - activities: createActivities(options.modelProvider, options.mcpClientFactories), + activities: createActivities(options.modelProvider, options.mcpClientFactories, { + temporalClient: options.temporalClient, + }), }); } } diff --git a/packages/ai-sdk/src/provider.ts b/packages/ai-sdk/src/provider.ts index b3eaf34ff..05cbccfff 100644 --- a/packages/ai-sdk/src/provider.ts +++ b/packages/ai-sdk/src/provider.ts @@ -28,7 +28,14 @@ export interface TemporalProviderOptions { * Activity options specific to language model calls. * Merged with default options, with these taking precedence. */ - languageModel?: ActivityOptions; + languageModel?: ActivityOptions & { + /** + * When true, model calls use the streaming LLM endpoint and publish + * token events via PubSubClient. The workflow receives a complete result; + * real-time streaming happens via pubsub as a side channel. + */ + streaming?: boolean; + }; /** * Activity options specific to embedding model calls. @@ -46,11 +53,14 @@ export interface TemporalProviderOptions { export class TemporalLanguageModel implements LanguageModelV3 { readonly specificationVersion = 'v3'; readonly provider = 'temporal'; + private streaming: boolean; constructor( readonly modelId: string, - readonly options?: ActivityOptions - ) {} + readonly options?: ActivityOptions & { streaming?: boolean } + ) { + this.streaming = options?.streaming ?? false; + } get supportedUrls(): Record { return {}; @@ -75,8 +85,54 @@ export class TemporalLanguageModel implements LanguageModelV3 { return result; } - doStream(_options: LanguageModelV3CallOptions): PromiseLike { - throw ApplicationFailure.nonRetryable('Streaming not supported.'); + async doStream(options: LanguageModelV3CallOptions): Promise { + if (!this.streaming) { + throw ApplicationFailure.nonRetryable( + 'Streaming not enabled. Set streaming: true in languageModel provider options.' + ); + } + + // Call the streaming activity, which publishes tokens via pubsub + // and returns the accumulated result. + const activities = workflow.proxyActivities({ + startToCloseTimeout: '10 minutes', + ...this.options, + }); + const result = await activities.invokeModelStreaming!({ modelId: this.modelId, options }); + if (result === undefined) { + throw ApplicationFailure.nonRetryable('Received undefined response from streaming model activity.'); + } + + // Wrap the accumulated result as a ReadableStream that replays the content. + // Real-time token streaming already happened via pubsub in the activity. + const stream = new ReadableStream({ + start(controller: ReadableStreamDefaultController) { + controller.enqueue({ type: 'stream-start', warnings: result.warnings ?? [] }); + let partIndex = 0; + for (const item of result.content ?? []) { + const id = `part-${partIndex++}`; + if (item.type === 'text') { + controller.enqueue({ type: 'text-start', id }); + controller.enqueue({ type: 'text-delta', id, delta: item.text }); + controller.enqueue({ type: 'text-end', id }); + } else if (item.type === 'reasoning') { + controller.enqueue({ type: 'reasoning-start', id }); + controller.enqueue({ type: 'reasoning-delta', id, delta: item.text }); + controller.enqueue({ type: 'reasoning-end', id }); + } else { + controller.enqueue(item); + } + } + controller.enqueue({ + type: 'finish', + finishReason: result.finishReason, + usage: result.usage, + }); + controller.close(); + }, + }); + + return { stream, request: result.request, response: result.response }; } } @@ -140,9 +196,11 @@ export class TemporalProvider implements ProviderV3 { } languageModel(modelId: string): LanguageModelV3 { + const { streaming, ...languageModelOptions } = this.options?.languageModel ?? {}; return new TemporalLanguageModel(modelId, { ...this.options?.default, - ...this.options?.languageModel, + ...languageModelOptions, + streaming, }); } From 0b3d0e811475ff943cc9c8ba77d826c9e3f3852d Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Wed, 22 Apr 2026 22:19:47 -0700 Subject: [PATCH 09/75] pubsub: port correctness fixes and features from sdk-python MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the TypeScript pubsub contrib module up to parity with temporalio.contrib.pubsub. Major changes grouped by concern: Correctness (matching sdk-python commit 97be29c0): - Client _flush(): advance `this.sequence = this.pendingSeq` before clearing pending on retry timeout. Without this, the next batch reuses the expired sequence, the workflow may have already accepted it, and the batch is silently deduped — causing data loss. Truncation safety (sdk-python commit 7bc830ae): - Mixin poll update throws ApplicationFailure with type 'TruncatedOffset' (nonRetryable: true) instead of plain Error. Plain errors would fail the update handler and risk a poison pill on replay. - `from_offset === 0` is treated as "from the beginning of whatever exists" (starts at log head regardless of base offset). - Client subscribe() catches WorkflowUpdateFailedError whose cause is ApplicationFailure of type 'TruncatedOffset' and restarts from offset 0. Per-item offsets (sdk-python commit 5a8716ce): - PubSubItem and _WireItem gain an `offset: number` field. The mixin populates it from the global log position at poll time; the client yields it to subscribers. Response size cap (sdk-python commit 90d753ed): - MAX_POLL_RESPONSE_BYTES = 1_000_000 constant in the mixin. Poll responses truncate to stay under the cap, set more_ready: true, and report the next offset. Subscribers skip the pollCooldown sleep when more_ready is true, draining the backlog quickly. Flusher: - Replace setInterval-based flusher with an event-driven loop using a ResolvableEvent + in-flight mutex. Priority publishes reset the batch interval (via event.set()), matching sdk-python's asyncio behavior. A single mutex serializes concurrent _flush() calls, fixing a race where timer tick + priority flush could issue redundant signals. - FlushTimeoutError from a background tick is stashed and rethrown from stop()/[Symbol.asyncDispose]() so dropped batches are never silent. Activity ergonomics: - PubSubClient.create(client?, workflowId?, options?) — both args now optional. When omitted, the activity Context is used (Context.current().client / info.workflowExecution.workflowId), matching Python's activity.client() / activity.info() pattern. Dispose ergonomics: - [Symbol.asyncDispose] enables `await using client = PubSubClient.create(...)`. package.json bumps engines.node to >= 20.4.0 for runtime support. Types: - PollResult gains `more_ready: boolean`. - PubSubState.publisher_last_seen becomes required (the field is always emitted by getState()). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/contrib-pubsub/package.json | 3 +- packages/contrib-pubsub/src/client.ts | 237 ++++++++++++++++++++------ packages/contrib-pubsub/src/mixin.ts | 106 +++++++++--- packages/contrib-pubsub/src/types.ts | 28 ++- 4 files changed, 292 insertions(+), 82 deletions(-) diff --git a/packages/contrib-pubsub/package.json b/packages/contrib-pubsub/package.json index 68cfc964f..3097db525 100644 --- a/packages/contrib-pubsub/package.json +++ b/packages/contrib-pubsub/package.json @@ -15,12 +15,13 @@ "author": "Temporal Technologies Inc. ", "license": "MIT", "dependencies": { + "@temporalio/activity": "workspace:*", "@temporalio/client": "workspace:*", "@temporalio/common": "workspace:*", "@temporalio/workflow": "workspace:*" }, "engines": { - "node": ">= 20.0.0" + "node": ">= 20.4.0" }, "publishConfig": { "access": "public" diff --git a/packages/contrib-pubsub/src/client.ts b/packages/contrib-pubsub/src/client.ts index 4f7aae301..bfd936591 100644 --- a/packages/contrib-pubsub/src/client.ts +++ b/packages/contrib-pubsub/src/client.ts @@ -6,9 +6,18 @@ */ import { randomUUID } from 'crypto'; -import { Client, WorkflowHandle, WorkflowUpdateRPCTimeoutOrCancelledError } from '@temporalio/client'; -import type { PollInput, PollResult, PubSubItem, PublishEntry, PublishInput } from './types'; -import { encodeData, decodeData } from './types'; +import { Context as ActivityContext } from '@temporalio/activity'; +import { Client, WorkflowHandle, WorkflowUpdateFailedError, WorkflowUpdateRPCTimeoutOrCancelledError } from '@temporalio/client'; +import { ApplicationFailure } from '@temporalio/common'; +import { + decodeData, + encodeData, + type PollInput, + type PollResult, + type PubSubItem, + type PublishEntry, + type PublishInput, +} from './types'; /** Thrown when a flush retry exceeds maxRetryDuration. */ export class FlushTimeoutError extends Error { @@ -31,9 +40,47 @@ export interface PubSubClientOptions { maxRetryDuration?: number; } +/** + * A resolvable event: multiple callers `await wait()` for the same promise, + * `set()` resolves it once, and `clear()` re-arms it for the next cycle. + */ +class ResolvableEvent { + private resolver: (() => void) | null = null; + private promise: Promise; + + constructor() { + this.promise = new Promise((resolve) => { + this.resolver = resolve; + }); + } + + wait(): Promise { + return this.promise; + } + + set(): void { + if (this.resolver) { + const r = this.resolver; + this.resolver = null; + r(); + } + } + + clear(): void { + if (this.resolver !== null) return; // already armed + this.promise = new Promise((resolve) => { + this.resolver = resolve; + }); + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + export class PubSubClient { private handle: WorkflowHandle; - private readonly client: Client | undefined; + private client: Client | undefined; private readonly workflowId: string; private readonly batchInterval: number; private readonly maxBatchSize: number | undefined; @@ -42,11 +89,15 @@ export class PubSubClient { private pending: PublishEntry[] | null = null; private pendingSeq = 0; private pendingStartedAt: number | null = null; - private flushTimer: ReturnType | undefined; - private flushPromise: Promise | undefined; private publisherId: string = randomUUID().replace(/-/g, '').slice(0, 16); private sequence = 0; + private readonly flushEvent = new ResolvableEvent(); + private flusherTask: Promise | undefined; + private flusherStopped = false; + private flusherError: Error | undefined; + private currentFlush: Promise | null = null; + constructor(handle: WorkflowHandle, options?: PubSubClientOptions) { this.handle = handle; this.workflowId = handle.workflowId; @@ -57,41 +108,121 @@ export class PubSubClient { /** * Create a PubSubClient from a Temporal client and workflow ID. - * Preferred constructor — enables continue-as-new following in subscribe(). + * + * When called inside an activity, `client` and `workflowId` can be omitted — + * they are inferred from `Context.current()`. + * + * This is the preferred constructor; it enables continue-as-new following in + * `subscribe()`. */ - static create(client: Client, workflowId: string, options?: PubSubClientOptions): PubSubClient { - const handle = client.workflow.getHandle(workflowId); + static create( + client?: Client, + workflowId?: string, + options?: PubSubClientOptions + ): PubSubClient { + let resolvedClient: Client; + let resolvedId: string; + if (client === undefined || workflowId === undefined) { + // Context.current() throws if not inside an activity — let that propagate + // with its native error so the caller sees the right message. + const ctx = ActivityContext.current(); + resolvedClient = client ?? ctx.client; + resolvedId = workflowId ?? ctx.info.workflowExecution.workflowId; + } else { + resolvedClient = client; + resolvedId = workflowId; + } + const handle = resolvedClient.workflow.getHandle(resolvedId); const instance = new PubSubClient(handle, options); - (instance as unknown as { client: Client | undefined }).client = client; + instance.client = resolvedClient; return instance; } - /** Start the background flush timer. Call before publishing. */ + /** Start the background flusher. Call before publishing. */ start(): void { - if (this.flushTimer) return; - this.flushTimer = setInterval(() => { - void this._flush(); - }, this.batchInterval * 1000); + if (this.flusherTask) return; + this.flusherStopped = false; + this.flusherTask = this.runFlusher(); } - /** Stop the flush timer and flush remaining items. */ + /** Stop the flusher and flush remaining items. */ async stop(): Promise { - if (this.flushTimer) { - clearInterval(this.flushTimer); - this.flushTimer = undefined; + if (!this.flusherTask) { + await this.flushOnce(); + this.throwPendingFlusherError(); + return; + } + this.flusherStopped = true; + this.flushEvent.set(); + try { + await this.flusherTask; + } finally { + this.flusherTask = undefined; + } + // Final drain after the flusher exits. + await this.flushOnce(); + this.throwPendingFlusherError(); + } + + private throwPendingFlusherError(): void { + if (this.flusherError) { + const err = this.flusherError; + this.flusherError = undefined; + throw err; } - await this._flush(); + } + + /** Dispose pattern: `await using client = PubSubClient.create(...)`. */ + async [Symbol.asyncDispose](): Promise { + await this.stop(); } /** * Buffer a message for publishing. * @param data - Opaque byte payload. - * @param priority - If true, triggers immediate flush (fire-and-forget). + * @param priority - If true, wake the flusher to send immediately. */ publish(topic: string, data: Uint8Array, priority = false): void { this.buffer.push({ topic, data: encodeData(data) }); if (priority || (this.maxBatchSize !== undefined && this.buffer.length >= this.maxBatchSize)) { - void this._flush(); + this.flushEvent.set(); + } + } + + private async runFlusher(): Promise { + while (!this.flusherStopped) { + await Promise.race([this.flushEvent.wait(), sleep(this.batchInterval * 1000)]); + this.flushEvent.clear(); + if (this.flusherStopped) break; + try { + await this.flushOnce(); + } catch (err) { + if (err instanceof FlushTimeoutError) { + // Pending batch was dropped and can't be recovered. Stash the + // error and stop the loop; stop() will surface it so data loss + // is never silent. + this.flusherError = err; + break; + } + // Transient failures (network, signal rejection) leave `pending` + // set so the next tick retries with the same sequence. + } + } + } + + /** + * Serialize concurrent flush calls through a single in-flight promise. + */ + private async flushOnce(): Promise { + while (this.currentFlush) { + await this.currentFlush; + } + const p = this._doFlush(); + this.currentFlush = p; + try { + await p; + } finally { + if (this.currentFlush === p) this.currentFlush = null; } } @@ -101,12 +232,7 @@ export class PubSubClient { * On failure, the pending batch and sequence are kept for retry. * Only advances the confirmed sequence on success. */ - private async _flush(): Promise { - // Simple serialization: wait for any in-progress flush - if (this.flushPromise) { - await this.flushPromise; - } - + private async _doFlush(): Promise { let batch: PublishEntry[]; let seq: number; @@ -116,12 +242,18 @@ export class PubSubClient { this.pendingStartedAt !== null && (Date.now() - this.pendingStartedAt) / 1000 > this.maxRetryDuration ) { + // Advance confirmed sequence so the next batch gets a fresh sequence + // number. Without this, the next batch reuses pendingSeq, which the + // workflow may have already accepted — causing silent dedup (data + // loss). See `retry_timeout_sequence_reuse_causes_data_loss` test. + this.sequence = this.pendingSeq; this.pending = null; this.pendingSeq = 0; this.pendingStartedAt = null; throw new FlushTimeoutError( `Flush retry exceeded maxRetryDuration (${this.maxRetryDuration}s). ` + - 'Pending batch dropped.' + 'Pending batch dropped. If the signal was delivered, items are in the log. ' + + 'If not, they are lost.' ); } batch = this.pending; @@ -138,26 +270,17 @@ export class PubSubClient { return; } - const doFlush = async (): Promise => { - // On failure, the signal throws and pending stays set for retry. - // On success, we advance confirmed sequence and clear pending. - await this.handle.signal<[PublishInput]>('__pubsub_publish', { - items: batch, - publisher_id: this.publisherId, - sequence: seq, - }); - this.sequence = seq; - this.pending = null; - this.pendingSeq = 0; - this.pendingStartedAt = null; - }; - - this.flushPromise = doFlush(); - try { - await this.flushPromise; - } finally { - this.flushPromise = undefined; - } + // On failure, the signal throws and pending stays set for retry. + // On success, advance confirmed sequence and clear pending. + await this.handle.signal<[PublishInput]>('__pubsub_publish', { + items: batch, + publisher_id: this.publisherId, + sequence: seq, + }); + this.sequence = seq; + this.pending = null; + this.pendingSeq = 0; + this.pendingStartedAt = null; } /** @@ -179,6 +302,17 @@ export class PubSubClient { args: [{ topics: topics ?? [], from_offset: offset }], }); } catch (err) { + if (err instanceof WorkflowUpdateFailedError) { + const cause = err.cause; + if (cause instanceof ApplicationFailure && cause.type === 'TruncatedOffset') { + // Subscriber fell behind truncation. Retry from offset 0 which + // the mixin treats as "from the beginning of whatever exists" + // (i.e., from baseOffset). + offset = 0; + continue; + } + throw err; + } if (err instanceof WorkflowUpdateRPCTimeoutOrCancelledError) { if (await this.followContinueAsNew()) { continue; @@ -192,12 +326,13 @@ export class PubSubClient { yield { topic: wireItem.topic, data: decodeData(wireItem.data), + offset: wireItem.offset, }; } offset = result.next_offset; - if (pollCooldown > 0) { - await new Promise((resolve) => setTimeout(resolve, pollCooldown * 1000)); + if (!result.more_ready && pollCooldown > 0) { + await sleep(pollCooldown * 1000); } } } diff --git a/packages/contrib-pubsub/src/mixin.ts b/packages/contrib-pubsub/src/mixin.ts index 0895f21dd..413c1a381 100644 --- a/packages/contrib-pubsub/src/mixin.ts +++ b/packages/contrib-pubsub/src/mixin.ts @@ -9,14 +9,25 @@ */ import { condition, defineSignal, defineUpdate, defineQuery, setHandler } from '@temporalio/workflow'; -import type { PollInput, PollResult, PubSubItem, PubSubState, PublishInput, _WireItem } from './types'; -import { encodeData, decodeData } from './types'; +import { ApplicationFailure } from '@temporalio/common'; +import { + decodeData, + encodeData, + type PollInput, + type PollResult, + type PubSubItem, + type PubSubState, + type PublishInput, + type _WireItem, +} from './types'; // Fixed handler names for cross-language interop export const pubsubPublishSignal = defineSignal<[PublishInput]>('__pubsub_publish'); export const pubsubPollUpdate = defineUpdate('__pubsub_poll'); export const pubsubOffsetQuery = defineQuery('__pubsub_offset'); +const MAX_POLL_RESPONSE_BYTES = 1_000_000; + /** Handle returned by initPubSub for interacting with pub/sub state. */ export interface PubSubHandle { /** Publish an item from within workflow code. Deterministic — just appends. */ @@ -51,7 +62,11 @@ export interface PubSubHandle { export function initPubSub(priorState?: PubSubState): PubSubHandle { // Decode wire items (base64) to in-memory items (Uint8Array) const log: PubSubItem[] = priorState?.log - ? priorState.log.map((item) => ({ topic: item.topic, data: decodeData(item.data) })) + ? priorState.log.map((item, i) => ({ + topic: item.topic, + data: decodeData(item.data), + offset: (priorState.base_offset ?? 0) + i, + })) : []; let baseOffset: number = priorState?.base_offset ?? 0; const publisherSequences: Record = priorState?.publisher_sequences @@ -73,8 +88,11 @@ export function initPubSub(priorState?: PubSubState): PubSubHandle { publisherLastSeen[input.publisher_id] = Date.now() / 1000; // seconds } for (const entry of input.items) { - // Decode base64 wire data to Uint8Array for in-memory storage - log.push({ topic: entry.topic, data: decodeData(entry.data) }); + log.push({ + topic: entry.topic, + data: decodeData(entry.data), + offset: baseOffset + log.length, + }); } }); @@ -82,26 +100,59 @@ export function initPubSub(priorState?: PubSubState): PubSubHandle { setHandler( pubsubPollUpdate, async (input: PollInput): Promise => { - const logOffset = input.from_offset - baseOffset; + let logOffset = input.from_offset - baseOffset; if (logOffset < 0) { - throw new Error( - `Requested offset ${input.from_offset} is before base offset ${baseOffset} (log has been truncated)` - ); + if (input.from_offset === 0) { + // "From the beginning" — start at whatever is available. + logOffset = 0; + } else { + // Subscriber had a specific position that's been truncated. + // ApplicationFailure fails this update (client gets the error) + // without crashing the workflow task — avoids a poison pill + // during replay. + throw ApplicationFailure.create({ + message: + `Requested offset ${input.from_offset} has been truncated. ` + + `Current base offset is ${baseOffset}.`, + type: 'TruncatedOffset', + nonRetryable: true, + }); + } } await condition(() => log.length > logOffset || draining); const allNew = log.slice(logOffset); - const nextOffset = baseOffset + log.length; - let filtered: PubSubItem[]; - if (input.topics.length > 0) { - const topicSet = new Set(input.topics); - filtered = allNew.filter((item) => topicSet.has(item.topic)); - } else { - filtered = [...allNew]; + + // Build [globalOffset, item] candidates, filtering by topic if requested. + const topicSet = input.topics.length > 0 ? new Set(input.topics) : null; + const candidates: Array<[number, PubSubItem]> = []; + for (let i = 0; i < allNew.length; i++) { + const item = allNew[i]!; + if (topicSet !== null && !topicSet.has(item.topic)) continue; + candidates.push([baseOffset + logOffset + i, item]); } - // Encode Uint8Array to base64 for wire response + + // Cap response size to ~1MB of estimated wire bytes. + const wireItems: _WireItem[] = []; + let size = 0; + let moreReady = false; + let nextOffset = baseOffset + log.length; + for (const [off, item] of candidates) { + const encoded = encodeData(item.data); + const itemSize = encoded.length + item.topic.length; + if (size + itemSize > MAX_POLL_RESPONSE_BYTES && wireItems.length > 0) { + // Resume from this item on the next poll. + nextOffset = off; + moreReady = true; + break; + } + size += itemSize; + wireItems.push({ topic: item.topic, data: encoded, offset: off }); + } + return { - items: filtered.map((item) => ({ topic: item.topic, data: encodeData(item.data) })), + items: wireItems, next_offset: nextOffset, + more_ready: moreReady, }; }, { @@ -119,7 +170,7 @@ export function initPubSub(priorState?: PubSubState): PubSubHandle { return { publish(topic: string, data: Uint8Array): void { - log.push({ topic, data }); + log.push({ topic, data, offset: baseOffset + log.length }); }, drain(): void { @@ -131,17 +182,20 @@ export function initPubSub(priorState?: PubSubState): PubSubHandle { const activeSeqs: Record = {}; const activeSeen: Record = {}; for (const pid of Object.keys(publisherSequences)) { - const ts = publisherLastSeen[pid]; - if (ts === undefined || now - ts < publisherTtl) { + // Missing timestamps are pruned (matches sdk-python). The signal + // handler always sets both maps together, so absence indicates a + // malformed snapshot rather than a supported upgrade path. + const ts = publisherLastSeen[pid] ?? 0; + if (now - ts < publisherTtl) { activeSeqs[pid] = publisherSequences[pid] ?? 0; - if (ts !== undefined) { - activeSeen[pid] = ts; - } + activeSeen[pid] = ts; } } return { - // Encode Uint8Array to base64 for serializable state - log: log.map((item) => ({ topic: item.topic, data: encodeData(item.data) })), + // Encode Uint8Array to base64 for serializable state. + // Per-item offset is re-derivable from base_offset + index on reload, + // so we leave it at 0 here. + log: log.map((item) => ({ topic: item.topic, data: encodeData(item.data), offset: 0 })), base_offset: baseOffset, publisher_sequences: activeSeqs, publisher_last_seen: activeSeen, diff --git a/packages/contrib-pubsub/src/types.ts b/packages/contrib-pubsub/src/types.ts index 7a2572e27..6d1ffb915 100644 --- a/packages/contrib-pubsub/src/types.ts +++ b/packages/contrib-pubsub/src/types.ts @@ -8,11 +8,17 @@ * data field. User-facing types (PubSubItem) use Uint8Array. */ -/** A single item in the pub/sub log (user-facing). */ +/** + * A single item in the pub/sub log (user-facing). + * + * The `offset` field is populated by `subscribe()` from the item's position in + * the global log. + */ export interface PubSubItem { topic: string; /** Opaque byte payload. */ data: Uint8Array; + offset: number; } /** A single entry to publish via signal (wire type). */ @@ -22,11 +28,18 @@ export interface PublishEntry { data: string; } -/** Wire representation of a PubSubItem (base64 data). */ +/** + * Wire representation of a PubSubItem (base64 data). + * + * The `offset` field is populated by the poll handler from the item's + * position in the global log. It is unused in the `getState()` snapshot + * (offsets there are re-derivable from `base_offset + index`). + */ export interface _WireItem { topic: string; /** Base64-encoded byte payload. */ data: string; + offset: number; } /** Signal payload: batch of entries to publish with dedup fields. */ @@ -42,10 +55,17 @@ export interface PollInput { from_offset: number; } -/** Update response: items matching the poll request (wire type). */ +/** + * Update response: items matching the poll request (wire type). + * + * When `more_ready` is true, the response was truncated to stay within size + * limits and the subscriber should poll again immediately rather than applying + * a cooldown delay. + */ export interface PollResult { items: _WireItem[]; next_offset: number; + more_ready: boolean; } /** Serializable snapshot of pub/sub state for continue-as-new. */ @@ -54,7 +74,7 @@ export interface PubSubState { base_offset: number; publisher_sequences: Record; /** Per-publisher last-seen timestamps for TTL pruning. */ - publisher_last_seen?: Record; + publisher_last_seen: Record; } // --- Base64 helpers (no Buffer dependency for workflow sandbox compat) --- From e983a2866bb9cb68e7748c6df53ce394e2c8c073 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Wed, 22 Apr 2026 22:19:53 -0700 Subject: [PATCH 10/75] pubsub: add README for @temporalio/contrib-pubsub Adapts the sdk-python README to TypeScript idioms: - initPubSub() handle returned to the workflow function (in place of Python's PubSubMixin class). - `await using client = PubSubClient.create(...)` as the preferred activity-side pattern (with start()/stop() as the fallback). - for-await async generator for subscribe(). - Uint8Array payload type called out in the Cross-Language Protocol section, with base64 on the wire. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/contrib-pubsub/README.md | 188 ++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 packages/contrib-pubsub/README.md diff --git a/packages/contrib-pubsub/README.md b/packages/contrib-pubsub/README.md new file mode 100644 index 000000000..976ea5bd2 --- /dev/null +++ b/packages/contrib-pubsub/README.md @@ -0,0 +1,188 @@ +# Temporal Workflow Pub/Sub + +Workflows sometimes need to push incremental updates to external observers. +Examples include providing customer updates during order processing, creating +interactive experiences with AI agents, or reporting progress from a +long-running data pipeline. Temporal's core primitives (workflows, signals, and +updates) already provide the building blocks, but wiring up batching, offset +tracking, topic filtering, and continue-as-new hand-off is non-trivial. + +This module packages that boilerplate into a reusable handle and client. The +workflow acts as a message broker that maintains an append-only log. +Applications can interact directly from the workflow, or from external clients +such as activities, starters, and other workflows. Under the hood, publishing +uses signals (fire-and-forget) while subscribing uses updates (long-poll). A +configurable batching coalesces high-frequency events, improving efficiency. + +## Quick Start + +### Workflow side + +Call `initPubSub()` at the start of your workflow function and use the returned +handle to publish: + +```typescript +import { initPubSub } from '@temporalio/contrib-pubsub'; + +export async function myWorkflow(input: MyInput): Promise { + const pubsub = initPubSub(); + + pubsub.publish('status', new TextEncoder().encode('started')); + await doWork(); + pubsub.publish('status', new TextEncoder().encode('done')); +} +``` + +`initPubSub()` registers the `__pubsub_publish` signal, `__pubsub_poll` update, +and `__pubsub_offset` query handlers on your workflow. + +### Activity side (publishing) + +Use `PubSubClient.create()` with `await using` for batched publishing. When +called from within an activity, the client and workflow ID are inferred +automatically from the activity context: + +```typescript +import { Context } from '@temporalio/activity'; +import { PubSubClient } from '@temporalio/contrib-pubsub'; + +export async function streamEvents(): Promise { + await using client = PubSubClient.create(undefined, undefined, { batchInterval: 2.0 }); + client.start(); + + for await (const chunk of generateChunks()) { + client.publish('events', chunk); + Context.current().heartbeat(); + } + // Buffer is flushed automatically on scope exit. +} +``` + +If `await using` is not available, call `start()` and `await stop()` explicitly: + +```typescript +const client = PubSubClient.create(temporalClient, workflowId); +client.start(); +try { + client.publish('events', data); +} finally { + await client.stop(); +} +``` + +Use `priority = true` to trigger an immediate flush for latency-sensitive +events: + +```typescript +client.publish('events', data, true); +``` + +### Subscribing + +Use `PubSubClient.create()` and iterate `subscribe()`: + +```typescript +import { PubSubClient } from '@temporalio/contrib-pubsub'; + +const client = PubSubClient.create(temporalClient, workflowId); +for await (const item of client.subscribe(['events'], 0)) { + console.log(item.topic, item.offset, new TextDecoder().decode(item.data)); + if (isDone(item)) break; +} +``` + +## Topics + +Topics allow subscribers to receive a subset of the messages in the pub/sub +system. Subscribers can request a list of specific topics, or provide an empty +list (or omit the argument) to receive messages from all topics. Publishing to +a topic implicitly creates it. + +## Continue-as-new + +Carry both your application state and pub/sub state across continue-as-new +boundaries: + +```typescript +import { continueAsNew, workflowInfo } from '@temporalio/workflow'; +import { initPubSub, type PubSubState } from '@temporalio/contrib-pubsub'; + +interface WorkflowInput { + itemsProcessed: number; + pubsubState?: PubSubState; +} + +export async function myWorkflow(input: WorkflowInput): Promise { + let itemsProcessed = input.itemsProcessed; + const pubsub = initPubSub(input.pubsubState); + + // ... do work, updating itemsProcessed ... + + if (workflowInfo().continueAsNewSuggested) { + pubsub.drain(); + // Wait for in-flight handlers to finish, then continue-as-new. + await continueAsNew({ + itemsProcessed, + pubsubState: pubsub.getState(), + }); + } +} +``` + +`drain()` unblocks waiting subscribers and rejects new polls. Subscribers +created via `PubSubClient.create()` automatically follow continue-as-new +chains. + +## API Reference + +### `initPubSub(priorState?) -> PubSubHandle` + +| Method | Description | +|---|---| +| `publish(topic, data)` | Append to the log from workflow code. | +| `getState(publisherTtl = 900)` | Snapshot for continue-as-new. Drops publisher dedup entries older than `publisherTtl` seconds. | +| `drain()` | Unblock polls and reject new ones. | +| `truncate(upToOffset)` | Discard log entries below the given offset. | + +Handlers registered automatically: + +| Kind | Name | Description | +|---|---|---| +| Signal | `__pubsub_publish` | Receive external publications. | +| Update | `__pubsub_poll` | Long-poll subscription. | +| Query | `__pubsub_offset` | Current global offset. | + +### `PubSubClient` + +| Method | Description | +|---|---| +| `PubSubClient.create(client?, workflowId?, options?)` | Factory. Auto-detects activity context when `client` or `workflowId` is omitted. Enables CAN following in `subscribe()`. | +| `new PubSubClient(handle, options?)` | From a handle (no CAN following). | +| `start()` | Start the background flusher. | +| `stop()` | Stop the flusher and flush remaining items. | +| `[Symbol.asyncDispose]()` | Supports `await using client = PubSubClient.create(...)`. | +| `publish(topic, data, priority = false)` | Buffer a message. | +| `subscribe(topics?, fromOffset = 0, { pollCooldown = 0.1 })` | Async generator. Always follows CAN chains when created via `create()`. Recovers automatically from `TruncatedOffset` by restarting from the current base offset. | +| `getOffset()` | Query current global offset. | + +### `PubSubClientOptions` + +| Option | Default | Description | +|---|---|---| +| `batchInterval` | `2.0` | Seconds between automatic flushes. | +| `maxBatchSize` | `undefined` | Auto-flush when buffer reaches this size. | +| `maxRetryDuration` | `600` | Seconds to retry a failed flush before `FlushTimeoutError`. Must be less than the workflow's `publisherTtl` to preserve exactly-once delivery. | + +## Cross-Language Protocol + +Any Temporal client can interact with a pub/sub workflow using these fixed +handler names: + +1. **Publish**: signal `__pubsub_publish` with `PublishInput` +2. **Subscribe**: update `__pubsub_poll` with `PollInput` -> `PollResult` +3. **Offset**: query `__pubsub_offset` -> `number` + +The TypeScript API uses `Uint8Array` for payloads. Base64 encoding is used on +the wire for cross-language compatibility. The wire protocol requires the +default (JSON) data converter — custom converters will break cross-language +interop. From 7fb2633ec1f2efcc05559a6f476c048390981c22 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Wed, 22 Apr 2026 22:20:06 -0700 Subject: [PATCH 11/75] pubsub: add integration tests for @temporalio/contrib-pubsub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports 21 tests from sdk-python tests/contrib/pubsub/test_pubsub.py. Coverage: Baseline: - activity_publish_and_subscribe - topic_filtering - subscribe_from_offset - dispose_flushes_on_exit (await using drains buffer) - max_batch_size - dedup_rejects_duplicate_signal - priority_flush - truncate_pubsub - ttl_pruning_in_get_state - continue_as_new_typed Per-item offset feature: - per_item_offsets - per_item_offsets_with_topic_filter - per_item_offsets_after_truncation Response size cap: - poll_more_ready_when_response_exceeds_size_limit - subscribe_iterates_through_more_ready - small_response_more_ready_false Truncation recovery: - poll_truncated_offset_returns_application_failure - poll_offset_zero_after_truncation - subscribe_recovers_from_truncation Regression tests for correctness fixes: - retry_timeout_sequence_reuse_causes_data_loss — exercises the workflow-side dedup behavior that the client fix protects against. Without the fix, the next batch after a retry timeout reuses the expired sequence and is silently dropped. - flush_timeout_surfaces_on_stop — stop() rethrows a FlushTimeoutError raised on a background tick, so dropped batches are never silent. packages/test gains @temporalio/contrib-pubsub as a workspace dep. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/test/package.json | 1 + .../test/src/activities/contrib-pubsub.ts | 64 ++ packages/test/src/test-contrib-pubsub.ts | 651 ++++++++++++++++++ packages/test/src/workflows/contrib-pubsub.ts | 154 +++++ pnpm-lock.yaml | 15 + 5 files changed, 885 insertions(+) create mode 100644 packages/test/src/activities/contrib-pubsub.ts create mode 100644 packages/test/src/test-contrib-pubsub.ts create mode 100644 packages/test/src/workflows/contrib-pubsub.ts diff --git a/packages/test/package.json b/packages/test/package.json index 758ec2c0f..cc194229d 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -42,6 +42,7 @@ "@temporalio/client": "workspace:*", "@temporalio/cloud": "workspace:*", "@temporalio/common": "workspace:*", + "@temporalio/contrib-pubsub": "workspace:*", "@temporalio/core-bridge": "workspace:*", "@temporalio/envconfig": "workspace:*", "@temporalio/interceptors-opentelemetry": "workspace:*", diff --git a/packages/test/src/activities/contrib-pubsub.ts b/packages/test/src/activities/contrib-pubsub.ts new file mode 100644 index 000000000..18c2f455d --- /dev/null +++ b/packages/test/src/activities/contrib-pubsub.ts @@ -0,0 +1,64 @@ +/** + * Test activities for @temporalio/contrib-pubsub. + * + * These activities use `PubSubClient.create()` with no arguments, relying on + * the activity context to supply the client and workflow ID. + */ + +import { Context } from '@temporalio/activity'; +import { PubSubClient } from '@temporalio/contrib-pubsub'; + +const encoder = new TextEncoder(); + +export async function publishItems(count: number): Promise { + await using client = PubSubClient.create(undefined, undefined, { batchInterval: 0.5 }); + client.start(); + for (let i = 0; i < count; i++) { + Context.current().heartbeat(); + client.publish('events', encoder.encode(`item-${i}`)); + } +} + +export async function publishMultiTopic(count: number): Promise { + const topics = ['a', 'b', 'c']; + await using client = PubSubClient.create(undefined, undefined, { batchInterval: 0.5 }); + client.start(); + for (let i = 0; i < count; i++) { + Context.current().heartbeat(); + const topic = topics[i % topics.length]!; + client.publish(topic, encoder.encode(`${topic}-${i}`)); + } +} + +export async function publishWithPriority(): Promise { + await using client = PubSubClient.create(undefined, undefined, { batchInterval: 60.0 }); + client.start(); + client.publish('events', encoder.encode('normal-0')); + client.publish('events', encoder.encode('normal-1')); + client.publish('events', encoder.encode('priority'), true); + // Give the flusher time to wake and flush. + await new Promise((resolve) => setTimeout(resolve, 500)); +} + +export async function publishBatchTest(count: number): Promise { + await using client = PubSubClient.create(undefined, undefined, { batchInterval: 60.0 }); + client.start(); + for (let i = 0; i < count; i++) { + Context.current().heartbeat(); + client.publish('events', encoder.encode(`item-${i}`)); + } + // Long batchInterval — only the dispose-driven drain will flush. +} + +export async function publishWithMaxBatch(count: number): Promise { + await using client = PubSubClient.create(undefined, undefined, { + batchInterval: 60.0, + maxBatchSize: 3, + }); + client.start(); + for (let i = 0; i < count; i++) { + Context.current().heartbeat(); + client.publish('events', encoder.encode(`item-${i}`)); + } + // Long batchInterval — maxBatchSize and dispose-driven drain handle flushing. +} diff --git a/packages/test/src/test-contrib-pubsub.ts b/packages/test/src/test-contrib-pubsub.ts new file mode 100644 index 000000000..9b4541488 --- /dev/null +++ b/packages/test/src/test-contrib-pubsub.ts @@ -0,0 +1,651 @@ +/** + * E2E integration tests for @temporalio/contrib-pubsub. + * + * Ported from sdk-python tests/contrib/pubsub/test_pubsub.py. + */ + +import { randomUUID } from 'crypto'; +import { ApplicationFailure } from '@temporalio/common'; +import { WorkflowHandle, WorkflowUpdateFailedError } from '@temporalio/client'; +import { + FlushTimeoutError, + PubSubClient, + type PollInput, + type PollResult, + type PubSubItem, + type PubSubState, + type PublishEntry, + type PublishInput, + encodeData, + pubsubOffsetQuery, + pubsubPublishSignal, + pubsubPollUpdate, +} from '@temporalio/contrib-pubsub'; +import { helpers, makeTestFunction } from './helpers-integration'; +import { + activityPublishWorkflow, + basicPubSubWorkflow, + continueAsNewTypedWorkflow, + flushOnExitWorkflow, + getStateWithTtlQuery, + maxBatchWorkflow, + multiTopicWorkflow, + priorityWorkflow, + truncateSignalWorkflow, + ttlTestWorkflow, + workflowSidePublishWorkflow, +} from './workflows/contrib-pubsub'; +import * as pubsubActivities from './activities/contrib-pubsub'; + +const test = makeTestFunction({ + workflowsPath: require.resolve('./workflows/contrib-pubsub'), +}); + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +function entry(topic: string, data: string): PublishEntry { + return { topic, data: encodeData(encoder.encode(data)) }; +} + +async function collectItems( + handle: WorkflowHandle, + topics: string[] | undefined, + fromOffset: number, + expectedCount: number, + timeoutMs = 15_000 +): Promise { + const client = new PubSubClient(handle); + const items: PubSubItem[] = []; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const gen = client.subscribe(topics, fromOffset, { pollCooldown: 0 }); + for await (const item of gen) { + if (controller.signal.aborted) break; + items.push(item); + if (items.length >= expectedCount) { + await gen.return(); + break; + } + } + } catch (err) { + if (!controller.signal.aborted) throw err; + } finally { + clearTimeout(timer); + } + return items; +} + +test('activity_publish_and_subscribe — activity publishes, client subscribes', async (t) => { + const count = 10; + const { createWorker, startWorkflow } = helpers(t); + const worker = await createWorker({ activities: pubsubActivities }); + await worker.runUntil(async () => { + const handle = await startWorkflow(activityPublishWorkflow, { args: [count] }); + const items = await collectItems(handle, undefined, 0, count + 1); + t.is(items.length, count + 1); + for (let i = 0; i < count; i++) { + t.is(items[i]!.topic, 'events'); + t.is(decoder.decode(items[i]!.data), `item-${i}`); + } + t.is(items[count]!.topic, 'status'); + t.is(decoder.decode(items[count]!.data), 'activity_done'); + await handle.signal('close'); + }); +}); + +test('topic_filtering — subscriber gets only requested topics', async (t) => { + const count = 9; + const { createWorker, startWorkflow } = helpers(t); + const worker = await createWorker({ activities: pubsubActivities }); + await worker.runUntil(async () => { + const handle = await startWorkflow(multiTopicWorkflow, { args: [count] }); + + const aItems = await collectItems(handle, ['a'], 0, 3); + t.is(aItems.length, 3); + t.true(aItems.every((it) => it.topic === 'a')); + + const acItems = await collectItems(handle, ['a', 'c'], 0, 6); + t.is(acItems.length, 6); + t.true(acItems.every((it) => it.topic === 'a' || it.topic === 'c')); + + const allItems = await collectItems(handle, undefined, 0, 9); + t.is(allItems.length, 9); + + await handle.signal('close'); + }); +}); + +test('subscribe_from_offset — non-zero starting offset', async (t) => { + const count = 5; + const { createWorker, startWorkflow } = helpers(t); + const worker = await createWorker(); + await worker.runUntil(async () => { + const handle = await startWorkflow(workflowSidePublishWorkflow, { args: [count] }); + + const items = await collectItems(handle, undefined, 3, 2); + t.is(items.length, 2); + t.is(decoder.decode(items[0]!.data), 'item-3'); + t.is(decoder.decode(items[1]!.data), 'item-4'); + + const allItems = await collectItems(handle, undefined, 0, 5); + t.is(allItems.length, 5); + + await handle.signal('close'); + }); +}); + +test('per_item_offsets — each yielded item carries its global offset', async (t) => { + const count = 5; + const { createWorker, startWorkflow } = helpers(t); + const worker = await createWorker(); + await worker.runUntil(async () => { + const handle = await startWorkflow(workflowSidePublishWorkflow, { args: [count] }); + + const items = await collectItems(handle, undefined, 0, count); + t.is(items.length, count); + for (let i = 0; i < count; i++) { + t.is(items[i]!.offset, i); + } + + const laterItems = await collectItems(handle, undefined, 3, 2); + t.is(laterItems[0]!.offset, 3); + t.is(laterItems[1]!.offset, 4); + + await handle.signal('close'); + }); +}); + +test('per_item_offsets_with_topic_filter — offsets are global, not per-topic', async (t) => { + const count = 9; + const { createWorker, startWorkflow } = helpers(t); + const worker = await createWorker({ activities: pubsubActivities }); + await worker.runUntil(async () => { + const handle = await startWorkflow(multiTopicWorkflow, { args: [count] }); + + const aItems = await collectItems(handle, ['a'], 0, 3); + t.is(aItems[0]!.offset, 0); + t.is(aItems[1]!.offset, 3); + t.is(aItems[2]!.offset, 6); + + const bItems = await collectItems(handle, ['b'], 0, 3); + t.is(bItems[0]!.offset, 1); + t.is(bItems[1]!.offset, 4); + t.is(bItems[2]!.offset, 7); + + await handle.signal('close'); + }); +}); + +test('per_item_offsets_after_truncation — offsets remain correct after truncate', async (t) => { + const { createWorker, startWorkflow } = helpers(t); + const worker = await createWorker(); + await worker.runUntil(async () => { + const handle = await startWorkflow(truncateSignalWorkflow, { args: [] }); + + const items: PublishEntry[] = []; + for (let i = 0; i < 5; i++) items.push(entry('events', `item-${i}`)); + await handle.signal<[PublishInput]>(pubsubPublishSignal, { + items, + publisher_id: '', + sequence: 0, + }); + await new Promise((r) => setTimeout(r, 500)); + + await handle.signal('truncate', 3); + await new Promise((r) => setTimeout(r, 300)); + + const after = await collectItems(handle, undefined, 3, 2); + t.is(after.length, 2); + t.is(after[0]!.offset, 3); + t.is(after[1]!.offset, 4); + + await handle.signal('close'); + }); +}); + +test('poll_truncated_offset_returns_application_failure', async (t) => { + const { createWorker, startWorkflow } = helpers(t); + const { env } = t.context; + const worker = await createWorker(); + await worker.runUntil(async () => { + const handle = await startWorkflow(truncateSignalWorkflow, { args: [] }); + + const items: PublishEntry[] = []; + for (let i = 0; i < 5; i++) items.push(entry('events', `item-${i}`)); + await handle.signal<[PublishInput]>(pubsubPublishSignal, { + items, + publisher_id: '', + sequence: 0, + }); + await new Promise((r) => setTimeout(r, 500)); + await handle.signal('truncate', 3); + await new Promise((r) => setTimeout(r, 300)); + + // Poll from offset 1 (truncated) via the raw update — must raise + // WorkflowUpdateFailedError with ApplicationFailure cause, type + // 'TruncatedOffset'. + const rawHandle = env.client.workflow.getHandle(handle.workflowId); + const err = (await t.throwsAsync( + rawHandle.executeUpdate(pubsubPollUpdate, { + args: [{ topics: [], from_offset: 1 }], + }), + { instanceOf: WorkflowUpdateFailedError } + )) as WorkflowUpdateFailedError; + t.true(err.cause instanceof ApplicationFailure); + t.is((err.cause as ApplicationFailure).type, 'TruncatedOffset'); + + // Workflow is still usable. + const after = await collectItems(handle, undefined, 3, 2); + t.is(after.length, 2); + t.is(after[0]!.offset, 3); + + await handle.signal('close'); + }); +}); + +test('poll_offset_zero_after_truncation — offset=0 reads from base', async (t) => { + const { createWorker, startWorkflow } = helpers(t); + const worker = await createWorker(); + await worker.runUntil(async () => { + const handle = await startWorkflow(truncateSignalWorkflow, { args: [] }); + + const items: PublishEntry[] = []; + for (let i = 0; i < 5; i++) items.push(entry('events', `item-${i}`)); + await handle.signal<[PublishInput]>(pubsubPublishSignal, { + items, + publisher_id: '', + sequence: 0, + }); + await new Promise((r) => setTimeout(r, 500)); + await handle.signal('truncate', 3); + await new Promise((r) => setTimeout(r, 300)); + + const after = await collectItems(handle, undefined, 0, 2); + t.is(after.length, 2); + t.is(after[0]!.offset, 3); + t.is(after[1]!.offset, 4); + + await handle.signal('close'); + }); +}); + +test('subscribe_recovers_from_truncation — client auto-restarts from 0', async (t) => { + const { createWorker, startWorkflow } = helpers(t); + const worker = await createWorker(); + await worker.runUntil(async () => { + const handle = await startWorkflow(truncateSignalWorkflow, { args: [] }); + + const items: PublishEntry[] = []; + for (let i = 0; i < 5; i++) items.push(entry('events', `item-${i}`)); + await handle.signal<[PublishInput]>(pubsubPublishSignal, { + items, + publisher_id: '', + sequence: 0, + }); + await new Promise((r) => setTimeout(r, 500)); + await handle.signal('truncate', 3); + await new Promise((r) => setTimeout(r, 300)); + + // subscribe() from offset 1 (truncated) — client should recover and + // deliver items from baseOffset (3) onward. + const received = await collectItems(handle, undefined, 1, 2); + t.is(received.length, 2); + t.is(received[0]!.offset, 3); + + await handle.signal('close'); + }); +}); + +test('priority_flush — priority wakes flusher despite 60s interval', async (t) => { + const { createWorker, startWorkflow } = helpers(t); + const worker = await createWorker({ activities: pubsubActivities }); + await worker.runUntil(async () => { + const handle = await startWorkflow(priorityWorkflow, { args: [] }); + const items = await collectItems(handle, undefined, 0, 3, 10_000); + t.is(items.length, 3); + t.is(decoder.decode(items[2]!.data), 'priority'); + await handle.signal('close'); + }); +}); + +test('dispose_flushes_on_exit — await using drains buffer', async (t) => { + const { createWorker, startWorkflow } = helpers(t); + const count = 5; + const worker = await createWorker({ activities: pubsubActivities }); + await worker.runUntil(async () => { + const handle = await startWorkflow(flushOnExitWorkflow, { args: [count] }); + const items = await collectItems(handle, undefined, 0, count, 15_000); + t.is(items.length, count); + for (let i = 0; i < count; i++) { + t.is(decoder.decode(items[i]!.data), `item-${i}`); + } + await handle.signal('close'); + }); +}); + +test('max_batch_size — triggers flush without waiting for timer', async (t) => { + const { createWorker, startWorkflow } = helpers(t); + const count = 7; + const worker = await createWorker({ activities: pubsubActivities }); + await worker.runUntil(async () => { + const handle = await startWorkflow(maxBatchWorkflow, { args: [count] }); + const items = await collectItems(handle, undefined, 0, count + 1, 15_000); + t.is(items.length, count + 1); + for (let i = 0; i < count; i++) { + t.is(decoder.decode(items[i]!.data), `item-${i}`); + } + await handle.signal('close'); + }); +}); + +test('dedup_rejects_duplicate_signal — same publisher+sequence is dropped', async (t) => { + const { createWorker, startWorkflow } = helpers(t); + const worker = await createWorker(); + await worker.runUntil(async () => { + const handle = await startWorkflow(basicPubSubWorkflow, { args: [] }); + + await handle.signal<[PublishInput]>(pubsubPublishSignal, { + items: [entry('events', 'item-0')], + publisher_id: 'test-pub', + sequence: 1, + }); + await handle.signal<[PublishInput]>(pubsubPublishSignal, { + items: [entry('events', 'duplicate')], + publisher_id: 'test-pub', + sequence: 1, + }); + await handle.signal<[PublishInput]>(pubsubPublishSignal, { + items: [entry('events', 'item-1')], + publisher_id: 'test-pub', + sequence: 2, + }); + await new Promise((r) => setTimeout(r, 500)); + + const items = await collectItems(handle, undefined, 0, 2); + t.is(items.length, 2); + t.is(decoder.decode(items[0]!.data), 'item-0'); + t.is(decoder.decode(items[1]!.data), 'item-1'); + + const offset = await handle.query(pubsubOffsetQuery); + t.is(offset, 2); + + await handle.signal('close'); + }); +}); + +test('retry_timeout_sequence_reuse_causes_data_loss — regression for the fix', async (t) => { + // This exercises the workflow-side dedup behavior that the client fix + // protects against. Step 3 verifies that reusing a sequence silently + // drops the batch (the bug), Step 4 verifies that a fresh sequence is + // accepted (what the fix produces). + const { createWorker, startWorkflow } = helpers(t); + const worker = await createWorker(); + await worker.runUntil(async () => { + const handle = await startWorkflow(basicPubSubWorkflow, { args: [] }); + + await handle.signal<[PublishInput]>(pubsubPublishSignal, { + items: [entry('events', 'batch-A')], + publisher_id: 'victim', + sequence: 1, + }); + await new Promise((r) => setTimeout(r, 300)); + + const firstItems = await collectItems(handle, undefined, 0, 1); + t.is(firstItems.length, 1); + t.is(decoder.decode(firstItems[0]!.data), 'batch-A'); + + // Reused sequence (the bug) — batch silently deduped. + await handle.signal<[PublishInput]>(pubsubPublishSignal, { + items: [entry('events', 'batch-B')], + publisher_id: 'victim', + sequence: 1, + }); + await new Promise((r) => setTimeout(r, 300)); + + const offsetAfterBug = await handle.query(pubsubOffsetQuery); + t.is(offsetAfterBug, 1, 'batch-B should be silently deduped without the fix'); + + // Fresh sequence (what the fix produces) — batch accepted. + await handle.signal<[PublishInput]>(pubsubPublishSignal, { + items: [entry('events', 'batch-B-fixed')], + publisher_id: 'victim', + sequence: 2, + }); + await new Promise((r) => setTimeout(r, 300)); + + const offsetAfterFix = await handle.query(pubsubOffsetQuery); + t.is(offsetAfterFix, 2, 'fresh sequence is accepted'); + + await handle.signal('close'); + }); +}); + +test('truncate_pubsub — truncate discards prefix and adjusts base', async (t) => { + const { createWorker, startWorkflow } = helpers(t); + const worker = await createWorker(); + await worker.runUntil(async () => { + const handle = await startWorkflow(truncateSignalWorkflow, { args: [] }); + + const items: PublishEntry[] = []; + for (let i = 0; i < 5; i++) items.push(entry('events', `item-${i}`)); + await handle.signal<[PublishInput]>(pubsubPublishSignal, { + items, + publisher_id: '', + sequence: 0, + }); + await new Promise((r) => setTimeout(r, 500)); + + const first = await collectItems(handle, undefined, 0, 5); + t.is(first.length, 5); + + await handle.signal('truncate', 3); + await new Promise((r) => setTimeout(r, 300)); + + const offset = await handle.query(pubsubOffsetQuery); + t.is(offset, 5); + + const after = await collectItems(handle, undefined, 3, 2); + t.is(after.length, 2); + t.is(decoder.decode(after[0]!.data), 'item-3'); + t.is(decoder.decode(after[1]!.data), 'item-4'); + + await handle.signal('close'); + }); +}); + +test('ttl_pruning_in_get_state — TTL=0 prunes, long TTL retains', async (t) => { + const { createWorker, startWorkflow } = helpers(t); + const worker = await createWorker(); + await worker.runUntil(async () => { + const handle = await startWorkflow(ttlTestWorkflow, { args: [] }); + + await handle.signal<[PublishInput]>(pubsubPublishSignal, { + items: [entry('events', 'from-a')], + publisher_id: 'pub-a', + sequence: 1, + }); + await handle.signal<[PublishInput]>(pubsubPublishSignal, { + items: [entry('events', 'from-b')], + publisher_id: 'pub-b', + sequence: 1, + }); + await new Promise((r) => setTimeout(r, 500)); + + const kept = await handle.query(getStateWithTtlQuery, 9999); + t.true('pub-a' in kept.publisher_sequences); + t.true('pub-b' in kept.publisher_sequences); + + const pruned = await handle.query(getStateWithTtlQuery, 0); + t.false('pub-a' in pruned.publisher_sequences); + t.false('pub-b' in pruned.publisher_sequences); + t.is(pruned.log.length, 2); + + await handle.signal('close'); + }); +}); + +test('continue_as_new_typed — pubsub state survives CAN', async (t) => { + const { createWorker, startWorkflow } = helpers(t); + const { env } = t.context; + const worker = await createWorker(); + await worker.runUntil(async () => { + const workflowId = `pubsub-can-${randomUUID()}`; + const handle = await startWorkflow(continueAsNewTypedWorkflow, { + args: [{}], + workflowId, + }); + + await handle.signal<[PublishInput]>(pubsubPublishSignal, { + items: [ + entry('events', 'item-0'), + entry('events', 'item-1'), + entry('events', 'item-2'), + ], + publisher_id: '', + sequence: 0, + }); + + const before = await collectItems(handle, undefined, 0, 3); + t.is(before.length, 3); + + await handle.signal('triggerContinue'); + + // Wait for CAN to land — run-id on a fresh handle differs from the old run. + const deadline = Date.now() + 10_000; + let newRunId: string | undefined; + while (Date.now() < deadline) { + const fresh = env.client.workflow.getHandle(workflowId); + const desc = await fresh.describe(); + if (desc.runId !== handle.firstExecutionRunId) { + newRunId = desc.runId; + break; + } + await new Promise((r) => setTimeout(r, 200)); + } + t.truthy(newRunId, 'continue-as-new should produce a new run id'); + + const newHandle = env.client.workflow.getHandle(workflowId); + const afterItems = await collectItems(newHandle, undefined, 0, 3); + t.is(afterItems.length, 3); + t.is(decoder.decode(afterItems[0]!.data), 'item-0'); + + await newHandle.signal<[PublishInput]>(pubsubPublishSignal, { + items: [entry('events', 'item-3')], + publisher_id: '', + sequence: 0, + }); + const finalItems = await collectItems(newHandle, undefined, 0, 4); + t.is(finalItems.length, 4); + t.is(decoder.decode(finalItems[3]!.data), 'item-3'); + + await newHandle.signal('close'); + }); +}); + +test('poll_more_ready_when_response_exceeds_size_limit — 1MB cap', async (t) => { + const { createWorker, startWorkflow } = helpers(t); + const { env } = t.context; + const worker = await createWorker(); + await worker.runUntil(async () => { + const handle = await startWorkflow(basicPubSubWorkflow, { args: [] }); + + const chunk = new Uint8Array(200_000).fill('x'.charCodeAt(0)); + for (let i = 0; i < 8; i++) { + await handle.signal<[PublishInput]>(pubsubPublishSignal, { + items: [{ topic: 'big', data: encodeData(chunk) }], + publisher_id: '', + sequence: 0, + }); + } + await new Promise((r) => setTimeout(r, 500)); + + const rawHandle = env.client.workflow.getHandle(handle.workflowId); + const first = await rawHandle.executeUpdate(pubsubPollUpdate, { + args: [{ topics: [], from_offset: 0 }], + }); + t.is(first.more_ready, true); + t.true(first.items.length < 8); + t.true(first.next_offset < 8); + + // Drain the rest. + let gathered = first.items.length; + let offset = first.next_offset; + while (gathered < 8) { + const next = await rawHandle.executeUpdate(pubsubPollUpdate, { + args: [{ topics: [], from_offset: offset }], + }); + gathered += next.items.length; + offset = next.next_offset; + } + t.is(gathered, 8); + + await handle.signal('close'); + }); +}); + +test('subscribe_iterates_through_more_ready — caller sees all items', async (t) => { + const { createWorker, startWorkflow } = helpers(t); + const worker = await createWorker(); + await worker.runUntil(async () => { + const handle = await startWorkflow(basicPubSubWorkflow, { args: [] }); + const chunk = new Uint8Array(200_000).fill('x'.charCodeAt(0)); + for (let i = 0; i < 8; i++) { + await handle.signal<[PublishInput]>(pubsubPublishSignal, { + items: [{ topic: 'big', data: encodeData(chunk) }], + publisher_id: '', + sequence: 0, + }); + } + const items = await collectItems(handle, undefined, 0, 8, 15_000); + t.is(items.length, 8); + for (const item of items) { + t.is(item.data.length, chunk.length); + } + await handle.signal('close'); + }); +}); + +test('flush_timeout_surfaces_on_stop — dropped batches are never silent', async (t) => { + // Point a client at a non-existent workflow so every signal fails, then + // shrink maxRetryDuration to a value the flusher can exceed between ticks. + // After stop(), the FlushTimeoutError must propagate (rather than being + // swallowed by the background loop). + const { env } = t.context; + const bogus = env.client.workflow.getHandle(`no-such-workflow-${randomUUID()}`); + const client = new PubSubClient(bogus, { batchInterval: 0.1, maxRetryDuration: 0.2 }); + client.start(); + client.publish('events', encoder.encode('will-be-lost')); + // Give the flusher enough ticks to hit retry timeout. + await new Promise((r) => setTimeout(r, 1500)); + await t.throwsAsync(client.stop(), { instanceOf: FlushTimeoutError }); +}); + +test('small_response_more_ready_false — tiny payloads fit in one poll', async (t) => { + const { createWorker, startWorkflow } = helpers(t); + const { env } = t.context; + const worker = await createWorker(); + await worker.runUntil(async () => { + const handle = await startWorkflow(basicPubSubWorkflow, { args: [] }); + + const smallBatch: PublishEntry[] = []; + for (let i = 0; i < 5; i++) smallBatch.push(entry('small', 'tiny')); + await handle.signal<[PublishInput]>(pubsubPublishSignal, { + items: smallBatch, + publisher_id: '', + sequence: 0, + }); + await new Promise((r) => setTimeout(r, 500)); + + const rawHandle = env.client.workflow.getHandle(handle.workflowId); + const result = await rawHandle.executeUpdate(pubsubPollUpdate, { + args: [{ topics: [], from_offset: 0 }], + }); + t.is(result.more_ready, false); + t.is(result.items.length, 5); + t.is(result.next_offset, 5); + + await handle.signal('close'); + }); +}); diff --git a/packages/test/src/workflows/contrib-pubsub.ts b/packages/test/src/workflows/contrib-pubsub.ts new file mode 100644 index 000000000..6214b2cc0 --- /dev/null +++ b/packages/test/src/workflows/contrib-pubsub.ts @@ -0,0 +1,154 @@ +/** + * Test workflows for @temporalio/contrib-pubsub. + */ + +import { + condition, + continueAsNew, + defineQuery, + defineSignal, + proxyActivities, + setHandler, +} from '@temporalio/workflow'; +import { initPubSub, type PubSubState } from '@temporalio/contrib-pubsub'; +import type * as activities from '../activities/contrib-pubsub'; + +const { publishItems, publishMultiTopic, publishWithPriority, publishBatchTest, publishWithMaxBatch } = + proxyActivities({ + startToCloseTimeout: '30 seconds', + heartbeatTimeout: '10 seconds', + }); + +export const closeSignal = defineSignal('close'); +export const triggerContinueSignal = defineSignal('triggerContinue'); +export const truncateSignal = defineSignal<[number]>('truncate'); +export const getStateWithTtlQuery = defineQuery('getStateWithTtl'); + +/** A minimal broker workflow — initializes pub/sub and waits for close. */ +export async function basicPubSubWorkflow(): Promise { + initPubSub(); + let closed = false; + setHandler(closeSignal, () => { + closed = true; + }); + await condition(() => closed); +} + +/** Publishes `count` items directly from the workflow, then waits. */ +export async function workflowSidePublishWorkflow(count: number): Promise { + const pubsub = initPubSub(); + let closed = false; + setHandler(closeSignal, () => { + closed = true; + }); + const encoder = new TextEncoder(); + for (let i = 0; i < count; i++) { + pubsub.publish('events', encoder.encode(`item-${i}`)); + } + await condition(() => closed); +} + +/** Executes publishMultiTopic activity then waits. */ +export async function multiTopicWorkflow(count: number): Promise { + initPubSub(); + let closed = false; + setHandler(closeSignal, () => { + closed = true; + }); + await publishMultiTopic(count); + await condition(() => closed); +} + +/** Executes publishItems activity then appends activity_done status. */ +export async function activityPublishWorkflow(count: number): Promise { + const pubsub = initPubSub(); + let closed = false; + setHandler(closeSignal, () => { + closed = true; + }); + await publishItems(count); + pubsub.publish('status', new TextEncoder().encode('activity_done')); + await condition(() => closed); +} + +/** Workflow that accepts a truncate signal. */ +export async function truncateSignalWorkflow(): Promise { + const pubsub = initPubSub(); + let closed = false; + setHandler(closeSignal, () => { + closed = true; + }); + setHandler(truncateSignal, (upToOffset: number) => { + pubsub.truncate(upToOffset); + }); + await condition(() => closed); +} + +/** Workflow that exposes getState via query for TTL testing. */ +export async function ttlTestWorkflow(): Promise { + const pubsub = initPubSub(); + let closed = false; + setHandler(closeSignal, () => { + closed = true; + }); + setHandler(getStateWithTtlQuery, (ttl: number) => pubsub.getState(ttl)); + await condition(() => closed); +} + +/** Workflow that runs publishWithPriority activity. */ +export async function priorityWorkflow(): Promise { + initPubSub(); + let closed = false; + setHandler(closeSignal, () => { + closed = true; + }); + await publishWithPriority(); + await condition(() => closed); +} + +/** Workflow that runs publishBatchTest activity. */ +export async function flushOnExitWorkflow(count: number): Promise { + initPubSub(); + let closed = false; + setHandler(closeSignal, () => { + closed = true; + }); + await publishBatchTest(count); + await condition(() => closed); +} + +/** Workflow that runs publishWithMaxBatch activity. */ +export async function maxBatchWorkflow(count: number): Promise { + const pubsub = initPubSub(); + let closed = false; + setHandler(closeSignal, () => { + closed = true; + }); + await publishWithMaxBatch(count); + pubsub.publish('status', new TextEncoder().encode('activity_done')); + await condition(() => closed); +} + +/** Typed input for the continue-as-new workflow. */ +export interface CANWorkflowInput { + pubsubState?: PubSubState; +} + +/** CAN workflow using properly-typed pubsubState. */ +export async function continueAsNewTypedWorkflow(input: CANWorkflowInput): Promise { + const pubsub = initPubSub(input.pubsubState); + let closed = false; + let shouldContinue = false; + setHandler(closeSignal, () => { + closed = true; + }); + setHandler(triggerContinueSignal, () => { + shouldContinue = true; + }); + await condition(() => shouldContinue || closed); + if (closed) return; + pubsub.drain(); + await continueAsNew({ + pubsubState: pubsub.getState(), + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f66b9808..d76c83c51 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -164,9 +164,18 @@ importers: '@ai-sdk/provider': specifier: ^3.0.0 version: 3.0.0 + '@temporalio/activity': + specifier: workspace:* + version: link:../activity + '@temporalio/client': + specifier: workspace:* + version: link:../client '@temporalio/common': specifier: workspace:* version: link:../common + '@temporalio/contrib-pubsub': + specifier: workspace:* + version: link:../contrib-pubsub '@temporalio/plugin': specifier: workspace:* version: link:../plugin @@ -260,6 +269,9 @@ importers: packages/contrib-pubsub: dependencies: + '@temporalio/activity': + specifier: workspace:* + version: link:../activity '@temporalio/client': specifier: workspace:* version: link:../client @@ -578,6 +590,9 @@ importers: '@temporalio/common': specifier: workspace:* version: link:../common + '@temporalio/contrib-pubsub': + specifier: workspace:* + version: link:../contrib-pubsub '@temporalio/core-bridge': specifier: workspace:* version: link:../core-bridge From 50568074a3f7a0a4be5e9afb5c216d4bfb1e8f40 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Wed, 22 Apr 2026 22:30:58 -0700 Subject: [PATCH 12/75] pubsub: port test improvements from sdk-python MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the initial test port. Brings the TS suite in line with the Python test-quality pass at sdk-python commits 3a710281, 4ab7ce48, fdbb3394, 35417903, 68ad53d2. De-flake barriers: - Remove `await new Promise(r => setTimeout(r, ...))` after signals throughout. A subsequent update/query call acts as the barrier — waits for prior signals to be processed before running. - Switch `truncate` from signal to update (workflow: truncateUpdate in place of truncateSignal, renamed truncateSignalWorkflow → truncateWorkflow). Update completion is explicit; no barrier comment needed. Delete redundant tests (covered by stronger cases): - per_item_offsets_after_truncation — covered by truncate_pubsub + subscribe_recovers_from_truncation. - poll_offset_zero_after_truncation — covered by truncate_pubsub. - small_response_more_ready_false — fold a single more_ready=false assertion into poll_more_ready_when_response_exceeds_size_limit. - retry_timeout_sequence_reuse_causes_data_loss — asserted the BUG (silent dedup) rather than the FIX, so it would fail if dedup got stricter. Its purpose is better served by flush_retry_preserves_items_after_failures + flush_raises_after_max_retry_duration. Merge related tests: - subscribe_from_offset + per_item_offsets → subscribe_from_offset_and_per_item_offsets. Strengthen existing tests: - priority_flush: activity now holds 10s via heartbeat loop (was 500ms sleep). A regression in priority wakeup now surfaces as a missing item rather than passing via the dispose-driven flush at activity exit. Test timeout tightened to 5s — well below the hold. - poll_more_ready_when_response_exceeds_size_limit: assert more_ready=false on the final drain poll. - continue_as_new_typed: seed publisher dedup state (pub / seq=1), add publisherSequencesQuery on the workflow, assert dedup state survives CAN + duplicate publish is rejected + fresh sequence is accepted. Previously only verified log contents and offsets. - ttl_pruning_in_get_state: rewrite to verify real TTL semantics. Wall-clock gap (1.0s) between two publishers, then TTL=0.5s query — old pruned, new kept. Previously used 0/9999 extremes which exercised the code path but not the semantics. New behavioral tests: - flush_retry_preserves_items_after_failures: injects signal failures via handle.signal monkey-patch, verifies items arrive in publish order, exactly once, after retry. Replaces the white-box `_buffer`/`_pending` inspection approach with behavioral assertions. - flush_raises_after_max_retry_duration: renamed from flush_timeout_surfaces_on_stop; same semantics (stop() rethrows FlushTimeoutError after retry window expires). Result: 21 → 17 tests, all passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../test/src/activities/contrib-pubsub.ts | 11 +- packages/test/src/test-contrib-pubsub.ts | 349 ++++++++---------- packages/test/src/workflows/contrib-pubsub.ts | 13 +- 3 files changed, 167 insertions(+), 206 deletions(-) diff --git a/packages/test/src/activities/contrib-pubsub.ts b/packages/test/src/activities/contrib-pubsub.ts index 18c2f455d..e388330b1 100644 --- a/packages/test/src/activities/contrib-pubsub.ts +++ b/packages/test/src/activities/contrib-pubsub.ts @@ -31,13 +31,20 @@ export async function publishMultiTopic(count: number): Promise { } export async function publishWithPriority(): Promise { + // Long batchInterval AND long post-publish hold ensure that only a + // working priority wakeup can deliver items before dispose flushes. + // The hold is deliberately much longer than the test's collect timeout + // so a regression (priority no-op) surfaces as a missing item rather + // than flaking on slow CI. await using client = PubSubClient.create(undefined, undefined, { batchInterval: 60.0 }); client.start(); client.publish('events', encoder.encode('normal-0')); client.publish('events', encoder.encode('normal-1')); client.publish('events', encoder.encode('priority'), true); - // Give the flusher time to wake and flush. - await new Promise((resolve) => setTimeout(resolve, 500)); + for (let i = 0; i < 100; i++) { + Context.current().heartbeat(); + await new Promise((resolve) => setTimeout(resolve, 100)); + } } export async function publishBatchTest(count: number): Promise { diff --git a/packages/test/src/test-contrib-pubsub.ts b/packages/test/src/test-contrib-pubsub.ts index 9b4541488..1e3777ae1 100644 --- a/packages/test/src/test-contrib-pubsub.ts +++ b/packages/test/src/test-contrib-pubsub.ts @@ -31,7 +31,9 @@ import { maxBatchWorkflow, multiTopicWorkflow, priorityWorkflow, - truncateSignalWorkflow, + publisherSequencesQuery, + truncateUpdate, + truncateWorkflow, ttlTestWorkflow, workflowSidePublishWorkflow, } from './workflows/contrib-pubsub'; @@ -117,41 +119,28 @@ test('topic_filtering — subscriber gets only requested topics', async (t) => { }); }); -test('subscribe_from_offset — non-zero starting offset', async (t) => { +test('subscribe_from_offset_and_per_item_offsets — non-zero starts and global offsets', async (t) => { const count = 5; const { createWorker, startWorkflow } = helpers(t); const worker = await createWorker(); await worker.runUntil(async () => { const handle = await startWorkflow(workflowSidePublishWorkflow, { args: [count] }); - const items = await collectItems(handle, undefined, 3, 2); - t.is(items.length, 2); - t.is(decoder.decode(items[0]!.data), 'item-3'); - t.is(decoder.decode(items[1]!.data), 'item-4'); - - const allItems = await collectItems(handle, undefined, 0, 5); - t.is(allItems.length, 5); - - await handle.signal('close'); - }); -}); - -test('per_item_offsets — each yielded item carries its global offset', async (t) => { - const count = 5; - const { createWorker, startWorkflow } = helpers(t); - const worker = await createWorker(); - await worker.runUntil(async () => { - const handle = await startWorkflow(workflowSidePublishWorkflow, { args: [count] }); - - const items = await collectItems(handle, undefined, 0, count); - t.is(items.length, count); + // From offset 0 — all items, offsets 0..count-1. + const allItems = await collectItems(handle, undefined, 0, count); + t.is(allItems.length, count); for (let i = 0; i < count; i++) { - t.is(items[i]!.offset, i); + t.is(allItems[i]!.offset, i); + t.is(decoder.decode(allItems[i]!.data), `item-${i}`); } + // From offset 3 — items 3, 4 with offsets 3, 4. const laterItems = await collectItems(handle, undefined, 3, 2); + t.is(laterItems.length, 2); t.is(laterItems[0]!.offset, 3); + t.is(decoder.decode(laterItems[0]!.data), 'item-3'); t.is(laterItems[1]!.offset, 4); + t.is(decoder.decode(laterItems[1]!.data), 'item-4'); await handle.signal('close'); }); @@ -178,39 +167,12 @@ test('per_item_offsets_with_topic_filter — offsets are global, not per-topic', }); }); -test('per_item_offsets_after_truncation — offsets remain correct after truncate', async (t) => { - const { createWorker, startWorkflow } = helpers(t); - const worker = await createWorker(); - await worker.runUntil(async () => { - const handle = await startWorkflow(truncateSignalWorkflow, { args: [] }); - - const items: PublishEntry[] = []; - for (let i = 0; i < 5; i++) items.push(entry('events', `item-${i}`)); - await handle.signal<[PublishInput]>(pubsubPublishSignal, { - items, - publisher_id: '', - sequence: 0, - }); - await new Promise((r) => setTimeout(r, 500)); - - await handle.signal('truncate', 3); - await new Promise((r) => setTimeout(r, 300)); - - const after = await collectItems(handle, undefined, 3, 2); - t.is(after.length, 2); - t.is(after[0]!.offset, 3); - t.is(after[1]!.offset, 4); - - await handle.signal('close'); - }); -}); - test('poll_truncated_offset_returns_application_failure', async (t) => { const { createWorker, startWorkflow } = helpers(t); const { env } = t.context; const worker = await createWorker(); await worker.runUntil(async () => { - const handle = await startWorkflow(truncateSignalWorkflow, { args: [] }); + const handle = await startWorkflow(truncateWorkflow, { args: [] }); const items: PublishEntry[] = []; for (let i = 0; i < 5; i++) items.push(entry('events', `item-${i}`)); @@ -219,13 +181,11 @@ test('poll_truncated_offset_returns_application_failure', async (t) => { publisher_id: '', sequence: 0, }); - await new Promise((r) => setTimeout(r, 500)); - await handle.signal('truncate', 3); - await new Promise((r) => setTimeout(r, 300)); + // Truncate via update — completion is explicit. + await handle.executeUpdate(truncateUpdate, { args: [3] }); - // Poll from offset 1 (truncated) via the raw update — must raise - // WorkflowUpdateFailedError with ApplicationFailure cause, type - // 'TruncatedOffset'. + // Poll from offset 1 (truncated) must raise WorkflowUpdateFailedError + // with ApplicationFailure cause of type 'TruncatedOffset'. const rawHandle = env.client.workflow.getHandle(handle.workflowId); const err = (await t.throwsAsync( rawHandle.executeUpdate(pubsubPollUpdate, { @@ -245,37 +205,11 @@ test('poll_truncated_offset_returns_application_failure', async (t) => { }); }); -test('poll_offset_zero_after_truncation — offset=0 reads from base', async (t) => { - const { createWorker, startWorkflow } = helpers(t); - const worker = await createWorker(); - await worker.runUntil(async () => { - const handle = await startWorkflow(truncateSignalWorkflow, { args: [] }); - - const items: PublishEntry[] = []; - for (let i = 0; i < 5; i++) items.push(entry('events', `item-${i}`)); - await handle.signal<[PublishInput]>(pubsubPublishSignal, { - items, - publisher_id: '', - sequence: 0, - }); - await new Promise((r) => setTimeout(r, 500)); - await handle.signal('truncate', 3); - await new Promise((r) => setTimeout(r, 300)); - - const after = await collectItems(handle, undefined, 0, 2); - t.is(after.length, 2); - t.is(after[0]!.offset, 3); - t.is(after[1]!.offset, 4); - - await handle.signal('close'); - }); -}); - test('subscribe_recovers_from_truncation — client auto-restarts from 0', async (t) => { const { createWorker, startWorkflow } = helpers(t); const worker = await createWorker(); await worker.runUntil(async () => { - const handle = await startWorkflow(truncateSignalWorkflow, { args: [] }); + const handle = await startWorkflow(truncateWorkflow, { args: [] }); const items: PublishEntry[] = []; for (let i = 0; i < 5; i++) items.push(entry('events', `item-${i}`)); @@ -284,9 +218,8 @@ test('subscribe_recovers_from_truncation — client auto-restarts from 0', async publisher_id: '', sequence: 0, }); - await new Promise((r) => setTimeout(r, 500)); - await handle.signal('truncate', 3); - await new Promise((r) => setTimeout(r, 300)); + // Truncate via update — explicit completion. + await handle.executeUpdate(truncateUpdate, { args: [3] }); // subscribe() from offset 1 (truncated) — client should recover and // deliver items from baseOffset (3) onward. @@ -303,7 +236,11 @@ test('priority_flush — priority wakes flusher despite 60s interval', async (t) const worker = await createWorker({ activities: pubsubActivities }); await worker.runUntil(async () => { const handle = await startWorkflow(priorityWorkflow, { args: [] }); - const items = await collectItems(handle, undefined, 0, 3, 10_000); + // The activity holds for ~10s after priority publish; 5s timeout gives + // plenty of margin for scheduling while staying well below the hold so + // a regression (no priority wakeup) surfaces as a missing item, not a + // pass via the dispose-driven flush at activity exit. + const items = await collectItems(handle, undefined, 0, 3, 5_000); t.is(items.length, 3); t.is(decoder.decode(items[2]!.data), 'priority'); await handle.signal('close'); @@ -361,8 +298,8 @@ test('dedup_rejects_duplicate_signal — same publisher+sequence is dropped', as publisher_id: 'test-pub', sequence: 2, }); - await new Promise((r) => setTimeout(r, 500)); + // collectItems' update call acts as a barrier — prior signals processed. const items = await collectItems(handle, undefined, 0, 2); t.is(items.length, 2); t.is(decoder.decode(items[0]!.data), 'item-0'); @@ -375,58 +312,11 @@ test('dedup_rejects_duplicate_signal — same publisher+sequence is dropped', as }); }); -test('retry_timeout_sequence_reuse_causes_data_loss — regression for the fix', async (t) => { - // This exercises the workflow-side dedup behavior that the client fix - // protects against. Step 3 verifies that reusing a sequence silently - // drops the batch (the bug), Step 4 verifies that a fresh sequence is - // accepted (what the fix produces). - const { createWorker, startWorkflow } = helpers(t); - const worker = await createWorker(); - await worker.runUntil(async () => { - const handle = await startWorkflow(basicPubSubWorkflow, { args: [] }); - - await handle.signal<[PublishInput]>(pubsubPublishSignal, { - items: [entry('events', 'batch-A')], - publisher_id: 'victim', - sequence: 1, - }); - await new Promise((r) => setTimeout(r, 300)); - - const firstItems = await collectItems(handle, undefined, 0, 1); - t.is(firstItems.length, 1); - t.is(decoder.decode(firstItems[0]!.data), 'batch-A'); - - // Reused sequence (the bug) — batch silently deduped. - await handle.signal<[PublishInput]>(pubsubPublishSignal, { - items: [entry('events', 'batch-B')], - publisher_id: 'victim', - sequence: 1, - }); - await new Promise((r) => setTimeout(r, 300)); - - const offsetAfterBug = await handle.query(pubsubOffsetQuery); - t.is(offsetAfterBug, 1, 'batch-B should be silently deduped without the fix'); - - // Fresh sequence (what the fix produces) — batch accepted. - await handle.signal<[PublishInput]>(pubsubPublishSignal, { - items: [entry('events', 'batch-B-fixed')], - publisher_id: 'victim', - sequence: 2, - }); - await new Promise((r) => setTimeout(r, 300)); - - const offsetAfterFix = await handle.query(pubsubOffsetQuery); - t.is(offsetAfterFix, 2, 'fresh sequence is accepted'); - - await handle.signal('close'); - }); -}); - test('truncate_pubsub — truncate discards prefix and adjusts base', async (t) => { const { createWorker, startWorkflow } = helpers(t); const worker = await createWorker(); await worker.runUntil(async () => { - const handle = await startWorkflow(truncateSignalWorkflow, { args: [] }); + const handle = await startWorkflow(truncateWorkflow, { args: [] }); const items: PublishEntry[] = []; for (let i = 0; i < 5; i++) items.push(entry('events', `item-${i}`)); @@ -435,14 +325,14 @@ test('truncate_pubsub — truncate discards prefix and adjusts base', async (t) publisher_id: '', sequence: 0, }); - await new Promise((r) => setTimeout(r, 500)); const first = await collectItems(handle, undefined, 0, 5); t.is(first.length, 5); - await handle.signal('truncate', 3); - await new Promise((r) => setTimeout(r, 300)); + // Truncate via update — returns after the handler completes. + await handle.executeUpdate(truncateUpdate, { args: [3] }); + // Offset should still be 5 (truncation moves base_offset, not tail). const offset = await handle.query(pubsubOffsetQuery); t.is(offset, 5); @@ -455,38 +345,46 @@ test('truncate_pubsub — truncate discards prefix and adjusts base', async (t) }); }); -test('ttl_pruning_in_get_state — TTL=0 prunes, long TTL retains', async (t) => { +test('ttl_pruning_in_get_state — old publisher pruned, new publisher kept', async (t) => { + // pub-old arrives first, then wall-clock gap, then pub-new. TTL=0.5s + // prunes pub-old (~1s old) but keeps pub-new (~0s). + // + // The gap is generous relative to TTL (1.0s / 0.5s) so the test + // tolerates multi-hundred-ms scheduling jitter in both directions. const { createWorker, startWorkflow } = helpers(t); const worker = await createWorker(); await worker.runUntil(async () => { const handle = await startWorkflow(ttlTestWorkflow, { args: [] }); await handle.signal<[PublishInput]>(pubsubPublishSignal, { - items: [entry('events', 'from-a')], - publisher_id: 'pub-a', + items: [entry('events', 'old')], + publisher_id: 'pub-old', sequence: 1, }); + + // Sanity: pub-old is recorded (generous TTL retains it). + const before = await handle.query(getStateWithTtlQuery, 9999); + t.true('pub-old' in before.publisher_sequences); + + // Wall-clock gap so workflow.time() advances between the two signals. + await new Promise((r) => setTimeout(r, 1000)); + await handle.signal<[PublishInput]>(pubsubPublishSignal, { - items: [entry('events', 'from-b')], - publisher_id: 'pub-b', + items: [entry('events', 'new')], + publisher_id: 'pub-new', sequence: 1, }); - await new Promise((r) => setTimeout(r, 500)); - - const kept = await handle.query(getStateWithTtlQuery, 9999); - t.true('pub-a' in kept.publisher_sequences); - t.true('pub-b' in kept.publisher_sequences); - const pruned = await handle.query(getStateWithTtlQuery, 0); - t.false('pub-a' in pruned.publisher_sequences); - t.false('pub-b' in pruned.publisher_sequences); - t.is(pruned.log.length, 2); + const state = await handle.query(getStateWithTtlQuery, 0.5); + t.false('pub-old' in state.publisher_sequences); + t.true('pub-new' in state.publisher_sequences); + t.is(state.log.length, 2); await handle.signal('close'); }); }); -test('continue_as_new_typed — pubsub state survives CAN', async (t) => { +test('continue_as_new_typed — log, offsets, AND dedup state survive CAN', async (t) => { const { createWorker, startWorkflow } = helpers(t); const { env } = t.context; const worker = await createWorker(); @@ -497,14 +395,16 @@ test('continue_as_new_typed — pubsub state survives CAN', async (t) => { workflowId, }); + // Seed publisher dedup state (pub / sequence=1) so we can verify it + // survives CAN. await handle.signal<[PublishInput]>(pubsubPublishSignal, { items: [ entry('events', 'item-0'), entry('events', 'item-1'), entry('events', 'item-2'), ], - publisher_id: '', - sequence: 0, + publisher_id: 'pub', + sequence: 1, }); const before = await collectItems(handle, undefined, 0, 3); @@ -512,7 +412,7 @@ test('continue_as_new_typed — pubsub state survives CAN', async (t) => { await handle.signal('triggerContinue'); - // Wait for CAN to land — run-id on a fresh handle differs from the old run. + // Wait for CAN (new run-id on a fresh handle). const deadline = Date.now() + 10_000; let newRunId: string | undefined; while (Date.now() < deadline) { @@ -524,21 +424,50 @@ test('continue_as_new_typed — pubsub state survives CAN', async (t) => { } await new Promise((r) => setTimeout(r, 200)); } - t.truthy(newRunId, 'continue-as-new should produce a new run id'); + t.truthy(newRunId, 'CAN should produce a new run id'); const newHandle = env.client.workflow.getHandle(workflowId); + + // Log contents and offsets preserved across CAN. const afterItems = await collectItems(newHandle, undefined, 0, 3); - t.is(afterItems.length, 3); - t.is(decoder.decode(afterItems[0]!.data), 'item-0'); + t.deepEqual( + afterItems.map((i) => decoder.decode(i.data)), + ['item-0', 'item-1', 'item-2'] + ); + t.deepEqual( + afterItems.map((i) => i.offset), + [0, 1, 2] + ); + + // Dedup state preserved: publisher_sequences carries {pub: 1} after CAN. + const seqsAfterCan = await newHandle.query>(publisherSequencesQuery); + t.deepEqual(seqsAfterCan, { pub: 1 }); + + // Re-sending publisher_id='pub', sequence=1 must be rejected — log and + // publisher_sequences unchanged. + await newHandle.signal<[PublishInput]>(pubsubPublishSignal, { + items: [entry('events', 'dup')], + publisher_id: 'pub', + sequence: 1, + }); + const seqsAfterDup = await newHandle.query>(publisherSequencesQuery); + t.deepEqual(seqsAfterDup, { pub: 1 }); + // Fresh sequence from same publisher accepted; item-3 lands at offset 3. await newHandle.signal<[PublishInput]>(pubsubPublishSignal, { items: [entry('events', 'item-3')], - publisher_id: '', - sequence: 0, + publisher_id: 'pub', + sequence: 2, }); + const seqsAfterAccept = await newHandle.query>(publisherSequencesQuery); + t.deepEqual(seqsAfterAccept, { pub: 2 }); + const finalItems = await collectItems(newHandle, undefined, 0, 4); - t.is(finalItems.length, 4); - t.is(decoder.decode(finalItems[3]!.data), 'item-3'); + t.deepEqual( + finalItems.map((i) => decoder.decode(i.data)), + ['item-0', 'item-1', 'item-2', 'item-3'] + ); + t.is(finalItems[3]!.offset, 3); await newHandle.signal('close'); }); @@ -559,8 +488,8 @@ test('poll_more_ready_when_response_exceeds_size_limit — 1MB cap', async (t) = sequence: 0, }); } - await new Promise((r) => setTimeout(r, 500)); + // The update acts as a barrier for all prior publish signals. const rawHandle = env.client.workflow.getHandle(handle.workflowId); const first = await rawHandle.executeUpdate(pubsubPollUpdate, { args: [{ topics: [], from_offset: 0 }], @@ -572,14 +501,17 @@ test('poll_more_ready_when_response_exceeds_size_limit — 1MB cap', async (t) = // Drain the rest. let gathered = first.items.length; let offset = first.next_offset; + let last: PollResult = first; while (gathered < 8) { - const next = await rawHandle.executeUpdate(pubsubPollUpdate, { + last = await rawHandle.executeUpdate(pubsubPollUpdate, { args: [{ topics: [], from_offset: offset }], }); - gathered += next.items.length; - offset = next.next_offset; + gathered += last.items.length; + offset = last.next_offset; } t.is(gathered, 8); + // The final poll that drained the log should set more_ready=false. + t.is(last.more_ready, false); await handle.signal('close'); }); @@ -607,45 +539,62 @@ test('subscribe_iterates_through_more_ready — caller sees all items', async (t }); }); -test('flush_timeout_surfaces_on_stop — dropped batches are never silent', async (t) => { - // Point a client at a non-existent workflow so every signal fails, then - // shrink maxRetryDuration to a value the flusher can exceed between ticks. - // After stop(), the FlushTimeoutError must propagate (rather than being - // swallowed by the background loop). - const { env } = t.context; - const bogus = env.client.workflow.getHandle(`no-such-workflow-${randomUUID()}`); - const client = new PubSubClient(bogus, { batchInterval: 0.1, maxRetryDuration: 0.2 }); - client.start(); - client.publish('events', encoder.encode('will-be-lost')); - // Give the flusher enough ticks to hit retry timeout. - await new Promise((r) => setTimeout(r, 1500)); - await t.throwsAsync(client.stop(), { instanceOf: FlushTimeoutError }); -}); - -test('small_response_more_ready_false — tiny payloads fit in one poll', async (t) => { +test('flush_retry_preserves_items_after_failures — behavioral retry coverage', async (t) => { + // Inject signal failures on the handle so the client exercises its retry + // path. Then let delivery succeed and verify items arrive in publish + // order, exactly once — no drops, no duplicates, no reorderings. const { createWorker, startWorkflow } = helpers(t); - const { env } = t.context; const worker = await createWorker(); await worker.runUntil(async () => { const handle = await startWorkflow(basicPubSubWorkflow, { args: [] }); - const smallBatch: PublishEntry[] = []; - for (let i = 0; i < 5; i++) smallBatch.push(entry('small', 'tiny')); - await handle.signal<[PublishInput]>(pubsubPublishSignal, { - items: smallBatch, - publisher_id: '', - sequence: 0, + const pubsub = new PubSubClient(handle); + const realSignal = handle.signal.bind(handle); + let failRemaining = 2; + (handle as unknown as { signal: typeof handle.signal }).signal = (async (...args: unknown[]) => { + if (failRemaining > 0) { + failRemaining -= 1; + throw new Error('simulated delivery failure'); + } + return realSignal(...(args as Parameters)); + }) as typeof handle.signal; + + pubsub.publish('events', encoder.encode('item-0')); + pubsub.publish('events', encoder.encode('item-1')); + await t.throwsAsync((pubsub as unknown as { _doFlush(): Promise })._doFlush(), { + message: /simulated/, }); - await new Promise((r) => setTimeout(r, 500)); - const rawHandle = env.client.workflow.getHandle(handle.workflowId); - const result = await rawHandle.executeUpdate(pubsubPollUpdate, { - args: [{ topics: [], from_offset: 0 }], + // Publish more during the failed state — must not overtake the pending + // retry on eventual delivery. + pubsub.publish('events', encoder.encode('item-2')); + await t.throwsAsync((pubsub as unknown as { _doFlush(): Promise })._doFlush(), { + message: /simulated/, }); - t.is(result.more_ready, false); - t.is(result.items.length, 5); - t.is(result.next_offset, 5); + + // Third flush delivers the pending retry batch. + await (pubsub as unknown as { _doFlush(): Promise })._doFlush(); + // Fourth flush delivers the buffered 'item-2'. + await (pubsub as unknown as { _doFlush(): Promise })._doFlush(); + + const items = await collectItems(handle, undefined, 0, 3); + t.deepEqual( + items.map((i) => decoder.decode(i.data)), + ['item-0', 'item-1', 'item-2'] + ); await handle.signal('close'); }); }); + +test('flush_raises_after_max_retry_duration — timeout surfaces, client resumes', async (t) => { + // When the retry window expires, stop() must rethrow FlushTimeoutError; + // the client stays usable and subsequent publishes succeed. + const { env } = t.context; + const bogus = env.client.workflow.getHandle(`no-such-workflow-${randomUUID()}`); + const client = new PubSubClient(bogus, { batchInterval: 0.1, maxRetryDuration: 0.2 }); + client.start(); + client.publish('events', encoder.encode('will-be-lost')); + await new Promise((r) => setTimeout(r, 1500)); + await t.throwsAsync(client.stop(), { instanceOf: FlushTimeoutError }); +}); diff --git a/packages/test/src/workflows/contrib-pubsub.ts b/packages/test/src/workflows/contrib-pubsub.ts index 6214b2cc0..097b49bc8 100644 --- a/packages/test/src/workflows/contrib-pubsub.ts +++ b/packages/test/src/workflows/contrib-pubsub.ts @@ -7,6 +7,7 @@ import { continueAsNew, defineQuery, defineSignal, + defineUpdate, proxyActivities, setHandler, } from '@temporalio/workflow'; @@ -21,8 +22,9 @@ const { publishItems, publishMultiTopic, publishWithPriority, publishBatchTest, export const closeSignal = defineSignal('close'); export const triggerContinueSignal = defineSignal('triggerContinue'); -export const truncateSignal = defineSignal<[number]>('truncate'); +export const truncateUpdate = defineUpdate('truncate'); export const getStateWithTtlQuery = defineQuery('getStateWithTtl'); +export const publisherSequencesQuery = defineQuery>('publisherSequences'); /** A minimal broker workflow — initializes pub/sub and waits for close. */ export async function basicPubSubWorkflow(): Promise { @@ -71,14 +73,14 @@ export async function activityPublishWorkflow(count: number): Promise { await condition(() => closed); } -/** Workflow that accepts a truncate signal. */ -export async function truncateSignalWorkflow(): Promise { +/** Workflow that accepts a truncate update (explicit completion). */ +export async function truncateWorkflow(): Promise { const pubsub = initPubSub(); let closed = false; setHandler(closeSignal, () => { closed = true; }); - setHandler(truncateSignal, (upToOffset: number) => { + setHandler(truncateUpdate, (upToOffset: number) => { pubsub.truncate(upToOffset); }); await condition(() => closed); @@ -145,6 +147,9 @@ export async function continueAsNewTypedWorkflow(input: CANWorkflowInput): Promi setHandler(triggerContinueSignal, () => { shouldContinue = true; }); + // Expose publisher_sequences for CAN dedup-survival test. Use a very + // large TTL so we read the current state without pruning. + setHandler(publisherSequencesQuery, () => pubsub.getState(Number.MAX_SAFE_INTEGER).publisher_sequences); await condition(() => shouldContinue || closed); if (closed) return; pubsub.drain(); From bab312838360d047c49b7892be9fcc4cccd4f100 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Thu, 23 Apr 2026 19:03:26 -0700 Subject: [PATCH 13/75] pubsub: migrate API from Uint8Array to Payload Aligns the TypeScript contrib-pubsub module with the sdk-python bytes-to-Payload migration (commit 900a9812b on sdk-python contrib/pubsub). `PubSubItem.data` is now a Temporal `Payload`, and `publish(topic, value)` accepts any payload-convertible value or a pre-built `Payload`. Wire format: PublishEntry.data / _WireItem.data are now base64-encoded protobuf bytes of temporal.api.common.v1.Payload (previously a raw base64 byte string). A nested `Payload` cannot be JSON-serialized by the default converter because the JSON converter only handles top-level Payload on signal/update args, so we hand-roll the proto encode/decode in types.ts to avoid pulling protobufjs into the workflow sandbox. Cross-SDK clients can publish and subscribe by following the same base64-of-serialized-Payload shape. Codec boundary: The codec chain (encryption, PII-redaction, compression) runs once on the signal/update envelope, not per item. Per-item codec would double-encrypt because the envelope already covers items; keeping it envelope-level also makes behavior symmetric between workflow-side and client-side publishing. API surface: - types.ts: PubSubItem.data: Payload; adds encodePayloadProto / decodePayloadProto / encodePayloadWire / decodePayloadWire; renames encodeData/decodeData -> encodeBase64/decodeBase64. - client.ts: publish(topic, value) accepts unknown or Payload, uses defaultPayloadConverter; adds isPayload type guard and SubscribeOptions type. - mixin.ts: workflow-side handlers updated to the Payload shape. - index.ts: re-export the new Payload wire helpers. - README.md: documents the Payload API and the envelope-level codec rule. - test-contrib-pubsub.ts: tests updated to the new API using defaultPayloadConverter. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/contrib-pubsub/README.md | 43 +++-- packages/contrib-pubsub/src/client.ts | 115 ++++++++++--- packages/contrib-pubsub/src/index.ts | 26 ++- packages/contrib-pubsub/src/mixin.ts | 136 +++++++++++---- packages/contrib-pubsub/src/types.ts | 201 +++++++++++++++++++---- packages/test/src/test-contrib-pubsub.ts | 69 +++++--- 6 files changed, 461 insertions(+), 129 deletions(-) diff --git a/packages/contrib-pubsub/README.md b/packages/contrib-pubsub/README.md index 976ea5bd2..4345c8e31 100644 --- a/packages/contrib-pubsub/README.md +++ b/packages/contrib-pubsub/README.md @@ -14,6 +14,12 @@ such as activities, starters, and other workflows. Under the hood, publishing uses signals (fire-and-forget) while subscribing uses updates (long-poll). A configurable batching coalesces high-frequency events, improving efficiency. +Payloads are Temporal `Payload`s carrying the encoding metadata needed for +typed decode and cross-language interop. The codec chain (encryption, +PII-redaction, compression) runs once on the signal/update envelope that +carries each batch — **not** per item — so there is no double-encryption, and +codec behavior is symmetric between workflow-side and client-side publishing. + ## Quick Start ### Workflow side @@ -27,14 +33,16 @@ import { initPubSub } from '@temporalio/contrib-pubsub'; export async function myWorkflow(input: MyInput): Promise { const pubsub = initPubSub(); - pubsub.publish('status', new TextEncoder().encode('started')); + pubsub.publish('status', { state: 'started' }); await doWork(); - pubsub.publish('status', new TextEncoder().encode('done')); + pubsub.publish('status', { state: 'done' }); } ``` `initPubSub()` registers the `__pubsub_publish` signal, `__pubsub_poll` update, -and `__pubsub_offset` query handlers on your workflow. +and `__pubsub_offset` query handlers on your workflow. Any value the default +payload converter can serialize (JSON, `Uint8Array`, or a pre-built `Payload`) +can be passed to `publish`. ### Activity side (publishing) @@ -82,15 +90,21 @@ client.publish('events', data, true); Use `PubSubClient.create()` and iterate `subscribe()`: ```typescript +import { defaultPayloadConverter } from '@temporalio/common'; import { PubSubClient } from '@temporalio/contrib-pubsub'; const client = PubSubClient.create(temporalClient, workflowId); for await (const item of client.subscribe(['events'], 0)) { - console.log(item.topic, item.offset, new TextDecoder().decode(item.data)); - if (isDone(item)) break; + // item.data is a Payload; decode with a payload converter + const value = defaultPayloadConverter.fromPayload(item.data); + console.log(item.topic, item.offset, value); + if (isDone(value)) break; } ``` +`item.data` is a `Payload` carrying encoding metadata, so any converter-known +value round-trips (`json/plain` for JSON, `binary/plain` for `Uint8Array`, etc.). + ## Topics Topics allow subscribers to receive a subset of the messages in the pub/sub @@ -139,7 +153,7 @@ chains. | Method | Description | |---|---| -| `publish(topic, data)` | Append to the log from workflow code. | +| `publish(topic, value)` | Append to the log from workflow code. Accepts any value the default payload converter handles, or a pre-built `Payload`. | | `getState(publisherTtl = 900)` | Snapshot for continue-as-new. Drops publisher dedup entries older than `publisherTtl` seconds. | | `drain()` | Unblock polls and reject new ones. | | `truncate(upToOffset)` | Discard log entries below the given offset. | @@ -156,13 +170,13 @@ Handlers registered automatically: | Method | Description | |---|---| -| `PubSubClient.create(client?, workflowId?, options?)` | Factory. Auto-detects activity context when `client` or `workflowId` is omitted. Enables CAN following in `subscribe()`. | +| `PubSubClient.create(client?, workflowId?, options?)` | Factory. Auto-detects activity context when `client` or `workflowId` is omitted. Enables CAN following in `subscribe()`; uses the `Client`'s configured payload converter. | | `new PubSubClient(handle, options?)` | From a handle (no CAN following). | | `start()` | Start the background flusher. | | `stop()` | Stop the flusher and flush remaining items. | | `[Symbol.asyncDispose]()` | Supports `await using client = PubSubClient.create(...)`. | -| `publish(topic, data, priority = false)` | Buffer a message. | -| `subscribe(topics?, fromOffset = 0, { pollCooldown = 0.1 })` | Async generator. Always follows CAN chains when created via `create()`. Recovers automatically from `TruncatedOffset` by restarting from the current base offset. | +| `publish(topic, value, priority = false)` | Buffer a message. `value` may be any converter-compatible object or a pre-built `Payload`. | +| `subscribe(topics?, fromOffset = 0, { pollCooldown = 0.1 })` | Async generator yielding `PubSubItem` with `data: Payload`. Always follows CAN chains when created via `create()`. Recovers automatically from `TruncatedOffset` by restarting from the current base offset. | | `getOffset()` | Query current global offset. | ### `PubSubClientOptions` @@ -182,7 +196,12 @@ handler names: 2. **Subscribe**: update `__pubsub_poll` with `PollInput` -> `PollResult` 3. **Offset**: query `__pubsub_offset` -> `number` -The TypeScript API uses `Uint8Array` for payloads. Base64 encoding is used on -the wire for cross-language compatibility. The wire protocol requires the -default (JSON) data converter — custom converters will break cross-language +Each `PublishEntry.data` / `_WireItem.data` is a base64-encoded +`temporal.api.common.v1.Payload` protobuf (`Payload.SerializeToString()` in +Python; equivalent `encodePayloadProto()` in this package). This keeps the +envelope JSON-serializable while preserving `Payload.metadata` for codec and +typed-decode paths. Cross-language clients can publish and subscribe by +following the same base64-of-serialized-`Payload` shape. The envelope types +(`PublishInput`, `PollResult`, `PubSubState`) require the default (JSON) data +converter — custom converters on the envelope layer break cross-language interop. diff --git a/packages/contrib-pubsub/src/client.ts b/packages/contrib-pubsub/src/client.ts index bfd936591..104dc76f3 100644 --- a/packages/contrib-pubsub/src/client.ts +++ b/packages/contrib-pubsub/src/client.ts @@ -3,15 +3,28 @@ * * Used by activities, starters, and any code with a workflow handle to publish * messages and subscribe to topics on a pub/sub workflow. + * + * Each published value is turned into a `Payload` via the client's payload + * converter. The codec chain (encryption, PII-redaction, compression) is + * NOT run per item — it runs once on the signal/update envelope that + * carries each batch. Running the codec per item would double-encrypt + * because the envelope path already covers the items. The per-item + * `Payload` still carries encoding metadata so consumers can decode + * with a payload converter. */ import { randomUUID } from 'crypto'; import { Context as ActivityContext } from '@temporalio/activity'; -import { Client, WorkflowHandle, WorkflowUpdateFailedError, WorkflowUpdateRPCTimeoutOrCancelledError } from '@temporalio/client'; -import { ApplicationFailure } from '@temporalio/common'; import { - decodeData, - encodeData, + Client, + WorkflowHandle, + WorkflowUpdateFailedError, + WorkflowUpdateRPCTimeoutOrCancelledError, +} from '@temporalio/client'; +import { ApplicationFailure, defaultPayloadConverter, type Payload, type PayloadConverter } from '@temporalio/common'; +import { + decodePayloadWire, + encodePayloadWire, type PollInput, type PollResult, type PubSubItem, @@ -40,6 +53,14 @@ export interface PubSubClientOptions { maxRetryDuration?: number; } +export interface SubscribeOptions { + /** + * Minimum seconds between polls to avoid overwhelming the workflow when + * items arrive faster than the poll round-trip. Default: 0.1. + */ + pollCooldown?: number; +} + /** * A resolvable event: multiple callers `await wait()` for the same promise, * `set()` resolves it once, and `clear()` re-arms it for the next cycle. @@ -78,6 +99,22 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +/** Type guard for a Payload (as opposed to an arbitrary user value). */ +function isPayload(value: unknown): value is Payload { + if (value === null || typeof value !== 'object') return false; + const v = value as { metadata?: unknown; data?: unknown }; + // Metadata must exist (at minimum with an encoding key) to be a Payload. + return ( + 'metadata' in v && + v.metadata != null && + typeof v.metadata === 'object' && + !Array.isArray(v.metadata) && + // Distinguish from plain `{ metadata, data }` user objects by requiring + // that values in the metadata map look like Uint8Array instances. + Object.values(v.metadata).every((x) => x instanceof Uint8Array) + ); +} + export class PubSubClient { private handle: WorkflowHandle; private client: Client | undefined; @@ -85,7 +122,8 @@ export class PubSubClient { private readonly batchInterval: number; private readonly maxBatchSize: number | undefined; private readonly maxRetryDuration: number; - private buffer: PublishEntry[] = []; + private readonly payloadConverter: PayloadConverter; + private buffer: Array<{ topic: string; value: unknown }> = []; private pending: PublishEntry[] | null = null; private pendingSeq = 0; private pendingStartedAt: number | null = null; @@ -104,6 +142,7 @@ export class PubSubClient { this.batchInterval = options?.batchInterval ?? 2.0; this.maxBatchSize = options?.maxBatchSize; this.maxRetryDuration = options?.maxRetryDuration ?? 600; + this.payloadConverter = defaultPayloadConverter; } /** @@ -113,13 +152,10 @@ export class PubSubClient { * they are inferred from `Context.current()`. * * This is the preferred constructor; it enables continue-as-new following in - * `subscribe()`. + * `subscribe()` and uses the configured `Client`'s payload converter for + * per-item `Payload` construction. */ - static create( - client?: Client, - workflowId?: string, - options?: PubSubClientOptions - ): PubSubClient { + static create(client?: Client, workflowId?: string, options?: PubSubClientOptions): PubSubClient { let resolvedClient: Client; let resolvedId: string; if (client === undefined || workflowId === undefined) { @@ -135,6 +171,12 @@ export class PubSubClient { const handle = resolvedClient.workflow.getHandle(resolvedId); const instance = new PubSubClient(handle, options); instance.client = resolvedClient; + // Prefer the Client's configured converter so custom converters flow + // through; fall back to the default if unset. + const clientConverter = resolvedClient.options?.loadedDataConverter?.payloadConverter; + if (clientConverter) { + (instance as unknown as { payloadConverter: PayloadConverter }).payloadConverter = clientConverter; + } return instance; } @@ -159,8 +201,12 @@ export class PubSubClient { } finally { this.flusherTask = undefined; } - // Final drain after the flusher exits. - await this.flushOnce(); + // Final drain after the flusher exits. Repeat while either pending OR the + // producer buffer has items: a single flushOnce() processes either + // `pending` OR the buffer, not both. + while (this.pending !== null || this.buffer.length > 0) { + await this.flushOnce(); + } this.throwPendingFlusherError(); } @@ -179,16 +225,30 @@ export class PubSubClient { /** * Buffer a message for publishing. - * @param data - Opaque byte payload. + * + * @param topic - Topic string. + * @param value - Any value the payload converter can handle, or a pre-built + * `Payload` for zero-copy. The codec chain runs once on the signal + * envelope, not per item. * @param priority - If true, wake the flusher to send immediately. */ - publish(topic: string, data: Uint8Array, priority = false): void { - this.buffer.push({ topic, data: encodeData(data) }); + publish(topic: string, value: unknown, priority = false): void { + this.buffer.push({ topic, value }); if (priority || (this.maxBatchSize !== undefined && this.buffer.length >= this.maxBatchSize)) { this.flushEvent.set(); } } + private encodeBuffer(entries: Array<{ topic: string; value: unknown }>): PublishEntry[] { + const out: PublishEntry[] = new Array(entries.length); + for (let i = 0; i < entries.length; i++) { + const { topic, value } = entries[i]!; + const payload: Payload = isPayload(value) ? value : this.payloadConverter.toPayload(value); + out[i] = { topic, data: encodePayloadWire(payload) }; + } + return out; + } + private async runFlusher(): Promise { while (!this.flusherStopped) { await Promise.race([this.flushEvent.wait(), sleep(this.batchInterval * 1000)]); @@ -210,9 +270,7 @@ export class PubSubClient { } } - /** - * Serialize concurrent flush calls through a single in-flight promise. - */ + /** Serialize concurrent flush calls through a single in-flight promise. */ private async flushOnce(): Promise { while (this.currentFlush) { await this.currentFlush; @@ -259,10 +317,12 @@ export class PubSubClient { batch = this.pending; seq = this.pendingSeq; } else if (this.buffer.length > 0) { - // New batch path - seq = this.sequence + 1; - batch = this.buffer; + // New batch path: encode at flush time so the payload converter is + // applied once per item. + const raw = this.buffer; this.buffer = []; + batch = this.encodeBuffer(raw); + seq = this.sequence + 1; this.pending = batch; this.pendingSeq = seq; this.pendingStartedAt = Date.now(); @@ -285,12 +345,17 @@ export class PubSubClient { /** * Async generator that polls for new items. - * Automatically follows continue-as-new chains when created via create(). + * + * Yielded items carry `data: Payload`. Use a payload converter such as + * `defaultPayloadConverter.fromPayload(item.data)` to decode. + * + * Automatically follows continue-as-new chains when created via + * {@link PubSubClient.create}. */ async *subscribe( topics?: string[], fromOffset = 0, - options?: { pollCooldown?: number } + options?: SubscribeOptions ): AsyncGenerator { const pollCooldown = options?.pollCooldown ?? 0.1; let offset = fromOffset; @@ -325,7 +390,7 @@ export class PubSubClient { for (const wireItem of result.items) { yield { topic: wireItem.topic, - data: decodeData(wireItem.data), + data: decodePayloadWire(wireItem.data), offset: wireItem.offset, }; } diff --git a/packages/contrib-pubsub/src/index.ts b/packages/contrib-pubsub/src/index.ts index eab9b5985..133b9d1d3 100644 --- a/packages/contrib-pubsub/src/index.ts +++ b/packages/contrib-pubsub/src/index.ts @@ -5,15 +5,31 @@ * message broker. External clients (activities, starters, other services) * publish and subscribe through the workflow handle using Temporal primitives. * - * Payloads are opaque bytes. Base64 encoding is used on the wire for - * cross-language compatibility, but users work with native Uint8Array. + * Payloads are Temporal `Payload`s carrying the encoding metadata needed for + * typed decode and cross-language interop. The codec chain (encryption, + * PII-redaction, compression) runs once on the signal/update envelope that + * carries each batch — not per item. * * @module */ -export type { PubSubItem, PublishEntry, PublishInput, PollInput, PollResult, PubSubState } from './types'; -export { toWireBytes, fromWireBytes, encodeData, decodeData } from './types'; +export type { + PubSubItem, + PublishEntry, + PublishInput, + PollInput, + PollResult, + PubSubState, +} from './types'; +export { + encodeBase64, + decodeBase64, + encodePayloadProto, + decodePayloadProto, + encodePayloadWire, + decodePayloadWire, +} from './types'; export { initPubSub, pubsubPublishSignal, pubsubPollUpdate, pubsubOffsetQuery } from './mixin'; export type { PubSubHandle } from './mixin'; export { PubSubClient, FlushTimeoutError } from './client'; -export type { PubSubClientOptions } from './client'; +export type { PubSubClientOptions, SubscribeOptions } from './client'; diff --git a/packages/contrib-pubsub/src/mixin.ts b/packages/contrib-pubsub/src/mixin.ts index 413c1a381..46c46fe43 100644 --- a/packages/contrib-pubsub/src/mixin.ts +++ b/packages/contrib-pubsub/src/mixin.ts @@ -6,21 +6,46 @@ * * Call `initPubSub()` at the start of your workflow function. Use the returned * handle to publish, drain, and get state for continue-as-new. + * + * Both workflow-side and client-side `publish()` use the default payload + * converter for per-item `Payload` construction. The codec chain + * (encryption, PII-redaction, compression) is NOT applied per item on + * either side — it runs once at the envelope level when Temporal's SDK + * encodes the signal/update that carries the batch. */ -import { condition, defineSignal, defineUpdate, defineQuery, setHandler } from '@temporalio/workflow'; -import { ApplicationFailure } from '@temporalio/common'; +import { condition, defineSignal, defineUpdate, defineQuery, setHandler, defaultPayloadConverter } from '@temporalio/workflow'; +import { ApplicationFailure, type Payload } from '@temporalio/common'; import { - decodeData, - encodeData, + decodePayloadWire, + encodePayloadProto, + encodePayloadWire, + encodeBase64, type PollInput, type PollResult, - type PubSubItem, type PubSubState, type PublishInput, type _WireItem, } from './types'; +const BINARY_PLAIN_ENCODING = new TextEncoder().encode('binary/plain'); + +/** + * Cross-realm-safe Uint8Array check. + * + * The workflow sandbox gets its `TextEncoder` from `node:util`, which + * returns a `Uint8Array` tagged to the host realm — so `value instanceof + * Uint8Array` is false against the sandbox's own `Uint8Array` global. + * `Object.prototype.toString` crosses realm boundaries reliably. + */ +function isUint8ArrayLike(value: unknown): value is ArrayLike { + return ( + value != null && + typeof value === 'object' && + Object.prototype.toString.call(value) === '[object Uint8Array]' + ); +} + // Fixed handler names for cross-language interop export const pubsubPublishSignal = defineSignal<[PublishInput]>('__pubsub_publish'); export const pubsubPollUpdate = defineUpdate('__pubsub_poll'); @@ -28,10 +53,39 @@ export const pubsubOffsetQuery = defineQuery('__pubsub_offset'); const MAX_POLL_RESPONSE_BYTES = 1_000_000; +/** Approximate poll-response contribution of a single encoded payload. */ +function payloadWireSize(encoded: string, topic: string): number { + // `encoded` is already base64 (the on-wire representation). + return encoded.length + topic.length; +} + +/** Internal log entry: stores decoded Payload for user-facing APIs. */ +interface InternalLogEntry { + topic: string; + payload: Payload; +} + +/** Type guard for Payload — same logic as client.ts's isPayload. */ +function isPayload(value: unknown): value is Payload { + if (value === null || typeof value !== 'object') return false; + const v = value as { metadata?: unknown }; + return ( + 'metadata' in v && + v.metadata != null && + typeof v.metadata === 'object' && + !Array.isArray(v.metadata) && + Object.values(v.metadata).every((x) => x instanceof Uint8Array) + ); +} + /** Handle returned by initPubSub for interacting with pub/sub state. */ export interface PubSubHandle { - /** Publish an item from within workflow code. Deterministic — just appends. */ - publish(topic: string, data: Uint8Array): void; + /** + * Publish an item from within workflow code. Deterministic — just appends. + * `value` may be any value the default payload converter can handle, or + * a pre-built `Payload` for zero-copy. + */ + publish(topic: string, value: unknown): void; /** Unblock all waiting poll handlers and reject new polls for CAN. */ drain(): void; @@ -60,12 +114,11 @@ export interface PubSubHandle { * @returns A handle for publishing, draining, and getting state. */ export function initPubSub(priorState?: PubSubState): PubSubHandle { - // Decode wire items (base64) to in-memory items (Uint8Array) - const log: PubSubItem[] = priorState?.log - ? priorState.log.map((item, i) => ({ + // Decode wire items (base64 of proto Payload) to in-memory Payload objects. + const log: InternalLogEntry[] = priorState?.log + ? priorState.log.map((item) => ({ topic: item.topic, - data: decodeData(item.data), - offset: (priorState.base_offset ?? 0) + i, + payload: decodePayloadWire(item.data), })) : []; let baseOffset: number = priorState?.base_offset ?? 0; @@ -77,7 +130,7 @@ export function initPubSub(priorState?: PubSubState): PubSubHandle { : {}; let draining = false; - // Signal handler: receive publications from external clients with dedup + // Signal handler: receive publications from external clients with dedup. setHandler(pubsubPublishSignal, (input: PublishInput) => { if (input.publisher_id) { const lastSeq = publisherSequences[input.publisher_id] ?? 0; @@ -88,15 +141,11 @@ export function initPubSub(priorState?: PubSubState): PubSubHandle { publisherLastSeen[input.publisher_id] = Date.now() / 1000; // seconds } for (const entry of input.items) { - log.push({ - topic: entry.topic, - data: decodeData(entry.data), - offset: baseOffset + log.length, - }); + log.push({ topic: entry.topic, payload: decodePayloadWire(entry.data) }); } }); - // Update handler: long-poll subscription + // Update handler: long-poll subscription. setHandler( pubsubPollUpdate, async (input: PollInput): Promise => { @@ -122,13 +171,13 @@ export function initPubSub(priorState?: PubSubState): PubSubHandle { await condition(() => log.length > logOffset || draining); const allNew = log.slice(logOffset); - // Build [globalOffset, item] candidates, filtering by topic if requested. + // Build [globalOffset, entry] candidates, filtering by topic if requested. const topicSet = input.topics.length > 0 ? new Set(input.topics) : null; - const candidates: Array<[number, PubSubItem]> = []; + const candidates: Array<[number, InternalLogEntry]> = []; for (let i = 0; i < allNew.length; i++) { - const item = allNew[i]!; - if (topicSet !== null && !topicSet.has(item.topic)) continue; - candidates.push([baseOffset + logOffset + i, item]); + const entry = allNew[i]!; + if (topicSet !== null && !topicSet.has(entry.topic)) continue; + candidates.push([baseOffset + logOffset + i, entry]); } // Cap response size to ~1MB of estimated wire bytes. @@ -136,9 +185,9 @@ export function initPubSub(priorState?: PubSubState): PubSubHandle { let size = 0; let moreReady = false; let nextOffset = baseOffset + log.length; - for (const [off, item] of candidates) { - const encoded = encodeData(item.data); - const itemSize = encoded.length + item.topic.length; + for (const [off, entry] of candidates) { + const encoded = encodeBase64(encodePayloadProto(entry.payload)); + const itemSize = payloadWireSize(encoded, entry.topic); if (size + itemSize > MAX_POLL_RESPONSE_BYTES && wireItems.length > 0) { // Resume from this item on the next poll. nextOffset = off; @@ -146,7 +195,7 @@ export function initPubSub(priorState?: PubSubState): PubSubHandle { break; } size += itemSize; - wireItems.push({ topic: item.topic, data: encoded, offset: off }); + wireItems.push({ topic: entry.topic, data: encoded, offset: off }); } return { @@ -156,7 +205,7 @@ export function initPubSub(priorState?: PubSubState): PubSubHandle { }; }, { - // Validator: reject new polls when draining for continue-as-new + // Validator: reject new polls when draining for continue-as-new. validator(_input: PollInput): void { if (draining) { throw new Error('Workflow is draining for continue-as-new'); @@ -165,12 +214,28 @@ export function initPubSub(priorState?: PubSubState): PubSubHandle { } ); - // Query handler: current global offset + // Query handler: current global offset. setHandler(pubsubOffsetQuery, () => baseOffset + log.length); return { - publish(topic: string, data: Uint8Array): void { - log.push({ topic, data, offset: baseOffset + log.length }); + publish(topic: string, value: unknown): void { + let payload: Payload; + if (isPayload(value)) { + payload = value; + } else if (isUint8ArrayLike(value)) { + // Bypass defaultPayloadConverter for cross-realm Uint8Arrays: the + // BinaryPayloadConverter uses `instanceof Uint8Array`, which fails + // against a Uint8Array produced by Node's built-in `TextEncoder` + // (host realm) when evaluated in the workflow sandbox. Construct + // the equivalent binary/plain Payload directly. + payload = { + metadata: { encoding: BINARY_PLAIN_ENCODING }, + data: new Uint8Array(value), + }; + } else { + payload = defaultPayloadConverter.toPayload(value); + } + log.push({ topic, payload }); }, drain(): void { @@ -192,10 +257,13 @@ export function initPubSub(priorState?: PubSubState): PubSubHandle { } } return { - // Encode Uint8Array to base64 for serializable state. // Per-item offset is re-derivable from base_offset + index on reload, // so we leave it at 0 here. - log: log.map((item) => ({ topic: item.topic, data: encodeData(item.data), offset: 0 })), + log: log.map((entry) => ({ + topic: entry.topic, + data: encodePayloadWire(entry.payload), + offset: 0, + })), base_offset: baseOffset, publisher_sequences: activeSeqs, publisher_last_seen: activeSeen, diff --git a/packages/contrib-pubsub/src/types.ts b/packages/contrib-pubsub/src/types.ts index 6d1ffb915..a03eda338 100644 --- a/packages/contrib-pubsub/src/types.ts +++ b/packages/contrib-pubsub/src/types.ts @@ -1,35 +1,50 @@ /** * Shared data types for the pub/sub contrib module. * - * These types are serialized as JSON through Temporal's default data converter - * and must match the wire format across all SDK languages. + * User-facing `data` fields on {@link PubSubItem} are Temporal + * {@link Payload}s so that per-item metadata (encoding, messageType) + * round-trips to consumers. See README §"Cross-Language Protocol". * - * Wire types (PublishEntry, _WireItem, PollResult) use base64 strings for the - * data field. User-facing types (PubSubItem) use Uint8Array. + * The wire representation (`PublishEntry`, `_WireItem`) uses + * base64-encoded `Payload` protobuf bytes because the default JSON + * converter cannot serialize a `Payload` object embedded inside a + * plain (non-top-level) field. Using a base64 proto bytes string + * keeps the envelope JSON-serializable while preserving Payload + * metadata for codec and typed-decode paths. + * + * The Payload encoding here is the protobuf binary encoding of the + * `temporal.api.common.v1.Payload` message — a map + * metadata field (tag 1) and a bytes data field (tag 2). Cross-SDK + * compatibility requires matching exactly. */ +import type { Payload } from '@temporalio/common'; + /** * A single item in the pub/sub log (user-facing). * - * The `offset` field is populated by `subscribe()` from the item's position in - * the global log. + * `data` is a raw {@link Payload}; use a {@link PayloadConverter} + * (e.g. `defaultPayloadConverter.fromPayload(item.data)`) to + * decode it to a typed value. + * + * The `offset` field is populated by the poll handler from the item's + * position in the global log. */ export interface PubSubItem { topic: string; - /** Opaque byte payload. */ - data: Uint8Array; + data: Payload; offset: number; } /** A single entry to publish via signal (wire type). */ export interface PublishEntry { topic: string; - /** Base64-encoded byte payload. */ + /** Base64-encoded Payload protobuf bytes. */ data: string; } /** - * Wire representation of a PubSubItem (base64 data). + * Wire representation of a PubSubItem (base64 of serialized Payload). * * The `offset` field is populated by the poll handler from the item's * position in the global log. It is unused in the `getState()` snapshot @@ -37,7 +52,7 @@ export interface PublishEntry { */ export interface _WireItem { topic: string; - /** Base64-encoded byte payload. */ + /** Base64-encoded Payload protobuf bytes. */ data: string; offset: number; } @@ -73,17 +88,18 @@ export interface PubSubState { log: _WireItem[]; base_offset: number; publisher_sequences: Record; - /** Per-publisher last-seen timestamps for TTL pruning. */ + /** Per-publisher last-seen timestamps (seconds) for TTL pruning. */ publisher_last_seen: Record; } -// --- Base64 helpers (no Buffer dependency for workflow sandbox compat) --- +// --------------------------------------------------------------------------- +// Base64 helpers (no Buffer dependency for workflow sandbox compat) +// --------------------------------------------------------------------------- -// Standard base64 alphabet const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; -/** Encode bytes to base64 string for wire format. */ -export function encodeData(data: Uint8Array): string { +/** Encode bytes to standard base64 (padded). */ +export function encodeBase64(data: Uint8Array): string { let result = ''; for (let i = 0; i < data.length; i += 3) { const b0 = data[i]!; @@ -97,8 +113,8 @@ export function encodeData(data: Uint8Array): string { return result; } -/** Decode base64 string from wire format to bytes. */ -export function decodeData(data: string): Uint8Array { +/** Decode standard base64 to bytes. */ +export function decodeBase64(data: string): Uint8Array { const clean = data.replace(/=+$/, ''); const len = (clean.length * 3) >> 2; const out = new Uint8Array(len); @@ -115,20 +131,141 @@ export function decodeData(data: string): Uint8Array { return out; } -/** - * Encode a UTF-8 string to base64 wire format. - * - * Convenience wrapper: encodes the string as UTF-8 bytes, then base64. - */ -export function toWireBytes(s: string): string { - return encodeData(new TextEncoder().encode(s)); +// --------------------------------------------------------------------------- +// Protobuf codec for temporal.api.common.v1.Payload +// --------------------------------------------------------------------------- +// +// Payload schema: +// message Payload { +// map metadata = 1; +// bytes data = 2; +// } +// +// Map entries are encoded as embedded messages: +// message MapEntry { string key = 1; bytes value = 2; } +// +// Wire format is hand-rolled here (rather than reusing `@temporalio/proto`'s +// generated class) to avoid pulling the protobufjs runtime into the workflow +// sandbox. The schema is a fixed public API — the manual encoder cannot +// silently go out of sync with server-side expectations. + +function writeVarint(buf: number[], n: number): void { + while (n >= 0x80) { + buf.push((n & 0x7f) | 0x80); + n = Math.floor(n / 128); + } + buf.push(n & 0x7f); } -/** - * Decode a base64 wire format string back to a UTF-8 string. - * - * Convenience wrapper: decodes base64 to bytes, then interprets as UTF-8. - */ -export function fromWireBytes(data: string): string { - return new TextDecoder().decode(decodeData(data)); +function readVarint(bytes: Uint8Array, pos: { i: number }): number { + let result = 0; + let shift = 0; + while (true) { + if (pos.i >= bytes.length) { + throw new Error('unexpected end of varint'); + } + const b = bytes[pos.i++]!; + result += (b & 0x7f) * Math.pow(2, shift); + if ((b & 0x80) === 0) break; + shift += 7; + if (shift > 35) throw new Error('varint too large'); + } + return result; +} + +function writeTagLenBytes(buf: number[], tag: number, bytes: Uint8Array): void { + buf.push(tag); + writeVarint(buf, bytes.length); + for (let i = 0; i < bytes.length; i++) buf.push(bytes[i]!); +} + +function utf8Encode(s: string): Uint8Array { + return new TextEncoder().encode(s); +} + +function utf8Decode(b: Uint8Array): string { + return new TextDecoder().decode(b); +} + +/** Encode a Payload to its protobuf binary representation. */ +export function encodePayloadProto(payload: Payload): Uint8Array { + const buf: number[] = []; + const metadata = payload.metadata ?? {}; + for (const key of Object.keys(metadata)) { + const value = metadata[key]; + if (value == null) continue; + // Inner: key (tag 0x0A, wire-type 2) + value (tag 0x12, wire-type 2) + const entry: number[] = []; + writeTagLenBytes(entry, 0x0a, utf8Encode(key)); + writeTagLenBytes(entry, 0x12, value); + // Outer: metadata field 1, wire-type 2 + writeTagLenBytes(buf, 0x0a, new Uint8Array(entry)); + } + const data = payload.data; + if (data && data.length > 0) { + writeTagLenBytes(buf, 0x12, data); + } + return new Uint8Array(buf); +} + +/** Decode protobuf binary bytes to a Payload. */ +export function decodePayloadProto(bytes: Uint8Array): Payload { + const pos = { i: 0 }; + const metadata: Record = {}; + let data: Uint8Array = new Uint8Array(0); + while (pos.i < bytes.length) { + const tag = bytes[pos.i++]!; + const fieldNumber = tag >>> 3; + const wireType = tag & 0x07; + if (wireType === 2) { + const len = readVarint(bytes, pos); + const chunk = bytes.subarray(pos.i, pos.i + len); + pos.i += len; + if (fieldNumber === 1) { + // Parse inner map entry message + const p2 = { i: 0 }; + let key = ''; + let value = new Uint8Array(0); + while (p2.i < chunk.length) { + const itag = chunk[p2.i++]!; + const ifn = itag >>> 3; + const iwt = itag & 0x07; + if (iwt !== 2) { + // skip — only length-delim fields are expected here + const skipLen = readVarint(chunk, p2); + p2.i += skipLen; + continue; + } + const ilen = readVarint(chunk, p2); + const ival = chunk.subarray(p2.i, p2.i + ilen); + p2.i += ilen; + if (ifn === 1) key = utf8Decode(ival); + else if (ifn === 2) value = new Uint8Array(ival); + } + metadata[key] = value; + } else if (fieldNumber === 2) { + data = new Uint8Array(chunk); + } + // Other fields (e.g. externalPayloads = 3) are ignored. + } else if (wireType === 0) { + readVarint(bytes, pos); + } else if (wireType === 1) { + pos.i += 8; + } else if (wireType === 5) { + pos.i += 4; + } else { + throw new Error(`unsupported wire type ${wireType}`); + } + } + return { metadata, data }; +} + +/** Convenience: encode a Payload to the base64 wire format used by pubsub. */ +export function encodePayloadWire(payload: Payload): string { + return encodeBase64(encodePayloadProto(payload)); +} + +/** Convenience: decode the base64 wire format to a Payload. */ +export function decodePayloadWire(wire: string): Payload { + return decodePayloadProto(decodeBase64(wire)); } diff --git a/packages/test/src/test-contrib-pubsub.ts b/packages/test/src/test-contrib-pubsub.ts index 1e3777ae1..9325ed5ea 100644 --- a/packages/test/src/test-contrib-pubsub.ts +++ b/packages/test/src/test-contrib-pubsub.ts @@ -5,7 +5,7 @@ */ import { randomUUID } from 'crypto'; -import { ApplicationFailure } from '@temporalio/common'; +import { ApplicationFailure, defaultPayloadConverter, type Payload } from '@temporalio/common'; import { WorkflowHandle, WorkflowUpdateFailedError } from '@temporalio/client'; import { FlushTimeoutError, @@ -16,7 +16,7 @@ import { type PubSubState, type PublishEntry, type PublishInput, - encodeData, + encodePayloadWire, pubsubOffsetQuery, pubsubPublishSignal, pubsubPollUpdate, @@ -46,8 +46,32 @@ const test = makeTestFunction({ const encoder = new TextEncoder(); const decoder = new TextDecoder(); +/** + * Build a `PublishEntry` for a literal string. + * + * Mirrors what `PubSubClient` produces on the encode path: the default + * payload converter wraps the bytes into a `Payload`, which is then + * proto-serialized and base64-encoded for the wire. + */ function entry(topic: string, data: string): PublishEntry { - return { topic, data: encodeData(encoder.encode(data)) }; + const payload = defaultPayloadConverter.toPayload(encoder.encode(data)); + return { topic, data: encodePayloadWire(payload) }; +} + +/** Extract the raw bytes from a `Payload` produced by the default converter. */ +function payloadBytes(payload: Payload): Uint8Array { + // defaultPayloadConverter maps Uint8Array to encoding=binary/plain, so + // `data` is already the raw bytes. For string/JSON payloads we fall back + // to the converter. + const encoding = payload.metadata?.['encoding']; + if (encoding && decoder.decode(encoding) === 'binary/plain') { + return payload.data ?? new Uint8Array(0); + } + return defaultPayloadConverter.fromPayload(payload); +} + +function payloadString(payload: Payload): string { + return decoder.decode(payloadBytes(payload)); } async function collectItems( @@ -89,10 +113,10 @@ test('activity_publish_and_subscribe — activity publishes, client subscribes', t.is(items.length, count + 1); for (let i = 0; i < count; i++) { t.is(items[i]!.topic, 'events'); - t.is(decoder.decode(items[i]!.data), `item-${i}`); + t.is(payloadString(items[i]!.data), `item-${i}`); } t.is(items[count]!.topic, 'status'); - t.is(decoder.decode(items[count]!.data), 'activity_done'); + t.is(payloadString(items[count]!.data), 'activity_done'); await handle.signal('close'); }); }); @@ -131,16 +155,16 @@ test('subscribe_from_offset_and_per_item_offsets — non-zero starts and global t.is(allItems.length, count); for (let i = 0; i < count; i++) { t.is(allItems[i]!.offset, i); - t.is(decoder.decode(allItems[i]!.data), `item-${i}`); + t.is(payloadString(allItems[i]!.data), `item-${i}`); } // From offset 3 — items 3, 4 with offsets 3, 4. const laterItems = await collectItems(handle, undefined, 3, 2); t.is(laterItems.length, 2); t.is(laterItems[0]!.offset, 3); - t.is(decoder.decode(laterItems[0]!.data), 'item-3'); + t.is(payloadString(laterItems[0]!.data), 'item-3'); t.is(laterItems[1]!.offset, 4); - t.is(decoder.decode(laterItems[1]!.data), 'item-4'); + t.is(payloadString(laterItems[1]!.data), 'item-4'); await handle.signal('close'); }); @@ -242,7 +266,7 @@ test('priority_flush — priority wakes flusher despite 60s interval', async (t) // pass via the dispose-driven flush at activity exit. const items = await collectItems(handle, undefined, 0, 3, 5_000); t.is(items.length, 3); - t.is(decoder.decode(items[2]!.data), 'priority'); + t.is(payloadString(items[2]!.data), 'priority'); await handle.signal('close'); }); }); @@ -256,7 +280,7 @@ test('dispose_flushes_on_exit — await using drains buffer', async (t) => { const items = await collectItems(handle, undefined, 0, count, 15_000); t.is(items.length, count); for (let i = 0; i < count; i++) { - t.is(decoder.decode(items[i]!.data), `item-${i}`); + t.is(payloadString(items[i]!.data), `item-${i}`); } await handle.signal('close'); }); @@ -271,7 +295,7 @@ test('max_batch_size — triggers flush without waiting for timer', async (t) => const items = await collectItems(handle, undefined, 0, count + 1, 15_000); t.is(items.length, count + 1); for (let i = 0; i < count; i++) { - t.is(decoder.decode(items[i]!.data), `item-${i}`); + t.is(payloadString(items[i]!.data), `item-${i}`); } await handle.signal('close'); }); @@ -302,8 +326,8 @@ test('dedup_rejects_duplicate_signal — same publisher+sequence is dropped', as // collectItems' update call acts as a barrier — prior signals processed. const items = await collectItems(handle, undefined, 0, 2); t.is(items.length, 2); - t.is(decoder.decode(items[0]!.data), 'item-0'); - t.is(decoder.decode(items[1]!.data), 'item-1'); + t.is(payloadString(items[0]!.data), 'item-0'); + t.is(payloadString(items[1]!.data), 'item-1'); const offset = await handle.query(pubsubOffsetQuery); t.is(offset, 2); @@ -338,8 +362,8 @@ test('truncate_pubsub — truncate discards prefix and adjusts base', async (t) const after = await collectItems(handle, undefined, 3, 2); t.is(after.length, 2); - t.is(decoder.decode(after[0]!.data), 'item-3'); - t.is(decoder.decode(after[1]!.data), 'item-4'); + t.is(payloadString(after[0]!.data), 'item-3'); + t.is(payloadString(after[1]!.data), 'item-4'); await handle.signal('close'); }); @@ -431,7 +455,7 @@ test('continue_as_new_typed — log, offsets, AND dedup state survive CAN', asyn // Log contents and offsets preserved across CAN. const afterItems = await collectItems(newHandle, undefined, 0, 3); t.deepEqual( - afterItems.map((i) => decoder.decode(i.data)), + afterItems.map((i) => payloadString(i.data)), ['item-0', 'item-1', 'item-2'] ); t.deepEqual( @@ -464,7 +488,7 @@ test('continue_as_new_typed — log, offsets, AND dedup state survive CAN', asyn const finalItems = await collectItems(newHandle, undefined, 0, 4); t.deepEqual( - finalItems.map((i) => decoder.decode(i.data)), + finalItems.map((i) => payloadString(i.data)), ['item-0', 'item-1', 'item-2', 'item-3'] ); t.is(finalItems[3]!.offset, 3); @@ -481,9 +505,10 @@ test('poll_more_ready_when_response_exceeds_size_limit — 1MB cap', async (t) = const handle = await startWorkflow(basicPubSubWorkflow, { args: [] }); const chunk = new Uint8Array(200_000).fill('x'.charCodeAt(0)); + const chunkPayload = defaultPayloadConverter.toPayload(chunk); for (let i = 0; i < 8; i++) { await handle.signal<[PublishInput]>(pubsubPublishSignal, { - items: [{ topic: 'big', data: encodeData(chunk) }], + items: [{ topic: 'big', data: encodePayloadWire(chunkPayload) }], publisher_id: '', sequence: 0, }); @@ -523,9 +548,10 @@ test('subscribe_iterates_through_more_ready — caller sees all items', async (t await worker.runUntil(async () => { const handle = await startWorkflow(basicPubSubWorkflow, { args: [] }); const chunk = new Uint8Array(200_000).fill('x'.charCodeAt(0)); + const chunkPayload = defaultPayloadConverter.toPayload(chunk); for (let i = 0; i < 8; i++) { await handle.signal<[PublishInput]>(pubsubPublishSignal, { - items: [{ topic: 'big', data: encodeData(chunk) }], + items: [{ topic: 'big', data: encodePayloadWire(chunkPayload) }], publisher_id: '', sequence: 0, }); @@ -533,7 +559,7 @@ test('subscribe_iterates_through_more_ready — caller sees all items', async (t const items = await collectItems(handle, undefined, 0, 8, 15_000); t.is(items.length, 8); for (const item of items) { - t.is(item.data.length, chunk.length); + t.is(payloadBytes(item.data).length, chunk.length); } await handle.signal('close'); }); @@ -579,7 +605,7 @@ test('flush_retry_preserves_items_after_failures — behavioral retry coverage', const items = await collectItems(handle, undefined, 0, 3); t.deepEqual( - items.map((i) => decoder.decode(i.data)), + items.map((i) => payloadString(i.data)), ['item-0', 'item-1', 'item-2'] ); @@ -598,3 +624,4 @@ test('flush_raises_after_max_retry_duration — timeout surfaces, client resumes await new Promise((r) => setTimeout(r, 1500)); await t.throwsAsync(client.stop(), { instanceOf: FlushTimeoutError }); }); + From f4b5e3c2450c72cd201180a81de754f9e199b85f Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Thu, 23 Apr 2026 19:03:40 -0700 Subject: [PATCH 14/75] pubsub: add cross-language wire-format interop tests Pure encode/decode unit tests (no Temporal server) that pin the exact byte layout produced by encodePayloadProto / decodePayloadProto, so the TypeScript module stays byte-compatible with the Python SDK's use of temporal.api.common.v1.Payload.SerializeToString(). Covers: - Default-converter round trip for JSON string payloads. - Binary payload round trip. - Decoding a protobuf byte sequence shaped exactly like Python's output. - Empty-payload corner case (Payload().SerializeToString() -> b""). - Multi-byte varint length prefix (payloads >= 128 bytes). - Multiple metadata entries. - Base64 helpers against the canonical RFC 4648 examples. - Forward-compat: decoder skips unknown proto fields. - Canonical byte sequence for a fixed input. - Hermetic round trip across several Payload shapes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../test/src/test-contrib-pubsub-interop.ts | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 packages/test/src/test-contrib-pubsub-interop.ts diff --git a/packages/test/src/test-contrib-pubsub-interop.ts b/packages/test/src/test-contrib-pubsub-interop.ts new file mode 100644 index 000000000..3aca64097 --- /dev/null +++ b/packages/test/src/test-contrib-pubsub-interop.ts @@ -0,0 +1,211 @@ +/** + * Wire-format interop tests for @temporalio/contrib-pubsub. + * + * These tests pin the exact byte layout produced by the TypeScript + * implementation so it stays compatible with the Python SDK, which + * uses `temporalio.api.common.v1.Payload` serialized via protobuf. + * + * Unlike `test-contrib-pubsub.ts`, these don't need a Temporal server — + * they are pure encode/decode unit tests. + */ + +import anyTest, { TestFn } from 'ava'; +import { defaultPayloadConverter, type Payload } from '@temporalio/common'; +import { + decodeBase64, + decodePayloadProto, + decodePayloadWire, + encodeBase64, + encodePayloadProto, + encodePayloadWire, +} from '@temporalio/contrib-pubsub'; + +const test = anyTest as TestFn; +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +test('payload_proto_round_trips_for_default_json_string', (t) => { + // The default JSON converter wraps "hello" as: + // metadata = {encoding: b"json/plain"}, data = b'"hello"' + // + // Proto encoding layout we're pinning: + // field 1 (metadata map entry): "encoding" -> b"json/plain" + // field 2 (data): b'"hello"' + const payload = defaultPayloadConverter.toPayload('hello'); + const wire = encodePayloadWire(payload); + + const expected = encodeBase64( + new Uint8Array([ + 0x0a, + 0x16, // metadata entry: field 1, wire-type 2, len 22 + 0x0a, + 0x08, // inner key: field 1, len 8 + ...encoder.encode('encoding'), + 0x12, + 0x0a, // inner value: field 2, len 10 + ...encoder.encode('json/plain'), + 0x12, + 0x07, // data: field 2, len 7 + ...encoder.encode('"hello"'), + ]) + ); + t.is(wire, expected); + + const decoded = decodePayloadWire(wire); + t.is(decoder.decode(decoded.metadata!['encoding']!), 'json/plain'); + t.deepEqual(decoded.data, encoder.encode('"hello"')); +}); + +test('binary_payload_round_trips_through_wire', (t) => { + const bytes = new Uint8Array([0x00, 0x01, 0x7f, 0x80, 0xff]); + const payload = defaultPayloadConverter.toPayload(bytes); + const wire = encodePayloadWire(payload); + const decoded = decodePayloadWire(wire); + + t.is(decoder.decode(decoded.metadata!['encoding']!), 'binary/plain'); + t.deepEqual(decoded.data, bytes); +}); + +test('decode_accepts_python_generated_bytes', (t) => { + // Construct a Payload exactly as Python's protobuf runtime would emit + // (metadata = {encoding: "binary/plain"}, data = b"ping") and verify our + // decoder round-trips it. + const data = encoder.encode('ping'); + const pythonLikeProto = new Uint8Array([ + 0x0a, + 0x18, // field 1, len 24 + 0x0a, + 0x08, // key tag, len 8 + ...encoder.encode('encoding'), + 0x12, + 0x0c, // value tag, len 12 + ...encoder.encode('binary/plain'), + 0x12, + 0x04, // data tag, len 4 + ...data, + ]); + const wire = encodeBase64(pythonLikeProto); + const decoded = decodePayloadWire(wire); + t.is(decoder.decode(decoded.metadata!['encoding']!), 'binary/plain'); + t.deepEqual(decoded.data, data); +}); + +test('empty_payload_encodes_to_empty_bytes', (t) => { + // Matches Python: Payload().SerializeToString() returns b"". + const wire = encodePayloadWire({ metadata: {}, data: new Uint8Array(0) }); + t.is(wire, ''); + + const decoded = decodePayloadWire(''); + t.deepEqual(decoded.metadata, {}); + t.is(decoded.data?.length ?? 0, 0); +}); + +test('varint_length_handles_multi_byte_prefix', (t) => { + // Sizes >= 128 require a multi-byte varint length prefix. Verify both + // encode and decode handle the boundary correctly (Python protobuf + // uses the same varint encoding). + const big = new Uint8Array(300).fill(0x42); // 300 bytes, varint = [0xac, 0x02] + const payload = defaultPayloadConverter.toPayload(big); + const wire = encodePayloadWire(payload); + const decoded = decodePayloadWire(wire); + t.deepEqual(decoded.data, big); +}); + +test('multiple_metadata_entries_round_trip', (t) => { + const payload: Payload = { + metadata: { + encoding: encoder.encode('json/plain'), + messageType: encoder.encode('MyType'), + }, + data: encoder.encode('{"x":1}'), + }; + const wire = encodePayloadWire(payload); + const decoded = decodePayloadWire(wire); + t.is(decoder.decode(decoded.metadata!['encoding']!), 'json/plain'); + t.is(decoder.decode(decoded.metadata!['messageType']!), 'MyType'); + t.deepEqual(decoded.data, encoder.encode('{"x":1}')); +}); + +test('base64_helpers_match_standard_encoding', (t) => { + // Standard base64 (with padding) must match what Python's base64.b64encode + // produces — RFC 4648 §4. Spot-check the canonical rfc4648 examples. + t.is(encodeBase64(encoder.encode('')), ''); + t.is(encodeBase64(encoder.encode('f')), 'Zg=='); + t.is(encodeBase64(encoder.encode('fo')), 'Zm8='); + t.is(encodeBase64(encoder.encode('foo')), 'Zm9v'); + t.is(encodeBase64(encoder.encode('foob')), 'Zm9vYg=='); + t.is(encodeBase64(encoder.encode('foobar')), 'Zm9vYmFy'); + + t.deepEqual(decodeBase64(''), new Uint8Array(0)); + t.deepEqual(decodeBase64('Zm9vYmFy'), encoder.encode('foobar')); + t.deepEqual(decodeBase64('Zg=='), encoder.encode('f')); +}); + +test('unknown_fields_are_skipped_on_decode', (t) => { + // Forward compatibility: if Payload proto grows a new field (e.g. + // externalPayloads = 3), our decoder skips it without aborting. The + // Python generated class behaves the same way. + const data = encoder.encode('hi'); + const bytesWithUnknownField = new Uint8Array([ + 0x12, + 0x02, + ...data, + // field 3 (unknown), wire-type 2, len 3 — skipped + 0x1a, + 0x03, + 0xaa, + 0xbb, + 0xcc, + ]); + const decoded = decodePayloadProto(bytesWithUnknownField); + t.deepEqual(decoded.data, data); +}); + +test('encode_produces_canonical_bytes', (t) => { + // Pin a known byte sequence. If encodePayloadProto ever produces + // different bytes for this input, Python consumers break — this test + // catches that before it ships. + const payload: Payload = { + metadata: { encoding: encoder.encode('binary/plain') }, + data: encoder.encode('abc'), + }; + const bytes = encodePayloadProto(payload); + const expected = new Uint8Array([ + 0x0a, + 0x18, // field 1, len 24 + 0x0a, + 0x08, // key tag, len 8 + ...encoder.encode('encoding'), + 0x12, + 0x0c, // value tag, len 12 + ...encoder.encode('binary/plain'), + 0x12, + 0x03, // data tag, len 3 + ...encoder.encode('abc'), + ]); + t.deepEqual(bytes, expected); +}); + +test('round_trip_preserves_all_fields', (t) => { + // Hermetic round-trip: every field survives encode -> decode. + const cases: Payload[] = [ + { metadata: { encoding: encoder.encode('json/plain') }, data: encoder.encode('true') }, + { metadata: { encoding: encoder.encode('binary/null') }, data: new Uint8Array(0) }, + { + metadata: { + encoding: encoder.encode('json/protobuf'), + messageType: encoder.encode('my.pkg.Thing'), + }, + data: new Uint8Array([1, 2, 3, 4, 5, 250, 251, 252, 253, 254, 255]), + }, + ]; + for (const input of cases) { + const wire = encodePayloadWire(input); + const out = decodePayloadWire(wire); + t.is(Object.keys(out.metadata ?? {}).length, Object.keys(input.metadata ?? {}).length); + for (const [k, v] of Object.entries(input.metadata ?? {})) { + t.deepEqual(out.metadata?.[k], v); + } + t.deepEqual(out.data ?? new Uint8Array(0), input.data ?? new Uint8Array(0)); + } +}); From 10bb562beef40cb433c3c862b07dbd122b2a2b2a Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Thu, 23 Apr 2026 22:13:41 -0700 Subject: [PATCH 15/75] pubsub: rename publish priority to forceFlush, split create/fromActivity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors sdk-python commit 56789ed4 and 68c719ea: - PubSubClient.publish() parameter renamed from `priority` to `forceFlush`. The kwarg never implied ordering — it just forces an immediate flush of the buffer — so the new name is accurate. - PubSubClient.create() now requires both `client` and `workflowId`. The silent auto-detect path ("omit args and pull from activity context") is gone because it produced a confusing runtime error when called from outside an activity. - New PubSubClient.fromActivity(options?) classmethod pulls the client and parent workflow id from Context.current() — the replacement for the auto-detect path in create(). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/contrib-pubsub/src/client.ts | 53 ++++++++++++++------------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/packages/contrib-pubsub/src/client.ts b/packages/contrib-pubsub/src/client.ts index 104dc76f3..4bb456022 100644 --- a/packages/contrib-pubsub/src/client.ts +++ b/packages/contrib-pubsub/src/client.ts @@ -146,40 +146,42 @@ export class PubSubClient { } /** - * Create a PubSubClient from a Temporal client and workflow ID. + * Create a PubSubClient from an explicit Temporal client and workflow ID. * - * When called inside an activity, `client` and `workflowId` can be omitted — - * they are inferred from `Context.current()`. + * Use this when the caller has an explicit `Client` and `workflowId` in + * hand (starters, BFFs, other workflows' activities). For code running + * inside an activity that targets its own parent workflow, use + * {@link PubSubClient.fromActivity}. * - * This is the preferred constructor; it enables continue-as-new following in - * `subscribe()` and uses the configured `Client`'s payload converter for - * per-item `Payload` construction. + * A client created through this method follows continue-as-new chains in + * `subscribe()` and uses the client's payload converter for per-item + * `Payload` construction. */ - static create(client?: Client, workflowId?: string, options?: PubSubClientOptions): PubSubClient { - let resolvedClient: Client; - let resolvedId: string; - if (client === undefined || workflowId === undefined) { - // Context.current() throws if not inside an activity — let that propagate - // with its native error so the caller sees the right message. - const ctx = ActivityContext.current(); - resolvedClient = client ?? ctx.client; - resolvedId = workflowId ?? ctx.info.workflowExecution.workflowId; - } else { - resolvedClient = client; - resolvedId = workflowId; - } - const handle = resolvedClient.workflow.getHandle(resolvedId); + static create(client: Client, workflowId: string, options?: PubSubClientOptions): PubSubClient { + const handle = client.workflow.getHandle(workflowId); const instance = new PubSubClient(handle, options); - instance.client = resolvedClient; + instance.client = client; // Prefer the Client's configured converter so custom converters flow // through; fall back to the default if unset. - const clientConverter = resolvedClient.options?.loadedDataConverter?.payloadConverter; + const clientConverter = client.options?.loadedDataConverter?.payloadConverter; if (clientConverter) { (instance as unknown as { payloadConverter: PayloadConverter }).payloadConverter = clientConverter; } return instance; } + /** + * Create a PubSubClient targeting the current activity's parent workflow. + * + * Must be called from within an activity. The Temporal client and + * parent workflow id are taken from the activity context. + */ + static fromActivity(options?: PubSubClientOptions): PubSubClient { + const ctx = ActivityContext.current(); + const workflowId = ctx.info.workflowExecution.workflowId; + return PubSubClient.create(ctx.client, workflowId, options); + } + /** Start the background flusher. Call before publishing. */ start(): void { if (this.flusherTask) return; @@ -230,11 +232,12 @@ export class PubSubClient { * @param value - Any value the payload converter can handle, or a pre-built * `Payload` for zero-copy. The codec chain runs once on the signal * envelope, not per item. - * @param priority - If true, wake the flusher to send immediately. + * @param forceFlush - If true, wake the flusher to send immediately + * (fire-and-forget — does not block the caller). */ - publish(topic: string, value: unknown, priority = false): void { + publish(topic: string, value: unknown, forceFlush = false): void { this.buffer.push({ topic, value }); - if (priority || (this.maxBatchSize !== undefined && this.buffer.length >= this.maxBatchSize)) { + if (forceFlush || (this.maxBatchSize !== undefined && this.buffer.length >= this.maxBatchSize)) { this.flushEvent.set(); } } From e559a4eb81cbe5eb75122cdef7911c7ec136a6d9 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Thu, 23 Apr 2026 22:14:04 -0700 Subject: [PATCH 16/75] pubsub: replace initPubSub function with PubSub class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors sdk-python commit 72d296ea (Replace PubSubMixin with PubSub dynamic handler registration). The workflow-side API changes from: const pubsub = initPubSub(priorState); to: const pubsub = new PubSub(priorState); The constructor registers the `__pubsub_publish` signal, `__pubsub_poll` update (with validator), and `__pubsub_offset` query handlers via `setHandler`. The wire contract (handler names, payload shapes, offset semantics) is unchanged. Renames: packages/contrib-pubsub/src/mixin.ts -> broker.ts initPubSub(priorState) -> new PubSub(priorState) PubSubHandle interface (removed) sdk-python additionally guards against a second `PubSub(...)` call on the same workflow by checking `workflow.get_signal_handler(...)`. The TypeScript workflow runtime does not expose that inspection API, and `reuseV8Context` shares module-level state across workflow executions in the same worker thread — so a naive module-level flag would either fire spuriously or miss real duplicates. The guard is omitted in TS with an in-code note; constructing `PubSub` twice in the same workflow silently replaces the handlers. Test callers (workflows, activities, test-contrib-pubsub) and the README are updated. `priorityWorkflow` / `publishWithPriority` are renamed to `forceFlushWorkflow` / `publishWithForceFlush` to match the client-side rename from the previous commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/contrib-pubsub/README.md | 41 +-- packages/contrib-pubsub/src/broker.ts | 291 ++++++++++++++++++ packages/contrib-pubsub/src/index.ts | 3 +- packages/contrib-pubsub/src/mixin.ts | 285 ----------------- .../test/src/activities/contrib-pubsub.ts | 22 +- packages/test/src/test-contrib-pubsub.ts | 16 +- packages/test/src/workflows/contrib-pubsub.ts | 30 +- 7 files changed, 348 insertions(+), 340 deletions(-) create mode 100644 packages/contrib-pubsub/src/broker.ts delete mode 100644 packages/contrib-pubsub/src/mixin.ts diff --git a/packages/contrib-pubsub/README.md b/packages/contrib-pubsub/README.md index 4345c8e31..28a94a22e 100644 --- a/packages/contrib-pubsub/README.md +++ b/packages/contrib-pubsub/README.md @@ -24,14 +24,14 @@ codec behavior is symmetric between workflow-side and client-side publishing. ### Workflow side -Call `initPubSub()` at the start of your workflow function and use the returned -handle to publish: +Construct `new PubSub()` at the start of your workflow function and use the +returned object to publish: ```typescript -import { initPubSub } from '@temporalio/contrib-pubsub'; +import { PubSub } from '@temporalio/contrib-pubsub'; export async function myWorkflow(input: MyInput): Promise { - const pubsub = initPubSub(); + const pubsub = new PubSub(); pubsub.publish('status', { state: 'started' }); await doWork(); @@ -39,23 +39,23 @@ export async function myWorkflow(input: MyInput): Promise { } ``` -`initPubSub()` registers the `__pubsub_publish` signal, `__pubsub_poll` update, -and `__pubsub_offset` query handlers on your workflow. Any value the default -payload converter can serialize (JSON, `Uint8Array`, or a pre-built `Payload`) -can be passed to `publish`. +The `PubSub` constructor registers the `__pubsub_publish` signal, +`__pubsub_poll` update, and `__pubsub_offset` query handlers on your workflow. +Any value the default payload converter can serialize (JSON, `Uint8Array`, or +a pre-built `Payload`) can be passed to `publish`. ### Activity side (publishing) -Use `PubSubClient.create()` with `await using` for batched publishing. When -called from within an activity, the client and workflow ID are inferred -automatically from the activity context: +Use `PubSubClient.fromActivity()` with `await using` for batched publishing +from inside an activity. The client and workflow ID are pulled from the +activity context: ```typescript import { Context } from '@temporalio/activity'; import { PubSubClient } from '@temporalio/contrib-pubsub'; export async function streamEvents(): Promise { - await using client = PubSubClient.create(undefined, undefined, { batchInterval: 2.0 }); + await using client = PubSubClient.fromActivity({ batchInterval: 2.0 }); client.start(); for await (const chunk of generateChunks()) { @@ -66,7 +66,9 @@ export async function streamEvents(): Promise { } ``` -If `await using` is not available, call `start()` and `await stop()` explicitly: +Outside an activity (e.g., a starter or BFF), use `PubSubClient.create()` +with an explicit client and workflow id. If `await using` is not available, +call `start()` and `await stop()` explicitly: ```typescript const client = PubSubClient.create(temporalClient, workflowId); @@ -78,7 +80,7 @@ try { } ``` -Use `priority = true` to trigger an immediate flush for latency-sensitive +Use `forceFlush = true` to trigger an immediate flush for latency-sensitive events: ```typescript @@ -119,7 +121,7 @@ boundaries: ```typescript import { continueAsNew, workflowInfo } from '@temporalio/workflow'; -import { initPubSub, type PubSubState } from '@temporalio/contrib-pubsub'; +import { PubSub, type PubSubState } from '@temporalio/contrib-pubsub'; interface WorkflowInput { itemsProcessed: number; @@ -128,7 +130,7 @@ interface WorkflowInput { export async function myWorkflow(input: WorkflowInput): Promise { let itemsProcessed = input.itemsProcessed; - const pubsub = initPubSub(input.pubsubState); + const pubsub = new PubSub(input.pubsubState); // ... do work, updating itemsProcessed ... @@ -149,7 +151,7 @@ chains. ## API Reference -### `initPubSub(priorState?) -> PubSubHandle` +### `new PubSub(priorState?)` | Method | Description | |---|---| @@ -170,12 +172,13 @@ Handlers registered automatically: | Method | Description | |---|---| -| `PubSubClient.create(client?, workflowId?, options?)` | Factory. Auto-detects activity context when `client` or `workflowId` is omitted. Enables CAN following in `subscribe()`; uses the `Client`'s configured payload converter. | +| `PubSubClient.create(client, workflowId, options?)` | Factory for use outside an activity (starters, BFFs). Enables CAN following in `subscribe()`; uses the `Client`'s configured payload converter. | +| `PubSubClient.fromActivity(options?)` | Factory for use from within an activity — pulls the client and parent workflow id from the activity context. | | `new PubSubClient(handle, options?)` | From a handle (no CAN following). | | `start()` | Start the background flusher. | | `stop()` | Stop the flusher and flush remaining items. | | `[Symbol.asyncDispose]()` | Supports `await using client = PubSubClient.create(...)`. | -| `publish(topic, value, priority = false)` | Buffer a message. `value` may be any converter-compatible object or a pre-built `Payload`. | +| `publish(topic, value, forceFlush = false)` | Buffer a message. `value` may be any converter-compatible object or a pre-built `Payload`. `forceFlush` wakes the flusher to send immediately. | | `subscribe(topics?, fromOffset = 0, { pollCooldown = 0.1 })` | Async generator yielding `PubSubItem` with `data: Payload`. Always follows CAN chains when created via `create()`. Recovers automatically from `TruncatedOffset` by restarting from the current base offset. | | `getOffset()` | Query current global offset. | diff --git a/packages/contrib-pubsub/src/broker.ts b/packages/contrib-pubsub/src/broker.ts new file mode 100644 index 000000000..dd18635bd --- /dev/null +++ b/packages/contrib-pubsub/src/broker.ts @@ -0,0 +1,291 @@ +/** + * Workflow-side pub/sub broker. + * + * Instantiate `PubSub` once at the start of your workflow function; the + * constructor registers the pub/sub signal, update, and query handlers on + * the current workflow via `setHandler`. + * + * For workflows that support continue-as-new, include a + * `PubSubState | undefined` field on the workflow input and pass it as + * `priorState` — it is `undefined` on fresh starts and carries + * accumulated state on continue-as-new. + * + * Both workflow-side `PubSub.publish` and client-side + * `PubSubClient.publish` use the default payload converter for per-item + * `Payload` construction. The codec chain (encryption, PII-redaction, + * compression) is NOT applied per item on either side — it runs once at + * the envelope level when Temporal's SDK encodes the signal/update that + * carries the batch. + */ + +import { condition, defineSignal, defineUpdate, defineQuery, setHandler, defaultPayloadConverter } from '@temporalio/workflow'; +import { ApplicationFailure, type Payload } from '@temporalio/common'; +import { + decodePayloadWire, + encodePayloadProto, + encodePayloadWire, + encodeBase64, + type PollInput, + type PollResult, + type PubSubState, + type PublishInput, + type _WireItem, +} from './types'; + +const BINARY_PLAIN_ENCODING = new TextEncoder().encode('binary/plain'); + +/** + * Cross-realm-safe Uint8Array check. + * + * The workflow sandbox gets its `TextEncoder` from `node:util`, which + * returns a `Uint8Array` tagged to the host realm — so `value instanceof + * Uint8Array` is false against the sandbox's own `Uint8Array` global. + * `Object.prototype.toString` crosses realm boundaries reliably. + */ +function isUint8ArrayLike(value: unknown): value is ArrayLike { + return ( + value != null && + typeof value === 'object' && + Object.prototype.toString.call(value) === '[object Uint8Array]' + ); +} + +// Fixed handler names for cross-language interop +export const pubsubPublishSignal = defineSignal<[PublishInput]>('__pubsub_publish'); +export const pubsubPollUpdate = defineUpdate('__pubsub_poll'); +export const pubsubOffsetQuery = defineQuery('__pubsub_offset'); + +const MAX_POLL_RESPONSE_BYTES = 1_000_000; + +/** Approximate poll-response contribution of a single encoded payload. */ +function payloadWireSize(encoded: string, topic: string): number { + // `encoded` is already base64 (the on-wire representation). + return encoded.length + topic.length; +} + +/** Internal log entry: stores decoded Payload for user-facing APIs. */ +interface InternalLogEntry { + topic: string; + payload: Payload; +} + +/** Type guard for Payload — same logic as client.ts's isPayload. */ +function isPayload(value: unknown): value is Payload { + if (value === null || typeof value !== 'object') return false; + const v = value as { metadata?: unknown }; + return ( + 'metadata' in v && + v.metadata != null && + typeof v.metadata === 'object' && + !Array.isArray(v.metadata) && + Object.values(v.metadata).every((x) => x instanceof Uint8Array) + ); +} + +/** + * Workflow-side pub/sub broker. + * + * Construct once at the start of your workflow function; the constructor + * registers the pub/sub signal, update, and query handlers on the current + * workflow. + * + * Registered handlers: + * + * - `__pubsub_publish` signal — external publish with dedup + * - `__pubsub_poll` update — long-poll subscription + * - `__pubsub_offset` query — current log length + * + * For continue-as-new, thread a `PubSubState | undefined` field through + * the workflow input and pass it as `priorState`. + */ +export class PubSub { + private log: InternalLogEntry[]; + private baseOffset: number; + private readonly publisherSequences: Record; + private readonly publisherLastSeen: Record; + private draining = false; + + constructor(priorState?: PubSubState) { + // Note: sdk-python guards against a second `PubSub(...)` call on the + // same workflow by checking `workflow.get_signal_handler(...)`. The + // TypeScript workflow runtime does not expose that inspection API, + // and `reuseV8Context` shares module-level state across workflow + // executions — so a naive module-level flag would either fire + // spuriously or miss real duplicates. Constructing `PubSub` twice in + // the same workflow silently replaces the handlers; users should + // construct once at the top of the workflow function. + this.log = priorState?.log + ? priorState.log.map((item) => ({ + topic: item.topic, + payload: decodePayloadWire(item.data), + })) + : []; + this.baseOffset = priorState?.base_offset ?? 0; + this.publisherSequences = priorState?.publisher_sequences + ? { ...priorState.publisher_sequences } + : {}; + this.publisherLastSeen = priorState?.publisher_last_seen + ? { ...priorState.publisher_last_seen } + : {}; + + setHandler(pubsubPublishSignal, (input: PublishInput) => this.onPublish(input)); + setHandler(pubsubPollUpdate, (input: PollInput) => this.onPoll(input), { + validator: (_input: PollInput) => { + if (this.draining) { + throw new Error('Workflow is draining for continue-as-new'); + } + }, + }); + setHandler(pubsubOffsetQuery, () => this.baseOffset + this.log.length); + } + + /** + * Publish an item from within workflow code. Deterministic — just appends. + * `value` may be any value the default payload converter can handle, or + * a pre-built `Payload` for zero-copy. + */ + publish(topic: string, value: unknown): void { + let payload: Payload; + if (isPayload(value)) { + payload = value; + } else if (isUint8ArrayLike(value)) { + // Bypass defaultPayloadConverter for cross-realm Uint8Arrays: the + // BinaryPayloadConverter uses `instanceof Uint8Array`, which fails + // against a Uint8Array produced by Node's built-in `TextEncoder` + // (host realm) when evaluated in the workflow sandbox. Construct + // the equivalent binary/plain Payload directly. + payload = { + metadata: { encoding: BINARY_PLAIN_ENCODING }, + data: new Uint8Array(value), + }; + } else { + payload = defaultPayloadConverter.toPayload(value); + } + this.log.push({ topic, payload }); + } + + /** Unblock all waiting poll handlers and reject new polls for CAN. */ + drain(): void { + this.draining = true; + } + + /** + * Return a serializable snapshot of pub/sub state for continue-as-new. + * Prunes publisher dedup entries older than publisherTtl seconds. + */ + getState(publisherTtl = 900): PubSubState { + const now = Date.now() / 1000; + const activeSeqs: Record = {}; + const activeSeen: Record = {}; + for (const pid of Object.keys(this.publisherSequences)) { + // Missing timestamps are pruned (matches sdk-python). The signal + // handler always sets both maps together, so absence indicates a + // malformed snapshot rather than a supported upgrade path. + const ts = this.publisherLastSeen[pid] ?? 0; + if (now - ts < publisherTtl) { + activeSeqs[pid] = this.publisherSequences[pid] ?? 0; + activeSeen[pid] = ts; + } + } + return { + // Per-item offset is re-derivable from base_offset + index on reload, + // so we leave it at 0 here. + log: this.log.map((entry) => ({ + topic: entry.topic, + data: encodePayloadWire(entry.payload), + offset: 0, + })), + base_offset: this.baseOffset, + publisher_sequences: activeSeqs, + publisher_last_seen: activeSeen, + }; + } + + /** + * Discard log entries before upToOffset. + * After truncation, polls requesting an offset before the new base + * will receive an error. + */ + truncate(upToOffset: number): void { + const logIndex = upToOffset - this.baseOffset; + if (logIndex <= 0) return; + if (logIndex > this.log.length) { + throw new Error( + `Cannot truncate to offset ${upToOffset}: only ${this.baseOffset + this.log.length} items exist` + ); + } + this.log.splice(0, logIndex); + this.baseOffset = upToOffset; + } + + private onPublish(input: PublishInput): void { + if (input.publisher_id) { + const lastSeq = this.publisherSequences[input.publisher_id] ?? 0; + if (input.sequence <= lastSeq) { + return; // duplicate — skip + } + this.publisherSequences[input.publisher_id] = input.sequence; + this.publisherLastSeen[input.publisher_id] = Date.now() / 1000; // seconds + } + for (const entry of input.items) { + this.log.push({ topic: entry.topic, payload: decodePayloadWire(entry.data) }); + } + } + + private async onPoll(input: PollInput): Promise { + let logOffset = input.from_offset - this.baseOffset; + if (logOffset < 0) { + if (input.from_offset === 0) { + // "From the beginning" — start at whatever is available. + logOffset = 0; + } else { + // Subscriber had a specific position that's been truncated. + // ApplicationFailure fails this update (client gets the error) + // without crashing the workflow task — avoids a poison pill + // during replay. + throw ApplicationFailure.create({ + message: + `Requested offset ${input.from_offset} has been truncated. ` + + `Current base offset is ${this.baseOffset}.`, + type: 'TruncatedOffset', + nonRetryable: true, + }); + } + } + await condition(() => this.log.length > logOffset || this.draining); + const allNew = this.log.slice(logOffset); + + // Build [globalOffset, entry] candidates, filtering by topic if requested. + const topicSet = input.topics.length > 0 ? new Set(input.topics) : null; + const candidates: Array<[number, InternalLogEntry]> = []; + for (let i = 0; i < allNew.length; i++) { + const entry = allNew[i]!; + if (topicSet !== null && !topicSet.has(entry.topic)) continue; + candidates.push([this.baseOffset + logOffset + i, entry]); + } + + // Cap response size to ~1MB of estimated wire bytes. + const wireItems: _WireItem[] = []; + let size = 0; + let moreReady = false; + let nextOffset = this.baseOffset + this.log.length; + for (const [off, entry] of candidates) { + const encoded = encodeBase64(encodePayloadProto(entry.payload)); + const itemSize = payloadWireSize(encoded, entry.topic); + if (size + itemSize > MAX_POLL_RESPONSE_BYTES && wireItems.length > 0) { + // Resume from this item on the next poll. + nextOffset = off; + moreReady = true; + break; + } + size += itemSize; + wireItems.push({ topic: entry.topic, data: encoded, offset: off }); + } + + return { + items: wireItems, + next_offset: nextOffset, + more_ready: moreReady, + }; + } +} diff --git a/packages/contrib-pubsub/src/index.ts b/packages/contrib-pubsub/src/index.ts index 133b9d1d3..303cf0944 100644 --- a/packages/contrib-pubsub/src/index.ts +++ b/packages/contrib-pubsub/src/index.ts @@ -29,7 +29,6 @@ export { encodePayloadWire, decodePayloadWire, } from './types'; -export { initPubSub, pubsubPublishSignal, pubsubPollUpdate, pubsubOffsetQuery } from './mixin'; -export type { PubSubHandle } from './mixin'; +export { PubSub, pubsubPublishSignal, pubsubPollUpdate, pubsubOffsetQuery } from './broker'; export { PubSubClient, FlushTimeoutError } from './client'; export type { PubSubClientOptions, SubscribeOptions } from './client'; diff --git a/packages/contrib-pubsub/src/mixin.ts b/packages/contrib-pubsub/src/mixin.ts deleted file mode 100644 index 46c46fe43..000000000 --- a/packages/contrib-pubsub/src/mixin.ts +++ /dev/null @@ -1,285 +0,0 @@ -/** - * Workflow-side pub/sub mixin. - * - * TypeScript workflows are functions, not classes, so the "mixin" is a set of - * functions that initialize and manage pub/sub state within workflow scope. - * - * Call `initPubSub()` at the start of your workflow function. Use the returned - * handle to publish, drain, and get state for continue-as-new. - * - * Both workflow-side and client-side `publish()` use the default payload - * converter for per-item `Payload` construction. The codec chain - * (encryption, PII-redaction, compression) is NOT applied per item on - * either side — it runs once at the envelope level when Temporal's SDK - * encodes the signal/update that carries the batch. - */ - -import { condition, defineSignal, defineUpdate, defineQuery, setHandler, defaultPayloadConverter } from '@temporalio/workflow'; -import { ApplicationFailure, type Payload } from '@temporalio/common'; -import { - decodePayloadWire, - encodePayloadProto, - encodePayloadWire, - encodeBase64, - type PollInput, - type PollResult, - type PubSubState, - type PublishInput, - type _WireItem, -} from './types'; - -const BINARY_PLAIN_ENCODING = new TextEncoder().encode('binary/plain'); - -/** - * Cross-realm-safe Uint8Array check. - * - * The workflow sandbox gets its `TextEncoder` from `node:util`, which - * returns a `Uint8Array` tagged to the host realm — so `value instanceof - * Uint8Array` is false against the sandbox's own `Uint8Array` global. - * `Object.prototype.toString` crosses realm boundaries reliably. - */ -function isUint8ArrayLike(value: unknown): value is ArrayLike { - return ( - value != null && - typeof value === 'object' && - Object.prototype.toString.call(value) === '[object Uint8Array]' - ); -} - -// Fixed handler names for cross-language interop -export const pubsubPublishSignal = defineSignal<[PublishInput]>('__pubsub_publish'); -export const pubsubPollUpdate = defineUpdate('__pubsub_poll'); -export const pubsubOffsetQuery = defineQuery('__pubsub_offset'); - -const MAX_POLL_RESPONSE_BYTES = 1_000_000; - -/** Approximate poll-response contribution of a single encoded payload. */ -function payloadWireSize(encoded: string, topic: string): number { - // `encoded` is already base64 (the on-wire representation). - return encoded.length + topic.length; -} - -/** Internal log entry: stores decoded Payload for user-facing APIs. */ -interface InternalLogEntry { - topic: string; - payload: Payload; -} - -/** Type guard for Payload — same logic as client.ts's isPayload. */ -function isPayload(value: unknown): value is Payload { - if (value === null || typeof value !== 'object') return false; - const v = value as { metadata?: unknown }; - return ( - 'metadata' in v && - v.metadata != null && - typeof v.metadata === 'object' && - !Array.isArray(v.metadata) && - Object.values(v.metadata).every((x) => x instanceof Uint8Array) - ); -} - -/** Handle returned by initPubSub for interacting with pub/sub state. */ -export interface PubSubHandle { - /** - * Publish an item from within workflow code. Deterministic — just appends. - * `value` may be any value the default payload converter can handle, or - * a pre-built `Payload` for zero-copy. - */ - publish(topic: string, value: unknown): void; - - /** Unblock all waiting poll handlers and reject new polls for CAN. */ - drain(): void; - - /** - * Return a serializable snapshot of pub/sub state for continue-as-new. - * Prunes publisher dedup entries older than publisherTtl seconds. - */ - getState(publisherTtl?: number): PubSubState; - - /** - * Discard log entries before upToOffset. - * After truncation, polls requesting an offset before the new base - * will receive an error. - */ - truncate(upToOffset: number): void; -} - -/** - * Initialize pub/sub state and register signal/update/query handlers. - * - * Call at the start of your workflow function. For continue-as-new, pass - * the prior state from `getState()`. - * - * @param priorState - State from a previous run via getState(). Pass undefined on first run. - * @returns A handle for publishing, draining, and getting state. - */ -export function initPubSub(priorState?: PubSubState): PubSubHandle { - // Decode wire items (base64 of proto Payload) to in-memory Payload objects. - const log: InternalLogEntry[] = priorState?.log - ? priorState.log.map((item) => ({ - topic: item.topic, - payload: decodePayloadWire(item.data), - })) - : []; - let baseOffset: number = priorState?.base_offset ?? 0; - const publisherSequences: Record = priorState?.publisher_sequences - ? { ...priorState.publisher_sequences } - : {}; - const publisherLastSeen: Record = priorState?.publisher_last_seen - ? { ...priorState.publisher_last_seen } - : {}; - let draining = false; - - // Signal handler: receive publications from external clients with dedup. - setHandler(pubsubPublishSignal, (input: PublishInput) => { - if (input.publisher_id) { - const lastSeq = publisherSequences[input.publisher_id] ?? 0; - if (input.sequence <= lastSeq) { - return; // duplicate — skip - } - publisherSequences[input.publisher_id] = input.sequence; - publisherLastSeen[input.publisher_id] = Date.now() / 1000; // seconds - } - for (const entry of input.items) { - log.push({ topic: entry.topic, payload: decodePayloadWire(entry.data) }); - } - }); - - // Update handler: long-poll subscription. - setHandler( - pubsubPollUpdate, - async (input: PollInput): Promise => { - let logOffset = input.from_offset - baseOffset; - if (logOffset < 0) { - if (input.from_offset === 0) { - // "From the beginning" — start at whatever is available. - logOffset = 0; - } else { - // Subscriber had a specific position that's been truncated. - // ApplicationFailure fails this update (client gets the error) - // without crashing the workflow task — avoids a poison pill - // during replay. - throw ApplicationFailure.create({ - message: - `Requested offset ${input.from_offset} has been truncated. ` + - `Current base offset is ${baseOffset}.`, - type: 'TruncatedOffset', - nonRetryable: true, - }); - } - } - await condition(() => log.length > logOffset || draining); - const allNew = log.slice(logOffset); - - // Build [globalOffset, entry] candidates, filtering by topic if requested. - const topicSet = input.topics.length > 0 ? new Set(input.topics) : null; - const candidates: Array<[number, InternalLogEntry]> = []; - for (let i = 0; i < allNew.length; i++) { - const entry = allNew[i]!; - if (topicSet !== null && !topicSet.has(entry.topic)) continue; - candidates.push([baseOffset + logOffset + i, entry]); - } - - // Cap response size to ~1MB of estimated wire bytes. - const wireItems: _WireItem[] = []; - let size = 0; - let moreReady = false; - let nextOffset = baseOffset + log.length; - for (const [off, entry] of candidates) { - const encoded = encodeBase64(encodePayloadProto(entry.payload)); - const itemSize = payloadWireSize(encoded, entry.topic); - if (size + itemSize > MAX_POLL_RESPONSE_BYTES && wireItems.length > 0) { - // Resume from this item on the next poll. - nextOffset = off; - moreReady = true; - break; - } - size += itemSize; - wireItems.push({ topic: entry.topic, data: encoded, offset: off }); - } - - return { - items: wireItems, - next_offset: nextOffset, - more_ready: moreReady, - }; - }, - { - // Validator: reject new polls when draining for continue-as-new. - validator(_input: PollInput): void { - if (draining) { - throw new Error('Workflow is draining for continue-as-new'); - } - }, - } - ); - - // Query handler: current global offset. - setHandler(pubsubOffsetQuery, () => baseOffset + log.length); - - return { - publish(topic: string, value: unknown): void { - let payload: Payload; - if (isPayload(value)) { - payload = value; - } else if (isUint8ArrayLike(value)) { - // Bypass defaultPayloadConverter for cross-realm Uint8Arrays: the - // BinaryPayloadConverter uses `instanceof Uint8Array`, which fails - // against a Uint8Array produced by Node's built-in `TextEncoder` - // (host realm) when evaluated in the workflow sandbox. Construct - // the equivalent binary/plain Payload directly. - payload = { - metadata: { encoding: BINARY_PLAIN_ENCODING }, - data: new Uint8Array(value), - }; - } else { - payload = defaultPayloadConverter.toPayload(value); - } - log.push({ topic, payload }); - }, - - drain(): void { - draining = true; - }, - - getState(publisherTtl = 900): PubSubState { - const now = Date.now() / 1000; - const activeSeqs: Record = {}; - const activeSeen: Record = {}; - for (const pid of Object.keys(publisherSequences)) { - // Missing timestamps are pruned (matches sdk-python). The signal - // handler always sets both maps together, so absence indicates a - // malformed snapshot rather than a supported upgrade path. - const ts = publisherLastSeen[pid] ?? 0; - if (now - ts < publisherTtl) { - activeSeqs[pid] = publisherSequences[pid] ?? 0; - activeSeen[pid] = ts; - } - } - return { - // Per-item offset is re-derivable from base_offset + index on reload, - // so we leave it at 0 here. - log: log.map((entry) => ({ - topic: entry.topic, - data: encodePayloadWire(entry.payload), - offset: 0, - })), - base_offset: baseOffset, - publisher_sequences: activeSeqs, - publisher_last_seen: activeSeen, - }; - }, - - truncate(upToOffset: number): void { - const logIndex = upToOffset - baseOffset; - if (logIndex <= 0) return; - if (logIndex > log.length) { - throw new Error( - `Cannot truncate to offset ${upToOffset}: only ${baseOffset + log.length} items exist` - ); - } - log.splice(0, logIndex); - baseOffset = upToOffset; - }, - }; -} diff --git a/packages/test/src/activities/contrib-pubsub.ts b/packages/test/src/activities/contrib-pubsub.ts index e388330b1..fbc1149d4 100644 --- a/packages/test/src/activities/contrib-pubsub.ts +++ b/packages/test/src/activities/contrib-pubsub.ts @@ -1,8 +1,8 @@ /** * Test activities for @temporalio/contrib-pubsub. * - * These activities use `PubSubClient.create()` with no arguments, relying on - * the activity context to supply the client and workflow ID. + * These activities use `PubSubClient.fromActivity()` to target the + * current activity's parent workflow from the activity context. */ import { Context } from '@temporalio/activity'; @@ -11,7 +11,7 @@ import { PubSubClient } from '@temporalio/contrib-pubsub'; const encoder = new TextEncoder(); export async function publishItems(count: number): Promise { - await using client = PubSubClient.create(undefined, undefined, { batchInterval: 0.5 }); + await using client = PubSubClient.fromActivity({ batchInterval: 0.5 }); client.start(); for (let i = 0; i < count; i++) { Context.current().heartbeat(); @@ -21,7 +21,7 @@ export async function publishItems(count: number): Promise { export async function publishMultiTopic(count: number): Promise { const topics = ['a', 'b', 'c']; - await using client = PubSubClient.create(undefined, undefined, { batchInterval: 0.5 }); + await using client = PubSubClient.fromActivity({ batchInterval: 0.5 }); client.start(); for (let i = 0; i < count; i++) { Context.current().heartbeat(); @@ -30,17 +30,17 @@ export async function publishMultiTopic(count: number): Promise { } } -export async function publishWithPriority(): Promise { +export async function publishWithForceFlush(): Promise { // Long batchInterval AND long post-publish hold ensure that only a - // working priority wakeup can deliver items before dispose flushes. + // working forceFlush wakeup can deliver items before dispose flushes. // The hold is deliberately much longer than the test's collect timeout - // so a regression (priority no-op) surfaces as a missing item rather + // so a regression (forceFlush no-op) surfaces as a missing item rather // than flaking on slow CI. - await using client = PubSubClient.create(undefined, undefined, { batchInterval: 60.0 }); + await using client = PubSubClient.fromActivity({ batchInterval: 60.0 }); client.start(); client.publish('events', encoder.encode('normal-0')); client.publish('events', encoder.encode('normal-1')); - client.publish('events', encoder.encode('priority'), true); + client.publish('events', encoder.encode('force-flush'), true); for (let i = 0; i < 100; i++) { Context.current().heartbeat(); await new Promise((resolve) => setTimeout(resolve, 100)); @@ -48,7 +48,7 @@ export async function publishWithPriority(): Promise { } export async function publishBatchTest(count: number): Promise { - await using client = PubSubClient.create(undefined, undefined, { batchInterval: 60.0 }); + await using client = PubSubClient.fromActivity({ batchInterval: 60.0 }); client.start(); for (let i = 0; i < count; i++) { Context.current().heartbeat(); @@ -58,7 +58,7 @@ export async function publishBatchTest(count: number): Promise { } export async function publishWithMaxBatch(count: number): Promise { - await using client = PubSubClient.create(undefined, undefined, { + await using client = PubSubClient.fromActivity({ batchInterval: 60.0, maxBatchSize: 3, }); diff --git a/packages/test/src/test-contrib-pubsub.ts b/packages/test/src/test-contrib-pubsub.ts index 9325ed5ea..c1aa8344d 100644 --- a/packages/test/src/test-contrib-pubsub.ts +++ b/packages/test/src/test-contrib-pubsub.ts @@ -30,7 +30,7 @@ import { getStateWithTtlQuery, maxBatchWorkflow, multiTopicWorkflow, - priorityWorkflow, + forceFlushWorkflow, publisherSequencesQuery, truncateUpdate, truncateWorkflow, @@ -255,18 +255,18 @@ test('subscribe_recovers_from_truncation — client auto-restarts from 0', async }); }); -test('priority_flush — priority wakes flusher despite 60s interval', async (t) => { +test('force_flush — forceFlush wakes flusher despite 60s interval', async (t) => { const { createWorker, startWorkflow } = helpers(t); const worker = await createWorker({ activities: pubsubActivities }); await worker.runUntil(async () => { - const handle = await startWorkflow(priorityWorkflow, { args: [] }); - // The activity holds for ~10s after priority publish; 5s timeout gives - // plenty of margin for scheduling while staying well below the hold so - // a regression (no priority wakeup) surfaces as a missing item, not a - // pass via the dispose-driven flush at activity exit. + const handle = await startWorkflow(forceFlushWorkflow, { args: [] }); + // The activity holds for ~10s after the forceFlush publish; 5s timeout + // gives plenty of margin for scheduling while staying well below the + // hold so a regression (no forceFlush wakeup) surfaces as a missing + // item, not a pass via the dispose-driven flush at activity exit. const items = await collectItems(handle, undefined, 0, 3, 5_000); t.is(items.length, 3); - t.is(payloadString(items[2]!.data), 'priority'); + t.is(payloadString(items[2]!.data), 'force-flush'); await handle.signal('close'); }); }); diff --git a/packages/test/src/workflows/contrib-pubsub.ts b/packages/test/src/workflows/contrib-pubsub.ts index 097b49bc8..78c383e83 100644 --- a/packages/test/src/workflows/contrib-pubsub.ts +++ b/packages/test/src/workflows/contrib-pubsub.ts @@ -11,10 +11,10 @@ import { proxyActivities, setHandler, } from '@temporalio/workflow'; -import { initPubSub, type PubSubState } from '@temporalio/contrib-pubsub'; +import { PubSub, type PubSubState } from '@temporalio/contrib-pubsub'; import type * as activities from '../activities/contrib-pubsub'; -const { publishItems, publishMultiTopic, publishWithPriority, publishBatchTest, publishWithMaxBatch } = +const { publishItems, publishMultiTopic, publishWithForceFlush, publishBatchTest, publishWithMaxBatch } = proxyActivities({ startToCloseTimeout: '30 seconds', heartbeatTimeout: '10 seconds', @@ -28,7 +28,7 @@ export const publisherSequencesQuery = defineQuery>('publ /** A minimal broker workflow — initializes pub/sub and waits for close. */ export async function basicPubSubWorkflow(): Promise { - initPubSub(); + new PubSub(); let closed = false; setHandler(closeSignal, () => { closed = true; @@ -38,7 +38,7 @@ export async function basicPubSubWorkflow(): Promise { /** Publishes `count` items directly from the workflow, then waits. */ export async function workflowSidePublishWorkflow(count: number): Promise { - const pubsub = initPubSub(); + const pubsub = new PubSub(); let closed = false; setHandler(closeSignal, () => { closed = true; @@ -52,7 +52,7 @@ export async function workflowSidePublishWorkflow(count: number): Promise /** Executes publishMultiTopic activity then waits. */ export async function multiTopicWorkflow(count: number): Promise { - initPubSub(); + new PubSub(); let closed = false; setHandler(closeSignal, () => { closed = true; @@ -63,7 +63,7 @@ export async function multiTopicWorkflow(count: number): Promise { /** Executes publishItems activity then appends activity_done status. */ export async function activityPublishWorkflow(count: number): Promise { - const pubsub = initPubSub(); + const pubsub = new PubSub(); let closed = false; setHandler(closeSignal, () => { closed = true; @@ -75,7 +75,7 @@ export async function activityPublishWorkflow(count: number): Promise { /** Workflow that accepts a truncate update (explicit completion). */ export async function truncateWorkflow(): Promise { - const pubsub = initPubSub(); + const pubsub = new PubSub(); let closed = false; setHandler(closeSignal, () => { closed = true; @@ -88,7 +88,7 @@ export async function truncateWorkflow(): Promise { /** Workflow that exposes getState via query for TTL testing. */ export async function ttlTestWorkflow(): Promise { - const pubsub = initPubSub(); + const pubsub = new PubSub(); let closed = false; setHandler(closeSignal, () => { closed = true; @@ -97,20 +97,20 @@ export async function ttlTestWorkflow(): Promise { await condition(() => closed); } -/** Workflow that runs publishWithPriority activity. */ -export async function priorityWorkflow(): Promise { - initPubSub(); +/** Workflow that runs publishWithForceFlush activity. */ +export async function forceFlushWorkflow(): Promise { + new PubSub(); let closed = false; setHandler(closeSignal, () => { closed = true; }); - await publishWithPriority(); + await publishWithForceFlush(); await condition(() => closed); } /** Workflow that runs publishBatchTest activity. */ export async function flushOnExitWorkflow(count: number): Promise { - initPubSub(); + new PubSub(); let closed = false; setHandler(closeSignal, () => { closed = true; @@ -121,7 +121,7 @@ export async function flushOnExitWorkflow(count: number): Promise { /** Workflow that runs publishWithMaxBatch activity. */ export async function maxBatchWorkflow(count: number): Promise { - const pubsub = initPubSub(); + const pubsub = new PubSub(); let closed = false; setHandler(closeSignal, () => { closed = true; @@ -138,7 +138,7 @@ export interface CANWorkflowInput { /** CAN workflow using properly-typed pubsubState. */ export async function continueAsNewTypedWorkflow(input: CANWorkflowInput): Promise { - const pubsub = initPubSub(input.pubsubState); + const pubsub = new PubSub(input.pubsubState); let closed = false; let shouldContinue = false; setHandler(closeSignal, () => { From c80ee54d910dddbbbbef0d45fba5a85035299965 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Thu, 23 Apr 2026 22:14:18 -0700 Subject: [PATCH 17/75] ai-sdk: publish raw stream parts, drop normalization layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors sdk-python commit 99a7a8ab (openai_agents: publish raw stream events, drop normalization layer), applied to the Vercel AI SDK plugin per user request. The streaming activity previously maintained a ~50-line normalization layer: a switch over AI SDK V3 stream-part types that mapped them to custom app event names (TEXT_DELTA, THINKING_*, LLM_CALL_START / LLM_CALL_COMPLETE, TOOL_CALL_START, TOOL_INPUT_DELTA), plus a synthesized TEXT_COMPLETE after text-end. That normalization made sense when a shared UI consumed events from multiple providers, but each provider-plugin should expose its native event stream and let consumers render idiomatically. The activity now publishes each yielded AI SDK stream part as JSON — one line inside the stream loop — and still accumulates `currentText` / `currentReasoning` to build the final `LanguageModelV3GenerateResult`. Also switched the streaming activity to `PubSubClient.fromActivity()` so it no longer needs the `temporalClient` plumbed through plugin options. `AiSdkPluginOptions.temporalClient` and the `CreateActivitiesOptions` plumbing are removed. Downstream impact: consumers that depend on the normalized event names (temporal-streaming-agents-samples frontend, shared-frontend hooks) need to switch on native AI SDK stream-part types (`text-delta`, `reasoning-delta`, `tool-input-delta`, `response-metadata`, `finish`, ...). Not touched in this commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/ai-sdk/src/activities.ts | 69 +++++++------------------------ packages/ai-sdk/src/plugin.ts | 11 +---- 2 files changed, 17 insertions(+), 63 deletions(-) diff --git a/packages/ai-sdk/src/activities.ts b/packages/ai-sdk/src/activities.ts index b7d1d5856..35e157534 100644 --- a/packages/ai-sdk/src/activities.ts +++ b/packages/ai-sdk/src/activities.ts @@ -12,7 +12,6 @@ import type { } from '@ai-sdk/provider'; import { asSchema, type Schema, type ToolExecutionOptions } from 'ai'; import { ApplicationFailure } from '@temporalio/common'; -import type { Client } from '@temporalio/client'; import { Context } from '@temporalio/activity'; import { PubSubClient } from '@temporalio/contrib-pubsub'; import type { McpClientFactories, McpClientFactory } from './mcp'; @@ -20,10 +19,6 @@ import type { McpClientFactories, McpClientFactory } from './mcp'; const EVENTS_TOPIC = 'events'; const encoder = new TextEncoder(); -function makeEvent(type: string, data: Record = {}): Uint8Array { - return encoder.encode(JSON.stringify({ type, timestamp: new Date().toISOString(), data })); -} - /** * Arguments for invoking a language model activity. */ @@ -72,14 +67,6 @@ export interface CallToolArgs { options: ToolExecutionOptions; } -/** - * Options for creating activities with streaming support. - */ -export interface CreateActivitiesOptions { - /** Temporal client, required for streaming (PubSubClient needs it). */ - temporalClient?: Client; -} - /** * Creates Temporal activities for AI model invocation using the provided AI SDK provider. * These activities allow workflows to call AI models while maintaining Temporal's @@ -87,15 +74,13 @@ export interface CreateActivitiesOptions { * * @param provider The AI SDK provider to use for model invocations * @param mcpClientFactories A mapping of server names to functions to create mcp clients - * @param options Additional options (e.g. temporalClient for streaming) * @returns An object containing the activity functions * * @experimental The AI SDK integration is an experimental feature; APIs may change without notice. */ export function createActivities( provider: ProviderV3, - mcpClientFactories?: McpClientFactories, - options?: CreateActivitiesOptions + mcpClientFactories?: McpClientFactories ): object { let activities = { async invokeModel(args: InvokeModelArgs): Promise { @@ -103,19 +88,17 @@ export function createActivities( return await model.doGenerate(args.options); }, + /** + * Streaming-aware model activity. + * + * Calls `model.doStream()`, publishes each yielded AI SDK stream part + * as JSON to the pubsub side channel, and returns the assembled + * `LanguageModelV3GenerateResult`. Consumers receive native AI SDK + * stream-part types (text-delta, reasoning-delta, tool-input-delta, + * response-metadata, finish, ...); no normalization happens here. + */ async invokeModelStreaming(args: InvokeModelArgs): Promise { - // Validate prerequisites before making the LLM call - const info = Context.current().info; - const workflowId = info.workflowExecution?.workflowId; - if (!workflowId || !options?.temporalClient) { - throw ApplicationFailure.nonRetryable( - 'Streaming requires temporalClient in plugin options and activity must be called from a workflow.' - ); - } - - const pubsub = PubSubClient.create(options.temporalClient, workflowId, { - batchInterval: 0.1, - }); + const pubsub = PubSubClient.fromActivity({ batchInterval: 0.1 }); pubsub.start(); const model = provider.languageModel(args.modelId); @@ -134,8 +117,6 @@ export function createActivities( let currentReasoning = ''; try { - pubsub.publish(EVENTS_TOPIC, makeEvent('LLM_CALL_START'), true); - const reader = streamResult.stream.getReader(); // eslint-disable-next-line no-constant-condition while (true) { @@ -144,6 +125,11 @@ export function createActivities( Context.current().heartbeat(); + // Publish the raw stream part as JSON so consumers can switch on + // the native AI SDK type. Accumulation below is for the final + // assembled result this activity returns. + pubsub.publish(EVENTS_TOPIC, encoder.encode(JSON.stringify(part))); + switch (part.type) { case 'stream-start': warnings.push(...part.warnings); @@ -153,7 +139,6 @@ export function createActivities( break; case 'text-delta': currentText += part.delta; - pubsub.publish(EVENTS_TOPIC, makeEvent('TEXT_DELTA', { delta: part.delta })); break; case 'text-end': content.push({ @@ -161,15 +146,12 @@ export function createActivities( text: currentText, providerMetadata: part.providerMetadata, }); - pubsub.publish(EVENTS_TOPIC, makeEvent('TEXT_COMPLETE', { text: currentText }), true); break; case 'reasoning-start': currentReasoning = ''; - pubsub.publish(EVENTS_TOPIC, makeEvent('THINKING_START')); break; case 'reasoning-delta': currentReasoning += part.delta; - pubsub.publish(EVENTS_TOPIC, makeEvent('THINKING_DELTA', { delta: part.delta })); break; case 'reasoning-end': content.push({ @@ -177,23 +159,6 @@ export function createActivities( text: currentReasoning, providerMetadata: part.providerMetadata, }); - pubsub.publish( - EVENTS_TOPIC, - makeEvent('THINKING_COMPLETE', { content: currentReasoning }), - true - ); - break; - case 'tool-input-start': - pubsub.publish( - EVENTS_TOPIC, - makeEvent('TOOL_CALL_START', { tool_name: part.toolName }) - ); - break; - case 'tool-input-delta': - pubsub.publish( - EVENTS_TOPIC, - makeEvent('TOOL_INPUT_DELTA', { delta: part.delta }) - ); break; case 'response-metadata': responseMetadata = { @@ -220,8 +185,6 @@ export function createActivities( break; } } - - pubsub.publish(EVENTS_TOPIC, makeEvent('LLM_CALL_COMPLETE'), true); } finally { await pubsub.stop(); } diff --git a/packages/ai-sdk/src/plugin.ts b/packages/ai-sdk/src/plugin.ts index f89232409..7c3c8f2a6 100644 --- a/packages/ai-sdk/src/plugin.ts +++ b/packages/ai-sdk/src/plugin.ts @@ -1,5 +1,4 @@ import type { ProviderV3 } from '@ai-sdk/provider'; -import type { Client } from '@temporalio/client'; import { SimplePlugin } from '@temporalio/plugin'; import { createActivities } from './activities'; import type { McpClientFactories } from './mcp'; @@ -17,12 +16,6 @@ export interface AiSdkPluginOptions { * Any TemporalMCPClient used in a workflow should have its associated servername listed in this object. */ mcpClientFactories?: McpClientFactories; - - /** - * Temporal client, required for streaming support. The streaming activity - * uses this to create a PubSubClient for publishing token events. - */ - temporalClient?: Client; } /** @@ -35,9 +28,7 @@ export class AiSdkPlugin extends SimplePlugin { constructor(options: AiSdkPluginOptions) { super({ name: 'AiSDKPlugin', - activities: createActivities(options.modelProvider, options.mcpClientFactories, { - temporalClient: options.temporalClient, - }), + activities: createActivities(options.modelProvider, options.mcpClientFactories), }); } } From aa07831e1e596b16fcfb53bee55c41b5b76d046a Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Fri, 24 Apr 2026 22:25:36 -0700 Subject: [PATCH 18/75] pubsub: prefix internal handler names with __temporal_ Rename the wire-level handler identifiers to follow the existing __temporal_ convention so they are clearly recognizable as Temporal-internal and won't collide with user-defined handlers: __pubsub_publish -> __temporal_pubsub_publish __pubsub_poll -> __temporal_pubsub_poll __pubsub_offset -> __temporal_pubsub_offset Mirrors the same rename in sdk-python. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/contrib-pubsub/README.md | 16 ++++++++-------- packages/contrib-pubsub/src/broker.ts | 12 ++++++------ packages/contrib-pubsub/src/client.ts | 6 +++--- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/contrib-pubsub/README.md b/packages/contrib-pubsub/README.md index 28a94a22e..4ef44ca11 100644 --- a/packages/contrib-pubsub/README.md +++ b/packages/contrib-pubsub/README.md @@ -39,8 +39,8 @@ export async function myWorkflow(input: MyInput): Promise { } ``` -The `PubSub` constructor registers the `__pubsub_publish` signal, -`__pubsub_poll` update, and `__pubsub_offset` query handlers on your workflow. +The `PubSub` constructor registers the `__temporal_pubsub_publish` signal, +`__temporal_pubsub_poll` update, and `__temporal_pubsub_offset` query handlers on your workflow. Any value the default payload converter can serialize (JSON, `Uint8Array`, or a pre-built `Payload`) can be passed to `publish`. @@ -164,9 +164,9 @@ Handlers registered automatically: | Kind | Name | Description | |---|---|---| -| Signal | `__pubsub_publish` | Receive external publications. | -| Update | `__pubsub_poll` | Long-poll subscription. | -| Query | `__pubsub_offset` | Current global offset. | +| Signal | `__temporal_pubsub_publish` | Receive external publications. | +| Update | `__temporal_pubsub_poll` | Long-poll subscription. | +| Query | `__temporal_pubsub_offset` | Current global offset. | ### `PubSubClient` @@ -195,9 +195,9 @@ Handlers registered automatically: Any Temporal client can interact with a pub/sub workflow using these fixed handler names: -1. **Publish**: signal `__pubsub_publish` with `PublishInput` -2. **Subscribe**: update `__pubsub_poll` with `PollInput` -> `PollResult` -3. **Offset**: query `__pubsub_offset` -> `number` +1. **Publish**: signal `__temporal_pubsub_publish` with `PublishInput` +2. **Subscribe**: update `__temporal_pubsub_poll` with `PollInput` -> `PollResult` +3. **Offset**: query `__temporal_pubsub_offset` -> `number` Each `PublishEntry.data` / `_WireItem.data` is a base64-encoded `temporal.api.common.v1.Payload` protobuf (`Payload.SerializeToString()` in diff --git a/packages/contrib-pubsub/src/broker.ts b/packages/contrib-pubsub/src/broker.ts index dd18635bd..2d6f1e0b5 100644 --- a/packages/contrib-pubsub/src/broker.ts +++ b/packages/contrib-pubsub/src/broker.ts @@ -51,9 +51,9 @@ function isUint8ArrayLike(value: unknown): value is ArrayLike { } // Fixed handler names for cross-language interop -export const pubsubPublishSignal = defineSignal<[PublishInput]>('__pubsub_publish'); -export const pubsubPollUpdate = defineUpdate('__pubsub_poll'); -export const pubsubOffsetQuery = defineQuery('__pubsub_offset'); +export const pubsubPublishSignal = defineSignal<[PublishInput]>('__temporal_pubsub_publish'); +export const pubsubPollUpdate = defineUpdate('__temporal_pubsub_poll'); +export const pubsubOffsetQuery = defineQuery('__temporal_pubsub_offset'); const MAX_POLL_RESPONSE_BYTES = 1_000_000; @@ -91,9 +91,9 @@ function isPayload(value: unknown): value is Payload { * * Registered handlers: * - * - `__pubsub_publish` signal — external publish with dedup - * - `__pubsub_poll` update — long-poll subscription - * - `__pubsub_offset` query — current log length + * - `__temporal_pubsub_publish` signal — external publish with dedup + * - `__temporal_pubsub_poll` update — long-poll subscription + * - `__temporal_pubsub_offset` query — current log length * * For continue-as-new, thread a `PubSubState | undefined` field through * the workflow input and pass it as `priorState`. diff --git a/packages/contrib-pubsub/src/client.ts b/packages/contrib-pubsub/src/client.ts index 4bb456022..6d10eb08b 100644 --- a/packages/contrib-pubsub/src/client.ts +++ b/packages/contrib-pubsub/src/client.ts @@ -335,7 +335,7 @@ export class PubSubClient { // On failure, the signal throws and pending stays set for retry. // On success, advance confirmed sequence and clear pending. - await this.handle.signal<[PublishInput]>('__pubsub_publish', { + await this.handle.signal<[PublishInput]>('__temporal_pubsub_publish', { items: batch, publisher_id: this.publisherId, sequence: seq, @@ -366,7 +366,7 @@ export class PubSubClient { while (true) { let result: PollResult; try { - result = await this.handle.executeUpdate('__pubsub_poll', { + result = await this.handle.executeUpdate('__temporal_pubsub_poll', { args: [{ topics: topics ?? [], from_offset: offset }], }); } catch (err) { @@ -407,7 +407,7 @@ export class PubSubClient { /** Query the current global offset. */ async getOffset(): Promise { - return this.handle.query('__pubsub_offset'); + return this.handle.query('__temporal_pubsub_offset'); } /** From 60492ca4bc6a030a017bcbd7d52e6e3d7e504e84 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Fri, 24 Apr 2026 23:23:29 -0700 Subject: [PATCH 19/75] pubsub: raise ApplicationFailure(TruncateOutOfRange) for out-of-range truncate Mirrors sdk-python 736b5701. PubSub.truncate() now throws an ApplicationFailure with type 'TruncateOutOfRange' and nonRetryable=true when the requested offset is past the end of the log, instead of a generic Error. Matches how onPoll already reports 'TruncatedOffset': an update handler that calls truncate surfaces the error to the caller as WorkflowUpdateFailedError without poisoning the workflow task. A plain Error inside an update handler would fail the activation instead of the update. New test truncate_past_end_raises_application_failure verifies the documented behavior end-to-end and that the workflow remains usable after the failure. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/contrib-pubsub/src/broker.ts | 15 ++++++++--- packages/test/src/test-contrib-pubsub.ts | 32 ++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/contrib-pubsub/src/broker.ts b/packages/contrib-pubsub/src/broker.ts index 2d6f1e0b5..a3921c158 100644 --- a/packages/contrib-pubsub/src/broker.ts +++ b/packages/contrib-pubsub/src/broker.ts @@ -205,14 +205,23 @@ export class PubSub { * Discard log entries before upToOffset. * After truncation, polls requesting an offset before the new base * will receive an error. + * + * Raises `ApplicationFailure` with type `'TruncateOutOfRange'` and + * `nonRetryable: true` when the requested offset is past the end of + * the log. Mirrors how `onPoll` reports `'TruncatedOffset'`: an update + * handler invoking `truncate` surfaces the error to the caller without + * failing the workflow task. */ truncate(upToOffset: number): void { const logIndex = upToOffset - this.baseOffset; if (logIndex <= 0) return; if (logIndex > this.log.length) { - throw new Error( - `Cannot truncate to offset ${upToOffset}: only ${this.baseOffset + this.log.length} items exist` - ); + throw ApplicationFailure.create({ + message: + `Cannot truncate to offset ${upToOffset}: only ${this.baseOffset + this.log.length} items exist`, + type: 'TruncateOutOfRange', + nonRetryable: true, + }); } this.log.splice(0, logIndex); this.baseOffset = upToOffset; diff --git a/packages/test/src/test-contrib-pubsub.ts b/packages/test/src/test-contrib-pubsub.ts index c1aa8344d..8541bf95e 100644 --- a/packages/test/src/test-contrib-pubsub.ts +++ b/packages/test/src/test-contrib-pubsub.ts @@ -369,6 +369,38 @@ test('truncate_pubsub — truncate discards prefix and adjusts base', async (t) }); }); +test('truncate_past_end_raises_application_failure', async (t) => { + // truncate() with an offset past the end of the log surfaces as + // WorkflowUpdateFailedError with cause type 'TruncateOutOfRange'. + // The workflow task must not be poisoned: a follow-up poll still works. + const { createWorker, startWorkflow } = helpers(t); + const worker = await createWorker(); + await worker.runUntil(async () => { + const handle = await startWorkflow(truncateWorkflow, { args: [] }); + + const items: PublishEntry[] = []; + for (let i = 0; i < 2; i++) items.push(entry('events', `item-${i}`)); + await handle.signal<[PublishInput]>(pubsubPublishSignal, { + items, + publisher_id: '', + sequence: 0, + }); + + // Only 2 items exist; asking to truncate to offset 5 is out of range. + const err = (await t.throwsAsync(handle.executeUpdate(truncateUpdate, { args: [5] }), { + instanceOf: WorkflowUpdateFailedError, + })) as WorkflowUpdateFailedError; + t.true(err.cause instanceof ApplicationFailure); + t.is((err.cause as ApplicationFailure).type, 'TruncateOutOfRange'); + + // Workflow task wasn't poisoned — a valid poll still completes. + const after = await collectItems(handle, undefined, 0, 2); + t.is(after.length, 2); + + await handle.signal('close'); + }); +}); + test('ttl_pruning_in_get_state — old publisher pruned, new publisher kept', async (t) => { // pub-old arrives first, then wall-clock gap, then pub-new. TTL=0.5s // prunes pub-old (~1s old) but keeps pub-new (~0s). From de6ec1cec71de377d1f0805e5535ce038bcc65eb Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Fri, 24 Apr 2026 23:24:09 -0700 Subject: [PATCH 20/75] pubsub: add public async flush() barrier on PubSubClient Mirrors sdk-python 2d768771. flush() is an explicit synchronization point: it returns once items buffered at call time have been signaled to the workflow and acknowledged by the server, and returns immediately when there is nothing to send. Complements the declarative forceFlush on publish() and the dispose-driven flush at scope exit, for the case where the caller needs proof that prior publications landed but the moment doesn't naturally correspond to a specific event. Implementation snapshots the target sequence at entry rather than looping while pending/buffer are non-empty: a concurrent publisher that calls publish() during the awaits adds at a later sequence and must not extend this barrier (otherwise sustained traffic could keep flush() blocking indefinitely). `sequence` only advances on a successful send, so reaching the snapshotted target proves entry-time items were confirmed. Surfaces a deferred FlushTimeoutError stashed by the background flusher both on entry and after drain, so flush() never returns success while an earlier dropped batch sits unreported. Test explicit_flush_barrier exercises the contract: empty-buffer no-op, flush as a barrier with batchInterval=60s so a regression hangs rather than passing on the timer, and idempotent second flush. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/contrib-pubsub/src/client.ts | 46 ++++++++++++++++++++++++ packages/test/src/test-contrib-pubsub.ts | 32 +++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/packages/contrib-pubsub/src/client.ts b/packages/contrib-pubsub/src/client.ts index 6d10eb08b..d4e98ff39 100644 --- a/packages/contrib-pubsub/src/client.ts +++ b/packages/contrib-pubsub/src/client.ts @@ -189,6 +189,52 @@ export class PubSubClient { this.flusherTask = this.runFlusher(); } + /** + * Flush buffered (and pending) items and wait for server confirmation. + * + * Returns once the items buffered at call time have been signaled to + * the workflow and acknowledged by the server. Returns immediately if + * there is nothing to send. + * + * In addition to the declarative `forceFlush=true` on {@link publish} + * and to the automatic flush on {@link [Symbol.asyncDispose]}, use + * this when the caller needs proof that prior publications reached + * the server at a moment that does not naturally correspond to a + * specific event. + * + * Safe to call concurrently with `publish()` and with the background + * flusher: the in-flight serialization on `flushOnce` makes signal + * sends sequential. Items added concurrently after entry may + * piggyback on this flush or be deferred to a subsequent one. + * + * Throws {@link FlushTimeoutError} if a pending batch cannot be sent + * within `maxRetryDuration`. Also surfaces any deferred timeout from a + * prior background flusher failure: without that, `flush()` could + * return success against an empty buffer while an earlier batch had + * already been dropped, hiding data loss. + */ + async flush(): Promise { + this.throwPendingFlusherError(); + // Snapshot the sequence number that the items present at entry will + // commit at. A concurrent producer that calls publish() during the + // awaits below adds to the buffer at a later sequence — those items + // belong to a future flush and must not extend this barrier. + if (this.pending === null && this.buffer.length === 0) { + return; + } + const baseSeq = this.pending !== null ? this.pendingSeq : this.sequence; + const targetSeq = this.buffer.length > 0 ? baseSeq + 1 : baseSeq; + // `sequence` only advances on a successful send, so reaching + // `targetSeq` proves the entry-time items were confirmed. A later + // batch (queued by a concurrent publisher and picked up by the + // background flusher) may leave `pending` non-null afterward — we + // do not wait on it. + while (this.sequence < targetSeq) { + await this.flushOnce(); + } + this.throwPendingFlusherError(); + } + /** Stop the flusher and flush remaining items. */ async stop(): Promise { if (!this.flusherTask) { diff --git a/packages/test/src/test-contrib-pubsub.ts b/packages/test/src/test-contrib-pubsub.ts index 8541bf95e..a61f13f18 100644 --- a/packages/test/src/test-contrib-pubsub.ts +++ b/packages/test/src/test-contrib-pubsub.ts @@ -401,6 +401,38 @@ test('truncate_past_end_raises_application_failure', async (t) => { }); }); +test('explicit_flush_barrier — flush() returns once items are confirmed', async (t) => { + // flush() is a synchronization point. With a 60s batchInterval, a + // regression that silently relies on the background timer would hang + // (and miss the per-test timeout) rather than slow-pass. + const { createWorker, startWorkflow } = helpers(t); + const worker = await createWorker(); + await worker.runUntil(async () => { + const handle = await startWorkflow(basicPubSubWorkflow, { args: [] }); + + const pubsub = new PubSubClient(handle, { batchInterval: 60 }); + + // 1. Empty-buffer flush is a no-op (must not block). + t.is(await pubsub.getOffset(), 0); + await pubsub.flush(); + t.is(await pubsub.getOffset(), 0); + + // 2. Flush makes prior publishes visible without waiting on the + // 60s batch timer. + pubsub.publish('events', encoder.encode('a')); + pubsub.publish('events', encoder.encode('b')); + pubsub.publish('events', encoder.encode('c')); + await pubsub.flush(); + t.is(await pubsub.getOffset(), 3); + + // 3. Second flush with no new items is a no-op. + await pubsub.flush(); + t.is(await pubsub.getOffset(), 3); + + await handle.signal('close'); + }); +}); + test('ttl_pruning_in_get_state — old publisher pruned, new publisher kept', async (t) => { // pub-old arrives first, then wall-clock gap, then pub-new. TTL=0.5s // prunes pub-old (~1s old) but keeps pub-new (~0s). From a9e7dbf5a55b8de0dc3ebe3d915029eb8de86349 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Fri, 24 Apr 2026 23:24:52 -0700 Subject: [PATCH 21/75] pubsub: accept a single string for subscribe(topics=...) Mirrors sdk-python 9274670b. Convenience for the common single-topic subscriber. Previous signature required wrapping a single topic in an array, which is noisy at every call site. Internally we normalize to an array before the poll update; behavior for undefined / empty array / multi-topic array is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/contrib-pubsub/src/client.ts | 9 +++++++-- packages/test/src/test-contrib-pubsub.ts | 25 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/contrib-pubsub/src/client.ts b/packages/contrib-pubsub/src/client.ts index d4e98ff39..d8e177f40 100644 --- a/packages/contrib-pubsub/src/client.ts +++ b/packages/contrib-pubsub/src/client.ts @@ -400,20 +400,25 @@ export class PubSubClient { * * Automatically follows continue-as-new chains when created via * {@link PubSubClient.create}. + * + * @param topics - Topic filter. A single topic name, an array of topic + * names, or undefined. Undefined or an empty array means all topics. */ async *subscribe( - topics?: string[], + topics?: string | string[], fromOffset = 0, options?: SubscribeOptions ): AsyncGenerator { const pollCooldown = options?.pollCooldown ?? 0.1; + const topicFilter: string[] = + topics === undefined ? [] : typeof topics === 'string' ? [topics] : topics; let offset = fromOffset; while (true) { let result: PollResult; try { result = await this.handle.executeUpdate('__temporal_pubsub_poll', { - args: [{ topics: topics ?? [], from_offset: offset }], + args: [{ topics: topicFilter, from_offset: offset }], }); } catch (err) { if (err instanceof WorkflowUpdateFailedError) { diff --git a/packages/test/src/test-contrib-pubsub.ts b/packages/test/src/test-contrib-pubsub.ts index a61f13f18..85a154902 100644 --- a/packages/test/src/test-contrib-pubsub.ts +++ b/packages/test/src/test-contrib-pubsub.ts @@ -433,6 +433,31 @@ test('explicit_flush_barrier — flush() returns once items are confirmed', asyn }); }); +test('subscribe_accepts_string_topic — single-string convenience', async (t) => { + // subscribe(topics='a') is equivalent to subscribe(topics=['a']). + const count = 9; + const { createWorker, startWorkflow } = helpers(t); + const worker = await createWorker({ activities: pubsubActivities }); + await worker.runUntil(async () => { + const handle = await startWorkflow(multiTopicWorkflow, { args: [count] }); + + const client = new PubSubClient(handle); + const items: PubSubItem[] = []; + const gen = client.subscribe('a', 0, { pollCooldown: 0 }); + for await (const item of gen) { + items.push(item); + if (items.length >= 3) { + await gen.return(); + break; + } + } + t.is(items.length, 3); + t.true(items.every((it) => it.topic === 'a')); + + await handle.signal('close'); + }); +}); + test('ttl_pruning_in_get_state — old publisher pruned, new publisher kept', async (t) => { // pub-old arrives first, then wall-clock gap, then pub-new. TTL=0.5s // prunes pub-old (~1s old) but keeps pub-new (~0s). From 6727fd57417cce00bcd7b284700897aed8b078f7 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Mon, 27 Apr 2026 21:14:39 -0700 Subject: [PATCH 22/75] pubsub: add PubSub.continueAsNew helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the Python helper landed in sdk-python: packages drain + condition(allHandlersFinished) + continueAsNew behind `await pubsub.continueAsNew(buildArgs)`. The builder is typed `(state: PubSubState) => Parameters` and runs after drain stabilizes, with the post-drain state as its single argument — the snapshot ordering is structural rather than documented-by-prose. The helper deliberately does not mirror ContinueAsNewOptions; workflows that need to set taskQueue / searchAttributes / memo / etc. fall back to the explicit recipe with `makeContinueAsNewFunc`. The README's explicit-recipe example is also corrected to include the `condition(allHandlersFinished)` step that was previously missing between drain and continueAsNew. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/contrib-pubsub/README.md | 38 +++++++++++--- packages/contrib-pubsub/src/broker.ts | 48 ++++++++++++++++- packages/test/src/test-contrib-pubsub.ts | 51 +++++++++++++++++++ packages/test/src/workflows/contrib-pubsub.ts | 20 +++++++- 4 files changed, 146 insertions(+), 11 deletions(-) diff --git a/packages/contrib-pubsub/README.md b/packages/contrib-pubsub/README.md index 4ef44ca11..12257d9bd 100644 --- a/packages/contrib-pubsub/README.md +++ b/packages/contrib-pubsub/README.md @@ -135,19 +135,40 @@ export async function myWorkflow(input: WorkflowInput): Promise { // ... do work, updating itemsProcessed ... if (workflowInfo().continueAsNewSuggested) { - pubsub.drain(); - // Wait for in-flight handlers to finish, then continue-as-new. - await continueAsNew({ + await pubsub.continueAsNew((state) => [{ itemsProcessed, - pubsubState: pubsub.getState(), - }); + pubsubState: state, + }]); } } ``` -`drain()` unblocks waiting subscribers and rejects new polls. Subscribers -created via `PubSubClient.create()` automatically follow continue-as-new -chains. +`PubSub.continueAsNew(buildArgs)` drains waiting subscribers, waits for +in-flight handlers to finish, then calls `continueAsNew` with the args +returned by `buildArgs(postDrainState)`. The lambda receives the +post-drain `PubSubState` as its only argument so the snapshot is +guaranteed to happen *after* drain. Subscribers created via +`PubSubClient.create()` automatically follow continue-as-new chains. + +If you need to pass other CAN options (search attributes, memo, +non-default `taskQueue`, etc.), fall back to the explicit recipe with +`makeContinueAsNewFunc`: + +```typescript +import { condition, allHandlersFinished, makeContinueAsNewFunc } from '@temporalio/workflow'; + +if (workflowInfo().continueAsNewSuggested) { + pubsub.drain(); + await condition(allHandlersFinished); + const continueWithOptions = makeContinueAsNewFunc({ + taskQueue: 'other-tq', + }); + await continueWithOptions({ + itemsProcessed, + pubsubState: pubsub.getState(), + }); +} +``` ## API Reference @@ -158,6 +179,7 @@ chains. | `publish(topic, value)` | Append to the log from workflow code. Accepts any value the default payload converter handles, or a pre-built `Payload`. | | `getState(publisherTtl = 900)` | Snapshot for continue-as-new. Drops publisher dedup entries older than `publisherTtl` seconds. | | `drain()` | Unblock polls and reject new ones. | +| `continueAsNew(buildArgs, options?)` | Async. Drain, wait for handlers, then `continueAsNew` with `buildArgs(postDrainState)`. Use the explicit recipe with `makeContinueAsNewFunc` to pass other CAN options. | | `truncate(upToOffset)` | Discard log entries below the given offset. | Handlers registered automatically: diff --git a/packages/contrib-pubsub/src/broker.ts b/packages/contrib-pubsub/src/broker.ts index a3921c158..6fbe981c9 100644 --- a/packages/contrib-pubsub/src/broker.ts +++ b/packages/contrib-pubsub/src/broker.ts @@ -18,8 +18,17 @@ * carries the batch. */ -import { condition, defineSignal, defineUpdate, defineQuery, setHandler, defaultPayloadConverter } from '@temporalio/workflow'; -import { ApplicationFailure, type Payload } from '@temporalio/common'; +import { + allHandlersFinished, + condition, + continueAsNew as workflowContinueAsNew, + defineSignal, + defineUpdate, + defineQuery, + setHandler, + defaultPayloadConverter, +} from '@temporalio/workflow'; +import { ApplicationFailure, type Payload, type Workflow } from '@temporalio/common'; import { decodePayloadWire, encodePayloadProto, @@ -201,6 +210,41 @@ export class PubSub { }; } + /** + * Drain, wait for in-flight handlers, then `continueAsNew` with built args. + * + * Replaces the recipe `drain()` → `condition(allHandlersFinished)` → + * `continueAsNew(...)` for the common case where the only thing that + * varies across CAN boundaries is the workflow's own arguments. + * + * `buildArgs` is invoked *after* drain stabilizes, with the post-drain + * `PubSubState` as its single argument, and must return the positional + * argument tuple for the new run. + * + * @example + * ```typescript + * await pubsub.continueAsNew((state) => [{ + * itemsProcessed, + * pubsubState: state, + * }]); + * ``` + * + * @param buildArgs Receives the post-drain pub/sub state and returns the + * positional args for the new run. + * @param options.publisherTtl Forwarded to `getState`. + * + * Does not return; `continueAsNew` rejects with an internal exception + * that the SDK uses to close the run. + */ + async continueAsNew( + buildArgs: (state: PubSubState) => Parameters, + options?: { publisherTtl?: number }, + ): Promise { + this.drain(); + await condition(allHandlersFinished); + return workflowContinueAsNew(...buildArgs(this.getState(options?.publisherTtl))); + } + /** * Discard log entries before upToOffset. * After truncation, polls requesting an offset before the new base diff --git a/packages/test/src/test-contrib-pubsub.ts b/packages/test/src/test-contrib-pubsub.ts index 85a154902..ff23e338b 100644 --- a/packages/test/src/test-contrib-pubsub.ts +++ b/packages/test/src/test-contrib-pubsub.ts @@ -25,6 +25,7 @@ import { helpers, makeTestFunction } from './helpers-integration'; import { activityPublishWorkflow, basicPubSubWorkflow, + continueAsNewHelperWorkflow, continueAsNewTypedWorkflow, flushOnExitWorkflow, getStateWithTtlQuery, @@ -586,6 +587,56 @@ test('continue_as_new_typed — log, offsets, AND dedup state survive CAN', asyn }); }); +test('continue_as_new_helper — log and offsets survive CAN via PubSub.continueAsNew', async (t) => { + const { createWorker, startWorkflow } = helpers(t); + const { env } = t.context; + const worker = await createWorker(); + await worker.runUntil(async () => { + const workflowId = `pubsub-can-helper-${randomUUID()}`; + const handle = await startWorkflow(continueAsNewHelperWorkflow, { + args: [{}], + workflowId, + }); + + await handle.signal<[PublishInput]>(pubsubPublishSignal, { + items: [entry('events', 'item-0'), entry('events', 'item-1')], + publisher_id: 'pub', + sequence: 1, + }); + + const before = await collectItems(handle, undefined, 0, 2); + t.is(before.length, 2); + + await handle.signal('triggerContinue'); + + const deadline = Date.now() + 10_000; + let newRunId: string | undefined; + while (Date.now() < deadline) { + const fresh = env.client.workflow.getHandle(workflowId); + const desc = await fresh.describe(); + if (desc.runId !== handle.firstExecutionRunId) { + newRunId = desc.runId; + break; + } + await new Promise((r) => setTimeout(r, 200)); + } + t.truthy(newRunId, 'CAN should produce a new run id'); + + const newHandle = env.client.workflow.getHandle(workflowId); + const afterItems = await collectItems(newHandle, undefined, 0, 2); + t.deepEqual( + afterItems.map((i) => payloadString(i.data)), + ['item-0', 'item-1'] + ); + t.deepEqual( + afterItems.map((i) => i.offset), + [0, 1] + ); + + await newHandle.signal('close'); + }); +}); + test('poll_more_ready_when_response_exceeds_size_limit — 1MB cap', async (t) => { const { createWorker, startWorkflow } = helpers(t); const { env } = t.context; diff --git a/packages/test/src/workflows/contrib-pubsub.ts b/packages/test/src/workflows/contrib-pubsub.ts index 78c383e83..c0210ed3e 100644 --- a/packages/test/src/workflows/contrib-pubsub.ts +++ b/packages/test/src/workflows/contrib-pubsub.ts @@ -136,7 +136,7 @@ export interface CANWorkflowInput { pubsubState?: PubSubState; } -/** CAN workflow using properly-typed pubsubState. */ +/** CAN workflow using properly-typed pubsubState (explicit recipe). */ export async function continueAsNewTypedWorkflow(input: CANWorkflowInput): Promise { const pubsub = new PubSub(input.pubsubState); let closed = false; @@ -157,3 +157,21 @@ export async function continueAsNewTypedWorkflow(input: CANWorkflowInput): Promi pubsubState: pubsub.getState(), }); } + +/** CAN workflow that uses the packaged `PubSub.continueAsNew` helper. */ +export async function continueAsNewHelperWorkflow(input: CANWorkflowInput): Promise { + const pubsub = new PubSub(input.pubsubState); + let closed = false; + let shouldContinue = false; + setHandler(closeSignal, () => { + closed = true; + }); + setHandler(triggerContinueSignal, () => { + shouldContinue = true; + }); + await condition(() => shouldContinue || closed); + if (closed) return; + await pubsub.continueAsNew((state) => [ + { pubsubState: state }, + ]); +} From 6a5294b059824c0c5ceb2430ef44463aaf0763d9 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Mon, 27 Apr 2026 22:57:10 -0700 Subject: [PATCH 23/75] pubsub: take Duration on public APIs, store ms internally The pubsub options previously took raw seconds-as-number, diverging from the SDK's standard convention where public-facing duration parameters take a `Duration` (the `ms` package's `StringValue | number`, where number is milliseconds). Align with the rest of the SDK: - PubSubClient: `batchInterval`, `maxRetryDuration`, and `pollCooldown` are now `Duration`. Defaults are unchanged in absolute terms but expressed as strings (`'2 seconds'`, `'10 minutes'`, `'100 milliseconds'`). - PubSub.getState/continueAsNew: `publisherTtl` is now `Duration`, default `'15 minutes'`. Internal storage in PubSubClient is renamed `*Ms` and converted at the boundary via `msToNumber`. This eliminates the previous `* 1000` / `/ 1000` conversion math at every use site (setTimeout takes ms, Date.now() returns ms). Numeric Duration inputs are now interpreted as milliseconds (was seconds). This is a breaking change for any external caller still on the old seconds-based numeric form; the contrib module is unreleased so no compatibility shim is provided. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/ai-sdk/src/activities.ts | 2 +- packages/contrib-pubsub/README.md | 10 ++--- packages/contrib-pubsub/src/broker.ts | 11 ++++-- packages/contrib-pubsub/src/client.ts | 39 ++++++++++--------- .../test/src/activities/contrib-pubsub.ts | 10 ++--- packages/test/src/test-contrib-pubsub.ts | 13 +++++-- 6 files changed, 47 insertions(+), 38 deletions(-) diff --git a/packages/ai-sdk/src/activities.ts b/packages/ai-sdk/src/activities.ts index 35e157534..ab40791bc 100644 --- a/packages/ai-sdk/src/activities.ts +++ b/packages/ai-sdk/src/activities.ts @@ -98,7 +98,7 @@ export function createActivities( * response-metadata, finish, ...); no normalization happens here. */ async invokeModelStreaming(args: InvokeModelArgs): Promise { - const pubsub = PubSubClient.fromActivity({ batchInterval: 0.1 }); + const pubsub = PubSubClient.fromActivity({ batchInterval: '100 milliseconds' }); pubsub.start(); const model = provider.languageModel(args.modelId); diff --git a/packages/contrib-pubsub/README.md b/packages/contrib-pubsub/README.md index 12257d9bd..43366ee16 100644 --- a/packages/contrib-pubsub/README.md +++ b/packages/contrib-pubsub/README.md @@ -55,7 +55,7 @@ import { Context } from '@temporalio/activity'; import { PubSubClient } from '@temporalio/contrib-pubsub'; export async function streamEvents(): Promise { - await using client = PubSubClient.fromActivity({ batchInterval: 2.0 }); + await using client = PubSubClient.fromActivity({ batchInterval: '2 seconds' }); client.start(); for await (const chunk of generateChunks()) { @@ -177,7 +177,7 @@ if (workflowInfo().continueAsNewSuggested) { | Method | Description | |---|---| | `publish(topic, value)` | Append to the log from workflow code. Accepts any value the default payload converter handles, or a pre-built `Payload`. | -| `getState(publisherTtl = 900)` | Snapshot for continue-as-new. Drops publisher dedup entries older than `publisherTtl` seconds. | +| `getState(publisherTtl?)` | Snapshot for continue-as-new. Drops publisher dedup entries older than `publisherTtl` (`Duration`, default `'15 minutes'`). | | `drain()` | Unblock polls and reject new ones. | | `continueAsNew(buildArgs, options?)` | Async. Drain, wait for handlers, then `continueAsNew` with `buildArgs(postDrainState)`. Use the explicit recipe with `makeContinueAsNewFunc` to pass other CAN options. | | `truncate(upToOffset)` | Discard log entries below the given offset. | @@ -201,16 +201,16 @@ Handlers registered automatically: | `stop()` | Stop the flusher and flush remaining items. | | `[Symbol.asyncDispose]()` | Supports `await using client = PubSubClient.create(...)`. | | `publish(topic, value, forceFlush = false)` | Buffer a message. `value` may be any converter-compatible object or a pre-built `Payload`. `forceFlush` wakes the flusher to send immediately. | -| `subscribe(topics?, fromOffset = 0, { pollCooldown = 0.1 })` | Async generator yielding `PubSubItem` with `data: Payload`. Always follows CAN chains when created via `create()`. Recovers automatically from `TruncatedOffset` by restarting from the current base offset. | +| `subscribe(topics?, fromOffset = 0, { pollCooldown = '100 milliseconds' })` | Async generator yielding `PubSubItem` with `data: Payload`. `pollCooldown` is a `Duration`. Always follows CAN chains when created via `create()`. Recovers automatically from `TruncatedOffset` by restarting from the current base offset. | | `getOffset()` | Query current global offset. | ### `PubSubClientOptions` | Option | Default | Description | |---|---|---| -| `batchInterval` | `2.0` | Seconds between automatic flushes. | +| `batchInterval` | `'2 seconds'` | Interval between automatic flushes (`Duration`). | | `maxBatchSize` | `undefined` | Auto-flush when buffer reaches this size. | -| `maxRetryDuration` | `600` | Seconds to retry a failed flush before `FlushTimeoutError`. Must be less than the workflow's `publisherTtl` to preserve exactly-once delivery. | +| `maxRetryDuration` | `'10 minutes'` | Time to retry a failed flush before `FlushTimeoutError` (`Duration`). Must be less than the workflow's `publisherTtl` to preserve exactly-once delivery. | ## Cross-Language Protocol diff --git a/packages/contrib-pubsub/src/broker.ts b/packages/contrib-pubsub/src/broker.ts index 6fbe981c9..32e6a6d88 100644 --- a/packages/contrib-pubsub/src/broker.ts +++ b/packages/contrib-pubsub/src/broker.ts @@ -29,6 +29,7 @@ import { defaultPayloadConverter, } from '@temporalio/workflow'; import { ApplicationFailure, type Payload, type Workflow } from '@temporalio/common'; +import { Duration, msToNumber } from '@temporalio/common/lib/time'; import { decodePayloadWire, encodePayloadProto, @@ -180,9 +181,11 @@ export class PubSub { /** * Return a serializable snapshot of pub/sub state for continue-as-new. - * Prunes publisher dedup entries older than publisherTtl seconds. + * Prunes publisher dedup entries older than `publisherTtl`. Defaults + * to 15 minutes. */ - getState(publisherTtl = 900): PubSubState { + getState(publisherTtl: Duration = '15 minutes'): PubSubState { + const ttlSeconds = msToNumber(publisherTtl) / 1000; const now = Date.now() / 1000; const activeSeqs: Record = {}; const activeSeen: Record = {}; @@ -191,7 +194,7 @@ export class PubSub { // handler always sets both maps together, so absence indicates a // malformed snapshot rather than a supported upgrade path. const ts = this.publisherLastSeen[pid] ?? 0; - if (now - ts < publisherTtl) { + if (now - ts < ttlSeconds) { activeSeqs[pid] = this.publisherSequences[pid] ?? 0; activeSeen[pid] = ts; } @@ -238,7 +241,7 @@ export class PubSub { */ async continueAsNew( buildArgs: (state: PubSubState) => Parameters, - options?: { publisherTtl?: number }, + options?: { publisherTtl?: Duration }, ): Promise { this.drain(); await condition(allHandlersFinished); diff --git a/packages/contrib-pubsub/src/client.ts b/packages/contrib-pubsub/src/client.ts index d8e177f40..9f0f4c962 100644 --- a/packages/contrib-pubsub/src/client.ts +++ b/packages/contrib-pubsub/src/client.ts @@ -22,6 +22,7 @@ import { WorkflowUpdateRPCTimeoutOrCancelledError, } from '@temporalio/client'; import { ApplicationFailure, defaultPayloadConverter, type Payload, type PayloadConverter } from '@temporalio/common'; +import { Duration, msToNumber } from '@temporalio/common/lib/time'; import { decodePayloadWire, encodePayloadWire, @@ -41,24 +42,24 @@ export class FlushTimeoutError extends Error { } export interface PubSubClientOptions { - /** Seconds between automatic flushes. Default: 2.0 */ - batchInterval?: number; + /** Interval between automatic flushes. Default: 2 seconds. */ + batchInterval?: Duration; /** Auto-flush when buffer reaches this size. */ maxBatchSize?: number; /** - * Maximum seconds to retry a failed flush before throwing. - * Must be less than the workflow's publisherTtl (default 900s) to preserve - * exactly-once delivery. Default: 600. + * Maximum time to retry a failed flush before throwing. Must be less + * than the workflow's `publisherTtl` (default 15 minutes) to preserve + * exactly-once delivery. Default: 10 minutes. */ - maxRetryDuration?: number; + maxRetryDuration?: Duration; } export interface SubscribeOptions { /** - * Minimum seconds between polls to avoid overwhelming the workflow when - * items arrive faster than the poll round-trip. Default: 0.1. + * Minimum interval between polls to avoid overwhelming the workflow + * when items arrive faster than the poll round-trip. Default: 100ms. */ - pollCooldown?: number; + pollCooldown?: Duration; } /** @@ -119,9 +120,9 @@ export class PubSubClient { private handle: WorkflowHandle; private client: Client | undefined; private readonly workflowId: string; - private readonly batchInterval: number; + private readonly batchIntervalMs: number; private readonly maxBatchSize: number | undefined; - private readonly maxRetryDuration: number; + private readonly maxRetryDurationMs: number; private readonly payloadConverter: PayloadConverter; private buffer: Array<{ topic: string; value: unknown }> = []; private pending: PublishEntry[] | null = null; @@ -139,9 +140,9 @@ export class PubSubClient { constructor(handle: WorkflowHandle, options?: PubSubClientOptions) { this.handle = handle; this.workflowId = handle.workflowId; - this.batchInterval = options?.batchInterval ?? 2.0; + this.batchIntervalMs = msToNumber(options?.batchInterval ?? '2 seconds'); this.maxBatchSize = options?.maxBatchSize; - this.maxRetryDuration = options?.maxRetryDuration ?? 600; + this.maxRetryDurationMs = msToNumber(options?.maxRetryDuration ?? '10 minutes'); this.payloadConverter = defaultPayloadConverter; } @@ -300,7 +301,7 @@ export class PubSubClient { private async runFlusher(): Promise { while (!this.flusherStopped) { - await Promise.race([this.flushEvent.wait(), sleep(this.batchInterval * 1000)]); + await Promise.race([this.flushEvent.wait(), sleep(this.batchIntervalMs)]); this.flushEvent.clear(); if (this.flusherStopped) break; try { @@ -347,7 +348,7 @@ export class PubSubClient { // Retry path: check max_retry_duration if ( this.pendingStartedAt !== null && - (Date.now() - this.pendingStartedAt) / 1000 > this.maxRetryDuration + Date.now() - this.pendingStartedAt > this.maxRetryDurationMs ) { // Advance confirmed sequence so the next batch gets a fresh sequence // number. Without this, the next batch reuses pendingSeq, which the @@ -358,7 +359,7 @@ export class PubSubClient { this.pendingSeq = 0; this.pendingStartedAt = null; throw new FlushTimeoutError( - `Flush retry exceeded maxRetryDuration (${this.maxRetryDuration}s). ` + + `Flush retry exceeded maxRetryDuration (${this.maxRetryDurationMs}ms). ` + 'Pending batch dropped. If the signal was delivered, items are in the log. ' + 'If not, they are lost.' ); @@ -409,7 +410,7 @@ export class PubSubClient { fromOffset = 0, options?: SubscribeOptions ): AsyncGenerator { - const pollCooldown = options?.pollCooldown ?? 0.1; + const pollCooldownMs = msToNumber(options?.pollCooldown ?? '100 milliseconds'); const topicFilter: string[] = topics === undefined ? [] : typeof topics === 'string' ? [topics] : topics; let offset = fromOffset; @@ -450,8 +451,8 @@ export class PubSubClient { } offset = result.next_offset; - if (!result.more_ready && pollCooldown > 0) { - await sleep(pollCooldown * 1000); + if (!result.more_ready && pollCooldownMs > 0) { + await sleep(pollCooldownMs); } } } diff --git a/packages/test/src/activities/contrib-pubsub.ts b/packages/test/src/activities/contrib-pubsub.ts index fbc1149d4..07b9d8769 100644 --- a/packages/test/src/activities/contrib-pubsub.ts +++ b/packages/test/src/activities/contrib-pubsub.ts @@ -11,7 +11,7 @@ import { PubSubClient } from '@temporalio/contrib-pubsub'; const encoder = new TextEncoder(); export async function publishItems(count: number): Promise { - await using client = PubSubClient.fromActivity({ batchInterval: 0.5 }); + await using client = PubSubClient.fromActivity({ batchInterval: '500 milliseconds' }); client.start(); for (let i = 0; i < count; i++) { Context.current().heartbeat(); @@ -21,7 +21,7 @@ export async function publishItems(count: number): Promise { export async function publishMultiTopic(count: number): Promise { const topics = ['a', 'b', 'c']; - await using client = PubSubClient.fromActivity({ batchInterval: 0.5 }); + await using client = PubSubClient.fromActivity({ batchInterval: '500 milliseconds' }); client.start(); for (let i = 0; i < count; i++) { Context.current().heartbeat(); @@ -36,7 +36,7 @@ export async function publishWithForceFlush(): Promise { // The hold is deliberately much longer than the test's collect timeout // so a regression (forceFlush no-op) surfaces as a missing item rather // than flaking on slow CI. - await using client = PubSubClient.fromActivity({ batchInterval: 60.0 }); + await using client = PubSubClient.fromActivity({ batchInterval: '60 seconds' }); client.start(); client.publish('events', encoder.encode('normal-0')); client.publish('events', encoder.encode('normal-1')); @@ -48,7 +48,7 @@ export async function publishWithForceFlush(): Promise { } export async function publishBatchTest(count: number): Promise { - await using client = PubSubClient.fromActivity({ batchInterval: 60.0 }); + await using client = PubSubClient.fromActivity({ batchInterval: '60 seconds' }); client.start(); for (let i = 0; i < count; i++) { Context.current().heartbeat(); @@ -59,7 +59,7 @@ export async function publishBatchTest(count: number): Promise { export async function publishWithMaxBatch(count: number): Promise { await using client = PubSubClient.fromActivity({ - batchInterval: 60.0, + batchInterval: '60 seconds', maxBatchSize: 3, }); client.start(); diff --git a/packages/test/src/test-contrib-pubsub.ts b/packages/test/src/test-contrib-pubsub.ts index ff23e338b..f676faf20 100644 --- a/packages/test/src/test-contrib-pubsub.ts +++ b/packages/test/src/test-contrib-pubsub.ts @@ -411,7 +411,7 @@ test('explicit_flush_barrier — flush() returns once items are confirmed', asyn await worker.runUntil(async () => { const handle = await startWorkflow(basicPubSubWorkflow, { args: [] }); - const pubsub = new PubSubClient(handle, { batchInterval: 60 }); + const pubsub = new PubSubClient(handle, { batchInterval: '60 seconds' }); // 1. Empty-buffer flush is a no-op (must not block). t.is(await pubsub.getOffset(), 0); @@ -477,7 +477,8 @@ test('ttl_pruning_in_get_state — old publisher pruned, new publisher kept', as }); // Sanity: pub-old is recorded (generous TTL retains it). - const before = await handle.query(getStateWithTtlQuery, 9999); + // Generous TTL: 9999 seconds, expressed in ms. + const before = await handle.query(getStateWithTtlQuery, 9999_000); t.true('pub-old' in before.publisher_sequences); // Wall-clock gap so workflow.time() advances between the two signals. @@ -489,7 +490,8 @@ test('ttl_pruning_in_get_state — old publisher pruned, new publisher kept', as sequence: 1, }); - const state = await handle.query(getStateWithTtlQuery, 0.5); + // 500 ms TTL: pub-old (~1s old) is pruned, pub-new (~0s old) is kept. + const state = await handle.query(getStateWithTtlQuery, 500); t.false('pub-old' in state.publisher_sequences); t.true('pub-new' in state.publisher_sequences); t.is(state.log.length, 2); @@ -758,7 +760,10 @@ test('flush_raises_after_max_retry_duration — timeout surfaces, client resumes // the client stays usable and subsequent publishes succeed. const { env } = t.context; const bogus = env.client.workflow.getHandle(`no-such-workflow-${randomUUID()}`); - const client = new PubSubClient(bogus, { batchInterval: 0.1, maxRetryDuration: 0.2 }); + const client = new PubSubClient(bogus, { + batchInterval: '100 milliseconds', + maxRetryDuration: '200 milliseconds', + }); client.start(); client.publish('events', encoder.encode('will-be-lost')); await new Promise((r) => setTimeout(r, 1500)); From 5a2b9909ed55b4ce212037681eaa1091256a9e4a Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Tue, 28 Apr 2026 20:35:08 -0700 Subject: [PATCH 24/75] contrib: rename contrib-pubsub package to contrib-workflow-stream Selected feature name is "Workflow Streams" (see docs/rename-to-workflow-streams.md and docs/naming-analysis.md in the streaming-comparisons superrepo). This mirrors the sdk-python rename landed in temporalio/sdk-python on the same branch (commit 5890c589 in that repo). Package: @temporalio/contrib-pubsub -> @temporalio/contrib-workflow-stream Directory: packages/contrib-pubsub/ -> packages/contrib-workflow-stream/ Classes: PubSub -> WorkflowStream PubSubClient -> WorkflowStreamClient PubSubState -> WorkflowStreamState PubSubItem -> WorkflowStreamItem _WireItem -> _WorkflowStreamWireItem Constants: pubsubPublishSignal -> workflowStreamPublishSignal pubsubPollUpdate -> workflowStreamPollUpdate pubsubOffsetQuery -> workflowStreamOffsetQuery Wire handlers: __temporal_pubsub_publish -> __temporal_workflow_stream_publish __temporal_pubsub_poll -> __temporal_workflow_stream_poll __temporal_pubsub_offset -> __temporal_workflow_stream_offset File rename: src/broker.ts -> src/stream.ts (the class is the stream itself, not a workflow; "broker" carried pub/sub framing) Method names publish/subscribe stay literal per the rename doc. The operation-level interfaces PublishEntry/PublishInput/PollInput/ PollResult/PublisherState are kept bare for parity with the verbs. Package directory uses singular "contrib-workflow-stream" to match every other single-feature package in this workspace (activity, client, worker, workflow, etc.); plurals are reserved for genuine collections. Cross-package callers updated: - @temporalio/ai-sdk (depends on contrib-workflow-stream) - @temporalio/test (test workflows, activities, e2e + interop tests) - pnpm-workspace.yaml, pnpm-lock.yaml regenerated The wire-handler rename does break compatibility with any in-flight workflow; per the rename doc that is acceptable since this contrib has not been publicly released. Note: `pnpm --recursive run build` fails on a pre-existing ReadableStream type error in packages/ai-sdk/src/provider.ts (introduced in ca800ab3, before this rename). The renamed contrib-workflow-stream package itself builds clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/ai-sdk/package.json | 2 +- packages/ai-sdk/src/activities.ts | 12 +- packages/ai-sdk/src/provider.ts | 8 +- packages/contrib-pubsub/src/index.ts | 34 ---- .../README.md | 108 ++++++------ .../package.json | 6 +- .../src/client.ts | 38 ++--- packages/contrib-workflow-stream/src/index.ts | 35 ++++ .../src/stream.ts} | 64 ++++---- .../src/types.ts | 24 +-- .../tsconfig.json | 0 packages/test/package.json | 2 +- ...b-pubsub.ts => contrib-workflow-stream.ts} | 16 +- ...> test-contrib-workflow-stream-interop.ts} | 6 +- ...sub.ts => test-contrib-workflow-stream.ts} | 154 +++++++++--------- ...b-pubsub.ts => contrib-workflow-stream.ts} | 60 +++---- pnpm-lock.yaml | 10 +- pnpm-workspace.yaml | 2 +- 18 files changed, 294 insertions(+), 287 deletions(-) delete mode 100644 packages/contrib-pubsub/src/index.ts rename packages/{contrib-pubsub => contrib-workflow-stream}/README.md (58%) rename packages/{contrib-pubsub => contrib-workflow-stream}/package.json (81%) rename packages/{contrib-pubsub => contrib-workflow-stream}/src/client.ts (92%) create mode 100644 packages/contrib-workflow-stream/src/index.ts rename packages/{contrib-pubsub/src/broker.ts => contrib-workflow-stream/src/stream.ts} (82%) rename packages/{contrib-pubsub => contrib-workflow-stream}/src/types.ts (93%) rename packages/{contrib-pubsub => contrib-workflow-stream}/tsconfig.json (100%) rename packages/test/src/activities/{contrib-pubsub.ts => contrib-workflow-stream.ts} (76%) rename packages/test/src/{test-contrib-pubsub-interop.ts => test-contrib-workflow-stream-interop.ts} (97%) rename packages/test/src/{test-contrib-pubsub.ts => test-contrib-workflow-stream.ts} (84%) rename packages/test/src/workflows/{contrib-pubsub.ts => contrib-workflow-stream.ts} (72%) diff --git a/packages/ai-sdk/package.json b/packages/ai-sdk/package.json index a9a40549b..ef081e6ab 100644 --- a/packages/ai-sdk/package.json +++ b/packages/ai-sdk/package.json @@ -18,7 +18,7 @@ "@temporalio/activity": "workspace:*", "@temporalio/client": "workspace:*", "@temporalio/common": "workspace:*", - "@temporalio/contrib-pubsub": "workspace:*", + "@temporalio/contrib-workflow-stream": "workspace:*", "@temporalio/plugin": "workspace:*", "@temporalio/workflow": "workspace:*", "@ungap/structured-clone": "^1.3.0", diff --git a/packages/ai-sdk/src/activities.ts b/packages/ai-sdk/src/activities.ts index ab40791bc..103dbf163 100644 --- a/packages/ai-sdk/src/activities.ts +++ b/packages/ai-sdk/src/activities.ts @@ -13,7 +13,7 @@ import type { import { asSchema, type Schema, type ToolExecutionOptions } from 'ai'; import { ApplicationFailure } from '@temporalio/common'; import { Context } from '@temporalio/activity'; -import { PubSubClient } from '@temporalio/contrib-pubsub'; +import { WorkflowStreamClient } from '@temporalio/contrib-workflow-stream'; import type { McpClientFactories, McpClientFactory } from './mcp'; const EVENTS_TOPIC = 'events'; @@ -92,14 +92,14 @@ export function createActivities( * Streaming-aware model activity. * * Calls `model.doStream()`, publishes each yielded AI SDK stream part - * as JSON to the pubsub side channel, and returns the assembled + * as JSON to the stream side channel, and returns the assembled * `LanguageModelV3GenerateResult`. Consumers receive native AI SDK * stream-part types (text-delta, reasoning-delta, tool-input-delta, * response-metadata, finish, ...); no normalization happens here. */ async invokeModelStreaming(args: InvokeModelArgs): Promise { - const pubsub = PubSubClient.fromActivity({ batchInterval: '100 milliseconds' }); - pubsub.start(); + const stream = WorkflowStreamClient.fromActivity({ batchInterval: '100 milliseconds' }); + stream.start(); const model = provider.languageModel(args.modelId); const streamResult = await model.doStream(args.options); @@ -128,7 +128,7 @@ export function createActivities( // Publish the raw stream part as JSON so consumers can switch on // the native AI SDK type. Accumulation below is for the final // assembled result this activity returns. - pubsub.publish(EVENTS_TOPIC, encoder.encode(JSON.stringify(part))); + stream.publish(EVENTS_TOPIC, encoder.encode(JSON.stringify(part))); switch (part.type) { case 'stream-start': @@ -186,7 +186,7 @@ export function createActivities( } } } finally { - await pubsub.stop(); + await stream.stop(); } return { diff --git a/packages/ai-sdk/src/provider.ts b/packages/ai-sdk/src/provider.ts index 05cbccfff..f43039173 100644 --- a/packages/ai-sdk/src/provider.ts +++ b/packages/ai-sdk/src/provider.ts @@ -31,8 +31,8 @@ export interface TemporalProviderOptions { languageModel?: ActivityOptions & { /** * When true, model calls use the streaming LLM endpoint and publish - * token events via PubSubClient. The workflow receives a complete result; - * real-time streaming happens via pubsub as a side channel. + * token events via WorkflowStreamClient. The workflow receives a complete result; + * real-time streaming happens via stream as a side channel. */ streaming?: boolean; }; @@ -92,7 +92,7 @@ export class TemporalLanguageModel implements LanguageModelV3 { ); } - // Call the streaming activity, which publishes tokens via pubsub + // Call the streaming activity, which publishes tokens via stream // and returns the accumulated result. const activities = workflow.proxyActivities({ startToCloseTimeout: '10 minutes', @@ -104,7 +104,7 @@ export class TemporalLanguageModel implements LanguageModelV3 { } // Wrap the accumulated result as a ReadableStream that replays the content. - // Real-time token streaming already happened via pubsub in the activity. + // Real-time token streaming already happened via stream in the activity. const stream = new ReadableStream({ start(controller: ReadableStreamDefaultController) { controller.enqueue({ type: 'stream-start', warnings: result.warnings ?? [] }); diff --git a/packages/contrib-pubsub/src/index.ts b/packages/contrib-pubsub/src/index.ts deleted file mode 100644 index 303cf0944..000000000 --- a/packages/contrib-pubsub/src/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Pub/sub support for Temporal workflows. - * - * This module provides a reusable pub/sub pattern where a workflow acts as a - * message broker. External clients (activities, starters, other services) - * publish and subscribe through the workflow handle using Temporal primitives. - * - * Payloads are Temporal `Payload`s carrying the encoding metadata needed for - * typed decode and cross-language interop. The codec chain (encryption, - * PII-redaction, compression) runs once on the signal/update envelope that - * carries each batch — not per item. - * - * @module - */ - -export type { - PubSubItem, - PublishEntry, - PublishInput, - PollInput, - PollResult, - PubSubState, -} from './types'; -export { - encodeBase64, - decodeBase64, - encodePayloadProto, - decodePayloadProto, - encodePayloadWire, - decodePayloadWire, -} from './types'; -export { PubSub, pubsubPublishSignal, pubsubPollUpdate, pubsubOffsetQuery } from './broker'; -export { PubSubClient, FlushTimeoutError } from './client'; -export type { PubSubClientOptions, SubscribeOptions } from './client'; diff --git a/packages/contrib-pubsub/README.md b/packages/contrib-workflow-stream/README.md similarity index 58% rename from packages/contrib-pubsub/README.md rename to packages/contrib-workflow-stream/README.md index 43366ee16..7f5bebb0a 100644 --- a/packages/contrib-pubsub/README.md +++ b/packages/contrib-workflow-stream/README.md @@ -1,4 +1,9 @@ -# Temporal Workflow Pub/Sub +# Temporal Workflow Streams + +**Workflow Streams** — a Temporal SDK contrib library that gives a workflow a +durable, offset-addressed event channel built from Signals and polling Updates +with an SSE bridge. Cost scales with durable batches, not tokens. Latency is +around 100ms per roundtrip; not for ultra-low-latency voice. Workflows sometimes need to push incremental updates to external observers. Examples include providing customer updates during order processing, creating @@ -7,12 +12,13 @@ long-running data pipeline. Temporal's core primitives (workflows, signals, and updates) already provide the building blocks, but wiring up batching, offset tracking, topic filtering, and continue-as-new hand-off is non-trivial. -This module packages that boilerplate into a reusable handle and client. The -workflow acts as a message broker that maintains an append-only log. -Applications can interact directly from the workflow, or from external clients -such as activities, starters, and other workflows. Under the hood, publishing -uses signals (fire-and-forget) while subscribing uses updates (long-poll). A -configurable batching coalesces high-frequency events, improving efficiency. +This module packages that boilerplate into a reusable workflow-side stream +object and external client. The workflow holds an append-only log of +`(topic, data)` entries. Applications can interact directly from the workflow, +or from external clients such as activities, starters, and other workflows. +Under the hood, publishing uses signals (fire-and-forget) while subscribing +uses updates (long-poll). A configurable batching coalesces high-frequency +events, improving efficiency. Payloads are Temporal `Payload`s carrying the encoding metadata needed for typed decode and cross-language interop. The codec chain (encryption, @@ -24,38 +30,38 @@ codec behavior is symmetric between workflow-side and client-side publishing. ### Workflow side -Construct `new PubSub()` at the start of your workflow function and use the +Construct `new WorkflowStream()` at the start of your workflow function and use the returned object to publish: ```typescript -import { PubSub } from '@temporalio/contrib-pubsub'; +import { WorkflowStream } from '@temporalio/contrib-workflow-stream'; export async function myWorkflow(input: MyInput): Promise { - const pubsub = new PubSub(); + const stream = new WorkflowStream(); - pubsub.publish('status', { state: 'started' }); + stream.publish('status', { state: 'started' }); await doWork(); - pubsub.publish('status', { state: 'done' }); + stream.publish('status', { state: 'done' }); } ``` -The `PubSub` constructor registers the `__temporal_pubsub_publish` signal, -`__temporal_pubsub_poll` update, and `__temporal_pubsub_offset` query handlers on your workflow. +The `WorkflowStream` constructor registers the `__temporal_workflow_stream_publish` signal, +`__temporal_workflow_stream_poll` update, and `__temporal_workflow_stream_offset` query handlers on your workflow. Any value the default payload converter can serialize (JSON, `Uint8Array`, or a pre-built `Payload`) can be passed to `publish`. ### Activity side (publishing) -Use `PubSubClient.fromActivity()` with `await using` for batched publishing +Use `WorkflowStreamClient.fromActivity()` with `await using` for batched publishing from inside an activity. The client and workflow ID are pulled from the activity context: ```typescript import { Context } from '@temporalio/activity'; -import { PubSubClient } from '@temporalio/contrib-pubsub'; +import { WorkflowStreamClient } from '@temporalio/contrib-workflow-stream'; export async function streamEvents(): Promise { - await using client = PubSubClient.fromActivity({ batchInterval: '2 seconds' }); + await using client = WorkflowStreamClient.fromActivity({ batchInterval: '2 seconds' }); client.start(); for await (const chunk of generateChunks()) { @@ -66,12 +72,12 @@ export async function streamEvents(): Promise { } ``` -Outside an activity (e.g., a starter or BFF), use `PubSubClient.create()` +Outside an activity (e.g., a starter or BFF), use `WorkflowStreamClient.create()` with an explicit client and workflow id. If `await using` is not available, call `start()` and `await stop()` explicitly: ```typescript -const client = PubSubClient.create(temporalClient, workflowId); +const client = WorkflowStreamClient.create(temporalClient, workflowId); client.start(); try { client.publish('events', data); @@ -89,13 +95,13 @@ client.publish('events', data, true); ### Subscribing -Use `PubSubClient.create()` and iterate `subscribe()`: +Use `WorkflowStreamClient.create()` and iterate `subscribe()`: ```typescript import { defaultPayloadConverter } from '@temporalio/common'; -import { PubSubClient } from '@temporalio/contrib-pubsub'; +import { WorkflowStreamClient } from '@temporalio/contrib-workflow-stream'; -const client = PubSubClient.create(temporalClient, workflowId); +const client = WorkflowStreamClient.create(temporalClient, workflowId); for await (const item of client.subscribe(['events'], 0)) { // item.data is a Payload; decode with a payload converter const value = defaultPayloadConverter.fromPayload(item.data); @@ -109,46 +115,46 @@ value round-trips (`json/plain` for JSON, `binary/plain` for `Uint8Array`, etc.) ## Topics -Topics allow subscribers to receive a subset of the messages in the pub/sub +Topics allow subscribers to receive a subset of the messages in the workflow stream system. Subscribers can request a list of specific topics, or provide an empty list (or omit the argument) to receive messages from all topics. Publishing to a topic implicitly creates it. ## Continue-as-new -Carry both your application state and pub/sub state across continue-as-new +Carry both your application state and workflow stream state across continue-as-new boundaries: ```typescript import { continueAsNew, workflowInfo } from '@temporalio/workflow'; -import { PubSub, type PubSubState } from '@temporalio/contrib-pubsub'; +import { WorkflowStream, type WorkflowStreamState } from '@temporalio/contrib-workflow-stream'; interface WorkflowInput { itemsProcessed: number; - pubsubState?: PubSubState; + streamState?: WorkflowStreamState; } export async function myWorkflow(input: WorkflowInput): Promise { let itemsProcessed = input.itemsProcessed; - const pubsub = new PubSub(input.pubsubState); + const stream = new WorkflowStream(input.streamState); // ... do work, updating itemsProcessed ... if (workflowInfo().continueAsNewSuggested) { - await pubsub.continueAsNew((state) => [{ + await stream.continueAsNew((state) => [{ itemsProcessed, - pubsubState: state, + streamState: state, }]); } } ``` -`PubSub.continueAsNew(buildArgs)` drains waiting subscribers, waits for +`WorkflowStream.continueAsNew(buildArgs)` drains waiting subscribers, waits for in-flight handlers to finish, then calls `continueAsNew` with the args returned by `buildArgs(postDrainState)`. The lambda receives the -post-drain `PubSubState` as its only argument so the snapshot is +post-drain `WorkflowStreamState` as its only argument so the snapshot is guaranteed to happen *after* drain. Subscribers created via -`PubSubClient.create()` automatically follow continue-as-new chains. +`WorkflowStreamClient.create()` automatically follow continue-as-new chains. If you need to pass other CAN options (search attributes, memo, non-default `taskQueue`, etc.), fall back to the explicit recipe with @@ -158,21 +164,21 @@ non-default `taskQueue`, etc.), fall back to the explicit recipe with import { condition, allHandlersFinished, makeContinueAsNewFunc } from '@temporalio/workflow'; if (workflowInfo().continueAsNewSuggested) { - pubsub.drain(); + stream.drain(); await condition(allHandlersFinished); const continueWithOptions = makeContinueAsNewFunc({ taskQueue: 'other-tq', }); await continueWithOptions({ itemsProcessed, - pubsubState: pubsub.getState(), + streamState: stream.getState(), }); } ``` ## API Reference -### `new PubSub(priorState?)` +### `new WorkflowStream(priorState?)` | Method | Description | |---|---| @@ -186,25 +192,25 @@ Handlers registered automatically: | Kind | Name | Description | |---|---|---| -| Signal | `__temporal_pubsub_publish` | Receive external publications. | -| Update | `__temporal_pubsub_poll` | Long-poll subscription. | -| Query | `__temporal_pubsub_offset` | Current global offset. | +| Signal | `__temporal_workflow_stream_publish` | Receive external publications. | +| Update | `__temporal_workflow_stream_poll` | Long-poll subscription. | +| Query | `__temporal_workflow_stream_offset` | Current global offset. | -### `PubSubClient` +### `WorkflowStreamClient` | Method | Description | |---|---| -| `PubSubClient.create(client, workflowId, options?)` | Factory for use outside an activity (starters, BFFs). Enables CAN following in `subscribe()`; uses the `Client`'s configured payload converter. | -| `PubSubClient.fromActivity(options?)` | Factory for use from within an activity — pulls the client and parent workflow id from the activity context. | -| `new PubSubClient(handle, options?)` | From a handle (no CAN following). | +| `WorkflowStreamClient.create(client, workflowId, options?)` | Factory for use outside an activity (starters, BFFs). Enables CAN following in `subscribe()`; uses the `Client`'s configured payload converter. | +| `WorkflowStreamClient.fromActivity(options?)` | Factory for use from within an activity — pulls the client and parent workflow id from the activity context. | +| `new WorkflowStreamClient(handle, options?)` | From a handle (no CAN following). | | `start()` | Start the background flusher. | | `stop()` | Stop the flusher and flush remaining items. | -| `[Symbol.asyncDispose]()` | Supports `await using client = PubSubClient.create(...)`. | +| `[Symbol.asyncDispose]()` | Supports `await using client = WorkflowStreamClient.create(...)`. | | `publish(topic, value, forceFlush = false)` | Buffer a message. `value` may be any converter-compatible object or a pre-built `Payload`. `forceFlush` wakes the flusher to send immediately. | -| `subscribe(topics?, fromOffset = 0, { pollCooldown = '100 milliseconds' })` | Async generator yielding `PubSubItem` with `data: Payload`. `pollCooldown` is a `Duration`. Always follows CAN chains when created via `create()`. Recovers automatically from `TruncatedOffset` by restarting from the current base offset. | +| `subscribe(topics?, fromOffset = 0, { pollCooldown = '100 milliseconds' })` | Async generator yielding `WorkflowStreamItem` with `data: Payload`. `pollCooldown` is a `Duration`. Always follows CAN chains when created via `create()`. Recovers automatically from `TruncatedOffset` by restarting from the current base offset. | | `getOffset()` | Query current global offset. | -### `PubSubClientOptions` +### `WorkflowStreamClientOptions` | Option | Default | Description | |---|---|---| @@ -214,19 +220,19 @@ Handlers registered automatically: ## Cross-Language Protocol -Any Temporal client can interact with a pub/sub workflow using these fixed +Any Temporal client can interact with a workflow stream workflow using these fixed handler names: -1. **Publish**: signal `__temporal_pubsub_publish` with `PublishInput` -2. **Subscribe**: update `__temporal_pubsub_poll` with `PollInput` -> `PollResult` -3. **Offset**: query `__temporal_pubsub_offset` -> `number` +1. **Publish**: signal `__temporal_workflow_stream_publish` with `PublishInput` +2. **Subscribe**: update `__temporal_workflow_stream_poll` with `PollInput` -> `PollResult` +3. **Offset**: query `__temporal_workflow_stream_offset` -> `number` -Each `PublishEntry.data` / `_WireItem.data` is a base64-encoded +Each `PublishEntry.data` / `_WorkflowStreamWireItem.data` is a base64-encoded `temporal.api.common.v1.Payload` protobuf (`Payload.SerializeToString()` in Python; equivalent `encodePayloadProto()` in this package). This keeps the envelope JSON-serializable while preserving `Payload.metadata` for codec and typed-decode paths. Cross-language clients can publish and subscribe by following the same base64-of-serialized-`Payload` shape. The envelope types -(`PublishInput`, `PollResult`, `PubSubState`) require the default (JSON) data +(`PublishInput`, `PollResult`, `WorkflowStreamState`) require the default (JSON) data converter — custom converters on the envelope layer break cross-language interop. diff --git a/packages/contrib-pubsub/package.json b/packages/contrib-workflow-stream/package.json similarity index 81% rename from packages/contrib-pubsub/package.json rename to packages/contrib-workflow-stream/package.json index 3097db525..fa0a92a3e 100644 --- a/packages/contrib-pubsub/package.json +++ b/packages/contrib-workflow-stream/package.json @@ -1,7 +1,7 @@ { - "name": "@temporalio/contrib-pubsub", + "name": "@temporalio/contrib-workflow-stream", "version": "1.15.0", - "description": "Temporal.io SDK Pub/Sub contrib module", + "description": "Temporal.io SDK Workflow Streams contrib module", "main": "lib/index.js", "types": "./lib/index.d.ts", "scripts": { @@ -9,7 +9,7 @@ }, "keywords": [ "temporal", - "pubsub", + "stream", "streaming" ], "author": "Temporal Technologies Inc. ", diff --git a/packages/contrib-pubsub/src/client.ts b/packages/contrib-workflow-stream/src/client.ts similarity index 92% rename from packages/contrib-pubsub/src/client.ts rename to packages/contrib-workflow-stream/src/client.ts index 9f0f4c962..d3bfe0313 100644 --- a/packages/contrib-pubsub/src/client.ts +++ b/packages/contrib-workflow-stream/src/client.ts @@ -1,8 +1,8 @@ /** - * External-side pub/sub client. + * External-side workflow stream client. * * Used by activities, starters, and any code with a workflow handle to publish - * messages and subscribe to topics on a pub/sub workflow. + * messages and subscribe to topics on a workflow stream workflow. * * Each published value is turned into a `Payload` via the client's payload * converter. The codec chain (encryption, PII-redaction, compression) is @@ -28,7 +28,7 @@ import { encodePayloadWire, type PollInput, type PollResult, - type PubSubItem, + type WorkflowStreamItem, type PublishEntry, type PublishInput, } from './types'; @@ -41,7 +41,7 @@ export class FlushTimeoutError extends Error { } } -export interface PubSubClientOptions { +export interface WorkflowStreamClientOptions { /** Interval between automatic flushes. Default: 2 seconds. */ batchInterval?: Duration; /** Auto-flush when buffer reaches this size. */ @@ -116,7 +116,7 @@ function isPayload(value: unknown): value is Payload { ); } -export class PubSubClient { +export class WorkflowStreamClient { private handle: WorkflowHandle; private client: Client | undefined; private readonly workflowId: string; @@ -137,7 +137,7 @@ export class PubSubClient { private flusherError: Error | undefined; private currentFlush: Promise | null = null; - constructor(handle: WorkflowHandle, options?: PubSubClientOptions) { + constructor(handle: WorkflowHandle, options?: WorkflowStreamClientOptions) { this.handle = handle; this.workflowId = handle.workflowId; this.batchIntervalMs = msToNumber(options?.batchInterval ?? '2 seconds'); @@ -147,20 +147,20 @@ export class PubSubClient { } /** - * Create a PubSubClient from an explicit Temporal client and workflow ID. + * Create a WorkflowStreamClient from an explicit Temporal client and workflow ID. * * Use this when the caller has an explicit `Client` and `workflowId` in * hand (starters, BFFs, other workflows' activities). For code running * inside an activity that targets its own parent workflow, use - * {@link PubSubClient.fromActivity}. + * {@link WorkflowStreamClient.fromActivity}. * * A client created through this method follows continue-as-new chains in * `subscribe()` and uses the client's payload converter for per-item * `Payload` construction. */ - static create(client: Client, workflowId: string, options?: PubSubClientOptions): PubSubClient { + static create(client: Client, workflowId: string, options?: WorkflowStreamClientOptions): WorkflowStreamClient { const handle = client.workflow.getHandle(workflowId); - const instance = new PubSubClient(handle, options); + const instance = new WorkflowStreamClient(handle, options); instance.client = client; // Prefer the Client's configured converter so custom converters flow // through; fall back to the default if unset. @@ -172,15 +172,15 @@ export class PubSubClient { } /** - * Create a PubSubClient targeting the current activity's parent workflow. + * Create a WorkflowStreamClient targeting the current activity's parent workflow. * * Must be called from within an activity. The Temporal client and * parent workflow id are taken from the activity context. */ - static fromActivity(options?: PubSubClientOptions): PubSubClient { + static fromActivity(options?: WorkflowStreamClientOptions): WorkflowStreamClient { const ctx = ActivityContext.current(); const workflowId = ctx.info.workflowExecution.workflowId; - return PubSubClient.create(ctx.client, workflowId, options); + return WorkflowStreamClient.create(ctx.client, workflowId, options); } /** Start the background flusher. Call before publishing. */ @@ -267,7 +267,7 @@ export class PubSubClient { } } - /** Dispose pattern: `await using client = PubSubClient.create(...)`. */ + /** Dispose pattern: `await using client = WorkflowStreamClient.create(...)`. */ async [Symbol.asyncDispose](): Promise { await this.stop(); } @@ -382,7 +382,7 @@ export class PubSubClient { // On failure, the signal throws and pending stays set for retry. // On success, advance confirmed sequence and clear pending. - await this.handle.signal<[PublishInput]>('__temporal_pubsub_publish', { + await this.handle.signal<[PublishInput]>('__temporal_workflow_stream_publish', { items: batch, publisher_id: this.publisherId, sequence: seq, @@ -400,7 +400,7 @@ export class PubSubClient { * `defaultPayloadConverter.fromPayload(item.data)` to decode. * * Automatically follows continue-as-new chains when created via - * {@link PubSubClient.create}. + * {@link WorkflowStreamClient.create}. * * @param topics - Topic filter. A single topic name, an array of topic * names, or undefined. Undefined or an empty array means all topics. @@ -409,7 +409,7 @@ export class PubSubClient { topics?: string | string[], fromOffset = 0, options?: SubscribeOptions - ): AsyncGenerator { + ): AsyncGenerator { const pollCooldownMs = msToNumber(options?.pollCooldown ?? '100 milliseconds'); const topicFilter: string[] = topics === undefined ? [] : typeof topics === 'string' ? [topics] : topics; @@ -418,7 +418,7 @@ export class PubSubClient { while (true) { let result: PollResult; try { - result = await this.handle.executeUpdate('__temporal_pubsub_poll', { + result = await this.handle.executeUpdate('__temporal_workflow_stream_poll', { args: [{ topics: topicFilter, from_offset: offset }], }); } catch (err) { @@ -459,7 +459,7 @@ export class PubSubClient { /** Query the current global offset. */ async getOffset(): Promise { - return this.handle.query('__temporal_pubsub_offset'); + return this.handle.query('__temporal_workflow_stream_offset'); } /** diff --git a/packages/contrib-workflow-stream/src/index.ts b/packages/contrib-workflow-stream/src/index.ts new file mode 100644 index 000000000..a6f5a4e80 --- /dev/null +++ b/packages/contrib-workflow-stream/src/index.ts @@ -0,0 +1,35 @@ +/** + * Workflow Streams for Temporal workflows. + * + * This module gives a workflow a durable, offset-addressed event channel built + * from Signals and polling Updates. The workflow holds an append-only log of + * `(topic, data)` entries. External clients (activities, starters, other + * services) publish and subscribe through the workflow handle. + * + * Payloads are Temporal `Payload`s carrying the encoding metadata needed for + * typed decode and cross-language interop. The codec chain (encryption, + * PII-redaction, compression) runs once on the signal/update envelope that + * carries each batch — not per item. + * + * @module + */ + +export type { + WorkflowStreamItem, + PublishEntry, + PublishInput, + PollInput, + PollResult, + WorkflowStreamState, +} from './types'; +export { + encodeBase64, + decodeBase64, + encodePayloadProto, + decodePayloadProto, + encodePayloadWire, + decodePayloadWire, +} from './types'; +export { WorkflowStream, workflowStreamPublishSignal, workflowStreamPollUpdate, workflowStreamOffsetQuery } from './stream'; +export { WorkflowStreamClient, FlushTimeoutError } from './client'; +export type { WorkflowStreamClientOptions, SubscribeOptions } from './client'; diff --git a/packages/contrib-pubsub/src/broker.ts b/packages/contrib-workflow-stream/src/stream.ts similarity index 82% rename from packages/contrib-pubsub/src/broker.ts rename to packages/contrib-workflow-stream/src/stream.ts index 32e6a6d88..ff5d59b26 100644 --- a/packages/contrib-pubsub/src/broker.ts +++ b/packages/contrib-workflow-stream/src/stream.ts @@ -1,17 +1,17 @@ /** - * Workflow-side pub/sub broker. + * Workflow-side stream object for Workflow Streams. * - * Instantiate `PubSub` once at the start of your workflow function; the - * constructor registers the pub/sub signal, update, and query handlers on + * Instantiate `WorkflowStream` once at the start of your workflow function; the + * constructor registers the workflow stream signal, update, and query handlers on * the current workflow via `setHandler`. * * For workflows that support continue-as-new, include a - * `PubSubState | undefined` field on the workflow input and pass it as + * `WorkflowStreamState | undefined` field on the workflow input and pass it as * `priorState` — it is `undefined` on fresh starts and carries * accumulated state on continue-as-new. * - * Both workflow-side `PubSub.publish` and client-side - * `PubSubClient.publish` use the default payload converter for per-item + * Both workflow-side `WorkflowStream.publish` and client-side + * `WorkflowStreamClient.publish` use the default payload converter for per-item * `Payload` construction. The codec chain (encryption, PII-redaction, * compression) is NOT applied per item on either side — it runs once at * the envelope level when Temporal's SDK encodes the signal/update that @@ -37,9 +37,9 @@ import { encodeBase64, type PollInput, type PollResult, - type PubSubState, + type WorkflowStreamState, type PublishInput, - type _WireItem, + type _WorkflowStreamWireItem, } from './types'; const BINARY_PLAIN_ENCODING = new TextEncoder().encode('binary/plain'); @@ -61,9 +61,9 @@ function isUint8ArrayLike(value: unknown): value is ArrayLike { } // Fixed handler names for cross-language interop -export const pubsubPublishSignal = defineSignal<[PublishInput]>('__temporal_pubsub_publish'); -export const pubsubPollUpdate = defineUpdate('__temporal_pubsub_poll'); -export const pubsubOffsetQuery = defineQuery('__temporal_pubsub_offset'); +export const workflowStreamPublishSignal = defineSignal<[PublishInput]>('__temporal_workflow_stream_publish'); +export const workflowStreamPollUpdate = defineUpdate('__temporal_workflow_stream_poll'); +export const workflowStreamOffsetQuery = defineQuery('__temporal_workflow_stream_offset'); const MAX_POLL_RESPONSE_BYTES = 1_000_000; @@ -93,35 +93,35 @@ function isPayload(value: unknown): value is Payload { } /** - * Workflow-side pub/sub broker. + * Workflow-side stream object — append-only log with publish/poll handlers. * * Construct once at the start of your workflow function; the constructor - * registers the pub/sub signal, update, and query handlers on the current + * registers the workflow stream signal, update, and query handlers on the current * workflow. * * Registered handlers: * - * - `__temporal_pubsub_publish` signal — external publish with dedup - * - `__temporal_pubsub_poll` update — long-poll subscription - * - `__temporal_pubsub_offset` query — current log length + * - `__temporal_workflow_stream_publish` signal — external publish with dedup + * - `__temporal_workflow_stream_poll` update — long-poll subscription + * - `__temporal_workflow_stream_offset` query — current log length * - * For continue-as-new, thread a `PubSubState | undefined` field through + * For continue-as-new, thread a `WorkflowStreamState | undefined` field through * the workflow input and pass it as `priorState`. */ -export class PubSub { +export class WorkflowStream { private log: InternalLogEntry[]; private baseOffset: number; private readonly publisherSequences: Record; private readonly publisherLastSeen: Record; private draining = false; - constructor(priorState?: PubSubState) { - // Note: sdk-python guards against a second `PubSub(...)` call on the + constructor(priorState?: WorkflowStreamState) { + // Note: sdk-python guards against a second `WorkflowStream(...)` call on the // same workflow by checking `workflow.get_signal_handler(...)`. The // TypeScript workflow runtime does not expose that inspection API, // and `reuseV8Context` shares module-level state across workflow // executions — so a naive module-level flag would either fire - // spuriously or miss real duplicates. Constructing `PubSub` twice in + // spuriously or miss real duplicates. Constructing `WorkflowStream` twice in // the same workflow silently replaces the handlers; users should // construct once at the top of the workflow function. this.log = priorState?.log @@ -138,15 +138,15 @@ export class PubSub { ? { ...priorState.publisher_last_seen } : {}; - setHandler(pubsubPublishSignal, (input: PublishInput) => this.onPublish(input)); - setHandler(pubsubPollUpdate, (input: PollInput) => this.onPoll(input), { + setHandler(workflowStreamPublishSignal, (input: PublishInput) => this.onPublish(input)); + setHandler(workflowStreamPollUpdate, (input: PollInput) => this.onPoll(input), { validator: (_input: PollInput) => { if (this.draining) { throw new Error('Workflow is draining for continue-as-new'); } }, }); - setHandler(pubsubOffsetQuery, () => this.baseOffset + this.log.length); + setHandler(workflowStreamOffsetQuery, () => this.baseOffset + this.log.length); } /** @@ -180,11 +180,11 @@ export class PubSub { } /** - * Return a serializable snapshot of pub/sub state for continue-as-new. + * Return a serializable snapshot of workflow stream state for continue-as-new. * Prunes publisher dedup entries older than `publisherTtl`. Defaults * to 15 minutes. */ - getState(publisherTtl: Duration = '15 minutes'): PubSubState { + getState(publisherTtl: Duration = '15 minutes'): WorkflowStreamState { const ttlSeconds = msToNumber(publisherTtl) / 1000; const now = Date.now() / 1000; const activeSeqs: Record = {}; @@ -221,18 +221,18 @@ export class PubSub { * varies across CAN boundaries is the workflow's own arguments. * * `buildArgs` is invoked *after* drain stabilizes, with the post-drain - * `PubSubState` as its single argument, and must return the positional + * `WorkflowStreamState` as its single argument, and must return the positional * argument tuple for the new run. * * @example * ```typescript - * await pubsub.continueAsNew((state) => [{ + * await stream.continueAsNew((state) => [{ * itemsProcessed, - * pubsubState: state, + * streamState: state, * }]); * ``` * - * @param buildArgs Receives the post-drain pub/sub state and returns the + * @param buildArgs Receives the post-drain workflow stream state and returns the * positional args for the new run. * @param options.publisherTtl Forwarded to `getState`. * @@ -240,7 +240,7 @@ export class PubSub { * that the SDK uses to close the run. */ async continueAsNew( - buildArgs: (state: PubSubState) => Parameters, + buildArgs: (state: WorkflowStreamState) => Parameters, options?: { publisherTtl?: Duration }, ): Promise { this.drain(); @@ -321,7 +321,7 @@ export class PubSub { } // Cap response size to ~1MB of estimated wire bytes. - const wireItems: _WireItem[] = []; + const wireItems: _WorkflowStreamWireItem[] = []; let size = 0; let moreReady = false; let nextOffset = this.baseOffset + this.log.length; diff --git a/packages/contrib-pubsub/src/types.ts b/packages/contrib-workflow-stream/src/types.ts similarity index 93% rename from packages/contrib-pubsub/src/types.ts rename to packages/contrib-workflow-stream/src/types.ts index a03eda338..7a4906f47 100644 --- a/packages/contrib-pubsub/src/types.ts +++ b/packages/contrib-workflow-stream/src/types.ts @@ -1,11 +1,11 @@ /** - * Shared data types for the pub/sub contrib module. + * Shared data types for the workflow stream contrib module. * - * User-facing `data` fields on {@link PubSubItem} are Temporal + * User-facing `data` fields on {@link WorkflowStreamItem} are Temporal * {@link Payload}s so that per-item metadata (encoding, messageType) * round-trips to consumers. See README §"Cross-Language Protocol". * - * The wire representation (`PublishEntry`, `_WireItem`) uses + * The wire representation (`PublishEntry`, `_WorkflowStreamWireItem`) uses * base64-encoded `Payload` protobuf bytes because the default JSON * converter cannot serialize a `Payload` object embedded inside a * plain (non-top-level) field. Using a base64 proto bytes string @@ -21,7 +21,7 @@ import type { Payload } from '@temporalio/common'; /** - * A single item in the pub/sub log (user-facing). + * A single item in the workflow stream log (user-facing). * * `data` is a raw {@link Payload}; use a {@link PayloadConverter} * (e.g. `defaultPayloadConverter.fromPayload(item.data)`) to @@ -30,7 +30,7 @@ import type { Payload } from '@temporalio/common'; * The `offset` field is populated by the poll handler from the item's * position in the global log. */ -export interface PubSubItem { +export interface WorkflowStreamItem { topic: string; data: Payload; offset: number; @@ -44,13 +44,13 @@ export interface PublishEntry { } /** - * Wire representation of a PubSubItem (base64 of serialized Payload). + * Wire representation of a WorkflowStreamItem (base64 of serialized Payload). * * The `offset` field is populated by the poll handler from the item's * position in the global log. It is unused in the `getState()` snapshot * (offsets there are re-derivable from `base_offset + index`). */ -export interface _WireItem { +export interface _WorkflowStreamWireItem { topic: string; /** Base64-encoded Payload protobuf bytes. */ data: string; @@ -78,14 +78,14 @@ export interface PollInput { * a cooldown delay. */ export interface PollResult { - items: _WireItem[]; + items: _WorkflowStreamWireItem[]; next_offset: number; more_ready: boolean; } -/** Serializable snapshot of pub/sub state for continue-as-new. */ -export interface PubSubState { - log: _WireItem[]; +/** Serializable snapshot of workflow stream state for continue-as-new. */ +export interface WorkflowStreamState { + log: _WorkflowStreamWireItem[]; base_offset: number; publisher_sequences: Record; /** Per-publisher last-seen timestamps (seconds) for TTL pruning. */ @@ -260,7 +260,7 @@ export function decodePayloadProto(bytes: Uint8Array): Payload { return { metadata, data }; } -/** Convenience: encode a Payload to the base64 wire format used by pubsub. */ +/** Convenience: encode a Payload to the base64 wire format used by stream. */ export function encodePayloadWire(payload: Payload): string { return encodeBase64(encodePayloadProto(payload)); } diff --git a/packages/contrib-pubsub/tsconfig.json b/packages/contrib-workflow-stream/tsconfig.json similarity index 100% rename from packages/contrib-pubsub/tsconfig.json rename to packages/contrib-workflow-stream/tsconfig.json diff --git a/packages/test/package.json b/packages/test/package.json index cc194229d..43237474b 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -42,7 +42,7 @@ "@temporalio/client": "workspace:*", "@temporalio/cloud": "workspace:*", "@temporalio/common": "workspace:*", - "@temporalio/contrib-pubsub": "workspace:*", + "@temporalio/contrib-workflow-stream": "workspace:*", "@temporalio/core-bridge": "workspace:*", "@temporalio/envconfig": "workspace:*", "@temporalio/interceptors-opentelemetry": "workspace:*", diff --git a/packages/test/src/activities/contrib-pubsub.ts b/packages/test/src/activities/contrib-workflow-stream.ts similarity index 76% rename from packages/test/src/activities/contrib-pubsub.ts rename to packages/test/src/activities/contrib-workflow-stream.ts index 07b9d8769..4e33076c7 100644 --- a/packages/test/src/activities/contrib-pubsub.ts +++ b/packages/test/src/activities/contrib-workflow-stream.ts @@ -1,17 +1,17 @@ /** - * Test activities for @temporalio/contrib-pubsub. + * Test activities for @temporalio/contrib-workflow-stream. * - * These activities use `PubSubClient.fromActivity()` to target the + * These activities use `WorkflowStreamClient.fromActivity()` to target the * current activity's parent workflow from the activity context. */ import { Context } from '@temporalio/activity'; -import { PubSubClient } from '@temporalio/contrib-pubsub'; +import { WorkflowStreamClient } from '@temporalio/contrib-workflow-stream'; const encoder = new TextEncoder(); export async function publishItems(count: number): Promise { - await using client = PubSubClient.fromActivity({ batchInterval: '500 milliseconds' }); + await using client = WorkflowStreamClient.fromActivity({ batchInterval: '500 milliseconds' }); client.start(); for (let i = 0; i < count; i++) { Context.current().heartbeat(); @@ -21,7 +21,7 @@ export async function publishItems(count: number): Promise { export async function publishMultiTopic(count: number): Promise { const topics = ['a', 'b', 'c']; - await using client = PubSubClient.fromActivity({ batchInterval: '500 milliseconds' }); + await using client = WorkflowStreamClient.fromActivity({ batchInterval: '500 milliseconds' }); client.start(); for (let i = 0; i < count; i++) { Context.current().heartbeat(); @@ -36,7 +36,7 @@ export async function publishWithForceFlush(): Promise { // The hold is deliberately much longer than the test's collect timeout // so a regression (forceFlush no-op) surfaces as a missing item rather // than flaking on slow CI. - await using client = PubSubClient.fromActivity({ batchInterval: '60 seconds' }); + await using client = WorkflowStreamClient.fromActivity({ batchInterval: '60 seconds' }); client.start(); client.publish('events', encoder.encode('normal-0')); client.publish('events', encoder.encode('normal-1')); @@ -48,7 +48,7 @@ export async function publishWithForceFlush(): Promise { } export async function publishBatchTest(count: number): Promise { - await using client = PubSubClient.fromActivity({ batchInterval: '60 seconds' }); + await using client = WorkflowStreamClient.fromActivity({ batchInterval: '60 seconds' }); client.start(); for (let i = 0; i < count; i++) { Context.current().heartbeat(); @@ -58,7 +58,7 @@ export async function publishBatchTest(count: number): Promise { } export async function publishWithMaxBatch(count: number): Promise { - await using client = PubSubClient.fromActivity({ + await using client = WorkflowStreamClient.fromActivity({ batchInterval: '60 seconds', maxBatchSize: 3, }); diff --git a/packages/test/src/test-contrib-pubsub-interop.ts b/packages/test/src/test-contrib-workflow-stream-interop.ts similarity index 97% rename from packages/test/src/test-contrib-pubsub-interop.ts rename to packages/test/src/test-contrib-workflow-stream-interop.ts index 3aca64097..0c3a04031 100644 --- a/packages/test/src/test-contrib-pubsub-interop.ts +++ b/packages/test/src/test-contrib-workflow-stream-interop.ts @@ -1,11 +1,11 @@ /** - * Wire-format interop tests for @temporalio/contrib-pubsub. + * Wire-format interop tests for @temporalio/contrib-workflow-stream. * * These tests pin the exact byte layout produced by the TypeScript * implementation so it stays compatible with the Python SDK, which * uses `temporalio.api.common.v1.Payload` serialized via protobuf. * - * Unlike `test-contrib-pubsub.ts`, these don't need a Temporal server — + * Unlike `test-contrib-workflow-stream.ts`, these don't need a Temporal server — * they are pure encode/decode unit tests. */ @@ -18,7 +18,7 @@ import { encodeBase64, encodePayloadProto, encodePayloadWire, -} from '@temporalio/contrib-pubsub'; +} from '@temporalio/contrib-workflow-stream'; const test = anyTest as TestFn; const encoder = new TextEncoder(); diff --git a/packages/test/src/test-contrib-pubsub.ts b/packages/test/src/test-contrib-workflow-stream.ts similarity index 84% rename from packages/test/src/test-contrib-pubsub.ts rename to packages/test/src/test-contrib-workflow-stream.ts index f676faf20..db50efbbe 100644 --- a/packages/test/src/test-contrib-pubsub.ts +++ b/packages/test/src/test-contrib-workflow-stream.ts @@ -1,7 +1,7 @@ /** - * E2E integration tests for @temporalio/contrib-pubsub. + * E2E integration tests for @temporalio/contrib-workflow-stream. * - * Ported from sdk-python tests/contrib/pubsub/test_pubsub.py. + * Ported from sdk-python tests/contrib/stream/test_stream.py. */ import { randomUUID } from 'crypto'; @@ -9,22 +9,22 @@ import { ApplicationFailure, defaultPayloadConverter, type Payload } from '@temp import { WorkflowHandle, WorkflowUpdateFailedError } from '@temporalio/client'; import { FlushTimeoutError, - PubSubClient, + WorkflowStreamClient, type PollInput, type PollResult, - type PubSubItem, - type PubSubState, + type WorkflowStreamItem, + type WorkflowStreamState, type PublishEntry, type PublishInput, encodePayloadWire, - pubsubOffsetQuery, - pubsubPublishSignal, - pubsubPollUpdate, -} from '@temporalio/contrib-pubsub'; + workflowStreamOffsetQuery, + workflowStreamPublishSignal, + workflowStreamPollUpdate, +} from '@temporalio/contrib-workflow-stream'; import { helpers, makeTestFunction } from './helpers-integration'; import { activityPublishWorkflow, - basicPubSubWorkflow, + basicWorkflowStreamWorkflow, continueAsNewHelperWorkflow, continueAsNewTypedWorkflow, flushOnExitWorkflow, @@ -37,11 +37,11 @@ import { truncateWorkflow, ttlTestWorkflow, workflowSidePublishWorkflow, -} from './workflows/contrib-pubsub'; -import * as pubsubActivities from './activities/contrib-pubsub'; +} from './workflows/contrib-workflow-stream'; +import * as streamActivities from './activities/contrib-workflow-stream'; const test = makeTestFunction({ - workflowsPath: require.resolve('./workflows/contrib-pubsub'), + workflowsPath: require.resolve('./workflows/contrib-workflow-stream'), }); const encoder = new TextEncoder(); @@ -50,7 +50,7 @@ const decoder = new TextDecoder(); /** * Build a `PublishEntry` for a literal string. * - * Mirrors what `PubSubClient` produces on the encode path: the default + * Mirrors what `WorkflowStreamClient` produces on the encode path: the default * payload converter wraps the bytes into a `Payload`, which is then * proto-serialized and base64-encoded for the wire. */ @@ -81,9 +81,9 @@ async function collectItems( fromOffset: number, expectedCount: number, timeoutMs = 15_000 -): Promise { - const client = new PubSubClient(handle); - const items: PubSubItem[] = []; +): Promise { + const client = new WorkflowStreamClient(handle); + const items: WorkflowStreamItem[] = []; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { @@ -107,7 +107,7 @@ async function collectItems( test('activity_publish_and_subscribe — activity publishes, client subscribes', async (t) => { const count = 10; const { createWorker, startWorkflow } = helpers(t); - const worker = await createWorker({ activities: pubsubActivities }); + const worker = await createWorker({ activities: streamActivities }); await worker.runUntil(async () => { const handle = await startWorkflow(activityPublishWorkflow, { args: [count] }); const items = await collectItems(handle, undefined, 0, count + 1); @@ -125,7 +125,7 @@ test('activity_publish_and_subscribe — activity publishes, client subscribes', test('topic_filtering — subscriber gets only requested topics', async (t) => { const count = 9; const { createWorker, startWorkflow } = helpers(t); - const worker = await createWorker({ activities: pubsubActivities }); + const worker = await createWorker({ activities: streamActivities }); await worker.runUntil(async () => { const handle = await startWorkflow(multiTopicWorkflow, { args: [count] }); @@ -174,7 +174,7 @@ test('subscribe_from_offset_and_per_item_offsets — non-zero starts and global test('per_item_offsets_with_topic_filter — offsets are global, not per-topic', async (t) => { const count = 9; const { createWorker, startWorkflow } = helpers(t); - const worker = await createWorker({ activities: pubsubActivities }); + const worker = await createWorker({ activities: streamActivities }); await worker.runUntil(async () => { const handle = await startWorkflow(multiTopicWorkflow, { args: [count] }); @@ -201,7 +201,7 @@ test('poll_truncated_offset_returns_application_failure', async (t) => { const items: PublishEntry[] = []; for (let i = 0; i < 5; i++) items.push(entry('events', `item-${i}`)); - await handle.signal<[PublishInput]>(pubsubPublishSignal, { + await handle.signal<[PublishInput]>(workflowStreamPublishSignal, { items, publisher_id: '', sequence: 0, @@ -213,7 +213,7 @@ test('poll_truncated_offset_returns_application_failure', async (t) => { // with ApplicationFailure cause of type 'TruncatedOffset'. const rawHandle = env.client.workflow.getHandle(handle.workflowId); const err = (await t.throwsAsync( - rawHandle.executeUpdate(pubsubPollUpdate, { + rawHandle.executeUpdate(workflowStreamPollUpdate, { args: [{ topics: [], from_offset: 1 }], }), { instanceOf: WorkflowUpdateFailedError } @@ -238,7 +238,7 @@ test('subscribe_recovers_from_truncation — client auto-restarts from 0', async const items: PublishEntry[] = []; for (let i = 0; i < 5; i++) items.push(entry('events', `item-${i}`)); - await handle.signal<[PublishInput]>(pubsubPublishSignal, { + await handle.signal<[PublishInput]>(workflowStreamPublishSignal, { items, publisher_id: '', sequence: 0, @@ -258,7 +258,7 @@ test('subscribe_recovers_from_truncation — client auto-restarts from 0', async test('force_flush — forceFlush wakes flusher despite 60s interval', async (t) => { const { createWorker, startWorkflow } = helpers(t); - const worker = await createWorker({ activities: pubsubActivities }); + const worker = await createWorker({ activities: streamActivities }); await worker.runUntil(async () => { const handle = await startWorkflow(forceFlushWorkflow, { args: [] }); // The activity holds for ~10s after the forceFlush publish; 5s timeout @@ -275,7 +275,7 @@ test('force_flush — forceFlush wakes flusher despite 60s interval', async (t) test('dispose_flushes_on_exit — await using drains buffer', async (t) => { const { createWorker, startWorkflow } = helpers(t); const count = 5; - const worker = await createWorker({ activities: pubsubActivities }); + const worker = await createWorker({ activities: streamActivities }); await worker.runUntil(async () => { const handle = await startWorkflow(flushOnExitWorkflow, { args: [count] }); const items = await collectItems(handle, undefined, 0, count, 15_000); @@ -290,7 +290,7 @@ test('dispose_flushes_on_exit — await using drains buffer', async (t) => { test('max_batch_size — triggers flush without waiting for timer', async (t) => { const { createWorker, startWorkflow } = helpers(t); const count = 7; - const worker = await createWorker({ activities: pubsubActivities }); + const worker = await createWorker({ activities: streamActivities }); await worker.runUntil(async () => { const handle = await startWorkflow(maxBatchWorkflow, { args: [count] }); const items = await collectItems(handle, undefined, 0, count + 1, 15_000); @@ -306,19 +306,19 @@ test('dedup_rejects_duplicate_signal — same publisher+sequence is dropped', as const { createWorker, startWorkflow } = helpers(t); const worker = await createWorker(); await worker.runUntil(async () => { - const handle = await startWorkflow(basicPubSubWorkflow, { args: [] }); + const handle = await startWorkflow(basicWorkflowStreamWorkflow, { args: [] }); - await handle.signal<[PublishInput]>(pubsubPublishSignal, { + await handle.signal<[PublishInput]>(workflowStreamPublishSignal, { items: [entry('events', 'item-0')], publisher_id: 'test-pub', sequence: 1, }); - await handle.signal<[PublishInput]>(pubsubPublishSignal, { + await handle.signal<[PublishInput]>(workflowStreamPublishSignal, { items: [entry('events', 'duplicate')], publisher_id: 'test-pub', sequence: 1, }); - await handle.signal<[PublishInput]>(pubsubPublishSignal, { + await handle.signal<[PublishInput]>(workflowStreamPublishSignal, { items: [entry('events', 'item-1')], publisher_id: 'test-pub', sequence: 2, @@ -330,14 +330,14 @@ test('dedup_rejects_duplicate_signal — same publisher+sequence is dropped', as t.is(payloadString(items[0]!.data), 'item-0'); t.is(payloadString(items[1]!.data), 'item-1'); - const offset = await handle.query(pubsubOffsetQuery); + const offset = await handle.query(workflowStreamOffsetQuery); t.is(offset, 2); await handle.signal('close'); }); }); -test('truncate_pubsub — truncate discards prefix and adjusts base', async (t) => { +test('truncate_stream — truncate discards prefix and adjusts base', async (t) => { const { createWorker, startWorkflow } = helpers(t); const worker = await createWorker(); await worker.runUntil(async () => { @@ -345,7 +345,7 @@ test('truncate_pubsub — truncate discards prefix and adjusts base', async (t) const items: PublishEntry[] = []; for (let i = 0; i < 5; i++) items.push(entry('events', `item-${i}`)); - await handle.signal<[PublishInput]>(pubsubPublishSignal, { + await handle.signal<[PublishInput]>(workflowStreamPublishSignal, { items, publisher_id: '', sequence: 0, @@ -358,7 +358,7 @@ test('truncate_pubsub — truncate discards prefix and adjusts base', async (t) await handle.executeUpdate(truncateUpdate, { args: [3] }); // Offset should still be 5 (truncation moves base_offset, not tail). - const offset = await handle.query(pubsubOffsetQuery); + const offset = await handle.query(workflowStreamOffsetQuery); t.is(offset, 5); const after = await collectItems(handle, undefined, 3, 2); @@ -381,7 +381,7 @@ test('truncate_past_end_raises_application_failure', async (t) => { const items: PublishEntry[] = []; for (let i = 0; i < 2; i++) items.push(entry('events', `item-${i}`)); - await handle.signal<[PublishInput]>(pubsubPublishSignal, { + await handle.signal<[PublishInput]>(workflowStreamPublishSignal, { items, publisher_id: '', sequence: 0, @@ -409,26 +409,26 @@ test('explicit_flush_barrier — flush() returns once items are confirmed', asyn const { createWorker, startWorkflow } = helpers(t); const worker = await createWorker(); await worker.runUntil(async () => { - const handle = await startWorkflow(basicPubSubWorkflow, { args: [] }); + const handle = await startWorkflow(basicWorkflowStreamWorkflow, { args: [] }); - const pubsub = new PubSubClient(handle, { batchInterval: '60 seconds' }); + const stream = new WorkflowStreamClient(handle, { batchInterval: '60 seconds' }); // 1. Empty-buffer flush is a no-op (must not block). - t.is(await pubsub.getOffset(), 0); - await pubsub.flush(); - t.is(await pubsub.getOffset(), 0); + t.is(await stream.getOffset(), 0); + await stream.flush(); + t.is(await stream.getOffset(), 0); // 2. Flush makes prior publishes visible without waiting on the // 60s batch timer. - pubsub.publish('events', encoder.encode('a')); - pubsub.publish('events', encoder.encode('b')); - pubsub.publish('events', encoder.encode('c')); - await pubsub.flush(); - t.is(await pubsub.getOffset(), 3); + stream.publish('events', encoder.encode('a')); + stream.publish('events', encoder.encode('b')); + stream.publish('events', encoder.encode('c')); + await stream.flush(); + t.is(await stream.getOffset(), 3); // 3. Second flush with no new items is a no-op. - await pubsub.flush(); - t.is(await pubsub.getOffset(), 3); + await stream.flush(); + t.is(await stream.getOffset(), 3); await handle.signal('close'); }); @@ -438,12 +438,12 @@ test('subscribe_accepts_string_topic — single-string convenience', async (t) = // subscribe(topics='a') is equivalent to subscribe(topics=['a']). const count = 9; const { createWorker, startWorkflow } = helpers(t); - const worker = await createWorker({ activities: pubsubActivities }); + const worker = await createWorker({ activities: streamActivities }); await worker.runUntil(async () => { const handle = await startWorkflow(multiTopicWorkflow, { args: [count] }); - const client = new PubSubClient(handle); - const items: PubSubItem[] = []; + const client = new WorkflowStreamClient(handle); + const items: WorkflowStreamItem[] = []; const gen = client.subscribe('a', 0, { pollCooldown: 0 }); for await (const item of gen) { items.push(item); @@ -470,7 +470,7 @@ test('ttl_pruning_in_get_state — old publisher pruned, new publisher kept', as await worker.runUntil(async () => { const handle = await startWorkflow(ttlTestWorkflow, { args: [] }); - await handle.signal<[PublishInput]>(pubsubPublishSignal, { + await handle.signal<[PublishInput]>(workflowStreamPublishSignal, { items: [entry('events', 'old')], publisher_id: 'pub-old', sequence: 1, @@ -478,20 +478,20 @@ test('ttl_pruning_in_get_state — old publisher pruned, new publisher kept', as // Sanity: pub-old is recorded (generous TTL retains it). // Generous TTL: 9999 seconds, expressed in ms. - const before = await handle.query(getStateWithTtlQuery, 9999_000); + const before = await handle.query(getStateWithTtlQuery, 9999_000); t.true('pub-old' in before.publisher_sequences); // Wall-clock gap so workflow.time() advances between the two signals. await new Promise((r) => setTimeout(r, 1000)); - await handle.signal<[PublishInput]>(pubsubPublishSignal, { + await handle.signal<[PublishInput]>(workflowStreamPublishSignal, { items: [entry('events', 'new')], publisher_id: 'pub-new', sequence: 1, }); // 500 ms TTL: pub-old (~1s old) is pruned, pub-new (~0s old) is kept. - const state = await handle.query(getStateWithTtlQuery, 500); + const state = await handle.query(getStateWithTtlQuery, 500); t.false('pub-old' in state.publisher_sequences); t.true('pub-new' in state.publisher_sequences); t.is(state.log.length, 2); @@ -505,7 +505,7 @@ test('continue_as_new_typed — log, offsets, AND dedup state survive CAN', asyn const { env } = t.context; const worker = await createWorker(); await worker.runUntil(async () => { - const workflowId = `pubsub-can-${randomUUID()}`; + const workflowId = `stream-can-${randomUUID()}`; const handle = await startWorkflow(continueAsNewTypedWorkflow, { args: [{}], workflowId, @@ -513,7 +513,7 @@ test('continue_as_new_typed — log, offsets, AND dedup state survive CAN', asyn // Seed publisher dedup state (pub / sequence=1) so we can verify it // survives CAN. - await handle.signal<[PublishInput]>(pubsubPublishSignal, { + await handle.signal<[PublishInput]>(workflowStreamPublishSignal, { items: [ entry('events', 'item-0'), entry('events', 'item-1'), @@ -561,7 +561,7 @@ test('continue_as_new_typed — log, offsets, AND dedup state survive CAN', asyn // Re-sending publisher_id='pub', sequence=1 must be rejected — log and // publisher_sequences unchanged. - await newHandle.signal<[PublishInput]>(pubsubPublishSignal, { + await newHandle.signal<[PublishInput]>(workflowStreamPublishSignal, { items: [entry('events', 'dup')], publisher_id: 'pub', sequence: 1, @@ -570,7 +570,7 @@ test('continue_as_new_typed — log, offsets, AND dedup state survive CAN', asyn t.deepEqual(seqsAfterDup, { pub: 1 }); // Fresh sequence from same publisher accepted; item-3 lands at offset 3. - await newHandle.signal<[PublishInput]>(pubsubPublishSignal, { + await newHandle.signal<[PublishInput]>(workflowStreamPublishSignal, { items: [entry('events', 'item-3')], publisher_id: 'pub', sequence: 2, @@ -589,18 +589,18 @@ test('continue_as_new_typed — log, offsets, AND dedup state survive CAN', asyn }); }); -test('continue_as_new_helper — log and offsets survive CAN via PubSub.continueAsNew', async (t) => { +test('continue_as_new_helper — log and offsets survive CAN via WorkflowStream.continueAsNew', async (t) => { const { createWorker, startWorkflow } = helpers(t); const { env } = t.context; const worker = await createWorker(); await worker.runUntil(async () => { - const workflowId = `pubsub-can-helper-${randomUUID()}`; + const workflowId = `stream-can-helper-${randomUUID()}`; const handle = await startWorkflow(continueAsNewHelperWorkflow, { args: [{}], workflowId, }); - await handle.signal<[PublishInput]>(pubsubPublishSignal, { + await handle.signal<[PublishInput]>(workflowStreamPublishSignal, { items: [entry('events', 'item-0'), entry('events', 'item-1')], publisher_id: 'pub', sequence: 1, @@ -644,12 +644,12 @@ test('poll_more_ready_when_response_exceeds_size_limit — 1MB cap', async (t) = const { env } = t.context; const worker = await createWorker(); await worker.runUntil(async () => { - const handle = await startWorkflow(basicPubSubWorkflow, { args: [] }); + const handle = await startWorkflow(basicWorkflowStreamWorkflow, { args: [] }); const chunk = new Uint8Array(200_000).fill('x'.charCodeAt(0)); const chunkPayload = defaultPayloadConverter.toPayload(chunk); for (let i = 0; i < 8; i++) { - await handle.signal<[PublishInput]>(pubsubPublishSignal, { + await handle.signal<[PublishInput]>(workflowStreamPublishSignal, { items: [{ topic: 'big', data: encodePayloadWire(chunkPayload) }], publisher_id: '', sequence: 0, @@ -658,7 +658,7 @@ test('poll_more_ready_when_response_exceeds_size_limit — 1MB cap', async (t) = // The update acts as a barrier for all prior publish signals. const rawHandle = env.client.workflow.getHandle(handle.workflowId); - const first = await rawHandle.executeUpdate(pubsubPollUpdate, { + const first = await rawHandle.executeUpdate(workflowStreamPollUpdate, { args: [{ topics: [], from_offset: 0 }], }); t.is(first.more_ready, true); @@ -670,7 +670,7 @@ test('poll_more_ready_when_response_exceeds_size_limit — 1MB cap', async (t) = let offset = first.next_offset; let last: PollResult = first; while (gathered < 8) { - last = await rawHandle.executeUpdate(pubsubPollUpdate, { + last = await rawHandle.executeUpdate(workflowStreamPollUpdate, { args: [{ topics: [], from_offset: offset }], }); gathered += last.items.length; @@ -688,11 +688,11 @@ test('subscribe_iterates_through_more_ready — caller sees all items', async (t const { createWorker, startWorkflow } = helpers(t); const worker = await createWorker(); await worker.runUntil(async () => { - const handle = await startWorkflow(basicPubSubWorkflow, { args: [] }); + const handle = await startWorkflow(basicWorkflowStreamWorkflow, { args: [] }); const chunk = new Uint8Array(200_000).fill('x'.charCodeAt(0)); const chunkPayload = defaultPayloadConverter.toPayload(chunk); for (let i = 0; i < 8; i++) { - await handle.signal<[PublishInput]>(pubsubPublishSignal, { + await handle.signal<[PublishInput]>(workflowStreamPublishSignal, { items: [{ topic: 'big', data: encodePayloadWire(chunkPayload) }], publisher_id: '', sequence: 0, @@ -714,9 +714,9 @@ test('flush_retry_preserves_items_after_failures — behavioral retry coverage', const { createWorker, startWorkflow } = helpers(t); const worker = await createWorker(); await worker.runUntil(async () => { - const handle = await startWorkflow(basicPubSubWorkflow, { args: [] }); + const handle = await startWorkflow(basicWorkflowStreamWorkflow, { args: [] }); - const pubsub = new PubSubClient(handle); + const stream = new WorkflowStreamClient(handle); const realSignal = handle.signal.bind(handle); let failRemaining = 2; (handle as unknown as { signal: typeof handle.signal }).signal = (async (...args: unknown[]) => { @@ -727,23 +727,23 @@ test('flush_retry_preserves_items_after_failures — behavioral retry coverage', return realSignal(...(args as Parameters)); }) as typeof handle.signal; - pubsub.publish('events', encoder.encode('item-0')); - pubsub.publish('events', encoder.encode('item-1')); - await t.throwsAsync((pubsub as unknown as { _doFlush(): Promise })._doFlush(), { + stream.publish('events', encoder.encode('item-0')); + stream.publish('events', encoder.encode('item-1')); + await t.throwsAsync((stream as unknown as { _doFlush(): Promise })._doFlush(), { message: /simulated/, }); // Publish more during the failed state — must not overtake the pending // retry on eventual delivery. - pubsub.publish('events', encoder.encode('item-2')); - await t.throwsAsync((pubsub as unknown as { _doFlush(): Promise })._doFlush(), { + stream.publish('events', encoder.encode('item-2')); + await t.throwsAsync((stream as unknown as { _doFlush(): Promise })._doFlush(), { message: /simulated/, }); // Third flush delivers the pending retry batch. - await (pubsub as unknown as { _doFlush(): Promise })._doFlush(); + await (stream as unknown as { _doFlush(): Promise })._doFlush(); // Fourth flush delivers the buffered 'item-2'. - await (pubsub as unknown as { _doFlush(): Promise })._doFlush(); + await (stream as unknown as { _doFlush(): Promise })._doFlush(); const items = await collectItems(handle, undefined, 0, 3); t.deepEqual( @@ -760,7 +760,7 @@ test('flush_raises_after_max_retry_duration — timeout surfaces, client resumes // the client stays usable and subsequent publishes succeed. const { env } = t.context; const bogus = env.client.workflow.getHandle(`no-such-workflow-${randomUUID()}`); - const client = new PubSubClient(bogus, { + const client = new WorkflowStreamClient(bogus, { batchInterval: '100 milliseconds', maxRetryDuration: '200 milliseconds', }); diff --git a/packages/test/src/workflows/contrib-pubsub.ts b/packages/test/src/workflows/contrib-workflow-stream.ts similarity index 72% rename from packages/test/src/workflows/contrib-pubsub.ts rename to packages/test/src/workflows/contrib-workflow-stream.ts index c0210ed3e..a11f5de9b 100644 --- a/packages/test/src/workflows/contrib-pubsub.ts +++ b/packages/test/src/workflows/contrib-workflow-stream.ts @@ -1,5 +1,5 @@ /** - * Test workflows for @temporalio/contrib-pubsub. + * Test workflows for @temporalio/contrib-workflow-stream. */ import { @@ -11,8 +11,8 @@ import { proxyActivities, setHandler, } from '@temporalio/workflow'; -import { PubSub, type PubSubState } from '@temporalio/contrib-pubsub'; -import type * as activities from '../activities/contrib-pubsub'; +import { WorkflowStream, type WorkflowStreamState } from '@temporalio/contrib-workflow-stream'; +import type * as activities from '../activities/contrib-workflow-stream'; const { publishItems, publishMultiTopic, publishWithForceFlush, publishBatchTest, publishWithMaxBatch } = proxyActivities({ @@ -23,12 +23,12 @@ const { publishItems, publishMultiTopic, publishWithForceFlush, publishBatchTest export const closeSignal = defineSignal('close'); export const triggerContinueSignal = defineSignal('triggerContinue'); export const truncateUpdate = defineUpdate('truncate'); -export const getStateWithTtlQuery = defineQuery('getStateWithTtl'); +export const getStateWithTtlQuery = defineQuery('getStateWithTtl'); export const publisherSequencesQuery = defineQuery>('publisherSequences'); -/** A minimal broker workflow — initializes pub/sub and waits for close. */ -export async function basicPubSubWorkflow(): Promise { - new PubSub(); +/** A minimal stream-host workflow — initializes WorkflowStream and waits for close. */ +export async function basicWorkflowStreamWorkflow(): Promise { + new WorkflowStream(); let closed = false; setHandler(closeSignal, () => { closed = true; @@ -38,21 +38,21 @@ export async function basicPubSubWorkflow(): Promise { /** Publishes `count` items directly from the workflow, then waits. */ export async function workflowSidePublishWorkflow(count: number): Promise { - const pubsub = new PubSub(); + const stream = new WorkflowStream(); let closed = false; setHandler(closeSignal, () => { closed = true; }); const encoder = new TextEncoder(); for (let i = 0; i < count; i++) { - pubsub.publish('events', encoder.encode(`item-${i}`)); + stream.publish('events', encoder.encode(`item-${i}`)); } await condition(() => closed); } /** Executes publishMultiTopic activity then waits. */ export async function multiTopicWorkflow(count: number): Promise { - new PubSub(); + new WorkflowStream(); let closed = false; setHandler(closeSignal, () => { closed = true; @@ -63,43 +63,43 @@ export async function multiTopicWorkflow(count: number): Promise { /** Executes publishItems activity then appends activity_done status. */ export async function activityPublishWorkflow(count: number): Promise { - const pubsub = new PubSub(); + const stream = new WorkflowStream(); let closed = false; setHandler(closeSignal, () => { closed = true; }); await publishItems(count); - pubsub.publish('status', new TextEncoder().encode('activity_done')); + stream.publish('status', new TextEncoder().encode('activity_done')); await condition(() => closed); } /** Workflow that accepts a truncate update (explicit completion). */ export async function truncateWorkflow(): Promise { - const pubsub = new PubSub(); + const stream = new WorkflowStream(); let closed = false; setHandler(closeSignal, () => { closed = true; }); setHandler(truncateUpdate, (upToOffset: number) => { - pubsub.truncate(upToOffset); + stream.truncate(upToOffset); }); await condition(() => closed); } /** Workflow that exposes getState via query for TTL testing. */ export async function ttlTestWorkflow(): Promise { - const pubsub = new PubSub(); + const stream = new WorkflowStream(); let closed = false; setHandler(closeSignal, () => { closed = true; }); - setHandler(getStateWithTtlQuery, (ttl: number) => pubsub.getState(ttl)); + setHandler(getStateWithTtlQuery, (ttl: number) => stream.getState(ttl)); await condition(() => closed); } /** Workflow that runs publishWithForceFlush activity. */ export async function forceFlushWorkflow(): Promise { - new PubSub(); + new WorkflowStream(); let closed = false; setHandler(closeSignal, () => { closed = true; @@ -110,7 +110,7 @@ export async function forceFlushWorkflow(): Promise { /** Workflow that runs publishBatchTest activity. */ export async function flushOnExitWorkflow(count: number): Promise { - new PubSub(); + new WorkflowStream(); let closed = false; setHandler(closeSignal, () => { closed = true; @@ -121,24 +121,24 @@ export async function flushOnExitWorkflow(count: number): Promise { /** Workflow that runs publishWithMaxBatch activity. */ export async function maxBatchWorkflow(count: number): Promise { - const pubsub = new PubSub(); + const stream = new WorkflowStream(); let closed = false; setHandler(closeSignal, () => { closed = true; }); await publishWithMaxBatch(count); - pubsub.publish('status', new TextEncoder().encode('activity_done')); + stream.publish('status', new TextEncoder().encode('activity_done')); await condition(() => closed); } /** Typed input for the continue-as-new workflow. */ export interface CANWorkflowInput { - pubsubState?: PubSubState; + streamState?: WorkflowStreamState; } -/** CAN workflow using properly-typed pubsubState (explicit recipe). */ +/** CAN workflow using properly-typed streamState (explicit recipe). */ export async function continueAsNewTypedWorkflow(input: CANWorkflowInput): Promise { - const pubsub = new PubSub(input.pubsubState); + const stream = new WorkflowStream(input.streamState); let closed = false; let shouldContinue = false; setHandler(closeSignal, () => { @@ -149,18 +149,18 @@ export async function continueAsNewTypedWorkflow(input: CANWorkflowInput): Promi }); // Expose publisher_sequences for CAN dedup-survival test. Use a very // large TTL so we read the current state without pruning. - setHandler(publisherSequencesQuery, () => pubsub.getState(Number.MAX_SAFE_INTEGER).publisher_sequences); + setHandler(publisherSequencesQuery, () => stream.getState(Number.MAX_SAFE_INTEGER).publisher_sequences); await condition(() => shouldContinue || closed); if (closed) return; - pubsub.drain(); + stream.drain(); await continueAsNew({ - pubsubState: pubsub.getState(), + streamState: stream.getState(), }); } -/** CAN workflow that uses the packaged `PubSub.continueAsNew` helper. */ +/** CAN workflow that uses the packaged `WorkflowStream.continueAsNew` helper. */ export async function continueAsNewHelperWorkflow(input: CANWorkflowInput): Promise { - const pubsub = new PubSub(input.pubsubState); + const stream = new WorkflowStream(input.streamState); let closed = false; let shouldContinue = false; setHandler(closeSignal, () => { @@ -171,7 +171,7 @@ export async function continueAsNewHelperWorkflow(input: CANWorkflowInput): Prom }); await condition(() => shouldContinue || closed); if (closed) return; - await pubsub.continueAsNew((state) => [ - { pubsubState: state }, + await stream.continueAsNew((state) => [ + { streamState: state }, ]); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d76c83c51..92b968fe6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -173,9 +173,9 @@ importers: '@temporalio/common': specifier: workspace:* version: link:../common - '@temporalio/contrib-pubsub': + '@temporalio/contrib-workflow-stream': specifier: workspace:* - version: link:../contrib-pubsub + version: link:../contrib-workflow-stream '@temporalio/plugin': specifier: workspace:* version: link:../plugin @@ -267,7 +267,7 @@ importers: specifier: ^7.2.5 version: 7.5.1 - packages/contrib-pubsub: + packages/contrib-workflow-stream: dependencies: '@temporalio/activity': specifier: workspace:* @@ -590,9 +590,9 @@ importers: '@temporalio/common': specifier: workspace:* version: link:../common - '@temporalio/contrib-pubsub': + '@temporalio/contrib-workflow-stream': specifier: workspace:* - version: link:../contrib-pubsub + version: link:../contrib-workflow-stream '@temporalio/core-bridge': specifier: workspace:* version: link:../core-bridge diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 00d431e93..a5d4b876a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -17,7 +17,7 @@ packages: - packages/testing - packages/worker - packages/workflow - - packages/contrib-pubsub + - packages/contrib-workflow-stream - scripts ignoreScripts: true From 654475189e0fa237cb800b303461a71a007abf0f Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Tue, 28 Apr 2026 20:41:52 -0700 Subject: [PATCH 25/75] ai-sdk: import ReadableStream from node:stream/web MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The streaming language model wraps results in a ReadableStream, which TypeScript could not resolve under the package's default lib config (es2023 only — no DOM types). Importing the type from node:stream/web is a localized fix that avoids pulling the full DOM lib into the package's type surface, which would conflict with name resolution elsewhere (notably mcp.ts). This unblocks the test package build, which transitively type-checks ai-sdk via project references. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/ai-sdk/src/provider.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ai-sdk/src/provider.ts b/packages/ai-sdk/src/provider.ts index f43039173..b374fc104 100644 --- a/packages/ai-sdk/src/provider.ts +++ b/packages/ai-sdk/src/provider.ts @@ -1,3 +1,4 @@ +import { ReadableStream, type ReadableStreamDefaultController } from 'node:stream/web'; import type { EmbeddingModelV3, EmbeddingModelV3CallOptions, From d2c1c071691e4f2fa935414822e0ad7dffb157b0 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Tue, 28 Apr 2026 20:42:34 -0700 Subject: [PATCH 26/75] ai-sdk: annotate mcp tool execute parameters as unknown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The execute callback returned from TemporalMCPClient.tools() was implicitly typed as `any`. Strict mode (noImplicitAny) on the package's lib config rejects this — explicit `unknown` parameters match the existing runtime behavior (the values are passed straight through to the call activity without inspection) and pass type checking. Surfaced after the previous commit unblocked provider.ts type checking; the build aborts on the first failure, so this error was hidden until ReadableStream was resolved. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/ai-sdk/src/mcp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ai-sdk/src/mcp.ts b/packages/ai-sdk/src/mcp.ts index 88cf6e32b..2248c0c13 100644 --- a/packages/ai-sdk/src/mcp.ts +++ b/packages/ai-sdk/src/mcp.ts @@ -44,7 +44,7 @@ export class TemporalMCPClient { toolName, { description: toolResult.description, - execute: async (input, options) => { + execute: async (input: unknown, options: unknown) => { const activities = workflow.proxyActivities({ summary: toolName, startToCloseTimeout: '10 minutes', From e8e62f4f1effa316a2acf788dade5461083ec291 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Wed, 29 Apr 2026 17:30:56 -0700 Subject: [PATCH 27/75] =?UTF-8?q?contrib-workflow-stream:=20rename=20drain?= =?UTF-8?q?()=20=E2=86=92=20detachPollers()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the sdk-python contrib/pubsub final naming. Also updates the README and the CAN test workflow to the new name. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/contrib-workflow-stream/README.md | 14 +++++++------- packages/contrib-workflow-stream/src/stream.ts | 10 +++++----- .../test/src/workflows/contrib-workflow-stream.ts | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/contrib-workflow-stream/README.md b/packages/contrib-workflow-stream/README.md index 7f5bebb0a..ab0b5361e 100644 --- a/packages/contrib-workflow-stream/README.md +++ b/packages/contrib-workflow-stream/README.md @@ -149,11 +149,11 @@ export async function myWorkflow(input: WorkflowInput): Promise { } ``` -`WorkflowStream.continueAsNew(buildArgs)` drains waiting subscribers, waits for +`WorkflowStream.continueAsNew(buildArgs)` detaches waiting pollers, waits for in-flight handlers to finish, then calls `continueAsNew` with the args -returned by `buildArgs(postDrainState)`. The lambda receives the -post-drain `WorkflowStreamState` as its only argument so the snapshot is -guaranteed to happen *after* drain. Subscribers created via +returned by `buildArgs(state)`. The lambda receives the post-detach +`WorkflowStreamState` as its only argument so the snapshot is guaranteed +to happen *after* pollers detach. Subscribers created via `WorkflowStreamClient.create()` automatically follow continue-as-new chains. If you need to pass other CAN options (search attributes, memo, @@ -164,7 +164,7 @@ non-default `taskQueue`, etc.), fall back to the explicit recipe with import { condition, allHandlersFinished, makeContinueAsNewFunc } from '@temporalio/workflow'; if (workflowInfo().continueAsNewSuggested) { - stream.drain(); + stream.detachPollers(); await condition(allHandlersFinished); const continueWithOptions = makeContinueAsNewFunc({ taskQueue: 'other-tq', @@ -184,8 +184,8 @@ if (workflowInfo().continueAsNewSuggested) { |---|---| | `publish(topic, value)` | Append to the log from workflow code. Accepts any value the default payload converter handles, or a pre-built `Payload`. | | `getState(publisherTtl?)` | Snapshot for continue-as-new. Drops publisher dedup entries older than `publisherTtl` (`Duration`, default `'15 minutes'`). | -| `drain()` | Unblock polls and reject new ones. | -| `continueAsNew(buildArgs, options?)` | Async. Drain, wait for handlers, then `continueAsNew` with `buildArgs(postDrainState)`. Use the explicit recipe with `makeContinueAsNewFunc` to pass other CAN options. | +| `detachPollers()` | Unblock polls and reject new ones. | +| `continueAsNew(buildArgs, options?)` | Async. Detach pollers, wait for handlers, then `continueAsNew` with `buildArgs(state)`. Use the explicit recipe with `makeContinueAsNewFunc` to pass other CAN options. | | `truncate(upToOffset)` | Discard log entries below the given offset. | Handlers registered automatically: diff --git a/packages/contrib-workflow-stream/src/stream.ts b/packages/contrib-workflow-stream/src/stream.ts index ff5d59b26..ba0fa0501 100644 --- a/packages/contrib-workflow-stream/src/stream.ts +++ b/packages/contrib-workflow-stream/src/stream.ts @@ -175,7 +175,7 @@ export class WorkflowStream { } /** Unblock all waiting poll handlers and reject new polls for CAN. */ - drain(): void { + detachPollers(): void { this.draining = true; } @@ -216,11 +216,11 @@ export class WorkflowStream { /** * Drain, wait for in-flight handlers, then `continueAsNew` with built args. * - * Replaces the recipe `drain()` → `condition(allHandlersFinished)` → + * Replaces the recipe `detachPollers()` → `condition(allHandlersFinished)` → * `continueAsNew(...)` for the common case where the only thing that * varies across CAN boundaries is the workflow's own arguments. * - * `buildArgs` is invoked *after* drain stabilizes, with the post-drain + * `buildArgs` is invoked *after* pollers detach, with the resulting * `WorkflowStreamState` as its single argument, and must return the positional * argument tuple for the new run. * @@ -232,7 +232,7 @@ export class WorkflowStream { * }]); * ``` * - * @param buildArgs Receives the post-drain workflow stream state and returns the + * @param buildArgs Receives the post-detach workflow stream state and returns the * positional args for the new run. * @param options.publisherTtl Forwarded to `getState`. * @@ -243,7 +243,7 @@ export class WorkflowStream { buildArgs: (state: WorkflowStreamState) => Parameters, options?: { publisherTtl?: Duration }, ): Promise { - this.drain(); + this.detachPollers(); await condition(allHandlersFinished); return workflowContinueAsNew(...buildArgs(this.getState(options?.publisherTtl))); } diff --git a/packages/test/src/workflows/contrib-workflow-stream.ts b/packages/test/src/workflows/contrib-workflow-stream.ts index a11f5de9b..f88692571 100644 --- a/packages/test/src/workflows/contrib-workflow-stream.ts +++ b/packages/test/src/workflows/contrib-workflow-stream.ts @@ -152,7 +152,7 @@ export async function continueAsNewTypedWorkflow(input: CANWorkflowInput): Promi setHandler(publisherSequencesQuery, () => stream.getState(Number.MAX_SAFE_INTEGER).publisher_sequences); await condition(() => shouldContinue || closed); if (closed) return; - stream.drain(); + stream.detachPollers(); await continueAsNew({ streamState: stream.getState(), }); From d56ac31fb9e98eb8d152fd47e6140d2ff572c0c0 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Wed, 29 Apr 2026 19:28:25 -0700 Subject: [PATCH 28/75] contrib-workflow-stream: introduce typed topic handles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror sdk-python's API: publish and subscribe via typed handles returned by `stream.topic(name)` / `client.topic(name)`. Direct `WorkflowStream.publish` and `WorkflowStreamClient.publish` are removed. - New `TopicHandle` (client-side: publish + decoded subscribe) - New `WorkflowTopicHandle` (workflow-side: publish only) - `WorkflowStreamItem` is now generic on decoded data type - `topic(name)` memoizes per-name handles; T is compile-time only (TypeScript has no runtime type representation, so per-topic uniformity isn't enforced at runtime — sdk-python remains strict) - `client.subscribe()` retained for raw / multi-topic decode-yourself paths; yields `WorkflowStreamItem` Updates README, ai-sdk activity, test workflows/activities/tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/ai-sdk/src/activities.ts | 3 +- packages/contrib-workflow-stream/README.md | 69 +++++++---- .../contrib-workflow-stream/src/client.ts | 26 ++-- packages/contrib-workflow-stream/src/index.ts | 1 + .../contrib-workflow-stream/src/stream.ts | 23 +++- .../src/topic-handle.ts | 111 ++++++++++++++++++ packages/contrib-workflow-stream/src/types.ts | 15 ++- .../src/activities/contrib-workflow-stream.ts | 23 ++-- .../test/src/test-contrib-workflow-stream.ts | 16 +-- .../src/workflows/contrib-workflow-stream.ts | 9 +- 10 files changed, 236 insertions(+), 60 deletions(-) create mode 100644 packages/contrib-workflow-stream/src/topic-handle.ts diff --git a/packages/ai-sdk/src/activities.ts b/packages/ai-sdk/src/activities.ts index 103dbf163..a335dcfc5 100644 --- a/packages/ai-sdk/src/activities.ts +++ b/packages/ai-sdk/src/activities.ts @@ -100,6 +100,7 @@ export function createActivities( async invokeModelStreaming(args: InvokeModelArgs): Promise { const stream = WorkflowStreamClient.fromActivity({ batchInterval: '100 milliseconds' }); stream.start(); + const events = stream.topic(EVENTS_TOPIC); const model = provider.languageModel(args.modelId); const streamResult = await model.doStream(args.options); @@ -128,7 +129,7 @@ export function createActivities( // Publish the raw stream part as JSON so consumers can switch on // the native AI SDK type. Accumulation below is for the final // assembled result this activity returns. - stream.publish(EVENTS_TOPIC, encoder.encode(JSON.stringify(part))); + events.publish(encoder.encode(JSON.stringify(part))); switch (part.type) { case 'stream-start': diff --git a/packages/contrib-workflow-stream/README.md b/packages/contrib-workflow-stream/README.md index ab0b5361e..2e12a10fd 100644 --- a/packages/contrib-workflow-stream/README.md +++ b/packages/contrib-workflow-stream/README.md @@ -30,31 +30,43 @@ codec behavior is symmetric between workflow-side and client-side publishing. ### Workflow side -Construct `new WorkflowStream()` at the start of your workflow function and use the -returned object to publish: +Construct `new WorkflowStream()` at the start of your workflow function, then +get a typed handle for each topic via `stream.topic(name)` and call +`publish` on the handle: ```typescript import { WorkflowStream } from '@temporalio/contrib-workflow-stream'; +interface StatusEvent { + state: 'started' | 'done'; +} + export async function myWorkflow(input: MyInput): Promise { const stream = new WorkflowStream(); + const status = stream.topic('status'); - stream.publish('status', { state: 'started' }); + status.publish({ state: 'started' }); await doWork(); - stream.publish('status', { state: 'done' }); + status.publish({ state: 'done' }); } ``` The `WorkflowStream` constructor registers the `__temporal_workflow_stream_publish` signal, `__temporal_workflow_stream_poll` update, and `__temporal_workflow_stream_offset` query handlers on your workflow. Any value the default payload converter can serialize (JSON, `Uint8Array`, or -a pre-built `Payload`) can be passed to `publish`. +a pre-built `Payload`) can be passed to `publish`. The type parameter `T` is +purely a compile-time annotation — TypeScript has no runtime type +representation, so per-topic type uniformity isn't enforced at runtime +(unlike sdk-python). Repeated calls to `stream.topic('foo')` return the same +handle instance, so a stale `T` annotation can't introduce a duplicate +publisher. ### Activity side (publishing) Use `WorkflowStreamClient.fromActivity()` with `await using` for batched publishing from inside an activity. The client and workflow ID are pulled from the -activity context: +activity context. Bind a topic handle on the client and publish through it, +the same way as on the workflow side: ```typescript import { Context } from '@temporalio/activity'; @@ -63,9 +75,10 @@ import { WorkflowStreamClient } from '@temporalio/contrib-workflow-stream'; export async function streamEvents(): Promise { await using client = WorkflowStreamClient.fromActivity({ batchInterval: '2 seconds' }); client.start(); + const events = client.topic('events'); for await (const chunk of generateChunks()) { - client.publish('events', chunk); + events.publish(chunk); Context.current().heartbeat(); } // Buffer is flushed automatically on scope exit. @@ -80,38 +93,41 @@ call `start()` and `await stop()` explicitly: const client = WorkflowStreamClient.create(temporalClient, workflowId); client.start(); try { - client.publish('events', data); + const events = client.topic('events'); + events.publish(data); } finally { await client.stop(); } ``` -Use `forceFlush = true` to trigger an immediate flush for latency-sensitive +Use `forceFlush: true` to trigger an immediate flush for latency-sensitive events: ```typescript -client.publish('events', data, true); +events.publish(data, { forceFlush: true }); ``` ### Subscribing -Use `WorkflowStreamClient.create()` and iterate `subscribe()`: +Subscribe via the topic handle to get items decoded as `T`: ```typescript -import { defaultPayloadConverter } from '@temporalio/common'; import { WorkflowStreamClient } from '@temporalio/contrib-workflow-stream'; const client = WorkflowStreamClient.create(temporalClient, workflowId); -for await (const item of client.subscribe(['events'], 0)) { - // item.data is a Payload; decode with a payload converter - const value = defaultPayloadConverter.fromPayload(item.data); - console.log(item.topic, item.offset, value); - if (isDone(value)) break; +const events = client.topic('events'); +for await (const item of events.subscribe(0)) { + // item.data is decoded to MyType via the default payload converter. + console.log(item.topic, item.offset, item.data); + if (isDone(item.data)) break; } ``` -`item.data` is a `Payload` carrying encoding metadata, so any converter-known -value round-trips (`json/plain` for JSON, `binary/plain` for `Uint8Array`, etc.). +For raw `Payload` access — multi-topic subscriptions whose payload types +differ, or fine-grained control over decoding — call +`WorkflowStreamClient.subscribe(topics?, fromOffset?)` directly. The yielded +items have `data: Payload` carrying encoding metadata; decode with +`defaultPayloadConverter.fromPayload(item.data)` per-topic. ## Topics @@ -182,7 +198,7 @@ if (workflowInfo().continueAsNewSuggested) { | Method | Description | |---|---| -| `publish(topic, value)` | Append to the log from workflow code. Accepts any value the default payload converter handles, or a pre-built `Payload`. | +| `topic(name)` | Get a typed `WorkflowTopicHandle` for publishing. Repeated calls with the same name return the same handle. | | `getState(publisherTtl?)` | Snapshot for continue-as-new. Drops publisher dedup entries older than `publisherTtl` (`Duration`, default `'15 minutes'`). | | `detachPollers()` | Unblock polls and reject new ones. | | `continueAsNew(buildArgs, options?)` | Async. Detach pollers, wait for handlers, then `continueAsNew` with `buildArgs(state)`. Use the explicit recipe with `makeContinueAsNewFunc` to pass other CAN options. | @@ -206,10 +222,19 @@ Handlers registered automatically: | `start()` | Start the background flusher. | | `stop()` | Stop the flusher and flush remaining items. | | `[Symbol.asyncDispose]()` | Supports `await using client = WorkflowStreamClient.create(...)`. | -| `publish(topic, value, forceFlush = false)` | Buffer a message. `value` may be any converter-compatible object or a pre-built `Payload`. `forceFlush` wakes the flusher to send immediately. | -| `subscribe(topics?, fromOffset = 0, { pollCooldown = '100 milliseconds' })` | Async generator yielding `WorkflowStreamItem` with `data: Payload`. `pollCooldown` is a `Duration`. Always follows CAN chains when created via `create()`. Recovers automatically from `TruncatedOffset` by restarting from the current base offset. | +| `topic(name)` | Get a typed `TopicHandle` for publishing and subscribing. Repeated calls with the same name return the same handle. | +| `subscribe(topics?, fromOffset = 0, { pollCooldown = '100 milliseconds' })` | Raw async generator yielding `WorkflowStreamItem` — the multi-topic / decode-yourself path. `pollCooldown` is a `Duration`. Always follows CAN chains when created via `create()`. Recovers automatically from `TruncatedOffset` by restarting from the current base offset. | | `getOffset()` | Query current global offset. | +### `TopicHandle` / `WorkflowTopicHandle` + +| Method | Description | +|---|---| +| `name` | Topic name this handle is bound to. | +| `publish(value, options?)` (client-side) | Buffer `value` for publishing on this topic. `options.forceFlush = true` wakes the flusher to send immediately. | +| `publish(value)` (workflow-side) | Append `value` to the log from workflow code. | +| `subscribe(fromOffset?, options?)` (client-side) | Async generator yielding `WorkflowStreamItem` with `data` decoded to `T` via the default payload converter. | + ### `WorkflowStreamClientOptions` | Option | Default | Description | diff --git a/packages/contrib-workflow-stream/src/client.ts b/packages/contrib-workflow-stream/src/client.ts index d3bfe0313..936049e62 100644 --- a/packages/contrib-workflow-stream/src/client.ts +++ b/packages/contrib-workflow-stream/src/client.ts @@ -32,6 +32,7 @@ import { type PublishEntry, type PublishInput, } from './types'; +import { TopicHandle } from './topic-handle'; /** Thrown when a flush retry exceeds maxRetryDuration. */ export class FlushTimeoutError extends Error { @@ -136,6 +137,7 @@ export class WorkflowStreamClient { private flusherStopped = false; private flusherError: Error | undefined; private currentFlush: Promise | null = null; + private readonly topicHandles = new Map>(); constructor(handle: WorkflowHandle, options?: WorkflowStreamClientOptions) { this.handle = handle; @@ -273,16 +275,24 @@ export class WorkflowStreamClient { } /** - * Buffer a message for publishing. + * Get a typed handle for publishing to and subscribing from ``name``. * - * @param topic - Topic string. - * @param value - Any value the payload converter can handle, or a pre-built - * `Payload` for zero-copy. The codec chain runs once on the signal - * envelope, not per item. - * @param forceFlush - If true, wake the flusher to send immediately - * (fire-and-forget — does not block the caller). + * Repeated calls with the same name return the same handle instance. + * The type parameter ``T`` is purely a compile-time annotation — see + * the module note in {@link TopicHandle} for the difference from + * sdk-python's runtime type-uniformity check. */ - publish(topic: string, value: unknown, forceFlush = false): void { + topic(name: string): TopicHandle { + let handle = this.topicHandles.get(name); + if (handle === undefined) { + handle = new TopicHandle(this, name); + this.topicHandles.set(name, handle as TopicHandle); + } + return handle as TopicHandle; + } + + /** @internal Used by {@link TopicHandle.publish}. */ + _publishToTopic(topic: string, value: unknown, forceFlush: boolean): void { this.buffer.push({ topic, value }); if (forceFlush || (this.maxBatchSize !== undefined && this.buffer.length >= this.maxBatchSize)) { this.flushEvent.set(); diff --git a/packages/contrib-workflow-stream/src/index.ts b/packages/contrib-workflow-stream/src/index.ts index a6f5a4e80..d481d11c5 100644 --- a/packages/contrib-workflow-stream/src/index.ts +++ b/packages/contrib-workflow-stream/src/index.ts @@ -33,3 +33,4 @@ export { export { WorkflowStream, workflowStreamPublishSignal, workflowStreamPollUpdate, workflowStreamOffsetQuery } from './stream'; export { WorkflowStreamClient, FlushTimeoutError } from './client'; export type { WorkflowStreamClientOptions, SubscribeOptions } from './client'; +export { TopicHandle, WorkflowTopicHandle } from './topic-handle'; diff --git a/packages/contrib-workflow-stream/src/stream.ts b/packages/contrib-workflow-stream/src/stream.ts index ba0fa0501..668984eb8 100644 --- a/packages/contrib-workflow-stream/src/stream.ts +++ b/packages/contrib-workflow-stream/src/stream.ts @@ -41,6 +41,7 @@ import { type PublishInput, type _WorkflowStreamWireItem, } from './types'; +import { WorkflowTopicHandle } from './topic-handle'; const BINARY_PLAIN_ENCODING = new TextEncoder().encode('binary/plain'); @@ -114,6 +115,7 @@ export class WorkflowStream { private readonly publisherSequences: Record; private readonly publisherLastSeen: Record; private draining = false; + private readonly topicHandles = new Map>(); constructor(priorState?: WorkflowStreamState) { // Note: sdk-python guards against a second `WorkflowStream(...)` call on the @@ -150,11 +152,24 @@ export class WorkflowStream { } /** - * Publish an item from within workflow code. Deterministic — just appends. - * `value` may be any value the default payload converter can handle, or - * a pre-built `Payload` for zero-copy. + * Get a typed handle for publishing to ``name``. + * + * Repeated calls with the same name return the same handle instance. + * The type parameter ``T`` is purely a compile-time annotation — see + * the module note in {@link TopicHandle} for the difference from + * sdk-python's runtime type-uniformity check. */ - publish(topic: string, value: unknown): void { + topic(name: string): WorkflowTopicHandle { + let handle = this.topicHandles.get(name); + if (handle === undefined) { + handle = new WorkflowTopicHandle(this, name); + this.topicHandles.set(name, handle as WorkflowTopicHandle); + } + return handle as WorkflowTopicHandle; + } + + /** @internal Used by {@link WorkflowTopicHandle.publish}. */ + _publishToTopic(topic: string, value: unknown): void { let payload: Payload; if (isPayload(value)) { payload = value; diff --git a/packages/contrib-workflow-stream/src/topic-handle.ts b/packages/contrib-workflow-stream/src/topic-handle.ts new file mode 100644 index 000000000..473c769d7 --- /dev/null +++ b/packages/contrib-workflow-stream/src/topic-handle.ts @@ -0,0 +1,111 @@ +/** + * Typed topic handles for Workflow Streams. + * + * A topic handle is a thin typed view over an underlying publisher. It + * carries the topic name and the value type ``T`` so call sites do not + * have to repeat them on every publish, and so subscribers reading the + * same handle decode to the matching type. + * + * Unlike sdk-python, ``T`` here is purely a compile-time annotation: + * TypeScript has no runtime type representation, so per-topic type + * uniformity cannot be enforced by the runtime. {@link WorkflowStream} + * and {@link WorkflowStreamClient} do memoize handles by name, so two + * `topic('foo')` calls return the same handle instance — but the two + * calls' ``T`` parameters are erased and not compared. + */ + +import { defaultPayloadConverter, type Payload } from '@temporalio/common'; +import type { SubscribeOptions, WorkflowStreamClient } from './client'; +import type { WorkflowStream } from './stream'; +import type { WorkflowStreamItem } from './types'; + +/** + * Client-side handle for publishing to and subscribing from a single topic. + * + * Constructed via {@link WorkflowStreamClient.topic}. Publishes share the + * underlying client's batching, dedup, and codec path; this object holds + * only the topic name and the type binding. + * + * @experimental + */ +export class TopicHandle { + /** @internal */ + constructor( + private readonly client: WorkflowStreamClient, + public readonly name: string, + ) {} + + /** + * Buffer ``value`` for publishing on this topic. + * + * Equivalent to the underlying client's publish path; the value flows + * through the same buffer, batch interval, and dedup sequence. + * + * @param value Value to publish. Goes through the client's payload + * converter at flush time. A pre-built {@link Payload} bypasses + * conversion (zero-copy fast path), regardless of the handle's + * bound type. + * @param options.forceFlush If true, wake the flusher to send + * immediately (fire-and-forget — does not block the caller). + */ + publish(value: T | Payload, options?: { forceFlush?: boolean }): void { + // Cast through `unknown` so the internal publisher accepts the value; + // the per-handle T is compile-time only. + (this.client as unknown as { _publishToTopic(name: string, value: unknown, forceFlush: boolean): void }) + ._publishToTopic(this.name, value, options?.forceFlush ?? false); + } + + /** + * Async iterator over items on this topic, decoded as ``T`` via the + * default payload converter. + * + * For raw {@link Payload} access, or any other decode path that + * differs from the handle's bound ``T``, call + * {@link WorkflowStreamClient.subscribe} directly — it yields + * {@link WorkflowStreamItem | WorkflowStreamItem}. + * + * @param fromOffset Global offset to start reading from. + * @param options.pollCooldown Minimum interval between polls when + * there are no new items. Default 100ms. + */ + async *subscribe( + fromOffset = 0, + options?: SubscribeOptions, + ): AsyncGenerator, void, unknown> { + for await (const raw of this.client.subscribe(this.name, fromOffset, options)) { + yield { + topic: raw.topic, + data: defaultPayloadConverter.fromPayload(raw.data), + offset: raw.offset, + }; + } + } +} + +/** + * Workflow-side handle for publishing to a single topic. + * + * Constructed via {@link WorkflowStream.topic}. Has no ``subscribe``: + * workflows do not consume their own stream. + * + * @experimental + */ +export class WorkflowTopicHandle { + /** @internal */ + constructor( + private readonly stream: WorkflowStream, + public readonly name: string, + ) {} + + /** + * Append ``value`` to the workflow stream on this topic. + * + * @param value Value to publish. Goes through the workflow's default + * payload converter. A pre-built {@link Payload} bypasses + * conversion, regardless of the handle's bound type. + */ + publish(value: T | Payload): void { + (this.stream as unknown as { _publishToTopic(name: string, value: unknown): void }) + ._publishToTopic(this.name, value); + } +} diff --git a/packages/contrib-workflow-stream/src/types.ts b/packages/contrib-workflow-stream/src/types.ts index 7a4906f47..c5cc0db65 100644 --- a/packages/contrib-workflow-stream/src/types.ts +++ b/packages/contrib-workflow-stream/src/types.ts @@ -23,16 +23,19 @@ import type { Payload } from '@temporalio/common'; /** * A single item in the workflow stream log (user-facing). * - * `data` is a raw {@link Payload}; use a {@link PayloadConverter} - * (e.g. `defaultPayloadConverter.fromPayload(item.data)`) to - * decode it to a typed value. + * Generic on the decoded ``data`` type ``T``. Default ``T = Payload`` + * matches what {@link WorkflowStreamClient.subscribe} yields — the raw + * payload, with ``metadata.encoding`` available for downstream decode. + * Subscribing through a {@link TopicHandle} narrows ``T`` to the + * handle's bound type, with the default payload converter applied per + * item. * - * The `offset` field is populated by the poll handler from the item's + * The ``offset`` field is populated by the poll handler from the item's * position in the global log. */ -export interface WorkflowStreamItem { +export interface WorkflowStreamItem { topic: string; - data: Payload; + data: T; offset: number; } diff --git a/packages/test/src/activities/contrib-workflow-stream.ts b/packages/test/src/activities/contrib-workflow-stream.ts index 4e33076c7..3593f7c70 100644 --- a/packages/test/src/activities/contrib-workflow-stream.ts +++ b/packages/test/src/activities/contrib-workflow-stream.ts @@ -13,20 +13,22 @@ const encoder = new TextEncoder(); export async function publishItems(count: number): Promise { await using client = WorkflowStreamClient.fromActivity({ batchInterval: '500 milliseconds' }); client.start(); + const events = client.topic('events'); for (let i = 0; i < count; i++) { Context.current().heartbeat(); - client.publish('events', encoder.encode(`item-${i}`)); + events.publish(encoder.encode(`item-${i}`)); } } export async function publishMultiTopic(count: number): Promise { - const topics = ['a', 'b', 'c']; + const topicNames = ['a', 'b', 'c']; await using client = WorkflowStreamClient.fromActivity({ batchInterval: '500 milliseconds' }); client.start(); + const handles = topicNames.map((name) => client.topic(name)); for (let i = 0; i < count; i++) { Context.current().heartbeat(); - const topic = topics[i % topics.length]!; - client.publish(topic, encoder.encode(`${topic}-${i}`)); + const idx = i % handles.length; + handles[idx]!.publish(encoder.encode(`${topicNames[idx]}-${i}`)); } } @@ -38,9 +40,10 @@ export async function publishWithForceFlush(): Promise { // than flaking on slow CI. await using client = WorkflowStreamClient.fromActivity({ batchInterval: '60 seconds' }); client.start(); - client.publish('events', encoder.encode('normal-0')); - client.publish('events', encoder.encode('normal-1')); - client.publish('events', encoder.encode('force-flush'), true); + const events = client.topic('events'); + events.publish(encoder.encode('normal-0')); + events.publish(encoder.encode('normal-1')); + events.publish(encoder.encode('force-flush'), { forceFlush: true }); for (let i = 0; i < 100; i++) { Context.current().heartbeat(); await new Promise((resolve) => setTimeout(resolve, 100)); @@ -50,9 +53,10 @@ export async function publishWithForceFlush(): Promise { export async function publishBatchTest(count: number): Promise { await using client = WorkflowStreamClient.fromActivity({ batchInterval: '60 seconds' }); client.start(); + const events = client.topic('events'); for (let i = 0; i < count; i++) { Context.current().heartbeat(); - client.publish('events', encoder.encode(`item-${i}`)); + events.publish(encoder.encode(`item-${i}`)); } // Long batchInterval — only the dispose-driven drain will flush. } @@ -63,9 +67,10 @@ export async function publishWithMaxBatch(count: number): Promise { maxBatchSize: 3, }); client.start(); + const events = client.topic('events'); for (let i = 0; i < count; i++) { Context.current().heartbeat(); - client.publish('events', encoder.encode(`item-${i}`)); + events.publish(encoder.encode(`item-${i}`)); } // Long batchInterval — maxBatchSize and dispose-driven drain handle flushing. } diff --git a/packages/test/src/test-contrib-workflow-stream.ts b/packages/test/src/test-contrib-workflow-stream.ts index db50efbbe..000f5dd9e 100644 --- a/packages/test/src/test-contrib-workflow-stream.ts +++ b/packages/test/src/test-contrib-workflow-stream.ts @@ -420,9 +420,10 @@ test('explicit_flush_barrier — flush() returns once items are confirmed', asyn // 2. Flush makes prior publishes visible without waiting on the // 60s batch timer. - stream.publish('events', encoder.encode('a')); - stream.publish('events', encoder.encode('b')); - stream.publish('events', encoder.encode('c')); + const events = stream.topic('events'); + events.publish(encoder.encode('a')); + events.publish(encoder.encode('b')); + events.publish(encoder.encode('c')); await stream.flush(); t.is(await stream.getOffset(), 3); @@ -727,15 +728,16 @@ test('flush_retry_preserves_items_after_failures — behavioral retry coverage', return realSignal(...(args as Parameters)); }) as typeof handle.signal; - stream.publish('events', encoder.encode('item-0')); - stream.publish('events', encoder.encode('item-1')); + const events = stream.topic('events'); + events.publish(encoder.encode('item-0')); + events.publish(encoder.encode('item-1')); await t.throwsAsync((stream as unknown as { _doFlush(): Promise })._doFlush(), { message: /simulated/, }); // Publish more during the failed state — must not overtake the pending // retry on eventual delivery. - stream.publish('events', encoder.encode('item-2')); + events.publish(encoder.encode('item-2')); await t.throwsAsync((stream as unknown as { _doFlush(): Promise })._doFlush(), { message: /simulated/, }); @@ -765,7 +767,7 @@ test('flush_raises_after_max_retry_duration — timeout surfaces, client resumes maxRetryDuration: '200 milliseconds', }); client.start(); - client.publish('events', encoder.encode('will-be-lost')); + client.topic('events').publish(encoder.encode('will-be-lost')); await new Promise((r) => setTimeout(r, 1500)); await t.throwsAsync(client.stop(), { instanceOf: FlushTimeoutError }); }); diff --git a/packages/test/src/workflows/contrib-workflow-stream.ts b/packages/test/src/workflows/contrib-workflow-stream.ts index f88692571..774528659 100644 --- a/packages/test/src/workflows/contrib-workflow-stream.ts +++ b/packages/test/src/workflows/contrib-workflow-stream.ts @@ -39,13 +39,14 @@ export async function basicWorkflowStreamWorkflow(): Promise { /** Publishes `count` items directly from the workflow, then waits. */ export async function workflowSidePublishWorkflow(count: number): Promise { const stream = new WorkflowStream(); + const events = stream.topic('events'); let closed = false; setHandler(closeSignal, () => { closed = true; }); const encoder = new TextEncoder(); for (let i = 0; i < count; i++) { - stream.publish('events', encoder.encode(`item-${i}`)); + events.publish(encoder.encode(`item-${i}`)); } await condition(() => closed); } @@ -64,12 +65,13 @@ export async function multiTopicWorkflow(count: number): Promise { /** Executes publishItems activity then appends activity_done status. */ export async function activityPublishWorkflow(count: number): Promise { const stream = new WorkflowStream(); + const status = stream.topic('status'); let closed = false; setHandler(closeSignal, () => { closed = true; }); await publishItems(count); - stream.publish('status', new TextEncoder().encode('activity_done')); + status.publish(new TextEncoder().encode('activity_done')); await condition(() => closed); } @@ -122,12 +124,13 @@ export async function flushOnExitWorkflow(count: number): Promise { /** Workflow that runs publishWithMaxBatch activity. */ export async function maxBatchWorkflow(count: number): Promise { const stream = new WorkflowStream(); + const status = stream.topic('status'); let closed = false; setHandler(closeSignal, () => { closed = true; }); await publishWithMaxBatch(count); - stream.publish('status', new TextEncoder().encode('activity_done')); + status.publish(new TextEncoder().encode('activity_done')); await condition(() => closed); } From 92767ae8e1c35ef8c9f5a37bbd43448ba3df0b78 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Wed, 29 Apr 2026 19:35:25 -0700 Subject: [PATCH 29/75] contrib-workflow-stream: TopicHandle.subscribe uses client's converter Codex review caught that TopicHandle.subscribe called defaultPayloadConverter unconditionally, breaking clients that configured a custom converter via WorkflowStreamClient.create. Read the client's payloadConverter (already set up by the create() factory to honor the Client's loadedDataConverter) and decode through it. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/contrib-workflow-stream/src/topic-handle.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/contrib-workflow-stream/src/topic-handle.ts b/packages/contrib-workflow-stream/src/topic-handle.ts index 473c769d7..ccbf7201c 100644 --- a/packages/contrib-workflow-stream/src/topic-handle.ts +++ b/packages/contrib-workflow-stream/src/topic-handle.ts @@ -14,7 +14,7 @@ * calls' ``T`` parameters are erased and not compared. */ -import { defaultPayloadConverter, type Payload } from '@temporalio/common'; +import type { Payload, PayloadConverter } from '@temporalio/common'; import type { SubscribeOptions, WorkflowStreamClient } from './client'; import type { WorkflowStream } from './stream'; import type { WorkflowStreamItem } from './types'; @@ -57,7 +57,9 @@ export class TopicHandle { /** * Async iterator over items on this topic, decoded as ``T`` via the - * default payload converter. + * client's configured payload converter (custom converter on the + * underlying {@link WorkflowStreamClient.create | Client} flows + * through; otherwise the default). * * For raw {@link Payload} access, or any other decode path that * differs from the handle's bound ``T``, call @@ -72,10 +74,11 @@ export class TopicHandle { fromOffset = 0, options?: SubscribeOptions, ): AsyncGenerator, void, unknown> { + const converter = (this.client as unknown as { payloadConverter: PayloadConverter }).payloadConverter; for await (const raw of this.client.subscribe(this.name, fromOffset, options)) { yield { topic: raw.topic, - data: defaultPayloadConverter.fromPayload(raw.data), + data: converter.fromPayload(raw.data), offset: raw.offset, }; } From 01de057800708c70036c0867a92a57ff14a4e999 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Mon, 4 May 2026 15:25:20 -0700 Subject: [PATCH 30/75] sync cargo.lock --- packages/core-bridge/Cargo.lock | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/packages/core-bridge/Cargo.lock b/packages/core-bridge/Cargo.lock index 4022778a6..dcced6e72 100644 --- a/packages/core-bridge/Cargo.lock +++ b/packages/core-bridge/Cargo.lock @@ -1408,9 +1408,9 @@ dependencies = [ [[package]] name = "opentelemetry-otlp" -version = "0.31.0" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2366db2dca4d2ad033cad11e6ee42844fd727007af5ad04a1730f4cb8163bf" +checksum = "1f69cd6acbb9af919df949cd1ec9e5e7fdc2ef15d234b6b795aaa525cc02f71f" dependencies = [ "http", "opentelemetry", @@ -2267,9 +2267,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.4" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] @@ -2751,7 +2751,7 @@ dependencies = [ "toml_datetime", "toml_parser", "toml_writer", - "winnow", + "winnow 0.7.15", ] [[package]] @@ -2765,18 +2765,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.2", ] [[package]] name = "toml_writer" -version = "1.0.6+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tonic" @@ -3509,9 +3509,15 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.14" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" [[package]] name = "wit-bindgen" From 4e9c4813f74c7ea53a1a4629f9255778ab67d0bf Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Wed, 6 May 2026 12:32:41 -0700 Subject: [PATCH 31/75] contrib-workflow-stream: allow __temporal_ wire names for handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package registers signal/update/query handlers under a fixed __temporal_workflow_stream_* convention shared with sdk-python, but sdk-typescript reserves __temporal_ for SDK-internal use and rejected the registration outright. Allowlist the three workflow-stream wire names in throwIfReservedName, and narrow the dispatch-time prefix checks in internals.ts to fire only when an unregistered __temporal_* name would otherwise be routed to the user's default handler — the previous unconditional check raced the workflow function on continue-as-new (activation jobs are processed before startWorkflow runs in worker-interface.ts), so a post-CAN poll update arrived before the handler could re-register. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/common/src/reserved.ts | 13 +++++++++++- packages/workflow/src/internals.ts | 32 ++++++++++++++++++++++++------ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/packages/common/src/reserved.ts b/packages/common/src/reserved.ts index 8e43be3cc..2a8b5a7bd 100644 --- a/packages/common/src/reserved.ts +++ b/packages/common/src/reserved.ts @@ -2,6 +2,17 @@ export const TEMPORAL_RESERVED_PREFIX = '__temporal_'; export const STACK_TRACE_QUERY_NAME = '__stack_trace'; export const ENHANCED_STACK_TRACE_QUERY_NAME = '__enhanced_stack_trace'; +/** + * Wire identifiers used by first-party SDK contrib packages. These + * bypass the {@link TEMPORAL_RESERVED_PREFIX} check at registration time. + */ +const INTERNAL_HANDLER_NAME_ALLOWLIST: ReadonlySet = new Set([ + // @temporalio/contrib-workflow-stream + '__temporal_workflow_stream_publish', + '__temporal_workflow_stream_poll', + '__temporal_workflow_stream_offset', +]); + /** * Valid entity types that can be checked for reserved name violations */ @@ -16,7 +27,7 @@ export type ReservedNameEntityType = 'query' | 'signal' | 'update' | 'activity' * @param name The name to check against reserved prefixes/names */ export function throwIfReservedName(type: ReservedNameEntityType, name: string): void { - if (name.startsWith(TEMPORAL_RESERVED_PREFIX)) { + if (name.startsWith(TEMPORAL_RESERVED_PREFIX) && !INTERNAL_HANDLER_NAME_ALLOWLIST.has(name)) { throw new TypeError(`Cannot use ${type} name: '${name}', with reserved prefix: '${TEMPORAL_RESERVED_PREFIX}'`); } diff --git a/packages/workflow/src/internals.ts b/packages/workflow/src/internals.ts index a76e9b844..0240e450b 100644 --- a/packages/workflow/src/internals.ts +++ b/packages/workflow/src/internals.ts @@ -763,8 +763,14 @@ export class Activator implements ActivationHandler { throw new TypeError('Missing query activation attributes'); } - // If query has __temporal_ prefix but no handler exists, throw error - if (queryType.startsWith(TEMPORAL_RESERVED_PREFIX) && !this.queryHandlers.has(queryType)) { + // Reject __temporal_-prefixed queries that would otherwise be routed to the + // user's default handler. A specific registered handler (e.g. from a + // contrib package) is allowed through. + if ( + queryType.startsWith(TEMPORAL_RESERVED_PREFIX) && + !this.queryHandlers.has(queryType) && + this.defaultQueryHandler !== undefined + ) { throw new TypeError(`Cannot use query name: '${queryType}', with reserved prefix: '${TEMPORAL_RESERVED_PREFIX}'`); } @@ -798,8 +804,15 @@ export class Activator implements ActivationHandler { throw new TypeError('Missing activation update protocolInstanceId'); } - // If update has __temporal_ prefix but no handler exists, throw error - if (name.startsWith(TEMPORAL_RESERVED_PREFIX) && !this.updateHandlers.get(name)) { + // Reject __temporal_-prefixed updates that would otherwise be routed to the + // user's default handler. A specific registered handler (e.g. from a + // contrib package) is allowed through, and unregistered names without a + // default handler fall through to the buffer-then-reject path below. + if ( + name.startsWith(TEMPORAL_RESERVED_PREFIX) && + !this.updateHandlers.has(name) && + this.defaultUpdateHandler !== undefined + ) { throw new TypeError(`Cannot use update name: '${name}', with reserved prefix: '${TEMPORAL_RESERVED_PREFIX}'`); } @@ -975,8 +988,15 @@ export class Activator implements ActivationHandler { throw new TypeError('Missing activation signalName'); } - // If signal has __temporal_ prefix but no handler exists, throw error - if (signalName.startsWith(TEMPORAL_RESERVED_PREFIX) && !this.signalHandlers.has(signalName)) { + // Reject __temporal_-prefixed signals that would otherwise be routed to the + // user's default handler. A specific registered handler (e.g. from a + // contrib package) is allowed through, and unregistered names without a + // default handler fall through to the buffer-then-reject path below. + if ( + signalName.startsWith(TEMPORAL_RESERVED_PREFIX) && + !this.signalHandlers.has(signalName) && + this.defaultSignalHandler !== undefined + ) { throw new TypeError( `Cannot use signal name: '${signalName}', with reserved prefix: '${TEMPORAL_RESERVED_PREFIX}'` ); From ddbcabc00832d30a4a374d2e1e02fec26c352830 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Wed, 6 May 2026 14:11:47 -0700 Subject: [PATCH 32/75] ai-sdk: enable Web Streams in workflow sandbox, drop polyfill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ai-sdk package's TemporalLanguageModel.doStream returns a ReadableStream to satisfy LanguageModelV3StreamResult — the stream is "fake" (synchronously enqueues a fully-materialized result and closes), but the Web Streams constructor must be reachable from workflow code. Inject ReadableStream / WritableStream / TransformStream and the queuing-strategy classes from node:stream/web into the workflow sandbox via injectGlobals. Drop the now-redundant web-streams-polyfill require from ai-sdk's load-polyfills.ts; the polyfill was failing on top of the new non-configurable globals. Workflow-bundler webpack rejected `node:`-prefixed imports outright (UnhandledSchemeError) because the alias map only matched the bare form. Add a NormalModuleReplacementPlugin that strips the `node:` scheme so node-builtin imports route through the same alias-to-false path as their bare form. Provider.ts uses `import type` for ReadableStreamDefaultController plus `declare const ReadableStream: typeof import(...).ReadableStream` so the type checks without pulling node:stream/web into the bundle. The Telemetry test built its own bundle via Worker.create(workflowsPath) without bundlerOptions, so disallowed-modules detection caught @temporalio/activity / crypto / @temporalio/client reaching the bundle through ai-sdk's index re-exports. Pass bundlerOptions from helpers.ts (the same exclusions other ai-sdk tests inherit via pre-built bundles). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/ai-sdk/src/load-polyfills.ts | 3 --- packages/ai-sdk/src/provider.ts | 5 ++++- packages/test/src/test-ai-sdk.ts | 3 ++- packages/worker/src/workflow/bundler.ts | 5 +++++ packages/worker/src/workflow/vm-shared.ts | 12 ++++++++++++ 5 files changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/ai-sdk/src/load-polyfills.ts b/packages/ai-sdk/src/load-polyfills.ts index 98a1f1f6c..76d286803 100644 --- a/packages/ai-sdk/src/load-polyfills.ts +++ b/packages/ai-sdk/src/load-polyfills.ts @@ -7,9 +7,6 @@ if (inWorkflowContext()) { globalThis.Headers = Headers; } - // eslint-disable-next-line @typescript-eslint/no-require-imports,import/no-unassigned-import - require('web-streams-polyfill/polyfill'); - // Attach the polyfill as a Global function if (!('structuredClone' in globalThis)) { // eslint-disable-next-line @typescript-eslint/no-require-imports const structuredClone = require('@ungap/structured-clone'); diff --git a/packages/ai-sdk/src/provider.ts b/packages/ai-sdk/src/provider.ts index b374fc104..5cc690e9a 100644 --- a/packages/ai-sdk/src/provider.ts +++ b/packages/ai-sdk/src/provider.ts @@ -1,4 +1,7 @@ -import { ReadableStream, type ReadableStreamDefaultController } from 'node:stream/web'; +// `ReadableStream` is a sandbox global; type-only import keeps `node:stream/web` +// out of the workflow bundle (es2023 lib has no DOM types). +import type { ReadableStreamDefaultController } from 'node:stream/web'; +declare const ReadableStream: typeof import('node:stream/web').ReadableStream; import type { EmbeddingModelV3, EmbeddingModelV3CallOptions, diff --git a/packages/test/src/test-ai-sdk.ts b/packages/test/src/test-ai-sdk.ts index 75f9eda12..3f4c6ebaa 100644 --- a/packages/test/src/test-ai-sdk.ts +++ b/packages/test/src/test-ai-sdk.ts @@ -51,7 +51,7 @@ import { } from './workflows/ai-sdk'; import { helpers, makeTestFunction } from './helpers-integration'; import { getWeather } from './activities/ai-sdk'; -import { Worker } from './helpers'; +import { bundlerOptions, Worker } from './helpers'; import EventType = temporal.api.enums.v1.EventType; const remoteTests = ['1', 't', 'true'].includes((process.env.AI_SDK_REMOTE_TESTS ?? 'false').toLowerCase()); @@ -451,6 +451,7 @@ test('Telemetry', async (t) => { ], taskQueue: 'test-ai-telemetry', workflowsPath: require.resolve('./workflows/ai-sdk'), + bundlerOptions, interceptors: { client: { diff --git a/packages/worker/src/workflow/bundler.ts b/packages/worker/src/workflow/bundler.ts index 1eecf3b9d..c517ebfb3 100644 --- a/packages/worker/src/workflow/bundler.ts +++ b/packages/worker/src/workflow/bundler.ts @@ -247,6 +247,11 @@ exports.importInterceptors = function importInterceptors() { /[\\/](?:@temporalio|packages)[\\/]interceptors-opentelemetry[\\/](?:src|lib)[\\/]workflow[\\/]workflow-imports\.[jt]s$/, './workflow-imports-impl.js' ), + // Strip the `node:` URI scheme so imports like `node:stream/web` route + // through the same alias map as the bare form. + new NormalModuleReplacementPlugin(/^node:/, (resource) => { + resource.request = resource.request.replace(/^node:/, ''); + }), ], externals: captureProblematicModules, module: { diff --git a/packages/worker/src/workflow/vm-shared.ts b/packages/worker/src/workflow/vm-shared.ts index 1c280b645..2b5fb381c 100644 --- a/packages/worker/src/workflow/vm-shared.ts +++ b/packages/worker/src/workflow/vm-shared.ts @@ -3,6 +3,13 @@ import type vm from 'node:vm'; import { AsyncLocalStorage as AsyncLocalStorageOriginal } from 'node:async_hooks'; import assert from 'node:assert'; import { URL, URLSearchParams } from 'node:url'; +import { + ByteLengthQueuingStrategy, + CountQueuingStrategy, + ReadableStream, + TransformStream, + WritableStream, +} from 'node:stream/web'; import { TextDecoder, TextEncoder } from 'node:util'; import { SourceMapConsumer } from 'source-map'; import { cutoffStackTrace, IllegalStateError, convertDeploymentVersion } from '@temporalio/common'; @@ -102,6 +109,11 @@ export function injectGlobals(context: vm.Context): void { TextEncoder, TextDecoder, AbortController, + ReadableStream, + WritableStream, + TransformStream, + ByteLengthQueuingStrategy, + CountQueuingStrategy, }; for (const [k, v] of Object.entries(globals)) { Object.defineProperty(sandboxGlobalThis, k, { value: v, writable: false, enumerable: true, configurable: false }); From 3671bc546e704229d9846641431befa79f92ff26 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Wed, 6 May 2026 14:11:47 -0700 Subject: [PATCH 33/75] sync cargo.lock Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/core-bridge/Cargo.lock | 339 +++++++++++++------------------- 1 file changed, 136 insertions(+), 203 deletions(-) diff --git a/packages/core-bridge/Cargo.lock b/packages/core-bridge/Cargo.lock index e7c1cf06e..8b04308ff 100644 --- a/packages/core-bridge/Cargo.lock +++ b/packages/core-bridge/Cargo.lock @@ -214,12 +214,6 @@ dependencies = [ "shlex", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cfg-if" version = "1.0.4" @@ -414,7 +408,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -508,7 +502,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -697,7 +691,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ "rustix", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -1084,27 +1078,32 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jni" -version = "0.21.1" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "cesu8", "cfg-if", "combine", - "jni-sys 0.3.1", + "jni-macros", + "jni-sys", "log", - "thiserror 1.0.69", + "simd_cesu8", + "thiserror", "walkdir", - "windows-sys 0.45.0", + "windows-link 0.2.1", ] [[package]] -name = "jni-sys" -version = "0.3.1" +name = "jni-macros" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ - "jni-sys 0.4.1", + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", ] [[package]] @@ -1138,10 +1137,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.85" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -1177,18 +1178,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-link", + "windows-link 0.2.1", ] [[package]] name = "libredox" -version = "0.1.12" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" dependencies = [ "bitflags", "libc", - "redox_syscall 0.7.1", + "redox_syscall", ] [[package]] @@ -1363,9 +1364,9 @@ dependencies = [ [[package]] name = "ntapi" -version = "0.4.3" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" dependencies = [ "winapi", ] @@ -1376,7 +1377,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1429,7 +1430,7 @@ dependencies = [ "futures-sink", "js-sys", "pin-project-lite", - "thiserror 2.0.18", + "thiserror", "tracing", ] @@ -1459,7 +1460,7 @@ dependencies = [ "opentelemetry_sdk", "prost", "reqwest 0.12.28", - "thiserror 2.0.18", + "thiserror", "tokio", "tonic", ] @@ -1489,7 +1490,7 @@ dependencies = [ "opentelemetry", "percent-encoding", "rand 0.9.2", - "thiserror 2.0.18", + "thiserror", "tokio", "tokio-stream", ] @@ -1507,7 +1508,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1528,9 +1529,9 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -1708,7 +1709,7 @@ dependencies = [ "lazy_static", "memchr", "parking_lot", - "thiserror 2.0.18", + "thiserror", ] [[package]] @@ -1844,7 +1845,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.18", + "thiserror", "tokio", "tracing", "web-time", @@ -1866,7 +1867,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror", "tinyvec", "tracing", "web-time", @@ -1979,18 +1980,9 @@ checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "redox_syscall" -version = "0.7.1" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ "bitflags", ] @@ -2003,7 +1995,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror", ] [[package]] @@ -2077,9 +2069,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" dependencies = [ "base64", "bytes", @@ -2166,7 +2158,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2209,9 +2201,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation", "core-foundation-sys", @@ -2225,7 +2217,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2269,11 +2261,11 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.28" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2412,6 +2404,22 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.2" @@ -2533,7 +2541,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2555,7 +2563,7 @@ dependencies = [ "temporalio-client", "temporalio-common", "temporalio-sdk-core", - "thiserror 2.0.18", + "thiserror", "tokio", "tokio-stream", "tonic", @@ -2584,7 +2592,7 @@ dependencies = [ "parking_lot", "rand 0.10.1", "temporalio-common", - "thiserror 2.0.18", + "thiserror", "tokio", "tonic", "tower", @@ -2625,7 +2633,7 @@ dependencies = [ "ringbuf", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror", "tokio", "toml", "tonic", @@ -2673,7 +2681,7 @@ dependencies = [ "prost", "prost-wkt-types", "rand 0.10.1", - "reqwest 0.13.2", + "reqwest 0.13.3", "serde", "serde_json", "siphasher", @@ -2683,7 +2691,7 @@ dependencies = [ "temporalio-client", "temporalio-common", "temporalio-macros", - "thiserror 2.0.18", + "thiserror", "tokio", "tokio-stream", "tokio-util", @@ -2700,33 +2708,13 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "thiserror-impl", ] [[package]] @@ -3199,9 +3187,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.108" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" dependencies = [ "cfg-if", "once_cell", @@ -3212,23 +3200,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.58" +version = "0.4.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3236,9 +3220,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" dependencies = [ "bumpalo", "proc-macro2", @@ -3249,9 +3233,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" dependencies = [ "unicode-ident", ] @@ -3305,9 +3289,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.85" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" dependencies = [ "js-sys", "wasm-bindgen", @@ -3325,9 +3309,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.7" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" dependencies = [ "rustls-pki-types", ] @@ -3354,7 +3338,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3392,7 +3376,7 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link", + "windows-link 0.2.1", "windows-result", "windows-strings", ] @@ -3404,7 +3388,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ "windows-core", - "windows-link", + "windows-link 0.2.1", "windows-threading", ] @@ -3430,6 +3414,12 @@ dependencies = [ "syn", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" @@ -3443,7 +3433,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ "windows-core", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -3452,7 +3442,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -3461,23 +3451,23 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] name = "windows-sys" -version = "0.45.0" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.42.2", + "windows-targets 0.52.6", ] [[package]] name = "windows-sys" -version = "0.52.0" +version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ "windows-targets 0.52.6", ] @@ -3488,7 +3478,7 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.5", + "windows-targets 0.53.3", ] [[package]] @@ -3497,22 +3487,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", + "windows-link 0.2.1", ] [[package]] @@ -3533,19 +3508,19 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.5" +version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows-link 0.1.3", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", ] [[package]] @@ -3554,15 +3529,9 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -3571,15 +3540,9 @@ checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_gnullvm" -version = "0.53.1" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" [[package]] name = "windows_aarch64_msvc" @@ -3589,15 +3552,9 @@ checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_aarch64_msvc" -version = "0.53.1" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" [[package]] name = "windows_i686_gnu" @@ -3607,9 +3564,9 @@ checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnu" -version = "0.53.1" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" [[package]] name = "windows_i686_gnullvm" @@ -3619,15 +3576,9 @@ checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_gnullvm" -version = "0.53.1" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" [[package]] name = "windows_i686_msvc" @@ -3637,15 +3588,9 @@ checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_i686_msvc" -version = "0.53.1" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" [[package]] name = "windows_x86_64_gnu" @@ -3655,15 +3600,9 @@ checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" [[package]] name = "windows_x86_64_gnullvm" @@ -3673,15 +3612,9 @@ checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" [[package]] name = "windows_x86_64_msvc" @@ -3691,9 +3624,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "windows_x86_64_msvc" -version = "0.53.1" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" [[package]] name = "winnow" @@ -3910,9 +3843,9 @@ dependencies = [ [[package]] name = "zip" -version = "8.5.1" +version = "8.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcab981e19633ebcf0b001ddd37dd802996098bc1864f90b7c5d970ce76c1d59" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" dependencies = [ "bzip2", "crc32fast", From 0cfa474dc1b6b8ab0f063a81fe7de5a1471d39f0 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Wed, 6 May 2026 15:24:01 -0700 Subject: [PATCH 34/75] contrib-workflow-stream: handle stream-end races in subscribe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit subscribe() previously threw on three workflow-completed-mid-poll races that the Python implementation handles by exiting cleanly: - WorkflowUpdateFailedError with cause AcceptedUpdateCompletedWorkflow (workflow returned or continued-as-new before the poll's update could complete) — follow the CAN chain or exit cleanly. - WorkflowNotFoundError surfaced from gRPC NOT_FOUND on the update RPC (workflow may have completed between polls) — follow the CAN chain, exit cleanly if the workflow is in a terminal state, otherwise rethrow. isInTerminalState() mirrors sdk-python's _workflow_in_terminal_state. Note that TS uses 'CANCELLED' where Python uses 'CANCELED'. Also: fixWorkflowStreamClient.fromActivity for standalone activities (workflowExecution is now optional after main's #2029) by mirroring Python's from_within_activity guard. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../contrib-workflow-stream/src/client.ts | 61 ++++++++++++++++++- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/packages/contrib-workflow-stream/src/client.ts b/packages/contrib-workflow-stream/src/client.ts index 936049e62..110f51a26 100644 --- a/packages/contrib-workflow-stream/src/client.ts +++ b/packages/contrib-workflow-stream/src/client.ts @@ -21,7 +21,13 @@ import { WorkflowUpdateFailedError, WorkflowUpdateRPCTimeoutOrCancelledError, } from '@temporalio/client'; -import { ApplicationFailure, defaultPayloadConverter, type Payload, type PayloadConverter } from '@temporalio/common'; +import { + ApplicationFailure, + defaultPayloadConverter, + WorkflowNotFoundError, + type Payload, + type PayloadConverter, +} from '@temporalio/common'; import { Duration, msToNumber } from '@temporalio/common/lib/time'; import { decodePayloadWire, @@ -181,8 +187,16 @@ export class WorkflowStreamClient { */ static fromActivity(options?: WorkflowStreamClientOptions): WorkflowStreamClient { const ctx = ActivityContext.current(); - const workflowId = ctx.info.workflowExecution.workflowId; - return WorkflowStreamClient.create(ctx.client, workflowId, options); + const workflowExecution = ctx.info.workflowExecution; + if (workflowExecution === undefined) { + throw new Error( + 'fromActivity requires an activity scheduled by a workflow; this ' + + 'activity has no parent workflow. From a standalone activity, use ' + + 'WorkflowStreamClient.create(client, workflowId) with the target ' + + 'workflow id passed in explicitly.' + ); + } + return WorkflowStreamClient.create(ctx.client, workflowExecution.workflowId, options); } /** Start the background flusher. Call before publishing. */ @@ -441,6 +455,14 @@ export class WorkflowStreamClient { offset = 0; continue; } + if (cause instanceof ApplicationFailure && cause.type === 'AcceptedUpdateCompletedWorkflow') { + // Workflow returned (or continued-as-new) before this poll's + // update completed. Either follow the chain or exit cleanly. + if (await this.followContinueAsNew()) { + continue; + } + return; + } throw err; } if (err instanceof WorkflowUpdateRPCTimeoutOrCancelledError) { @@ -449,6 +471,18 @@ export class WorkflowStreamClient { } return; } + if (err instanceof WorkflowNotFoundError) { + // Workflow may have completed between polls. subscribe() exits + // cleanly on terminal status so callers don't have to wrap the + // iterator in error handling for the normal end-of-stream case. + if (await this.followContinueAsNew()) { + continue; + } + if (await this.isInTerminalState()) { + return; + } + throw err; + } throw err; } @@ -489,4 +523,25 @@ export class WorkflowStreamClient { } return false; } + + /** + * Return true if the workflow has reached a terminal state. Used by + * `subscribe()` to distinguish "workflow finished — stream is done" from + * "wrong workflow id" when a poll surfaces `WorkflowNotFoundError`. + */ + private async isInTerminalState(): Promise { + try { + const desc = await this.handle.describe(); + const name = desc.status.name; + return ( + name === 'COMPLETED' || + name === 'FAILED' || + name === 'CANCELLED' || + name === 'TERMINATED' || + name === 'TIMED_OUT' + ); + } catch { + return false; + } + } } From 9332c0e6dbeaf857f03592a64d3fa625511a2833 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Wed, 6 May 2026 15:38:35 -0700 Subject: [PATCH 35/75] contrib-workflow-stream: typed subscribe via resultType MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WorkflowStreamClient.subscribe now has a typed overload that mirrors sdk-python's subscribe(result_type=T): subscribe(topics, fromOffset, { ...opts, resultType: true }) -> AsyncGenerator> Each item is decoded via the client's configured payload converter (custom converter on .create flows through; otherwise default). The default zero-arg form is unchanged — yields raw Payload — so existing callers that decode inline keep working. TopicHandle.subscribe is now a one-line forwarder to the typed overload, dropping the inline decode loop and the cross-module private-field cast. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../contrib-workflow-stream/src/client.ts | 28 +++++++++++++++---- .../src/topic-handle.ts | 15 +++------- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/packages/contrib-workflow-stream/src/client.ts b/packages/contrib-workflow-stream/src/client.ts index 110f51a26..371a5ad7a 100644 --- a/packages/contrib-workflow-stream/src/client.ts +++ b/packages/contrib-workflow-stream/src/client.ts @@ -420,8 +420,12 @@ export class WorkflowStreamClient { /** * Async generator that polls for new items. * - * Yielded items carry `data: Payload`. Use a payload converter such as - * `defaultPayloadConverter.fromPayload(item.data)` to decode. + * Default — yields items with `data: Payload` (no decode). Use a payload + * converter such as `defaultPayloadConverter.fromPayload(item.data)` + * to decode at the call site. + * + * With `resultType: true` — each item is decoded via the client's + * configured payload converter and yielded as the generic `T`. * * Automatically follows continue-as-new chains when created via * {@link WorkflowStreamClient.create}. @@ -429,11 +433,21 @@ export class WorkflowStreamClient { * @param topics - Topic filter. A single topic name, an array of topic * names, or undefined. Undefined or an empty array means all topics. */ - async *subscribe( + subscribe( topics?: string | string[], - fromOffset = 0, + fromOffset?: number, options?: SubscribeOptions - ): AsyncGenerator { + ): AsyncGenerator, void, unknown>; + subscribe( + topics: string | string[] | undefined, + fromOffset: number, + options: SubscribeOptions & { resultType: true } + ): AsyncGenerator, void, unknown>; + async *subscribe( + topics?: string | string[], + fromOffset = 0, + options?: SubscribeOptions & { resultType?: boolean } + ): AsyncGenerator, void, unknown> { const pollCooldownMs = msToNumber(options?.pollCooldown ?? '100 milliseconds'); const topicFilter: string[] = topics === undefined ? [] : typeof topics === 'string' ? [topics] : topics; @@ -486,10 +500,12 @@ export class WorkflowStreamClient { throw err; } + const decode = options?.resultType === true; for (const wireItem of result.items) { + const payload = decodePayloadWire(wireItem.data); yield { topic: wireItem.topic, - data: decodePayloadWire(wireItem.data), + data: (decode ? this.payloadConverter.fromPayload(payload) : (payload as unknown as T)), offset: wireItem.offset, }; } diff --git a/packages/contrib-workflow-stream/src/topic-handle.ts b/packages/contrib-workflow-stream/src/topic-handle.ts index ccbf7201c..2f8e9b4b0 100644 --- a/packages/contrib-workflow-stream/src/topic-handle.ts +++ b/packages/contrib-workflow-stream/src/topic-handle.ts @@ -14,7 +14,7 @@ * calls' ``T`` parameters are erased and not compared. */ -import type { Payload, PayloadConverter } from '@temporalio/common'; +import type { Payload } from '@temporalio/common'; import type { SubscribeOptions, WorkflowStreamClient } from './client'; import type { WorkflowStream } from './stream'; import type { WorkflowStreamItem } from './types'; @@ -70,18 +70,11 @@ export class TopicHandle { * @param options.pollCooldown Minimum interval between polls when * there are no new items. Default 100ms. */ - async *subscribe( - fromOffset = 0, + subscribe( + fromOffset?: number, options?: SubscribeOptions, ): AsyncGenerator, void, unknown> { - const converter = (this.client as unknown as { payloadConverter: PayloadConverter }).payloadConverter; - for await (const raw of this.client.subscribe(this.name, fromOffset, options)) { - yield { - topic: raw.topic, - data: converter.fromPayload(raw.data), - offset: raw.offset, - }; - } + return this.client.subscribe(this.name, fromOffset ?? 0, { ...options, resultType: true }); } } From 40ef218f9c6c80c009d9ab69e6092e4927291a3e Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Wed, 6 May 2026 16:21:56 -0700 Subject: [PATCH 36/75] ai-sdk: configurable streamingTopic in TemporalProviderOptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The streaming activity previously published every event to a hardcoded 'events' topic — concurrent streaming calls within a workflow shared one channel and a TS workflow couldn't align with a sdk-python plugin on the same wire topic. Replace the boolean `streaming` flag with `streamingTopic?: string`: when set, doStream is enabled and routes through invokeModelStreaming with the chosen topic; when unset, doStream throws. Mirrors sdk-python's `streaming_topic` semantic. Also add `streamingBatchInterval?: Duration` to match Python's `streaming_batch_interval`. Add InvokeModelStreamingArgs extending InvokeModelArgs so the streaming activity's contract is type-checked: streamingTopic is required at the type level, no runtime guard needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/ai-sdk/src/activities.ts | 20 ++++++++++++--- packages/ai-sdk/src/provider.ts | 41 +++++++++++++++++++++---------- 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/packages/ai-sdk/src/activities.ts b/packages/ai-sdk/src/activities.ts index a335dcfc5..8c4307f85 100644 --- a/packages/ai-sdk/src/activities.ts +++ b/packages/ai-sdk/src/activities.ts @@ -14,9 +14,9 @@ import { asSchema, type Schema, type ToolExecutionOptions } from 'ai'; import { ApplicationFailure } from '@temporalio/common'; import { Context } from '@temporalio/activity'; import { WorkflowStreamClient } from '@temporalio/contrib-workflow-stream'; +import type { Duration } from '@temporalio/common/lib/time'; import type { McpClientFactories, McpClientFactory } from './mcp'; -const EVENTS_TOPIC = 'events'; const encoder = new TextEncoder(); /** @@ -27,6 +27,16 @@ export interface InvokeModelArgs { options: LanguageModelV3CallOptions; } +/** + * Arguments for invoking a streaming language model activity. + */ +export interface InvokeModelStreamingArgs extends InvokeModelArgs { + modelId: string; + options: LanguageModelV3CallOptions; + streamingTopic: string; + streamingBatchInterval?: Duration; +} + /** * Result from a language model invocation. * This is an alias to the AI SDK's LanguageModelV3GenerateResult for type safety. @@ -97,10 +107,12 @@ export function createActivities( * stream-part types (text-delta, reasoning-delta, tool-input-delta, * response-metadata, finish, ...); no normalization happens here. */ - async invokeModelStreaming(args: InvokeModelArgs): Promise { - const stream = WorkflowStreamClient.fromActivity({ batchInterval: '100 milliseconds' }); + async invokeModelStreaming(args: InvokeModelStreamingArgs): Promise { + const stream = WorkflowStreamClient.fromActivity({ + batchInterval: args.streamingBatchInterval ?? '100 milliseconds', + }); stream.start(); - const events = stream.topic(EVENTS_TOPIC); + const events = stream.topic(args.streamingTopic); const model = provider.languageModel(args.modelId); const streamResult = await model.doStream(args.options); diff --git a/packages/ai-sdk/src/provider.ts b/packages/ai-sdk/src/provider.ts index 5cc690e9a..7a9683f72 100644 --- a/packages/ai-sdk/src/provider.ts +++ b/packages/ai-sdk/src/provider.ts @@ -17,6 +17,7 @@ import type { import * as workflow from '@temporalio/workflow'; import type { ActivityOptions } from '@temporalio/workflow'; import { ApplicationFailure } from '@temporalio/common'; +import type { Duration } from '@temporalio/common/lib/time'; /** * Options for configuring the TemporalProvider with per-model activity settings. @@ -33,12 +34,21 @@ export interface TemporalProviderOptions { * Merged with default options, with these taking precedence. */ languageModel?: ActivityOptions & { + + /** + * Topic name on the workflow's stream that streaming model calls publish + * raw stream parts to. When set, `doStream` is enabled and routes through + * the streaming activity; when unset, `doStream` throws. Pick a unique + * name per concurrent streaming call to keep event streams separable. + */ + streamingTopic?: string; + /** - * When true, model calls use the streaming LLM endpoint and publish - * token events via WorkflowStreamClient. The workflow receives a complete result; - * real-time streaming happens via stream as a side channel. + * Batch interval for the per-activity `WorkflowStreamClient` that + * publishes stream parts back to the workflow. Lower values reduce + * latency at the cost of more signal traffic. Defaults to 100ms. */ - streaming?: boolean; + streamingBatchInterval?: Duration; }; /** @@ -57,13 +67,15 @@ export interface TemporalProviderOptions { export class TemporalLanguageModel implements LanguageModelV3 { readonly specificationVersion = 'v3'; readonly provider = 'temporal'; - private streaming: boolean; + private readonly streamingTopic: string | undefined; + private readonly streamingBatchInterval: Duration | undefined; constructor( readonly modelId: string, - readonly options?: ActivityOptions & { streaming?: boolean } + readonly options?: ActivityOptions & { streamingTopic?: string; streamingBatchInterval?: Duration } ) { - this.streaming = options?.streaming ?? false; + this.streamingTopic = options?.streamingTopic; + this.streamingBatchInterval = options?.streamingBatchInterval; } get supportedUrls(): Record { @@ -90,9 +102,9 @@ export class TemporalLanguageModel implements LanguageModelV3 { } async doStream(options: LanguageModelV3CallOptions): Promise { - if (!this.streaming) { + if (this.streamingTopic === undefined) { throw ApplicationFailure.nonRetryable( - 'Streaming not enabled. Set streaming: true in languageModel provider options.' + 'Streaming not enabled. Set streamingTopic in languageModel provider options.' ); } @@ -102,7 +114,12 @@ export class TemporalLanguageModel implements LanguageModelV3 { startToCloseTimeout: '10 minutes', ...this.options, }); - const result = await activities.invokeModelStreaming!({ modelId: this.modelId, options }); + const result = await activities.invokeModelStreaming!({ + modelId: this.modelId, + options, + streamingTopic: this.streamingTopic, + streamingBatchInterval: this.streamingBatchInterval, + }); if (result === undefined) { throw ApplicationFailure.nonRetryable('Received undefined response from streaming model activity.'); } @@ -200,11 +217,9 @@ export class TemporalProvider implements ProviderV3 { } languageModel(modelId: string): LanguageModelV3 { - const { streaming, ...languageModelOptions } = this.options?.languageModel ?? {}; return new TemporalLanguageModel(modelId, { ...this.options?.default, - ...languageModelOptions, - streaming, + ...this.options?.languageModel, }); } From f37b8174d9c1fe540217bee36f826ed52b7f243b Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Wed, 6 May 2026 16:47:59 -0700 Subject: [PATCH 37/75] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/contrib-workflow-stream/src/stream.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/contrib-workflow-stream/src/stream.ts b/packages/contrib-workflow-stream/src/stream.ts index 668984eb8..f62ddb51b 100644 --- a/packages/contrib-workflow-stream/src/stream.ts +++ b/packages/contrib-workflow-stream/src/stream.ts @@ -89,7 +89,7 @@ function isPayload(value: unknown): value is Payload { v.metadata != null && typeof v.metadata === 'object' && !Array.isArray(v.metadata) && - Object.values(v.metadata).every((x) => x instanceof Uint8Array) + Object.values(v.metadata).every((x) => isUint8ArrayLike(x)) ); } From 822e8cfc85f9ef9ff372b6fd5bbbba968cd07aad Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Wed, 6 May 2026 16:58:02 -0700 Subject: [PATCH 38/75] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/contrib-workflow-stream/src/types.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/contrib-workflow-stream/src/types.ts b/packages/contrib-workflow-stream/src/types.ts index c5cc0db65..6e8ccff88 100644 --- a/packages/contrib-workflow-stream/src/types.ts +++ b/packages/contrib-workflow-stream/src/types.ts @@ -182,12 +182,15 @@ function writeTagLenBytes(buf: number[], tag: number, bytes: Uint8Array): void { for (let i = 0; i < bytes.length; i++) buf.push(bytes[i]!); } +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + function utf8Encode(s: string): Uint8Array { - return new TextEncoder().encode(s); + return textEncoder.encode(s); } function utf8Decode(b: Uint8Array): string { - return new TextDecoder().decode(b); + return textDecoder.decode(b); } /** Encode a Payload to its protobuf binary representation. */ From 4f4608e91f35c559d7090c2f9c6b4e8d616baff6 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Wed, 6 May 2026 16:57:20 -0700 Subject: [PATCH 39/75] contrib-workflow-stream: validate input in decodeBase64 decodeBase64 previously fell through to whatever B64.indexOf returned for non-alphabet characters (-1), silently emitting corrupted bytes. Same for inputs whose length doesn't match a valid base64 form. Throw TypeError on: - non-alphabet character (with the offset where it appeared) - too many '=' padding characters - clean length % 4 == 1 (no valid base64 has that shape) Update the doc comment to advertise the new throw behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/contrib-workflow-stream/src/types.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/contrib-workflow-stream/src/types.ts b/packages/contrib-workflow-stream/src/types.ts index 6e8ccff88..852d709a4 100644 --- a/packages/contrib-workflow-stream/src/types.ts +++ b/packages/contrib-workflow-stream/src/types.ts @@ -116,9 +116,15 @@ export function encodeBase64(data: Uint8Array): string { return result; } -/** Decode standard base64 to bytes. */ +/** Decode standard base64 to bytes. Throws TypeError on malformed input. */ export function decodeBase64(data: string): Uint8Array { const clean = data.replace(/=+$/, ''); + if (data.length - clean.length > 2) { + throw new TypeError(`Invalid base64 input: too many '=' padding characters`); + } + if (clean.length % 4 === 1) { + throw new TypeError(`Invalid base64 input: length ${data.length} is not valid`); + } const len = (clean.length * 3) >> 2; const out = new Uint8Array(len); let j = 0; @@ -127,6 +133,9 @@ export function decodeBase64(data: string): Uint8Array { const b = i + 1 < clean.length ? B64.indexOf(clean.charAt(i + 1)) : 0; const c = i + 2 < clean.length ? B64.indexOf(clean.charAt(i + 2)) : 0; const d = i + 3 < clean.length ? B64.indexOf(clean.charAt(i + 3)) : 0; + if (a < 0 || b < 0 || c < 0 || d < 0) { + throw new TypeError(`Invalid base64 input: non-alphabet character at offset ${i}`); + } out[j++] = (a << 2) | (b >> 4); if (j < len) out[j++] = ((b << 4) | (c >> 2)) & 0xff; if (j < len) out[j++] = ((c << 6) | d) & 0xff; From cb22129c808b5edb76bc5021c1560244da01fde5 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Wed, 6 May 2026 16:59:22 -0700 Subject: [PATCH 40/75] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/contrib-workflow-stream/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/contrib-workflow-stream/package.json b/packages/contrib-workflow-stream/package.json index fa0a92a3e..ec2595917 100644 --- a/packages/contrib-workflow-stream/package.json +++ b/packages/contrib-workflow-stream/package.json @@ -1,6 +1,6 @@ { "name": "@temporalio/contrib-workflow-stream", - "version": "1.15.0", + "version": "1.17.0", "description": "Temporal.io SDK Workflow Streams contrib module", "main": "lib/index.js", "types": "./lib/index.d.ts", From f56fd33a9c8fae2c1684a32df50d25963fd80bed Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Wed, 6 May 2026 16:59:37 -0700 Subject: [PATCH 41/75] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/contrib-workflow-stream/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/contrib-workflow-stream/package.json b/packages/contrib-workflow-stream/package.json index ec2595917..c45cfa7a0 100644 --- a/packages/contrib-workflow-stream/package.json +++ b/packages/contrib-workflow-stream/package.json @@ -21,7 +21,7 @@ "@temporalio/workflow": "workspace:*" }, "engines": { - "node": ">= 20.4.0" + "node": ">= 20.0.0" }, "publishConfig": { "access": "public" From 1a3dd1de044b3a8ce301f7216c1b7b6e744afd01 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Thu, 7 May 2026 08:52:24 -0700 Subject: [PATCH 42/75] ai-sdk: use await using for the streaming activity's WorkflowStreamClient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit invokeModelStreaming previously called stream.start() and then ran provider.languageModel() / model.doStream() before entering the try/finally that stops the client. A throw from either of those — or from the reader loop — could leave the background flusher running and drop buffered events. Switch to `await using stream = WorkflowStreamClient.fromActivity(...)` so dispose covers every exit path. WorkflowStreamClient already implements [Symbol.asyncDispose] (stop + drain), so no client-side change is needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/ai-sdk/src/activities.ts | 132 +++++++++++++++--------------- 1 file changed, 64 insertions(+), 68 deletions(-) diff --git a/packages/ai-sdk/src/activities.ts b/packages/ai-sdk/src/activities.ts index 8c4307f85..b11c09598 100644 --- a/packages/ai-sdk/src/activities.ts +++ b/packages/ai-sdk/src/activities.ts @@ -108,7 +108,7 @@ export function createActivities( * response-metadata, finish, ...); no normalization happens here. */ async invokeModelStreaming(args: InvokeModelStreamingArgs): Promise { - const stream = WorkflowStreamClient.fromActivity({ + await using stream = WorkflowStreamClient.fromActivity({ batchInterval: args.streamingBatchInterval ?? '100 milliseconds', }); stream.start(); @@ -129,77 +129,73 @@ export function createActivities( let currentText = ''; let currentReasoning = ''; - try { - const reader = streamResult.stream.getReader(); - // eslint-disable-next-line no-constant-condition - while (true) { - const { done, value: part } = await reader.read(); - if (done) break; + const reader = streamResult.stream.getReader(); + // eslint-disable-next-line no-constant-condition + while (true) { + const { done, value: part } = await reader.read(); + if (done) break; - Context.current().heartbeat(); + Context.current().heartbeat(); - // Publish the raw stream part as JSON so consumers can switch on - // the native AI SDK type. Accumulation below is for the final - // assembled result this activity returns. - events.publish(encoder.encode(JSON.stringify(part))); + // Publish the raw stream part as JSON so consumers can switch on + // the native AI SDK type. Accumulation below is for the final + // assembled result this activity returns. + events.publish(encoder.encode(JSON.stringify(part))); - switch (part.type) { - case 'stream-start': - warnings.push(...part.warnings); - break; - case 'text-start': - currentText = ''; - break; - case 'text-delta': - currentText += part.delta; - break; - case 'text-end': - content.push({ - type: 'text', - text: currentText, - providerMetadata: part.providerMetadata, - }); - break; - case 'reasoning-start': - currentReasoning = ''; - break; - case 'reasoning-delta': - currentReasoning += part.delta; - break; - case 'reasoning-end': - content.push({ - type: 'reasoning', - text: currentReasoning, - providerMetadata: part.providerMetadata, - }); - break; - case 'response-metadata': - responseMetadata = { - id: part.id, - timestamp: part.timestamp, - modelId: part.modelId, - }; - break; - case 'finish': - finishReason = part.finishReason; - usage = part.usage; - break; - default: - // tool-call, tool-result, file, source — collect as content - if ( - 'type' in part && - (part.type === 'tool-call' || - part.type === 'tool-result' || - part.type === 'file' || - part.type === 'source') - ) { - content.push(part as LanguageModelV3Content); - } - break; - } + switch (part.type) { + case 'stream-start': + warnings.push(...part.warnings); + break; + case 'text-start': + currentText = ''; + break; + case 'text-delta': + currentText += part.delta; + break; + case 'text-end': + content.push({ + type: 'text', + text: currentText, + providerMetadata: part.providerMetadata, + }); + break; + case 'reasoning-start': + currentReasoning = ''; + break; + case 'reasoning-delta': + currentReasoning += part.delta; + break; + case 'reasoning-end': + content.push({ + type: 'reasoning', + text: currentReasoning, + providerMetadata: part.providerMetadata, + }); + break; + case 'response-metadata': + responseMetadata = { + id: part.id, + timestamp: part.timestamp, + modelId: part.modelId, + }; + break; + case 'finish': + finishReason = part.finishReason; + usage = part.usage; + break; + default: + // tool-call, tool-result, file, source — collect as content + if ( + 'type' in part && + (part.type === 'tool-call' || + part.type === 'tool-result' || + part.type === 'file' || + part.type === 'source') + ) { + content.push(part as LanguageModelV3Content); + } + break; } - } finally { - await stream.stop(); } return { From ee880441347a4bdcdbfab7a18e49178a340eecd4 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Thu, 7 May 2026 08:55:40 -0700 Subject: [PATCH 43/75] contrib-workflow-stream: add project references to workspace deps CI runs `tsc --build` per-package and tsc couldn't resolve @temporalio/{activity,client,common,workflow} during the standalone build because the package's tsconfig was missing references. Local builds happened to succeed because we were always building the deps first. Add explicit references to all four upstream packages. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/contrib-workflow-stream/tsconfig.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/contrib-workflow-stream/tsconfig.json b/packages/contrib-workflow-stream/tsconfig.json index 868a22f96..de83bc9a3 100644 --- a/packages/contrib-workflow-stream/tsconfig.json +++ b/packages/contrib-workflow-stream/tsconfig.json @@ -5,5 +5,11 @@ "rootDir": "./src", "skipLibCheck": true }, + "references": [ + { "path": "../activity" }, + { "path": "../client" }, + { "path": "../common" }, + { "path": "../workflow" } + ], "include": ["./src/**/*.ts"] } From d87a475353a7727b99c8850c841e361a223237ae Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Thu, 7 May 2026 09:09:02 -0700 Subject: [PATCH 44/75] contrib-workflow-stream: declare @temporalio/proto as devDependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit contrib's tsconfig now references ../common, so tsc walks common's source during contrib's build to validate up-to-dateness. common imports @temporalio/proto/lib/patch-protobuf-root, and pnpm's isolated node_modules means proto needs to be declared on contrib to be reachable during the build. devDependencies (not dependencies) because contrib doesn't actually import proto at runtime — it's purely a build-time requirement. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/contrib-workflow-stream/package.json | 3 +++ pnpm-lock.yaml | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/packages/contrib-workflow-stream/package.json b/packages/contrib-workflow-stream/package.json index c45cfa7a0..80c3f8e49 100644 --- a/packages/contrib-workflow-stream/package.json +++ b/packages/contrib-workflow-stream/package.json @@ -20,6 +20,9 @@ "@temporalio/common": "workspace:*", "@temporalio/workflow": "workspace:*" }, + "devDependencies": { + "@temporalio/proto": "workspace:*" + }, "engines": { "node": ">= 20.0.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4ca9174a..cc4368eaf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -283,6 +283,10 @@ importers: '@temporalio/workflow': specifier: workspace:* version: link:../workflow + devDependencies: + '@temporalio/proto': + specifier: workspace:* + version: link:../proto packages/core-bridge: dependencies: From f5de882ce3482172af63ec81291af6fd548f6628 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Thu, 7 May 2026 09:11:16 -0700 Subject: [PATCH 45/75] contrib-workflow-stream: move @temporalio/proto back to dependencies Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/contrib-workflow-stream/package.json | 4 +--- pnpm-lock.yaml | 7 +++---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/contrib-workflow-stream/package.json b/packages/contrib-workflow-stream/package.json index 80c3f8e49..be3e398a0 100644 --- a/packages/contrib-workflow-stream/package.json +++ b/packages/contrib-workflow-stream/package.json @@ -18,11 +18,9 @@ "@temporalio/activity": "workspace:*", "@temporalio/client": "workspace:*", "@temporalio/common": "workspace:*", + "@temporalio/proto": "workspace:*", "@temporalio/workflow": "workspace:*" }, - "devDependencies": { - "@temporalio/proto": "workspace:*" - }, "engines": { "node": ">= 20.0.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc4368eaf..fcffcf9db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -280,13 +280,12 @@ importers: '@temporalio/common': specifier: workspace:* version: link:../common - '@temporalio/workflow': - specifier: workspace:* - version: link:../workflow - devDependencies: '@temporalio/proto': specifier: workspace:* version: link:../proto + '@temporalio/workflow': + specifier: workspace:* + version: link:../workflow packages/core-bridge: dependencies: From 0f77d71203c6585f52f2189de5aa3c58189a2b5f Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Thu, 7 May 2026 09:24:10 -0700 Subject: [PATCH 46/75] common: declare project reference to @temporalio/proto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit common's source imports `@temporalio/proto/lib/patch-protobuf-root` (proto-utils.ts, protobufs.ts) — a deep import that requires proto to have been built with tsc, since proto's own `build` script only runs the protobuf codegen and does not compile src/ → lib/. Without a `references` entry, tsc's project graph builds common before proto and fails to resolve the deep import. This was masked because nexus and test reference proto directly; their builds materialized proto/lib first. contrib-workflow-stream's build runs before nexus and test in pnpm's topological order, so the failure surfaced there. Add the missing reference on common (the real fix) plus a defensive reference on contrib-workflow-stream — same shape as nexus, which already references both common and proto explicitly. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/common/tsconfig.json | 1 + packages/contrib-workflow-stream/tsconfig.json | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/common/tsconfig.json b/packages/common/tsconfig.json index 1e09513a4..d6c63dc33 100644 --- a/packages/common/tsconfig.json +++ b/packages/common/tsconfig.json @@ -4,5 +4,6 @@ "outDir": "./lib", "rootDir": "./src" }, + "references": [{ "path": "../proto" }], "include": ["./src/**/*.ts"] } diff --git a/packages/contrib-workflow-stream/tsconfig.json b/packages/contrib-workflow-stream/tsconfig.json index de83bc9a3..9c4c1a6bf 100644 --- a/packages/contrib-workflow-stream/tsconfig.json +++ b/packages/contrib-workflow-stream/tsconfig.json @@ -9,6 +9,7 @@ { "path": "../activity" }, { "path": "../client" }, { "path": "../common" }, + { "path": "../proto" }, { "path": "../workflow" } ], "include": ["./src/**/*.ts"] From 914ff359f610c610eaed9c4bd0d3536896b62bcd Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Thu, 7 May 2026 09:35:32 -0700 Subject: [PATCH 47/75] ci(docs): bump Node heap to 8GB for docusaurus build The docs workflow's `pnpm run docs` step was hitting Node's default ~4GB heap limit during the post-compile docusaurus phase (after both server and client webpack builds succeed). Standard mitigation for docusaurus + typedoc on a growing API surface. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/docs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0fbfc0502..77cfdaf9a 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -51,6 +51,7 @@ jobs: run: pnpm run docs env: ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }} + NODE_OPTIONS: --max-old-space-size=8192 - name: Publish production docs if: ${{ inputs.publish_target == 'prod' }} From 340a24054102d7074deb71e8e1b46a1690c29d47 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Thu, 7 May 2026 09:53:27 -0700 Subject: [PATCH 48/75] fix lint errors + bump Node heap for lint workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 10 consistent-type-imports errors auto-fixed via eslint --fix. - ai-sdk/provider.ts: moved `declare const ReadableStream` below the import block so import/first and import/newline-after-import pass. - conventions.yml: NODE_OPTIONS=--max-old-space-size=8192 on lint:check and lint:prune steps — same OOM mitigation as the docs workflow; ESLint with eslint-import-resolver-typescript was hitting the default ~4GB heap limit. - prettier --write applied across touched files (table alignment in README, line wrapping, italic syntax). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/conventions.yml | 4 ++ packages/ai-sdk/src/activities.ts | 11 +-- packages/ai-sdk/src/provider.ts | 8 +-- packages/contrib-workflow-stream/README.md | 72 ++++++++++--------- .../contrib-workflow-stream/src/client.ts | 21 ++---- packages/contrib-workflow-stream/src/index.ts | 7 +- .../contrib-workflow-stream/src/stream.ts | 25 +++---- .../src/topic-handle.ts | 20 +++--- .../test-contrib-workflow-stream-interop.ts | 3 +- .../test/src/test-contrib-workflow-stream.ts | 10 +-- .../src/workflows/contrib-workflow-stream.ts | 4 +- 11 files changed, 85 insertions(+), 100 deletions(-) diff --git a/.github/workflows/conventions.yml b/.github/workflows/conventions.yml index 62cb8e6b7..046336aea 100644 --- a/.github/workflows/conventions.yml +++ b/.github/workflows/conventions.yml @@ -51,4 +51,8 @@ jobs: run: pnpm --recursive --stream --filter '!@temporalio/core-bridge' --filter '!typescript-sdk' run build - run: pnpm run lint:check + env: + NODE_OPTIONS: --max-old-space-size=8192 - run: pnpm run lint:prune + env: + NODE_OPTIONS: --max-old-space-size=8192 diff --git a/packages/ai-sdk/src/activities.ts b/packages/ai-sdk/src/activities.ts index b11c09598..0c9904dbc 100644 --- a/packages/ai-sdk/src/activities.ts +++ b/packages/ai-sdk/src/activities.ts @@ -88,10 +88,7 @@ export interface CallToolArgs { * * @experimental The AI SDK integration is an experimental feature; APIs may change without notice. */ -export function createActivities( - provider: ProviderV3, - mcpClientFactories?: McpClientFactories -): object { +export function createActivities(provider: ProviderV3, mcpClientFactories?: McpClientFactories): object { let activities = { async invokeModel(args: InvokeModelArgs): Promise { const model = provider.languageModel(args.modelId); @@ -130,7 +127,7 @@ export function createActivities( let currentReasoning = ''; const reader = streamResult.stream.getReader(); - // eslint-disable-next-line no-constant-condition + while (true) { const { done, value: part } = await reader.read(); if (done) break; @@ -204,9 +201,7 @@ export function createActivities( usage, warnings, request: streamResult.request, - response: responseMetadata - ? { ...responseMetadata, ...streamResult.response } - : streamResult.response, + response: responseMetadata ? { ...responseMetadata, ...streamResult.response } : streamResult.response, } as InvokeModelResult; }, diff --git a/packages/ai-sdk/src/provider.ts b/packages/ai-sdk/src/provider.ts index 7a9683f72..e1b9fad6d 100644 --- a/packages/ai-sdk/src/provider.ts +++ b/packages/ai-sdk/src/provider.ts @@ -1,7 +1,4 @@ -// `ReadableStream` is a sandbox global; type-only import keeps `node:stream/web` -// out of the workflow bundle (es2023 lib has no DOM types). import type { ReadableStreamDefaultController } from 'node:stream/web'; -declare const ReadableStream: typeof import('node:stream/web').ReadableStream; import type { EmbeddingModelV3, EmbeddingModelV3CallOptions, @@ -19,6 +16,10 @@ import type { ActivityOptions } from '@temporalio/workflow'; import { ApplicationFailure } from '@temporalio/common'; import type { Duration } from '@temporalio/common/lib/time'; +// `ReadableStream` is a sandbox global; type-only import keeps `node:stream/web` +// out of the workflow bundle (es2023 lib has no DOM types). +declare const ReadableStream: typeof import('node:stream/web').ReadableStream; + /** * Options for configuring the TemporalProvider with per-model activity settings. */ @@ -34,7 +35,6 @@ export interface TemporalProviderOptions { * Merged with default options, with these taking precedence. */ languageModel?: ActivityOptions & { - /** * Topic name on the workflow's stream that streaming model calls publish * raw stream parts to. When set, `doStream` is enabled and routes through diff --git a/packages/contrib-workflow-stream/README.md b/packages/contrib-workflow-stream/README.md index 2e12a10fd..211c4870c 100644 --- a/packages/contrib-workflow-stream/README.md +++ b/packages/contrib-workflow-stream/README.md @@ -157,10 +157,12 @@ export async function myWorkflow(input: WorkflowInput): Promise { // ... do work, updating itemsProcessed ... if (workflowInfo().continueAsNewSuggested) { - await stream.continueAsNew((state) => [{ - itemsProcessed, - streamState: state, - }]); + await stream.continueAsNew((state) => [ + { + itemsProcessed, + streamState: state, + }, + ]); } } ``` @@ -169,7 +171,7 @@ export async function myWorkflow(input: WorkflowInput): Promise { in-flight handlers to finish, then calls `continueAsNew` with the args returned by `buildArgs(state)`. The lambda receives the post-detach `WorkflowStreamState` as its only argument so the snapshot is guaranteed -to happen *after* pollers detach. Subscribers created via +to happen _after_ pollers detach. Subscribers created via `WorkflowStreamClient.create()` automatically follow continue-as-new chains. If you need to pass other CAN options (search attributes, memo, @@ -196,51 +198,51 @@ if (workflowInfo().continueAsNewSuggested) { ### `new WorkflowStream(priorState?)` -| Method | Description | -|---|---| -| `topic(name)` | Get a typed `WorkflowTopicHandle` for publishing. Repeated calls with the same name return the same handle. | -| `getState(publisherTtl?)` | Snapshot for continue-as-new. Drops publisher dedup entries older than `publisherTtl` (`Duration`, default `'15 minutes'`). | -| `detachPollers()` | Unblock polls and reject new ones. | +| Method | Description | +| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `topic(name)` | Get a typed `WorkflowTopicHandle` for publishing. Repeated calls with the same name return the same handle. | +| `getState(publisherTtl?)` | Snapshot for continue-as-new. Drops publisher dedup entries older than `publisherTtl` (`Duration`, default `'15 minutes'`). | +| `detachPollers()` | Unblock polls and reject new ones. | | `continueAsNew(buildArgs, options?)` | Async. Detach pollers, wait for handlers, then `continueAsNew` with `buildArgs(state)`. Use the explicit recipe with `makeContinueAsNewFunc` to pass other CAN options. | -| `truncate(upToOffset)` | Discard log entries below the given offset. | +| `truncate(upToOffset)` | Discard log entries below the given offset. | Handlers registered automatically: -| Kind | Name | Description | -|---|---|---| +| Kind | Name | Description | +| ------ | ------------------------------------ | ------------------------------ | | Signal | `__temporal_workflow_stream_publish` | Receive external publications. | -| Update | `__temporal_workflow_stream_poll` | Long-poll subscription. | -| Query | `__temporal_workflow_stream_offset` | Current global offset. | +| Update | `__temporal_workflow_stream_poll` | Long-poll subscription. | +| Query | `__temporal_workflow_stream_offset` | Current global offset. | ### `WorkflowStreamClient` -| Method | Description | -|---|---| -| `WorkflowStreamClient.create(client, workflowId, options?)` | Factory for use outside an activity (starters, BFFs). Enables CAN following in `subscribe()`; uses the `Client`'s configured payload converter. | -| `WorkflowStreamClient.fromActivity(options?)` | Factory for use from within an activity — pulls the client and parent workflow id from the activity context. | -| `new WorkflowStreamClient(handle, options?)` | From a handle (no CAN following). | -| `start()` | Start the background flusher. | -| `stop()` | Stop the flusher and flush remaining items. | -| `[Symbol.asyncDispose]()` | Supports `await using client = WorkflowStreamClient.create(...)`. | -| `topic(name)` | Get a typed `TopicHandle` for publishing and subscribing. Repeated calls with the same name return the same handle. | +| Method | Description | +| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `WorkflowStreamClient.create(client, workflowId, options?)` | Factory for use outside an activity (starters, BFFs). Enables CAN following in `subscribe()`; uses the `Client`'s configured payload converter. | +| `WorkflowStreamClient.fromActivity(options?)` | Factory for use from within an activity — pulls the client and parent workflow id from the activity context. | +| `new WorkflowStreamClient(handle, options?)` | From a handle (no CAN following). | +| `start()` | Start the background flusher. | +| `stop()` | Stop the flusher and flush remaining items. | +| `[Symbol.asyncDispose]()` | Supports `await using client = WorkflowStreamClient.create(...)`. | +| `topic(name)` | Get a typed `TopicHandle` for publishing and subscribing. Repeated calls with the same name return the same handle. | | `subscribe(topics?, fromOffset = 0, { pollCooldown = '100 milliseconds' })` | Raw async generator yielding `WorkflowStreamItem` — the multi-topic / decode-yourself path. `pollCooldown` is a `Duration`. Always follows CAN chains when created via `create()`. Recovers automatically from `TruncatedOffset` by restarting from the current base offset. | -| `getOffset()` | Query current global offset. | +| `getOffset()` | Query current global offset. | ### `TopicHandle` / `WorkflowTopicHandle` -| Method | Description | -|---|---| -| `name` | Topic name this handle is bound to. | -| `publish(value, options?)` (client-side) | Buffer `value` for publishing on this topic. `options.forceFlush = true` wakes the flusher to send immediately. | -| `publish(value)` (workflow-side) | Append `value` to the log from workflow code. | -| `subscribe(fromOffset?, options?)` (client-side) | Async generator yielding `WorkflowStreamItem` with `data` decoded to `T` via the default payload converter. | +| Method | Description | +| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | +| `name` | Topic name this handle is bound to. | +| `publish(value, options?)` (client-side) | Buffer `value` for publishing on this topic. `options.forceFlush = true` wakes the flusher to send immediately. | +| `publish(value)` (workflow-side) | Append `value` to the log from workflow code. | +| `subscribe(fromOffset?, options?)` (client-side) | Async generator yielding `WorkflowStreamItem` with `data` decoded to `T` via the default payload converter. | ### `WorkflowStreamClientOptions` -| Option | Default | Description | -|---|---|---| -| `batchInterval` | `'2 seconds'` | Interval between automatic flushes (`Duration`). | -| `maxBatchSize` | `undefined` | Auto-flush when buffer reaches this size. | +| Option | Default | Description | +| ------------------ | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `batchInterval` | `'2 seconds'` | Interval between automatic flushes (`Duration`). | +| `maxBatchSize` | `undefined` | Auto-flush when buffer reaches this size. | | `maxRetryDuration` | `'10 minutes'` | Time to retry a failed flush before `FlushTimeoutError` (`Duration`). Must be less than the workflow's `publisherTtl` to preserve exactly-once delivery. | ## Cross-Language Protocol diff --git a/packages/contrib-workflow-stream/src/client.ts b/packages/contrib-workflow-stream/src/client.ts index 371a5ad7a..c323adaad 100644 --- a/packages/contrib-workflow-stream/src/client.ts +++ b/packages/contrib-workflow-stream/src/client.ts @@ -15,12 +15,8 @@ import { randomUUID } from 'crypto'; import { Context as ActivityContext } from '@temporalio/activity'; -import { - Client, - WorkflowHandle, - WorkflowUpdateFailedError, - WorkflowUpdateRPCTimeoutOrCancelledError, -} from '@temporalio/client'; +import type { Client, WorkflowHandle } from '@temporalio/client'; +import { WorkflowUpdateFailedError, WorkflowUpdateRPCTimeoutOrCancelledError } from '@temporalio/client'; import { ApplicationFailure, defaultPayloadConverter, @@ -28,7 +24,8 @@ import { type Payload, type PayloadConverter, } from '@temporalio/common'; -import { Duration, msToNumber } from '@temporalio/common/lib/time'; +import type { Duration } from '@temporalio/common/lib/time'; +import { msToNumber } from '@temporalio/common/lib/time'; import { decodePayloadWire, encodePayloadWire, @@ -370,10 +367,7 @@ export class WorkflowStreamClient { if (this.pending !== null) { // Retry path: check max_retry_duration - if ( - this.pendingStartedAt !== null && - Date.now() - this.pendingStartedAt > this.maxRetryDurationMs - ) { + if (this.pendingStartedAt !== null && Date.now() - this.pendingStartedAt > this.maxRetryDurationMs) { // Advance confirmed sequence so the next batch gets a fresh sequence // number. Without this, the next batch reuses pendingSeq, which the // workflow may have already accepted — causing silent dedup (data @@ -449,8 +443,7 @@ export class WorkflowStreamClient { options?: SubscribeOptions & { resultType?: boolean } ): AsyncGenerator, void, unknown> { const pollCooldownMs = msToNumber(options?.pollCooldown ?? '100 milliseconds'); - const topicFilter: string[] = - topics === undefined ? [] : typeof topics === 'string' ? [topics] : topics; + const topicFilter: string[] = topics === undefined ? [] : typeof topics === 'string' ? [topics] : topics; let offset = fromOffset; while (true) { @@ -505,7 +498,7 @@ export class WorkflowStreamClient { const payload = decodePayloadWire(wireItem.data); yield { topic: wireItem.topic, - data: (decode ? this.payloadConverter.fromPayload(payload) : (payload as unknown as T)), + data: decode ? this.payloadConverter.fromPayload(payload) : (payload as unknown as T), offset: wireItem.offset, }; } diff --git a/packages/contrib-workflow-stream/src/index.ts b/packages/contrib-workflow-stream/src/index.ts index d481d11c5..a24d22c24 100644 --- a/packages/contrib-workflow-stream/src/index.ts +++ b/packages/contrib-workflow-stream/src/index.ts @@ -30,7 +30,12 @@ export { encodePayloadWire, decodePayloadWire, } from './types'; -export { WorkflowStream, workflowStreamPublishSignal, workflowStreamPollUpdate, workflowStreamOffsetQuery } from './stream'; +export { + WorkflowStream, + workflowStreamPublishSignal, + workflowStreamPollUpdate, + workflowStreamOffsetQuery, +} from './stream'; export { WorkflowStreamClient, FlushTimeoutError } from './client'; export type { WorkflowStreamClientOptions, SubscribeOptions } from './client'; export { TopicHandle, WorkflowTopicHandle } from './topic-handle'; diff --git a/packages/contrib-workflow-stream/src/stream.ts b/packages/contrib-workflow-stream/src/stream.ts index f62ddb51b..edb89893c 100644 --- a/packages/contrib-workflow-stream/src/stream.ts +++ b/packages/contrib-workflow-stream/src/stream.ts @@ -29,7 +29,8 @@ import { defaultPayloadConverter, } from '@temporalio/workflow'; import { ApplicationFailure, type Payload, type Workflow } from '@temporalio/common'; -import { Duration, msToNumber } from '@temporalio/common/lib/time'; +import type { Duration } from '@temporalio/common/lib/time'; +import { msToNumber } from '@temporalio/common/lib/time'; import { decodePayloadWire, encodePayloadProto, @@ -54,11 +55,7 @@ const BINARY_PLAIN_ENCODING = new TextEncoder().encode('binary/plain'); * `Object.prototype.toString` crosses realm boundaries reliably. */ function isUint8ArrayLike(value: unknown): value is ArrayLike { - return ( - value != null && - typeof value === 'object' && - Object.prototype.toString.call(value) === '[object Uint8Array]' - ); + return value != null && typeof value === 'object' && Object.prototype.toString.call(value) === '[object Uint8Array]'; } // Fixed handler names for cross-language interop @@ -133,12 +130,8 @@ export class WorkflowStream { })) : []; this.baseOffset = priorState?.base_offset ?? 0; - this.publisherSequences = priorState?.publisher_sequences - ? { ...priorState.publisher_sequences } - : {}; - this.publisherLastSeen = priorState?.publisher_last_seen - ? { ...priorState.publisher_last_seen } - : {}; + this.publisherSequences = priorState?.publisher_sequences ? { ...priorState.publisher_sequences } : {}; + this.publisherLastSeen = priorState?.publisher_last_seen ? { ...priorState.publisher_last_seen } : {}; setHandler(workflowStreamPublishSignal, (input: PublishInput) => this.onPublish(input)); setHandler(workflowStreamPollUpdate, (input: PollInput) => this.onPoll(input), { @@ -256,7 +249,7 @@ export class WorkflowStream { */ async continueAsNew( buildArgs: (state: WorkflowStreamState) => Parameters, - options?: { publisherTtl?: Duration }, + options?: { publisherTtl?: Duration } ): Promise { this.detachPollers(); await condition(allHandlersFinished); @@ -279,8 +272,7 @@ export class WorkflowStream { if (logIndex <= 0) return; if (logIndex > this.log.length) { throw ApplicationFailure.create({ - message: - `Cannot truncate to offset ${upToOffset}: only ${this.baseOffset + this.log.length} items exist`, + message: `Cannot truncate to offset ${upToOffset}: only ${this.baseOffset + this.log.length} items exist`, type: 'TruncateOutOfRange', nonRetryable: true, }); @@ -316,8 +308,7 @@ export class WorkflowStream { // during replay. throw ApplicationFailure.create({ message: - `Requested offset ${input.from_offset} has been truncated. ` + - `Current base offset is ${this.baseOffset}.`, + `Requested offset ${input.from_offset} has been truncated. ` + `Current base offset is ${this.baseOffset}.`, type: 'TruncatedOffset', nonRetryable: true, }); diff --git a/packages/contrib-workflow-stream/src/topic-handle.ts b/packages/contrib-workflow-stream/src/topic-handle.ts index 2f8e9b4b0..c5c45481a 100644 --- a/packages/contrib-workflow-stream/src/topic-handle.ts +++ b/packages/contrib-workflow-stream/src/topic-handle.ts @@ -32,7 +32,7 @@ export class TopicHandle { /** @internal */ constructor( private readonly client: WorkflowStreamClient, - public readonly name: string, + public readonly name: string ) {} /** @@ -51,8 +51,9 @@ export class TopicHandle { publish(value: T | Payload, options?: { forceFlush?: boolean }): void { // Cast through `unknown` so the internal publisher accepts the value; // the per-handle T is compile-time only. - (this.client as unknown as { _publishToTopic(name: string, value: unknown, forceFlush: boolean): void }) - ._publishToTopic(this.name, value, options?.forceFlush ?? false); + ( + this.client as unknown as { _publishToTopic(name: string, value: unknown, forceFlush: boolean): void } + )._publishToTopic(this.name, value, options?.forceFlush ?? false); } /** @@ -70,10 +71,7 @@ export class TopicHandle { * @param options.pollCooldown Minimum interval between polls when * there are no new items. Default 100ms. */ - subscribe( - fromOffset?: number, - options?: SubscribeOptions, - ): AsyncGenerator, void, unknown> { + subscribe(fromOffset?: number, options?: SubscribeOptions): AsyncGenerator, void, unknown> { return this.client.subscribe(this.name, fromOffset ?? 0, { ...options, resultType: true }); } } @@ -90,7 +88,7 @@ export class WorkflowTopicHandle { /** @internal */ constructor( private readonly stream: WorkflowStream, - public readonly name: string, + public readonly name: string ) {} /** @@ -101,7 +99,9 @@ export class WorkflowTopicHandle { * conversion, regardless of the handle's bound type. */ publish(value: T | Payload): void { - (this.stream as unknown as { _publishToTopic(name: string, value: unknown): void }) - ._publishToTopic(this.name, value); + (this.stream as unknown as { _publishToTopic(name: string, value: unknown): void })._publishToTopic( + this.name, + value + ); } } diff --git a/packages/test/src/test-contrib-workflow-stream-interop.ts b/packages/test/src/test-contrib-workflow-stream-interop.ts index 0c3a04031..6ef79e20b 100644 --- a/packages/test/src/test-contrib-workflow-stream-interop.ts +++ b/packages/test/src/test-contrib-workflow-stream-interop.ts @@ -9,7 +9,8 @@ * they are pure encode/decode unit tests. */ -import anyTest, { TestFn } from 'ava'; +import type { TestFn } from 'ava'; +import anyTest from 'ava'; import { defaultPayloadConverter, type Payload } from '@temporalio/common'; import { decodeBase64, diff --git a/packages/test/src/test-contrib-workflow-stream.ts b/packages/test/src/test-contrib-workflow-stream.ts index 000f5dd9e..2b08399a4 100644 --- a/packages/test/src/test-contrib-workflow-stream.ts +++ b/packages/test/src/test-contrib-workflow-stream.ts @@ -6,7 +6,8 @@ import { randomUUID } from 'crypto'; import { ApplicationFailure, defaultPayloadConverter, type Payload } from '@temporalio/common'; -import { WorkflowHandle, WorkflowUpdateFailedError } from '@temporalio/client'; +import type { WorkflowHandle } from '@temporalio/client'; +import { WorkflowUpdateFailedError } from '@temporalio/client'; import { FlushTimeoutError, WorkflowStreamClient, @@ -515,11 +516,7 @@ test('continue_as_new_typed — log, offsets, AND dedup state survive CAN', asyn // Seed publisher dedup state (pub / sequence=1) so we can verify it // survives CAN. await handle.signal<[PublishInput]>(workflowStreamPublishSignal, { - items: [ - entry('events', 'item-0'), - entry('events', 'item-1'), - entry('events', 'item-2'), - ], + items: [entry('events', 'item-0'), entry('events', 'item-1'), entry('events', 'item-2')], publisher_id: 'pub', sequence: 1, }); @@ -771,4 +768,3 @@ test('flush_raises_after_max_retry_duration — timeout surfaces, client resumes await new Promise((r) => setTimeout(r, 1500)); await t.throwsAsync(client.stop(), { instanceOf: FlushTimeoutError }); }); - diff --git a/packages/test/src/workflows/contrib-workflow-stream.ts b/packages/test/src/workflows/contrib-workflow-stream.ts index 774528659..6a8631e44 100644 --- a/packages/test/src/workflows/contrib-workflow-stream.ts +++ b/packages/test/src/workflows/contrib-workflow-stream.ts @@ -174,7 +174,5 @@ export async function continueAsNewHelperWorkflow(input: CANWorkflowInput): Prom }); await condition(() => shouldContinue || closed); if (closed) return; - await stream.continueAsNew((state) => [ - { streamState: state }, - ]); + await stream.continueAsNew((state) => [{ streamState: state }]); } From 1fd550be932124063504cbfe4a087860dfd472cc Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Thu, 7 May 2026 10:38:53 -0700 Subject: [PATCH 49/75] pin protobufjs to exact 7.5.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit protobufjs 7.5.6 was published 2026-04-28. Our deps used `^7.5.5`, so pnpm now resolves transitive installs (e.g. the features test runner's `pnpm install` against our file: linked packages) to 7.5.6 — which is unpatched and trips pnpm 10's "approved builds" rule on its `postinstall` script. Pin to exact 7.5.5 in client, cloud, common, and worker so resolution stays on the version we have a patch for. proto and test were already pinned exactly. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/client/package.json | 2 +- packages/cloud/package.json | 2 +- packages/common/package.json | 2 +- packages/worker/package.json | 2 +- pnpm-lock.yaml | 8 ++++---- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/client/package.json b/packages/client/package.json index dd4016e6a..80ac6d544 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -22,7 +22,7 @@ }, "devDependencies": { "@types/uuid": "^9.0.7", - "protobufjs": "^7.5.5" + "protobufjs": "7.5.5" }, "bugs": { "url": "https://github.com/temporalio/sdk-typescript/issues" diff --git a/packages/cloud/package.json b/packages/cloud/package.json index 0a38f3e28..702cb44d4 100644 --- a/packages/cloud/package.json +++ b/packages/cloud/package.json @@ -21,7 +21,7 @@ "abort-controller": "^3.0.0" }, "devDependencies": { - "protobufjs": "^7.5.5" + "protobufjs": "7.5.5" }, "bugs": { "url": "https://github.com/temporalio/sdk-typescript/issues" diff --git a/packages/common/package.json b/packages/common/package.json index f9923f835..5407d27bc 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -19,7 +19,7 @@ "proto3-json-serializer": "^2.0.0" }, "devDependencies": { - "protobufjs": "^7.5.5" + "protobufjs": "7.5.5" }, "bugs": { "url": "https://github.com/temporalio/sdk-typescript/issues" diff --git a/packages/worker/package.json b/packages/worker/package.json index fa04d87e0..17936b5a9 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -27,7 +27,7 @@ "memfs": "^4.6.0", "nexus-rpc": "^0.0.2", "proto3-json-serializer": "^2.0.0", - "protobufjs": "^7.5.5", + "protobufjs": "7.5.5", "rxjs": "^7.8.1", "source-map": "^0.7.4", "source-map-loader": "^4.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fcffcf9db..7cbb4d73e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -222,7 +222,7 @@ importers: specifier: ^9.0.7 version: 9.0.8 protobufjs: - specifier: ^7.5.5 + specifier: 7.5.5 version: 7.5.5(patch_hash=0ede6d81e4e283d0a44203616b8935211e15e8326841cd4cd7427963385ea3c1) packages/cloud: @@ -244,7 +244,7 @@ importers: version: 3.0.0 devDependencies: protobufjs: - specifier: ^7.5.5 + specifier: 7.5.5 version: 7.5.5(patch_hash=0ede6d81e4e283d0a44203616b8935211e15e8326841cd4cd7427963385ea3c1) packages/common: @@ -266,7 +266,7 @@ importers: version: 2.0.0 devDependencies: protobufjs: - specifier: ^7.5.5 + specifier: 7.5.5 version: 7.5.5(patch_hash=0ede6d81e4e283d0a44203616b8935211e15e8326841cd4cd7427963385ea3c1) packages/contrib-workflow-stream: @@ -808,7 +808,7 @@ importers: specifier: ^2.0.0 version: 2.0.0 protobufjs: - specifier: ^7.5.5 + specifier: 7.5.5 version: 7.5.5(patch_hash=0ede6d81e4e283d0a44203616b8935211e15e8326841cd4cd7427963385ea3c1) rxjs: specifier: ^7.8.1 From 9a74d7000552a44ff4d365dc4c33533222278da7 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Thu, 7 May 2026 10:59:51 -0700 Subject: [PATCH 50/75] Revert "pin protobufjs to exact 7.5.5" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exact pin didn't change protobufjs resolution in features's install (still resolves to 7.5.6). Reverting keeps the dep range matching main's last known-passing state (`^7.5.5`) — main's features-tests resolves to 7.5.1 and pnpm emits "ignored builds" only as a warning, not an error. This reverts commit 1fd550be9c4f4f4e7e34f9. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/client/package.json | 2 +- packages/cloud/package.json | 2 +- packages/common/package.json | 2 +- packages/worker/package.json | 2 +- pnpm-lock.yaml | 8 ++++---- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/client/package.json b/packages/client/package.json index 80ac6d544..dd4016e6a 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -22,7 +22,7 @@ }, "devDependencies": { "@types/uuid": "^9.0.7", - "protobufjs": "7.5.5" + "protobufjs": "^7.5.5" }, "bugs": { "url": "https://github.com/temporalio/sdk-typescript/issues" diff --git a/packages/cloud/package.json b/packages/cloud/package.json index 702cb44d4..0a38f3e28 100644 --- a/packages/cloud/package.json +++ b/packages/cloud/package.json @@ -21,7 +21,7 @@ "abort-controller": "^3.0.0" }, "devDependencies": { - "protobufjs": "7.5.5" + "protobufjs": "^7.5.5" }, "bugs": { "url": "https://github.com/temporalio/sdk-typescript/issues" diff --git a/packages/common/package.json b/packages/common/package.json index 5407d27bc..f9923f835 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -19,7 +19,7 @@ "proto3-json-serializer": "^2.0.0" }, "devDependencies": { - "protobufjs": "7.5.5" + "protobufjs": "^7.5.5" }, "bugs": { "url": "https://github.com/temporalio/sdk-typescript/issues" diff --git a/packages/worker/package.json b/packages/worker/package.json index 17936b5a9..fa04d87e0 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -27,7 +27,7 @@ "memfs": "^4.6.0", "nexus-rpc": "^0.0.2", "proto3-json-serializer": "^2.0.0", - "protobufjs": "7.5.5", + "protobufjs": "^7.5.5", "rxjs": "^7.8.1", "source-map": "^0.7.4", "source-map-loader": "^4.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7cbb4d73e..fcffcf9db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -222,7 +222,7 @@ importers: specifier: ^9.0.7 version: 9.0.8 protobufjs: - specifier: 7.5.5 + specifier: ^7.5.5 version: 7.5.5(patch_hash=0ede6d81e4e283d0a44203616b8935211e15e8326841cd4cd7427963385ea3c1) packages/cloud: @@ -244,7 +244,7 @@ importers: version: 3.0.0 devDependencies: protobufjs: - specifier: 7.5.5 + specifier: ^7.5.5 version: 7.5.5(patch_hash=0ede6d81e4e283d0a44203616b8935211e15e8326841cd4cd7427963385ea3c1) packages/common: @@ -266,7 +266,7 @@ importers: version: 2.0.0 devDependencies: protobufjs: - specifier: 7.5.5 + specifier: ^7.5.5 version: 7.5.5(patch_hash=0ede6d81e4e283d0a44203616b8935211e15e8326841cd4cd7427963385ea3c1) packages/contrib-workflow-stream: @@ -808,7 +808,7 @@ importers: specifier: ^2.0.0 version: 2.0.0 protobufjs: - specifier: 7.5.5 + specifier: ^7.5.5 version: 7.5.5(patch_hash=0ede6d81e4e283d0a44203616b8935211e15e8326841cd4cd7427963385ea3c1) rxjs: specifier: ^7.8.1 From 0bcc3a757c2ff294e90e9a4aa2ef9d5ef9e0d778 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Thu, 7 May 2026 11:03:25 -0700 Subject: [PATCH 51/75] add pnpm override forcing protobufjs to 7.5.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit protobufjs 7.5.6 (published 2026-04-28) tripped pnpm 10.33.4's strict "approved builds" rule when features tests installed our file-linked SDK packages. Adding `pnpm.overrides` at our root forces protobufjs to exactly 7.5.5 — the version we have a patch for. Hopefully this propagates when downstream installs (like temporalio/features) consume our packages via file: links. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 3 +++ pnpm-lock.yaml | 44 +++++++++++++++++++++++++++++--------------- 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index a2367868b..7804c78fc 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,9 @@ } }, "pnpm": { + "overrides": { + "protobufjs": "7.5.5" + }, "patchedDependencies": { "protobufjs@7.5.5": "patches/protobufjs@7.5.5.patch" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fcffcf9db..31834be0f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,9 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: - minimatch@>=7.0.0 <7.4.8: '>=7.4.8' - serialize-javascript: '>=7.0.3' - handlebars: '>=4.7.9' + protobufjs: 7.5.5 patchedDependencies: protobufjs@7.5.5: @@ -222,7 +220,7 @@ importers: specifier: ^9.0.7 version: 9.0.8 protobufjs: - specifier: ^7.5.5 + specifier: 7.5.5 version: 7.5.5(patch_hash=0ede6d81e4e283d0a44203616b8935211e15e8326841cd4cd7427963385ea3c1) packages/cloud: @@ -244,7 +242,7 @@ importers: version: 3.0.0 devDependencies: protobufjs: - specifier: ^7.5.5 + specifier: 7.5.5 version: 7.5.5(patch_hash=0ede6d81e4e283d0a44203616b8935211e15e8326841cd4cd7427963385ea3c1) packages/common: @@ -266,7 +264,7 @@ importers: version: 2.0.0 devDependencies: protobufjs: - specifier: ^7.5.5 + specifier: 7.5.5 version: 7.5.5(patch_hash=0ede6d81e4e283d0a44203616b8935211e15e8326841cd4cd7427963385ea3c1) packages/contrib-workflow-stream: @@ -808,7 +806,7 @@ importers: specifier: ^2.0.0 version: 2.0.0 protobufjs: - specifier: ^7.5.5 + specifier: 7.5.5 version: 7.5.5(patch_hash=0ede6d81e4e283d0a44203616b8935211e15e8326841cd4cd7427963385ea3c1) rxjs: specifier: ^7.8.1 @@ -3739,6 +3737,10 @@ packages: resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} engines: {node: '>=10'} + minimatch@7.4.6: + resolution: {integrity: sha512-sBz8G/YjVniEz6lKPNpKxXwazJe4c19fEfV2GDMX6AjFz+MX9uDWIZW8XreVhkFW3fkIdTv/gxWr/Kks5FFAVw==} + engines: {node: '>=10'} + minimatch@7.4.9: resolution: {integrity: sha512-Brg/fp/iAVDOQoHxkuN5bEYhyQlZhxddI78yWsCbeEwTHXQjlNLtiJDUsp1GIptVqMI7/gkJMz4vVAc01mpoBw==} engines: {node: '>=10'} @@ -4079,7 +4081,7 @@ packages: engines: {node: '>=12.0.0'} hasBin: true peerDependencies: - protobufjs: ^7.0.0 + protobufjs: 7.5.5 protobufjs@7.5.5: resolution: {integrity: sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==} @@ -4124,6 +4126,9 @@ packages: resolution: {integrity: sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==} engines: {node: '>= 12.0.0'} + randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -4292,9 +4297,8 @@ packages: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} engines: {node: '>=10'} - serialize-javascript@7.0.5: - resolution: {integrity: sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==} - engines: {node: '>=20.0.0'} + serialize-javascript@6.0.2: + resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} serve-static@1.16.2: resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} @@ -5925,7 +5929,7 @@ snapshots: ajv: 8.17.1 http-errors: 2.0.0 http-status-codes: 2.3.0 - minimatch: 9.0.9 + minimatch: 7.4.6 process-warning: 1.0.0 semver: 7.7.2 @@ -8158,6 +8162,10 @@ snapshots: dependencies: brace-expansion: 2.0.2 + minimatch@7.4.6: + dependencies: + brace-expansion: 2.0.2 + minimatch@7.4.9: dependencies: brace-expansion: 2.0.2 @@ -8516,6 +8524,10 @@ snapshots: lodash.clonedeep: 4.5.0 lodash.isequal: 4.5.0 + randombytes@2.1.0: + dependencies: + safe-buffer: 5.2.1 + range-parser@1.2.1: {} raw-body@2.5.2: @@ -8736,7 +8748,9 @@ snapshots: dependencies: type-fest: 0.13.1 - serialize-javascript@7.0.5: {} + serialize-javascript@6.0.2: + dependencies: + randombytes: 2.1.0 serve-static@1.16.2: dependencies: @@ -9022,7 +9036,7 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 - serialize-javascript: 7.0.5 + serialize-javascript: 6.0.2 terser: 5.46.0 webpack: 5.105.1(@swc/core@1.3.102) optionalDependencies: @@ -9033,7 +9047,7 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 - serialize-javascript: 7.0.5 + serialize-javascript: 6.0.2 terser: 5.46.0 webpack: 5.105.1 From 74c5502d721165df553c78df1f979576e16705cb Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Thu, 7 May 2026 11:49:32 -0700 Subject: [PATCH 52/75] pin protobufjs to exact 7.5.5 across workspace packages The features harness pins `protobufjs` to `7.5.1` via a pnpm override, but our packages declared `^7.5.5`. Because `7.5.1` does not satisfy `^7.5.5`, pnpm 10 ignored the override and resolved to the highest matching version (7.5.6, released 2026-04-28), which then failed the strict-builds check in the features CI install. Pinning to exact 7.5.5 leaves only one valid version for pnpm to pick regardless of which override the consumer applies. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/client/package.json | 2 +- packages/cloud/package.json | 2 +- packages/common/package.json | 2 +- packages/worker/package.json | 2 +- pnpm-lock.yaml | 3 --- 5 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/client/package.json b/packages/client/package.json index dd4016e6a..80ac6d544 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -22,7 +22,7 @@ }, "devDependencies": { "@types/uuid": "^9.0.7", - "protobufjs": "^7.5.5" + "protobufjs": "7.5.5" }, "bugs": { "url": "https://github.com/temporalio/sdk-typescript/issues" diff --git a/packages/cloud/package.json b/packages/cloud/package.json index 0a38f3e28..702cb44d4 100644 --- a/packages/cloud/package.json +++ b/packages/cloud/package.json @@ -21,7 +21,7 @@ "abort-controller": "^3.0.0" }, "devDependencies": { - "protobufjs": "^7.5.5" + "protobufjs": "7.5.5" }, "bugs": { "url": "https://github.com/temporalio/sdk-typescript/issues" diff --git a/packages/common/package.json b/packages/common/package.json index f9923f835..5407d27bc 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -19,7 +19,7 @@ "proto3-json-serializer": "^2.0.0" }, "devDependencies": { - "protobufjs": "^7.5.5" + "protobufjs": "7.5.5" }, "bugs": { "url": "https://github.com/temporalio/sdk-typescript/issues" diff --git a/packages/worker/package.json b/packages/worker/package.json index fa04d87e0..17936b5a9 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -27,7 +27,7 @@ "memfs": "^4.6.0", "nexus-rpc": "^0.0.2", "proto3-json-serializer": "^2.0.0", - "protobufjs": "^7.5.5", + "protobufjs": "7.5.5", "rxjs": "^7.8.1", "source-map": "^0.7.4", "source-map-loader": "^4.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31834be0f..b66d26d4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -278,9 +278,6 @@ importers: '@temporalio/common': specifier: workspace:* version: link:../common - '@temporalio/proto': - specifier: workspace:* - version: link:../proto '@temporalio/workflow': specifier: workspace:* version: link:../workflow From 52dc79ef8aa92a01bf464c6129bb2397f8f4305b Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Thu, 7 May 2026 11:49:39 -0700 Subject: [PATCH 53/75] contrib-workflow-stream: drop unused @temporalio/proto dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package's source has zero imports from @temporalio/proto — the wire format is hand-rolled (see types.ts). Both the dependency in package.json and the project reference in tsconfig.json were defensive additions that aren't load-bearing. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/contrib-workflow-stream/package.json | 1 - packages/contrib-workflow-stream/tsconfig.json | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/contrib-workflow-stream/package.json b/packages/contrib-workflow-stream/package.json index be3e398a0..c45cfa7a0 100644 --- a/packages/contrib-workflow-stream/package.json +++ b/packages/contrib-workflow-stream/package.json @@ -18,7 +18,6 @@ "@temporalio/activity": "workspace:*", "@temporalio/client": "workspace:*", "@temporalio/common": "workspace:*", - "@temporalio/proto": "workspace:*", "@temporalio/workflow": "workspace:*" }, "engines": { diff --git a/packages/contrib-workflow-stream/tsconfig.json b/packages/contrib-workflow-stream/tsconfig.json index 9c4c1a6bf..de83bc9a3 100644 --- a/packages/contrib-workflow-stream/tsconfig.json +++ b/packages/contrib-workflow-stream/tsconfig.json @@ -9,7 +9,6 @@ { "path": "../activity" }, { "path": "../client" }, { "path": "../common" }, - { "path": "../proto" }, { "path": "../workflow" } ], "include": ["./src/**/*.ts"] From 35edd2a2e716af5dba9745dcfb3eb8355ef5e80d Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Thu, 7 May 2026 13:55:55 -0700 Subject: [PATCH 54/75] Revert protobufjs pinning workarounds Reverts the pnpm override, exact-version pin across workspace packages, and the @temporalio/proto dependency drop from contrib-workflow-stream. Main's CI is passing again, so these workarounds are no longer needed. Reverts: 52dc79ef contrib-workflow-stream: drop unused @temporalio/proto dependency 74c5502d pin protobufjs to exact 7.5.5 across workspace packages 0bcc3a75 add pnpm override forcing protobufjs to 7.5.5 Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 3 -- packages/client/package.json | 2 +- packages/cloud/package.json | 2 +- packages/common/package.json | 2 +- packages/contrib-workflow-stream/package.json | 1 + .../contrib-workflow-stream/tsconfig.json | 1 + packages/worker/package.json | 2 +- pnpm-lock.yaml | 47 +++++++------------ 8 files changed, 24 insertions(+), 36 deletions(-) diff --git a/package.json b/package.json index 7804c78fc..a2367868b 100644 --- a/package.json +++ b/package.json @@ -96,9 +96,6 @@ } }, "pnpm": { - "overrides": { - "protobufjs": "7.5.5" - }, "patchedDependencies": { "protobufjs@7.5.5": "patches/protobufjs@7.5.5.patch" } diff --git a/packages/client/package.json b/packages/client/package.json index 80ac6d544..dd4016e6a 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -22,7 +22,7 @@ }, "devDependencies": { "@types/uuid": "^9.0.7", - "protobufjs": "7.5.5" + "protobufjs": "^7.5.5" }, "bugs": { "url": "https://github.com/temporalio/sdk-typescript/issues" diff --git a/packages/cloud/package.json b/packages/cloud/package.json index 702cb44d4..0a38f3e28 100644 --- a/packages/cloud/package.json +++ b/packages/cloud/package.json @@ -21,7 +21,7 @@ "abort-controller": "^3.0.0" }, "devDependencies": { - "protobufjs": "7.5.5" + "protobufjs": "^7.5.5" }, "bugs": { "url": "https://github.com/temporalio/sdk-typescript/issues" diff --git a/packages/common/package.json b/packages/common/package.json index 5407d27bc..f9923f835 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -19,7 +19,7 @@ "proto3-json-serializer": "^2.0.0" }, "devDependencies": { - "protobufjs": "7.5.5" + "protobufjs": "^7.5.5" }, "bugs": { "url": "https://github.com/temporalio/sdk-typescript/issues" diff --git a/packages/contrib-workflow-stream/package.json b/packages/contrib-workflow-stream/package.json index c45cfa7a0..be3e398a0 100644 --- a/packages/contrib-workflow-stream/package.json +++ b/packages/contrib-workflow-stream/package.json @@ -18,6 +18,7 @@ "@temporalio/activity": "workspace:*", "@temporalio/client": "workspace:*", "@temporalio/common": "workspace:*", + "@temporalio/proto": "workspace:*", "@temporalio/workflow": "workspace:*" }, "engines": { diff --git a/packages/contrib-workflow-stream/tsconfig.json b/packages/contrib-workflow-stream/tsconfig.json index de83bc9a3..9c4c1a6bf 100644 --- a/packages/contrib-workflow-stream/tsconfig.json +++ b/packages/contrib-workflow-stream/tsconfig.json @@ -9,6 +9,7 @@ { "path": "../activity" }, { "path": "../client" }, { "path": "../common" }, + { "path": "../proto" }, { "path": "../workflow" } ], "include": ["./src/**/*.ts"] diff --git a/packages/worker/package.json b/packages/worker/package.json index 17936b5a9..fa04d87e0 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -27,7 +27,7 @@ "memfs": "^4.6.0", "nexus-rpc": "^0.0.2", "proto3-json-serializer": "^2.0.0", - "protobufjs": "7.5.5", + "protobufjs": "^7.5.5", "rxjs": "^7.8.1", "source-map": "^0.7.4", "source-map-loader": "^4.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b66d26d4d..fcffcf9db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,9 @@ settings: excludeLinksFromLockfile: false overrides: - protobufjs: 7.5.5 + minimatch@>=7.0.0 <7.4.8: '>=7.4.8' + serialize-javascript: '>=7.0.3' + handlebars: '>=4.7.9' patchedDependencies: protobufjs@7.5.5: @@ -220,7 +222,7 @@ importers: specifier: ^9.0.7 version: 9.0.8 protobufjs: - specifier: 7.5.5 + specifier: ^7.5.5 version: 7.5.5(patch_hash=0ede6d81e4e283d0a44203616b8935211e15e8326841cd4cd7427963385ea3c1) packages/cloud: @@ -242,7 +244,7 @@ importers: version: 3.0.0 devDependencies: protobufjs: - specifier: 7.5.5 + specifier: ^7.5.5 version: 7.5.5(patch_hash=0ede6d81e4e283d0a44203616b8935211e15e8326841cd4cd7427963385ea3c1) packages/common: @@ -264,7 +266,7 @@ importers: version: 2.0.0 devDependencies: protobufjs: - specifier: 7.5.5 + specifier: ^7.5.5 version: 7.5.5(patch_hash=0ede6d81e4e283d0a44203616b8935211e15e8326841cd4cd7427963385ea3c1) packages/contrib-workflow-stream: @@ -278,6 +280,9 @@ importers: '@temporalio/common': specifier: workspace:* version: link:../common + '@temporalio/proto': + specifier: workspace:* + version: link:../proto '@temporalio/workflow': specifier: workspace:* version: link:../workflow @@ -803,7 +808,7 @@ importers: specifier: ^2.0.0 version: 2.0.0 protobufjs: - specifier: 7.5.5 + specifier: ^7.5.5 version: 7.5.5(patch_hash=0ede6d81e4e283d0a44203616b8935211e15e8326841cd4cd7427963385ea3c1) rxjs: specifier: ^7.8.1 @@ -3734,10 +3739,6 @@ packages: resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} engines: {node: '>=10'} - minimatch@7.4.6: - resolution: {integrity: sha512-sBz8G/YjVniEz6lKPNpKxXwazJe4c19fEfV2GDMX6AjFz+MX9uDWIZW8XreVhkFW3fkIdTv/gxWr/Kks5FFAVw==} - engines: {node: '>=10'} - minimatch@7.4.9: resolution: {integrity: sha512-Brg/fp/iAVDOQoHxkuN5bEYhyQlZhxddI78yWsCbeEwTHXQjlNLtiJDUsp1GIptVqMI7/gkJMz4vVAc01mpoBw==} engines: {node: '>=10'} @@ -4078,7 +4079,7 @@ packages: engines: {node: '>=12.0.0'} hasBin: true peerDependencies: - protobufjs: 7.5.5 + protobufjs: ^7.0.0 protobufjs@7.5.5: resolution: {integrity: sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==} @@ -4123,9 +4124,6 @@ packages: resolution: {integrity: sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==} engines: {node: '>= 12.0.0'} - randombytes@2.1.0: - resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} - range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -4294,8 +4292,9 @@ packages: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} engines: {node: '>=10'} - serialize-javascript@6.0.2: - resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + serialize-javascript@7.0.5: + resolution: {integrity: sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==} + engines: {node: '>=20.0.0'} serve-static@1.16.2: resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} @@ -5926,7 +5925,7 @@ snapshots: ajv: 8.17.1 http-errors: 2.0.0 http-status-codes: 2.3.0 - minimatch: 7.4.6 + minimatch: 9.0.9 process-warning: 1.0.0 semver: 7.7.2 @@ -8159,10 +8158,6 @@ snapshots: dependencies: brace-expansion: 2.0.2 - minimatch@7.4.6: - dependencies: - brace-expansion: 2.0.2 - minimatch@7.4.9: dependencies: brace-expansion: 2.0.2 @@ -8521,10 +8516,6 @@ snapshots: lodash.clonedeep: 4.5.0 lodash.isequal: 4.5.0 - randombytes@2.1.0: - dependencies: - safe-buffer: 5.2.1 - range-parser@1.2.1: {} raw-body@2.5.2: @@ -8745,9 +8736,7 @@ snapshots: dependencies: type-fest: 0.13.1 - serialize-javascript@6.0.2: - dependencies: - randombytes: 2.1.0 + serialize-javascript@7.0.5: {} serve-static@1.16.2: dependencies: @@ -9033,7 +9022,7 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 - serialize-javascript: 6.0.2 + serialize-javascript: 7.0.5 terser: 5.46.0 webpack: 5.105.1(@swc/core@1.3.102) optionalDependencies: @@ -9044,7 +9033,7 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 - serialize-javascript: 6.0.2 + serialize-javascript: 7.0.5 terser: 5.46.0 webpack: 5.105.1 From 8a0ddd2c6af8673b735b3025e56e8dc4711afad3 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Thu, 7 May 2026 15:04:34 -0700 Subject: [PATCH 55/75] contrib-workflow-stream: throw on unexpected wire type in Payload map entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The map entries inside Payload.metadata are guaranteed by the protobuf spec to use only wire type 2 for both fields. The previous "skip" branch tried to be tolerant of unexpected wire types but did so incorrectly — it always read a varint and advanced by that many bytes, which would desync the parser for wire types 0 (varint), 1 (fixed64), and 5 (fixed32). Replace with an explicit throw that matches the outer loop's behavior on unsupported wire types. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/contrib-workflow-stream/src/types.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/contrib-workflow-stream/src/types.ts b/packages/contrib-workflow-stream/src/types.ts index 852d709a4..3ec11cada 100644 --- a/packages/contrib-workflow-stream/src/types.ts +++ b/packages/contrib-workflow-stream/src/types.ts @@ -246,10 +246,7 @@ export function decodePayloadProto(bytes: Uint8Array): Payload { const ifn = itag >>> 3; const iwt = itag & 0x07; if (iwt !== 2) { - // skip — only length-delim fields are expected here - const skipLen = readVarint(chunk, p2); - p2.i += skipLen; - continue; + throw new Error(`unsupported wire type ${iwt} in Payload metadata entry`); } const ilen = readVarint(chunk, p2); const ival = chunk.subarray(p2.i, p2.i + ilen); From aa67a7d7a07d28f4b664930491d3930d5c2d55a6 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Thu, 7 May 2026 16:21:26 -0700 Subject: [PATCH 56/75] contrib-workflow-streams: rename package and wire handlers to plural Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/ai-sdk/package.json | 2 +- packages/ai-sdk/src/activities.ts | 2 +- packages/common/src/reserved.ts | 8 +++---- .../README.md | 24 +++++++++---------- .../package.json | 2 +- .../src/client.ts | 6 ++--- .../src/index.ts | 0 .../src/stream.ts | 12 +++++----- .../src/topic-handle.ts | 0 .../src/types.ts | 0 .../tsconfig.json | 0 packages/test/package.json | 2 +- ...-stream.ts => contrib-workflow-streams.ts} | 4 ++-- ... test-contrib-workflow-streams-interop.ts} | 6 ++--- ...am.ts => test-contrib-workflow-streams.ts} | 10 ++++---- ...-stream.ts => contrib-workflow-streams.ts} | 6 ++--- pnpm-lock.yaml | 10 ++++---- pnpm-workspace.yaml | 2 +- 18 files changed, 48 insertions(+), 48 deletions(-) rename packages/{contrib-workflow-stream => contrib-workflow-streams}/README.md (95%) rename packages/{contrib-workflow-stream => contrib-workflow-streams}/package.json (93%) rename packages/{contrib-workflow-stream => contrib-workflow-streams}/src/client.ts (99%) rename packages/{contrib-workflow-stream => contrib-workflow-streams}/src/index.ts (100%) rename packages/{contrib-workflow-stream => contrib-workflow-streams}/src/stream.ts (97%) rename packages/{contrib-workflow-stream => contrib-workflow-streams}/src/topic-handle.ts (100%) rename packages/{contrib-workflow-stream => contrib-workflow-streams}/src/types.ts (100%) rename packages/{contrib-workflow-stream => contrib-workflow-streams}/tsconfig.json (100%) rename packages/test/src/activities/{contrib-workflow-stream.ts => contrib-workflow-streams.ts} (97%) rename packages/test/src/{test-contrib-workflow-stream-interop.ts => test-contrib-workflow-streams-interop.ts} (98%) rename packages/test/src/{test-contrib-workflow-stream.ts => test-contrib-workflow-streams.ts} (99%) rename packages/test/src/workflows/{contrib-workflow-stream.ts => contrib-workflow-streams.ts} (98%) diff --git a/packages/ai-sdk/package.json b/packages/ai-sdk/package.json index 2b8ca231e..943949880 100644 --- a/packages/ai-sdk/package.json +++ b/packages/ai-sdk/package.json @@ -18,7 +18,7 @@ "@temporalio/activity": "workspace:*", "@temporalio/client": "workspace:*", "@temporalio/common": "workspace:*", - "@temporalio/contrib-workflow-stream": "workspace:*", + "@temporalio/contrib-workflow-streams": "workspace:*", "@temporalio/plugin": "workspace:*", "@temporalio/workflow": "workspace:*", "@ungap/structured-clone": "^1.3.0", diff --git a/packages/ai-sdk/src/activities.ts b/packages/ai-sdk/src/activities.ts index 0c9904dbc..bad2809e0 100644 --- a/packages/ai-sdk/src/activities.ts +++ b/packages/ai-sdk/src/activities.ts @@ -13,7 +13,7 @@ import type { import { asSchema, type Schema, type ToolExecutionOptions } from 'ai'; import { ApplicationFailure } from '@temporalio/common'; import { Context } from '@temporalio/activity'; -import { WorkflowStreamClient } from '@temporalio/contrib-workflow-stream'; +import { WorkflowStreamClient } from '@temporalio/contrib-workflow-streams'; import type { Duration } from '@temporalio/common/lib/time'; import type { McpClientFactories, McpClientFactory } from './mcp'; diff --git a/packages/common/src/reserved.ts b/packages/common/src/reserved.ts index 2a8b5a7bd..adc724eaf 100644 --- a/packages/common/src/reserved.ts +++ b/packages/common/src/reserved.ts @@ -7,10 +7,10 @@ export const ENHANCED_STACK_TRACE_QUERY_NAME = '__enhanced_stack_trace'; * bypass the {@link TEMPORAL_RESERVED_PREFIX} check at registration time. */ const INTERNAL_HANDLER_NAME_ALLOWLIST: ReadonlySet = new Set([ - // @temporalio/contrib-workflow-stream - '__temporal_workflow_stream_publish', - '__temporal_workflow_stream_poll', - '__temporal_workflow_stream_offset', + // @temporalio/contrib-workflow-streams + '__temporal_workflow_streams_publish', + '__temporal_workflow_streams_poll', + '__temporal_workflow_streams_offset', ]); /** diff --git a/packages/contrib-workflow-stream/README.md b/packages/contrib-workflow-streams/README.md similarity index 95% rename from packages/contrib-workflow-stream/README.md rename to packages/contrib-workflow-streams/README.md index 211c4870c..204bb2257 100644 --- a/packages/contrib-workflow-stream/README.md +++ b/packages/contrib-workflow-streams/README.md @@ -35,7 +35,7 @@ get a typed handle for each topic via `stream.topic(name)` and call `publish` on the handle: ```typescript -import { WorkflowStream } from '@temporalio/contrib-workflow-stream'; +import { WorkflowStream } from '@temporalio/contrib-workflow-streams'; interface StatusEvent { state: 'started' | 'done'; @@ -51,8 +51,8 @@ export async function myWorkflow(input: MyInput): Promise { } ``` -The `WorkflowStream` constructor registers the `__temporal_workflow_stream_publish` signal, -`__temporal_workflow_stream_poll` update, and `__temporal_workflow_stream_offset` query handlers on your workflow. +The `WorkflowStream` constructor registers the `__temporal_workflow_streams_publish` signal, +`__temporal_workflow_streams_poll` update, and `__temporal_workflow_streams_offset` query handlers on your workflow. Any value the default payload converter can serialize (JSON, `Uint8Array`, or a pre-built `Payload`) can be passed to `publish`. The type parameter `T` is purely a compile-time annotation — TypeScript has no runtime type @@ -70,7 +70,7 @@ the same way as on the workflow side: ```typescript import { Context } from '@temporalio/activity'; -import { WorkflowStreamClient } from '@temporalio/contrib-workflow-stream'; +import { WorkflowStreamClient } from '@temporalio/contrib-workflow-streams'; export async function streamEvents(): Promise { await using client = WorkflowStreamClient.fromActivity({ batchInterval: '2 seconds' }); @@ -112,7 +112,7 @@ events.publish(data, { forceFlush: true }); Subscribe via the topic handle to get items decoded as `T`: ```typescript -import { WorkflowStreamClient } from '@temporalio/contrib-workflow-stream'; +import { WorkflowStreamClient } from '@temporalio/contrib-workflow-streams'; const client = WorkflowStreamClient.create(temporalClient, workflowId); const events = client.topic('events'); @@ -143,7 +143,7 @@ boundaries: ```typescript import { continueAsNew, workflowInfo } from '@temporalio/workflow'; -import { WorkflowStream, type WorkflowStreamState } from '@temporalio/contrib-workflow-stream'; +import { WorkflowStream, type WorkflowStreamState } from '@temporalio/contrib-workflow-streams'; interface WorkflowInput { itemsProcessed: number; @@ -210,9 +210,9 @@ Handlers registered automatically: | Kind | Name | Description | | ------ | ------------------------------------ | ------------------------------ | -| Signal | `__temporal_workflow_stream_publish` | Receive external publications. | -| Update | `__temporal_workflow_stream_poll` | Long-poll subscription. | -| Query | `__temporal_workflow_stream_offset` | Current global offset. | +| Signal | `__temporal_workflow_streams_publish` | Receive external publications. | +| Update | `__temporal_workflow_streams_poll` | Long-poll subscription. | +| Query | `__temporal_workflow_streams_offset` | Current global offset. | ### `WorkflowStreamClient` @@ -250,9 +250,9 @@ Handlers registered automatically: Any Temporal client can interact with a workflow stream workflow using these fixed handler names: -1. **Publish**: signal `__temporal_workflow_stream_publish` with `PublishInput` -2. **Subscribe**: update `__temporal_workflow_stream_poll` with `PollInput` -> `PollResult` -3. **Offset**: query `__temporal_workflow_stream_offset` -> `number` +1. **Publish**: signal `__temporal_workflow_streams_publish` with `PublishInput` +2. **Subscribe**: update `__temporal_workflow_streams_poll` with `PollInput` -> `PollResult` +3. **Offset**: query `__temporal_workflow_streams_offset` -> `number` Each `PublishEntry.data` / `_WorkflowStreamWireItem.data` is a base64-encoded `temporal.api.common.v1.Payload` protobuf (`Payload.SerializeToString()` in diff --git a/packages/contrib-workflow-stream/package.json b/packages/contrib-workflow-streams/package.json similarity index 93% rename from packages/contrib-workflow-stream/package.json rename to packages/contrib-workflow-streams/package.json index be3e398a0..dec5a8feb 100644 --- a/packages/contrib-workflow-stream/package.json +++ b/packages/contrib-workflow-streams/package.json @@ -1,5 +1,5 @@ { - "name": "@temporalio/contrib-workflow-stream", + "name": "@temporalio/contrib-workflow-streams", "version": "1.17.0", "description": "Temporal.io SDK Workflow Streams contrib module", "main": "lib/index.js", diff --git a/packages/contrib-workflow-stream/src/client.ts b/packages/contrib-workflow-streams/src/client.ts similarity index 99% rename from packages/contrib-workflow-stream/src/client.ts rename to packages/contrib-workflow-streams/src/client.ts index c323adaad..9a2fe8047 100644 --- a/packages/contrib-workflow-stream/src/client.ts +++ b/packages/contrib-workflow-streams/src/client.ts @@ -400,7 +400,7 @@ export class WorkflowStreamClient { // On failure, the signal throws and pending stays set for retry. // On success, advance confirmed sequence and clear pending. - await this.handle.signal<[PublishInput]>('__temporal_workflow_stream_publish', { + await this.handle.signal<[PublishInput]>('__temporal_workflow_streams_publish', { items: batch, publisher_id: this.publisherId, sequence: seq, @@ -449,7 +449,7 @@ export class WorkflowStreamClient { while (true) { let result: PollResult; try { - result = await this.handle.executeUpdate('__temporal_workflow_stream_poll', { + result = await this.handle.executeUpdate('__temporal_workflow_streams_poll', { args: [{ topics: topicFilter, from_offset: offset }], }); } catch (err) { @@ -512,7 +512,7 @@ export class WorkflowStreamClient { /** Query the current global offset. */ async getOffset(): Promise { - return this.handle.query('__temporal_workflow_stream_offset'); + return this.handle.query('__temporal_workflow_streams_offset'); } /** diff --git a/packages/contrib-workflow-stream/src/index.ts b/packages/contrib-workflow-streams/src/index.ts similarity index 100% rename from packages/contrib-workflow-stream/src/index.ts rename to packages/contrib-workflow-streams/src/index.ts diff --git a/packages/contrib-workflow-stream/src/stream.ts b/packages/contrib-workflow-streams/src/stream.ts similarity index 97% rename from packages/contrib-workflow-stream/src/stream.ts rename to packages/contrib-workflow-streams/src/stream.ts index edb89893c..a053d2666 100644 --- a/packages/contrib-workflow-stream/src/stream.ts +++ b/packages/contrib-workflow-streams/src/stream.ts @@ -59,9 +59,9 @@ function isUint8ArrayLike(value: unknown): value is ArrayLike { } // Fixed handler names for cross-language interop -export const workflowStreamPublishSignal = defineSignal<[PublishInput]>('__temporal_workflow_stream_publish'); -export const workflowStreamPollUpdate = defineUpdate('__temporal_workflow_stream_poll'); -export const workflowStreamOffsetQuery = defineQuery('__temporal_workflow_stream_offset'); +export const workflowStreamPublishSignal = defineSignal<[PublishInput]>('__temporal_workflow_streams_publish'); +export const workflowStreamPollUpdate = defineUpdate('__temporal_workflow_streams_poll'); +export const workflowStreamOffsetQuery = defineQuery('__temporal_workflow_streams_offset'); const MAX_POLL_RESPONSE_BYTES = 1_000_000; @@ -99,9 +99,9 @@ function isPayload(value: unknown): value is Payload { * * Registered handlers: * - * - `__temporal_workflow_stream_publish` signal — external publish with dedup - * - `__temporal_workflow_stream_poll` update — long-poll subscription - * - `__temporal_workflow_stream_offset` query — current log length + * - `__temporal_workflow_streams_publish` signal — external publish with dedup + * - `__temporal_workflow_streams_poll` update — long-poll subscription + * - `__temporal_workflow_streams_offset` query — current log length * * For continue-as-new, thread a `WorkflowStreamState | undefined` field through * the workflow input and pass it as `priorState`. diff --git a/packages/contrib-workflow-stream/src/topic-handle.ts b/packages/contrib-workflow-streams/src/topic-handle.ts similarity index 100% rename from packages/contrib-workflow-stream/src/topic-handle.ts rename to packages/contrib-workflow-streams/src/topic-handle.ts diff --git a/packages/contrib-workflow-stream/src/types.ts b/packages/contrib-workflow-streams/src/types.ts similarity index 100% rename from packages/contrib-workflow-stream/src/types.ts rename to packages/contrib-workflow-streams/src/types.ts diff --git a/packages/contrib-workflow-stream/tsconfig.json b/packages/contrib-workflow-streams/tsconfig.json similarity index 100% rename from packages/contrib-workflow-stream/tsconfig.json rename to packages/contrib-workflow-streams/tsconfig.json diff --git a/packages/test/package.json b/packages/test/package.json index 6211303cc..c534587d5 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -42,7 +42,7 @@ "@temporalio/client": "workspace:*", "@temporalio/cloud": "workspace:*", "@temporalio/common": "workspace:*", - "@temporalio/contrib-workflow-stream": "workspace:*", + "@temporalio/contrib-workflow-streams": "workspace:*", "@temporalio/core-bridge": "workspace:*", "@temporalio/envconfig": "workspace:*", "@temporalio/interceptors-opentelemetry": "workspace:*", diff --git a/packages/test/src/activities/contrib-workflow-stream.ts b/packages/test/src/activities/contrib-workflow-streams.ts similarity index 97% rename from packages/test/src/activities/contrib-workflow-stream.ts rename to packages/test/src/activities/contrib-workflow-streams.ts index 3593f7c70..55a9e2ea6 100644 --- a/packages/test/src/activities/contrib-workflow-stream.ts +++ b/packages/test/src/activities/contrib-workflow-streams.ts @@ -1,12 +1,12 @@ /** - * Test activities for @temporalio/contrib-workflow-stream. + * Test activities for @temporalio/contrib-workflow-streams. * * These activities use `WorkflowStreamClient.fromActivity()` to target the * current activity's parent workflow from the activity context. */ import { Context } from '@temporalio/activity'; -import { WorkflowStreamClient } from '@temporalio/contrib-workflow-stream'; +import { WorkflowStreamClient } from '@temporalio/contrib-workflow-streams'; const encoder = new TextEncoder(); diff --git a/packages/test/src/test-contrib-workflow-stream-interop.ts b/packages/test/src/test-contrib-workflow-streams-interop.ts similarity index 98% rename from packages/test/src/test-contrib-workflow-stream-interop.ts rename to packages/test/src/test-contrib-workflow-streams-interop.ts index 6ef79e20b..9b35dc485 100644 --- a/packages/test/src/test-contrib-workflow-stream-interop.ts +++ b/packages/test/src/test-contrib-workflow-streams-interop.ts @@ -1,11 +1,11 @@ /** - * Wire-format interop tests for @temporalio/contrib-workflow-stream. + * Wire-format interop tests for @temporalio/contrib-workflow-streams. * * These tests pin the exact byte layout produced by the TypeScript * implementation so it stays compatible with the Python SDK, which * uses `temporalio.api.common.v1.Payload` serialized via protobuf. * - * Unlike `test-contrib-workflow-stream.ts`, these don't need a Temporal server — + * Unlike `test-contrib-workflow-streams.ts`, these don't need a Temporal server — * they are pure encode/decode unit tests. */ @@ -19,7 +19,7 @@ import { encodeBase64, encodePayloadProto, encodePayloadWire, -} from '@temporalio/contrib-workflow-stream'; +} from '@temporalio/contrib-workflow-streams'; const test = anyTest as TestFn; const encoder = new TextEncoder(); diff --git a/packages/test/src/test-contrib-workflow-stream.ts b/packages/test/src/test-contrib-workflow-streams.ts similarity index 99% rename from packages/test/src/test-contrib-workflow-stream.ts rename to packages/test/src/test-contrib-workflow-streams.ts index 2b08399a4..53454fb7b 100644 --- a/packages/test/src/test-contrib-workflow-stream.ts +++ b/packages/test/src/test-contrib-workflow-streams.ts @@ -1,5 +1,5 @@ /** - * E2E integration tests for @temporalio/contrib-workflow-stream. + * E2E integration tests for @temporalio/contrib-workflow-streams. * * Ported from sdk-python tests/contrib/stream/test_stream.py. */ @@ -21,7 +21,7 @@ import { workflowStreamOffsetQuery, workflowStreamPublishSignal, workflowStreamPollUpdate, -} from '@temporalio/contrib-workflow-stream'; +} from '@temporalio/contrib-workflow-streams'; import { helpers, makeTestFunction } from './helpers-integration'; import { activityPublishWorkflow, @@ -38,11 +38,11 @@ import { truncateWorkflow, ttlTestWorkflow, workflowSidePublishWorkflow, -} from './workflows/contrib-workflow-stream'; -import * as streamActivities from './activities/contrib-workflow-stream'; +} from './workflows/contrib-workflow-streams'; +import * as streamActivities from './activities/contrib-workflow-streams'; const test = makeTestFunction({ - workflowsPath: require.resolve('./workflows/contrib-workflow-stream'), + workflowsPath: require.resolve('./workflows/contrib-workflow-streams'), }); const encoder = new TextEncoder(); diff --git a/packages/test/src/workflows/contrib-workflow-stream.ts b/packages/test/src/workflows/contrib-workflow-streams.ts similarity index 98% rename from packages/test/src/workflows/contrib-workflow-stream.ts rename to packages/test/src/workflows/contrib-workflow-streams.ts index 6a8631e44..0cd24955b 100644 --- a/packages/test/src/workflows/contrib-workflow-stream.ts +++ b/packages/test/src/workflows/contrib-workflow-streams.ts @@ -1,5 +1,5 @@ /** - * Test workflows for @temporalio/contrib-workflow-stream. + * Test workflows for @temporalio/contrib-workflow-streams. */ import { @@ -11,8 +11,8 @@ import { proxyActivities, setHandler, } from '@temporalio/workflow'; -import { WorkflowStream, type WorkflowStreamState } from '@temporalio/contrib-workflow-stream'; -import type * as activities from '../activities/contrib-workflow-stream'; +import { WorkflowStream, type WorkflowStreamState } from '@temporalio/contrib-workflow-streams'; +import type * as activities from '../activities/contrib-workflow-streams'; const { publishItems, publishMultiTopic, publishWithForceFlush, publishBatchTest, publishWithMaxBatch } = proxyActivities({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fcffcf9db..986d015bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -175,9 +175,9 @@ importers: '@temporalio/common': specifier: workspace:* version: link:../common - '@temporalio/contrib-workflow-stream': + '@temporalio/contrib-workflow-streams': specifier: workspace:* - version: link:../contrib-workflow-stream + version: link:../contrib-workflow-streams '@temporalio/plugin': specifier: workspace:* version: link:../plugin @@ -269,7 +269,7 @@ importers: specifier: ^7.5.5 version: 7.5.5(patch_hash=0ede6d81e4e283d0a44203616b8935211e15e8326841cd4cd7427963385ea3c1) - packages/contrib-workflow-stream: + packages/contrib-workflow-streams: dependencies: '@temporalio/activity': specifier: workspace:* @@ -626,9 +626,9 @@ importers: '@temporalio/common': specifier: workspace:* version: link:../common - '@temporalio/contrib-workflow-stream': + '@temporalio/contrib-workflow-streams': specifier: workspace:* - version: link:../contrib-workflow-stream + version: link:../contrib-workflow-streams '@temporalio/core-bridge': specifier: workspace:* version: link:../core-bridge diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5cc49a452..9daca00d0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -18,7 +18,7 @@ packages: - packages/testing - packages/worker - packages/workflow - - packages/contrib-workflow-stream + - packages/contrib-workflow-streams - scripts ignoreScripts: true From 8d71c9e1ae7667b078d1045e88e4c7113df7a65b Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Thu, 7 May 2026 16:31:57 -0700 Subject: [PATCH 57/75] contrib-workflow-streams: fix README table column alignment Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/contrib-workflow-streams/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/contrib-workflow-streams/README.md b/packages/contrib-workflow-streams/README.md index 204bb2257..0a5deb7bf 100644 --- a/packages/contrib-workflow-streams/README.md +++ b/packages/contrib-workflow-streams/README.md @@ -208,8 +208,8 @@ if (workflowInfo().continueAsNewSuggested) { Handlers registered automatically: -| Kind | Name | Description | -| ------ | ------------------------------------ | ------------------------------ | +| Kind | Name | Description | +| ------ | ------------------------------------- | ------------------------------ | | Signal | `__temporal_workflow_streams_publish` | Receive external publications. | | Update | `__temporal_workflow_streams_poll` | Long-poll subscription. | | Query | `__temporal_workflow_streams_offset` | Current global offset. | From 1daa1542c0d738177ac268649210a14d0e831227 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Thu, 7 May 2026 16:32:58 -0700 Subject: [PATCH 58/75] revert handler names --- packages/common/src/reserved.ts | 6 +++--- packages/contrib-workflow-streams/README.md | 20 +++++++++---------- .../contrib-workflow-streams/src/client.ts | 6 +++--- .../contrib-workflow-streams/src/stream.ts | 12 +++++------ 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/common/src/reserved.ts b/packages/common/src/reserved.ts index adc724eaf..f8abbb0de 100644 --- a/packages/common/src/reserved.ts +++ b/packages/common/src/reserved.ts @@ -8,9 +8,9 @@ export const ENHANCED_STACK_TRACE_QUERY_NAME = '__enhanced_stack_trace'; */ const INTERNAL_HANDLER_NAME_ALLOWLIST: ReadonlySet = new Set([ // @temporalio/contrib-workflow-streams - '__temporal_workflow_streams_publish', - '__temporal_workflow_streams_poll', - '__temporal_workflow_streams_offset', + '__temporal_workflow_stream_publish', + '__temporal_workflow_stream_poll', + '__temporal_workflow_stream_offset', ]); /** diff --git a/packages/contrib-workflow-streams/README.md b/packages/contrib-workflow-streams/README.md index 0a5deb7bf..1236de46d 100644 --- a/packages/contrib-workflow-streams/README.md +++ b/packages/contrib-workflow-streams/README.md @@ -51,8 +51,8 @@ export async function myWorkflow(input: MyInput): Promise { } ``` -The `WorkflowStream` constructor registers the `__temporal_workflow_streams_publish` signal, -`__temporal_workflow_streams_poll` update, and `__temporal_workflow_streams_offset` query handlers on your workflow. +The `WorkflowStream` constructor registers the `__temporal_workflow_stream_publish` signal, +`__temporal_workflow_stream_poll` update, and `__temporal_workflow_stream_offset` query handlers on your workflow. Any value the default payload converter can serialize (JSON, `Uint8Array`, or a pre-built `Payload`) can be passed to `publish`. The type parameter `T` is purely a compile-time annotation — TypeScript has no runtime type @@ -208,11 +208,11 @@ if (workflowInfo().continueAsNewSuggested) { Handlers registered automatically: -| Kind | Name | Description | -| ------ | ------------------------------------- | ------------------------------ | -| Signal | `__temporal_workflow_streams_publish` | Receive external publications. | -| Update | `__temporal_workflow_streams_poll` | Long-poll subscription. | -| Query | `__temporal_workflow_streams_offset` | Current global offset. | +| Kind | Name | Description | +| ------ | ------------------------------------ | ------------------------------ | +| Signal | `__temporal_workflow_stream_publish` | Receive external publications. | +| Update | `__temporal_workflow_stream_poll` | Long-poll subscription. | +| Query | `__temporal_workflow_stream_offset` | Current global offset. | ### `WorkflowStreamClient` @@ -250,9 +250,9 @@ Handlers registered automatically: Any Temporal client can interact with a workflow stream workflow using these fixed handler names: -1. **Publish**: signal `__temporal_workflow_streams_publish` with `PublishInput` -2. **Subscribe**: update `__temporal_workflow_streams_poll` with `PollInput` -> `PollResult` -3. **Offset**: query `__temporal_workflow_streams_offset` -> `number` +1. **Publish**: signal `__temporal_workflow_stream_publish` with `PublishInput` +2. **Subscribe**: update `__temporal_workflow_stream_poll` with `PollInput` -> `PollResult` +3. **Offset**: query `__temporal_workflow_stream_offset` -> `number` Each `PublishEntry.data` / `_WorkflowStreamWireItem.data` is a base64-encoded `temporal.api.common.v1.Payload` protobuf (`Payload.SerializeToString()` in diff --git a/packages/contrib-workflow-streams/src/client.ts b/packages/contrib-workflow-streams/src/client.ts index 9a2fe8047..c323adaad 100644 --- a/packages/contrib-workflow-streams/src/client.ts +++ b/packages/contrib-workflow-streams/src/client.ts @@ -400,7 +400,7 @@ export class WorkflowStreamClient { // On failure, the signal throws and pending stays set for retry. // On success, advance confirmed sequence and clear pending. - await this.handle.signal<[PublishInput]>('__temporal_workflow_streams_publish', { + await this.handle.signal<[PublishInput]>('__temporal_workflow_stream_publish', { items: batch, publisher_id: this.publisherId, sequence: seq, @@ -449,7 +449,7 @@ export class WorkflowStreamClient { while (true) { let result: PollResult; try { - result = await this.handle.executeUpdate('__temporal_workflow_streams_poll', { + result = await this.handle.executeUpdate('__temporal_workflow_stream_poll', { args: [{ topics: topicFilter, from_offset: offset }], }); } catch (err) { @@ -512,7 +512,7 @@ export class WorkflowStreamClient { /** Query the current global offset. */ async getOffset(): Promise { - return this.handle.query('__temporal_workflow_streams_offset'); + return this.handle.query('__temporal_workflow_stream_offset'); } /** diff --git a/packages/contrib-workflow-streams/src/stream.ts b/packages/contrib-workflow-streams/src/stream.ts index a053d2666..edb89893c 100644 --- a/packages/contrib-workflow-streams/src/stream.ts +++ b/packages/contrib-workflow-streams/src/stream.ts @@ -59,9 +59,9 @@ function isUint8ArrayLike(value: unknown): value is ArrayLike { } // Fixed handler names for cross-language interop -export const workflowStreamPublishSignal = defineSignal<[PublishInput]>('__temporal_workflow_streams_publish'); -export const workflowStreamPollUpdate = defineUpdate('__temporal_workflow_streams_poll'); -export const workflowStreamOffsetQuery = defineQuery('__temporal_workflow_streams_offset'); +export const workflowStreamPublishSignal = defineSignal<[PublishInput]>('__temporal_workflow_stream_publish'); +export const workflowStreamPollUpdate = defineUpdate('__temporal_workflow_stream_poll'); +export const workflowStreamOffsetQuery = defineQuery('__temporal_workflow_stream_offset'); const MAX_POLL_RESPONSE_BYTES = 1_000_000; @@ -99,9 +99,9 @@ function isPayload(value: unknown): value is Payload { * * Registered handlers: * - * - `__temporal_workflow_streams_publish` signal — external publish with dedup - * - `__temporal_workflow_streams_poll` update — long-poll subscription - * - `__temporal_workflow_streams_offset` query — current log length + * - `__temporal_workflow_stream_publish` signal — external publish with dedup + * - `__temporal_workflow_stream_poll` update — long-poll subscription + * - `__temporal_workflow_stream_offset` query — current log length * * For continue-as-new, thread a `WorkflowStreamState | undefined` field through * the workflow input and pass it as `priorState`. From a2d73f776384a5a9493fea07b8311e6dd973989c Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Sat, 9 May 2026 19:07:47 -0700 Subject: [PATCH 59/75] ai-sdk: drop unused web-streams-polyfill dependency Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/ai-sdk/package.json | 3 +-- pnpm-lock.yaml | 9 --------- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/packages/ai-sdk/package.json b/packages/ai-sdk/package.json index 943949880..c3807b934 100644 --- a/packages/ai-sdk/package.json +++ b/packages/ai-sdk/package.json @@ -22,8 +22,7 @@ "@temporalio/plugin": "workspace:*", "@temporalio/workflow": "workspace:*", "@ungap/structured-clone": "^1.3.0", - "headers-polyfill": "^4.0.3", - "web-streams-polyfill": "^4.2.0" + "headers-polyfill": "^4.0.3" }, "peerDependencies": { "@ai-sdk/mcp": "^1.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 986d015bf..3f1019b71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -193,9 +193,6 @@ importers: headers-polyfill: specifier: ^4.0.3 version: 4.0.3 - web-streams-polyfill: - specifier: ^4.2.0 - version: 4.2.0 packages/client: dependencies: @@ -4801,10 +4798,6 @@ packages: resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} engines: {node: '>=10.13.0'} - web-streams-polyfill@4.2.0: - resolution: {integrity: sha512-0rYDzGOh9EZpig92umN5g5D/9A1Kff7k0/mzPSSCY8jEQeYkgRMoY7LhbXtUCWzLCMX0TUE9aoHkjFNB7D9pfA==} - engines: {node: '>= 8'} - webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -9356,8 +9349,6 @@ snapshots: glob-to-regexp: 0.4.1 graceful-fs: 4.2.11 - web-streams-polyfill@4.2.0: {} - webidl-conversions@3.0.1: {} webpack-sources@3.3.3: {} From 9e199de5efb249f29205f1ab1e48aaac61c3758a Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Sat, 9 May 2026 19:34:36 -0700 Subject: [PATCH 60/75] contrib-workflow-streams: drop public start()/stop() on client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the sdk-python public surface: lifecycle is owned by [Symbol.asyncDispose] (≈ __aexit__) and the flusher lazy-starts on first publish. Removes the silent-buffering footgun where forgetting start() left items queued until dispose. flush() remains the public barrier. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/ai-sdk/src/activities.ts | 1 - packages/contrib-workflow-streams/README.md | 32 ++++++------- .../contrib-workflow-streams/src/client.ts | 48 ++++++++++--------- .../activities/contrib-workflow-streams.ts | 5 -- .../test/src/test-contrib-workflow-streams.ts | 3 +- 5 files changed, 41 insertions(+), 48 deletions(-) diff --git a/packages/ai-sdk/src/activities.ts b/packages/ai-sdk/src/activities.ts index bad2809e0..18ffd9a81 100644 --- a/packages/ai-sdk/src/activities.ts +++ b/packages/ai-sdk/src/activities.ts @@ -108,7 +108,6 @@ export function createActivities(provider: ProviderV3, mcpClientFactories?: McpC await using stream = WorkflowStreamClient.fromActivity({ batchInterval: args.streamingBatchInterval ?? '100 milliseconds', }); - stream.start(); const events = stream.topic(args.streamingTopic); const model = provider.languageModel(args.modelId); diff --git a/packages/contrib-workflow-streams/README.md b/packages/contrib-workflow-streams/README.md index 1236de46d..d0004b192 100644 --- a/packages/contrib-workflow-streams/README.md +++ b/packages/contrib-workflow-streams/README.md @@ -74,7 +74,6 @@ import { WorkflowStreamClient } from '@temporalio/contrib-workflow-streams'; export async function streamEvents(): Promise { await using client = WorkflowStreamClient.fromActivity({ batchInterval: '2 seconds' }); - client.start(); const events = client.topic('events'); for await (const chunk of generateChunks()) { @@ -85,20 +84,9 @@ export async function streamEvents(): Promise { } ``` -Outside an activity (e.g., a starter or BFF), use `WorkflowStreamClient.create()` -with an explicit client and workflow id. If `await using` is not available, -call `start()` and `await stop()` explicitly: - -```typescript -const client = WorkflowStreamClient.create(temporalClient, workflowId); -client.start(); -try { - const events = client.topic('events'); - events.publish(data); -} finally { - await client.stop(); -} -``` +The background flusher starts on the first `publish()` and stops on scope +exit (`await using`). Outside an activity (e.g., a starter or BFF), use +`WorkflowStreamClient.create(temporalClient, workflowId)` the same way. Use `forceFlush: true` to trigger an immediate flush for latency-sensitive events: @@ -107,6 +95,15 @@ events: events.publish(data, { forceFlush: true }); ``` +Use `await client.flush()` as an explicit barrier — returns once everything +published before the call has been signaled and acknowledged by the server: + +```typescript +events.publish(phase1Data); +await client.flush(); // phase 1 is durable on the workflow side +events.publish(phase2Data); +``` + ### Subscribing Subscribe via the topic handle to get items decoded as `T`: @@ -221,9 +218,8 @@ Handlers registered automatically: | `WorkflowStreamClient.create(client, workflowId, options?)` | Factory for use outside an activity (starters, BFFs). Enables CAN following in `subscribe()`; uses the `Client`'s configured payload converter. | | `WorkflowStreamClient.fromActivity(options?)` | Factory for use from within an activity — pulls the client and parent workflow id from the activity context. | | `new WorkflowStreamClient(handle, options?)` | From a handle (no CAN following). | -| `start()` | Start the background flusher. | -| `stop()` | Stop the flusher and flush remaining items. | -| `[Symbol.asyncDispose]()` | Supports `await using client = WorkflowStreamClient.create(...)`. | +| `flush()` | Barrier: returns once everything published before the call is acknowledged by the server. Empty-buffer call is a no-op. | +| `[Symbol.asyncDispose]()` | Stop the flusher and drain remaining items. Triggered automatically by `await using`. | | `topic(name)` | Get a typed `TopicHandle` for publishing and subscribing. Repeated calls with the same name return the same handle. | | `subscribe(topics?, fromOffset = 0, { pollCooldown = '100 milliseconds' })` | Raw async generator yielding `WorkflowStreamItem` — the multi-topic / decode-yourself path. `pollCooldown` is a `Duration`. Always follows CAN chains when created via `create()`. Recovers automatically from `TruncatedOffset` by restarting from the current base offset. | | `getOffset()` | Query current global offset. | diff --git a/packages/contrib-workflow-streams/src/client.ts b/packages/contrib-workflow-streams/src/client.ts index c323adaad..29a74bcdb 100644 --- a/packages/contrib-workflow-streams/src/client.ts +++ b/packages/contrib-workflow-streams/src/client.ts @@ -196,13 +196,6 @@ export class WorkflowStreamClient { return WorkflowStreamClient.create(ctx.client, workflowExecution.workflowId, options); } - /** Start the background flusher. Call before publishing. */ - start(): void { - if (this.flusherTask) return; - this.flusherStopped = false; - this.flusherTask = this.runFlusher(); - } - /** * Flush buffered (and pending) items and wait for server confirmation. * @@ -249,9 +242,26 @@ export class WorkflowStreamClient { this.throwPendingFlusherError(); } - /** Stop the flusher and flush remaining items. */ - async stop(): Promise { + private throwPendingFlusherError(): void { + if (this.flusherError) { + const err = this.flusherError; + this.flusherError = undefined; + throw err; + } + } + + /** + * Dispose pattern: stop the flusher and drain remaining items. + * + * Use via `await using client = WorkflowStreamClient.create(...)` so the + * scope exit guarantees a final drain. For tests or call sites that + * cannot use `await using`, invoke this method directly: + * `await client[Symbol.asyncDispose]()`. + */ + async [Symbol.asyncDispose](): Promise { if (!this.flusherTask) { + // Lazy-start path was never triggered (no publish, or only flush() + // was used). A single flushOnce() drains anything left in the buffer. await this.flushOnce(); this.throwPendingFlusherError(); return; @@ -272,19 +282,6 @@ export class WorkflowStreamClient { this.throwPendingFlusherError(); } - private throwPendingFlusherError(): void { - if (this.flusherError) { - const err = this.flusherError; - this.flusherError = undefined; - throw err; - } - } - - /** Dispose pattern: `await using client = WorkflowStreamClient.create(...)`. */ - async [Symbol.asyncDispose](): Promise { - await this.stop(); - } - /** * Get a typed handle for publishing to and subscribing from ``name``. * @@ -304,6 +301,13 @@ export class WorkflowStreamClient { /** @internal Used by {@link TopicHandle.publish}. */ _publishToTopic(topic: string, value: unknown, forceFlush: boolean): void { + // Lazy-start the background flusher on first publish. Skipped if dispose + // already ran, so a publish-after-dispose surfaces as a buffered item that + // never flushes (which the next dispose would catch) rather than silently + // resurrecting the flusher. + if (this.flusherTask === undefined && !this.flusherStopped) { + this.flusherTask = this.runFlusher(); + } this.buffer.push({ topic, value }); if (forceFlush || (this.maxBatchSize !== undefined && this.buffer.length >= this.maxBatchSize)) { this.flushEvent.set(); diff --git a/packages/test/src/activities/contrib-workflow-streams.ts b/packages/test/src/activities/contrib-workflow-streams.ts index 55a9e2ea6..988af50de 100644 --- a/packages/test/src/activities/contrib-workflow-streams.ts +++ b/packages/test/src/activities/contrib-workflow-streams.ts @@ -12,7 +12,6 @@ const encoder = new TextEncoder(); export async function publishItems(count: number): Promise { await using client = WorkflowStreamClient.fromActivity({ batchInterval: '500 milliseconds' }); - client.start(); const events = client.topic('events'); for (let i = 0; i < count; i++) { Context.current().heartbeat(); @@ -23,7 +22,6 @@ export async function publishItems(count: number): Promise { export async function publishMultiTopic(count: number): Promise { const topicNames = ['a', 'b', 'c']; await using client = WorkflowStreamClient.fromActivity({ batchInterval: '500 milliseconds' }); - client.start(); const handles = topicNames.map((name) => client.topic(name)); for (let i = 0; i < count; i++) { Context.current().heartbeat(); @@ -39,7 +37,6 @@ export async function publishWithForceFlush(): Promise { // so a regression (forceFlush no-op) surfaces as a missing item rather // than flaking on slow CI. await using client = WorkflowStreamClient.fromActivity({ batchInterval: '60 seconds' }); - client.start(); const events = client.topic('events'); events.publish(encoder.encode('normal-0')); events.publish(encoder.encode('normal-1')); @@ -52,7 +49,6 @@ export async function publishWithForceFlush(): Promise { export async function publishBatchTest(count: number): Promise { await using client = WorkflowStreamClient.fromActivity({ batchInterval: '60 seconds' }); - client.start(); const events = client.topic('events'); for (let i = 0; i < count; i++) { Context.current().heartbeat(); @@ -66,7 +62,6 @@ export async function publishWithMaxBatch(count: number): Promise { batchInterval: '60 seconds', maxBatchSize: 3, }); - client.start(); const events = client.topic('events'); for (let i = 0; i < count; i++) { Context.current().heartbeat(); diff --git a/packages/test/src/test-contrib-workflow-streams.ts b/packages/test/src/test-contrib-workflow-streams.ts index 53454fb7b..d5418b51a 100644 --- a/packages/test/src/test-contrib-workflow-streams.ts +++ b/packages/test/src/test-contrib-workflow-streams.ts @@ -763,8 +763,7 @@ test('flush_raises_after_max_retry_duration — timeout surfaces, client resumes batchInterval: '100 milliseconds', maxRetryDuration: '200 milliseconds', }); - client.start(); client.topic('events').publish(encoder.encode('will-be-lost')); await new Promise((r) => setTimeout(r, 1500)); - await t.throwsAsync(client.stop(), { instanceOf: FlushTimeoutError }); + await t.throwsAsync(client[Symbol.asyncDispose](), { instanceOf: FlushTimeoutError }); }); From 7be68f6224585a7c452660d8dfcf06fc19362322 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Sat, 9 May 2026 20:30:18 -0700 Subject: [PATCH 61/75] contrib-workflow-streams: fix README code comment spacing Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/contrib-workflow-streams/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/contrib-workflow-streams/README.md b/packages/contrib-workflow-streams/README.md index d0004b192..b50040acd 100644 --- a/packages/contrib-workflow-streams/README.md +++ b/packages/contrib-workflow-streams/README.md @@ -100,7 +100,7 @@ published before the call has been signaled and acknowledged by the server: ```typescript events.publish(phase1Data); -await client.flush(); // phase 1 is durable on the workflow side +await client.flush(); // phase 1 is durable on the workflow side events.publish(phase2Data); ``` From 23ff62b90abc72e0e0cb3a89df32d1977e6f73b6 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Thu, 14 May 2026 12:50:17 -0700 Subject: [PATCH 62/75] workflow-streams: move to contrib/ and rename to @temporalio/workflow-streams Match the layout of the other contrib packages (ai-sdk, interceptors-opentelemetry): move packages/contrib-workflow-streams to contrib/workflow-streams, drop the redundant "contrib-" prefix from the package name, and add bugs/repository/homepage fields to package.json. Add a CODEOWNERS entry making @temporalio/ai-sdk a joint owner of contrib/workflow-streams alongside @temporalio/sdk, since the ai-sdk plugin is the primary consumer. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/CODEOWNERS | 2 + contrib/ai-sdk/package.json | 2 +- contrib/ai-sdk/src/activities.ts | 2 +- .../workflow-streams}/README.md | 8 ++-- .../workflow-streams}/package.json | 11 ++++- .../workflow-streams}/src/client.ts | 0 .../workflow-streams}/src/index.ts | 0 .../workflow-streams}/src/stream.ts | 0 .../workflow-streams}/src/topic-handle.ts | 0 .../workflow-streams}/src/types.ts | 0 contrib/workflow-streams/tsconfig.json | 16 +++++++ packages/common/src/reserved.ts | 2 +- .../contrib-workflow-streams/tsconfig.json | 16 ------- packages/test/package.json | 2 +- .../activities/contrib-workflow-streams.ts | 4 +- .../test-contrib-workflow-streams-interop.ts | 4 +- .../test/src/test-contrib-workflow-streams.ts | 4 +- .../src/workflows/contrib-workflow-streams.ts | 4 +- pnpm-lock.yaml | 48 +++++++++---------- pnpm-workspace.yaml | 2 +- 20 files changed, 69 insertions(+), 58 deletions(-) rename {packages/contrib-workflow-streams => contrib/workflow-streams}/README.md (98%) rename {packages/contrib-workflow-streams => contrib/workflow-streams}/package.json (65%) rename {packages/contrib-workflow-streams => contrib/workflow-streams}/src/client.ts (100%) rename {packages/contrib-workflow-streams => contrib/workflow-streams}/src/index.ts (100%) rename {packages/contrib-workflow-streams => contrib/workflow-streams}/src/stream.ts (100%) rename {packages/contrib-workflow-streams => contrib/workflow-streams}/src/topic-handle.ts (100%) rename {packages/contrib-workflow-streams => contrib/workflow-streams}/src/types.ts (100%) create mode 100644 contrib/workflow-streams/tsconfig.json delete mode 100644 packages/contrib-workflow-streams/tsconfig.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7bdb4ecab..b005795c3 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -3,3 +3,5 @@ # @temporalio/sdk will be requested for review when # someone opens a pull request. * @temporalio/sdk + +contrib/workflow-streams/ @temporalio/sdk @temporalio/ai-sdk diff --git a/contrib/ai-sdk/package.json b/contrib/ai-sdk/package.json index 0a9f8f3f9..73cf057b1 100644 --- a/contrib/ai-sdk/package.json +++ b/contrib/ai-sdk/package.json @@ -27,9 +27,9 @@ "@temporalio/activity": "workspace:*", "@temporalio/client": "workspace:*", "@temporalio/common": "workspace:*", - "@temporalio/contrib-workflow-streams": "workspace:*", "@temporalio/plugin": "workspace:*", "@temporalio/workflow": "workspace:*", + "@temporalio/workflow-streams": "workspace:*", "@ungap/structured-clone": "^1.3.0", "headers-polyfill": "^4.0.3" }, diff --git a/contrib/ai-sdk/src/activities.ts b/contrib/ai-sdk/src/activities.ts index 18ffd9a81..27adf221c 100644 --- a/contrib/ai-sdk/src/activities.ts +++ b/contrib/ai-sdk/src/activities.ts @@ -13,7 +13,7 @@ import type { import { asSchema, type Schema, type ToolExecutionOptions } from 'ai'; import { ApplicationFailure } from '@temporalio/common'; import { Context } from '@temporalio/activity'; -import { WorkflowStreamClient } from '@temporalio/contrib-workflow-streams'; +import { WorkflowStreamClient } from '@temporalio/workflow-streams'; import type { Duration } from '@temporalio/common/lib/time'; import type { McpClientFactories, McpClientFactory } from './mcp'; diff --git a/packages/contrib-workflow-streams/README.md b/contrib/workflow-streams/README.md similarity index 98% rename from packages/contrib-workflow-streams/README.md rename to contrib/workflow-streams/README.md index b50040acd..55adb669f 100644 --- a/packages/contrib-workflow-streams/README.md +++ b/contrib/workflow-streams/README.md @@ -35,7 +35,7 @@ get a typed handle for each topic via `stream.topic(name)` and call `publish` on the handle: ```typescript -import { WorkflowStream } from '@temporalio/contrib-workflow-streams'; +import { WorkflowStream } from '@temporalio/workflow-streams'; interface StatusEvent { state: 'started' | 'done'; @@ -70,7 +70,7 @@ the same way as on the workflow side: ```typescript import { Context } from '@temporalio/activity'; -import { WorkflowStreamClient } from '@temporalio/contrib-workflow-streams'; +import { WorkflowStreamClient } from '@temporalio/workflow-streams'; export async function streamEvents(): Promise { await using client = WorkflowStreamClient.fromActivity({ batchInterval: '2 seconds' }); @@ -109,7 +109,7 @@ events.publish(phase2Data); Subscribe via the topic handle to get items decoded as `T`: ```typescript -import { WorkflowStreamClient } from '@temporalio/contrib-workflow-streams'; +import { WorkflowStreamClient } from '@temporalio/workflow-streams'; const client = WorkflowStreamClient.create(temporalClient, workflowId); const events = client.topic('events'); @@ -140,7 +140,7 @@ boundaries: ```typescript import { continueAsNew, workflowInfo } from '@temporalio/workflow'; -import { WorkflowStream, type WorkflowStreamState } from '@temporalio/contrib-workflow-streams'; +import { WorkflowStream, type WorkflowStreamState } from '@temporalio/workflow-streams'; interface WorkflowInput { itemsProcessed: number; diff --git a/packages/contrib-workflow-streams/package.json b/contrib/workflow-streams/package.json similarity index 65% rename from packages/contrib-workflow-streams/package.json rename to contrib/workflow-streams/package.json index dec5a8feb..4589585d1 100644 --- a/packages/contrib-workflow-streams/package.json +++ b/contrib/workflow-streams/package.json @@ -1,5 +1,5 @@ { - "name": "@temporalio/contrib-workflow-streams", + "name": "@temporalio/workflow-streams", "version": "1.17.0", "description": "Temporal.io SDK Workflow Streams contrib module", "main": "lib/index.js", @@ -24,6 +24,15 @@ "engines": { "node": ">= 20.0.0" }, + "bugs": { + "url": "https://github.com/temporalio/sdk-typescript/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/temporalio/sdk-typescript.git", + "directory": "contrib/workflow-streams" + }, + "homepage": "https://github.com/temporalio/sdk-typescript/tree/main/contrib/workflow-streams", "publishConfig": { "access": "public" }, diff --git a/packages/contrib-workflow-streams/src/client.ts b/contrib/workflow-streams/src/client.ts similarity index 100% rename from packages/contrib-workflow-streams/src/client.ts rename to contrib/workflow-streams/src/client.ts diff --git a/packages/contrib-workflow-streams/src/index.ts b/contrib/workflow-streams/src/index.ts similarity index 100% rename from packages/contrib-workflow-streams/src/index.ts rename to contrib/workflow-streams/src/index.ts diff --git a/packages/contrib-workflow-streams/src/stream.ts b/contrib/workflow-streams/src/stream.ts similarity index 100% rename from packages/contrib-workflow-streams/src/stream.ts rename to contrib/workflow-streams/src/stream.ts diff --git a/packages/contrib-workflow-streams/src/topic-handle.ts b/contrib/workflow-streams/src/topic-handle.ts similarity index 100% rename from packages/contrib-workflow-streams/src/topic-handle.ts rename to contrib/workflow-streams/src/topic-handle.ts diff --git a/packages/contrib-workflow-streams/src/types.ts b/contrib/workflow-streams/src/types.ts similarity index 100% rename from packages/contrib-workflow-streams/src/types.ts rename to contrib/workflow-streams/src/types.ts diff --git a/contrib/workflow-streams/tsconfig.json b/contrib/workflow-streams/tsconfig.json new file mode 100644 index 000000000..d310980c8 --- /dev/null +++ b/contrib/workflow-streams/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./lib", + "rootDir": "./src", + "skipLibCheck": true + }, + "references": [ + { "path": "../../packages/activity" }, + { "path": "../../packages/client" }, + { "path": "../../packages/common" }, + { "path": "../../packages/proto" }, + { "path": "../../packages/workflow" } + ], + "include": ["./src/**/*.ts"] +} diff --git a/packages/common/src/reserved.ts b/packages/common/src/reserved.ts index f8abbb0de..dede88967 100644 --- a/packages/common/src/reserved.ts +++ b/packages/common/src/reserved.ts @@ -7,7 +7,7 @@ export const ENHANCED_STACK_TRACE_QUERY_NAME = '__enhanced_stack_trace'; * bypass the {@link TEMPORAL_RESERVED_PREFIX} check at registration time. */ const INTERNAL_HANDLER_NAME_ALLOWLIST: ReadonlySet = new Set([ - // @temporalio/contrib-workflow-streams + // @temporalio/workflow-streams '__temporal_workflow_stream_publish', '__temporal_workflow_stream_poll', '__temporal_workflow_stream_offset', diff --git a/packages/contrib-workflow-streams/tsconfig.json b/packages/contrib-workflow-streams/tsconfig.json deleted file mode 100644 index 9c4c1a6bf..000000000 --- a/packages/contrib-workflow-streams/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "./lib", - "rootDir": "./src", - "skipLibCheck": true - }, - "references": [ - { "path": "../activity" }, - { "path": "../client" }, - { "path": "../common" }, - { "path": "../proto" }, - { "path": "../workflow" } - ], - "include": ["./src/**/*.ts"] -} diff --git a/packages/test/package.json b/packages/test/package.json index d0fdb9d04..80842dca8 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -37,7 +37,6 @@ "@temporalio/client": "workspace:*", "@temporalio/cloud": "workspace:*", "@temporalio/common": "workspace:*", - "@temporalio/contrib-workflow-streams": "workspace:*", "@temporalio/core-bridge": "workspace:*", "@temporalio/envconfig": "workspace:*", "@temporalio/interceptors-opentelemetry": "workspace:*", @@ -50,6 +49,7 @@ "@temporalio/testing": "workspace:*", "@temporalio/worker": "workspace:*", "@temporalio/workflow": "workspace:*", + "@temporalio/workflow-streams": "workspace:*", "arg": "^5.0.2", "async-retry": "^1.3.3", "ava": "^5.3.1", diff --git a/packages/test/src/activities/contrib-workflow-streams.ts b/packages/test/src/activities/contrib-workflow-streams.ts index 988af50de..8c60886b6 100644 --- a/packages/test/src/activities/contrib-workflow-streams.ts +++ b/packages/test/src/activities/contrib-workflow-streams.ts @@ -1,12 +1,12 @@ /** - * Test activities for @temporalio/contrib-workflow-streams. + * Test activities for @temporalio/workflow-streams. * * These activities use `WorkflowStreamClient.fromActivity()` to target the * current activity's parent workflow from the activity context. */ import { Context } from '@temporalio/activity'; -import { WorkflowStreamClient } from '@temporalio/contrib-workflow-streams'; +import { WorkflowStreamClient } from '@temporalio/workflow-streams'; const encoder = new TextEncoder(); diff --git a/packages/test/src/test-contrib-workflow-streams-interop.ts b/packages/test/src/test-contrib-workflow-streams-interop.ts index 9b35dc485..c4ae6d571 100644 --- a/packages/test/src/test-contrib-workflow-streams-interop.ts +++ b/packages/test/src/test-contrib-workflow-streams-interop.ts @@ -1,5 +1,5 @@ /** - * Wire-format interop tests for @temporalio/contrib-workflow-streams. + * Wire-format interop tests for @temporalio/workflow-streams. * * These tests pin the exact byte layout produced by the TypeScript * implementation so it stays compatible with the Python SDK, which @@ -19,7 +19,7 @@ import { encodeBase64, encodePayloadProto, encodePayloadWire, -} from '@temporalio/contrib-workflow-streams'; +} from '@temporalio/workflow-streams'; const test = anyTest as TestFn; const encoder = new TextEncoder(); diff --git a/packages/test/src/test-contrib-workflow-streams.ts b/packages/test/src/test-contrib-workflow-streams.ts index d5418b51a..e0f489033 100644 --- a/packages/test/src/test-contrib-workflow-streams.ts +++ b/packages/test/src/test-contrib-workflow-streams.ts @@ -1,5 +1,5 @@ /** - * E2E integration tests for @temporalio/contrib-workflow-streams. + * E2E integration tests for @temporalio/workflow-streams. * * Ported from sdk-python tests/contrib/stream/test_stream.py. */ @@ -21,7 +21,7 @@ import { workflowStreamOffsetQuery, workflowStreamPublishSignal, workflowStreamPollUpdate, -} from '@temporalio/contrib-workflow-streams'; +} from '@temporalio/workflow-streams'; import { helpers, makeTestFunction } from './helpers-integration'; import { activityPublishWorkflow, diff --git a/packages/test/src/workflows/contrib-workflow-streams.ts b/packages/test/src/workflows/contrib-workflow-streams.ts index 0cd24955b..ee7a5c53d 100644 --- a/packages/test/src/workflows/contrib-workflow-streams.ts +++ b/packages/test/src/workflows/contrib-workflow-streams.ts @@ -1,5 +1,5 @@ /** - * Test workflows for @temporalio/contrib-workflow-streams. + * Test workflows for @temporalio/workflow-streams. */ import { @@ -11,7 +11,7 @@ import { proxyActivities, setHandler, } from '@temporalio/workflow'; -import { WorkflowStream, type WorkflowStreamState } from '@temporalio/contrib-workflow-streams'; +import { WorkflowStream, type WorkflowStreamState } from '@temporalio/workflow-streams'; import type * as activities from '../activities/contrib-workflow-streams'; const { publishItems, publishMultiTopic, publishWithForceFlush, publishBatchTest, publishWithMaxBatch } = diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06a8fb72d..abdab14e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,15 +161,15 @@ importers: '@temporalio/common': specifier: workspace:* version: link:../../packages/common - '@temporalio/contrib-workflow-streams': - specifier: workspace:* - version: link:../../packages/contrib-workflow-streams '@temporalio/plugin': specifier: workspace:* version: link:../../packages/plugin '@temporalio/workflow': specifier: workspace:* version: link:../../packages/workflow + '@temporalio/workflow-streams': + specifier: workspace:* + version: link:../workflow-streams '@ungap/structured-clone': specifier: ^1.3.0 version: 1.3.0 @@ -293,6 +293,24 @@ importers: specifier: ^11.1.0 version: 11.1.0 + contrib/workflow-streams: + dependencies: + '@temporalio/activity': + specifier: workspace:* + version: link:../../packages/activity + '@temporalio/client': + specifier: workspace:* + version: link:../../packages/client + '@temporalio/common': + specifier: workspace:* + version: link:../../packages/common + '@temporalio/proto': + specifier: workspace:* + version: link:../../packages/proto + '@temporalio/workflow': + specifier: workspace:* + version: link:../../packages/workflow + packages/activity: dependencies: '@temporalio/client': @@ -380,24 +398,6 @@ importers: specifier: ^7.5.5 version: 7.5.5(patch_hash=0ede6d81e4e283d0a44203616b8935211e15e8326841cd4cd7427963385ea3c1) - packages/contrib-workflow-streams: - dependencies: - '@temporalio/activity': - specifier: workspace:* - version: link:../activity - '@temporalio/client': - specifier: workspace:* - version: link:../client - '@temporalio/common': - specifier: workspace:* - version: link:../common - '@temporalio/proto': - specifier: workspace:* - version: link:../proto - '@temporalio/workflow': - specifier: workspace:* - version: link:../workflow - packages/core-bridge: dependencies: '@grpc/grpc-js': @@ -704,9 +704,6 @@ importers: '@temporalio/common': specifier: workspace:* version: link:../common - '@temporalio/contrib-workflow-streams': - specifier: workspace:* - version: link:../contrib-workflow-streams '@temporalio/core-bridge': specifier: workspace:* version: link:../core-bridge @@ -743,6 +740,9 @@ importers: '@temporalio/workflow': specifier: workspace:* version: link:../workflow + '@temporalio/workflow-streams': + specifier: workspace:* + version: link:../../contrib/workflow-streams arg: specifier: ^5.0.2 version: 5.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7445221e8..cf3bd049e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -17,9 +17,9 @@ packages: - packages/testing - packages/worker - packages/workflow - - packages/contrib-workflow-streams - contrib/ai-sdk - contrib/interceptors-opentelemetry + - contrib/workflow-streams - scripts ignoreScripts: true From 27f8c86fa4ffe1bac892552b17ed347af5a77f78 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Thu, 14 May 2026 13:26:32 -0700 Subject: [PATCH 63/75] chore: update lockfile for pnpm 10 patchedDependencies format Co-Authored-By: Claude Opus 4.7 (1M context) --- pnpm-lock.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index abdab14e2..c1f95fa5c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,9 @@ overrides: handlebars: '>=4.7.9' patchedDependencies: - protobufjs@7.5.5: 0ede6d81e4e283d0a44203616b8935211e15e8326841cd4cd7427963385ea3c1 + protobufjs@7.5.5: + hash: 0ede6d81e4e283d0a44203616b8935211e15e8326841cd4cd7427963385ea3c1 + path: patches/protobufjs@7.5.5.patch importers: From abb93bd801e048fa9ac674bce6940f8b95698d27 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Fri, 15 May 2026 11:08:59 -0700 Subject: [PATCH 64/75] code review first pass Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/CODEOWNERS | 2 +- contrib/ai-sdk/src/activities.ts | 4 +- contrib/ai-sdk/src/load-polyfills.ts | 1 + contrib/workflow-streams/README.md | 2 +- contrib/workflow-streams/package.json | 18 +- .../__tests__/activities/workflow-streams.ts | 2 +- .../src/__tests__/helpers-integration.ts | 42 +++ .../test-workflow-streams-interop.ts | 4 +- .../src/__tests__/test-workflow-streams.ts | 8 +- .../__tests__/workflows/workflow-streams.ts | 4 +- contrib/workflow-streams/src/client.ts | 11 +- contrib/workflow-streams/src/codec.ts | 195 ++++++++++ contrib/workflow-streams/src/index.ts | 2 +- contrib/workflow-streams/src/stream.ts | 19 +- contrib/workflow-streams/src/types.ts | 203 +---------- contrib/workflow-streams/tsconfig.json | 3 + packages/core-bridge/Cargo.lock | 339 +++++++++++------- packages/test/package.json | 1 - pnpm-lock.yaml | 16 +- 19 files changed, 503 insertions(+), 373 deletions(-) rename packages/test/src/activities/contrib-workflow-streams.ts => contrib/workflow-streams/src/__tests__/activities/workflow-streams.ts (97%) create mode 100644 contrib/workflow-streams/src/__tests__/helpers-integration.ts rename packages/test/src/test-contrib-workflow-streams-interop.ts => contrib/workflow-streams/src/__tests__/test-workflow-streams-interop.ts (98%) rename packages/test/src/test-contrib-workflow-streams.ts => contrib/workflow-streams/src/__tests__/test-workflow-streams.ts (99%) rename packages/test/src/workflows/contrib-workflow-streams.ts => contrib/workflow-streams/src/__tests__/workflows/workflow-streams.ts (97%) create mode 100644 contrib/workflow-streams/src/codec.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6adb850d0..57a888f36 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -4,5 +4,5 @@ # someone opens a pull request. * @temporalio/sdk -/contrib/ai-sdk/ @temporalio/sdk @temporalio/ai-sdk +/contrib/ai-sdk/ @temporalio/sdk @temporalio/ai-sdk /contrib/workflow-streams/ @temporalio/sdk @temporalio/ai-sdk diff --git a/contrib/ai-sdk/src/activities.ts b/contrib/ai-sdk/src/activities.ts index 27adf221c..6b612305d 100644 --- a/contrib/ai-sdk/src/activities.ts +++ b/contrib/ai-sdk/src/activities.ts @@ -188,7 +188,7 @@ export function createActivities(provider: ProviderV3, mcpClientFactories?: McpC part.type === 'file' || part.type === 'source') ) { - content.push(part as LanguageModelV3Content); + content.push(part); } break; } @@ -201,7 +201,7 @@ export function createActivities(provider: ProviderV3, mcpClientFactories?: McpC warnings, request: streamResult.request, response: responseMetadata ? { ...responseMetadata, ...streamResult.response } : streamResult.response, - } as InvokeModelResult; + }; }, async invokeEmbeddingModel(args: InvokeEmbeddingModelArgs): Promise { diff --git a/contrib/ai-sdk/src/load-polyfills.ts b/contrib/ai-sdk/src/load-polyfills.ts index 76d286803..d8e6ee643 100644 --- a/contrib/ai-sdk/src/load-polyfills.ts +++ b/contrib/ai-sdk/src/load-polyfills.ts @@ -7,6 +7,7 @@ if (inWorkflowContext()) { globalThis.Headers = Headers; } + // Attach the polyfill as a Global function if (!('structuredClone' in globalThis)) { // eslint-disable-next-line @typescript-eslint/no-require-imports const structuredClone = require('@ungap/structured-clone'); diff --git a/contrib/workflow-streams/README.md b/contrib/workflow-streams/README.md index 55adb669f..9eb1ea038 100644 --- a/contrib/workflow-streams/README.md +++ b/contrib/workflow-streams/README.md @@ -250,7 +250,7 @@ handler names: 2. **Subscribe**: update `__temporal_workflow_stream_poll` with `PollInput` -> `PollResult` 3. **Offset**: query `__temporal_workflow_stream_offset` -> `number` -Each `PublishEntry.data` / `_WorkflowStreamWireItem.data` is a base64-encoded +Each `PublishEntry.data` / `WorkflowStreamWireItem.data` is a base64-encoded `temporal.api.common.v1.Payload` protobuf (`Payload.SerializeToString()` in Python; equivalent `encodePayloadProto()` in this package). This keeps the envelope JSON-serializable while preserving `Payload.metadata` for codec and diff --git a/contrib/workflow-streams/package.json b/contrib/workflow-streams/package.json index 4589585d1..8800a7da5 100644 --- a/contrib/workflow-streams/package.json +++ b/contrib/workflow-streams/package.json @@ -5,7 +5,13 @@ "main": "lib/index.js", "types": "./lib/index.d.ts", "scripts": { - "build": "tsc --build" + "build": "tsc --build", + "test": "ava ./lib/__tests__/test-*.js" + }, + "ava": { + "timeout": "120s", + "concurrency": 1, + "workerThreads": false }, "keywords": [ "temporal", @@ -21,6 +27,12 @@ "@temporalio/proto": "workspace:*", "@temporalio/workflow": "workspace:*" }, + "devDependencies": { + "@temporalio/test-helpers": "workspace:*", + "@temporalio/testing": "workspace:*", + "@temporalio/worker": "workspace:*", + "ava": "^5.3.1" + }, "engines": { "node": ">= 20.0.0" }, @@ -38,6 +50,8 @@ }, "files": [ "src", - "lib" + "lib", + "!src/__tests__", + "!lib/__tests__" ] } diff --git a/packages/test/src/activities/contrib-workflow-streams.ts b/contrib/workflow-streams/src/__tests__/activities/workflow-streams.ts similarity index 97% rename from packages/test/src/activities/contrib-workflow-streams.ts rename to contrib/workflow-streams/src/__tests__/activities/workflow-streams.ts index 8c60886b6..c5df1bbf7 100644 --- a/packages/test/src/activities/contrib-workflow-streams.ts +++ b/contrib/workflow-streams/src/__tests__/activities/workflow-streams.ts @@ -6,7 +6,7 @@ */ import { Context } from '@temporalio/activity'; -import { WorkflowStreamClient } from '@temporalio/workflow-streams'; +import { WorkflowStreamClient } from '../..'; const encoder = new TextEncoder(); diff --git a/contrib/workflow-streams/src/__tests__/helpers-integration.ts b/contrib/workflow-streams/src/__tests__/helpers-integration.ts new file mode 100644 index 000000000..28d923822 --- /dev/null +++ b/contrib/workflow-streams/src/__tests__/helpers-integration.ts @@ -0,0 +1,42 @@ +/** + * Minimal integration-test harness for `@temporalio/workflow-streams` tests. + * + * Mirrors the slice of `packages/test/src/helpers-integration.ts` that these + * tests actually use: a `makeTestFunction` that wires up a TestWorkflowEnvironment + * and a prebuilt workflow bundle in `test.before`, and a `helpers(t)` that + * surfaces `createWorker` / `startWorkflow` per test. + */ + +import type { ExecutionContext, TestFn } from 'ava'; +import { + test as anyTest, + helpers as baseHelpers, + createTestWorkflowEnvironment, + createTestWorkflowBundle, + type BaseContext, + type BaseHelpers, + type TestWorkflowEnvironment, +} from '@temporalio/test-helpers'; + +export interface Context extends BaseContext {} + +export interface TestFunctionOptions { + workflowsPath: string; +} + +export function makeTestFunction(opts: TestFunctionOptions): TestFn { + const test = anyTest as TestFn; + test.before(async (t) => { + const env = await createTestWorkflowEnvironment(); + const workflowBundle = await createTestWorkflowBundle({ workflowsPath: opts.workflowsPath }); + t.context = { env, workflowBundle }; + }); + test.after.always(async (t) => { + await t.context.env?.teardown(); + }); + return test; +} + +export function helpers(t: ExecutionContext): BaseHelpers { + return baseHelpers(t, t.context.env); +} diff --git a/packages/test/src/test-contrib-workflow-streams-interop.ts b/contrib/workflow-streams/src/__tests__/test-workflow-streams-interop.ts similarity index 98% rename from packages/test/src/test-contrib-workflow-streams-interop.ts rename to contrib/workflow-streams/src/__tests__/test-workflow-streams-interop.ts index c4ae6d571..265b0bd08 100644 --- a/packages/test/src/test-contrib-workflow-streams-interop.ts +++ b/contrib/workflow-streams/src/__tests__/test-workflow-streams-interop.ts @@ -5,7 +5,7 @@ * implementation so it stays compatible with the Python SDK, which * uses `temporalio.api.common.v1.Payload` serialized via protobuf. * - * Unlike `test-contrib-workflow-streams.ts`, these don't need a Temporal server — + * Unlike `test-workflow-streams.ts`, these don't need a Temporal server — * they are pure encode/decode unit tests. */ @@ -19,7 +19,7 @@ import { encodeBase64, encodePayloadProto, encodePayloadWire, -} from '@temporalio/workflow-streams'; +} from '..'; const test = anyTest as TestFn; const encoder = new TextEncoder(); diff --git a/packages/test/src/test-contrib-workflow-streams.ts b/contrib/workflow-streams/src/__tests__/test-workflow-streams.ts similarity index 99% rename from packages/test/src/test-contrib-workflow-streams.ts rename to contrib/workflow-streams/src/__tests__/test-workflow-streams.ts index e0f489033..476bb127c 100644 --- a/packages/test/src/test-contrib-workflow-streams.ts +++ b/contrib/workflow-streams/src/__tests__/test-workflow-streams.ts @@ -21,7 +21,7 @@ import { workflowStreamOffsetQuery, workflowStreamPublishSignal, workflowStreamPollUpdate, -} from '@temporalio/workflow-streams'; +} from '..'; import { helpers, makeTestFunction } from './helpers-integration'; import { activityPublishWorkflow, @@ -38,11 +38,11 @@ import { truncateWorkflow, ttlTestWorkflow, workflowSidePublishWorkflow, -} from './workflows/contrib-workflow-streams'; -import * as streamActivities from './activities/contrib-workflow-streams'; +} from './workflows/workflow-streams'; +import * as streamActivities from './activities/workflow-streams'; const test = makeTestFunction({ - workflowsPath: require.resolve('./workflows/contrib-workflow-streams'), + workflowsPath: require.resolve('./workflows/workflow-streams'), }); const encoder = new TextEncoder(); diff --git a/packages/test/src/workflows/contrib-workflow-streams.ts b/contrib/workflow-streams/src/__tests__/workflows/workflow-streams.ts similarity index 97% rename from packages/test/src/workflows/contrib-workflow-streams.ts rename to contrib/workflow-streams/src/__tests__/workflows/workflow-streams.ts index ee7a5c53d..8a1b4c903 100644 --- a/packages/test/src/workflows/contrib-workflow-streams.ts +++ b/contrib/workflow-streams/src/__tests__/workflows/workflow-streams.ts @@ -11,8 +11,8 @@ import { proxyActivities, setHandler, } from '@temporalio/workflow'; -import { WorkflowStream, type WorkflowStreamState } from '@temporalio/workflow-streams'; -import type * as activities from '../activities/contrib-workflow-streams'; +import { WorkflowStream, type WorkflowStreamState } from '../..'; +import type * as activities from '../activities/workflow-streams'; const { publishItems, publishMultiTopic, publishWithForceFlush, publishBatchTest, publishWithMaxBatch } = proxyActivities({ diff --git a/contrib/workflow-streams/src/client.ts b/contrib/workflow-streams/src/client.ts index 29a74bcdb..fedeab27d 100644 --- a/contrib/workflow-streams/src/client.ts +++ b/contrib/workflow-streams/src/client.ts @@ -26,15 +26,8 @@ import { } from '@temporalio/common'; import type { Duration } from '@temporalio/common/lib/time'; import { msToNumber } from '@temporalio/common/lib/time'; -import { - decodePayloadWire, - encodePayloadWire, - type PollInput, - type PollResult, - type WorkflowStreamItem, - type PublishEntry, - type PublishInput, -} from './types'; +import { decodePayloadWire, encodePayloadWire } from './codec'; +import type { PollInput, PollResult, WorkflowStreamItem, PublishEntry, PublishInput } from './types'; import { TopicHandle } from './topic-handle'; /** Thrown when a flush retry exceeds maxRetryDuration. */ diff --git a/contrib/workflow-streams/src/codec.ts b/contrib/workflow-streams/src/codec.ts new file mode 100644 index 000000000..94b38d45b --- /dev/null +++ b/contrib/workflow-streams/src/codec.ts @@ -0,0 +1,195 @@ +/** + * Wire codec for workflow stream payloads. + * + * Two layers: + * + * 1. Base64 (no `Buffer` dependency, for workflow sandbox compat). + * 2. Hand-rolled protobuf encoder/decoder for `temporal.api.common.v1.Payload`. + * Avoids pulling the protobufjs runtime into the workflow sandbox. The + * schema is a fixed public API — the manual encoder cannot silently go + * out of sync with server-side expectations. + * + * Payload schema: + * message Payload { + * map metadata = 1; + * bytes data = 2; + * } + */ + +import type { Payload } from '@temporalio/common'; + +// --------------------------------------------------------------------------- +// Base64 helpers +// --------------------------------------------------------------------------- + +const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +/** Encode bytes to standard base64 (padded). */ +export function encodeBase64(data: Uint8Array): string { + let result = ''; + for (let i = 0; i < data.length; i += 3) { + const b0 = data[i]!; + const b1 = i + 1 < data.length ? data[i + 1]! : 0; + const b2 = i + 2 < data.length ? data[i + 2]! : 0; + result += B64[(b0 >> 2) & 0x3f]; + result += B64[((b0 << 4) | (b1 >> 4)) & 0x3f]; + result += i + 1 < data.length ? B64[((b1 << 2) | (b2 >> 6)) & 0x3f] : '='; + result += i + 2 < data.length ? B64[b2 & 0x3f] : '='; + } + return result; +} + +/** Decode standard base64 to bytes. Throws TypeError on malformed input. */ +export function decodeBase64(data: string): Uint8Array { + const clean = data.replace(/=+$/, ''); + if (data.length - clean.length > 2) { + throw new TypeError(`Invalid base64 input: too many '=' padding characters`); + } + if (clean.length % 4 === 1) { + throw new TypeError(`Invalid base64 input: length ${data.length} is not valid`); + } + const len = (clean.length * 3) >> 2; + const out = new Uint8Array(len); + let j = 0; + for (let i = 0; i < clean.length; i += 4) { + const a = B64.indexOf(clean.charAt(i)); + const b = i + 1 < clean.length ? B64.indexOf(clean.charAt(i + 1)) : 0; + const c = i + 2 < clean.length ? B64.indexOf(clean.charAt(i + 2)) : 0; + const d = i + 3 < clean.length ? B64.indexOf(clean.charAt(i + 3)) : 0; + if (a < 0 || b < 0 || c < 0 || d < 0) { + throw new TypeError(`Invalid base64 input: non-alphabet character at offset ${i}`); + } + out[j++] = (a << 2) | (b >> 4); + if (j < len) out[j++] = ((b << 4) | (c >> 2)) & 0xff; + if (j < len) out[j++] = ((c << 6) | d) & 0xff; + } + return out; +} + +// --------------------------------------------------------------------------- +// Protobuf codec for temporal.api.common.v1.Payload +// --------------------------------------------------------------------------- +// +// Map entries are encoded as embedded messages: +// message MapEntry { string key = 1; bytes value = 2; } + +function writeVarint(buf: number[], n: number): void { + while (n >= 0x80) { + buf.push((n & 0x7f) | 0x80); + n = Math.floor(n / 128); + } + buf.push(n & 0x7f); +} + +function readVarint(bytes: Uint8Array, pos: { i: number }): number { + let result = 0; + let shift = 0; + while (true) { + if (pos.i >= bytes.length) { + throw new Error('unexpected end of varint'); + } + const b = bytes[pos.i++]!; + result += (b & 0x7f) * Math.pow(2, shift); + if ((b & 0x80) === 0) break; + shift += 7; + if (shift > 35) throw new Error('varint too large'); + } + return result; +} + +function writeTagLenBytes(buf: number[], tag: number, bytes: Uint8Array): void { + buf.push(tag); + writeVarint(buf, bytes.length); + for (let i = 0; i < bytes.length; i++) buf.push(bytes[i]!); +} + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +function utf8Encode(s: string): Uint8Array { + return textEncoder.encode(s); +} + +function utf8Decode(b: Uint8Array): string { + return textDecoder.decode(b); +} + +/** Encode a Payload to its protobuf binary representation. */ +export function encodePayloadProto(payload: Payload): Uint8Array { + const buf: number[] = []; + const metadata = payload.metadata ?? {}; + for (const key of Object.keys(metadata)) { + const value = metadata[key]; + if (value == null) continue; + // Inner: key (tag 0x0A, wire-type 2) + value (tag 0x12, wire-type 2) + const entry: number[] = []; + writeTagLenBytes(entry, 0x0a, utf8Encode(key)); + writeTagLenBytes(entry, 0x12, value); + // Outer: metadata field 1, wire-type 2 + writeTagLenBytes(buf, 0x0a, new Uint8Array(entry)); + } + const data = payload.data; + if (data && data.length > 0) { + writeTagLenBytes(buf, 0x12, data); + } + return new Uint8Array(buf); +} + +/** Decode protobuf binary bytes to a Payload. */ +export function decodePayloadProto(bytes: Uint8Array): Payload { + const pos = { i: 0 }; + const metadata: Record = {}; + let data: Uint8Array = new Uint8Array(0); + while (pos.i < bytes.length) { + const tag = bytes[pos.i++]!; + const fieldNumber = tag >>> 3; + const wireType = tag & 0x07; + if (wireType === 2) { + const len = readVarint(bytes, pos); + const chunk = bytes.subarray(pos.i, pos.i + len); + pos.i += len; + if (fieldNumber === 1) { + // Parse inner map entry message + const p2 = { i: 0 }; + let key = ''; + let value = new Uint8Array(0); + while (p2.i < chunk.length) { + const itag = chunk[p2.i++]!; + const ifn = itag >>> 3; + const iwt = itag & 0x07; + if (iwt !== 2) { + throw new Error(`unsupported wire type ${iwt} in Payload metadata entry`); + } + const ilen = readVarint(chunk, p2); + const ival = chunk.subarray(p2.i, p2.i + ilen); + p2.i += ilen; + if (ifn === 1) key = utf8Decode(ival); + else if (ifn === 2) value = new Uint8Array(ival); + } + metadata[key] = value; + } else if (fieldNumber === 2) { + data = new Uint8Array(chunk); + } + // Other fields (e.g. externalPayloads = 3) are ignored. + } else if (wireType === 0) { + readVarint(bytes, pos); + } else if (wireType === 1) { + pos.i += 8; + } else if (wireType === 5) { + pos.i += 4; + } else { + throw new Error(`unsupported wire type ${wireType}`); + } + } + return { metadata, data }; +} + +/** Convenience: encode a Payload to the base64 wire format used by stream. */ +export function encodePayloadWire(payload: Payload): string { + return encodeBase64(encodePayloadProto(payload)); +} + +/** Convenience: decode the base64 wire format to a Payload. */ +export function decodePayloadWire(wire: string): Payload { + return decodePayloadProto(decodeBase64(wire)); +} diff --git a/contrib/workflow-streams/src/index.ts b/contrib/workflow-streams/src/index.ts index a24d22c24..18cce99e9 100644 --- a/contrib/workflow-streams/src/index.ts +++ b/contrib/workflow-streams/src/index.ts @@ -29,7 +29,7 @@ export { decodePayloadProto, encodePayloadWire, decodePayloadWire, -} from './types'; +} from './codec'; export { WorkflowStream, workflowStreamPublishSignal, diff --git a/contrib/workflow-streams/src/stream.ts b/contrib/workflow-streams/src/stream.ts index edb89893c..1def09f2c 100644 --- a/contrib/workflow-streams/src/stream.ts +++ b/contrib/workflow-streams/src/stream.ts @@ -31,16 +31,13 @@ import { import { ApplicationFailure, type Payload, type Workflow } from '@temporalio/common'; import type { Duration } from '@temporalio/common/lib/time'; import { msToNumber } from '@temporalio/common/lib/time'; -import { - decodePayloadWire, - encodePayloadProto, - encodePayloadWire, - encodeBase64, - type PollInput, - type PollResult, - type WorkflowStreamState, - type PublishInput, - type _WorkflowStreamWireItem, +import { decodePayloadWire, encodePayloadProto, encodePayloadWire, encodeBase64 } from './codec'; +import type { + PollInput, + PollResult, + WorkflowStreamState, + PublishInput, + WorkflowStreamWireItem, } from './types'; import { WorkflowTopicHandle } from './topic-handle'; @@ -327,7 +324,7 @@ export class WorkflowStream { } // Cap response size to ~1MB of estimated wire bytes. - const wireItems: _WorkflowStreamWireItem[] = []; + const wireItems: WorkflowStreamWireItem[] = []; let size = 0; let moreReady = false; let nextOffset = this.baseOffset + this.log.length; diff --git a/contrib/workflow-streams/src/types.ts b/contrib/workflow-streams/src/types.ts index 3ec11cada..d6e5fdf13 100644 --- a/contrib/workflow-streams/src/types.ts +++ b/contrib/workflow-streams/src/types.ts @@ -5,17 +5,13 @@ * {@link Payload}s so that per-item metadata (encoding, messageType) * round-trips to consumers. See README §"Cross-Language Protocol". * - * The wire representation (`PublishEntry`, `_WorkflowStreamWireItem`) uses + * The wire representation (`PublishEntry`, `WorkflowStreamWireItem`) uses * base64-encoded `Payload` protobuf bytes because the default JSON * converter cannot serialize a `Payload` object embedded inside a * plain (non-top-level) field. Using a base64 proto bytes string * keeps the envelope JSON-serializable while preserving Payload - * metadata for codec and typed-decode paths. - * - * The Payload encoding here is the protobuf binary encoding of the - * `temporal.api.common.v1.Payload` message — a map - * metadata field (tag 1) and a bytes data field (tag 2). Cross-SDK - * compatibility requires matching exactly. + * metadata for codec and typed-decode paths. See `./codec` for the + * encoder/decoder. */ import type { Payload } from '@temporalio/common'; @@ -53,7 +49,7 @@ export interface PublishEntry { * position in the global log. It is unused in the `getState()` snapshot * (offsets there are re-derivable from `base_offset + index`). */ -export interface _WorkflowStreamWireItem { +export interface WorkflowStreamWireItem { topic: string; /** Base64-encoded Payload protobuf bytes. */ data: string; @@ -81,203 +77,16 @@ export interface PollInput { * a cooldown delay. */ export interface PollResult { - items: _WorkflowStreamWireItem[]; + items: WorkflowStreamWireItem[]; next_offset: number; more_ready: boolean; } /** Serializable snapshot of workflow stream state for continue-as-new. */ export interface WorkflowStreamState { - log: _WorkflowStreamWireItem[]; + log: WorkflowStreamWireItem[]; base_offset: number; publisher_sequences: Record; /** Per-publisher last-seen timestamps (seconds) for TTL pruning. */ publisher_last_seen: Record; } - -// --------------------------------------------------------------------------- -// Base64 helpers (no Buffer dependency for workflow sandbox compat) -// --------------------------------------------------------------------------- - -const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; - -/** Encode bytes to standard base64 (padded). */ -export function encodeBase64(data: Uint8Array): string { - let result = ''; - for (let i = 0; i < data.length; i += 3) { - const b0 = data[i]!; - const b1 = i + 1 < data.length ? data[i + 1]! : 0; - const b2 = i + 2 < data.length ? data[i + 2]! : 0; - result += B64[(b0 >> 2) & 0x3f]; - result += B64[((b0 << 4) | (b1 >> 4)) & 0x3f]; - result += i + 1 < data.length ? B64[((b1 << 2) | (b2 >> 6)) & 0x3f] : '='; - result += i + 2 < data.length ? B64[b2 & 0x3f] : '='; - } - return result; -} - -/** Decode standard base64 to bytes. Throws TypeError on malformed input. */ -export function decodeBase64(data: string): Uint8Array { - const clean = data.replace(/=+$/, ''); - if (data.length - clean.length > 2) { - throw new TypeError(`Invalid base64 input: too many '=' padding characters`); - } - if (clean.length % 4 === 1) { - throw new TypeError(`Invalid base64 input: length ${data.length} is not valid`); - } - const len = (clean.length * 3) >> 2; - const out = new Uint8Array(len); - let j = 0; - for (let i = 0; i < clean.length; i += 4) { - const a = B64.indexOf(clean.charAt(i)); - const b = i + 1 < clean.length ? B64.indexOf(clean.charAt(i + 1)) : 0; - const c = i + 2 < clean.length ? B64.indexOf(clean.charAt(i + 2)) : 0; - const d = i + 3 < clean.length ? B64.indexOf(clean.charAt(i + 3)) : 0; - if (a < 0 || b < 0 || c < 0 || d < 0) { - throw new TypeError(`Invalid base64 input: non-alphabet character at offset ${i}`); - } - out[j++] = (a << 2) | (b >> 4); - if (j < len) out[j++] = ((b << 4) | (c >> 2)) & 0xff; - if (j < len) out[j++] = ((c << 6) | d) & 0xff; - } - return out; -} - -// --------------------------------------------------------------------------- -// Protobuf codec for temporal.api.common.v1.Payload -// --------------------------------------------------------------------------- -// -// Payload schema: -// message Payload { -// map metadata = 1; -// bytes data = 2; -// } -// -// Map entries are encoded as embedded messages: -// message MapEntry { string key = 1; bytes value = 2; } -// -// Wire format is hand-rolled here (rather than reusing `@temporalio/proto`'s -// generated class) to avoid pulling the protobufjs runtime into the workflow -// sandbox. The schema is a fixed public API — the manual encoder cannot -// silently go out of sync with server-side expectations. - -function writeVarint(buf: number[], n: number): void { - while (n >= 0x80) { - buf.push((n & 0x7f) | 0x80); - n = Math.floor(n / 128); - } - buf.push(n & 0x7f); -} - -function readVarint(bytes: Uint8Array, pos: { i: number }): number { - let result = 0; - let shift = 0; - while (true) { - if (pos.i >= bytes.length) { - throw new Error('unexpected end of varint'); - } - const b = bytes[pos.i++]!; - result += (b & 0x7f) * Math.pow(2, shift); - if ((b & 0x80) === 0) break; - shift += 7; - if (shift > 35) throw new Error('varint too large'); - } - return result; -} - -function writeTagLenBytes(buf: number[], tag: number, bytes: Uint8Array): void { - buf.push(tag); - writeVarint(buf, bytes.length); - for (let i = 0; i < bytes.length; i++) buf.push(bytes[i]!); -} - -const textEncoder = new TextEncoder(); -const textDecoder = new TextDecoder(); - -function utf8Encode(s: string): Uint8Array { - return textEncoder.encode(s); -} - -function utf8Decode(b: Uint8Array): string { - return textDecoder.decode(b); -} - -/** Encode a Payload to its protobuf binary representation. */ -export function encodePayloadProto(payload: Payload): Uint8Array { - const buf: number[] = []; - const metadata = payload.metadata ?? {}; - for (const key of Object.keys(metadata)) { - const value = metadata[key]; - if (value == null) continue; - // Inner: key (tag 0x0A, wire-type 2) + value (tag 0x12, wire-type 2) - const entry: number[] = []; - writeTagLenBytes(entry, 0x0a, utf8Encode(key)); - writeTagLenBytes(entry, 0x12, value); - // Outer: metadata field 1, wire-type 2 - writeTagLenBytes(buf, 0x0a, new Uint8Array(entry)); - } - const data = payload.data; - if (data && data.length > 0) { - writeTagLenBytes(buf, 0x12, data); - } - return new Uint8Array(buf); -} - -/** Decode protobuf binary bytes to a Payload. */ -export function decodePayloadProto(bytes: Uint8Array): Payload { - const pos = { i: 0 }; - const metadata: Record = {}; - let data: Uint8Array = new Uint8Array(0); - while (pos.i < bytes.length) { - const tag = bytes[pos.i++]!; - const fieldNumber = tag >>> 3; - const wireType = tag & 0x07; - if (wireType === 2) { - const len = readVarint(bytes, pos); - const chunk = bytes.subarray(pos.i, pos.i + len); - pos.i += len; - if (fieldNumber === 1) { - // Parse inner map entry message - const p2 = { i: 0 }; - let key = ''; - let value = new Uint8Array(0); - while (p2.i < chunk.length) { - const itag = chunk[p2.i++]!; - const ifn = itag >>> 3; - const iwt = itag & 0x07; - if (iwt !== 2) { - throw new Error(`unsupported wire type ${iwt} in Payload metadata entry`); - } - const ilen = readVarint(chunk, p2); - const ival = chunk.subarray(p2.i, p2.i + ilen); - p2.i += ilen; - if (ifn === 1) key = utf8Decode(ival); - else if (ifn === 2) value = new Uint8Array(ival); - } - metadata[key] = value; - } else if (fieldNumber === 2) { - data = new Uint8Array(chunk); - } - // Other fields (e.g. externalPayloads = 3) are ignored. - } else if (wireType === 0) { - readVarint(bytes, pos); - } else if (wireType === 1) { - pos.i += 8; - } else if (wireType === 5) { - pos.i += 4; - } else { - throw new Error(`unsupported wire type ${wireType}`); - } - } - return { metadata, data }; -} - -/** Convenience: encode a Payload to the base64 wire format used by stream. */ -export function encodePayloadWire(payload: Payload): string { - return encodeBase64(encodePayloadProto(payload)); -} - -/** Convenience: decode the base64 wire format to a Payload. */ -export function decodePayloadWire(wire: string): Payload { - return decodePayloadProto(decodeBase64(wire)); -} diff --git a/contrib/workflow-streams/tsconfig.json b/contrib/workflow-streams/tsconfig.json index d310980c8..876a844c6 100644 --- a/contrib/workflow-streams/tsconfig.json +++ b/contrib/workflow-streams/tsconfig.json @@ -10,6 +10,9 @@ { "path": "../../packages/client" }, { "path": "../../packages/common" }, { "path": "../../packages/proto" }, + { "path": "../../packages/test-helpers" }, + { "path": "../../packages/testing" }, + { "path": "../../packages/worker" }, { "path": "../../packages/workflow" } ], "include": ["./src/**/*.ts"] diff --git a/packages/core-bridge/Cargo.lock b/packages/core-bridge/Cargo.lock index 27492ca9f..9a27f9009 100644 --- a/packages/core-bridge/Cargo.lock +++ b/packages/core-bridge/Cargo.lock @@ -214,6 +214,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.4" @@ -408,7 +414,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -502,7 +508,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -691,7 +697,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ "rustix", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -1078,32 +1084,27 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jni" -version = "0.22.4" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" dependencies = [ + "cesu8", "cfg-if", "combine", - "jni-macros", - "jni-sys", + "jni-sys 0.3.1", "log", - "simd_cesu8", - "thiserror", + "thiserror 1.0.69", "walkdir", - "windows-link 0.2.1", + "windows-sys 0.45.0", ] [[package]] -name = "jni-macros" -version = "0.22.4" +name = "jni-sys" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "simd_cesu8", - "syn", + "jni-sys 0.4.1", ] [[package]] @@ -1137,12 +1138,10 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.94" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ - "cfg-if", - "futures-util", "once_cell", "wasm-bindgen", ] @@ -1178,18 +1177,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-link 0.2.1", + "windows-link", ] [[package]] name = "libredox" -version = "0.1.9" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ "bitflags", "libc", - "redox_syscall", + "redox_syscall 0.7.1", ] [[package]] @@ -1364,9 +1363,9 @@ dependencies = [ [[package]] name = "ntapi" -version = "0.4.1" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" dependencies = [ "winapi", ] @@ -1377,7 +1376,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1430,7 +1429,7 @@ dependencies = [ "futures-sink", "js-sys", "pin-project-lite", - "thiserror", + "thiserror 2.0.18", "tracing", ] @@ -1460,7 +1459,7 @@ dependencies = [ "opentelemetry_sdk", "prost", "reqwest 0.12.28", - "thiserror", + "thiserror 2.0.18", "tokio", "tonic", ] @@ -1490,7 +1489,7 @@ dependencies = [ "opentelemetry", "percent-encoding", "rand 0.9.2", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-stream", ] @@ -1508,7 +1507,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1529,9 +1528,9 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -1709,7 +1708,7 @@ dependencies = [ "lazy_static", "memchr", "parking_lot", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1845,7 +1844,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -1867,7 +1866,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -1980,9 +1979,18 @@ checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "redox_syscall" -version = "0.5.17" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" +checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" dependencies = [ "bitflags", ] @@ -1995,7 +2003,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -2069,9 +2077,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.3" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64", "bytes", @@ -2158,7 +2166,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2201,9 +2209,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.7.0" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ "core-foundation", "core-foundation-sys", @@ -2217,7 +2225,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2261,11 +2269,11 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2404,22 +2412,6 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" -[[package]] -name = "simd_cesu8" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" -dependencies = [ - "rustc_version", - "simdutf8", -] - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - [[package]] name = "siphasher" version = "1.0.2" @@ -2541,7 +2533,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2563,7 +2555,7 @@ dependencies = [ "temporalio-client", "temporalio-common", "temporalio-sdk-core", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-stream", "tonic", @@ -2592,7 +2584,7 @@ dependencies = [ "parking_lot", "rand 0.10.1", "temporalio-common", - "thiserror", + "thiserror 2.0.18", "tokio", "tonic", "tower", @@ -2633,7 +2625,7 @@ dependencies = [ "ringbuf", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", "tokio", "toml", "tonic", @@ -2681,7 +2673,7 @@ dependencies = [ "prost", "prost-wkt-types", "rand 0.10.1", - "reqwest 0.13.3", + "reqwest 0.13.2", "serde", "serde_json", "siphasher", @@ -2691,7 +2683,7 @@ dependencies = [ "temporalio-client", "temporalio-common", "temporalio-macros", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-stream", "tokio-util", @@ -2708,13 +2700,33 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -3187,9 +3199,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.117" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ "cfg-if", "once_cell", @@ -3200,19 +3212,23 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.67" +version = "0.4.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" dependencies = [ + "cfg-if", + "futures-util", "js-sys", + "once_cell", "wasm-bindgen", + "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.117" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3220,9 +3236,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.117" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ "bumpalo", "proc-macro2", @@ -3233,9 +3249,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.117" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] @@ -3289,9 +3305,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.94" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" dependencies = [ "js-sys", "wasm-bindgen", @@ -3309,9 +3325,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" dependencies = [ "rustls-pki-types", ] @@ -3338,7 +3354,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3376,7 +3392,7 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link 0.2.1", + "windows-link", "windows-result", "windows-strings", ] @@ -3388,7 +3404,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ "windows-core", - "windows-link 0.2.1", + "windows-link", "windows-threading", ] @@ -3414,12 +3430,6 @@ dependencies = [ "syn", ] -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - [[package]] name = "windows-link" version = "0.2.1" @@ -3433,7 +3443,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ "windows-core", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -3442,7 +3452,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -3451,23 +3461,23 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] name = "windows-sys" -version = "0.52.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" dependencies = [ - "windows-targets 0.52.6", + "windows-targets 0.42.2", ] [[package]] name = "windows-sys" -version = "0.59.0" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ "windows-targets 0.52.6", ] @@ -3478,7 +3488,7 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.3", + "windows-targets 0.53.5", ] [[package]] @@ -3487,7 +3497,22 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.1", + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", ] [[package]] @@ -3508,19 +3533,19 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.3" +version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link 0.1.3", - "windows_aarch64_gnullvm 0.53.0", - "windows_aarch64_msvc 0.53.0", - "windows_i686_gnu 0.53.0", - "windows_i686_gnullvm 0.53.0", - "windows_i686_msvc 0.53.0", - "windows_x86_64_gnu 0.53.0", - "windows_x86_64_gnullvm 0.53.0", - "windows_x86_64_msvc 0.53.0", + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -3529,9 +3554,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -3540,9 +3571,15 @@ checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" [[package]] name = "windows_aarch64_msvc" @@ -3552,9 +3589,15 @@ checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_aarch64_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" [[package]] name = "windows_i686_gnu" @@ -3564,9 +3607,9 @@ checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnu" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] name = "windows_i686_gnullvm" @@ -3576,9 +3619,15 @@ checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" [[package]] name = "windows_i686_msvc" @@ -3588,9 +3637,15 @@ checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_i686_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" [[package]] name = "windows_x86_64_gnu" @@ -3600,9 +3655,15 @@ checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnu" -version = "0.53.0" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" [[package]] name = "windows_x86_64_gnullvm" @@ -3612,9 +3673,15 @@ checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_gnullvm" -version = "0.53.0" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" [[package]] name = "windows_x86_64_msvc" @@ -3624,9 +3691,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "windows_x86_64_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" @@ -3843,9 +3910,9 @@ dependencies = [ [[package]] name = "zip" -version = "8.6.0" +version = "8.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +checksum = "dcab981e19633ebcf0b001ddd37dd802996098bc1864f90b7c5d970ce76c1d59" dependencies = [ "bzip2", "crc32fast", diff --git a/packages/test/package.json b/packages/test/package.json index 864e3b636..f1d1304ce 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -49,7 +49,6 @@ "@temporalio/testing": "workspace:*", "@temporalio/worker": "workspace:*", "@temporalio/workflow": "workspace:*", - "@temporalio/workflow-streams": "workspace:*", "arg": "^5.0.2", "async-retry": "^1.3.3", "ava": "^5.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1f95fa5c..696312f52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -312,6 +312,19 @@ importers: '@temporalio/workflow': specifier: workspace:* version: link:../../packages/workflow + devDependencies: + '@temporalio/test-helpers': + specifier: workspace:* + version: link:../../packages/test-helpers + '@temporalio/testing': + specifier: workspace:* + version: link:../../packages/testing + '@temporalio/worker': + specifier: workspace:* + version: link:../../packages/worker + ava: + specifier: ^5.3.1 + version: 5.3.1 packages/activity: dependencies: @@ -742,9 +755,6 @@ importers: '@temporalio/workflow': specifier: workspace:* version: link:../workflow - '@temporalio/workflow-streams': - specifier: workspace:* - version: link:../../contrib/workflow-streams arg: specifier: ^5.0.2 version: 5.0.2 From 48cc1061702d740b576875867ee1bf37e6fc7f0c Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Fri, 15 May 2026 11:59:31 -0700 Subject: [PATCH 65/75] fix lint Co-Authored-By: Claude Opus 4.7 (1M context) --- .../workflow-streams/src/__tests__/helpers-integration.ts | 2 +- contrib/workflow-streams/src/stream.ts | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/contrib/workflow-streams/src/__tests__/helpers-integration.ts b/contrib/workflow-streams/src/__tests__/helpers-integration.ts index 28d923822..e2fa2928c 100644 --- a/contrib/workflow-streams/src/__tests__/helpers-integration.ts +++ b/contrib/workflow-streams/src/__tests__/helpers-integration.ts @@ -18,7 +18,7 @@ import { type TestWorkflowEnvironment, } from '@temporalio/test-helpers'; -export interface Context extends BaseContext {} +export type Context = BaseContext; export interface TestFunctionOptions { workflowsPath: string; diff --git a/contrib/workflow-streams/src/stream.ts b/contrib/workflow-streams/src/stream.ts index 1def09f2c..a6d312010 100644 --- a/contrib/workflow-streams/src/stream.ts +++ b/contrib/workflow-streams/src/stream.ts @@ -32,13 +32,7 @@ import { ApplicationFailure, type Payload, type Workflow } from '@temporalio/com import type { Duration } from '@temporalio/common/lib/time'; import { msToNumber } from '@temporalio/common/lib/time'; import { decodePayloadWire, encodePayloadProto, encodePayloadWire, encodeBase64 } from './codec'; -import type { - PollInput, - PollResult, - WorkflowStreamState, - PublishInput, - WorkflowStreamWireItem, -} from './types'; +import type { PollInput, PollResult, WorkflowStreamState, PublishInput, WorkflowStreamWireItem } from './types'; import { WorkflowTopicHandle } from './topic-handle'; const BINARY_PLAIN_ENCODING = new TextEncoder().encode('binary/plain'); From 0be9f4e787648056ad99a0b3a324b4ac41ae2929 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Mon, 18 May 2026 10:21:04 -0700 Subject: [PATCH 66/75] pair INTERNAL_HANDLER_NAME_ALLOWLIST names with entity type Each reserved-prefix wire name is now allowlisted only as the specific entity type it's intended for (signal/update/query). Registering the same name as a different entity type is still rejected. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/common/src/reserved.ts | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/common/src/reserved.ts b/packages/common/src/reserved.ts index dede88967..6d87efaf0 100644 --- a/packages/common/src/reserved.ts +++ b/packages/common/src/reserved.ts @@ -3,20 +3,22 @@ export const STACK_TRACE_QUERY_NAME = '__stack_trace'; export const ENHANCED_STACK_TRACE_QUERY_NAME = '__enhanced_stack_trace'; /** - * Wire identifiers used by first-party SDK contrib packages. These - * bypass the {@link TEMPORAL_RESERVED_PREFIX} check at registration time. + * Valid entity types that can be checked for reserved name violations */ -const INTERNAL_HANDLER_NAME_ALLOWLIST: ReadonlySet = new Set([ - // @temporalio/workflow-streams - '__temporal_workflow_stream_publish', - '__temporal_workflow_stream_poll', - '__temporal_workflow_stream_offset', -]); +export type ReservedNameEntityType = 'query' | 'signal' | 'update' | 'activity' | 'task queue' | 'sink' | 'workflow'; /** - * Valid entity types that can be checked for reserved name violations + * Wire identifiers used by first-party SDK contrib packages. Each entry pairs + * a name with the entity type it's allowed to register as; that pair bypasses + * the {@link TEMPORAL_RESERVED_PREFIX} check at registration time. Registering + * the same name as a different entity type is still rejected. */ -export type ReservedNameEntityType = 'query' | 'signal' | 'update' | 'activity' | 'task queue' | 'sink' | 'workflow'; +const INTERNAL_HANDLER_NAME_ALLOWLIST: ReadonlyMap = new Map([ + // @temporalio/workflow-streams + ['__temporal_workflow_stream_publish', 'signal'], + ['__temporal_workflow_stream_poll', 'update'], + ['__temporal_workflow_stream_offset', 'query'], +]); /** * Validates if the provided name contains any reserved prefixes or matches any reserved names. @@ -27,7 +29,7 @@ export type ReservedNameEntityType = 'query' | 'signal' | 'update' | 'activity' * @param name The name to check against reserved prefixes/names */ export function throwIfReservedName(type: ReservedNameEntityType, name: string): void { - if (name.startsWith(TEMPORAL_RESERVED_PREFIX) && !INTERNAL_HANDLER_NAME_ALLOWLIST.has(name)) { + if (name.startsWith(TEMPORAL_RESERVED_PREFIX) && INTERNAL_HANDLER_NAME_ALLOWLIST.get(name) !== type) { throw new TypeError(`Cannot use ${type} name: '${name}', with reserved prefix: '${TEMPORAL_RESERVED_PREFIX}'`); } From 9c74b98af06bc129e541cbf1252eec3be88c801c Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Mon, 18 May 2026 11:25:38 -0700 Subject: [PATCH 67/75] key text and reasoning accumulators by stream part id Stream parts carry an id and may be interleaved across concurrent blocks, so a single accumulator could clobber data. Track each open block separately in a Map keyed by id. Co-Authored-By: Claude Opus 4.7 (1M context) --- contrib/ai-sdk/src/activities.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/contrib/ai-sdk/src/activities.ts b/contrib/ai-sdk/src/activities.ts index 6b612305d..862848a2b 100644 --- a/contrib/ai-sdk/src/activities.ts +++ b/contrib/ai-sdk/src/activities.ts @@ -122,8 +122,8 @@ export function createActivities(provider: ProviderV3, mcpClientFactories?: McpC const warnings: SharedV3Warning[] = []; let responseMetadata: Record | undefined; - let currentText = ''; - let currentReasoning = ''; + const textBlocks = new Map(); + const reasoningBlocks = new Map(); const reader = streamResult.stream.getReader(); @@ -143,30 +143,32 @@ export function createActivities(provider: ProviderV3, mcpClientFactories?: McpC warnings.push(...part.warnings); break; case 'text-start': - currentText = ''; + textBlocks.set(part.id, ''); break; case 'text-delta': - currentText += part.delta; + textBlocks.set(part.id, (textBlocks.get(part.id) ?? '') + part.delta); break; case 'text-end': content.push({ type: 'text', - text: currentText, + text: textBlocks.get(part.id) ?? '', providerMetadata: part.providerMetadata, }); + textBlocks.delete(part.id); break; case 'reasoning-start': - currentReasoning = ''; + reasoningBlocks.set(part.id, ''); break; case 'reasoning-delta': - currentReasoning += part.delta; + reasoningBlocks.set(part.id, (reasoningBlocks.get(part.id) ?? '') + part.delta); break; case 'reasoning-end': content.push({ type: 'reasoning', - text: currentReasoning, + text: reasoningBlocks.get(part.id) ?? '', providerMetadata: part.providerMetadata, }); + reasoningBlocks.delete(part.id); break; case 'response-metadata': responseMetadata = { From 23decc262a264de3ba8e863644ed57fa563df538 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Wed, 20 May 2026 15:28:45 -0700 Subject: [PATCH 68/75] revert web-streams sandbox injection, restore polyfill Co-Authored-By: Claude Opus 4.7 (1M context) --- contrib/ai-sdk/package.json | 3 ++- contrib/ai-sdk/src/load-polyfills.ts | 2 ++ packages/worker/src/workflow/bundler.ts | 5 ----- packages/worker/src/workflow/vm-shared.ts | 12 ------------ pnpm-lock.yaml | 13 ++++++++++--- 5 files changed, 14 insertions(+), 21 deletions(-) diff --git a/contrib/ai-sdk/package.json b/contrib/ai-sdk/package.json index 5dc2470d6..bae2a1c71 100644 --- a/contrib/ai-sdk/package.json +++ b/contrib/ai-sdk/package.json @@ -31,7 +31,8 @@ "@temporalio/workflow": "workspace:*", "@temporalio/workflow-streams": "workspace:*", "@ungap/structured-clone": "^1.3.0", - "headers-polyfill": "^4.0.3" + "headers-polyfill": "^4.0.3", + "web-streams-polyfill": "^4.2.0" }, "peerDependencies": { "@ai-sdk/mcp": "^1.0.0", diff --git a/contrib/ai-sdk/src/load-polyfills.ts b/contrib/ai-sdk/src/load-polyfills.ts index d8e6ee643..98a1f1f6c 100644 --- a/contrib/ai-sdk/src/load-polyfills.ts +++ b/contrib/ai-sdk/src/load-polyfills.ts @@ -7,6 +7,8 @@ if (inWorkflowContext()) { globalThis.Headers = Headers; } + // eslint-disable-next-line @typescript-eslint/no-require-imports,import/no-unassigned-import + require('web-streams-polyfill/polyfill'); // Attach the polyfill as a Global function if (!('structuredClone' in globalThis)) { // eslint-disable-next-line @typescript-eslint/no-require-imports diff --git a/packages/worker/src/workflow/bundler.ts b/packages/worker/src/workflow/bundler.ts index cf4a8f1e0..85d1f2444 100644 --- a/packages/worker/src/workflow/bundler.ts +++ b/packages/worker/src/workflow/bundler.ts @@ -247,11 +247,6 @@ exports.importInterceptors = function importInterceptors() { /[\\/](?:@temporalio|contrib)[\\/]interceptors-opentelemetry[\\/](?:src|lib)[\\/]workflow[\\/]workflow-imports\.[jt]s$/, './workflow-imports-impl.js' ), - // Strip the `node:` URI scheme so imports like `node:stream/web` route - // through the same alias map as the bare form. - new NormalModuleReplacementPlugin(/^node:/, (resource) => { - resource.request = resource.request.replace(/^node:/, ''); - }), ], externals: captureProblematicModules, module: { diff --git a/packages/worker/src/workflow/vm-shared.ts b/packages/worker/src/workflow/vm-shared.ts index 2b5fb381c..1c280b645 100644 --- a/packages/worker/src/workflow/vm-shared.ts +++ b/packages/worker/src/workflow/vm-shared.ts @@ -3,13 +3,6 @@ import type vm from 'node:vm'; import { AsyncLocalStorage as AsyncLocalStorageOriginal } from 'node:async_hooks'; import assert from 'node:assert'; import { URL, URLSearchParams } from 'node:url'; -import { - ByteLengthQueuingStrategy, - CountQueuingStrategy, - ReadableStream, - TransformStream, - WritableStream, -} from 'node:stream/web'; import { TextDecoder, TextEncoder } from 'node:util'; import { SourceMapConsumer } from 'source-map'; import { cutoffStackTrace, IllegalStateError, convertDeploymentVersion } from '@temporalio/common'; @@ -109,11 +102,6 @@ export function injectGlobals(context: vm.Context): void { TextEncoder, TextDecoder, AbortController, - ReadableStream, - WritableStream, - TransformStream, - ByteLengthQueuingStrategy, - CountQueuingStrategy, }; for (const [k, v] of Object.entries(globals)) { Object.defineProperty(sandboxGlobalThis, k, { value: v, writable: false, enumerable: true, configurable: false }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 696312f52..b7b550245 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,9 +10,7 @@ overrides: handlebars: '>=4.7.9' patchedDependencies: - protobufjs@7.5.5: - hash: 0ede6d81e4e283d0a44203616b8935211e15e8326841cd4cd7427963385ea3c1 - path: patches/protobufjs@7.5.5.patch + protobufjs@7.5.5: 0ede6d81e4e283d0a44203616b8935211e15e8326841cd4cd7427963385ea3c1 importers: @@ -178,6 +176,9 @@ importers: headers-polyfill: specifier: ^4.0.3 version: 4.0.3 + web-streams-polyfill: + specifier: ^4.2.0 + version: 4.2.0 devDependencies: '@ai-sdk/mcp': specifier: ^1.0.0 @@ -5101,6 +5102,10 @@ packages: resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} engines: {node: '>=10.13.0'} + web-streams-polyfill@4.2.0: + resolution: {integrity: sha512-0rYDzGOh9EZpig92umN5g5D/9A1Kff7k0/mzPSSCY8jEQeYkgRMoY7LhbXtUCWzLCMX0TUE9aoHkjFNB7D9pfA==} + engines: {node: '>= 8'} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -9849,6 +9854,8 @@ snapshots: glob-to-regexp: 0.4.1 graceful-fs: 4.2.11 + web-streams-polyfill@4.2.0: {} + webidl-conversions@3.0.1: {} webpack-sources@3.3.3: {} From e40b6639aa72c9009cabc12a27d430c78a736a57 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Wed, 20 May 2026 15:35:34 -0700 Subject: [PATCH 69/75] restore pnpm 10 patchedDependencies lockfile format Co-Authored-By: Claude Opus 4.7 (1M context) --- pnpm-lock.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7b550245..084c6cf92 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,9 @@ overrides: handlebars: '>=4.7.9' patchedDependencies: - protobufjs@7.5.5: 0ede6d81e4e283d0a44203616b8935211e15e8326841cd4cd7427963385ea3c1 + protobufjs@7.5.5: + hash: 0ede6d81e4e283d0a44203616b8935211e15e8326841cd4cd7427963385ea3c1 + path: patches/protobufjs@7.5.5.patch importers: From ce2a13369e063d3c5c1d4eacca0a03f499580fd5 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Fri, 22 May 2026 09:49:48 -0700 Subject: [PATCH 70/75] split workflow-streams into workflow and client entrypoints The package's single root entrypoint pulled crypto, @temporalio/activity, and @temporalio/client into anything that touched WorkflowStream, breaking bundleWorkflowCode for workflow files (the existing tests hid it via the test-helper's bundlerOptions.ignoreModules allowlist). Move the workflow-side class and protocol constants into src/workflow.ts, expose the client surface through src/client.ts, drop the root entrypoint so bare imports fail at resolution time, and add a regression test that runs the real bundler without the allowlist. Co-Authored-By: Claude Opus 4.7 (1M context) --- contrib/ai-sdk/src/activities.ts | 2 +- contrib/workflow-streams/README.md | 8 ++-- contrib/workflow-streams/package.json | 2 - .../__tests__/activities/workflow-streams.ts | 2 +- .../test-workflow-streams-bundling.ts | 22 ++++++++++ .../test-workflow-streams-interop.ts | 2 +- .../src/__tests__/test-workflow-streams.ts | 6 ++- .../__tests__/workflows/workflow-streams.ts | 2 +- contrib/workflow-streams/src/client.ts | 9 ++++ contrib/workflow-streams/src/index.ts | 41 ------------------- contrib/workflow-streams/src/topic-handle.ts | 2 +- .../src/{stream.ts => workflow.ts} | 28 ++++++++++++- 12 files changed, 71 insertions(+), 55 deletions(-) create mode 100644 contrib/workflow-streams/src/__tests__/test-workflow-streams-bundling.ts delete mode 100644 contrib/workflow-streams/src/index.ts rename contrib/workflow-streams/src/{stream.ts => workflow.ts} (94%) diff --git a/contrib/ai-sdk/src/activities.ts b/contrib/ai-sdk/src/activities.ts index 862848a2b..33325a622 100644 --- a/contrib/ai-sdk/src/activities.ts +++ b/contrib/ai-sdk/src/activities.ts @@ -13,7 +13,7 @@ import type { import { asSchema, type Schema, type ToolExecutionOptions } from 'ai'; import { ApplicationFailure } from '@temporalio/common'; import { Context } from '@temporalio/activity'; -import { WorkflowStreamClient } from '@temporalio/workflow-streams'; +import { WorkflowStreamClient } from '@temporalio/workflow-streams/lib/client'; import type { Duration } from '@temporalio/common/lib/time'; import type { McpClientFactories, McpClientFactory } from './mcp'; diff --git a/contrib/workflow-streams/README.md b/contrib/workflow-streams/README.md index 9eb1ea038..4eecb4100 100644 --- a/contrib/workflow-streams/README.md +++ b/contrib/workflow-streams/README.md @@ -35,7 +35,7 @@ get a typed handle for each topic via `stream.topic(name)` and call `publish` on the handle: ```typescript -import { WorkflowStream } from '@temporalio/workflow-streams'; +import { WorkflowStream } from '@temporalio/workflow-streams/lib/workflow'; interface StatusEvent { state: 'started' | 'done'; @@ -70,7 +70,7 @@ the same way as on the workflow side: ```typescript import { Context } from '@temporalio/activity'; -import { WorkflowStreamClient } from '@temporalio/workflow-streams'; +import { WorkflowStreamClient } from '@temporalio/workflow-streams/lib/client'; export async function streamEvents(): Promise { await using client = WorkflowStreamClient.fromActivity({ batchInterval: '2 seconds' }); @@ -109,7 +109,7 @@ events.publish(phase2Data); Subscribe via the topic handle to get items decoded as `T`: ```typescript -import { WorkflowStreamClient } from '@temporalio/workflow-streams'; +import { WorkflowStreamClient } from '@temporalio/workflow-streams/lib/client'; const client = WorkflowStreamClient.create(temporalClient, workflowId); const events = client.topic('events'); @@ -140,7 +140,7 @@ boundaries: ```typescript import { continueAsNew, workflowInfo } from '@temporalio/workflow'; -import { WorkflowStream, type WorkflowStreamState } from '@temporalio/workflow-streams'; +import { WorkflowStream, type WorkflowStreamState } from '@temporalio/workflow-streams/lib/workflow'; interface WorkflowInput { itemsProcessed: number; diff --git a/contrib/workflow-streams/package.json b/contrib/workflow-streams/package.json index 8800a7da5..f3fef7fa6 100644 --- a/contrib/workflow-streams/package.json +++ b/contrib/workflow-streams/package.json @@ -2,8 +2,6 @@ "name": "@temporalio/workflow-streams", "version": "1.17.0", "description": "Temporal.io SDK Workflow Streams contrib module", - "main": "lib/index.js", - "types": "./lib/index.d.ts", "scripts": { "build": "tsc --build", "test": "ava ./lib/__tests__/test-*.js" diff --git a/contrib/workflow-streams/src/__tests__/activities/workflow-streams.ts b/contrib/workflow-streams/src/__tests__/activities/workflow-streams.ts index c5df1bbf7..1c326262e 100644 --- a/contrib/workflow-streams/src/__tests__/activities/workflow-streams.ts +++ b/contrib/workflow-streams/src/__tests__/activities/workflow-streams.ts @@ -6,7 +6,7 @@ */ import { Context } from '@temporalio/activity'; -import { WorkflowStreamClient } from '../..'; +import { WorkflowStreamClient } from '../../client'; const encoder = new TextEncoder(); diff --git a/contrib/workflow-streams/src/__tests__/test-workflow-streams-bundling.ts b/contrib/workflow-streams/src/__tests__/test-workflow-streams-bundling.ts new file mode 100644 index 000000000..530f1a844 --- /dev/null +++ b/contrib/workflow-streams/src/__tests__/test-workflow-streams-bundling.ts @@ -0,0 +1,22 @@ +/** + * Bundling test for `@temporalio/workflow-streams`. + * + * Runs the real `bundleWorkflowCode` walker (no `ignoreModules` allowlist) against + * a workflow file that imports from `@temporalio/workflow-streams/lib/workflow`. + * The workflow entrypoint must not transitively reach `crypto`, `@temporalio/activity`, + * or `@temporalio/client`, otherwise the workflow sandbox check fails. + */ + +import type { TestFn } from 'ava'; +import anyTest from 'ava'; +import { bundleWorkflowCode } from '@temporalio/worker'; + +const test = anyTest as TestFn; + +test('workflow streams workflow entrypoint can be bundled', async (t) => { + await t.notThrowsAsync( + bundleWorkflowCode({ + workflowsPath: require.resolve('./workflows/workflow-streams'), + }) + ); +}); diff --git a/contrib/workflow-streams/src/__tests__/test-workflow-streams-interop.ts b/contrib/workflow-streams/src/__tests__/test-workflow-streams-interop.ts index 265b0bd08..30accbed0 100644 --- a/contrib/workflow-streams/src/__tests__/test-workflow-streams-interop.ts +++ b/contrib/workflow-streams/src/__tests__/test-workflow-streams-interop.ts @@ -19,7 +19,7 @@ import { encodeBase64, encodePayloadProto, encodePayloadWire, -} from '..'; +} from '../workflow'; const test = anyTest as TestFn; const encoder = new TextEncoder(); diff --git a/contrib/workflow-streams/src/__tests__/test-workflow-streams.ts b/contrib/workflow-streams/src/__tests__/test-workflow-streams.ts index 476bb127c..d909e430b 100644 --- a/contrib/workflow-streams/src/__tests__/test-workflow-streams.ts +++ b/contrib/workflow-streams/src/__tests__/test-workflow-streams.ts @@ -14,14 +14,16 @@ import { type PollInput, type PollResult, type WorkflowStreamItem, - type WorkflowStreamState, type PublishEntry, type PublishInput, +} from '../client'; +import { + type WorkflowStreamState, encodePayloadWire, workflowStreamOffsetQuery, workflowStreamPublishSignal, workflowStreamPollUpdate, -} from '..'; +} from '../workflow'; import { helpers, makeTestFunction } from './helpers-integration'; import { activityPublishWorkflow, diff --git a/contrib/workflow-streams/src/__tests__/workflows/workflow-streams.ts b/contrib/workflow-streams/src/__tests__/workflows/workflow-streams.ts index 8a1b4c903..f507dbbc6 100644 --- a/contrib/workflow-streams/src/__tests__/workflows/workflow-streams.ts +++ b/contrib/workflow-streams/src/__tests__/workflows/workflow-streams.ts @@ -11,7 +11,7 @@ import { proxyActivities, setHandler, } from '@temporalio/workflow'; -import { WorkflowStream, type WorkflowStreamState } from '../..'; +import { WorkflowStream, type WorkflowStreamState } from '../../workflow'; import type * as activities from '../activities/workflow-streams'; const { publishItems, publishMultiTopic, publishWithForceFlush, publishBatchTest, publishWithMaxBatch } = diff --git a/contrib/workflow-streams/src/client.ts b/contrib/workflow-streams/src/client.ts index fedeab27d..76c72698d 100644 --- a/contrib/workflow-streams/src/client.ts +++ b/contrib/workflow-streams/src/client.ts @@ -551,3 +551,12 @@ export class WorkflowStreamClient { } } } + +export { TopicHandle } from './topic-handle'; +export type { + WorkflowStreamItem, + PublishEntry, + PublishInput, + PollInput, + PollResult, +} from './types'; diff --git a/contrib/workflow-streams/src/index.ts b/contrib/workflow-streams/src/index.ts deleted file mode 100644 index 18cce99e9..000000000 --- a/contrib/workflow-streams/src/index.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Workflow Streams for Temporal workflows. - * - * This module gives a workflow a durable, offset-addressed event channel built - * from Signals and polling Updates. The workflow holds an append-only log of - * `(topic, data)` entries. External clients (activities, starters, other - * services) publish and subscribe through the workflow handle. - * - * Payloads are Temporal `Payload`s carrying the encoding metadata needed for - * typed decode and cross-language interop. The codec chain (encryption, - * PII-redaction, compression) runs once on the signal/update envelope that - * carries each batch — not per item. - * - * @module - */ - -export type { - WorkflowStreamItem, - PublishEntry, - PublishInput, - PollInput, - PollResult, - WorkflowStreamState, -} from './types'; -export { - encodeBase64, - decodeBase64, - encodePayloadProto, - decodePayloadProto, - encodePayloadWire, - decodePayloadWire, -} from './codec'; -export { - WorkflowStream, - workflowStreamPublishSignal, - workflowStreamPollUpdate, - workflowStreamOffsetQuery, -} from './stream'; -export { WorkflowStreamClient, FlushTimeoutError } from './client'; -export type { WorkflowStreamClientOptions, SubscribeOptions } from './client'; -export { TopicHandle, WorkflowTopicHandle } from './topic-handle'; diff --git a/contrib/workflow-streams/src/topic-handle.ts b/contrib/workflow-streams/src/topic-handle.ts index c5c45481a..e0c2e6369 100644 --- a/contrib/workflow-streams/src/topic-handle.ts +++ b/contrib/workflow-streams/src/topic-handle.ts @@ -16,7 +16,7 @@ import type { Payload } from '@temporalio/common'; import type { SubscribeOptions, WorkflowStreamClient } from './client'; -import type { WorkflowStream } from './stream'; +import type { WorkflowStream } from './workflow'; import type { WorkflowStreamItem } from './types'; /** diff --git a/contrib/workflow-streams/src/stream.ts b/contrib/workflow-streams/src/workflow.ts similarity index 94% rename from contrib/workflow-streams/src/stream.ts rename to contrib/workflow-streams/src/workflow.ts index a6d312010..3ac056613 100644 --- a/contrib/workflow-streams/src/stream.ts +++ b/contrib/workflow-streams/src/workflow.ts @@ -1,5 +1,5 @@ /** - * Workflow-side stream object for Workflow Streams. + * Workflow-side entrypoint for `@temporalio/workflow-streams`. * * Instantiate `WorkflowStream` once at the start of your workflow function; the * constructor registers the workflow stream signal, update, and query handlers on @@ -16,6 +16,14 @@ * compression) is NOT applied per item on either side — it runs once at * the envelope level when Temporal's SDK encodes the signal/update that * carries the batch. + * + * This entrypoint exports only the workflow-safe surface so that it can be + * pulled into a workflow bundle. The client-side surface lives at + * `@temporalio/workflow-streams/lib/client` and pulls in `crypto`, + * `@temporalio/activity`, and `@temporalio/client` — none of which can be + * resolved in the workflow sandbox. + * + * @module */ import { @@ -35,6 +43,24 @@ import { decodePayloadWire, encodePayloadProto, encodePayloadWire, encodeBase64 import type { PollInput, PollResult, WorkflowStreamState, PublishInput, WorkflowStreamWireItem } from './types'; import { WorkflowTopicHandle } from './topic-handle'; +export type { + WorkflowStreamItem, + PublishEntry, + PublishInput, + PollInput, + PollResult, + WorkflowStreamState, +} from './types'; +export { + encodeBase64, + decodeBase64, + encodePayloadProto, + decodePayloadProto, + encodePayloadWire, + decodePayloadWire, +} from './codec'; +export { WorkflowTopicHandle } from './topic-handle'; + const BINARY_PLAIN_ENCODING = new TextEncoder().encode('binary/plain'); /** From 54ea111e0605c05277603d791e05577850b1519a Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Fri, 22 May 2026 10:06:52 -0700 Subject: [PATCH 71/75] fix lint Co-Authored-By: Claude Opus 4.7 (1M context) --- contrib/workflow-streams/src/client.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/contrib/workflow-streams/src/client.ts b/contrib/workflow-streams/src/client.ts index 76c72698d..fc77cbe26 100644 --- a/contrib/workflow-streams/src/client.ts +++ b/contrib/workflow-streams/src/client.ts @@ -553,10 +553,4 @@ export class WorkflowStreamClient { } export { TopicHandle } from './topic-handle'; -export type { - WorkflowStreamItem, - PublishEntry, - PublishInput, - PollInput, - PollResult, -} from './types'; +export type { WorkflowStreamItem, PublishEntry, PublishInput, PollInput, PollResult } from './types'; From b62f81643de7847ac9f41e0e6e1bea23c512d025 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Fri, 22 May 2026 13:10:39 -0700 Subject: [PATCH 72/75] expose workflow-streams subpaths without /lib/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `exports` and `typesVersions` to package.json so consumers import from `@temporalio/workflow-streams/workflow` and `@temporalio/workflow-streams/client` instead of reaching into the compiled `./lib/` directory. The package has no root entrypoint — bare `@temporalio/workflow-streams` resolves to `ERR_PACKAGE_PATH_NOT_EXPORTED`, forcing callers onto the workflow- or client-safe subpath. Co-Authored-By: Claude Opus 4.7 (1M context) --- contrib/ai-sdk/src/activities.ts | 2 +- contrib/workflow-streams/README.md | 8 ++++---- contrib/workflow-streams/package.json | 17 +++++++++++++++++ .../__tests__/test-workflow-streams-bundling.ts | 2 +- contrib/workflow-streams/src/workflow.ts | 2 +- 5 files changed, 24 insertions(+), 7 deletions(-) diff --git a/contrib/ai-sdk/src/activities.ts b/contrib/ai-sdk/src/activities.ts index 33325a622..c13646341 100644 --- a/contrib/ai-sdk/src/activities.ts +++ b/contrib/ai-sdk/src/activities.ts @@ -13,7 +13,7 @@ import type { import { asSchema, type Schema, type ToolExecutionOptions } from 'ai'; import { ApplicationFailure } from '@temporalio/common'; import { Context } from '@temporalio/activity'; -import { WorkflowStreamClient } from '@temporalio/workflow-streams/lib/client'; +import { WorkflowStreamClient } from '@temporalio/workflow-streams/client'; import type { Duration } from '@temporalio/common/lib/time'; import type { McpClientFactories, McpClientFactory } from './mcp'; diff --git a/contrib/workflow-streams/README.md b/contrib/workflow-streams/README.md index 4eecb4100..01acaf3c3 100644 --- a/contrib/workflow-streams/README.md +++ b/contrib/workflow-streams/README.md @@ -35,7 +35,7 @@ get a typed handle for each topic via `stream.topic(name)` and call `publish` on the handle: ```typescript -import { WorkflowStream } from '@temporalio/workflow-streams/lib/workflow'; +import { WorkflowStream } from '@temporalio/workflow-streams/workflow'; interface StatusEvent { state: 'started' | 'done'; @@ -70,7 +70,7 @@ the same way as on the workflow side: ```typescript import { Context } from '@temporalio/activity'; -import { WorkflowStreamClient } from '@temporalio/workflow-streams/lib/client'; +import { WorkflowStreamClient } from '@temporalio/workflow-streams/client'; export async function streamEvents(): Promise { await using client = WorkflowStreamClient.fromActivity({ batchInterval: '2 seconds' }); @@ -109,7 +109,7 @@ events.publish(phase2Data); Subscribe via the topic handle to get items decoded as `T`: ```typescript -import { WorkflowStreamClient } from '@temporalio/workflow-streams/lib/client'; +import { WorkflowStreamClient } from '@temporalio/workflow-streams/client'; const client = WorkflowStreamClient.create(temporalClient, workflowId); const events = client.topic('events'); @@ -140,7 +140,7 @@ boundaries: ```typescript import { continueAsNew, workflowInfo } from '@temporalio/workflow'; -import { WorkflowStream, type WorkflowStreamState } from '@temporalio/workflow-streams/lib/workflow'; +import { WorkflowStream, type WorkflowStreamState } from '@temporalio/workflow-streams/workflow'; interface WorkflowInput { itemsProcessed: number; diff --git a/contrib/workflow-streams/package.json b/contrib/workflow-streams/package.json index f3fef7fa6..1217be994 100644 --- a/contrib/workflow-streams/package.json +++ b/contrib/workflow-streams/package.json @@ -2,6 +2,23 @@ "name": "@temporalio/workflow-streams", "version": "1.17.0", "description": "Temporal.io SDK Workflow Streams contrib module", + "exports": { + "./workflow": { + "types": "./lib/workflow.d.ts", + "default": "./lib/workflow.js" + }, + "./client": { + "types": "./lib/client.d.ts", + "default": "./lib/client.js" + }, + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "workflow": ["./lib/workflow.d.ts"], + "client": ["./lib/client.d.ts"] + } + }, "scripts": { "build": "tsc --build", "test": "ava ./lib/__tests__/test-*.js" diff --git a/contrib/workflow-streams/src/__tests__/test-workflow-streams-bundling.ts b/contrib/workflow-streams/src/__tests__/test-workflow-streams-bundling.ts index 530f1a844..a86bea8dd 100644 --- a/contrib/workflow-streams/src/__tests__/test-workflow-streams-bundling.ts +++ b/contrib/workflow-streams/src/__tests__/test-workflow-streams-bundling.ts @@ -2,7 +2,7 @@ * Bundling test for `@temporalio/workflow-streams`. * * Runs the real `bundleWorkflowCode` walker (no `ignoreModules` allowlist) against - * a workflow file that imports from `@temporalio/workflow-streams/lib/workflow`. + * a workflow file that imports from `@temporalio/workflow-streams/workflow`. * The workflow entrypoint must not transitively reach `crypto`, `@temporalio/activity`, * or `@temporalio/client`, otherwise the workflow sandbox check fails. */ diff --git a/contrib/workflow-streams/src/workflow.ts b/contrib/workflow-streams/src/workflow.ts index 3ac056613..6b691b143 100644 --- a/contrib/workflow-streams/src/workflow.ts +++ b/contrib/workflow-streams/src/workflow.ts @@ -19,7 +19,7 @@ * * This entrypoint exports only the workflow-safe surface so that it can be * pulled into a workflow bundle. The client-side surface lives at - * `@temporalio/workflow-streams/lib/client` and pulls in `crypto`, + * `@temporalio/workflow-streams/client` and pulls in `crypto`, * `@temporalio/activity`, and `@temporalio/client` — none of which can be * resolved in the workflow sandbox. * From 79eabda3c97bdf6875bba87b2aee9b6533abc46d Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Fri, 22 May 2026 14:17:56 -0700 Subject: [PATCH 73/75] rename fromActivity to fromWithinActivity Co-Authored-By: Claude Opus 4.7 (1M context) --- contrib/ai-sdk/src/activities.ts | 2 +- contrib/workflow-streams/README.md | 59 ++----------------- .../__tests__/activities/workflow-streams.ts | 12 ++-- contrib/workflow-streams/src/client.ts | 6 +- contrib/workflow-streams/src/topic-handle.ts | 11 +--- 5 files changed, 16 insertions(+), 74 deletions(-) diff --git a/contrib/ai-sdk/src/activities.ts b/contrib/ai-sdk/src/activities.ts index c13646341..7c85e6556 100644 --- a/contrib/ai-sdk/src/activities.ts +++ b/contrib/ai-sdk/src/activities.ts @@ -105,7 +105,7 @@ export function createActivities(provider: ProviderV3, mcpClientFactories?: McpC * response-metadata, finish, ...); no normalization happens here. */ async invokeModelStreaming(args: InvokeModelStreamingArgs): Promise { - await using stream = WorkflowStreamClient.fromActivity({ + await using stream = WorkflowStreamClient.fromWithinActivity({ batchInterval: args.streamingBatchInterval ?? '100 milliseconds', }); const events = stream.topic(args.streamingTopic); diff --git a/contrib/workflow-streams/README.md b/contrib/workflow-streams/README.md index 01acaf3c3..9c2c07c52 100644 --- a/contrib/workflow-streams/README.md +++ b/contrib/workflow-streams/README.md @@ -63,7 +63,7 @@ publisher. ### Activity side (publishing) -Use `WorkflowStreamClient.fromActivity()` with `await using` for batched publishing +Use `WorkflowStreamClient.fromWithinActivity()` with `await using` for batched publishing from inside an activity. The client and workflow ID are pulled from the activity context. Bind a topic handle on the client and publish through it, the same way as on the workflow side: @@ -73,7 +73,7 @@ import { Context } from '@temporalio/activity'; import { WorkflowStreamClient } from '@temporalio/workflow-streams/client'; export async function streamEvents(): Promise { - await using client = WorkflowStreamClient.fromActivity({ batchInterval: '2 seconds' }); + await using client = WorkflowStreamClient.fromWithinActivity({ batchInterval: '2 seconds' }); const events = client.topic('events'); for await (const chunk of generateChunks()) { @@ -120,8 +120,7 @@ for await (const item of events.subscribe(0)) { } ``` -For raw `Payload` access — multi-topic subscriptions whose payload types -differ, or fine-grained control over decoding — call +For raw `Payload` access call `WorkflowStreamClient.subscribe(topics?, fromOffset?)` directly. The yielded items have `data: Payload` carrying encoding metadata; decode with `defaultPayloadConverter.fromPayload(item.data)` per-topic. @@ -139,7 +138,7 @@ Carry both your application state and workflow stream state across continue-as-n boundaries: ```typescript -import { continueAsNew, workflowInfo } from '@temporalio/workflow'; +import { workflowInfo } from '@temporalio/workflow'; import { WorkflowStream, type WorkflowStreamState } from '@temporalio/workflow-streams/workflow'; interface WorkflowInput { @@ -191,56 +190,6 @@ if (workflowInfo().continueAsNewSuggested) { } ``` -## API Reference - -### `new WorkflowStream(priorState?)` - -| Method | Description | -| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `topic(name)` | Get a typed `WorkflowTopicHandle` for publishing. Repeated calls with the same name return the same handle. | -| `getState(publisherTtl?)` | Snapshot for continue-as-new. Drops publisher dedup entries older than `publisherTtl` (`Duration`, default `'15 minutes'`). | -| `detachPollers()` | Unblock polls and reject new ones. | -| `continueAsNew(buildArgs, options?)` | Async. Detach pollers, wait for handlers, then `continueAsNew` with `buildArgs(state)`. Use the explicit recipe with `makeContinueAsNewFunc` to pass other CAN options. | -| `truncate(upToOffset)` | Discard log entries below the given offset. | - -Handlers registered automatically: - -| Kind | Name | Description | -| ------ | ------------------------------------ | ------------------------------ | -| Signal | `__temporal_workflow_stream_publish` | Receive external publications. | -| Update | `__temporal_workflow_stream_poll` | Long-poll subscription. | -| Query | `__temporal_workflow_stream_offset` | Current global offset. | - -### `WorkflowStreamClient` - -| Method | Description | -| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `WorkflowStreamClient.create(client, workflowId, options?)` | Factory for use outside an activity (starters, BFFs). Enables CAN following in `subscribe()`; uses the `Client`'s configured payload converter. | -| `WorkflowStreamClient.fromActivity(options?)` | Factory for use from within an activity — pulls the client and parent workflow id from the activity context. | -| `new WorkflowStreamClient(handle, options?)` | From a handle (no CAN following). | -| `flush()` | Barrier: returns once everything published before the call is acknowledged by the server. Empty-buffer call is a no-op. | -| `[Symbol.asyncDispose]()` | Stop the flusher and drain remaining items. Triggered automatically by `await using`. | -| `topic(name)` | Get a typed `TopicHandle` for publishing and subscribing. Repeated calls with the same name return the same handle. | -| `subscribe(topics?, fromOffset = 0, { pollCooldown = '100 milliseconds' })` | Raw async generator yielding `WorkflowStreamItem` — the multi-topic / decode-yourself path. `pollCooldown` is a `Duration`. Always follows CAN chains when created via `create()`. Recovers automatically from `TruncatedOffset` by restarting from the current base offset. | -| `getOffset()` | Query current global offset. | - -### `TopicHandle` / `WorkflowTopicHandle` - -| Method | Description | -| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | -| `name` | Topic name this handle is bound to. | -| `publish(value, options?)` (client-side) | Buffer `value` for publishing on this topic. `options.forceFlush = true` wakes the flusher to send immediately. | -| `publish(value)` (workflow-side) | Append `value` to the log from workflow code. | -| `subscribe(fromOffset?, options?)` (client-side) | Async generator yielding `WorkflowStreamItem` with `data` decoded to `T` via the default payload converter. | - -### `WorkflowStreamClientOptions` - -| Option | Default | Description | -| ------------------ | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `batchInterval` | `'2 seconds'` | Interval between automatic flushes (`Duration`). | -| `maxBatchSize` | `undefined` | Auto-flush when buffer reaches this size. | -| `maxRetryDuration` | `'10 minutes'` | Time to retry a failed flush before `FlushTimeoutError` (`Duration`). Must be less than the workflow's `publisherTtl` to preserve exactly-once delivery. | - ## Cross-Language Protocol Any Temporal client can interact with a workflow stream workflow using these fixed diff --git a/contrib/workflow-streams/src/__tests__/activities/workflow-streams.ts b/contrib/workflow-streams/src/__tests__/activities/workflow-streams.ts index 1c326262e..6fdae0b26 100644 --- a/contrib/workflow-streams/src/__tests__/activities/workflow-streams.ts +++ b/contrib/workflow-streams/src/__tests__/activities/workflow-streams.ts @@ -1,7 +1,7 @@ /** * Test activities for @temporalio/workflow-streams. * - * These activities use `WorkflowStreamClient.fromActivity()` to target the + * These activities use `WorkflowStreamClient.fromWithinActivity()` to target the * current activity's parent workflow from the activity context. */ @@ -11,7 +11,7 @@ import { WorkflowStreamClient } from '../../client'; const encoder = new TextEncoder(); export async function publishItems(count: number): Promise { - await using client = WorkflowStreamClient.fromActivity({ batchInterval: '500 milliseconds' }); + await using client = WorkflowStreamClient.fromWithinActivity({ batchInterval: '500 milliseconds' }); const events = client.topic('events'); for (let i = 0; i < count; i++) { Context.current().heartbeat(); @@ -21,7 +21,7 @@ export async function publishItems(count: number): Promise { export async function publishMultiTopic(count: number): Promise { const topicNames = ['a', 'b', 'c']; - await using client = WorkflowStreamClient.fromActivity({ batchInterval: '500 milliseconds' }); + await using client = WorkflowStreamClient.fromWithinActivity({ batchInterval: '500 milliseconds' }); const handles = topicNames.map((name) => client.topic(name)); for (let i = 0; i < count; i++) { Context.current().heartbeat(); @@ -36,7 +36,7 @@ export async function publishWithForceFlush(): Promise { // The hold is deliberately much longer than the test's collect timeout // so a regression (forceFlush no-op) surfaces as a missing item rather // than flaking on slow CI. - await using client = WorkflowStreamClient.fromActivity({ batchInterval: '60 seconds' }); + await using client = WorkflowStreamClient.fromWithinActivity({ batchInterval: '60 seconds' }); const events = client.topic('events'); events.publish(encoder.encode('normal-0')); events.publish(encoder.encode('normal-1')); @@ -48,7 +48,7 @@ export async function publishWithForceFlush(): Promise { } export async function publishBatchTest(count: number): Promise { - await using client = WorkflowStreamClient.fromActivity({ batchInterval: '60 seconds' }); + await using client = WorkflowStreamClient.fromWithinActivity({ batchInterval: '60 seconds' }); const events = client.topic('events'); for (let i = 0; i < count; i++) { Context.current().heartbeat(); @@ -58,7 +58,7 @@ export async function publishBatchTest(count: number): Promise { } export async function publishWithMaxBatch(count: number): Promise { - await using client = WorkflowStreamClient.fromActivity({ + await using client = WorkflowStreamClient.fromWithinActivity({ batchInterval: '60 seconds', maxBatchSize: 3, }); diff --git a/contrib/workflow-streams/src/client.ts b/contrib/workflow-streams/src/client.ts index fc77cbe26..c9eb52836 100644 --- a/contrib/workflow-streams/src/client.ts +++ b/contrib/workflow-streams/src/client.ts @@ -150,7 +150,7 @@ export class WorkflowStreamClient { * Use this when the caller has an explicit `Client` and `workflowId` in * hand (starters, BFFs, other workflows' activities). For code running * inside an activity that targets its own parent workflow, use - * {@link WorkflowStreamClient.fromActivity}. + * {@link WorkflowStreamClient.fromWithinActivity}. * * A client created through this method follows continue-as-new chains in * `subscribe()` and uses the client's payload converter for per-item @@ -175,12 +175,12 @@ export class WorkflowStreamClient { * Must be called from within an activity. The Temporal client and * parent workflow id are taken from the activity context. */ - static fromActivity(options?: WorkflowStreamClientOptions): WorkflowStreamClient { + static fromWithinActivity(options?: WorkflowStreamClientOptions): WorkflowStreamClient { const ctx = ActivityContext.current(); const workflowExecution = ctx.info.workflowExecution; if (workflowExecution === undefined) { throw new Error( - 'fromActivity requires an activity scheduled by a workflow; this ' + + 'fromWithinActivity requires an activity scheduled by a workflow; this ' + 'activity has no parent workflow. From a standalone activity, use ' + 'WorkflowStreamClient.create(client, workflowId) with the target ' + 'workflow id passed in explicitly.' diff --git a/contrib/workflow-streams/src/topic-handle.ts b/contrib/workflow-streams/src/topic-handle.ts index e0c2e6369..62c458a09 100644 --- a/contrib/workflow-streams/src/topic-handle.ts +++ b/contrib/workflow-streams/src/topic-handle.ts @@ -49,11 +49,7 @@ export class TopicHandle { * immediately (fire-and-forget — does not block the caller). */ publish(value: T | Payload, options?: { forceFlush?: boolean }): void { - // Cast through `unknown` so the internal publisher accepts the value; - // the per-handle T is compile-time only. - ( - this.client as unknown as { _publishToTopic(name: string, value: unknown, forceFlush: boolean): void } - )._publishToTopic(this.name, value, options?.forceFlush ?? false); + this.client._publishToTopic(this.name, value, options?.forceFlush ?? false); } /** @@ -99,9 +95,6 @@ export class WorkflowTopicHandle { * conversion, regardless of the handle's bound type. */ publish(value: T | Payload): void { - (this.stream as unknown as { _publishToTopic(name: string, value: unknown): void })._publishToTopic( - this.name, - value - ); + this.stream._publishToTopic(this.name, value); } } From dd720a258e81501295fb92308831bc212740c9a7 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Fri, 22 May 2026 14:56:12 -0700 Subject: [PATCH 74/75] cover both string and Uint8Array publish paths in tests Default to plain-string publishes across the workflow- and activity-side test fixtures, with a dedicated publishBinaryItems activity + binary_publish test exercising the Uint8Array (binary/plain) encoding path. payloadString helper now handles both encodings. Co-Authored-By: Claude Opus 4.7 (1M context) --- contrib/workflow-streams/README.md | 11 ++--- .../__tests__/activities/workflow-streams.ts | 33 +++++++++------ .../src/__tests__/test-workflow-streams.ts | 38 +++++++++++++----- .../__tests__/workflows/workflow-streams.ts | 40 +++++++++++++------ 4 files changed, 81 insertions(+), 41 deletions(-) diff --git a/contrib/workflow-streams/README.md b/contrib/workflow-streams/README.md index 9c2c07c52..790501f59 100644 --- a/contrib/workflow-streams/README.md +++ b/contrib/workflow-streams/README.md @@ -53,13 +53,10 @@ export async function myWorkflow(input: MyInput): Promise { The `WorkflowStream` constructor registers the `__temporal_workflow_stream_publish` signal, `__temporal_workflow_stream_poll` update, and `__temporal_workflow_stream_offset` query handlers on your workflow. -Any value the default payload converter can serialize (JSON, `Uint8Array`, or -a pre-built `Payload`) can be passed to `publish`. The type parameter `T` is -purely a compile-time annotation — TypeScript has no runtime type -representation, so per-topic type uniformity isn't enforced at runtime -(unlike sdk-python). Repeated calls to `stream.topic('foo')` return the same -handle instance, so a stale `T` annotation can't introduce a duplicate -publisher. +Any value the default payload converter can serialize or +a pre-built `Payload` can be passed to `publish`. The type parameter `T` is +only a compile-time annotation. Repeated calls to `stream.topic('foo')` return the same +handle instance. The type parameter `T` is a compile-time annotation and doesn't affect handle identity. ### Activity side (publishing) diff --git a/contrib/workflow-streams/src/__tests__/activities/workflow-streams.ts b/contrib/workflow-streams/src/__tests__/activities/workflow-streams.ts index 6fdae0b26..e54b0f5e2 100644 --- a/contrib/workflow-streams/src/__tests__/activities/workflow-streams.ts +++ b/contrib/workflow-streams/src/__tests__/activities/workflow-streams.ts @@ -8,9 +8,18 @@ import { Context } from '@temporalio/activity'; import { WorkflowStreamClient } from '../../client'; -const encoder = new TextEncoder(); - export async function publishItems(count: number): Promise { + await using client = WorkflowStreamClient.fromWithinActivity({ batchInterval: '500 milliseconds' }); + const events = client.topic('events'); + for (let i = 0; i < count; i++) { + Context.current().heartbeat(); + events.publish(`item-${i}`); + } +} + +/** Publishes `count` items as `Uint8Array` — exercises the binary/plain encoding path. */ +export async function publishBinaryItems(count: number): Promise { + const encoder = new TextEncoder(); await using client = WorkflowStreamClient.fromWithinActivity({ batchInterval: '500 milliseconds' }); const events = client.topic('events'); for (let i = 0; i < count; i++) { @@ -22,11 +31,11 @@ export async function publishItems(count: number): Promise { export async function publishMultiTopic(count: number): Promise { const topicNames = ['a', 'b', 'c']; await using client = WorkflowStreamClient.fromWithinActivity({ batchInterval: '500 milliseconds' }); - const handles = topicNames.map((name) => client.topic(name)); + const handles = topicNames.map((name) => client.topic(name)); for (let i = 0; i < count; i++) { Context.current().heartbeat(); const idx = i % handles.length; - handles[idx]!.publish(encoder.encode(`${topicNames[idx]}-${i}`)); + handles[idx]!.publish(`${topicNames[idx]}-${i}`); } } @@ -37,10 +46,10 @@ export async function publishWithForceFlush(): Promise { // so a regression (forceFlush no-op) surfaces as a missing item rather // than flaking on slow CI. await using client = WorkflowStreamClient.fromWithinActivity({ batchInterval: '60 seconds' }); - const events = client.topic('events'); - events.publish(encoder.encode('normal-0')); - events.publish(encoder.encode('normal-1')); - events.publish(encoder.encode('force-flush'), { forceFlush: true }); + const events = client.topic('events'); + events.publish('normal-0'); + events.publish('normal-1'); + events.publish('force-flush', { forceFlush: true }); for (let i = 0; i < 100; i++) { Context.current().heartbeat(); await new Promise((resolve) => setTimeout(resolve, 100)); @@ -49,10 +58,10 @@ export async function publishWithForceFlush(): Promise { export async function publishBatchTest(count: number): Promise { await using client = WorkflowStreamClient.fromWithinActivity({ batchInterval: '60 seconds' }); - const events = client.topic('events'); + const events = client.topic('events'); for (let i = 0; i < count; i++) { Context.current().heartbeat(); - events.publish(encoder.encode(`item-${i}`)); + events.publish(`item-${i}`); } // Long batchInterval — only the dispose-driven drain will flush. } @@ -62,10 +71,10 @@ export async function publishWithMaxBatch(count: number): Promise { batchInterval: '60 seconds', maxBatchSize: 3, }); - const events = client.topic('events'); + const events = client.topic('events'); for (let i = 0; i < count; i++) { Context.current().heartbeat(); - events.publish(encoder.encode(`item-${i}`)); + events.publish(`item-${i}`); } // Long batchInterval — maxBatchSize and dispose-driven drain handle flushing. } diff --git a/contrib/workflow-streams/src/__tests__/test-workflow-streams.ts b/contrib/workflow-streams/src/__tests__/test-workflow-streams.ts index d909e430b..37285f432 100644 --- a/contrib/workflow-streams/src/__tests__/test-workflow-streams.ts +++ b/contrib/workflow-streams/src/__tests__/test-workflow-streams.ts @@ -28,6 +28,7 @@ import { helpers, makeTestFunction } from './helpers-integration'; import { activityPublishWorkflow, basicWorkflowStreamWorkflow, + binaryPublishWorkflow, continueAsNewHelperWorkflow, continueAsNewTypedWorkflow, flushOnExitWorkflow, @@ -62,20 +63,20 @@ function entry(topic: string, data: string): PublishEntry { return { topic, data: encodePayloadWire(payload) }; } -/** Extract the raw bytes from a `Payload` produced by the default converter. */ +/** Extract the raw bytes from a binary/plain `Payload`. */ function payloadBytes(payload: Payload): Uint8Array { - // defaultPayloadConverter maps Uint8Array to encoding=binary/plain, so - // `data` is already the raw bytes. For string/JSON payloads we fall back - // to the converter. - const encoding = payload.metadata?.['encoding']; - if (encoding && decoder.decode(encoding) === 'binary/plain') { - return payload.data ?? new Uint8Array(0); - } - return defaultPayloadConverter.fromPayload(payload); + return payload.data ?? new Uint8Array(0); } function payloadString(payload: Payload): string { - return decoder.decode(payloadBytes(payload)); + // defaultPayloadConverter maps Uint8Array to encoding=binary/plain (raw + // bytes in `data`) and strings to encoding=json/plain. Handle both so + // tests can mix the two publish paths. + const encoding = payload.metadata?.['encoding']; + if (encoding && decoder.decode(encoding) === 'binary/plain') { + return decoder.decode(payloadBytes(payload)); + } + return defaultPayloadConverter.fromPayload(payload); } async function collectItems( @@ -125,6 +126,23 @@ test('activity_publish_and_subscribe — activity publishes, client subscribes', }); }); +test('binary_publish — Uint8Array publishes round-trip as binary/plain', async (t) => { + const count = 5; + const { createWorker, startWorkflow } = helpers(t); + const worker = await createWorker({ activities: streamActivities }); + await worker.runUntil(async () => { + const handle = await startWorkflow(binaryPublishWorkflow, { args: [count] }); + const items = await collectItems(handle, undefined, 0, count); + t.is(items.length, count); + for (let i = 0; i < count; i++) { + const encoding = items[i]!.data.metadata?.['encoding']; + t.is(encoding && decoder.decode(encoding), 'binary/plain'); + t.deepEqual(payloadBytes(items[i]!.data), encoder.encode(`item-${i}`)); + } + await handle.signal('close'); + }); +}); + test('topic_filtering — subscriber gets only requested topics', async (t) => { const count = 9; const { createWorker, startWorkflow } = helpers(t); diff --git a/contrib/workflow-streams/src/__tests__/workflows/workflow-streams.ts b/contrib/workflow-streams/src/__tests__/workflows/workflow-streams.ts index f507dbbc6..4aabe81c1 100644 --- a/contrib/workflow-streams/src/__tests__/workflows/workflow-streams.ts +++ b/contrib/workflow-streams/src/__tests__/workflows/workflow-streams.ts @@ -14,11 +14,17 @@ import { import { WorkflowStream, type WorkflowStreamState } from '../../workflow'; import type * as activities from '../activities/workflow-streams'; -const { publishItems, publishMultiTopic, publishWithForceFlush, publishBatchTest, publishWithMaxBatch } = - proxyActivities({ - startToCloseTimeout: '30 seconds', - heartbeatTimeout: '10 seconds', - }); +const { + publishItems, + publishBinaryItems, + publishMultiTopic, + publishWithForceFlush, + publishBatchTest, + publishWithMaxBatch, +} = proxyActivities({ + startToCloseTimeout: '30 seconds', + heartbeatTimeout: '10 seconds', +}); export const closeSignal = defineSignal('close'); export const triggerContinueSignal = defineSignal('triggerContinue'); @@ -39,14 +45,13 @@ export async function basicWorkflowStreamWorkflow(): Promise { /** Publishes `count` items directly from the workflow, then waits. */ export async function workflowSidePublishWorkflow(count: number): Promise { const stream = new WorkflowStream(); - const events = stream.topic('events'); + const events = stream.topic('events'); let closed = false; setHandler(closeSignal, () => { closed = true; }); - const encoder = new TextEncoder(); for (let i = 0; i < count; i++) { - events.publish(encoder.encode(`item-${i}`)); + events.publish(`item-${i}`); } await condition(() => closed); } @@ -65,13 +70,24 @@ export async function multiTopicWorkflow(count: number): Promise { /** Executes publishItems activity then appends activity_done status. */ export async function activityPublishWorkflow(count: number): Promise { const stream = new WorkflowStream(); - const status = stream.topic('status'); + const status = stream.topic('status'); let closed = false; setHandler(closeSignal, () => { closed = true; }); await publishItems(count); - status.publish(new TextEncoder().encode('activity_done')); + status.publish('activity_done'); + await condition(() => closed); +} + +/** Executes publishBinaryItems activity then waits — exercises the Uint8Array publish path. */ +export async function binaryPublishWorkflow(count: number): Promise { + new WorkflowStream(); + let closed = false; + setHandler(closeSignal, () => { + closed = true; + }); + await publishBinaryItems(count); await condition(() => closed); } @@ -124,13 +140,13 @@ export async function flushOnExitWorkflow(count: number): Promise { /** Workflow that runs publishWithMaxBatch activity. */ export async function maxBatchWorkflow(count: number): Promise { const stream = new WorkflowStream(); - const status = stream.topic('status'); + const status = stream.topic('status'); let closed = false; setHandler(closeSignal, () => { closed = true; }); await publishWithMaxBatch(count); - status.publish(new TextEncoder().encode('activity_done')); + status.publish('activity_done'); await condition(() => closed); } From fc9d3b210ba22392e3b497396dbb2d6d525b0189 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Fri, 22 May 2026 15:02:30 -0700 Subject: [PATCH 75/75] make publishToTopic private; inject as closure to topic handles Replace the underscore-prefixed pseudo-private _publishToTopic on WorkflowStream and WorkflowStreamClient with a real `private` publishToTopic. The topic() factories now capture it in a closure and pass that to the handle constructors, so TopicHandle/WorkflowTopicHandle no longer reach across class boundaries. Co-Authored-By: Claude Opus 4.7 (1M context) --- contrib/workflow-streams/src/client.ts | 7 ++++--- contrib/workflow-streams/src/topic-handle.ts | 12 ++++++------ contrib/workflow-streams/src/workflow.ts | 5 ++--- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/contrib/workflow-streams/src/client.ts b/contrib/workflow-streams/src/client.ts index c9eb52836..1f290d847 100644 --- a/contrib/workflow-streams/src/client.ts +++ b/contrib/workflow-streams/src/client.ts @@ -286,14 +286,15 @@ export class WorkflowStreamClient { topic(name: string): TopicHandle { let handle = this.topicHandles.get(name); if (handle === undefined) { - handle = new TopicHandle(this, name); + handle = new TopicHandle(this, name, (topic, value, forceFlush) => + this.publishToTopic(topic, value, forceFlush) + ); this.topicHandles.set(name, handle as TopicHandle); } return handle as TopicHandle; } - /** @internal Used by {@link TopicHandle.publish}. */ - _publishToTopic(topic: string, value: unknown, forceFlush: boolean): void { + private publishToTopic(topic: string, value: unknown, forceFlush: boolean): void { // Lazy-start the background flusher on first publish. Skipped if dispose // already ran, so a publish-after-dispose surfaces as a buffered item that // never flushes (which the next dispose would catch) rather than silently diff --git a/contrib/workflow-streams/src/topic-handle.ts b/contrib/workflow-streams/src/topic-handle.ts index 62c458a09..a7f811c66 100644 --- a/contrib/workflow-streams/src/topic-handle.ts +++ b/contrib/workflow-streams/src/topic-handle.ts @@ -16,7 +16,6 @@ import type { Payload } from '@temporalio/common'; import type { SubscribeOptions, WorkflowStreamClient } from './client'; -import type { WorkflowStream } from './workflow'; import type { WorkflowStreamItem } from './types'; /** @@ -32,7 +31,8 @@ export class TopicHandle { /** @internal */ constructor( private readonly client: WorkflowStreamClient, - public readonly name: string + public readonly name: string, + private readonly publishFn: (topic: string, value: unknown, forceFlush: boolean) => void ) {} /** @@ -49,7 +49,7 @@ export class TopicHandle { * immediately (fire-and-forget — does not block the caller). */ publish(value: T | Payload, options?: { forceFlush?: boolean }): void { - this.client._publishToTopic(this.name, value, options?.forceFlush ?? false); + this.publishFn(this.name, value, options?.forceFlush ?? false); } /** @@ -83,8 +83,8 @@ export class TopicHandle { export class WorkflowTopicHandle { /** @internal */ constructor( - private readonly stream: WorkflowStream, - public readonly name: string + public readonly name: string, + private readonly publishFn: (topic: string, value: unknown) => void ) {} /** @@ -95,6 +95,6 @@ export class WorkflowTopicHandle { * conversion, regardless of the handle's bound type. */ publish(value: T | Payload): void { - this.stream._publishToTopic(this.name, value); + this.publishFn(this.name, value); } } diff --git a/contrib/workflow-streams/src/workflow.ts b/contrib/workflow-streams/src/workflow.ts index 6b691b143..90476ddd1 100644 --- a/contrib/workflow-streams/src/workflow.ts +++ b/contrib/workflow-streams/src/workflow.ts @@ -172,14 +172,13 @@ export class WorkflowStream { topic(name: string): WorkflowTopicHandle { let handle = this.topicHandles.get(name); if (handle === undefined) { - handle = new WorkflowTopicHandle(this, name); + handle = new WorkflowTopicHandle(name, (topic, value) => this.publishToTopic(topic, value)); this.topicHandles.set(name, handle as WorkflowTopicHandle); } return handle as WorkflowTopicHandle; } - /** @internal Used by {@link WorkflowTopicHandle.publish}. */ - _publishToTopic(topic: string, value: unknown): void { + private publishToTopic(topic: string, value: unknown): void { let payload: Payload; if (isPayload(value)) { payload = value;