better-ship 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{application-C4JOf71r.js → application-Cf9SER-i.js} +2 -2
- package/dist/application-Cf9SER-i.js.map +1 -0
- package/dist/application.d.ts +2 -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.js +1 -1
- package/dist/cloudflare/auth-secondary-storage.d.ts +1 -1
- package/dist/cloudflare/auth-secondary-storage.js +1 -1
- package/dist/cloudflare/durable-object-client.d.ts +18 -0
- package/dist/cloudflare/durable-object-client.d.ts.map +1 -0
- package/dist/cloudflare/durable-object-client.js +46 -0
- package/dist/cloudflare/durable-object-client.js.map +1 -0
- package/dist/cloudflare/durable-object-errors.d.ts +40 -0
- package/dist/cloudflare/durable-object-errors.d.ts.map +1 -0
- package/dist/cloudflare/durable-object-errors.js +48 -0
- package/dist/cloudflare/durable-object-errors.js.map +1 -0
- package/dist/cloudflare/queue.d.ts +2 -2
- package/dist/cloudflare/queue.js +2 -2
- package/dist/cloudflare/scheduled.d.ts +1 -1
- package/dist/cloudflare/scheduled.js +1 -1
- package/dist/cloudflare.d.ts +3 -3
- package/dist/cloudflare.js +3 -3
- package/dist/{core-hdaNiCOU.js → core-ClSP5-V9.js} +94 -44
- package/dist/core-ClSP5-V9.js.map +1 -0
- package/dist/core.d.ts +2 -2
- package/dist/core.js +2 -2
- package/dist/http.d.ts +2 -2
- package/dist/http.js +2 -2
- package/dist/{index-C28HgVfY.d.ts → index-Cchdg2aQ.d.ts} +4 -4
- package/dist/{index-C28HgVfY.d.ts.map → index-Cchdg2aQ.d.ts.map} +1 -1
- package/dist/{index-G1QG0P5g.d.ts → index-DvJUcSW1.d.ts} +102 -48
- package/dist/{index-G1QG0P5g.d.ts.map → index-DvJUcSW1.d.ts.map} +1 -1
- package/dist/{outbox-repository-options-BZjfDqGf.d.ts → outbox-repository-options-AkihEaik.d.ts} +3 -3
- package/dist/{outbox-repository-options-BZjfDqGf.d.ts.map → outbox-repository-options-AkihEaik.d.ts.map} +1 -1
- package/dist/{outbox-repository-options-DOTpJH-e.js → outbox-repository-options-ICOF8g2h.js} +2 -2
- package/dist/{outbox-repository-options-DOTpJH-e.js.map → outbox-repository-options-ICOF8g2h.js.map} +1 -1
- package/dist/postgres.d.ts +3 -3
- package/dist/postgres.js +3 -3
- package/dist/posthog/metrics.d.ts +1 -1
- package/dist/posthog/server.d.ts +2 -2
- package/dist/posthog/server.js +1 -1
- package/dist/resend.d.ts +3 -3
- package/dist/resend.js +3 -3
- package/dist/resend.js.map +1 -1
- package/dist/sentry/client.d.ts +1 -1
- package/dist/sentry/client.js +1 -1
- package/dist/sentry/server.d.ts +2 -2
- package/dist/tanstack.d.ts +1 -1
- package/dist/tanstack.js +1 -1
- package/dist/testing/outbox-repository.d.ts +2 -2
- package/dist/{webhook-verification-D7DIhH6C.d.ts → webhook-verification-BcQQG-vY.d.ts} +2 -2
- package/dist/{webhook-verification-D7DIhH6C.d.ts.map → webhook-verification-BcQQG-vY.d.ts.map} +1 -1
- package/dist/{webhook-verification-trEgyz7I.js → webhook-verification-D6aGQfNi.js} +2 -2
- package/dist/{webhook-verification-trEgyz7I.js.map → webhook-verification-D6aGQfNi.js.map} +1 -1
- package/package.json +3 -1
- package/src/application/email-service.ts +2 -2
- package/src/core/define-messages.ts +209 -103
- package/src/core/index.ts +3 -1
- package/src/core/messages.ts +1 -1
- package/src/infrastructure/cloudflare/durable-object-client.ts +70 -0
- package/src/infrastructure/cloudflare/durable-object-errors.ts +70 -0
- package/src/infrastructure/resend/resend-email-service.ts +4 -1
- package/dist/application-C4JOf71r.js.map +0 -1
- package/dist/core-hdaNiCOU.js.map +0 -1
|
@@ -1,136 +1,242 @@
|
|
|
1
|
+
import { panic } from 'better-result'
|
|
1
2
|
import { v7 as uuidv7 } from 'uuid'
|
|
2
3
|
import { z } from 'zod'
|
|
3
4
|
|
|
4
|
-
import
|
|
5
|
+
import { AppError } from './app-error.ts'
|
|
6
|
+
import type { MessageId } from './messages.ts'
|
|
5
7
|
|
|
6
|
-
/** Payload schemas keyed by message name.
|
|
8
|
+
/** Payload schemas keyed by message name. Payloads cannot declare `type`, `name`, or `id`. */
|
|
7
9
|
export type PayloadSchemas = Readonly<Record<string, z.ZodType<object>>>
|
|
8
10
|
|
|
9
|
-
/**
|
|
11
|
+
/** Optional payload schema maps for one message collection. */
|
|
10
12
|
export type MessageDefinition = {
|
|
11
|
-
readonly commands
|
|
12
|
-
readonly events
|
|
13
|
-
readonly queries
|
|
13
|
+
readonly commands?: PayloadSchemas
|
|
14
|
+
readonly events?: PayloadSchemas
|
|
15
|
+
readonly queries?: PayloadSchemas
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
Payload<Def<Messages>['commands'], Name>
|
|
24
|
-
}[keyof Def<Messages>['commands'] & string]
|
|
25
|
-
|
|
26
|
-
/** Every event of an application: `EventOf<typeof messages>`. */
|
|
27
|
-
export type EventOf<Messages extends AnyMessages> = {
|
|
28
|
-
[Name in keyof Def<Messages>['events'] & string]: DomainEvent<Name> &
|
|
29
|
-
Payload<Def<Messages>['events'], Name>
|
|
30
|
-
}[keyof Def<Messages>['events'] & string]
|
|
31
|
-
|
|
32
|
-
/** Every query of an application: `QueryOf<typeof messages>`. */
|
|
33
|
-
export type QueryOf<Messages extends AnyMessages> = {
|
|
34
|
-
[Name in keyof Def<Messages>['queries'] & string]: DomainQuery<Name> &
|
|
35
|
-
Payload<Def<Messages>['queries'], Name>
|
|
36
|
-
}[keyof Def<Messages>['queries'] & string]
|
|
37
|
-
|
|
38
|
-
/** Every message of an application: what the registry and the bus are typed with. */
|
|
39
|
-
export type MessageOf<Messages extends AnyMessages> =
|
|
40
|
-
| CommandOf<Messages>
|
|
41
|
-
| EventOf<Messages>
|
|
42
|
-
| QueryOf<Messages>
|
|
18
|
+
const completed = Symbol('MessageCollection')
|
|
19
|
+
const MessageIdSchema = z.string().min(1)
|
|
20
|
+
const envelopes = {
|
|
21
|
+
command: z.object({ type: z.literal('command'), id: MessageIdSchema }),
|
|
22
|
+
event: z.object({ type: z.literal('event'), id: MessageIdSchema }),
|
|
23
|
+
query: z.object({ type: z.literal('query') }),
|
|
24
|
+
}
|
|
43
25
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
readonly
|
|
50
|
-
|
|
51
|
-
|
|
26
|
+
type MessageKind = keyof typeof envelopes
|
|
27
|
+
type RoutingField = 'type' | 'name' | 'id'
|
|
28
|
+
type SchemaMap = Readonly<Record<string, z.ZodType<object>>>
|
|
29
|
+
type EmptySchemas = Record<never, never>
|
|
30
|
+
type SchemaMaps = {
|
|
31
|
+
readonly commandSchemas: SchemaMap
|
|
32
|
+
readonly eventSchemas: SchemaMap
|
|
33
|
+
readonly querySchemas: SchemaMap
|
|
34
|
+
}
|
|
35
|
+
type CompletedMessages = SchemaMaps & { readonly [completed]: true }
|
|
36
|
+
type SchemaOf<Schemas extends SchemaMap> = Schemas[keyof Schemas & string]
|
|
37
|
+
type Payload<Message> = Message extends object ? Omit<Message, RoutingField> : never
|
|
38
|
+
|
|
39
|
+
/** Complete schemas, union schemas, and factories for one message collection. */
|
|
40
|
+
export type MessageCollection<Maps extends SchemaMaps> = Maps & {
|
|
41
|
+
readonly [completed]: true
|
|
42
|
+
readonly commandSchema: z.ZodUnion<SchemaOf<Maps['commandSchemas']>[]>
|
|
43
|
+
readonly eventSchema: z.ZodUnion<SchemaOf<Maps['eventSchemas']>[]>
|
|
44
|
+
readonly querySchema: z.ZodUnion<SchemaOf<Maps['querySchemas']>[]>
|
|
45
|
+
/** Commands and events can cross a queue. Queries cannot. */
|
|
46
|
+
readonly queueSchema: z.ZodUnion<
|
|
47
|
+
(SchemaOf<Maps['commandSchemas']> | SchemaOf<Maps['eventSchemas']>)[]
|
|
48
|
+
>
|
|
49
|
+
/** Add the command envelope without parsing. The default ID is UUID v7; supplied IDs stay unchanged. */
|
|
50
|
+
createCommand<Name extends keyof Maps['commandSchemas'] & string>(
|
|
51
|
+
this: void,
|
|
52
52
|
name: Name,
|
|
53
|
-
payload: Payload<
|
|
53
|
+
payload: Payload<z.output<Maps['commandSchemas'][NoInfer<Name>]>>,
|
|
54
54
|
id?: MessageId,
|
|
55
|
-
):
|
|
56
|
-
/**
|
|
57
|
-
createEvent<Name extends keyof
|
|
55
|
+
): z.output<Maps['commandSchemas'][Name]>
|
|
56
|
+
/** Add the event envelope without parsing. The default ID is UUID v7; supplied IDs stay unchanged. */
|
|
57
|
+
createEvent<Name extends keyof Maps['eventSchemas'] & string>(
|
|
58
|
+
this: void,
|
|
58
59
|
name: Name,
|
|
59
|
-
payload: Payload<
|
|
60
|
+
payload: Payload<z.output<Maps['eventSchemas'][NoInfer<Name>]>>,
|
|
60
61
|
id?: MessageId,
|
|
61
|
-
):
|
|
62
|
-
/**
|
|
63
|
-
createQuery<Name extends keyof
|
|
62
|
+
): z.output<Maps['eventSchemas'][Name]>
|
|
63
|
+
/** Add the query envelope without parsing. Queries have no ID. */
|
|
64
|
+
createQuery<Name extends keyof Maps['querySchemas'] & string>(
|
|
65
|
+
this: void,
|
|
64
66
|
name: Name,
|
|
65
|
-
payload: Payload<
|
|
66
|
-
):
|
|
67
|
-
readonly commandSchema: z.ZodType<CommandOf<AppMessages<Definition>>>
|
|
68
|
-
readonly eventSchema: z.ZodType<EventOf<AppMessages<Definition>>>
|
|
69
|
-
readonly querySchema: z.ZodType<QueryOf<AppMessages<Definition>>>
|
|
70
|
-
/** What a queue body may be: a command or an event. Queries never cross a queue. */
|
|
71
|
-
readonly queueSchema: z.ZodType<
|
|
72
|
-
CommandOf<AppMessages<Definition>> | EventOf<AppMessages<Definition>>
|
|
73
|
-
>
|
|
67
|
+
payload: Payload<z.output<Maps['querySchemas'][NoInfer<Name>]>>,
|
|
68
|
+
): z.output<Maps['querySchemas'][Name]>
|
|
74
69
|
}
|
|
75
70
|
|
|
76
|
-
|
|
71
|
+
/** The command union inferred from a collection's complete schemas. */
|
|
72
|
+
export type CommandOf<Messages extends CompletedMessages> = z.output<
|
|
73
|
+
SchemaOf<Messages['commandSchemas']>
|
|
74
|
+
>
|
|
75
|
+
|
|
76
|
+
/** The event union inferred from a collection's complete schemas. */
|
|
77
|
+
export type EventOf<Messages extends CompletedMessages> = z.output<
|
|
78
|
+
SchemaOf<Messages['eventSchemas']>
|
|
79
|
+
>
|
|
77
80
|
|
|
78
|
-
/**
|
|
81
|
+
/** The query union inferred from a collection's complete schemas. */
|
|
82
|
+
export type QueryOf<Messages extends CompletedMessages> = z.output<
|
|
83
|
+
SchemaOf<Messages['querySchemas']>
|
|
84
|
+
>
|
|
85
|
+
|
|
86
|
+
/** Every message inferred from a collection's complete schemas. */
|
|
87
|
+
export type MessageOf<Messages extends CompletedMessages> =
|
|
88
|
+
| CommandOf<Messages>
|
|
89
|
+
| EventOf<Messages>
|
|
90
|
+
| QueryOf<Messages>
|
|
91
|
+
|
|
92
|
+
type KeysOfUnion<Value> = Value extends Value ? keyof Value : never
|
|
79
93
|
type WithoutRoutingFields<Schemas extends PayloadSchemas> = {
|
|
80
|
-
readonly [Name in keyof Schemas]:
|
|
94
|
+
readonly [Name in keyof Schemas]: KeysOfUnion<z.output<Schemas[Name]>> &
|
|
95
|
+
RoutingField extends never
|
|
81
96
|
? Schemas[Name]
|
|
82
97
|
: never
|
|
83
98
|
}
|
|
84
99
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
100
|
+
function messageSchema<
|
|
101
|
+
Kind extends MessageKind,
|
|
102
|
+
Name extends string,
|
|
103
|
+
Schema extends z.ZodType<object>,
|
|
104
|
+
>(kind: Kind, name: Name, payload: Schema) {
|
|
105
|
+
return z.intersection(envelopes[kind], z.object({ name: z.literal(name) })).and(payload)
|
|
90
106
|
}
|
|
91
107
|
|
|
92
|
-
|
|
108
|
+
type CompleteSchemas<Kind extends MessageKind, Schemas extends PayloadSchemas> = {
|
|
109
|
+
readonly [Name in keyof Schemas & string]: ReturnType<
|
|
110
|
+
typeof messageSchema<Kind, Name, Schemas[Name]>
|
|
111
|
+
>
|
|
112
|
+
}
|
|
93
113
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
114
|
+
function completeSchemas<Kind extends MessageKind, Schemas extends PayloadSchemas>(
|
|
115
|
+
kind: Kind,
|
|
116
|
+
schemas: Schemas | undefined,
|
|
117
|
+
): CompleteSchemas<Kind, Schemas> {
|
|
118
|
+
const entries = Object.entries<z.ZodType<object>>(schemas ?? {}).map(([name, payload]) => [
|
|
119
|
+
name,
|
|
120
|
+
messageSchema(kind, name, payload),
|
|
121
|
+
])
|
|
122
|
+
// SAFETY: Each entry preserves its name and wraps its payload schema with the selected envelope.
|
|
123
|
+
return Object.freeze(Object.fromEntries(entries)) as CompleteSchemas<Kind, Schemas>
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function unionOf<Schemas extends SchemaMap>(schemas: Schemas): z.ZodUnion<SchemaOf<Schemas>[]> {
|
|
127
|
+
// SAFETY: Object.values contains exactly the complete schemas indexed by this map's keys.
|
|
128
|
+
return z.union(Object.values(schemas)) as z.ZodUnion<SchemaOf<Schemas>[]>
|
|
105
129
|
}
|
|
106
130
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
131
|
+
function collection<Maps extends SchemaMaps>(maps: Maps): MessageCollection<Maps> {
|
|
132
|
+
return Object.freeze<MessageCollection<Maps>>({
|
|
133
|
+
...maps,
|
|
134
|
+
[completed]: true,
|
|
135
|
+
commandSchema: unionOf<Maps['commandSchemas']>(maps.commandSchemas),
|
|
136
|
+
eventSchema: unionOf<Maps['eventSchemas']>(maps.eventSchemas),
|
|
137
|
+
querySchema: unionOf<Maps['querySchemas']>(maps.querySchemas),
|
|
138
|
+
// SAFETY: The queue union contains the command and event schemas from these maps, without rebuilding them.
|
|
139
|
+
queueSchema: z.union([
|
|
140
|
+
...Object.values(maps.commandSchemas),
|
|
141
|
+
...Object.values(maps.eventSchemas),
|
|
142
|
+
]) as MessageCollection<Maps>['queueSchema'],
|
|
143
|
+
createCommand: (name, payload, id = uuidv7()) => {
|
|
144
|
+
// SAFETY: The typed payload supplies all non-envelope fields for this name; the factory supplies its envelope.
|
|
145
|
+
return { ...payload, type: 'command', name, id } as z.output<
|
|
146
|
+
Maps['commandSchemas'][typeof name]
|
|
147
|
+
>
|
|
148
|
+
},
|
|
149
|
+
createEvent: (name, payload, id = uuidv7()) => {
|
|
150
|
+
// SAFETY: The typed payload supplies all non-envelope fields for this name; the factory supplies its envelope.
|
|
151
|
+
return { ...payload, type: 'event', name, id } as z.output<Maps['eventSchemas'][typeof name]>
|
|
152
|
+
},
|
|
153
|
+
createQuery: (name, payload) => {
|
|
154
|
+
// SAFETY: The typed payload supplies all non-envelope fields for this name; a query has no ID.
|
|
155
|
+
return { ...payload, type: 'query', name } as z.output<Maps['querySchemas'][typeof name]>
|
|
156
|
+
},
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Build each complete schema once. Omitted kinds have empty maps and schemas that reject every input. */
|
|
161
|
+
export function defineMessages<
|
|
162
|
+
const Commands extends PayloadSchemas = EmptySchemas,
|
|
163
|
+
const Events extends PayloadSchemas = EmptySchemas,
|
|
164
|
+
const Queries extends PayloadSchemas = EmptySchemas,
|
|
165
|
+
>(definition: {
|
|
166
|
+
readonly commands?: Commands & WithoutRoutingFields<Commands>
|
|
167
|
+
readonly events?: Events & WithoutRoutingFields<Events>
|
|
168
|
+
readonly queries?: Queries & WithoutRoutingFields<Queries>
|
|
169
|
+
}) {
|
|
170
|
+
return collection({
|
|
171
|
+
commandSchemas: completeSchemas<'command', Commands>('command', definition.commands),
|
|
172
|
+
eventSchemas: completeSchemas<'event', Events>('event', definition.events),
|
|
173
|
+
querySchemas: completeSchemas<'query', Queries>('query', definition.queries),
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Two collections declare the same name within one message kind. */
|
|
178
|
+
export class DuplicateMessageNameError extends AppError {
|
|
179
|
+
readonly _tag = 'DuplicateMessageNameError'
|
|
180
|
+
readonly classification = 'terminal'
|
|
181
|
+
|
|
182
|
+
/** Identify the duplicate kind and name so the application can correct its definitions. */
|
|
183
|
+
constructor(
|
|
184
|
+
readonly kind: MessageKind,
|
|
185
|
+
readonly messageName: string,
|
|
186
|
+
) {
|
|
187
|
+
super(`Duplicate ${kind} name: ${messageName}`)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
type SchemaAtName<Maps, Name extends PropertyKey> =
|
|
192
|
+
Maps extends Record<Name, infer Schema extends z.ZodType<object>> ? Schema : never
|
|
193
|
+
type MergedMaps<Collections extends readonly CompletedMessages[]> = {
|
|
194
|
+
readonly [Key in keyof SchemaMaps]: {
|
|
195
|
+
readonly [Name in KeysOfUnion<Collections[number][Key]> & string]: SchemaAtName<
|
|
196
|
+
Collections[number][Key],
|
|
197
|
+
Name
|
|
198
|
+
>
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
type DuplicateNames<Collections extends readonly CompletedMessages[]> =
|
|
202
|
+
Collections extends readonly [
|
|
203
|
+
infer First extends CompletedMessages,
|
|
204
|
+
...infer Rest extends readonly CompletedMessages[],
|
|
205
|
+
]
|
|
206
|
+
?
|
|
207
|
+
| {
|
|
208
|
+
[Key in keyof SchemaMaps]: keyof First[Key] & KeysOfUnion<Rest[number][Key]>
|
|
209
|
+
}[keyof SchemaMaps]
|
|
210
|
+
| DuplicateNames<Rest>
|
|
211
|
+
: never
|
|
212
|
+
|
|
213
|
+
function mergeSchemas<
|
|
214
|
+
Collections extends readonly CompletedMessages[],
|
|
215
|
+
Key extends keyof SchemaMaps,
|
|
216
|
+
>(collections: Collections, key: Key, kind: MessageKind): MergedMaps<Collections>[Key] {
|
|
217
|
+
const merged = new Map<string, z.ZodType<object>>()
|
|
218
|
+
for (const messages of collections) {
|
|
219
|
+
if (!messages?.[completed])
|
|
220
|
+
return panic('combineMessages requires completed message collections')
|
|
221
|
+
for (const [name, schema] of Object.entries(messages[key])) {
|
|
222
|
+
if (merged.has(name)) throw new DuplicateMessageNameError(kind, name)
|
|
223
|
+
merged.set(name, schema)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// SAFETY: Every schema comes from the selected complete map, and duplicate names are rejected before insertion.
|
|
227
|
+
return Object.freeze(Object.fromEntries(merged)) as MergedMaps<Collections>[Key]
|
|
112
228
|
}
|
|
113
229
|
|
|
114
230
|
/**
|
|
115
|
-
*
|
|
116
|
-
*
|
|
231
|
+
* Merge completed collections without rebuilding complete schemas.
|
|
232
|
+
* @throws DuplicateMessageNameError when two collections declare the same name within one kind.
|
|
117
233
|
*/
|
|
118
|
-
export function
|
|
119
|
-
|
|
120
|
-
)
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
createCommand: (name, payload, id = uuidv7()) => ({ ...payload, type: 'command', name, id }),
|
|
127
|
-
createEvent: (name, payload, id = uuidv7()) => ({ ...payload, type: 'event', name, id }),
|
|
128
|
-
createQuery: (name, payload) => ({ ...payload, type: 'query', name }),
|
|
129
|
-
commandSchema: commandSchema.pipe(z.custom<CommandOf<AppMessages<Definition>>>()),
|
|
130
|
-
eventSchema: eventSchema.pipe(z.custom<EventOf<AppMessages<Definition>>>()),
|
|
131
|
-
querySchema: querySchema.pipe(z.custom<QueryOf<AppMessages<Definition>>>()),
|
|
132
|
-
queueSchema: z
|
|
133
|
-
.union([commandSchema, eventSchema])
|
|
134
|
-
.pipe(z.custom<CommandOf<AppMessages<Definition>> | EventOf<AppMessages<Definition>>>()),
|
|
135
|
-
}
|
|
234
|
+
export function combineMessages<const Collections extends readonly CompletedMessages[]>(
|
|
235
|
+
...collections: Collections & (DuplicateNames<Collections> extends never ? unknown : never)
|
|
236
|
+
) {
|
|
237
|
+
return collection({
|
|
238
|
+
commandSchemas: mergeSchemas(collections, 'commandSchemas', 'command'),
|
|
239
|
+
eventSchemas: mergeSchemas(collections, 'eventSchemas', 'event'),
|
|
240
|
+
querySchemas: mergeSchemas(collections, 'querySchemas', 'query'),
|
|
241
|
+
})
|
|
136
242
|
}
|
package/src/core/index.ts
CHANGED
|
@@ -62,7 +62,9 @@ export { notImplemented, unreachable } from './defects.ts'
|
|
|
62
62
|
export { defineId, type Id, type IdDefinition, type IdOf } from './identity.ts'
|
|
63
63
|
export {
|
|
64
64
|
defineMessages,
|
|
65
|
-
|
|
65
|
+
combineMessages,
|
|
66
|
+
DuplicateMessageNameError,
|
|
67
|
+
type MessageCollection,
|
|
66
68
|
type CommandOf,
|
|
67
69
|
type EventOf,
|
|
68
70
|
type MessageDefinition,
|
package/src/core/messages.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Internal messages use UUID v7, so receipts sort by creation. External messages use the
|
|
5
5
|
* provider's own key, `stripe:event:evt_123`, so a redelivered webhook is a duplicate by
|
|
6
|
-
* construction.
|
|
6
|
+
* construction. Message factories generate IDs; complete message schemas parse external IDs.
|
|
7
7
|
*/
|
|
8
8
|
export type MessageId = string
|
|
9
9
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Result } from 'better-result'
|
|
2
|
+
|
|
3
|
+
import { isAbortError, shouldRetryFailure } from '@/core'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
classifyDurableObjectError,
|
|
7
|
+
DurableObjectCallError,
|
|
8
|
+
isClassifiedDurableObjectError,
|
|
9
|
+
} from './durable-object-errors.ts'
|
|
10
|
+
|
|
11
|
+
/** Resolve a fresh stub on every call, including calls after a failed attempt. */
|
|
12
|
+
export type DurableObjectResolver<Object extends object> = {
|
|
13
|
+
getByName(name: string): Object | Promise<Object>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type Call<Object extends object, R> = (object: Object) => Promise<R>
|
|
17
|
+
|
|
18
|
+
/** Product clients name operations and establish repeat safety; this client owns transport retries. */
|
|
19
|
+
export abstract class DurableObjectClient<
|
|
20
|
+
T extends Rpc.DurableObjectBranded,
|
|
21
|
+
Object extends object = DurableObjectStub<T>,
|
|
22
|
+
> {
|
|
23
|
+
constructor(private readonly objects: DurableObjectResolver<Object>) {}
|
|
24
|
+
|
|
25
|
+
/** Retry transient failures only when repeating the entire callback is safe. */
|
|
26
|
+
protected repeatable<R>(name: string, work: Call<Object, R>): Promise<R> {
|
|
27
|
+
return this.run(name, work, true)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Make one attempt when repetition is unsafe or another caller owns retries. */
|
|
31
|
+
protected once<R>(name: string, work: Call<Object, R>): Promise<R> {
|
|
32
|
+
return this.run(name, work, false)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private async run<R>(name: string, work: Call<Object, R>, repeatSafe: boolean): Promise<R> {
|
|
36
|
+
const result = await Result.tryPromise(
|
|
37
|
+
{
|
|
38
|
+
// A failed RPC can break the stub permanently, so every attempt resolves it again.
|
|
39
|
+
try: async () => work(await this.objects.getByName(name)),
|
|
40
|
+
catch: (cause) => cause,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
retry: {
|
|
44
|
+
times: 2,
|
|
45
|
+
delayMs: 25,
|
|
46
|
+
backoff: 'exponential',
|
|
47
|
+
jitter: true,
|
|
48
|
+
shouldRetry: (cause) => {
|
|
49
|
+
if (isAbortError(cause) || isClassifiedDurableObjectError(cause)) return false
|
|
50
|
+
return shouldRetryFailure({
|
|
51
|
+
classification: classifyDurableObjectError(cause),
|
|
52
|
+
repeatSafe,
|
|
53
|
+
owner: 'immediate',
|
|
54
|
+
})
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
if (result.isOk()) return result.value
|
|
60
|
+
if (
|
|
61
|
+
isAbortError(result.error) ||
|
|
62
|
+
isClassifiedDurableObjectError(result.error) ||
|
|
63
|
+
classifyDurableObjectError(result.error) === 'unknown'
|
|
64
|
+
) {
|
|
65
|
+
// oxlint-disable-next-line typescript/only-throw-error -- Preserve raw failures for the owning error boundary.
|
|
66
|
+
throw result.error
|
|
67
|
+
}
|
|
68
|
+
throw new DurableObjectCallError({ cause: result.error })
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
AppError,
|
|
5
|
+
isAbortError,
|
|
6
|
+
isCloudflareOverloadedError,
|
|
7
|
+
isCloudflareRetryableError,
|
|
8
|
+
isTransientCloudflareRuntimeError,
|
|
9
|
+
isTransientTransportError,
|
|
10
|
+
type FailureClassification,
|
|
11
|
+
} from '@/core'
|
|
12
|
+
|
|
13
|
+
/** Stable tag for a failed Durable Object call. */
|
|
14
|
+
export const DURABLE_OBJECT_CALL_ERROR = 'DurableObjectCallError'
|
|
15
|
+
|
|
16
|
+
const RemoteClassifiedErrorSchema = z.object({
|
|
17
|
+
_tag: z.string(),
|
|
18
|
+
classification: z.enum(['transient', 'terminal', 'unknown']),
|
|
19
|
+
remote: z.literal(true),
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const RemoteCallErrorSchema = RemoteClassifiedErrorSchema.extend({
|
|
23
|
+
_tag: z.literal(DURABLE_OBJECT_CALL_ERROR),
|
|
24
|
+
cause: z.unknown(),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
/** Recognize a judged local or remote error without admitting its tag to the public error table. */
|
|
28
|
+
export function isClassifiedDurableObjectError(
|
|
29
|
+
cause: unknown,
|
|
30
|
+
): cause is AppError | (Error & z.output<typeof RemoteClassifiedErrorSchema>) {
|
|
31
|
+
return (
|
|
32
|
+
cause instanceof AppError ||
|
|
33
|
+
(cause instanceof Error && RemoteClassifiedErrorSchema.safeParse(cause).success)
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Classify the error itself. Its cause remains diagnostic evidence, not a second retry decision. */
|
|
38
|
+
export function classifyDurableObjectError(cause: unknown): FailureClassification {
|
|
39
|
+
if (isAbortError(cause)) return 'terminal'
|
|
40
|
+
if (isClassifiedDurableObjectError(cause)) return cause.classification
|
|
41
|
+
if (isCloudflareOverloadedError(cause)) return 'terminal'
|
|
42
|
+
if (
|
|
43
|
+
isCloudflareRetryableError(cause) ||
|
|
44
|
+
isTransientCloudflareRuntimeError(cause) ||
|
|
45
|
+
isTransientTransportError(cause)
|
|
46
|
+
)
|
|
47
|
+
return 'transient'
|
|
48
|
+
return 'unknown'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Identify a local call error or its validated Workers RPC representation. */
|
|
52
|
+
export function isDurableObjectCallError(
|
|
53
|
+
cause: unknown,
|
|
54
|
+
): cause is DurableObjectCallError | (Error & z.output<typeof RemoteCallErrorSchema>) {
|
|
55
|
+
return (
|
|
56
|
+
cause instanceof DurableObjectCallError ||
|
|
57
|
+
(cause instanceof Error && RemoteCallErrorSchema.safeParse(cause).success)
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A call failed after retries stopped. Classification does not establish repeat safety. */
|
|
62
|
+
export class DurableObjectCallError extends AppError {
|
|
63
|
+
readonly _tag = DURABLE_OBJECT_CALL_ERROR
|
|
64
|
+
readonly classification: FailureClassification
|
|
65
|
+
|
|
66
|
+
constructor(args: { readonly cause: unknown }) {
|
|
67
|
+
super('Durable Object call failed', { cause: args.cause })
|
|
68
|
+
this.classification = classifyDurableObjectError(args.cause)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -109,7 +109,10 @@ export class ResendEmailService implements IEmailService {
|
|
|
109
109
|
if (email.tags !== undefined)
|
|
110
110
|
payload.tags = Object.entries(email.tags).map(([name, value]) => ({ name, value }))
|
|
111
111
|
if (email.attachments !== undefined) payload.attachments = email.attachments.map(toAttachment)
|
|
112
|
-
const result = await client.emails.send(
|
|
112
|
+
const result = await client.emails.send(
|
|
113
|
+
payload,
|
|
114
|
+
email.idempotencyKey === undefined ? undefined : { idempotencyKey: email.idempotencyKey },
|
|
115
|
+
)
|
|
113
116
|
if (result.error !== null) {
|
|
114
117
|
throw new EmailServiceError({
|
|
115
118
|
reason: reasonFromResend(result.error),
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"application-C4JOf71r.js","names":["logger"],"sources":["../src/application/analytics-service.ts","../src/application/metrics-service.ts","../src/application/email-service.ts","../src/application/record-email-event.ts","../src/application/message-already-processed.error.ts","../src/application/message-bus.ts","../src/application/outbox-relay.ts","../src/application/outbox-retention.ts","../src/application/unit-of-work.ts","../src/application/unit-of-work.errors.ts"],"sourcesContent":["import type { AnalyticsEventIdentity, AnalyticsEventInput, AnalyticsProperties } from '@/core'\n\n/** Product analytics without an SDK dependency, ported from Typist and Porte. */\nexport interface AnalyticsService<TEvent extends AnalyticsEventInput = AnalyticsEventInput> {\n /** Attach traits to one person. */\n identify(distinctId: string, properties?: AnalyticsProperties): void\n /** Merge a previous ID into this person; the application must establish that both identify the same person. */\n alias(distinctId: string, previousDistinctId: string): void\n /** Schedule an event for a person; returning does not confirm remote acceptance. */\n track(event: TEvent, distinctId: string, identity?: AnalyticsEventIdentity): void\n /** Schedule an event without creating a person profile for its distinct ID. */\n trackAnonymous(event: TEvent, distinctId: string, identity?: AnalyticsEventIdentity): void\n}\n\n/** Reports to nobody. The composition root selects this service when analytics is disabled. */\nexport const NULL_ANALYTICS: AnalyticsService = {\n identify: () => {},\n alias: () => {},\n track: () => {},\n trackAnonymous: () => {},\n}\n","/** A metric dimension; use a small, bounded value set and never user or request IDs. */\nexport type MetricAttributeValue = string | number | boolean\n\n/** Dimensions shared by observations in one time series. */\nexport type MetricAttributes = Readonly<Record<string, MetricAttributeValue>>\n\n/** Unit and dimensions for an operational measurement. */\nexport interface MetricOptions {\n readonly attributes?: MetricAttributes\n readonly unit?: string\n}\n\n/** Operational measurements, ported from Typist's metrics-service.ts; applications own metric names. */\nexport interface MetricsService<TName extends string = string> {\n /** Add a nonnegative value to a total; the default increment is one. */\n count(name: TName, value?: number, options?: MetricOptions): void\n /** Record a current value. */\n gauge(name: TName, value: number, options?: MetricOptions): void\n /** Record one observation in a distribution. */\n histogram(name: TName, value: number, options?: MetricOptions): void\n}\n\n/** Records nothing when the composition root disables metrics. */\nexport const NULL_METRICS_SERVICE: MetricsService = {\n count: () => {},\n gauge: () => {},\n histogram: () => {},\n}\n","import type { EmailIdempotencyKey } from '@/core'\n\n/** A media type such as `application/pdf`. */\nexport type MimeType = `${string}/${string}`\n\n/** A file to attach. Binary content is bytes; the adapter encodes it for the provider. */\nexport type EmailAttachment = {\n readonly filename: string\n readonly content: string | Uint8Array\n /** Optional MIME metadata; Resend accepts filename and content without it. */\n readonly contentType?: MimeType\n}\n\n/**\n * One transactional email. Resend accepts 50 recipients, 75 tags with ASCII names and values,\n * and 40 MB of attachments after base64. Sources: https://resend.com/docs/api-reference/emails/send-email\n * and https://resend.com/docs/dashboard/emails/tags\n */\nexport type OutboundEmail = {\n /** Override the default for this email, for example \"Display Name <address@domain.com>\". */\n readonly from?: string\n readonly to: string | readonly string[]\n readonly subject: string\n readonly html: string\n /** The stable identity of this send. Resend deduplicates it for 24 hours. */\n readonly idempotencyKey: EmailIdempotencyKey\n /** Resend generates plain text from html when omitted. An empty string disables that generation. */\n readonly text?: string\n readonly replyTo?: string\n /** Labels the provider stores with the email and returns on every delivery event. */\n readonly tags?: Readonly<Record<string, string>>\n readonly attachments?: readonly EmailAttachment[]\n}\n\n/** The provider accepted the email and assigned its id. */\nexport type EmailAccepted = { readonly providerId: string }\n\n/**\n * The adapter supplies the default sender and maps provider errors. Each email can override the sender.\n * A provider rejection throws `EmailServiceError`; unexpected SDK rejections propagate unchanged.\n */\nexport interface IEmailService {\n /** Resolve after provider acceptance; this does not confirm delivery to the recipient. */\n sendEmail(email: OutboundEmail): Promise<EmailAccepted>\n}\n\n/** Records every send for a test. Provider ids count up as `recorded-1`, `recorded-2`. */\nexport class RecordingEmailService implements IEmailService {\n /** Sends in call order, including repeated idempotency keys. */\n readonly sent: OutboundEmail[] = []\n\n /** Record the send before returning its provider id. */\n async sendEmail(email: OutboundEmail): Promise<EmailAccepted> {\n this.sent.push(email)\n return { providerId: `recorded-${this.sent.length}` }\n }\n}\n","import type { BounceType, DomainCommand, EmailAddress, RecordEmailEventPayload } from '@/core'\n\nimport type { IUnitOfWork } from './unit-of-work.ts'\n\n/** What a bounce records for one address. */\nexport type EmailBounceRecord = {\n readonly type: BounceType\n readonly subType: string | null\n}\n\n/**\n * The application's deliverability state per address. An implementation keeps the higher bounce\n * severity with `shouldUpdateBounce`, and treats an unknown address as nothing to record.\n */\nexport interface IEmailDeliverabilityStore {\n /** Keep the higher severity atomically. Equal severity permits replacement; the store owns timestamp ordering. */\n recordBounce(address: EmailAddress, bounce: EmailBounceRecord): Promise<void>\n /** Record a complaint without clearing existing suppression state. */\n recordComplaint(address: EmailAddress): Promise<void>\n}\n\n/** The repositories the handler needs from the unit of work. */\nexport type RecordEmailEventRepos = { readonly emailDeliverability: IEmailDeliverabilityStore }\n\n/** The container slice the handler reads. */\nexport type RecordEmailEventDeps = { readonly uow: IUnitOfWork<RecordEmailEventRepos> }\n\n/** The command, under whatever name the application registered the payload. */\nexport type RecordEmailEventCommand = DomainCommand & RecordEmailEventPayload\n\n/**\n * Record a bounce or a complaint for every recipient inside the command's unit of work.\n * The command id is the provider's event id, so a redelivered webhook is `MessageAlreadyProcessedError`.\n */\nexport async function recordEmailEvent(\n command: RecordEmailEventCommand,\n deps: RecordEmailEventDeps,\n): Promise<void> {\n await deps.uow.run(command.id, async ({ emailDeliverability }) => {\n for (const address of command.recipients) {\n if (command.event.type === 'bounced') {\n // oxlint-disable-next-line no-await-in-loop -- Finish each transaction write before starting another; a failure must leave no pending writes.\n await emailDeliverability.recordBounce(address, {\n type: command.event.bounceType,\n subType: command.event.bounceSubType,\n })\n } else {\n // oxlint-disable-next-line no-await-in-loop -- A rejected write must stop the transaction before another write starts.\n await emailDeliverability.recordComplaint(address)\n }\n }\n })\n}\n","import { AppError, DatabaseError, type MessageId } from '@/core'\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 {\n createLogger,\n unreachable,\n type DomainCommand,\n type DomainEvent,\n type DomainMessage,\n type DomainQuery,\n} from '@/core'\n\nimport { MessageAlreadyProcessedError } from './message-already-processed.error.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 DomainMessage & 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 DomainMessage & 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: DomainMessage): 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: DomainCommand): 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: DomainQuery): 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: DomainEvent): 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 { classifyFailure, createLogger, type DomainEvent } from '@/core'\n\nimport type {\n DeadLetterReason,\n IOutboxRepository,\n OutboxClaim,\n OutboxMessage,\n} from './outbox-repository.ts'\n\n/**\n * Delivers committed outbox rows. The unit of work only wakes it after a commit that wrote\n * events; the app decides what a wake is: `waitUntil`, a Durable Object alarm, nothing.\n * The sweep is the source of truth, so a lost wake loses no events.\n */\nexport interface IOutboxRelay {\n wake(): void\n}\n\n/** The publisher sends valid events and returns oversized events. QueueClient satisfies this port directly. */\nexport interface IOutboxPublisher {\n /** Resolves after queue acceptance; a rejected batch may already have sent some events. */\n sendBatchPartial(messages: ReadonlyArray<DomainEvent>): Promise<ReadonlyArray<DomainEvent>>\n}\n\n/** Optional limits for one relay; durable attempts exclude the publisher's immediate retries. */\nexport type OutboxRelayOptions = {\n readonly batchSize?: number\n readonly maxBatches?: number\n readonly maxAttempts?: number\n}\n\nconst logger = createLogger('outbox-relay')\n\n/**\n * Publishes committed events with bounded durable retries. Ported from Typist's relay with per-row exhaustion.\n * Send failures are recorded; repository failures propagate to the wake or scheduled boundary.\n */\nexport class OutboxRelay {\n private readonly options: Required<OutboxRelayOptions>\n\n constructor(\n private readonly publisher: IOutboxPublisher,\n private readonly repository: IOutboxRepository,\n options: OutboxRelayOptions = {},\n ) {\n this.options = {\n batchSize: options.batchSize ?? 50,\n maxBatches: options.maxBatches ?? 10,\n maxAttempts: options.maxAttempts ?? 10,\n }\n }\n\n /** Return the claimed count, including released and dead-lettered rows. Zero means no due rows. */\n async flush(): Promise<number> {\n const claim = await this.repository.claimPending(this.options.batchSize)\n const exhausted = claim.messages.filter((row) => row.attempts > this.options.maxAttempts)\n await this.deadLetter(claim, exhausted, 'exhausted', 'Claim attempt limit exceeded')\n const pending = claim.messages.filter((row) => row.attempts <= this.options.maxAttempts)\n if (pending.length === 0) return claim.messages.length\n\n let oversized: ReadonlyArray<DomainEvent>\n try {\n oversized = await this.publisher.sendBatchPartial(pending.map((row) => row.payload))\n } catch (error) {\n await this.handleSendFailure(claim, pending, error)\n return claim.messages.length\n }\n\n const oversizedIds = new Set(oversized.map((event) => event.id))\n await this.deadLetter(\n claim,\n pending.filter((row) => oversizedIds.has(row.payload.id)),\n 'oversized',\n 'Exceeds queue message size limit',\n )\n const sent = pending.filter((row) => !oversizedIds.has(row.payload.id))\n if (sent.length > 0) await this.repository.markPublished(claim, sent)\n return claim.messages.length\n }\n\n /** Drain until an empty claim or the batch limit; return the total claimed count. */\n async drain(): Promise<number> {\n let total = 0\n for (let batch = 0; batch < this.options.maxBatches; batch += 1) {\n // oxlint-disable-next-line no-await-in-loop -- An empty claim must stop the drain before another claim starts.\n const claimed = await this.flush()\n total += claimed\n if (claimed === 0) break\n }\n return total\n }\n\n private async handleSendFailure(\n claim: OutboxClaim,\n pending: ReadonlyArray<OutboxMessage>,\n cause: unknown,\n ): Promise<void> {\n const classification = classifyFailure(cause)\n const message =\n cause instanceof Error ? cause.message : 'Publisher rejected with a non-error value'\n if (classification === 'terminal') {\n await this.deadLetter(claim, pending, 'terminal', message, cause)\n return\n }\n const exhausted = pending.filter((row) => row.attempts >= this.options.maxAttempts)\n await this.deadLetter(claim, exhausted, 'exhausted', message, cause)\n const retry = pending.filter((row) => row.attempts < this.options.maxAttempts)\n if (retry.length > 0) await this.repository.releaseClaim(claim, retry)\n }\n\n private async deadLetter(\n claim: OutboxClaim,\n messages: ReadonlyArray<OutboxMessage>,\n reason: DeadLetterReason,\n errorMessage: string,\n cause?: unknown,\n ): Promise<void> {\n if (messages.length === 0) return\n await this.repository.deadLetter(claim, messages, reason, errorMessage)\n logger.error('outbox_messages_dead_lettered', {\n error: cause,\n details: { claimToken: claim.token, count: messages.length, reason },\n })\n }\n}\n","import type { IOutboxRetentionRepository } from './outbox-repository.ts'\n\n/** Typist's retention defaults; receipt retention must cover the application's supported replay period. */\nexport type OutboxRetentionOptions = {\n readonly publishedRetentionMs?: number\n readonly failedRetentionMs?: number\n readonly receiptRetentionMs?: number\n readonly batchSize?: number\n readonly maxBatches?: number\n}\n\n/** Deleted records per category during one bounded sweep. */\nexport type OutboxRetentionResult = {\n readonly published: number\n readonly failed: number\n readonly receipts: number\n}\n\n/** Bounded cleanup ported from Typist's message retention handler. The application schedules each sweep. */\nexport class OutboxRetention {\n private readonly options: Required<OutboxRetentionOptions>\n\n constructor(\n private readonly repository: IOutboxRetentionRepository,\n options: OutboxRetentionOptions = {},\n ) {\n this.options = {\n publishedRetentionMs: options.publishedRetentionMs ?? 86_400_000,\n failedRetentionMs: options.failedRetentionMs ?? 30 * 86_400_000,\n receiptRetentionMs: options.receiptRetentionMs ?? 7 * 86_400_000,\n batchSize: options.batchSize ?? 1_000,\n maxBatches: options.maxBatches ?? 10,\n }\n }\n\n /** Calculate all cutoffs from one supplied time; propagate repository failures to the calling boundary. */\n async sweep(at: Date): Promise<OutboxRetentionResult> {\n const { publishedRetentionMs, failedRetentionMs, receiptRetentionMs } = this.options\n const published = await this.deleteBatches((size) =>\n this.repository.deletePublishedBefore(new Date(at.getTime() - publishedRetentionMs), size),\n )\n const failed = await this.deleteBatches((size) =>\n this.repository.deleteFailedBefore(new Date(at.getTime() - failedRetentionMs), size),\n )\n const receipts = await this.deleteBatches((size) =>\n this.repository.deleteReceiptsBefore(new Date(at.getTime() - receiptRetentionMs), size),\n )\n return { published, failed, receipts }\n }\n\n private async deleteBatches(deleteBatch: (size: number) => Promise<number>): Promise<number> {\n let total = 0\n for (let batch = 0; batch < this.options.maxBatches; batch += 1) {\n // oxlint-disable-next-line no-await-in-loop -- The delete count decides whether another batch is needed.\n const deleted = await deleteBatch(this.options.batchSize)\n total += deleted\n if (deleted < this.options.batchSize) break\n }\n return total\n }\n}\n","import type { MessageId } from '@/core'\n\nimport type { IEventCollector } from './event-collector.ts'\nimport { MessageAlreadyProcessedError } from './message-already-processed.error.ts'\nimport type { IMessageStore } from './message-store.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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"}
|