Skip to content
Source
Sign inSign up

baseSchemas.ts

335 lines12.6 KB

1import * as Effect from "effect/Effect";
2import * as Option from "effect/Option";
3import * as Schema from "effect/Schema";
4import * as SchemaGetter from "effect/SchemaGetter";
5import * as SchemaTransformation from "effect/SchemaTransformation";
6​
7// Hermes copies the string on every `trim()`, even when nothing changes.
8// Returning the original keeps repeated IDs shared after decode.
9const trim = (value: string) => {
10 const trimmed = value.trim();
11 return trimmed.length === value.length ? value : trimmed;
12};
13​
14export const TrimmedString = Schema.String.pipe(
15 Schema.decodeTo(
16 Schema.String,
17 SchemaTransformation.transformEffect({
18 decode: (value) => Effect.succeed(trim(value)),
19 encode: (value) => Effect.succeed(trim(value)),
20 }),
21 ),
22);
23// `make` and encode see untrimmed values; `isNonEmpty` would accept whitespace that encodes to an invalid empty string.
24const isNonBlank = Schema.makeFilter((value: string) => value.trim().length > 0, {
25 expected: "a non-blank string",
26 toJsonSchema: () => [{ minLength: 1 }, true],
27 arbitraryConstraint: { minLength: 1 },
28});
29export const TrimmedNonEmptyString = TrimmedString.check(isNonBlank);
30​
31export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
32export const PositiveInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1));
33export const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }));
34​
35export const DpopFailureReason = Schema.Literals([
36 "time_window",
37 "key_mismatch",
38 "request_mismatch",
39 "token_mismatch",
40 "replay",
41 "invalid_proof",
42]);
43export type DpopFailureReason = typeof DpopFailureReason.Type;
44​
45export const IsoDateTime = Schema.String;
46export type IsoDateTime = typeof IsoDateTime.Type;
47​
48export const ForwardCompatibleOptional = <Value extends Schema.Top>(value: Value) => {
49 const decodeValue = Schema.decodeUnknownOption(value as never);
50 return Schema.optionalKey(
51 Schema.Unknown.pipe(
52 Schema.decodeTo(
53 Schema.UndefinedOr(value),
54 SchemaTransformation.transform<Value["Encoded"] | undefined, unknown>({
55 decode: (raw) =>
56 Option.isSome(decodeValue(raw)) ? (raw as Value["Encoded"]) : undefined,
57 encode: (raw) => raw,
58 }),
59 ),
60 ),
61 );
62};
63​
64export const ForwardCompatibleNullable = <Value extends Schema.Top>(value: Value) => {
65 const decodeValue = Schema.decodeUnknownOption(value as never);
66 return Schema.Unknown.pipe(
67 Schema.decodeTo(
68 Schema.NullOr(value),
69 SchemaTransformation.transform<Value["Encoded"] | null, unknown>({
70 decode: (raw) => (Option.isSome(decodeValue(raw)) ? (raw as Value["Encoded"]) : null),
71 encode: (raw) => raw,
72 }),
73 ),
74 );
75};
76​
77/** Missing or unknown values decode to null; null encodes as absent for older clients requiring a literal. */
78export const OmittedWhenNull = <Value extends Schema.Top>(value: Value) => {
79 const decodeValue = Schema.decodeUnknownOption(value as never);
80 return Schema.optionalKey(Schema.Unknown).pipe(
81 Schema.decodeTo(
82 Schema.NullOr(value),
83 SchemaTransformation.transformOptional<Value["Encoded"] | null, unknown>({
84 decode: (raw) =>
85 Option.some(
86 Option.isSome(raw) && Option.isSome(decodeValue(raw.value))
87 ? (raw.value as Value["Encoded"])
88 : null,
89 ),
90 encode: (raw) =>
91 Option.isSome(raw) && raw.value !== null ? Option.some(raw.value) : Option.none(),
92 }),
93 ),
94 );
95};
96​
97/** Prefer {@link ForwardCompatibleUnion} for tagged unions so malformed known members still fail. */
98export const ForwardCompatibleArray = <Element extends Schema.Top>(element: Element) =>
99 Schema.Array(
100 Schema.UndefinedOr(element).pipe(
101 Schema.catchDecoding(() => Effect.succeedSome(undefined)),
102 Schema.catchEncoding(() => Effect.succeedSome(undefined)),
103 ),
104 ).pipe(
105 Schema.decodeTo(
106 Schema.Array(
107 Schema.UndefinedOr(Schema.toType(element)).pipe(
108 Schema.catchEncoding(() => Effect.succeedSome(undefined)),
109 ),
110 ).check(
111 // Schema.is/make must reject holes; abort before later array checks.
112 Schema.makeFilter(
113 (values) => {
114 // Every index, not `every`, which skips the holes of a sparse array.
115 for (let index = 0; index < values.length; index++) {
116 if (values[index] === undefined) return false;
117 }
118 return true;
119 },
120 { expected: "an array without holes" },
121 true,
122 ),
123 ),
124 SchemaTransformation.transform<
125 ReadonlyArray<Element["Type"]>,
126 ReadonlyArray<Element["Type"] | undefined>
127 >({
128 decode: (values) => values.filter((value) => value !== undefined),
129 encode: (values) => values.filter((value) => value !== undefined),
130 }),
131 ),
132 ) as unknown as ForwardCompatibleArray<Element>;
133export type ForwardCompatibleArray<Element extends Schema.Top> = Schema.Codec<
134 ReadonlyArray<Element["Type"]>,
135 ReadonlyArray<Element["Encoded"]>,
136 Element["DecodingServices"],
137 Element["EncodingServices"]
138>;
139​
140/** Class identity prevents decoded payloads from impersonating unknown members. */
141export class UnknownUnionMember<Tag extends string = string> {
142 readonly tag: Tag;
143 readonly value: string;
144 constructor(tag: Tag, value: string) {
145 this.tag = tag;
146 this.value = value;
147 }
149​
150/** Unknown members are decode-only; encoding one fails. */
151export const ForwardCompatibleUnion = <
152 const Members extends ReadonlyArray<Schema.Top & { readonly fields: object }>,
153 const Tag extends string,
154>(
155 members: Members,
156 tag: Tag,
157) => {
158 const known = knownTags(members, tag);
159 const unknownMember = Schema.Struct({
160 [tag]: Schema.String.check(
161 Schema.makeFilter(
162 (value: string) => !known.has(value) || `A known ${tag} must decode in full.`,
163 ),
164 ),
165 } as Record<Tag, Schema.String>).pipe(
166 Schema.decodeTo(Schema.instanceOf(UnknownUnionMember<Tag>), {
167 decode: SchemaGetter.transform(
168 (raw: Record<string, string>) => new UnknownUnionMember(tag, raw[tag]!),
169 ),
170 encode: SchemaGetter.forbidden(() => `Unknown ${tag} values are never sent.`),
171 }),
172 );
173 return Schema.Union([...members, unknownMember]) as unknown as ForwardCompatibleUnion<
174 Members,
175 Tag
176 >;
177};
178export type ForwardCompatibleUnion<
179 Members extends ReadonlyArray<Schema.Top>,
180 Tag extends string,
181> = Schema.Codec<
182 Members[number]["Type"] | UnknownUnionMember<Tag>,
183 Members[number]["Encoded"],
184 Members[number]["DecodingServices"],
185 Members[number]["EncodingServices"]
186>;
187​
188export const hasUnknownUnionTag = (
189 members: ReadonlyArray<Schema.Top & { readonly fields: object }>,
190 tag: string,
191): ((value: unknown) => boolean) => {
192 const known = knownTags(members, tag);
193 return (value) =>
194 typeof value === "object" &&
195 value !== null &&
196 tag in value &&
197 !known.has((value as Record<string, unknown>)[tag] as string);
198};
199​
200export const isUnknownUnionMember = <A>(
201 value: A,
202): value is Extract<A, UnknownUnionMember<string>> => value instanceof UnknownUnionMember;
203​
204export const ForwardCompatibleUnionArray = <
205 const Members extends ReadonlyArray<Schema.Top & { readonly fields: object }>,
206 const Tag extends string,
207>(
208 members: Members,
209 tag: Tag,
210) =>
211 Schema.Array(ForwardCompatibleUnion(members, tag)).pipe(
212 Schema.decodeTo(
213 Schema.Array(Schema.toType(Schema.Union(members))),
214 SchemaTransformation.transform<
215 ReadonlyArray<Members[number]["Type"]>,
216 ReadonlyArray<Members[number]["Type"] | UnknownUnionMember<Tag>>
217 >({
218 decode: (values) =>
219 values.filter((value) => !isUnknownUnionMember(value)) as ReadonlyArray<
220 Members[number]["Type"]
221 >,
222 encode: (values) => values,
223 }),
224 ),
225 );
226​
227const knownTags = (
228 members: ReadonlyArray<Schema.Top & { readonly fields: object }>,
229 tag: string,
230): ReadonlySet<string> => {
231 const tags = new Set<string>();
232 for (const member of members) {
233 const field = (member.fields as Record<string, unknown>)[tag] as
234 | { readonly literal?: unknown; readonly literals?: ReadonlyArray<unknown> }
235 | undefined;
236 for (const literal of field?.literals ?? [field?.literal]) {
237 if (typeof literal !== "string") {
238 throw new Error(`Every ForwardCompatibleUnion member needs a string literal ${tag}.`);
239 }
240 tags.add(literal);
241 }
242 }
243 return tags;
244};
245​
246const makeEntityId = <Brand extends string>(brand: Parameters<typeof Schema.brand<Brand>>[0]) => {
247 return TrimmedNonEmptyString.pipe(Schema.brand<Brand>(brand));
248};
249​
250export const ThreadId = makeEntityId("ThreadId");
251export type ThreadId = typeof ThreadId.Type;
252export const ProjectId = makeEntityId("ProjectId");
253export type ProjectId = typeof ProjectId.Type;
254export const EnvironmentId = makeEntityId("EnvironmentId");
255export type EnvironmentId = typeof EnvironmentId.Type;
256export const CommandId = makeEntityId("CommandId");
257export type CommandId = typeof CommandId.Type;
258export const EventId = makeEntityId("EventId");
259export type EventId = typeof EventId.Type;
260export const MessageId = makeEntityId("MessageId");
261export type MessageId = typeof MessageId.Type;
262export const TurnId = makeEntityId("TurnId");
263export type TurnId = typeof TurnId.Type;
264export const RunId = makeEntityId("RunId");
265export type RunId = typeof RunId.Type;
266export const RunAttemptId = makeEntityId("RunAttemptId");
267export type RunAttemptId = typeof RunAttemptId.Type;
268export const NodeId = makeEntityId("NodeId");
269export type NodeId = typeof NodeId.Type;
270export const GuestShareId = makeEntityId("GuestShareId");
271export type GuestShareId = typeof GuestShareId.Type;
272export const GuestMessageId = makeEntityId("GuestMessageId");
273export type GuestMessageId = typeof GuestMessageId.Type;
274export const AuthSessionId = makeEntityId("AuthSessionId");
275export type AuthSessionId = typeof AuthSessionId.Type;
276export const RpcClientId = NonNegativeInt.pipe(Schema.brand("RpcClientId"));
277export type RpcClientId = typeof RpcClientId.Type;
278​
279export const ClientSurface = Schema.Literals(["web", "desktop", "mobile", "cli"]);
280export type ClientSurface = typeof ClientSurface.Type;
281​
282export const ClientOs = Schema.Literals([
283 "macOS",
284 "Windows",
285 "Linux",
286 "iOS",
287 "Android",
288 "ChromeOS",
289 "other",
290 "unknown",
291]);
292export type ClientOs = typeof ClientOs.Type;
293​
294export const ClientDeviceType = Schema.Literals(["desktop", "phone", "tablet", "unknown"]);
295export type ClientDeviceType = typeof ClientDeviceType.Type;
296​
297export const ClientWebDeployment = Schema.Literals(["hosted", "server"]);
298export type ClientWebDeployment = typeof ClientWebDeployment.Type;
299​
300export const ClientConnectionMethod = Schema.Literals(["direct", "ssh", "relay", "unknown"]);
301export type ClientConnectionMethod = typeof ClientConnectionMethod.Type;
302​
303export const ProviderItemId = makeEntityId("ProviderItemId");
304export type ProviderItemId = typeof ProviderItemId.Type;
305export const ProviderSessionId = makeEntityId("ProviderSessionId");
306export type ProviderSessionId = typeof ProviderSessionId.Type;
307export const ProviderThreadId = makeEntityId("ProviderThreadId");
308export type ProviderThreadId = typeof ProviderThreadId.Type;
309export const ProviderTurnId = makeEntityId("ProviderTurnId");
310export type ProviderTurnId = typeof ProviderTurnId.Type;
311export const TurnItemId = makeEntityId("TurnItemId");
312export type TurnItemId = typeof TurnItemId.Type;
313export const RuntimeRequestId = makeEntityId("RuntimeRequestId");
314export type RuntimeRequestId = typeof RuntimeRequestId.Type;
315export const ScheduledTaskId = makeEntityId("ScheduledTaskId");
316/** A one-use handle to a secret the user entered for an agent; the agent never sees the value. */
317export const SecretRef = makeEntityId("SecretRef");
318export type SecretRef = typeof SecretRef.Type;
319export type ScheduledTaskId = typeof ScheduledTaskId.Type;
320export const ApprovalRequestId = makeEntityId("ApprovalRequestId");
321export type ApprovalRequestId = typeof ApprovalRequestId.Type;
322export const CheckpointRef = makeEntityId("CheckpointRef");
323export type CheckpointRef = typeof CheckpointRef.Type;
324export const CheckpointId = makeEntityId("CheckpointId");
325export type CheckpointId = typeof CheckpointId.Type;
326export const CheckpointScopeId = makeEntityId("CheckpointScopeId");
327export type CheckpointScopeId = typeof CheckpointScopeId.Type;
328export const ContextHandoffId = makeEntityId("ContextHandoffId");
329export type ContextHandoffId = typeof ContextHandoffId.Type;
330export const ContextTransferId = makeEntityId("ContextTransferId");
331export type ContextTransferId = typeof ContextTransferId.Type;
332export const RawEventId = makeEntityId("RawEventId");
333export type RawEventId = typeof RawEventId.Type;
334export const PlanId = makeEntityId("PlanId");
335export type PlanId = typeof PlanId.Type;