Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

INN-998 Add local Zod schemas when instantiating clients #66

Closed
wants to merge 21 commits into from

Conversation

jpwilliams
Copy link
Member

@jpwilliams jpwilliams commented Jan 6, 2023

Summary - INN-998

Same-app types should be easily specified within the codebase, without having to wait for data to flow to production and then generating types.

Also, nobody likes code generation, so if we can avoid requiring that, then everyone's happier.

This PR introduces the ability to utilise colinhacks/zod to specify typing.

const inngest = new Inngest({
  name: "My Client",
  schemas: {
    "app/user.created": {
      data: z.object({
        id: z.number(),
      }),
    },
  },
});

If you want to specify types using just TS types instead, a function is provided to let you do that.

const inngest= new Inngest({
  name: "My Client",
  schemas: schemaFromTypes<{
    "app/user.created": {
      data: {
        id: number;
      };
    };
  }>(),
});

I'd love to provide this as a top-level generic, so we can do new Inngest<TYPES>() as we can now, but this locks us in a nasty place. We use a lot of inference across the client, but TS still doesn't support inferring only some generics - see microsoft/TypeScript#26242; if we enabled this, then we'd no longer be able to infer functionality or tooling from options choices smartly in the future.

An alternative we could explore could be to create a Schemas class, where the API would allow us to build and merge our types from varying sources, e.g.:

const inngest = new Inngest({
  name: "My Client",
  schemas: new Schemas()
    .fromGenerated<GeneratedEvents>()
    .fromTypes<{
      "app/user.created": {
        data: { id: number };
      };
    }>()
    .fromZod({
      "app/user.deleted": {
        data: z.object({ id: z.number() }),
      },
    }),
});

This feels a touch messier to get started but provides a lot more power in the long run, as we could merge generated, TS, and Zod typing as one, choosing overwriting order. In addition, discoverability is great here, allowing users to see the fromZod() function during creation to understand they can use Zod types without having to read comments or docs.

Related

@jpwilliams jpwilliams self-assigned this Jan 6, 2023
@jpwilliams jpwilliams changed the base branch from main to async-await-parallel-steps January 14, 2023 18:54
@djfarrelly
Copy link
Member

Hey @jpwilliams - excited about first class Zod support. Here are my thoughts on this topic and the potential UX:

  • What's the benefit of using chainable methods w/ new Schemas constructor? Which is a better UX over passing them as options/args to the Schemas constructor? Or alternatively, make schemas an array where you can pass any number of schemas that we combine:
const MyZodEvent = z.object({
  name: z.literal("my.zod.event"),
  data: z.object({
    z.string(),
  })
});

type APIEvents = API_ItemCreated | API_ItemDeleted;

const new Inngest({
  schemas: [ 
    MyZodEvent
    new Schema<EventA | EventB>(), 
    new Schema<APIEvents>(), 
    new Schema<GeneratedEvents>()
    new Schema()
  ]
})
  • I'm not sure the simplest UX here, added a Schemas or Schema constructor feels like another concept that we're creating, although it could give us flexibility to accept things like json schema in the future as well.
  • I'm wondering about cases where some folks may separate schemas or types into objects or unions in different files and use the Inngest client to combine different groups of events. Mostly thinking about flexibility here.
  • If we use a Schema constructor - is there any other future potential benefit to that object? Could we use that object to help with data governance/pushing types to Inngest Cloud?
  • If we lean into schemas as an array/object, could we create a @inngest/schemas package that has all of our integrations types as Zod types? e.g.
import { stripeSchemas } from "@inngest/schemas";
const new Inngest({ schemas: [ new Schemas<MyCustomEventA>(), stripeSchemas ] });
  • For TypeScript types (probably our default recommended), I think it's a nicer UX to pass all types as a union instead of a type with keys that are duplicates of the event type's name. e.g. Constructor<{ name: "event.b", data: { ok: boolean } } | { name: "event.b", data: { id: string } }>

I'm not set on the best UX, but thinking through some of the above might allow us to think about something that gives us future flexibility.

@jpwilliams
Copy link
Member Author

What's the benefit of using chainable methods w/ new Schemas constructor? Which is a better UX over passing them as options/args to the Schemas constructor? Or alternatively, make schemas an array where you can pass any number of schemas that we combine

