Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .changeset/activity-idempotency-key.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
"@temporal-contract/contract": minor
"@temporal-contract/worker": minor
"@temporal-contract/testing": minor
---

Activities can declare an **idempotency key**, derived from their input:

```ts
const chargeCard = defineActivity({
input: z.object({ orderId: z.string(), customerId: z.string(), amount: z.number() }),
output: PaymentSchema,
// Key on what IDENTIFIES the charge, not on what describes it: one customer
// placing two orders of the same value must not collide on one key.
idempotencyKey: ({ orderId }) => `charge:${orderId}`,
});

chargeCard: ({ input, idempotencyKey }) =>
fromPromise(
gateway.charge(input, { idempotencyKey }),
qualifyFailure("CHARGE_FAILED", { expected: GatewayError }),
),
```

Temporal runs activities **at least once**, and nothing in the library helped
with that until now — `idempotency` on a workflow is start deduplication and
says nothing about an activity running twice. Being payload-derived, the key is
stable across activity retries, worker crashes, and a fresh workflow execution
with the same input.

`helpers.idempotencyKey` is typed `string` for an activity that declares one and
`undefined` for one that does not, so reaching for a key that was never declared
is a compile error. `runActivity` hands over the same value.

Good key sources: a business identifier already in the input, a dedicated
`idempotencyKey` field the caller mints, or the workflow ID — which is
per-execution and, when the contract derives it, a function of the payload.
17 changes: 17 additions & 0 deletions .changeset/best-effort-and-propagate-rename.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@temporal-contract/worker": minor
---

`bestEffort(result, onFailure)` — the counterpart to `propagateFailure` for a
non-critical call (a notification, a metric, an audit write). It hands the
failure to `onFailure` and resolves `undefined` instead of ending the workflow,
but **re-raises real cancellation** (`ActivityCancelledError`,
`ChildWorkflowCancelledError`, `WorkflowCancelledError`) so a workflow can no
longer absorb its own cancel by accident. That rule used to live in every
hand-written best-effort fold; it is now structural.

`propagateActivityFailure` is renamed to **`propagateFailure`** — it has always
also handled child-workflow calls and cancellation scopes, and the old name said
otherwise. The old name is **removed**, not aliased: it only ever shipped in 8.0
betas, and this release already renames `idempotency` to `startPolicy` outright.
Rename the import; behaviour is unchanged.
16 changes: 16 additions & 0 deletions .changeset/client-error-patterns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@temporal-contract/client": minor
---

Ready-made error pattern groups — `WORKFLOW_START_PATTERNS`,
`WORKFLOW_RESULT_PATTERNS`, `WORKFLOW_EXECUTE_PATTERNS`,
`WORKFLOW_STOPPED_PATTERNS`, `SIGNAL_PATTERNS`, `QUERY_PATTERNS`,
`UPDATE_PATTERNS`, `SCHEDULE_CREATE_PATTERNS`. Each mirrors one method's error
union exactly, so `matcher.with(...WORKFLOW_RESULT_PATTERNS, handler)` replaces
six hand-written `P.tag(...)` arguments.

Exhaustiveness is unchanged: these are ordinary pattern tuples, so a missing
member is still a compile error naming it. A workflow's **declared contract
errors** are deliberately not in these groups — no shipped group can name a
user's own errors — so for a workflow that declares `errors`, a group alone is
not exhaustive: match those first with `{ errorName: "..." }`.
25 changes: 25 additions & 0 deletions .changeset/derived-workflow-id.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
"@temporal-contract/contract": minor
"@temporal-contract/client": minor
---

Workflows can derive their **workflow ID** from their input:

```ts
const processOrder = defineWorkflow({
input: OrderSchema,
output: OrderResultSchema,
workflowId: ({ orderId }) => `order-${orderId}`,
startPolicy: "once-per-id",
});
```

`startPolicy` only bites when two starts of the same logical request collide on
one ID, and the ID used to be entirely the caller's — passing
`crypto.randomUUID()` made `"once-per-id"` inert with no diagnostic. A workflow
that declares `workflowId` now derives it from the validated payload on
`startWorkflow` / `executeWorkflow` / `signalWithStart`, and supplying one at
the call site is a type error. Workflows that declare none are unchanged.

`IdempotencyMode` is renamed to `WorkflowStartPolicy` (the old name stays as a
deprecated type alias).
14 changes: 14 additions & 0 deletions .changeset/time-skipping-contract-test.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@temporal-contract/testing": minor
---

`createTimeSkippingContractTest({ contract, workflowsPath, activities })` — the
one-call fixture for the **time-skipping** tier, the Docker-free counterpart to
`createContractTest`. It owns the `TestWorkflowEnvironment`, the workflow bundle
(built once per Vitest worker process), the worker, the `TypedClient` binding,
and the replay-on-finish check, and hands the test `{ worker, client }`.

Previously the tier with the better ergonomics was also the one that needed
Docker: the time-skipping tier only offered `testRig`, which makes the caller
build a bundle and manage the environment. `testRig` stays as the lower-level
seam.
Loading
Loading