effect-inngest 0.1.2 → 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 +83 -32
  47. package/dist/_virtual/rolldown_runtime.js +0 -18
@@ -1,21 +1,20 @@
1
1
  import { formatTimestamp, timeStr } from "./helpers.js";
2
+ import { stepRun } from "./protocol.js";
2
3
  import { InngestClient } from "../Client.js";
3
4
  import { SendEventError, StepError, isNonRetriableError, isRetryAfterError } from "./errors.js";
4
- import { StepInterrupt, errorInterrupt, invokeInterrupt, plannedInterrupt, runInterrupt, sleepInterrupt, waitForEventInterrupt } from "./interrupts.js";
5
+ import { errorInterrupt, invokeInterrupt, plannedInterrupt, runInterrupt, sleepInterrupt, waitForEventInterrupt } from "./interrupts.js";
5
6
  import { OtelAttributes } from "./constants.js";
6
7
  import { decodeMemo } from "./memo.js";
7
8
  import * as Duration from "effect/Duration";
8
- import * as Context from "effect/Context";
9
9
  import * as Effect from "effect/Effect";
10
10
  import * as Option from "effect/Option";
11
+ import * as Ref from "effect/Ref";
11
12
  import * as Schema from "effect/Schema";
12
13
  import * as Predicate from "effect/Predicate";
13
- import * as Arr from "effect/Array";
14
14
  import * as HashMap from "effect/HashMap";
15
- import * as Match from "effect/Match";
16
- import * as Ref from "effect/Ref";
17
15
  import { pipe } from "effect/Function";
18
-
16
+ import * as Arr from "effect/Array";
17
+ import * as Match from "effect/Match";
19
18
  //#region src/internal/step.ts
