Skip to content

Commit 872f7c4

Browse files
authored
Handle local Project binding write failures with typed results (#78)
* feat: type local pin binding writes * chore: drop phase diary entry * chore: mark binding error bridge temporary * fix: rethrow local pin serialization errors
1 parent da7b057 commit 872f7c4

9 files changed

Lines changed: 431 additions & 50 deletions

File tree

.agents/projects/better-result-error-handling.plan.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ None.
1616

1717
### Phase 1: Foundation And Local Pin Read
1818

19-
**Status:** ◐ Implemented; targeted tests pass, repo typecheck blocked by unrelated existing errors
19+
**Status:** ✓ Complete
2020

2121
**Goal:** Add the dependency and prove typed expected failures on the smallest read-only project context slice.
2222

@@ -43,7 +43,7 @@ None.
4343

4444
### Phase 2: Local Pin Write And Directory Binding
4545

46-
**Status:** ☐ Not started
46+
**Status:** ✓ Complete; full package typecheck blocked by unrelated existing errors
4747

4848
**Goal:** Complete the local-pin call stack by typing write and gitignore update failures used by project binding.
4949

@@ -510,3 +510,5 @@ None.
510510
- `pnpm build:cli` passes if command runner, package metadata, or build-facing code changed in the phase.
511511

512512
## Revision Log
513+
514+
- 2026-06-09: Phase 2 added `LOCAL_STATE_WRITE_FAILED` to the product error conventions because local Project binding write failures need a stable structured error code before controller-facing conversion.

docs/product/error-conventions.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ These codes are the minimum stable set for the MVP:
167167
- `PROJECT_AMBIGUOUS`
168168
- `APP_AMBIGUOUS`
169169
- `LOCAL_PROJECT_WORKSPACE_MISMATCH`
170+
- `LOCAL_STATE_WRITE_FAILED`
170171
- `LOCAL_STATE_STALE`
171172
- `BRANCH_NOT_DEPLOYABLE`
172173
- `APP_CONFIG_INVALID`
@@ -219,6 +220,7 @@ Recommended meanings:
219220
- `PROJECT_AMBIGUOUS`: multiple safe project candidates matched
220221
- `APP_AMBIGUOUS`: multiple apps matched the inferred or explicit app target
221222
- `LOCAL_PROJECT_WORKSPACE_MISMATCH`: local Project pin points at a different workspace than the active authenticated workspace; callers should sign in to the linked workspace or relink the directory
223+
- `LOCAL_STATE_WRITE_FAILED`: the CLI could not save local Project binding state such as `.prisma/local.json` or the matching `.gitignore` entry; callers should fix directory permissions or filesystem state before retrying
222224
- `LOCAL_STATE_STALE`: local Project pin no longer matches platform data and continuing would be ambiguous
223225
- `BRANCH_NOT_DEPLOYABLE`: command tried to deploy to a non-deployable branch context
224226
- `APP_CONFIG_INVALID`: `prisma.app.json` is missing required build settings, has invalid JSON, or points outside the app root

packages/cli/src/controllers/app.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import {
6363
bindProjectToDirectory,
6464
formatCommandArgument,
6565
projectCreateFailedError,
66+
projectDirectoryBindingErrorToCliError,
6667
projectSetupNameRequiredError,
6768
resolveProjectForSetup,
6869
toProjectSummary,
@@ -275,8 +276,12 @@ export async function runAppDeploy(
275276
target.project,
276277
target.localPinAction,
277278
);
278-
localPinResult = setupResult.localPin;
279-
maybeRenderProjectLinked(context, setupResult.directory, setupResult.project.name, setupResult.localPin.path);
279+
if (setupResult.isErr()) {
280+
throw projectDirectoryBindingErrorToCliError(setupResult.error);
281+
}
282+
const projectSetup = setupResult.value;
283+
localPinResult = projectSetup.localPin;
284+
maybeRenderProjectLinked(context, projectSetup.directory, projectSetup.project.name, projectSetup.localPin.path);
280285
}
281286

282287
let framework = await resolveDeployFramework(context, {

packages/cli/src/controllers/project.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
formatCommandArgument,
2525
isValidProjectSetupName,
2626
projectCreateFailedError,
27+
projectDirectoryBindingErrorToCliError,
2728
projectSetupNameRequiredError,
2829
resolveProjectForSetup,
2930
toProjectSummary,
@@ -40,6 +41,7 @@ import type {
4041
ProjectRepositoryConnectionResult,
4142
ProjectSetupResult,
4243
ProjectShowResult,
44+
ProjectSummary,
4345
} from "../types/project";
4446
import { createCliUseCaseGateways } from "../use-cases/create-cli-gateways";
4547
import { createProjectUseCases } from "../use-cases/project";
@@ -218,10 +220,14 @@ export async function runProjectCreate(
218220
fallbackFix: "Retry the command, or choose an existing Project with prisma-cli project link <id-or-name>.",
219221
});
220222
});
221-
const result = await bindProjectToDirectory(context, workspace, {
223+
const bindResult = await bindProjectToDirectory(context, workspace, {
222224
id: created.id,
223225
name: created.name,
224226
}, "created");
227+
if (bindResult.isErr()) {
228+
throw projectDirectoryBindingErrorToCliError(bindResult.error);
229+
}
230+
const result = bindResult.value;
225231

226232
return {
227233
command: "project.create",
@@ -257,7 +263,7 @@ export async function runProjectLink(
257263
let result: ProjectSetupResult;
258264
if (projectRef?.trim()) {
259265
const project = resolveProjectForSetup(projectRef.trim(), projects, workspace);
260-
result = await bindProjectToDirectory(context, workspace, toProjectSummary(project), "linked");
266+
result = await requireProjectDirectoryBinding(context, workspace, toProjectSummary(project), "linked");
261267
} else if (canPrompt(context) && !context.flags.yes) {
262268
result = await resolveInteractiveProjectLinkSetup(
263269
context,
@@ -305,7 +311,21 @@ async function resolveInteractiveProjectLinkSetup(
305311
},
306312
});
307313

308-
return bindProjectToDirectory(context, workspace, setup.project, setup.action);
314+
return requireProjectDirectoryBinding(context, workspace, setup.project, setup.action);
315+
}
316+
317+
async function requireProjectDirectoryBinding(
318+
context: CommandContext,
319+
workspace: AuthWorkspace,
320+
project: ProjectSummary,
321+
action: ProjectSetupResult["action"],
322+
): Promise<ProjectSetupResult> {
323+
const bindResult = await bindProjectToDirectory(context, workspace, project, action);
324+
if (bindResult.isErr()) {
325+
throw projectDirectoryBindingErrorToCliError(bindResult.error);
326+
}
327+
328+
return bindResult.value;
309329
}
310330

311331
async function createProjectForLinkSetup(

packages/cli/src/lib/project/local-pin.ts

Lines changed: 194 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,99 @@ export type LocalResolutionPinReadError =
6666
| LocalResolutionPinReadAbortedError
6767
| UnhandledException;
6868

69+
export class LocalResolutionPinSerializationError extends TaggedError(
70+
"LocalResolutionPinSerializationError",
71+
)<{
72+
message: string;
73+
cause: unknown;
74+
pinPath: string;
75+
}>() {
76+
constructor(cause: unknown) {
77+
super({
78+
message: `Could not serialize ${LOCAL_RESOLUTION_PIN_RELATIVE_PATH}.`,
79+
cause,
80+
pinPath: LOCAL_RESOLUTION_PIN_RELATIVE_PATH,
81+
});
82+
}
83+
}
84+
85+
export class LocalResolutionPinWriteAbortedError extends TaggedError(
86+
"LocalResolutionPinWriteAbortedError",
87+
)<{
88+
message: string;
89+
cause: unknown;
90+
pinPath: string;
91+
}>() {
92+
constructor(cause: unknown) {
93+
super({
94+
message: `Writing ${LOCAL_RESOLUTION_PIN_RELATIVE_PATH} was aborted.`,
95+
cause,
96+
pinPath: LOCAL_RESOLUTION_PIN_RELATIVE_PATH,
97+
});
98+
}
99+
}
100+
101+
export class LocalResolutionPinWriteFailedError extends TaggedError(
102+
"LocalResolutionPinWriteFailedError",
103+
)<{
104+
message: string;
105+
cause: unknown;
106+
operation: "create-directory" | "write-temp-file" | "rename-temp-file";
107+
pinPath: string;
108+
}>() {
109+
constructor(operation: "create-directory" | "write-temp-file" | "rename-temp-file", cause: unknown) {
110+
super({
111+
message: `Could not write ${LOCAL_RESOLUTION_PIN_RELATIVE_PATH}.`,
112+
cause,
113+
operation,
114+
pinPath: LOCAL_RESOLUTION_PIN_RELATIVE_PATH,
115+
});
116+
}
117+
}
118+
119+
export type LocalResolutionPinWriteError =
120+
| LocalResolutionPinSerializationError
121+
| LocalResolutionPinWriteAbortedError
122+
| LocalResolutionPinWriteFailedError;
123+
124+
export class LocalResolutionPinGitignoreUpdateAbortedError extends TaggedError(
125+
"LocalResolutionPinGitignoreUpdateAbortedError",
126+
)<{
127+
message: string;
128+
cause: unknown;
129+
gitignorePath: string;
130+
}>() {
131+
constructor(cause: unknown) {
132+
super({
133+
message: "Updating .gitignore for the local Project binding was aborted.",
134+
cause,
135+
gitignorePath: ".gitignore",
136+
});
137+
}
138+
}
139+
140+
export class LocalResolutionPinGitignoreUpdateFailedError extends TaggedError(
141+
"LocalResolutionPinGitignoreUpdateFailedError",
142+
)<{
143+
message: string;
144+
cause: unknown;
145+
operation: "read" | "write";
146+
gitignorePath: string;
147+
}>() {
148+
constructor(operation: "read" | "write", cause: unknown) {
149+
super({
150+
message: "Could not update .gitignore for the local Project binding.",
151+
cause,
152+
operation,
153+
gitignorePath: ".gitignore",
154+
});
155+
}
156+
}
157+
158+
export type LocalResolutionPinGitignoreUpdateError =
159+
| LocalResolutionPinGitignoreUpdateAbortedError
160+
| LocalResolutionPinGitignoreUpdateFailedError;
161+
69162
export async function readLocalResolutionPin(
70163
cwd: string,
71164
signal?: AbortSignal,
@@ -138,58 +231,132 @@ export async function writeLocalResolutionPin(
138231
cwd: string,
139232
pin: LocalResolutionPin,
140233
signal?: AbortSignal,
141-
): Promise<void> {
142-
const prismaDir = path.join(cwd, ".prisma");
143-
signal?.throwIfAborted();
144-
// mkdir does not accept AbortSignal; check before the filesystem boundary.
145-
await mkdir(prismaDir, { recursive: true });
146-
const pinPath = path.join(cwd, LOCAL_RESOLUTION_PIN_RELATIVE_PATH);
147-
const tmpPath = path.join(
148-
prismaDir,
149-
`local.${process.pid}.${Date.now()}.tmp`,
150-
);
151-
await writeFile(tmpPath, `${JSON.stringify(pin, null, 2)}\n`, {
152-
encoding: "utf8",
153-
signal,
234+
): Promise<Result<void, LocalResolutionPinWriteError>> {
235+
return Result.gen(async function* () {
236+
const prismaDir = path.join(cwd, ".prisma");
237+
yield* ensureLocalResolutionPinWriteNotAborted(signal);
238+
// mkdir does not accept AbortSignal; check before the filesystem boundary.
239+
yield* Result.await(writeLocalResolutionPinBoundary(
240+
() => mkdir(prismaDir, { recursive: true }),
241+
"create-directory",
242+
signal,
243+
));
244+
const pinPath = path.join(cwd, LOCAL_RESOLUTION_PIN_RELATIVE_PATH);
245+
const tmpPath = path.join(
246+
prismaDir,
247+
`local.${process.pid}.${Date.now()}.tmp`,
248+
);
249+
const serialized = yield* serializeLocalResolutionPin(pin);
250+
yield* Result.await(writeLocalResolutionPinBoundary(
251+
() => writeFile(tmpPath, serialized, { encoding: "utf8", signal }),
252+
"write-temp-file",
253+
signal,
254+
));
255+
yield* ensureLocalResolutionPinWriteNotAborted(signal);
256+
// rename does not accept AbortSignal; check before the filesystem boundary.
257+
yield* Result.await(writeLocalResolutionPinBoundary(
258+
() => rename(tmpPath, pinPath),
259+
"rename-temp-file",
260+
signal,
261+
));
262+
263+
return Result.ok(undefined);
154264
});
155-
signal?.throwIfAborted();
156-
// rename does not accept AbortSignal; check before the filesystem boundary.
157-
await rename(tmpPath, pinPath);
158265
}
159266

160267
export async function ensureLocalResolutionPinGitignore(
161268
cwd: string,
162269
signal?: AbortSignal,
163-
): Promise<void> {
270+
): Promise<Result<void, LocalResolutionPinGitignoreUpdateError>> {
164271
const gitignorePath = path.join(cwd, ".gitignore");
165272
let existing: string | null = null;
166273

167-
signal?.throwIfAborted();
168-
try {
169-
existing = await readFile(gitignorePath, { encoding: "utf8", signal });
170-
} catch (error) {
171-
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
172-
throw error;
274+
const notAborted = ensureLocalResolutionPinGitignoreUpdateNotAborted(signal);
275+
if (notAborted.isErr()) {
276+
return Result.err(notAborted.error);
277+
}
278+
279+
const existingResult = await Result.tryPromise({
280+
try: () => readFile(gitignorePath, { encoding: "utf8", signal }),
281+
catch: (cause) => signal?.aborted
282+
? new LocalResolutionPinGitignoreUpdateAbortedError(cause)
283+
: new LocalResolutionPinGitignoreUpdateFailedError("read", cause),
284+
});
285+
if (existingResult.isErr()) {
286+
if (existingResult.error instanceof LocalResolutionPinGitignoreUpdateFailedError && (existingResult.error.cause as NodeJS.ErrnoException).code === "ENOENT") {
287+
existing = null;
288+
} else {
289+
return Result.err(existingResult.error);
173290
}
291+
} else {
292+
existing = existingResult.value;
174293
}
175294

176295
if (existing === null) {
177-
await writeFile(gitignorePath, ".prisma/\n", { encoding: "utf8", signal });
178-
return;
296+
return writeLocalResolutionPinGitignore(gitignorePath, ".prisma/\n", signal);
179297
}
180298

181299
const hasPrismaIgnore = existing
182300
.split(/\r?\n/)
183301
.map((line) => line.trim())
184302
.some((line) => line === ".prisma/" || line === ".prisma/local.json");
185303
if (hasPrismaIgnore) {
186-
return;
304+
return Result.ok(undefined);
187305
}
188306

189307
const next = existing.endsWith("\n")
190308
? `${existing}.prisma/\n`
191309
: `${existing}\n.prisma/\n`;
192-
await writeFile(gitignorePath, next, { encoding: "utf8", signal });
310+
return writeLocalResolutionPinGitignore(gitignorePath, next, signal);
311+
}
312+
313+
function ensureLocalResolutionPinWriteNotAborted(signal: AbortSignal | undefined): Result<void, LocalResolutionPinWriteAbortedError> {
314+
return Result.try({
315+
try: () => signal?.throwIfAborted(),
316+
catch: (cause) => new LocalResolutionPinWriteAbortedError(cause),
317+
});
318+
}
319+
320+
function serializeLocalResolutionPin(pin: LocalResolutionPin): Result<string, LocalResolutionPinSerializationError | LocalResolutionPinWriteAbortedError> {
321+
return Result.try({
322+
try: () => `${JSON.stringify(pin, null, 2)}\n`,
323+
catch: (cause) => new LocalResolutionPinSerializationError(cause),
324+
});
325+
}
326+
327+
function writeLocalResolutionPinBoundary(
328+
run: () => Promise<unknown>,
329+
operation: "create-directory" | "write-temp-file" | "rename-temp-file",
330+
signal: AbortSignal | undefined,
331+
): Promise<Result<void, LocalResolutionPinWriteAbortedError | LocalResolutionPinWriteFailedError>> {
332+
return Result.tryPromise({
333+
try: async () => {
334+
await run();
335+
},
336+
catch: (cause) => signal?.aborted
337+
? new LocalResolutionPinWriteAbortedError(cause)
338+
: new LocalResolutionPinWriteFailedError(operation, cause),
339+
});
340+
}
341+
342+
function ensureLocalResolutionPinGitignoreUpdateNotAborted(signal: AbortSignal | undefined): Result<void, LocalResolutionPinGitignoreUpdateAbortedError> {
343+
return Result.try({
344+
try: () => signal?.throwIfAborted(),
345+
catch: (cause) => new LocalResolutionPinGitignoreUpdateAbortedError(cause),
346+
});
347+
}
348+
349+
function writeLocalResolutionPinGitignore(
350+
gitignorePath: string,
351+
contents: string,
352+
signal: AbortSignal | undefined,
353+
): Promise<Result<void, LocalResolutionPinGitignoreUpdateAbortedError | LocalResolutionPinGitignoreUpdateFailedError>> {
354+
return Result.tryPromise({
355+
try: () => writeFile(gitignorePath, contents, { encoding: "utf8", signal }),
356+
catch: (cause) => signal?.aborted
357+
? new LocalResolutionPinGitignoreUpdateAbortedError(cause)
358+
: new LocalResolutionPinGitignoreUpdateFailedError("write", cause),
359+
});
193360
}
194361

195362
function isLocalResolutionPin(value: unknown): value is LocalResolutionPin {

0 commit comments

Comments
 (0)