Aviary
Tooling

Linting for non-determinism

Catch Date.now(), Math.random(), new Date() and crypto.randomUUID() inside a @Workflow run at author time with @dudousxd/nestjs-durable-eslint-plugin — shipped for both ESLint (flat config) and Biome (GritQL plugin).

A durable run(ctx, input) is executed and replayed: the engine re-runs the workflow body from the top on every resume, replaying recorded step results to reach the suspension point. That only stays correct if the body is deterministic — the same input has to produce the same sequence of step calls every time. A bare Date.now(), Math.random(), new Date(), crypto.randomUUID() or performance.now() returns a different value on every replay, so the replayed path diverges from the recorded one and silently corrupts the run.

The engine already detects this drift at runtime and throws a NonDeterminismError — but that's a replay-time failure, often far from where the offending call lives. The @dudousxd/nestjs-durable-eslint-plugin package moves the check to author time: it flags those calls in your editor and CI, before they ever reach a replay. See versioning for why determinism matters and how the runtime guard works.

npm i -D @dudousxd/nestjs-durable-eslint-plugin

What it flags

Inside a @Workflow run, these are banned and the fix is the engine's checkpointed equivalent — each is recorded once on the first execution and replayed verbatim afterwards:

Banned sourceWhy it driftsUse instead
Date.now()wall clock, different every replayawait ctx.now()
performance.now()monotonic clock, different every replayawait ctx.now()
new Date() (no args)wraps Date.now()new Date(await ctx.now())
Math.random()fresh entropy every replayawait ctx.sideEffect(() => Math.random())
crypto.randomUUID()fresh entropy every replayawait ctx.sideEffect(() => crypto.randomUUID())
// ✗ corrupts the run on replay
@Workflow({ name: 'checkout', version: '1' })
export class CheckoutWorkflow {
  async run(ctx: WorkflowCtx, order: Order) {
    const startedAt = Date.now();              // ✗ useNow
    const idempotencyKey = crypto.randomUUID(); // ✗ useUuid
    const jitter = Math.random();               // ✗ useRandom
    const at = new Date();                      // ✗ useNowDate
    /* ... */
  }
}

// ✓ deterministic — recorded once, then replayed
@Workflow({ name: 'checkout', version: '1' })
export class CheckoutWorkflow {
  async run(ctx: WorkflowCtx, order: Order) {
    const startedAt = await ctx.now();
    const idempotencyKey = await ctx.sideEffect(() => crypto.randomUUID());
    const jitter = await ctx.sideEffect(() => Math.random());
    const at = new Date(await ctx.now());
    /* ... */
  }
}

crypto.randomUUID() is matched whether called bare, as globalThis.crypto.randomUUID(). The rule only fires inside the workflow body — calling Date.now() elsewhere (a service, a controller, a @Step handler that runs once and checkpoints its result) is fine, because those aren't replayed.

ESLint (flat config)

The no-nondeterminism rule is AST-scoped: it walks up from each banned call and only reports it when the call sits lexically inside the run method of a class decorated with @Workflow (whether written @Workflow or @Workflow({ ... })). Code outside a workflow run is never touched, so you can enable it across your whole **/*.ts glob without false positives.

Wire it into your flat config under the plugin namespace:

// eslint.config.js
import durable from '@dudousxd/nestjs-durable-eslint-plugin';

export default [
  {
    files: ['**/*.ts'],
    plugins: { '@dudousxd/nestjs-durable': durable },
    rules: {
      '@dudousxd/nestjs-durable/no-nondeterminism': 'error',
    },
  },
];

Or spread the shipped preset, which enables the rule at error for you:

// eslint.config.js
import durable from '@dudousxd/nestjs-durable-eslint-plugin';

export default [
  durable.configs.recommended,
];

Each report points at the exact call and names its deterministic replacement, e.g. "Non-deterministic Date.now() inside a @Workflow run — use ctx.now() (recorded once, then replayed)."

Biome (>= 2.0)

For projects on Biome 2.x, the same rule ships as a GritQL plugin at grit/no-nondeterminism.grit inside the package. Biome plugins can't yet scope a match by decorator or method, so instead of detecting the @Workflow run from the AST, you point the plugin at your workflow files via an overrides block. The conventional way to make that selectable is a file suffix like *.workflow.ts:

// biome.json
{
  "$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
  "overrides": [
    {
      "include": ["**/*.workflow.ts"],
      "plugins": [
        "./node_modules/@dudousxd/nestjs-durable-eslint-plugin/grit/no-nondeterminism.grit"
      ]
    }
  ]
}

The plugin pattern matches Date.now(), performance.now(), Math.random(), crypto.randomUUID(), globalThis.crypto.randomUUID() and new Date() and raises a diagnostic on each, with the same guidance to switch to ctx.now() / ctx.sideEffect(...). Because the scope is the file rather than the method, keep one workflow per *.workflow.ts file (or another glob you reserve for workflow bodies) so the plugin doesn't flag a legitimate Date.now() that happens to share the file.

How it complements NonDeterminismError

The lint rule and the runtime guard are two layers of the same defense:

  • Author time (this plugin). The drift is caught as a red squiggle / a failing CI step, pointing at the exact source line. You fix it before it ships.
  • Replay time (NonDeterminismError). If a non-deterministic call slips through — say it came from a transitive helper the linter couldn't see — the engine still refuses to corrupt the run and throws NonDeterminismError when the replayed path diverges from the recorded one.

The lint rule is the cheaper, earlier signal; the runtime error is the backstop. Together they keep a durable run deterministic. For the full picture of how recorded-vs-replayed history is compared, and how to evolve a workflow's body safely once it's in production, see versioning.

On this page