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
package/src/Function.ts CHANGED
@@ -2,8 +2,13 @@
2
2
  * @since 0.1.0
3
3
  */
4
4
  import { Array as Arr, Duration, Predicate, Schema } from "effect";
5
+ import { pipeArguments, type Pipeable } from "effect/Pipeable";
6
+ import * as Checkpoint from "./internal/checkpoint.js";
7
+ import type { CheckpointingOption } from "./internal/checkpoint.js";
5
8
  import { timeStr } from "./internal/helpers.js";
6
9
 
10
+ export type { CheckpointingOption } from "./internal/checkpoint.js";
11
+
7
12
  /**
8
13
  * @since 0.1.0
9
14
  * @category type ids
@@ -16,7 +21,7 @@ export const TypeId: unique symbol = Symbol.for("effect-inngest/Function");
16
21
  */
17
22
  export type TypeId = typeof TypeId;
18
23
 
19
- type EventSchema = Schema.Schema.Any & { readonly _tag: string };
24
+ type EventSchema = Schema.Top & { readonly identifier: string };
20
25
 
21
26
  /**
22
27
  * An event-based trigger configuration.
@@ -98,7 +103,7 @@ interface RateLimitOption {
98
103
  /**
99
104
  * The period of time to allow the function to run `limit` times.
100
105
  */
101
- readonly period: Duration.DurationInput;
106
+ readonly period: Duration.Input;
102
107
  }
103
108
 