@djfarrelly The upside of a class with chainable methods is that it's easier to both discover and support new methods of defining schemas.

Suppose the Generic in new Schema<Generic>() can be a wide array of formats (Zod, keyed events, non-keyed events (in a union), JSON Schema, etc.). In that case, it's much harder for the user to understand how they can define their schema, and much harder to validate on our side. Type errors (e.g. for defining an event with no name) are naturally more verbose as they'll explain how the given type doesn't fit all of the cases we support.

With the chainable methods, every method is discoverable via autocomplete, is responsible only for its own simpler roll-up of the given type to an expected standard type, and errors will be much easier to read as they are solely focused on a single format.


I'm not sure the simplest UX here, added a Schemas or Schema constructor feels like another concept that we're creating, although it could give us flexibility to accept things like json schema in the future as well.

@djfarrelly 💯 Like you, I'm not a fan of introducing more concepts where it feels like a user could just define an object, though I think the benefits are worth it. Already with just supporting Zod, TS types, and generated schemas, the schemas field has to accept way too many permutations and it's unclear for users what they can provide without reading docs.

Hopefully with this, we make it clear that users can define a schemas with a new Schemas() instance and then each chained method can have prettier comments around how to use each format correctly.


I'm wondering about cases where some folks may separate schemas or types into objects or unions in different files and use the Inngest client to combine different groups of events. Mostly thinking about flexibility here.

@djfarrelly Agreed - it's likely some users will want their schemas held away from the client to not muddy that file. Chainable methods will support this pattern well, allowing you to build up your schemas from multiple imported sources.


If we use a Schema constructor - is there any other future potential benefit to that object? Could we use that object to help with data governance/pushing types to Inngest Cloud?

@djfarrelly Yes! One common improvement I can see is being able to set schema-specific options up front. For example, some users might like the ability to build an untyped function before specifying their event. For this, we could provide something like .allowUntyped() so that Inngest will do its best to infer data from event names given, but it can also just be a string and type the event as any.

const schemas = new Schemas()
  .fromTypes<{
    "app/user.created": {
      data: { id: number };
    };
  }>()
  .allowUntyped();

Could also be an option passed to new Schemas(opts) or whatever we see fit. Generally though, yes for sure. Zod schemas in general also allow us to enforce data shapes on the client in the future which we could - as you say - push from these objects.


If we lean into schemas as an array/object, could we create a @inngest/schemas package that has all of our integrations types as Zod types? e.g.

import { stripeSchemas } from "@inngest/schemas";
const new Inngest({ schemas: [ new Schemas<MyCustomEventA>(), stripeSchemas ] });

@djfarrelly Absolutely! This would be a great way to grab many known types without forcing them on new users.

We could even bundle this into the SDK itself, so there's not a separate package; seeing as they're just types they'll never be shipped so resulting package size isn't an issue, though general package download size for the developer might bloat a bit.


For TypeScript types (probably our default recommended), I think it's a nicer UX to pass all types as a union instead of a type with keys that are duplicates of the event type's name. e.g. Constructor<{ name: "event.b", data: { ok: boolean } } | { name: "event.b", data: { id: string } }>

@djfarrelly Yep, we can support this pattern! My only reservation is that you can accidentally provide events with the same name which might change some of the internal typing.

When a user does this and then declares inngest.createFunction("event.b", ...), should the library provide a union of the data as the possible event? e.g. in the case of your example above, event.data for me would have the type:

{ ok: boolean } | { id: string }

I guess in these cases we'd also want to enforce a version that the user could then use to ensure TypeScript knows which data type is appropriate, e.g. they could do:

if (event.v === "2") {
  // data is typed correctly for this particular event
} else {
  // only one other option, so typed correctly again
}

@DaleLJefferson
Copy link

DaleLJefferson commented Jan 18, 2023

This would be really good, the automatic schema generation makes a good demo but I really want things static and defined in code.

I'm currently using zod for this which works today.

const data = z.object({
    a: z.string()
})

type Events = {
    "event1": {
        name: "event1";
        data:  z.infer<typeof data>;
    };
};

Then I parse the event into Zod in the function, having this built in sounds great.

PS having to write out the event name twice is frustrating.

