@prisma-next/vite-plugin-contract-emit 0.5.0-dev.5 → 0.5.0-dev.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,18 +6,28 @@ Vite plugin for automatic Prisma Next contract artifact emission during developm
6
6
 
7
7
  This plugin integrates with Vite's dev server to automatically emit contract artifacts (`contract.json` and `contract.d.ts`) when you start the server and whenever your contract authoring files change.
8
8
 
9
+ ## Support Matrix
10
+
11
+ - Supported Vite majors: 7 and 8
12
+ - Peer dependency range: `^7.0.0 || ^8.0.0`
13
+ - Validation: the repo runs `test/integration/test/vite-plugin.hmr.e2e.test.ts` against both majors
14
+ - Compatibility note: the current implementation uses the same `configureServer` and `handleHotUpdate` flow on Vite 7 and Vite 8; there is no Vite-8-specific code path today, so the support matrix exists to catch future hook or overlay regressions early
15
+
9
16
  ## Features
10
17
 
11
18
  - **Emit on startup**: Emits contract artifacts when the Vite dev server starts
12
19
  - **Config graph + resolved inputs**: Re-emits from the config module graph plus loader-finalized `contract.source.inputs`
13
20
  - **Debounce**: Configurable debounce prevents rapid re-emission during rapid edits
14
- - **Last-change-wins**: Overlapping emit requests are cancelled to avoid stale results
21
+ - **Serialized re-emits**: Overlapping change bursts are coalesced into one follow-up emit instead of cancelling the emit already in flight
22
+ - **Ordered pair publication**: Emits stage temp artifacts, rename `contract.d.ts` before `contract.json`, and attempts to restore the last good pair if publication fails
15
23
  - **Config-only fallback warning**: Falls back to watching the config path and warns when loader-resolved inputs cannot be determined
16
24
  - **Error overlay**: Emission failures are surfaced via Vite's error overlay
17
25
  - **Console logging**: Compact success/error messages with optional debug output
18
26
 
19
27
  ## Installation
20
28
 
29
+ Install the plugin alongside Vite 7 or Vite 8.
30
+
21
31
  ```bash
22
32
  pnpm add -D @prisma-next/vite-plugin-contract-emit vite
23
33
  ```
