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.
Files changed (116) hide show
  1. package/dist/application-C4JOf71r.js +291 -0
  2. package/dist/application-C4JOf71r.js.map +1 -0
  3. package/dist/application.d.ts +3 -3
  4. package/dist/application.js +2 -2
  5. package/dist/better-auth/apple-client-secret.d.ts +1 -1
  6. package/dist/better-auth/apple-client-secret.js +1 -1
  7. package/dist/better-auth/auth-client.js +1 -1
  8. package/dist/better-auth/rate-limit.d.ts +1 -1
  9. package/dist/better-auth/rate-limit.js +1 -1
  10. package/dist/better-auth.js +1 -1
  11. package/dist/cloudflare/auth-secondary-storage.d.ts +1 -1
  12. package/dist/cloudflare/auth-secondary-storage.js +1 -1
  13. package/dist/cloudflare/queue.d.ts +8 -3
  14. package/dist/cloudflare/queue.d.ts.map +1 -1
  15. package/dist/cloudflare/queue.js +26 -16
  16. package/dist/cloudflare/queue.js.map +1 -1
  17. package/dist/cloudflare/scheduled.d.ts +1 -1
  18. package/dist/cloudflare/scheduled.js +1 -1
  19. package/dist/cloudflare.d.ts +30 -4
  20. package/dist/cloudflare.d.ts.map +1 -1
  21. package/dist/cloudflare.js +98 -5
  22. package/dist/cloudflare.js.map +1 -1
  23. package/dist/{core-DG_cC8YT.js → core-hdaNiCOU.js} +22 -2
  24. package/dist/core-hdaNiCOU.js.map +1 -0
  25. package/dist/core.d.ts +2 -2
  26. package/dist/core.js +2 -2
  27. package/dist/http.d.ts +2 -2
  28. package/dist/http.js +2 -2
  29. package/dist/{index-DwWxIdQP.d.ts → index-C28HgVfY.d.ts} +145 -3
  30. package/dist/index-C28HgVfY.d.ts.map +1 -0
  31. package/dist/{index-Bl0K1RQW.d.ts → index-G1QG0P5g.d.ts} +36 -2
  32. package/dist/index-G1QG0P5g.d.ts.map +1 -0
  33. package/dist/outbox-repository-options-BZjfDqGf.d.ts +26 -0
  34. package/dist/outbox-repository-options-BZjfDqGf.d.ts.map +1 -0
  35. package/dist/{unit-of-work-context-BPE1In2f.js → outbox-repository-options-DOTpJH-e.js} +12 -3
  36. package/dist/outbox-repository-options-DOTpJH-e.js.map +1 -0
  37. package/dist/postgres.d.ts +30 -4
  38. package/dist/postgres.d.ts.map +1 -1
  39. package/dist/postgres.js +92 -5
  40. package/dist/postgres.js.map +1 -1
  41. package/dist/posthog/client.d.ts +22 -0
  42. package/dist/posthog/client.d.ts.map +1 -0
  43. package/dist/posthog/client.js +34 -0
  44. package/dist/posthog/client.js.map +1 -0
  45. package/dist/posthog/metrics.d.ts +18 -0
  46. package/dist/posthog/metrics.d.ts.map +1 -0
  47. package/dist/posthog/metrics.js +30 -0
  48. package/dist/posthog/metrics.js.map +1 -0
  49. package/dist/posthog/proxy.d.ts +17 -0
  50. package/dist/posthog/proxy.d.ts.map +1 -0
  51. package/dist/posthog/proxy.js +34 -0
  52. package/dist/posthog/proxy.js.map +1 -0
  53. package/dist/posthog/server.d.ts +37 -0
  54. package/dist/posthog/server.d.ts.map +1 -0
  55. package/dist/posthog/server.js +81 -0
  56. package/dist/posthog/server.js.map +1 -0
  57. package/dist/resend.d.ts +3 -3
  58. package/dist/resend.js +2 -2
  59. package/dist/sentry/client.d.ts +22 -0
  60. package/dist/sentry/client.d.ts.map +1 -0
  61. package/dist/sentry/client.js +48 -0
  62. package/dist/sentry/client.js.map +1 -0
  63. package/dist/sentry/server.d.ts +24 -0
  64. package/dist/sentry/server.d.ts.map +1 -0
  65. package/dist/sentry/server.js +48 -0
  66. package/dist/sentry/server.js.map +1 -0
  67. package/dist/sentry/vite.d.ts +11 -0
  68. package/dist/sentry/vite.d.ts.map +1 -0
  69. package/dist/sentry/vite.js +17 -0
  70. package/dist/sentry/vite.js.map +1 -0
  71. package/dist/tanstack.d.ts +1 -1
  72. package/dist/tanstack.js +2 -2
  73. package/dist/testing/outbox-repository.d.ts +44 -0
  74. package/dist/testing/outbox-repository.d.ts.map +1 -0
  75. package/dist/testing/outbox-repository.js +206 -0
  76. package/dist/testing/outbox-repository.js.map +1 -0
  77. package/dist/{webhook-verification-sZtwTI5n.d.ts → webhook-verification-D7DIhH6C.d.ts} +2 -2
  78. package/dist/{webhook-verification-sZtwTI5n.d.ts.map → webhook-verification-D7DIhH6C.d.ts.map} +1 -1
  79. package/dist/{webhook-verification-BQ9qZnVK.js → webhook-verification-trEgyz7I.js} +2 -2
  80. package/dist/{webhook-verification-BQ9qZnVK.js.map → webhook-verification-trEgyz7I.js.map} +1 -1
  81. package/package.json +42 -1
  82. package/src/application/analytics-service.ts +21 -0
  83. package/src/application/index.ts +31 -1
  84. package/src/application/metrics-service.ts +28 -0
  85. package/src/application/observability-service.ts +16 -0
  86. package/src/application/outbox-relay.ts +117 -0
  87. package/src/application/outbox-repository.ts +46 -0
  88. package/src/application/outbox-retention.ts +61 -0
  89. package/src/core/analytics-event.ts +37 -0
  90. package/src/core/backoff.ts +6 -1
  91. package/src/core/index.ts +10 -1
  92. package/src/core/missing-configuration.error.ts +12 -0
  93. package/src/infrastructure/cloudflare/d1-outbox-repository.ts +224 -0
  94. package/src/infrastructure/cloudflare/index.ts +2 -0
  95. package/src/infrastructure/cloudflare/queue/index.ts +1 -0
  96. package/src/infrastructure/cloudflare/queue/queue-client.ts +6 -2
  97. package/src/infrastructure/cloudflare/queue/wait-until-wake.ts +19 -0
  98. package/src/infrastructure/outbox-repository-options.ts +9 -0
  99. package/src/infrastructure/postgres/index.ts +2 -0
  100. package/src/infrastructure/postgres/postgres-outbox-repository.ts +216 -0
  101. package/src/infrastructure/posthog/client.ts +41 -0
  102. package/src/infrastructure/posthog/metrics.ts +30 -0
  103. package/src/infrastructure/posthog/proxy.ts +47 -0
  104. package/src/infrastructure/posthog/server.ts +107 -0
  105. package/src/infrastructure/sentry/client.ts +55 -0
  106. package/src/infrastructure/sentry/server.ts +51 -0
  107. package/src/infrastructure/sentry/vite.ts +17 -0
  108. package/src/infrastructure/testing/outbox-repository-contract.ts +228 -0
  109. package/dist/application-CIxo_BpK.js +0 -160
  110. package/dist/application-CIxo_BpK.js.map +0 -1
  111. package/dist/core-DG_cC8YT.js.map +0 -1
  112. package/dist/index-Bl0K1RQW.d.ts.map +0 -1
  113. package/dist/index-DwWxIdQP.d.ts.map +0 -1
  114. package/dist/unit-of-work-context-BPE1In2f.js.map +0 -1
  115. package/dist/unit-of-work-context-D5ytJIa-.d.ts +0 -16
  116. 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'
@@ -23,3 +23,4 @@ export {
23
23
  type QueueOperation,
24
24
  } from './queue.errors.ts'
25
25
  export { decideRedelivery, type RedeliveryDecision } from './redelivery-policy.ts'
26
+ export { waitUntilWake } from './wait-until-wake.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(messages: ReadonlyArray<TMessage>): Promise<ReadonlyArray<TMessage>>
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(messages: ReadonlyArray<TMessage>): Promise<ReadonlyArray<TMessage>> {
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
+ }