Skip to content

fix(cli): make contract emission atomic and serialized under rapid saves#365

Open
jkomyno wants to merge 78 commits intomainfrom
tml-2279-atomic-emit-writes
Open

fix(cli): make contract emission atomic and serialized under rapid saves#365
jkomyno wants to merge 78 commits intomainfrom
tml-2279-atomic-emit-writes

Conversation

@jkomyno
Copy link
Copy Markdown
Contributor

@jkomyno jkomyno commented Apr 22, 2026

Summary

  • Add publishContractArtifactPair helper: stages contract.json and contract.d.ts to per-PID/per-token temp paths, then renames contract.d.ts before contract.json so type-only consumers never observe a mismatched pair. On failure mid-rename it best-effort restores the previous pair and attaches rollback failures to the publish error via cause (AggregateError).
  • Add publishContractArtifactPairSerialized: a per-output-path FIFO queue keyed off a monotonic generation counter. An older emit that arrives at the queue after a newer one returns publication: 'superseded' instead of overwriting the newer artifacts. Both executeContractEmit and the prisma-next emit command go through this path.
  • Surface the publication outcome via a new ContractEmitResult.publication: 'written' | 'superseded' field; the vite plugin consumes it for HMR ordering, and the CLI command translates 'superseded' into an errorRuntime envelope so callers can react.
  • Bump the @prisma-next/cli and @prisma-next/vite-plugin-contract-emit tsconfigs to lib: ["ES2024"] so Promise.withResolvers is available natively (project engines.node >= 24); drop the duplicated ambient declarations from the test files.
  • Hoist validateContractDeps from a dynamic await import(...) to a top-level static import in both call sites — the deferred load had no cycle/cost justification.
  • Tests: new publish-contract-artifact-pair unit suite (rename ordering, write-failure preservation, beforePublish recheck, rollback-failure cause); integration tests for queued supersession (keeps the newer generation on disk, keeps the last good artifacts when a newer request fails); table-driven source-provider validation; and a vite-plugin overlapping-emit test.

Validation

  • pnpm --filter @prisma-next/cli typecheck
  • pnpm --filter @prisma-next/cli test
  • pnpm --filter @prisma-next/vite-plugin-contract-emit typecheck
  • pnpm --filter @prisma-next/vite-plugin-contract-emit test
  • pnpm lint on the touched files

Refs TML-2279

Summary by CodeRabbit

  • Bug Fixes

    • More reliable, atomic publication of contract artifacts with rollback on failure.
    • Fixes race conditions when multiple emits target the same output path; newer emits supersede older ones.
    • Ensures temporary files are cleaned up after publish.
  • New Features

    • Command and plugin now report when an emit is superseded before artifacts are written.
  • Documentation

    • Updated workflow docs to describe staged/temp artifact publication ordering.
  • Tests

    • Expanded tests covering publication ordering, supersede behavior, rollback, and cleanup.

jkomyno and others added 30 commits April 19, 2026 23:37
The SQL provider was updated to use context.configDir but the mongo
provider was missed, causing watcher/loader path divergence when cwd
differs from the config directory.
…onfigDir

- Switch config-loader from node:path to pathe for consistency with
  the rest of the tooling layer.
- Resolve .ts contract paths inside load() via context.configDir instead
  of capturing process.cwd() at config-definition time, so the watcher
  and loader always reference the same file.
The paths branch omitted the config file from the watch set, so edits
to prisma-next.config.ts (e.g. changing paths or swapping provider)
would not trigger re-emit. Also surface a partial-coverage warning when
loadConfig fails instead of silently falling back.
Close D1 from the PR #356 review by driving a real Vite dev server
through both scenarios that were previously only unit-mocked:
modifying `contract.prisma` re-emits the contract, and editing the
config to swap the declared PSL input re-emits against the new file.
Align with the Mongo PSL provider and with the parser step's sourceId.
The absolute form stays accessible via meta.absoluteSchemaPath for
downstream consumers that need it.
Collapse the ~20-line inline provider that postgres and mongo facades
each build around a dynamic import of the user's `contract.ts`. The
logic (path resolution, pathToFileURL, default-export extraction) is
family-agnostic, so each contract-ts package exposes it as a sibling
to typescriptContract.
- handleTrackedFileChange now runs ctx.file through pathe.resolve
  before comparing against watchedFiles / ignoredOutputFiles, so
  symlinked or case-folded paths line up with the resolved entries.
- resolveContractOutputFiles no longer falls back to the hardcoded
  'src/prisma/contract.json' when contract.output is missing. The
  filter simply skips; defineConfig() guarantees output is present
  in the happy path, and raw-object configs get a clean no-op instead
  of a path the CLI never actually writes.
…ceContext

Left over from the split that moved configDir out of ContractSourceContext
into ContractSourceEnvironment — the context builder no longer needs it.
Introduce publishPairWithRollback so writePlainContractArtifacts no
longer needs a nested try/catch or the replacedJson/replacedDts
tracking flags. The outer try/finally now owns only temp cleanup,
which uses the idempotent rm(force: true) directly, so the
cleanupJsonTemp/cleanupDtsTemp booleans drop out too.
Copy link
Copy Markdown
Contributor

@wmadden wmadden left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rename "managed"/"plain" to publication strategies in contract-emit.ts

Intent

The two write paths in packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts are not two kinds of contract artifact; they are two publication strategies for the same pair of artifacts. The current names ("plain" vs "managed") read as a quality axis — one sounds like the baseline, the other like the upgrade. That is misleading in two ways:

  • Nothing about the "managed" path produces a different artifact. It produces the same contract.json + contract.d.ts, it just commits the pair to the public paths via a different mechanism.
  • The vocabulary leaks: managedPublication: boolean, ManagedArtifactPaths, bootstrapManagedArtifactsFromExistingOutputs, ensurePublicManagedLinks all imply a property of the artifacts rather than a choice of commit mechanism.

Rename both strategies symmetrically so the code names the axis it actually varies, and so neither strategy wears the mask of being the default or the premium option.

The axis

Both paths:

  • receive the same inputs,
  • run through the same per-target serial queue (emitWriteTargets),
  • honor the same generation check,
  • emit the same bytes.

They differ only in how the pair becomes visible to readers:

current "plain" current "managed"
Commit mechanism Two renames over the public paths One rename of a staged current symlink
Public paths Regular files Symlinks into generations/g-<N>/
Prior versions Overwritten Accumulate under generations/
Rollback on failure Restore captured bytes Don't flip current

The naming should name that.

Naming decision

Two strategies, named after the commit mechanism:

  • pair-rename — stage two temp files alongside the outputs, then rename them into the public paths as a pair (DTS first, JSON last).
  • pointer-swap — materialize generations/g-<N>/ inside a hidden sibling root, then atomically re-point a current symlink to it; the public paths are symlinks into current/.

Default: 'pair-rename'.

Rename map

Types / options

Before After
(local) const managedPublication = options.signal !== undefined (removed; see "API change" below)
interface ManagedArtifactPaths interface PointerPublicationLayout

API (exported) change

Add an explicit publication strategy option to ContractEmitOptions and stop deriving the strategy from signal:

export interface ContractEmitOptions {
  readonly configPath: string;
  readonly signal?: AbortSignal;
  /**
   * How the emitted pair is committed to the public paths.
   * Defaults to 'pair-rename'.
   */
  readonly publication?: 'pair-rename' | 'pointer-swap';
}

Callers:

  • vite-plugin-contract-emit/src/plugin.ts passes publication: 'pointer-swap' explicitly (along with signal).
  • cli/src/commands/init/init.ts and all other callers stay on the default.

Functions

Before After
writePlainContractArtifacts publishByPairRename
writeManagedContractArtifacts publishByPointerSwap
writeContractArtifacts (dispatcher) unchanged in name; dispatches on the new publication value
getManagedArtifactPaths getPointerPublicationLayout
createManagedGenerationDirName createGenerationDirName
bootstrapManagedArtifactsFromExistingOutputs bootstrapPointerPublicationFromExistingOutputs
ensurePublicManagedLinks ensurePointerPublicationSymlinks
ensureSymlinkIfMissing unchanged
replacePathWithSymlink unchanged
publishPairWithRollback unchanged (already named after its mechanism)
restoreArtifact unchanged
tryLstat, pathExists, readExistingArtifact, createTempArtifactPath unchanged
issueEmitGeneration, queueEmitWrite, getEmitWriteTargetState unchanged (these are target-level coordination, not strategy-specific)

