@modern-admin/adapter-drizzle 0.1.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.
@@ -0,0 +1,527 @@
1
+ import { and, asc, count as countFn, eq, gte, ilike, inArray, isNotNull, like, lte, ne, or, sql } from 'drizzle-orm'
2
+ import {
3
+ BaseRecord,
4
+ BaseResource,
5
+ type Filter,
6
+ type FindOptions,
7
+ type ParamsType,
8
+ type TimeSeriesQuery,
9
+ type TimeSeriesResult,
10
+ type TimeSeriesSeries,
11
+ type TimeSeriesStep,
12
+ } from '@modern-admin/core'
13
+ import { DrizzleProperty, extractForeignKeys, findPrimaryColumn } from './property.js'
14
+ import { filterToWhere, findOptionsToDrizzle } from './converters.js'
15
+ import type {
16
+ DrizzleClientLike,
17
+ DrizzleColumn,
18
+ DrizzleDialect,
19
+ DrizzleResourceConfig,
20
+ DrizzleTable,
21
+ } from './types.js'
22
+
23
+ interface DrizzleResourceInit extends DrizzleResourceConfig {
24
+ client: DrizzleClientLike
25
+ table: DrizzleTable
26
+ tableKey: string
27
+ dialect: DrizzleDialect
28
+ }
29
+
30
+ const isInit = (raw: unknown): raw is DrizzleResourceInit =>
31
+ typeof raw === 'object' &&
32
+ raw !== null &&
33
+ 'client' in raw &&
34
+ 'table' in raw &&
35
+ typeof (raw as { table?: object }).table === 'object'
36
+
37
+ export class DrizzleResource extends BaseResource {
38
+ public readonly client: DrizzleClientLike
39
+ public readonly table: DrizzleTable
40
+ public readonly tableKey: string
41
+ public readonly dialect: DrizzleDialect
42
+ private readonly _id: string
43
+ private readonly _properties: DrizzleProperty[]
44
+ private readonly idColumn: DrizzleColumn
45
+
46
+ constructor(raw: unknown) {
47
+ super()
48
+ if (!isInit(raw)) {
49
+ throw new Error('DrizzleResource requires { client, table, tableKey } config')
50
+ }
51
+ this.client = raw.client
52
+ this.table = raw.table
53
+ this.tableKey = raw.tableKey
54
+ this.dialect = raw.dialect ?? 'pg'
55
+ this._id = raw.id ?? raw.table._?.name ?? raw.tableKey
56
+
57
+ const idColumn = findPrimaryColumn(raw.table)
58
+ if (!idColumn) {
59
+ throw new Error(`Drizzle table "${this._id}" has no primary-key column`)
60
+ }
61
+ this.idColumn = idColumn
62
+
63
+ const fks = extractForeignKeys(raw.table)
64
+ let position = 1
65
+ this._properties = []
66
+ for (const key of Object.keys(raw.table)) {
67
+ if (key === '_') continue
68
+ const col = raw.table[key] as DrizzleColumn | undefined
69
+ if (!col || typeof col.name !== 'string') continue
70
+ this._properties.push(new DrizzleProperty(col, fks[col.name] ?? null, position++))
71
+ }
72
+ }
73
+
74
+ static override isAdapterFor(raw: unknown): boolean {
75
+ return isInit(raw)
76
+ }
77
+
78
+ override id(): string {
79
+ return this._id
80
+ }
81
+
82
+ override databaseName(): string {
83
+ return this.table._?.name ?? this._id
84
+ }
85
+
86
+ override databaseType(): string {
87
+ return 'drizzle'
88
+ }
89
+
90
+ override properties(): DrizzleProperty[] {
91
+ return this._properties
92
+ }
93
+
94
+ override property(path: string): DrizzleProperty | null {
95
+ return this._properties.find((p) => p.path() === path) ?? null
96
+ }
97
+
98
+ private castId(id: string | number): unknown {
99
+ if (typeof id === 'number') return id
100
+ if (this.idColumn.dataType === 'number' || this.idColumn.dataType === 'bigint') {
101
+ const n = Number(id)
102
+ return Number.isFinite(n) ? n : id
103
+ }
104
+ return id
105
+ }
106
+
107
+ /** Drop unknown keys so we never write to columns that don't exist. */
108
+ private writableData(params: ParamsType): Record<string, unknown> {
109
+ const out: Record<string, unknown> = {}
110
+ for (const prop of this._properties) {
111
+ const path = prop.path()
112
+ if (path in params) out[path] = params[path]
113
+ }
114
+ return out
115
+ }
116
+
117
+ override async distinct(
118
+ field: string,
119
+ options?: { limit?: number; search?: string },
120
+ ): Promise<string[]> {
121
+ const column = this.table[field] as DrizzleColumn | undefined
122
+ if (!column) return []
123
+ const prop = this.property(field)
124
+ if (!prop || prop.type() !== 'string') return []
125
+
126
+ const limit = options?.limit ?? 100
127
+ const conds: unknown[] = [
128
+ isNotNull(column as never),
129
+ ne(column as never, '' as never),
130
+ ]
131
+ if (options?.search) {
132
+ const op = (column as DrizzleColumn).columnType?.startsWith('Pg') ? ilike : like
133
+ conds.push(op(column as never, `%${options.search}%` as never))
134
+ }
135
+ const where = and(...(conds as never[]))
136
+
137
+ // Use select + groupBy for DISTINCT — avoids requiring selectDistinct
138
+ // on the client interface while producing the same SQL result.
139
+ const rows = (await this.client
140
+ .select({ value: column })
141
+ .from(this.table)
142
+ .where(where as unknown)
143
+ .groupBy(column as never)
144
+ .orderBy(asc(column as never))
145
+ .limit(limit)) as Array<{ value: unknown }>
146
+
147
+ return rows
148
+ .map((r) => r.value)
149
+ .filter((v): v is string => typeof v === 'string')
150
+ }
151
+
152
+ override async count(filter: Filter): Promise<number> {
153
+ const where = filterToWhere(filter, this.table)
154
+ let qb = this.client.select({ value: countFn() }).from(this.table)
155
+ if (where !== undefined) qb = qb.where(where)
156
+ const rows = (await qb) as Array<{ value: number | string }>
157
+ const v = rows[0]?.value ?? 0
158
+ return typeof v === 'number' ? v : Number(v)
159
+ }
160
+
161
+ override async find(filter: Filter, options: FindOptions): Promise<BaseRecord[]> {
162
+ const where = filterToWhere(filter, this.table)
163
+ const { limit, offset, orderBy } = findOptionsToDrizzle(options, this.table)
164
+ let qb = this.client.select().from(this.table)
165
+ if (where !== undefined) qb = qb.where(where)
166
+ if (orderBy !== undefined) qb = qb.orderBy(orderBy)
167
+ if (limit !== undefined) qb = qb.limit(limit)
168
+ if (offset !== undefined) qb = qb.offset(offset)
169
+ const rows = (await qb) as ParamsType[]
170
+ return rows.map((row) => new BaseRecord(row, this))
171
+ }
172
+
173
+ override async search(
174
+ query: string,
175
+ fields: string[],
176
+ options?: { limit?: number },
177
+ ): Promise<BaseRecord[]> {
178
+ const limit = options?.limit ?? 50
179
+ if (!query || fields.length === 0) return []
180
+ // Build per-field substring conditions. Only string-typed columns are
181
+ // valid for `ilike`/`like`; skip the rest defensively so a stray
182
+ // property path can't crash the query.
183
+ const isPg = this.dialect === 'pg'
184
+ const conds: unknown[] = []
185
+ for (const field of fields) {
186
+ const col = this.table[field] as DrizzleColumn | undefined
187
+ if (!col) continue
188
+ const prop = this.property(field)
189
+ if (!prop || prop.type() !== 'string') continue
190
+ const op = isPg ? ilike : like
191
+ conds.push(op(col as never, `%${query}%` as never))
192
+ }
193
+ if (conds.length === 0) return []
194
+ const where =
195
+ conds.length === 1 ? conds[0] : or(...(conds as never[]))
196
+ const rows = (await this.client
197
+ .select()
198
+ .from(this.table)
199
+ .where(where as unknown)
200
+ .limit(limit)) as ParamsType[]
201
+ return rows.map((row) => new BaseRecord(row, this))
202
+ }
203
+
204
+ override async findOne(id: string): Promise<BaseRecord | null> {
205
+ const rows = (await this.client
206
+ .select()
207
+ .from(this.table)
208
+ .where(eq(this.idColumn as never, this.castId(id) as never))
209
+ .limit(1)) as ParamsType[]
210
+ const row = rows[0]
211
+ return row ? new BaseRecord(row, this) : null
212
+ }
213
+
214
+ override async findMany(ids: Array<string | number>): Promise<BaseRecord[]> {
215
+ if (ids.length === 0) return []
216
+ const cast = ids.map((id) => this.castId(id))
217
+ const rows = (await this.client
218
+ .select()
219
+ .from(this.table)
220
+ .where(inArray(this.idColumn as never, cast as never))) as ParamsType[]
221
+ return rows.map((row) => new BaseRecord(row, this))
222
+ }
223
+
224
+ override async create(params: ParamsType): Promise<ParamsType> {
225
+ const rows = await this.client
226
+ .insert(this.table)
227
+ .values(this.writableData(params))
228
+ .returning()
229
+ return (rows[0] ?? {}) as ParamsType
230
+ }
231
+
232
+ override async update(id: string, params: ParamsType): Promise<ParamsType> {
233
+ const rows = await this.client
234
+ .update(this.table)
235
+ .set(this.writableData(params))
236
+ .where(eq(this.idColumn as never, this.castId(id) as never))
237
+ .returning()
238
+ return (rows[0] ?? {}) as ParamsType
239
+ }
240
+
241
+ override async delete(id: string): Promise<void> {
242
+ await this.client
243
+ .delete(this.table)
244
+ .where(eq(this.idColumn as never, this.castId(id) as never))
245
+ }
246
+
247
+ override supportsTimeSeries(): boolean {
248
+ return true
249
+ }
250
+
251
+ override async aggregateTimeSeries(
252
+ filter: Filter,
253
+ query: TimeSeriesQuery,
254
+ ): Promise<TimeSeriesResult> {
255
+ const series = await this.runTimeSeriesQuery(filter, query)
256
+ let previous: TimeSeriesSeries[] | undefined
257
+ if (query.comparePrevious) {
258
+ const span = query.to.getTime() - query.from.getTime()
259
+ const prevTo = new Date(query.from.getTime())
260
+ const prevFrom = new Date(query.from.getTime() - span)
261
+ previous = (
262
+ await this.runTimeSeriesQuery(filter, { ...query, from: prevFrom, to: prevTo })
263
+ ).series
264
+ }
265
+ return {
266
+ series: series.series,
267
+ ...(previous ? { previous } : {}),
268
+ sql: series.sql,
269
+ }
270
+ }
271
+
272
+ private async runTimeSeriesQuery(
273
+ filter: Filter,
274
+ query: TimeSeriesQuery,
275
+ ): Promise<{ series: TimeSeriesSeries[]; sql: string }> {
276
+ const dateCol = this.table[query.dateField] as DrizzleColumn | undefined
277
+ if (!dateCol) {
278
+ throw new Error(
279
+ `aggregateTimeSeries: dateField "${query.dateField}" not found on resource "${this._id}"`,
280
+ )
281
+ }
282
+ let fieldCol: DrizzleColumn | undefined
283
+ if (query.metric !== 'count') {
284
+ if (!query.field) {
285
+ throw new Error(`aggregateTimeSeries: metric "${query.metric}" requires "field"`)
286
+ }
287
+ fieldCol = this.table[query.field] as DrizzleColumn | undefined
288
+ if (!fieldCol) {
289
+ throw new Error(`aggregateTimeSeries: field "${query.field}" not found`)
290
+ }
291
+ }
292
+ let groupCol: DrizzleColumn | undefined
293
+ if (query.groupBy) {
294
+ groupCol = this.table[query.groupBy] as DrizzleColumn | undefined
295
+ if (!groupCol) {
296
+ throw new Error(`aggregateTimeSeries: groupBy "${query.groupBy}" not found`)
297
+ }
298
+ }
299
+
300
+ const bucketSql = bucketExpr(this.dialect, query.step, dateCol)
301
+ const metricSql = metricExpr(query.metric, fieldCol)
302
+
303
+ // WHERE = date range + user filters.
304
+ const conds: unknown[] = [
305
+ gte(dateCol as never, query.from as never),
306
+ lte(dateCol as never, query.to as never),
307
+ ]
308
+ const filterWhere = filterToWhere(filter, this.table)
309
+ if (filterWhere !== undefined) conds.push(filterWhere)
310
+ const where = conds.length === 1 ? conds[0] : and(...(conds as never[]))
311
+
312
+ type Row = { bucket?: unknown; value: unknown; series_key?: unknown }
313
+ const select: Record<string, unknown> = { value: metricSql }
314
+ if (query.step !== 'all') select.bucket = bucketSql
315
+ if (groupCol) select.series_key = groupCol
316
+
317
+ let qb = this.client.select(select).from(this.table).where(where as unknown)
318
+ const groupKeys: unknown[] = []
319
+ if (query.step !== 'all') groupKeys.push(bucketSql)
320
+ if (groupCol) groupKeys.push(groupCol)
321
+ if (groupKeys.length) qb = qb.groupBy(...groupKeys)
322
+ if (query.step !== 'all') qb = qb.orderBy(asc(bucketSql as never))
323
+
324
+ const rows = (await qb) as Row[]
325
+
326
+ // Bucket → series_key → numeric value.
327
+ const fromIso = isoDate(query.from)
328
+ const seriesMap = new Map<string, Map<string, number>>()
329
+ for (const row of rows) {
330
+ const seriesKey = groupCol ? stringifyKey(row.series_key) : '__total__'
331
+ const bucketKey =
332
+ query.step === 'all' ? fromIso : isoDate(toDate(row.bucket))
333
+ const num = toNumber(row.value)
334
+ let inner = seriesMap.get(seriesKey)
335
+ if (!inner) {
336
+ inner = new Map()
337
+ seriesMap.set(seriesKey, inner)
338
+ }
339
+ inner.set(bucketKey, num)
340
+ }
341
+
342
+ // Top-N truncation: rank series by total, keep top N, collapse rest into '__other__'.
343
+ const topN = query.topN ?? 10
344
+ const totals = Array.from(seriesMap.entries()).map(
345
+ ([k, m]) => [k, sumValues(m)] as const,
346
+ )
347
+ totals.sort((a, b) => b[1] - a[1])
348
+ const keep = new Set(totals.slice(0, topN).map(([k]) => k))
349
+ const otherInner = new Map<string, number>()
350
+ for (const [key, inner] of seriesMap) {
351
+ if (keep.has(key)) continue
352
+ for (const [bucket, val] of inner) {
353
+ otherInner.set(bucket, (otherInner.get(bucket) ?? 0) + val)
354
+ }
355
+ seriesMap.delete(key)
356
+ }
357
+ if (otherInner.size > 0) {
358
+ seriesMap.set('__other__', otherInner)
359
+ }
360
+
361
+ const seriesOut: TimeSeriesSeries[] = []
362
+ for (const [key, inner] of seriesMap) {
363
+ const points = Array.from(inner.entries())
364
+ .map(([date, value]) => ({ date, value }))
365
+ .sort((a, b) => a.date.localeCompare(b.date))
366
+ seriesOut.push({ key, points })
367
+ }
368
+
369
+ return {
370
+ series: seriesOut,
371
+ sql: buildDisplaySql(this.dialect, this.databaseName(), query, filter),
372
+ }
373
+ }
374
+
375
+ override async transaction<T>(fn: () => Promise<T>): Promise<T> {
376
+ if (typeof this.client.transaction !== 'function') return fn()
377
+ return this.client.transaction(async () => fn())
378
+ }
379
+ }
380
+
381
+ // ─── Time-series helpers ─────────────────────────────────────────────────
382
+
383
+ const bucketExpr = (
384
+ dialect: DrizzleDialect,
385
+ step: TimeSeriesStep,
386
+ dateCol: DrizzleColumn,
387
+ ): unknown => {
388
+ if (step === 'all') return sql`MIN(${dateCol})`
389
+ if (dialect === 'pg') {
390
+ return sql`DATE_TRUNC(${step}, ${dateCol})`
391
+ }
392
+ if (dialect === 'mysql') {
393
+ const fmt =
394
+ step === 'day'
395
+ ? '%Y-%m-%d'
396
+ : step === 'week'
397
+ ? '%x-W%v'
398
+ : step === 'month'
399
+ ? '%Y-%m-01'
400
+ : '%Y-01-01'
401
+ return sql`DATE_FORMAT(${dateCol}, ${fmt})`
402
+ }
403
+ // sqlite
404
+ const fmt =
405
+ step === 'day'
406
+ ? '%Y-%m-%d'
407
+ : step === 'week'
408
+ ? '%Y-W%W'
409
+ : step === 'month'
410
+ ? '%Y-%m-01'
411
+ : '%Y-01-01'
412
+ return sql`STRFTIME(${fmt}, ${dateCol})`
413
+ }
414
+
415
+ const metricExpr = (
416
+ op: TimeSeriesQuery['metric'],
417
+ fieldCol: DrizzleColumn | undefined,
418
+ ): unknown => {
419
+ if (op === 'count') return sql`COUNT(*)`
420
+ if (!fieldCol) {
421
+ throw new Error(`metric "${op}" requires field`)
422
+ }
423
+ switch (op) {
424
+ case 'sum':
425
+ return sql`SUM(${fieldCol})`
426
+ case 'avg':
427
+ return sql`AVG(${fieldCol})`
428
+ case 'min':
429
+ return sql`MIN(${fieldCol})`
430
+ case 'max':
431
+ return sql`MAX(${fieldCol})`
432
+ }
433
+ }
434
+
435
+ const isoDate = (d: Date): string => d.toISOString().slice(0, 10)
436
+
437
+ const toDate = (v: unknown): Date => {
438
+ if (v instanceof Date) return v
439
+ if (typeof v === 'string' || typeof v === 'number') return new Date(v)
440
+ return new Date(String(v))
441
+ }
442
+
443
+ const toNumber = (v: unknown): number => {
444
+ if (typeof v === 'number') return v
445
+ if (v == null) return 0
446
+ const n = Number(v)
447
+ return Number.isFinite(n) ? n : 0
448
+ }
449
+
450
+ const stringifyKey = (v: unknown): string => {
451
+ if (v == null) return '__null__'
452
+ if (typeof v === 'string') return v
453
+ return String(v)
454
+ }
455
+
456
+ const sumValues = (m: Map<string, number>): number => {
457
+ let s = 0
458
+ for (const v of m.values()) s += v
459
+ return s
460
+ }
461
+
462
+ const buildDisplaySql = (
463
+ dialect: DrizzleDialect,
464
+ tableName: string,
465
+ query: TimeSeriesQuery,
466
+ filter: Filter,
467
+ ): string => {
468
+ const ident = (s: string) => (dialect === 'mysql' ? `\`${s}\`` : `"${s}"`)
469
+ const t = ident(tableName)
470
+ const dateCol = ident(query.dateField)
471
+ const bucket =
472
+ query.step === 'all'
473
+ ? `MIN(${dateCol})`
474
+ : dialect === 'pg'
475
+ ? `DATE_TRUNC('${query.step}', ${dateCol})`
476
+ : dialect === 'mysql'
477
+ ? `DATE_FORMAT(${dateCol}, ${mysqlFmt(query.step)})`
478
+ : `STRFTIME(${sqliteFmt(query.step)}, ${dateCol})`
479
+ const metric =
480
+ query.metric === 'count'
481
+ ? 'COUNT(*)'
482
+ : `${query.metric.toUpperCase()}(${ident(query.field as string)})`
483
+ const cols: string[] = []
484
+ if (query.step !== 'all') cols.push(`${bucket} AS bucket`)
485
+ cols.push(`${metric} AS value`)
486
+ if (query.groupBy) cols.push(`${ident(query.groupBy)} AS series_key`)
487
+
488
+ const where: string[] = [
489
+ `${dateCol} >= '${query.from.toISOString()}'`,
490
+ `${dateCol} <= '${query.to.toISOString()}'`,
491
+ ]
492
+ filter.reduce<null>((_, el) => {
493
+ where.push(`${ident(el.path)} = '${String(el.value)}'`)
494
+ return null
495
+ }, null)
496
+
497
+ const groupBy: string[] = []
498
+ if (query.step !== 'all') groupBy.push('bucket')
499
+ if (query.groupBy) groupBy.push('series_key')
500
+
501
+ const lines = [
502
+ `SELECT ${cols.join(', ')}`,
503
+ `FROM ${t}`,
504
+ `WHERE ${where.join(' AND ')}`,
505
+ ]
506
+ if (groupBy.length) lines.push(`GROUP BY ${groupBy.join(', ')}`)
507
+ if (query.step !== 'all') lines.push('ORDER BY bucket ASC')
508
+ return lines.join('\n')
509
+ }
510
+
511
+ const mysqlFmt = (step: TimeSeriesStep): string =>
512
+ step === 'day'
513
+ ? "'%Y-%m-%d'"
514
+ : step === 'week'
515
+ ? "'%x-W%v'"
516
+ : step === 'month'
517
+ ? "'%Y-%m-01'"
518
+ : "'%Y-01-01'"
519
+
520
+ const sqliteFmt = (step: TimeSeriesStep): string =>
521
+ step === 'day'
522
+ ? "'%Y-%m-%d'"
523
+ : step === 'week'
524
+ ? "'%Y-W%W'"
525
+ : step === 'month'
526
+ ? "'%Y-%m-01'"
527
+ : "'%Y-01-01'"
package/src/types.ts ADDED
@@ -0,0 +1,90 @@
1
+ // Structural types matching the surface of `drizzle-orm` we depend on.
2
+ // Importing the real types breaks for consumers who use a different DB driver
3
+ // (pg, mysql, sqlite) since each has its own column/table classes — keeping
4
+ // this duck-typed lets the adapter work uniformly across drivers.
5
+
6
+ export interface DrizzleColumn {
7
+ name: string
8
+ /** drizzle's runtime data tag — 'string', 'number', 'boolean', 'date', 'json', 'bigint', 'buffer', 'array'. */
9
+ dataType: string
10
+ /** specific column kind such as 'PgUUID', 'PgEnum', 'PgText', 'PgArray'. Optional. */
11
+ columnType?: string
12
+ primary?: boolean
13
+ notNull?: boolean
14
+ hasDefault?: boolean
15
+ enumValues?: readonly string[]
16
+ /** For PgArray columns — points to the inner element column. */
17
+ baseColumn?: DrizzleColumn
18
+ }
19
+
20
+ export type DrizzleTable = Record<string, DrizzleColumn> & {
21
+ /** drizzle's hidden table metadata bag. */
22
+ _?: { name?: string }
23
+ }
24
+
25
+ export interface DrizzleQueryBuilder<T> {
26
+ where(condition: unknown): DrizzleQueryBuilder<T>
27
+ orderBy(...columns: unknown[]): DrizzleQueryBuilder<T>
28
+ groupBy(...columns: unknown[]): DrizzleQueryBuilder<T>
29
+ limit(n: number): DrizzleQueryBuilder<T>
30
+ offset(n: number): DrizzleQueryBuilder<T>
31
+ // The library returns a thenable at the end of the chain.
32
+ then<U>(onfulfilled?: (value: T[]) => U | PromiseLike<U>): Promise<U>
33
+ }
34
+
35
+ export interface DrizzleSelectBuilder {
36
+ from(table: DrizzleTable): DrizzleQueryBuilder<Record<string, unknown>>
37
+ }
38
+
39
+ export interface DrizzleInsertBuilder {
40
+ values(value: Record<string, unknown>): {
41
+ returning(): Promise<Array<Record<string, unknown>>>
42
+ }
43
+ }
44
+
45
+ export interface DrizzleUpdateBuilder {
46
+ set(values: Record<string, unknown>): {
47
+ where(condition: unknown): {
48
+ returning(): Promise<Array<Record<string, unknown>>>
49
+ }
50
+ }
51
+ }
52
+
53
+ export interface DrizzleDeleteBuilder {
54
+ where(condition: unknown): Promise<unknown>
55
+ }
56
+
57
+ export interface DrizzleClientLike {
58
+ select(): DrizzleSelectBuilder
59
+ select(fields: Record<string, unknown>): DrizzleSelectBuilder
60
+ insert(table: DrizzleTable): DrizzleInsertBuilder
61
+ update(table: DrizzleTable): DrizzleUpdateBuilder
62
+ delete(table: DrizzleTable): DrizzleDeleteBuilder
63
+ transaction?<T>(fn: (tx: DrizzleClientLike) => Promise<T>): Promise<T>
64
+ }
65
+
66
+ export interface DrizzleSchema {
67
+ [tableName: string]: DrizzleTable
68
+ }
69
+
70
+ export type DrizzleDialect = 'pg' | 'mysql' | 'sqlite'
71
+
72
+ export interface DrizzleResourceConfig {
73
+ /** Override the resource id (defaults to drizzle's table name). */
74
+ id?: string
75
+ }
76
+
77
+ export interface DrizzleDatabaseConfig {
78
+ /** drizzle client, e.g. `drizzle(pool, { schema })`. */
79
+ client: DrizzleClientLike
80
+ /** drizzle schema (object exporting tables). */
81
+ schema: DrizzleSchema
82
+ /**
83
+ * Database dialect. Required to build dialect-specific SQL for
84
+ * `aggregateTimeSeries` (DATE_TRUNC vs DATE_FORMAT vs strftime).
85
+ * Defaults to `'pg'` when omitted.
86
+ */
87
+ dialect?: DrizzleDialect
88
+ /** Optional per-table overrides. */
89
+ resources?: Record<string, DrizzleResourceConfig>
90
+ }