better-ship 0.12.0 → 0.14.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 +291 -0
- package/dist/application-C4JOf71r.js.map +1 -0
- package/dist/application.d.ts +3 -3
- package/dist/application.js +2 -2
- 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/queue.d.ts +8 -3
- package/dist/cloudflare/queue.d.ts.map +1 -1
- package/dist/cloudflare/queue.js +26 -16
- package/dist/cloudflare/queue.js.map +1 -1
- package/dist/cloudflare/scheduled.d.ts +1 -1
- package/dist/cloudflare/scheduled.js +1 -1
- package/dist/cloudflare.d.ts +30 -4
- package/dist/cloudflare.d.ts.map +1 -1
- package/dist/cloudflare.js +98 -5
- package/dist/cloudflare.js.map +1 -1
- package/dist/{core-DG_cC8YT.js → core-hdaNiCOU.js} +22 -2
- package/dist/core-hdaNiCOU.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-DwWxIdQP.d.ts → index-C28HgVfY.d.ts} +145 -3
- package/dist/index-C28HgVfY.d.ts.map +1 -0
- package/dist/{index-Bl0K1RQW.d.ts → index-G1QG0P5g.d.ts} +36 -2
- package/dist/index-G1QG0P5g.d.ts.map +1 -0
- package/dist/outbox-repository-options-BZjfDqGf.d.ts +26 -0
- package/dist/outbox-repository-options-BZjfDqGf.d.ts.map +1 -0
- package/dist/{unit-of-work-context-BPE1In2f.js → outbox-repository-options-DOTpJH-e.js} +12 -3
- package/dist/outbox-repository-options-DOTpJH-e.js.map +1 -0
- package/dist/postgres.d.ts +30 -4
- package/dist/postgres.d.ts.map +1 -1
- package/dist/postgres.js +92 -5
- package/dist/postgres.js.map +1 -1
- package/dist/posthog/client.d.ts +22 -0
- package/dist/posthog/client.d.ts.map +1 -0
- package/dist/posthog/client.js +34 -0
- package/dist/posthog/client.js.map +1 -0
- package/dist/posthog/metrics.d.ts +18 -0
- package/dist/posthog/metrics.d.ts.map +1 -0
- package/dist/posthog/metrics.js +30 -0
- package/dist/posthog/metrics.js.map +1 -0
- package/dist/posthog/proxy.d.ts +17 -0
- package/dist/posthog/proxy.d.ts.map +1 -0
- package/dist/posthog/proxy.js +34 -0
- package/dist/posthog/proxy.js.map +1 -0
- package/dist/posthog/server.d.ts +37 -0
- package/dist/posthog/server.d.ts.map +1 -0
- package/dist/posthog/server.js +81 -0
- package/dist/posthog/server.js.map +1 -0
- package/dist/resend.d.ts +3 -3
- package/dist/resend.js +2 -2
- package/dist/sentry/client.d.ts +22 -0
- package/dist/sentry/client.d.ts.map +1 -0
- package/dist/sentry/client.js +48 -0
- package/dist/sentry/client.js.map +1 -0
- package/dist/sentry/server.d.ts +24 -0
- package/dist/sentry/server.d.ts.map +1 -0
- package/dist/sentry/server.js +48 -0
- package/dist/sentry/server.js.map +1 -0
- package/dist/sentry/vite.d.ts +11 -0
- package/dist/sentry/vite.d.ts.map +1 -0
- package/dist/sentry/vite.js +17 -0
- package/dist/sentry/vite.js.map +1 -0
- package/dist/tanstack.d.ts +1 -1
- package/dist/tanstack.js +2 -2
- package/dist/testing/outbox-repository.d.ts +44 -0
- package/dist/testing/outbox-repository.d.ts.map +1 -0
- package/dist/testing/outbox-repository.js +206 -0
- package/dist/testing/outbox-repository.js.map +1 -0
- package/dist/{webhook-verification-sZtwTI5n.d.ts → webhook-verification-D7DIhH6C.d.ts} +2 -2
- package/dist/{webhook-verification-sZtwTI5n.d.ts.map → webhook-verification-D7DIhH6C.d.ts.map} +1 -1
- package/dist/{webhook-verification-BQ9qZnVK.js → webhook-verification-trEgyz7I.js} +2 -2
- package/dist/{webhook-verification-BQ9qZnVK.js.map → webhook-verification-trEgyz7I.js.map} +1 -1
- package/package.json +42 -1
- package/src/application/analytics-service.ts +21 -0
- package/src/application/index.ts +31 -1
- package/src/application/metrics-service.ts +28 -0
- package/src/application/observability-service.ts +16 -0
- package/src/application/outbox-relay.ts +117 -0
- package/src/application/outbox-repository.ts +46 -0
- package/src/application/outbox-retention.ts +61 -0
- package/src/core/analytics-event.ts +37 -0
- package/src/core/backoff.ts +6 -1
- package/src/core/index.ts +10 -1
- package/src/core/missing-configuration.error.ts +12 -0
- package/src/infrastructure/cloudflare/d1-outbox-repository.ts +224 -0
- package/src/infrastructure/cloudflare/index.ts +2 -0
- package/src/infrastructure/cloudflare/queue/index.ts +1 -0
- package/src/infrastructure/cloudflare/queue/queue-client.ts +6 -2
- package/src/infrastructure/cloudflare/queue/wait-until-wake.ts +19 -0
- package/src/infrastructure/outbox-repository-options.ts +9 -0
- package/src/infrastructure/postgres/index.ts +2 -0
- package/src/infrastructure/postgres/postgres-outbox-repository.ts +216 -0
- package/src/infrastructure/posthog/client.ts +41 -0
- package/src/infrastructure/posthog/metrics.ts +30 -0
- package/src/infrastructure/posthog/proxy.ts +47 -0
- package/src/infrastructure/posthog/server.ts +107 -0
- package/src/infrastructure/sentry/client.ts +55 -0
- package/src/infrastructure/sentry/server.ts +51 -0
- package/src/infrastructure/sentry/vite.ts +17 -0
- package/src/infrastructure/testing/outbox-repository-contract.ts +228 -0
- package/dist/application-CIxo_BpK.js +0 -160
- package/dist/application-CIxo_BpK.js.map +0 -1
- package/dist/core-DG_cC8YT.js.map +0 -1
- package/dist/index-Bl0K1RQW.d.ts.map +0 -1
- package/dist/index-DwWxIdQP.d.ts.map +0 -1
- package/dist/unit-of-work-context-BPE1In2f.js.map +0 -1
- package/dist/unit-of-work-context-D5ytJIa-.d.ts +0 -16
- package/dist/unit-of-work-context-D5ytJIa-.d.ts.map +0 -1
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { and, asc, eq, inArray, isNull, lt, lte, or, sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { DeadLetterReason, IOutboxRepository, OutboxClaim, OutboxMessage } from '@/application'
|
|
4
|
+
import { exponentialDelayCapMs } from '@/core'
|
|
5
|
+
|
|
6
|
+
import type { DrizzleSchema } from '../drizzle-schema.ts'
|
|
7
|
+
import {
|
|
8
|
+
OUTBOX_REPOSITORY_DEFAULTS,
|
|
9
|
+
type OutboxRepositoryOptions,
|
|
10
|
+
} from '../outbox-repository-options.ts'
|
|
11
|
+
import { executeDatabase } from './d1-database-execution.ts'
|
|
12
|
+
import type { D1Db } from './d1-database-types.ts'
|
|
13
|
+
import { messageOutbox, processedMessages } from './d1-schema.ts'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Typist's atomic D1 claims on a plain connection, outside a unit of work.
|
|
17
|
+
* Every write runs once; lease expiry and the scheduled sweep recover interrupted operations.
|
|
18
|
+
*/
|
|
19
|
+
export class D1OutboxRepository<Schema extends DrizzleSchema> implements IOutboxRepository {
|
|
20
|
+
private readonly options: Required<OutboxRepositoryOptions>
|
|
21
|
+
|
|
22
|
+
constructor(
|
|
23
|
+
private readonly db: D1Db<Schema>,
|
|
24
|
+
options: OutboxRepositoryOptions = {},
|
|
25
|
+
private readonly now: () => Date = () => new Date(),
|
|
26
|
+
) {
|
|
27
|
+
this.options = { ...OUTBOX_REPOSITORY_DEFAULTS, ...options }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Lease due rows in one statement; increment attempts even when reclaiming an expired lease. */
|
|
31
|
+
async claimPending(batchSize: number): Promise<OutboxClaim> {
|
|
32
|
+
const token = crypto.randomUUID()
|
|
33
|
+
const now = this.now()
|
|
34
|
+
const rows = await executeDatabase('claim pending outbox messages', () =>
|
|
35
|
+
this.db
|
|
36
|
+
.update(messageOutbox)
|
|
37
|
+
.set({
|
|
38
|
+
claimToken: token,
|
|
39
|
+
claimExpiresAt: deadline(now.getTime() + this.options.leaseMs),
|
|
40
|
+
attempts: sql`${messageOutbox.attempts} + 1`,
|
|
41
|
+
})
|
|
42
|
+
.where(
|
|
43
|
+
inArray(
|
|
44
|
+
messageOutbox.id,
|
|
45
|
+
this.db
|
|
46
|
+
.select({ id: messageOutbox.id })
|
|
47
|
+
.from(messageOutbox)
|
|
48
|
+
.where(
|
|
49
|
+
and(
|
|
50
|
+
isNull(messageOutbox.publishedAt),
|
|
51
|
+
isNull(messageOutbox.failedAt),
|
|
52
|
+
lte(messageOutbox.availableAt, now),
|
|
53
|
+
or(isNull(messageOutbox.claimExpiresAt), lte(messageOutbox.claimExpiresAt, now)),
|
|
54
|
+
),
|
|
55
|
+
)
|
|
56
|
+
.orderBy(
|
|
57
|
+
asc(messageOutbox.availableAt),
|
|
58
|
+
asc(messageOutbox.createdAt),
|
|
59
|
+
asc(messageOutbox.id),
|
|
60
|
+
)
|
|
61
|
+
.limit(batchSize),
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
.returning({
|
|
65
|
+
payload: messageOutbox.payload,
|
|
66
|
+
attempts: messageOutbox.attempts,
|
|
67
|
+
availableAt: messageOutbox.availableAt,
|
|
68
|
+
createdAt: messageOutbox.createdAt,
|
|
69
|
+
}),
|
|
70
|
+
)
|
|
71
|
+
// UPDATE RETURNING does not preserve the selection order.
|
|
72
|
+
rows.sort(
|
|
73
|
+
(a, b) =>
|
|
74
|
+
a.availableAt.getTime() - b.availableAt.getTime() ||
|
|
75
|
+
a.createdAt.getTime() - b.createdAt.getTime() ||
|
|
76
|
+
a.payload.id.localeCompare(b.payload.id),
|
|
77
|
+
)
|
|
78
|
+
return { token, messages: rows.map(({ payload, attempts }) => ({ payload, attempts })) }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Mark only supplied rows that still hold this token as published. */
|
|
82
|
+
async markPublished(claim: OutboxClaim, messages: ReadonlyArray<OutboxMessage>): Promise<void> {
|
|
83
|
+
if (messages.length === 0) return
|
|
84
|
+
await executeDatabase('mark outbox messages published', () =>
|
|
85
|
+
this.db
|
|
86
|
+
.update(messageOutbox)
|
|
87
|
+
.set({ publishedAt: this.now(), claimToken: null, claimExpiresAt: null })
|
|
88
|
+
.where(
|
|
89
|
+
and(
|
|
90
|
+
inArray(messageOutbox.id, outboxIds(messages)),
|
|
91
|
+
eq(messageOutbox.claimToken, claim.token),
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Release supplied rows with per-row backoff; a stale token cannot change a newer claim. */
|
|
98
|
+
async releaseClaim(claim: OutboxClaim, messages: ReadonlyArray<OutboxMessage>): Promise<void> {
|
|
99
|
+
const now = this.now()
|
|
100
|
+
for (const attempts of new Set(messages.map((row) => row.attempts))) {
|
|
101
|
+
const rows = messages.filter((row) => row.attempts === attempts)
|
|
102
|
+
const delay = exponentialDelayCapMs(
|
|
103
|
+
this.options.retryBaseDelayMs,
|
|
104
|
+
this.options.retryMaxDelayMs,
|
|
105
|
+
attempts,
|
|
106
|
+
)
|
|
107
|
+
// oxlint-disable-next-line no-await-in-loop -- Stop releasing rows on the first database failure.
|
|
108
|
+
await executeDatabase('release outbox claim', () =>
|
|
109
|
+
this.db
|
|
110
|
+
.update(messageOutbox)
|
|
111
|
+
.set({
|
|
112
|
+
availableAt: deadline(now.getTime() + delay),
|
|
113
|
+
claimToken: null,
|
|
114
|
+
claimExpiresAt: null,
|
|
115
|
+
})
|
|
116
|
+
.where(
|
|
117
|
+
and(
|
|
118
|
+
inArray(messageOutbox.id, outboxIds(rows)),
|
|
119
|
+
eq(messageOutbox.claimToken, claim.token),
|
|
120
|
+
),
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Retain the full payload and record a closed reason with a readable error message. */
|
|
127
|
+
async deadLetter(
|
|
128
|
+
claim: OutboxClaim,
|
|
129
|
+
messages: ReadonlyArray<OutboxMessage>,
|
|
130
|
+
reason: DeadLetterReason,
|
|
131
|
+
errorMessage: string,
|
|
132
|
+
): Promise<void> {
|
|
133
|
+
if (messages.length === 0) return
|
|
134
|
+
await executeDatabase('dead-letter outbox messages', () =>
|
|
135
|
+
this.db
|
|
136
|
+
.update(messageOutbox)
|
|
137
|
+
.set({
|
|
138
|
+
failedAt: this.now(),
|
|
139
|
+
lastError: `${reason}: ${errorMessage}`,
|
|
140
|
+
claimToken: null,
|
|
141
|
+
claimExpiresAt: null,
|
|
142
|
+
})
|
|
143
|
+
.where(
|
|
144
|
+
and(
|
|
145
|
+
inArray(messageOutbox.id, outboxIds(messages)),
|
|
146
|
+
eq(messageOutbox.claimToken, claim.token),
|
|
147
|
+
),
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Delete at most batchSize published rows, strictly before the cutoff. */
|
|
153
|
+
async deletePublishedBefore(cutoff: Date, batchSize: number): Promise<number> {
|
|
154
|
+
const rows = await executeDatabase('delete published outbox messages', () =>
|
|
155
|
+
this.db
|
|
156
|
+
.delete(messageOutbox)
|
|
157
|
+
.where(
|
|
158
|
+
inArray(
|
|
159
|
+
messageOutbox.id,
|
|
160
|
+
this.db
|
|
161
|
+
.select({ id: messageOutbox.id })
|
|
162
|
+
.from(messageOutbox)
|
|
163
|
+
.where(lt(messageOutbox.publishedAt, cutoff))
|
|
164
|
+
.orderBy(asc(messageOutbox.publishedAt), asc(messageOutbox.id))
|
|
165
|
+
.limit(batchSize),
|
|
166
|
+
),
|
|
167
|
+
)
|
|
168
|
+
.returning({ id: messageOutbox.id }),
|
|
169
|
+
)
|
|
170
|
+
return rows.length
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Delete at most batchSize failed rows; pending and published rows remain untouched. */
|
|
174
|
+
async deleteFailedBefore(cutoff: Date, batchSize: number): Promise<number> {
|
|
175
|
+
const rows = await executeDatabase('delete failed outbox messages', () =>
|
|
176
|
+
this.db
|
|
177
|
+
.delete(messageOutbox)
|
|
178
|
+
.where(
|
|
179
|
+
inArray(
|
|
180
|
+
messageOutbox.id,
|
|
181
|
+
this.db
|
|
182
|
+
.select({ id: messageOutbox.id })
|
|
183
|
+
.from(messageOutbox)
|
|
184
|
+
.where(and(isNull(messageOutbox.publishedAt), lt(messageOutbox.failedAt, cutoff)))
|
|
185
|
+
.orderBy(asc(messageOutbox.failedAt), asc(messageOutbox.id))
|
|
186
|
+
.limit(batchSize),
|
|
187
|
+
),
|
|
188
|
+
)
|
|
189
|
+
.returning({ id: messageOutbox.id }),
|
|
190
|
+
)
|
|
191
|
+
return rows.length
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Delete at most batchSize receipts, strictly before the cutoff. */
|
|
195
|
+
async deleteReceiptsBefore(cutoff: Date, batchSize: number): Promise<number> {
|
|
196
|
+
const rows = await executeDatabase('delete message receipts', () =>
|
|
197
|
+
this.db
|
|
198
|
+
.delete(processedMessages)
|
|
199
|
+
.where(
|
|
200
|
+
inArray(
|
|
201
|
+
processedMessages.messageId,
|
|
202
|
+
this.db
|
|
203
|
+
.select({ messageId: processedMessages.messageId })
|
|
204
|
+
.from(processedMessages)
|
|
205
|
+
.where(lt(processedMessages.processedAt, cutoff))
|
|
206
|
+
.orderBy(asc(processedMessages.processedAt), asc(processedMessages.messageId))
|
|
207
|
+
.limit(batchSize),
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
.returning({ messageId: processedMessages.messageId }),
|
|
211
|
+
)
|
|
212
|
+
return rows.length
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// One JSON parameter keeps large claims below D1's 100-parameter limit.
|
|
217
|
+
function outboxIds(messages: ReadonlyArray<OutboxMessage>) {
|
|
218
|
+
return sql`(select value from json_each(${JSON.stringify(messages.map((row) => row.payload.id))}))`
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// D1 timestamps repository seconds; round future deadlines up so a lease or delay never ends early.
|
|
222
|
+
function deadline(ms: number): Date {
|
|
223
|
+
return new Date(Math.ceil(ms / 1_000) * 1_000)
|
|
224
|
+
}
|
|
@@ -7,3 +7,5 @@ export type { D1Connection, D1Db, D1ReadDb, D1Tx } from './d1-database-types.ts'
|
|
|
7
7
|
export { DatabaseError, type DatabaseFailure, type D1ConstraintKind } from '@/core'
|
|
8
8
|
export { executeDatabase } from './d1-database-execution.ts'
|
|
9
9
|
export { eventCollector } from '../unit-of-work-context.ts'
|
|
10
|
+
export { D1OutboxRepository } from './d1-outbox-repository.ts'
|
|
11
|
+
export type { OutboxRepositoryOptions } from '../outbox-repository-options.ts'
|
|
@@ -35,7 +35,9 @@ export interface IQueueClient<TMessage extends QueueMessage> {
|
|
|
35
35
|
send(message: TMessage, options?: QueueSendOptions): Promise<void>
|
|
36
36
|
sendBatch(messages: ReadonlyArray<TMessage>): Promise<void>
|
|
37
37
|
/** Sends every message within the limits and returns the oversized ones. */
|
|
38
|
-
sendBatchPartial
|
|
38
|
+
sendBatchPartial<Sent extends TMessage>(
|
|
39
|
+
messages: ReadonlyArray<Sent>,
|
|
40
|
+
): Promise<ReadonlyArray<Sent>>
|
|
39
41
|
/** Parks a delivered message's body as it arrived. */
|
|
40
42
|
sendToDlq(message: Message): Promise<void>
|
|
41
43
|
}
|
|
@@ -109,7 +111,9 @@ export class QueueClient<
|
|
|
109
111
|
await this.sendSizedBatch(sized)
|
|
110
112
|
}
|
|
111
113
|
|
|
112
|
-
async sendBatchPartial
|
|
114
|
+
async sendBatchPartial<Sent extends TMessage>(
|
|
115
|
+
messages: ReadonlyArray<Sent>,
|
|
116
|
+
): Promise<ReadonlyArray<Sent>> {
|
|
113
117
|
const sized = sizeMessages(messages)
|
|
114
118
|
await this.sendSizedBatch(sized.filter((item) => !isOversized(item)))
|
|
115
119
|
return sized.filter(isOversized).map((item) => item.message)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { IOutboxRelay, OutboxRelay } from '@/application'
|
|
2
|
+
import { createLogger } from '@/core'
|
|
3
|
+
|
|
4
|
+
const logger = createLogger('outbox-wake')
|
|
5
|
+
|
|
6
|
+
/** Register delivery after commit; log a failed drain once and leave recovery to the scheduled sweep. */
|
|
7
|
+
export function waitUntilWake(
|
|
8
|
+
context: Pick<ExecutionContext, 'waitUntil'>,
|
|
9
|
+
relay: Pick<OutboxRelay, 'drain'>,
|
|
10
|
+
): IOutboxRelay {
|
|
11
|
+
return {
|
|
12
|
+
wake: () =>
|
|
13
|
+
context.waitUntil(
|
|
14
|
+
relay.drain().catch((cause: unknown) => {
|
|
15
|
+
logger.error('outbox_wake_failed', { error: cause })
|
|
16
|
+
}),
|
|
17
|
+
),
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Shared lease and delay defaults for both database adapters. */
|
|
2
|
+
export const OUTBOX_REPOSITORY_DEFAULTS = {
|
|
3
|
+
leaseMs: 60_000,
|
|
4
|
+
retryBaseDelayMs: 1_000,
|
|
5
|
+
retryMaxDelayMs: 60_000,
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Override lease and delay limits at the composition root. */
|
|
9
|
+
export type OutboxRepositoryOptions = Readonly<Partial<typeof OUTBOX_REPOSITORY_DEFAULTS>>
|
|
@@ -13,3 +13,5 @@ export { DatabaseError, type DatabaseFailure } from '@/core'
|
|
|
13
13
|
export { executeDatabase } from './postgres-database-execution.ts'
|
|
14
14
|
export { classifyPostgresError } from './classify-postgres-error.ts'
|
|
15
15
|
export { eventCollector } from '../unit-of-work-context.ts'
|
|
16
|
+
export { PostgresOutboxRepository } from './postgres-outbox-repository.ts'
|
|
17
|
+
export type { OutboxRepositoryOptions } from '../outbox-repository-options.ts'
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { and, asc, eq, inArray, isNull, lt, lte, or, sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { DeadLetterReason, IOutboxRepository, OutboxClaim, OutboxMessage } from '@/application'
|
|
4
|
+
import { exponentialDelayCapMs } from '@/core'
|
|
5
|
+
|
|
6
|
+
import type { DrizzleSchema } from '../drizzle-schema.ts'
|
|
7
|
+
import {
|
|
8
|
+
OUTBOX_REPOSITORY_DEFAULTS,
|
|
9
|
+
type OutboxRepositoryOptions,
|
|
10
|
+
} from '../outbox-repository-options.ts'
|
|
11
|
+
import { executeDatabase } from './postgres-database-execution.ts'
|
|
12
|
+
import type { PostgresDb } from './postgres-database-types.ts'
|
|
13
|
+
import { messageOutbox, processedMessages } from './postgres-schema.ts'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Typist's lease protocol with riposte's SKIP LOCKED selection on a plain connection, outside a unit of work.
|
|
17
|
+
* Every write runs once; lease expiry and the scheduled sweep recover interrupted operations.
|
|
18
|
+
*/
|
|
19
|
+
export class PostgresOutboxRepository implements IOutboxRepository {
|
|
20
|
+
private readonly options: Required<OutboxRepositoryOptions>
|
|
21
|
+
|
|
22
|
+
constructor(
|
|
23
|
+
private readonly db: PostgresDb<DrizzleSchema>,
|
|
24
|
+
options: OutboxRepositoryOptions = {},
|
|
25
|
+
private readonly now: () => Date = () => new Date(),
|
|
26
|
+
) {
|
|
27
|
+
this.options = { ...OUTBOX_REPOSITORY_DEFAULTS, ...options }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Lease due rows in one statement; increment attempts even when reclaiming an expired lease. */
|
|
31
|
+
async claimPending(batchSize: number): Promise<OutboxClaim> {
|
|
32
|
+
const token = crypto.randomUUID()
|
|
33
|
+
const now = this.now()
|
|
34
|
+
const rows = await executeDatabase('claim pending outbox messages', () =>
|
|
35
|
+
this.db
|
|
36
|
+
.update(messageOutbox)
|
|
37
|
+
.set({
|
|
38
|
+
claimToken: token,
|
|
39
|
+
claimExpiresAt: new Date(now.getTime() + this.options.leaseMs),
|
|
40
|
+
attempts: sql`${messageOutbox.attempts} + 1`,
|
|
41
|
+
})
|
|
42
|
+
.where(
|
|
43
|
+
inArray(
|
|
44
|
+
messageOutbox.id,
|
|
45
|
+
this.db
|
|
46
|
+
.select({ id: messageOutbox.id })
|
|
47
|
+
.from(messageOutbox)
|
|
48
|
+
.where(
|
|
49
|
+
and(
|
|
50
|
+
isNull(messageOutbox.publishedAt),
|
|
51
|
+
isNull(messageOutbox.failedAt),
|
|
52
|
+
lte(messageOutbox.availableAt, now),
|
|
53
|
+
or(isNull(messageOutbox.claimExpiresAt), lte(messageOutbox.claimExpiresAt, now)),
|
|
54
|
+
),
|
|
55
|
+
)
|
|
56
|
+
.orderBy(
|
|
57
|
+
asc(messageOutbox.availableAt),
|
|
58
|
+
asc(messageOutbox.createdAt),
|
|
59
|
+
asc(messageOutbox.id),
|
|
60
|
+
)
|
|
61
|
+
.limit(batchSize)
|
|
62
|
+
.for('update', { skipLocked: true }),
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
.returning({
|
|
66
|
+
payload: messageOutbox.payload,
|
|
67
|
+
attempts: messageOutbox.attempts,
|
|
68
|
+
availableAt: messageOutbox.availableAt,
|
|
69
|
+
createdAt: messageOutbox.createdAt,
|
|
70
|
+
}),
|
|
71
|
+
)
|
|
72
|
+
// UPDATE RETURNING does not preserve the selection order.
|
|
73
|
+
rows.sort(
|
|
74
|
+
(a, b) =>
|
|
75
|
+
a.availableAt.getTime() - b.availableAt.getTime() ||
|
|
76
|
+
a.createdAt.getTime() - b.createdAt.getTime() ||
|
|
77
|
+
a.payload.id.localeCompare(b.payload.id),
|
|
78
|
+
)
|
|
79
|
+
return { token, messages: rows.map(({ payload, attempts }) => ({ payload, attempts })) }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Mark only supplied rows that still hold this token as published. */
|
|
83
|
+
async markPublished(claim: OutboxClaim, messages: ReadonlyArray<OutboxMessage>): Promise<void> {
|
|
84
|
+
if (messages.length === 0) return
|
|
85
|
+
await executeDatabase('mark outbox messages published', () =>
|
|
86
|
+
this.db
|
|
87
|
+
.update(messageOutbox)
|
|
88
|
+
.set({ publishedAt: this.now(), claimToken: null, claimExpiresAt: null })
|
|
89
|
+
.where(
|
|
90
|
+
and(
|
|
91
|
+
inArray(
|
|
92
|
+
messageOutbox.id,
|
|
93
|
+
messages.map((row) => row.payload.id),
|
|
94
|
+
),
|
|
95
|
+
eq(messageOutbox.claimToken, claim.token),
|
|
96
|
+
),
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Release supplied rows with per-row backoff; a stale token cannot change a newer claim. */
|
|
102
|
+
async releaseClaim(claim: OutboxClaim, messages: ReadonlyArray<OutboxMessage>): Promise<void> {
|
|
103
|
+
const now = this.now()
|
|
104
|
+
for (const attempts of new Set(messages.map((row) => row.attempts))) {
|
|
105
|
+
const ids = messages.filter((row) => row.attempts === attempts).map((row) => row.payload.id)
|
|
106
|
+
const delay = exponentialDelayCapMs(
|
|
107
|
+
this.options.retryBaseDelayMs,
|
|
108
|
+
this.options.retryMaxDelayMs,
|
|
109
|
+
attempts,
|
|
110
|
+
)
|
|
111
|
+
// oxlint-disable-next-line no-await-in-loop -- Stop releasing rows on the first database failure.
|
|
112
|
+
await executeDatabase('release outbox claim', () =>
|
|
113
|
+
this.db
|
|
114
|
+
.update(messageOutbox)
|
|
115
|
+
.set({
|
|
116
|
+
availableAt: new Date(now.getTime() + delay),
|
|
117
|
+
claimToken: null,
|
|
118
|
+
claimExpiresAt: null,
|
|
119
|
+
})
|
|
120
|
+
.where(and(inArray(messageOutbox.id, ids), eq(messageOutbox.claimToken, claim.token))),
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Retain the full payload and record a closed reason with a readable error message. */
|
|
126
|
+
async deadLetter(
|
|
127
|
+
claim: OutboxClaim,
|
|
128
|
+
messages: ReadonlyArray<OutboxMessage>,
|
|
129
|
+
reason: DeadLetterReason,
|
|
130
|
+
errorMessage: string,
|
|
131
|
+
): Promise<void> {
|
|
132
|
+
if (messages.length === 0) return
|
|
133
|
+
await executeDatabase('dead-letter outbox messages', () =>
|
|
134
|
+
this.db
|
|
135
|
+
.update(messageOutbox)
|
|
136
|
+
.set({
|
|
137
|
+
failedAt: this.now(),
|
|
138
|
+
lastError: `${reason}: ${errorMessage}`,
|
|
139
|
+
claimToken: null,
|
|
140
|
+
claimExpiresAt: null,
|
|
141
|
+
})
|
|
142
|
+
.where(
|
|
143
|
+
and(
|
|
144
|
+
inArray(
|
|
145
|
+
messageOutbox.id,
|
|
146
|
+
messages.map((row) => row.payload.id),
|
|
147
|
+
),
|
|
148
|
+
eq(messageOutbox.claimToken, claim.token),
|
|
149
|
+
),
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Delete at most batchSize published rows, strictly before the cutoff. */
|
|
155
|
+
async deletePublishedBefore(cutoff: Date, batchSize: number): Promise<number> {
|
|
156
|
+
const rows = await executeDatabase('delete published outbox messages', () =>
|
|
157
|
+
this.db
|
|
158
|
+
.delete(messageOutbox)
|
|
159
|
+
.where(
|
|
160
|
+
inArray(
|
|
161
|
+
messageOutbox.id,
|
|
162
|
+
this.db
|
|
163
|
+
.select({ id: messageOutbox.id })
|
|
164
|
+
.from(messageOutbox)
|
|
165
|
+
.where(lt(messageOutbox.publishedAt, cutoff))
|
|
166
|
+
.orderBy(asc(messageOutbox.publishedAt), asc(messageOutbox.id))
|
|
167
|
+
.limit(batchSize),
|
|
168
|
+
),
|
|
169
|
+
)
|
|
170
|
+
.returning({ id: messageOutbox.id }),
|
|
171
|
+
)
|
|
172
|
+
return rows.length
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Delete at most batchSize failed rows; pending and published rows remain untouched. */
|
|
176
|
+
async deleteFailedBefore(cutoff: Date, batchSize: number): Promise<number> {
|
|
177
|
+
const rows = await executeDatabase('delete failed outbox messages', () =>
|
|
178
|
+
this.db
|
|
179
|
+
.delete(messageOutbox)
|
|
180
|
+
.where(
|
|
181
|
+
inArray(
|
|
182
|
+
messageOutbox.id,
|
|
183
|
+
this.db
|
|
184
|
+
.select({ id: messageOutbox.id })
|
|
185
|
+
.from(messageOutbox)
|
|
186
|
+
.where(and(isNull(messageOutbox.publishedAt), lt(messageOutbox.failedAt, cutoff)))
|
|
187
|
+
.orderBy(asc(messageOutbox.failedAt), asc(messageOutbox.id))
|
|
188
|
+
.limit(batchSize),
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
.returning({ id: messageOutbox.id }),
|
|
192
|
+
)
|
|
193
|
+
return rows.length
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Delete at most batchSize receipts, strictly before the cutoff. */
|
|
197
|
+
async deleteReceiptsBefore(cutoff: Date, batchSize: number): Promise<number> {
|
|
198
|
+
const rows = await executeDatabase('delete message receipts', () =>
|
|
199
|
+
this.db
|
|
200
|
+
.delete(processedMessages)
|
|
201
|
+
.where(
|
|
202
|
+
inArray(
|
|
203
|
+
processedMessages.messageId,
|
|
204
|
+
this.db
|
|
205
|
+
.select({ messageId: processedMessages.messageId })
|
|
206
|
+
.from(processedMessages)
|
|
207
|
+
.where(lt(processedMessages.processedAt, cutoff))
|
|
208
|
+
.orderBy(asc(processedMessages.processedAt), asc(processedMessages.messageId))
|
|
209
|
+
.limit(batchSize),
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
.returning({ messageId: processedMessages.messageId }),
|
|
213
|
+
)
|
|
214
|
+
return rows.length
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import posthog, { type PostHog, type PostHogConfig } from 'posthog-js'
|
|
2
|
+
|
|
3
|
+
/** The application controls enablement; disabled initialization needs no project key. */
|
|
4
|
+
export type PostHogClientConfig =
|
|
5
|
+
| { readonly enabled: false }
|
|
6
|
+
| {
|
|
7
|
+
readonly enabled: true
|
|
8
|
+
readonly apiKey: string
|
|
9
|
+
readonly environment: string
|
|
10
|
+
readonly source: string
|
|
11
|
+
readonly options?: Partial<PostHogConfig>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Browser options from Typist and Porte; SDK option names and application overrides stay intact. */
|
|
15
|
+
export function createPostHogClientOptions(
|
|
16
|
+
config: Extract<PostHogClientConfig, { enabled: true }>,
|
|
17
|
+
): Partial<PostHogConfig> {
|
|
18
|
+
return {
|
|
19
|
+
api_host: '/relay',
|
|
20
|
+
ui_host: 'https://us.posthog.com',
|
|
21
|
+
person_profiles: 'identified_only',
|
|
22
|
+
defaults: '2026-08-29',
|
|
23
|
+
disable_surveys: true,
|
|
24
|
+
advanced_disable_feature_flags: true,
|
|
25
|
+
capture_performance: { web_vitals: false },
|
|
26
|
+
...config.options,
|
|
27
|
+
loaded: (client) => {
|
|
28
|
+
client.register({ environment: config.environment, source: config.source })
|
|
29
|
+
config.options?.loaded?.(client)
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Initialize once in the browser entrypoint; pass the returned SDK client to PostHogProvider. */
|
|
35
|
+
export function initializePostHog(config: PostHogClientConfig): PostHog {
|
|
36
|
+
if (!config.enabled) return posthog
|
|
37
|
+
posthog.init(config.apiKey, createPostHogClientOptions(config))
|
|
38
|
+
return posthog
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export { PostHogProvider, usePostHog } from '@posthog/react'
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { PostHog } from 'posthog-node'
|
|
2
|
+
|
|
3
|
+
import type { MetricOptions, MetricsService } from '@/application'
|
|
4
|
+
|
|
5
|
+
/** PostHog Metrics with Worker lifetime management, ported from Typist's metrics-service.ts. */
|
|
6
|
+
export class PostHogMetricsService implements MetricsService {
|
|
7
|
+
/** Use the analytics client's metrics; the runtime owns each flush promise. */
|
|
8
|
+
constructor(
|
|
9
|
+
private readonly client: Pick<PostHog, 'metrics'>,
|
|
10
|
+
private readonly waitUntil: (promise: Promise<unknown>) => void,
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
/** Record a total and schedule its flush. */
|
|
14
|
+
count(name: string, value = 1, options?: MetricOptions): void {
|
|
15
|
+
this.client.metrics.count(name, value, options)
|
|
16
|
+
this.waitUntil(this.client.metrics.flush())
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Record a current value and schedule its flush. */
|
|
20
|
+
gauge(name: string, value: number, options?: MetricOptions): void {
|
|
21
|
+
this.client.metrics.gauge(name, value, options)
|
|
22
|
+
this.waitUntil(this.client.metrics.flush())
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Record an observation and schedule its flush. */
|
|
26
|
+
histogram(name: string, value: number, options?: MetricOptions): void {
|
|
27
|
+
this.client.metrics.histogram(name, value, options)
|
|
28
|
+
this.waitUntil(this.client.metrics.flush())
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Runtime resources and deployment hosts for Typist's same-origin proxy. */
|
|
2
|
+
export interface PostHogProxyOptions {
|
|
3
|
+
readonly cache: Pick<Cache, 'match' | 'put'>
|
|
4
|
+
readonly waitUntil: (promise: Promise<void>) => void
|
|
5
|
+
readonly basePath?: string
|
|
6
|
+
readonly ingestHost?: string
|
|
7
|
+
readonly assetHost?: string
|
|
8
|
+
readonly fetch?: typeof globalThis.fetch
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Mount at /relay/$ with GET, POST, and OPTIONS handlers. Supply Cloudflare's asset cache and waitUntil.
|
|
13
|
+
* The application must use the same base path in the browser's api_host option.
|
|
14
|
+
*/
|
|
15
|
+
export async function proxyPostHogRequest(
|
|
16
|
+
request: Request,
|
|
17
|
+
options: PostHogProxyOptions,
|
|
18
|
+
): Promise<Response> {
|
|
19
|
+
const url = new URL(request.url)
|
|
20
|
+
const basePath = options.basePath ?? '/relay'
|
|
21
|
+
if (!url.pathname.startsWith(`${basePath}/`)) return new Response(null, { status: 404 })
|
|
22
|
+
const pathname = url.pathname.slice(basePath.length)
|
|
23
|
+
const pathWithSearch = pathname + url.search
|
|
24
|
+
const send = options.fetch ?? globalThis.fetch
|
|
25
|
+
|
|
26
|
+
if (pathname.startsWith('/static/') || pathname.startsWith('/array/')) {
|
|
27
|
+
const cached = await options.cache.match(request)
|
|
28
|
+
if (cached) return cached
|
|
29
|
+
const response = await send(
|
|
30
|
+
`https://${options.assetHost ?? 'us-assets.i.posthog.com'}${pathWithSearch}`,
|
|
31
|
+
)
|
|
32
|
+
options.waitUntil(options.cache.put(request, response.clone()))
|
|
33
|
+
return response
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const headers = new Headers(request.headers)
|
|
37
|
+
headers.delete('cookie')
|
|
38
|
+
headers.delete('authorization')
|
|
39
|
+
headers.set('X-Forwarded-For', request.headers.get('CF-Connecting-IP') ?? '')
|
|
40
|
+
return await send(`https://${options.ingestHost ?? 'us.i.posthog.com'}${pathWithSearch}`, {
|
|
41
|
+
method: request.method,
|
|
42
|
+
headers,
|
|
43
|
+
body:
|
|
44
|
+
request.method !== 'GET' && request.method !== 'HEAD' ? await request.arrayBuffer() : null,
|
|
45
|
+
redirect: request.redirect,
|
|
46
|
+
})
|
|
47
|
+
}
|