Skip to content

Commit 8d2e6d6

Browse files
avalleteclaude
andauthored
fix(cli): handle AlreadyExists errors in directory creation (#5724)
Extract directory creation logic into a new `legacyMakeDir` utility that matches Go's `os.MkdirAll` behavior by treating an already-existing directory as success. The Effect/Bun `FileSystem.makeDirectory` API can surface an `AlreadyExists` `SystemError` for existing directories on some platforms (notably Windows with OneDrive reparse points — CLI-1849), even with `recursive: true`. This differs from Go's `os.MkdirAll`, which returns nil when the target is already a directory. **Key changes:** - Add `legacyMakeDir` utility in `apps/cli/src/legacy/shared/legacy-make-dir.ts` that wraps `FileSystem.makeDirectory` and recovers from `AlreadyExists` errors - Add comprehensive unit tests covering success case, `AlreadyExists` recovery, and error propagation - Replace three inline `makeDirectory` calls in `db diff`, `db pull`, and `db schema declarative sync` handlers with `legacyMakeDir` This ensures the CLI's migration writers never fail on a pre-existing `supabase/migrations` directory, matching the original Go implementation's behavior. https://claude.ai/code/session_01AWa8Hr8BS2pZucac9r3wb7 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 778644f commit 8d2e6d6

5 files changed

Lines changed: 132 additions & 10 deletions

File tree

apps/cli/src/legacy/commands/db/diff/diff.handler.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service
1111
import type { LegacyPgConnInput } from "../../../shared/legacy-db-connection.service.ts";
1212
import type { LegacyDbConnType } from "../../../shared/legacy-db-target-flags.ts";
1313
import { legacyGetHostname } from "../../../shared/legacy-hostname.ts";
14+
import { legacyMakeDir } from "../../../shared/legacy-make-dir.ts";
1415
import { legacyToPostgresURL } from "../../../shared/legacy-postgres-url.ts";
1516
import { legacySchemaToCsvField } from "../../../shared/legacy-schema-flags.ts";
1617
import { legacyFindDropStatements } from "../../../shared/legacy-sql-split.ts";
@@ -277,9 +278,9 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy
277278
// Create parent dirs first, matching Go's `writeOutput` → `utils.WriteFile`
278279
// (`internal/db/diff/explicit.go`, `internal/utils/misc.go`), so a nested
279280
// `--output tmp/diff.sql` doesn't fail when `tmp/` doesn't exist yet.
280-
yield* fs
281-
.makeDirectory(path.dirname(target), { recursive: true })
282-
.pipe(Effect.mapError((cause) => new LegacyDbDiffWriteError({ message: cause.message })));
281+
yield* legacyMakeDir(fs, path.dirname(target)).pipe(
282+
Effect.mapError((cause) => new LegacyDbDiffWriteError({ message: cause.message })),
283+
);
283284
yield* fs
284285
.writeFileString(target, result.sql)
285286
.pipe(Effect.mapError((cause) => new LegacyDbDiffWriteError({ message: cause.message })));
@@ -457,9 +458,9 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy
457458
timestamp,
458459
flags.file.value,
459460
);
460-
yield* fs
461-
.makeDirectory(path.dirname(migrationPath), { recursive: true })
462-
.pipe(Effect.mapError((cause) => new LegacyDbDiffWriteError({ message: cause.message })));
461+
yield* legacyMakeDir(fs, path.dirname(migrationPath)).pipe(
462+
Effect.mapError((cause) => new LegacyDbDiffWriteError({ message: cause.message })),
463+
);
463464
yield* fs
464465
.writeFileString(migrationPath, out)
465466
.pipe(Effect.mapError((cause) => new LegacyDbDiffWriteError({ message: cause.message })));

