diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index f21044361110..66e143bd90cc 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -1,12 +1,10 @@ import { CommandId, - ModelSelection, ProjectId, type Project, - type ProjectIconOverride, - type ProjectScript, + type ProjectCreatePayload, + type ProjectUpdatePayload, type ProjectSnapshot, - type ThreadEnvMode, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -29,27 +27,14 @@ import * as ProjectionProjects from "../persistence/Services/ProjectionProjects. import { ProjectEnrichmentService, type ProjectEnrichment } from "./ProjectEnrichmentService.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; -export interface ProjectCreateInput { +export interface ProjectCreateInput extends ProjectCreatePayload { readonly commandId: CommandId; readonly projectId: ProjectId; - readonly title: string; - readonly workspaceRoot: string; - readonly createWorkspaceRootIfMissing?: boolean; - readonly defaultModelSelection?: ModelSelection | null; - readonly scripts?: ReadonlyArray; } -export interface ProjectUpdateInput { +export interface ProjectUpdateInput extends ProjectUpdatePayload { readonly commandId: CommandId; readonly projectId: ProjectId; - readonly title?: string; - readonly workspaceRoot?: string; - readonly defaultModelSelection?: ModelSelection | null; - readonly autoPull?: boolean; - readonly projectIcon?: ProjectIconOverride | null; - readonly faviconPath?: string | null; - readonly defaultThreadEnvMode?: ThreadEnvMode | null; - readonly scripts?: ReadonlyArray; } export interface ProjectBootstrapInput extends ProjectCreateInput {} diff --git a/packages/contracts/src/project.test.ts b/packages/contracts/src/project.test.ts index 8e6771cba88f..16aaeee4f2ab 100644 --- a/packages/contracts/src/project.test.ts +++ b/packages/contracts/src/project.test.ts @@ -3,6 +3,9 @@ import { describe, expect, it } from "vite-plus/test"; import { ProjectReadFileError, + ProjectCreatePayload, + ProjectUpdatePayload, + ProjectMutation, ProjectSearchContentsError, ProjectSearchContentsInput, ProjectSearchEntriesError, @@ -10,6 +13,9 @@ import { ProjectWriteFileError, } from "./project.ts"; +const decodeProjectCreatePayload = Schema.decodeUnknownSync(ProjectCreatePayload); +const decodeProjectUpdatePayload = Schema.decodeUnknownSync(ProjectUpdatePayload); +const decodeProjectMutation = Schema.decodeUnknownSync(ProjectMutation); const decodeSearchEntriesInput = Schema.decodeUnknownSync(ProjectSearchEntriesInput); const decodeSearchContentsInput = Schema.decodeUnknownSync(ProjectSearchContentsInput); @@ -107,3 +113,39 @@ describe("project RPC errors", () => { expect(writeError.failure).toBeUndefined(); }); }); + +describe("shared project payloads", () => { + it("preserves omitted, false, and null values through RPC envelopes", () => { + const create = decodeProjectCreatePayload({ + title: " Example ", + workspaceRoot: "/workspace", + createWorkspaceRootIfMissing: false, + }); + const update = decodeProjectUpdatePayload({ + autoPull: false, + defaultModelSelection: null, + faviconPath: null, + }); + const envelope = { commandId: "command", projectId: "project" }; + expect(decodeProjectMutation({ type: "project.create", ...envelope, ...create })).toEqual({ + type: "project.create", + ...envelope, + title: "Example", + workspaceRoot: "/workspace", + createWorkspaceRootIfMissing: false, + }); + expect(decodeProjectMutation({ type: "project.update", ...envelope, ...update })).toEqual({ + type: "project.update", + ...envelope, + autoPull: false, + defaultModelSelection: null, + faviconPath: null, + }); + expect(Object.hasOwn(create, "scripts")).toBe(false); + expect(Object.hasOwn(update, "title")).toBe(false); + // Internal RPC callers may explicitly supply undefined, as before the extraction. + expect( + decodeProjectMutation({ type: "project.update", ...envelope, title: undefined }), + ).toHaveProperty("title", undefined); + }); +}); diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index aa67ed072e13..3be2956bc0d9 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -113,29 +113,39 @@ export const ProjectChange = Schema.Union([ ]); export type ProjectChange = typeof ProjectChange.Type; +export const ProjectCreatePayload = Schema.Struct({ + title: TrimmedNonEmptyString, + workspaceRoot: TrimmedNonEmptyString, + createWorkspaceRootIfMissing: Schema.optional(Schema.Boolean), + defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), + scripts: Schema.optional(Schema.Array(ProjectScript)), +}); +export type ProjectCreatePayload = typeof ProjectCreatePayload.Type; + +export const ProjectUpdatePayload = Schema.Struct({ + title: Schema.optional(TrimmedNonEmptyString), + workspaceRoot: Schema.optional(TrimmedNonEmptyString), + defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), + autoPull: Schema.optional(Schema.Boolean), + projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), + faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), + scripts: Schema.optional(Schema.Array(ProjectScript)), +}); +export type ProjectUpdatePayload = typeof ProjectUpdatePayload.Type; + export const ProjectMutation = Schema.Union([ Schema.Struct({ type: Schema.Literal("project.create"), commandId: CommandId, projectId: ProjectId, - title: TrimmedNonEmptyString, - workspaceRoot: TrimmedNonEmptyString, - createWorkspaceRootIfMissing: Schema.optional(Schema.Boolean), - defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), - scripts: Schema.optional(Schema.Array(ProjectScript)), + ...ProjectCreatePayload.fields, }), Schema.Struct({ type: Schema.Literal("project.update"), commandId: CommandId, projectId: ProjectId, - title: Schema.optional(TrimmedNonEmptyString), - workspaceRoot: Schema.optional(TrimmedNonEmptyString), - defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), - autoPull: Schema.optional(Schema.Boolean), - projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), - faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), - defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), - scripts: Schema.optional(Schema.Array(ProjectScript)), + ...ProjectUpdatePayload.fields, }), Schema.Struct({ type: Schema.Literal("project.delete"),