better-ship 0.3.2 → 0.4.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 (59) hide show
  1. package/dist/application-BpSwCKJ5.js +131 -0
  2. package/dist/application-BpSwCKJ5.js.map +1 -0
  3. package/dist/application.d.ts +2 -127
  4. package/dist/application.js +2 -84
  5. package/dist/cloudflare.d.ts +301 -1
  6. package/dist/cloudflare.d.ts.map +1 -0
  7. package/dist/cloudflare.js +169 -0
  8. package/dist/cloudflare.js.map +1 -0
  9. package/dist/{core-D0asUCvq.js → core-D3G-2gIx.js} +18 -2
  10. package/dist/core-D3G-2gIx.js.map +1 -0
  11. package/dist/core.d.ts +2 -2
  12. package/dist/core.js +2 -2
  13. package/dist/{index-eL4vb02m.d.ts → index-CT1yZd4D.d.ts} +31 -2
  14. package/dist/index-CT1yZd4D.d.ts.map +1 -0
  15. package/dist/index-CkaFqbbV.d.ts +200 -0
  16. package/dist/index-CkaFqbbV.d.ts.map +1 -0
  17. package/dist/postgres.d.ts +289 -1
  18. package/dist/postgres.d.ts.map +1 -0
  19. package/dist/postgres.js +169 -0
  20. package/dist/postgres.js.map +1 -0
  21. package/dist/unit-of-work-context-CILhzKWb.js +48 -0
  22. package/dist/unit-of-work-context-CILhzKWb.js.map +1 -0
  23. package/dist/unit-of-work-context-DOkIB7HK.d.ts +15 -0
  24. package/dist/unit-of-work-context-DOkIB7HK.d.ts.map +1 -0
  25. package/package.json +17 -2
  26. package/src/application/event-collector.ts +7 -0
  27. package/src/application/index.ts +7 -1
  28. package/src/application/message-already-processed.error.ts +33 -0
  29. package/src/application/message-bus.ts +3 -3
  30. package/src/application/message-store.ts +12 -0
  31. package/src/application/outbox-relay.ts +8 -0
  32. package/src/application/transaction.ts +8 -0
  33. package/src/application/unit-of-work.errors.ts +11 -0
  34. package/src/application/unit-of-work.ts +54 -0
  35. package/src/core/database.error.ts +37 -0
  36. package/src/core/index.ts +1 -0
  37. package/src/infrastructure/cloudflare/base-d1-repository.ts +40 -0
  38. package/src/infrastructure/cloudflare/classify-d1-error.ts +82 -0
  39. package/src/infrastructure/cloudflare/d1-batch-transaction.ts +27 -0
  40. package/src/infrastructure/cloudflare/d1-database-execution.ts +13 -0
  41. package/src/infrastructure/cloudflare/d1-database-types.ts +20 -0
  42. package/src/infrastructure/cloudflare/d1-message-store.ts +41 -0
  43. package/src/infrastructure/cloudflare/d1-schema.ts +43 -0
  44. package/src/infrastructure/cloudflare/index.ts +9 -0
  45. package/src/infrastructure/drizzle-schema.ts +6 -0
  46. package/src/infrastructure/postgres/base-postgres-repository.ts +30 -0
  47. package/src/infrastructure/postgres/classify-postgres-error.ts +90 -0
  48. package/src/infrastructure/postgres/index.ts +15 -0
  49. package/src/infrastructure/postgres/postgres-database-execution.ts +13 -0
  50. package/src/infrastructure/postgres/postgres-database-types.ts +27 -0
  51. package/src/infrastructure/postgres/postgres-message-store.ts +32 -0
  52. package/src/infrastructure/postgres/postgres-schema.ts +33 -0
  53. package/src/infrastructure/postgres/postgres-transaction.ts +22 -0
  54. package/src/infrastructure/unit-of-work-context.ts +55 -0
  55. package/dist/application.d.ts.map +0 -1
  56. package/dist/application.js.map +0 -1
  57. package/dist/core-D0asUCvq.js.map +0 -1
  58. package/dist/index-eL4vb02m.d.ts.map +0 -1
  59. package/src/application/message-bus.errors.ts +0 -16
