@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.
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +48 -0
- package/dist/index.js.map +1 -0
- package/dist/schema/pg.d.ts +4982 -0
- package/dist/schema/pg.d.ts.map +1 -0
- package/dist/schema/pg.js +297 -0
- package/dist/schema/pg.js.map +1 -0
- package/dist/stores/ai-task-store.d.ts +20 -0
- package/dist/stores/ai-task-store.d.ts.map +1 -0
- package/dist/stores/ai-task-store.js +128 -0
- package/dist/stores/ai-task-store.js.map +1 -0
- package/dist/stores/cache-store.d.ts +23 -0
- package/dist/stores/cache-store.d.ts.map +1 -0
- package/dist/stores/cache-store.js +72 -0
- package/dist/stores/cache-store.js.map +1 -0
- package/dist/stores/config-store.d.ts +13 -0
- package/dist/stores/config-store.d.ts.map +1 -0
- package/dist/stores/config-store.js +60 -0
- package/dist/stores/config-store.js.map +1 -0
- package/dist/stores/history-store.d.ts +22 -0
- package/dist/stores/history-store.d.ts.map +1 -0
- package/dist/stores/history-store.js +62 -0
- package/dist/stores/history-store.js.map +1 -0
- package/dist/stores/log-store.d.ts +10 -0
- package/dist/stores/log-store.d.ts.map +1 -0
- package/dist/stores/log-store.js +62 -0
- package/dist/stores/log-store.js.map +1 -0
- package/dist/stores/webhook-store.d.ts +16 -0
- package/dist/stores/webhook-store.d.ts.map +1 -0
- package/dist/stores/webhook-store.js +129 -0
- package/dist/stores/webhook-store.js.map +1 -0
- package/dist/types.d.ts +23 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +12 -0
- package/dist/types.js.map +1 -0
- package/package.json +53 -0
- package/src/index.ts +65 -0
- package/src/schema/pg.ts +354 -0
- package/src/stores/ai-task-store.ts +174 -0
- package/src/stores/cache-store.ts +90 -0
- package/src/stores/config-store.ts +80 -0
- package/src/stores/history-store.ts +98 -0
- package/src/stores/log-store.ts +71 -0
- package/src/stores/webhook-store.ts +167 -0
- package/src/types.ts +27 -0
package/src/schema/pg.ts
ADDED
|
@@ -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
|
+
}
|