@jpwilliams jpwilliams force-pushed the async-await-parallel-steps branch 4 times, most recently from e242f29 to eff8f4e Compare January 27, 2023 20:59
@jpwilliams
Copy link
Member Author

jpwilliams commented Feb 2, 2023

In addition to the above requirements, supporting wildcards is a big win for many systems, e.g. listening to pipeline/*, pipeline/sync.* or pipeline/*.errored.

Given the stacking set of schemas using new Schemas(), we might be able to create smart union types from the input events, splitting on our recommended separators of / and ..

For example, the following stacked schema:

const inngest = new Inngest({
  name: "My Client",
  schemas: new Schemas()
    .fromTypes<{
      "app/user.created": {
        data: { id: number };
      };
    }>()
    .fromZod({
      "app/user.deleted": {
        data: z.object({ id: z.number() }),
      },
      "app/post.created": {
        data: z.object({ id: z.number() }),
      },
    }),
});

Would produce the following types for listening:

type Events = {
  "app/user.created": AppUserCreated;
  "app/user.deleted": AppUserDeleted;
  "app/post.created": AppPostCreated;
  "app/*": AppUserCreated | AppUserDeleted | AppPostCreated;
  "app/user.*": AppUserCreated | AppUserDeleted;
  "app/post.*": AppPostCreated;
  "app/*.created": AppUserCreated | AppPostCreated;
  "app/*.deleted": AppUserDeleted;
  "*": AppUserCreated | AppUserDeleted | AppPostCreated;
  "*/user.*": AppUserCreated | AppUserDeleted;
  "*/post.*": AppPostCreated;
  "*/*.created": AppUserCreated | AppPostCreated;
  "*/*.deleted": AppUserDeleted;
};