apps/cli/src/legacy/commands/db/pull/pull.handler.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
legacyResolveDeclarativeDir,
2222
} from "../../../shared/legacy-db-config.toml-read.ts";
2323
import type { LegacyDbConnType } from "../../../shared/legacy-db-target-flags.ts";
24+
import { legacyMakeDir } from "../../../shared/legacy-make-dir.ts";
2425
import { legacyToPostgresURL } from "../../../shared/legacy-postgres-url.ts";
2526
import { legacySchemaToCsvField } from "../../../shared/legacy-schema-flags.ts";
2627
import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts";
@@ -540,9 +541,9 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy
540541
new LegacyDbPullInSyncError({ message: "No schema changes found" }),
541542
);
542543
}
543-
yield* fs
544-
.makeDirectory(path.dirname(migrationPath), { recursive: true })
545-
.pipe(Effect.mapError((cause) => new LegacyDbPullWriteError({ message: cause.message })));
544+
yield* legacyMakeDir(fs, path.dirname(migrationPath)).pipe(
545+
Effect.mapError((cause) => new LegacyDbPullWriteError({ message: cause.message })),
546+
);
546547
yield* fs.writeFileString(migrationPath, out).pipe(
547548
Effect.mapError(
548549
(cause) =>

apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
legacyReadDbToml,
1717
legacyResolveDeclarativeDir,
1818
} from "../../../../../shared/legacy-db-config.toml-read.ts";
19+
import { legacyMakeDir } from "../../../../../shared/legacy-make-dir.ts";
1920
import { legacyApplyMigrationFile } from "../../../../../shared/legacy-migration-apply.ts";
2021
import { legacyReadProjectRefFile } from "../../../../../shared/legacy-temp-paths.ts";
2122
import { LegacyLinkedProjectCache } from "../../../../../telemetry/legacy-linked-project-cache.service.ts";
@@ -276,7 +277,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara
276277
// Step 5: write the timestamped migration file.
277278
const timestamp = formatTimestamp(yield* Clock.currentTimeMillis);
278279
const migrationPath = path.join(migrationsDir, `${timestamp}_${migrationName}.sql`);
279-
yield* fs.makeDirectory(migrationsDir, { recursive: true });
280+
yield* legacyMakeDir(fs, migrationsDir);
280281
yield* fs.writeFileString(migrationPath, result.diffSQL);
281282
yield* output.raw(`Created new migration at ${legacyBold(migrationPath)}\n`, "stderr");
282283

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { Effect, FileSystem } from "effect";
2+
import type { PlatformError } from "effect/PlatformError";
3+
4+
/**
5+
* `os.MkdirAll`-equivalent: create `dir` and any missing parents, treating an
6+
* already-existing directory as success.
7+
*
8+
* Go's `os.MkdirAll` returns nil when the target is already a directory, so the
9+
* Go CLI's migration writers never failed on a pre-existing `supabase/migrations`.
10+
* Effect's Bun `FileSystem.makeDirectory` does not always match that: even with
11+
* `recursive: true` it can surface an `AlreadyExists` `SystemError` for an
12+
* existing directory on some platforms (notably Windows / OneDrive reparse
13+
* points — see CLI-1849). Recover from that single reason so re-creating an
14+
* existing directory is a no-op, and let every other failure propagate.
15+
*/
16+
export const legacyMakeDir = (
17+
fs: FileSystem.FileSystem,
18+
dir: string,
19+
): Effect.Effect<void, PlatformError> =>
20+
fs
21+
.makeDirectory(dir, { recursive: true })
22+
.pipe(
23+
Effect.catchTag("PlatformError", (error) =>
24+
error.reason._tag === "AlreadyExists" ? Effect.void : Effect.fail(error),
25+
),
26+
);
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
import { Effect, Exit, FileSystem, Layer, PlatformError } from "effect";
3+
4+
import { legacyMakeDir } from "./legacy-make-dir.ts";
5+
6+
const DIR = "/home/user/project/supabase/migrations";
7+
8+
type SystemReason = Parameters<typeof PlatformError.systemError>[0]["_tag"];
9+
10+
/** A FileSystem whose `makeDirectory` always fails with the given system reason. */
11+
function failingFs(reason: SystemReason) {
12+
const calls: Array<{ readonly path: string; readonly recursive?: boolean }> = [];
13+
return {
14+
calls,
15+
layer: Layer.succeed(
16+
FileSystem.FileSystem,
17+
FileSystem.makeNoop({
18+
makeDirectory: (path, opts) =>
19+
Effect.suspend(() => {
20+
calls.push({ path, recursive: opts?.recursive });
21+
return Effect.fail(
22+
PlatformError.systemError({
23+
_tag: reason,
24+
module: "FileSystem",
25+
method: "makeDirectory",
26+
description: reason,
27+
pathOrDescriptor: path,
28+
}),
29+
);
30+
}),
31+
}),
32+
),
33+
};
34+
}
35+
36+
const run = (dir: string) =>
37+
Effect.gen(function* () {
38+
const fs = yield* FileSystem.FileSystem;
39+
return yield* legacyMakeDir(fs, dir);
40+
});
41+
42+
describe("legacyMakeDir", () => {
43+
it.effect("creates the directory recursively, matching os.MkdirAll", () => {
44+
const calls: Array<{ readonly path: string; readonly recursive?: boolean }> = [];
45+
const layer = Layer.succeed(
46+
FileSystem.FileSystem,
47+
FileSystem.makeNoop({
48+
makeDirectory: (path, opts) =>
49+
Effect.sync(() => {
50+
calls.push({ path, recursive: opts?.recursive });
51+
}),
52+
}),
53+
);
54+
return run(DIR).pipe(
55+
Effect.tap(() =>
56+
Effect.sync(() => {
57+
expect(calls).toEqual([{ path: DIR, recursive: true }]);
58+
}),
59+
),
60+
Effect.provide(layer),
61+
);
62+
});
63+
64+
it.effect("treats an already-existing directory as success (CLI-1849)", () => {
65+
const fs = failingFs("AlreadyExists");
66+
return run(DIR).pipe(
67+
Effect.exit,
68+
Effect.tap((exit) =>
69+
Effect.sync(() => {
70+
expect(Exit.isSuccess(exit)).toBe(true);
71+
expect(fs.calls).toEqual([{ path: DIR, recursive: true }]);
72+
}),
73+
),
74+
Effect.provide(fs.layer),
75+
);
76+
});
77+
78+
it.effect("propagates every other filesystem error", () => {
79+
const fs = failingFs("PermissionDenied");
80+
return run(DIR).pipe(
81+
Effect.exit,
82+
Effect.tap((exit) =>
83+
Effect.sync(() => {
84+
expect(Exit.isFailure(exit)).toBe(true);
85+
if (Exit.isFailure(exit)) {
86+
expect(JSON.stringify(exit.cause)).toContain("PermissionDenied");
87+
}
88+
}),
89+
),
90+
Effect.provide(fs.layer),
91+
);
92+
});
93+
});

0 commit comments

Comments
 (0)