better-ship 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/application-BpSwCKJ5.js +131 -0
- package/dist/application-BpSwCKJ5.js.map +1 -0
- package/dist/application.d.ts +2 -127
- package/dist/application.js +2 -84
- package/dist/cloudflare.d.ts +301 -1
- package/dist/cloudflare.d.ts.map +1 -0
- package/dist/cloudflare.js +169 -0
- package/dist/cloudflare.js.map +1 -0
- package/dist/{core-D0asUCvq.js → core-D3G-2gIx.js} +18 -2
- package/dist/core-D3G-2gIx.js.map +1 -0
- package/dist/core.d.ts +2 -2
- package/dist/core.js +2 -2
- package/dist/{index-eL4vb02m.d.ts → index-CT1yZd4D.d.ts} +31 -2
- package/dist/index-CT1yZd4D.d.ts.map +1 -0
- package/dist/index-CkaFqbbV.d.ts +200 -0
- package/dist/index-CkaFqbbV.d.ts.map +1 -0
- package/dist/postgres.d.ts +289 -1
- package/dist/postgres.d.ts.map +1 -0
- package/dist/postgres.js +169 -0
- package/dist/postgres.js.map +1 -0
- package/dist/unit-of-work-context-CILhzKWb.js +48 -0
- package/dist/unit-of-work-context-CILhzKWb.js.map +1 -0
- package/dist/unit-of-work-context-DOkIB7HK.d.ts +15 -0
- package/dist/unit-of-work-context-DOkIB7HK.d.ts.map +1 -0
- package/package.json +17 -2
- package/src/application/event-collector.ts +7 -0
- package/src/application/index.ts +7 -1
- package/src/application/message-already-processed.error.ts +33 -0
- package/src/application/message-bus.ts +3 -3
- package/src/application/message-store.ts +12 -0
- package/src/application/outbox-relay.ts +8 -0
- package/src/application/transaction.ts +8 -0
- package/src/application/unit-of-work.errors.ts +11 -0
- package/src/application/unit-of-work.ts +54 -0
- package/src/core/database.error.ts +37 -0
- package/src/core/index.ts +1 -0
- package/src/infrastructure/cloudflare/base-d1-repository.ts +40 -0
- package/src/infrastructure/cloudflare/classify-d1-error.ts +82 -0
- package/src/infrastructure/cloudflare/d1-batch-transaction.ts +27 -0
- package/src/infrastructure/cloudflare/d1-database-execution.ts +13 -0
- package/src/infrastructure/cloudflare/d1-database-types.ts +20 -0
- package/src/infrastructure/cloudflare/d1-message-store.ts +41 -0
- package/src/infrastructure/cloudflare/d1-schema.ts +43 -0
- package/src/infrastructure/cloudflare/index.ts +9 -0
- package/src/infrastructure/drizzle-schema.ts +6 -0
- package/src/infrastructure/postgres/base-postgres-repository.ts +30 -0
- package/src/infrastructure/postgres/classify-postgres-error.ts +90 -0
- package/src/infrastructure/postgres/index.ts +15 -0
- package/src/infrastructure/postgres/postgres-database-execution.ts +13 -0
- package/src/infrastructure/postgres/postgres-database-types.ts +27 -0
- package/src/infrastructure/postgres/postgres-message-store.ts +32 -0
- package/src/infrastructure/postgres/postgres-schema.ts +33 -0
- package/src/infrastructure/postgres/postgres-transaction.ts +22 -0
- package/src/infrastructure/unit-of-work-context.ts +55 -0
- package/dist/application.d.ts.map +0 -1
- package/dist/application.js.map +0 -1
- package/dist/core-D0asUCvq.js.map +0 -1
- package/dist/index-eL4vb02m.d.ts.map +0 -1
- 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 {
|
|
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 `
|
|
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
|
|
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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { DrizzleQueryError } from 'drizzle-orm/errors'
|
|
2
|
+
|
|
3
|
+
import { AppError, DatabaseError, type FailureClassification } from '@/core'
|
|
4
|
+
|
|
5
|
+
const TRANSIENT_CODES = new Set([
|
|
6
|
+
'40001',
|
|
7
|
+
'40P01',
|
|
8
|
+
'08000',
|
|
9
|
+
'08001',
|
|
10
|
+
'08003',
|
|
11
|
+
'08006',
|
|
12
|
+
'57P01',
|
|
13
|
+
'57P02',
|
|
14
|
+
'57P03',
|
|
15
|
+
'CONNECT_TIMEOUT',
|
|
16
|
+
'CONNECTION_CLOSED',
|
|
17
|
+
'ECONNRESET',
|
|
18
|
+
'ECONNREFUSED',
|
|
19
|
+
'EPIPE',
|
|
20
|
+
'ETIMEDOUT',
|
|
21
|
+
'EAI_AGAIN',
|
|
22
|
+
])
|
|
23
|
+
const TERMINAL_CODES = new Set(['57014', 'CONNECTION_ENDED', 'CONNECTION_DESTROYED', 'ECANCELED'])
|
|
24
|
+
const DRIVER_CODES = new Set([
|
|
25
|
+
'CONNECT_TIMEOUT',
|
|
26
|
+
'CONNECTION_CLOSED',
|
|
27
|
+
'CONNECTION_ENDED',
|
|
28
|
+
'CONNECTION_DESTROYED',
|
|
29
|
+
])
|
|
30
|
+
const TERMINAL_CLASSES = new Set(['0A', '22', '23', '25', '28', '3D', '3F', '42', '54'])
|
|
31
|
+
|
|
32
|
+
/* oxlint-disable anti-slop/no-runtime-typeof -- Raw driver exceptions have no shared schema; this boundary validates the fields it exposes. */
|
|
33
|
+
/** Read SQLSTATE or driver codes; unknown callback exceptions remain unchanged. */
|
|
34
|
+
export function classifyPostgresError(
|
|
35
|
+
cause: unknown,
|
|
36
|
+
operation: string,
|
|
37
|
+
): DatabaseError | undefined {
|
|
38
|
+
let error = cause
|
|
39
|
+
const seen = new Set<unknown>()
|
|
40
|
+
while (error instanceof DrizzleQueryError && !seen.has(error)) {
|
|
41
|
+
seen.add(error)
|
|
42
|
+
error = error.cause
|
|
43
|
+
}
|
|
44
|
+
if (error instanceof AppError || (error instanceof Error && error.name === 'AbortError'))
|
|
45
|
+
return undefined
|
|
46
|
+
const code =
|
|
47
|
+
error instanceof Error && 'code' in error && typeof error.code === 'string'
|
|
48
|
+
? error.code
|
|
49
|
+
: undefined
|
|
50
|
+
const serverError =
|
|
51
|
+
error instanceof Error &&
|
|
52
|
+
error.name === 'PostgresError' &&
|
|
53
|
+
code !== undefined &&
|
|
54
|
+
/^[0-9A-Z]{5}$/.test(code)
|
|
55
|
+
const driverError = code !== undefined && DRIVER_CODES.has(code)
|
|
56
|
+
if (!(cause instanceof DrizzleQueryError) && !serverError && !driverError) return undefined
|
|
57
|
+
|
|
58
|
+
return new DatabaseError(
|
|
59
|
+
operation,
|
|
60
|
+
classificationOf(code),
|
|
61
|
+
{
|
|
62
|
+
database: 'postgres',
|
|
63
|
+
code,
|
|
64
|
+
table_name:
|
|
65
|
+
error instanceof Error && 'table_name' in error && typeof error.table_name === 'string'
|
|
66
|
+
? error.table_name
|
|
67
|
+
: undefined,
|
|
68
|
+
column_name:
|
|
69
|
+
error instanceof Error && 'column_name' in error && typeof error.column_name === 'string'
|
|
70
|
+
? error.column_name
|
|
71
|
+
: undefined,
|
|
72
|
+
constraint_name:
|
|
73
|
+
error instanceof Error &&
|
|
74
|
+
'constraint_name' in error &&
|
|
75
|
+
typeof error.constraint_name === 'string'
|
|
76
|
+
? error.constraint_name
|
|
77
|
+
: undefined,
|
|
78
|
+
},
|
|
79
|
+
{ cause },
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* oxlint-enable anti-slop/no-runtime-typeof */
|
|
84
|
+
|
|
85
|
+
function classificationOf(code: string | undefined): FailureClassification {
|
|
86
|
+
if (code === undefined) return 'unknown'
|
|
87
|
+
if (TRANSIENT_CODES.has(code)) return 'transient'
|
|
88
|
+
if (TERMINAL_CODES.has(code) || TERMINAL_CLASSES.has(code.slice(0, 2))) return 'terminal'
|
|
89
|
+
return 'unknown'
|
|
90
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { BasePostgresRepository, type AggregateEvents } from './base-postgres-repository.ts'
|
|
2
|
+
export { PostgresMessageStore } from './postgres-message-store.ts'
|
|
3
|
+
export { messageOutbox, processedMessages } from './postgres-schema.ts'
|
|
4
|
+
export { PostgresTransaction } from './postgres-transaction.ts'
|
|
5
|
+
export type {
|
|
6
|
+
PostgresConnection,
|
|
7
|
+
PostgresDb,
|
|
8
|
+
PostgresReadDb,
|
|
9
|
+
PostgresDbTransaction,
|
|
10
|
+
PostgresTx,
|
|
11
|
+
} from './postgres-database-types.ts'
|
|
12
|
+
export { DatabaseError, type DatabaseFailure } from '@/core'
|
|
13
|
+
export { executeDatabase } from './postgres-database-execution.ts'
|
|
14
|
+
export { classifyPostgresError } from './classify-postgres-error.ts'
|
|
15
|
+
export { eventCollector } from '../unit-of-work-context.ts'
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { classifyPostgresError } from './classify-postgres-error.ts'
|
|
2
|
+
|
|
3
|
+
/** Execute once without retries; convert recognized Postgres 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 classifyPostgresError(cause, operation) ?? cause
|
|
12
|
+
}
|
|
13
|
+
}
|