@@ -67,8 +77,9 @@ interface PrismaVitePluginOptions {
67
77
  4. **Merge declared inputs**: It adds any explicit `contract.source.inputs`, and treats JS/TS inputs as additional module-graph roots
68
78
  5. **Filter emitted artifacts**: Output files are removed from the watch set to avoid self-trigger loops
69
79
  6. **Fallback on load failure**: If resolved inputs cannot be loaded, it watches only the config path and warns that coverage is partial
70
- 7. **Initial emit**: The contract is emitted immediately on server start
71
- 8. **Hot updates**: When any watched file changes, a debounced re-emit is triggered
80
+ 7. **Publish staged artifacts**: Emits write temp files beside the output paths, rename `contract.d.ts` first, then rename `contract.json`, and attempts to roll back to the previous pair if publication fails
81
+ 8. **Initial emit**: The contract is emitted immediately on server start
82
+ 9. **Queued hot updates**: When any watched file changes, a debounced re-emit is requested; if another emit is already running, the plugin runs one follow-up emit after the current one settles
72
83
 
73
84
  ## Architecture
74
85
 
@@ -86,24 +97,39 @@ graph TD
86
97
  J[File change] --> K[handleHotUpdate hook]
87
98
  K --> L[Schedule debounced emit]
88
99
  L --> M[executeContractEmit]
89
- M --> N[Write artifacts]
90
-
100
+ M --> N[Write temp artifacts]
101
+ N --> O[Rename d.ts then json]
102
+
91
103
  P[Error] --> Q[Overlay or console logging]
92
104
  ```
93
105
 
94
- ## Dependencies
106
+ ## Canonical publish path
107
+
108
+ > **For agents/contributors**: this plugin must publish through
109
+ > `executeContractEmit` from `@prisma-next/cli/control-api`. Do NOT call
110
+ > `publishContractArtifactPair` directly, do NOT re-implement the load → emit
111
+ > → publish dance, and do NOT add a parallel "fast path" for any reason. The
112
+ > atomic-rename invariant and the per-output FIFO queue live in one place;
113
+ > bypassing them races with the CLI command and other callers.
114
+ >
115
+ > Lifecycle: track every `outputJsonPath` you publish to and call
116
+ > `disposeEmitQueue(outputJsonPath)` from your server-close cleanup hook. The
117
+ > per-output queue is module-global; not disposing leaks one entry per unique
118
+ > output path for the lifetime of the process.
119
+ >
120
+ > `ContractEmitResult.validationWarning` is the dependency-validation message
121
+ > from the operation; render it through your plugin's logger when present.
95
122
 
96
- - **@prisma-next/cli**: Uses the control-api `executeContractEmit` operation
97
- - **vite**: Peer dependency (>=5.0.0)
123
+ ## Dependencies
98
124
 
99
- ## Example
125
+ - **@prisma-next/cli**: Uses the control-api `executeContractEmit` and
126
+ `disposeEmitQueue` exports — the canonical publish path
127
+ - **vite**: Peer dependency (`^7.0.0 || ^8.0.0`)
100
128
 
101
- See `examples/prisma-next-demo` for a working example with:
102
- - `vite.config.ts` configured with the plugin
103
- - `pnpm dev` script to start Vite
104
- - `prisma/contract.ts` as the contract authoring source
129
+ ## Examples
105
130
 
106
- Run `pnpm dev` in the demo, edit `prisma/contract.ts`, and watch the artifacts regenerate.
131
+ - `examples/prisma-next-demo` plain Vite + React SPA, covers TS-first and PSL-first contracts. Run `pnpm dev`, edit `prisma/contract.ts`, watch the artifacts regenerate.
132
+ - `examples/react-router-demo` — React Router v7 Framework Mode (SSR). The plugin runs alongside `@react-router/dev/vite`; a `loader` and an `action` exercise the Prisma Next runtime on the server, and a smoke test proves a PSL edit re-emits the contract without a manual command. See `examples/react-router-demo/test/react-router.smoke.e2e.test.ts` for the validation flow.
107
133
 
108
134
  ## Related
109
135
 
package/dist/index.d.mts CHANGED
@@ -25,8 +25,8 @@ interface PrismaVitePluginOptions {
25
25
  * Creates a Vite plugin that automatically emits Prisma Next contract artifacts.
26
26
  *
27
27
  * The plugin resolves watched files from contract source provider metadata,
28
- * re-emitting contract artifacts on changes with debounce and "last change wins"
29
- * semantics.
28
+ * re-emitting contract artifacts on changes with debounce while serializing
29
+ * overlapping emits into a single follow-up run.
30
30
  *
31
31
  * @param configPath - Path to prisma-next.config.ts (relative or absolute). Defaults to 'prisma-next.config.ts'
32
32
  * @param options - Optional plugin configuration
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { loadConfig } from "@prisma-next/cli/config-loader";
2
- import { executeContractEmit } from "@prisma-next/cli/control-api";
2
+ import { disposeEmitQueue, executeContractEmit } from "@prisma-next/cli/control-api";
3
3
  import { getEmittedArtifactPaths } from "@prisma-next/emitter";
4
4
  import { extname, resolve } from "pathe";
5
5
 
@@ -21,8 +21,8 @@ const MODULE_GRAPH_EXTENSIONS = new Set([
21
21
  * Creates a Vite plugin that automatically emits Prisma Next contract artifacts.
22
22
  *
23
23
  * The plugin resolves watched files from contract source provider metadata,
24
- * re-emitting contract artifacts on changes with debounce and "last change wins"
25
- * semantics.
24
+ * re-emitting contract artifacts on changes with debounce while serializing
25
+ * overlapping emits into a single follow-up run.
26
26
  *
27
27
  * @param configPath - Path to prisma-next.config.ts (relative or absolute). Defaults to 'prisma-next.config.ts'
28
28
  * @param options - Optional plugin configuration
@@ -50,10 +50,13 @@ function prismaVitePlugin(configPath = DEFAULT_CONFIG_PATH, options) {
50
50
  let absoluteConfigPath;
51
51
  const watchedFiles = /* @__PURE__ */ new Set();
52
52
  const ignoredOutputFiles = /* @__PURE__ */ new Set();
53
+ const ownedOutputJsonPaths = /* @__PURE__ */ new Set();
53
54
  let debounceTimer = null;
54
- let currentAbortController = null;
55
+ let lifecycleAbortController = new AbortController();
55
56
  let server = null;
56
- let emitRequestId = 0;
57
+ let isEmitInFlight = false;
58
+ let hasQueuedEmit = false;
59
+ let queuedEmitNeedsWatchedFileRefresh = false;
57
60
  let didWarnConfigWatchFallback = false;
58
61
  function log(message, level = "info") {
59
62
  if (logLevel === "silent") return;
@@ -82,31 +85,24 @@ function prismaVitePlugin(configPath = DEFAULT_CONFIG_PATH, options) {
82
85
  }
83
86
  }
84
87
  async function emitContract({ refreshWatchedFiles = true } = {}) {
85
- const requestId = ++emitRequestId;
86
- if (currentAbortController) currentAbortController.abort();
87
- currentAbortController = new AbortController();
88
- const signal = currentAbortController.signal;
88
+ const signal = lifecycleAbortController.signal;
89
89
  try {
90
90
  if (server && refreshWatchedFiles) await updateWatchedFiles(server);
91
91
  const result = await executeContractEmit({
92
92
  configPath: absoluteConfigPath,
93
93
  signal
94
94
  });
95
- if (requestId !== emitRequestId) {
96
- log("Emit superseded by newer request", "debug");
97
- return null;
98
- }
99
95
  log(`Emitted contract (storageHash: ${result.storageHash.slice(0, 8)}...)`);
100
96
  log(` → ${result.files.json}`, "debug");
101
97
  log(` → ${result.files.dts}`, "debug");
102
- if (server) server.ws.send({ type: "full-reload" });
98
+ if (server && !hasQueuedEmit) server.ws.send({ type: "full-reload" });
99
+ else if (hasQueuedEmit) log("Skipped full reload because a newer emit is queued", "debug");
103
100
  return result;
104
101
  } catch (error) {
105
102
  if (signal.aborted || error instanceof Error && error.name === "AbortError") {
106
103
  log("Emit cancelled", "debug");
107
104
  return null;
108
105
  }
109
- if (requestId !== emitRequestId) return null;
110
106
  logError("Contract emit failed:", error);
111
107
  if (server) {
112
108
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -123,16 +119,41 @@ function prismaVitePlugin(configPath = DEFAULT_CONFIG_PATH, options) {
123
119
  return null;
124
120
  }
125
121
  }
122
+ async function drainQueuedEmits() {
123
+ if (isEmitInFlight || lifecycleAbortController.signal.aborted) return;
124
+ isEmitInFlight = true;
125
+ try {
126
+ while (hasQueuedEmit && !lifecycleAbortController.signal.aborted) {
127
+ const refreshWatchedFiles = queuedEmitNeedsWatchedFileRefresh;
128
+ hasQueuedEmit = false;
129
+ queuedEmitNeedsWatchedFileRefresh = false;
130
+ await emitContract({ refreshWatchedFiles });
131
+ }
132
+ } finally {
133
+ isEmitInFlight = false;
134
+ }
135
+ }
136
+ function requestEmit({ refreshWatchedFiles = true } = {}) {
137
+ if (lifecycleAbortController.signal.aborted) return Promise.resolve();
138
+ hasQueuedEmit = true;
139
+ queuedEmitNeedsWatchedFileRefresh ||= refreshWatchedFiles;
140
+ if (isEmitInFlight) {
141
+ log("Queued follow-up emit while another emit is running", "debug");
142
+ return Promise.resolve();
143
+ }
144
+ return drainQueuedEmits();
145
+ }
126
146
  function scheduleEmit() {
127
147
  if (debounceTimer) clearTimeout(debounceTimer);
128
148
  debounceTimer = setTimeout(() => {
129
149
  debounceTimer = null;
130
- emitContract();
150
+ requestEmit();
131
151
  }, debounceMs);
132
152
  }
133
153
  function resolveContractOutputFiles(contractOutput) {
134
154
  if (contractOutput === void 0) return /* @__PURE__ */ new Set();
135
155
  const { jsonPath, dtsPath } = getEmittedArtifactPaths(contractOutput);
156
+ ownedOutputJsonPaths.add(jsonPath);
136
157
  return new Set([jsonPath, dtsPath]);
137
158
  }
138
159
  function isModuleGraphRoot(filePath) {
@@ -217,6 +238,10 @@ function prismaVitePlugin(configPath = DEFAULT_CONFIG_PATH, options) {
217
238
  },
218
239
  async configureServer(viteServer) {
219
240
  server = viteServer;
241
+ lifecycleAbortController = new AbortController();
242
+ isEmitInFlight = false;
243
+ hasQueuedEmit = false;
244
+ queuedEmitNeedsWatchedFileRefresh = false;
220
245
  const onTrackedWatcherEvent = (file) => {
221
246
  handleTrackedFileChange(file);
222
247
  };
@@ -225,14 +250,15 @@ function prismaVitePlugin(configPath = DEFAULT_CONFIG_PATH, options) {
225
250
  clearTimeout(debounceTimer);
226
251
  debounceTimer = null;
227
252
  }
228
- if (currentAbortController) {
229
- currentAbortController.abort();
230
- currentAbortController = null;
231
- }
253
+ hasQueuedEmit = false;
254
+ queuedEmitNeedsWatchedFileRefresh = false;
255
+ lifecycleAbortController.abort();
232
256
  viteServer.watcher.off?.("change", onTrackedWatcherEvent);
233
257
  viteServer.watcher.off?.("add", onTrackedWatcherEvent);
234
258
  viteServer.watcher.off?.("unlink", onTrackedWatcherEvent);
235
259
  ignoredOutputFiles.clear();
260
+ for (const outputJsonPath of ownedOutputJsonPaths) disposeEmitQueue(outputJsonPath);
261
+ ownedOutputJsonPaths.clear();
236
262
  didWarnConfigWatchFallback = false;
237
263
  server = null;
238
264
  watchedFiles.clear();
@@ -261,7 +287,7 @@ function prismaVitePlugin(configPath = DEFAULT_CONFIG_PATH, options) {
261
287
  log(`Watching ${watchedFiles.size} files`, "debug");
262
288
  if (logLevel === "debug") for (const file of watchedFiles) log(` ${file}`, "debug");
263
289
  }
264
- await emitContract({ refreshWatchedFiles: false });
290
+ await requestEmit({ refreshWatchedFiles: false });
265
291
  },
266
292
  handleHotUpdate(ctx) {
267
293
  handleTrackedFileChange(ctx.file);
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":["absoluteConfigPath: string","debounceTimer: ReturnType<typeof setTimeout> | null","currentAbortController: AbortController | null","server: ViteDevServer | null","toAdd: string[]","toRemove: string[]"],"sources":["../src/plugin.ts"],"sourcesContent":["import { loadConfig } from '@prisma-next/cli/config-loader';\nimport type { ContractEmitResult } from '@prisma-next/cli/control-api';\nimport { executeContractEmit } from '@prisma-next/cli/control-api';\nimport { getEmittedArtifactPaths } from '@prisma-next/emitter';\nimport { extname, resolve } from 'pathe';\nimport type { Plugin, ViteDevServer } from 'vite';\nimport type { PrismaVitePluginOptions } from './types';\n\nconst PLUGIN_NAME = 'prisma-vite-plugin-contract-emit';\nconst DEFAULT_DEBOUNCE_MS = 150;\nconst DEFAULT_CONFIG_PATH = 'prisma-next.config.ts';\nconst MODULE_GRAPH_EXTENSIONS = new Set([\n '.js',\n '.jsx',\n '.mjs',\n '.cjs',\n '.ts',\n '.tsx',\n '.mts',\n '.cts',\n]);\n\n/**\n * Creates a Vite plugin that automatically emits Prisma Next contract artifacts.\n *\n * The plugin resolves watched files from contract source provider metadata,\n * re-emitting contract artifacts on changes with debounce and \"last change wins\"\n * semantics.\n *\n * @param configPath - Path to prisma-next.config.ts (relative or absolute). Defaults to 'prisma-next.config.ts'\n * @param options - Optional plugin configuration\n * @returns Vite plugin\n *\n * @example\n * ```ts\n * import { defineConfig } from 'vite';\n * import { prismaVitePlugin } from '@prisma-next/vite-plugin-contract-emit';\n *\n * // Use default config path\n * export default defineConfig({\n * plugins: [prismaVitePlugin()],\n * });\n *\n * // Or specify a custom path\n * export default defineConfig({\n * plugins: [prismaVitePlugin('custom/prisma-next.config.ts')],\n * });\n * ```\n */\nexport function prismaVitePlugin(\n configPath: string = DEFAULT_CONFIG_PATH,\n options?: PrismaVitePluginOptions,\n): Plugin {\n const debounceMs = options?.debounceMs ?? DEFAULT_DEBOUNCE_MS;\n const logLevel = options?.logLevel ?? 'info';\n\n let absoluteConfigPath: string;\n const watchedFiles = new Set<string>();\n // Vite watches the project root, so writes to emitted artifacts can still surface as change\n // events even when those files are excluded from watchedFiles.\n const ignoredOutputFiles = new Set<string>();\n let debounceTimer: ReturnType<typeof setTimeout> | null = null;\n let currentAbortController: AbortController | null = null;\n let server: ViteDevServer | null = null;\n let emitRequestId = 0;\n let didWarnConfigWatchFallback = false;\n\n function log(message: string, level: 'info' | 'debug' = 'info') {\n if (logLevel === 'silent') return;\n if (level === 'debug' && logLevel !== 'debug') return;\n console.log(`[${PLUGIN_NAME}] ${message}`);\n }\n\n function logError(message: string, error?: unknown) {\n if (logLevel === 'silent') return;\n const errorMessage = error instanceof Error ? error.message : error ? String(error) : '';\n console.error(`[${PLUGIN_NAME}] ${message}${errorMessage ? ` ${errorMessage}` : ''}`);\n if (error instanceof Error && error.stack && logLevel === 'debug') {\n console.error(error.stack);\n }\n }\n\n function logWarning(message: string) {\n if (logLevel === 'silent') return;\n console.warn(`[${PLUGIN_NAME}] ${message}`);\n }\n\n function handleTrackedFileChange(file: string) {\n const normalized = resolve(file);\n if (ignoredOutputFiles.has(normalized)) {\n log(`Ignoring emitted artifact update: ${normalized}`, 'debug');\n return;\n }\n\n if (watchedFiles.has(normalized)) {\n log(`Detected change: ${normalized}`, 'debug');\n scheduleEmit();\n }\n }\n\n async function emitContract({\n refreshWatchedFiles = true,\n }: {\n refreshWatchedFiles?: boolean;\n } = {}): Promise<ContractEmitResult | null> {\n const requestId = ++emitRequestId;\n\n // Cancel any in-flight emit\n if (currentAbortController) {\n currentAbortController.abort();\n }\n currentAbortController = new AbortController();\n const signal = currentAbortController.signal;\n\n try {\n if (server && refreshWatchedFiles) {\n await updateWatchedFiles(server);\n }\n\n const result = await executeContractEmit({\n configPath: absoluteConfigPath,\n signal,\n });\n\n // Check if this emit is still the latest request\n if (requestId !== emitRequestId) {\n log('Emit superseded by newer request', 'debug');\n return null;\n }\n\n log(`Emitted contract (storageHash: ${result.storageHash.slice(0, 8)}...)`);\n log(` → ${result.files.json}`, 'debug');\n log(` → ${result.files.dts}`, 'debug');\n\n if (server) {\n server.ws.send({ type: 'full-reload' });\n }\n\n return result;\n } catch (error) {\n // Ignore cancellation - check signal first, then error name\n if (signal.aborted || (error instanceof Error && error.name === 'AbortError')) {\n log('Emit cancelled', 'debug');\n return null;\n }\n\n // Check if this emit is still the latest request\n if (requestId !== emitRequestId) {\n return null;\n }\n\n logError('Contract emit failed:', error);\n\n // Send error to Vite overlay\n if (server) {\n const errorMessage = error instanceof Error ? error.message : String(error);\n const errorStack = error instanceof Error ? error.stack : undefined;\n server.ws.send({\n type: 'error',\n err: {\n message: `[prisma-next] ${errorMessage}`,\n stack: errorStack ?? '',\n plugin: PLUGIN_NAME,\n },\n });\n }\n\n return null;\n }\n }\n\n function scheduleEmit() {\n if (debounceTimer) {\n clearTimeout(debounceTimer);\n }\n debounceTimer = setTimeout(() => {\n debounceTimer = null;\n void emitContract();\n }, debounceMs);\n }\n\n function resolveContractOutputFiles(contractOutput: string | undefined): Set<string> {\n if (contractOutput === undefined) {\n return new Set();\n }\n const { jsonPath, dtsPath } = getEmittedArtifactPaths(contractOutput);\n return new Set<string>([jsonPath, dtsPath]);\n }\n\n function isModuleGraphRoot(filePath: string): boolean {\n return MODULE_GRAPH_EXTENSIONS.has(extname(filePath));\n }\n\n async function collectModuleGraphFiles(\n viteServer: ViteDevServer,\n roots: readonly string[],\n ): Promise<Set<string>> {\n const files = new Set<string>();\n const uniqueRoots = [...new Set(roots)];\n\n for (const root of uniqueRoots) {\n try {\n await viteServer.ssrLoadModule(root);\n } catch (error) {\n if (root === absoluteConfigPath) {\n logError('Failed to load config module graph root:', error);\n } else {\n log(`Skipped module-graph root after load failure: ${root}`, 'debug');\n }\n }\n }\n\n try {\n const visited = new Set<string>();\n const queue = [...uniqueRoots];\n\n while (queue.length > 0) {\n const current = queue.shift();\n if (current === undefined || visited.has(current)) continue;\n visited.add(current);\n\n const mod = viteServer.moduleGraph.getModuleById(current);\n if (!mod) continue;\n\n // Add file to watched set if it's a file path\n if (mod.file) {\n files.add(mod.file);\n }\n\n // Add imported modules to queue\n for (const imported of mod.importedModules) {\n if (imported.id && !visited.has(imported.id)) {\n queue.push(imported.id);\n }\n }\n }\n } catch (error) {\n logError('Failed to collect watched files:', error);\n }\n\n return files;\n }\n\n async function resolveWatchedFiles(viteServer: ViteDevServer): Promise<Set<string>> {\n const previousWatchedFiles = new Set(watchedFiles);\n const previousIgnoredOutputFiles = new Set(ignoredOutputFiles);\n ignoredOutputFiles.clear();\n\n try {\n const config = await loadConfig(absoluteConfigPath);\n didWarnConfigWatchFallback = false;\n const contract = config.contract;\n\n if (!contract) {\n return new Set([absoluteConfigPath]);\n }\n\n const files = new Set<string>([absoluteConfigPath]);\n const inputs = contract.source.inputs ?? [];\n for (const outputFile of resolveContractOutputFiles(contract.output)) {\n ignoredOutputFiles.add(outputFile);\n }\n\n const moduleGraphRoots = [absoluteConfigPath];\n for (const input of inputs) {\n if (!ignoredOutputFiles.has(input)) {\n files.add(input);\n }\n if (isModuleGraphRoot(input)) {\n moduleGraphRoots.push(input);\n }\n }\n\n for (const file of await collectModuleGraphFiles(viteServer, moduleGraphRoots)) {\n if (!ignoredOutputFiles.has(file)) {\n files.add(file);\n }\n }\n\n return files;\n } catch (error) {\n if (previousIgnoredOutputFiles.size > 0) {\n for (const outputFile of previousIgnoredOutputFiles) {\n ignoredOutputFiles.add(outputFile);\n }\n }\n if (!didWarnConfigWatchFallback) {\n didWarnConfigWatchFallback = true;\n const reason = error instanceof Error ? ` ${error.message}` : '';\n const watchScope =\n previousWatchedFiles.size > 0\n ? `Watching the previous dependency set plus ${absoluteConfigPath}`\n : `Watching only ${absoluteConfigPath}`;\n logWarning(\n `${watchScope} because Prisma Next config inputs could not be resolved.${reason} Contract watch coverage is partial.`,\n );\n }\n if (previousWatchedFiles.size > 0) {\n previousWatchedFiles.add(absoluteConfigPath);\n return previousWatchedFiles;\n }\n return new Set([absoluteConfigPath]);\n }\n }\n\n async function updateWatchedFiles(viteServer: ViteDevServer): Promise<void> {\n const newWatchedFiles = await resolveWatchedFiles(viteServer);\n\n // Find files to add and remove\n const toAdd: string[] = [];\n const toRemove: string[] = [];\n\n for (const file of newWatchedFiles) {\n if (!watchedFiles.has(file)) {\n toAdd.push(file);\n }\n }\n\n for (const file of watchedFiles) {\n if (!newWatchedFiles.has(file)) {\n toRemove.push(file);\n }\n }\n\n // Update the watcher\n for (const file of toAdd) {\n viteServer.watcher.add(file);\n }\n for (const file of toRemove) {\n viteServer.watcher.unwatch(file);\n }\n\n // Replace the watched files set\n watchedFiles.clear();\n for (const file of newWatchedFiles) {\n watchedFiles.add(file);\n }\n\n if (toAdd.length > 0 || toRemove.length > 0) {\n log(`Updated watched files: +${toAdd.length} -${toRemove.length}`, 'debug');\n }\n }\n\n return {\n name: PLUGIN_NAME,\n\n configResolved(config) {\n // Resolve config path to absolute path based on Vite root\n absoluteConfigPath = resolve(config.root, configPath);\n log(`Config path: ${absoluteConfigPath}`, 'debug');\n },\n\n async configureServer(viteServer) {\n server = viteServer;\n const onTrackedWatcherEvent = (file: string) => {\n handleTrackedFileChange(file);\n };\n\n // Register close hook to clean up timers and abort in-flight work\n const cleanup = () => {\n if (debounceTimer) {\n clearTimeout(debounceTimer);\n debounceTimer = null;\n }\n if (currentAbortController) {\n currentAbortController.abort();\n currentAbortController = null;\n }\n viteServer.watcher.off?.('change', onTrackedWatcherEvent);\n viteServer.watcher.off?.('add', onTrackedWatcherEvent);\n viteServer.watcher.off?.('unlink', onTrackedWatcherEvent);\n ignoredOutputFiles.clear();\n didWarnConfigWatchFallback = false;\n server = null;\n watchedFiles.clear();\n log('Server closed, cleaned up resources', 'debug');\n };\n\n // Register cleanup on server close via httpServer or watcher\n viteServer.httpServer?.on('close', cleanup);\n viteServer.watcher?.on?.('close', cleanup);\n viteServer.watcher.on('change', onTrackedWatcherEvent);\n viteServer.watcher.on('add', onTrackedWatcherEvent);\n viteServer.watcher.on('unlink', onTrackedWatcherEvent);\n\n const initialWatchedFiles = await resolveWatchedFiles(viteServer);\n\n // Collect files to watch from provider metadata\n for (const file of initialWatchedFiles) {\n watchedFiles.add(file);\n }\n\n // Add all dependency files to Vite's watcher\n for (const file of watchedFiles) {\n viteServer.watcher.add(file);\n }\n\n // Error if no files are being watched - this indicates a configuration problem\n if (watchedFiles.size === 0) {\n const errorMessage =\n `No files are being watched. The config file \"${absoluteConfigPath}\" could not be loaded ` +\n 'or has no dependencies. HMR for contract changes will not work.';\n logError(errorMessage);\n viteServer.ws.send({\n type: 'error',\n err: {\n message: `[prisma-next] ${errorMessage}`,\n stack: '',\n plugin: PLUGIN_NAME,\n },\n });\n } else {\n log(`Watching ${watchedFiles.size} files`, 'debug');\n if (logLevel === 'debug') {\n for (const file of watchedFiles) {\n log(` ${file}`, 'debug');\n }\n }\n }\n\n // Initial emit on server start\n await emitContract({ refreshWatchedFiles: false });\n },\n\n handleHotUpdate(ctx) {\n handleTrackedFileChange(ctx.file);\n },\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":["absoluteConfigPath: string","debounceTimer: ReturnType<typeof setTimeout> | null","server: ViteDevServer | null","toAdd: string[]","toRemove: string[]"],"sources":["../src/plugin.ts"],"sourcesContent":["import { loadConfig } from '@prisma-next/cli/config-loader';\nimport type { ContractEmitResult } from '@prisma-next/cli/control-api';\nimport { disposeEmitQueue, executeContractEmit } from '@prisma-next/cli/control-api';\nimport { getEmittedArtifactPaths } from '@prisma-next/emitter';\nimport { extname, resolve } from 'pathe';\nimport type { Plugin, ViteDevServer } from 'vite';\nimport type { PrismaVitePluginOptions } from './types';\n\nconst PLUGIN_NAME = 'prisma-vite-plugin-contract-emit';\nconst DEFAULT_DEBOUNCE_MS = 150;\nconst DEFAULT_CONFIG_PATH = 'prisma-next.config.ts';\nconst MODULE_GRAPH_EXTENSIONS = new Set([\n '.js',\n '.jsx',\n '.mjs',\n '.cjs',\n '.ts',\n '.tsx',\n '.mts',\n '.cts',\n]);\n\n/**\n * Creates a Vite plugin that automatically emits Prisma Next contract artifacts.\n *\n * The plugin resolves watched files from contract source provider metadata,\n * re-emitting contract artifacts on changes with debounce while serializing\n * overlapping emits into a single follow-up run.\n *\n * @param configPath - Path to prisma-next.config.ts (relative or absolute). Defaults to 'prisma-next.config.ts'\n * @param options - Optional plugin configuration\n * @returns Vite plugin\n *\n * @example\n * ```ts\n * import { defineConfig } from 'vite';\n * import { prismaVitePlugin } from '@prisma-next/vite-plugin-contract-emit';\n *\n * // Use default config path\n * export default defineConfig({\n * plugins: [prismaVitePlugin()],\n * });\n *\n * // Or specify a custom path\n * export default defineConfig({\n * plugins: [prismaVitePlugin('custom/prisma-next.config.ts')],\n * });\n * ```\n */\nexport function prismaVitePlugin(\n configPath: string = DEFAULT_CONFIG_PATH,\n options?: PrismaVitePluginOptions,\n): Plugin {\n const debounceMs = options?.debounceMs ?? DEFAULT_DEBOUNCE_MS;\n const logLevel = options?.logLevel ?? 'info';\n\n let absoluteConfigPath: string;\n const watchedFiles = new Set<string>();\n // Vite watches the project root, so writes to emitted artifacts can still surface as change\n // events even when those files are excluded from watchedFiles.\n const ignoredOutputFiles = new Set<string>();\n // Output JSON paths whose serialization queue this plugin instance owns. Disposed on cleanup\n // so long-lived dev sessions don't accumulate per-process queue state across config edits.\n const ownedOutputJsonPaths = new Set<string>();\n let debounceTimer: ReturnType<typeof setTimeout> | null = null;\n let lifecycleAbortController = new AbortController();\n let server: ViteDevServer | null = null;\n let isEmitInFlight = false;\n let hasQueuedEmit = false;\n let queuedEmitNeedsWatchedFileRefresh = false;\n let didWarnConfigWatchFallback = false;\n\n function log(message: string, level: 'info' | 'debug' = 'info') {\n if (logLevel === 'silent') return;\n if (level === 'debug' && logLevel !== 'debug') return;\n console.log(`[${PLUGIN_NAME}] ${message}`);\n }\n\n function logError(message: string, error?: unknown) {\n if (logLevel === 'silent') return;\n const errorMessage = error instanceof Error ? error.message : error ? String(error) : '';\n console.error(`[${PLUGIN_NAME}] ${message}${errorMessage ? ` ${errorMessage}` : ''}`);\n if (error instanceof Error && error.stack && logLevel === 'debug') {\n console.error(error.stack);\n }\n }\n\n function logWarning(message: string) {\n if (logLevel === 'silent') return;\n console.warn(`[${PLUGIN_NAME}] ${message}`);\n }\n\n function handleTrackedFileChange(file: string) {\n const normalized = resolve(file);\n if (ignoredOutputFiles.has(normalized)) {\n log(`Ignoring emitted artifact update: ${normalized}`, 'debug');\n return;\n }\n\n if (watchedFiles.has(normalized)) {\n log(`Detected change: ${normalized}`, 'debug');\n scheduleEmit();\n }\n }\n\n async function emitContract({\n refreshWatchedFiles = true,\n }: {\n refreshWatchedFiles?: boolean;\n } = {}): Promise<ContractEmitResult | null> {\n const signal = lifecycleAbortController.signal;\n\n try {\n if (server && refreshWatchedFiles) {\n await updateWatchedFiles(server);\n }\n\n const result = await executeContractEmit({\n configPath: absoluteConfigPath,\n signal,\n });\n\n log(`Emitted contract (storageHash: ${result.storageHash.slice(0, 8)}...)`);\n log(` → ${result.files.json}`, 'debug');\n log(` → ${result.files.dts}`, 'debug');\n\n if (server && !hasQueuedEmit) {\n server.ws.send({ type: 'full-reload' });\n } else if (hasQueuedEmit) {\n log('Skipped full reload because a newer emit is queued', 'debug');\n }\n\n return result;\n } catch (error) {\n // Ignore cancellation - check signal first, then error name\n if (signal.aborted || (error instanceof Error && error.name === 'AbortError')) {\n log('Emit cancelled', 'debug');\n return null;\n }\n\n logError('Contract emit failed:', error);\n\n // Send error to Vite overlay\n if (server) {\n const errorMessage = error instanceof Error ? error.message : String(error);\n const errorStack = error instanceof Error ? error.stack : undefined;\n server.ws.send({\n type: 'error',\n err: {\n message: `[prisma-next] ${errorMessage}`,\n stack: errorStack ?? '',\n plugin: PLUGIN_NAME,\n },\n });\n }\n\n return null;\n }\n }\n\n async function drainQueuedEmits(): Promise<void> {\n if (isEmitInFlight || lifecycleAbortController.signal.aborted) {\n return;\n }\n\n isEmitInFlight = true;\n\n try {\n while (hasQueuedEmit && !lifecycleAbortController.signal.aborted) {\n const refreshWatchedFiles = queuedEmitNeedsWatchedFileRefresh;\n hasQueuedEmit = false;\n queuedEmitNeedsWatchedFileRefresh = false;\n\n await emitContract({ refreshWatchedFiles });\n }\n } finally {\n isEmitInFlight = false;\n }\n }\n\n function requestEmit({\n refreshWatchedFiles = true,\n }: {\n refreshWatchedFiles?: boolean;\n } = {}): Promise<void> {\n if (lifecycleAbortController.signal.aborted) {\n return Promise.resolve();\n }\n\n hasQueuedEmit = true;\n queuedEmitNeedsWatchedFileRefresh ||= refreshWatchedFiles;\n\n if (isEmitInFlight) {\n log('Queued follow-up emit while another emit is running', 'debug');\n return Promise.resolve();\n }\n\n return drainQueuedEmits();\n }\n\n function scheduleEmit() {\n if (debounceTimer) {\n clearTimeout(debounceTimer);\n }\n debounceTimer = setTimeout(() => {\n debounceTimer = null;\n void requestEmit();\n }, debounceMs);\n }\n\n function resolveContractOutputFiles(contractOutput: string | undefined): Set<string> {\n if (contractOutput === undefined) {\n return new Set();\n }\n const { jsonPath, dtsPath } = getEmittedArtifactPaths(contractOutput);\n ownedOutputJsonPaths.add(jsonPath);\n return new Set<string>([jsonPath, dtsPath]);\n }\n\n function isModuleGraphRoot(filePath: string): boolean {\n return MODULE_GRAPH_EXTENSIONS.has(extname(filePath));\n }\n\n async function collectModuleGraphFiles(\n viteServer: ViteDevServer,\n roots: readonly string[],\n ): Promise<Set<string>> {\n const files = new Set<string>();\n const uniqueRoots = [...new Set(roots)];\n\n for (const root of uniqueRoots) {\n try {\n await viteServer.ssrLoadModule(root);\n } catch (error) {\n if (root === absoluteConfigPath) {\n logError('Failed to load config module graph root:', error);\n } else {\n log(`Skipped module-graph root after load failure: ${root}`, 'debug');\n }\n }\n }\n\n try {\n const visited = new Set<string>();\n const queue = [...uniqueRoots];\n\n while (queue.length > 0) {\n const current = queue.shift();\n if (current === undefined || visited.has(current)) continue;\n visited.add(current);\n\n const mod = viteServer.moduleGraph.getModuleById(current);\n if (!mod) continue;\n\n // Add file to watched set if it's a file path\n if (mod.file) {\n files.add(mod.file);\n }\n\n // Add imported modules to queue\n for (const imported of mod.importedModules) {\n if (imported.id && !visited.has(imported.id)) {\n queue.push(imported.id);\n }\n }\n }\n } catch (error) {\n logError('Failed to collect watched files:', error);\n }\n\n return files;\n }\n\n async function resolveWatchedFiles(viteServer: ViteDevServer): Promise<Set<string>> {\n const previousWatchedFiles = new Set(watchedFiles);\n const previousIgnoredOutputFiles = new Set(ignoredOutputFiles);\n ignoredOutputFiles.clear();\n\n try {\n const config = await loadConfig(absoluteConfigPath);\n didWarnConfigWatchFallback = false;\n const contract = config.contract;\n\n if (!contract) {\n return new Set([absoluteConfigPath]);\n }\n\n const files = new Set<string>([absoluteConfigPath]);\n const inputs = contract.source.inputs ?? [];\n for (const outputFile of resolveContractOutputFiles(contract.output)) {\n ignoredOutputFiles.add(outputFile);\n }\n\n const moduleGraphRoots = [absoluteConfigPath];\n for (const input of inputs) {\n if (!ignoredOutputFiles.has(input)) {\n files.add(input);\n }\n if (isModuleGraphRoot(input)) {\n moduleGraphRoots.push(input);\n }\n }\n\n for (const file of await collectModuleGraphFiles(viteServer, moduleGraphRoots)) {\n if (!ignoredOutputFiles.has(file)) {\n files.add(file);\n }\n }\n\n return files;\n } catch (error) {\n if (previousIgnoredOutputFiles.size > 0) {\n for (const outputFile of previousIgnoredOutputFiles) {\n ignoredOutputFiles.add(outputFile);\n }\n }\n if (!didWarnConfigWatchFallback) {\n didWarnConfigWatchFallback = true;\n const reason = error instanceof Error ? ` ${error.message}` : '';\n const watchScope =\n previousWatchedFiles.size > 0\n ? `Watching the previous dependency set plus ${absoluteConfigPath}`\n : `Watching only ${absoluteConfigPath}`;\n logWarning(\n `${watchScope} because Prisma Next config inputs could not be resolved.${reason} Contract watch coverage is partial.`,\n );\n }\n if (previousWatchedFiles.size > 0) {\n previousWatchedFiles.add(absoluteConfigPath);\n return previousWatchedFiles;\n }\n return new Set([absoluteConfigPath]);\n }\n }\n\n async function updateWatchedFiles(viteServer: ViteDevServer): Promise<void> {\n const newWatchedFiles = await resolveWatchedFiles(viteServer);\n\n // Find files to add and remove\n const toAdd: string[] = [];\n const toRemove: string[] = [];\n\n for (const file of newWatchedFiles) {\n if (!watchedFiles.has(file)) {\n toAdd.push(file);\n }\n }\n\n for (const file of watchedFiles) {\n if (!newWatchedFiles.has(file)) {\n toRemove.push(file);\n }\n }\n\n // Update the watcher\n for (const file of toAdd) {\n viteServer.watcher.add(file);\n }\n for (const file of toRemove) {\n viteServer.watcher.unwatch(file);\n }\n\n // Replace the watched files set\n watchedFiles.clear();\n for (const file of newWatchedFiles) {\n watchedFiles.add(file);\n }\n\n if (toAdd.length > 0 || toRemove.length > 0) {\n log(`Updated watched files: +${toAdd.length} -${toRemove.length}`, 'debug');\n }\n }\n\n return {\n name: PLUGIN_NAME,\n\n configResolved(config) {\n // Resolve config path to absolute path based on Vite root\n absoluteConfigPath = resolve(config.root, configPath);\n log(`Config path: ${absoluteConfigPath}`, 'debug');\n },\n\n async configureServer(viteServer) {\n server = viteServer;\n lifecycleAbortController = new AbortController();\n isEmitInFlight = false;\n hasQueuedEmit = false;\n queuedEmitNeedsWatchedFileRefresh = false;\n const onTrackedWatcherEvent = (file: string) => {\n handleTrackedFileChange(file);\n };\n\n // Register close hook to clean up timers and abort in-flight work.\n const cleanup = () => {\n if (debounceTimer) {\n clearTimeout(debounceTimer);\n debounceTimer = null;\n }\n hasQueuedEmit = false;\n queuedEmitNeedsWatchedFileRefresh = false;\n lifecycleAbortController.abort();\n viteServer.watcher.off?.('change', onTrackedWatcherEvent);\n viteServer.watcher.off?.('add', onTrackedWatcherEvent);\n viteServer.watcher.off?.('unlink', onTrackedWatcherEvent);\n ignoredOutputFiles.clear();\n for (const outputJsonPath of ownedOutputJsonPaths) {\n disposeEmitQueue(outputJsonPath);\n }\n ownedOutputJsonPaths.clear();\n didWarnConfigWatchFallback = false;\n server = null;\n watchedFiles.clear();\n log('Server closed, cleaned up resources', 'debug');\n };\n\n // Register cleanup on server close via httpServer or watcher\n viteServer.httpServer?.on('close', cleanup);\n viteServer.watcher?.on?.('close', cleanup);\n viteServer.watcher.on('change', onTrackedWatcherEvent);\n viteServer.watcher.on('add', onTrackedWatcherEvent);\n viteServer.watcher.on('unlink', onTrackedWatcherEvent);\n\n const initialWatchedFiles = await resolveWatchedFiles(viteServer);\n\n // Collect files to watch from provider metadata\n for (const file of initialWatchedFiles) {\n watchedFiles.add(file);\n }\n\n // Add all dependency files to Vite's watcher\n for (const file of watchedFiles) {\n viteServer.watcher.add(file);\n }\n\n // Error if no files are being watched - this indicates a configuration problem\n if (watchedFiles.size === 0) {\n const errorMessage =\n `No files are being watched. The config file \"${absoluteConfigPath}\" could not be loaded ` +\n 'or has no dependencies. HMR for contract changes will not work.';\n logError(errorMessage);\n viteServer.ws.send({\n type: 'error',\n err: {\n message: `[prisma-next] ${errorMessage}`,\n stack: '',\n plugin: PLUGIN_NAME,\n },\n });\n } else {\n log(`Watching ${watchedFiles.size} files`, 'debug');\n if (logLevel === 'debug') {\n for (const file of watchedFiles) {\n log(` ${file}`, 'debug');\n }\n }\n }\n\n // Initial emit on server start\n await requestEmit({ refreshWatchedFiles: false });\n },\n\n handleHotUpdate(ctx) {\n handleTrackedFileChange(ctx.file);\n },\n };\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prisma-next/vite-plugin-contract-emit",
3
- "version": "0.5.0-dev.5",
3
+ "version": "0.5.0-dev.51",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "files": [
@@ -9,8 +9,8 @@
9
9
  ],
10
10
  "dependencies": {
11
11
  "pathe": "^2.0.3",
12
- "@prisma-next/cli": "0.5.0-dev.5",
13
- "@prisma-next/emitter": "0.5.0-dev.5"
12
+ "@prisma-next/cli": "0.5.0-dev.51",
13
+ "@prisma-next/emitter": "0.5.0-dev.51"
14
14
  },
15
15
  "devDependencies": {
16
16
  "@types/node": "24.10.4",
@@ -22,7 +22,7 @@
22
22
  "@prisma-next/tsdown": "0.0.0"
23
23
  },
24
24
  "peerDependencies": {
25
- "vite": ">=5.0.0"
25
+ "vite": "^7.0.0 || ^8.0.0"
26
26
  },
27
27
  "engines": {
28
28
  "node": ">=20"
@@ -43,7 +43,7 @@
43
43
  "build": "tsdown",
44
44
  "test": "vitest run",
45
45
  "test:coverage": "vitest run --coverage",
46
- "typecheck": "tsc --project tsconfig.json --noEmit",
46
+ "typecheck": "tsc --project tsconfig.json --noEmit && tsc --project tsconfig.test.json --noEmit",
47
47
  "lint": "biome check . --error-on-warnings",
48
48
  "lint:fix": "biome check --write .",
49
49
  "lint:fix:unsafe": "biome check --write --unsafe .",
package/src/plugin.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { loadConfig } from '@prisma-next/cli/config-loader';
2
2
  import type { ContractEmitResult } from '@prisma-next/cli/control-api';
3
- import { executeContractEmit } from '@prisma-next/cli/control-api';
3
+ import { disposeEmitQueue, executeContractEmit } from '@prisma-next/cli/control-api';
4
4
  import { getEmittedArtifactPaths } from '@prisma-next/emitter';
5
5
  import { extname, resolve } from 'pathe';
6
6
  import type { Plugin, ViteDevServer } from 'vite';
@@ -24,8 +24,8 @@ const MODULE_GRAPH_EXTENSIONS = new Set([
24
24
  * Creates a Vite plugin that automatically emits Prisma Next contract artifacts.
25
25
  *
26
26
  * The plugin resolves watched files from contract source provider metadata,
27
- * re-emitting contract artifacts on changes with debounce and "last change wins"
28
- * semantics.
27
+ * re-emitting contract artifacts on changes with debounce while serializing
28
+ * overlapping emits into a single follow-up run.
29
29
  *
30
30
  * @param configPath - Path to prisma-next.config.ts (relative or absolute). Defaults to 'prisma-next.config.ts'
31
31
  * @param options - Optional plugin configuration
@@ -59,10 +59,15 @@ export function prismaVitePlugin(
59
59
  // Vite watches the project root, so writes to emitted artifacts can still surface as change
60
60
  // events even when those files are excluded from watchedFiles.
61
61
  const ignoredOutputFiles = new Set<string>();
62
+ // Output JSON paths whose serialization queue this plugin instance owns. Disposed on cleanup
63
+ // so long-lived dev sessions don't accumulate per-process queue state across config edits.
64
+ const ownedOutputJsonPaths = new Set<string>();
62
65
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
63
- let currentAbortController: AbortController | null = null;
66
+ let lifecycleAbortController = new AbortController();
64
67
  let server: ViteDevServer | null = null;
65
- let emitRequestId = 0;
68
+ let isEmitInFlight = false;
69
+ let hasQueuedEmit = false;
70
+ let queuedEmitNeedsWatchedFileRefresh = false;
66
71
  let didWarnConfigWatchFallback = false;
67
72
 
68
73
  function log(message: string, level: 'info' | 'debug' = 'info') {
@@ -103,14 +108,7 @@ export function prismaVitePlugin(
103
108
  }: {
104
109
  refreshWatchedFiles?: boolean;
105
110
  } = {}): Promise<ContractEmitResult | null> {
106
- const requestId = ++emitRequestId;
107
-
108
- // Cancel any in-flight emit
109
- if (currentAbortController) {
110
- currentAbortController.abort();
111
- }
112
- currentAbortController = new AbortController();
113
- const signal = currentAbortController.signal;
111
+ const signal = lifecycleAbortController.signal;
114
112
 
115
113
  try {
116
114
  if (server && refreshWatchedFiles) {
@@ -122,18 +120,14 @@ export function prismaVitePlugin(
122
120
  signal,
123
121
  });
124
122
 
125
- // Check if this emit is still the latest request
126
- if (requestId !== emitRequestId) {
127
- log('Emit superseded by newer request', 'debug');
128
- return null;
129
- }
130
-
131
123
  log(`Emitted contract (storageHash: ${result.storageHash.slice(0, 8)}...)`);
132
124
  log(` → ${result.files.json}`, 'debug');
133
125
  log(` → ${result.files.dts}`, 'debug');
134
126
 
135
- if (server) {
127
+ if (server && !hasQueuedEmit) {
136
128
  server.ws.send({ type: 'full-reload' });
129
+ } else if (hasQueuedEmit) {
130
+ log('Skipped full reload because a newer emit is queued', 'debug');
137
131
  }
138
132
 
139
133
  return result;
@@ -144,11 +138,6 @@ export function prismaVitePlugin(
144
138
  return null;
145
139
  }
146
140
 
147
- // Check if this emit is still the latest request
148
- if (requestId !== emitRequestId) {
149
- return null;
150
- }
151
-
152
141
  logError('Contract emit failed:', error);
153
142
 
154
143
  // Send error to Vite overlay
@@ -169,13 +158,53 @@ export function prismaVitePlugin(
169
158
  }
170
159
  }
171
160
 
161
+ async function drainQueuedEmits(): Promise<void> {
162
+ if (isEmitInFlight || lifecycleAbortController.signal.aborted) {
163
+ return;
164
+ }
165
+
166
+ isEmitInFlight = true;
167
+
168
+ try {
169
+ while (hasQueuedEmit && !lifecycleAbortController.signal.aborted) {
170
+ const refreshWatchedFiles = queuedEmitNeedsWatchedFileRefresh;
171
+ hasQueuedEmit = false;
172
+ queuedEmitNeedsWatchedFileRefresh = false;
173
+
174
+ await emitContract({ refreshWatchedFiles });
175
+ }
176
+ } finally {
177
+ isEmitInFlight = false;
178
+ }
179
+ }
180
+
181
+ function requestEmit({
182
+ refreshWatchedFiles = true,
183
+ }: {
184
+ refreshWatchedFiles?: boolean;
185
+ } = {}): Promise<void> {
186
+ if (lifecycleAbortController.signal.aborted) {
187
+ return Promise.resolve();
188
+ }
189
+
190
+ hasQueuedEmit = true;
191
+ queuedEmitNeedsWatchedFileRefresh ||= refreshWatchedFiles;
192
+
193
+ if (isEmitInFlight) {
194
+ log('Queued follow-up emit while another emit is running', 'debug');
195
+ return Promise.resolve();
196
+ }
197
+
198
+ return drainQueuedEmits();
199
+ }
200
+
172
201
  function scheduleEmit() {
173
202
  if (debounceTimer) {
174
203
  clearTimeout(debounceTimer);
175
204
  }
176
205
  debounceTimer = setTimeout(() => {
177
206
  debounceTimer = null;
178
- void emitContract();
207
+ void requestEmit();
179
208
  }, debounceMs);
180
209
  }
181
210
 
@@ -184,6 +213,7 @@ export function prismaVitePlugin(
184
213
  return new Set();
185
214
  }
186
215
  const { jsonPath, dtsPath } = getEmittedArtifactPaths(contractOutput);
216
+ ownedOutputJsonPaths.add(jsonPath);
187
217
  return new Set<string>([jsonPath, dtsPath]);
188
218
  }
189
219
 
@@ -352,24 +382,31 @@ export function prismaVitePlugin(
352
382
 
353
383
  async configureServer(viteServer) {
354
384
  server = viteServer;
385
+ lifecycleAbortController = new AbortController();
386
+ isEmitInFlight = false;
387
+ hasQueuedEmit = false;
388
+ queuedEmitNeedsWatchedFileRefresh = false;
355
389
  const onTrackedWatcherEvent = (file: string) => {
356
390
  handleTrackedFileChange(file);
357
391
  };
358
392
 
359
- // Register close hook to clean up timers and abort in-flight work
393
+ // Register close hook to clean up timers and abort in-flight work.
360
394
  const cleanup = () => {
361
395
  if (debounceTimer) {
362
396
  clearTimeout(debounceTimer);
363
397
  debounceTimer = null;
364
398
  }
365
- if (currentAbortController) {
366
- currentAbortController.abort();
367
- currentAbortController = null;
368
- }
399
+ hasQueuedEmit = false;
400
+ queuedEmitNeedsWatchedFileRefresh = false;
401
+ lifecycleAbortController.abort();
369
402
  viteServer.watcher.off?.('change', onTrackedWatcherEvent);
370
403
  viteServer.watcher.off?.('add', onTrackedWatcherEvent);
371
404
  viteServer.watcher.off?.('unlink', onTrackedWatcherEvent);
372
405
  ignoredOutputFiles.clear();
406
+ for (const outputJsonPath of ownedOutputJsonPaths) {
407
+ disposeEmitQueue(outputJsonPath);
408
+ }
409
+ ownedOutputJsonPaths.clear();
373
410
  didWarnConfigWatchFallback = false;
374
411
  server = null;
375
412
  watchedFiles.clear();
@@ -419,7 +456,7 @@ export function prismaVitePlugin(
419
456
  }
420
457
 
421
458
  // Initial emit on server start
422
- await emitContract({ refreshWatchedFiles: false });
459
+ await requestEmit({ refreshWatchedFiles: false });
423
460
  },
424
461
 
425
462
  handleHotUpdate(ctx) {