authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-05 09:42:59-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 09:42:59-07:00
log5c03ced3be8444567db4b50a474619bc5e63db98
tree0dec8dbf1c1a4f2dc037652c8627a61d9e253e4f
parentca3f9738c39c3502dd2258aae872f971080969ef
signature Signed by PGP key B5690EEEBB952194

refactor(contracts): project icons decode forward-compatibly instead of encoding a fallback (#16118)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

6 files changed, 101 insertions(+), 103 deletions(-)

apps/server/src/orchestration-v2/ProjectStore.ts+3-3
......@@ -3,7 +3,7 @@ import {
33 IsoDateTime,
44 ModelSelection,
55 type OrchestrationProjectShell,
6 ProjectIconOverride,
6 StoredProjectIcon,
77 ProjectId,
88 ProjectScript,
99 ThreadEnvMode,
......@@ -37,7 +37,7 @@ export const ProjectRow = Schema.Struct({
3737 defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode),
3838 autoPull: Schema.Boolean,
3939 faviconPath: Schema.NullOr(Schema.String),
40 projectIcon: Schema.NullOr(ProjectIconOverride),
40 projectIcon: Schema.NullOr(StoredProjectIcon),
4141 scripts: Schema.Array(ProjectScript),
4242 createdAt: IsoDateTime,
4343 updatedAt: IsoDateTime,
......@@ -49,7 +49,7 @@ const ProjectDbRow = Schema.Struct({
4949 ...ProjectRow.fields,
5050 defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)),
5151 autoPull: Schema.BooleanFromBit,
52 projectIcon: Schema.NullOr(Schema.fromJsonString(ProjectIconOverride)),
52 projectIcon: Schema.NullOr(Schema.fromJsonString(StoredProjectIcon)),
5353 scripts: Schema.fromJsonString(Schema.Array(ProjectScript)),
5454});
5555
apps/server/src/persistence/Layers/OrchestrationEventStore.ts+2-2
......@@ -9,7 +9,7 @@ import {
99 OrchestrationV2DomainEventJson,
1010 OrchestrationV2StoredEvent,
1111 ProjectId,
12 ProjectIconOverride,
12 StoredProjectIcon,
1313 ThreadId,
1414 type OrchestrationV2DomainEvent,
1515} from "@t3tools/contracts";
......@@ -31,7 +31,7 @@ import {
3131} from "../Errors.ts";
3232import * as OrchestrationEventStore from "../Services/OrchestrationEventStore.ts";
3333
34const encodeProjectIcon = Schema.encodeSync(ProjectIconOverride);
34const encodeProjectIcon = Schema.encodeSync(StoredProjectIcon);
3535const decodeProjectEvent = Schema.decodeUnknownEffect(ApplicationProjectEvent);
3636const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown);
3737const EventMetadataFromJsonString = Schema.fromJsonString(ApplicationEventMetadata);
packages/contracts/src/applicationEvent.ts+3-3
......@@ -14,7 +14,7 @@ import {
1414import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts";
1515import { ModelSelection } from "./modelSelection.ts";
1616import type { OrchestrationV2StoredEvent } from "./orchestrationV2.ts";
17import { ProjectIconOverride, ProjectScript } from "./project.ts";
17import { ProjectScript, StoredProjectIcon } from "./project.ts";
1818
1919/**
2020 * Which client dispatched the command that produced this event (#7774).
......@@ -51,7 +51,7 @@ export const ApplicationProjectCreatedPayload = Schema.Struct({
5151 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
5252 // Optional so persisted events from older servers still decode.
5353 faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
54 projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)),
54 projectIcon: Schema.optional(Schema.NullOr(StoredProjectIcon)),
5555 scripts: Schema.Array(ProjectScript),
5656 createdAt: IsoDateTime,
5757 updatedAt: IsoDateTime,
......@@ -68,7 +68,7 @@ export const ApplicationProjectMetaUpdatedPayload = Schema.Struct({
6868 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
6969 autoPull: Schema.optional(Schema.Boolean),
7070 faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
71 projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)),
71 projectIcon: Schema.optional(Schema.NullOr(StoredProjectIcon)),
7272 scripts: Schema.optional(Schema.Array(ProjectScript)),
7373 updatedAt: IsoDateTime,
7474});
packages/contracts/src/orchestrationProject.ts+2-2
......@@ -3,7 +3,7 @@ import * as Schema from "effect/Schema";
33import { IsoDateTime, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts";
44import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts";
55import { ModelSelection } from "./modelSelection.ts";
6import { ProjectIconOverride, ProjectScript } from "./project.ts";
6import { ProjectScript, ReceivedProjectIcon } from "./project.ts";
77
88/** Project summary shared by the V2 shell and application project APIs. */
99export const OrchestrationProjectShell = Schema.Struct({
......@@ -20,7 +20,7 @@ export const OrchestrationProjectShell = Schema.Struct({
2020 autoPull: Schema.optional(Schema.Boolean),
2121 // Optional on the wire so cached snapshots from older servers still decode.
2222 faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
23 projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)),
23 projectIcon: Schema.optional(Schema.NullOr(ReceivedProjectIcon)),
2424 scripts: Schema.Array(ProjectScript),
2525 createdAt: IsoDateTime,
2626 updatedAt: IsoDateTime,
packages/contracts/src/project.test.ts+34-62
......@@ -8,7 +8,8 @@ import { OrchestrationProjectShell } from "./orchestrationProject.ts";
88
99import {
1010 ProjectFaviconPath,
11 ProjectIconOverride,
11 ReceivedProjectIcon,
12 StoredProjectIcon,
1213 ProjectReadFileError,
1314 ProjectCreatePayload,
1415 ProjectUpdatePayload,
......@@ -204,85 +205,56 @@ effectIt.effect("project monograms validate text and palette colors", () =>
204205 }),
205206);
206207
207const decodeProjectIcon = Schema.decodeUnknownEffect(ProjectIconOverride);
208const encodeProjectIcon = Schema.encodeEffect(ProjectIconOverride);
208const decodeStoredIcon = Schema.decodeUnknownEffect(StoredProjectIcon);
209const encodeStoredIcon = Schema.encodeEffect(StoredProjectIcon);
210const decodeReceivedIcon = Schema.decodeUnknownEffect(ReceivedProjectIcon);
211const encodeReceivedIcon = Schema.encodeEffect(ReceivedProjectIcon);
212const decodeProjectShell = Schema.decodeUnknownEffect(OrchestrationProjectShell);
209213
210// Pre-monogram clients reject unknown variants; nightly clients additionally validate monogram.
211const decodeOldIcon = Schema.decodeUnknownEffect(
212 Schema.Union([
213 Schema.Struct({ kind: Schema.Literal("lucide"), name: Schema.String, color: Schema.String }),
214 Schema.Struct({ kind: Schema.Literal("emoji"), emoji: Schema.String }),
215 ]),
216);
217const decodeNightlyIcon = Schema.decodeUnknownEffect(
218 Schema.Union([
219 Schema.Struct({
220 kind: Schema.Literal("lucide"),
221 name: Schema.String,
222 color: Schema.String,
223 // Fail if this field is ever sent; old validators must never see the new text.
224 monogram: Schema.optional(Schema.Never),
225 }),
226 Schema.Struct({ kind: Schema.Literal("emoji"), emoji: Schema.String }),
227 ]),
228);
229
230effectIt.effect("sends monograms as fallback icons that old and nightly clients can decode", () =>
214effectIt.effect("sends and stores icons in their plain shape", () =>
231215 Effect.gen(function* () {
232 const fallback = { kind: "lucide", name: "folder-code", color: "violet" } as const;
233 for (const text of ["T3", "क्ष्म", "e\u0301"]) {
234 const monogram = { kind: "monogram", text, color: "violet" } as const;
235 const wire = yield* encodeProjectIcon(monogram);
236 assert.deepEqual(wire, { ...fallback, monogramText: text });
237 assert.deepEqual(yield* decodeOldIcon(wire), fallback);
238 assert.deepEqual(yield* decodeNightlyIcon(wire), fallback);
239 assert.deepEqual(yield* decodeProjectIcon(wire), monogram);
240 assert.deepEqual(yield* decodeProjectIcon(monogram), monogram);
241 assert.deepEqual(yield* decodeProjectIcon({ ...fallback, monogram: text }), monogram);
242 }
243216 for (const icon of [
217 { kind: "monogram", text: "क्ष्म", color: "violet" },
244218 { kind: "lucide", name: "alarm-clock", color: "blue" },
245219 { kind: "emoji", emoji: "🚀" },
246220 ] as const) {
247 assert.deepEqual(yield* decodeProjectIcon(icon), icon);
248 assert.deepEqual(yield* encodeProjectIcon(icon), icon);
221 assert.deepEqual(yield* encodeReceivedIcon(icon), icon);
222 assert.deepEqual(yield* encodeStoredIcon(icon), icon);
223 assert.deepEqual(yield* decodeReceivedIcon(icon), icon);
249224 }
250225 }),
251226);
252227
253const encodeProjectShell = Schema.encodeEffect(OrchestrationProjectShell);
254const encodeProjectUpdate = Schema.encodeEffect(ProjectUpdatePayload);
255const decodeLegacyShell = Schema.decodeUnknownEffect(
256 Schema.Struct({
257 ...OrchestrationProjectShell.fields,
258 projectIcon: Schema.optional(
259 Schema.NullOr(
260 Schema.Struct({
261 kind: Schema.Literal("lucide"),
262 name: Schema.String,
263 color: Schema.String,
264 }),
265 ),
266 ),
228effectIt.effect("reads monograms stored in the pre-v2 fallback shape", () =>
229 Effect.gen(function* () {
230 const monogram = { kind: "monogram", text: "T3", color: "violet" } as const;
231 const fallback = { kind: "lucide", name: "folder-code", color: "violet" } as const;
232 for (const legacy of [
233 { ...fallback, monogramText: "T3" },
234 { ...fallback, monogram: "T3" },
235 ]) {
236 assert.deepEqual(yield* decodeStoredIcon(legacy), monogram);
237 assert.deepEqual(yield* decodeReceivedIcon(legacy), monogram);
238 }
267239 }),
268240);
269241
270effectIt.effect("encodes compatible icons inside snapshots and project updates", () =>
242effectIt.effect("an icon kind from a newer server shows the default icon", () =>
271243 Effect.gen(function* () {
272 const projectIcon = { kind: "monogram", text: "क्ष्म", color: "violet" } as const;
273 const shell = yield* encodeProjectShell({
274 id: ProjectId.make("monogram"),
275 title: "Monogram",
276 workspaceRoot: "/tmp/monogram",
244 assert.isNull(yield* decodeReceivedIcon({ kind: "image", url: "https://example.com/a.png" }));
245 const shell = yield* decodeProjectShell({
246 id: "project-1",
247 title: "Project",
248 workspaceRoot: "/tmp/project",
277249 defaultModelSelection: null,
278250 scripts: [],
279 projectIcon,
251 projectIcon: { kind: "image", url: "https://example.com/a.png" },
280252 createdAt: "2026-01-01T00:00:00.000Z",
281253 updatedAt: "2026-01-01T00:00:00.000Z",
282254 });
283 const fallback = { kind: "lucide", name: "folder-code", color: "violet" } as const;
284 assert.deepEqual((yield* decodeLegacyShell(shell)).projectIcon, fallback);
285 const update = yield* encodeProjectUpdate({ projectIcon });
286 assert.deepEqual(yield* decodeNightlyIcon(update.projectIcon), fallback);
255 assert.isNull(shell.projectIcon);
256 // A known kind with a broken payload still fails.
257 const broken = yield* Effect.exit(decodeReceivedIcon({ kind: "emoji" }));
258 assert.strictEqual(broken._tag, "Failure");
287259 }),
288260);
packages/contracts/src/project.ts+57-31
......@@ -4,12 +4,15 @@ import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts";
44import { ModelSelection } from "./modelSelection.ts";
55import {
66 CommandId,
7 ForwardCompatibleUnion,
8 isUnknownUnionMember,
79 IsoDateTime,
810 NonNegativeInt,
911 PositiveInt,
1012 ProjectId,
1113 TrimmedNonEmptyString,
1214 TrimmedString,
15 type UnknownUnionMember,
1316} from "./baseSchemas.ts";
1417
1518const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200;
......@@ -89,13 +92,6 @@ const ProjectMonogramIcon = Schema.Struct({
8992 text: ProjectMonogramText,
9093 color: ProjectIconColor,
9194});
92const ProjectIcon = Schema.Union([ProjectLucideIcon, ProjectEmojiIcon, ProjectMonogramIcon]);
93const ProjectLucideIconWire = Schema.Struct({
94 ...ProjectLucideIcon.fields,
95 monogramText: Schema.optional(ProjectMonogramText),
96 monogram: Schema.optional(ProjectMonogramText),
97});
98
9995/** A workspace-relative image a project may use as its favicon. */
10096export const ProjectFaviconPath = TrimmedNonEmptyString.check(
10197 Schema.isMaxLength(1024),
......@@ -103,36 +99,66 @@ export const ProjectFaviconPath = TrimmedNonEmptyString.check(
10399);
104100export type ProjectFaviconPath = typeof ProjectFaviconPath.Type;
105101
106// Older peers only know lucide/emoji. Keep monograms out of their validated
107// `monogram` field too: old grapheme counters can reject otherwise valid text.
102/** A project's chosen icon, as clients set it. */
108103export const ProjectIconOverride = Schema.Union([
109 ProjectLucideIconWire,
104 ProjectLucideIcon,
110105 ProjectEmojiIcon,
111106 ProjectMonogramIcon,
112]).pipe(
107]);
108export type ProjectIconOverride = typeof ProjectIconOverride.Type;
109
110/**
111 * Before v2, servers sent a monogram as a lucide icon carrying its text, so
112 * older clients showed a folder. Icons stored then, and v2 servers released
113 * before this change, still use that shape; it is read here, never written.
114 */
115const ProjectLucideIconWithLegacyMonogram = Schema.Struct({
116 ...ProjectLucideIcon.fields,
117 monogramText: Schema.optional(ProjectMonogramText),
118 monogram: Schema.optional(ProjectMonogramText),
119});
120const projectIconMembers = [
121 ProjectLucideIconWithLegacyMonogram,
122 ProjectEmojiIcon,
123 ProjectMonogramIcon,
124] as const;
125type ProjectIconMember = (typeof projectIconMembers)[number]["Type"];
126
127const fromLegacyMonogram = (icon: ProjectIconMember): ProjectIconOverride => {
128 if (icon.kind !== "lucide") return icon;
129 const text = icon.monogramText ?? icon.monogram;
130 return text === undefined
131 ? { kind: "lucide", name: icon.name, color: icon.color }
132 : { kind: "monogram", text, color: icon.color };
133};
134
135/** An icon the server stores; it reads legacy monograms and writes the plain shape. */
136export const StoredProjectIcon = Schema.Union(projectIconMembers).pipe(
113137 Schema.decodeTo(
114 ProjectIcon,
115 SchemaTransformation.transform({
116 decode: (icon): typeof ProjectIcon.Type => {
117 if (icon.kind !== "lucide") return icon;
118 const text = icon.monogramText ?? icon.monogram;
119 return text === undefined
120 ? { kind: "lucide", name: icon.name, color: icon.color }
121 : { kind: "monogram", text, color: icon.color };
122 },
123 encode: (icon) =>
124 icon.kind === "monogram"
125 ? {
126 kind: "lucide" as const,
127 name: "folder-code",
128 color: icon.color,
129 monogramText: icon.text,
130 }
131 : icon,
138 Schema.toType(ProjectIconOverride),
139 SchemaTransformation.transform<ProjectIconOverride, ProjectIconMember>({
140 decode: fromLegacyMonogram,
141 encode: (icon) => icon,
142 }),
143 ),
144);
145
146/**
147 * An icon as clients receive it. A kind from a newer server decodes as no
148 * override, so the client shows the project's default icon.
149 */
150export const ReceivedProjectIcon = ForwardCompatibleUnion(projectIconMembers, "kind").pipe(
151 Schema.decodeTo(
152 Schema.NullOr(Schema.toType(ProjectIconOverride)),
153 SchemaTransformation.transform<
154 ProjectIconOverride | null,
155 ProjectIconMember | UnknownUnionMember<"kind">
156 >({
157 decode: (icon) => (isUnknownUnionMember(icon) ? null : fromLegacyMonogram(icon)),
158 encode: (icon) => icon as ProjectIconMember,
132159 }),
133160 ),
134161);
135export type ProjectIconOverride = typeof ProjectIconOverride.Type;
136162
137163export const Project = Schema.Struct({
138164 id: ProjectId,
......@@ -140,7 +166,7 @@ export const Project = Schema.Struct({
140166 workspaceRoot: TrimmedNonEmptyString,
141167 repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),
142168 faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
143 projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)),
169 projectIcon: Schema.optional(Schema.NullOr(ReceivedProjectIcon)),
144170 defaultModelSelection: Schema.NullOr(ModelSelection),
145171 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
146172 // Opt-in because background sync performs network I/O and may move the checkout.