better-ship 0.3.2 → 0.4.1

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 (60) hide show
  1. package/dist/application-rpZoTfzU.js +131 -0
  2. package/dist/application-rpZoTfzU.js.map +1 -0
  3. package/dist/application.d.ts +2 -127
  4. package/dist/application.js +2 -84
  5. package/dist/cloudflare.d.ts +301 -1
  6. package/dist/cloudflare.d.ts.map +1 -0
  7. package/dist/cloudflare.js +169 -0
  8. package/dist/cloudflare.js.map +1 -0
  9. package/dist/{core-D0asUCvq.js → core-MjwyZJ4i.js} +26 -6
  10. package/dist/core-MjwyZJ4i.js.map +1 -0
  11. package/dist/core.d.ts +2 -2
  12. package/dist/core.js +2 -2
  13. package/dist/index-BN_tjxME.d.ts +200 -0
  14. package/dist/index-BN_tjxME.d.ts.map +1 -0
  15. package/dist/{index-eL4vb02m.d.ts → index-ToddX0m3.d.ts} +33 -3
  16. package/dist/index-ToddX0m3.d.ts.map +1 -0
  17. package/dist/postgres.d.ts +289 -1
  18. package/dist/postgres.d.ts.map +1 -0
  19. package/dist/postgres.js +169 -0
  20. package/dist/postgres.js.map +1 -0
  21. package/dist/unit-of-work-context-BLNj72v0.js +48 -0
  22. package/dist/unit-of-work-context-BLNj72v0.js.map +1 -0
  23. package/dist/unit-of-work-context-CbUJGHlL.d.ts +15 -0
  24. package/dist/unit-of-work-context-CbUJGHlL.d.ts.map +1 -0
  25. package/package.json +17 -2
  26. package/src/application/event-collector.ts +7 -0
  27. package/src/application/index.ts +7 -1
  28. package/src/application/message-already-processed.error.ts +33 -0
  29. package/src/application/message-bus.ts +3 -3
  30. package/src/application/message-store.ts +12 -0
  31. package/src/application/outbox-relay.ts +8 -0
  32. package/src/application/transaction.ts +8 -0
  33. package/src/application/unit-of-work.errors.ts +11 -0
  34. package/src/application/unit-of-work.ts +54 -0
  35. package/src/core/database.error.ts +37 -0
  36. package/src/core/index.ts +1 -0
  37. package/src/core/logger.ts +13 -4
  38. package/src/infrastructure/cloudflare/base-d1-repository.ts +40 -0
  39. package/src/infrastructure/cloudflare/classify-d1-error.ts +82 -0
  40. package/src/infrastructure/cloudflare/d1-batch-transaction.ts +27 -0
  41. package/src/infrastructure/cloudflare/d1-database-execution.ts +13 -0
  42. package/src/infrastructure/cloudflare/d1-database-types.ts +20 -0
  43. package/src/infrastructure/cloudflare/d1-message-store.ts +41 -0
  44. package/src/infrastructure/cloudflare/d1-schema.ts +43 -0
  45. package/src/infrastructure/cloudflare/index.ts +9 -0
  46. package/src/infrastructure/drizzle-schema.ts +6 -0
  47. package/src/infrastructure/postgres/base-postgres-repository.ts +30 -0
  48. package/src/infrastructure/postgres/classify-postgres-error.ts +90 -0
  49. package/src/infrastructure/postgres/index.ts +15 -0
  50. package/src/infrastructure/postgres/postgres-database-execution.ts +13 -0
  51. package/src/infrastructure/postgres/postgres-database-types.ts +27 -0
  52. package/src/infrastructure/postgres/postgres-message-store.ts +32 -0
  53. package/src/infrastructure/postgres/postgres-schema.ts +33 -0
  54. package/src/infrastructure/postgres/postgres-transaction.ts +22 -0
  55. package/src/infrastructure/unit-of-work-context.ts +55 -0
  56. package/dist/application.d.ts.map +0 -1
  57. package/dist/application.js.map +0 -1
  58. package/dist/core-D0asUCvq.js.map +0 -1
  59. package/dist/index-eL4vb02m.d.ts.map +0 -1
  60. package/src/application/message-bus.errors.ts +0 -16
