Skip to content
Source
Sign inSign up

ProjectCommands.ts

235 lines8.4 KB

1import {
2 type CommandId,
3 type EventId,
4 MAX_SCRIPT_ID_LENGTH,
5 type ModelSelection,
6 type ProjectIconOverride,
7 ProjectId,
8 type ProjectScript,
9 SCRIPT_RUN_COMMAND_PATTERN,
10 type ThreadEnvMode,
11} from "@t3tools/contracts";
12import * as DateTime from "effect/DateTime";
13import * as Result from "effect/Result";
14import * as Schema from "effect/Schema";
15​
16import type { UnsequencedProjectEvent } from "../persistence/OrchestrationEventStore.ts";
17import type { ProjectRow } from "./ProjectStore.ts";
18​
19export interface ProjectCreateCommand {
20 readonly type: "project.create";
21 readonly commandId: CommandId;
22 readonly projectId: ProjectId;
23 readonly title: string;
24 readonly workspaceRoot: string;
25 readonly scripts?: ReadonlyArray<ProjectScript>;
27​
28export interface ProjectMetaUpdateCommand {
29 readonly type: "project.meta.update";
30 readonly commandId: CommandId;
31 readonly projectId: ProjectId;
32 readonly title?: string;
33 readonly workspaceRoot?: string;
34 readonly defaultModelSelection?: ModelSelection | null;
35 readonly defaultThreadEnvMode?: ThreadEnvMode | null;
36 readonly autoPull?: boolean;
37 readonly faviconPath?: string | null;
38 readonly projectIcon?: ProjectIconOverride | null;
39 readonly scripts?: ReadonlyArray<ProjectScript>;
40 readonly pinned?: boolean;
41 readonly archived?: boolean;
43​
44export interface ProjectDeleteCommand {
45 readonly type: "project.delete";
46 readonly commandId: CommandId;
47 readonly projectId: ProjectId;
49​
50export type ProjectCommand = ProjectCreateCommand | ProjectMetaUpdateCommand | ProjectDeleteCommand;
51​
52export class ProjectCommandInvariantError extends Schema.TaggedError<ProjectCommandInvariantError>()(
53 "ProjectCommandInvariantError",
54 {
55 commandType: Schema.String,
56 detail: Schema.String,
57 },
58) {
59 override get message(): string {
60 return `Project command invariant failed (${this.commandType}): ${this.detail}`;
61 }
63​
64export class ProjectCommandMissingProjectError extends Schema.TaggedError<ProjectCommandMissingProjectError>()(
65 "ProjectCommandMissingProjectError",
66 {
67 commandType: Schema.String,
68 projectId: ProjectId,
69 },
70) {
71 override get message(): string {
72 return `Project '${this.projectId}' does not exist for command '${this.commandType}'.`;
73 }
75​
76export class ProjectWorkspaceConflictError extends Schema.TaggedError<ProjectWorkspaceConflictError>()(
77 "ProjectWorkspaceConflictError",
78 {
79 workspaceRoot: Schema.String,
80 conflictingProjectId: ProjectId,
81 },
82) {
83 override get message(): string {
84 return `Active project '${this.conflictingProjectId}' already exists for workspace root '${this.workspaceRoot}'.`;
85 }
87​
88export const ProjectCommandRejection = Schema.Union([
89 ProjectCommandInvariantError,
90 ProjectCommandMissingProjectError,
91 ProjectWorkspaceConflictError,
92]);
93export type ProjectCommandRejection = typeof ProjectCommandRejection.Type;
94​
95const ProjectCommandRejectionJson = Schema.fromJsonString(ProjectCommandRejection);
96export const encodeProjectCommandRejection = Schema.encodeSync(ProjectCommandRejectionJson);
97export const decodeProjectCommandRejection = Schema.decodeUnknownOption(
98 ProjectCommandRejectionJson,
99);
100​
101export interface ProjectCommandState {
102 readonly project: ProjectRow | undefined;
103 readonly workspaceOwner: ProjectRow | undefined;
105​
106const monogramSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
107const isScriptRunCommand = Schema.is(SCRIPT_RUN_COMMAND_PATTERN);
108​
109/** Read state and commit the planned event under the project lock. */
110export function planProjectCommand(input: {
111 readonly command: ProjectCommand;
112 readonly state: ProjectCommandState;
113 readonly eventId: EventId;
114 readonly now: DateTime.Utc;
115}): Result.Result<UnsequencedProjectEvent, ProjectCommandRejection> {
116 const { command, state } = input;
117 const invariant = (detail: string) =>
118 Result.fail(new ProjectCommandInvariantError({ commandType: command.type, detail }));
119 const missingProject = () =>
120 Result.fail(
121 new ProjectCommandMissingProjectError({
122 commandType: command.type,
123 projectId: command.projectId,
124 }),
125 );
126 const activeProject = state.project?.deletedAt === null ? state.project : undefined;
127 const requireWorkspaceAvailable = (workspaceRoot: string) =>
128 state.workspaceOwner === undefined || state.workspaceOwner.projectId === command.projectId
129 ? undefined
130 : new ProjectWorkspaceConflictError({
131 workspaceRoot,
132 conflictingProjectId: state.workspaceOwner.projectId,
133 });
134 const occurredAt = DateTime.formatIso(input.now);
135 const base = {
136 eventId: input.eventId,
137 aggregateKind: "project" as const,
138 aggregateId: command.projectId,
139 occurredAt,
140 commandId: command.commandId,
141 causationEventId: null,
142 correlationId: command.commandId,
143 metadata: {},
144 };
145​
146 switch (command.type) {
147 case "project.create": {
148 if (state.project !== undefined) {
149 return invariant(
150 `Project '${command.projectId}' already exists and cannot be created twice.`,
151 );
152 }
153 const conflict = requireWorkspaceAvailable(command.workspaceRoot);
154 if (conflict !== undefined) return Result.fail(conflict);
155 return Result.succeed({
156 ...base,
157 type: "project.created",
158 payload: {
159 projectId: command.projectId,
160 title: command.title,
161 workspaceRoot: command.workspaceRoot,
162 defaultModelSelection: null,
163 faviconPath: null,
164 projectIcon: null,
165 scripts: command.scripts ?? [],
166 createdAt: occurredAt,
167 updatedAt: occurredAt,
168 },
169 });
170 }
171​
172 case "project.meta.update": {
173 const project = activeProject;
174 if (project === undefined) return missingProject();
175 if (
176 command.projectIcon?.kind === "monogram" &&
177 Array.from(monogramSegmenter.segment(command.projectIcon.text)).length > 2
178 ) {
179 return invariant("Project monograms must contain at most two characters.");
180 }
181 if (command.scripts !== undefined) {
182 // Allow editing or removing persisted invalid script ids, while rejecting new invalid ids.
183 const existingIds = new Set(project.scripts.map((script) => script.id));
184 for (const script of command.scripts) {
185 if (!existingIds.has(script.id) && !isScriptRunCommand(`script.${script.id}.run`)) {
186 return invariant(
187 `Script IDs must be 1-${MAX_SCRIPT_ID_LENGTH} lowercase letters, digits or hyphens, starting with a letter or digit (got ${script.id.length} characters).`,
188 );
189 }
190 }
191 }
192 if (command.workspaceRoot !== undefined) {
193 const conflict = requireWorkspaceAvailable(command.workspaceRoot);
194 if (conflict !== undefined) return Result.fail(conflict);
195 }
196 return Result.succeed({
197 ...base,
198 type: "project.meta-updated",
199 payload: {
200 projectId: command.projectId,
201 ...(command.title === undefined ? {} : { title: command.title }),
202 ...(command.workspaceRoot === undefined ? {} : { workspaceRoot: command.workspaceRoot }),
203 ...(command.defaultModelSelection === undefined
204 ? {}
205 : { defaultModelSelection: command.defaultModelSelection }),
206 ...(command.defaultThreadEnvMode === undefined
207 ? {}
208 : { defaultThreadEnvMode: command.defaultThreadEnvMode }),
209 ...(command.autoPull === undefined ? {} : { autoPull: command.autoPull }),
210 ...(command.faviconPath === undefined ? {} : { faviconPath: command.faviconPath }),
211 ...(command.projectIcon === undefined ? {} : { projectIcon: command.projectIcon }),
212 ...(command.scripts === undefined ? {} : { scripts: command.scripts }),
213 // Repeating a pin or archive keeps the original time.
214 ...(command.pinned === undefined
215 ? {}
216 : { pinnedAt: command.pinned ? (project.pinnedAt ?? occurredAt) : null }),
217 ...(command.archived === undefined
218 ? {}
219 : { archivedAt: command.archived ? (project.archivedAt ?? occurredAt) : null }),
220 updatedAt: occurredAt,
221 },
222 });
223 }
224​
225 case "project.delete": {
226 if (activeProject === undefined) return missingProject();
227 // Thread children are deleted by ProjectService before this event commits.
228 return Result.succeed({
229 ...base,
230 type: "project.deleted",
231 payload: { projectId: command.projectId, deletedAt: occurredAt },
232 });
233 }
234 }