Skip to content

Commit c6a8296

Browse files
authored
refactor: add output schemas, extract and use input schema as types (#275)
We previously were not enforcing output schemas, and also not using the input schemas as types, meaning we did not have 1 source of truth.
1 parent 000405d commit c6a8296

File tree

14 files changed

+274
-125
lines changed

14 files changed

+274
-125
lines changed

ARCHITECTURE.md

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -127,22 +127,25 @@ We use [tRPC](https://trpc.io/) with [trpc-electron](https://github.com/jsonnull
127127

128128
```typescript
129129
// src/main/trpc/routers/my-router.ts
130-
import { z } from "zod";
131130
import { container } from "../../di/container";
132131
import { MAIN_TOKENS } from "../../di/tokens";
132+
import {
133+
getDataInput,
134+
getDataOutput,
135+
updateDataInput,
136+
} from "../../services/my-service/schemas";
133137
import { router, publicProcedure } from "../trpc";
134138

135139
const getService = () => container.get<MyService>(MAIN_TOKENS.MyService);
136140

137141
export const myRouter = router({
138-
// Query - for read operations
139142
getData: publicProcedure
140-
.input(z.object({ id: z.string() }))
143+
.input(getDataInput)
144+
.output(getDataOutput)
141145
.query(({ input }) => getService().getData(input.id)),
142146

143-
// Mutation - for write operations
144147
updateData: publicProcedure
145-
.input(z.object({ id: z.string(), value: z.string() }))
148+
.input(updateDataInput)
146149
.mutation(({ input }) => getService().updateData(input.id, input.value)),
147150
});
148151
```
@@ -238,12 +241,64 @@ Main services should be:
238241
src/main/services/
239242
├── my-service/
240243
│ ├── service.ts # The injectable service class
241-
│ └── types.ts # Types and interfaces
244+
│ ├── schemas.ts # Zod schemas for tRPC input/output
245+
│ └── types.ts # Internal types (not exposed via tRPC)
242246
243247
src/renderer/services/
244248
├── my-service.ts # Renderer-side service
245249
```
246250

251+
### Zod Schemas
252+
253+
All tRPC inputs and outputs use Zod schemas as the single source of truth. Types are inferred from schemas.
254+
255+
```typescript
256+
// src/main/services/my-service/schemas.ts
257+
import { z } from "zod";
258+
259+
export const getDataInput = z.object({
260+
id: z.string(),
261+
});
262+
263+
export const getDataOutput = z.object({
264+
id: z.string(),
265+
name: z.string(),
266+
createdAt: z.string(),
267+
});
268+
269+
export type GetDataInput = z.infer<typeof getDataInput>;
270+
export type GetDataOutput = z.infer<typeof getDataOutput>;
271+
```
272+
273+
```typescript
274+
// src/main/trpc/routers/my-router.ts
275+
import { getDataInput, getDataOutput } from "../../services/my-service/schemas";
276+
277+
export const myRouter = router({
278+
getData: publicProcedure
279+
.input(getDataInput)
280+
.output(getDataOutput)
281+
.query(({ input }) => getService().getData(input.id)),
282+
});
283+
```
284+
285+
```typescript
286+
// src/main/services/my-service/service.ts
287+
import type { GetDataInput, GetDataOutput } from "./schemas";
288+
289+
@injectable()
290+
export class MyService {
291+
async getData(id: string): Promise<GetDataOutput> {
292+
// ...
293+
}
294+
}
295+
```
296+
297+
This pattern provides:
298+
- Runtime validation of inputs and outputs
299+
- Single source of truth for types
300+
- Explicit API contracts between main and renderer
301+
247302
## Adding a New Feature
248303

249304
1. **Create the service** in `src/main/services/`
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { z } from "zod";
2+
3+
export const taskContextMenuInput = z.object({
4+
taskTitle: z.string(),
5+
worktreePath: z.string().optional(),
6+
});
7+
8+
export const folderContextMenuInput = z.object({
9+
folderName: z.string(),
10+
folderPath: z.string().optional(),
11+
});
12+
13+
export const tabContextMenuInput = z.object({
14+
canClose: z.boolean(),
15+
filePath: z.string().optional(),
16+
});
17+
18+
export const fileContextMenuInput = z.object({
19+
filePath: z.string(),
20+
showCollapseAll: z.boolean().optional(),
21+
});
22+
23+
const externalAppAction = z.discriminatedUnion("type", [
24+
z.object({ type: z.literal("open-in-app"), appId: z.string() }),
25+
z.object({ type: z.literal("copy-path") }),
26+
]);
27+
28+
const taskAction = z.discriminatedUnion("type", [
29+
z.object({ type: z.literal("rename") }),
30+
z.object({ type: z.literal("duplicate") }),
31+
z.object({ type: z.literal("delete") }),
32+
z.object({ type: z.literal("external-app"), action: externalAppAction }),
33+
]);
34+
35+
const folderAction = z.discriminatedUnion("type", [
36+
z.object({ type: z.literal("remove") }),
37+
z.object({ type: z.literal("external-app"), action: externalAppAction }),
38+
]);
39+
40+
const tabAction = z.discriminatedUnion("type", [
41+
z.object({ type: z.literal("close") }),
42+
z.object({ type: z.literal("close-others") }),
43+
z.object({ type: z.literal("close-right") }),
44+
z.object({ type: z.literal("external-app"), action: externalAppAction }),
45+
]);
46+
47+
const fileAction = z.discriminatedUnion("type", [
48+
z.object({ type: z.literal("collapse-all") }),
49+
z.object({ type: z.literal("external-app"), action: externalAppAction }),
50+
]);
51+
52+
const splitDirection = z.enum(["left", "right", "up", "down"]);
53+
54+
export const taskContextMenuOutput = z.object({
55+
action: taskAction.nullable(),
56+
});
57+
export const folderContextMenuOutput = z.object({
58+
action: folderAction.nullable(),
59+
});
60+
export const tabContextMenuOutput = z.object({ action: tabAction.nullable() });
61+
export const fileContextMenuOutput = z.object({
62+
action: fileAction.nullable(),
63+
});
64+
export const splitContextMenuOutput = z.object({
65+
direction: splitDirection.nullable(),
66+
});
67+
68+
export type TaskContextMenuInput = z.infer<typeof taskContextMenuInput>;
69+
export type FolderContextMenuInput = z.infer<typeof folderContextMenuInput>;
70+
export type TabContextMenuInput = z.infer<typeof tabContextMenuInput>;
71+
export type FileContextMenuInput = z.infer<typeof fileContextMenuInput>;
72+
73+
export type ExternalAppAction = z.infer<typeof externalAppAction>;
74+
export type TaskAction = z.infer<typeof taskAction>;
75+
export type FolderAction = z.infer<typeof folderAction>;
76+
export type TabAction = z.infer<typeof tabAction>;
77+
export type FileAction = z.infer<typeof fileAction>;
78+
export type SplitDirection = z.infer<typeof splitDirection>;
79+
80+
export type TaskContextMenuResult = z.infer<typeof taskContextMenuOutput>;
81+
export type FolderContextMenuResult = z.infer<typeof folderContextMenuOutput>;
82+
export type TabContextMenuResult = z.infer<typeof tabContextMenuOutput>;
83+
export type FileContextMenuResult = z.infer<typeof fileContextMenuOutput>;
84+
export type SplitContextMenuResult = z.infer<typeof splitContextMenuOutput>;

apps/array/src/main/services/context-menu/service.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,12 @@ import { MAIN_TOKENS } from "../../di/tokens.js";
1010
import { getMainWindow } from "../../trpc/context.js";
1111
import type { ExternalAppsService } from "../external-apps/service.js";
1212
import type {
13-
ActionItemDef,
14-
ConfirmOptions,
1513
FileAction,
1614
FileContextMenuInput,
1715
FileContextMenuResult,
1816
FolderAction,
1917
FolderContextMenuInput,
2018
FolderContextMenuResult,
21-
MenuItemDef,
22-
SeparatorDef,
2319
SplitContextMenuResult,
2420
SplitDirection,
2521
TabAction,
@@ -28,6 +24,12 @@ import type {
2824
TaskAction,
2925
TaskContextMenuInput,
3026
TaskContextMenuResult,
27+
} from "./schemas.js";
28+
import type {
29+
ActionItemDef,
30+
ConfirmOptions,
31+
MenuItemDef,
32+
SeparatorDef,
3133
} from "./types.js";
3234

3335
@injectable()

apps/array/src/main/services/context-menu/types.ts

Lines changed: 0 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,3 @@
1-
export type ExternalAppAction =
2-
| { type: "open-in-app"; appId: string }
3-
| { type: "copy-path" };
4-
5-
export type TaskAction =
6-
| { type: "rename" }
7-
| { type: "duplicate" }
8-
| { type: "delete" }
9-
| { type: "external-app"; action: ExternalAppAction };
10-
11-
export type FolderAction =
12-
| { type: "remove" }
13-
| { type: "external-app"; action: ExternalAppAction };
14-
15-
export type TabAction =
16-
| { type: "close" }
17-
| { type: "close-others" }
18-
| { type: "close-right" }
19-
| { type: "external-app"; action: ExternalAppAction };
20-
21-
export type FileAction =
22-
| { type: "collapse-all" }
23-
| { type: "external-app"; action: ExternalAppAction };
24-
25-
export type SplitDirection = "left" | "right" | "up" | "down";
26-
27-
export interface TaskContextMenuInput {
28-
taskTitle: string;
29-
worktreePath?: string;
30-
}
31-
32-
export interface TaskContextMenuResult {
33-
action: TaskAction | null;
34-
}
35-
36-
export interface FolderContextMenuInput {
37-
folderName: string;
38-
folderPath?: string;
39-
}
40-
41-
export interface FolderContextMenuResult {
42-
action: FolderAction | null;
43-
}
44-
45-
export interface TabContextMenuInput {
46-
canClose: boolean;
47-
filePath?: string;
48-
}
49-
50-
export interface TabContextMenuResult {
51-
action: TabAction | null;
52-
}
53-
54-
export interface SplitContextMenuResult {
55-
direction: SplitDirection | null;
56-
}
57-
58-
export interface FileContextMenuInput {
59-
filePath: string;
60-
showCollapseAll?: boolean;
61-
}
62-
63-
export interface FileContextMenuResult {
64-
action: FileAction | null;
65-
}
66-
671
export interface ConfirmOptions {
682
title: string;
693
message: string;
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { z } from "zod";
2+
3+
export const openInAppInput = z.object({
4+
appId: z.string(),
5+
targetPath: z.string(),
6+
});
7+
8+
export const setLastUsedInput = z.object({
9+
appId: z.string(),
10+
});
11+
12+
export const copyPathInput = z.object({
13+
targetPath: z.string(),
14+
});
15+
16+
const externalAppType = z.enum(["editor", "terminal", "file-manager"]);
17+
18+
const detectedApplication = z.object({
19+
id: z.string(),
20+
name: z.string(),
21+
type: externalAppType,
22+
path: z.string(),
23+
command: z.string(),
24+
icon: z.string().optional(),
25+
});
26+
27+
export const getDetectedAppsOutput = z.array(detectedApplication);
28+
export const openInAppOutput = z.object({
29+
success: z.boolean(),
30+
error: z.string().optional(),
31+
});
32+
export const getLastUsedOutput = z.object({
33+
lastUsedApp: z.string().optional(),
34+
});
35+
36+
export type OpenInAppInput = z.infer<typeof openInAppInput>;
37+
export type SetLastUsedInput = z.infer<typeof setLastUsedInput>;
38+
export type CopyPathInput = z.infer<typeof copyPathInput>;
39+
export type DetectedApplication = z.infer<typeof detectedApplication>;
40+
export type OpenInAppOutput = z.infer<typeof openInAppOutput>;
41+
export type GetLastUsedOutput = z.infer<typeof getLastUsedOutput>;

apps/array/src/main/services/external-apps/service.ts

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,12 @@ import Store from "electron-store";
77
import { injectable } from "inversify";
88
import type {
99
DetectedApplication,
10-
ExternalAppsPreferences,
1110
ExternalAppType,
1211
} from "../../../shared/types.js";
12+
import type { AppDefinition, ExternalAppsSchema } from "./types.js";
1313

1414
const execAsync = promisify(exec);
1515

16-
interface AppDefinition {
17-
path: string;
18-
type: ExternalAppType;
19-
}
20-
21-
interface ExternalAppsSchema {
22-
externalAppsPrefs: ExternalAppsPreferences;
23-
}
24-
2516
@injectable()
2617
export class ExternalAppsService {
2718
private readonly APP_DEFINITIONS: Record<string, AppDefinition> = {
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { ExternalAppType } from "../../../shared/types.js";
2+
3+
export interface AppDefinition {
4+
path: string;
5+
type: ExternalAppType;
6+
}
7+
8+
export interface ExternalAppsPreferences {
9+
lastUsedApp?: string;
10+
}
11+
12+
export interface ExternalAppsSchema {
13+
externalAppsPrefs: ExternalAppsPreferences;
14+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { z } from "zod";
2+
3+
export const detectRepoInput = z.object({
4+
directoryPath: z.string(),
5+
});
6+
7+
export const detectRepoOutput = z
8+
.object({
9+
organization: z.string(),
10+
repository: z.string(),
11+
remote: z.string().optional(),
12+
branch: z.string().optional(),
13+
})
14+
.nullable();
15+
16+
export type DetectRepoInput = z.infer<typeof detectRepoInput>;
17+
export type DetectRepoResult = z.infer<typeof detectRepoOutput>;

apps/array/src/main/services/git/service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { execFile } from "node:child_process";
22
import { promisify } from "node:util";
33
import { injectable } from "inversify";
4-
import type { DetectRepoResult } from "./types";
5-
import { parseGitHubUrl } from "./utils";
4+
import type { DetectRepoResult } from "./schemas.js";
5+
import { parseGitHubUrl } from "./utils.js";
66

77
const execFileAsync = promisify(execFile);
88

0 commit comments

Comments
 (0)