effect-inngest 0.1.3 → 0.2.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/Client.d.ts +55 -24
  2. package/dist/Client.js +84 -25
  3. package/dist/Events.d.ts +36 -61
  4. package/dist/Events.js +5 -13
  5. package/dist/Function.d.ts +35 -15
  6. package/dist/Function.js +14 -8
  7. package/dist/Group.d.ts +5 -4
  8. package/dist/Group.js +26 -24
  9. package/dist/HttpApi.d.ts +49 -54
  10. package/dist/HttpApi.js +32 -17
  11. package/dist/_virtual/_rolldown/runtime.js +13 -0
  12. package/dist/index.js +1 -2
  13. package/dist/internal/checkpoint.d.ts +32 -0
  14. package/dist/internal/checkpoint.js +112 -0
  15. package/dist/internal/constants.js +1 -2
  16. package/dist/internal/driver.d.ts +1 -5
  17. package/dist/internal/driver.js +164 -52
  18. package/dist/internal/errors.d.ts +20 -27
  19. package/dist/internal/errors.js +29 -15
  20. package/dist/internal/handler.d.ts +4 -13
  21. package/dist/internal/handler.js +70 -43
  22. package/dist/internal/helpers.js +12 -9
  23. package/dist/internal/interrupts.d.ts +1 -2
  24. package/dist/internal/interrupts.js +1 -3
  25. package/dist/internal/memo.js +22 -20
  26. package/dist/internal/protocol.d.ts +28 -1
  27. package/dist/internal/protocol.js +84 -52
  28. package/dist/internal/signature.d.ts +5 -9
  29. package/dist/internal/signature.js +34 -18
  30. package/dist/internal/step.d.ts +5 -11
  31. package/dist/internal/step.js +48 -32
  32. package/package.json +26 -21
  33. package/src/Client.ts +244 -68
  34. package/src/Events.ts +3 -3
  35. package/src/Function.ts +52 -15
  36. package/src/Group.ts +39 -26
  37. package/src/HttpApi.ts +38 -27
  38. package/src/internal/checkpoint.ts +218 -0
  39. package/src/internal/driver.ts +241 -93
  40. package/src/internal/errors.ts +21 -14
  41. package/src/internal/handler.ts +185 -142
  42. package/src/internal/helpers.ts +9 -9
  43. package/src/internal/memo.ts +48 -33
  44. package/src/internal/protocol.ts +89 -45
  45. package/src/internal/signature.ts +76 -58
  46. package/src/internal/step.ts +84 -31
  47. package/dist/_virtual/rolldown_runtime.js +0 -18
@@ -2,24 +2,28 @@
2
2
  * Internal handler implementation.
3
3
  * @internal
4
4
  */
5
- import * as HttpClient from "@effect/platform/HttpClient";
6
- import * as HttpClientRequest from "@effect/platform/HttpClientRequest";
7
- import * as HttpClientResponse from "@effect/platform/HttpClientResponse";
8
- import * as HttpServerRequest from "@effect/platform/HttpServerRequest";
5
+ import * as Headers from "effect/unstable/http/Headers";
6
+ import * as HttpClient from "effect/unstable/http/HttpClient";
7
+ import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
8
+ import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
9
+ import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
10
+ import * as Cause from "effect/Cause";
9
11
  import * as Context from "effect/Context";
10
12
  import * as Effect from "effect/Effect";
13
+ import * as Option from "effect/Option";
11
14
  import * as Predicate from "effect/Predicate";
12
15
  import * as Schema from "effect/Schema";
13
16
  import { InngestClient } from "../Client.js";
14
17
  import type { InngestFunction } from "../Function.js";
15
18
  import type { InngestGroup } from "../Group.js";
19
+ import * as Checkpoint from "./checkpoint.js";
16
20
  import { Signature, SignatureError } from "./signature.js";
17
21
  import * as Protocol from "./protocol.js";
18
22
  export { SignatureError } from "./signature.js";
19
23
  import { execute, type TraceHeaders } from "./driver.js";
20
24
  export type { TraceHeaders } from "./driver.js";
21
25
 