20
19
  /**
21
20
  * Internal step tools implementation.
@@ -40,10 +39,9 @@ const errorOtelAttributes = (err) => {
40
39
  };
41
40
  const encodeTaggedEvent = (event) => {
42
41
  const ctor = event.constructor;
43
- if (Schema.isSchema(ctor)) return Schema.encode(ctor)(event).pipe(Effect.orElseSucceed(() => event));
42
+ if (Schema.isSchema(ctor)) return Schema.encodeEffect(ctor)(event).pipe(Effect.orElseSucceed(() => event));
44
43
  return Effect.succeed(event);
45
44
  };
46
- var Step = class extends Context.Tag("effect-inngest/Step")() {};
47
45
  const normalizeOpts = (opts) => typeof opts === "string" ? {
48
46
  id: opts,
49
47
  name: opts
@@ -57,7 +55,7 @@ const stepError = (stepId, message, opts) => Effect.fail(StepError.make({
57
55
  noRetry: opts?.noRetry,
58
56
  cause: opts?.cause
59
57
  }));
60
- const createStepTools = (request, appName, stepIdCounts) => {
58
+ const createStepTools = (request, appName, stepIdCounts, checkpoint = Option.none()) => {
61
59
  const ctx = request.ctx;
62
60
  const steps = request.steps;
63
61
  const getInfo = (opts) => {
@@ -74,30 +72,40 @@ const createStepTools = (request, appName, stepIdCounts) => {
74
72
  const getMemo = (hash) => decodeMemo(steps[hash]);
75
73
  const canExecute = (hash) => ctx.step_id === hash || ctx.step_id === "step";
76
74
  const isBlocked = (hash) => ctx.disable_immediate_execution && ctx.step_id !== hash;
77
- const sleep = (opts, duration) => Effect.flatMap(getInfo(opts), (info) => pipe(getMemo(info.hash), Match.value, Match.tag("MemoData", () => Effect.void), Match.tag("MemoTimeout", () => Effect.void), Match.tag("MemoError", () => Effect.void), Match.tag("MemoInput", () => Effect.void), Match.tag("MemoNone", () => Effect.fail(sleepInterrupt({
75
+ const flushIfCheckpoint = Option.match(checkpoint, {
76
+ onNone: () => Effect.void,
77
+ onSome: (state) => state.flush
78
+ });
79
+ const sleep = (opts, duration) => Effect.flatMap(getInfo(opts), (info) => pipe(getMemo(info.hash), Match.value, Match.tag("MemoData", () => Effect.void), Match.tag("MemoTimeout", () => Effect.void), Match.tag("MemoError", () => Effect.void), Match.tag("MemoInput", () => Effect.void), Match.tag("MemoNone", () => Effect.andThen(flushIfCheckpoint, Effect.fail(sleepInterrupt({
78
80
  info,
79
81
  duration: timeStr(duration)
80
- }))), Match.exhaustive));
81
- const sleepUntil = (opts, timestamp) => Effect.flatMap(getInfo(opts), (info) => pipe(getMemo(info.hash), Match.value, Match.tag("MemoData", () => Effect.void), Match.tag("MemoTimeout", () => Effect.void), Match.tag("MemoError", () => Effect.void), Match.tag("MemoInput", () => Effect.void), Match.tag("MemoNone", () => Effect.fail(sleepInterrupt({
82
+ }))).pipe(Effect.withSpan(`inngest.step/sleep/${info.id}`, { attributes: {
83
+ [OtelAttributes.StepId]: info.id,
84
+ [OtelAttributes.StepType]: "sleep"
85
+ } }))), Match.exhaustive));
86
+ const sleepUntil = (opts, timestamp) => Effect.flatMap(getInfo(opts), (info) => pipe(getMemo(info.hash), Match.value, Match.tag("MemoData", () => Effect.void), Match.tag("MemoTimeout", () => Effect.void), Match.tag("MemoError", () => Effect.void), Match.tag("MemoInput", () => Effect.void), Match.tag("MemoNone", () => Effect.andThen(flushIfCheckpoint, Effect.fail(sleepInterrupt({
82
87
  info,
83
88
  duration: formatTimestamp(timestamp)
84
- }))), Match.exhaustive));
89
+ }))).pipe(Effect.withSpan(`inngest.step/sleepUntil/${info.id}`, { attributes: {
90
+ [OtelAttributes.StepId]: info.id,
91
+ [OtelAttributes.StepType]: "sleepUntil"
92
+ } }))), Match.exhaustive));
85
93
  const waitForEvent = (opts, event, options) => Effect.flatMap(getInfo(opts), (info) => pipe(getMemo(info.hash), Match.value, Match.tag("MemoData", ({ data }) => {
86
94
  if (data == null) return Effect.succeed(Option.none());
87
95
  const event = data;
88
96
  const payload = event.data !== void 0 ? event.data : data;
89
97
  if (payload == null) return Effect.succeed(Option.none());
90
98
  return Effect.succeed(Option.some(payload));
91
- }), Match.tag("MemoTimeout", () => Effect.succeed(Option.none())), Match.tag("MemoError", () => Effect.succeed(Option.none())), Match.tag("MemoInput", () => Effect.succeed(Option.none())), Match.tag("MemoNone", () => Effect.fail(waitForEventInterrupt({
99
+ }), Match.tag("MemoTimeout", () => Effect.succeed(Option.none())), Match.tag("MemoError", () => Effect.succeed(Option.none())), Match.tag("MemoInput", () => Effect.succeed(Option.none())), Match.tag("MemoNone", () => Effect.andThen(flushIfCheckpoint, Effect.fail(waitForEventInterrupt({
92
100
  info,
93
- event: event._tag,
101
+ event: event.identifier,
94
102
  timeout: timeStr(options.timeout),
95
103
  if: options.if
96
- }))), Match.exhaustive));
104
+ })))), Match.exhaustive));
97
105
  const invoke = (opts, options) => Effect.flatMap(getInfo(opts), (info) => pipe(getMemo(info.hash), Match.value, Match.tag("MemoData", ({ data }) => Effect.succeed(data)), Match.tag("MemoError", ({ error }) => stepError(info.id, Predicate.hasProperty(error, "message") ? String(error.message) : "Invoke failed", { cause: error })), Match.tag("MemoTimeout", () => stepError(info.id, "Invoke timed out", { noRetry: true })), Match.tag("MemoInput", () => Effect.succeed(void 0)), Match.tag("MemoNone", () => {
98
106
  const rawData = Predicate.hasProperty(options, "data") ? options.data : void 0;
99
107
  const encodeData = rawData ? encodeTaggedEvent(rawData) : Effect.succeed(void 0);
100
- return Effect.flatMap(encodeData, (encodedData) => Effect.fail(invokeInterrupt({
108
+ return Effect.flatMap(encodeData, (encodedData) => Effect.andThen(flushIfCheckpoint, Effect.fail(invokeInterrupt({
101
109
  info,
102
110
  functionId: `${appName}-${options.function._tag}`,
103
111
  payload: {
@@ -106,9 +114,12 @@ const createStepTools = (request, appName, stepIdCounts) => {
106
114
  v: options.v ?? "1"
107
115
  },
108
116
  timeout: options.timeout ? timeStr(options.timeout) : "365d"
109
- })));
117
+ }))));
110
118
  }), Match.exhaustive));
111
- const run = (opts, effect) => Effect.flatMap(getInfo(opts), (info) => pipe(getMemo(info.hash), Match.value, Match.tag("MemoData", ({ data }) => Effect.succeed(data)), Match.tag("MemoError", ({ error }) => stepError(info.id, Predicate.hasProperty(error, "message") ? String(error.message) : "Step failed", { noRetry: true })), Match.tag("MemoTimeout", () => stepError(info.id, "Step timed out", { noRetry: true })), Match.tag("MemoInput", () => stepError(info.id, "Unexpected step result type: input")), Match.tag("MemoNone", () => {
119
+ const run = (opts, effect) => Effect.flatMap(getInfo(opts), (info) => pipe(getMemo(info.hash), Match.value, Match.tag("MemoData", ({ data }) => Effect.succeed(data)), Match.tag("MemoError", ({ error }) => stepError(info.id, Predicate.hasProperty(error, "message") ? String(error.message) : "Step failed", {
120
+ noRetry: true,
121
+ cause: error
122
+ })), Match.tag("MemoTimeout", () => stepError(info.id, "Step timed out", { noRetry: true })), Match.tag("MemoInput", () => stepError(info.id, "Unexpected step result type: input")), Match.tag("MemoNone", () => {
112
123
  if (isBlocked(info.hash) || !canExecute(info.hash)) return Effect.fail(plannedInterrupt({ info }));
113
124
  return effect.pipe(Effect.withSpan(`inngest.step/run/${info.id}`, { attributes: {
114
125
  [OtelAttributes.StepId]: info.id,
@@ -117,18 +128,21 @@ const createStepTools = (request, appName, stepIdCounts) => {
117
128
  onFailure: (err) => {
118
129
  const noRetry = isNonRetriableError(err) ? true : void 0;
119
130
  const retryAfterMs = isRetryAfterError(err) ? Duration.toMillis(err.retryAfter) : void 0;
120
- return Effect.zipRight(Effect.annotateCurrentSpan(errorOtelAttributes(err)), Effect.fail(errorInterrupt({
131
+ return Effect.andThen(Effect.annotateCurrentSpan(errorOtelAttributes(err)), Effect.fail(errorInterrupt({
121
132
  info,
122
133
  error: err,
123
134
  noRetry,
124
135
  retryAfterMs
125
136
  })));
126
137
  },
127
- onSuccess: (data) => Effect.fail(runInterrupt({
128
- info,
129
- data
130
- }))
131
- }), Effect.catchAllDefect((defect) => Effect.zipRight(Effect.annotateCurrentSpan(errorOtelAttributes(defect)), Effect.fail(errorInterrupt({
138
+ onSuccess: (data) => Option.match(checkpoint, {
139
+ onNone: () => Effect.fail(runInterrupt({
140
+ info,
141
+ data
142
+ })),
143
+ onSome: (state) => Effect.as(state.bufferStep(stepRun(info, data)), data)
144
+ })
145
+ }), Effect.catchDefect((defect) => Effect.andThen(Effect.annotateCurrentSpan(errorOtelAttributes(defect)), Effect.fail(errorInterrupt({
132
146
  info,
133
147
  error: defect
134
148
  })))));
@@ -145,13 +159,16 @@ const createStepTools = (request, appName, stepIdCounts) => {
145
159
  return Effect.flatMap(Effect.forEach(events, (e) => Effect.map(encodeTaggedEvent(e), (encoded) => ({
146
160
  name: e._tag,
147
161
  data: encoded
148
- }))), (eventPayloads) => Effect.flatMap(InngestClient, (client) => client.sendEvent(eventPayloads).pipe(Effect.withSpan(`inngest.step/sendEvent/${info.id}`, { attributes: {
162
+ }))), (eventPayloads) => InngestClient.use((client) => client.sendEvent(eventPayloads).pipe(Effect.withSpan(`inngest.step/sendEvent/${info.id}`, { attributes: {
149
163
  [OtelAttributes.StepId]: info.id,
150
164
  [OtelAttributes.StepType]: "sendEvent"
151
- } }), Effect.flatMap((result) => Effect.fail(runInterrupt({
152
- info,
153
- data: result
154
- }))))));
165
+ } }), Effect.flatMap((result) => Option.match(checkpoint, {
166
+ onNone: () => Effect.fail(runInterrupt({
167
+ info,
168
+ data: result
169
+ })),
170
+ onSome: (state) => Effect.as(state.bufferStep(stepRun(info, result)), result)
171
+ })))));
155
172
  }), Match.exhaustive));
156
173
  return {
157
174
  run,
@@ -187,6 +204,5 @@ const buildHandlerContext = (fn, step, request) => {
187
204
  }
188
205
  };
189
206
  };
190
-
191
207
  //#endregion
192
- export { Step, buildHandlerContext, createStepTools };
208
+ export { buildHandlerContext, createStepTools };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "effect-inngest",
3
- "version": "0.1.2",
3
+ "version": "0.2.0-beta.0",
4
4
  "description": "Native Effect client library for Inngest - build durable, type-safe workflows",
5
5
  "keywords": [
6
6
  "background-jobs",
@@ -91,33 +91,38 @@
91
91
  }
92
92
  },
93
93
  "scripts": {
94
- "build": "tsdown",
95
- "format": "oxfmt",
96
- "lint": "oxlint --fix --type-aware --type-check --promise-plugin",
94
+ "build": "vp pack",
95
+ "test": "vp test run",
96
+ "test:watch": "vp test",
97
+ "format": "vp fmt",
98
+ "lint": "vp lint --fix --type-aware --type-check --promise-plugin",
97
99
  "typecheck": "tsgo --noEmit",
98
- "prepare": "effect-language-service patch",
100
+ "prepare": "vp config && effect-language-service patch && effect-tsgo patch",
99
101
  "changeset": "changeset",
100
102
  "changeset:version": "changeset version",
101
103
  "changeset:publish": "bun run build && changeset publish"
102
104
  },
103
105
  "devDependencies": {
104
- "@changesets/changelog-github": "^0.5.2",
105
- "@changesets/cli": "^2.29.8",
106
- "@effect/cluster": "^0.56.1",
107
- "@effect/language-service": "0.72.0",
108
- "@effect/platform": "^0.94.2",
109
- "@effect/platform-bun": "^0.87.0",
110
- "@types/bun": "1.3.6",
111
- "@typescript/native-preview": "^7.0.0-dev.20260124.1",
112
- "effect": "^3.15.0",
113
- "oxfmt": "0.26.0",
114
- "oxlint": "1.41.0",
115
- "oxlint-tsgolint": "0.11.1",
116
- "tsdown": "0.20.1",
117
- "typescript": "^5.9.3"
106
+ "@changesets/changelog-github": "^0.6.0",
107
+ "@changesets/cli": "^2.31.0",
108
+ "@effect/platform-bun": "4.0.0-beta.59",
109
+ "@effect/tsgo": "0.5.2",
110
+ "@effect/vitest": "4.0.0-beta.59",
111
+ "@types/bun": "1.3.12",
112
+ "@typescript/native-preview": "^7.0.0-dev.20260420.1",
113
+ "typescript": "^5.9.3",
114
+ "vite-plus": "latest"
118
115
  },
119
116
  "peerDependencies": {
120
- "@effect/platform": ">=0.87.0",
121
- "effect": ">=3.15.0"
117
+ "effect": ">=4.0.0-beta.44",
118
+ "typescript": ">=5.9"
119
+ },
120
+ "overrides": {
121
+ "vite": "npm:@voidzero-dev/vite-plus-core@latest",
122
+ "vitest": "npm:@voidzero-dev/vite-plus-test@latest"
123
+ },
124
+ "packageManager": "bun@1.3.13",
125
+ "patchedDependencies": {
126
+ "@effect/vitest@4.0.0-beta.59": "patches/@effect%2Fvitest@4.0.0-beta.59.patch"
122
127
  }
123
128
  }
package/src/Client.ts CHANGED
@@ -1,18 +1,25 @@
1
1
  /**
2
2
  * @since 0.1.0
3
3
  */