@@ -0,0 +1,33 @@
1
+ import { AppError, DatabaseError } from '@/core'
2
+
3
+ import type { MessageId } from './messages.ts'
4
+
5
+ /** The message's effects already committed. A caller can skip duplicate delivery without repeating those effects. */
6
+ export class MessageAlreadyProcessedError extends AppError {
7
+ readonly _tag = 'MessageAlreadyProcessedError'
8
+ readonly classification = 'terminal'
9
+
10
+ constructor(
11
+ readonly messageId: MessageId,
12
+ options?: { readonly cause: unknown },
13
+ ) {
14
+ super(`message ${messageId} already processed`, options)
15
+ }
16
+
17
+ /** Recognize only the processed-message primary key; preserve other failures unchanged at the call site. */
18
+ static from(cause: unknown, messageId: MessageId): MessageAlreadyProcessedError | undefined {
19
+ if (cause instanceof MessageAlreadyProcessedError) return cause
20
+ if (!(cause instanceof DatabaseError)) return undefined
21
+ const details = cause.details
22
+ const duplicate =
23
+ details.database === 'd1'
24
+ ? details.kind === 'unique' &&
25
+ details.table === 'processed_messages' &&
26
+ details.column === 'message_id'
27
+ : details.code === '23505' &&
28
+ details.table_name === 'processed_messages' &&
29
+ details.constraint_name === 'processed_messages_pkey'
30
+ if (!duplicate) return undefined
31
+ return new MessageAlreadyProcessedError(messageId, { cause })
32
+ }
33
+ }
@@ -2,7 +2,7 @@ import { panic } from 'better-result'
2
2
 
3
3
  import { createLogger, unreachable } from '@/core'
4
4
 
5
- import { DuplicateMessageError } from './message-bus.errors.ts'
5
+ import { MessageAlreadyProcessedError } from './message-already-processed.error.ts'
6
6
  import type { Command, Event, Message, Query } from './messages.ts'
7
7
  import type { Handlers, MessageResult, RegistryMessage } from './registry.ts'
8
8
 
@@ -60,7 +60,7 @@ export class MessageBus<Deps, TRegistry extends Handlers<Deps>> implements IMess
60
60
 
61
61
  /**
62
62
  * Every subscriber runs to completion. One failure is rethrown as is. Several are thrown as
63
- * one `AggregateError`. A `DuplicateMessageError` means that subscriber already committed on
63
+ * one `AggregateError`. A `MessageAlreadyProcessedError` means that subscriber already committed on
64
64
  * an earlier delivery, so it counts as delivered.
65
65
  */
