Skip to content

Commit 2d004cd

Browse files
committed
Refactor scope config boundary
1 parent 3f52c60 commit 2d004cd

36 files changed

Lines changed: 1834 additions & 360 deletions

packages/platform/sdk-file/src/capture-release-workspace-fixture.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ import * as Data from "effect/Data";
66
import * as Effect from "effect/Effect";
77
import * as Exit from "effect/Exit";
88

9-
import type { LocalExecutorConfig } from "@executor/platform-sdk/schema";
9+
import type { ExecutorScopeConfig } from "@executor/platform-sdk/schema";
1010

1111
import {
12-
readOptionalLocalExecutorConfig,
12+
readOptionalExecutorScopeConfig,
1313
resolveLocalWorkspaceContext,
1414
} from "./config";
1515
import {
@@ -186,8 +186,8 @@ const copyRecursive = (
186186
});
187187

188188
const sanitizeSourceConfig = (
189-
sourceConfig: NonNullable<LocalExecutorConfig["sources"]>[string],
190-
): NonNullable<LocalExecutorConfig["sources"]>[string] => sourceConfig;
189+
sourceConfig: NonNullable<ExecutorScopeConfig["sources"]>[string],
190+
): NonNullable<ExecutorScopeConfig["sources"]>[string] => sourceConfig;
191191