4
- import * as HttpClient from "@effect/platform/HttpClient";
5
- import * as HttpClientRequest from "@effect/platform/HttpClientRequest";
6
- import * as HttpClientResponse from "@effect/platform/HttpClientResponse";
4
+ import * as HttpClient from "effect/unstable/http/HttpClient";
5
+ import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
6
+ import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
7
+ import * as Clock from "effect/Clock";
7
8
  import * as Config from "effect/Config";
8
- import type * as ConfigError from "effect/ConfigError";
9
9
  import * as Context from "effect/Context";
10
10
  import * as Effect from "effect/Effect";
11
11
  import * as Layer from "effect/Layer";
12
12
  import * as Option from "effect/Option";
13
13
  import * as Predicate from "effect/Predicate";
14
+ import * as Ref from "effect/Ref";
15
+ import * as Schedule from "effect/Schedule";
14
16
  import * as Schema from "effect/Schema";
17
+ import { CheckpointApiError, type CheckpointingOption } from "./internal/checkpoint.js";
15
18
  import * as Protocol from "./internal/protocol.js";
19
+ import { hashSigningKey } from "./internal/signature.js";
20
+
21
+ export type { CheckpointingOption } from "./internal/checkpoint.js";
22
+ export { CheckpointApiError } from "./internal/checkpoint.js";
16
23
 
