1import { describe, expect, it } from "@effect/vitest";
2import * as Arbitrary from "effect/Arbitrary";
3import * as DateTime from "effect/DateTime";
4import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";
6import * as Schema from "effect/Schema";
7
8import * as BaseSchemas from "./baseSchemas.ts";
9import {
10 ForwardCompatibleArray,
11 ForwardCompatibleUnion,
12 ForwardCompatibleUnionArray,
13 isUnknownUnionMember,
14 TrimmedNonEmptyString,
15 UnknownUnionMember,
16} from "./baseSchemas.ts";
17
18const at = "2026-10-05T00:00:00.000Z";
19const Shape = Schema.Union([
20 Schema.Struct({ kind: Schema.Literal("circle"), radius: Schema.Number, at: Schema.DateTimeUtc }),
21 Schema.Struct({ kind: Schema.Literal("square"), side: Schema.Number }),
22]);
23const members = Shape.members;
24/** How clients decode: the JSON wire codec over the runtime schema. */
25const fromWire = <S extends Schema.Top>(schema: S) =>
26 Schema.decodeUnknownSync(Schema.toCodecJson(schema) as never) as (value: unknown) => S["Type"];
27
28describe("ForwardCompatibleArray", () => {
29 it("decodes elements through their own codec and drops ones it cannot read", () => {
30 const decoded = fromWire(ForwardCompatibleArray(Shape))([
31 { kind: "circle", radius: 1, at },
32 { kind: "triangle" },
33 { kind: "square", side: "wide" },
34 ]);
35 expect(decoded).toHaveLength(1);
36 expect(DateTime.isDateTime((decoded[0] as { at: unknown }).at)).toBe(true);
37 });
38
39 it("applies decoding defaults instead of failing", () => {
40 const WithDefault = Schema.Struct({
41 name: Schema.String,
42 count: Schema.optionalKey(Schema.Number).pipe(
43 Schema.withDecodingDefaultKey(Effect.succeed(7)),
44 ),
45 });
46 expect(Schema.decodeUnknownSync(ForwardCompatibleArray(WithDefault))([{ name: "a" }])).toEqual([
47 { name: "a", count: 7 },
48 ]);
49 });
50
51 const Named = ForwardCompatibleArray(Schema.Struct({ name: TrimmedNonEmptyString }));
52
53 it("drops an element it cannot encode instead of failing the array", () => {
54 const wire = JSON.parse(
55 JSON.stringify(
56 Schema.encodeUnknownSync(Schema.toCodecJson(Named))([
57 { name: "a" },
58 { name: " " },
59 { name: "b" },
60 ]),
61 ),
62 );
63 expect(wire).toEqual([{ name: "a" }, { name: "b" }]);
64 expect(fromWire(Named)(wire)).toEqual([{ name: "a" }, { name: "b" }]);
65 });
66
67 it("drops it too when a wrapper reads the encoded array as JSON values", () => {
68 // How context records are bounded before forward-compatible decoding.
69 const Wrapped = Schema.Array(Schema.Unknown).pipe(Schema.decodeTo(Named));
70 expect(
71 Schema.encodeUnknownSync(Schema.toCodecJson(Wrapped))([{ name: "a" }, { name: " " }]),
72 ).toEqual([{ name: "a" }]);
73 });
74
75 it("still drops the null holes a server on an earlier build sends", () => {
76 expect(fromWire(Named)([{ name: "a" }, null, { name: "b" }])).toEqual([
77 { name: "a" },
78 { name: "b" },
79 ]);
80 });
81
82 it("does not accept holes as a decoded value", () => {
83 expect(Schema.is(Named)([undefined])).toBe(false);
84 // A sparse array's missing index is a hole too.
85 const sparse: Array<{ name: string }> = [{ name: "a" }];
86 sparse.length = 2;
87 expect(Schema.is(Named)(sparse)).toBe(false);
88 expect(() => Named.make(sparse)).toThrow();
89 expect(Schema.is(Named)([{ name: "a" }])).toBe(true);
90 });
91});
92
93describe("ForwardCompatibleUnion", () => {
94 const decode = fromWire(ForwardCompatibleUnion(members, "kind"));
95
96 it("decodes a member from a newer server as unknown", () => {
97 const decoded = decode({ kind: "triangle", corners: 3 });
98 expect(isUnknownUnionMember(decoded)).toBe(true);
99 expect(decoded).toEqual(new UnknownUnionMember("kind", "triangle"));
100 });
101
102 it("decodes known members in full, transformations included", () => {
103 const decoded = decode({ kind: "circle", radius: 1, at });
104 expect(isUnknownUnionMember(decoded)).toBe(false);
105 expect(DateTime.isDateTime((decoded as { at: unknown }).at)).toBe(true);
106 });
107
108 it("still fails a known member whose payload is broken", () => {
109 expect(() => decode({ kind: "square", side: "wide" })).toThrow();
110 });
111
112 it("refuses to encode an unknown member", () => {
113 const encode = Schema.encodeSync(ForwardCompatibleUnion(members, "kind") as never);
114 expect(() => encode(new UnknownUnionMember("kind", "triangle") as never)).toThrow();
115 });
116});
117
118describe("ForwardCompatibleUnionArray", () => {
119 it("keeps a known member however its fields are named", () => {
120 const Flagged = Schema.Struct({
121 kind: Schema.Literal("flag"),
122 _unknown: Schema.Literal(true),
123 tag: Schema.String,
124 value: Schema.String,
125 });
126 const flag = { kind: "flag", _unknown: true, tag: "kind", value: "x" };
127 expect(fromWire(ForwardCompatibleUnionArray([Flagged], "kind"))([flag])).toEqual([flag]);
128 });
129
130 const decode = fromWire(ForwardCompatibleUnionArray(members, "kind"));
131
132 it("drops members a newer server added and keeps the rest", () => {
133 const decoded = decode([
134 { kind: "triangle" },
135 { kind: "circle", radius: 1, at },
136 { kind: "square", side: 2 },
137 ]);
138 expect(decoded.map((shape) => shape.kind)).toEqual(["circle", "square"]);
139 });
140
141 it("fails when a known member is broken, so real bugs stay visible", () => {
142 expect(() => decode([{ kind: "square", side: "wide" }])).toThrow();
143 });
144
145 it("encodes as a plain array", () => {
146 const encode = Schema.encodeSync(
147 Schema.toCodecJson(ForwardCompatibleUnionArray(members, "kind")),
148 );
149 const square = { kind: "square" as const, side: 2 };
150 expect(encode([square])).toEqual([square]);
151 });
152});
153
154describe("trimmed non-empty strings", () => {
155 const entityIds = [
156 "ThreadId",
157 "ProjectId",
158 "EnvironmentId",
159 "CommandId",
160 "EventId",
161 "MessageId",
162 "TurnId",
163 "RunId",
164 "RunAttemptId",
165 "NodeId",
166 "AuthSessionId",
167 "ProviderItemId",
168 "ProviderSessionId",
169 "ProviderThreadId",
170 "ProviderTurnId",
171 "RuntimeSessionId",
172 "RuntimeItemId",
173 "TurnItemId",
174 "RuntimeRequestId",
175 "RuntimeTaskId",
176 "ScheduledTaskId",
177 "ApprovalRequestId",
178 "CheckpointRef",
179 "CheckpointId",
180 "CheckpointScopeId",
181 "ContextHandoffId",
182 "ContextTransferId",
183 "RawEventId",
184 "PlanId",
185 ] as const;
186 const schemas = [
187 ["TrimmedNonEmptyString", TrimmedNonEmptyString],
188 ...entityIds.map((name) => [name, BaseSchemas[name]] as const),
189 ] as const;
190
191 // Schema-derived strings are rarely padded, so pad them with every kind of
192 // whitespace `trim()` removes, and include whitespace-only values.
193 const whitespace = Arbitrary.schema(
194 Schema.Literals([" ", "\t", "\n", "\r\n", "\v", "\f", "\u00a0", "\u2028", "\u3000", "\ufeff"]),
195 );
196 const padding = Arbitrary.array(whitespace, { maxLength: 4 }).pipe(
197 Arbitrary.map((parts) => parts.join("")),
198 );
199 const paddedString = Arbitrary.all([padding, Arbitrary.schema(Schema.String), padding]).pipe(
200 Arbitrary.map(([before, value, after]) => before + value + after),
201 );
202 const options = { arbitrary: { runs: 500 } };
203
204 for (const [name, schema] of schemas) {
205 const make = schema.makeOption;
206 const encode = Schema.encodeUnknownExit(schema);
207 const decode = Schema.decodeExit(schema);
208
209 it.prop(
210 `${name}: whatever make accepts encodes to something that decodes back`,
211 [paddedString],
212 ([input]) => {
213 const made = make(input);
214 if (made._tag === "None") {
215 expect(input.trim()).toBe("");
216 return;
217 }
218 const encoded = encode(made.value);
219 expect(encoded).toStrictEqual(Exit.succeed(input.trim()));
220 expect(decode(input.trim())).toStrictEqual(Exit.succeed(input.trim()));
221 },
222 options,
223 );
224
225 it.prop(
226 `${name}: decoding then encoding is stable`,
227 [paddedString],
228 ([input]) => {
229 const decoded = decode(input);
230 if (Exit.isFailure(decoded)) {
231 expect(input.trim()).toBe("");
232 expect(Exit.isFailure(encode(input))).toBe(true);
233 return;
234 }
235 expect(decoded.value).toBe(input.trim());
236 expect(encode(decoded.value)).toStrictEqual(Exit.succeed(decoded.value));
237 },
238 options,
239 );
240
241 it.prop(
242 `${name}: generated values round-trip`,
243 [schema],
244 ([value]) => {
245 const encoded = encode(value);
246 expect(Exit.isSuccess(encoded)).toBe(true);
247 if (Exit.isSuccess(encoded))
248 expect(decode(encoded.value)).toStrictEqual(Exit.succeed(value.trim()));
249 },
250 options,
251 );
252 }
253
254 const isThreadId = Schema.is(BaseSchemas.ThreadId);
255 const encodeThreadId = Schema.encodeUnknownExit(BaseSchemas.ThreadId);
256 const decodeThreadId = Schema.decodeExit(BaseSchemas.ThreadId);
257
258 it("rejects whitespace-only values everywhere", () => {
259 for (const value of [" ", "\t\n", "\u00a0\u3000"]) {
260 expect(() => BaseSchemas.ThreadId.make(value)).toThrow();
261 expect(isThreadId(value)).toBe(false);
262 expect(Exit.isFailure(encodeThreadId(value))).toBe(true);
263 expect(Exit.isFailure(decodeThreadId(value))).toBe(true);
264 }
265 });
266
267 it("keeps the encoded form and JSON Schema of valid values", () => {
268 expect(encodeThreadId(BaseSchemas.ThreadId.make("thread-1"))).toStrictEqual(
269 Exit.succeed("thread-1"),
270 );
271 expect(encodeThreadId(" a b ")).toStrictEqual(Exit.succeed("a b"));
272 expect(Schema.toJsonSchemaDocument(Schema.toType(BaseSchemas.ThreadId)).schema).toEqual({
273 type: "string",
274 minLength: 1,
275 });
276 });
277});