Skip to content

Commit 0aa7ada

Browse files
committed
refactor: add output schemas, extract and use input schema as types
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 d121998 commit 0aa7ada

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)