66
66
  private async handleEvent(event: Event): Promise<void> {
@@ -71,7 +71,7 @@ export class MessageBus<Deps, TRegistry extends Handlers<Deps>> implements IMess
71
71
  const failures: unknown[] = []
72
72
  for (const [subscriber, outcome] of outcomes.entries()) {
73
73
  if (outcome.status === 'fulfilled') continue
74
- if (outcome.reason instanceof DuplicateMessageError) {
74
+ if (outcome.reason instanceof MessageAlreadyProcessedError) {
75
75
  // The bus swallows this error, so this line is the only record that the skip happened.
76
76
  logger.info('duplicate_subscriber_ignored', { event: event.name, id: event.id, subscriber })
77
77
  continue
@@ -0,0 +1,12 @@
1
+ import type { Event, MessageId } from './messages.ts'
2
+
3
+ /**
4
+ * Stores processed messages and outgoing events in the same transaction as application writes.
5
+ * One adapter per database, built on the boundary like every other repository.
6
+ */
7
+ export interface IMessageStore {
8
+ /** Record this message in the transaction; reject an existing record with `MessageAlreadyProcessedError`. */
9
+ claim(messageId: MessageId): Promise<void>
10
+ /** Write the events to the outbox, inside the same boundary as the state change. */
11
+ persistEvents(events: ReadonlyArray<Event>): Promise<void>
12
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Delivers committed outbox rows. The unit of work only wakes it after a commit that wrote
3
+ * events; the app decides what a wake is: `waitUntil`, a Durable Object alarm, nothing.
4
+ * The sweep is the source of truth, so a lost wake loses no events.
5
+ */
6
+ export interface IOutboxRelay {
7
+ wake(): void
8
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Supplies `Tx` to participating repositories and resolves after their writes commit. Failure rolls back or discards writes.
3
+ * Nested calls reject with `UnitOfWorkAlreadyActiveError` before opening another boundary.
4
+ * Independent concurrent calls are allowed.
5
+ */
6
+ export interface ITransaction<Tx> {
7
+ transaction<T>(work: (tx: Tx) => Promise<T>): Promise<T>
8
+ }
@@ -0,0 +1,11 @@
1
+ import { AppError } from '@/core'
2
+
3
+ /** A boundary was opened inside another. One message, one atomic scope. */
4
+ export class UnitOfWorkAlreadyActiveError extends AppError {
5
+ readonly _tag = 'UnitOfWorkAlreadyActiveError'
6
+ readonly classification = 'terminal'
7
+
8
+ constructor() {
9
+ super('a unit of work is already active')
10
+ }
11
+ }
@@ -0,0 +1,54 @@
1
+ import type { IEventCollector } from './event-collector.ts'
2
+ import { MessageAlreadyProcessedError } from './message-already-processed.error.ts'
3
+ import type { IMessageStore } from './message-store.ts'
4
+ import type { MessageId } from './messages.ts'
5
+ import type { IOutboxRelay } from './outbox-relay.ts'
6
+ import type { ITransaction } from './transaction.ts'
7
+
8
+ /**
9
+ * Commits the processed-message record, repository writes, and emitted events together before resolving.
10
+ * An existing record rejects with `MessageAlreadyProcessedError`; external effects must run through the outbox after commit.
11
+ * Nested calls reject with `UnitOfWorkAlreadyActiveError`; independent concurrent calls are allowed.
12
+ */
13
+ export interface IUnitOfWork<Repos> {
14
+ run<T>(messageId: MessageId, work: (repos: Repos) => Promise<T>): Promise<T>
15
+ }
16
+
17
+ export type UnitOfWorkDeps<Tx, Repos> = {
18
+ readonly transaction: ITransaction<Tx>
19
+ /** The processed-messages and outbox repository for this transaction. */
20
+ readonly messageStore: (tx: Tx) => IMessageStore
21
+ /** Repositories participate in the current transaction. */
22
+ readonly createRepositories: (tx: Tx) => Repos
23
+ readonly eventCollector: IEventCollector
24
+ readonly relay: IOutboxRelay
25
+ }
26
+
27
+ /** The unit of work: the boundary, the repositories, the events. Every database call is a port. */
28
+ export class UnitOfWork<Tx, Repos> implements IUnitOfWork<Repos> {
29
+ constructor(private readonly deps: UnitOfWorkDeps<Tx, Repos>) {}
30
+
31
+ async run<T>(messageId: MessageId, work: (repos: Repos) => Promise<T>): Promise<T> {
32
+ const { transaction, messageStore, createRepositories, eventCollector, relay } = this.deps
33
+ let written = 0
34
+
35
+ let value: T
36
+ try {
37
+ value = await transaction.transaction(async (tx) => {
38
+ const store = messageStore(tx)
39
+ await store.claim(messageId)
40
+ const repos = createRepositories(tx)
41
+ const result = await work(repos)
42
+ const pending = eventCollector.collect()
43
+ await store.persistEvents(pending)
44
+ written = pending.length
45
+ return result
46
+ })
47
+ } catch (error) {
48
+ throw MessageAlreadyProcessedError.from(error, messageId) ?? error
49
+ }
50
+
51
+ if (written > 0) relay.wake()
52
+ return value
53
+ }
54
+ }
@@ -0,0 +1,37 @@
1
+ import { AppError } from './app-error.ts'
2
+ import type { FailureClassification } from './failure-classification.ts'
3
+
4
+ /** SQLite reports these constraint categories through D1. */
5
+ export type D1ConstraintKind = 'unique' | 'not_null' | 'foreign_key' | 'check'
6
+
7
+ /** Database evidence for callers that interpret a specific code or constraint. */
8
+ export type DatabaseFailure =
9
+ | {
10
+ readonly database: 'd1'
11
+ readonly code: string | undefined
12
+ readonly kind: D1ConstraintKind | undefined
13
+ readonly table: string | undefined
14
+ readonly column: string | undefined
15
+ readonly constraintName: string | undefined
16
+ }
17
+ | {
18
+ readonly database: 'postgres'
19
+ readonly code: string | undefined
20
+ readonly table_name: string | undefined
21
+ readonly column_name: string | undefined
22
+ readonly constraint_name: string | undefined
23
+ }
24
+
25
+ /** Classification describes the failure; callers must separately establish whether repetition is safe. */
26
+ export class DatabaseError extends AppError {
27
+ readonly _tag = 'DatabaseError'
28
+
29
+ constructor(
30
+ readonly operation: string,
31
+ readonly classification: FailureClassification,
32
+ readonly details: DatabaseFailure,
33
+ options: { readonly cause: unknown },
34
+ ) {
35
+ super(`Database operation failed: ${operation}`, options)
36
+ }
37
+ }
package/src/core/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { AppError } from './app-error.ts'
2
+ export { DatabaseError, type DatabaseFailure, type D1ConstraintKind } from './database.error.ts'
2
3
  export { notImplemented, unreachable } from './defects.ts'
3
4
  export {
4
5
  shouldRetryFailure,
@@ -31,13 +31,14 @@ export const LogLevel = {
31
31
  } as const
32
32
  export type LogLevel = (typeof LogLevel)[keyof typeof LogLevel]
33
33
 
34
- /** An error as plain data: name, message, stack, allowlisted fields, and the cause chain. */
34
+ /** An error as plain data, including diagnostics, causes, and aggregate members. */
35
35
  export type SerializedError = {
36
36
  readonly name: string
37
37
  readonly message: string
38
38
  readonly stack?: string
39
39
  readonly fields?: LogFields
40
40
  readonly cause?: SerializedError
41
+ readonly errors?: ReadonlyArray<SerializedError>
41
42
  }
42
43
 
43
44
  /** One log, as the sink receives it. Plain data, safe to stringify. */
@@ -192,7 +193,7 @@ export function setLogSink(fn: LogSink): void {
192
193
  }
193
194
 
194
195
  const LOG_LEVELS = Object.values(LogLevel)
195
- const MAX_ERROR_CAUSE_DEPTH = 3
196
+ const MAX_ERROR_DEPTH = 3
196
197
 
197
198
  /**
198
199
  * Diagnostics worth keeping off an error, named one by one.
@@ -220,18 +221,26 @@ type ErrorDiagnostics = Error & { readonly [K in (typeof KEPT_ERROR_FIELDS)[numb
220
221
  type MutableSerializedError = { -readonly [K in keyof SerializedError]: SerializedError[K] }
221
222
 
222
223
  const serializeError = (error: Error, depth = 0): SerializedError => {
223
- if (depth > MAX_ERROR_CAUSE_DEPTH) return { name: 'Error', message: '[cause chain truncated]' }
224
+ if (depth > MAX_ERROR_DEPTH) return { name: 'Error', message: '[error nesting truncated]' }
224
225
 
225
226
  // Every field is optional, so an `Error` is already one of these.
226
227
  const carrier: ErrorDiagnostics = error
227
228
  const kept = KEPT_ERROR_FIELDS.filter((key) => carrier[key] !== undefined)
228
229
  const serialized: MutableSerializedError = { name: error.name, message: error.message }
229
- if (depth === 0 && error.stack !== undefined) serialized.stack = error.stack
230
+ if (error.stack !== undefined) serialized.stack = error.stack
230
231
  if (kept.length > 0)
231
232
  serialized.fields = Object.fromEntries(kept.map((key) => [key, carrier[key]]))
232
233
  if (error.cause instanceof Error) serialized.cause = serializeError(error.cause, depth + 1)
233
234
  else if (error.cause !== undefined)
234
235
  serialized.cause = { name: 'Error', message: '[non-error cause]' }
236
+ if (error instanceof AggregateError) {
237
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- AggregateError accepts arbitrary values; instanceof Error validates each member.
238
+ serialized.errors = error.errors.map((failure: unknown) =>
239
+ failure instanceof Error
240
+ ? serializeError(failure, depth + 1)
241
+ : { name: 'NonError', message: '[non-error aggregate member]' },
242
+ )
243
+ }
235
244
  return serialized
236
245
  }
237
246
 
@@ -0,0 +1,40 @@
1
+ import type { BatchItem } from 'drizzle-orm/batch'
2
+
3
+ import type { Event } from '@/application'
4
+
5
+ import type { DrizzleSchema } from '../drizzle-schema.ts'
6
+ import { eventCollector, stageD1Statement } from '../unit-of-work-context.ts'
7
+ import { executeDatabase } from './d1-database-execution.ts'
8
+ import type { D1Db } from './d1-database-types.ts'
9
+
10
+ /** A domain aggregate that accumulates events for the unit of work to drain. */
11
+ export interface AggregateEvents {
12
+ collectEvents(): ReadonlyArray<Event>
13
+ clearEvents(): void
14
+ }
15
+
16
+ /**
17
+ * Shared plumbing for a repository that writes inside a D1 unit of work.
18
+ * Writes are staged, never run; the boundary commits them as one batch. Reads run on `db` now.
19
+ */
20
+ export abstract class BaseD1Repository<Schema extends DrizzleSchema> {
21
+ constructor(protected readonly db: D1Db<Schema>) {}
22
+
23
+ /** Add a statement to the current D1 batch without executing it. */
24
+ protected stage(statement: BatchItem<'sqlite'>): void {
25
+ stageD1Statement(statement)
26
+ }
27
+
28
+ /** Execute one read with database error conversion and no automatic retries. */
29
+ protected read<T>(operation: string, query: () => PromiseLike<T>): Promise<T> {
30
+ return executeDatabase(operation, query)
31
+ }
32
+
33
+ /** Drain an aggregate's pending events into the active boundary. */
34
+ protected dispatchEvents(aggregate: AggregateEvents): void {
35
+ const events = aggregate.collectEvents()
36
+ if (events.length === 0) return
37
+ for (const event of events) eventCollector.emit(event)
38
+ aggregate.clearEvents()
39
+ }
40
+ }
@@ -0,0 +1,82 @@
1
+ import { DrizzleQueryError } from 'drizzle-orm/errors'
2
+
3
+ import { AppError, DatabaseError, type D1ConstraintKind, type FailureClassification } from '@/core'
4
+
5
+ const KIND_BY_CODE: ReadonlyMap<string, D1ConstraintKind> = new Map([
6
+ ['SQLITE_CONSTRAINT_PRIMARYKEY', 'unique'],
7
+ ['SQLITE_CONSTRAINT_UNIQUE', 'unique'],
8
+ ['SQLITE_CONSTRAINT_NOTNULL', 'not_null'],
9
+ ['SQLITE_CONSTRAINT_FOREIGNKEY', 'foreign_key'],
10
+ ['SQLITE_CONSTRAINT_CHECK', 'check'],
11
+ ])
12
+
13
+ const KIND_BY_PROSE: ReadonlyArray<readonly [string, D1ConstraintKind]> = [
14
+ ['UNIQUE constraint failed', 'unique'],
15
+ ['NOT NULL constraint failed', 'not_null'],
16
+ ['FOREIGN KEY constraint failed', 'foreign_key'],
17
+ ['CHECK constraint failed', 'check'],
18
+ ]
19
+
20
+ /**
21
+ * Recognize D1 failures and preserve the original cause. Unrecognized callback exceptions remain unchanged.
22
+ * D1 exposes error codes and constraint locations in text, so parsing stays at this boundary.
23
+ */
24
+ export function classifyD1Error(cause: unknown, operation = 'D1 query'): DatabaseError | undefined {
25
+ if (cause instanceof AppError || (cause instanceof Error && cause.name === 'AbortError'))
26
+ return undefined
27
+ const message = messagesOf(cause)
28
+ const kind = kindOf(message)
29
+ const classification = kind === undefined ? classificationOf(message) : 'terminal'
30
+ const code = message.match(/\b(?:SQLITE_[A-Z_]+|D1_[A-Z_]+)\b/)?.[0]
31
+ if (code === undefined && classification === 'unknown' && !(cause instanceof DrizzleQueryError))
32
+ return undefined
33
+ // A composite unique constraint must not be mistaken for the processed-message primary key.
34
+ const location = message.match(/constraint failed: (\w+)\.(\w+)(?=\s*(?::\s*SQLITE_|$|\n))/)
35
+ const check = kind === 'check' ? message.match(/CHECK constraint failed: ([A-Za-z_]\w*)/) : null
36
+ return new DatabaseError(
37
+ operation,
38
+ classification,
39
+ {
40
+ database: 'd1',
41
+ code,
42
+ kind,
43
+ table: location?.[1],
44
+ column: location?.[2],
45
+ constraintName: check?.[1],
46
+ },
47
+ { cause },
48
+ )
49
+ }
50
+
51
+ function kindOf(message: string): D1ConstraintKind | undefined {
52
+ const code = message.match(/SQLITE_CONSTRAINT_\w+/)?.[0]
53
+ if (code !== undefined) return KIND_BY_CODE.get(code)
54
+ return KIND_BY_PROSE.find(([prose]) => message.includes(prose))?.[1]
55
+ }
56
+
57
+ // Drizzle query messages contain SQL and values, which are not evidence about the database failure.
58
+ function messagesOf(cause: unknown): string {
59
+ const messages: string[] = []
60
+ const seen = new Set<unknown>()
61
+ let current = cause
62
+ while (current instanceof Error && !seen.has(current)) {
63
+ seen.add(current)
64
+ if (!(current instanceof DrizzleQueryError)) messages.push(current.message)
65
+ current = current.cause
66
+ }
67
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof -- D1 can expose a raw string cause; this boundary accepts only that representation.
68
+ if (typeof current === 'string') messages.push(current)
69
+ return messages.join('\n')
70
+ }
71
+
72
+ // Cloudflare documents these conditions separately from transient storage resets.
73
+ const TERMINAL_FAILURE =
74
+ /\bSQLITE_(?:ERROR|MISMATCH|TOOBIG|FULL|AUTH|PERM|READONLY)\b|\bD1_(?:TYPE_ERROR|COLUMN_NOTFOUND)\b|no such (?:table|column|function)|syntax error|datatype mismatch|exceeded (?:timeout|its memory limit|its CPU time limit|maximum DB size)|maximum account storage limit|free tier daily row (?:read|write) limit/i
75
+ const TRANSIENT_FAILURE =
76
+ /Network connection lost\.|Replica disconnected from primary\.|D1 DB reset because its code was updated\.|Internal error (?:while starting up|in) D1 DB storage caused object to be reset\.|Cannot resolve D1 DB due to transient issue on remote node\.|Can't read from request stream because client disconnected\./i
77
+
78
+ function classificationOf(message: string): FailureClassification {
79
+ if (TERMINAL_FAILURE.test(message)) return 'terminal'
80
+ if (TRANSIENT_FAILURE.test(message)) return 'transient'
81
+ return 'unknown'
82
+ }
@@ -0,0 +1,27 @@
1
+ import type { ITransaction } from '@/application'
2
+
3
+ import type { DrizzleSchema } from '../drizzle-schema.ts'
4
+ import { collectD1Statements, runWithUnitOfWorkContext } from '../unit-of-work-context.ts'
5
+ import { executeDatabase } from './d1-database-execution.ts'
6
+ import type { D1Db, D1Tx } from './d1-database-types.ts'
7
+
8
+ /**
9
+ * The D1 transaction boundary: every write is staged during `work` and committed as one
10
+ * `db.batch()`. A throw inside `work` means the batch never runs.
11
+ */
12
+ export class D1BatchTransaction<Schema extends DrizzleSchema> implements ITransaction<
13
+ D1Tx<Schema>
14
+ > {
15
+ constructor(private readonly db: D1Db<Schema>) {}
16
+
17
+ async transaction<T>(work: (tx: D1Tx<Schema>) => Promise<T>): Promise<T> {
18
+ return runWithUnitOfWorkContext('d1', async () => {
19
+ const value = await work({ db: this.db })
20
+ const [first, ...rest] = collectD1Statements()
21
+ if (first !== undefined) {
22
+ await executeDatabase('commit unit of work', () => this.db.batch([first, ...rest]))
23
+ }
24
+ return value
25
+ })
26
+ }
27
+ }
@@ -0,0 +1,13 @@
1
+ import { classifyD1Error } from './classify-d1-error.ts'
2
+
3
+ /** Execute once without retries; convert recognized D1 failures and preserve other exceptions unchanged. */
4
+ export async function executeDatabase<T>(
5
+ operation: string,
6
+ work: () => PromiseLike<T>,
7
+ ): Promise<T> {
8
+ try {
9
+ return await work()
10
+ } catch (cause) {
11
+ throw classifyD1Error(cause, operation) ?? cause
12
+ }
13
+ }
@@ -0,0 +1,20 @@
1
+ import type { AnyD1Database, DrizzleD1Database } from 'drizzle-orm/d1'
2
+
3
+ import type { DrizzleSchema } from '../drizzle-schema.ts'
4
+
5
+ /** A D1 binding or local D1 client accepted by Drizzle. */
6
+ export type D1Connection = AnyD1Database
7
+
8
+ /** The application's Drizzle D1 database, preserving its schema and relational query types. */
9
+ export type D1Db<Schema extends DrizzleSchema> = DrizzleD1Database<Schema>
10
+
11
+ /** Exposes Drizzle query methods without insert, update, delete, batch, or raw execution methods. */
12
+ export type D1ReadDb<Schema extends DrizzleSchema> = Pick<
13
+ D1Db<Schema>,
14
+ 'select' | 'selectDistinct' | 'query'
15
+ >
16
+
17
+ /** D1 reads use this database; repository writes join the active batch through the unit-of-work context. */
18
+ export type D1Tx<Schema extends DrizzleSchema> = {
19
+ readonly db: D1Db<Schema>
20
+ }
@@ -0,0 +1,41 @@
1
+ import { eq } from 'drizzle-orm'
2
+
3
+ import {
4
+ MessageAlreadyProcessedError,
5
+ type Event,
6
+ type IMessageStore,
7
+ type MessageId,
8
+ } from '@/application'
9
+
10
+ import type { DrizzleSchema } from '../drizzle-schema.ts'
11
+ import { BaseD1Repository } from './base-d1-repository.ts'
12
+ import { messageOutbox, processedMessages } from './d1-schema.ts'
13
+
14
+ /** Processed messages and outbox events share one D1 batch; the primary key prevents concurrent duplicate commits. */
15
+ export class D1MessageStore<Schema extends DrizzleSchema>
16
+ extends BaseD1Repository<Schema>
17
+ implements IMessageStore
18
+ {
19
+ async claim(messageId: MessageId): Promise<void> {
20
+ const processed = await this.read('claim message', () =>
21
+ this.db
22
+ .select({ messageId: processedMessages.messageId })
23
+ .from(processedMessages)
24
+ .where(eq(processedMessages.messageId, messageId))
25
+ .get(),
26
+ )
27
+ if (processed !== undefined) throw new MessageAlreadyProcessedError(messageId)
28
+
29
+ // Concurrent deliveries can pass the read; the primary key enforces the claim at commit.
30
+ this.stage(this.db.insert(processedMessages).values({ messageId }))
31
+ }
32
+
33
+ async persistEvents(events: ReadonlyArray<Event>): Promise<void> {
34
+ if (events.length === 0) return
35
+ this.stage(
36
+ this.db
37
+ .insert(messageOutbox)
38
+ .values(events.map((event) => ({ id: event.id, payload: event }))),
39
+ )
40
+ }
41
+ }
@@ -0,0 +1,43 @@
1
+ import { sql } from 'drizzle-orm'
2
+ import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
3
+
4
+ import type { Event } from '@/application'
5
+
6
+ /** One row per committed message. The primary key is the idempotency claim. */
7
+ export const processedMessages = sqliteTable(
8
+ 'processed_messages',
9
+ {
10
+ messageId: text('message_id').primaryKey(),
11
+ processedAt: integer('processed_at', { mode: 'timestamp' })
12
+ .notNull()
13
+ .$defaultFn(() => new Date()),
14
+ },
15
+ (table) => [index('idx_processed_messages_processed_at').on(table.processedAt)],
16
+ )
17
+
18
+ /** Events committed with their state change, waiting for the relay to deliver them. */
19
+ export const messageOutbox = sqliteTable(
20
+ 'message_outbox',
21
+ {
22
+ id: text('id').primaryKey(),
23
+ payload: text('payload', { mode: 'json' }).$type<Event>().notNull(),
24
+ createdAt: integer('created_at', { mode: 'timestamp' })
25
+ .notNull()
26
+ .$defaultFn(() => new Date()),
27
+ availableAt: integer('available_at', { mode: 'timestamp' })
28
+ .notNull()
29
+ .$defaultFn(() => new Date()),
30
+ attempts: integer('attempts').notNull().default(0),
31
+ claimToken: text('claim_token'),
32
+ claimExpiresAt: integer('claim_expires_at', { mode: 'timestamp' }),
33
+ publishedAt: integer('published_at', { mode: 'timestamp' }),
34
+ failedAt: integer('failed_at', { mode: 'timestamp' }),
35
+ lastError: text('last_error'),
36
+ },
37
+ (table) => [
38
+ index('idx_message_outbox_due')
39
+ .on(table.availableAt, table.createdAt)
40
+ .where(sql`${table.publishedAt} IS NULL`),
41
+ index('idx_message_outbox_claim_token').on(table.claimToken),
42
+ ],
43
+ )
@@ -0,0 +1,9 @@
1
+ export { BaseD1Repository, type AggregateEvents } from './base-d1-repository.ts'
2
+ export { classifyD1Error } from './classify-d1-error.ts'
3
+ export { D1MessageStore } from './d1-message-store.ts'
4
+ export { messageOutbox, processedMessages } from './d1-schema.ts'
5
+ export { D1BatchTransaction } from './d1-batch-transaction.ts'
6
+ export type { D1Connection, D1Db, D1ReadDb, D1Tx } from './d1-database-types.ts'
7
+ export { DatabaseError, type DatabaseFailure, type D1ConstraintKind } from '@/core'
8
+ export { executeDatabase } from './d1-database-execution.ts'
9
+ export { eventCollector } from '../unit-of-work-context.ts'
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The app's drizzle schema: the tables it declared, keyed by name. This is drizzle's own
3
+ * constraint on every database and transaction type, restated once so the adapters name it.
4
+ */
5
+ // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- drizzle's constraint; the tables are the contract.
6
+ export type DrizzleSchema = Record<string, unknown>
@@ -0,0 +1,30 @@
1
+ import type { Event } from '@/application'
2
+
3
+ import type { DrizzleSchema } from '../drizzle-schema.ts'
4
+ import { eventCollector } from '../unit-of-work-context.ts'
5
+ import { executeDatabase } from './postgres-database-execution.ts'
6
+ import type { PostgresDbTransaction } from './postgres-database-types.ts'
7
+
8
+ /** A domain aggregate that accumulates events for the unit of work to drain. */
9
+ export interface AggregateEvents {
10
+ collectEvents(): ReadonlyArray<Event>
11
+ clearEvents(): void
12
+ }
13
+
14
+ /** Shared plumbing for a repository that writes inside a Postgres unit of work. */
15
+ export abstract class BasePostgresRepository<Schema extends DrizzleSchema> {
16
+ constructor(protected readonly db: PostgresDbTransaction<Schema>) {}
17
+
18
+ /** Execute one read with database error conversion and no automatic retries. */
19
+ protected read<T>(operation: string, query: () => PromiseLike<T>): Promise<T> {
20
+ return executeDatabase(operation, query)
21
+ }
22
+
23
+ /** Drain an aggregate's pending events into the active boundary. */
24
+ protected dispatchEvents(aggregate: AggregateEvents): void {
25
+ const events = aggregate.collectEvents()
26
+ if (events.length === 0) return
27
+ for (const event of events) eventCollector.emit(event)
28
+ aggregate.clearEvents()
29
+ }
30
+ }