better-ship 0.15.0 → 0.16.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 (57) hide show
  1. package/dist/{application-C4JOf71r.js → application-Cf9SER-i.js} +2 -2
  2. package/dist/{application-C4JOf71r.js.map → application-Cf9SER-i.js.map} +1 -1
  3. package/dist/application.d.ts +2 -2
  4. package/dist/application.js +1 -1
  5. package/dist/better-auth/apple-client-secret.d.ts +1 -1
  6. package/dist/better-auth/apple-client-secret.js +1 -1
  7. package/dist/better-auth/auth-client.js +1 -1
  8. package/dist/better-auth/rate-limit.d.ts +1 -1
  9. package/dist/better-auth/rate-limit.js +1 -1
  10. package/dist/better-auth.js +1 -1
  11. package/dist/cloudflare/auth-secondary-storage.d.ts +1 -1
  12. package/dist/cloudflare/auth-secondary-storage.js +1 -1
  13. package/dist/cloudflare/durable-object-client.js +1 -1
  14. package/dist/cloudflare/durable-object-errors.d.ts +1 -1
  15. package/dist/cloudflare/durable-object-errors.js +1 -1
  16. package/dist/cloudflare/queue.d.ts +2 -2
  17. package/dist/cloudflare/queue.js +2 -2
  18. package/dist/cloudflare/scheduled.d.ts +1 -1
  19. package/dist/cloudflare/scheduled.js +1 -1
  20. package/dist/cloudflare.d.ts +3 -3
  21. package/dist/cloudflare.js +3 -3
  22. package/dist/{core-hdaNiCOU.js → core-ClSP5-V9.js} +94 -44
  23. package/dist/core-ClSP5-V9.js.map +1 -0
  24. package/dist/core.d.ts +2 -2
  25. package/dist/core.js +2 -2
  26. package/dist/http.d.ts +2 -2
  27. package/dist/http.js +2 -2
  28. package/dist/{index-B45Dr5gs.d.ts → index-Cchdg2aQ.d.ts} +2 -2
  29. package/dist/{index-B45Dr5gs.d.ts.map → index-Cchdg2aQ.d.ts.map} +1 -1
  30. package/dist/{index-G1QG0P5g.d.ts → index-DvJUcSW1.d.ts} +102 -48
  31. package/dist/{index-G1QG0P5g.d.ts.map → index-DvJUcSW1.d.ts.map} +1 -1
  32. package/dist/{outbox-repository-options-CwTTuy7B.d.ts → outbox-repository-options-AkihEaik.d.ts} +3 -3
  33. package/dist/{outbox-repository-options-CwTTuy7B.d.ts.map → outbox-repository-options-AkihEaik.d.ts.map} +1 -1
  34. package/dist/{outbox-repository-options-DOTpJH-e.js → outbox-repository-options-ICOF8g2h.js} +2 -2
  35. package/dist/{outbox-repository-options-DOTpJH-e.js.map → outbox-repository-options-ICOF8g2h.js.map} +1 -1
  36. package/dist/postgres.d.ts +3 -3
  37. package/dist/postgres.js +3 -3
  38. package/dist/posthog/metrics.d.ts +1 -1
  39. package/dist/posthog/server.d.ts +2 -2
  40. package/dist/posthog/server.js +1 -1
  41. package/dist/resend.d.ts +3 -3
  42. package/dist/resend.js +2 -2
  43. package/dist/sentry/client.d.ts +1 -1
  44. package/dist/sentry/client.js +1 -1
  45. package/dist/sentry/server.d.ts +2 -2
  46. package/dist/tanstack.d.ts +1 -1
  47. package/dist/tanstack.js +1 -1
  48. package/dist/testing/outbox-repository.d.ts +2 -2
  49. package/dist/{webhook-verification-D7DIhH6C.d.ts → webhook-verification-BcQQG-vY.d.ts} +2 -2
  50. package/dist/{webhook-verification-D7DIhH6C.d.ts.map → webhook-verification-BcQQG-vY.d.ts.map} +1 -1
  51. package/dist/{webhook-verification-trEgyz7I.js → webhook-verification-D6aGQfNi.js} +2 -2
  52. package/dist/{webhook-verification-trEgyz7I.js.map → webhook-verification-D6aGQfNi.js.map} +1 -1
  53. package/package.json +1 -1
  54. package/src/core/define-messages.ts +209 -103
  55. package/src/core/index.ts +3 -1
  56. package/src/core/messages.ts +1 -1
  57. package/dist/core-hdaNiCOU.js.map +0 -1