@@ -0,0 +1,131 @@
1
+ import { a as createLogger, f as unreachable, m as AppError, p as DatabaseError } from "./core-D3G-2gIx.js";
2
+ import { panic } from "better-result";
3
+
4
+ //#region src/application/message-already-processed.error.ts
5
+ /** The message's effects already committed. A caller can skip duplicate delivery without repeating those effects. */
6
+ var MessageAlreadyProcessedError = class MessageAlreadyProcessedError extends AppError {
7
+ messageId;
8
+ _tag = "MessageAlreadyProcessedError";
9
+ classification = "terminal";
10
+ constructor(messageId, options) {
11
+ super(`message ${messageId} already processed`, options);
12
+ this.messageId = messageId;
13
+ }
14
+ /** Recognize only the processed-message primary key; preserve other failures unchanged at the call site. */
15
+ static from(cause, messageId) {
16
+ if (cause instanceof MessageAlreadyProcessedError) return cause;
17
+ if (!(cause instanceof DatabaseError)) return void 0;
18
+ const details = cause.details;
19
+ if (!(details.database === "d1" ? details.kind === "unique" && details.table === "processed_messages" && details.column === "message_id" : details.code === "23505" && details.table_name === "processed_messages" && details.constraint_name === "processed_messages_pkey")) return void 0;
20
+ return new MessageAlreadyProcessedError(messageId, { cause });
21
+ }
22
+ };
23
+
24
+ //#endregion
25
+ //#region src/application/message-bus.ts
26
+ const logger = createLogger("message-bus");
27
+ /**
28
+ * Routes one message to its slot. The bus opens no transaction and keeps no outbox:
29
+ * a handler that writes opens the unit of work itself.
30
+ */
31
+ var MessageBus = class {
32
+ deps;
33
+ registry;
34
+ constructor(deps, registry) {
35
+ this.deps = deps;
36
+ this.registry = registry;
37
+ }
38
+ /** Resolves to the handler's own value for a command or query, and to void for an event. */
39
+ async handle(message) {
40
+ return await this.dispatch(message);
41
+ }
42
+ dispatch(message) {
43
+ if (message.type === "command") return this.handleCommand(message);
44
+ if (message.type === "query") return this.handleQuery(message);
45
+ if (message.type === "event") return this.handleEvent(message);
46
+ return unreachable(message);
47
+ }
48
+ handleCommand(command) {
49
+ const handler = this.registry.commands[command.name];
50
+ if (handler === void 0) return panic(`No handler for command ${command.name}`);
51
+ logger.debug("handling_command", {
52
+ name: command.name,
53
+ id: command.id
54
+ });
55
+ return handler(command, this.deps);
56
+ }
57
+ handleQuery(query) {
58
+ const handler = this.registry.queries[query.name];
59
+ if (handler === void 0) return panic(`No handler for query ${query.name}`);
60
+ return handler(query, this.deps);
61
+ }
62
+ /**
63
+ * Every subscriber runs to completion. One failure is rethrown as is. Several are thrown as
64
+ * one `AggregateError`. A `MessageAlreadyProcessedError` means that subscriber already committed on
65
+ * an earlier delivery, so it counts as delivered.
66
+ */
67
+ async handleEvent(event) {
68
+ const subscriptions = this.registry.events[event.name] ?? [];
69
+ const outcomes = await Promise.allSettled(subscriptions.map((subscription) => subscription(event, this.deps)));
70
+ const failures = [];
71
+ for (const [subscriber, outcome] of outcomes.entries()) {
72
+ if (outcome.status === "fulfilled") continue;
73
+ if (outcome.reason instanceof MessageAlreadyProcessedError) {
74
+ logger.info("duplicate_subscriber_ignored", {
75
+ event: event.name,
76
+ id: event.id,
77
+ subscriber
78
+ });
79
+ continue;
80
+ }
81
+ failures.push(outcome.reason);
82
+ }
83
+ if (failures.length === 1) throw failures[0];
84
+ if (failures.length > 1) throw new AggregateError(failures, `${event.name}: ${failures.length} subscribers failed`);
85
+ }
86
+ };
87
+
88
+ //#endregion
89
+ //#region src/application/unit-of-work.ts
90
+ /** The unit of work: the boundary, the repositories, the events. Every database call is a port. */
91
+ var UnitOfWork = class {
92
+ deps;
93
+ constructor(deps) {
94
+ this.deps = deps;
95
+ }
96
+ async run(messageId, work) {
97
+ const { transaction, messageStore, createRepositories, eventCollector, relay } = this.deps;
98
+ let written = 0;
99
+ let value;
100
+ try {
101
+ value = await transaction.transaction(async (tx) => {
102
+ const store = messageStore(tx);
103
+ await store.claim(messageId);
104
+ const result = await work(createRepositories(tx));
105
+ const pending = eventCollector.collect();
106
+ await store.persistEvents(pending);
107
+ written = pending.length;
108
+ return result;
109
+ });
110
+ } catch (error) {
111
+ throw MessageAlreadyProcessedError.from(error, messageId) ?? error;
112
+ }
113
+ if (written > 0) relay.wake();
114
+ return value;
115
+ }
116
+ };
117
+
118
+ //#endregion
119
+ //#region src/application/unit-of-work.errors.ts
120
+ /** A boundary was opened inside another. One message, one atomic scope. */
121
+ var UnitOfWorkAlreadyActiveError = class extends AppError {
122
+ _tag = "UnitOfWorkAlreadyActiveError";
123
+ classification = "terminal";
124
+ constructor() {
125
+ super("a unit of work is already active");
126
+ }
127
+ };
128
+
129
+ //#endregion
130
+ export { MessageAlreadyProcessedError as i, UnitOfWork as n, MessageBus as r, UnitOfWorkAlreadyActiveError as t };
131
+ //# sourceMappingURL=application-BpSwCKJ5.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"application-BpSwCKJ5.js","names":[],"sources":["../src/application/message-already-processed.error.ts","../src/application/message-bus.ts","../src/application/unit-of-work.ts","../src/application/unit-of-work.errors.ts"],"sourcesContent":["import { AppError, DatabaseError } from '@/core'\n\nimport type { MessageId } from './messages.ts'\n\n/** The message's effects already committed. A caller can skip duplicate delivery without repeating those effects. */\nexport class MessageAlreadyProcessedError extends AppError {\n readonly _tag = 'MessageAlreadyProcessedError'\n readonly classification = 'terminal'\n\n constructor(\n readonly messageId: MessageId,\n options?: { readonly cause: unknown },\n ) {\n super(`message ${messageId} already processed`, options)\n }\n\n /** Recognize only the processed-message primary key; preserve other failures unchanged at the call site. */\n static from(cause: unknown, messageId: MessageId): MessageAlreadyProcessedError | undefined {\n if (cause instanceof MessageAlreadyProcessedError) return cause\n if (!(cause instanceof DatabaseError)) return undefined\n const details = cause.details\n const duplicate =\n details.database === 'd1'\n ? details.kind === 'unique' &&\n details.table === 'processed_messages' &&\n details.column === 'message_id'\n : details.code === '23505' &&\n details.table_name === 'processed_messages' &&\n details.constraint_name === 'processed_messages_pkey'\n if (!duplicate) return undefined\n return new MessageAlreadyProcessedError(messageId, { cause })\n }\n}\n","import { panic } from 'better-result'\n\nimport { createLogger, unreachable } from '@/core'\n\nimport { MessageAlreadyProcessedError } from './message-already-processed.error.ts'\nimport type { Command, Event, Message, Query } from './messages.ts'\nimport type { Handlers, MessageResult, RegistryMessage } from './registry.ts'\n\nconst logger = createLogger('message-bus')\n\n/** The port an entrypoint dispatches through. `AppDeps` names this as `IMessageBus<typeof registry>`. */\nexport interface IMessageBus<TRegistry extends Handlers<never>> {\n handle<TMessage extends Message & RegistryMessage<TRegistry>>(\n message: TMessage,\n ): Promise<MessageResult<TMessage, TRegistry>>\n}\n\n/**\n * Routes one message to its slot. The bus opens no transaction and keeps no outbox:\n * a handler that writes opens the unit of work itself.\n */\nexport class MessageBus<Deps, TRegistry extends Handlers<Deps>> implements IMessageBus<TRegistry> {\n constructor(\n private readonly deps: Deps,\n private readonly registry: TRegistry,\n ) {}\n\n /** Resolves to the handler's own value for a command or query, and to void for an event. */\n async handle<TMessage extends Message & RegistryMessage<TRegistry>>(\n message: TMessage,\n ): Promise<MessageResult<TMessage, TRegistry>> {\n // SAFETY: the slot for this name is what `MessageResult` names, and dispatch returns its value.\n return (await this.dispatch(message)) as MessageResult<TMessage, TRegistry>\n }\n\n // oxlint-disable-next-line anti-slop/no-unknown-returns -- one table holds every slot's value; `handle` names it.\n private dispatch(message: Message): Promise<unknown> {\n if (message.type === 'command') return this.handleCommand(message)\n if (message.type === 'query') return this.handleQuery(message)\n if (message.type === 'event') return this.handleEvent(message)\n // A message parsed at a boundary cannot reach here; one that does must not resolve silently.\n return unreachable(message)\n }\n\n // oxlint-disable-next-line anti-slop/no-unknown-returns -- see dispatch.\n private handleCommand(command: Command): Promise<unknown> {\n // `satisfies` proved the registry complete, so a missing slot is a broken registry object.\n const handler = this.registry.commands[command.name]\n if (handler === undefined) return panic(`No handler for command ${command.name}`)\n logger.debug('handling_command', { name: command.name, id: command.id })\n return handler(command, this.deps)\n }\n\n // oxlint-disable-next-line anti-slop/no-unknown-returns -- see dispatch.\n private handleQuery(query: Query): Promise<unknown> {\n const handler = this.registry.queries[query.name]\n if (handler === undefined) return panic(`No handler for query ${query.name}`)\n return handler(query, this.deps)\n }\n\n /**\n * Every subscriber runs to completion. One failure is rethrown as is. Several are thrown as\n * one `AggregateError`. A `MessageAlreadyProcessedError` means that subscriber already committed on\n * an earlier delivery, so it counts as delivered.\n */\n private async handleEvent(event: Event): Promise<void> {\n const subscriptions = this.registry.events[event.name] ?? []\n const outcomes = await Promise.allSettled(\n subscriptions.map((subscription) => subscription(event, this.deps)),\n )\n const failures: unknown[] = []\n for (const [subscriber, outcome] of outcomes.entries()) {\n if (outcome.status === 'fulfilled') continue\n if (outcome.reason instanceof MessageAlreadyProcessedError) {\n // The bus swallows this error, so this line is the only record that the skip happened.\n logger.info('duplicate_subscriber_ignored', { event: event.name, id: event.id, subscriber })\n continue\n }\n failures.push(outcome.reason)\n }\n if (failures.length === 1) throw failures[0]\n if (failures.length > 1) {\n throw new AggregateError(failures, `${event.name}: ${failures.length} subscribers failed`)\n }\n }\n}\n","import type { IEventCollector } from './event-collector.ts'\nimport { MessageAlreadyProcessedError } from './message-already-processed.error.ts'\nimport type { IMessageStore } from './message-store.ts'\nimport type { MessageId } from './messages.ts'\nimport type { IOutboxRelay } from './outbox-relay.ts'\nimport type { ITransaction } from './transaction.ts'\n\n/**\n * Commits the processed-message record, repository writes, and emitted events together before resolving.\n * An existing record rejects with `MessageAlreadyProcessedError`; external effects must run through the outbox after commit.\n * Nested calls reject with `UnitOfWorkAlreadyActiveError`; independent concurrent calls are allowed.\n */\nexport interface IUnitOfWork<Repos> {\n run<T>(messageId: MessageId, work: (repos: Repos) => Promise<T>): Promise<T>\n}\n\nexport type UnitOfWorkDeps<Tx, Repos> = {\n readonly transaction: ITransaction<Tx>\n /** The processed-messages and outbox repository for this transaction. */\n readonly messageStore: (tx: Tx) => IMessageStore\n /** Repositories participate in the current transaction. */\n readonly createRepositories: (tx: Tx) => Repos\n readonly eventCollector: IEventCollector\n readonly relay: IOutboxRelay\n}\n\n/** The unit of work: the boundary, the repositories, the events. Every database call is a port. */\nexport class UnitOfWork<Tx, Repos> implements IUnitOfWork<Repos> {\n constructor(private readonly deps: UnitOfWorkDeps<Tx, Repos>) {}\n\n async run<T>(messageId: MessageId, work: (repos: Repos) => Promise<T>): Promise<T> {\n const { transaction, messageStore, createRepositories, eventCollector, relay } = this.deps\n let written = 0\n\n let value: T\n try {\n value = await transaction.transaction(async (tx) => {\n const store = messageStore(tx)\n await store.claim(messageId)\n const repos = createRepositories(tx)\n const result = await work(repos)\n const pending = eventCollector.collect()\n await store.persistEvents(pending)\n written = pending.length\n return result\n })\n } catch (error) {\n throw MessageAlreadyProcessedError.from(error, messageId) ?? error\n }\n\n if (written > 0) relay.wake()\n return value\n }\n}\n","import { AppError } from '@/core'\n\n/** A boundary was opened inside another. One message, one atomic scope. */\nexport class UnitOfWorkAlreadyActiveError extends AppError {\n readonly _tag = 'UnitOfWorkAlreadyActiveError'\n readonly classification = 'terminal'\n\n constructor() {\n super('a unit of work is already active')\n }\n}\n"],"mappings":"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"}
@@ -1,127 +1,2 @@
1
- import { w as AppError } from "./index-eL4vb02m.js";
2
- //#region src/application/messages.d.ts
3
- /**
4
- * The identity of a command or event. Receipts and the outbox are keyed by it.
5
- *
6
- * Internal messages use UUID v7, so receipts sort by creation. External messages use the
7
- * provider's own key, `stripe:event:evt_123`, so a redelivered webhook is a duplicate by
8
- * construction. The app parses and generates ids; this package only carries them.
9
- */
10
- type MessageId = string;
11
- /** An action to perform. One handler, returns a value. The product adds its payload. */
12
- type Command<Name extends string = string> = {
13
- readonly type: 'command';
14
- readonly name: Name;
15
- readonly id: MessageId;
16
- };
17
- /** A request for data. One handler, returns a value. No id: nothing claims a read. */
18
- type Query<Name extends string = string> = {
19
- readonly type: 'query';
20
- readonly name: Name;
21
- };
22
- /** A fact that happened. Zero or more subscribers, returns nothing. The product adds its payload. */
23
- type Event<Name extends string = string> = {
24
- readonly type: 'event';
25
- readonly name: Name;
26
- readonly id: MessageId;
27
- };
28
- /** The routing fields the bus reads. `type` selects the table, `name` selects the slot. */
29
- type Message = Command | Event | Query;
30
- //#endregion
31
- //#region src/application/message-bus.errors.d.ts
32
- /**
33
- * The unit of work throws this when a message's effects already committed.
34
- * The bus counts an event subscriber that throws it as delivered. From a command it reaches the caller.
35
- */
36
- export declare class DuplicateMessageError extends AppError {
37
- readonly messageId: MessageId;
38
- readonly _tag = "DuplicateMessageError";
39
- readonly classification = "terminal";
40
- constructor(messageId: MessageId);
41
- }
42
- //#endregion
43
- //#region src/application/handlers.d.ts
44
- /**
45
- * Handles one command with the dependency container and returns the caller's value.
46
- * A handler that writes inside a transaction opens the unit of work itself. Failures throw.
47
- */
48
- type CommandHandler<TCommand extends Command, Deps, Value> = (command: TCommand, deps: Deps) => Promise<Value>;
49
- /**
50
- * Handles one query with the read-only view of the container.
51
- * The view is a type over the same object, so a query cannot name a write capability.
52
- */
53
- type QueryHandler<TQuery extends Query, QueryDeps, Value> = (query: TQuery, deps: QueryDeps) => Promise<Value>;
54
- /** Completes one subscriber's work for an event. Failures throw. */
55
- type EventHandler<TEvent extends Event, Deps> = (event: TEvent, deps: Deps) => Promise<void>;
56
- //#endregion
57
- //#region src/application/registry.d.ts
58
- /** One slot per command. `unknown` keeps the slot covariant; `MessageResult` recovers the value. */
59
- type CommandRegistry<TCommand extends Command, Deps> = { readonly [M in TCommand as M['name']]: CommandHandler<M, Deps, unknown>; };
60
- /** One slot per query, typed with the read-only view of the container. */
61
- type QueryRegistry<TQuery extends Query, QueryDeps> = { readonly [M in TQuery as M['name']]: QueryHandler<M, QueryDeps, unknown>; };
62
- /**
63
- * Zero or more subscribers per event. Annotate the object with this type rather than
64
- * `satisfies`, so an event with no subscribers is still a message the bus accepts.
65
- */
66
- type EventRegistry<TEvent extends Event, Deps> = { readonly [M in TEvent as M['name']]?: ReadonlyArray<EventHandler<M, Deps>>; };
67
- /**
68
- * The app's three tables. `satisfies MessageRegistry<AppMessage, AppDeps, AppQueryDeps>`
69
- * makes a missing handler a compile error. `Deps extends QueryDeps` is checked here, the one
70
- * place both types meet: the read view must be a subset of the container.
71
- */
72
- type MessageRegistry<TMessage extends Message, Deps extends QueryDeps, QueryDeps> = {
73
- readonly commands: CommandRegistry<Extract<TMessage, Command>, Deps>;
74
- readonly queries: QueryRegistry<Extract<TMessage, Query>, QueryDeps>;
75
- readonly events: EventRegistry<Extract<TMessage, Event>, Deps>;
76
- };
77
- type Slot<Deps> = {
78
- handle(message: Message, deps: Deps): Promise<unknown>;
79
- }['handle'];
80
- type Subscription<Deps> = {
81
- handle(event: Event, deps: Deps): Promise<void>;
82
- }['handle'];
83
- /**
84
- * The registry as the bus routes it: each table keyed by name. The app's names are unknown
85
- * inside the package, so this is what a runtime string can index.
86
- */
87
- type Handlers<Deps> = {
88
- readonly commands: Readonly<Record<string, Slot<Deps>>>;
89
- readonly queries: Readonly<Record<string, Slot<Deps>>>;
90
- readonly events: Readonly<Partial<Record<string, ReadonlyArray<Subscription<Deps>>>>>;
91
- };
92
- type SlotMessage<TSlots> = Parameters<Extract<TSlots[keyof TSlots], Slot<never>>>[0];
93
- type SubscriptionMessage<TSlots> = NonNullable<TSlots[keyof TSlots]> extends ReadonlyArray<infer TSubscription> ? Parameters<Extract<TSubscription, Subscription<never>>>[0] : never;
94
- /** The messages a registry has a slot for, read off each slot's first parameter. */
95
- type RegistryMessage<TRegistry extends Handlers<never>> = SlotMessage<TRegistry['commands']> | SlotMessage<TRegistry['queries']> | SubscriptionMessage<TRegistry['events']>;
96
- type SlotValue<TSlots, TName extends string> = TName extends keyof TSlots ? Awaited<ReturnType<Extract<TSlots[TName], Slot<never>>>> : never;
97
- /** The value `bus.handle(message)` resolves to: the slot's own return type, or void for an event. */
98
- type MessageResult<TMessage extends Message, TRegistry extends Handlers<never>> = TMessage extends Command ? SlotValue<TRegistry['commands'], TMessage['name']> : TMessage extends Query ? SlotValue<TRegistry['queries'], TMessage['name']> : void;
99
- //#endregion
100
- //#region src/application/message-bus.d.ts
101
- /** The port an entrypoint dispatches through. `AppDeps` names this as `IMessageBus<typeof registry>`. */
102
- interface IMessageBus<TRegistry extends Handlers<never>> {
103
- handle<TMessage extends Message & RegistryMessage<TRegistry>>(message: TMessage): Promise<MessageResult<TMessage, TRegistry>>;
104
- }
105
- /**
106
- * Routes one message to its slot. The bus opens no transaction and keeps no outbox:
107
- * a handler that writes opens the unit of work itself.
108
- */
109
- export declare class MessageBus<Deps, TRegistry extends Handlers<Deps>> implements IMessageBus<TRegistry> {
110
- private readonly deps;
111
- private readonly registry;
112
- constructor(deps: Deps, registry: TRegistry);
113
- /** Resolves to the handler's own value for a command or query, and to void for an event. */
114
- handle<TMessage extends Message & RegistryMessage<TRegistry>>(message: TMessage): Promise<MessageResult<TMessage, TRegistry>>;
115
- private dispatch;
116
- private handleCommand;
117
- private handleQuery;
118
- /**
119
- * Every subscriber runs to completion. One failure is rethrown as is. Several are thrown as
120
- * one `AggregateError`. A `DuplicateMessageError` means that subscriber already committed on
121
- * an earlier delivery, so it counts as delivered.
122
- */
123
- private handleEvent;
124
- }
125
- //#endregion
126
- export type { Command, CommandHandler, CommandRegistry, Event, EventHandler, EventRegistry, Handlers, IMessageBus, Message, MessageId, MessageRegistry, MessageResult, Query, QueryHandler, QueryRegistry, RegistryMessage };
127
- //# sourceMappingURL=application.d.ts.map
1
+ import { C as Event, E as Query, S as Command, T as MessageId, _ as RegistryMessage, a as UnitOfWorkDeps, b as EventHandler, c as IOutboxRelay, d as CommandRegistry, f as EventRegistry, g as QueryRegistry, h as MessageResult, i as UnitOfWork, l as IMessageBus, m as MessageRegistry, n as MessageAlreadyProcessedError, o as IEventCollector, p as Handlers, r as IUnitOfWork, s as ITransaction, t as UnitOfWorkAlreadyActiveError, u as MessageBus, v as IMessageStore, w as Message, x as QueryHandler, y as CommandHandler } from "./index-CkaFqbbV.js";
2
+ export { type Command, type CommandHandler, type CommandRegistry, type Event, type EventHandler, type EventRegistry, type Handlers, type IEventCollector, type IMessageBus, type IMessageStore, type IOutboxRelay, type ITransaction, type IUnitOfWork, type Message, MessageAlreadyProcessedError, MessageBus, type MessageId, type MessageRegistry, type MessageResult, type Query, type QueryHandler, type QueryRegistry, type RegistryMessage, UnitOfWork, UnitOfWorkAlreadyActiveError, type UnitOfWorkDeps };
@@ -1,85 +1,3 @@
1
- import { a as createLogger, f as unreachable, p as AppError } from "./core-D0asUCvq.js";
2
- import { panic } from "better-result";
1
+ import { i as MessageAlreadyProcessedError, n as UnitOfWork, r as MessageBus, t as UnitOfWorkAlreadyActiveError } from "./application-BpSwCKJ5.js";
3
2
 
