@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
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @beignet/provider-db-drizzle/mysql
|
|
3
|
+
*
|
|
4
|
+
* Drizzle ORM MySQL provider, backed by mysql2, that creates a typed
|
|
5
|
+
* database port. Works with self-hosted MySQL 8.0+ and hosted MySQL services.
|
|
6
|
+
*
|
|
7
|
+
* Configuration:
|
|
8
|
+
* - MYSQL_DB_URL: MySQL connection URL (required), e.g.
|
|
9
|
+
* "mysql://user:password@localhost:3306/app"
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* import * as schema from "@/db/schema";
|
|
14
|
+
* import { createDrizzleMysqlProvider } from "@beignet/provider-db-drizzle/mysql";
|
|
15
|
+
*
|
|
16
|
+
* export const drizzleMysqlProvider = createDrizzleMysqlProvider({ schema });
|
|
17
|
+
*
|
|
18
|
+
* // In createNextServer:
|
|
19
|
+
* const server = await createNextServer({
|
|
20
|
+
* ports: basePorts,
|
|
21
|
+
* providers: [drizzleMysqlProvider],
|
|
22
|
+
* // ...
|
|
23
|
+
* });
|
|
24
|
+
*
|
|
25
|
+
* // In infra:
|
|
26
|
+
* const todos = createDrizzleTodoRepository(ctx.ports.db.db);
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { IdempotencyPort } from "@beignet/core/idempotency";
|
|
31
|
+
import type { OutboxPort } from "@beignet/core/outbox";
|
|
32
|
+
import {
|
|
33
|
+
type BufferedDomainEventRecorder,
|
|
34
|
+
createDomainEventRecorder,
|
|
35
|
+
type EventBusPort,
|
|
36
|
+
type UnitOfWorkCallback,
|
|
37
|
+
type UnitOfWorkPort,
|
|
38
|
+
} from "@beignet/core/ports";
|
|
39
|
+
import {
|
|
40
|
+
createProvider,
|
|
41
|
+
createProviderInstrumentation,
|
|
42
|
+
} from "@beignet/core/providers";
|
|
43
|
+
import { type ExtractTablesWithRelations, type SQL, sql } from "drizzle-orm";
|
|
44
|
+
import type {
|
|
45
|
+
MySqlDatabase,
|
|
46
|
+
MySqlQueryResultHKT,
|
|
47
|
+
MySqlTransaction,
|
|
48
|
+
MySqlTransactionConfig,
|
|
49
|
+
PreparedQueryHKTBase,
|
|
50
|
+
} from "drizzle-orm/mysql-core";
|
|
51
|
+
import { drizzle, type MySql2Database } from "drizzle-orm/mysql2";
|
|
52
|
+
import type { Pool, PoolOptions } from "mysql2/promise";
|
|
53
|
+
import * as mysql from "mysql2/promise";
|
|
54
|
+
import { z } from "zod";
|
|
55
|
+
import {
|
|
56
|
+
createIdempotencyPortCore,
|
|
57
|
+
formatIdempotencyMutationErrorMessage,
|
|
58
|
+
type IdempotencySqlExecutor,
|
|
59
|
+
} from "../shared/idempotency-core.js";
|
|
60
|
+
import { quoteIdentifier } from "../shared/identifiers.js";
|
|
61
|
+
import { createDbQueryLogger } from "../shared/instrumentation.js";
|
|
62
|
+
import {
|
|
63
|
+
createOutboxPortCore,
|
|
64
|
+
type OutboxSqlExecutor,
|
|
65
|
+
type SqlExecutorBase,
|
|
66
|
+
} from "../shared/outbox-core.js";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Typed database port interface.
|
|
70
|
+
* Exposes a typed Drizzle database instance for your schema.
|
|
71
|
+
*
|
|
72
|
+
* @template TSchema - The Drizzle schema type (e.g., `typeof schema`)
|
|
73
|
+
*/
|
|
74
|
+
export interface DbPort<
|
|
75
|
+
TSchema extends Record<string, unknown> = Record<string, unknown>,
|
|
76
|
+
> {
|
|
77
|
+
/**
|
|
78
|
+
* The typed Drizzle database instance.
|
|
79
|
+
* Use this to query your database with full type safety.
|
|
80
|
+
*/
|
|
81
|
+
db: MySql2Database<TSchema>;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The underlying mysql2 connection pool.
|
|
85
|
+
* Use this for advanced operations not covered by Drizzle ORM.
|
|
86
|
+
*/
|
|
87
|
+
pool: Pool;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Drizzle database or transaction client accepted by repository factories.
|
|
92
|
+
*
|
|
93
|
+
* `MySqlTransaction` extends `MySqlDatabase`, so both the root database and
|
|
94
|
+
* transaction clients satisfy this seam.
|
|
95
|
+
*/
|
|
96
|
+
export type DrizzleMysqlDatabase<
|
|
97
|
+
TSchema extends Record<string, unknown> = Record<string, unknown>,
|
|
98
|
+
> = MySqlDatabase<MySqlQueryResultHKT, PreparedQueryHKTBase, TSchema>;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Drizzle transaction client passed to Unit of Work repository factories.
|
|
102
|
+
*/
|
|
103
|
+
export type DrizzleMysqlTransaction<
|
|
104
|
+
TSchema extends Record<string, unknown> = Record<string, unknown>,
|
|
105
|
+
> = MySqlTransaction<
|
|
106
|
+
MySqlQueryResultHKT,
|
|
107
|
+
PreparedQueryHKTBase,
|
|
108
|
+
TSchema,
|
|
109
|
+
ExtractTablesWithRelations<TSchema>
|
|
110
|
+
>;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Options for creating a Drizzle MySQL-backed Unit of Work port.
|
|
114
|
+
*/
|
|
115
|
+
export interface DrizzleMysqlUnitOfWorkOptions<
|
|
116
|
+
TSchema extends Record<string, unknown>,
|
|
117
|
+
TxPorts,
|
|
118
|
+
> {
|
|
119
|
+
/**
|
|
120
|
+
* The root Drizzle database instance from `ctx.ports.db.db`.
|
|
121
|
+
*/
|
|
122
|
+
db: DrizzleMysqlDatabase<TSchema>;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Create transaction-scoped ports from the Drizzle transaction client.
|
|
126
|
+
*
|
|
127
|
+
* The event recorder is transaction-local. Record domain events inside the
|
|
128
|
+
* callback and pass `eventBus` to publish them after the database commit.
|
|
129
|
+
*/
|
|
130
|
+
createTransactionPorts: (
|
|
131
|
+
db: DrizzleMysqlTransaction<TSchema>,
|
|
132
|
+
events: BufferedDomainEventRecorder,
|
|
133
|
+
) => TxPorts;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Optional event bus used to flush recorded domain events after commit.
|
|
137
|
+
*/
|
|
138
|
+
eventBus?: EventBusPort;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Optional Drizzle transaction configuration.
|
|
142
|
+
*/
|
|
143
|
+
transactionConfig?: MySqlTransactionConfig;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Create a Unit of Work port backed by Drizzle's MySQL transaction API.
|
|
148
|
+
*
|
|
149
|
+
* Beignet does not own your repository interfaces. This helper only
|
|
150
|
+
* provides the transaction boundary and post-commit event flushing; your app
|
|
151
|
+
* decides which transaction-scoped ports to create.
|
|
152
|
+
*/
|
|
153
|
+
export function createDrizzleMysqlUnitOfWork<
|
|
154
|
+
TSchema extends Record<string, unknown>,
|
|
155
|
+
TxPorts,
|
|
156
|
+
>(
|
|
157
|
+
options: DrizzleMysqlUnitOfWorkOptions<TSchema, TxPorts>,
|
|
158
|
+
): UnitOfWorkPort<TxPorts> {
|
|
159
|
+
return {
|
|
160
|
+
async transaction<Result>(
|
|
161
|
+
work: UnitOfWorkCallback<TxPorts, Result>,
|
|
162
|
+
): Promise<Result> {
|
|
163
|
+
const events = createDomainEventRecorder();
|
|
164
|
+
|
|
165
|
+
const result = await options.db.transaction(async (tx) => {
|
|
166
|
+
const txPorts = options.createTransactionPorts(
|
|
167
|
+
tx as DrizzleMysqlTransaction<TSchema>,
|
|
168
|
+
events,
|
|
169
|
+
);
|
|
170
|
+
return work(txPorts);
|
|
171
|
+
}, options.transactionConfig);
|
|
172
|
+
|
|
173
|
+
if (options.eventBus) {
|
|
174
|
+
await events.flush(options.eventBus);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return result;
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Options for a Drizzle MySQL-backed outbox port.
|
|
184
|
+
*/
|
|
185
|
+
export interface DrizzleMysqlOutboxOptions {
|
|
186
|
+
/**
|
|
187
|
+
* Table that stores outbox messages.
|
|
188
|
+
*
|
|
189
|
+
* Default: "outbox_messages".
|
|
190
|
+
*/
|
|
191
|
+
tableName?: string;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Clock used by tests and deterministic environments.
|
|
195
|
+
*/
|
|
196
|
+
now?: () => Date;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Read the first element of a drizzle mysql2 `db.execute(...)` result tuple.
|
|
201
|
+
*
|
|
202
|
+
* drizzle-orm/mysql2 returns `[rows | ResultSetHeader, fields]`: for selects
|
|
203
|
+
* element 0 is the row array; for mutations element 0 is a ResultSetHeader.
|
|
204
|
+
*/
|
|
205
|
+
function readResultHead(result: unknown): unknown {
|
|
206
|
+
return Array.isArray(result) ? result[0] : undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function readResultRows(result: unknown): Record<string, unknown>[] {
|
|
210
|
+
const head = readResultHead(result);
|
|
211
|
+
return Array.isArray(head) ? (head as Record<string, unknown>[]) : [];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function readAffectedRows(result: unknown): number | undefined {
|
|
215
|
+
const head = readResultHead(result);
|
|
216
|
+
if (head === null || typeof head !== "object" || Array.isArray(head)) {
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
const affectedRows = (head as { affectedRows?: unknown }).affectedRows;
|
|
220
|
+
return typeof affectedRows === "number" ? affectedRows : undefined;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Detect a MySQL duplicate-key violation (ER_DUP_ENTRY, errno 1062) anywhere
|
|
225
|
+
* in an error's cause chain — drizzle wraps driver errors in
|
|
226
|
+
* DrizzleQueryError with the mysql2 error as `cause`.
|
|
227
|
+
*/
|
|
228
|
+
function isDuplicateKeyError(error: unknown): boolean {
|
|
229
|
+
for (
|
|
230
|
+
let current = error;
|
|
231
|
+
current !== null && typeof current === "object";
|
|
232
|
+
current = (current as { cause?: unknown }).cause
|
|
233
|
+
) {
|
|
234
|
+
const candidate = current as { errno?: unknown; code?: unknown };
|
|
235
|
+
if (candidate.errno === 1062 || candidate.code === "ER_DUP_ENTRY") {
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* MySQL executor implementing both the outbox and idempotency seams of the
|
|
244
|
+
* shared database cores.
|
|
245
|
+
*/
|
|
246
|
+
interface DrizzleMysqlSqlExecutor
|
|
247
|
+
extends SqlExecutorBase<DrizzleMysqlSqlExecutor> {
|
|
248
|
+
claimLockSuffix: SQL;
|
|
249
|
+
insertPrefix: SQL;
|
|
250
|
+
insertConflictSuffix: SQL;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function createMysqlExecutor<TSchema extends Record<string, unknown>>(
|
|
254
|
+
db: DrizzleMysqlDatabase<TSchema>,
|
|
255
|
+
table: SQL,
|
|
256
|
+
): DrizzleMysqlSqlExecutor {
|
|
257
|
+
return {
|
|
258
|
+
table,
|
|
259
|
+
// MySQL 8.0+ row-locking clause so concurrent claimers skip locked rows.
|
|
260
|
+
claimLockSuffix: sql.raw(" for update skip locked"),
|
|
261
|
+
// Duplicate keys surface as ER_DUP_ENTRY caught in run() below — a plain
|
|
262
|
+
// insert keeps the conflict signal independent of connection flags.
|
|
263
|
+
// mysql2 enables CLIENT_FOUND_ROWS by default, which makes
|
|
264
|
+
// `on duplicate key update x = x` report affectedRows 1 instead of 0,
|
|
265
|
+
// so counting-based duplicate detection silently misreads duplicates as
|
|
266
|
+
// inserts. Do NOT switch to INSERT IGNORE either: strict mode downgrades
|
|
267
|
+
// truncation errors to warnings, silently corrupting stored values.
|
|
268
|
+
insertPrefix: sql.raw("insert into"),
|
|
269
|
+
insertConflictSuffix: sql.raw(""),
|
|
270
|
+
|
|
271
|
+
async rows(query) {
|
|
272
|
+
return readResultRows(await db.execute(query));
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
async row(query) {
|
|
276
|
+
return readResultRows(await db.execute(query))[0];
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
async run(query) {
|
|
280
|
+
// With mysql2's default CLIENT_FOUND_ROWS flag, affectedRows for
|
|
281
|
+
// UPDATE counts MATCHED rows (without it, CHANGED rows). Every guarded
|
|
282
|
+
// update issued by the shared cores deterministically changes at least
|
|
283
|
+
// one column (fresh claim token or status transition), so the count is
|
|
284
|
+
// identical either way. Keep it that way: a no-op update would make
|
|
285
|
+
// the two flag modes disagree.
|
|
286
|
+
//
|
|
287
|
+
// A duplicate-key violation reports 0 instead of throwing: the only
|
|
288
|
+
// unique-key inserts the shared cores issue are the idempotency
|
|
289
|
+
// reserve (where "0 rows" is exactly the conflict fall-through signal)
|
|
290
|
+
// and outbox enqueues keyed by fresh UUIDs. MySQL does not abort the
|
|
291
|
+
// surrounding transaction on a statement error, so the reserve's
|
|
292
|
+
// read-and-classify step still runs inside the same transaction.
|
|
293
|
+
try {
|
|
294
|
+
return readAffectedRows(await db.execute(query));
|
|
295
|
+
} catch (error) {
|
|
296
|
+
if (isDuplicateKeyError(error)) {
|
|
297
|
+
return 0;
|
|
298
|
+
}
|
|
299
|
+
throw error;
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
|
|
303
|
+
withTransaction(fn) {
|
|
304
|
+
if ("transaction" in db && typeof db.transaction === "function") {
|
|
305
|
+
return db.transaction((tx) =>
|
|
306
|
+
fn(createMysqlExecutor(tx as DrizzleMysqlDatabase<TSchema>, table)),
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return undefined;
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Create idempotent SQL statements for the Drizzle MySQL outbox table.
|
|
317
|
+
*
|
|
318
|
+
* Run these during migrations or local setup before using
|
|
319
|
+
* `createDrizzleMysqlOutboxPort(...)`.
|
|
320
|
+
*
|
|
321
|
+
* Indexes are declared inline because MySQL has no
|
|
322
|
+
* `CREATE INDEX IF NOT EXISTS`; the whole shape stays idempotent under
|
|
323
|
+
* `CREATE TABLE IF NOT EXISTS`. The binary no-pad collation
|
|
324
|
+
* `utf8mb4_0900_bin` (MySQL 8.0+) keeps string comparisons case-sensitive
|
|
325
|
+
* and free of PAD SPACE semantics, which claim tokens and fingerprints
|
|
326
|
+
* require.
|
|
327
|
+
*/
|
|
328
|
+
export function createDrizzleMysqlOutboxSetupStatements(
|
|
329
|
+
options: DrizzleMysqlOutboxOptions = {},
|
|
330
|
+
): readonly string[] {
|
|
331
|
+
const tableName = options.tableName ?? "outbox_messages";
|
|
332
|
+
const table = quoteIdentifier(tableName, "`");
|
|
333
|
+
const indexPrefix = tableName.replaceAll(/[^A-Za-z0-9_]/g, "_");
|
|
334
|
+
|
|
335
|
+
return [
|
|
336
|
+
`CREATE TABLE IF NOT EXISTS ${table} (
|
|
337
|
+
id varchar(255) NOT NULL PRIMARY KEY,
|
|
338
|
+
kind varchar(32) NOT NULL,
|
|
339
|
+
name varchar(255) NOT NULL,
|
|
340
|
+
payload_json longtext NOT NULL,
|
|
341
|
+
status varchar(32) NOT NULL,
|
|
342
|
+
attempts int NOT NULL DEFAULT 0,
|
|
343
|
+
max_attempts int NOT NULL DEFAULT 3,
|
|
344
|
+
available_at varchar(32) NOT NULL,
|
|
345
|
+
claimed_at varchar(32),
|
|
346
|
+
locked_until varchar(32),
|
|
347
|
+
claim_token varchar(64),
|
|
348
|
+
delivered_at varchar(32),
|
|
349
|
+
last_error_json longtext,
|
|
350
|
+
created_at varchar(32) NOT NULL,
|
|
351
|
+
updated_at varchar(32) NOT NULL,
|
|
352
|
+
INDEX ${quoteIdentifier(`${indexPrefix}_available_idx`, "`")} (status, available_at),
|
|
353
|
+
INDEX ${quoteIdentifier(`${indexPrefix}_locked_idx`, "`")} (status, locked_until)
|
|
354
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin`,
|
|
355
|
+
];
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Create a Beignet outbox port backed by a Drizzle MySQL database.
|
|
360
|
+
*
|
|
361
|
+
* Claiming uses a claim-token lease so concurrent workers can safely compete
|
|
362
|
+
* for eligible messages, combined with `for update skip locked` row locking.
|
|
363
|
+
*/
|
|
364
|
+
export function createDrizzleMysqlOutboxPort<
|
|
365
|
+
TSchema extends Record<string, unknown>,
|
|
366
|
+
>(
|
|
367
|
+
db: DrizzleMysqlDatabase<TSchema>,
|
|
368
|
+
adapterOptions: DrizzleMysqlOutboxOptions = {},
|
|
369
|
+
): OutboxPort {
|
|
370
|
+
const table = sql.raw(
|
|
371
|
+
quoteIdentifier(adapterOptions.tableName ?? "outbox_messages", "`"),
|
|
372
|
+
);
|
|
373
|
+
const executor: OutboxSqlExecutor = createMysqlExecutor(db, table);
|
|
374
|
+
|
|
375
|
+
return createOutboxPortCore(executor, { now: adapterOptions.now });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Options for a Drizzle MySQL-backed idempotency port.
|
|
380
|
+
*/
|
|
381
|
+
export interface DrizzleMysqlIdempotencyOptions {
|
|
382
|
+
/**
|
|
383
|
+
* Table that stores idempotency records.
|
|
384
|
+
*
|
|
385
|
+
* Default: "idempotency_records".
|
|
386
|
+
*/
|
|
387
|
+
tableName?: string;
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Clock used by tests and deterministic environments.
|
|
391
|
+
*/
|
|
392
|
+
now?: () => Date;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Error thrown when `complete(...)` or `fail(...)` does not match an
|
|
397
|
+
* in-progress idempotency reservation with the provided fingerprint.
|
|
398
|
+
*
|
|
399
|
+
* This usually signals a workflow bug: the reservation was never made, was
|
|
400
|
+
* already completed or released, expired and was cleaned up, or the caller
|
|
401
|
+
* passed a different fingerprint than the one used to reserve.
|
|
402
|
+
*/
|
|
403
|
+
export class DrizzleMysqlIdempotencyMutationError extends Error {
|
|
404
|
+
readonly action: "complete" | "fail";
|
|
405
|
+
readonly namespace: string;
|
|
406
|
+
readonly key: string;
|
|
407
|
+
readonly scopeKey: string;
|
|
408
|
+
|
|
409
|
+
constructor(args: {
|
|
410
|
+
action: "complete" | "fail";
|
|
411
|
+
namespace: string;
|
|
412
|
+
key: string;
|
|
413
|
+
scopeKey: string;
|
|
414
|
+
}) {
|
|
415
|
+
super(formatIdempotencyMutationErrorMessage(args));
|
|
416
|
+
this.name = "DrizzleMysqlIdempotencyMutationError";
|
|
417
|
+
this.action = args.action;
|
|
418
|
+
this.namespace = args.namespace;
|
|
419
|
+
this.key = args.key;
|
|
420
|
+
this.scopeKey = args.scopeKey;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Create idempotent SQL statements for the Drizzle MySQL idempotency table.
|
|
426
|
+
*
|
|
427
|
+
* Run these during migrations or local setup before using
|
|
428
|
+
* `createDrizzleMysqlIdempotencyPort(...)`.
|
|
429
|
+
*
|
|
430
|
+
* Indexes are declared inline because MySQL has no
|
|
431
|
+
* `CREATE INDEX IF NOT EXISTS`; the whole shape stays idempotent under
|
|
432
|
+
* `CREATE TABLE IF NOT EXISTS`. The binary no-pad collation
|
|
433
|
+
* `utf8mb4_0900_bin` (MySQL 8.0+) keeps fingerprint comparisons
|
|
434
|
+
* case-sensitive — "ABC" and "abc" must classify as conflict, never replay.
|
|
435
|
+
*/
|
|
436
|
+
export function createDrizzleMysqlIdempotencySetupStatements(
|
|
437
|
+
options: DrizzleMysqlIdempotencyOptions = {},
|
|
438
|
+
): readonly string[] {
|
|
439
|
+
const tableName = options.tableName ?? "idempotency_records";
|
|
440
|
+
const table = quoteIdentifier(tableName, "`");
|
|
441
|
+
const indexPrefix = tableName.replaceAll(/[^A-Za-z0-9_]/g, "_");
|
|
442
|
+
|
|
443
|
+
return [
|
|
444
|
+
`CREATE TABLE IF NOT EXISTS ${table} (
|
|
445
|
+
storage_key varchar(512) NOT NULL PRIMARY KEY,
|
|
446
|
+
namespace varchar(255) NOT NULL,
|
|
447
|
+
idempotency_key varchar(255) NOT NULL,
|
|
448
|
+
scope_key varchar(255) NOT NULL,
|
|
449
|
+
fingerprint varchar(255) NOT NULL,
|
|
450
|
+
status varchar(16) NOT NULL,
|
|
451
|
+
result_json longtext,
|
|
452
|
+
reserved_at varchar(32) NOT NULL,
|
|
453
|
+
completed_at varchar(32),
|
|
454
|
+
expires_at varchar(32),
|
|
455
|
+
updated_at varchar(32) NOT NULL,
|
|
456
|
+
INDEX ${quoteIdentifier(`${indexPrefix}_lookup_idx`, "`")} (namespace, scope_key, idempotency_key),
|
|
457
|
+
INDEX ${quoteIdentifier(`${indexPrefix}_expires_idx`, "`")} (expires_at)
|
|
458
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin`,
|
|
459
|
+
];
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Create a Beignet idempotency port backed by a Drizzle MySQL database.
|
|
464
|
+
*
|
|
465
|
+
* The port accepts both the root Drizzle database and transaction clients, so
|
|
466
|
+
* applications can expose root idempotency for ordinary retry-safe commands and
|
|
467
|
+
* transaction-scoped idempotency from Unit of Work.
|
|
468
|
+
*/
|
|
469
|
+
export function createDrizzleMysqlIdempotencyPort<
|
|
470
|
+
TSchema extends Record<string, unknown>,
|
|
471
|
+
>(
|
|
472
|
+
db: DrizzleMysqlDatabase<TSchema>,
|
|
473
|
+
adapterOptions: DrizzleMysqlIdempotencyOptions = {},
|
|
474
|
+
): IdempotencyPort {
|
|
475
|
+
const table = sql.raw(
|
|
476
|
+
quoteIdentifier(adapterOptions.tableName ?? "idempotency_records", "`"),
|
|
477
|
+
);
|
|
478
|
+
const executor: IdempotencySqlExecutor = createMysqlExecutor(db, table);
|
|
479
|
+
|
|
480
|
+
return createIdempotencyPortCore(
|
|
481
|
+
executor,
|
|
482
|
+
{ now: adapterOptions.now },
|
|
483
|
+
DrizzleMysqlIdempotencyMutationError,
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Configuration schema for the Drizzle MySQL provider.
|
|
489
|
+
* Validates environment variables with MYSQL_ prefix.
|
|
490
|
+
*/
|
|
491
|
+
export const MysqlConfigSchema = z.object({
|
|
492
|
+
/**
|
|
493
|
+
* MySQL connection URL.
|
|
494
|
+
* Example: "mysql://user:password@localhost:3306/app"
|
|
495
|
+
*/
|
|
496
|
+
DB_URL: z
|
|
497
|
+
.string()
|
|
498
|
+
.min(1)
|
|
499
|
+
.refine((val) => val.startsWith("mysql://"), {
|
|
500
|
+
message:
|
|
501
|
+
'DB_URL must start with "mysql://" (e.g., "mysql://user:password@localhost:3306/app")',
|
|
502
|
+
}),
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* MySQL provider config loaded from `MYSQL_*` env vars.
|
|
507
|
+
*/
|
|
508
|
+
export type MysqlConfig = z.infer<typeof MysqlConfigSchema>;
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Options for creating a Drizzle MySQL provider.
|
|
512
|
+
*
|
|
513
|
+
* @template TSchema - The Drizzle schema type
|
|
514
|
+
*/
|
|
515
|
+
export interface DrizzleMysqlProviderOptions<
|
|
516
|
+
TSchema extends Record<string, unknown>,
|
|
517
|
+
PortName extends string = "db",
|
|
518
|
+
> {
|
|
519
|
+
/**
|
|
520
|
+
* The Drizzle schema object (e.g. `import * as schema from '@/db/schema'`).
|
|
521
|
+
* This schema is used to create a typed Drizzle database instance.
|
|
522
|
+
*/
|
|
523
|
+
schema: TSchema;
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Optional port name. Defaults to "db".
|
|
527
|
+
* Renames the contributed port. The provider always reads its connection
|
|
528
|
+
* from `MYSQL_DB_URL`; for a second database, wire an app-owned provider
|
|
529
|
+
* with its own env prefix instead.
|
|
530
|
+
*/
|
|
531
|
+
portName?: PortName;
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Optional mysql2 pool options merged into the pool created from
|
|
535
|
+
* `MYSQL_DB_URL` (e.g. `connectionLimit`, `timezone`, TLS settings).
|
|
536
|
+
*/
|
|
537
|
+
pool?: Omit<PoolOptions, "uri">;
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Drizzle relational-query mode. Use "planetscale" when connecting to
|
|
541
|
+
* PlanetScale; defaults to "default".
|
|
542
|
+
*/
|
|
543
|
+
mode?: "default" | "planetscale";
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Create a Drizzle MySQL provider factory.
|
|
548
|
+
*
|
|
549
|
+
* This factory creates a service provider that:
|
|
550
|
+
* - Uses a mysql2/promise connection pool
|
|
551
|
+
* - Uses Drizzle ORM via drizzle-orm/mysql2
|
|
552
|
+
* - Exposes a typed DbPort<TSchema> on ports
|
|
553
|
+
* - Uses MYSQL_-prefixed env vars for connection config
|
|
554
|
+
*
|
|
555
|
+
* @template TSchema - The Drizzle schema type (inferred from the schema parameter)
|
|
556
|
+
* @param options - Configuration options including the schema
|
|
557
|
+
* @returns A service provider that can be used with createNextServer
|
|
558
|
+
*
|
|
559
|
+
* @example
|
|
560
|
+
* ```ts
|
|
561
|
+
* import * as schema from "@/db/schema";
|
|
562
|
+
* import { createDrizzleMysqlProvider } from "@beignet/provider-db-drizzle/mysql";
|
|
563
|
+
*
|
|
564
|
+
* export const drizzleMysqlProvider = createDrizzleMysqlProvider({ schema });
|
|
565
|
+
* ```
|
|
566
|
+
*/
|
|
567
|
+
export function createDrizzleMysqlProvider<
|
|
568
|
+
TSchema extends Record<string, unknown>,
|
|
569
|
+
PortName extends string = "db",
|
|
570
|
+
>(options: DrizzleMysqlProviderOptions<TSchema, PortName>) {
|
|
571
|
+
const { schema, portName = "db", mode = "default" } = options;
|
|
572
|
+
|
|
573
|
+
return createProvider({
|
|
574
|
+
name: "drizzle-mysql",
|
|
575
|
+
|
|
576
|
+
config: {
|
|
577
|
+
schema: MysqlConfigSchema,
|
|
578
|
+
envPrefix: "MYSQL_",
|
|
579
|
+
},
|
|
580
|
+
|
|
581
|
+
async setup({ ports, config }) {
|
|
582
|
+
if (!config) {
|
|
583
|
+
throw new Error(
|
|
584
|
+
"[drizzleMysqlProvider] Missing config. Set MYSQL_DB_URL.",
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// 1. Create mysql2 connection pool
|
|
589
|
+
const pool: Pool = mysql.createPool({
|
|
590
|
+
uri: config.DB_URL,
|
|
591
|
+
...options.pool,
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
const instrumentation = createProviderInstrumentation(ports, {
|
|
595
|
+
providerName: "drizzle-mysql",
|
|
596
|
+
watcher: "db",
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
const logger = createDbQueryLogger(instrumentation, portName);
|
|
600
|
+
|
|
601
|
+
// 2. Create typed Drizzle instance (mode is required when a schema is
|
|
602
|
+
// provided to drizzle-orm/mysql2)
|
|
603
|
+
const db: MySql2Database<TSchema> = logger
|
|
604
|
+
? drizzle(pool, { schema, logger, mode })
|
|
605
|
+
: drizzle(pool, { schema, mode });
|
|
606
|
+
|
|
607
|
+
// 3. Build a typed DbPort
|
|
608
|
+
const dbPort: DbPort<TSchema> = { db, pool };
|
|
609
|
+
|
|
610
|
+
return {
|
|
611
|
+
ports: { [portName]: dbPort } as Record<PortName, DbPort<TSchema>>,
|
|
612
|
+
async stop() {
|
|
613
|
+
// Gracefully close the database connection pool
|
|
614
|
+
try {
|
|
615
|
+
await dbPort.pool.end();
|
|
616
|
+
} catch (error) {
|
|
617
|
+
// Log error but don't throw - we're shutting down anyway
|
|
618
|
+
console.error(
|
|
619
|
+
"[drizzleMysqlProvider] Error closing database connection:",
|
|
620
|
+
error,
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
},
|
|
624
|
+
};
|
|
625
|
+
},
|
|
626
|
+
});
|
|
627
|
+
}
|