@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +1046 -0
  3. package/dist/mysql/index.d.ts +254 -0
  4. package/dist/mysql/index.d.ts.map +1 -0
  5. package/dist/mysql/index.js +348 -0
  6. package/dist/mysql/index.js.map +1 -0
  7. package/dist/postgres/index.d.ts +240 -0
  8. package/dist/postgres/index.d.ts.map +1 -0
  9. package/dist/postgres/index.js +296 -0
  10. package/dist/postgres/index.js.map +1 -0
  11. package/dist/shared/idempotency-core.d.ts +71 -0
  12. package/dist/shared/idempotency-core.d.ts.map +1 -0
  13. package/dist/shared/idempotency-core.js +169 -0
  14. package/dist/shared/idempotency-core.js.map +1 -0
  15. package/dist/shared/identifiers.d.ts +20 -0
  16. package/dist/shared/identifiers.d.ts.map +1 -0
  17. package/dist/shared/identifiers.js +27 -0
  18. package/dist/shared/identifiers.js.map +1 -0
  19. package/dist/shared/instrumentation.d.ts +19 -0
  20. package/dist/shared/instrumentation.d.ts.map +1 -0
  21. package/dist/shared/instrumentation.js +41 -0
  22. package/dist/shared/instrumentation.js.map +1 -0
  23. package/dist/shared/outbox-core.d.ts +76 -0
  24. package/dist/shared/outbox-core.d.ts.map +1 -0
  25. package/dist/shared/outbox-core.js +193 -0
  26. package/dist/shared/outbox-core.js.map +1 -0
  27. package/dist/shared/rows.d.ts +84 -0
  28. package/dist/shared/rows.d.ts.map +1 -0
  29. package/dist/shared/rows.js +128 -0
  30. package/dist/shared/rows.js.map +1 -0
  31. package/dist/sqlite/index.d.ts +235 -0
  32. package/dist/sqlite/index.d.ts.map +1 -0
  33. package/dist/sqlite/index.js +293 -0
  34. package/dist/sqlite/index.js.map +1 -0
  35. package/package.json +173 -0
  36. package/src/mysql/index.ts +627 -0
  37. package/src/postgres/index.ts +572 -0
  38. package/src/shared/idempotency-core.ts +280 -0
  39. package/src/shared/identifiers.ts +28 -0
  40. package/src/shared/instrumentation.ts +49 -0
  41. package/src/shared/outbox-core.ts +322 -0
  42. package/src/shared/rows.ts +197 -0
  43. 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