better-ship 0.5.0 → 0.7.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.
- package/dist/{application-DEerezCl.js → application-DjsQtpgw.js} +2 -2
- package/dist/application-DjsQtpgw.js.map +1 -0
- package/dist/application.d.ts +3 -2
- package/dist/application.js +1 -1
- package/dist/better-auth/apple-client-secret.d.ts +1 -1
- package/dist/better-auth/apple-client-secret.js +1 -1
- package/dist/better-auth/auth-client.js +1 -1
- package/dist/better-auth/rate-limit.d.ts +1 -1
- package/dist/better-auth/rate-limit.js +1 -1
- package/dist/better-auth.d.ts +4 -2
- package/dist/better-auth.d.ts.map +1 -1
- package/dist/better-auth.js +23 -7
- package/dist/better-auth.js.map +1 -1
- package/dist/cloudflare/auth-secondary-storage.d.ts +1 -1
- package/dist/cloudflare/auth-secondary-storage.js +1 -1
- package/dist/cloudflare.d.ts +7 -7
- package/dist/cloudflare.d.ts.map +1 -1
- package/dist/cloudflare.js +3 -3
- package/dist/cloudflare.js.map +1 -1
- package/dist/{core-CnwWj4MY.js → core-Cx5SeyKV.js} +91 -2
- package/dist/core-Cx5SeyKV.js.map +1 -0
- package/dist/core.d.ts +2 -2
- package/dist/core.js +2 -2
- package/dist/{index-B9Y1hVh6.d.ts → index-BStnggQO.d.ts} +126 -2
- package/dist/index-BStnggQO.d.ts.map +1 -0
- package/dist/{index-Bdyh3_xC.d.ts → index-DWIRrJMP.d.ts} +21 -50
- package/dist/index-DWIRrJMP.d.ts.map +1 -0
- package/dist/postgres.d.ts +7 -7
- package/dist/postgres.d.ts.map +1 -1
- package/dist/postgres.js +3 -3
- package/dist/postgres.js.map +1 -1
- package/dist/tanstack.d.ts +8 -1
- package/dist/tanstack.d.ts.map +1 -1
- package/dist/tanstack.js +13 -2
- package/dist/tanstack.js.map +1 -1
- package/dist/{unit-of-work-context-ml9L9cyY.d.ts → unit-of-work-context-CPz9rdSS.d.ts} +3 -2
- package/dist/unit-of-work-context-CPz9rdSS.d.ts.map +1 -0
- package/dist/{unit-of-work-context-C7vz5OYS.js → unit-of-work-context-DToDpAkv.js} +2 -2
- package/dist/unit-of-work-context-DToDpAkv.js.map +1 -0
- package/package.json +1 -1
- package/src/application/event-collector.ts +3 -3
- package/src/application/handlers.ts +7 -4
- package/src/application/index.ts +1 -1
- package/src/application/message-already-processed.error.ts +1 -3
- package/src/application/message-bus.ts +14 -8
- package/src/application/message-store.ts +2 -2
- package/src/application/registry.ts +14 -13
- package/src/application/unit-of-work.ts +2 -1
- package/src/core/define-messages.ts +136 -0
- package/src/core/entity.ts +33 -0
- package/src/core/identity.ts +29 -0
- package/src/core/index.ts +19 -0
- package/src/{application → core}/messages.ts +4 -4
- package/src/infrastructure/better-auth/options.ts +29 -8
- package/src/infrastructure/cloudflare/base-d1-repository.ts +2 -2
- package/src/infrastructure/cloudflare/d1-message-store.ts +3 -7
- package/src/infrastructure/cloudflare/d1-schema.ts +2 -2
- package/src/infrastructure/postgres/base-postgres-repository.ts +2 -2
- package/src/infrastructure/postgres/postgres-message-store.ts +3 -7
- package/src/infrastructure/postgres/postgres-schema.ts +2 -2
- package/src/infrastructure/tanstack/index.ts +1 -0
- package/src/infrastructure/tanstack/last-login-method.ts +13 -0
- package/src/infrastructure/unit-of-work-context.ts +3 -2
- package/dist/application-DEerezCl.js.map +0 -1
- package/dist/core-CnwWj4MY.js.map +0 -1
- package/dist/index-B9Y1hVh6.d.ts.map +0 -1
- package/dist/index-Bdyh3_xC.d.ts.map +0 -1
- package/dist/unit-of-work-context-C7vz5OYS.js.map +0 -1
- package/dist/unit-of-work-context-ml9L9cyY.d.ts.map +0 -1
package/dist/tanstack.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Y as ErrorTable } from "./index-BStnggQO.js";
|
|
2
2
|
import { Register, RequestServerOptions } from "@tanstack/react-start";
|
|
3
3
|
import { AnyRedirect } from "@tanstack/react-router";
|
|
4
4
|
import { BetterAuthCookies } from "better-auth";
|
|
@@ -77,5 +77,12 @@ export declare function createErrorMiddleware<Entries>(options: ErrorMiddlewareO
|
|
|
77
77
|
toHttpErrorResponse: (cause: unknown) => Response;
|
|
78
78
|
};
|
|
79
79
|
//#endregion
|
|
80
|
+
//#region src/infrastructure/tanstack/last-login-method.d.ts
|
|
81
|
+
/**
|
|
82
|
+
* Read the method this browser last signed in with from the current request, for the sign-in loader on the server.
|
|
83
|
+
* Pass `cookieName` only when `integrations.lastLoginMethod.cookieName` is set. Returns `null` when the cookie is absent.
|
|
84
|
+
*/
|
|
85
|
+
export declare function getLastUsedLoginMethod(cookieName?: string): string | null;
|
|
86
|
+
//#endregion
|
|
80
87
|
export type { AuthContext, AuthCookieSource, AuthMiddlewareOptions, AuthSession, ErrorMiddlewareOptions, RequiredAuthContext };
|
|
81
88
|
//# sourceMappingURL=tanstack.d.ts.map
|
package/dist/tanstack.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tanstack.d.ts","names":[],"sources":["../src/infrastructure/tanstack/auth.middleware.ts","../src/infrastructure/tanstack/error.middleware.ts"],"mappings":";;;;;;KASY;WACD;aAAiB;;WACjB;aAAoB;;;;KAInB,oBAAoB,gBAAgB;WACrC,MAAM;WACN,SAAS;;;KAIR,YAAY,gBAAgB,eACpC,oBAAoB;WAET;WACA;;;KAIH,sBAAsB,gBAAgB;WACvC,UAAU,SAAS,qBAAqB;aACtC;eACE,aAAa;QAAW,SAAS;YAAc,QAAQ;;;;;;;;wBAStD,qBAAqB,gBAAgB,aACnD,SAAS,sBAAsB;;;;;;;KChC5B,iBAAiB,qBAAqB;;KAG/B;WACD,UAAU;aAAmB,aAAa;;;;KAIzC,uBAAuB;WACxB,QAAQ,WAAW;;WAEnB,cAAc;;WAEd,UAAU,SAAS,mBAAmB;;;;;;wBAOjC,sBAAsB,SAAS,SAAS,uBAAuB"}
|
|
1
|
+
{"version":3,"file":"tanstack.d.ts","names":[],"sources":["../src/infrastructure/tanstack/auth.middleware.ts","../src/infrastructure/tanstack/error.middleware.ts","../src/infrastructure/tanstack/last-login-method.ts"],"mappings":";;;;;;KASY;WACD;aAAiB;;WACjB;aAAoB;;;;KAInB,oBAAoB,gBAAgB;WACrC,MAAM;WACN,SAAS;;;KAIR,YAAY,gBAAgB,eACpC,oBAAoB;WAET;WACA;;;KAIH,sBAAsB,gBAAgB;WACvC,UAAU,SAAS,qBAAqB;aACtC;eACE,aAAa;QAAW,SAAS;YAAc,QAAQ;;;;;;;;wBAStD,qBAAqB,gBAAgB,aACnD,SAAS,sBAAsB;;;;;;;KChC5B,iBAAiB,qBAAqB;;KAG/B;WACD,UAAU;aAAmB,aAAa;;;;KAIzC,uBAAuB;WACxB,QAAQ,WAAW;;WAEnB,cAAc;;WAEd,UAAU,SAAS,mBAAmB;;;;;;wBAOjC,sBAAsB,SAAS,SAAS,uBAAuB;;;;;;;;;;;;;;;;;;;;wBCpB/D,uBAAuB"}
|
package/dist/tanstack.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { A as
|
|
1
|
+
import { A as StaleSessionError, E as ValidationError, M as AuthenticationError, N as AppError, a as createLogger } from "./core-Cx5SeyKV.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { createMiddleware } from "@tanstack/react-start";
|
|
4
4
|
import { deleteCookie, getRequestHeaders } from "@tanstack/react-start/server";
|
|
@@ -210,5 +210,16 @@ function createErrorMiddleware(options) {
|
|
|
210
210
|
}
|
|
211
211
|
|
|
212
212
|
//#endregion
|
|
213
|
-
|
|
213
|
+
//#region src/infrastructure/tanstack/last-login-method.ts
|
|
214
|
+
const DEFAULT_COOKIE_NAME = "better-auth.last_used_login_method";
|
|
215
|
+
/**
|
|
216
|
+
* Read the method this browser last signed in with from the current request, for the sign-in loader on the server.
|
|
217
|
+
* Pass `cookieName` only when `integrations.lastLoginMethod.cookieName` is set. Returns `null` when the cookie is absent.
|
|
218
|
+
*/
|
|
219
|
+
function getLastUsedLoginMethod(cookieName = DEFAULT_COOKIE_NAME) {
|
|
220
|
+
return parseCookies(getRequestHeaders().get("cookie") ?? "").get(cookieName) ?? null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
//#endregion
|
|
224
|
+
export { createAuthMiddleware, createErrorMiddleware, getLastUsedLoginMethod };
|
|
214
225
|
//# sourceMappingURL=tanstack.js.map
|
package/dist/tanstack.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tanstack.js","names":["logger"],"sources":["../src/infrastructure/tanstack/auth.middleware.ts","../src/infrastructure/tanstack/resolve-failure.ts","../src/infrastructure/tanstack/http-error-response.ts","../src/infrastructure/tanstack/session-cookies.ts","../src/infrastructure/tanstack/to-error-payload.ts","../src/infrastructure/tanstack/error.middleware.ts"],"sourcesContent":["import { createMiddleware, type Register, type RequestServerOptions } from '@tanstack/react-start'\nimport { getRequestHeaders } from '@tanstack/react-start/server'\nimport { getSessionCookie } from 'better-auth/cookies'\n\nimport { AuthenticationError, StaleSessionError, createLogger } from '@/core'\n\nconst logger = createLogger('auth-middleware')\n\n/** The fields middleware reads; generic callers retain every additional session and user field. */\nexport type AuthSession = {\n readonly user: { readonly id: string }\n readonly session: { readonly id: string }\n}\n\n/** Authenticated context retains the application's complete user and session types. */\nexport type RequiredAuthContext<Session extends AuthSession> = {\n readonly user: Session['user']\n readonly session: Session['session']\n}\n\n/** Optional authentication supplies both fields together or neither field. */\nexport type AuthContext<Session extends AuthSession> =\n | RequiredAuthContext<Session>\n | {\n readonly user: undefined\n readonly session: undefined\n }\n\n/** Select the current request's Better Auth instance from TanStack's registered context. */\nexport type AuthMiddlewareOptions<Session extends AuthSession> = {\n readonly getAuth: (context: RequestServerOptions<Register, undefined>['context']) => {\n readonly api: {\n readonly getSession: (options: { headers: Headers }) => Promise<Session | null>\n }\n }\n}\n\n/**\n * Preserve Porte's function and request middleware contracts with application-owned services.\n * Optional lookup failures log once and continue as guests. Required lookup failures propagate.\n */\nexport function createAuthMiddleware<Session extends AuthSession>(\n options: AuthMiddlewareOptions<Session>,\n) {\n const extractAuth = createMiddleware({ type: 'function' }).server<AuthContext<Session>>(\n async ({ next, context }) => {\n let resolved: Session | null\n try {\n resolved = await options.getAuth(context).api.getSession({ headers: getRequestHeaders() })\n } catch (error) {\n logger.error('get_session_failed', { error })\n resolved = null\n }\n if (resolved === null)\n return next<AuthContext<Session>>({ context: { user: undefined, session: undefined } })\n return next<AuthContext<Session>>({\n context: { user: resolved.user, session: resolved.session },\n })\n },\n )\n\n async function resolveRequiredSession(\n context: RequestServerOptions<Register, undefined>['context'],\n headers: Headers,\n ): Promise<Session> {\n const resolved = await options.getAuth(context).api.getSession({ headers })\n if (resolved === null) {\n if (getSessionCookie(headers)) throw new StaleSessionError()\n throw new AuthenticationError()\n }\n return resolved\n }\n\n const requireAuth = createMiddleware({ type: 'function' }).server(async ({ next, context }) => {\n const resolved = await resolveRequiredSession(context, getRequestHeaders())\n return next<RequiredAuthContext<Session>>({\n context: { user: resolved.user, session: resolved.session },\n })\n })\n const requireAuthRequest = createMiddleware({ type: 'request' }).server(\n async ({ request, next, context }) => {\n const resolved = await resolveRequiredSession(context, request.headers)\n return next<RequiredAuthContext<Session>>({\n context: { user: resolved.user, session: resolved.session },\n })\n },\n )\n return { extractAuth, requireAuth, requireAuthRequest }\n}\n","import { AppError, createLogger, type ErrorTable, type ResolvedError } from '@/core'\nimport type { ErrorPayloadOf } from '@/core'\n\nconst logger = createLogger('error-boundary')\n\n/**\n * Resolve one failure through the table or fall back to the generic replacement, and log it once.\n * Both transport converters call this, so one rule decides the log level: mapped is a warning, unmapped is an error with the cause.\n */\nexport function resolveFailure<Entries>(\n table: ErrorTable<Entries>,\n cause: unknown,\n): ResolvedError<ErrorPayloadOf<ErrorTable<Entries>>> {\n const resolved = table.resolve(cause)\n if (resolved !== null) {\n logger.warn('handled_error', {\n details: { tag: resolved.payload._tag, status: resolved.status },\n })\n return resolved\n }\n const classification = cause instanceof AppError ? cause.classification : 'unknown'\n logger.error('unexpected_error', { error: cause, details: { classification } })\n return table.fallback(cause)\n}\n","import { getReasonPhrase } from 'http-status-codes'\nimport { z } from 'zod'\n\nimport type { ErrorTable, ProblemDetails } from '@/core'\n\nimport { resolveFailure } from './resolve-failure.ts'\n\n// A known delay becomes the header; a null delay sends no header.\nconst RetryAfterSchema = z.object({ retryAfter: z.number() })\n\n/**\n * Build the API route converter for one error table.\n * The body is RFC 9457 problem details; public fields become extension members.\n */\nexport function createHttpErrorResponseConverter<Entries>(table: ErrorTable<Entries>) {\n return function toHttpErrorResponse(cause: unknown): Response {\n const { status, payload } = resolveFailure(table, cause)\n const { _tag, message, ...fields } = payload\n const body: ProblemDetails = {\n type: 'about:blank',\n title: getReasonPhrase(status),\n detail: message,\n ...fields,\n }\n const headers = new Headers({ 'Content-Type': 'application/problem+json' })\n const retry = RetryAfterSchema.safeParse(payload)\n if (retry.success) headers.set('Retry-After', String(retry.data.retryAfter))\n return Response.json(body, { status, headers })\n }\n}\n","import { deleteCookie, getRequestHeaders } from '@tanstack/react-start/server'\nimport type { BetterAuthCookies } from 'better-auth'\nimport { parseCookies } from 'better-auth/cookies'\n\ntype AuthCookie = BetterAuthCookies['sessionToken']\ntype DeleteCookieOptions = NonNullable<Parameters<typeof deleteCookie>[1]>\n\n/**\n * Delete the session, session cache, account cache, and remember-me cookies of one instance.\n * Chunks present on the request go too. Names and attributes come from the instance, so custom prefixes and domain cookies clear.\n */\nexport function clearStaleSessionCookies(cookies: BetterAuthCookies): void {\n const present = [...parseCookies(getRequestHeaders().get('cookie') ?? '').keys()]\n const definitions = [\n cookies.sessionToken,\n cookies.sessionData,\n cookies.accountData,\n cookies.dontRememberToken,\n ]\n for (const cookie of definitions) {\n // Better Auth names a chunk `${name}.${index}`, so a missed chunk would resurrect the value.\n const chunks = present.filter((name) => name.startsWith(`${cookie.name}.`))\n for (const name of new Set([cookie.name, ...chunks]))\n deleteCookie(name, deletionOf(cookie.attributes))\n }\n}\n\nfunction deletionOf(attributes: AuthCookie['attributes']): DeleteCookieOptions {\n // Better Auth sets `/` and the secure flag at runtime; the types leave both optional.\n const options: DeleteCookieOptions = {\n path: attributes.path ?? '/',\n secure: attributes.secure ?? false,\n }\n if (attributes.domain !== undefined) options.domain = attributes.domain\n return options\n}\n","import { z } from 'zod'\n\nimport { ValidationError, type ErrorPayloadOf, type ErrorTable } from '@/core'\n\nimport { resolveFailure } from './resolve-failure.ts'\n\n// TanStack serializes a validator's issues into `Error.message` as JSON.\nconst SerializedIssuesSchema = z\n .array(z.object({ message: z.string(), path: z.array(z.union([z.string(), z.number()])) }))\n .min(1)\n\nfunction reconstructValidationError(cause: unknown): ValidationError | null {\n if (!(cause instanceof Error)) return null\n let message: unknown\n try {\n message = JSON.parse(cause.message)\n } catch {\n return null\n }\n const parsed = SerializedIssuesSchema.safeParse(message)\n return parsed.success ? ValidationError.fromIssues(parsed.data) : null\n}\n\n/**\n * Build the server-function converter for one error table.\n * The result is a plain object, because only own data survives serialization.\n */\nexport function createErrorPayloadConverter<Entries>(table: ErrorTable<Entries>) {\n return function toErrorPayload(cause: unknown): ErrorPayloadOf<ErrorTable<Entries>> {\n return resolveFailure(table, reconstructValidationError(cause) ?? cause).payload\n }\n}\n","import { isNotFound, isRedirect, type AnyRedirect } from '@tanstack/react-router'\nimport { createMiddleware, type Register, type RequestServerOptions } from '@tanstack/react-start'\nimport type { BetterAuthCookies } from 'better-auth'\n\nimport { StaleSessionError, type ErrorTable } from '@/core'\n\nimport { createHttpErrorResponseConverter } from './http-error-response.ts'\nimport { clearStaleSessionCookies } from './session-cookies.ts'\nimport { createErrorPayloadConverter } from './to-error-payload.ts'\n\ntype RequestContext = RequestServerOptions<Register, undefined>['context']\n\n/** The part of a Better Auth instance the boundary reads: its computed cookie definitions. */\nexport type AuthCookieSource = {\n readonly $context: Promise<{ readonly authCookies: BetterAuthCookies }>\n}\n\n/** The application's error table, its stale-session redirect, and its Better Auth instance. */\nexport type ErrorMiddlewareOptions<Entries> = {\n readonly errors: ErrorTable<Entries>\n /** Build the redirect a server function throws for a stale session. */\n readonly signIn: () => AnyRedirect\n /** Select the request's Better Auth instance; its cookie definitions say what to clear. */\n readonly getAuth: (context: RequestContext) => AuthCookieSource\n}\n\n/**\n * Build both transport boundaries from one error table.\n * Register `functionErrorMiddleware` globally. Put `routeErrorMiddleware` first in an API route's `server.middleware`.\n */\nexport function createErrorMiddleware<Entries>(options: ErrorMiddlewareOptions<Entries>) {\n const toErrorPayload = createErrorPayloadConverter(options.errors)\n const toHttpErrorResponse = createHttpErrorResponseConverter(options.errors)\n\n async function clearSession(context: RequestContext): Promise<void> {\n const { authCookies } = await options.getAuth(context).$context\n clearStaleSessionCookies(authCookies)\n }\n\n async function handleFunctionError(cause: unknown, context: RequestContext): Promise<never> {\n // oxlint-disable-next-line typescript/only-throw-error -- TanStack uses throwable control-flow objects.\n if (isNotFound(cause) || isRedirect(cause)) throw cause\n if (cause instanceof StaleSessionError) {\n await clearSession(context)\n // oxlint-disable-next-line typescript/only-throw-error -- Router redirects by throwing this.\n throw options.signIn()\n }\n // oxlint-disable-next-line typescript/only-throw-error -- The client rejects with this and reads its tag.\n throw toErrorPayload(cause)\n }\n\n // A route answers 401 instead of a redirect because fetch would follow it into the HTML shell.\n async function handleRouteError(cause: unknown, context: RequestContext): Promise<Response> {\n // oxlint-disable-next-line typescript/only-throw-error -- TanStack uses throwable control-flow objects.\n if (isNotFound(cause) || isRedirect(cause)) throw cause\n if (cause instanceof StaleSessionError) await clearSession(context)\n return toHttpErrorResponse(cause)\n }\n\n const functionErrorMiddleware = createMiddleware({ type: 'function' }).server(\n async ({ next, context }) => {\n try {\n return await next()\n } catch (cause) {\n return handleFunctionError(cause, context)\n }\n },\n )\n\n const routeErrorMiddleware = createMiddleware({ type: 'request' }).server(\n async ({ next, context }) => {\n try {\n return await next()\n } catch (cause) {\n return handleRouteError(cause, context)\n }\n },\n )\n\n return { functionErrorMiddleware, routeErrorMiddleware, toErrorPayload, toHttpErrorResponse }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"tanstack.js","names":["logger"],"sources":["../src/infrastructure/tanstack/auth.middleware.ts","../src/infrastructure/tanstack/resolve-failure.ts","../src/infrastructure/tanstack/http-error-response.ts","../src/infrastructure/tanstack/session-cookies.ts","../src/infrastructure/tanstack/to-error-payload.ts","../src/infrastructure/tanstack/error.middleware.ts","../src/infrastructure/tanstack/last-login-method.ts"],"sourcesContent":["import { createMiddleware, type Register, type RequestServerOptions } from '@tanstack/react-start'\nimport { getRequestHeaders } from '@tanstack/react-start/server'\nimport { getSessionCookie } from 'better-auth/cookies'\n\nimport { AuthenticationError, StaleSessionError, createLogger } from '@/core'\n\nconst logger = createLogger('auth-middleware')\n\n/** The fields middleware reads; generic callers retain every additional session and user field. */\nexport type AuthSession = {\n readonly user: { readonly id: string }\n readonly session: { readonly id: string }\n}\n\n/** Authenticated context retains the application's complete user and session types. */\nexport type RequiredAuthContext<Session extends AuthSession> = {\n readonly user: Session['user']\n readonly session: Session['session']\n}\n\n/** Optional authentication supplies both fields together or neither field. */\nexport type AuthContext<Session extends AuthSession> =\n | RequiredAuthContext<Session>\n | {\n readonly user: undefined\n readonly session: undefined\n }\n\n/** Select the current request's Better Auth instance from TanStack's registered context. */\nexport type AuthMiddlewareOptions<Session extends AuthSession> = {\n readonly getAuth: (context: RequestServerOptions<Register, undefined>['context']) => {\n readonly api: {\n readonly getSession: (options: { headers: Headers }) => Promise<Session | null>\n }\n }\n}\n\n/**\n * Preserve Porte's function and request middleware contracts with application-owned services.\n * Optional lookup failures log once and continue as guests. Required lookup failures propagate.\n */\nexport function createAuthMiddleware<Session extends AuthSession>(\n options: AuthMiddlewareOptions<Session>,\n) {\n const extractAuth = createMiddleware({ type: 'function' }).server<AuthContext<Session>>(\n async ({ next, context }) => {\n let resolved: Session | null\n try {\n resolved = await options.getAuth(context).api.getSession({ headers: getRequestHeaders() })\n } catch (error) {\n logger.error('get_session_failed', { error })\n resolved = null\n }\n if (resolved === null)\n return next<AuthContext<Session>>({ context: { user: undefined, session: undefined } })\n return next<AuthContext<Session>>({\n context: { user: resolved.user, session: resolved.session },\n })\n },\n )\n\n async function resolveRequiredSession(\n context: RequestServerOptions<Register, undefined>['context'],\n headers: Headers,\n ): Promise<Session> {\n const resolved = await options.getAuth(context).api.getSession({ headers })\n if (resolved === null) {\n if (getSessionCookie(headers)) throw new StaleSessionError()\n throw new AuthenticationError()\n }\n return resolved\n }\n\n const requireAuth = createMiddleware({ type: 'function' }).server(async ({ next, context }) => {\n const resolved = await resolveRequiredSession(context, getRequestHeaders())\n return next<RequiredAuthContext<Session>>({\n context: { user: resolved.user, session: resolved.session },\n })\n })\n const requireAuthRequest = createMiddleware({ type: 'request' }).server(\n async ({ request, next, context }) => {\n const resolved = await resolveRequiredSession(context, request.headers)\n return next<RequiredAuthContext<Session>>({\n context: { user: resolved.user, session: resolved.session },\n })\n },\n )\n return { extractAuth, requireAuth, requireAuthRequest }\n}\n","import { AppError, createLogger, type ErrorTable, type ResolvedError } from '@/core'\nimport type { ErrorPayloadOf } from '@/core'\n\nconst logger = createLogger('error-boundary')\n\n/**\n * Resolve one failure through the table or fall back to the generic replacement, and log it once.\n * Both transport converters call this, so one rule decides the log level: mapped is a warning, unmapped is an error with the cause.\n */\nexport function resolveFailure<Entries>(\n table: ErrorTable<Entries>,\n cause: unknown,\n): ResolvedError<ErrorPayloadOf<ErrorTable<Entries>>> {\n const resolved = table.resolve(cause)\n if (resolved !== null) {\n logger.warn('handled_error', {\n details: { tag: resolved.payload._tag, status: resolved.status },\n })\n return resolved\n }\n const classification = cause instanceof AppError ? cause.classification : 'unknown'\n logger.error('unexpected_error', { error: cause, details: { classification } })\n return table.fallback(cause)\n}\n","import { getReasonPhrase } from 'http-status-codes'\nimport { z } from 'zod'\n\nimport type { ErrorTable, ProblemDetails } from '@/core'\n\nimport { resolveFailure } from './resolve-failure.ts'\n\n// A known delay becomes the header; a null delay sends no header.\nconst RetryAfterSchema = z.object({ retryAfter: z.number() })\n\n/**\n * Build the API route converter for one error table.\n * The body is RFC 9457 problem details; public fields become extension members.\n */\nexport function createHttpErrorResponseConverter<Entries>(table: ErrorTable<Entries>) {\n return function toHttpErrorResponse(cause: unknown): Response {\n const { status, payload } = resolveFailure(table, cause)\n const { _tag, message, ...fields } = payload\n const body: ProblemDetails = {\n type: 'about:blank',\n title: getReasonPhrase(status),\n detail: message,\n ...fields,\n }\n const headers = new Headers({ 'Content-Type': 'application/problem+json' })\n const retry = RetryAfterSchema.safeParse(payload)\n if (retry.success) headers.set('Retry-After', String(retry.data.retryAfter))\n return Response.json(body, { status, headers })\n }\n}\n","import { deleteCookie, getRequestHeaders } from '@tanstack/react-start/server'\nimport type { BetterAuthCookies } from 'better-auth'\nimport { parseCookies } from 'better-auth/cookies'\n\ntype AuthCookie = BetterAuthCookies['sessionToken']\ntype DeleteCookieOptions = NonNullable<Parameters<typeof deleteCookie>[1]>\n\n/**\n * Delete the session, session cache, account cache, and remember-me cookies of one instance.\n * Chunks present on the request go too. Names and attributes come from the instance, so custom prefixes and domain cookies clear.\n */\nexport function clearStaleSessionCookies(cookies: BetterAuthCookies): void {\n const present = [...parseCookies(getRequestHeaders().get('cookie') ?? '').keys()]\n const definitions = [\n cookies.sessionToken,\n cookies.sessionData,\n cookies.accountData,\n cookies.dontRememberToken,\n ]\n for (const cookie of definitions) {\n // Better Auth names a chunk `${name}.${index}`, so a missed chunk would resurrect the value.\n const chunks = present.filter((name) => name.startsWith(`${cookie.name}.`))\n for (const name of new Set([cookie.name, ...chunks]))\n deleteCookie(name, deletionOf(cookie.attributes))\n }\n}\n\nfunction deletionOf(attributes: AuthCookie['attributes']): DeleteCookieOptions {\n // Better Auth sets `/` and the secure flag at runtime; the types leave both optional.\n const options: DeleteCookieOptions = {\n path: attributes.path ?? '/',\n secure: attributes.secure ?? false,\n }\n if (attributes.domain !== undefined) options.domain = attributes.domain\n return options\n}\n","import { z } from 'zod'\n\nimport { ValidationError, type ErrorPayloadOf, type ErrorTable } from '@/core'\n\nimport { resolveFailure } from './resolve-failure.ts'\n\n// TanStack serializes a validator's issues into `Error.message` as JSON.\nconst SerializedIssuesSchema = z\n .array(z.object({ message: z.string(), path: z.array(z.union([z.string(), z.number()])) }))\n .min(1)\n\nfunction reconstructValidationError(cause: unknown): ValidationError | null {\n if (!(cause instanceof Error)) return null\n let message: unknown\n try {\n message = JSON.parse(cause.message)\n } catch {\n return null\n }\n const parsed = SerializedIssuesSchema.safeParse(message)\n return parsed.success ? ValidationError.fromIssues(parsed.data) : null\n}\n\n/**\n * Build the server-function converter for one error table.\n * The result is a plain object, because only own data survives serialization.\n */\nexport function createErrorPayloadConverter<Entries>(table: ErrorTable<Entries>) {\n return function toErrorPayload(cause: unknown): ErrorPayloadOf<ErrorTable<Entries>> {\n return resolveFailure(table, reconstructValidationError(cause) ?? cause).payload\n }\n}\n","import { isNotFound, isRedirect, type AnyRedirect } from '@tanstack/react-router'\nimport { createMiddleware, type Register, type RequestServerOptions } from '@tanstack/react-start'\nimport type { BetterAuthCookies } from 'better-auth'\n\nimport { StaleSessionError, type ErrorTable } from '@/core'\n\nimport { createHttpErrorResponseConverter } from './http-error-response.ts'\nimport { clearStaleSessionCookies } from './session-cookies.ts'\nimport { createErrorPayloadConverter } from './to-error-payload.ts'\n\ntype RequestContext = RequestServerOptions<Register, undefined>['context']\n\n/** The part of a Better Auth instance the boundary reads: its computed cookie definitions. */\nexport type AuthCookieSource = {\n readonly $context: Promise<{ readonly authCookies: BetterAuthCookies }>\n}\n\n/** The application's error table, its stale-session redirect, and its Better Auth instance. */\nexport type ErrorMiddlewareOptions<Entries> = {\n readonly errors: ErrorTable<Entries>\n /** Build the redirect a server function throws for a stale session. */\n readonly signIn: () => AnyRedirect\n /** Select the request's Better Auth instance; its cookie definitions say what to clear. */\n readonly getAuth: (context: RequestContext) => AuthCookieSource\n}\n\n/**\n * Build both transport boundaries from one error table.\n * Register `functionErrorMiddleware` globally. Put `routeErrorMiddleware` first in an API route's `server.middleware`.\n */\nexport function createErrorMiddleware<Entries>(options: ErrorMiddlewareOptions<Entries>) {\n const toErrorPayload = createErrorPayloadConverter(options.errors)\n const toHttpErrorResponse = createHttpErrorResponseConverter(options.errors)\n\n async function clearSession(context: RequestContext): Promise<void> {\n const { authCookies } = await options.getAuth(context).$context\n clearStaleSessionCookies(authCookies)\n }\n\n async function handleFunctionError(cause: unknown, context: RequestContext): Promise<never> {\n // oxlint-disable-next-line typescript/only-throw-error -- TanStack uses throwable control-flow objects.\n if (isNotFound(cause) || isRedirect(cause)) throw cause\n if (cause instanceof StaleSessionError) {\n await clearSession(context)\n // oxlint-disable-next-line typescript/only-throw-error -- Router redirects by throwing this.\n throw options.signIn()\n }\n // oxlint-disable-next-line typescript/only-throw-error -- The client rejects with this and reads its tag.\n throw toErrorPayload(cause)\n }\n\n // A route answers 401 instead of a redirect because fetch would follow it into the HTML shell.\n async function handleRouteError(cause: unknown, context: RequestContext): Promise<Response> {\n // oxlint-disable-next-line typescript/only-throw-error -- TanStack uses throwable control-flow objects.\n if (isNotFound(cause) || isRedirect(cause)) throw cause\n if (cause instanceof StaleSessionError) await clearSession(context)\n return toHttpErrorResponse(cause)\n }\n\n const functionErrorMiddleware = createMiddleware({ type: 'function' }).server(\n async ({ next, context }) => {\n try {\n return await next()\n } catch (cause) {\n return handleFunctionError(cause, context)\n }\n },\n )\n\n const routeErrorMiddleware = createMiddleware({ type: 'request' }).server(\n async ({ next, context }) => {\n try {\n return await next()\n } catch (cause) {\n return handleRouteError(cause, context)\n }\n },\n )\n\n return { functionErrorMiddleware, routeErrorMiddleware, toErrorPayload, toHttpErrorResponse }\n}\n","import { getRequestHeaders } from '@tanstack/react-start/server'\nimport { parseCookies } from 'better-auth/cookies'\n\n// Better Auth keeps the plugin default private; this is the value LastLoginMethodOptions.cookieName documents.\nconst DEFAULT_COOKIE_NAME = 'better-auth.last_used_login_method'\n\n/**\n * Read the method this browser last signed in with from the current request, for the sign-in loader on the server.\n * Pass `cookieName` only when `integrations.lastLoginMethod.cookieName` is set. Returns `null` when the cookie is absent.\n */\nexport function getLastUsedLoginMethod(cookieName = DEFAULT_COOKIE_NAME): string | null {\n return parseCookies(getRequestHeaders().get('cookie') ?? '').get(cookieName) ?? null\n}\n"],"mappings":"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"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import "./index-BStnggQO.js";
|
|
2
|
+
import { o as IEventCollector } from "./index-DWIRrJMP.js";
|
|
2
3
|
import { BatchItem } from "drizzle-orm/batch";
|
|
3
4
|
//#region src/infrastructure/drizzle-schema.d.ts
|
|
4
5
|
/**
|
|
@@ -12,4 +13,4 @@ type DrizzleSchema = Record<string, unknown>;
|
|
|
12
13
|
declare const eventCollector: IEventCollector;
|
|
13
14
|
//#endregion
|
|
14
15
|
export { DrizzleSchema as n, eventCollector as t };
|
|
15
|
-
//# sourceMappingURL=unit-of-work-context-
|
|
16
|
+
//# sourceMappingURL=unit-of-work-context-CPz9rdSS.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"unit-of-work-context-CPz9rdSS.d.ts","names":[],"sources":["../src/infrastructure/drizzle-schema.ts","../src/infrastructure/unit-of-work-context.ts"],"mappings":";;;;;;;;KAKY,gBAAgB;;;;cCuBf,gBAAgB"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { t as UnitOfWorkAlreadyActiveError } from "./application-
|
|
1
|
+
import { t as UnitOfWorkAlreadyActiveError } from "./application-DjsQtpgw.js";
|
|
2
2
|
import { panic } from "better-result";
|
|
3
3
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
4
4
|
|
|
@@ -45,4 +45,4 @@ function currentContext() {
|
|
|
45
45
|
|
|
46
46
|
//#endregion
|
|
47
47
|
export { stageD1Statement as i, eventCollector as n, runWithUnitOfWorkContext as r, collectD1Statements as t };
|
|
48
|
-
//# sourceMappingURL=unit-of-work-context-
|
|
48
|
+
//# sourceMappingURL=unit-of-work-context-DToDpAkv.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"unit-of-work-context-DToDpAkv.js","names":[],"sources":["../src/infrastructure/unit-of-work-context.ts"],"sourcesContent":["import { AsyncLocalStorage } from 'node:async_hooks'\n\nimport { panic } from 'better-result'\nimport type { BatchItem } from 'drizzle-orm/batch'\n\nimport { UnitOfWorkAlreadyActiveError, type IEventCollector } from '@/application'\nimport type { DomainEvent } from '@/core'\n\ntype UnitOfWorkContext = { readonly events: DomainEvent[] } & (\n | { readonly database: 'd1'; readonly statements: BatchItem<'sqlite'>[] }\n | { readonly database: 'postgres' }\n)\n\n// One async scope isolates events and D1 statements and rejects nesting across both adapters.\nconst storage = new AsyncLocalStorage<UnitOfWorkContext>()\n\n/** Reject nested transactions before database work starts; preserve independent concurrent execution. */\nexport async function runWithUnitOfWorkContext<T>(\n database: UnitOfWorkContext['database'],\n work: () => Promise<T>,\n): Promise<T> {\n if (storage.getStore() !== undefined) throw new UnitOfWorkAlreadyActiveError()\n const context: UnitOfWorkContext =\n database === 'd1' ? { database, events: [], statements: [] } : { database, events: [] }\n return storage.run(context, work)\n}\n\n/** Inject this shared scope into the unit of work and expose its emitter to application handlers. */\nexport const eventCollector: IEventCollector = {\n emit(event) {\n currentContext().events.push(event)\n },\n collect() {\n return [...currentContext().events]\n },\n}\n\n/** Queue one native D1 statement for the active batch; other contexts cannot accept D1 writes. */\nexport function stageD1Statement(statement: BatchItem<'sqlite'>): void {\n d1Context().statements.push(statement)\n}\n\n/** Read the active D1 batch in staging order for commit. */\nexport function collectD1Statements(): ReadonlyArray<BatchItem<'sqlite'>> {\n return [...d1Context().statements]\n}\n\nfunction d1Context(): Extract<UnitOfWorkContext, { database: 'd1' }> {\n const context = currentContext()\n if (context.database !== 'd1') return panic('D1 statements require a D1 unit of work')\n return context\n}\n\nfunction currentContext(): UnitOfWorkContext {\n return storage.getStore() ?? panic('operation requires an active unit of work')\n}\n"],"mappings":";;;;;AAcA,MAAM,UAAU,IAAI,kBAAqC;;AAGzD,eAAsB,yBACpB,UACA,MACY;CACZ,IAAI,QAAQ,SAAS,MAAM,QAAW,MAAM,IAAI,6BAA6B;CAC7E,MAAM,UACJ,aAAa,OAAO;EAAE;EAAU,QAAQ,CAAC;EAAG,YAAY,CAAC;CAAE,IAAI;EAAE;EAAU,QAAQ,CAAC;CAAE;CACxF,OAAO,QAAQ,IAAI,SAAS,IAAI;AAClC;;AAGA,MAAa,iBAAkC;CAC7C,KAAK,OAAO;EACV,eAAe,CAAC,CAAC,OAAO,KAAK,KAAK;CACpC;CACA,UAAU;EACR,OAAO,CAAC,GAAG,eAAe,CAAC,CAAC,MAAM;CACpC;AACF;;AAGA,SAAgB,iBAAiB,WAAsC;CACrE,UAAU,CAAC,CAAC,WAAW,KAAK,SAAS;AACvC;;AAGA,SAAgB,sBAA0D;CACxE,OAAO,CAAC,GAAG,UAAU,CAAC,CAAC,UAAU;AACnC;AAEA,SAAS,YAA4D;CACnE,MAAM,UAAU,eAAe;CAC/B,IAAI,QAAQ,aAAa,MAAM,OAAO,MAAM,yCAAyC;CACrF,OAAO;AACT;AAEA,SAAS,iBAAoC;CAC3C,OAAO,QAAQ,SAAS,KAAK,MAAM,2CAA2C;AAChF"}
|
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.
|
|
4
|
+
"version": "0.7.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,7 +1,7 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { DomainEvent } from '@/core'
|
|
2
2
|
|
|
3
3
|
/** Records events in the current unit of work; the unit of work collects a snapshot for outbox persistence. */
|
|
4
4
|
export interface IEventCollector {
|
|
5
|
-
emit(event:
|
|
6
|
-
collect(): ReadonlyArray<
|
|
5
|
+
emit(event: DomainEvent): void
|
|
6
|
+
collect(): ReadonlyArray<DomainEvent>
|
|
7
7
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { DomainCommand, DomainEvent, DomainQuery } from '@/core'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Handles one command with the dependency container and returns the caller's value.
|
|
5
5
|
* A handler that writes inside a transaction opens the unit of work itself. Failures throw.
|
|
6
6
|
*/
|
|
7
|
-
export type CommandHandler<TCommand extends
|
|
7
|
+
export type CommandHandler<TCommand extends DomainCommand, Deps, Value> = (
|
|
8
8
|
command: TCommand,
|
|
9
9
|
deps: Deps,
|
|
10
10
|
) => Promise<Value>
|
|
@@ -13,10 +13,13 @@ export type CommandHandler<TCommand extends Command, Deps, Value> = (
|
|
|
13
13
|
* Handles one query with the read-only view of the container.
|
|
14
14
|
* The view is a type over the same object, so a query cannot name a write capability.
|
|
15
15
|
*/
|
|
16
|
-
export type QueryHandler<TQuery extends
|
|
16
|
+
export type QueryHandler<TQuery extends DomainQuery, QueryDeps, Value> = (
|
|
17
17
|
query: TQuery,
|
|
18
18
|
deps: QueryDeps,
|
|
19
19
|
) => Promise<Value>
|
|
20
20
|
|
|
21
21
|
/** Completes one subscriber's work for an event. Failures throw. */
|
|
22
|
-
export type EventHandler<TEvent extends
|
|
22
|
+
export type EventHandler<TEvent extends DomainEvent, Deps> = (
|
|
23
|
+
event: TEvent,
|
|
24
|
+
deps: Deps,
|
|
25
|
+
) => Promise<void>
|
package/src/application/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export type { CommandHandler, EventHandler, QueryHandler } from './handlers.ts'
|
|
2
2
|
export type { IMessageStore } from './message-store.ts'
|
|
3
|
-
export type {
|
|
3
|
+
export type { DomainCommand, DomainEvent, DomainMessage, DomainQuery, MessageId } from '@/core'
|
|
4
4
|
export { MessageBus, type IMessageBus } from './message-bus.ts'
|
|
5
5
|
export type { IOutboxRelay } from './outbox-relay.ts'
|
|
6
6
|
export type {
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import { AppError, DatabaseError } from '@/core'
|
|
2
|
-
|
|
3
|
-
import type { MessageId } from './messages.ts'
|
|
1
|
+
import { AppError, DatabaseError, type MessageId } from '@/core'
|
|
4
2
|
|
|
5
3
|
/** The message's effects already committed. A caller can skip duplicate delivery without repeating those effects. */
|
|
6
4
|
export class MessageAlreadyProcessedError extends AppError {
|
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import { panic } from 'better-result'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
createLogger,
|
|
5
|
+
unreachable,
|
|
6
|
+
type DomainCommand,
|
|
7
|
+
type DomainEvent,
|
|
8
|
+
type DomainMessage,
|
|
9
|
+
type DomainQuery,
|
|
10
|
+
} from '@/core'
|
|
4
11
|
|
|
5
12
|
import { MessageAlreadyProcessedError } from './message-already-processed.error.ts'
|
|
6
|
-
import type { Command, Event, Message, Query } from './messages.ts'
|
|
7
13
|
import type { Handlers, MessageResult, RegistryMessage } from './registry.ts'
|
|
8
14
|
|
|
9
15
|
const logger = createLogger('message-bus')
|
|
10
16
|
|
|
11
17
|
/** The port an entrypoint dispatches through. `AppDeps` names this as `IMessageBus<typeof registry>`. */
|
|
12
18
|
export interface IMessageBus<TRegistry extends Handlers<never>> {
|
|
13
|
-
handle<TMessage extends
|
|
19
|
+
handle<TMessage extends DomainMessage & RegistryMessage<TRegistry>>(
|
|
14
20
|
message: TMessage,
|
|
15
21
|
): Promise<MessageResult<TMessage, TRegistry>>
|
|
16
22
|
}
|
|
@@ -26,7 +32,7 @@ export class MessageBus<Deps, TRegistry extends Handlers<Deps>> implements IMess
|
|
|
26
32
|
) {}
|
|
27
33
|
|
|
28
34
|
/** Resolves to the handler's own value for a command or query, and to void for an event. */
|
|
29
|
-
async handle<TMessage extends
|
|
35
|
+
async handle<TMessage extends DomainMessage & RegistryMessage<TRegistry>>(
|
|
30
36
|
message: TMessage,
|
|
31
37
|
): Promise<MessageResult<TMessage, TRegistry>> {
|
|
32
38
|
// SAFETY: the slot for this name is what `MessageResult` names, and dispatch returns its value.
|
|
@@ -34,7 +40,7 @@ export class MessageBus<Deps, TRegistry extends Handlers<Deps>> implements IMess
|
|
|
34
40
|
}
|
|
35
41
|
|
|
36
42
|
// oxlint-disable-next-line anti-slop/no-unknown-returns -- one table holds every slot's value; `handle` names it.
|
|
37
|
-
private dispatch(message:
|
|
43
|
+
private dispatch(message: DomainMessage): Promise<unknown> {
|
|
38
44
|
if (message.type === 'command') return this.handleCommand(message)
|
|
39
45
|
if (message.type === 'query') return this.handleQuery(message)
|
|
40
46
|
if (message.type === 'event') return this.handleEvent(message)
|
|
@@ -43,7 +49,7 @@ export class MessageBus<Deps, TRegistry extends Handlers<Deps>> implements IMess
|
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
// oxlint-disable-next-line anti-slop/no-unknown-returns -- see dispatch.
|
|
46
|
-
private handleCommand(command:
|
|
52
|
+
private handleCommand(command: DomainCommand): Promise<unknown> {
|
|
47
53
|
// `satisfies` proved the registry complete, so a missing slot is a broken registry object.
|
|
48
54
|
const handler = this.registry.commands[command.name]
|
|
49
55
|
if (handler === undefined) return panic(`No handler for command ${command.name}`)
|
|
@@ -52,7 +58,7 @@ export class MessageBus<Deps, TRegistry extends Handlers<Deps>> implements IMess
|
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
// oxlint-disable-next-line anti-slop/no-unknown-returns -- see dispatch.
|
|
55
|
-
private handleQuery(query:
|
|
61
|
+
private handleQuery(query: DomainQuery): Promise<unknown> {
|
|
56
62
|
const handler = this.registry.queries[query.name]
|
|
57
63
|
if (handler === undefined) return panic(`No handler for query ${query.name}`)
|
|
58
64
|
return handler(query, this.deps)
|
|
@@ -63,7 +69,7 @@ export class MessageBus<Deps, TRegistry extends Handlers<Deps>> implements IMess
|
|
|
63
69
|
* one `AggregateError`. A `MessageAlreadyProcessedError` means that subscriber already committed on
|
|
64
70
|
* an earlier delivery, so it counts as delivered.
|
|
65
71
|
*/
|
|
66
|
-
private async handleEvent(event:
|
|
72
|
+
private async handleEvent(event: DomainEvent): Promise<void> {
|
|
67
73
|
const subscriptions = this.registry.events[event.name] ?? []
|
|
68
74
|
const outcomes = await Promise.allSettled(
|
|
69
75
|
subscriptions.map((subscription) => subscription(event, this.deps)),
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { DomainEvent, MessageId } from '@/core'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Stores processed messages and outgoing events in the same transaction as application writes.
|
|
@@ -8,5 +8,5 @@ export interface IMessageStore {
|
|
|
8
8
|
/** Record this message in the transaction; reject an existing record with `MessageAlreadyProcessedError`. */
|
|
9
9
|
claim(messageId: MessageId): Promise<void>
|
|
10
10
|
/** Write the events to the outbox, inside the same boundary as the state change. */
|
|
11
|
-
persistEvents(events: ReadonlyArray<
|
|
11
|
+
persistEvents(events: ReadonlyArray<DomainEvent>): Promise<void>
|
|
12
12
|
}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
+
import type { DomainCommand, DomainEvent, DomainMessage, DomainQuery } from '@/core'
|
|
2
|
+
|
|
1
3
|
import type { CommandHandler, EventHandler, QueryHandler } from './handlers.ts'
|
|
2
|
-
import type { Command, Event, Message, Query } from './messages.ts'
|
|
3
4
|
|
|
4
5
|
/** One slot per command. `unknown` keeps the slot covariant; `MessageResult` recovers the value. */
|
|
5
|
-
export type CommandRegistry<TCommand extends
|
|
6
|
+
export type CommandRegistry<TCommand extends DomainCommand, Deps> = {
|
|
6
7
|
readonly [M in TCommand as M['name']]: CommandHandler<M, Deps, unknown>
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
/** One slot per query, typed with the read-only view of the container. */
|
|
10
|
-
export type QueryRegistry<TQuery extends
|
|
11
|
+
export type QueryRegistry<TQuery extends DomainQuery, QueryDeps> = {
|
|
11
12
|
readonly [M in TQuery as M['name']]: QueryHandler<M, QueryDeps, unknown>
|
|
12
13
|
}
|
|
13
14
|
|
|
@@ -15,7 +16,7 @@ export type QueryRegistry<TQuery extends Query, QueryDeps> = {
|
|
|
15
16
|
* Zero or more subscribers per event. Annotate the object with this type rather than
|
|
16
17
|
* `satisfies`, so an event with no subscribers is still a message the bus accepts.
|
|
17
18
|
*/
|
|
18
|
-
export type EventRegistry<TEvent extends
|
|
19
|
+
export type EventRegistry<TEvent extends DomainEvent, Deps> = {
|
|
19
20
|
readonly [M in TEvent as M['name']]?: ReadonlyArray<EventHandler<M, Deps>>
|
|
20
21
|
}
|
|
21
22
|
|
|
@@ -24,16 +25,16 @@ export type EventRegistry<TEvent extends Event, Deps> = {
|
|
|
24
25
|
* makes a missing handler a compile error. `Deps extends QueryDeps` is checked here, the one
|
|
25
26
|
* place both types meet: the read view must be a subset of the container.
|
|
26
27
|
*/
|
|
27
|
-
export type MessageRegistry<TMessage extends
|
|
28
|
-
readonly commands: CommandRegistry<Extract<TMessage,
|
|
29
|
-
readonly queries: QueryRegistry<Extract<TMessage,
|
|
30
|
-
readonly events: EventRegistry<Extract<TMessage,
|
|
28
|
+
export type MessageRegistry<TMessage extends DomainMessage, Deps extends QueryDeps, QueryDeps> = {
|
|
29
|
+
readonly commands: CommandRegistry<Extract<TMessage, DomainCommand>, Deps>
|
|
30
|
+
readonly queries: QueryRegistry<Extract<TMessage, DomainQuery>, QueryDeps>
|
|
31
|
+
readonly events: EventRegistry<Extract<TMessage, DomainEvent>, Deps>
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
// Method syntax makes the parameters bivariant, so an app's slot, typed for one message, fits.
|
|
34
35
|
// oxlint-disable-next-line anti-slop/no-unknown-returns -- one table holds every slot's value; `MessageResult` names it.
|
|
35
|
-
type Slot<Deps> = { handle(message:
|
|
36
|
-
type Subscription<Deps> = { handle(event:
|
|
36
|
+
type Slot<Deps> = { handle(message: DomainMessage, deps: Deps): Promise<unknown> }['handle']
|
|
37
|
+
type Subscription<Deps> = { handle(event: DomainEvent, deps: Deps): Promise<void> }['handle']
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
40
|
* The registry as the bus routes it: each table keyed by name. The app's names are unknown
|
|
@@ -64,10 +65,10 @@ type SlotValue<TSlots, TName extends string> = TName extends keyof TSlots
|
|
|
64
65
|
|
|
65
66
|
/** The value `bus.handle(message)` resolves to: the slot's own return type, or void for an event. */
|
|
66
67
|
export type MessageResult<
|
|
67
|
-
TMessage extends
|
|
68
|
+
TMessage extends DomainMessage,
|
|
68
69
|
TRegistry extends Handlers<never>,
|
|
69
|
-
> = TMessage extends
|
|
70
|
+
> = TMessage extends DomainCommand
|
|
70
71
|
? SlotValue<TRegistry['commands'], TMessage['name']>
|
|
71
|
-
: TMessage extends
|
|
72
|
+
: TMessage extends DomainQuery
|
|
72
73
|
? SlotValue<TRegistry['queries'], TMessage['name']>
|
|
73
74
|
: void
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import type { MessageId } from '@/core'
|
|
2
|
+
|
|
1
3
|
import type { IEventCollector } from './event-collector.ts'
|
|
2
4
|
import { MessageAlreadyProcessedError } from './message-already-processed.error.ts'
|
|
3
5
|
import type { IMessageStore } from './message-store.ts'
|
|
4
|
-
import type { MessageId } from './messages.ts'
|
|
5
6
|
import type { IOutboxRelay } from './outbox-relay.ts'
|
|
6
7
|
import type { ITransaction } from './transaction.ts'
|
|
7
8
|
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { v7 as uuidv7 } from 'uuid'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
|
|
4
|
+
import type { DomainCommand, DomainEvent, DomainQuery, MessageId } from './messages.ts'
|
|
5
|
+
|
|
6
|
+
/** Payload schemas keyed by message name. A payload never carries `type`, `name`, or `id`. */
|
|
7
|
+
export type PayloadSchemas = Readonly<Record<string, z.ZodType<object>>>
|
|
8
|
+
|
|
9
|
+
/** An application's messages: one list of payload schemas per kind. */
|
|
10
|
+
export type MessageDefinition = {
|
|
11
|
+
readonly commands: PayloadSchemas
|
|
12
|
+
readonly events: PayloadSchemas
|
|
13
|
+
readonly queries: PayloadSchemas
|
|
14
|
+
}
|
|
15
|
+
|
|
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>
|
|
43
|
+
|
|
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>(
|
|
52
|
+
name: Name,
|
|
53
|
+
payload: Payload<Definition['commands'], Name>,
|
|
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>(
|
|
58
|
+
name: Name,
|
|
59
|
+
payload: Payload<Definition['events'], Name>,
|
|
60
|
+
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>(
|
|
64
|
+
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
|
+
>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type RoutingField = 'type' | 'name' | 'id'
|
|
77
|
+
|
|
78
|
+
/** A payload that declares a routing field maps to `never`, so `defineMessages` rejects it. */
|
|
79
|
+
type WithoutRoutingFields<Schemas extends PayloadSchemas> = {
|
|
80
|
+
readonly [Name in keyof Schemas]: keyof z.output<Schemas[Name]> & RoutingField extends never
|
|
81
|
+
? Schemas[Name]
|
|
82
|
+
: never
|
|
83
|
+
}
|
|
84
|
+
|
|
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']>
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const MessageIdSchema = z.string().min(1)
|
|
93
|
+
|
|
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)
|
|
105
|
+
}
|
|
106
|
+
|
|
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
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
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.
|
|
117
|
+
*/
|
|
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
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { DomainEvent } from './messages.ts'
|
|
2
|
+
|
|
3
|
+
/** What an aggregate's plain data must have: an identity. A branded id keeps its brand. */
|
|
4
|
+
export type EntityData = { readonly id: string }
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The base of an aggregate: its identity, the events it raised, and the plain data a repository
|
|
8
|
+
* stores. The subclass keeps its constructor private and declares `static restore(data: Data)`,
|
|
9
|
+
* the one door a repository rebuilds it through. A repository drains the events after `save`.
|
|
10
|
+
*/
|
|
11
|
+
export abstract class Entity<Data extends EntityData, TEvent extends DomainEvent = DomainEvent> {
|
|
12
|
+
private events: TEvent[] = []
|
|
13
|
+
|
|
14
|
+
abstract readonly id: Data['id']
|
|
15
|
+
|
|
16
|
+
/** Record one event with the state change that caused it. The unit of work commits both. */
|
|
17
|
+
protected addEvent(event: TEvent): void {
|
|
18
|
+
this.events.push(event)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A copy of the events raised since the last `clearEvents`. */
|
|
22
|
+
collectEvents(): ReadonlyArray<TEvent> {
|
|
23
|
+
return [...this.events]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Forget the raised events once the boundary has taken them. */
|
|
27
|
+
clearEvents(): void {
|
|
28
|
+
this.events = []
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Plain data for the repository. The subclass's `restore(data)` accepts exactly this. */
|
|
32
|
+
abstract toPlainObject(): Data
|
|
33
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { v7 as uuidv7 } from 'uuid'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
|
|
4
|
+
/** A string only `create()` or `schema.parse()` of the same kind can produce. */
|
|
5
|
+
export type Id<Brand extends string> = string & z.core.$brand<Brand>
|
|
6
|
+
|
|
7
|
+
/** One id kind the application mints. `schema` parses untrusted input; `create` mints a uuid v7. */
|
|
8
|
+
export type IdDefinition<Brand extends string> = {
|
|
9
|
+
readonly schema: z.core.$ZodBranded<z.ZodUUID, Brand>
|
|
10
|
+
/** Mint a new time-ordered id. No parse: we produced it, so it is valid by construction. */
|
|
11
|
+
create(): Id<Brand>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The id type of a definition: `type CallId = IdOf<typeof CallId>`. */
|
|
15
|
+
export type IdOf<Definition extends IdDefinition<string>> = z.output<Definition['schema']>
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Define an id kind the application mints: `export const CallId = defineId('CallId')`.
|
|
19
|
+
* An id another system mints, such as a provider's call id, stays a plain branded schema.
|
|
20
|
+
*/
|
|
21
|
+
export function defineId<const Brand extends string>(brand: Brand): IdDefinition<Brand> {
|
|
22
|
+
const schema = z.uuidv7({ error: `Invalid ${brand}` })
|
|
23
|
+
return {
|
|
24
|
+
// SAFETY: `brand()` is type-only and returns a conditional TypeScript cannot reduce for a generic brand; for every literal it is this branded schema.
|
|
25
|
+
schema: schema.brand<Brand>() as z.core.$ZodBranded<z.ZodUUID, Brand>,
|
|
26
|
+
// SAFETY: uuidv7() always yields a valid v7 uuid, and the brand is a compile-time mark only.
|
|
27
|
+
create: () => uuidv7() as Id<Brand>,
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/core/index.ts
CHANGED
|
@@ -32,6 +32,25 @@ export {
|
|
|
32
32
|
} from './problem-details.ts'
|
|
33
33
|
export { DatabaseError, type DatabaseFailure, type D1ConstraintKind } from './database.error.ts'
|
|
34
34
|
export { notImplemented, unreachable } from './defects.ts'
|
|
35
|
+
export { defineId, type Id, type IdDefinition, type IdOf } from './identity.ts'
|
|
36
|
+
export {
|
|
37
|
+
defineMessages,
|
|
38
|
+
type AppMessages,
|
|
39
|
+
type CommandOf,
|
|
40
|
+
type EventOf,
|
|
41
|
+
type MessageDefinition,
|
|
42
|
+
type MessageOf,
|
|
43
|
+
type PayloadSchemas,
|
|
44
|
+
type QueryOf,
|
|
45
|
+
} from './define-messages.ts'
|
|
46
|
+
export type {
|
|
47
|
+
DomainCommand,
|
|
48
|
+
DomainEvent,
|
|
49
|
+
DomainMessage,
|
|
50
|
+
DomainQuery,
|
|
51
|
+
MessageId,
|
|
52
|
+
} from './messages.ts'
|
|
53
|
+
export { Entity, type EntityData } from './entity.ts'
|
|
35
54
|
export {
|
|
36
55
|
shouldRetryFailure,
|
|
37
56
|
type FailureClassification,
|