22
- export class InvalidRequestError extends Schema.TaggedError<InvalidRequestError>()("InvalidRequestError", {
26
+ export class InvalidRequestError extends Schema.TaggedErrorClass<InvalidRequestError>()("InvalidRequestError", {
23
27
  message: Schema.String,
24
28
  }) {}
25
29
 
@@ -28,7 +32,7 @@ const SDK_VERSION = "2.0.0";
28
32
  const baseHeaders = (): Record<string, string> => ({
29
33
  "Content-Type": "application/json",
30
34
  [Protocol.Headers.SDK]: `effect-inngest:v${SDK_VERSION}`,
31
- [Protocol.Headers.RequestVersion]: "1",
35
+ [Protocol.Headers.RequestVersion]: "2",
32
36
  });
33
37
 
34
38
  const buildServeUrl = (requestUrl: string, serveHost?: string, servePath?: string): URL => {
@@ -48,119 +52,140 @@ export interface HandlerResponse<T> {
48
52
  readonly body: T;
49
53
  }
50
54
 
51
- export const verifyAndParseRequestBody = (
55
+ export const verifyAndParseRequestBody = Effect.fn("effect-inngest/handler/verifyAndParseRequestBody")(function* (
52
56
  request: HttpServerRequest.HttpServerRequest,
53
- ): Effect.Effect<
54
- typeof Protocol.SDKRequestBody.Type,
55
- SignatureError | InvalidRequestError,
56
- InngestClient | Signature
57
- > =>
58
- Effect.gen(function* () {
59
- const client = yield* InngestClient;
60
- const sig = yield* Signature;
61
- const config = client.config;
62
- const isDev = client.mode === "dev";
63
-
64
- const bodyText = yield* request.text.pipe(
65
- Effect.mapError((error) => new InvalidRequestError({ message: `Failed to read request body: ${String(error)}` })),
66
- );
57
+ ) {
58
+ const client = yield* InngestClient;
59
+ const sig = yield* Signature;
60
+ const config = client.config;
61
+ const isDev = client.mode === "dev";
67
62
 
68
- yield* sig.verify({
69
- body: new TextEncoder().encode(bodyText),
70
- signatureHeader: request.headers[Protocol.Headers.Signature.toLowerCase()],
71
- signingKey: config.signingKey,
72
- signingKeyFallback: config.signingKeyFallback,
73
- isDev,
74
- });
63
+ const bodyText = yield* request.text.pipe(
64
+ Effect.mapError((error) => {
65
+ const msg =
66
+ Predicate.hasProperty(error, "message") && typeof error.message === "string" ? error.message : "unknown";
67
+ return new InvalidRequestError({ message: `Failed to read request body: ${msg}` });
68
+ }),
69
+ );
75
70
 
76
- return yield* Schema.decodeUnknown(Schema.parseJson(Protocol.SDKRequestBody))(bodyText).pipe(
77
- Effect.mapError((error) => new InvalidRequestError({ message: `Invalid request body: ${String(error)}` })),
78
- );
71
+ yield* sig.verify({
72
+ body: new TextEncoder().encode(bodyText),
73
+ signatureHeader: Option.getOrUndefined(Headers.get(request.headers, Protocol.Headers.Signature)),
74
+ signingKey: config.signingKey,
75
+ signingKeyFallback: config.signingKeyFallback,
76
+ isDev,
79
77
  });
80
78
 
81
- export const handleIntrospection = (
79
+ return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Protocol.SDKRequestBody))(bodyText).pipe(
80
+ Effect.mapError((error) => new InvalidRequestError({ message: `Invalid request body: ${String(error)}` })),
81
+ );
82
+ });
83
+
84
+ export const handleIntrospection = Effect.fn("effect-inngest/handler/handleIntrospection")(function* (
82
85
  group: InngestGroup.Any,
83
86
  _requestUrl: string,
84
- ): Effect.Effect<HandlerResponse<typeof Protocol.IntrospectionResponse.Type>, never, InngestClient> =>
85
- Effect.gen(function* () {
86
- const client = yield* InngestClient;
87
- const config = client.config;
88
-
89
- const body: typeof Protocol.IntrospectionResponse.Type = {
90
- function_count: group.functions.size,
91
- has_event_key: config.eventKey !== undefined,
92
- has_signing_key: config.signingKey !== undefined,
93
- has_signing_key_fallback: config.signingKeyFallback !== undefined,
94
- mode: client.mode === "dev" ? "dev" : "cloud",
95
- schema_version: "2024-05-24",
96
- authentication_succeeded: null,
97
- };
87
+ ) {
88
+ const client = yield* InngestClient;
89
+ const config = client.config;
98
90
 
99
- return { status: 200, headers: baseHeaders(), body };
100
- });
91
+ const body: typeof Protocol.IntrospectionResponse.Type = {
92
+ function_count: group.functions.size,
93
+ has_event_key: config.eventKey !== undefined,
94
+ has_signing_key: config.signingKey !== undefined,
95
+ has_signing_key_fallback: config.signingKeyFallback !== undefined,
96
+ mode: client.mode === "dev" ? "dev" : "cloud",
97
+ schema_version: "2024-05-24",
98
+ authentication_succeeded: null,
99
+ };
101
100
 
102
- const RegisterRequest = Schema.Struct({
103
- url: Schema.String,
104
- v: Schema.String,
105
- deployType: Schema.Literal("ping"),
106
- sdk: Schema.String,
107
- appName: Schema.String,
108
- framework: Schema.String,
109
- functions: Schema.Array(Schema.Unknown),
101
+ return { status: 200, headers: baseHeaders(), body } as HandlerResponse<typeof Protocol.IntrospectionResponse.Type>;
110
102
  });
111
103
 
112
- export const handleRegistration = (
104
+ export const handleRegistration = Effect.fn("effect-inngest/handler/handleRegistration")(function* (
113
105
  group: InngestGroup.Any,
114
106
  requestUrl: string,
115
- ): Effect.Effect<
116
- HandlerResponse<typeof Protocol.RegisterResponse.Type>,
117
- never,
118
- InngestClient | HttpClient.HttpClient
119
- > =>
120
- Effect.gen(function* () {
121
- const client = yield* InngestClient;
122
- const httpClient = yield* HttpClient.HttpClient;
123
- const config = client.config;
124
- const url = buildServeUrl(requestUrl, config.serveHost, config.servePath);
125
-
126
- const functions = Array.from(group.functions.values()).map((fn) =>
127
- fn.toRegistration({ appId: config.id, url: url.href }),
128
- );
107
+ ) {
108
+ const client = yield* InngestClient;
109
+ const httpClient = yield* HttpClient.HttpClient;
110
+ const config = client.config;
111
+ const url = buildServeUrl(requestUrl, config.serveHost, config.servePath);
129
112
 
130
- const registerUrl = new URL("fn/register", client.apiBaseUrl).toString();
131
- const request = HttpClientRequest.post(registerUrl).pipe(
132
- HttpClientRequest.setHeaders({
133
- ...baseHeaders(),
134
- Authorization: `Bearer ${config.signingKey ?? ""}`,
135
- }),
136
- HttpClientRequest.schemaBodyJson(RegisterRequest)({
137
- url: url.href,
138
- v: "0.1",
139
- deployType: "ping" as const,
140
- sdk: `effect-inngest:v${SDK_VERSION}`,
141
- appName: config.id,
142
- framework: "effect",
143
- functions,
144
- }),
145
- );
113
+ const functions = Array.from(group.functions.values()).map((fn) =>
114
+ fn.toRegistration({ appId: config.id, url: url.href }),
115
+ );
116
+
117
+ const registerUrl = new URL("fn/register", client.apiBaseUrl).toString();
118
+ const request = HttpClientRequest.post(registerUrl).pipe(
119
+ HttpClientRequest.setHeaders({
120
+ ...baseHeaders(),
121
+ Authorization: `Bearer ${config.signingKey ?? ""}`,
122
+ }),
123
+ HttpClientRequest.bodyJsonUnsafe({
124
+ url: url.href,
125
+ v: "0.1",
126
+ deployType: "ping" as const,
127
+ sdk: `effect-inngest:v${SDK_VERSION}`,
128
+ appName: config.id,
129
+ framework: "effect",
130
+ functions,
131
+ }),
132
+ );
146
133
 
147
- const response = yield* request.pipe(
148
- Effect.flatMap(httpClient.execute),
149
- Effect.flatMap(HttpClientResponse.schemaBodyJson(Protocol.RegisterResponse)),
150
- Effect.scoped,
151
- Effect.catchAll((error) =>
152
- Effect.succeed({
153
- message: `Registration failed: ${Predicate.hasProperty(error, "message") ? (error.message as string) : "Unknown error"}`,
154
- }),
155
- ),
134
+ // Spec §4.3.1: PUT sync responds with `{ message: string; modified: boolean }`.
135
+ // Spec §4.3.3 / §4.3.4: 500 on failure with `modified: false`; 200 on success
136
+ // with the executor-supplied `modified` value (or `false` if omitted).
137
+ return yield* Effect.gen(function* () {
138
+ const response = yield* httpClient.execute(request).pipe(Effect.scoped);
139
+ const responseBody = yield* HttpClientResponse.schemaBodyJson(Protocol.RegisterServerResponse)(response).pipe(
140
+ Effect.catch(() => Effect.succeed({ error: "Unknown registration response" } as const)),
156
141
  );
157
142
 
143
+ if (response.status !== 200 || !Predicate.hasProperty(responseBody, "ok")) {
144
+ return {
145
+ status: 500,
146
+ headers: baseHeaders(),
147
+ body: {
148
+ message:
149
+ Predicate.hasProperty(responseBody, "error") && responseBody.error
150
+ ? responseBody.error
151
+ : `Registration failed with status ${response.status}`,
152
+ modified: false,
153
+ },
154
+ } as HandlerResponse<typeof Protocol.RegisterResponse.Type>;
155
+ }
156
+
158
157
  return {
159
158
  status: 200,
160
159
  headers: baseHeaders(),
161
- body: response,
162
- };
163
- });
160
+ body: {
161
+ message: "Successfully synced.",
162
+ modified: responseBody.modified ?? false,
163
+ },
164
+ } as HandlerResponse<typeof Protocol.RegisterResponse.Type>;
165
+ }).pipe(
166
+ Effect.catchCause((cause) => {
167
+ // Network/transport failure (e.g. die from HttpClient mock). Per
168
+ // §4.3.3 SHOULD return 500 with the body shape; never let the
169
+ // toWebHandler default 500 wrapper escape.
170
+ const errorOpt = Cause.findErrorOption(cause);
171
+ const dieReason = cause.reasons.find(Cause.isDieReason);
172
+ const message = Option.isSome(errorOpt)
173
+ ? Predicate.hasProperty(errorOpt.value, "message") && typeof errorOpt.value.message === "string"
174
+ ? errorOpt.value.message
175
+ : "Registration failed"
176
+ : dieReason
177
+ ? Predicate.hasProperty(dieReason.defect, "message") && typeof dieReason.defect.message === "string"
178
+ ? dieReason.defect.message
179
+ : "Registration failed"
180
+ : "Registration failed";
181
+ return Effect.succeed({
182
+ status: 500,
183
+ headers: baseHeaders(),
184
+ body: { message, modified: false },
185
+ } as HandlerResponse<typeof Protocol.RegisterResponse.Type>);
186
+ }),
187
+ );
188
+ });
164
189
 
165
190
  export interface Handler<Tag extends string> {
166
191
  readonly tag: Tag;
@@ -168,55 +193,73 @@ export interface Handler<Tag extends string> {
168
193
  readonly context: Context.Context<any>;
169
194
  }
170
195
 
171
- export const handleExecution = (
196
+ export const handleExecution = Effect.fn("effect-inngest/handler/handleExecution")(function* (
172
197
  group: InngestGroup.Any,
173
198
  fnId: string,
174
199
  urlStepId: string | undefined,
175
200
  body: typeof Protocol.SDKRequestBody.Type,
176
201
  traceHeaders: TraceHeaders = {},
177
- ): Effect.Effect<HandlerResponse<unknown>, never, InngestClient> =>
178
- Effect.gen(function* () {
179
- const client = yield* InngestClient;
180
- const context = yield* Effect.context<never>();
202
+ ) {
203
+ const client = yield* InngestClient;
204
+ const context = yield* Effect.context<never>();
181
205
 
182
- const appId = client.config.id;
183
- const prefix = `${appId}-`;
184
- const fnTag = fnId.startsWith(prefix) ? fnId.slice(prefix.length) : fnId;
206
+ const appId = client.config.id;
207
+ const prefix = `${appId}-`;
208
+ const fnTag = fnId.startsWith(prefix) ? fnId.slice(prefix.length) : fnId;
185
209
 
186
- const fn = group.functions.get(fnTag) as InngestFunction.Any | undefined;
187
- const entry = fn ? (context.unsafeMap.get(fn.key) as Handler<string> | undefined) : undefined;
210
+ const fn = group.functions.get(fnTag) as InngestFunction.Any | undefined;
211
+ const entry = fn ? (context.mapUnsafe.get(fn.key) as Handler<string> | undefined) : undefined;
188
212
 
189
- if (!fn || !entry) {
190
- return {
191
- status: 404 as const,
192
- headers: { ...baseHeaders(), [Protocol.Headers.NoRetry]: "true" },
193
- body: {
194
- error: Protocol.UserError.make({ name: "FunctionNotFoundError", message: `Unknown function: ${fnId}` }),
195
- },
196
- };
197
- }
213
+ if (!fn || !entry) {
214
+ // Spec §4.4.1: unknown function MUST return 500 with the error payload
215
+ // at the response root (no { error: ... } envelope).
216
+ // Spec §4.4.3 pair rule: 500 MUST pair with X-Inngest-No-Retry: false.
217
+ return {
218
+ status: 500 as const,
219
+ headers: { ...baseHeaders(), [Protocol.Headers.NoRetry]: "false" },
220
+ body: Protocol.UserError.make({ name: "FunctionNotFoundError", message: `Unknown function: ${fnId}` }),
221
+ };
222
+ }
198
223
 
199
- // URL stepId takes precedence over body.ctx.step_id
200
- // This is how Inngest requests execution of a specific step
201
- const effectiveBody =
202
- urlStepId && urlStepId !== body.ctx.step_id
203
- ? Protocol.SDKRequestBody.make({
204
- event: body.event,
205
- events: body.events,
206
- steps: body.steps,
207
- ctx: Protocol.SDKRequestContext.make({
208
- fn_id: body.ctx.fn_id,
209
- run_id: body.ctx.run_id,
210
- step_id: urlStepId,
211
- attempt: body.ctx.attempt,
212
- disable_immediate_execution: body.ctx.disable_immediate_execution,
213
- use_api: body.ctx.use_api,
214
- stack: body.ctx.stack,
215
- }),
216
- use_api: body.use_api,
217
- })
218
- : body;
219
-
220
- const result = yield* Effect.provide(execute(fn, entry.handler, effectiveBody, appId, traceHeaders), entry.context);
221
- return { status: result.status, headers: result.headers, body: result.body };
222
- });
224
+ // URL stepId takes precedence over body.ctx.step_id
225
+ // This is how Inngest requests execution of a specific step
226
+ const effectiveBody =
227
+ urlStepId && urlStepId !== body.ctx.step_id
228
+ ? Protocol.SDKRequestBody.make({
229
+ event: body.event,
230
+ events: body.events,
231
+ steps: body.steps,
232
+ ctx: Protocol.SDKRequestContext.make({
233
+ fn_id: body.ctx.fn_id,
234
+ run_id: body.ctx.run_id,
235
+ env: body.ctx.env,
236
+ step_id: urlStepId,
237
+ attempt: body.ctx.attempt,
238
+ max_attempts: body.ctx.max_attempts,
239
+ qi_id: body.ctx.qi_id,
240
+ disable_immediate_execution: body.ctx.disable_immediate_execution,
241
+ use_api: body.ctx.use_api,
242
+ stack: body.ctx.stack,
243
+ }),
244
+ version: body.version,
245
+ use_api: body.use_api,
246
+ })
247
+ : body;
248
+
249
+ // Checkpoint mode entry decision (spec §10):
250
+ // - URL stepId NOT set (we are not running a targeted step)
251
+ // - ctx.fn_id present (executor knows the function)
252
+ // - ctx.disable_immediate_execution false (executor allowed it)
253
+ // - resolved per-fn or per-client config not opted out
254
+ const enterCheckpoint =
255
+ !urlStepId && effectiveBody.ctx.fn_id !== "" && !effectiveBody.ctx.disable_immediate_execution;
256
+ const checkpointConfig = enterCheckpoint
257
+ ? Option.fromNullishOr(Checkpoint.resolveConfig(fn.options.checkpointing, client.config.checkpointing))
258
+ : Option.none<Checkpoint.CheckpointConfig>();
259
+
260
+ const result = yield* Effect.provide(
261
+ execute(fn, entry.handler, effectiveBody, appId, traceHeaders, checkpointConfig),
262
+ entry.context,
263
+ );
264
+ return { status: result.status, headers: result.headers, body: result.body } as HandlerResponse<unknown>;
265
+ });
@@ -2,6 +2,7 @@
2
2
  * Internal helper utilities.
3
3
  * @internal
4
4
  */
5
+ import * as DateTime from "effect/DateTime";
5
6
  import * as Duration from "effect/Duration";
6
7
 
7
8
  const millisecond = 1;
@@ -17,6 +18,7 @@ const periods = [
17
18
  ["h", hour],
18
19
  ["m", minute],
19
20
  ["s", second],
21
+ ["ms", millisecond],
20
22
  ] as const;
21
23
 
22
24
  /**
@@ -25,8 +27,8 @@ const periods = [
25
27
  * Supports weeks, days, hours, minutes, and seconds. Years/months are converted
26
28
  * to their equivalent in weeks/days.
27
29
  */
28
- export const timeStr = (input: Duration.DurationInput): string => {
29
- let ms = Duration.toMillis(Duration.decode(input));
30
+ export const timeStr = (input: Duration.Input): string => {
31
+ let ms = Duration.toMillis(Duration.fromInputUnsafe(input));
30
32
 
31
33
  const [, result] = periods.reduce<[number, string]>(
32
34
  ([num, str], [suffix, period]) => {
@@ -46,13 +48,11 @@ export const timeStr = (input: Duration.DurationInput): string => {
46
48
 
47
49
  /**
48
50
  * Format a timestamp as an ISO string for Inngest's sleepUntil.
51
+ *
52
+ * String inputs are passed through unchanged (assumed ISO-formatted), matching
53
+ * the prior contract. Date/number inputs are normalized via DateTime.
49
54
  */
50
55
  export const formatTimestamp = (timestamp: Date | number | string): string => {
51
- if (timestamp instanceof Date) {
52
- return timestamp.toISOString();
53
- }
54
- if (typeof timestamp === "number") {
55
- return new Date(timestamp).toISOString();
56
- }
57
- return timestamp;
56
+ if (typeof timestamp === "string") return timestamp;
57
+ return DateTime.formatIso(DateTime.makeUnsafe(timestamp));
58
58
  };
@@ -2,14 +2,16 @@
2
2
  * Step memoization schemas for decoding cached step results.
3
3
  * @internal
4
4
  */
5
+ import * as Option from "effect/Option";
5
6
  import * as Predicate from "effect/Predicate";
6
7
  import * as Schema from "effect/Schema";
8
+ import * as SchemaTransformation from "effect/SchemaTransformation";
7
9
 
8
10
  // Wire format: require property KEY to exist (not just value to be defined)
9
11
  const hasKey =
10
12
  (key: string) =>
11
13
  (u: unknown): u is Record<string, unknown> =>
12
- Predicate.isRecord(u) && Predicate.hasProperty(u, key);
14
+ Predicate.isObject(u) && Predicate.hasProperty(u, key);
13
15
 
14
16
  // Output schemas (tagged structs)
15
17
  const MemoDataSchema = Schema.TaggedStruct("MemoData", { data: Schema.Unknown });
@@ -20,41 +22,60 @@ const MemoNoneSchema = Schema.TaggedStruct("MemoNone", {});
20
22
 
21
23
  // Wire schemas with property existence filters + tag attachment
22
24
  const DataWire = Schema.Unknown.pipe(
23
- Schema.filter(hasKey("data")),
24
- Schema.transform(MemoDataSchema, {
25
- decode: (v) => ({ _tag: "MemoData" as const, data: (v as Record<string, unknown>).data }),
26
- encode: ({ data }) => ({ data }),
27
- }),
25
+ Schema.check(Schema.makeFilter(hasKey("data"))),
26
+ Schema.decodeTo(
27
+ MemoDataSchema,
28
+ SchemaTransformation.transform({
29
+ decode: (v) => ({ _tag: "MemoData" as const, data: (v as Record<string, unknown>).data }),
30
+ encode: ({ data }) => ({ data }),
31
+ }),
32
+ ),
28
33
  );
29
34
 
30
35
  const ErrorWire = Schema.Unknown.pipe(
31
- Schema.filter(hasKey("error")),
32
- Schema.transform(MemoErrorSchema, {
33
- decode: (v) => ({ _tag: "MemoError" as const, error: (v as Record<string, unknown>).error }),
34
- encode: ({ error }) => ({ error }),
35
- }),
36
+ Schema.check(Schema.makeFilter(hasKey("error"))),
37
+ Schema.decodeTo(
38
+ MemoErrorSchema,
39
+ SchemaTransformation.transform({
40
+ decode: (v) => ({ _tag: "MemoError" as const, error: (v as Record<string, unknown>).error }),
41
+ encode: ({ error }) => ({ error }),
42
+ }),
43
+ ),
36
44
  );
37
45
 
38
46
  const InputWire = Schema.Unknown.pipe(
39
- Schema.filter(hasKey("input")),
40
- Schema.transform(MemoInputSchema, {
41
- decode: (v) => ({ _tag: "MemoInput" as const, input: (v as Record<string, unknown>).input }),
42
- encode: ({ input }) => ({ input }),
43
- }),
47
+ Schema.check(Schema.makeFilter(hasKey("input"))),
48
+ Schema.decodeTo(
49
+ MemoInputSchema,
50
+ SchemaTransformation.transform({
51
+ decode: (v) => ({ _tag: "MemoInput" as const, input: (v as Record<string, unknown>).input }),
52
+ encode: ({ input }) => ({ input }),
53
+ }),
54
+ ),
44
55
  );
45
56
 
46
- const TimeoutWire = Schema.transform(Schema.Null, MemoTimeoutSchema, {
47
- decode: () => ({ _tag: "MemoTimeout" as const }),
48
- encode: () => null,
49
- });
57
+ const TimeoutWire = Schema.Null.pipe(
58
+ Schema.decodeTo(
59
+ MemoTimeoutSchema,
60
+ SchemaTransformation.transform({
61
+ decode: () => ({ _tag: "MemoTimeout" as const }),
62
+ encode: () => null,
63
+ }),
64
+ ),
65
+ );
50
66
 
51
- const NoneWire = Schema.transform(Schema.Undefined, MemoNoneSchema, {
52
- decode: () => ({ _tag: "MemoNone" as const }),
53
- encode: () => undefined,
54
- });
67
+ const NoneWire = Schema.Undefined.pipe(
68
+ Schema.decodeTo(
69
+ MemoNoneSchema,
70
+ SchemaTransformation.transform({
71
+ decode: () => ({ _tag: "MemoNone" as const }),
72
+ encode: () => undefined,
73
+ }),
74
+ ),
75
+ );
55
76
 
56
77
  // Union order matters: error > input > data (more specific first)
57
- const MemoSchema = Schema.Union(ErrorWire, InputWire, DataWire, TimeoutWire, NoneWire);
78
+ const MemoSchema = Schema.Union([ErrorWire, InputWire, DataWire, TimeoutWire, NoneWire]);
58
79
 
59
80
  // Derived type (only union is needed externally - Match.tag uses string literals)
60
81
  export type Memo = Schema.Schema.Type<typeof MemoSchema>;
@@ -63,11 +84,5 @@ export type Memo = Schema.Schema.Type<typeof MemoSchema>;
63
84
  * Decode a step result into a Memo type.
64
85
  * Order matters: error > input > data (more specific properties first).
65
86
  */
66
- export const decodeMemo = (value: unknown): Memo => {
67
- const result = Schema.decodeUnknownOption(MemoSchema)(value);
68
- if (result._tag === "Some") {
69
- return result.value;
70
- }
71
-
72
- return MemoNoneSchema.make();
73
- };
87
+ export const decodeMemo = (value: unknown): Memo =>
88
+ Option.getOrElse(Schema.decodeUnknownOption(MemoSchema)(value), () => MemoNoneSchema.make({}));