@@ -1,6 +1,6 @@
1
- import { G as DomainEvent, J as MessageId, nt as DatabaseError, rt as DatabaseFailure } from "./index-G1QG0P5g.js";
2
- import { A as IOutboxRepository, M as OutboxClaim, N as OutboxMessage, P as IMessageStore, k as DeadLetterReason, w as ITransaction } from "./index-B45Dr5gs.js";
3
- import { n as eventCollector, r as DrizzleSchema, t as OutboxRepositoryOptions } from "./outbox-repository-options-CwTTuy7B.js";
1
+ import { X as MessageId, at as DatabaseFailure, it as DatabaseError, q as DomainEvent } from "./index-DvJUcSW1.js";
2
+ import { A as IOutboxRepository, M as OutboxClaim, N as OutboxMessage, P as IMessageStore, k as DeadLetterReason, w as ITransaction } from "./index-Cchdg2aQ.js";
3
+ import { n as eventCollector, r as DrizzleSchema, t as OutboxRepositoryOptions } from "./outbox-repository-options-AkihEaik.js";
4
4
  import { ExtractTablesWithRelations } from "drizzle-orm";
5
5
  import { PostgresJsDatabase, PostgresJsTransaction, drizzle } from "drizzle-orm/postgres-js";
6
6
  //#region src/infrastructure/postgres/postgres-database-types.d.ts
package/dist/postgres.js CHANGED
@@ -1,6 +1,6 @@
1
- import { D as DatabaseError, c as exponentialDelayCapMs, tt as AppError } from "./core-hdaNiCOU.js";
2
- import { o as MessageAlreadyProcessedError } from "./application-C4JOf71r.js";
3
- import { i as runWithUnitOfWorkContext, r as eventCollector, t as OUTBOX_REPOSITORY_DEFAULTS } from "./outbox-repository-options-DOTpJH-e.js";
1
+ import { c as exponentialDelayCapMs, k as DatabaseError, rt as AppError } from "./core-ClSP5-V9.js";
2
+ import { o as MessageAlreadyProcessedError } from "./application-Cf9SER-i.js";
3
+ import { i as runWithUnitOfWorkContext, r as eventCollector, t as OUTBOX_REPOSITORY_DEFAULTS } from "./outbox-repository-options-ICOF8g2h.js";
4
4
  import { DrizzleQueryError } from "drizzle-orm/errors";
5
5
  import { and, asc, eq, inArray, isNull, lt, lte, or, sql } from "drizzle-orm";
6
6
  import { index, integer, jsonb, pgTable, text, timestamp } from "drizzle-orm/pg-core";
@@ -1,4 +1,4 @@
1
- import { J as MetricsService, q as MetricOptions } from "../index-B45Dr5gs.js";
1
+ import { J as MetricsService, q as MetricOptions } from "../index-Cchdg2aQ.js";
2
2
  import { PostHog } from "posthog-node";
3
3
  //#region src/infrastructure/posthog/metrics.d.ts
4
4
  /** PostHog Metrics with Worker lifetime management, ported from Typist's metrics-service.ts. */
@@ -1,5 +1,5 @@
1
- import { Jt as AnalyticsProperties, Kt as AnalyticsEventIdentity, qt as AnalyticsEventInput } from "../index-G1QG0P5g.js";
2
- import { X as AnalyticsService } from "../index-B45Dr5gs.js";
1
+ import { Jt as AnalyticsEventIdentity, Xt as AnalyticsProperties, Yt as AnalyticsEventInput } from "../index-DvJUcSW1.js";
2
+ import { X as AnalyticsService } from "../index-Cchdg2aQ.js";
3
3
  import { PostHogMetricsService } from "./metrics.js";
4
4
  import { PostHog, PostHogOptions } from "posthog-node";
5
5
  //#region src/infrastructure/posthog/server.d.ts
@@ -1,4 +1,4 @@
1
- import { et as MissingConfigurationError } from "../core-hdaNiCOU.js";
1
+ import { nt as MissingConfigurationError } from "../core-ClSP5-V9.js";
2
2
  import { PostHogMetricsService } from "./metrics.js";
3
3
  import { PostHog } from "posthog-node";
4
4
 
package/dist/resend.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { Mt as BounceType } from "./index-G1QG0P5g.js";
2
- import { B as OutboundEmail, I as EmailAccepted, R as IEmailService } from "./index-B45Dr5gs.js";
3
- import { r as WebhookVerifier, t as WebhookVerification } from "./webhook-verification-D7DIhH6C.js";
1
+ import { Pt as BounceType } from "./index-DvJUcSW1.js";
2
+ import { B as OutboundEmail, I as EmailAccepted, R as IEmailService } from "./index-Cchdg2aQ.js";
3
+ import { r as WebhookVerifier, t as WebhookVerification } from "./webhook-verification-BcQQG-vY.js";
4
4
  import { Resend } from "resend";
5
5
  //#region src/infrastructure/resend/resend-email-service.d.ts
6
6
  /** The SDK operations required by the adapter; a test factory can return a plain object. */
package/dist/resend.js CHANGED
@@ -1,5 +1,5 @@
1
- import { Q as EmailAddressSchema, Y as EmailServiceError, q as parseBounceType } from "./core-hdaNiCOU.js";
2
- import { t as WebhookVerificationError } from "./webhook-verification-trEgyz7I.js";
1
+ import { Y as parseBounceType, Z as EmailServiceError, et as EmailAddressSchema } from "./core-ClSP5-V9.js";
2
+ import { t as WebhookVerificationError } from "./webhook-verification-D6aGQfNi.js";
3
3
  import { z } from "zod";