192192
const main = Effect.gen(function* () {
193193
const args = yield* Effect.try({
@@ -218,7 +218,7 @@ const main = Effect.gen(function* () {
218218
),
219219
);
220220

221-
const projectConfig = yield* readOptionalLocalExecutorConfig(
221+
const projectConfig = yield* readOptionalExecutorScopeConfig(
222222
context.projectConfigPath,
223223
).pipe(
224224
Effect.mapError((cause) =>
@@ -293,7 +293,7 @@ const main = Effect.gen(function* () {
293293
sources: {
294294
[args.sourceId]: sanitizeSourceConfig(sourceConfig),
295295
},
296-
} satisfies LocalExecutorConfig;
296+
} satisfies ExecutorScopeConfig;
297297

298298
const fixtureState = {
299299
version: 1 as const,
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import {
2+
type ParseError as JsoncParseError,
3+
parse as parseJsoncDocument,
4+
printParseErrorCode,
5+
} from "jsonc-parser/lib/esm/main.js";
6+
7+
import {
8+
ExecutorScopeConfigSchema,
9+
type ExecutorScopeConfig,
10+
} from "@executor/platform-sdk/schema";
11+
import * as Schema from "effect/Schema";
12+
import {
13+
ExecutorScopeConfigDecodeError,
14+
unknownLocalErrorDetails,
15+
} from "./errors";
16+
17+
export const decodeExecutorScopeConfig = Schema.decodeUnknownSync(
18+
ExecutorScopeConfigSchema,
19+
{
20+
onExcessProperty: "error",
21+
},
22+
);
23+
24+
export const encodeExecutorScopeConfig = (
25+
config: ExecutorScopeConfig,
26+
): string => `${JSON.stringify(config, null, 2)}\n`;
27+
28+
const formatJsoncParseErrors = (
29+
content: string,
30+
errors: readonly JsoncParseError[],
31+
): string => {
32+
const lines = content.split("\n");
33+
34+
return errors
35+
.map((error) => {
36+
const beforeOffset = content.slice(0, error.offset).split("\n");
37+
const line = beforeOffset.length;
38+
const column = beforeOffset[beforeOffset.length - 1]?.length ?? 0;
39+
const lineText = lines[line - 1];
40+
const location = `line ${line}, column ${column + 1}`;
41+
const detail = printParseErrorCode(error.error);
42+
43+
if (!lineText) {
44+
return `${detail} at ${location}`;
45+
}
46+
47+
return `${detail} at ${location}\n${lineText}`;
48+
})
49+
.join("\n");
50+
};
51+
52+
export const parseJsoncValue = (input: {
53+
path: string;
54+
content: string;
55+
}): unknown => {
56+
const errors: JsoncParseError[] = [];
57+
const parsed = parseJsoncDocument(input.content, errors, {
58+
allowTrailingComma: true,
59+
});
60+
61+
if (errors.length > 0) {
62+
throw new ExecutorScopeConfigDecodeError({
63+
message: `Invalid executor config at ${input.path}: ${formatJsoncParseErrors(input.content, errors)}`,
64+
path: input.path,
65+
details: formatJsoncParseErrors(input.content, errors),
66+
});
67+
}
68+
69+
return parsed;
70+
};
71+
72+
export const parseExecutorScopeConfig = (input: {
73+
path: string;
74+
content: string;
75+
}): ExecutorScopeConfig => {
76+
try {
77+
return decodeExecutorScopeConfig(
78+
parseJsoncValue({
79+
path: input.path,
80+
content: input.content,
81+
}),
82+
);
83+
} catch (cause) {
84+
if (cause instanceof ExecutorScopeConfigDecodeError) {
85+
throw cause;
86+
}
87+
throw new ExecutorScopeConfigDecodeError({
88+
message: `Invalid executor config at ${input.path}: ${unknownLocalErrorDetails(cause)}`,
89+
path: input.path,
90+
details: unknownLocalErrorDetails(cause),
91+
});
92+
}
93+
};
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { tmpdir } from "node:os";
2+
import { join } from "node:path";
3+
import { FileSystem } from "@effect/platform";
4+
import { NodeFileSystem } from "@effect/platform-node";
5+
import { describe, expect, it } from "@effect/vitest";
6+
import * as Effect from "effect/Effect";
7+
8+
import {
9+
loadExecutorScopeConfig,
10+
resolveLocalWorkspaceContext,
11+
} from "./config";
12+
import {
13+
migrateLegacyExecutorScopeConfigs,
14+
} from "./config-migrations";
15+
import {
16+
createLocalExecutorRepositoriesEffect,
17+
} from "./index";
18+
19+
const resolveMaybeEffect = <T>(
20+
value: T | Promise<T> | Effect.Effect<T, Error, never>,
21+
): Effect.Effect<T, Error, never> =>
22+
Effect.isEffect(value)
23+
? value
24+
: value instanceof Promise
25+
? Effect.promise(() => value)
26+
: Effect.succeed(value);
27+
28+
const makeWorkspaceRoot = () =>
29+
FileSystem.FileSystem.pipe(
30+
Effect.flatMap((fs) =>
31+
fs.makeTempDirectory({
32+
directory: tmpdir(),
33+
prefix: "executor-config-migrations-",
34+
})
35+
),
36+
);
37+
38+
describe("config-migrations", () => {
39+
it.effect("rewrites legacy source entries into the current config shape", () =>
40+
Effect.gen(function* () {
41+
const fs = yield* FileSystem.FileSystem;
42+
const workspaceRoot = yield* makeWorkspaceRoot();
43+
const configDirectory = join(workspaceRoot, ".executor");
44+
yield* fs.makeDirectory(configDirectory, { recursive: true });
45+
yield* fs.writeFileString(
46+
join(configDirectory, "executor.jsonc"),
47+
`{
48+
"sources": {
49+
"github": {
50+
"kind": "openapi",
51+
"name": "GitHub",
52+
"connection": {
53+
"endpoint": "https://api.github.com"
54+
},
55+
"binding": {
56+
"specUrl": "https://example.com/openapi.json",
57+
"defaultHeaders": {
58+
"x-test": "1"
59+
}
60+
}
61+
},
62+
"google-calendar": {
63+
"kind": "google_discovery",
64+
"name": "Google Calendar",
65+
"namespace": "google.calendar",
66+
"connection": {
67+
"endpoint": "https://calendar-json.googleapis.com/$discovery/rest?version=v3"
68+
},
69+
"binding": {
70+
"service": "calendar",
71+
"version": "v3",
72+
"discoveryUrl": "https://calendar-json.googleapis.com/$discovery/rest?version=v3",
73+
"defaultHeaders": {
74+
"x-goog-api-client": "executor"
75+
},
76+
"scopes": [
77+
"https://www.googleapis.com/auth/calendar.readonly"
78+
]
79+
}
80+
}
81+
}
82+
}
83+
`,
84+
);
85+
86+
const context = yield* resolveLocalWorkspaceContext({ workspaceRoot });
87+
const beforeMigration = yield* Effect.flip(loadExecutorScopeConfig(context));
88+
expect(beforeMigration.message).toContain("Invalid executor config");
89+
90+
const migratedPaths = yield* migrateLegacyExecutorScopeConfigs(context);
91+
expect(migratedPaths).toContain(context.projectConfigPath);
92+
expect(yield* fs.exists(`${context.projectConfigPath}.legacy-backup`)).toBe(true);
93+
94+
const loaded = yield* loadExecutorScopeConfig(context);
95+
const rewritten = yield* fs.readFileString(context.projectConfigPath, "utf8");
96+
97+
expect(rewritten).not.toContain("\"connection\"");
98+
expect(rewritten).not.toContain("\"binding\"");
99+
expect(loaded.config?.sources?.github?.config).toEqual({
100+
specUrl: "https://example.com/openapi.json",
101+
baseUrl: "https://api.github.com",
102+
auth: {
103+
kind: "none",
104+
},
105+
defaultHeaders: {
106+
"x-test": "1",
107+
},
108+
});
109+
110+
expect(loaded.config?.sources?.["google-calendar"]?.config).toEqual({
111+
service: "calendar",
112+
version: "v3",
113+
discoveryUrl: "https://calendar-json.googleapis.com/$discovery/rest?version=v3",
114+
defaultHeaders: {
115+
"x-goog-api-client": "executor",
116+
},
117+
scopes: ["https://www.googleapis.com/auth/calendar.readonly"],
118+
auth: {
119+
kind: "none",
120+
},
121+
});
122+
}).pipe(Effect.provide(NodeFileSystem.layer)),
123+
);
124+
125+
it.effect("runs startup migrations before the strict config loader", () =>
126+
Effect.gen(function* () {
127+
const fs = yield* FileSystem.FileSystem;
128+
const workspaceRoot = yield* makeWorkspaceRoot();
129+
const homeConfigPath = join(workspaceRoot, ".executor-home.jsonc");
130+
const homeStateDirectory = join(workspaceRoot, ".executor-home-state");
131+
const configDirectory = join(workspaceRoot, ".executor");
132+
const projectConfigPath = join(configDirectory, "executor.jsonc");
133+
yield* fs.makeDirectory(configDirectory, { recursive: true });
134+
yield* fs.writeFileString(
135+
projectConfigPath,
136+
`{
137+
"secrets": {
138+
"providers": {},
139+
"defaults": {}
140+
},
141+
"sources": {
142+
"github": {
143+
"kind": "openapi",
144+
"config": {
145+
"specUrl": "https://example.com/openapi.json",
146+
"baseUrl": "https://api.github.com",
147+
"auth": {
148+
"kind": "none"
149+
},
150+
"defaultHeaders": null
151+
}
152+
}
153+
}
154+
}
155+
`,
156+
);
157+
158+
const context = yield* resolveLocalWorkspaceContext({
159+
workspaceRoot,
160+
homeConfigPath,
161+
homeStateDirectory,
162+
});
163+
const beforeStartup = yield* Effect.flip(loadExecutorScopeConfig(context));
164+
expect(beforeStartup.message).toContain("Invalid executor config");
165+
166+
const repositories = yield* createLocalExecutorRepositoriesEffect({
167+
workspaceRoot,
168+
homeConfigPath,
169+
homeStateDirectory,
170+
});
171+
const rewritten = yield* fs.readFileString(projectConfigPath, "utf8");
172+
const loaded = yield* resolveMaybeEffect(
173+
repositories.workspace.config.load(),
174+
);
175+
176+
expect(yield* fs.exists(`${projectConfigPath}.legacy-backup`)).toBe(true);
177+
expect(rewritten).not.toContain("\"secrets\"");
178+
expect(loaded.projectConfig?.sources?.github?.config).toEqual({
179+
specUrl: "https://example.com/openapi.json",
180+
baseUrl: "https://api.github.com",
181+
auth: {
182+
kind: "none",
183+
},
184+
defaultHeaders: null,
185+
});
186+
}).pipe(Effect.provide(NodeFileSystem.layer)),
187+
);
188+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { FileSystem } from "@effect/platform";
2+
import * as Effect from "effect/Effect";
3+
import type {
4+
ExecutorScopeConfigDecodeError,
5+
LocalFileSystemError,
6+
} from "../errors";
7+
import {
8+
migrateLegacyExecutorScopeConfigs,
9+
type StartupConfigMigrationContext,
10+
} from "./legacy-local-config";
11+
12+
type StartupConfigMigration = (
13+
context: StartupConfigMigrationContext,
14+
) => Effect.Effect<
15+
ReadonlyArray<string>,
16+
LocalFileSystemError | ExecutorScopeConfigDecodeError,
17+
FileSystem.FileSystem
18+
>;
19+
20+
// Keep legacy upgrade steps out of steady-state config loading so they can be
21+
// extended or deleted without touching the main application path.
22+
const startupConfigMigrations: readonly StartupConfigMigration[] = [
23+
migrateLegacyExecutorScopeConfigs,
24+
];
25+
26+
export const runStartupConfigMigrations = (
27+
context: StartupConfigMigrationContext,
28+
): Effect.Effect<
29+
ReadonlyArray<string>,
30+
LocalFileSystemError | ExecutorScopeConfigDecodeError,
31+
FileSystem.FileSystem
32+
> =>
33+
Effect.gen(function* () {
34+
const migratedPaths = new Set<string>();
35+
36+
for (const migrate of startupConfigMigrations) {
37+
for (const path of yield* migrate(context)) {
38+
migratedPaths.add(path);
39+
}
40+
}
41+
42+
return [...migratedPaths];
43+
});
44+
45+
export {
46+
migrateLegacyExecutorScopeConfigs,
47+
type StartupConfigMigrationContext,
48+
} from "./legacy-local-config";

0 commit comments

Comments
 (0)