@beignet/provider-db-drizzle 0.0.9
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/CHANGELOG.md +82 -0
- package/README.md +1046 -0
- package/dist/mysql/index.d.ts +254 -0
- package/dist/mysql/index.d.ts.map +1 -0
- package/dist/mysql/index.js +348 -0
- package/dist/mysql/index.js.map +1 -0
- package/dist/postgres/index.d.ts +240 -0
- package/dist/postgres/index.d.ts.map +1 -0
- package/dist/postgres/index.js +296 -0
- package/dist/postgres/index.js.map +1 -0
- package/dist/shared/idempotency-core.d.ts +71 -0
- package/dist/shared/idempotency-core.d.ts.map +1 -0
- package/dist/shared/idempotency-core.js +169 -0
- package/dist/shared/idempotency-core.js.map +1 -0
- package/dist/shared/identifiers.d.ts +20 -0
- package/dist/shared/identifiers.d.ts.map +1 -0
- package/dist/shared/identifiers.js +27 -0
- package/dist/shared/identifiers.js.map +1 -0
- package/dist/shared/instrumentation.d.ts +19 -0
- package/dist/shared/instrumentation.d.ts.map +1 -0
- package/dist/shared/instrumentation.js +41 -0
- package/dist/shared/instrumentation.js.map +1 -0
- package/dist/shared/outbox-core.d.ts +76 -0
- package/dist/shared/outbox-core.d.ts.map +1 -0
- package/dist/shared/outbox-core.js +193 -0
- package/dist/shared/outbox-core.js.map +1 -0
- package/dist/shared/rows.d.ts +84 -0
- package/dist/shared/rows.d.ts.map +1 -0
- package/dist/shared/rows.js +128 -0
- package/dist/shared/rows.js.map +1 -0
- package/dist/sqlite/index.d.ts +235 -0
- package/dist/sqlite/index.d.ts.map +1 -0
- package/dist/sqlite/index.js +293 -0
- package/dist/sqlite/index.js.map +1 -0
- package/package.json +173 -0
- package/src/mysql/index.ts +627 -0
- package/src/postgres/index.ts +572 -0
- package/src/shared/idempotency-core.ts +280 -0
- package/src/shared/identifiers.ts +28 -0
- package/src/shared/instrumentation.ts +49 -0
- package/src/shared/outbox-core.ts +322 -0
- package/src/shared/rows.ts +197 -0
- package/src/sqlite/index.ts +547 -0
package/README.md
ADDED
|
@@ -0,0 +1,1046 @@
|
|
|
1
|
+
# @beignet/provider-db-drizzle
|
|
2
|
+
|
|
3
|
+
> [!CAUTION]
|
|
4
|
+
> Beignet is experimental alpha software. The `0.0.x` package line is for early
|
|
5
|
+
> evaluation, and APIs may change between releases while the framework settles.
|
|
6
|
+
|
|
7
|
+
Drizzle ORM database providers for Beignet applications, one subpath export per
|
|
8
|
+
database backend:
|
|
9
|
+
|
|
10
|
+
| Subpath | Status | Backend |
|
|
11
|
+
|---------|--------|---------|
|
|
12
|
+
| `@beignet/provider-db-drizzle/sqlite` | Available | SQLite via libSQL — local `file:` databases and Turso's hosted libSQL service |
|
|
13
|
+
| `@beignet/provider-db-drizzle/postgres` | Available | Postgres via node-postgres (`pg`) |
|
|
14
|
+
| `@beignet/provider-db-drizzle/mysql` | Available | MySQL 8.0+ via `mysql2` |
|
|
15
|
+
|
|
16
|
+
Each subpath installs a typed database port backed by Drizzle ORM. Your
|
|
17
|
+
application still owns the schema, repository interfaces, and migration
|
|
18
|
+
workflow.
|
|
19
|
+
|
|
20
|
+
## Features
|
|
21
|
+
|
|
22
|
+
- Factory-based provider creation with your schema at the call site.
|
|
23
|
+
- Full TypeScript inference from your Drizzle schema.
|
|
24
|
+
- One subpath per backend — SQLite (libSQL/Turso), Postgres (node-postgres),
|
|
25
|
+
and MySQL (`mysql2`) — with the same provider, Unit of Work, outbox, and
|
|
26
|
+
idempotency surface.
|
|
27
|
+
- Keeps runtime provider wiring separate from Drizzle CLI configuration.
|
|
28
|
+
- Provides a Unit of Work helper that can build transaction-scoped repositories,
|
|
29
|
+
audit logs, outbox recorders, and other app-owned ports from one Drizzle
|
|
30
|
+
transaction client.
|
|
31
|
+
|
|
32
|
+
## Current scope
|
|
33
|
+
|
|
34
|
+
`@beignet/provider-db-drizzle/sqlite` is the reference SQL shape for Beignet
|
|
35
|
+
apps: keep schema and migrations app-owned, accept both a root Drizzle database
|
|
36
|
+
and transaction client in repository factories, and create transaction-scoped
|
|
37
|
+
app ports inside Unit of Work.
|
|
38
|
+
|
|
39
|
+
The `/postgres` and `/mysql` subpaths ship the same surface with
|
|
40
|
+
backend-specific naming, so switching databases means changing the driver
|
|
41
|
+
install, the subpath import, and the connection env var. Contracts, use cases,
|
|
42
|
+
policies, and routes keep depending on ports; only the infra adapter changes.
|
|
43
|
+
|
|
44
|
+
The walkthrough below uses SQLite. The [Postgres](#postgres) and
|
|
45
|
+
[MySQL](#mysql) sections cover what changes per backend. The CLI starter
|
|
46
|
+
scaffolds SQLite; switching to Postgres or MySQL is manual wiring this
|
|
47
|
+
release.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
Database drivers are optional peer dependencies, so you install only the
|
|
52
|
+
driver for the backend you use:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# SQLite (libSQL)
|
|
56
|
+
bun add @beignet/provider-db-drizzle @beignet/core drizzle-orm @libsql/client
|
|
57
|
+
|
|
58
|
+
# Postgres (node-postgres)
|
|
59
|
+
bun add @beignet/provider-db-drizzle @beignet/core drizzle-orm pg
|
|
60
|
+
|
|
61
|
+
# MySQL (mysql2)
|
|
62
|
+
bun add @beignet/provider-db-drizzle @beignet/core drizzle-orm mysql2
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Setup
|
|
66
|
+
|
|
67
|
+
### 1. Define your schema
|
|
68
|
+
|
|
69
|
+
Create your Drizzle schema files wherever makes sense for your app. Framework
|
|
70
|
+
usage keeps them under `infra/db/schema/` so larger apps can split schema by
|
|
71
|
+
feature:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
// infra/db/schema/todos.ts
|
|
75
|
+
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
76
|
+
|
|
77
|
+
export const todos = sqliteTable("todos", {
|
|
78
|
+
id: text("id").primaryKey(),
|
|
79
|
+
title: text("title").notNull(),
|
|
80
|
+
completed: integer("completed", { mode: "boolean" }).notNull().default(false),
|
|
81
|
+
createdAt: text("created_at").notNull(),
|
|
82
|
+
});
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
// infra/db/schema/index.ts
|
|
87
|
+
export { todos } from "./todos";
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### 2. Configure Drizzle CLI (build-time)
|
|
91
|
+
|
|
92
|
+
Create `drizzle.config.ts` in your app root for the Drizzle CLI:
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
// drizzle.config.ts
|
|
96
|
+
export default {
|
|
97
|
+
schema: "./infra/db/schema/index.ts",
|
|
98
|
+
out: "./drizzle",
|
|
99
|
+
dialect: "sqlite",
|
|
100
|
+
dbCredentials: {
|
|
101
|
+
url: process.env.SQLITE_DB_URL!,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### 3. Create the provider (runtime)
|
|
107
|
+
|
|
108
|
+
Import your schema and create the provider:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
// server/providers.ts
|
|
112
|
+
import { createDrizzleSqliteProvider } from "@beignet/provider-db-drizzle/sqlite";
|
|
113
|
+
import * as schema from "@/infra/db/schema";
|
|
114
|
+
|
|
115
|
+
const drizzleSqliteProvider = createDrizzleSqliteProvider({ schema });
|
|
116
|
+
|
|
117
|
+
export const providers = [drizzleSqliteProvider];
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### 4. Type your ports
|
|
121
|
+
|
|
122
|
+
Define your app's ports type:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
// ports/index.ts
|
|
126
|
+
import type { DbPort } from "@beignet/provider-db-drizzle/sqlite";
|
|
127
|
+
import * as schema from "@/infra/db/schema";
|
|
128
|
+
|
|
129
|
+
export type AppPorts = {
|
|
130
|
+
db: DbPort<typeof schema>;
|
|
131
|
+
// other ports...
|
|
132
|
+
};
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### 5. Wire it up in your server
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
// server/index.ts
|
|
139
|
+
import { createNextServer } from "@beignet/next";
|
|
140
|
+
import { appPorts } from "@/infra/app-ports";
|
|
141
|
+
import { routes } from "@/server/routes";
|
|
142
|
+
import { providers } from "./providers";
|
|
143
|
+
|
|
144
|
+
export const server = await createNextServer({
|
|
145
|
+
ports: appPorts,
|
|
146
|
+
providers,
|
|
147
|
+
context: ({ ports }) => ({ ports }),
|
|
148
|
+
routes,
|
|
149
|
+
});
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### 6. Expose repository ports to use cases
|
|
153
|
+
|
|
154
|
+
Use cases should depend on app-owned repository ports. Keep raw Drizzle access in
|
|
155
|
+
infrastructure, then wire the repository into `ctx.ports`.
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
// infra/todos/drizzle-todo-repository.ts
|
|
159
|
+
import { Buffer } from "node:buffer";
|
|
160
|
+
import { and, desc, eq, lt, or } from "drizzle-orm";
|
|
161
|
+
import { cursorPageResult } from "@beignet/core/pagination";
|
|
162
|
+
import type { DrizzleSqliteDatabase } from "@beignet/provider-db-drizzle/sqlite";
|
|
163
|
+
import type { TodoRepository } from "@/features/todos/ports";
|
|
164
|
+
import * as schema from "@/infra/db/schema";
|
|
165
|
+
|
|
166
|
+
type TodoCursor = {
|
|
167
|
+
sortBy: "createdAt";
|
|
168
|
+
sortDirection: "desc";
|
|
169
|
+
sortValue: string;
|
|
170
|
+
id: string;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
function encodeTodoCursor(row: typeof schema.todos.$inferSelect): string {
|
|
174
|
+
return Buffer.from(
|
|
175
|
+
JSON.stringify({
|
|
176
|
+
sortBy: "createdAt",
|
|
177
|
+
sortDirection: "desc",
|
|
178
|
+
sortValue: row.createdAt,
|
|
179
|
+
id: row.id,
|
|
180
|
+
}),
|
|
181
|
+
).toString("base64url");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function decodeTodoCursor(cursor: string): TodoCursor {
|
|
185
|
+
return JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function createTodoRepository(
|
|
189
|
+
db: DrizzleSqliteDatabase<typeof schema>,
|
|
190
|
+
): TodoRepository {
|
|
191
|
+
return {
|
|
192
|
+
async list(input) {
|
|
193
|
+
const cursor = input.page.cursor
|
|
194
|
+
? decodeTodoCursor(input.page.cursor)
|
|
195
|
+
: null;
|
|
196
|
+
const rows = await db
|
|
197
|
+
.select()
|
|
198
|
+
.from(schema.todos)
|
|
199
|
+
.where(
|
|
200
|
+
cursor
|
|
201
|
+
? or(
|
|
202
|
+
lt(schema.todos.createdAt, cursor.sortValue),
|
|
203
|
+
and(
|
|
204
|
+
eq(schema.todos.createdAt, cursor.sortValue),
|
|
205
|
+
lt(schema.todos.id, cursor.id),
|
|
206
|
+
),
|
|
207
|
+
)
|
|
208
|
+
: undefined,
|
|
209
|
+
)
|
|
210
|
+
.orderBy(desc(schema.todos.createdAt), desc(schema.todos.id))
|
|
211
|
+
.limit(input.page.limit + 1);
|
|
212
|
+
const pageRows = rows.slice(0, input.page.limit);
|
|
213
|
+
const nextCursor =
|
|
214
|
+
rows.length > input.page.limit && pageRows.length > 0
|
|
215
|
+
? encodeTodoCursor(pageRows[pageRows.length - 1])
|
|
216
|
+
: null;
|
|
217
|
+
|
|
218
|
+
return cursorPageResult(pageRows, input.page, nextCursor);
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Collect repositories in one infra factory:
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
// infra/db/repositories.ts
|
|
228
|
+
import type { DrizzleSqliteDatabase } from "@beignet/provider-db-drizzle/sqlite";
|
|
229
|
+
import { createTodoRepository } from "@/infra/todos/drizzle-todo-repository";
|
|
230
|
+
import * as schema from "./schema";
|
|
231
|
+
|
|
232
|
+
export function createRepositories(db: DrizzleSqliteDatabase<typeof schema>) {
|
|
233
|
+
return {
|
|
234
|
+
todos: createTodoRepository(db),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Wire repositories into app ports with a typed app-owned database provider.
|
|
240
|
+
The curried `createProvider<Requires, Context, ServiceInput>()` form types the
|
|
241
|
+
required `db` port, the app context, and `createServiceContext` without casts.
|
|
242
|
+
Register it after `createDrizzleSqliteProvider`, which installs the `db` port it
|
|
243
|
+
requires:
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
// infra/db/provider.ts
|
|
247
|
+
import { createProvider } from "@beignet/core/providers";
|
|
248
|
+
import type { DbPort } from "@beignet/provider-db-drizzle/sqlite";
|
|
249
|
+
import type { AppContext } from "@/app-context";
|
|
250
|
+
import type { AppPorts } from "@/ports";
|
|
251
|
+
import type { AppServiceContextInput } from "@/server";
|
|
252
|
+
import { createRepositories } from "./repositories";
|
|
253
|
+
import type * as schema from "./schema";
|
|
254
|
+
|
|
255
|
+
export const appDatabaseProvider = createProvider<
|
|
256
|
+
{ db: DbPort<typeof schema> },
|
|
257
|
+
AppContext,
|
|
258
|
+
AppServiceContextInput
|
|
259
|
+
>()({
|
|
260
|
+
name: "app-database",
|
|
261
|
+
async setup({ ports }) {
|
|
262
|
+
const providedPorts: Pick<AppPorts, "todos"> = createRepositories(
|
|
263
|
+
ports.db.db,
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
return { ports: providedPorts };
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Type `ctx.ports` with `InferProviderPorts` so provider-contributed ports,
|
|
272
|
+
including the provider-owned `db` port, stay typed in app code:
|
|
273
|
+
|
|
274
|
+
```ts
|
|
275
|
+
// app-context.ts
|
|
276
|
+
import type { InferProviderPorts } from "@beignet/core/providers";
|
|
277
|
+
import type { AppPorts } from "@/ports";
|
|
278
|
+
import type { providers } from "@/server/providers";
|
|
279
|
+
|
|
280
|
+
export type AppRuntimePorts = AppPorts & InferProviderPorts<typeof providers>;
|
|
281
|
+
|
|
282
|
+
export type AppContext = {
|
|
283
|
+
requestId: string;
|
|
284
|
+
ports: AppRuntimePorts;
|
|
285
|
+
};
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
```ts
|
|
289
|
+
// features/todos/use-cases/list-todos.ts
|
|
290
|
+
import { normalizeCursorPage } from "@beignet/core/pagination";
|
|
291
|
+
|
|
292
|
+
export const listTodos = useCase.query("todos.list").run(async ({ ctx }) => {
|
|
293
|
+
const page = normalizeCursorPage({}, { defaultLimit: 20, maxLimit: 100 });
|
|
294
|
+
|
|
295
|
+
return ctx.ports.todos.list({ page });
|
|
296
|
+
});
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
`ctx.ports.db.db` remains available as a provider-specific escape hatch for
|
|
300
|
+
infrastructure code and one-off advanced Drizzle features. Do not make it the
|
|
301
|
+
normal dependency for application use cases.
|
|
302
|
+
|
|
303
|
+
## Devtools
|
|
304
|
+
|
|
305
|
+
When `@beignet/devtools` is registered before this provider, Drizzle query
|
|
306
|
+
logging is recorded automatically under the `db` watcher. Events include the SQL
|
|
307
|
+
query text, parameter count, port name, and provider name. Parameter values are
|
|
308
|
+
redacted by default.
|
|
309
|
+
|
|
310
|
+
```ts
|
|
311
|
+
import { createDevtoolsProvider } from "@beignet/devtools";
|
|
312
|
+
import { createDrizzleSqliteProvider } from "@beignet/provider-db-drizzle/sqlite";
|
|
313
|
+
|
|
314
|
+
export const providers = [
|
|
315
|
+
createDevtoolsProvider(),
|
|
316
|
+
createDrizzleSqliteProvider({ schema }),
|
|
317
|
+
];
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
## Unit of work
|
|
321
|
+
|
|
322
|
+
Use `createDrizzleSqliteUnitOfWork(...)` when a use case needs a real database
|
|
323
|
+
transaction. Your app still owns the repository interfaces; the helper only
|
|
324
|
+
starts a Drizzle transaction, gives you the transaction client, and optionally
|
|
325
|
+
flushes recorded domain events after commit.
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
// ports/index.ts
|
|
329
|
+
import type {
|
|
330
|
+
DomainEventRecorderPort,
|
|
331
|
+
UnitOfWorkPort,
|
|
332
|
+
} from "@beignet/core/ports";
|
|
333
|
+
import type { DbPort } from "@beignet/provider-db-drizzle/sqlite";
|
|
334
|
+
import * as schema from "@/infra/db/schema";
|
|
335
|
+
import type { TodoRepository } from "./todo-repository";
|
|
336
|
+
|
|
337
|
+
export type AppTransactionPorts = {
|
|
338
|
+
todos: TodoRepository;
|
|
339
|
+
events: DomainEventRecorderPort;
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
export type AppPorts = {
|
|
343
|
+
db: DbPort<typeof schema>;
|
|
344
|
+
todos: TodoRepository;
|
|
345
|
+
uow: UnitOfWorkPort<AppTransactionPorts>;
|
|
346
|
+
};
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
```ts
|
|
350
|
+
// infra/todos/drizzle-todo-repository.ts
|
|
351
|
+
import type { DrizzleSqliteDatabase } from "@beignet/provider-db-drizzle/sqlite";
|
|
352
|
+
import type { TodoRepository } from "@/features/todos/ports";
|
|
353
|
+
import * as schema from "@/infra/db/schema";
|
|
354
|
+
|
|
355
|
+
export function createDrizzleTodoRepository(
|
|
356
|
+
db: DrizzleSqliteDatabase<typeof schema>,
|
|
357
|
+
): TodoRepository {
|
|
358
|
+
return {
|
|
359
|
+
async create(input) {
|
|
360
|
+
const [row] = await db.insert(schema.todos).values(input).returning();
|
|
361
|
+
return row;
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
```ts
|
|
368
|
+
// infra/db/provider.ts
|
|
369
|
+
import { createDrizzleSqliteUnitOfWork } from "@beignet/provider-db-drizzle/sqlite";
|
|
370
|
+
import { createRepositories } from "./repositories";
|
|
371
|
+
|
|
372
|
+
// Inside the app database provider's setup({ ports }):
|
|
373
|
+
const providedPorts: Pick<AppPorts, "todos" | "uow"> = {
|
|
374
|
+
...createRepositories(ports.db.db),
|
|
375
|
+
uow: createDrizzleSqliteUnitOfWork({
|
|
376
|
+
db: ports.db.db,
|
|
377
|
+
eventBus: ports.eventBus,
|
|
378
|
+
createTransactionPorts: (tx, events) => ({
|
|
379
|
+
...createRepositories(tx),
|
|
380
|
+
events,
|
|
381
|
+
}),
|
|
382
|
+
}),
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
return { ports: providedPorts };
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
Inside a use case, call transaction-scoped repositories through `tx` and record
|
|
389
|
+
declared events through the use-case `events` helper:
|
|
390
|
+
|
|
391
|
+
```ts
|
|
392
|
+
const todo = await ctx.ports.uow.transaction(async (tx) => {
|
|
393
|
+
const created = await tx.todos.create(input);
|
|
394
|
+
await events.record(tx.events, todoCreated, { todoId: created.id });
|
|
395
|
+
return created;
|
|
396
|
+
});
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
Recorded events are published only after Drizzle commits. If the transaction
|
|
400
|
+
throws, events are not flushed. If event publishing fails after commit,
|
|
401
|
+
`transaction(...)` rejects but the database transaction is already committed;
|
|
402
|
+
use an outbox when events or jobs need durable delivery guarantees.
|
|
403
|
+
|
|
404
|
+
## Durable outbox
|
|
405
|
+
|
|
406
|
+
Use `createDrizzleSqliteOutboxPort(...)` when events or jobs must be recorded in
|
|
407
|
+
the same database transaction as the business write, then drained after commit:
|
|
408
|
+
|
|
409
|
+
```ts
|
|
410
|
+
import {
|
|
411
|
+
createOutboxEventRecorder,
|
|
412
|
+
drainOutbox,
|
|
413
|
+
} from "@beignet/core/outbox";
|
|
414
|
+
import {
|
|
415
|
+
createDrizzleSqliteIdempotencyPort,
|
|
416
|
+
createDrizzleSqliteIdempotencySetupStatements,
|
|
417
|
+
createDrizzleSqliteOutboxPort,
|
|
418
|
+
createDrizzleSqliteOutboxSetupStatements,
|
|
419
|
+
createDrizzleSqliteUnitOfWork,
|
|
420
|
+
} from "@beignet/provider-db-drizzle/sqlite";
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
Add Beignet's outbox and idempotency tables to your app-owned migration or
|
|
424
|
+
bootstrap flow:
|
|
425
|
+
|
|
426
|
+
```ts
|
|
427
|
+
for (const statement of createDrizzleSqliteIdempotencySetupStatements()) {
|
|
428
|
+
await client.execute(statement);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
for (const statement of createDrizzleSqliteOutboxSetupStatements()) {
|
|
432
|
+
await client.execute(statement);
|
|
433
|
+
}
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
Then expose transaction-scoped outbox-backed event recording:
|
|
437
|
+
|
|
438
|
+
```ts
|
|
439
|
+
uow: createDrizzleSqliteUnitOfWork({
|
|
440
|
+
db: ports.db.db,
|
|
441
|
+
createTransactionPorts: (tx) => {
|
|
442
|
+
const idempotency = createDrizzleSqliteIdempotencyPort(tx);
|
|
443
|
+
const outbox = createDrizzleSqliteOutboxPort(tx);
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
...createRepositories(tx),
|
|
447
|
+
events: createOutboxEventRecorder(outbox),
|
|
448
|
+
idempotency,
|
|
449
|
+
outbox,
|
|
450
|
+
};
|
|
451
|
+
},
|
|
452
|
+
});
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
Any port that must commit with the business write belongs in
|
|
456
|
+
`createTransactionPorts`: repositories, audit logs, outbox-backed events/jobs,
|
|
457
|
+
feature history repositories, and durable idempotency records. Root ports are
|
|
458
|
+
still useful for reads and background work, but they do not participate in the
|
|
459
|
+
current transaction unless you rebuild them from `tx`.
|
|
460
|
+
|
|
461
|
+
Use `createDrizzleSqliteIdempotencyPort(...)` at the root app port for ordinary
|
|
462
|
+
retry-safe commands, and rebuild it from `tx` when the idempotency reservation,
|
|
463
|
+
business write, audit row, outbox records, and replay result must commit
|
|
464
|
+
together.
|
|
465
|
+
|
|
466
|
+
## Durable idempotency
|
|
467
|
+
|
|
468
|
+
Use `createDrizzleSqliteIdempotencyPort(...)` when `runIdempotently(...)` needs
|
|
469
|
+
durable storage instead of the in-memory test adapter:
|
|
470
|
+
|
|
471
|
+
```ts
|
|
472
|
+
import { runIdempotently } from "@beignet/core/idempotency";
|
|
473
|
+
import {
|
|
474
|
+
createDrizzleSqliteIdempotencyPort,
|
|
475
|
+
createDrizzleSqliteIdempotencySetupStatements,
|
|
476
|
+
} from "@beignet/provider-db-drizzle/sqlite";
|
|
477
|
+
|
|
478
|
+
for (const statement of createDrizzleSqliteIdempotencySetupStatements()) {
|
|
479
|
+
await client.execute(statement);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const idempotency = createDrizzleSqliteIdempotencyPort(db);
|
|
483
|
+
|
|
484
|
+
await runIdempotently(idempotency, {
|
|
485
|
+
namespace: "posts.create",
|
|
486
|
+
key: input.idempotencyKey,
|
|
487
|
+
scope: { actorId: ctx.actor.id, tenantId: ctx.tenant.id },
|
|
488
|
+
fingerprint,
|
|
489
|
+
run: () => ctx.ports.posts.create(input),
|
|
490
|
+
});
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
The default table is `idempotency_records`; pass `tableName` to both setup and
|
|
494
|
+
port creation if your app uses a different table name. Expired records are
|
|
495
|
+
removed during reservation, failed in-progress reservations are released, and
|
|
496
|
+
completed matching records replay their stored result.
|
|
497
|
+
|
|
498
|
+
`complete(...)` and `fail(...)` must match an in-progress reservation with the
|
|
499
|
+
provided fingerprint. When the database reports that no row matched, the port
|
|
500
|
+
throws `DrizzleSqliteIdempotencyMutationError` instead of silently doing
|
|
501
|
+
nothing, so workflow bugs such as double completion, a missing reservation, or
|
|
502
|
+
a mismatched fingerprint surface immediately. The error exposes `action`,
|
|
503
|
+
`namespace`, `key`, and `scopeKey` for logging and debugging.
|
|
504
|
+
|
|
505
|
+
Drain from a worker, cron route, or schedule:
|
|
506
|
+
|
|
507
|
+
```ts
|
|
508
|
+
await drainOutbox({
|
|
509
|
+
outbox: ports.outbox,
|
|
510
|
+
registry: outboxRegistry,
|
|
511
|
+
eventBus: ports.eventBus,
|
|
512
|
+
jobs: ports.jobs,
|
|
513
|
+
});
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
The default table is `outbox_messages`; pass `tableName` to both setup and port
|
|
517
|
+
creation if your app uses a different table name.
|
|
518
|
+
|
|
519
|
+
The adapter preserves Beignet's durable workflow semantics in SQL:
|
|
520
|
+
|
|
521
|
+
- `attempts` increments when a worker claims a message.
|
|
522
|
+
- `max_attempts` stores the maximum total attempts for the message.
|
|
523
|
+
- `available_at` stores retry timing and backoff decisions.
|
|
524
|
+
- `locked_until` and `claim_token` provide a lease so workers can compete.
|
|
525
|
+
- `deadLettered` is the terminal state for messages that should no longer be
|
|
526
|
+
retried automatically.
|
|
527
|
+
|
|
528
|
+
`drainOutbox(...)` owns retry classification and backoff computation. The
|
|
529
|
+
Drizzle SQLite adapter stores the resulting state and enforces claim ownership.
|
|
530
|
+
|
|
531
|
+
## Configuration
|
|
532
|
+
|
|
533
|
+
The provider reads configuration from environment variables with the `SQLITE_` prefix:
|
|
534
|
+
|
|
535
|
+
| Variable | Required | Description |
|
|
536
|
+
|----------|----------|-------------|
|
|
537
|
+
| `SQLITE_DB_URL` | Yes | libSQL database URL (e.g., `file:local.db`, `libsql://your-db.turso.io`, or `http://127.0.0.1:8080` for local sqld) |
|
|
538
|
+
| `SQLITE_DB_AUTH_TOKEN` | No | Auth token for remote connections (required for Turso's hosted libSQL service, optional for local) |
|
|
539
|
+
|
|
540
|
+
`SQLITE_DB_URL` accepts every URL scheme `@libsql/client` accepts: `libsql://`
|
|
541
|
+
and `file:` plus `http://`, `https://`, `ws://`, and `wss://` for local or
|
|
542
|
+
self-hosted sqld instances.
|
|
543
|
+
|
|
544
|
+
### Example `.env`
|
|
545
|
+
|
|
546
|
+
```env
|
|
547
|
+
# For local development
|
|
548
|
+
SQLITE_DB_URL=file:local.db
|
|
549
|
+
|
|
550
|
+
# For Turso's hosted libSQL service
|
|
551
|
+
SQLITE_DB_URL=libsql://my-app-db.turso.io
|
|
552
|
+
SQLITE_DB_AUTH_TOKEN=eyJhbGciOiJFZERTQSIsInR5cCI...
|
|
553
|
+
|
|
554
|
+
# For a local sqld instance
|
|
555
|
+
SQLITE_DB_URL=http://127.0.0.1:8080
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
## Postgres
|
|
559
|
+
|
|
560
|
+
`@beignet/provider-db-drizzle/postgres` mirrors the SQLite surface on top of
|
|
561
|
+
Drizzle's node-postgres driver. Every `DrizzleSqliteX` export has a
|
|
562
|
+
`DrizzlePostgresX` counterpart with the same behavior, so the walkthrough
|
|
563
|
+
above applies; this section covers what changes per backend.
|
|
564
|
+
|
|
565
|
+
### Install
|
|
566
|
+
|
|
567
|
+
```bash
|
|
568
|
+
bun add @beignet/provider-db-drizzle @beignet/core drizzle-orm pg
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
`pg` is an optional peer dependency (`>=8.11.0`). The other backends' drivers
|
|
572
|
+
are not required.
|
|
573
|
+
|
|
574
|
+
### Configuration
|
|
575
|
+
|
|
576
|
+
The provider reads configuration from environment variables with the
|
|
577
|
+
`POSTGRES_` prefix:
|
|
578
|
+
|
|
579
|
+
| Variable | Required | Description |
|
|
580
|
+
|----------|----------|-------------|
|
|
581
|
+
| `POSTGRES_DB_URL` | Yes | Postgres connection string (`postgres://` or `postgresql://`) |
|
|
582
|
+
|
|
583
|
+
```env
|
|
584
|
+
POSTGRES_DB_URL=postgres://postgres:postgres@localhost:5432/my_app
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
### Drizzle CLI
|
|
588
|
+
|
|
589
|
+
Define schema with `drizzle-orm/pg-core` table builders such as `pgTable`,
|
|
590
|
+
and point `drizzle.config.ts` at the `postgresql` dialect:
|
|
591
|
+
|
|
592
|
+
```ts
|
|
593
|
+
// drizzle.config.ts
|
|
594
|
+
export default {
|
|
595
|
+
schema: "./infra/db/schema/index.ts",
|
|
596
|
+
out: "./drizzle",
|
|
597
|
+
dialect: "postgresql",
|
|
598
|
+
dbCredentials: {
|
|
599
|
+
url: process.env.POSTGRES_DB_URL!,
|
|
600
|
+
},
|
|
601
|
+
};
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
### Provider
|
|
605
|
+
|
|
606
|
+
```ts
|
|
607
|
+
// server/providers.ts
|
|
608
|
+
import { createDrizzlePostgresProvider } from "@beignet/provider-db-drizzle/postgres";
|
|
609
|
+
import * as schema from "@/infra/db/schema";
|
|
610
|
+
|
|
611
|
+
const drizzlePostgresProvider = createDrizzlePostgresProvider({ schema });
|
|
612
|
+
|
|
613
|
+
export const providers = [drizzlePostgresProvider];
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
**Parameters:**
|
|
617
|
+
|
|
618
|
+
- `options.schema` (required): Your Drizzle schema object
|
|
619
|
+
- `options.portName` (optional): Port name, defaults to `"db"`
|
|
620
|
+
- `options.pool` (optional): node-postgres pool options
|
|
621
|
+
(`Omit<PoolConfig, "connectionString">`) such as `max` or `ssl`
|
|
622
|
+
|
|
623
|
+
The provider is named `"drizzle-postgres"` and installs a `DbPort` with the
|
|
624
|
+
typed Drizzle database under `db` (a `NodePgDatabase<TSchema>`) and the
|
|
625
|
+
node-postgres `Pool` under `pool` as the escape hatch.
|
|
626
|
+
|
|
627
|
+
Type repository factories with `DrizzlePostgresDatabase<TSchema>` so they
|
|
628
|
+
accept both the root database and a transaction client
|
|
629
|
+
(`DrizzlePostgresTransaction`). Both types accept any Drizzle Postgres
|
|
630
|
+
driver, so the same repository code runs against node-postgres at runtime and
|
|
631
|
+
PGlite in tests:
|
|
632
|
+
|
|
633
|
+
```ts
|
|
634
|
+
import type { DrizzlePostgresDatabase } from "@beignet/provider-db-drizzle/postgres";
|
|
635
|
+
import type { TodoRepository } from "@/features/todos/ports";
|
|
636
|
+
import * as schema from "@/infra/db/schema";
|
|
637
|
+
|
|
638
|
+
export function createTodoRepository(
|
|
639
|
+
db: DrizzlePostgresDatabase<typeof schema>,
|
|
640
|
+
): TodoRepository {
|
|
641
|
+
// same repository shape as the SQLite walkthrough
|
|
642
|
+
}
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
### Unit of work
|
|
646
|
+
|
|
647
|
+
`createDrizzlePostgresUnitOfWork(...)` takes the same options as the SQLite
|
|
648
|
+
helper: `db`, `createTransactionPorts`, optional `eventBus`, and optional
|
|
649
|
+
`transactionConfig` for Drizzle's Postgres transaction configuration.
|
|
650
|
+
|
|
651
|
+
```ts
|
|
652
|
+
import { createDrizzlePostgresUnitOfWork } from "@beignet/provider-db-drizzle/postgres";
|
|
653
|
+
|
|
654
|
+
// Inside the app database provider's setup({ ports }):
|
|
655
|
+
uow: createDrizzlePostgresUnitOfWork({
|
|
656
|
+
db: ports.db.db,
|
|
657
|
+
eventBus: ports.eventBus,
|
|
658
|
+
createTransactionPorts: (tx, events) => ({
|
|
659
|
+
...createRepositories(tx),
|
|
660
|
+
events,
|
|
661
|
+
}),
|
|
662
|
+
}),
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
### Outbox and idempotency
|
|
666
|
+
|
|
667
|
+
```ts
|
|
668
|
+
import {
|
|
669
|
+
createDrizzlePostgresIdempotencyPort,
|
|
670
|
+
createDrizzlePostgresIdempotencySetupStatements,
|
|
671
|
+
createDrizzlePostgresOutboxPort,
|
|
672
|
+
createDrizzlePostgresOutboxSetupStatements,
|
|
673
|
+
} from "@beignet/provider-db-drizzle/postgres";
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
Run the setup statements through your app-owned migration or bootstrap flow:
|
|
677
|
+
|
|
678
|
+
```ts
|
|
679
|
+
for (const statement of createDrizzlePostgresIdempotencySetupStatements()) {
|
|
680
|
+
await pool.query(statement);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
for (const statement of createDrizzlePostgresOutboxSetupStatements()) {
|
|
684
|
+
await pool.query(statement);
|
|
685
|
+
}
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
Then build transaction-scoped ports exactly as in the SQLite walkthrough:
|
|
689
|
+
|
|
690
|
+
```ts
|
|
691
|
+
createTransactionPorts: (tx) => {
|
|
692
|
+
const idempotency = createDrizzlePostgresIdempotencyPort(tx);
|
|
693
|
+
const outbox = createDrizzlePostgresOutboxPort(tx);
|
|
694
|
+
|
|
695
|
+
return {
|
|
696
|
+
...createRepositories(tx),
|
|
697
|
+
events: createOutboxEventRecorder(outbox),
|
|
698
|
+
idempotency,
|
|
699
|
+
outbox,
|
|
700
|
+
};
|
|
701
|
+
},
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
Both factories accept a root database or transaction client plus the same
|
|
705
|
+
`tableName` and `now` options as the SQLite ports. The idempotency port throws
|
|
706
|
+
`DrizzlePostgresIdempotencyMutationError` when `complete(...)` or `fail(...)`
|
|
707
|
+
does not match an in-progress reservation.
|
|
708
|
+
|
|
709
|
+
## MySQL
|
|
710
|
+
|
|
711
|
+
`@beignet/provider-db-drizzle/mysql` mirrors the same surface on top of
|
|
712
|
+
Drizzle's `mysql2` driver, with `DrizzleMysqlX` naming.
|
|
713
|
+
|
|
714
|
+
MySQL 8.0 or newer is required: the outbox uses `FOR UPDATE SKIP LOCKED` for
|
|
715
|
+
claim leases and the idempotency table uses the `utf8mb4_0900_bin` collation
|
|
716
|
+
for binary key comparison. MariaDB is not supported.
|
|
717
|
+
|
|
718
|
+
### Install
|
|
719
|
+
|
|
720
|
+
```bash
|
|
721
|
+
bun add @beignet/provider-db-drizzle @beignet/core drizzle-orm mysql2
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
`mysql2` is an optional peer dependency (`>=3.6.0`).
|
|
725
|
+
|
|
726
|
+
### Configuration
|
|
727
|
+
|
|
728
|
+
The provider reads configuration from environment variables with the `MYSQL_`
|
|
729
|
+
prefix:
|
|
730
|
+
|
|
731
|
+
| Variable | Required | Description |
|
|
732
|
+
|----------|----------|-------------|
|
|
733
|
+
| `MYSQL_DB_URL` | Yes | MySQL connection string (`mysql://`) |
|
|
734
|
+
|
|
735
|
+
```env
|
|
736
|
+
MYSQL_DB_URL=mysql://root:root@localhost:3306/my_app
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
### Drizzle CLI
|
|
740
|
+
|
|
741
|
+
Define schema with `drizzle-orm/mysql-core` table builders such as
|
|
742
|
+
`mysqlTable`, and point `drizzle.config.ts` at the `mysql` dialect:
|
|
743
|
+
|
|
744
|
+
```ts
|
|
745
|
+
// drizzle.config.ts
|
|
746
|
+
export default {
|
|
747
|
+
schema: "./infra/db/schema/index.ts",
|
|
748
|
+
out: "./drizzle",
|
|
749
|
+
dialect: "mysql",
|
|
750
|
+
dbCredentials: {
|
|
751
|
+
url: process.env.MYSQL_DB_URL!,
|
|
752
|
+
},
|
|
753
|
+
};
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
### Provider
|
|
757
|
+
|
|
758
|
+
```ts
|
|
759
|
+
// server/providers.ts
|
|
760
|
+
import { createDrizzleMysqlProvider } from "@beignet/provider-db-drizzle/mysql";
|
|
761
|
+
import * as schema from "@/infra/db/schema";
|
|
762
|
+
|
|
763
|
+
const drizzleMysqlProvider = createDrizzleMysqlProvider({ schema });
|
|
764
|
+
|
|
765
|
+
export const providers = [drizzleMysqlProvider];
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
**Parameters:**
|
|
769
|
+
|
|
770
|
+
- `options.schema` (required): Your Drizzle schema object
|
|
771
|
+
- `options.portName` (optional): Port name, defaults to `"db"`
|
|
772
|
+
- `options.pool` (optional): `mysql2` pool options (`Omit<PoolOptions, "uri">`)
|
|
773
|
+
such as `connectionLimit` or TLS settings
|
|
774
|
+
- `options.mode` (optional): Drizzle relational-query mode, `"default"` or
|
|
775
|
+
`"planetscale"`; use `"planetscale"` when connecting to PlanetScale
|
|
776
|
+
|
|
777
|
+
The provider is named `"drizzle-mysql"` and installs a `DbPort` with the typed
|
|
778
|
+
Drizzle database under `db` and the `mysql2` pool under `pool` as the escape
|
|
779
|
+
hatch.
|
|
780
|
+
|
|
781
|
+
Type repository factories with `DrizzleMysqlDatabase<TSchema>`;
|
|
782
|
+
`DrizzleMysqlTransaction` is the transaction client type.
|
|
783
|
+
|
|
784
|
+
### Unit of work, outbox, and idempotency
|
|
785
|
+
|
|
786
|
+
The factories mirror the Postgres section with `Mysql` naming:
|
|
787
|
+
|
|
788
|
+
```ts
|
|
789
|
+
import {
|
|
790
|
+
createDrizzleMysqlIdempotencyPort,
|
|
791
|
+
createDrizzleMysqlIdempotencySetupStatements,
|
|
792
|
+
createDrizzleMysqlOutboxPort,
|
|
793
|
+
createDrizzleMysqlOutboxSetupStatements,
|
|
794
|
+
createDrizzleMysqlUnitOfWork,
|
|
795
|
+
} from "@beignet/provider-db-drizzle/mysql";
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
Run the setup statements through your app-owned migration or bootstrap flow:
|
|
799
|
+
|
|
800
|
+
```ts
|
|
801
|
+
for (const statement of createDrizzleMysqlIdempotencySetupStatements()) {
|
|
802
|
+
await pool.query(statement);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
for (const statement of createDrizzleMysqlOutboxSetupStatements()) {
|
|
806
|
+
await pool.query(statement);
|
|
807
|
+
}
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
Unit of Work and transaction-port wiring are identical to the SQLite and
|
|
811
|
+
Postgres examples. The idempotency port throws
|
|
812
|
+
`DrizzleMysqlIdempotencyMutationError` on unmatched `complete(...)` or
|
|
813
|
+
`fail(...)` mutations.
|
|
814
|
+
|
|
815
|
+
MySQL enforces length limits on idempotency keys: `storage_key` is
|
|
816
|
+
`varchar(512)` and `namespace`, `key`, and `scope` are `varchar(255)`.
|
|
817
|
+
Over-length keys fail loudly instead of being truncated, so keep idempotency
|
|
818
|
+
namespaces, keys, and scope values short.
|
|
819
|
+
|
|
820
|
+
## Design notes
|
|
821
|
+
|
|
822
|
+
These choices are deliberate and shared across the three backends:
|
|
823
|
+
|
|
824
|
+
- **ISO-8601 text timestamps.** All three dialects store outbox and
|
|
825
|
+
idempotency timestamps as ISO-8601 UTC strings in text columns. This keeps
|
|
826
|
+
retry timing, lease expiry, and replay semantics identical across backends,
|
|
827
|
+
and lexicographic ordering matches chronological ordering. A later release
|
|
828
|
+
may move the Postgres ports to `timestamptz`.
|
|
829
|
+
- **MySQL key length limits.** SQLite and Postgres use unbounded text columns
|
|
830
|
+
for idempotency keys; MySQL needs bounded `varchar` columns for its unique
|
|
831
|
+
index, so over-length keys fail loudly rather than truncating silently.
|
|
832
|
+
- **Shared conformance suite.** One conformance suite runs the same Unit of
|
|
833
|
+
Work, outbox, and idempotency behavior tests against SQLite (libSQL),
|
|
834
|
+
Postgres (PGlite in-process plus a real server in CI), and MySQL (a real
|
|
835
|
+
server in CI), so durable workflow semantics do not drift between backends.
|
|
836
|
+
|
|
837
|
+
## Advanced usage
|
|
838
|
+
|
|
839
|
+
### Multiple databases
|
|
840
|
+
|
|
841
|
+
This provider reads one connection from the `SQLITE_DB_URL` and
|
|
842
|
+
`SQLITE_DB_AUTH_TOKEN` environment variables. Registering it twice, even with
|
|
843
|
+
different `portName` values, reads the same env vars, so both ports would
|
|
844
|
+
connect to the same database. Use `portName` only to rename the single
|
|
845
|
+
provider-owned port.
|
|
846
|
+
|
|
847
|
+
To connect a second database, wire it through an app-owned provider that loads
|
|
848
|
+
its own connection config, following the same typed custom provider pattern
|
|
849
|
+
shown above:
|
|
850
|
+
|
|
851
|
+
```ts
|
|
852
|
+
// infra/db/analytics-provider.ts
|
|
853
|
+
import { createClient } from "@libsql/client";
|
|
854
|
+
import { drizzle } from "drizzle-orm/libsql";
|
|
855
|
+
import { createProvider } from "@beignet/core/providers";
|
|
856
|
+
import type { DbPort } from "@beignet/provider-db-drizzle/sqlite";
|
|
857
|
+
import { z } from "zod";
|
|
858
|
+
import * as analyticsSchema from "./analytics-schema";
|
|
859
|
+
|
|
860
|
+
const AnalyticsDbConfigSchema = z.object({
|
|
861
|
+
DB_URL: z.string().min(1),
|
|
862
|
+
DB_AUTH_TOKEN: z.string().optional(),
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
export const analyticsDbProvider = createProvider({
|
|
866
|
+
name: "analytics-db",
|
|
867
|
+
|
|
868
|
+
config: {
|
|
869
|
+
schema: AnalyticsDbConfigSchema,
|
|
870
|
+
envPrefix: "ANALYTICS_",
|
|
871
|
+
},
|
|
872
|
+
|
|
873
|
+
async setup({ config }) {
|
|
874
|
+
if (!config) {
|
|
875
|
+
throw new Error(
|
|
876
|
+
"[analyticsDbProvider] Missing config. Set ANALYTICS_DB_URL (and optional ANALYTICS_DB_AUTH_TOKEN).",
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const client = createClient({
|
|
881
|
+
url: config.DB_URL,
|
|
882
|
+
authToken: config.DB_AUTH_TOKEN,
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
const analyticsDb: DbPort<typeof analyticsSchema> = {
|
|
886
|
+
db: drizzle(client, { schema: analyticsSchema }),
|
|
887
|
+
client,
|
|
888
|
+
};
|
|
889
|
+
|
|
890
|
+
return {
|
|
891
|
+
ports: { analyticsDb },
|
|
892
|
+
async stop() {
|
|
893
|
+
await client.close();
|
|
894
|
+
},
|
|
895
|
+
};
|
|
896
|
+
},
|
|
897
|
+
});
|
|
898
|
+
```
|
|
899
|
+
|
|
900
|
+
```ts
|
|
901
|
+
// ports/index.ts
|
|
902
|
+
export type AppPorts = {
|
|
903
|
+
db: DbPort<typeof mainSchema>;
|
|
904
|
+
analyticsDb: DbPort<typeof analyticsSchema>;
|
|
905
|
+
};
|
|
906
|
+
```
|
|
907
|
+
|
|
908
|
+
### Accessing the Drizzle instance
|
|
909
|
+
|
|
910
|
+
The `DbPort` exposes the typed Drizzle database instance:
|
|
911
|
+
|
|
912
|
+
```ts
|
|
913
|
+
import { eq } from "drizzle-orm";
|
|
914
|
+
|
|
915
|
+
const db = ctx.ports.db.db; // LibSQLDatabase<TSchema>
|
|
916
|
+
|
|
917
|
+
// All Drizzle operations are available:
|
|
918
|
+
await db.select().from(schema.todos);
|
|
919
|
+
await db.insert(schema.todos).values({ id: "1", title: "Hello" });
|
|
920
|
+
await db.update(schema.todos).set({ title: "Updated" }).where(eq(schema.todos.id, "1"));
|
|
921
|
+
await db.delete(schema.todos).where(eq(schema.todos.id, "1"));
|
|
922
|
+
|
|
923
|
+
// Prefer repository ports and createDrizzleSqliteUnitOfWork(...) for application
|
|
924
|
+
// workflows. Use raw db access in infra, scripts, and vendor-specific escape
|
|
925
|
+
// hatches.
|
|
926
|
+
|
|
927
|
+
// Access the underlying libSQL client for advanced operations:
|
|
928
|
+
const client = ctx.ports.db.client;
|
|
929
|
+
const result = await client.execute("SELECT * FROM todos WHERE id = ?", ["1"]);
|
|
930
|
+
```
|
|
931
|
+
|
|
932
|
+
## Key design principles
|
|
933
|
+
|
|
934
|
+
### Runtime vs. build-time separation
|
|
935
|
+
|
|
936
|
+
This provider follows a clean separation of concerns:
|
|
937
|
+
|
|
938
|
+
- **Build-time (Drizzle CLI)**: Configured via `drizzle.config.ts`
|
|
939
|
+
- Used for generating migrations
|
|
940
|
+
- Used for introspecting the database
|
|
941
|
+
- Lives in your app repository
|
|
942
|
+
|
|
943
|
+
- **Runtime (Provider)**: Configured via factory function
|
|
944
|
+
- Used for connecting to the database at runtime
|
|
945
|
+
- Used for executing queries in your use cases
|
|
946
|
+
- Gets the schema from your imports
|
|
947
|
+
|
|
948
|
+
### Schema location independence
|
|
949
|
+
|
|
950
|
+
The provider **does not care** where your schema file lives. You:
|
|
951
|
+
|
|
952
|
+
1. Define your schema files wherever makes sense (`infra/db/schema/`, `db/schema.ts`, etc.)
|
|
953
|
+
2. Import them in your app: `import * as schema from "@/infra/db/schema"`
|
|
954
|
+
3. Pass it to the factory: `createDrizzleSqliteProvider({ schema })`
|
|
955
|
+
|
|
956
|
+
This keeps the provider flexible and your app in control of its structure.
|
|
957
|
+
|
|
958
|
+
## API reference
|
|
959
|
+
|
|
960
|
+
All exports below come from `@beignet/provider-db-drizzle/sqlite`. The
|
|
961
|
+
`/postgres` and `/mysql` subpaths export the same surface with
|
|
962
|
+
`DrizzlePostgres` and `DrizzleMysql` naming; the [Postgres](#postgres) and
|
|
963
|
+
[MySQL](#mysql) sections above cover the backend-specific differences:
|
|
964
|
+
provider options (`pool`, and `mode` for MySQL), the `DbPort` escape hatch
|
|
965
|
+
(`pool` instead of `client`), and the mutation error class names.
|
|
966
|
+
|
|
967
|
+
### `DbPort<TSchema>`
|
|
968
|
+
|
|
969
|
+
The port interface exposed on `ctx.ports.db`:
|
|
970
|
+
|
|
971
|
+
```ts
|
|
972
|
+
interface DbPort<TSchema extends Record<string, any> = any> {
|
|
973
|
+
db: LibSQLDatabase<TSchema>;
|
|
974
|
+
client: Client;
|
|
975
|
+
}
|
|
976
|
+
```
|
|
977
|
+
|
|
978
|
+
- `db`: The typed Drizzle database instance for ORM operations
|
|
979
|
+
- `client`: The underlying libSQL client for advanced operations not covered by Drizzle
|
|
980
|
+
|
|
981
|
+
### `createDrizzleSqliteProvider<TSchema>(options)`
|
|
982
|
+
|
|
983
|
+
Factory function to create a Drizzle SQLite provider.
|
|
984
|
+
|
|
985
|
+
**Parameters:**
|
|
986
|
+
- `options.schema` (required): Your Drizzle schema object
|
|
987
|
+
- `options.portName` (optional): Port name, defaults to `"db"`
|
|
988
|
+
|
|
989
|
+
**Returns:** A provider that can be registered with `createServer`, `createNextServer`, or another Beignet server adapter.
|
|
990
|
+
|
|
991
|
+
**Example:**
|
|
992
|
+
```ts
|
|
993
|
+
const provider = createDrizzleSqliteProvider({
|
|
994
|
+
schema: mySchema,
|
|
995
|
+
portName: "db", // optional
|
|
996
|
+
});
|
|
997
|
+
```
|
|
998
|
+
|
|
999
|
+
### `createDrizzleSqliteUnitOfWork<TSchema, TxPorts>(options)`
|
|
1000
|
+
|
|
1001
|
+
Factory function to create a transaction-backed `UnitOfWorkPort`.
|
|
1002
|
+
|
|
1003
|
+
**Parameters:**
|
|
1004
|
+
- `options.db` (required): The root `LibSQLDatabase<TSchema>` instance
|
|
1005
|
+
- `options.createTransactionPorts` (required): Factory that receives the Drizzle transaction client and event recorder
|
|
1006
|
+
- `options.eventBus` (optional): Event bus used to flush recorded events after commit
|
|
1007
|
+
- `options.transactionConfig` (optional): Drizzle transaction configuration
|
|
1008
|
+
|
|
1009
|
+
**Returns:** A `UnitOfWorkPort<TxPorts>` that runs work inside
|
|
1010
|
+
`db.transaction(...)`.
|
|
1011
|
+
|
|
1012
|
+
### `createDrizzleSqliteOutboxPort<TSchema>(db, options?)`
|
|
1013
|
+
|
|
1014
|
+
Factory function to create a SQL-backed `OutboxPort` from a root Drizzle
|
|
1015
|
+
database or transaction client.
|
|
1016
|
+
|
|
1017
|
+
**Parameters:**
|
|
1018
|
+
- `db` (required): A `DrizzleSqliteDatabase<TSchema>` root database or transaction
|
|
1019
|
+
- `options.tableName` (optional): Outbox table name, defaults to `"outbox_messages"`
|
|
1020
|
+
- `options.now` (optional): Test clock
|
|
1021
|
+
|
|
1022
|
+
### `createDrizzleSqliteOutboxSetupStatements(options?)`
|
|
1023
|
+
|
|
1024
|
+
Returns SQL setup statements for the app-owned outbox table and indexes. Run
|
|
1025
|
+
these through your migration/bootstrap flow or translate them into your normal
|
|
1026
|
+
Drizzle migrations.
|
|
1027
|
+
|
|
1028
|
+
### `createDrizzleSqliteIdempotencyPort<TSchema>(db, options?)`
|
|
1029
|
+
|
|
1030
|
+
Factory function to create a SQL-backed `IdempotencyPort` from a root Drizzle
|
|
1031
|
+
database or transaction client.
|
|
1032
|
+
|
|
1033
|
+
**Parameters:**
|
|
1034
|
+
- `db` (required): A `DrizzleSqliteDatabase<TSchema>` root database or transaction
|
|
1035
|
+
- `options.tableName` (optional): Idempotency table name, defaults to `"idempotency_records"`
|
|
1036
|
+
- `options.now` (optional): Test clock
|
|
1037
|
+
|
|
1038
|
+
### `createDrizzleSqliteIdempotencySetupStatements(options?)`
|
|
1039
|
+
|
|
1040
|
+
Returns SQL setup statements for the app-owned idempotency table and indexes.
|
|
1041
|
+
Run these through your migration/bootstrap flow or translate them into your
|
|
1042
|
+
normal Drizzle migrations.
|
|
1043
|
+
|
|
1044
|
+
## License
|
|
1045
|
+
|
|
1046
|
+
MIT
|