| 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import * as Arbitrary from "effect/Arbitrary"; |
| 3 | import * as DateTime from "effect/DateTime"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Exit from "effect/Exit"; |
| 6 | import * as Schema from "effect/Schema"; |
| 7 | |
| 8 | import * as BaseSchemas from "./baseSchemas.ts"; |
| 9 | import { |
| 10 | ForwardCompatibleArray, |
| 11 | ForwardCompatibleUnion, |
| 12 | ForwardCompatibleUnionArray, |
| 13 | isUnknownUnionMember, |
| 14 | TrimmedNonEmptyString, |
| 15 | UnknownUnionMember, |
| 16 | } from "./baseSchemas.ts"; |
| 17 | |
| 18 | const at = "2026-10-05T00:00:00.000Z"; |
| 19 | const 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 | ]); |
| 23 | const members = Shape.members; |
| 24 | /** How clients decode: the JSON wire codec over the runtime schema. */ |
| 25 | const fromWire = <S extends Schema.Top>(schema: S) => |
| 26 | Schema.decodeUnknownSync(Schema.toCodecJson(schema) as never) as (value: unknown) => S["Type"]; |
| 27 | |
| 28 | describe("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 | |
| 93 | describe("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 | |
| 118 | describe("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 | |
| 154 | describe("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 | }); |