4
4
  import { Buffer } from "node:buffer";
5
5
 
@@ -1,4 +1,4 @@
1
- import { f as LoggerErrorHook } from "../index-G1QG0P5g.js";
1
+ import { f as LoggerErrorHook } from "../index-DvJUcSW1.js";
2
2
  import * as Sentry from "@sentry/tanstackstart-react";
3
3
  //#region src/infrastructure/sentry/client.d.ts
4
4
  /** Application settings use Sentry's names; enablement and environment belong to the application. */
@@ -1,4 +1,4 @@
1
- import { s as setLoggerErrorHook } from "../core-hdaNiCOU.js";
1
+ import { s as setLoggerErrorHook } from "../core-ClSP5-V9.js";
2
2
  import * as Sentry from "@sentry/tanstackstart-react";
3
3
  import { isNotFound, isRedirect } from "@tanstack/react-router";
4
4
 
@@ -1,5 +1,5 @@
1
- import { f as LoggerErrorHook } from "../index-G1QG0P5g.js";
2
- import { H as ObservabilityScope, U as ObservabilityService, W as ObservabilityUser } from "../index-B45Dr5gs.js";
1
+ import { f as LoggerErrorHook } from "../index-DvJUcSW1.js";
2
+ import { H as ObservabilityScope, U as ObservabilityService, W as ObservabilityUser } from "../index-Cchdg2aQ.js";
3
3
  import * as Sentry from "@sentry/cloudflare";
4
4
  //#region src/infrastructure/sentry/server.d.ts
5
5
  /** Application-owned deployment settings with the full Cloudflare SDK option contract. */
@@ -1,4 +1,4 @@
1
- import { st as ErrorTable } from "./index-G1QG0P5g.js";
1
+ import { lt as ErrorTable } from "./index-DvJUcSW1.js";
2
2
  import { Register } from "@tanstack/react-start";
3
3
  import { BetterAuthCookies } from "better-auth";
4
4
  //#region src/infrastructure/tanstack/request-context.d.ts
package/dist/tanstack.js CHANGED
@@ -1,4 +1,4 @@
1
- import { L as ValidationError, U as AuthenticationError, V as StaleSessionError, a as createLogger, tt as AppError } from "./core-hdaNiCOU.js";
1
+ import { G as AuthenticationError, U as StaleSessionError, a as createLogger, rt as AppError, z as ValidationError } from "./core-ClSP5-V9.js";
2
2
  import { z } from "zod";
3
3
  import { getReasonPhrase } from "http-status-codes";
4
4
  import { isNotFound, isRedirect, redirect } from "@tanstack/react-router";
@@ -1,5 +1,5 @@
1
- import { G as DomainEvent } from "../index-G1QG0P5g.js";
2
- import { A as IOutboxRepository } from "../index-B45Dr5gs.js";
1
+ import { q as DomainEvent } from "../index-DvJUcSW1.js";
2
+ import { A as IOutboxRepository } from "../index-Cchdg2aQ.js";
3
3
  //#region src/infrastructure/testing/outbox-repository-contract.d.ts
4
4
  /** Stored fields observed by the adapter contract, independent of a database library. */