4
- //#region src/application/message-bus.errors.ts
5
- /**
6
- * The unit of work throws this when a message's effects already committed.
7
- * The bus counts an event subscriber that throws it as delivered. From a command it reaches the caller.
8
- */
9
- var DuplicateMessageError = class extends AppError {
10
- messageId;
11
- _tag = "DuplicateMessageError";
12
- classification = "terminal";
13
- constructor(messageId) {
14
- super(`message ${messageId} already committed`);
15
- this.messageId = messageId;
16
- }
17
- };
18
-
19
- //#endregion
20
- //#region src/application/message-bus.ts
21
- const logger = createLogger("message-bus");
22
- /**
23
- * Routes one message to its slot. The bus opens no transaction and keeps no outbox:
24
- * a handler that writes opens the unit of work itself.
25
- */
26
- var MessageBus = class {
27
- deps;
28
- registry;
29
- constructor(deps, registry) {
30
- this.deps = deps;
31
- this.registry = registry;
32
- }
33
- /** Resolves to the handler's own value for a command or query, and to void for an event. */
34
- async handle(message) {
35
- return await this.dispatch(message);
36
- }
37
- dispatch(message) {
38
- if (message.type === "command") return this.handleCommand(message);
39
- if (message.type === "query") return this.handleQuery(message);
40
- if (message.type === "event") return this.handleEvent(message);
41
- return unreachable(message);
42
- }
43
- handleCommand(command) {
44
- const handler = this.registry.commands[command.name];
45
- if (handler === void 0) return panic(`No handler for command ${command.name}`);
46
- logger.debug("handling_command", {
47
- name: command.name,
48
- id: command.id
49
- });
50
- return handler(command, this.deps);
51
- }
52
- handleQuery(query) {
53
- const handler = this.registry.queries[query.name];
54
- if (handler === void 0) return panic(`No handler for query ${query.name}`);
55
- return handler(query, this.deps);
56
- }
57
- /**
58
- * Every subscriber runs to completion. One failure is rethrown as is. Several are thrown as
59
- * one `AggregateError`. A `DuplicateMessageError` means that subscriber already committed on
60
- * an earlier delivery, so it counts as delivered.
61
- */
62
- async handleEvent(event) {
63
- const subscriptions = this.registry.events[event.name] ?? [];
64
- const outcomes = await Promise.allSettled(subscriptions.map((subscription) => subscription(event, this.deps)));
65
- const failures = [];
66
- for (const [subscriber, outcome] of outcomes.entries()) {
67
- if (outcome.status === "fulfilled") continue;
68
- if (outcome.reason instanceof DuplicateMessageError) {
69
- logger.info("duplicate_subscriber_ignored", {
70
- event: event.name,
71
- id: event.id,
72
- subscriber
73
- });
74
- continue;
75
- }
76
- failures.push(outcome.reason);
77
- }
78
- if (failures.length === 1) throw failures[0];
79
- if (failures.length > 1) throw new AggregateError(failures, `${event.name}: ${failures.length} subscribers failed`);
80
- }
81
- };
82
-
83
- //#endregion
84
- export { DuplicateMessageError, MessageBus };
85
- //# sourceMappingURL=application.js.map
3
+ export { MessageAlreadyProcessedError, MessageBus, UnitOfWork, UnitOfWorkAlreadyActiveError };
@@ -1 +1,301 @@
1
- export {}
1
+ import { C as Event, T as MessageId, s as ITransaction, v as IMessageStore } from "./index-CkaFqbbV.js";
2
+ import { E as DatabaseFailure, T as DatabaseError, w as D1ConstraintKind } from "./index-CT1yZd4D.js";
3
+ import { n as DrizzleSchema, t as eventCollector } from "./unit-of-work-context-DOkIB7HK.js";
4
+ import { BatchItem } from "drizzle-orm/batch";
5
+ import { AnyD1Database, DrizzleD1Database } from "drizzle-orm/d1";
6
+ //#region src/infrastructure/cloudflare/d1-database-types.d.ts
7
+ /** A D1 binding or local D1 client accepted by Drizzle. */
8
+ type D1Connection = AnyD1Database;
9
+ /** The application's Drizzle D1 database, preserving its schema and relational query types. */
10
+ type D1Db<Schema extends DrizzleSchema> = DrizzleD1Database<Schema>;
11
+ /** Exposes Drizzle query methods without insert, update, delete, batch, or raw execution methods. */
12
+ type D1ReadDb<Schema extends DrizzleSchema> = Pick<D1Db<Schema>, 'select' | 'selectDistinct' | 'query'>;
13
+ /** D1 reads use this database; repository writes join the active batch through the unit-of-work context. */
14
+ type D1Tx<Schema extends DrizzleSchema> = {
15
+ readonly db: D1Db<Schema>;
16
+ };
17
+ //#endregion
18
+ //#region src/infrastructure/cloudflare/base-d1-repository.d.ts
19
+ /** A domain aggregate that accumulates events for the unit of work to drain. */
20
+ interface AggregateEvents {
21
+ collectEvents(): ReadonlyArray<Event>;
22
+ clearEvents(): void;
23
+ }
24
+ /**
25
+ * Shared plumbing for a repository that writes inside a D1 unit of work.
26
+ * Writes are staged, never run; the boundary commits them as one batch. Reads run on `db` now.
27
+ */
28
+ export declare abstract class BaseD1Repository<Schema extends DrizzleSchema> {
29
+ protected readonly db: D1Db<Schema>;
30
+ constructor(db: D1Db<Schema>);
31
+ /** Add a statement to the current D1 batch without executing it. */
32
+ protected stage(statement: BatchItem<'sqlite'>): void;
33
+ /** Execute one read with database error conversion and no automatic retries. */
34
+ protected read<T>(operation: string, query: () => PromiseLike<T>): Promise<T>;
35
+ /** Drain an aggregate's pending events into the active boundary. */
36
+ protected dispatchEvents(aggregate: AggregateEvents): void;
37
+ }
38
+ //#endregion
39
+ //#region src/infrastructure/cloudflare/classify-d1-error.d.ts
40
+ /**
41
+ * Recognize D1 failures and preserve the original cause. Unrecognized callback exceptions remain unchanged.
42
+ * D1 exposes error codes and constraint locations in text, so parsing stays at this boundary.
43
+ */
44
+ export declare function classifyD1Error(cause: unknown, operation?: string): DatabaseError | undefined;
45
+ //#endregion
46
+ //#region src/infrastructure/cloudflare/d1-message-store.d.ts
47
+ /** Processed messages and outbox events share one D1 batch; the primary key prevents concurrent duplicate commits. */
48
+ export declare class D1MessageStore<Schema extends DrizzleSchema> extends BaseD1Repository<Schema> implements IMessageStore {
49
+ claim(messageId: MessageId): Promise<void>;
50
+ persistEvents(events: ReadonlyArray<Event>): Promise<void>;
51
+ }
52
+ //#endregion
53
+ //#region src/infrastructure/cloudflare/d1-schema.d.ts
54
+ /** One row per committed message. The primary key is the idempotency claim. */
55
+ export declare const processedMessages: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
56
+ name: "processed_messages";
57
+ schema: undefined;
58
+ columns: {
59
+ messageId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
60
+ name: "message_id";
61
+ tableName: "processed_messages";
62
+ dataType: "string";
63
+ columnType: "SQLiteText";
64
+ data: string;
65
+ driverParam: string;
66
+ notNull: true;
67
+ hasDefault: false;
68
+ isPrimaryKey: true;
69
+ isAutoincrement: false;
70
+ hasRuntimeDefault: false;
71
+ enumValues: [string, ...string[]];
72
+ baseColumn: never;
73
+ identity: undefined;
74
+ generated: undefined;
75
+ }, {}, {
76
+ length: number | undefined;
77
+ }>;
78
+ processedAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
79
+ name: "processed_at";
80
+ tableName: "processed_messages";
81
+ dataType: "date";
82
+ columnType: "SQLiteTimestamp";
83
+ data: Date;
84
+ driverParam: number;
85
+ notNull: true;
86
+ hasDefault: true;
87
+ isPrimaryKey: false;
88
+ isAutoincrement: false;
89
+ hasRuntimeDefault: true;
90
+ enumValues: undefined;
91
+ baseColumn: never;
92
+ identity: undefined;
93
+ generated: undefined;
94
+ }, {}, {}>;
95
+ };
96
+ dialect: 'sqlite';
97
+ }>;
98
+ /** Events committed with their state change, waiting for the relay to deliver them. */
99
+ export declare const messageOutbox: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
100
+ name: "message_outbox";
101
+ schema: undefined;
102
+ columns: {
103
+ id: import("drizzle-orm/sqlite-core").SQLiteColumn<{
104
+ name: "id";
105
+ tableName: "message_outbox";
106
+ dataType: "string";
107
+ columnType: "SQLiteText";
108
+ data: string;
109
+ driverParam: string;
110
+ notNull: true;
111
+ hasDefault: false;
112
+ isPrimaryKey: true;
113
+ isAutoincrement: false;
114
+ hasRuntimeDefault: false;
115
+ enumValues: [string, ...string[]];
116
+ baseColumn: never;
117
+ identity: undefined;
118
+ generated: undefined;
119
+ }, {}, {
120
+ length: number | undefined;
121
+ }>;
122
+ payload: import("drizzle-orm/sqlite-core").SQLiteColumn<{
123
+ name: "payload";
124
+ tableName: "message_outbox";
125
+ dataType: "json";
126
+ columnType: "SQLiteTextJson";
127
+ data: Event;
128
+ driverParam: string;
129
+ notNull: true;
130
+ hasDefault: false;
131
+ isPrimaryKey: false;
132
+ isAutoincrement: false;
133
+ hasRuntimeDefault: false;
134
+ enumValues: undefined;
135
+ baseColumn: never;
136
+ identity: undefined;
137
+ generated: undefined;
138
+ }, {}, {
139
+ $type: Event;
140
+ }>;
141
+ createdAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
142
+ name: "created_at";
143
+ tableName: "message_outbox";
144
+ dataType: "date";
145
+ columnType: "SQLiteTimestamp";
146
+ data: Date;
147
+ driverParam: number;
148
+ notNull: true;
149
+ hasDefault: true;
150
+ isPrimaryKey: false;
151
+ isAutoincrement: false;
152
+ hasRuntimeDefault: true;
153
+ enumValues: undefined;
154
+ baseColumn: never;
155
+ identity: undefined;
156
+ generated: undefined;
157
+ }, {}, {}>;
158
+ availableAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
159
+ name: "available_at";
160
+ tableName: "message_outbox";
161
+ dataType: "date";
162
+ columnType: "SQLiteTimestamp";
163
+ data: Date;
164
+ driverParam: number;
165
+ notNull: true;
166
+ hasDefault: true;
167
+ isPrimaryKey: false;
168
+ isAutoincrement: false;
169
+ hasRuntimeDefault: true;
170
+ enumValues: undefined;
171
+ baseColumn: never;
172
+ identity: undefined;
173
+ generated: undefined;
174
+ }, {}, {}>;
175
+ attempts: import("drizzle-orm/sqlite-core").SQLiteColumn<{
176
+ name: "attempts";
177
+ tableName: "message_outbox";
178
+ dataType: "number";
179
+ columnType: "SQLiteInteger";
180
+ data: number;
181
+ driverParam: number;
182
+ notNull: true;
183
+ hasDefault: true;
184
+ isPrimaryKey: false;
185
+ isAutoincrement: false;
186
+ hasRuntimeDefault: false;
187
+ enumValues: undefined;
188
+ baseColumn: never;
189
+ identity: undefined;
190
+ generated: undefined;
191
+ }, {}, {}>;
192
+ claimToken: import("drizzle-orm/sqlite-core").SQLiteColumn<{
193
+ name: "claim_token";
194
+ tableName: "message_outbox";
195
+ dataType: "string";
196
+ columnType: "SQLiteText";
197
+ data: string;
198
+ driverParam: string;
199
+ notNull: false;
200
+ hasDefault: false;
201
+ isPrimaryKey: false;
202
+ isAutoincrement: false;
203
+ hasRuntimeDefault: false;
204
+ enumValues: [string, ...string[]];
205
+ baseColumn: never;
206
+ identity: undefined;
207
+ generated: undefined;
208
+ }, {}, {
209
+ length: number | undefined;
210
+ }>;
211
+ claimExpiresAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
212
+ name: "claim_expires_at";
213
+ tableName: "message_outbox";
214
+ dataType: "date";
215
+ columnType: "SQLiteTimestamp";
216
+ data: Date;
217
+ driverParam: number;
218
+ notNull: false;
219
+ hasDefault: false;
220
+ isPrimaryKey: false;
221
+ isAutoincrement: false;
222
+ hasRuntimeDefault: false;
223
+ enumValues: undefined;
224
+ baseColumn: never;
225
+ identity: undefined;
226
+ generated: undefined;
227
+ }, {}, {}>;
228
+ publishedAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
229
+ name: "published_at";
230
+ tableName: "message_outbox";
231
+ dataType: "date";
232
+ columnType: "SQLiteTimestamp";
233
+ data: Date;
234
+ driverParam: number;
235
+ notNull: false;
236
+ hasDefault: false;
237
+ isPrimaryKey: false;
238
+ isAutoincrement: false;
239
+ hasRuntimeDefault: false;
240
+ enumValues: undefined;
241
+ baseColumn: never;
242
+ identity: undefined;
243
+ generated: undefined;
244
+ }, {}, {}>;
245
+ failedAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
246
+ name: "failed_at";
247
+ tableName: "message_outbox";
248
+ dataType: "date";
249
+ columnType: "SQLiteTimestamp";
250
+ data: Date;
251
+ driverParam: number;
252
+ notNull: false;
253
+ hasDefault: false;
254
+ isPrimaryKey: false;
255
+ isAutoincrement: false;
256
+ hasRuntimeDefault: false;
257
+ enumValues: undefined;
258
+ baseColumn: never;
259
+ identity: undefined;
260
+ generated: undefined;
261
+ }, {}, {}>;
262
+ lastError: import("drizzle-orm/sqlite-core").SQLiteColumn<{
263
+ name: "last_error";
264
+ tableName: "message_outbox";
265
+ dataType: "string";
266
+ columnType: "SQLiteText";
267
+ data: string;
268
+ driverParam: string;
269
+ notNull: false;
270
+ hasDefault: false;
271
+ isPrimaryKey: false;
272
+ isAutoincrement: false;
273
+ hasRuntimeDefault: false;
274
+ enumValues: [string, ...string[]];
275
+ baseColumn: never;
276
+ identity: undefined;
277
+ generated: undefined;
278
+ }, {}, {
279
+ length: number | undefined;
280
+ }>;
281
+ };
282
+ dialect: 'sqlite';
283
+ }>;
284
+ //#endregion
285
+ //#region src/infrastructure/cloudflare/d1-batch-transaction.d.ts
286
+ /**
287
+ * The D1 transaction boundary: every write is staged during `work` and committed as one
288
+ * `db.batch()`. A throw inside `work` means the batch never runs.
289
+ */
290
+ export declare class D1BatchTransaction<Schema extends DrizzleSchema> implements ITransaction<D1Tx<Schema>> {
291
+ private readonly db;
292
+ constructor(db: D1Db<Schema>);
293
+ transaction<T>(work: (tx: D1Tx<Schema>) => Promise<T>): Promise<T>;
294
+ }
295
+ //#endregion
296
+ //#region src/infrastructure/cloudflare/d1-database-execution.d.ts
297
+ /** Execute once without retries; convert recognized D1 failures and preserve other exceptions unchanged. */
298
+ export declare function executeDatabase<T>(operation: string, work: () => PromiseLike<T>): Promise<T>;
299
+ //#endregion
300
+ export { type AggregateEvents, type D1Connection, type D1ConstraintKind, type D1Db, type D1ReadDb, type D1Tx, DatabaseError, type DatabaseFailure, eventCollector };
301
+ //# sourceMappingURL=cloudflare.d.ts.map