Parameters / local variables

Before After
Parameter managedPublication: boolean on writeContractArtifacts publication: 'pair-rename' | 'pointer-swap'
Local managedPaths (in publishByPointerSwap, bootstrap) layout
publicJsonLinkTarget / publicDtsLinkTarget on ManagedArtifactPaths unchanged

Scope

Files in scope:

  • packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts — the renames above.
  • packages/1-framework/3-tooling/cli/src/control-api/types.ts — add the publication field to ContractEmitOptions.
  • packages/1-framework/3-tooling/vite-plugin-contract-emit/src/plugin.ts — pass publication: 'pointer-swap' at the executeContractEmit call site.
  • packages/1-framework/3-tooling/cli/test/control-api/contract-emit.test.ts — rename test descriptions to say (pair-rename publication) / (pointer-swap publication) where relevant; update any tests that pass signal purely to opt into the old managed path so they now also pass publication: 'pointer-swap'.
  • packages/1-framework/3-tooling/vite-plugin-contract-emit/README.md — update the "Atomic publication" bullet and the "How It Works" / mermaid flow to use the strategy name (pointer-swap) rather than "managed".

Non-goals

  • No change to on-disk layout, bytes, or semantics. This is a naming / API-shape change only.
  • No change to the generation counter, the serial queue, or the rollback logic.
  • No change to callers that don't pass a signal — they continue to use pair-rename implicitly via the default.
  • Does not address the other findings from the PR code review (.gitignore, restart-time g-<N> collision, documentation of disk side effects). Those are tracked separately.

Acceptance criteria

  • rg "managed" in packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts returns no matches.
  • ContractEmitOptions exposes a publication?: 'pair-rename' | 'pointer-swap' field, and executeContractEmit does not infer strategy from signal.
  • The vite plugin opts into pointer-swap explicitly.
  • pnpm --filter @prisma-next/cli typecheck and the full contract-emit.test.ts suite pass unchanged in behavior.
  • The vite plugin's README no longer uses "managed" to describe the publication strategy.

@wmadden
Copy link
Copy Markdown
Contributor

wmadden commented Apr 22, 2026

Code Review — PR #365

Scope: tml-2279-atomic-emit-writes vs origin/tml-2277-vite-plugin-watch-behavior (explicit PR base — not main). See spec.md for expectations.


Review outcome: this PR cannot land as written

The "managed" publication path silently rewrites the user-configured contract.json and contract.d.ts as symlinks into a hidden sibling directory (.<stem>.prisma-next-artifacts/current/…). A dev-time plugin must not change the storage format of files the user treats as canonical source. Once pnpm dev has run:

  • The files the user's app imports from (import contractJson from './contract.json' …) are no longer regular files.
  • The files stay symlinks after the dev server stops — there is no reversion step.
  • Committing pushes the symlinks (git mode 120000) to origin, breaking any collaborator clone or CI run that hasn't itself started the dev server.
  • Tools that copy rather than follow symlinks (Docker builds, pnpm pack, vendored copies) will behave inconsistently between dev and prod.
  • The mental model "the file I see in my editor is the file" is silently broken.

All of this is in exchange for closing a sub-millisecond window between two rename calls inside the dev inner loop. That cost/benefit is badly inverted, and no amount of README copy or .gitignore entries makes it acceptable.

Required change before this can merge: remove the managed-publication path entirely. Keep the rest of the hardening — per-output serialization, synchronous generation issuance, temp-file staging, and publishPairWithRollback — as the single strategy. Details in F01.


Summary

The core hardening (per-output serial queue, monotonic generation counter issued synchronously, temp-file staging, pair-rename with rollback) is sound and the test intent is right. The managed-publication path built on top of it is not landable on its own terms (see banner and F01). Once that path is removed, a short list of smaller findings on the remaining code stands.

What looks solid (and survives after F01)

  • Per-output state is keyed on the resolved outputJsonPath and built lazily; the queue's .then(ok, err) pattern correctly prevents a single failure from poisoning the queue for subsequent writes.
  • issueEmitGeneration is called synchronously in executeContractEmit before any await, which is exactly what makes AC2 robust against provider reordering.
  • publishPairWithRollback renames in [dts, json] order (so contract.json is always the last file to flip) and restores only what it has already replaced — correct and minimal.
  • Temp paths embed process.pid + generation + phase, which avoids cross-run collisions on temp names.
  • Error paths in readExistingArtifact / tryLstat correctly narrow ENOENT via isRecord(error) && error['code'] === 'ENOENT' (no any casts, consistent with AGENTS.md typesafety rules).

Findings

F01 — Remove the managed-publication path (blocker)

packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts — lines 26–32 (ManagedArtifactPaths), 86–166 (managed helpers), 179–225 (bootstrap), 324–393 (writeManagedContractArtifacts), 395–432 (dispatcher), 451–454 (managedPublication selection), 568–577 (call site)
packages/1-framework/3-tooling/vite-plugin-contract-emit/README.md — lines 14–16, 71, 75–95

Issue. Managed publication rewrites the public contract.json and contract.d.ts paths as symlinks into .<stem>.prisma-next-artifacts/current/. That changes the storage format of files the user treats as canonical source, with the follow-on consequences described in the banner above (persistent symlinks after the dev server stops, broken collaborator clones, inconsistent dev/prod behavior in copying tools, broken "the file I see is the file" mental model). No docs change or .gitignore entry makes this acceptable — the only fix is to not do it.

Suggestion. Delete the managed-publication path. Concretely:

  • Remove ManagedArtifactPaths, getManagedArtifactPaths, createManagedGenerationDirName, tryLstat's pointer-swap callers if nothing else needs it, replacePathWithSymlink, ensureSymlinkIfMissing, ensurePublicManagedLinks, bootstrapManagedArtifactsFromExistingOutputs, writeManagedContractArtifacts, and the dispatcher writeContractArtifacts (fold the remaining strategy back inline into executeContractEmit).
  • Remove the const managedPublication = options.signal !== undefined line and the two-strategy branching.
  • Keep queueEmitWrite, issueEmitGeneration, getEmitWriteTargetState, createTempArtifactPath, readExistingArtifact, restoreArtifact, publishPairWithRollback, and the current writePlainContractArtifacts body (renamed — see "Addressed upstream" below).
  • In the vite-plugin README, remove the "Atomic publication" bullet's reference to a "managed generation directory" and the "Swap current pointer" step in the mermaid diagram. Replace with a sentence like "emits stage to temporary files and rename into place as a pair, rolling back to the previous pair on write failure".

The surviving architecture gives you AC2 (generation + queue) and AC3 (publishPairWithRollback) unchanged, and AC1 at the "two sequential renames, DTS first" level of atomicity, which is the relevant level for a dev inner loop. See the AC-verification section below for how this changes the AC1 verdict.

F02 — Silently skipping older superseded emits is a behavioral change worth surfacing

packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts — lines 285–303 (applies equally after F01's collapse)

Issue. Before this PR, every call to executeContractEmit either wrote its emit bytes or threw. After this PR, a superseded call may resolve with a full ContractEmitResult (non-throwing) even though nothing was written to disk. The returned storageHash/profileHash now describe an emit that is not observable on disk. That's fine for the vite plugin (it discards the result in the superseded branch at plugin.ts lines 126–129), but it violates a naive reading of the return type. This applies whether or not F01 is accepted.

Suggestion. Two options:

  1. Encode the outcome in the result — add written: boolean (or publication: 'written' | 'superseded') to ContractEmitResult. Honest and easy to reason about.
  2. If keeping the shape is preferred, document the new semantics on the return type and on the docblock, and add a test that pins "superseded call still resolves successfully with no disk write" (today that's only incidentally covered by the supersession test's await first; line).

F03 — Docblock on executeContractEmit is now inaccurate

packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts — lines 434–450

Issue. The docblock still says "5. Writes files to the paths specified in config", which is only half the story. Even after F01 simplifies things, the operation's post-change contract is:

  • per-output serialization (overlapping emits queue),
  • an older emit may silently no-op if a newer one has already been issued,
  • a failed write restores the previous good pair.

None of those are in the docblock.

Suggestion. Update the docblock to call out the serialization, supersession-skip, and rollback guarantees. Link to the spec once one lands in the repo.

F04 — unlessAborted(mkdir(...)) inconsistent with queue-time abort checks

packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts — lines 568–577

Issue. mkdir(dirname(outputJsonPath), …) on the public output dir is wrapped in unlessAborted(…), while the write path does its own throwIfAborted() inside the queue action. The outer unlessAborted predates the new architecture and is now redundant-ish; it's also slightly off-pattern — the rest of the new code relies on the queue-time check, not a pre-queue check.

Suggestion. Pick one style. Either drop the outer unlessAborted and let the queue action own the abort check consistently, or keep the outer and the inner checks deliberately and say so in a short comment.

F05 — queueEmitWrite hides the intended "generation already superseded → no-op" branch from callers

packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts — lines 70–84, 285–321

Issue. The generation-check — if (generation < state.nextGeneration) return; — is duplicated in writePlainContractArtifacts (and will be the only site after F01's collapse), and happens in two places within the queue action (before temp files, and before publish). The logic is invisible from queueEmitWrite's signature, and the two guard points are easy to drift.

Suggestion. Either extract an isSuperseded(state, generation) helper and call it at both guard points so the intent reads identically, or (more invasive) thread generation through queueEmitWrite explicitly so it can early-return on supersession:

function queueEmitWrite<T>(
  outputJsonPath: string,
  generation: number,
  action: (state: EmitWriteTargetState) => Promise<T>,
): Promise<T | 'superseded'> { ... }

Nice-to-have, not required.

Addressed upstream

Naming & explicit publication strategy (previously a finding on this review)

An earlier draft of this review flagged that (a) the choice of publication strategy was inferred from options.signal !== undefined, and (b) the names "plain" and "managed" described a quality axis rather than a commit mechanism. Both concerns have been consolidated into a separate spec submitted on the PR:

That spec proposed renaming the axis to pair-rename / pointer-swap and making it an explicit option on ContractEmitOptions.

Status after F01. With the managed (pointer-swap) path removed, the spec is itself superseded: there is no longer an axis to name, no option to add, and no strategy-selection coupling to untangle. Keep only the pair-rename implementation inline. The spec's on-PR comment can be closed with a pointer back to F01 here.

Per-output vocabulary clean-up (walkthrough follow-up)

The walkthrough's use of "per-target" (meaning "per output JSON path") collides with the domain's use of "target" (ControlTargetDescriptor, target.targetId). A matching smell exists in the code itself — emitWriteTargets / EmitWriteTargetState / getEmitWriteTargetState use "target" to mean "output destination". Renaming to emitOutputQueues / EmitOutputQueueState / getEmitOutputQueue would pair naturally with F01's simplification. Tracked outside this review; not a blocker.

Deferred (out of scope)

  • Standalone CLI command atomicity. The non-signal CLI command at packages/1-framework/3-tooling/cli/src/commands/contract-emit.ts (lines 195–201) still uses raw writeFileSync, bypassing executeContractEmit entirely. TML-2279 scopes the fix to the dev plugin's call path, and one-shot CLI emits do not exhibit the "rapid successive saves" problem. Worth a follow-up ticket to unify writes. After F01 this becomes simpler — there's only one strategy to share.
  • Windows atomicity semantics. rename onto an open file has different behavior on Windows. TML-2279 does not call Windows out as a supported platform.
  • Cross-process locking. Explicitly out of scope per Linear.

Already addressed

None (initial review round; the rename spec is tracked in "Addressed upstream" above).

Acceptance-criteria verification

These verdicts assume F01 is accepted and the managed-publication path is removed.

AC Verdict Detail
AC1: contract.json and contract.d.ts are replaced as one logical update rather than a split-brain sequence. WEAK With F01 applied, the commit is two sequential renames (DTS then JSON, by design). A reader that samples the pair between the two renames can observe {new_dts, old_json}. For the dev-inner-loop use case this is well below any observable window. Strict atomicity of both files at the exact same instant is not achievable without changing the user-visible output shape (directory renames, symlink flips, or similar), and the only implementation of strict atomicity in the PR as submitted (pointer-swap) is being rejected on other grounds (F01). Recommendation: update the spec (and Linear) to reword AC1 from "replaced as one logical update" to something like "ordered commit where DTS is in place before JSON, such that any consumer that gates on contract.json existence sees matching types", and keep the current test as evidence.
AC2: A superseded emit cannot overwrite a newer successful emit. PASS "keeps the newer generation on disk when an older emit finishes later" (contract-emit.test.ts lines 308–335) drives two overlapping emits, resolves them in reverse order, and asserts the on-disk bytes match the newer createEmitResult('newer') — byte-level, for both files. The implementation advances nextGeneration synchronously in issueEmitGeneration (contract-emit.ts line 493) and guards at publish time (lines 288–290, 301–303). Unchanged by F01.
AC3: A failed emit leaves the previous good artifacts intact. PASS "preserves the previous artifacts when a new emit write fails" (lines 337–366) seeds a previous pair, forces the .d.ts write to throw, and byte-compares the on-disk content against the seeded previousJson/previousDts. "keeps the last good artifacts when a newer request fails after superseding an older emit" (lines 368–408) covers the composite case where the newer emit fails after advancing the generation, locking out the still-in-flight older emit — again with byte-level assertions. Both tests drive the pair-rename path and therefore survive F01 unchanged.
AC4: The dev plugin can keep using the same executeContractEmit() control path after the hardening. NOT VERIFIED The plugin source at plugin.ts lines 115–124 still calls executeContractEmit({ configPath, signal }) — no plugin-side changes required. The ContractEmitOptions / ContractEmitResult shape in types.ts lines 518–543 is unchanged. Type-level compatibility is real. There is no integration or E2E test exercising the plugin's full flow against the new executeContractEmit, so "it compiles and re-exports" is the only evidence. Given TML-2279 calls this out as an acceptance criterion, an E2E or plugin-level test that runs a dev-style scenario (two rapid emits, assert on the output pair) would close this gap.

Summary

Result Count ACs
PASS 2 AC2, AC3
FAIL 0
NOT VERIFIED 1 AC4
WEAK 1 AC1

Note on missing integration/E2E coverage. AC1 and AC4 are end-to-end claims. The current coverage is entirely mock-based at the control-api boundary. A small integration test at packages/1-framework/3-tooling/vite-plugin-contract-emit/test/plugin.test.ts that drives emitContract() twice in quick succession against a real temp directory and inspects the final pair would materially strengthen both ACs and is in scope for a PR that claims to deliver them. Safe to land as fast-follow once F01 is resolved.

Note on AC1's wording. Strict "replaced as one logical update" requires atomic multi-file commit, which the filesystem doesn't offer without changing the user-visible output shape. The PR's current answer to that (pointer-swap) is rejected by F01. A narrower, achievable AC is proposed in the table above.

@wmadden
Copy link
Copy Markdown
Contributor

wmadden commented Apr 22, 2026

System / Solution Design Review — PR #365

Scope: tml-2279-atomic-emit-writes vs origin/tml-2277-vite-plugin-watch-behavior (the explicit PR base — not main). Grounded in the reviewer-inferred spec.md, Linear TML-2279, and the in-tree design docs referenced by packages/1-framework/3-tooling/vite-plugin-contract-emit/README.md (ADR 032, ADR 008, Subsystem 2).


Design outcome: the "managed" / pointer-swap strategy should not land

The two-strategy design hinges on one of those strategies (managed / pointer-swap) silently rewriting the user-configured contract.json and contract.d.ts as symlinks into a hidden sibling directory. That's a category error for a dev plugin: the files at the user's configured output paths are part of the external surface of the project (imported by app code, read by tsc and bundlers, committed to VCS), not the plugin's private staging area. The cost (persistent-after-dev symlinks, broken collaborator clones, inconsistent copy-vs-follow tooling behavior, "the file I see is the file" mental-model violation) is disproportionate to the upside (closing a sub-millisecond window between two rename calls inside a dev inner loop).

Required change before the architecture can land: drop the managed/pointer-swap strategy entirely. Keep the rest of the hardening — per-output serial queue, synchronous generation issuance, temp-file staging, and publishPairWithRollback — as the single publication strategy. Details in D1.


What the PR changes at the design level

executeContractEmit previously ended with two unguarded writeFile calls on the public artifact paths: packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts. In a dev loop where saves overlap, this is racy in two ways — interleaving between the JSON and DTS writes, and an older emit finishing after a newer one.

The PR introduces two publication strategies inside this single operation, selected by whether a caller provided an AbortSignal:

  • Plain publication (no signal): stage tmpJson + tmpDts under the output directory, then rename both into place inside a per-output serial queue, with a rollback step if either rename fails.
  • Managed publication (signal present): maintain a hidden <output>/.<stem>.prisma-next-artifacts/ area with generations/g-<N>/ directories and a current symlink. The public artifacts become symlinks into current/. A new emit materializes generations/g-<N>/, then atomically re-points current via a rename of a staged symlink.

Both strategies share per-output coordination state: a Map<outputJsonPath, { nextGeneration, queue }> with an atomic "issue generation" step run synchronously in executeContractEmit before awaiting the source provider.

This review is scoped around the banner above. The one-strategy simplification that follows from D1 is treated as given; remaining concerns are evaluated against that simplified shape.

Subsystem fit (after D1)

  • CLI control-api boundary. The change is scoped to the executeContractEmit operation in the cli's control-api layer. It does not leak into ControlClient.emit() (which remains a pure "produce bytes, return Result" surface). That is correct: the atomic-write responsibility belongs with the tool that actually lands files on disk.
  • Dev plugin boundary. The vite plugin already calls executeContractEmit({ configPath, signal }) with a per-save signal and retains responsibility for cancelling older emits; the operation adds an orthogonal per-output serialization + generation check. With the pointer-swap strategy gone, the plugin no longer relies on any signal-specific disk shape — the signal goes back to being purely about cancellation.
  • Contract subsystem invariant. Consumers of contract.json + contract.d.ts expect a matched pair (hashes in JSON are typed by DTS). After D1 the invariant is held by ordered pair-rename (DTS first, JSON last) plus rollback on failure. That is not literally atomic, but it is atomic at the granularity the consumer observes — any reader that gates on contract.json existence sees the matching types already in place. See D3 for the implication on AC1's wording.

Boundary correctness

  • No domain/layer violations introduced. Imports stay within node:fs/promises and pathe, consistent with use-pathe-for-paths and existing conventions in the package.
  • State is module-level. emitWriteTargets is a hidden module-level Map. Acceptable for a process-local serialization primitive, but note:
    • It ties per-output state to the module instance, so any transitive re-import into a different realm would duplicate state. Not an issue in practice (dev plugin loads via Vite/CLI), but worth calling out.
    • Entries are never pruned. Benign for the dev-server lifecycle; worth a comment noting the intent.
  • Naming. The map's own vocabulary (emitWriteTargets, EmitWriteTargetState, getEmitWriteTargetState) uses "target" to mean "output JSON path", which collides with the domain's use of "target" (ControlTargetDescriptor, target.targetId). Rename to emitOutputQueues / EmitOutputQueueState / getEmitOutputQueue in the same pass as D1's simplification.

Design-level concerns

D1 — Reject the managed / pointer-swap publication strategy (blocker)

packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts — lines 26–32 (ManagedArtifactPaths), 86–166 (managed helpers, symlink management), 179–225 (bootstrap), 324–393 (writeManagedContractArtifacts), 395–432 (dispatcher), 451–454 (signal-based selection), 568–577 (call site)

Issue. Managed publication's commit mechanism is "flip a symlink called current", which requires the public contract.json and contract.d.ts to themselves be symlinks into the managed root. That makes the files at the user-configured output paths no longer regular files. The follow-on effects:

  • Persistence past the dev session. Stopping the dev server does not revert the public paths to regular files. The symlinks remain until someone manually rms them and re-runs a non-managed emit.
  • VCS breakage. Git records the symlinks as mode 120000; a collaborator who clones and hasn't run pnpm dev sees contract.json as a symlink whose target does not exist on their machine. CI that reads contract.json breaks.
  • Dev/prod divergence in tooling. Most tools follow symlinks (tsc, Vite, import … with { type: 'json' }), so local dev "works". Tools that copy (Docker builds, pnpm pack, vendored copies) may ship the symlink or the target inconsistently.
  • Mental-model cost. The user's canonical contract is now a three-level indirection (contract.jsoncurrentg-<N>/contract.json). Inspecting the file in an editor no longer answers "what is my contract?".

All of this is in exchange for closing a sub-millisecond window between two rename calls — a window below any observable threshold in a dev inner loop whose only consumer is Vite's own HMR, which is already debouncing.

Recommendation. Remove the managed-publication path. Concretely:

  • Delete ManagedArtifactPaths, getManagedArtifactPaths, createManagedGenerationDirName, replacePathWithSymlink, ensureSymlinkIfMissing, ensurePublicManagedLinks, bootstrapManagedArtifactsFromExistingOutputs, writeManagedContractArtifacts, and the writeContractArtifacts dispatcher (inline the remaining body into executeContractEmit).
  • Remove the const managedPublication = options.signal !== undefined line and the two-strategy branching.
  • Keep queueEmitWrite, issueEmitGeneration, getEmitWriteTargetState, createTempArtifactPath, readExistingArtifact, restoreArtifact, publishPairWithRollback, and the current writePlainContractArtifacts body (rename it to match the new one-strategy-only reality).
  • Update the vite-plugin README to drop the "managed generation directory" language and the "Swap current pointer" step in the mermaid flow.

A theoretical alternative that preserves strict multi-file atomicity without symlinking public files is to emit into a directory at the output path and rename a staged directory over it. That would require reshaping the public output API (the user's contract.json path stops being a file and starts being a directory), which is a much larger change and out of scope for TML-2279.

D2 — nextGeneration advances before the write phase, so failed newer emits silently skip older ones (medium)

packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts — lines 64–68 (issueEmitGeneration), 493 (call site), 285–303 (guard under pair-rename)

Issue. issueEmitGeneration is called synchronously in executeContractEmit before source.load, validation, or emit. That is what makes AC2 hold even if the provider pipeline reorders. But it also means a newer emit that fails in its pipeline still permanently advances nextGeneration, causing any concurrently-in-flight older emit to be skipped at publish time even though it produced a valid result.

This scenario does not arise from the vite plugin today (the plugin aborts the older emit before issuing a new one), but it is a precondition of correctness for any other caller. It survives D1's simplification unchanged — the generation counter is part of the surviving pair-rename path.

Recommendation. Document this precondition on executeContractEmit (and on ContractEmitOptions.signal): "callers must cancel any in-flight emit to the same output before starting a new one; otherwise a newer failed emit may drop an older successful emit." Alternatively, only advance nextGeneration on successful commit (would change the synchronous-monotonic property the design currently relies on — non-trivial trade-off, not recommended).

D3 — AC1's strict "as one logical update" wording is not achievable without reshaping the public API

spec.md — acceptance criteria

Issue. AC1 as written ("contract.json and contract.d.ts are replaced as one logical update rather than a split-brain sequence") can only be literally satisfied at the POSIX filesystem level by something like the rejected pointer-swap strategy (symlink flip) or by reshaping the public output into a directory (out of scope, per D1). With pair-rename as the only surviving strategy, AC1 is formally WEAK — a reader sampling the pair between the DTS rename and the JSON rename can observe { new_dts, old_json } for a sub-millisecond window.

In the context that TML-2279 actually cares about (dev inner loop, Vite HMR as the only consumer), that window is well below any observable threshold and the relevant invariant is the ordered one: "if a consumer gates on contract.json existence, the matching types are already in place".

Recommendation. Reword AC1 in the spec and Linear ticket to something like: "contract.json is the last file in the pair to flip; any reader that gates on contract.json existence observes matching contract.d.ts types already in place, and a failed write leaves the previous good pair intact." That is achievable by pair-rename, is what consumers actually care about, and is testable. Keep the existing snapshot-invariance test idea as evidence for the ordered version.

Addressed upstream

Strategy-selection coupling and naming (previously design-level concerns)

An earlier version of this review raised two adjacent concerns:

  • "Publication strategy is keyed to signal !== undefined" — signal was doing double duty as cancellation and as on-disk-shape selection, with no way to opt out on either side.
  • Naming: "plain" and "managed" described a quality axis rather than a commit mechanism.

Both were consolidated into a separate rename spec submitted on the PR (wip/specs/contract-emit-publication-rename/spec.md in this worktree), which proposed naming the axis pair-rename / pointer-swap and exposing it as an explicit option on ContractEmitOptions.

Status after D1. With pointer-swap removed, the rename spec is itself superseded: there is no axis to name, no option to add, and no strategy-selection coupling to untangle. The on-PR comment for that spec can be closed with a pointer back to D1 here. The emitOutputQueues rename noted under "Boundary correctness" is the only part of the rename idea that remains relevant.

Collapsed by D1 (no longer concerns)

These were earlier design-level concerns whose sole cause was the managed-publication path. D1 removes them:

  • Per-process generation counter colliding with on-disk generations/g-<N>/ across restarts. No generations/ directory after D1; no collision possible.
  • Two coexisting atomicity strategies inside one operation. One strategy only after D1.
  • ensureSymlinkIfMissing not reconciling a wrong-target symlink. No symlinks in the commit path after D1.
  • Hidden .<stem>.prisma-next-artifacts/ directory needing a README / .gitignore entry. Directory not created after D1.

Test strategy adequacy at the architectural level (post-D1)

The new control-api tests in packages/1-framework/3-tooling/cli/test/control-api/contract-emit.test.ts materially cover the surviving surface:

  • AC2 (supersession): covered by the two-emit, reverse-resolution test.
  • AC3 (rollback on write failure): covered by both the "simulated dts write failure" test and the composite "newer fails after superseding older" test.
  • AC1 (under its proposed re-wording, D3): partly covered — the "no split artifacts during commit" test currently drives the pointer-swap path, but its mock-rename-snapshot technique is trivially reusable against pair-rename and should be after D1.

Remaining architectural gaps:

  • No plugin-level or integration test. AC4 is implicitly covered by "the plugin still compiles". A small integration test at packages/1-framework/3-tooling/vite-plugin-contract-emit/test/plugin.test.ts that drives emitContract() twice in quick succession against a real temp directory and inspects the final pair would materially strengthen AC1 and AC4 end-to-end.
  • No test for the "precondition" in D2. Worth adding one that drives two overlapping emits where the newer fails in its pipeline (before reaching the queue) and asserts that the older successful emit still writes — unless D2's precondition is instead documented and the test is the documentation.

Summary

The PR moves the right invariants (monotonic publication, rollback on failure, serialized per-output writes) into the right layer (executeContractEmit). The pair-rename path is a clean minimal hardening of the previous write logic and is worth keeping. The managed / pointer-swap path reaches for an atomicity target that the dev inner loop does not need, at a cost (mutating the user's canonical source files into symlinks) that no dev-time plugin should pay. Landing the PR requires D1 — delete the pointer-swap path and ship the surviving pair-rename hardening as the single strategy — plus the AC1 re-wording from D3 and the precondition documentation from D2. Everything else resolves by collapse.

};
}

function createDeferred<T>() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can use Promise.withResolvers() instead

() => action(state),
() => action(state),
);
state.queue = run.then(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the point of this? Why not just state.queue = run?

Base automatically changed from tml-2277-vite-plugin-watch-behavior to main April 22, 2026 13:43
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts (1)

181-195: ⚠️ Potential issue | 🟡 Minor

Skip dependency warnings for superseded bytes.

When publication === 'superseded', these declarations were not written, so warning about their imports can confuse Vite/control-api callers during rapid saves.

🔧 Proposed fix
   const publication = await publishContractArtifactPairSerialized({
     outputJsonPath,
     outputDtsPath,
     generation,
     signal,
     contractJson: emitResult.contractJson,
     contractDts: emitResult.contractDts,
   });
 
-  const { validateContractDeps } = await import('../../utils/validate-contract-deps');
-  const depsValidation = validateContractDeps(emitResult.contractDts, dirname(outputDtsPath));
-  if (depsValidation.warning) {
-    process.stderr.write(`\n⚠ ${depsValidation.warning}\n`);
+  if (publication === 'written') {
+    const { validateContractDeps } = await import('../../utils/validate-contract-deps');
+    const depsValidation = validateContractDeps(emitResult.contractDts, dirname(outputDtsPath));
+    if (depsValidation.warning) {
+      process.stderr.write(`\n⚠ ${depsValidation.warning}\n`);
+    }
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts`
around lines 181 - 195, When publishContractArtifactPairSerialized returns
publication === 'superseded', skip running validateContractDeps and avoid
emitting depsValidation.warning; update the control flow around the
publishContractArtifactPairSerialized call (variable publication) so that
validateContractDeps(emitResult.contractDts, dirname(outputDtsPath)) is only
executed and its warning written to stderr when publication is not 'superseded',
ensuring callers aren't shown import warnings for declarations that were not
written.
packages/1-framework/3-tooling/vite-plugin-contract-emit/test/plugin.test.ts (1)

457-463: ⚠️ Potential issue | 🟡 Minor

Update this stale emit mock to include publication.

This test still returns the old result shape, while the rest of the file now uses createContractEmitResult() with publication. Reuse the helper here so the fallback path exercises the current control-api contract.

🧪 Proposed fix
-      mockedExecuteContractEmit.mockResolvedValue({
-        storageHash: 'abc123',
-        profileHash: 'def456',
-        files: { json: '/out/contract.json', dts: '/out/contract.d.ts' },
-      });
+      mockedExecuteContractEmit.mockResolvedValue(createContractEmitResult());
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/1-framework/3-tooling/vite-plugin-contract-emit/test/plugin.test.ts`
around lines 457 - 463, The test "falls back to watching the config file and
warns when loadConfig fails" uses a stale mockedExecuteContractEmit result
shape; update the mock to return the current shape by calling or reusing
createContractEmitResult(...) and include a valid publication object (e.g., with
needed fields) so the fallback path exercises the control-api contract; replace
the hardcoded storageHash/profileHash/files object with
createContractEmitResult({ publication: { ... } }) or equivalent to match other
tests.
🧹 Nitpick comments (2)
packages/1-framework/3-tooling/cli/test/utils/publish-contract-artifact-pair.test.ts (1)

110-110: Remove the hardcoded test timeouts.

These unit tests should rely on the default timeout or a shared timeout helper instead of raw 1000 values. As per coding guidelines, “Use shared timeouts helper functions instead of raw timeout numbers in tests”.

🧹 Proposed cleanup
-  }, 1000);
+  });