17
24
  /**
18
25
  * @since 0.1.0
@@ -111,6 +118,23 @@ interface ClientConfig {
111
118
  * The path where the Inngest serve handler is mounted. Used for registration.
112
119
  */
113
120
  readonly servePath?: string | undefined;
121
+
122
+ /**
123
+ * Whether to use checkpointing by default for executions of functions
124
+ * created using this client.
125
+ *
126
+ * - `false` disables checkpointing for all functions on this client.
127
+ * - `true` enables checkpointing with safe defaults (`bufferedSteps: 1`,
128
+ * `maxInterval: 0`, `maxRuntime: 10s`). Steps are checkpointed to the
129
+ * Inngest API as they complete instead of yielding a 206 per step.
130
+ * - An object lets you tune `bufferedSteps`, `maxInterval`, `maxRuntime`.
131
+ *
132
+ * Per-function `checkpointing` overrides this setting. Defaults to `true`
133
+ * (checkpointing enabled).
134
+ *
135
+ * @default true
136
+ */
137
+ readonly checkpointing?: CheckpointingOption | undefined;
114
138
  }
115
139
 
116
140
  export const EventPayload = Schema.Struct({
@@ -122,14 +146,14 @@ export const EventPayload = Schema.Struct({
122
146
  });
123
147
 
124
148
  const SendEventResponse = Schema.Struct({
125
- ids: Schema.optionalWith(Schema.Array(Schema.String), { default: () => [] }),
149
+ ids: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))),
126
150
  status: Schema.optional(Schema.Number),
127
151
  });
