@modern-admin/system-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.
Files changed (46) hide show
  1. package/dist/index.d.ts +30 -0
  2. package/dist/index.d.ts.map +1 -0
  3. package/dist/index.js +48 -0
  4. package/dist/index.js.map +1 -0
  5. package/dist/schema/pg.d.ts +4982 -0
  6. package/dist/schema/pg.d.ts.map +1 -0
  7. package/dist/schema/pg.js +297 -0
  8. package/dist/schema/pg.js.map +1 -0
  9. package/dist/stores/ai-task-store.d.ts +20 -0
  10. package/dist/stores/ai-task-store.d.ts.map +1 -0
  11. package/dist/stores/ai-task-store.js +128 -0
  12. package/dist/stores/ai-task-store.js.map +1 -0
  13. package/dist/stores/cache-store.d.ts +23 -0
  14. package/dist/stores/cache-store.d.ts.map +1 -0
  15. package/dist/stores/cache-store.js +72 -0
  16. package/dist/stores/cache-store.js.map +1 -0
  17. package/dist/stores/config-store.d.ts +13 -0
  18. package/dist/stores/config-store.d.ts.map +1 -0
  19. package/dist/stores/config-store.js +60 -0
  20. package/dist/stores/config-store.js.map +1 -0
  21. package/dist/stores/history-store.d.ts +22 -0
  22. package/dist/stores/history-store.d.ts.map +1 -0
  23. package/dist/stores/history-store.js +62 -0
  24. package/dist/stores/history-store.js.map +1 -0
  25. package/dist/stores/log-store.d.ts +10 -0
  26. package/dist/stores/log-store.d.ts.map +1 -0
  27. package/dist/stores/log-store.js +62 -0
  28. package/dist/stores/log-store.js.map +1 -0
  29. package/dist/stores/webhook-store.d.ts +16 -0
  30. package/dist/stores/webhook-store.d.ts.map +1 -0
  31. package/dist/stores/webhook-store.js +129 -0
  32. package/dist/stores/webhook-store.js.map +1 -0
  33. package/dist/types.d.ts +23 -0
  34. package/dist/types.d.ts.map +1 -0
  35. package/dist/types.js +12 -0
  36. package/dist/types.js.map +1 -0
  37. package/package.json +53 -0
  38. package/src/index.ts +65 -0
  39. package/src/schema/pg.ts +354 -0
  40. package/src/stores/ai-task-store.ts +174 -0
  41. package/src/stores/cache-store.ts +90 -0
  42. package/src/stores/config-store.ts +80 -0
  43. package/src/stores/history-store.ts +98 -0
  44. package/src/stores/log-store.ts +71 -0
  45. package/src/stores/webhook-store.ts +167 -0
  46. package/src/types.ts +27 -0
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Drizzle pg-core table objects for the Modern Admin system tables.
3
+ *
4
+ * Mirrors the `prisma/modern-admin.prisma` fragment shipped with
5
+ * `@modern-admin/system-prisma` so the runtime stores see equivalent
6
+ * shapes regardless of which ORM the host picks.
7
+ *
8
+ * Tables fall into two groups:
9
+ *
10
+ * 1. **Better Auth** (`maUser`, `maSession`, `maAccount`,
11
+ * `maVerification`, `maApiKey`) — physical tables that back Better
12
+ * Auth's `user/session/account/verification` and the `apikey`
13
+ * plugin. Better Auth is configured to remap its logical names to
14
+ * these `ma_*` SQL tables; declaring them here means a single CLI
15
+ * run (`bunx @modern-admin/create-modern-admin generate`) wires up
16
+ * everything the runtime needs.
17
+ * 2. **Modern Admin core** (`maRole`, `maLog`, `maWebhook`, …) — the
18
+ * framework's own runtime tables.
19
+ *
20
+ * Usage:
21
+ *
22
+ * import * as systemSchema from '@modern-admin/system-drizzle/pg'
23
+ * export const schema = { ...mySchema, ...systemSchema }
24
+ * export const db = drizzle(client, { schema })
25
+ *
26
+ * Naming: every table has its `ma_*` SQL name baked into the table
27
+ * declaration. To use a different prefix, copy this file into your
28
+ * project, rename, and pass the renamed objects into `setupDrizzleSystem`.
29
+ */
30
+
31
+ import { sql } from 'drizzle-orm'
32
+ import { bigint, boolean, index, integer, jsonb, pgTable, text, timestamp, unique, uuid } from 'drizzle-orm/pg-core'
33
+
34
+ // ─── Better Auth ──────────────────────────────────────────────────────────
35
+
36
+ /**
37
+ * Panel admin (Better Auth `user` table, remapped to `ma_user`).
38
+ *
39
+ * Admin plugin contributes `role`/`banned`/`banReason`/`banExpires`. The
40
+ * `role` column references `ma_role.id` (which doubles as the
41
+ * user-visible name) and is what `ModernAdmin.invoke()` uses to look up
42
+ * the permissions matrix.
43
+ */
44
+ export const maUser = pgTable('ma_user', {
45
+ id: uuid('id').primaryKey().defaultRandom(),
46
+ name: text('name').notNull(),
47
+ email: text('email').notNull().unique(),
48
+ emailVerified: boolean('email_verified').notNull().default(false),
49
+ image: text('image'),
50
+ /** Role id (= name); resolves to a row in `ma_role` for permissions lookup. */
51
+ role: text('role'),
52
+ banned: boolean('banned').default(false),
53
+ banReason: text('ban_reason'),
54
+ banExpires: timestamp('ban_expires', { withTimezone: true }),
55
+ createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
56
+ updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
57
+ })
58
+
59
+ export const maSession = pgTable('ma_session', {
60
+ id: uuid('id').primaryKey().defaultRandom(),
61
+ expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
62
+ token: text('token').notNull().unique(),
63
+ ipAddress: text('ip_address'),
64
+ userAgent: text('user_agent'),
65
+ userId: uuid('user_id')
66
+ .notNull()
67
+ .references(() => maUser.id, { onDelete: 'cascade' }),
68
+ /** Active impersonation source; admin plugin uses this to track
69
+ * "log in as" sessions so audit logs can attribute the real actor. */
70
+ impersonatedBy: text('impersonated_by'),
71
+ createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
72
+ updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
73
+ })
74
+
75
+ export const maAccount = pgTable('ma_account', {
76
+ id: uuid('id').primaryKey().defaultRandom(),
77
+ accountId: text('account_id').notNull(),
78
+ providerId: text('provider_id').notNull(),
79
+ userId: uuid('user_id')
80
+ .notNull()
81
+ .references(() => maUser.id, { onDelete: 'cascade' }),
82
+ accessToken: text('access_token'),
83
+ refreshToken: text('refresh_token'),
84
+ idToken: text('id_token'),
85
+ accessTokenExpiresAt: timestamp('access_token_expires_at', { withTimezone: true }),
86
+ refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }),
87
+ scope: text('scope'),
88
+ /** Hashed password for the email/password strategy. OAuth/passkey rows
89
+ * leave this null — credentials are encoded in the provider tokens. */
90
+ password: text('password'),
91
+ createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
92
+ updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
93
+ })
94
+
95
+ export const maVerification = pgTable('ma_verification', {
96
+ id: uuid('id').primaryKey().defaultRandom(),
97
+ identifier: text('identifier').notNull(),
98
+ value: text('value').notNull(),
99
+ expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
100
+ createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
101
+ updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
102
+ })
103
+
104
+ /**
105
+ * Better Auth api-key plugin storage. `permissions` is a JSON object
106
+ * `{ resourceId: action[] }` consumed by `ModernAdmin.invoke()`'s
107
+ * api-key gate (see also `maRole.permissions` for the role-based gate —
108
+ * both share the same matching helper).
109
+ */
110
+ export const maApiKey = pgTable('ma_apikey', {
111
+ id: uuid('id').primaryKey().defaultRandom(),
112
+ configId: text('config_id').notNull().default('default'),
113
+ name: text('name'),
114
+ start: text('start'),
115
+ prefix: text('prefix'),
116
+ key: text('key').notNull(),
117
+ referenceId: uuid('reference_id')
118
+ .notNull()
119
+ .references(() => maUser.id, { onDelete: 'cascade' }),
120
+ refillInterval: integer('refill_interval'),
121
+ refillAmount: integer('refill_amount'),
122
+ lastRefillAt: timestamp('last_refill_at', { withTimezone: true }),
123
+ enabled: boolean('enabled').notNull().default(true),
124
+ rateLimitEnabled: boolean('rate_limit_enabled').notNull().default(false),
125
+ rateLimitTimeWindow: integer('rate_limit_time_window'),
126
+ rateLimitMax: integer('rate_limit_max'),
127
+ requestCount: integer('request_count').notNull().default(0),
128
+ remaining: integer('remaining'),
129
+ lastRequest: timestamp('last_request', { withTimezone: true }),
130
+ expiresAt: timestamp('expires_at', { withTimezone: true }),
131
+ createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
132
+ updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
133
+ permissions: jsonb('permissions'),
134
+ metadata: jsonb('metadata'),
135
+ })
136
+
137
+ // ─── Modern Admin core ────────────────────────────────────────────────────
138
+
139
+ /**
140
+ * Configurable role with a permissions matrix.
141
+ *
142
+ * `permissions` shape: `Record<resourceId, action[]>`, where `'*'` is a
143
+ * wildcard for actions or for a resource key (matches every resource).
144
+ * The `id` doubles as the user-visible role name — there is no separate
145
+ * `name` column. Application code supplies `id` on insert (`'admin'`,
146
+ * `'editor'`, …); it's a meaningful business id, not a surrogate UUID.
147
+ *
148
+ * Renames aren't supported (would orphan every panel admin holding the
149
+ * role). The id is referenced from your panel-user table's `role`
150
+ * column — e.g. Better Auth's `ma_user.role`.
151
+ */
152
+ export const maRole = pgTable('ma_role', {
153
+ id: text('id').primaryKey(),
154
+ description: text('description'),
155
+ permissions: jsonb('permissions').notNull().default(sql`'{}'::jsonb`),
156
+ /** Built-in roles are seeded on boot and cannot be deleted via the UI. */
157
+ isBuiltin: boolean('is_builtin').notNull().default(false),
158
+ createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
159
+ updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
160
+ })
161
+
162
+ export const maLog = pgTable(
163
+ 'ma_log',
164
+ {
165
+ id: uuid('id').primaryKey().defaultRandom(),
166
+ resourceId: text('resource_id').notNull(),
167
+ action: text('action').notNull(),
168
+ recordId: text('record_id'),
169
+ recordIds: jsonb('record_ids'),
170
+ userId: text('user_id'),
171
+ payload: jsonb('payload'),
172
+ result: jsonb('result'),
173
+ /** Unix-ms timestamp captured at the after-hook. */
174
+ at: bigint('at', { mode: 'number' }).notNull(),
175
+ createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
176
+ },
177
+ (t) => ({
178
+ resourceActionIdx: index('ma_log_resource_action_idx').on(t.resourceId, t.action),
179
+ userIdx: index('ma_log_user_idx').on(t.userId),
180
+ createdAtIdx: index('ma_log_created_at_idx').on(t.createdAt),
181
+ }),
182
+ )
183
+
184
+ export const maWebhook = pgTable('ma_webhook', {
185
+ id: uuid('id').primaryKey().defaultRandom(),
186
+ name: text('name').notNull(),
187
+ url: text('url').notNull(),
188
+ events: jsonb('events').notNull(),
189
+ resourceId: text('resource_id'),
190
+ enabled: boolean('enabled').notNull().default(true),
191
+ secret: text('secret'),
192
+ headers: jsonb('headers').notNull().default(sql`'{}'::jsonb`),
193
+ filters: jsonb('filters').notNull().default(sql`'{}'::jsonb`),
194
+ payloadFields: jsonb('payload_fields').notNull().default(sql`'[]'::jsonb`),
195
+ createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
196
+ updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
197
+ })
198
+
199
+ export const maWebhookDelivery = pgTable(
200
+ 'ma_webhook_delivery',
201
+ {
202
+ id: uuid('id').primaryKey().defaultRandom(),
203
+ webhookId: uuid('webhook_id')
204
+ .notNull()
205
+ .references(() => maWebhook.id, { onDelete: 'cascade' }),
206
+ event: text('event').notNull(),
207
+ payload: jsonb('payload').notNull(),
208
+ status: text('status').notNull(),
209
+ responseStatus: integer('response_status'),
210
+ responseBody: text('response_body'),
211
+ error: text('error'),
212
+ attempt: integer('attempt').notNull().default(1),
213
+ createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
214
+ deliveredAt: timestamp('delivered_at', { withTimezone: true }),
215
+ },
216
+ (t) => ({
217
+ webhookCreatedAtIdx: index('ma_webhook_delivery_webhook_created_idx').on(
218
+ t.webhookId,
219
+ t.createdAt,
220
+ ),
221
+ }),
222
+ )
223
+
224
+ export const maConfig = pgTable(
225
+ 'ma_config',
226
+ {
227
+ /**
228
+ * Surrogate primary key. Application code MUST set this with
229
+ * `uuidv7()` from `@modern-admin/core` on insert — Drizzle's
230
+ * `defaultRandom()` produces v4, which the project policy disallows.
231
+ */
232
+ id: uuid('id').primaryKey(),
233
+ scope: text('scope').notNull(),
234
+ /** `null` for global, userId for user, resourceId for resource. */
235
+ scopeId: text('scope_id'),
236
+ key: text('key').notNull(),
237
+ value: jsonb('value'),
238
+ updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
239
+ },
240
+ (t) => ({
241
+ // Postgres treats NULL values as distinct in unique constraints by
242
+ // default, so two `(scope='global', scopeId=null, key='foo')` rows
243
+ // would coexist. `nullsNotDistinct()` matches the prisma fragment's
244
+ // `@@unique` semantics and makes the global scope behave like a
245
+ // regular composite key. Drizzle's `nullsNotDistinct` lives on the
246
+ // `unique()` constraint builder, not on `uniqueIndex()`.
247
+ scopeKey: unique('ma_config_scope_scope_id_key_uq')
248
+ .on(t.scope, t.scopeId, t.key)
249
+ .nullsNotDistinct(),
250
+ }),
251
+ )
252
+
253
+ export const maHistory = pgTable(
254
+ 'ma_history',
255
+ {
256
+ id: uuid('id').primaryKey().defaultRandom(),
257
+ resourceId: text('resource_id').notNull(),
258
+ recordId: text('record_id').notNull(),
259
+ op: text('op').notNull(),
260
+ userId: text('user_id'),
261
+ snapshot: jsonb('snapshot').notNull(),
262
+ /** State of the record _before_ this revision — fed back into the
263
+ * resource on revert. Nullable for legacy rows. */
264
+ snapshotBefore: jsonb('snapshot_before'),
265
+ createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
266
+ },
267
+ (t) => ({
268
+ recordIdx: index('ma_history_record_idx').on(
269
+ t.resourceId,
270
+ t.recordId,
271
+ t.createdAt,
272
+ ),
273
+ }),
274
+ )
275
+
276
+ export const maAiTask = pgTable(
277
+ 'ma_ai_task',
278
+ {
279
+ id: uuid('id').primaryKey().defaultRandom(),
280
+ kind: text('kind').notNull(),
281
+ resourceId: text('resource_id'),
282
+ recordId: text('record_id'),
283
+ userId: text('user_id'),
284
+ status: text('status').notNull(),
285
+ input: jsonb('input').notNull().default(sql`'{}'::jsonb`),
286
+ output: jsonb('output'),
287
+ error: text('error'),
288
+ progress: integer('progress'),
289
+ createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
290
+ updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
291
+ startedAt: timestamp('started_at', { withTimezone: true }),
292
+ finishedAt: timestamp('finished_at', { withTimezone: true }),
293
+ },
294
+ (t) => ({
295
+ kindStatusIdx: index('ma_ai_task_kind_status_idx').on(t.kind, t.status),
296
+ userIdx: index('ma_ai_task_user_idx').on(t.userId),
297
+ recordIdx: index('ma_ai_task_record_idx').on(t.resourceId, t.recordId),
298
+ }),
299
+ )
300
+
301
+ export const maAiTaskEvent = pgTable(
302
+ 'ma_ai_task_event',
303
+ {
304
+ id: uuid('id').primaryKey().defaultRandom(),
305
+ taskId: uuid('task_id')
306
+ .notNull()
307
+ .references(() => maAiTask.id, { onDelete: 'cascade' }),
308
+ type: text('type').notNull(),
309
+ data: jsonb('data').notNull().default(sql`'{}'::jsonb`),
310
+ createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
311
+ },
312
+ (t) => ({
313
+ taskCreatedIdx: index('ma_ai_task_event_task_created_idx').on(
314
+ t.taskId,
315
+ t.createdAt,
316
+ ),
317
+ }),
318
+ )
319
+
320
+ export const maCache = pgTable(
321
+ 'ma_cache',
322
+ {
323
+ key: text('key').primaryKey(),
324
+ value: jsonb('value'),
325
+ tags: jsonb('tags').notNull().default(sql`'[]'::jsonb`),
326
+ expiresAt: timestamp('expires_at', { withTimezone: true }),
327
+ createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
328
+ updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
329
+ },
330
+ (t) => ({
331
+ expiresIdx: index('ma_cache_expires_idx').on(t.expiresAt),
332
+ }),
333
+ )
334
+
335
+ export const systemTables = {
336
+ // Better Auth
337
+ maUser,
338
+ maSession,
339
+ maAccount,
340
+ maVerification,
341
+ maApiKey,
342
+ // Modern Admin core
343
+ maRole,
344
+ maLog,
345
+ maWebhook,
346
+ maWebhookDelivery,
347
+ maConfig,
348
+ maHistory,
349
+ maAiTask,
350
+ maAiTaskEvent,
351
+ maCache,
352
+ } as const
353
+
354
+ export type SystemTables = typeof systemTables
@@ -0,0 +1,174 @@
1
+ import {
2
+ uuidv7,
3
+ type AiTask,
4
+ type AiTaskEvent,
5
+ type AiTaskInput,
6
+ type AiTaskStatus,
7
+ type IAiTaskStore,
8
+ } from '@modern-admin/core'
9
+ import { and, asc, desc, eq, inArray, type SQL } from 'drizzle-orm'
10
+ import type { DrizzleLike, SystemTables } from '../types.js'
11
+
12
+ interface TaskRow {
13
+ id: string
14
+ kind: string
15
+ resourceId: string | null
16
+ recordId: string | null
17
+ userId: string | null
18
+ status: string
19
+ input: unknown
20
+ output: unknown
21
+ error: string | null
22
+ progress: number | null
23
+ createdAt: Date
24
+ updatedAt: Date
25
+ startedAt: Date | null
26
+ finishedAt: Date | null
27
+ }
28
+
29
+ interface EventRow {
30
+ id: string
31
+ taskId: string
32
+ type: string
33
+ data: unknown
34
+ createdAt: Date
35
+ }
36
+
37
+ const rowToTask = (row: TaskRow): AiTask => ({
38
+ id: row.id,
39
+ kind: row.kind,
40
+ ...(row.resourceId !== null ? { resourceId: row.resourceId } : {}),
41
+ ...(row.recordId !== null ? { recordId: row.recordId } : {}),
42
+ ...(row.userId !== null ? { userId: row.userId } : {}),
43
+ status: row.status as AiTaskStatus,
44
+ input: (row.input as Record<string, unknown>) ?? {},
45
+ ...(row.output !== null && row.output !== undefined
46
+ ? { output: row.output as Record<string, unknown> }
47
+ : {}),
48
+ ...(row.error !== null ? { error: row.error } : {}),
49
+ progress: row.progress,
50
+ createdAt: row.createdAt.toISOString(),
51
+ updatedAt: row.updatedAt.toISOString(),
52
+ ...(row.startedAt !== null ? { startedAt: row.startedAt.toISOString() } : {}),
53
+ ...(row.finishedAt !== null ? { finishedAt: row.finishedAt.toISOString() } : {}),
54
+ })
55
+
56
+ const rowToEvent = (row: EventRow): AiTaskEvent => ({
57
+ id: row.id,
58
+ taskId: row.taskId,
59
+ type: row.type,
60
+ data: (row.data as Record<string, unknown>) ?? {},
61
+ createdAt: row.createdAt.toISOString(),
62
+ })
63
+
64
+ const TERMINAL: AiTaskStatus[] = ['succeeded', 'failed', 'cancelled']
65
+
66
+ export class DrizzleAiTaskStore implements IAiTaskStore {
67
+ constructor(
68
+ private readonly db: DrizzleLike,
69
+ private readonly taskTable: SystemTables['maAiTask'],
70
+ private readonly eventTable: SystemTables['maAiTaskEvent'],
71
+ ) {}
72
+
73
+ async enqueue(input: AiTaskInput): Promise<AiTask> {
74
+ const rows = (await this.db
75
+ .insert(this.taskTable)
76
+ .values({
77
+ id: uuidv7(),
78
+ kind: input.kind,
79
+ resourceId: input.resourceId ?? null,
80
+ recordId: input.recordId ?? null,
81
+ userId: input.userId ?? null,
82
+ status: 'pending',
83
+ input: input.input ?? {},
84
+ progress: null,
85
+ })
86
+ .returning()) as TaskRow[]
87
+ return rowToTask(rows[0]!)
88
+ }
89
+
90
+ async get(id: string): Promise<AiTask | null> {
91
+ const rows = (await this.db
92
+ .select()
93
+ .from(this.taskTable)
94
+ .where(eq(this.taskTable.id, id))
95
+ .limit(1)) as TaskRow[]
96
+ return rows[0] ? rowToTask(rows[0]) : null
97
+ }
98
+
99
+ async list(filter: Parameters<IAiTaskStore['list']>[0] = {}): Promise<AiTask[]> {
100
+ const conds: SQL[] = []
101
+ if (filter.kind) conds.push(eq(this.taskTable.kind, filter.kind))
102
+ if (filter.status) {
103
+ const list = Array.isArray(filter.status) ? filter.status : [filter.status]
104
+ conds.push(inArray(this.taskTable.status, list))
105
+ }
106
+ if (filter.userId) conds.push(eq(this.taskTable.userId, filter.userId))
107
+ if (filter.resourceId) conds.push(eq(this.taskTable.resourceId, filter.resourceId))
108
+
109
+ let q = this.db.select().from(this.taskTable)
110
+ if (conds.length) q = q.where(conds.length === 1 ? conds[0] : and(...conds))
111
+ q = q.orderBy(desc(this.taskTable.createdAt))
112
+ if (filter.limit !== undefined) q = q.limit(filter.limit)
113
+ const rows = (await q) as TaskRow[]
114
+ return rows.map(rowToTask)
115
+ }
116
+
117
+ async updateStatus(
118
+ id: string,
119
+ patch: {
120
+ status: AiTaskStatus
121
+ progress?: number | null
122
+ output?: Record<string, unknown>
123
+ error?: string
124
+ },
125
+ ): Promise<AiTask> {
126
+ const data: Record<string, unknown> = { status: patch.status, updatedAt: new Date() }
127
+ if (patch.progress !== undefined) data['progress'] = patch.progress
128
+ if (patch.output !== undefined) data['output'] = patch.output
129
+ if (patch.error !== undefined) data['error'] = patch.error
130
+
131
+ if (patch.status === 'running') {
132
+ const existing = (await this.db
133
+ .select()
134
+ .from(this.taskTable)
135
+ .where(eq(this.taskTable.id, id))
136
+ .limit(1)) as TaskRow[]
137
+ if (existing[0] && !existing[0].startedAt) data['startedAt'] = new Date()
138
+ }
139
+ if (TERMINAL.includes(patch.status)) {
140
+ data['finishedAt'] = new Date()
141
+ }
142
+
143
+ const rows = (await this.db
144
+ .update(this.taskTable)
145
+ .set(data)
146
+ .where(eq(this.taskTable.id, id))
147
+ .returning()) as TaskRow[]
148
+ if (!rows[0]) throw new Error(`AI task not found: ${id}`)
149
+ return rowToTask(rows[0])
150
+ }
151
+
152
+ async appendEvent(
153
+ taskId: string,
154
+ type: string,
155
+ data: Record<string, unknown>,
156
+ ): Promise<AiTaskEvent> {
157
+ const rows = (await this.db
158
+ .insert(this.eventTable)
159
+ .values({ id: uuidv7(), taskId, type, data })
160
+ .returning()) as EventRow[]
161
+ return rowToEvent(rows[0]!)
162
+ }
163
+
164
+ async events(taskId: string, sinceId?: string): Promise<AiTaskEvent[]> {
165
+ const all = (await this.db
166
+ .select()
167
+ .from(this.eventTable)
168
+ .where(eq(this.eventTable.taskId, taskId))
169
+ .orderBy(asc(this.eventTable.createdAt))) as EventRow[]
170
+ if (!sinceId) return all.map(rowToEvent)
171
+ const idx = all.findIndex((r) => r.id === sinceId)
172
+ return (idx < 0 ? all : all.slice(idx + 1)).map(rowToEvent)
173
+ }
174
+ }
@@ -0,0 +1,90 @@
1
+ import type { CacheEntry, ICacheStore } from '@modern-admin/core'
2
+ import { eq, inArray, lt } from 'drizzle-orm'
3
+ import type { DrizzleLike, SystemTables } from '../types.js'
4
+
5
+ interface CacheRow {
6
+ key: string
7
+ value: unknown
8
+ tags: unknown
9
+ expiresAt: Date | null
10
+ createdAt: Date
11
+ updatedAt: Date
12
+ }
13
+
14
+ const rowToEntry = (row: CacheRow): CacheEntry => ({
15
+ key: row.key,
16
+ value: row.value,
17
+ tags: Array.isArray(row.tags) ? (row.tags as string[]) : [],
18
+ expiresAt: row.expiresAt ? row.expiresAt.toISOString() : null,
19
+ createdAt: row.createdAt.toISOString(),
20
+ updatedAt: row.updatedAt.toISOString(),
21
+ })
22
+
23
+ export class DrizzleCacheStore implements ICacheStore {
24
+ constructor(
25
+ private readonly db: DrizzleLike,
26
+ private readonly table: SystemTables['maCache'],
27
+ ) {}
28
+
29
+ async get(key: string): Promise<CacheEntry | null> {
30
+ const rows = (await this.db
31
+ .select()
32
+ .from(this.table)
33
+ .where(eq(this.table.key, key))
34
+ .limit(1)) as CacheRow[]
35
+ const row = rows[0]
36
+ if (!row) return null
37
+ if (row.expiresAt && row.expiresAt.getTime() < Date.now()) {
38
+ await this.db.delete(this.table).where(eq(this.table.key, key))
39
+ return null
40
+ }
41
+ return rowToEntry(row)
42
+ }
43
+
44
+ async set(
45
+ key: string,
46
+ value: unknown,
47
+ options: { ttlMs?: number; tags?: string[] } = {},
48
+ ): Promise<void> {
49
+ const expiresAt = options.ttlMs ? new Date(Date.now() + options.ttlMs) : null
50
+ const tags = options.tags ?? []
51
+ await this.db
52
+ .insert(this.table)
53
+ .values({ key, value: value ?? null, tags, expiresAt, updatedAt: new Date() })
54
+ .onConflictDoUpdate({
55
+ target: this.table.key,
56
+ set: { value: value ?? null, tags, expiresAt, updatedAt: new Date() },
57
+ })
58
+ }
59
+
60
+ async delete(key: string): Promise<void> {
61
+ await this.db.delete(this.table).where(eq(this.table.key, key))
62
+ }
63
+
64
+ /**
65
+ * Tag invalidation walks the table because Drizzle's portable `jsonb`
66
+ * column type doesn't support array-contains via this generic adapter.
67
+ * For high-volume caches use a Postgres-specific implementation
68
+ * (`tags text[]` with `ANY(tags)`) or Redis. Correctness > throughput
69
+ * here by design.
70
+ */
71
+ async invalidateTags(tags: string[]): Promise<number> {
72
+ if (!tags.length) return 0
73
+ const set = new Set(tags)
74
+ const all = (await this.db.select().from(this.table)) as CacheRow[]
75
+ const targetKeys = all
76
+ .filter((r) => Array.isArray(r.tags) && (r.tags as string[]).some((t) => set.has(t)))
77
+ .map((r) => r.key)
78
+ if (!targetKeys.length) return 0
79
+ await this.db.delete(this.table).where(inArray(this.table.key, targetKeys))
80
+ return targetKeys.length
81
+ }
82
+
83
+ async prune(): Promise<number> {
84
+ const expired = (await this.db
85
+ .delete(this.table)
86
+ .where(lt(this.table.expiresAt, new Date()))
87
+ .returning()) as CacheRow[]
88
+ return expired.length
89
+ }
90
+ }
@@ -0,0 +1,80 @@
1
+ import { uuidv7, type ConfigEntry, type ConfigScope, type IConfigStore } from '@modern-admin/core'
2
+ import { and, asc, eq, isNull, type SQL } from 'drizzle-orm'
3
+ import type { DrizzleLike, SystemTables } from '../types.js'
4
+
5
+ interface ConfigRow {
6
+ scope: string
7
+ scopeId: string | null
8
+ key: string
9
+ value: unknown
10
+ updatedAt: Date
11
+ }
12
+
13
+ const rowToEntry = (row: ConfigRow): ConfigEntry => ({
14
+ scope: row.scope as ConfigScope,
15
+ scopeId: row.scopeId,
16
+ key: row.key,
17
+ value: row.value,
18
+ updatedAt: row.updatedAt.toISOString(),
19
+ })
20
+
21
+ export class DrizzleConfigStore implements IConfigStore {
22
+ constructor(
23
+ private readonly db: DrizzleLike,
24
+ private readonly table: SystemTables['maConfig'],
25
+ ) {}
26
+
27
+ private pkCondition(scope: ConfigScope, scopeId: string | null, key: string): SQL {
28
+ const scopeIdCond = scopeId === null
29
+ ? isNull(this.table.scopeId)
30
+ : eq(this.table.scopeId, scopeId)
31
+ return and(eq(this.table.scope, scope), scopeIdCond, eq(this.table.key, key))!
32
+ }
33
+
34
+ async get(scope: ConfigScope, scopeId: string | null, key: string): Promise<unknown> {
35
+ const rows = (await this.db
36
+ .select()
37
+ .from(this.table)
38
+ .where(this.pkCondition(scope, scopeId, key))
39
+ .limit(1)) as ConfigRow[]
40
+ return rows[0]?.value
41
+ }
42
+
43
+ async set(
44
+ scope: ConfigScope,
45
+ scopeId: string | null,
46
+ key: string,
47
+ value: unknown,
48
+ ): Promise<void> {
49
+ await this.db
50
+ .insert(this.table)
51
+ .values({
52
+ id: uuidv7(),
53
+ scope,
54
+ scopeId,
55
+ key,
56
+ value: value ?? null,
57
+ updatedAt: new Date(),
58
+ })
59
+ .onConflictDoUpdate({
60
+ target: [this.table.scope, this.table.scopeId, this.table.key],
61
+ set: { value: value ?? null, updatedAt: new Date() },
62
+ })
63
+ }
64
+
65
+ async delete(scope: ConfigScope, scopeId: string | null, key: string): Promise<void> {
66
+ await this.db.delete(this.table).where(this.pkCondition(scope, scopeId, key))
67
+ }
68
+
69
+ async list(scope: ConfigScope, scopeId: string | null): Promise<ConfigEntry[]> {
70
+ const scopeIdCond = scopeId === null
71
+ ? isNull(this.table.scopeId)
72
+ : eq(this.table.scopeId, scopeId)
73
+ const rows = (await this.db
74
+ .select()
75
+ .from(this.table)
76
+ .where(and(eq(this.table.scope, scope), scopeIdCond))
77
+ .orderBy(asc(this.table.key))) as ConfigRow[]
78
+ return rows.map(rowToEntry)
79
+ }
80
+ }