104
109
  interface ThrottleOption {
@@ -119,7 +124,7 @@ interface ThrottleOption {
119
124
  * The period of time for the rate limit. Run starts are evenly spaced through
120
125
  * the given period. The minimum granularity is 1 second.
121
126
  */
122
- readonly period: Duration.DurationInput;
127
+ readonly period: Duration.Input;
123
128
 
124
129
  /**
125
130
  * The number of runs allowed to start in the given window in a single burst.
@@ -138,14 +143,14 @@ interface DebounceOption {
138
143
  /**
139
144
  * The period of time to delay after receiving the last trigger to run the function.
140
145
  */
141
- readonly period: Duration.DurationInput;
146
+ readonly period: Duration.Input;
142
147
 
143
148
  /**
144
149
  * The maximum time that a debounce can be extended before running.
145
150
  * If events are continually received within the given period, a function
146
151
  * will always run after the given timeout period.
147
152
  */
148
- readonly timeout?: Duration.DurationInput;
153
+ readonly timeout?: Duration.Input;
149
154
  }
150
155
 
151
156
  interface BatchEventsOption {
@@ -159,7 +164,7 @@ interface BatchEventsOption {
159
164
  * If timeout is reached, the function will be invoked with a batch
160
165
  * even if it's not filled up to `maxSize`.
161
166
  */
162
- readonly timeout: Duration.DurationInput;
167
+ readonly timeout: Duration.Input;
163
168
 
164
169
  /**
165
170
  * An optional key to use for batching.
@@ -192,14 +197,14 @@ interface TimeoutsOption {
192
197
  * This is, essentially, the amount of time that a function sits in the
193
198
  * queue before starting.
194
199
  */
195
- readonly start?: Duration.DurationInput;
200
+ readonly start?: Duration.Input;
196
201
 
197
202
  /**
198
203
  * Finish represents the time between a function starting and the function
199
204
  * finishing. If a function takes longer than this time to finish, the
200
205
  * function is marked as cancelled.
201
206
  */
202
- readonly finish?: Duration.DurationInput;
207
+ readonly finish?: Duration.Input;
203
208
  }
204
209
 
205
210
  interface SingletonOption {
@@ -237,7 +242,7 @@ interface CancellationOption {
237
242
  * specified, cancellation triggers are valid for up to a year or until the
238
243
  * function ends.
239
244
  */
240
- readonly timeout?: Duration.DurationInput;
245
+ readonly timeout?: Duration.Input;
241
246
  }
242
247
 
243
248
  type Retries = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20;
@@ -321,6 +326,20 @@ export interface FunctionOptions {
321
326
  * Batch events configuration.
322
327
  */
323
328
  readonly batchEvents?: BatchEventsOption;
329
+
330
+ /**
331
+ * Whether to use checkpointing for executions of this function. Overrides
332
+ * the client-level `checkpointing` setting.
333
+ *
334
+ * - `false` disables checkpointing for this function.
335
+ * - `true` enables checkpointing with safe defaults
336
+ * (`bufferedSteps: 1`, `maxInterval: 0`, `maxRuntime: 10s`).
337
+ * - An object lets you tune `bufferedSteps`, `maxInterval`, `maxRuntime`.
338
+ *
339
+ * Defaults to inheriting from the client-level setting (which itself
340
+ * defaults to enabled with safe defaults).
341
+ */
342
+ readonly checkpointing?: CheckpointingOption;
324
343
  }
325
344
 
326
345
  interface RegistrationConfig {
@@ -379,6 +398,11 @@ interface FunctionRegistration {
379
398
  readonly key?: string;
380
399
  };
381
400
  readonly idempotency?: string;
401
+ readonly checkpoint?: {
402
+ readonly batch_steps: number;
403
+ readonly batch_interval: string;
404
+ readonly max_runtime: string;
405
+ };
382
406
  }
383
407
 
384
408
  /**
@@ -390,9 +414,9 @@ interface FunctionRegistration {
390
414
  export interface InngestFunction<
391
415
  Tag extends string,
392
416
  Triggers extends Trigger,
393
- Success extends Schema.Schema.Any,
417
+ Success extends Schema.Top,
394
418
  Options extends FunctionOptions = FunctionOptions,
395
- > {
419
+ > extends Pipeable {
396
420
  readonly [TypeId]: TypeId;
397
421
  readonly _tag: Tag;
398
422
  readonly key: string;
@@ -407,7 +431,7 @@ export interface InngestFunction<
407
431
  * @category models
408
432
  */
409
433
  export declare namespace InngestFunction {
410
- export type Any = InngestFunction<string, Trigger, Schema.Schema.Any, FunctionOptions>;
434
+ export type Any = InngestFunction<string, Trigger, Schema.Top, FunctionOptions>;
411
435
  export type Tag<F> = F extends InngestFunction<infer T, any, any, any> ? T : never;
412
436
  export type Triggers<F> = F extends InngestFunction<any, infer T, any, any> ? T : never;
413
437
  export type Events<F> =
@@ -428,6 +452,11 @@ const isEventTrigger = (t: Trigger): t is EventTrigger => Predicate.hasProperty(
428
452
  const Proto = {
429
453
  [TypeId]: TypeId,
430
454
 
455
+ pipe() {
456
+ // eslint-disable-next-line prefer-rest-params
457
+ return pipeArguments(this, arguments);
458
+ },
459
+
431
460
  toRegistration(this: InngestFunction.Any, config: RegistrationConfig): FunctionRegistration {
432
461
  const triggers: Array<{
433
462
  event?: string;
@@ -437,7 +466,7 @@ const Proto = {
437
466
 
438
467
  for (const t of this.triggers) {
439
468
  if (isEventTrigger(t)) {
440
- triggers.push({ event: t.event._tag, expression: t.if });
469
+ triggers.push({ event: t.event.identifier, expression: t.if });
441
470
  } else {
442
471
  triggers.push({ cron: t.cron });
443
472
  }
@@ -484,7 +513,7 @@ const Proto = {
484
513
 
485
514
  const concurrency =
486
515
  opts.concurrency != null
487
- ? typeof opts.concurrency === "number"
516
+ ? Predicate.isNumber(opts.concurrency)
488
517
  ? [{ limit: opts.concurrency }]
489
518
  : Arr.ensure(opts.concurrency).map((c) => ({
490
519
  key: c.key,
@@ -505,6 +534,13 @@ const Proto = {
505
534
 
506
535
  const idempotency = opts.idempotency;
507
536
 
537
+ // Function-level checkpoint config only — client-level default is
538
+ // applied at runtime by the handler. Mirrors how other client defaults
539
+ // (e.g. retries) interact with registration.
540
+ const resolvedCheckpoint =
541
+ opts.checkpointing !== undefined ? Checkpoint.resolveConfig(opts.checkpointing, undefined) : undefined;
542
+ const checkpoint = resolvedCheckpoint ? Checkpoint.toRegistration(resolvedCheckpoint) : undefined;
543
+
508
544
  const fnId = `${config.appId}-${this._tag}`;
509
545
  const stepUrl = new URL(config.url);
510
546
  stepUrl.searchParams.set("fnId", fnId);
@@ -532,6 +568,7 @@ const Proto = {
532
568
  singleton,
533
569
  batchEvents,
534
570
  idempotency,
571
+ checkpoint,
535
572
  };
536
573
  },
537
574
  };
@@ -574,7 +611,7 @@ type NormalizeTriggers<T extends TriggerInput> = T extends ReadonlyArray<Trigger
574
611
  export function make<
575
612
  const Tag extends string,
576
613
  T extends TriggerInput,
577
- S extends Schema.Schema.Any,
614
+ S extends Schema.Top,
578
615
  const O extends FunctionOptions = {},
579
616
  >(
580
617
  tag: Tag,
package/src/Group.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * @since 0.1.0
3
3
  */
4
- import * as HttpApp from "@effect/platform/HttpApp";
5
- import * as HttpClient from "@effect/platform/HttpClient";
6
- import * as HttpServerRequest from "@effect/platform/HttpServerRequest";
7
- import * as HttpServerResponse from "@effect/platform/HttpServerResponse";
8
- import * as UrlParams from "@effect/platform/UrlParams";
4
+ import * as HttpClient from "effect/unstable/http/HttpClient";
5
+ import * as HttpEffect from "effect/unstable/http/HttpEffect";
6
+ import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
7
+ import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
8
+ import * as UrlParams from "effect/unstable/http/UrlParams";
9
9
  import * as Context from "effect/Context";
10
10
  import * as Effect from "effect/Effect";
11
11
  import * as Layer from "effect/Layer";
@@ -150,7 +150,7 @@ const Proto = {
150
150
  const fn = functions.get(tag)!;
151
151
  contextMap.set(fn.key, { handler, context });
152
152
  }
153
- return Context.unsafeMake(contextMap);
153
+ return Context.makeUnsafe(contextMap);
154
154
  }),
155
155
  );
156
156
  },
@@ -162,7 +162,7 @@ const Proto = {
162
162
  const context = yield* Effect.context<never>();
163
163
  const contextMap = new Map<string, unknown>();
164
164
  contextMap.set(fn.key, { handler, context });
165
- return Context.unsafeMake(contextMap);
165
+ return Context.makeUnsafe(contextMap);
166
166
  }),
167
167
  );
168
168
  },
@@ -199,8 +199,8 @@ export const make = <Fns extends ReadonlyArray<InngestFunction.Any>>(...fns: Fns
199
199
  * )
200
200
  * ```
201
201
  */
202
- export const toHttpApp = (group: InngestGroup.Any): HttpApp.Default<never, InngestClient | HttpClient.HttpClient> =>
203
- Effect.gen(function* () {
202
+ export const toHttpApp = Effect.fn("InngestGroup.toHttpApp")(
203
+ function* (group: InngestGroup.Any) {
204
204
  const request = yield* HttpServerRequest.HttpServerRequest;
205
205
  const method = request.method;
206
206
  const requestUrl = Option.match(HttpServerRequest.toURL(request), {
@@ -225,17 +225,30 @@ export const toHttpApp = (group: InngestGroup.Any): HttpApp.Default<never, Innge
225
225
  }
226
226
 
227
227
  if (method === "POST") {
228
- const url = Option.getOrThrow(HttpServerRequest.toURL(request));
229
-
230
- const ExecuteParams = Schema.Struct({
231
- fnId: Schema.String,
232
- stepId: Schema.optional(Schema.String),
228
+ const url = yield* Option.match(HttpServerRequest.toURL(request), {
229
+ onNone: () =>
230
+ Effect.fail(
231
+ HttpServerResponse.jsonUnsafe(
232
+ { error: "Unable to parse request URL" },
233
+ { status: 400, headers: { [Protocol.Headers.NoRetry]: "true" } },
234
+ ),
235
+ ),
236
+ onSome: (u) => Effect.succeed(u),
233
237
  });
234
238
 
235
- const params = yield* UrlParams.schemaStruct(ExecuteParams)(UrlParams.fromInput(url.searchParams)).pipe(
236
- Effect.catchAll(() =>
239
+ const ExecuteParamsSchema = UrlParams.schemaRecord.pipe(
240
+ Schema.decodeTo(
241
+ Schema.Struct({
242
+ fnId: Schema.String,
243
+ stepId: Schema.optional(Schema.String),
244
+ }),
245
+ ),
246
+ );
247
+
248
+ const params = yield* Schema.decodeUnknownEffect(ExecuteParamsSchema)(UrlParams.fromInput(url.searchParams)).pipe(
249
+ Effect.catch(() =>
237
250
  Effect.fail(
238
- HttpServerResponse.unsafeJson(
251
+ HttpServerResponse.jsonUnsafe(
239
252
  { error: "Missing or invalid fnId query parameter" },
240
253
  { status: 400, headers: { [Protocol.Headers.NoRetry]: "true" } },
241
254
  ),
@@ -245,9 +258,9 @@ export const toHttpApp = (group: InngestGroup.Any): HttpApp.Default<never, Innge
245
258
 
246
259
  const body = yield* InternalHandler.verifyAndParseRequestBody(request).pipe(
247
260
  Effect.provide(SignatureLive),
248
- Effect.catchAll((error) =>
261
+ Effect.catch((error) =>
249
262
  Effect.fail(
250
- HttpServerResponse.unsafeJson(
263
+ HttpServerResponse.jsonUnsafe(
251
264
  { error: error.message },
252
265
  {
253
266
  status: error._tag === "SignatureError" ? 401 : 400,
@@ -267,13 +280,13 @@ export const toHttpApp = (group: InngestGroup.Any): HttpApp.Default<never, Innge
267
280
  }
268
281
 
269
282
  return yield* HttpServerResponse.json({ error: `Method ${method} not allowed` }, { status: 405 });
270
- }).pipe(
271
- Effect.catchAllCause((cause) =>
272
- HttpServerResponse.json({ error: "Internal server error", cause: String(cause) }, { status: 500 }).pipe(
273
- Effect.orDie,
274
- ),
283
+ },
284
+ Effect.catchCause((cause) =>
285
+ HttpServerResponse.json({ error: "Internal server error", cause: String(cause) }, { status: 500 }).pipe(
286
+ Effect.orDie,
275
287
  ),
276
- );
288
+ ),
289
+ );
277
290
 
278
291
  /**
279
292
  * Create a standalone web handler from an InngestGroup.
@@ -307,4 +320,4 @@ export const toWebHandler = <R, E>(
307
320
  ): {
308
321
  readonly handler: (request: Request, context?: Context.Context<never>) => Promise<Response>;
309
322
  readonly dispose: () => Promise<void>;
310
- } => HttpApp.toWebHandlerLayer(toHttpApp(group), Layer.mergeAll(options.layer, Layer.scope));
323
+ } => HttpEffect.toWebHandlerLayer(toHttpApp(group), options.layer);
package/src/HttpApi.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * @since 0.1.0
3
3
  */
4
- import * as HttpApi from "@effect/platform/HttpApi";
5
- import * as HttpApiBuilder from "@effect/platform/HttpApiBuilder";
6
- import * as HttpApiEndpoint from "@effect/platform/HttpApiEndpoint";
7
- import * as HttpApiGroup from "@effect/platform/HttpApiGroup";
8
- import * as HttpClient from "@effect/platform/HttpClient";
9
- import * as HttpServerRequest from "@effect/platform/HttpServerRequest";
4
+ import * as HttpApi from "effect/unstable/httpapi/HttpApi";
5
+ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
6
+ import * as HttpApiEndpoint from "effect/unstable/httpapi/HttpApiEndpoint";
7
+ import * as HttpApiGroup from "effect/unstable/httpapi/HttpApiGroup";
8
+ import * as HttpClient from "effect/unstable/http/HttpClient";
9
+ import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
10
10
  import * as Effect from "effect/Effect";
11
11
  import * as Layer from "effect/Layer";
12
12
  import * as Option from "effect/Option";
@@ -22,25 +22,34 @@ const ExecuteParams = Schema.Struct({
22
22
  stepId: Schema.optional(Schema.String),
23
23
  });
24
24
 
25
- class FunctionNotFoundError extends Schema.TaggedError<FunctionNotFoundError>()("FunctionNotFoundError", {
25
+ class FunctionNotFoundError extends Schema.TaggedErrorClass<FunctionNotFoundError>()("FunctionNotFoundError", {
26
26
  message: Schema.String,
27
27
  }) {}
28
28
 
29
- const IntrospectEndpoint = HttpApiEndpoint.get("introspect", "/").addSuccess(Protocol.IntrospectionResponse);
30
- const RegisterEndpoint = HttpApiEndpoint.put("register", "/").addSuccess(Protocol.RegisterResponse);
31
- const ExecuteEndpoint = HttpApiEndpoint.post("execute", "/").setUrlParams(ExecuteParams).addSuccess(Schema.Unknown);
29
+ const CommonErrors = [FunctionNotFoundError, InternalHandler.InvalidRequestError, InternalHandler.SignatureError];
30
+
31
+ const IntrospectEndpoint = HttpApiEndpoint.get("introspect", "/", {
32
+ success: Protocol.IntrospectionResponse,
33
+ error: CommonErrors,
34
+ });
35
+ const RegisterEndpoint = HttpApiEndpoint.put("register", "/", {
36
+ success: Protocol.RegisterResponse,
37
+ error: CommonErrors,
38
+ });
39
+ const ExecuteEndpoint = HttpApiEndpoint.post("execute", "/", {
40
+ query: ExecuteParams,
41
+ success: Schema.Unknown,
42
+ error: CommonErrors,
43
+ });
32
44
 
33
45
  /**
34
46
  * @since 0.1.0
35
47
  * @category api
36
48
  */
37
- export const InngestApiGroup = HttpApiGroup.make("inngest")
49
+ export class InngestApiGroup extends HttpApiGroup.make("inngest")
38
50
  .add(IntrospectEndpoint)
39
51
  .add(RegisterEndpoint)
40
- .add(ExecuteEndpoint)
41
- .addError(FunctionNotFoundError, { status: 404 })
42
- .addError(InternalHandler.InvalidRequestError, { status: 400 })
43
- .addError(InternalHandler.SignatureError, { status: 401 });
52
+ .add(ExecuteEndpoint) {}
44
53
 
45
54
  type InngestApiGroupType = typeof InngestApiGroup;
46
55
 
@@ -48,8 +57,8 @@ type InngestApiGroupType = typeof InngestApiGroup;
48
57
  * @since 0.1.0
49
58
  * @category layers
50
59
  */
51
- export const layerGroup = <ApiId extends string, Groups extends HttpApiGroup.HttpApiGroup.Any, ApiError, ApiR>(
52
- api: HttpApi.HttpApi<ApiId, Groups, ApiError, ApiR>,
60
+ export const layerGroup = <ApiId extends string, Groups extends HttpApiGroup.Any>(
61
+ api: HttpApi.HttpApi<ApiId, Groups>,
53
62
  group: InngestGroup.Any,
54
63
  ): Layer.Layer<HttpApiGroup.ApiGroup<ApiId, "inngest">, never, InngestClient | HttpClient.HttpClient> => {
55
64
  const toUrl = (req: HttpServerRequest.HttpServerRequest) =>
@@ -59,24 +68,26 @@ export const layerGroup = <ApiId extends string, Groups extends HttpApiGroup.Htt
59
68
  });
60
69
 
61
70
  return HttpApiBuilder.group(
62
- api as unknown as HttpApi.HttpApi<ApiId, InngestApiGroupType, ApiError, ApiR>,
71
+ api as unknown as HttpApi.HttpApi<ApiId, InngestApiGroupType>,
63
72
  "inngest",
64
- (handlers) =>
65
- handlers
73
+ Effect.fn(function* (handlers) {
74
+ return handlers
66
75
  .handle("introspect", ({ request }) =>
67
76
  InternalHandler.handleIntrospection(group, toUrl(request)).pipe(Effect.map((r) => r.body)),
68
77
  )
69
78
  .handle("register", ({ request }) =>
70
79
  InternalHandler.handleRegistration(group, toUrl(request)).pipe(Effect.map((r) => r.body)),
71
80
  )
72
- .handleRaw("execute", ({ urlParams, request }) =>
81
+ .handleRaw("execute", ({ query, request }) =>
73
82
  InternalHandler.verifyAndParseRequestBody(request).pipe(
74
- Effect.provide(SignatureLive),
75
- Effect.flatMap((payload) =>
76
- InternalHandler.handleExecution(group, urlParams.fnId, urlParams.stepId, payload),
77
- ),
83
+ Effect.flatMap((payload) => InternalHandler.handleExecution(group, query.fnId, query.stepId, payload)),
78
84
  Effect.map((r) => r.body),
79
85
  ),
80
- ),
81
- ) as Layer.Layer<HttpApiGroup.ApiGroup<ApiId, "inngest">, never, InngestClient | HttpClient.HttpClient>;
86
+ );
87
+ }),
88
+ ).pipe(Layer.provide(SignatureLive)) as unknown as Layer.Layer<
89
+ HttpApiGroup.ApiGroup<ApiId, "inngest">,
90
+ never,
91
+ InngestClient | HttpClient.HttpClient
92
+ >;
82
93
  };
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Checkpoint configuration + per-execution state for spec §10 async
3
+ * checkpointing.
4
+ *
5
+ * Sync checkpointing (§10.3.2 / §10.4.2) is intentionally out of scope —
6
+ * it requires a durable endpoint primitive the SDK does not yet expose.
7
+ *
8
+ * @internal
9
+ */
10
+ import * as Clock from "effect/Clock";
11
+ import * as Duration from "effect/Duration";
12
+ import * as Effect from "effect/Effect";
13
+ import * as Option from "effect/Option";
14
+ import * as Ref from "effect/Ref";
15
+ import * as Result from "effect/Result";
16
+ import * as Schema from "effect/Schema";
17
+ import { timeStr } from "./helpers.js";
18
+ import type * as Protocol from "./protocol.js";
19
+
20
+ /**
21
+ * Tagged error returned by `InngestClient.checkpointAsync` when the API call
22
+ * fails (network error or non-2xx after retries). The driver and step tools
23
+ * treat this as a graceful-fallback signal — buffered steps are restored to
24
+ * the buffer so they get included in the final 206 response.
25
+ */
26
+ export class CheckpointApiError extends Schema.TaggedErrorClass<CheckpointApiError>()("CheckpointApiError", {
27
+ message: Schema.String,
28
+ status: Schema.optionalKey(Schema.Number),
29
+ }) {}
30
+
31
+ /**
32
+ * User-facing checkpointing option, accepted on `ClientConfig.checkpointing`
33
+ * and `FunctionOptions.checkpointing`.
34
+ *
35
+ * - `false` disables checkpointing entirely (no API calls; classic 206-per-step).
36
+ * - `true` enables checkpointing with the safe defaults
37
+ * (`bufferedSteps: 1`, `maxInterval: 0`, `maxRuntime: 10 seconds`).
38
+ * - An object lets you tune `bufferedSteps`, `maxInterval`, `maxRuntime`.
39
+ */
40
+ export type CheckpointingOption =
41
+ | boolean
42
+ | {
43
+ readonly bufferedSteps?: number;
44
+ readonly maxInterval?: Duration.Input;
45
+ readonly maxRuntime?: Duration.Input;
46
+ };
47
+
48
+ /**
49
+ * Resolved internal config — defaults applied, durations normalized.
50
+ */
51
+ export interface CheckpointConfig {
52
+ readonly bufferedSteps: number;
53
+ readonly maxInterval: Duration.Duration;
54
+ readonly maxRuntime: Duration.Duration;
55
+ }
56
+
57
+ const DEFAULTS: CheckpointConfig = {
58
+ bufferedSteps: 1,
59
+ maxInterval: Duration.millis(0),
60
+ maxRuntime: Duration.seconds(10),
61
+ };
62
+
63
+ const normalize = (option: Exclude<CheckpointingOption, boolean>): CheckpointConfig => ({
64
+ bufferedSteps: option.bufferedSteps ?? DEFAULTS.bufferedSteps,
65
+ maxInterval: option.maxInterval !== undefined ? Duration.fromInputUnsafe(option.maxInterval) : DEFAULTS.maxInterval,
66
+ maxRuntime: option.maxRuntime !== undefined ? Duration.fromInputUnsafe(option.maxRuntime) : DEFAULTS.maxRuntime,
67
+ });
68
+
69
+ /**
70
+ * Resolve final config from function-level + client-level settings.
71
+ *
72
+ * Precedence: function-level explicit > client-level explicit > built-in
73
+ * defaults (default-ON, matching the TS reference SDK).
74
+ *
75
+ * Returns `undefined` if either level explicitly disables checkpointing.
76
+ */
77
+ export const resolveConfig = (
78
+ fnLevel: CheckpointingOption | undefined,
79
+ clientLevel: CheckpointingOption | undefined,
80
+ ): CheckpointConfig | undefined => {
81
+ if (fnLevel === false) return undefined;
82
+ // Per the `CheckpointingOption` docstring, `true` means "enable with safe
83
+ // defaults" — it never inherits a client-level object. Explicit object at
84
+ // the fn level overrides everything; otherwise fall through to client.
85
+ if (fnLevel === true) return DEFAULTS;
86
+ if (fnLevel !== undefined) return normalize(fnLevel);
87
+ // fnLevel === undefined — use client-level
88
+ if (clientLevel === false) return undefined;
89
+ if (clientLevel === true) return DEFAULTS;
90
+ if (clientLevel !== undefined) return normalize(clientLevel);
91
+ // Default-ON: undefined at both levels
92
+ return DEFAULTS;
93
+ };
94
+
95
+ /**
96
+ * Registration payload fragment per spec §10.1.1.
97
+ */
98
+ export interface RegistrationFragment {
99
+ readonly batch_steps: number;
100
+ readonly batch_interval: string;
101
+ readonly max_runtime: string;
102
+ }
103
+
104
+ export const toRegistration = (cfg: CheckpointConfig): RegistrationFragment => ({
105
+ batch_steps: cfg.bufferedSteps,
106
+ batch_interval: timeStr(cfg.maxInterval),
107
+ max_runtime: timeStr(cfg.maxRuntime),
108
+ });
109
+
110
+ /**
111
+ * Per-execution state for checkpoint mode. NOT a `Context.Service` — each
112
+ * `execute` call constructs its own and passes it directly to step tools and
113
+ * the driver.
114
+ *
115
+ * Internal `Ref`s are closure-private; only effectful ops are exposed. All
116
+ * ops are safe to sequence from a single fiber; the buffer/interval/runtime
117
+ * primitives are not designed for concurrent fibers (none are spawned today).
118
+ */
119
+ export interface CheckpointState {
120
+ readonly config: CheckpointConfig;
121
+ readonly runId: string;
122
+ readonly fnId: string;
123
+ readonly qiId: string;
124
+ /** Append a sync opcode; flush if `bufferedSteps` or `maxInterval` reached. */
125
+ readonly bufferStep: (op: typeof Protocol.GeneratorOpcode.Type) => Effect.Effect<void>;
126
+ /**
127
+ * Best-effort flush. On API error the buffered steps are re-prepended so
128
+ * `drain` can include them in the terminal 206 (no step loss per §10.4.3).
129
+ */
130
+ readonly flush: Effect.Effect<void>;
131
+ /** Atomic snapshot + clear, for terminal response assembly. */
132
+ readonly drain: Effect.Effect<ReadonlyArray<typeof Protocol.GeneratorOpcode.Type>>;
133
+ /** Signal that the handler's `maxRuntime` deadline fired (spec §10.4.1 #7). */
134
+ readonly markRuntimeExceeded: Effect.Effect<void>;
135
+ /** Query whether the `maxRuntime` deadline fired. */
136
+ readonly isRuntimeExceeded: Effect.Effect<boolean>;
137
+ }
138
+
139
+ /**
140
+ * Construct a `CheckpointState`. Caller supplies a pre-bound `checkpointAsync`
141
+ * callback (typically `(steps) => client.checkpointAsync({runId, fnId, qiId, steps})`),
142
+ * so this module has no dependency on `InngestClient`.
143
+ */
144
+ export const make = (args: {
145
+ readonly config: CheckpointConfig;
146
+ readonly runId: string;
147
+ readonly fnId: string;
148
+ readonly qiId: string;
149
+ readonly checkpointAsync: (
150
+ steps: ReadonlyArray<typeof Protocol.GeneratorOpcode.Type>,
151
+ ) => Effect.Effect<void, CheckpointApiError>;
152
+ }): Effect.Effect<CheckpointState> =>
153
+ Effect.sync(() => {
154
+ const buffer = Ref.makeUnsafe<ReadonlyArray<typeof Protocol.GeneratorOpcode.Type>>([]);
155
+ const intervalStartedAt = Ref.makeUnsafe<Option.Option<number>>(Option.none());
156
+ const runtimeExceeded = Ref.makeUnsafe(false);
157
+
158
+ const maxIntervalMs = Duration.toMillis(args.config.maxInterval);
159
+
160
+ // Extract-then-restore is NOT atomic across multiple fibers; a concurrent
161
+ // `bufferStep` between the `Ref.modify` snapshot and the error-path
162
+ // `Ref.update` restore would reorder opcodes. Request-scoped execution
163
+ // today only runs a single fiber, so this is safe. If parallel fibers
164
+ // are ever added here, guard `flushInner` with a `Semaphore(1)`.
165
+ const flushInner: Effect.Effect<void> = Effect.gen(function* () {
166
+ const steps = yield* Ref.modify(buffer, (current) => [
167
+ current,
168
+ [] as ReadonlyArray<typeof Protocol.GeneratorOpcode.Type>,
169
+ ]);
170
+ if (steps.length === 0) return;
171
+ const result = yield* Effect.result(args.checkpointAsync(steps));
172
+ if (Result.isFailure(result)) {
173
+ // Restore at the head so subsequent `drain` includes them in the 206.
174
+ yield* Ref.update(buffer, (current) => [...steps, ...current]);
175
+ return;
176
+ }
177
+ yield* Ref.set(intervalStartedAt, Option.none());
178
+ });
179
+
180
+ const bufferStep = (op: typeof Protocol.GeneratorOpcode.Type): Effect.Effect<void> =>
181
+ Effect.gen(function* () {
182
+ const len = yield* Ref.modify(buffer, (current) => {
183
+ const next = [...current, op];
184
+ return [next.length, next] as const;
185
+ });
186
+ const now = yield* Clock.currentTimeMillis;
187
+ const start = yield* Ref.get(intervalStartedAt);
188
+
189
+ if (len >= args.config.bufferedSteps) {
190
+ yield* flushInner;
191
+ return;
192
+ }
193
+ if (Option.isSome(start) && maxIntervalMs > 0 && now - start.value >= maxIntervalMs) {
194
+ yield* flushInner;
195
+ return;
196
+ }
197
+ if (Option.isNone(start) && maxIntervalMs > 0) {
198
+ yield* Ref.set(intervalStartedAt, Option.some(now));
199
+ }
200
+ });
201
+
202
+ const drain: Effect.Effect<ReadonlyArray<typeof Protocol.GeneratorOpcode.Type>> = Ref.modify(buffer, (current) => [
203
+ current,
204
+ [] as ReadonlyArray<typeof Protocol.GeneratorOpcode.Type>,
205
+ ]);
206
+
207
+ return {
208
+ config: args.config,
209
+ runId: args.runId,
210
+ fnId: args.fnId,
211
+ qiId: args.qiId,
212
+ bufferStep,
213
+ flush: flushInner,
214
+ drain,
215
+ markRuntimeExceeded: Ref.set(runtimeExceeded, true),
216
+ isRuntimeExceeded: Ref.get(runtimeExceeded),
217
+ };
218
+ });