We might need a ** for multiple items, too. For example, if I have app/foo.errored and app/foo.bar.errored, I might want to listen for app/**.foo which would capture both, whereas app/*.errored would only catch the former. 🤔


A separate feature is also planned to be able to specify multiple triggers which would produce a similar union. Post V1, the trigger section will be string | TriggerOptions, where TriggerOptions would be { event: string; } | { cron: string; }.

To specify multiple events, we'd just be passing an array instead.

inngest.createFunction(
  "My Function",
  ["app/user.created", { event: "app/user.updated" }, { cron: "0 9 * * MON" }]
  async ({ event }) => {
    // typeof event is (AppUserCreated | AppUserUpdated | null)
  }
);

Base automatically changed from async-await-parallel-steps to main February 9, 2023 16:17
jpwilliams added a commit that referenced this pull request Feb 9, 2023
…ean examples (#45)

## Breaking changes

### Function creation helpers removed

The following function creation methods have been removed and should be
replaced by using `Inngest#createFunction`:
- `createFunction`
- `createScheduledFunction`
- `createStepFunction`
- `Inngest#createScheduledFunction`
- `Inngest#createStepFunction`

```ts
import { createFunction } from "inngest";
createFunction("Example", "app/user.created", () => {});
// becomes
import { Inngest } from "inngest";
const inngest = new Inngest({ name: "App" });
inngest.createFunction("Example", "app/user.created", () => {});
```

```ts
import { createScheduledFunction } from "inngest";
createScheduledFunction("Example", "* * * * *", () => {}); // or inngest.createScheduledFunction
// becomes
import { Inngest } from "inngest";
const inngest = new Inngest({ name: "App" });
inngest.createFunction("Example", { cron: "* * * * *" }, () => {});
```

```ts
import { createStepFunction } from "inngest";
createStepFunction("Example", "* * * * *", () => {}); // or inngest.createStepFunction
// becomes
import { Inngest } from "inngest";
const inngest = new Inngest({ name: "App" });
inngest.createFunction("Example", { cron: "* * * * *" }, async ({ tools }) => {
  // Use a tool to create a step function
});
```

### Step functions are now asynchronous

In order to provide the full power of asynchronous JavaScript, step
functions are now required to be async functions, and all step tooling
will return promises instead of synchronously.

If you're using TypeScript, you'll be guided through the changes at each
stage. For example, trying to access `.id` of the new `Promise<User>`
will throw an error telling you that it must first be awaited.

```ts
import { createStepFunction } from "inngest";
import { userDb } from "./db";
import { email } from "./email";

export default createStepFunction(
  "Example",
  "app/user.created",
  ({ event, tools }) => {
    const user = tools.run("Get user email", () => userDb.get(event.userId));

    // We run synchronously, so wait for the email to be send before the alert is sent
    tools.run("Send email", () => email.sendEmail(user.email, "Welcome!"));
    tools.run("Send alert to staff", () =>
      email.sendAlert("New user created!")
    );
  }
);
```

This would be converted to the following:

```ts
import { inngest } from "./client";
import { userDb } from "./db";
import { email } from "./email";

export default inngest.createFunction( // use client instead of helper
  { name: "Example", fns: { ...userDb, ...email } }, // can pass functions to wrap in tools.run()
  "app/user.created",
  async ({ event, fns: { getUser, sendEmail, sendAlert }}) => {
    const user = await getUser(event.userId); // use fns directly that have been passed

    // We don't await these, so they are run in parallel now
    sendEmail(user.email, "Welcome!");
    sendAlert("New user created!");
  }
);
```

### Custom handlers require a `stepId`

In order to provide the parallel functionality in this PR, all handlers
created using `InngestCommHandler` must provide a `stepId` parameter
when attempting to run a function. This should be accessed via the query
string using the exported `queryKeys.StepId` enum.

```diff
run: async () => {
  if (req.method === "POST") {
    return {
      fnId: url.searchParams.get(queryKeys.FnId) as string,
+     stepId: url.searchParams.get(queryKeys.StepId) as string,
```

## Features

### Pass functions to wrap as retriable steps

When creating a function, you can now pass a `fns` key to automatically
wrap all found functions in `tools.run()` step tooling, automatically
providing your existing functionality with retries and durability.

```ts
import { inngest } from "./client";
import { userDb } from "./db";
import { email } from "./email";

export default inngest.createFunction(
  { name: "Example", fns: { ...userDb, ...email } },
  "app/user.created",
  async ({ event, fns: { getUser, sendEmail}}) => {
    const user = await getUser(event.userId);
    sendEmail(user.email, "Welcome!");
  }
);
```

## Fixes

- `user` key in event payloads can now be any value, to ensure
conflicting generated events are accepted; see #87 for further
discussion

## Related changes/issues based on this PR

- #66 
- #73 
- #74 
- #75 
- #76 
- #69 
- #43 

## SDK changes

- [x] Refactor step function tooling to be `async`
- [x] Unify `createFunction`, `createScheduledFunction`, and
`createStepFunction` under a single `createFunction` method
- [x] ~Preserve complex client-less `createFunction` helper~
  > Prefer instantiation with a client for now.
- [x] Add error-handling capabilities by giving the SDK the ability to
send back a serialised `error` as well as `data`
- [x] Allow use of regular JS tooling (`.then()`, `try/catch`, etc) in
step functions without gotchas
- [x] ~Create a new `StepOpCode` for a no-op other than `None`~
We just return an empty array.
- [x] Add ability to pass steps in as "just functions" to avoid wrapping
all user code in `tools.run()`
- [x] ~Add ability to pass steps in as "just functions" to a `new
Inngest()` client, which is merged with any steps passed directly to
`inngest.createFunction()`~
> Defer this to be implemented with #66, as this will alter generics for
`Inngest` and `InngestFunction` to aid with this change.
- [x] Hash synchronous groups of steps and throw errors if synchronous
step order doesn't match
- [x] Allow triggering steps using `stepId` query param instead of op
position codes
- [x] Add new `StepPlanned` (or similar) op code for back compat
- [ ] Create `step` alias for `tools` and deprecate `tools`
- [ ] Add a large warning when the a step function resolves, but some
tooling is still pending; we want to highlight for users that to be safe
they should make sure to await in serverless environments

## Examples

<details>
<summary>See examples</summary>

```ts
// Any type of function uses the same method.
// declare createFunction: (nameOrFunctionOpts, eventNameOrTriggerOpts, fn) => void;
inngest.createFunction("...", "demo/event.sent", () => {});
inngest.createFunction("...", { event: "demo/event.sent" }, () => {});
inngest.createFunction("...", { cron: "* * * * *" }, () => {});
```

```ts
// A step function uses the same method; just use a tool.
inngest.createFunction("...", "demo/event.sent", ({ tools: { run } }) => {
  run("Step", () => {});
});
```

```ts
// Step functions now use async functions - easier to map complex flows.
inngest.createFunction("...", "demo/event.sent", async ({ tools: { run } }) => {
  const randomNumber = await run("Get random number", () => Math.random());
  await run("Send random number", () => sendEmail("...", randomNumber));
});
```

```ts
// With async support, fire-and-forget parallel steps are easy to create - just
// trigger them all and SDK will let Inngest know of all of the pending actions.
inngest.createFunction("...", "demo/event.sent", async ({ tools: { run } }) => {
  run("Send email", () => sendEmail("..."));
  run("Send another email", () => sendEmail("..."));
  run("Send yet another email", () => sendEmail("..."));
});

// This means that the response from the SDK to Inngest is always an array of
// upcoming steps.
//
// In this case, the first call would return all three actions, as we're not
// awaiting them; the SDK gets a single synchronous tick of the event loop to
// decide what is next.
[
  { op: "Step", name: "Send email", run: true },
  { op: "Step", name: "Send another email", run: true },
  { op: "Step", name: "Send yet another email", run: true },
];
```

```ts
// Step functions can handle errors too, meaning we can use usual tools like
// try/catch.
inngest.createFunction("...", "demo/event.sent", async ({ tools: { run } }) => {
  try {
    await run("Step", () => {});
  } catch (err) {
    await run("Send error to Tim", () => sendEmail("...", err));
  }
});

// Or perhaps we want to silently handle an error case and not have it affect
// other steps.
inngest.createFunction("...", "demo/event.sent", async () => {
  await run("Send email", () => sendEmail("...")).catch(() => {
    run("Send error to Tim", () => sendEmail("...", err));
  });
});
```

```ts
// Tools such as `Promise.all` are fine too.
inngest.createFunction("...", "demo/event.sent", async ({ tools: { run } }) => {
  await Promise.all([
    run("Send email", () => sendEmail("...")),
    run("Send another email", () => sendEmail("...")),
  ]);
});
```

```ts
// We can even create parallel chains of steps that trigger immediately, follow
// their own path, then eventually come back together.
inngest.createFunction("...", "demo/event.sent", async ({ tools: { run } }) => {
  const fooStep = run("Foo", () => fooSomething())
    .then((data) => run("Foo again", () => fooSomethingElse(data)))
    .then((data) => run("Foo again again", () => fooSomethingElse(data)));

  const barStep = run("Bar", () => barSomething())
    .then((data) => run("Bar again", () => barSomethingElse(data)))
    .then((data) => run("Bar again again", () => barSomethingElse(data)));

  const [foo, bar] = await Promise.all([fooStep, barStep]);

  await run("Send email", () => sendEmail("...", { foo, bar }));
});
```

```ts
// Wrapping all user code in `run()` can be a bit verbose. One of the initial
// goals of the SDK was that steps were "just functions". If we pass those to
// our Inngest function then we can shim them with `run()` automatically, which
// makes the code look _super_ clean.
//
// The TS types and runtime JS will non-destructively filter the input and add
// shims, meaning you could pass entire structures/classes in with no bother,
// like we do here with `userDb` and `email` which may export more than just
// functions. We even retain the function's comments!
import * as userDb from "./dbs/user";
import * as email from "./email";

inngest.createFunction(
  { name: "...", fns: { ...email, ...userDb } },
  "demo/event.sent",
  async ({ event, fns: { sendEmail, getUserById } }) => {
    const user = await getUserById(event.user.id);
    await sendEmail(user.email, "...");
  }
);

// This can really help clean up functions with lots of steps, like our example
// of parallel paths above.
inngest.createFunction(
  { name: "...", fns: { ...fooLib, ...barLib, ...email } },
  "demo/event.sent",
  async ({ tools: { run } }) => {
    const fooStep = fooSomething()
      .then(fooSomethingElse)
      .then(fooSomethingElse);

    const barStep = barSomething()
      .then(barSomethingElse)
      .then(barSomethingElse);

    const [foo, bar] = await Promise.all([fooStep, barStep]);
    await sendEmail("...", { foo, bar });
  }
);
```

```ts
// Sometimes, a user may want to bundle async actions together in a single step,
// for example when something must be fetched from the DB right before sending
// an email.
//
// If this is unique to this Inngest function, we can always use `tools.run()`
// to run in-line code. If we wanted to create a reusable step, however, we just
// make a regular function.
const sendEmailToUser = async (userId: string, body: string) => {
  const user = await getUserById(userId);
  await sendEmail(user.email, body);
};

inngest.createFunction(
  { name: "...", fns: { sendEmailToUser, something } },
  "demo/event.sent",
  async ({ event }) => {
    await something();
    await sendEmailToUser(event.user.id, "...");
  }
);
```

```ts
// If we were to create a library of reusable steps, a _future_ option that is
// not in this PR could be to pass steps in to the Inngest constructor to
// provide the tooling for every function automatically.
const inngest = new Inngest<Events>({
  name: "...",
  fns: { ...email, ...userDb, ...postDb },
});

inngest.createFunction(
  "...",
  "demo/event.sent",
  async ({ event, fns: { getUserById, getPostsByTag, sendEmail } }) => {
    const user = await getUserById(event.user.id);
    const posts = await getPostsByTag(user.favouriteTag);
    await sendEmail(user.email, posts);
  }
);
```
</details>
@jpwilliams jpwilliams changed the base branch from main to feat/edge-handler February 15, 2023 20:26
@jpwilliams jpwilliams changed the base branch from feat/edge-handler to main February 15, 2023 20:26
@jpwilliams
Copy link
Member Author

Will also consider ArkType for a schema candidate. See arktypeio/arktype#687.

@jpwilliams jpwilliams changed the title Add local Zod schemas when instantiating clients INN-998 Add local Zod schemas when instantiating clients Mar 22, 2023
@changeset-bot
Copy link

changeset-bot bot commented Mar 24, 2023

⚠️ No Changeset found

Latest commit: 10e76f9

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@jpwilliams jpwilliams marked this pull request as ready for review March 24, 2023 12:49
Copy link
Contributor

@tonyhb tonyhb left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good! Excited about this change — it opens the door for a lot!

@mmachatschek
Copy link
Contributor

mmachatschek commented May 12, 2023

@tonyhb @jpwilliams whats the best way to test this out? do you plan to release rc/alpha versions of the sdk so we can get our hands dirty testing this out?

@jpwilliams
Copy link
Member Author

@mmachatschek: whats the best way to test this out? do you plan to release rc/alpha versions of the sdk so we can get our hands dirty testing this out?

This PR is currently available to test out by installing inngest@schemas (e.g. npm install inngest@schemas).

It's a breaking change, so working on bundling together a few breaking changes for 2.0 before shipping. :)

@mmachatschek
Copy link
Contributor

@jpwilliams thanks for the bump. this comes right when I needed it 👍 will keep you posted if anything comes up while testing it out

@mmachatschek
Copy link
Contributor

@jpwilliams this is working great so far 👍 no issues. currently using the fromUnion method.

@jpwilliams
Copy link
Member Author

@mmachatschek: this is working great so far 👍 no issues. currently using the fromUnion method.

Awesome to hear! 🙂 Thanks for reporting back.

Any preference in regard to fromUnion vs fromRecord? We've been wondering what to push as the healthiest default in docs. fromRecord feels good for for first couple of events, but feels like it becomes a bit unruly once you start defining more.

@mmachatschek
Copy link
Contributor

Any preference in regard to fromUnion vs fromRecord? We've been wondering what to push as the healthiest default in docs. fromRecord feels good for for first couple of events, but feels like it becomes a bit unruly once you start defining more.

@jpwilliams I think this is just preference. for easy examples in the docs I'd says that the union types are more expressive as you don't need to repeat function names everywhere. I don't know (yet) if I'll use the other options (fromRecord) etc. in the future. A dedicated example page for each of the available options would be great.

@jpwilliams jpwilliams mentioned this pull request May 26, 2023
@jpwilliams
Copy link
Member Author

Closing this PR as it's shifting to #203. Will include release notes for this feature there.

@jpwilliams jpwilliams closed this May 26, 2023
jpwilliams added a commit that referenced this pull request Jun 6, 2023
## Summary

SDK 2.0 release branch. This will pad out with release notes!

## Related

- #66 
- #199

---------

Co-authored-by: Darwin <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Development

Successfully merging this pull request may close these issues.

Simplification of defining event types
5 participants