128
152
 
129
153
  type EventPayload = typeof EventPayload.Type;
130
154
  type SendEventResponse = typeof SendEventResponse.Type;
131
155
 
132
- class SendEventError extends Schema.TaggedError<SendEventError>()("SendEventError", {
156
+ class SendEventError extends Schema.TaggedErrorClass<SendEventError>()("SendEventError", {
133
157
  message: Schema.String,
134
158
  events: Schema.Array(Schema.String),
135
159
  }) {}
@@ -141,6 +165,23 @@ interface InngestClientService {
141
165
  readonly eventBaseUrl: string;
142
166
  readonly apiBaseUrl: string;
143
167
  readonly sendEvent: (events: ReadonlyArray<EventPayload>) => Effect.Effect<SendEventResponse, SendEventError>;
168
+ /**
169
+ * Async checkpoint API call per spec §10.3.1.
170
+ *
171
+ * POSTs `{run_id, fn_id, qi_id, steps, ts}` to
172
+ * `{apiBaseUrl}/v1/checkpoint/{runId}/async` with a hashed-signing-key
173
+ * Bearer token. Retries 5xx responses with exponential backoff + jitter
174
+ * (5 attempts). On 401, falls back to the configured fallback signing key
175
+ * for the remainder of the client's lifetime (one-way switch).
176
+ *
177
+ * @internal
178
+ */
179
+ readonly checkpointAsync: (args: {
180
+ readonly runId: string;
181
+ readonly fnId: string;
182
+ readonly qiId: string;
183
+ readonly steps: ReadonlyArray<typeof Protocol.GeneratorOpcode.Type>;
184
+ }) => Effect.Effect<void, CheckpointApiError>;
144
185
  }
145
186
 
146
187
  /**
@@ -149,7 +190,9 @@ interface InngestClientService {
149
190
  * @since 0.1.0
150
191
  * @category context
151
192
  */
152
- export class InngestClient extends Context.Tag("effect-inngest/InngestClient")<InngestClient, InngestClientService>() {}
193
+ export class InngestClient extends Context.Service<InngestClient, InngestClientService>()(
194
+ "effect-inngest/InngestClient",
195
+ ) {}
153
196
 
154
197
  const resolveMode = (config: ClientConfig): ClientMode => config.mode ?? "dev";
155
198
 
@@ -159,64 +202,175 @@ const resolveEventBaseUrl = (config: ClientConfig, mode: ClientMode): string =>
159
202
  const resolveApiBaseUrl = (config: ClientConfig, mode: ClientMode): string =>
160
203
  config.apiBaseUrl ?? (mode === "dev" ? DEFAULT_DEV_SERVER_URL : DEFAULT_API_BASE_URL);
161
204
 
162
- const makeClient = (config: ClientConfig, httpClient: HttpClient.HttpClient): InngestClientService => {
163
- const mode = resolveMode(config);
164
- const eventBaseUrl = resolveEventBaseUrl(config, mode);
165
- const apiBaseUrl = resolveApiBaseUrl(config, mode);
166
-
167
- const sendEvent = (events: ReadonlyArray<EventPayload>): Effect.Effect<SendEventResponse, SendEventError> => {
168
- if (!config.eventKey && mode === "cloud") {
169
- return new SendEventError({
170
- message: "Event key is required to send events in cloud mode",
171
- events: events.map((e) => e.name),
172
- });
173
- }
174
-
175
- const key = config.eventKey ?? "local";
176
- const url = new URL(`e/${key}`, eventBaseUrl).toString();
177
- const eventNames = events.map((e) => e.name);
178
- const now = Date.now();
179
-
180
- const payloads = events.map((e) => ({
181
- name: e.name,
182
- data: e.data ?? {},
183
- ts: e.ts ?? now,
184
- id: e.id,
185
- v: e.v,
186
- }));
187
-
188
- const request = HttpClientRequest.post(url).pipe(
189
- HttpClientRequest.setHeaders({
190
- "Content-Type": "application/json",
191
- [Protocol.Headers.SDK]: `effect-ts:v${SDK_VERSION}`,
192
- ...(config.env ? { [Protocol.Headers.Env]: config.env } : {}),
193
- }),
194
- );
205
+ const isRetriable = (error: CheckpointApiError): boolean => error.status === undefined || error.status >= 500;
206
+
207
+ const defaultCheckpointRetrySchedule: Schedule.Schedule<unknown, CheckpointApiError> = Schedule.exponential(
208
+ "100 millis",
209
+ 2.0,
210
+ ).pipe(
211
+ Schedule.jittered,
212
+ Schedule.both(Schedule.recurs(5)),
213
+ Schedule.while((m: Schedule.Metadata<unknown, CheckpointApiError>) => isRetriable(m.input)),
214
+ );
195
215
 
196
- return HttpClientRequest.schemaBodyJson(Schema.Array(EventPayload))(request, payloads).pipe(
197
- Effect.flatMap(httpClient.execute),
198
- Effect.flatMap(HttpClientResponse.schemaBodyJson(SendEventResponse)),
199
- Effect.map((response) => ({ ids: response.ids, status: response.status })),
200
- Effect.scoped,
201
- Effect.catchAll(
202
- (error) =>
216
+ const makeClient = (
217
+ config: ClientConfig,
218
+ httpClient: HttpClient.HttpClient,
219
+ retrySchedule: Schedule.Schedule<unknown, CheckpointApiError> = defaultCheckpointRetrySchedule,
220
+ ): Effect.Effect<InngestClientService> =>
221
+ Effect.gen(function* () {
222
+ const mode = resolveMode(config);
223
+ const eventBaseUrl = resolveEventBaseUrl(config, mode);
224
+ const apiBaseUrl = resolveApiBaseUrl(config, mode);
225
+
226
+ /**
227
+ * One-way switch: once the fallback signing key succeeds for any
228
+ * checkpoint API call, all subsequent calls use it first. Per spec
229
+ * §10.3.4.
230
+ */
231
+ const useFallbackKey = yield* Ref.make(false);
232
+
233
+ const sendEvent = Effect.fn("InngestClient.sendEvent")(function* (events: ReadonlyArray<EventPayload>) {
234
+ if (!config.eventKey && mode === "cloud") {
235
+ return yield* Effect.fail(
203
236
  new SendEventError({
204
- message: `Failed to send events: ${Predicate.hasProperty(error, "message") ? (error.message as string) : "Unknown error"}`,
205
- events: eventNames,
237
+ message: "Event key is required to send events in cloud mode",
238
+ events: events.map((e) => e.name),
206
239
  }),
207
- ),
240
+ );
241
+ }
242
+
243
+ const key = config.eventKey ?? "local";
244
+ const url = new URL(`e/${key}`, eventBaseUrl).toString();
245
+ const eventNames = events.map((e) => e.name);
246
+ const now = yield* Clock.currentTimeMillis;
247
+
248
+ const payloads = events.map((e) => ({
249
+ name: e.name,
250
+ data: e.data ?? {},
251
+ ts: e.ts ?? now,
252
+ id: e.id,
253
+ v: e.v,
254
+ }));
255
+
256
+ const request = HttpClientRequest.post(url).pipe(
257
+ HttpClientRequest.setHeaders({
258
+ "Content-Type": "application/json",
259
+ [Protocol.Headers.SDK]: `effect-ts:v${SDK_VERSION}`,
260
+ ...(config.env ? { [Protocol.Headers.Env]: config.env } : {}),
261
+ }),
262
+ );
263
+
264
+ return yield* HttpClientRequest.schemaBodyJson(Schema.Array(EventPayload))(request, payloads).pipe(
265
+ Effect.flatMap(httpClient.execute),
266
+ Effect.flatMap(HttpClientResponse.schemaBodyJson(SendEventResponse)),
267
+ Effect.map((response) => ({ ids: response.ids, status: response.status })),
268
+ Effect.scoped,
269
+ Effect.catch((error) =>
270
+ Effect.fail(
271
+ new SendEventError({
272
+ message: `Failed to send events: ${Predicate.hasProperty(error, "message") ? (error.message as string) : "Unknown error"}`,
273
+ events: eventNames,
274
+ }),
275
+ ),
276
+ ),
277
+ );
278
+ });
279
+
280
+ /**
281
+ * Issue a checkpoint POST with the given signing key. Returns a tagged
282
+ * error on non-2xx so the caller can decide whether to retry, swap keys,
283
+ * or fall back.
284
+ */
285
+ const checkpointRequest = (
286
+ runId: string,
287
+ body: string,
288
+ signingKey: string,
289
+ ): Effect.Effect<void, CheckpointApiError> => {
290
+ const url = new URL(`v1/checkpoint/${runId}/async`, apiBaseUrl).toString();
291
+ const request = HttpClientRequest.post(url).pipe(
292
+ HttpClientRequest.setHeaders({
293
+ "Content-Type": "application/json",
294
+ [Protocol.Headers.SDK]: `effect-inngest:v${SDK_VERSION}`,
295
+ [Protocol.Headers.RequestVersion]: "2",
296
+ ...(config.env ? { [Protocol.Headers.Env]: config.env } : {}),
297
+ }),
298
+ HttpClientRequest.bearerToken(hashSigningKey(signingKey)),
299
+ HttpClientRequest.bodyText(body, "application/json"),
300
+ );
301
+
302
+ return httpClient.execute(request).pipe(
303
+ Effect.scoped,
304
+ Effect.catch((error) =>
305
+ Effect.fail(
306
+ new CheckpointApiError({
307
+ message: `Network error issuing checkpoint: ${Predicate.hasProperty(error, "message") ? (error.message as string) : String(error)}`,
308
+ }),
309
+ ),
310
+ ),
311
+ Effect.flatMap((response) => {
312
+ if (response.status >= 200 && response.status < 300) {
313
+ return Effect.void;
314
+ }
315
+ return Effect.fail(
316
+ new CheckpointApiError({
317
+ message: `Checkpoint API returned ${response.status}`,
318
+ status: response.status,
319
+ }),
320
+ );
321
+ }),
322
+ );
323
+ };
324
+
325
+ const checkpointAsync: InngestClientService["checkpointAsync"] = Effect.fn("InngestClient.checkpointAsync")(
326
+ function* (args) {
327
+ const signingKey = config.signingKey;
328
+ if (!signingKey) {
329
+ return yield* Effect.fail(
330
+ new CheckpointApiError({ message: "No signing key configured for checkpoint API" }),
331
+ );
332
+ }
333
+
334
+ const now = yield* Clock.currentTimeMillis;
335
+ const body = JSON.stringify({
336
+ run_id: args.runId,
337
+ fn_id: args.fnId,
338
+ qi_id: args.qiId,
339
+ steps: Schema.encodeSync(Schema.Array(Protocol.GeneratorOpcode))(args.steps),
340
+ ts: now,
341
+ });
342
+
343
+ const fallbackKey = config.signingKeyFallback;
344
+
345
+ // Single attempt: tries the chosen key first; on 401 with primary,
346
+ // tries the fallback once and flips the Ref.
347
+ const attempt: Effect.Effect<void, CheckpointApiError> = Ref.get(useFallbackKey).pipe(
348
+ Effect.flatMap((usingFallback) => {
349
+ const primaryKey = usingFallback && fallbackKey ? fallbackKey : signingKey;
350
+ return checkpointRequest(args.runId, body, primaryKey).pipe(
351
+ Effect.catch((error) =>
352
+ error.status === 401 && !usingFallback && fallbackKey
353
+ ? checkpointRequest(args.runId, body, fallbackKey).pipe(Effect.andThen(Ref.set(useFallbackKey, true)))
354
+ : Effect.fail(error),
355
+ ),
356
+ );
357
+ }),
358
+ );
359
+
360
+ return yield* attempt.pipe(Effect.retry(retrySchedule));
361
+ },
208
362
  );
209
- };
210
-
211
- return {
212
- [TypeId]: TypeId,
213
- config,
214
- mode,
215
- eventBaseUrl,
216
- apiBaseUrl,
217
- sendEvent,
218
- };
219
- };
363
+
364
+ return {
365
+ [TypeId]: TypeId,
366
+ config,
367
+ mode,
368
+ eventBaseUrl,
369
+ apiBaseUrl,
370
+ sendEvent,
371
+ checkpointAsync,
372
+ };
373
+ });
220
374
 
221
375
  /**
222
376
  * Create an InngestClient layer from a config.
@@ -234,7 +388,27 @@ const makeClient = (config: ClientConfig, httpClient: HttpClient.HttpClient): In
234
388
  export const layer = (config: ClientConfig): Layer.Layer<InngestClient, never, HttpClient.HttpClient> =>
235
389
  Layer.effect(
236
390
  InngestClient,
237
- Effect.map(HttpClient.HttpClient, (httpClient) => makeClient(config, httpClient)),
391
+ Effect.gen(function* () {
392
+ const httpClient = yield* HttpClient.HttpClient;
393
+ return yield* makeClient(config, httpClient);
394
+ }),
395
+ );
396
+
397
+ // Test-only factory attached via Symbol.for; not a named export, not in
398
+ // TypeScript autocomplete, not in the public `InngestClient.*` namespace.
399
+ // Tests retrieve it by re-deriving the same global symbol. Do not use from
400
+ // application code.
401
+ const TEST_FACTORY_KEY = Symbol.for("effect-inngest/internal/test-retry-schedule-factory");
402
+ (InngestClient as unknown as Record<symbol, unknown>)[TEST_FACTORY_KEY] = (
403
+ config: ClientConfig,
404
+ retrySchedule: Schedule.Schedule<unknown, CheckpointApiError>,
405
+ ): Layer.Layer<InngestClient, never, HttpClient.HttpClient> =>
406
+ Layer.effect(
407
+ InngestClient,
408
+ Effect.gen(function* () {
409
+ const httpClient = yield* HttpClient.HttpClient;
410
+ return yield* makeClient(config, httpClient, retrySchedule);
411
+ }),
238
412
  );
239
413
 
240
414
  /**
@@ -244,13 +418,15 @@ export const layer = (config: ClientConfig): Layer.Layer<InngestClient, never, H
244
418
  * @category layers
245
419
  */
246
420
  export const layerConfig = (
247
- config: Config.Config.Wrap<ClientConfig>,
248
- ): Layer.Layer<InngestClient, ConfigError.ConfigError, HttpClient.HttpClient> =>
421
+ config: Config.Wrap<ClientConfig>,
422
+ ): Layer.Layer<InngestClient, Config.ConfigError, HttpClient.HttpClient> =>
249
423
  Layer.effect(
250
424
  InngestClient,
251
- Effect.flatMap(Config.unwrap(config), (resolvedConfig) =>
252
- Effect.map(HttpClient.HttpClient, (httpClient) => makeClient(resolvedConfig, httpClient)),
253
- ),
425
+ Effect.gen(function* () {
426
+ const resolvedConfig = yield* Config.unwrap(config);
427
+ const httpClient = yield* HttpClient.HttpClient;
428
+ return yield* makeClient(resolvedConfig, httpClient);
429
+ }),
254
430
  );
255
431
 
256
432
  /**
@@ -266,7 +442,7 @@ export const layerConfig = (
266
442
  * @since 0.1.0
267
443
  * @category layers
268
444
  */
269
- export const layerFromEnv: Layer.Layer<InngestClient, ConfigError.ConfigError, HttpClient.HttpClient> = layerConfig(
445
+ export const layerFromEnv: Layer.Layer<InngestClient, Config.ConfigError, HttpClient.HttpClient> = layerConfig(
270
446
  Config.all({
271
447
  id: Config.string("INNGEST_APP_ID").pipe(Config.withDefault("app")),
272
448
  eventKey: Config.string("INNGEST_EVENT_KEY").pipe(Config.option, Config.map(Option.getOrUndefined)),
package/src/Events.ts CHANGED
@@ -25,7 +25,7 @@ export class FunctionFailed extends Schema.TaggedClass<FunctionFailed>()("innges
25
25
  function_id: Schema.String,
26
26
  run_id: Schema.String,
27
27
  error: JsonError,
28
- event: Schema.Record({ key: Schema.String, value: Schema.Unknown }),
28
+ event: Schema.Record(Schema.String, Schema.Unknown),
29
29
  }) {}
30
30
 
31
31
  /**
@@ -57,7 +57,7 @@ export class FunctionFinishedSuccess extends Schema.TaggedClass<FunctionFinished
57
57
  * Union of both FunctionFinished variants.
58
58
  * @since 0.1.0
59
59
  */
60
- export const FunctionFinished = Schema.Union(FunctionFinishedError, FunctionFinishedSuccess);
60
+ export const FunctionFinished = Schema.Union([FunctionFinishedError, FunctionFinishedSuccess]);
61
61
  export type FunctionFinished = typeof FunctionFinished.Type;
62
62
 
63
63
  /**
@@ -75,7 +75,7 @@ export class FunctionCancelled extends Schema.TaggedClass<FunctionCancelled>()("
75
75
  * @since 0.1.0
76
76
  */
77
77
  export class FunctionInvoked extends Schema.TaggedClass<FunctionInvoked>()("inngest/function.invoked", {
78
- data: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Unknown })),
78
+ data: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
79
79
  }) {}
80
80
 
81
81
  /**