5
5
  export type OutboxContractRow = {
@@ -1,4 +1,4 @@
1
- import { Zt as AppError } from "./index-G1QG0P5g.js";
1
+ import { $t as AppError } from "./index-DvJUcSW1.js";
2
2
  //#region src/infrastructure/http/webhook-verification.d.ts
3
3
  /** A webhook cannot be trusted or parsed. The HTTP handler logs the rejection once. */
4
4
  declare class WebhookVerificationError extends AppError {
@@ -26,4 +26,4 @@ interface WebhookVerifier<Event> {
26
26
  }
27
27
  //#endregion
28
28
  export { WebhookVerificationError as n, WebhookVerifier as r, WebhookVerification as t };
29
- //# sourceMappingURL=webhook-verification-D7DIhH6C.d.ts.map
29
+ //# sourceMappingURL=webhook-verification-BcQQG-vY.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"webhook-verification-D7DIhH6C.d.ts","names":[],"sources":["../src/infrastructure/http/webhook-verification.ts"],"mappings":";;;cAGa,iCAAiC;WAMjC;WALF;WACA;;EAGT,YACW,qEACT;;;KAOQ,oBAAoB;WACjB;WAA2B,OAAO;;WAClC;WAA6B;;WAC7B;WAA8B,OAAO;;;UAGnC,gBAAgB;;EAE/B,OAAO,cAAc,SAAS,UAAU,QAAQ,oBAAoB"}
1
+ {"version":3,"file":"webhook-verification-BcQQG-vY.d.ts","names":[],"sources":["../src/infrastructure/http/webhook-verification.ts"],"mappings":";;;cAGa,iCAAiC;WAMjC;WALF;WACA;;EAGT,YACW,qEACT;;;KAOQ,oBAAoB;WACjB;WAA2B,OAAO;;WAClC;WAA6B;;WAC7B;WAA8B,OAAO;;;UAGnC,gBAAgB;;EAE/B,OAAO,cAAc,SAAS,UAAU,QAAQ,oBAAoB"}
@@ -1,4 +1,4 @@
1
- import { tt as AppError } from "./core-hdaNiCOU.js";
1
+ import { rt as AppError } from "./core-ClSP5-V9.js";
2
2
 
3
3
  //#region src/infrastructure/http/webhook-verification.ts
4
4
  /** A webhook cannot be trusted or parsed. The HTTP handler logs the rejection once. */
@@ -15,4 +15,4 @@ var WebhookVerificationError = class extends AppError {
15
15
 
16
16
  //#endregion
17
17
  export { WebhookVerificationError as t };
18
- //# sourceMappingURL=webhook-verification-trEgyz7I.js.map
18
+ //# sourceMappingURL=webhook-verification-D6aGQfNi.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"webhook-verification-trEgyz7I.js","names":[],"sources":["../src/infrastructure/http/webhook-verification.ts"],"sourcesContent":["import { AppError } from '@/core'\n\n/** A webhook cannot be trusted or parsed. The HTTP handler logs the rejection once. */\nexport class WebhookVerificationError extends AppError {\n readonly _tag = 'WebhookVerificationError'\n readonly classification = 'terminal'\n\n /** Keep secrets and payload details out of the public message. */\n constructor(\n readonly reason: 'missing_headers' | 'invalid_signature' | 'invalid_payload',\n cause?: unknown,\n ) {\n super(`Webhook rejected: ${reason}`, { cause })\n }\n}\n\n/** A verified event, an ignored event, or an invalid request. */\nexport type WebhookVerification<Event> =\n | { readonly outcome: 'event'; readonly event: Event }\n | { readonly outcome: 'ignored'; readonly type: string }\n | { readonly outcome: 'rejected'; readonly error: WebhookVerificationError }\n\n/** The provider owns signature verification, timestamp tolerance, and payload parsing. */\nexport interface WebhookVerifier<Event> {\n /** Verify the exact body before parsing it. Return invalid requests; throw operational failures. */\n verify(body: string, headers: Headers): Promise<WebhookVerification<Event>>\n}\n"],"mappings":";;;;AAGA,IAAa,2BAAb,cAA8C,SAAS;CAM1C;CALX,AAAS,OAAO;CAChB,AAAS,iBAAiB;;CAG1B,YACE,AAAS,QACT,OACA;EACA,MAAM,qBAAqB,UAAU,EAAE,MAAM,CAAC;EAHrC;CAIX;AACF"}
1
+ {"version":3,"file":"webhook-verification-D6aGQfNi.js","names":[],"sources":["../src/infrastructure/http/webhook-verification.ts"],"sourcesContent":["import { AppError } from '@/core'\n\n/** A webhook cannot be trusted or parsed. The HTTP handler logs the rejection once. */\nexport class WebhookVerificationError extends AppError {\n readonly _tag = 'WebhookVerificationError'\n readonly classification = 'terminal'\n\n /** Keep secrets and payload details out of the public message. */\n constructor(\n readonly reason: 'missing_headers' | 'invalid_signature' | 'invalid_payload',\n cause?: unknown,\n ) {\n super(`Webhook rejected: ${reason}`, { cause })\n }\n}\n\n/** A verified event, an ignored event, or an invalid request. */\nexport type WebhookVerification<Event> =\n | { readonly outcome: 'event'; readonly event: Event }\n | { readonly outcome: 'ignored'; readonly type: string }\n | { readonly outcome: 'rejected'; readonly error: WebhookVerificationError }\n\n/** The provider owns signature verification, timestamp tolerance, and payload parsing. */\nexport interface WebhookVerifier<Event> {\n /** Verify the exact body before parsing it. Return invalid requests; throw operational failures. */\n verify(body: string, headers: Headers): Promise<WebhookVerification<Event>>\n}\n"],"mappings":";;;;AAGA,IAAa,2BAAb,cAA8C,SAAS;CAM1C;CALX,AAAS,OAAO;CAChB,AAAS,iBAAiB;;CAG1B,YACE,AAAS,QACT,OACA;EACA,MAAM,qBAAqB,UAAU,EAAE,MAAM,CAAC;EAHrC;CAIX;AACF"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://www.schemastore.org/package.json",
3
3
  "name": "better-ship",
4
- "version": "0.15.0",
4
+ "version": "0.16.0",
5
5
  "description": "Foundation for applications on Cloudflare Workers: logger, errors, message bus, infrastructure adapters, and UI primitives",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
@@ -1,136 +1,242 @@
1
+ import { panic } from 'better-result'
1
2
  import { v7 as uuidv7 } from 'uuid'
2
3
  import { z } from 'zod'
3
4
 
4
- import type { DomainCommand, DomainEvent, DomainQuery, MessageId } from './messages.ts'
5
+ import { AppError } from './app-error.ts'
6
+ import type { MessageId } from './messages.ts'
5
7
 
6
- /** Payload schemas keyed by message name. A payload never carries `type`, `name`, or `id`. */
8
+ /** Payload schemas keyed by message name. Payloads cannot declare `type`, `name`, or `id`. */
7
9
  export type PayloadSchemas = Readonly<Record<string, z.ZodType<object>>>
8
10
 
9
- /** An application's messages: one list of payload schemas per kind. */
11
+ /** Optional payload schema maps for one message collection. */
10
12
  export type MessageDefinition = {
11
- readonly commands: PayloadSchemas
12
- readonly events: PayloadSchemas
13
- readonly queries: PayloadSchemas
13
+ readonly commands?: PayloadSchemas
14
+ readonly events?: PayloadSchemas
15
+ readonly queries?: PayloadSchemas
14
16
  }
15
17
 
16
- type AnyMessages = { readonly definition: MessageDefinition }
17
- type Def<Messages extends AnyMessages> = Messages['definition']
18
- type Payload<Schemas extends PayloadSchemas, Name extends keyof Schemas> = z.output<Schemas[Name]>
19
-
20
- /** Every command of an application: `CommandOf<typeof messages>`. */
21
- export type CommandOf<Messages extends AnyMessages> = {
22
- [Name in keyof Def<Messages>['commands'] & string]: DomainCommand<Name> &
23
- Payload<Def<Messages>['commands'], Name>
24
- }[keyof Def<Messages>['commands'] & string]
25
-
26
- /** Every event of an application: `EventOf<typeof messages>`. */
27
- export type EventOf<Messages extends AnyMessages> = {
28
- [Name in keyof Def<Messages>['events'] & string]: DomainEvent<Name> &
29
- Payload<Def<Messages>['events'], Name>
30
- }[keyof Def<Messages>['events'] & string]
31
-
32
- /** Every query of an application: `QueryOf<typeof messages>`. */
33
- export type QueryOf<Messages extends AnyMessages> = {
34
- [Name in keyof Def<Messages>['queries'] & string]: DomainQuery<Name> &
35
- Payload<Def<Messages>['queries'], Name>
36
- }[keyof Def<Messages>['queries'] & string]
37
-
38
- /** Every message of an application: what the registry and the bus are typed with. */
39
- export type MessageOf<Messages extends AnyMessages> =
40
- | CommandOf<Messages>
41
- | EventOf<Messages>
42
- | QueryOf<Messages>
18
+ const completed = Symbol('MessageCollection')
19
+ const MessageIdSchema = z.string().min(1)
20
+ const envelopes = {
21
+ command: z.object({ type: z.literal('command'), id: MessageIdSchema }),
22
+ event: z.object({ type: z.literal('event'), id: MessageIdSchema }),
23
+ query: z.object({ type: z.literal('query') }),
24
+ }
43
25
 
44
- /**
45
- * What `defineMessages` returns. The factories build messages for in-process dispatch without
46
- * parsing; the data is already typed. The schemas parse untrusted input at a boundary.
47
- */
48
- export type AppMessages<Definition extends MessageDefinition> = {
49
- readonly definition: Definition
50
- /** Build a command. `id` defaults to a uuid v7; a webhook passes the provider's key, `stripe:event:evt_123`. */
51
- createCommand<Name extends keyof Definition['commands'] & string>(
26
+ type MessageKind = keyof typeof envelopes
27
+ type RoutingField = 'type' | 'name' | 'id'
28
+ type SchemaMap = Readonly<Record<string, z.ZodType<object>>>
29
+ type EmptySchemas = Record<never, never>
30
+ type SchemaMaps = {
31
+ readonly commandSchemas: SchemaMap
32
+ readonly eventSchemas: SchemaMap
33
+ readonly querySchemas: SchemaMap
34
+ }
35
+ type CompletedMessages = SchemaMaps & { readonly [completed]: true }
36
+ type SchemaOf<Schemas extends SchemaMap> = Schemas[keyof Schemas & string]
37
+ type Payload<Message> = Message extends object ? Omit<Message, RoutingField> : never
38
+
39
+ /** Complete schemas, union schemas, and factories for one message collection. */
40
+ export type MessageCollection<Maps extends SchemaMaps> = Maps & {
41
+ readonly [completed]: true
42
+ readonly commandSchema: z.ZodUnion<SchemaOf<Maps['commandSchemas']>[]>
43
+ readonly eventSchema: z.ZodUnion<SchemaOf<Maps['eventSchemas']>[]>
44
+ readonly querySchema: z.ZodUnion<SchemaOf<Maps['querySchemas']>[]>
45
+ /** Commands and events can cross a queue. Queries cannot. */
46
+ readonly queueSchema: z.ZodUnion<
47
+ (SchemaOf<Maps['commandSchemas']> | SchemaOf<Maps['eventSchemas']>)[]
48
+ >
49
+ /** Add the command envelope without parsing. The default ID is UUID v7; supplied IDs stay unchanged. */
50
+ createCommand<Name extends keyof Maps['commandSchemas'] & string>(
51
+ this: void,
52
52
  name: Name,
53
- payload: Payload<Definition['commands'], Name>,
53
+ payload: Payload<z.output<Maps['commandSchemas'][NoInfer<Name>]>>,
54
54
  id?: MessageId,
55
- ): DomainCommand<Name> & Payload<Definition['commands'], Name>
56
- /** Build an event. `id` defaults to a uuid v7, which also orders it in time. */
57
- createEvent<Name extends keyof Definition['events'] & string>(
55
+ ): z.output<Maps['commandSchemas'][Name]>
56
+ /** Add the event envelope without parsing. The default ID is UUID v7; supplied IDs stay unchanged. */
57
+ createEvent<Name extends keyof Maps['eventSchemas'] & string>(
58
+ this: void,
58
59
  name: Name,
59
- payload: Payload<Definition['events'], Name>,
60
+ payload: Payload<z.output<Maps['eventSchemas'][NoInfer<Name>]>>,
60
61
  id?: MessageId,
61
- ): DomainEvent<Name> & Payload<Definition['events'], Name>
62
- /** Build a query. No id: nothing claims a read. */
63
- createQuery<Name extends keyof Definition['queries'] & string>(
62
+ ): z.output<Maps['eventSchemas'][Name]>
63
+ /** Add the query envelope without parsing. Queries have no ID. */
64
+ createQuery<Name extends keyof Maps['querySchemas'] & string>(
65
+ this: void,
64
66
  name: Name,
65
- payload: Payload<Definition['queries'], Name>,
66
- ): DomainQuery<Name> & Payload<Definition['queries'], Name>
67
- readonly commandSchema: z.ZodType<CommandOf<AppMessages<Definition>>>
68
- readonly eventSchema: z.ZodType<EventOf<AppMessages<Definition>>>
69
- readonly querySchema: z.ZodType<QueryOf<AppMessages<Definition>>>
70
- /** What a queue body may be: a command or an event. Queries never cross a queue. */
71
- readonly queueSchema: z.ZodType<
72
- CommandOf<AppMessages<Definition>> | EventOf<AppMessages<Definition>>
73
- >
67
+ payload: Payload<z.output<Maps['querySchemas'][NoInfer<Name>]>>,
68
+ ): z.output<Maps['querySchemas'][Name]>
74
69
  }
75
70
 
76
- type RoutingField = 'type' | 'name' | 'id'
71
+ /** The command union inferred from a collection's complete schemas. */
72
+ export type CommandOf<Messages extends CompletedMessages> = z.output<
73
+ SchemaOf<Messages['commandSchemas']>
74
+ >
75
+
76
+ /** The event union inferred from a collection's complete schemas. */
77
+ export type EventOf<Messages extends CompletedMessages> = z.output<
78
+ SchemaOf<Messages['eventSchemas']>
79
+ >
77
80
 
78
- /** A payload that declares a routing field maps to `never`, so `defineMessages` rejects it. */
81
+ /** The query union inferred from a collection's complete schemas. */
82
+ export type QueryOf<Messages extends CompletedMessages> = z.output<
83
+ SchemaOf<Messages['querySchemas']>
84
+ >
85
+
86
+ /** Every message inferred from a collection's complete schemas. */
87
+ export type MessageOf<Messages extends CompletedMessages> =
88
+ | CommandOf<Messages>
89
+ | EventOf<Messages>
90
+ | QueryOf<Messages>
91
+
92
+ type KeysOfUnion<Value> = Value extends Value ? keyof Value : never
79
93
  type WithoutRoutingFields<Schemas extends PayloadSchemas> = {
80
- readonly [Name in keyof Schemas]: keyof z.output<Schemas[Name]> & RoutingField extends never
94
+ readonly [Name in keyof Schemas]: KeysOfUnion<z.output<Schemas[Name]>> &
95
+ RoutingField extends never
81
96
  ? Schemas[Name]
82
97
  : never
83
98
  }
84
99
 
85
- /** The compile-time rule: no payload of any kind declares `type`, `name`, or `id`. */
86
- type ValidDefinition<Definition extends MessageDefinition> = {
87
- readonly commands: WithoutRoutingFields<Definition['commands']>
88
- readonly events: WithoutRoutingFields<Definition['events']>
89
- readonly queries: WithoutRoutingFields<Definition['queries']>
100
+ function messageSchema<
101
+ Kind extends MessageKind,
102
+ Name extends string,
103
+ Schema extends z.ZodType<object>,
104
+ >(kind: Kind, name: Name, payload: Schema) {
105
+ return z.intersection(envelopes[kind], z.object({ name: z.literal(name) })).and(payload)
90
106
  }
91
107
 
92
- const MessageIdSchema = z.string().min(1)
108
+ type CompleteSchemas<Kind extends MessageKind, Schemas extends PayloadSchemas> = {
109
+ readonly [Name in keyof Schemas & string]: ReturnType<
110
+ typeof messageSchema<Kind, Name, Schemas[Name]>
111
+ >
112
+ }
93
113
 
94
- /** One full message schema: the routing fields joined to the payload. */
95
- function messageSchema(
96
- type: 'command' | 'event' | 'query',
97
- name: string,
98
- payload: z.ZodType<object>,
99
- ) {
100
- const routing =
101
- type === 'query'
102
- ? z.object({ type: z.literal(type), name: z.literal(name) })
103
- : z.object({ type: z.literal(type), name: z.literal(name), id: MessageIdSchema })
104
- return routing.and(payload)
114
+ function completeSchemas<Kind extends MessageKind, Schemas extends PayloadSchemas>(
115
+ kind: Kind,
116
+ schemas: Schemas | undefined,
117
+ ): CompleteSchemas<Kind, Schemas> {
118
+ const entries = Object.entries<z.ZodType<object>>(schemas ?? {}).map(([name, payload]) => [
119
+ name,
120
+ messageSchema(kind, name, payload),
121
+ ])
122
+ // SAFETY: Each entry preserves its name and wraps its payload schema with the selected envelope.
123
+ return Object.freeze(Object.fromEntries(entries)) as CompleteSchemas<Kind, Schemas>
124
+ }
125
+
126
+ function unionOf<Schemas extends SchemaMap>(schemas: Schemas): z.ZodUnion<SchemaOf<Schemas>[]> {
127
+ // SAFETY: Object.values contains exactly the complete schemas indexed by this map's keys.
128
+ return z.union(Object.values(schemas)) as z.ZodUnion<SchemaOf<Schemas>[]>
105
129
  }
106
130
 
107
- /** A union over every message of one kind. Empty when the application has none of that kind. */
108
- function unionOf(type: 'command' | 'event' | 'query', schemas: PayloadSchemas) {
109
- return z.union(
110
- Object.entries(schemas).map(([name, payload]) => messageSchema(type, name, payload)),
111
- )
131
+ function collection<Maps extends SchemaMaps>(maps: Maps): MessageCollection<Maps> {
132
+ return Object.freeze<MessageCollection<Maps>>({
133
+ ...maps,
134
+ [completed]: true,
135
+ commandSchema: unionOf<Maps['commandSchemas']>(maps.commandSchemas),
136
+ eventSchema: unionOf<Maps['eventSchemas']>(maps.eventSchemas),
137
+ querySchema: unionOf<Maps['querySchemas']>(maps.querySchemas),
138
+ // SAFETY: The queue union contains the command and event schemas from these maps, without rebuilding them.
139
+ queueSchema: z.union([
140
+ ...Object.values(maps.commandSchemas),
141
+ ...Object.values(maps.eventSchemas),
142
+ ]) as MessageCollection<Maps>['queueSchema'],
143
+ createCommand: (name, payload, id = uuidv7()) => {
144
+ // SAFETY: The typed payload supplies all non-envelope fields for this name; the factory supplies its envelope.
145
+ return { ...payload, type: 'command', name, id } as z.output<
146
+ Maps['commandSchemas'][typeof name]
147
+ >
148
+ },
149
+ createEvent: (name, payload, id = uuidv7()) => {
150
+ // SAFETY: The typed payload supplies all non-envelope fields for this name; the factory supplies its envelope.
151
+ return { ...payload, type: 'event', name, id } as z.output<Maps['eventSchemas'][typeof name]>
152
+ },
153
+ createQuery: (name, payload) => {
154
+ // SAFETY: The typed payload supplies all non-envelope fields for this name; a query has no ID.
155
+ return { ...payload, type: 'query', name } as z.output<Maps['querySchemas'][typeof name]>
156
+ },
157
+ })
158
+ }
159
+
160
+ /** Build each complete schema once. Omitted kinds have empty maps and schemas that reject every input. */
161
+ export function defineMessages<
162
+ const Commands extends PayloadSchemas = EmptySchemas,
163
+ const Events extends PayloadSchemas = EmptySchemas,
164
+ const Queries extends PayloadSchemas = EmptySchemas,
165
+ >(definition: {
166
+ readonly commands?: Commands & WithoutRoutingFields<Commands>
167
+ readonly events?: Events & WithoutRoutingFields<Events>
168
+ readonly queries?: Queries & WithoutRoutingFields<Queries>
169
+ }) {
170
+ return collection({
171
+ commandSchemas: completeSchemas<'command', Commands>('command', definition.commands),
172
+ eventSchemas: completeSchemas<'event', Events>('event', definition.events),
173
+ querySchemas: completeSchemas<'query', Queries>('query', definition.queries),
174
+ })
175
+ }
176
+
177
+ /** Two collections declare the same name within one message kind. */
178
+ export class DuplicateMessageNameError extends AppError {
179
+ readonly _tag = 'DuplicateMessageNameError'
180
+ readonly classification = 'terminal'
181
+
182
+ /** Identify the duplicate kind and name so the application can correct its definitions. */
183
+ constructor(
184
+ readonly kind: MessageKind,
185
+ readonly messageName: string,
186
+ ) {
187
+ super(`Duplicate ${kind} name: ${messageName}`)
188
+ }
189
+ }
190
+
191
+ type SchemaAtName<Maps, Name extends PropertyKey> =
192
+ Maps extends Record<Name, infer Schema extends z.ZodType<object>> ? Schema : never
193
+ type MergedMaps<Collections extends readonly CompletedMessages[]> = {
194
+ readonly [Key in keyof SchemaMaps]: {
195
+ readonly [Name in KeysOfUnion<Collections[number][Key]> & string]: SchemaAtName<
196
+ Collections[number][Key],
197
+ Name
198
+ >
199
+ }
200
+ }
201
+ type DuplicateNames<Collections extends readonly CompletedMessages[]> =
202
+ Collections extends readonly [
203
+ infer First extends CompletedMessages,
204
+ ...infer Rest extends readonly CompletedMessages[],
205
+ ]
206
+ ?
207
+ | {
208
+ [Key in keyof SchemaMaps]: keyof First[Key] & KeysOfUnion<Rest[number][Key]>
209
+ }[keyof SchemaMaps]
210
+ | DuplicateNames<Rest>
211
+ : never
212
+
213
+ function mergeSchemas<
214
+ Collections extends readonly CompletedMessages[],
215
+ Key extends keyof SchemaMaps,
216
+ >(collections: Collections, key: Key, kind: MessageKind): MergedMaps<Collections>[Key] {
217
+ const merged = new Map<string, z.ZodType<object>>()
218
+ for (const messages of collections) {
219
+ if (!messages?.[completed])
220
+ return panic('combineMessages requires completed message collections')
221
+ for (const [name, schema] of Object.entries(messages[key])) {
222
+ if (merged.has(name)) throw new DuplicateMessageNameError(kind, name)
223
+ merged.set(name, schema)
224
+ }
225
+ }
226
+ // SAFETY: Every schema comes from the selected complete map, and duplicate names are rejected before insertion.
227
+ return Object.freeze(Object.fromEntries(merged)) as MergedMaps<Collections>[Key]
112
228
  }
113
229
 
114
230
  /**
115
- * Define an application's messages once. The key is the name; the factories add the routing
116
- * fields `type`, `name`, and `id`. A payload that declares one of them is a compile error.
231
+ * Merge completed collections without rebuilding complete schemas.
232
+ * @throws DuplicateMessageNameError when two collections declare the same name within one kind.
117
233
  */
118
- export function defineMessages<const Definition extends MessageDefinition>(
119
- definition: Definition & ValidDefinition<Definition>,
120
- ): AppMessages<Definition> {
121
- const commandSchema = unionOf('command', definition.commands)
122
- const eventSchema = unionOf('event', definition.events)
123
- const querySchema = unionOf('query', definition.queries)
124
- return {
125
- definition,
126
- createCommand: (name, payload, id = uuidv7()) => ({ ...payload, type: 'command', name, id }),
127
- createEvent: (name, payload, id = uuidv7()) => ({ ...payload, type: 'event', name, id }),
128
- createQuery: (name, payload) => ({ ...payload, type: 'query', name }),
129
- commandSchema: commandSchema.pipe(z.custom<CommandOf<AppMessages<Definition>>>()),
130
- eventSchema: eventSchema.pipe(z.custom<EventOf<AppMessages<Definition>>>()),
131
- querySchema: querySchema.pipe(z.custom<QueryOf<AppMessages<Definition>>>()),
132
- queueSchema: z
133
- .union([commandSchema, eventSchema])
134
- .pipe(z.custom<CommandOf<AppMessages<Definition>> | EventOf<AppMessages<Definition>>>()),
135
- }
234
+ export function combineMessages<const Collections extends readonly CompletedMessages[]>(
235
+ ...collections: Collections & (DuplicateNames<Collections> extends never ? unknown : never)
236
+ ) {
237
+ return collection({
238
+ commandSchemas: mergeSchemas(collections, 'commandSchemas', 'command'),
239
+ eventSchemas: mergeSchemas(collections, 'eventSchemas', 'event'),
240
+ querySchemas: mergeSchemas(collections, 'querySchemas', 'query'),
241
+ })
136
242
  }
package/src/core/index.ts CHANGED
@@ -62,7 +62,9 @@ export { notImplemented, unreachable } from './defects.ts'
62
62
  export { defineId, type Id, type IdDefinition, type IdOf } from './identity.ts'
63
63
  export {
64
64
  defineMessages,
65
- type AppMessages,
65
+ combineMessages,
66
+ DuplicateMessageNameError,
67
+ type MessageCollection,
66
68
  type CommandOf,
67
69
  type EventOf,
68
70
  type MessageDefinition,
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Internal messages use UUID v7, so receipts sort by creation. External messages use the
5
5
  * provider's own key, `stripe:event:evt_123`, so a redelivered webhook is a duplicate by
6
- * construction. The app parses and generates ids; this package only carries them.
6
+ * construction. Message factories generate IDs; complete message schemas parse external IDs.
7
7
  */
8
8
  export type MessageId = string
9
9