@pablo2410/core-server 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/db.d.ts ADDED
@@ -0,0 +1,97 @@
1
+ import mysql, { Pool } from 'mysql2/promise';
2
+ import * as drizzle_orm_mysql2 from 'drizzle-orm/mysql2';
3
+
4
+ /**
5
+ * What keeps a database connection that TiDB has closed, or that has stalled,
6
+ * from failing or hanging this app (oplytics-subdomains#1285). On 21 Oct the
7
+ * test copy's Action Manager and SQDCP logged "Failed query" for creates and
8
+ * lists at random: every one was PROTOCOL_CONNECTION_LOST or ETIMEDOUT, a
9
+ * pooled connection the database had already closed. The pool had no
10
+ * keep-alive, no idle clean-up and no timeouts. The same fix has run in the
11
+ * Portal since portal#499. This is the one shared copy (core-server#137),
12
+ * without the Portal's request-level 503 flag.
13
+ *
14
+ * resilientPool() wraps a mysql2 promise pool so that:
15
+ * - waiting for a free connection ends after ACQUIRE_TIMEOUT_MS;
16
+ * - a query that gets no answer ends after READ_TIMEOUT_MS (reads) or
17
+ * WRITE_TIMEOUT_MS (everything else), and its connection is DESTROYED, not
18
+ * given back: mysql2's own `timeout` option leaves a dead connection in the
19
+ * pool, where the next query would wait on it again;
20
+ * - a read that fails on a dropped connection is tried once more on a fresh
21
+ * one. Writes are never retried: the first one may have been applied.
22
+ *
23
+ * Every limit can be set with an environment variable (DB_ACQUIRE_TIMEOUT_MS,
24
+ * DB_QUERY_TIMEOUT_MS, DB_WRITE_TIMEOUT_MS). What the user is told when the
25
+ * database can't be reached is DB_UNAVAILABLE_MESSAGE (see withDbUnavailableMessage).
26
+ */
27
+
28
+ interface Limits {
29
+ /** Longest wait for a free pooled connection. */
30
+ acquireTimeoutMs: number;
31
+ /** Longest wait for the answer to a read (SELECT, SHOW, ...). */
32
+ readTimeoutMs: number;
33
+ /** Longest wait for the answer to anything else. */
34
+ writeTimeoutMs: number;
35
+ }
36
+ declare const defaultLimits: () => Limits;
37
+ /**
38
+ * Pool settings (merged into mysql2.createPool in dbClient.ts).
39
+ * - connectTimeout: a new connection that does not come up fails in 10 s.
40
+ * - enableKeepAlive: the OS notices a peer that has vanished.
41
+ * - maxIdle < connectionLimit is what switches mysql2's idle clean-up on; it
42
+ * then closes any connection idle for over idleTimeout, so connections are
43
+ * replaced before TiDB closes them from its side.
44
+ * - queueLimit: past this many requests waiting for a connection, new ones
45
+ * fail at once instead of piling up behind stuck ones.
46
+ */
47
+ declare const POOL_TUNING: {
48
+ readonly connectionLimit: 10;
49
+ readonly maxIdle: 5;
50
+ readonly idleTimeout: 60000;
51
+ readonly connectTimeout: 10000;
52
+ readonly enableKeepAlive: true;
53
+ readonly keepAliveInitialDelay: 10000;
54
+ readonly queueLimit: 200;
55
+ };
56
+ /** What a user sees when a request failed because the database was out of reach. */
57
+ declare const DB_UNAVAILABLE_MESSAGE = "The database could not be reached just now. Please try again in a moment. If you were saving a change, check whether it was saved before you try again.";
58
+ declare class DbUnavailableError extends Error {
59
+ code: "DB_ACQUIRE_TIMEOUT" | "DB_QUEUE_FULL" | "DB_QUERY_TIMEOUT";
60
+ constructor(code: "DB_ACQUIRE_TIMEOUT" | "DB_QUEUE_FULL" | "DB_QUERY_TIMEOUT", message: string);
61
+ }
62
+ declare const isDbUnavailable: (err: unknown) => boolean;
63
+ /**
64
+ * For tRPC's errorFormatter: a call that failed because the database was out
65
+ * of reach tells the user so in plain words, never with the SQL that failed.
66
+ * Every other error keeps its message.
67
+ */
68
+ declare function withDbUnavailableMessage<S extends {
69
+ message: string;
70
+ }>(shape: S, error: unknown): S;
71
+ declare function resilientPool<T extends Pool>(pool: T, overrides?: Partial<Limits>): T;
72
+
73
+ /**
74
+ * Create a Drizzle client from a DATABASE_URL, enabling TLS when the host
75
+ * requires it. TiDB Cloud Serverless enforces TLS + SNI, so a bare `mysql://`
76
+ * URL is rejected ("Connections using insecure transport are prohibited") and
77
+ * the failure surfaces at query time as a generic "Failed query" error. For
78
+ * those hosts we connect with TLS; hosts that don't need TLS (e.g. plain local
79
+ * MySQL) use the connection string as it is.
80
+ *
81
+ * Set DATABASE_TLS=true to force TLS for a non-tidbcloud.com host.
82
+ */
83
+ declare function connectionOptions(url: string): string | mysql.PoolOptions;
84
+ /**
85
+ * Always a pool we configure ourselves (never drizzle's bare `drizzle(url)`
86
+ * one, which has no keep-alive or timeouts): connect, keep-alive, idle and
87
+ * queue limits from POOL_TUNING, and a wrapper that retries a read once on a
88
+ * fresh connection when TiDB has closed the old one, and ends a wait for a dead
89
+ * connection (resilience.ts, oplytics-subdomains#1285). `overrides`
90
+ * and `limits` are for tests.
91
+ */
92
+ declare function createDbClient(url: string, overrides?: Partial<mysql.PoolOptions>, limits?: Partial<Limits>): drizzle_orm_mysql2.MySql2Database<Record<string, never>> & {
93
+ $client: mysql.Pool;
94
+ };
95
+
96
+ export { DB_UNAVAILABLE_MESSAGE, DbUnavailableError, POOL_TUNING, connectionOptions, createDbClient, defaultLimits, isDbUnavailable, resilientPool, withDbUnavailableMessage };
97
+ export type { Limits };
package/dist/db.js ADDED
@@ -0,0 +1,208 @@
1
+ // src/db/resilience.ts
2
+ var envMs = (name, fallback) => {
3
+ const n = Number(process.env[name]);
4
+ return Number.isFinite(n) && n > 0 ? n : fallback;
5
+ };
6
+ var defaultLimits = () => ({
7
+ acquireTimeoutMs: envMs("DB_ACQUIRE_TIMEOUT_MS", 1e4),
8
+ readTimeoutMs: envMs("DB_QUERY_TIMEOUT_MS", 15e3),
9
+ writeTimeoutMs: envMs("DB_WRITE_TIMEOUT_MS", 6e4)
10
+ });
11
+ var POOL_TUNING = {
12
+ connectionLimit: 10,
13
+ maxIdle: 5,
14
+ idleTimeout: 6e4,
15
+ connectTimeout: 1e4,
16
+ enableKeepAlive: true,
17
+ keepAliveInitialDelay: 1e4,
18
+ queueLimit: 200
19
+ };
20
+ var DB_UNAVAILABLE_MESSAGE = "The database could not be reached just now. Please try again in a moment. If you were saving a change, check whether it was saved before you try again.";
21
+ var RETRY_CODES = /* @__PURE__ */ new Set([
22
+ "PROTOCOL_CONNECTION_LOST",
23
+ "PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR",
24
+ "PROTOCOL_ENQUEUE_AFTER_QUIT",
25
+ "ECONNRESET",
26
+ "EPIPE",
27
+ "ETIMEDOUT",
28
+ "ECONNREFUSED",
29
+ "EAI_AGAIN",
30
+ "DB_QUERY_TIMEOUT"
31
+ ]);
32
+ var UNAVAILABLE_CODES = /* @__PURE__ */ new Set([
33
+ ...Array.from(RETRY_CODES),
34
+ "ENOTFOUND",
35
+ "DB_ACQUIRE_TIMEOUT",
36
+ "DB_QUEUE_FULL"
37
+ ]);
38
+ var DbUnavailableError = class extends Error {
39
+ constructor(code, message) {
40
+ super(message);
41
+ this.code = code;
42
+ this.name = "DbUnavailableError";
43
+ }
44
+ code;
45
+ };
46
+ function codes(err) {
47
+ const found = [];
48
+ let cur = err;
49
+ for (let depth = 0; cur && depth < 5; depth++) {
50
+ if (typeof cur.code === "string") found.push(cur.code);
51
+ cur = cur.cause;
52
+ }
53
+ return found;
54
+ }
55
+ var isDbUnavailable = (err) => codes(err).some((c) => UNAVAILABLE_CODES.has(c));
56
+ function withDbUnavailableMessage(shape, error) {
57
+ return isDbUnavailable(error) ? { ...shape, message: DB_UNAVAILABLE_MESSAGE } : shape;
58
+ }
59
+ var isRetryable = (err) => codes(err).some((c) => RETRY_CODES.has(c));
60
+ var READ_ONLY = /^\s*(select|show|describe|desc|explain)\b/i;
61
+ var sqlOf = (first) => typeof first === "string" ? first : String(first?.sql ?? "");
62
+ function dropIdleConnections(pool) {
63
+ try {
64
+ const free = pool.pool?._freeConnections;
65
+ if (!free || typeof free.get !== "function") return;
66
+ for (let guard = 0; free.length > 0 && guard < 100; guard++) {
67
+ free.get(0).destroy();
68
+ }
69
+ } catch {
70
+ }
71
+ }
72
+ function resilientPool(pool, overrides = {}) {
73
+ const limits = { ...defaultLimits(), ...overrides };
74
+ const acquire = async () => {
75
+ let timer;
76
+ let gaveUp = false;
77
+ const pending = pool.getConnection();
78
+ pending.then(
79
+ (conn) => {
80
+ if (gaveUp) conn.release();
81
+ },
82
+ () => void 0
83
+ );
84
+ const deadline = new Promise((_, reject) => {
85
+ timer = setTimeout(() => {
86
+ gaveUp = true;
87
+ reject(
88
+ new DbUnavailableError(
89
+ "DB_ACQUIRE_TIMEOUT",
90
+ `No database connection came free within ${limits.acquireTimeoutMs} ms`
91
+ )
92
+ );
93
+ }, limits.acquireTimeoutMs);
94
+ });
95
+ try {
96
+ return await Promise.race([pending, deadline]);
97
+ } catch (err) {
98
+ if (err instanceof Error && /queue limit reached/i.test(err.message)) {
99
+ throw new DbUnavailableError(
100
+ "DB_QUEUE_FULL",
101
+ "Too many requests are waiting for a database connection"
102
+ );
103
+ }
104
+ throw err;
105
+ } finally {
106
+ clearTimeout(timer);
107
+ }
108
+ };
109
+ const attempt = async (method, first, values, ms) => {
110
+ const conn = await acquire();
111
+ let timer;
112
+ let abandoned = false;
113
+ const deadline = new Promise((_, reject) => {
114
+ timer = setTimeout(() => {
115
+ abandoned = true;
116
+ conn.destroy();
117
+ dropIdleConnections(pool);
118
+ reject(
119
+ new DbUnavailableError(
120
+ "DB_QUERY_TIMEOUT",
121
+ `The database did not answer within ${ms} ms`
122
+ )
123
+ );
124
+ }, ms);
125
+ });
126
+ try {
127
+ const work = conn[method](first, values);
128
+ work.catch(() => void 0);
129
+ const out = await Promise.race([work, deadline]);
130
+ conn.release();
131
+ return out;
132
+ } catch (err) {
133
+ if (!abandoned) conn.release();
134
+ throw err;
135
+ } finally {
136
+ clearTimeout(timer);
137
+ }
138
+ };
139
+ const run = async (method, first, values) => {
140
+ const read = READ_ONLY.test(sqlOf(first));
141
+ const ms = read ? limits.readTimeoutMs : limits.writeTimeoutMs;
142
+ try {
143
+ try {
144
+ return await attempt(method, first, values, ms);
145
+ } catch (err) {
146
+ if (!isRetryable(err)) throw err;
147
+ dropIdleConnections(pool);
148
+ if (!read) throw err;
149
+ console.warn(
150
+ `[Database] read failed (${codes(err)[0]}); trying once on a fresh connection`
151
+ );
152
+ return await attempt(method, first, values, ms);
153
+ }
154
+ } catch (err) {
155
+ if (isDbUnavailable(err)) {
156
+ console.error(
157
+ `[Database] unavailable (${codes(err)[0]}): ${err.message}`
158
+ );
159
+ }
160
+ throw err;
161
+ }
162
+ };
163
+ return new Proxy(pool, {
164
+ get(target, prop) {
165
+ if (prop === "query" || prop === "execute") {
166
+ return (first, values) => run(prop, first, values);
167
+ }
168
+ if (prop === "getConnection") return acquire;
169
+ const value = Reflect.get(target, prop, target);
170
+ return typeof value === "function" ? value.bind(target) : value;
171
+ }
172
+ });
173
+ }
174
+
175
+ // src/db/client.ts
176
+ import { drizzle } from "drizzle-orm/mysql2";
177
+ import mysql from "mysql2/promise";
178
+ function connectionOptions(url) {
179
+ const requiresTls = /tidbcloud\.com/i.test(url) || process.env.DATABASE_TLS === "true";
180
+ if (!requiresTls) return url;
181
+ const parsed = new URL(url);
182
+ return {
183
+ host: parsed.hostname,
184
+ port: parsed.port ? Number(parsed.port) : 4e3,
185
+ user: decodeURIComponent(parsed.username),
186
+ password: decodeURIComponent(parsed.password),
187
+ database: parsed.pathname.replace(/^\//, ""),
188
+ ssl: { minVersion: "TLSv1.2" }
189
+ };
190
+ }
191
+ function createDbClient(url, overrides = {}, limits = {}) {
192
+ const options = connectionOptions(url);
193
+ const base = typeof options === "string" ? { uri: options } : options;
194
+ const pool = mysql.createPool({ ...base, ...POOL_TUNING, ...overrides });
195
+ return drizzle(resilientPool(pool, limits));
196
+ }
197
+ export {
198
+ DB_UNAVAILABLE_MESSAGE,
199
+ DbUnavailableError,
200
+ POOL_TUNING,
201
+ connectionOptions,
202
+ createDbClient,
203
+ defaultLimits,
204
+ isDbUnavailable,
205
+ resilientPool,
206
+ withDbUnavailableMessage
207
+ };
208
+ //# sourceMappingURL=db.js.map
package/dist/db.js.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/db/resilience.ts","../src/db/client.ts"],"sourcesContent":["/**\n * What keeps a database connection that TiDB has closed, or that has stalled,\n * from failing or hanging this app (oplytics-subdomains#1285). On 21 Oct the\n * test copy's Action Manager and SQDCP logged \"Failed query\" for creates and\n * lists at random: every one was PROTOCOL_CONNECTION_LOST or ETIMEDOUT, a\n * pooled connection the database had already closed. The pool had no\n * keep-alive, no idle clean-up and no timeouts. The same fix has run in the\n * Portal since portal#499. This is the one shared copy (core-server#137),\n * without the Portal's request-level 503 flag.\n *\n * resilientPool() wraps a mysql2 promise pool so that:\n * - waiting for a free connection ends after ACQUIRE_TIMEOUT_MS;\n * - a query that gets no answer ends after READ_TIMEOUT_MS (reads) or\n * WRITE_TIMEOUT_MS (everything else), and its connection is DESTROYED, not\n * given back: mysql2's own `timeout` option leaves a dead connection in the\n * pool, where the next query would wait on it again;\n * - a read that fails on a dropped connection is tried once more on a fresh\n * one. Writes are never retried: the first one may have been applied.\n *\n * Every limit can be set with an environment variable (DB_ACQUIRE_TIMEOUT_MS,\n * DB_QUERY_TIMEOUT_MS, DB_WRITE_TIMEOUT_MS). What the user is told when the\n * database can't be reached is DB_UNAVAILABLE_MESSAGE (see withDbUnavailableMessage).\n */\nimport type { Pool, PoolConnection } from \"mysql2/promise\";\n\nconst envMs = (name: string, fallback: number) => {\n const n = Number(process.env[name]);\n return Number.isFinite(n) && n > 0 ? n : fallback;\n};\n\nexport interface Limits {\n /** Longest wait for a free pooled connection. */\n acquireTimeoutMs: number;\n /** Longest wait for the answer to a read (SELECT, SHOW, ...). */\n readTimeoutMs: number;\n /** Longest wait for the answer to anything else. */\n writeTimeoutMs: number;\n}\n\nexport const defaultLimits = (): Limits => ({\n acquireTimeoutMs: envMs(\"DB_ACQUIRE_TIMEOUT_MS\", 10_000),\n readTimeoutMs: envMs(\"DB_QUERY_TIMEOUT_MS\", 15_000),\n writeTimeoutMs: envMs(\"DB_WRITE_TIMEOUT_MS\", 60_000),\n});\n\n/**\n * Pool settings (merged into mysql2.createPool in dbClient.ts).\n * - connectTimeout: a new connection that does not come up fails in 10 s.\n * - enableKeepAlive: the OS notices a peer that has vanished.\n * - maxIdle < connectionLimit is what switches mysql2's idle clean-up on; it\n * then closes any connection idle for over idleTimeout, so connections are\n * replaced before TiDB closes them from its side.\n * - queueLimit: past this many requests waiting for a connection, new ones\n * fail at once instead of piling up behind stuck ones.\n */\nexport const POOL_TUNING = {\n connectionLimit: 10,\n maxIdle: 5,\n idleTimeout: 60_000,\n connectTimeout: 10_000,\n enableKeepAlive: true,\n keepAliveInitialDelay: 10_000,\n queueLimit: 200,\n} as const;\n\n/** What a user sees when a request failed because the database was out of reach. */\nexport const DB_UNAVAILABLE_MESSAGE =\n \"The database could not be reached just now. Please try again in a moment. If you were saving a change, check whether it was saved before you try again.\";\n\n// ─── telling a dropped database from a bad query ────────────────────────────\n\n/** Errors that mean \"that connection is gone\"; a read is worth one retry. */\nconst RETRY_CODES = new Set([\n \"PROTOCOL_CONNECTION_LOST\",\n \"PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR\",\n \"PROTOCOL_ENQUEUE_AFTER_QUIT\",\n \"ECONNRESET\",\n \"EPIPE\",\n \"ETIMEDOUT\",\n \"ECONNREFUSED\",\n \"EAI_AGAIN\",\n \"DB_QUERY_TIMEOUT\",\n]);\n/** Everything that means \"the database cannot be reached right now\". */\nconst UNAVAILABLE_CODES = new Set([\n ...Array.from(RETRY_CODES),\n \"ENOTFOUND\",\n \"DB_ACQUIRE_TIMEOUT\",\n \"DB_QUEUE_FULL\",\n]);\n\nexport class DbUnavailableError extends Error {\n constructor(\n public code: \"DB_ACQUIRE_TIMEOUT\" | \"DB_QUEUE_FULL\" | \"DB_QUERY_TIMEOUT\",\n message: string\n ) {\n super(message);\n this.name = \"DbUnavailableError\";\n }\n}\n\n/** drizzle wraps a driver error and keeps the original as `cause`. */\nfunction codes(err: unknown): string[] {\n const found: string[] = [];\n let cur = err as { code?: unknown; cause?: unknown } | undefined;\n for (let depth = 0; cur && depth < 5; depth++) {\n if (typeof cur.code === \"string\") found.push(cur.code);\n cur = cur.cause as typeof cur | undefined;\n }\n return found;\n}\n\nexport const isDbUnavailable = (err: unknown) =>\n codes(err).some(c => UNAVAILABLE_CODES.has(c));\n\n/**\n * For tRPC's errorFormatter: a call that failed because the database was out\n * of reach tells the user so in plain words, never with the SQL that failed.\n * Every other error keeps its message.\n */\nexport function withDbUnavailableMessage<S extends { message: string }>(\n shape: S,\n error: unknown\n): S {\n return isDbUnavailable(error)\n ? { ...shape, message: DB_UNAVAILABLE_MESSAGE }\n : shape;\n}\nconst isRetryable = (err: unknown) => codes(err).some(c => RETRY_CODES.has(c));\n\nconst READ_ONLY = /^\\s*(select|show|describe|desc|explain)\\b/i;\nconst sqlOf = (first: unknown): string =>\n typeof first === \"string\"\n ? first\n : String((first as { sql?: unknown } | null)?.sql ?? \"\");\n\n// ─── the pool wrapper ───────────────────────────────────────────────────────\n\n/**\n * Close every connection that is sitting idle in the pool. When one connection\n * has stopped answering, the idle ones behind it have usually stopped too (the\n * network stalled, not one socket), and each would cost a request a timeout\n * before it was found out. mysql2 keeps them in a private queue and closes\n * idle ones the same way itself; if that shape ever changes this does nothing.\n */\nfunction dropIdleConnections(pool: Pool) {\n try {\n const free = (\n pool as unknown as {\n pool?: {\n _freeConnections?: { length: number; get(i: number): PoolConnection };\n };\n }\n ).pool?._freeConnections;\n if (!free || typeof free.get !== \"function\") return;\n for (let guard = 0; free.length > 0 && guard < 100; guard++) {\n free.get(0).destroy();\n }\n } catch {\n /* best effort */\n }\n}\n\nexport function resilientPool<T extends Pool>(\n pool: T,\n overrides: Partial<Limits> = {}\n): T {\n const limits = { ...defaultLimits(), ...overrides };\n\n /** A free connection, or an error after acquireTimeoutMs. */\n const acquire = async (): Promise<PoolConnection> => {\n let timer: NodeJS.Timeout | undefined;\n let gaveUp = false;\n const pending = pool.getConnection();\n // A connection that turns up after we gave up must go back, not leak.\n pending.then(\n conn => {\n if (gaveUp) conn.release();\n },\n () => undefined\n );\n const deadline = new Promise<never>((_, reject) => {\n timer = setTimeout(() => {\n gaveUp = true;\n reject(\n new DbUnavailableError(\n \"DB_ACQUIRE_TIMEOUT\",\n `No database connection came free within ${limits.acquireTimeoutMs} ms`\n )\n );\n }, limits.acquireTimeoutMs);\n });\n try {\n return await Promise.race([pending, deadline]);\n } catch (err) {\n if (err instanceof Error && /queue limit reached/i.test(err.message)) {\n throw new DbUnavailableError(\n \"DB_QUEUE_FULL\",\n \"Too many requests are waiting for a database connection\"\n );\n }\n throw err;\n } finally {\n clearTimeout(timer);\n }\n };\n\n /** One try on one connection. A connection that does not answer is destroyed. */\n const attempt = async (\n method: \"query\" | \"execute\",\n first: unknown,\n values: unknown,\n ms: number\n ) => {\n const conn = await acquire();\n let timer: NodeJS.Timeout | undefined;\n let abandoned = false;\n const deadline = new Promise<never>((_, reject) => {\n timer = setTimeout(() => {\n abandoned = true;\n conn.destroy();\n dropIdleConnections(pool);\n reject(\n new DbUnavailableError(\n \"DB_QUERY_TIMEOUT\",\n `The database did not answer within ${ms} ms`\n )\n );\n }, ms);\n });\n try {\n const work = (\n conn[method] as (a: unknown, b?: unknown) => Promise<unknown>\n )(first, values);\n // The destroyed connection fails this later; nobody is waiting for it.\n work.catch(() => undefined);\n const out = await Promise.race([work, deadline]);\n conn.release();\n return out;\n } catch (err) {\n if (!abandoned) conn.release();\n throw err;\n } finally {\n clearTimeout(timer);\n }\n };\n\n const run = async (\n method: \"query\" | \"execute\",\n first: unknown,\n values?: unknown\n ) => {\n const read = READ_ONLY.test(sqlOf(first));\n const ms = read ? limits.readTimeoutMs : limits.writeTimeoutMs;\n try {\n try {\n return await attempt(method, first, values, ms);\n } catch (err) {\n if (!isRetryable(err)) throw err;\n // A blip that drops one connection drops the ones idle beside it too,\n // and the pool hands out the next idle one unchecked: close them, so\n // the retry (or, for a write, the user's own retry) gets a fresh one.\n dropIdleConnections(pool);\n if (!read) throw err;\n console.warn(\n `[Database] read failed (${codes(err)[0]}); trying once on a fresh connection`\n );\n return await attempt(method, first, values, ms);\n }\n } catch (err) {\n if (isDbUnavailable(err)) {\n console.error(\n `[Database] unavailable (${codes(err)[0]}): ${(err as Error).message}`\n );\n }\n throw err;\n }\n };\n\n return new Proxy(pool, {\n get(target, prop) {\n if (prop === \"query\" || prop === \"execute\") {\n return (first: unknown, values?: unknown) => run(prop, first, values);\n }\n if (prop === \"getConnection\") return acquire;\n const value = Reflect.get(target, prop, target);\n return typeof value === \"function\" ? value.bind(target) : value;\n },\n });\n}\n","/* Shared Drizzle client factory. */\nimport { drizzle } from \"drizzle-orm/mysql2\";\nimport mysql from \"mysql2/promise\";\nimport { POOL_TUNING, resilientPool, type Limits } from \"./resilience\";\n\n/**\n * Create a Drizzle client from a DATABASE_URL, enabling TLS when the host\n * requires it. TiDB Cloud Serverless enforces TLS + SNI, so a bare `mysql://`\n * URL is rejected (\"Connections using insecure transport are prohibited\") and\n * the failure surfaces at query time as a generic \"Failed query\" error. For\n * those hosts we connect with TLS; hosts that don't need TLS (e.g. plain local\n * MySQL) use the connection string as it is.\n *\n * Set DATABASE_TLS=true to force TLS for a non-tidbcloud.com host.\n */\nexport function connectionOptions(url: string): string | mysql.PoolOptions {\n const requiresTls =\n /tidbcloud\\.com/i.test(url) || process.env.DATABASE_TLS === \"true\";\n if (!requiresTls) return url;\n const parsed = new URL(url);\n return {\n host: parsed.hostname,\n port: parsed.port ? Number(parsed.port) : 4000,\n user: decodeURIComponent(parsed.username),\n password: decodeURIComponent(parsed.password),\n database: parsed.pathname.replace(/^\\//, \"\"),\n ssl: { minVersion: \"TLSv1.2\" },\n };\n}\n\n/**\n * Always a pool we configure ourselves (never drizzle's bare `drizzle(url)`\n * one, which has no keep-alive or timeouts): connect, keep-alive, idle and\n * queue limits from POOL_TUNING, and a wrapper that retries a read once on a\n * fresh connection when TiDB has closed the old one, and ends a wait for a dead\n * connection (resilience.ts, oplytics-subdomains#1285). `overrides`\n * and `limits` are for tests.\n */\nexport function createDbClient(\n url: string,\n overrides: Partial<mysql.PoolOptions> = {},\n limits: Partial<Limits> = {}\n) {\n const options = connectionOptions(url);\n const base = typeof options === \"string\" ? { uri: options } : options;\n const pool = mysql.createPool({ ...base, ...POOL_TUNING, ...overrides });\n return drizzle(resilientPool(pool, limits));\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pablo2410/core-server",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Shared server utilities for Oplytics.digital subdomains",
5
5
  "type": "module",
6
6
  "exports": {
@@ -19,6 +19,10 @@
19
19
  "./isolation": {
20
20
  "types": "./dist/isolation.d.ts",
21
21
  "import": "./dist/isolation.js"
22
+ },
23
+ "./db": {
24
+ "types": "./dist/db.d.ts",
25
+ "import": "./dist/db.js"
22
26
  }
23
27
  },
24
28
  "files": [
@@ -46,10 +50,12 @@
46
50
  "@types/node": "^26.6.6",
47
51
  "axios": "^1.21.0",
48
52
  "cookie": "^2.0.1",
49
- "eslint": "^10.13.0",
53
+ "drizzle-orm": "^0.45.4",
54
+ "eslint": "^10.14.0",
50
55
  "express": "^5.3.0",
51
56
  "globals": "^17.13.0",
52
57
  "jose": "^6.2.14",
58
+ "mysql2": "^3.24.6",
53
59
  "prettier": "^3.9.10",
54
60
  "tsup": "^9.0.0",
55
61
  "typescript": "^5.6.0",
@@ -62,7 +68,9 @@
62
68
  "axios": ">=1.0.0",
63
69
  "cookie": ">=0.6.0",
64
70
  "express": ">=4.0.0",
65
- "jose": ">=5.0.0"
71
+ "jose": ">=5.0.0",
72
+ "drizzle-orm": ">=0.30.0",
73
+ "mysql2": ">=3.0.0"
66
74
  },
67
75
  "peerDependenciesMeta": {
68
76
  "@pablo2410/shared-ui": {
@@ -79,6 +87,12 @@
79
87
  },
80
88
  "axios": {
81
89
  "optional": true
90
+ },
91
+ "drizzle-orm": {
92
+ "optional": true
93
+ },
94
+ "mysql2": {
95
+ "optional": true
82
96
  }
83
97
  },
84
98
  "pnpm": {