Apply the same removal to the other two it(..., 1000) calls.

Also applies to: 143-143, 192-192

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/1-framework/3-tooling/cli/test/utils/publish-contract-artifact-pair.test.ts`
at line 110, Three tests in publish-contract-artifact-pair.test.ts are using a
hardcoded timeout value (the trailing , 1000) on their it(...) calls; remove the
raw numeric timeout and use the shared test timeout helper (or rely on the
default Jest timeout). Specifically, edit the three it(...) invocations that end
with ", 1000" so they no longer pass the literal 1000; instead import and apply
the project's shared timeouts helper (or simply omit the timeout parameter) for
the tests that wrap the async assertions.
packages/1-framework/3-tooling/cli/test/control-api/contract-emit.test.ts (1)

431-443: Assert the older emit reports superseded here too.

This test is the closest coverage for the “newer request fails after superseding an older emit” precondition; asserting firstResult.publication would lock in the return semantics, not just the final disk state.

🧪 Proposed test tightening
       await expect(second).rejects.toThrow('simulated newer generation write failure');
 
       firstEmit.resolve(createEmitResult('older'));
-      await first;
+      const firstResult = await first;
+
+      expect(firstResult.publication).toBe('superseded');
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/1-framework/3-tooling/cli/test/control-api/contract-emit.test.ts`
around lines 431 - 443, The test must also assert that the older emit returns a
publication status of "superseded": change the final await of the first emit to
capture its resolved value (e.g. const firstResult = await first) and add an
assertion like expect(firstResult.publication).toBe('superseded') after
firstEmit.resolve(createEmitResult('older')) and before exiting the
withMockedConfig block; this involves updating the executeContractEmit usage and
the final await so the returned result from executeContractEmit can be
inspected.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts`:
- Around line 48-50: Update the docblock in contract-emit.ts to avoid
guaranteeing rollback; change the sentence that currently reads "and restores
the previous pair if publication fails after either path has been replaced" to a
best-effort phrasing such as "and attempts to restore the previous pair on
failure" (or similar), so the documentation reflects that rollback is
best-effort and may itself fail; locate the comment around the
publication/rename behavior in the contract-emit module to make this single-line
wording change.

In
`@packages/1-framework/3-tooling/cli/src/utils/publish-contract-artifact-pair.ts`:
- Around line 114-127: Re-check the beforePublish callback after taking rollback
snapshots to avoid TOCTOU races: after calling readExistingArtifact
(previousJson/previousDts) but before invoking publishPairWithRollback, call
beforePublish() again and if it returns false, abort (return false) so we don't
overwrite the newer emit with an older one; update the logic around the existing
beforePublish check and the publishPairWithRollback call in
publish-contract-artifact-pair.ts to perform this secondary check.

In
`@packages/1-framework/3-tooling/cli/test/commands/contract-emit.command.test.ts`:
- Around line 72-74: The test "returns an error envelope when publication is
superseded" currently uses a stable tmpdir-derived path via join(tmpdir(), ...),
leaving dirname(outputPath) behind; change it to create a unique per-test temp
directory using fs.mkdtemp (or mkdtempSync) and set outputPath inside that
directory, then ensure the test removes that temp directory in a finally block
or via afterEach cleanup so dirname(outputPath) is removed; locate the
outputPath and any logic that calls dirname(outputPath) in this test to replace
the tmpdir() usage and add cleanup.

In `@packages/1-framework/3-tooling/vite-plugin-contract-emit/README.md`:
- Line 15: The README's "Ordered pair publication" line promises guaranteed
restoration; change the wording to indicate best-effort rollback — e.g., replace
"and restore the last good pair if publication fails" with language like "and
attempts to restore the last good pair if publication fails (rollback is
best-effort and may fail if rollback I/O errors occur)"; update the same
phrasing wherever it appears (the "Ordered pair publication" line referencing
renaming contract.d.ts before contract.json).

---

Outside diff comments:
In
`@packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts`:
- Around line 181-195: When publishContractArtifactPairSerialized returns
publication === 'superseded', skip running validateContractDeps and avoid
emitting depsValidation.warning; update the control flow around the
publishContractArtifactPairSerialized call (variable publication) so that
validateContractDeps(emitResult.contractDts, dirname(outputDtsPath)) is only
executed and its warning written to stderr when publication is not 'superseded',
ensuring callers aren't shown import warnings for declarations that were not
written.

In
`@packages/1-framework/3-tooling/vite-plugin-contract-emit/test/plugin.test.ts`:
- Around line 457-463: The test "falls back to watching the config file and
warns when loadConfig fails" uses a stale mockedExecuteContractEmit result
shape; update the mock to return the current shape by calling or reusing
createContractEmitResult(...) and include a valid publication object (e.g., with
needed fields) so the fallback path exercises the control-api contract; replace
the hardcoded storageHash/profileHash/files object with
createContractEmitResult({ publication: { ... } }) or equivalent to match other
tests.

---

Nitpick comments:
In `@packages/1-framework/3-tooling/cli/test/control-api/contract-emit.test.ts`:
- Around line 431-443: The test must also assert that the older emit returns a
publication status of "superseded": change the final await of the first emit to
capture its resolved value (e.g. const firstResult = await first) and add an
assertion like expect(firstResult.publication).toBe('superseded') after
firstEmit.resolve(createEmitResult('older')) and before exiting the
withMockedConfig block; this involves updating the executeContractEmit usage and
the final await so the returned result from executeContractEmit can be
inspected.

In
`@packages/1-framework/3-tooling/cli/test/utils/publish-contract-artifact-pair.test.ts`:
- Line 110: Three tests in publish-contract-artifact-pair.test.ts are using a
hardcoded timeout value (the trailing , 1000) on their it(...) calls; remove the
raw numeric timeout and use the shared test timeout helper (or rely on the
default Jest timeout). Specifically, edit the three it(...) invocations that end
with ", 1000" so they no longer pass the literal 1000; instead import and apply
the project's shared timeouts helper (or simply omit the timeout parameter) for
the tests that wrap the async assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: afc2ff97-a8b7-4da6-92e0-e256a59295bc

📥 Commits

Reviewing files that changed from the base of the PR and between 5a8b0f1 and 355e3fb.

📒 Files selected for processing (12)
  • packages/1-framework/3-tooling/cli/src/commands/contract-emit.ts
  • packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts
  • packages/1-framework/3-tooling/cli/src/control-api/types.ts
  • packages/1-framework/3-tooling/cli/src/utils/publish-contract-artifact-pair-serialized.ts
  • packages/1-framework/3-tooling/cli/src/utils/publish-contract-artifact-pair.ts
  • packages/1-framework/3-tooling/cli/test/commands/contract-emit.command.test.ts
  • packages/1-framework/3-tooling/cli/test/control-api/contract-emit.test.ts
  • packages/1-framework/3-tooling/cli/test/utils/publish-contract-artifact-pair.test.ts
  • packages/1-framework/3-tooling/vite-plugin-contract-emit/README.md
  • packages/1-framework/3-tooling/vite-plugin-contract-emit/src/plugin.ts
  • packages/1-framework/3-tooling/vite-plugin-contract-emit/test/plugin.test.ts
  • test/integration/test/cli.emit-command.e2e.test.ts

Comment thread packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts Outdated
Comment thread packages/1-framework/3-tooling/cli/src/utils/publish-contract-artifact-pair.ts Outdated
Comment thread packages/1-framework/3-tooling/vite-plugin-contract-emit/README.md Outdated
jkomyno and others added 5 commits April 22, 2026 16:39
Address SevInf review feedback: drop the single-use toQueueTail wrapper
and assign the in-flight promise directly. Subsequent waiters already
handle both fulfillment and rejection branches, so the noop tail was
redundant. Type the queue field as Promise<unknown> so the assignment
type-checks.
Trim ~230 LOC of duplication and boilerplate without losing coverage:

- Drop three integration tests that re-proved publishContractArtifactPair
  atomic semantics already covered by its unit tests:
  preserves-on-write-failure, restores-on-rename-failure, and
  publishes-dts-before-json. The "supersedes/superseded" tests in the
  integration suite still exercise that the helper is wired in.
- Table-drive the four near-identical source-provider validation tests
  via describe.each, parameterized by source payload, expected error
  code, and expected substring.
- Extract a seedPreviousArtifacts helper in the publish-contract-
  artifact-pair test to remove the repeated mkdir + writeFile preamble,
  and lift the previous/next fixture strings to module-level constants.
Both concurrent-emit tests assumed that the synchronously-first executeContractEmit call would also win the issueContractArtifactGeneration race. Under turbo's parallel workers that ordering is not guaranteed, and a swap leaves the "newer" call holding generation 1 — bypassing the .2.next.tmp write-failure mock and flipping the test's expectations.

Wait for the first call to reach emit() before invoking the second so the queue assigns generations 1 and 2 in the expected order, and re-import actualFs locally inside the failing-write test (matching the prior pattern that survived parallel load).
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (7)
packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts (1)

105-106: Eager generation claim widens the D2 supersession window (documented tradeoff).

issueContractArtifactGeneration is called here before source.load and emit run, so a later caller that races through a faster pipeline can easily claim generation + 1 while this call is still in source.load. The docblock at lines 56-58 documents this correctly; flagging only because moving this line to just before publishContractArtifactPairSerialized(...) would narrow the window substantially without changing public semantics (the serialized publisher still enforces per-path ordering on enqueue). Leave as-is if the current ordering is an intentional serialization point for queueing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts`
around lines 105 - 106, The call to
issueContractArtifactGeneration(outputJsonPath) happens before source.load and
emit, which unnecessarily widens the D2 supersession window; move the invocation
of issueContractArtifactGeneration (currently assigned to generation) from just
after destructuring outputPaths to immediately before
publishContractArtifactPairSerialized(...) so the generation claim happens right
before enqueueing the serialized artifact, narrowing the race window while
preserving public semantics; keep references to outputJsonPath/outputDtsPath and
ensure source.load and emit run first, then call
issueContractArtifactGeneration, then call
publishContractArtifactPairSerialized.
packages/1-framework/3-tooling/vite-plugin-contract-emit/test/plugin.test.ts (4)

1-921: File size exceeds the 500-line test-file guideline; consider splitting.

plugin.test.ts is ~920 lines and covers distinct concerns (configResolved, configureServer setup, handleHotUpdate, error handling, concurrency/supersession). A split along the top-level describe blocks — e.g., plugin.configure-server.test.ts, plugin.hot-update.test.ts, plugin.errors.test.ts, plugin.concurrency.test.ts — would match the coding guideline and keep the new overlapping-emit coverage easier to evolve. This PR only adds to the file; a preparatory split can be done separately.

As per coding guidelines: "Keep test files under 500 lines to maintain readability and navigability. If a test file exceeds this limit, it should be split into multiple files."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/1-framework/3-tooling/vite-plugin-contract-emit/test/plugin.test.ts`
around lines 1 - 921, The test file is too large; split plugin.test.ts into
multiple focused test files by moving each top-level describe block into its own
file (for example: move "configResolved" tests to
plugin.config-resolved.test.ts, "configureServer" to
plugin.configure-server.test.ts, "handleHotUpdate" and debounce/concurrency
tests to plugin.hot-update.test.ts, and "error handling"/overlay tests to
plugin.errors.test.ts), and factor shared helpers (createMockServer,
createLoadedConfig, createContractEmitResult, applyModuleGraph, eventually,
mocks/vi.setup) into a new test-utils module that each new test file imports;
update each new file to import prismaVitePlugin and the shared utilities and
preserve existing vi.mock calls so behavior is unchanged.

769-826: Good coverage for overlapping emits; verify the abort assertion timing.

This test nicely closes the plugin-level gap the reviewer flagged (AC4). One thing worth confirming: the assertion expect(firstSignal?.aborted).toBe(true) at line 817 relies on the plugin cancelling the in-flight emit synchronously when the second handleHotUpdate fires and its debounce elapses. If the plugin were ever changed to defer cancellation (e.g., schedule a microtask), this could become a flake — consider guarding with eventually() as a hardening step.

-      expect(mockedExecuteContractEmit).toHaveBeenCalledTimes(2);
-      expect(firstSignal?.aborted).toBe(true);
+      expect(mockedExecuteContractEmit).toHaveBeenCalledTimes(2);
+      await eventually(() => {
+        expect(firstSignal?.aborted).toBe(true);
+      });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/1-framework/3-tooling/vite-plugin-contract-emit/test/plugin.test.ts`
around lines 769 - 826, The assertion that the first emit's AbortSignal is
aborted is timing-sensitive; replace the direct synchronous check of
firstSignal.aborted (the expect at the line asserting firstSignal?.aborted
toBe(true)) with a guarded assertion using eventually() to poll until
firstSignal?.aborted becomes true to avoid flakiness when handleHotUpdate or
cancellation becomes asynchronous; update the test around the handleHotUpdate
calls / mockedExecuteContractEmit checks to use eventually(() =>
expect(firstSignal?.aborted).toBe(true)) while keeping the rest of the flow
(handleHotUpdate, advanceTimersByTimeAsync, firstEmit/secondEmit resolution, and
ws.send assertions) intact.

91-103: eventually helper is duplicated with contract-emit.test.ts.

The same polling helper (body identical, 20 attempts, awaits Promise.resolve() between checks) is defined in packages/1-framework/3-tooling/cli/test/control-api/contract-emit.test.ts (lines 72-84). Extract into a shared test util (e.g., under packages/1-framework/3-tooling/cli/test/utils/eventually.ts or a shared test-utils package) and import from both call sites.

Also consider using vi.waitFor(assertion, { timeout, interval }) from Vitest, which already provides this semantics with configurable timing and better failure diagnostics.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/1-framework/3-tooling/vite-plugin-contract-emit/test/plugin.test.ts`
around lines 91 - 103, The helper function eventually is duplicated; extract the
function eventually into a shared test util (e.g., create an exported helper
eventually in a new test utils module) and replace the local definitions in both
plugin.test.ts and contract-emit.test.ts with imports from that module, or
alternatively replace calls to eventually with Vitest's vi.waitFor(assertion, {
timeout, interval }) for better diagnostics; ensure the exported symbol is named
eventually so existing call sites need only update their imports.

34-40: Mocking pathe to re-export node:path is unnecessary.

pathe is a drop-in replacement for node:path with an identical API, so the production code under test (plugin.ts, executeContractEmit) can run against the real pathe in a Node test environment without any divergence from node:path behaviour for the paths used here (all POSIX). Dropping this mock removes a source-of-truth divergence between tests and runtime.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/1-framework/3-tooling/vite-plugin-contract-emit/test/plugin.test.ts`
around lines 34 - 40, Remove the unnecessary vi.mock that re-exports node:path
for 'pathe' in the test: delete the vi.mock(...) block so the tests use the real
'pathe' implementation. Ensure the test still imports/run the code under test
(executeContractEmit and any helpers in plugin.ts) normally and that no other
mocks for 'pathe' remain; run the tests to verify behavior unchanged.
packages/1-framework/3-tooling/cli/test/utils/publish-contract-artifact-pair.test.ts (1)

160-178: Optional: capture-and-assert reads more naturally than rejects.toSatisfy + inner expect.

The inner expect() calls inside toSatisfy work (they throw on mismatch, so Vitest surfaces the right failure), but capturing the rejection and asserting on it is a bit more direct and avoids the return true dance. Not blocking.

✏️ Suggested alternative
-    await expect(
-      publishContractArtifactPair({
-        outputJsonPath,
-        outputDtsPath,
-        contractJson: NEXT_JSON,
-        contractDts: NEXT_DTS,
-        publicationToken: 'publish',
-      }),
-    ).rejects.toSatisfy((error: unknown) => {
-      expect(error).toBe(publishError);
-      expect(error).toBeInstanceOf(Error);
-      expect((error as Error & { cause?: unknown }).cause).toBeInstanceOf(AggregateError);
-      expect((error as Error & { cause?: AggregateError }).cause?.errors).toEqual([rollbackError]);
-      return true;
-    });
+    const caught = await publishContractArtifactPair({
+      outputJsonPath,
+      outputDtsPath,
+      contractJson: NEXT_JSON,
+      contractDts: NEXT_DTS,
+      publicationToken: 'publish',
+    }).then(
+      () => undefined,
+      (error: unknown) => error,
+    );
+    expect(caught).toBe(publishError);
+    const cause = (caught as Error & { cause?: unknown }).cause;
+    expect(cause).toBeInstanceOf(AggregateError);
+    expect((cause as AggregateError).errors).toEqual([rollbackError]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/1-framework/3-tooling/cli/test/utils/publish-contract-artifact-pair.test.ts`
around lines 160 - 178, Replace the rejects.toSatisfy pattern in the
publishContractArtifactPair test with an explicit capture of the thrown error
(try/catch or await expect(...).rejects to capture via a variable) and perform
direct assertions on that captured error: assert equality to publishError,
assert instance of Error, assert that (error as Error & { cause?: unknown
}).cause is an instance of AggregateError, and assert that (error as Error & {
cause?: AggregateError }).cause?.errors equals [rollbackError]; keep the
subsequent file-read assertions the same. Ensure you reference the same call to
publishContractArtifactPair and the same local variables (outputJsonPath,
outputDtsPath, NEXT_JSON, NEXT_DTS, publishError, rollbackError) when replacing
the rejects.toSatisfy block.
packages/1-framework/3-tooling/cli/test/control-api/contract-emit.test.ts (1)

310-317: Brittle match on .2.next.tmp; also redundant localFs.

Two small concerns:

  1. The substring '.2.next.tmp' bakes in both the tmp-file naming convention and the assumption that the second emit gets generation 2. The latter holds because each test uses a fresh mkdtemp (so issueContractArtifactGeneration starts at 1 per outputJsonPath) and the publicationToken is stringified as-is. A refactor of the generation counter (start at 0, UUID token, different phase suffix, etc.) would silently break this test without changing observable behaviour. Consider deriving the expected token from the known second-call generation, or at minimum leaving a comment explaining the 2 and next.tmp derivation.
  2. localFs at line 310 is the same module already captured as actualFs in beforeEach (line 145). Reuse actualFs here.
-    const localFs = await vi.importActual<FsModule>('node:fs/promises');
     mockedWriteFile.mockImplementation(async (...args: Parameters<FsWriteFile>) => {
       const [path] = args;
-      if (String(path).includes('.2.next.tmp')) {
+      // Token comes from `publishContractArtifactPairSerialized` which passes
+      // `String(generation)`; per-path generation starts at 1 in a fresh tmpdir,
+      // so the second emit's tmp file contains '.2.next.tmp'.
+      if (String(path).includes('.2.next.tmp')) {
         throw new Error('simulated newer generation write failure');
       }
-      return localFs.writeFile(...args);
+      return actualFs.writeFile(...args);
     });
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts`:
- Around line 191-197: The dynamic import of validateContractDeps (used inside
the publication === 'written' branch and called with emitResult.contractDts and
dirname(outputDtsPath)) is unnecessary—either make the import a top-level import
(import { validateContractDeps } from '../../utils/validate-contract-deps') for
clarity, or if you intentionally deferred loading to avoid work when publication
!== 'written' add a one-line comment above the dynamic import explaining the
laziness intent; apply the same change to the identical pattern in the other
contract-emit usage so both places consistently use a top-level import or
clearly-documented lazy import.

In
`@packages/1-framework/3-tooling/vite-plugin-contract-emit/test/plugin.test.ts`:
- Around line 7-17: Extract the duplicated ambient declaration into a single
shared test-declaration file named promise-with-resolvers.d.ts that exports the
PromiseResolvers<T> interface and the global declaration for
Promise.withResolvers<T>(); remove the duplicate blocks from both test files
(the PromiseResolvers<T> interface and the declare global { interface
PromiseConstructor { withResolvers<T>(): PromiseResolvers<T>; } }); then add a
reference to that shared declaration from each test tsconfig (or include it in
the test-utils type entries) so both tests pick up the declaration during
compilation.

---

Nitpick comments:
In
`@packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts`:
- Around line 105-106: The call to
issueContractArtifactGeneration(outputJsonPath) happens before source.load and
emit, which unnecessarily widens the D2 supersession window; move the invocation
of issueContractArtifactGeneration (currently assigned to generation) from just
after destructuring outputPaths to immediately before
publishContractArtifactPairSerialized(...) so the generation claim happens right
before enqueueing the serialized artifact, narrowing the race window while
preserving public semantics; keep references to outputJsonPath/outputDtsPath and
ensure source.load and emit run first, then call
issueContractArtifactGeneration, then call
publishContractArtifactPairSerialized.

In
`@packages/1-framework/3-tooling/cli/test/utils/publish-contract-artifact-pair.test.ts`:
- Around line 160-178: Replace the rejects.toSatisfy pattern in the
publishContractArtifactPair test with an explicit capture of the thrown error
(try/catch or await expect(...).rejects to capture via a variable) and perform
direct assertions on that captured error: assert equality to publishError,
assert instance of Error, assert that (error as Error & { cause?: unknown
}).cause is an instance of AggregateError, and assert that (error as Error & {
cause?: AggregateError }).cause?.errors equals [rollbackError]; keep the
subsequent file-read assertions the same. Ensure you reference the same call to
publishContractArtifactPair and the same local variables (outputJsonPath,
outputDtsPath, NEXT_JSON, NEXT_DTS, publishError, rollbackError) when replacing
the rejects.toSatisfy block.

In
`@packages/1-framework/3-tooling/vite-plugin-contract-emit/test/plugin.test.ts`:
- Around line 1-921: The test file is too large; split plugin.test.ts into
multiple focused test files by moving each top-level describe block into its own
file (for example: move "configResolved" tests to
plugin.config-resolved.test.ts, "configureServer" to
plugin.configure-server.test.ts, "handleHotUpdate" and debounce/concurrency
tests to plugin.hot-update.test.ts, and "error handling"/overlay tests to
plugin.errors.test.ts), and factor shared helpers (createMockServer,
createLoadedConfig, createContractEmitResult, applyModuleGraph, eventually,
mocks/vi.setup) into a new test-utils module that each new test file imports;
update each new file to import prismaVitePlugin and the shared utilities and
preserve existing vi.mock calls so behavior is unchanged.
- Around line 769-826: The assertion that the first emit's AbortSignal is
aborted is timing-sensitive; replace the direct synchronous check of
firstSignal.aborted (the expect at the line asserting firstSignal?.aborted
toBe(true)) with a guarded assertion using eventually() to poll until
firstSignal?.aborted becomes true to avoid flakiness when handleHotUpdate or
cancellation becomes asynchronous; update the test around the handleHotUpdate
calls / mockedExecuteContractEmit checks to use eventually(() =>
expect(firstSignal?.aborted).toBe(true)) while keeping the rest of the flow
(handleHotUpdate, advanceTimersByTimeAsync, firstEmit/secondEmit resolution, and
ws.send assertions) intact.
- Around line 91-103: The helper function eventually is duplicated; extract the
function eventually into a shared test util (e.g., create an exported helper
eventually in a new test utils module) and replace the local definitions in both
plugin.test.ts and contract-emit.test.ts with imports from that module, or
alternatively replace calls to eventually with Vitest's vi.waitFor(assertion, {
timeout, interval }) for better diagnostics; ensure the exported symbol is named
eventually so existing call sites need only update their imports.
- Around line 34-40: Remove the unnecessary vi.mock that re-exports node:path
for 'pathe' in the test: delete the vi.mock(...) block so the tests use the real
'pathe' implementation. Ensure the test still imports/run the code under test
(executeContractEmit and any helpers in plugin.ts) normally and that no other
mocks for 'pathe' remain; run the tests to verify behavior unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 0db79898-9259-419d-b330-6500776d8068

📥 Commits

Reviewing files that changed from the base of the PR and between 355e3fb and f126b37.

📒 Files selected for processing (8)
  • packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts
  • packages/1-framework/3-tooling/cli/src/utils/publish-contract-artifact-pair-serialized.ts
  • packages/1-framework/3-tooling/cli/src/utils/publish-contract-artifact-pair.ts
  • packages/1-framework/3-tooling/cli/test/commands/contract-emit.command.test.ts
  • packages/1-framework/3-tooling/cli/test/control-api/contract-emit.test.ts
  • packages/1-framework/3-tooling/cli/test/utils/publish-contract-artifact-pair.test.ts
  • packages/1-framework/3-tooling/vite-plugin-contract-emit/README.md
  • packages/1-framework/3-tooling/vite-plugin-contract-emit/test/plugin.test.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/1-framework/3-tooling/vite-plugin-contract-emit/README.md
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/1-framework/3-tooling/cli/test/commands/contract-emit.command.test.ts
  • packages/1-framework/3-tooling/cli/src/utils/publish-contract-artifact-pair.ts
  • packages/1-framework/3-tooling/cli/src/utils/publish-contract-artifact-pair-serialized.ts

Comment thread packages/1-framework/3-tooling/vite-plugin-contract-emit/test/plugin.test.ts Outdated
jkomyno added 2 commits April 22, 2026 18:00
Address coderabbit review: the dynamic await import had no cycle or
load-time cost justification. validate-contract-deps.ts only depends on
node:module and has no back-edges into contract-emit.ts. Use a regular
static import in both call sites for consistency and clarity.
Address coderabbit review: the duplicated PromiseConstructor.withResolvers ambient declaration in two test files only existed because the base tsconfig pinned lib to ES2022. Engines.node is >=24 (which has Promise.withResolvers natively), so opt the cli and vite-plugin-contract-emit packages into the ES2024 lib and drop the local ambient declarations from contract-emit.test.ts and plugin.test.ts.
@jkomyno jkomyno changed the title fix(cli): make contract emission atomic under rapid saves fix(cli): make contract emission atomic and serialized under rapid saves Apr 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants