@manablox/db 0.1.0 → 0.2.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/README.md +21 -0
- package/drizzle.config.ts +1 -1
- package/migrations/0005_menus.sql +44 -0
- package/migrations/0006_roles.sql +13 -0
- package/migrations/0007_apikey-permissions.sql +4 -0
- package/migrations/0008_workflows.sql +49 -0
- package/migrations/meta/0005_snapshot.json +2504 -0
- package/migrations/meta/0006_snapshot.json +2605 -0
- package/migrations/meta/0007_snapshot.json +2605 -0
- package/migrations/meta/0008_snapshot.json +2986 -0
- package/migrations/meta/_journal.json +28 -0
- package/package.json +10 -5
- package/src/cli/create-db.ts +30 -0
- package/src/cli/migrate.ts +2 -9
- package/src/client.ts +8 -1
- package/src/errors.ts +50 -0
- package/src/index.ts +8 -2
- package/src/migrate.ts +21 -0
- package/src/pagination.ts +52 -0
- package/src/query.ts +1 -5
- package/src/repositories/asset-usage.ts +1 -1
- package/src/repositories/asset.ts +13 -21
- package/src/repositories/content-type.ts +1 -1
- package/src/repositories/content.ts +150 -97
- package/src/repositories/index.ts +12 -0
- package/src/repositories/menu.ts +235 -0
- package/src/repositories/role.ts +85 -0
- package/src/repositories/space.ts +7 -2
- package/src/repositories/user.ts +171 -25
- package/src/repositories/webhook.ts +46 -0
- package/src/repositories/workflow.ts +306 -0
- package/src/schema/assets.ts +108 -0
- package/src/schema/auth.ts +166 -0
- package/src/schema/content-types.ts +31 -0
- package/src/schema/content.ts +133 -0
- package/src/schema/index.ts +38 -0
- package/src/schema/menus.ts +61 -0
- package/src/schema/relations.ts +64 -0
- package/src/schema/spaces.ts +20 -0
- package/src/schema/webhooks.ts +46 -0
- package/src/schema/workflows.ts +92 -0
- package/{test/helpers.ts → src/testing-fixtures.ts} +21 -35
- package/src/testing.ts +105 -0
- package/test/asset-usage.test.ts +3 -3
- package/test/menu.test.ts +126 -0
- package/test/publish.test.ts +31 -3
- package/test/query.test.ts +33 -3
- package/test/role.test.ts +81 -0
- package/test/tree.test.ts +3 -3
- package/test/user.test.ts +126 -0
- package/test/webhook.test.ts +48 -0
- package/vitest.config.ts +0 -2
- package/src/schema.ts +0 -513
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Loose,
|
|
3
|
+
ManabloxError,
|
|
4
|
+
type WorkflowCursor,
|
|
5
|
+
type WorkflowRunContext,
|
|
6
|
+
type WorkflowRunStatus,
|
|
7
|
+
type WorkflowSelection,
|
|
8
|
+
type WorkflowStep,
|
|
9
|
+
type WorkflowStepLog,
|
|
10
|
+
type WorkflowTrigger,
|
|
11
|
+
} from '@manablox/core';
|
|
12
|
+
import { and, desc, eq, gte, inArray, isNull, lt, lte, or, sql } from 'drizzle-orm';
|
|
13
|
+
import type { Database } from '../client.js';
|
|
14
|
+
import {
|
|
15
|
+
type ContentRow,
|
|
16
|
+
contents,
|
|
17
|
+
type PushSubscriptionRow,
|
|
18
|
+
pushSubscriptions,
|
|
19
|
+
type WorkflowRow,
|
|
20
|
+
type WorkflowRunRow,
|
|
21
|
+
workflowRuns,
|
|
22
|
+
workflows,
|
|
23
|
+
} from '../schema/index.js';
|
|
24
|
+
|
|
25
|
+
export interface WorkflowWriteData {
|
|
26
|
+
id?: string | undefined;
|
|
27
|
+
spaceId: string;
|
|
28
|
+
name: string;
|
|
29
|
+
description?: string | null | undefined;
|
|
30
|
+
enabled?: boolean | undefined;
|
|
31
|
+
trigger: WorkflowTrigger;
|
|
32
|
+
steps: WorkflowStep[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface WorkflowRunCreateData {
|
|
36
|
+
workflowId: string;
|
|
37
|
+
spaceId: string;
|
|
38
|
+
trigger: string;
|
|
39
|
+
context: WorkflowRunContext;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** How many runs a workflow keeps; older ones are pruned as new ones are written. */
|
|
43
|
+
export const RUNS_KEPT_PER_WORKFLOW = 200;
|
|
44
|
+
|
|
45
|
+
export class WorkflowRepository {
|
|
46
|
+
constructor(private readonly db: Database) {}
|
|
47
|
+
|
|
48
|
+
// --- workflows -------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
async listBySpace(spaceId: string): Promise<WorkflowRow[]> {
|
|
51
|
+
return this.db
|
|
52
|
+
.select()
|
|
53
|
+
.from(workflows)
|
|
54
|
+
.where(eq(workflows.spaceId, spaceId))
|
|
55
|
+
.orderBy(workflows.name);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Every enabled workflow of a space, for the dispatcher; every enabled one at all for the scheduler. */
|
|
59
|
+
async listEnabled(spaceId?: string): Promise<WorkflowRow[]> {
|
|
60
|
+
return this.db
|
|
61
|
+
.select()
|
|
62
|
+
.from(workflows)
|
|
63
|
+
.where(
|
|
64
|
+
spaceId
|
|
65
|
+
? and(eq(workflows.enabled, true), eq(workflows.spaceId, spaceId))
|
|
66
|
+
: eq(workflows.enabled, true),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async findById(id: string): Promise<WorkflowRow | null> {
|
|
71
|
+
const rows = await this.db.select().from(workflows).where(eq(workflows.id, id)).limit(1);
|
|
72
|
+
return rows[0] ?? null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async create(data: WorkflowWriteData): Promise<WorkflowRow> {
|
|
76
|
+
const [row] = await this.db
|
|
77
|
+
.insert(workflows)
|
|
78
|
+
.values({
|
|
79
|
+
...(data.id ? { id: data.id } : {}),
|
|
80
|
+
spaceId: data.spaceId,
|
|
81
|
+
name: data.name,
|
|
82
|
+
description: data.description ?? null,
|
|
83
|
+
enabled: data.enabled ?? false,
|
|
84
|
+
trigger: data.trigger,
|
|
85
|
+
steps: data.steps,
|
|
86
|
+
})
|
|
87
|
+
.returning();
|
|
88
|
+
if (!row) throw new ManabloxError('workflow.create.failed');
|
|
89
|
+
return row;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async update(id: string, data: Loose<Omit<WorkflowWriteData, 'spaceId'>>): Promise<WorkflowRow> {
|
|
93
|
+
const [row] = await this.db
|
|
94
|
+
.update(workflows)
|
|
95
|
+
.set({
|
|
96
|
+
...(data.name !== undefined ? { name: data.name } : {}),
|
|
97
|
+
...(data.description !== undefined ? { description: data.description } : {}),
|
|
98
|
+
...(data.enabled !== undefined ? { enabled: data.enabled } : {}),
|
|
99
|
+
...(data.trigger !== undefined ? { trigger: data.trigger } : {}),
|
|
100
|
+
...(data.steps !== undefined ? { steps: data.steps } : {}),
|
|
101
|
+
updatedAt: new Date(),
|
|
102
|
+
})
|
|
103
|
+
.where(eq(workflows.id, id))
|
|
104
|
+
.returning();
|
|
105
|
+
if (!row) throw ManabloxError.notFound('workflow.notFound', { id });
|
|
106
|
+
return row;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async delete(id: string): Promise<void> {
|
|
110
|
+
await this.db.delete(workflows).where(eq(workflows.id, id));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Claims a scheduled workflow for one minute. Returns false when another process got
|
|
115
|
+
* there first — the update matches nothing once `lastScheduledAt` is already `minute`.
|
|
116
|
+
*/
|
|
117
|
+
async claimSchedule(id: string, minute: Date): Promise<boolean> {
|
|
118
|
+
const rows = await this.db
|
|
119
|
+
.update(workflows)
|
|
120
|
+
.set({ lastScheduledAt: minute })
|
|
121
|
+
.where(
|
|
122
|
+
and(
|
|
123
|
+
eq(workflows.id, id),
|
|
124
|
+
or(isNull(workflows.lastScheduledAt), lt(workflows.lastScheduledAt, minute)),
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
.returning({ id: workflows.id });
|
|
128
|
+
return rows.length > 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async touchRun(id: string, at: Date): Promise<void> {
|
|
132
|
+
await this.db.update(workflows).set({ lastRunAt: at }).where(eq(workflows.id, id));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// --- runs ------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
async createRun(data: WorkflowRunCreateData): Promise<WorkflowRunRow> {
|
|
138
|
+
const [row] = await this.db
|
|
139
|
+
.insert(workflowRuns)
|
|
140
|
+
.values({
|
|
141
|
+
workflowId: data.workflowId,
|
|
142
|
+
spaceId: data.spaceId,
|
|
143
|
+
trigger: data.trigger,
|
|
144
|
+
context: data.context,
|
|
145
|
+
status: 'queued',
|
|
146
|
+
})
|
|
147
|
+
.returning();
|
|
148
|
+
if (!row) throw new ManabloxError('workflow.create.failed');
|
|
149
|
+
await this.pruneRuns(data.workflowId);
|
|
150
|
+
return row;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async findRun(id: string): Promise<WorkflowRunRow | null> {
|
|
154
|
+
const rows = await this.db.select().from(workflowRuns).where(eq(workflowRuns.id, id)).limit(1);
|
|
155
|
+
return rows[0] ?? null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async listRuns(workflowId: string, limit = 50): Promise<WorkflowRunRow[]> {
|
|
159
|
+
return this.db
|
|
160
|
+
.select()
|
|
161
|
+
.from(workflowRuns)
|
|
162
|
+
.where(eq(workflowRuns.workflowId, workflowId))
|
|
163
|
+
.orderBy(desc(workflowRuns.createdAt))
|
|
164
|
+
.limit(limit);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Moves a run from `queued` or `waiting` to `running`, or reports that it is not
|
|
169
|
+
* there to be moved. The status check in the predicate is what keeps two workers off
|
|
170
|
+
* the same run.
|
|
171
|
+
*/
|
|
172
|
+
async claimRun(id: string): Promise<WorkflowRunRow | null> {
|
|
173
|
+
const rows = await this.db
|
|
174
|
+
.update(workflowRuns)
|
|
175
|
+
.set({ status: 'running', startedAt: sql`coalesce(${workflowRuns.startedAt}, now())` })
|
|
176
|
+
.where(and(eq(workflowRuns.id, id), inArray(workflowRuns.status, ['queued', 'waiting'])))
|
|
177
|
+
.returning();
|
|
178
|
+
return rows[0] ?? null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Runs paused by a delay step whose time has come. */
|
|
182
|
+
async dueRuns(now: Date, limit = 100): Promise<WorkflowRunRow[]> {
|
|
183
|
+
return this.db
|
|
184
|
+
.select()
|
|
185
|
+
.from(workflowRuns)
|
|
186
|
+
.where(and(eq(workflowRuns.status, 'waiting'), lte(workflowRuns.resumeAt, now)))
|
|
187
|
+
.orderBy(workflowRuns.resumeAt)
|
|
188
|
+
.limit(limit);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async saveRunProgress(
|
|
192
|
+
id: string,
|
|
193
|
+
data: {
|
|
194
|
+
status: WorkflowRunStatus;
|
|
195
|
+
cursor: WorkflowCursor;
|
|
196
|
+
log: WorkflowStepLog[];
|
|
197
|
+
error?: string | null | undefined;
|
|
198
|
+
resumeAt?: Date | null | undefined;
|
|
199
|
+
finished?: boolean | undefined;
|
|
200
|
+
},
|
|
201
|
+
): Promise<void> {
|
|
202
|
+
await this.db
|
|
203
|
+
.update(workflowRuns)
|
|
204
|
+
.set({
|
|
205
|
+
status: data.status,
|
|
206
|
+
cursor: data.cursor,
|
|
207
|
+
log: data.log,
|
|
208
|
+
error: data.error ?? null,
|
|
209
|
+
resumeAt: data.resumeAt ?? null,
|
|
210
|
+
...(data.finished ? { finishedAt: new Date() } : {}),
|
|
211
|
+
})
|
|
212
|
+
.where(eq(workflowRuns.id, id));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private async pruneRuns(workflowId: string): Promise<void> {
|
|
216
|
+
await this.db.execute(sql`
|
|
217
|
+
delete from ${workflowRuns}
|
|
218
|
+
where ${workflowRuns.workflowId} = ${workflowId}
|
|
219
|
+
and ${workflowRuns.id} in (
|
|
220
|
+
select id from ${workflowRuns}
|
|
221
|
+
where ${workflowRuns.workflowId} = ${workflowId}
|
|
222
|
+
order by ${workflowRuns.createdAt} desc
|
|
223
|
+
offset ${RUNS_KEPT_PER_WORKFLOW}
|
|
224
|
+
)
|
|
225
|
+
`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// --- documents for scheduled runs -----------------------------------------
|
|
229
|
+
|
|
230
|
+
/** The documents a scheduled workflow's selection names, newest change first. */
|
|
231
|
+
async selectDocuments(
|
|
232
|
+
spaceId: string,
|
|
233
|
+
selection: WorkflowSelection,
|
|
234
|
+
limit = 500,
|
|
235
|
+
): Promise<ContentRow[]> {
|
|
236
|
+
const predicates = [eq(contents.spaceId, spaceId)];
|
|
237
|
+
if (selection.typeIds.length) predicates.push(inArray(contents.typeId, selection.typeIds));
|
|
238
|
+
if (selection.status !== 'any') predicates.push(eq(contents.status, selection.status));
|
|
239
|
+
if (selection.locale) predicates.push(eq(contents.locale, selection.locale));
|
|
240
|
+
if (selection.changedWithinHours) {
|
|
241
|
+
const since = new Date(Date.now() - selection.changedWithinHours * 3_600_000);
|
|
242
|
+
predicates.push(gte(contents.updatedAt, since));
|
|
243
|
+
}
|
|
244
|
+
return this.db
|
|
245
|
+
.select()
|
|
246
|
+
.from(contents)
|
|
247
|
+
.where(and(...predicates))
|
|
248
|
+
.orderBy(desc(contents.updatedAt))
|
|
249
|
+
.limit(limit);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// --- push subscriptions ----------------------------------------------------
|
|
253
|
+
|
|
254
|
+
async subscriptionsFor(userIds: string[]): Promise<PushSubscriptionRow[]> {
|
|
255
|
+
if (userIds.length === 0) return [];
|
|
256
|
+
return this.db
|
|
257
|
+
.select()
|
|
258
|
+
.from(pushSubscriptions)
|
|
259
|
+
.where(inArray(pushSubscriptions.userId, userIds));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async subscriptionsOf(userId: string): Promise<PushSubscriptionRow[]> {
|
|
263
|
+
return this.db
|
|
264
|
+
.select()
|
|
265
|
+
.from(pushSubscriptions)
|
|
266
|
+
.where(eq(pushSubscriptions.userId, userId))
|
|
267
|
+
.orderBy(desc(pushSubscriptions.createdAt));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Upserts on the endpoint: a browser re-subscribing keeps one row, not two. */
|
|
271
|
+
async subscribe(data: {
|
|
272
|
+
userId: string;
|
|
273
|
+
endpoint: string;
|
|
274
|
+
keys: { p256dh: string; auth: string };
|
|
275
|
+
userAgent: string | null;
|
|
276
|
+
}): Promise<PushSubscriptionRow> {
|
|
277
|
+
const [row] = await this.db
|
|
278
|
+
.insert(pushSubscriptions)
|
|
279
|
+
.values(data)
|
|
280
|
+
.onConflictDoUpdate({
|
|
281
|
+
target: pushSubscriptions.endpoint,
|
|
282
|
+
set: { userId: data.userId, keys: data.keys, userAgent: data.userAgent },
|
|
283
|
+
})
|
|
284
|
+
.returning();
|
|
285
|
+
if (!row) throw new ManabloxError('workflow.create.failed');
|
|
286
|
+
return row;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async unsubscribe(userId: string, endpoint: string): Promise<void> {
|
|
290
|
+
await this.db
|
|
291
|
+
.delete(pushSubscriptions)
|
|
292
|
+
.where(and(eq(pushSubscriptions.userId, userId), eq(pushSubscriptions.endpoint, endpoint)));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** A push service answered 404/410: the browser is gone, and so is the row. */
|
|
296
|
+
async dropSubscription(id: string): Promise<void> {
|
|
297
|
+
await this.db.delete(pushSubscriptions).where(eq(pushSubscriptions.id, id));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async markSubscriptionUsed(id: string): Promise<void> {
|
|
301
|
+
await this.db
|
|
302
|
+
.update(pushSubscriptions)
|
|
303
|
+
.set({ lastUsedAt: new Date() })
|
|
304
|
+
.where(eq(pushSubscriptions.id, id));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm';
|
|
2
|
+
import {
|
|
3
|
+
boolean,
|
|
4
|
+
index,
|
|
5
|
+
integer,
|
|
6
|
+
jsonb,
|
|
7
|
+
pgTable,
|
|
8
|
+
primaryKey,
|
|
9
|
+
text,
|
|
10
|
+
timestamp,
|
|
11
|
+
uniqueIndex,
|
|
12
|
+
uuid,
|
|
13
|
+
} from 'drizzle-orm/pg-core';
|
|
14
|
+
import { contents } from './content.js';
|
|
15
|
+
import { spaces } from './spaces.js';
|
|
16
|
+
|
|
17
|
+
export const assets = pgTable(
|
|
18
|
+
'assets',
|
|
19
|
+
{
|
|
20
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
21
|
+
spaceId: uuid()
|
|
22
|
+
.notNull()
|
|
23
|
+
.references(() => spaces.id, { onDelete: 'cascade' }),
|
|
24
|
+
driver: text().notNull().default('local'),
|
|
25
|
+
/** Storage-relative key, e.g. `<space>/2026/09/photo.jpg`. */
|
|
26
|
+
key: text().notNull(),
|
|
27
|
+
filename: text().notNull(),
|
|
28
|
+
name: text().notNull(),
|
|
29
|
+
mimeType: text().notNull(),
|
|
30
|
+
size: integer().notNull(),
|
|
31
|
+
width: integer(),
|
|
32
|
+
height: integer(),
|
|
33
|
+
/** Seconds, for audio/video. */
|
|
34
|
+
duration: integer(),
|
|
35
|
+
/** SHA-256 of the bytes, for dedupe. */
|
|
36
|
+
checksum: text(),
|
|
37
|
+
alt: text(),
|
|
38
|
+
title: text(),
|
|
39
|
+
meta: jsonb().$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
|
|
40
|
+
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
41
|
+
updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
42
|
+
createdBy: uuid(),
|
|
43
|
+
},
|
|
44
|
+
(table) => [
|
|
45
|
+
uniqueIndex('assets_driver_key_key').on(table.driver, table.key),
|
|
46
|
+
index('assets_space_idx').on(table.spaceId, table.createdAt),
|
|
47
|
+
index('assets_checksum_idx').on(table.spaceId, table.checksum),
|
|
48
|
+
index('assets_mime_idx').on(table.spaceId, table.mimeType),
|
|
49
|
+
],
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
export const assetVariants = pgTable(
|
|
53
|
+
'asset_variants',
|
|
54
|
+
{
|
|
55
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
56
|
+
assetId: uuid()
|
|
57
|
+
.notNull()
|
|
58
|
+
.references(() => assets.id, { onDelete: 'cascade' }),
|
|
59
|
+
preset: text().notNull(),
|
|
60
|
+
format: text().notNull(),
|
|
61
|
+
key: text().notNull(),
|
|
62
|
+
width: integer(),
|
|
63
|
+
height: integer(),
|
|
64
|
+
size: integer().notNull(),
|
|
65
|
+
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
66
|
+
},
|
|
67
|
+
(table) => [
|
|
68
|
+
uniqueIndex('asset_variants_asset_preset_format_key').on(
|
|
69
|
+
table.assetId,
|
|
70
|
+
table.preset,
|
|
71
|
+
table.format,
|
|
72
|
+
),
|
|
73
|
+
],
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Which documents reference which assets, and whether any of them is published.
|
|
78
|
+
*
|
|
79
|
+
* Assets have no draft/published distinction of their own, so before this table an
|
|
80
|
+
* asset id resolved on the public API whether or not anything published pointed at it —
|
|
81
|
+
* including a file uploaded for a draft that never shipped. Maintained from the same
|
|
82
|
+
* publish/unpublish/delete hooks that purge the cache, out of the references each field
|
|
83
|
+
* type already declares.
|
|
84
|
+
*/
|
|
85
|
+
export const assetUsages = pgTable(
|
|
86
|
+
'asset_usages',
|
|
87
|
+
{
|
|
88
|
+
assetId: uuid()
|
|
89
|
+
.notNull()
|
|
90
|
+
.references(() => assets.id, { onDelete: 'cascade' }),
|
|
91
|
+
contentId: uuid()
|
|
92
|
+
.notNull()
|
|
93
|
+
.references(() => contents.id, { onDelete: 'cascade' }),
|
|
94
|
+
spaceId: uuid()
|
|
95
|
+
.notNull()
|
|
96
|
+
.references(() => spaces.id, { onDelete: 'cascade' }),
|
|
97
|
+
/** True when the *published* projection of `contentId` references the asset. */
|
|
98
|
+
published: boolean().notNull().default(false),
|
|
99
|
+
updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
100
|
+
},
|
|
101
|
+
(table) => [
|
|
102
|
+
primaryKey({ columns: [table.assetId, table.contentId] }),
|
|
103
|
+
// The public asset check is `asset_id = any($1) and published`; this index serves it
|
|
104
|
+
// without touching the heap.
|
|
105
|
+
index('asset_usages_published_idx').on(table.assetId, table.published),
|
|
106
|
+
index('asset_usages_content_idx').on(table.contentId),
|
|
107
|
+
],
|
|
108
|
+
);
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm';
|
|
2
|
+
import {
|
|
3
|
+
boolean,
|
|
4
|
+
index,
|
|
5
|
+
jsonb,
|
|
6
|
+
pgTable,
|
|
7
|
+
primaryKey,
|
|
8
|
+
text,
|
|
9
|
+
timestamp,
|
|
10
|
+
uniqueIndex,
|
|
11
|
+
uuid,
|
|
12
|
+
} from 'drizzle-orm/pg-core';
|
|
13
|
+
import { spaces } from './spaces.js';
|
|
14
|
+
|
|
15
|
+
// better-auth owns the user, session, account and verification tables; they are declared
|
|
16
|
+
// here so Drizzle can join and migrate them.
|
|
17
|
+
|
|
18
|
+
export const users = pgTable(
|
|
19
|
+
'users',
|
|
20
|
+
{
|
|
21
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
22
|
+
name: text().notNull().default(''),
|
|
23
|
+
email: text().notNull(),
|
|
24
|
+
emailVerified: boolean().notNull().default(false),
|
|
25
|
+
image: text(),
|
|
26
|
+
/** Instance-wide role. Space-scoped roles live on `memberships`. */
|
|
27
|
+
role: text().notNull().default('editor'),
|
|
28
|
+
banned: boolean().notNull().default(false),
|
|
29
|
+
banReason: text(),
|
|
30
|
+
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
31
|
+
updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
32
|
+
},
|
|
33
|
+
(table) => [uniqueIndex('users_email_key').on(table.email)],
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
/** One row per active session, so multiple devices can be signed in at once. */
|
|
37
|
+
export const sessions = pgTable(
|
|
38
|
+
'sessions',
|
|
39
|
+
{
|
|
40
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
41
|
+
userId: uuid()
|
|
42
|
+
.notNull()
|
|
43
|
+
.references(() => users.id, { onDelete: 'cascade' }),
|
|
44
|
+
token: text().notNull(),
|
|
45
|
+
expiresAt: timestamp({ withTimezone: true }).notNull(),
|
|
46
|
+
ipAddress: text(),
|
|
47
|
+
userAgent: text(),
|
|
48
|
+
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
49
|
+
updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
50
|
+
},
|
|
51
|
+
(table) => [
|
|
52
|
+
uniqueIndex('sessions_token_key').on(table.token),
|
|
53
|
+
index('sessions_user_idx').on(table.userId),
|
|
54
|
+
index('sessions_expires_idx').on(table.expiresAt),
|
|
55
|
+
],
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
export const accounts = pgTable(
|
|
59
|
+
'accounts',
|
|
60
|
+
{
|
|
61
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
62
|
+
userId: uuid()
|
|
63
|
+
.notNull()
|
|
64
|
+
.references(() => users.id, { onDelete: 'cascade' }),
|
|
65
|
+
accountId: text().notNull(),
|
|
66
|
+
providerId: text().notNull(),
|
|
67
|
+
/** Required by better-auth 1.7 for OIDC issuer disambiguation. */
|
|
68
|
+
issuer: text().notNull().default(''),
|
|
69
|
+
accessToken: text(),
|
|
70
|
+
refreshToken: text(),
|
|
71
|
+
accessTokenExpiresAt: timestamp({ withTimezone: true }),
|
|
72
|
+
refreshTokenExpiresAt: timestamp({ withTimezone: true }),
|
|
73
|
+
scope: text(),
|
|
74
|
+
idToken: text(),
|
|
75
|
+
password: text(),
|
|
76
|
+
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
77
|
+
updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
78
|
+
},
|
|
79
|
+
(table) => [uniqueIndex('accounts_provider_account_key').on(table.providerId, table.accountId)],
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
export const verifications = pgTable(
|
|
83
|
+
'verifications',
|
|
84
|
+
{
|
|
85
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
86
|
+
identifier: text().notNull(),
|
|
87
|
+
value: text().notNull(),
|
|
88
|
+
expiresAt: timestamp({ withTimezone: true }).notNull(),
|
|
89
|
+
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
90
|
+
updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
91
|
+
},
|
|
92
|
+
(table) => [index('verifications_identifier_idx').on(table.identifier)],
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
export const apikeys = pgTable(
|
|
96
|
+
'apikeys',
|
|
97
|
+
{
|
|
98
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
99
|
+
name: text(),
|
|
100
|
+
start: text(),
|
|
101
|
+
prefix: text(),
|
|
102
|
+
key: text().notNull(),
|
|
103
|
+
userId: uuid()
|
|
104
|
+
.notNull()
|
|
105
|
+
.references(() => users.id, { onDelete: 'cascade' }),
|
|
106
|
+
enabled: boolean().notNull().default(true),
|
|
107
|
+
expiresAt: timestamp({ withTimezone: true }),
|
|
108
|
+
lastRequest: timestamp({ withTimezone: true }),
|
|
109
|
+
/**
|
|
110
|
+
* Grants this key is confined to, in the roles' vocabulary; `null` means whatever
|
|
111
|
+
* the owner's role allows. Like `spaceIds`, a restriction only ever narrows.
|
|
112
|
+
*/
|
|
113
|
+
permissions: jsonb().$type<string[] | null>(),
|
|
114
|
+
/**
|
|
115
|
+
* Space ids this key may act in; `null` means every space the owner belongs to.
|
|
116
|
+
* A restriction only ever narrows the owner's own access, never widens it.
|
|
117
|
+
*/
|
|
118
|
+
spaceIds: jsonb().$type<string[] | null>(),
|
|
119
|
+
metadata: text(),
|
|
120
|
+
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
121
|
+
updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
122
|
+
},
|
|
123
|
+
(table) => [index('apikeys_user_idx').on(table.userId)],
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
/** Space-scoped role assignment. */
|
|
127
|
+
export const memberships = pgTable(
|
|
128
|
+
'memberships',
|
|
129
|
+
{
|
|
130
|
+
userId: uuid()
|
|
131
|
+
.notNull()
|
|
132
|
+
.references(() => users.id, { onDelete: 'cascade' }),
|
|
133
|
+
spaceId: uuid()
|
|
134
|
+
.notNull()
|
|
135
|
+
.references(() => spaces.id, { onDelete: 'cascade' }),
|
|
136
|
+
/** The machine name of a built-in role or of a row in `roles` for the same space. */
|
|
137
|
+
role: text().notNull().default('editor'),
|
|
138
|
+
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
139
|
+
},
|
|
140
|
+
(table) => [
|
|
141
|
+
primaryKey({ columns: [table.userId, table.spaceId] }),
|
|
142
|
+
index('memberships_space_idx').on(table.spaceId),
|
|
143
|
+
],
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A role someone created for a space, beside the built-in five. `permissions` holds the
|
|
148
|
+
* grants: `space:write`, `content:read` for every type, or `content:read:<typeId>` for
|
|
149
|
+
* one. Memberships name it by `machineName`, so a custom role is one row and one name.
|
|
150
|
+
*/
|
|
151
|
+
export const roles = pgTable(
|
|
152
|
+
'roles',
|
|
153
|
+
{
|
|
154
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
155
|
+
spaceId: uuid()
|
|
156
|
+
.notNull()
|
|
157
|
+
.references(() => spaces.id, { onDelete: 'cascade' }),
|
|
158
|
+
name: text().notNull(),
|
|
159
|
+
machineName: text().notNull(),
|
|
160
|
+
description: text(),
|
|
161
|
+
permissions: jsonb().$type<string[]>().notNull().default(sql`'[]'::jsonb`),
|
|
162
|
+
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
163
|
+
updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
164
|
+
},
|
|
165
|
+
(table) => [uniqueIndex('roles_space_machine_name_key').on(table.spaceId, table.machineName)],
|
|
166
|
+
);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { FieldDefinition } from '@manablox/core';
|
|
2
|
+
import { sql } from 'drizzle-orm';
|
|
3
|
+
import { boolean, index, jsonb, pgTable, text, timestamp, unique, uuid } from 'drizzle-orm/pg-core';
|
|
4
|
+
import { spaces } from './spaces.js';
|
|
5
|
+
|
|
6
|
+
/** Runtime-defined content types only; code-defined ones live in the registry. */
|
|
7
|
+
export const contentTypes = pgTable(
|
|
8
|
+
'content_types',
|
|
9
|
+
{
|
|
10
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
11
|
+
spaceId: uuid().references(() => spaces.id, { onDelete: 'cascade' }),
|
|
12
|
+
name: text().notNull(),
|
|
13
|
+
label: text().notNull(),
|
|
14
|
+
description: text(),
|
|
15
|
+
icon: text(),
|
|
16
|
+
kind: text().$type<'content' | 'block'>().notNull().default('content'),
|
|
17
|
+
hasSlug: boolean().notNull().default(true),
|
|
18
|
+
isPublishable: boolean().notNull().default(true),
|
|
19
|
+
isVisibleInTree: boolean().notNull().default(true),
|
|
20
|
+
canBeVisibleInMenu: boolean().notNull().default(true),
|
|
21
|
+
fields: jsonb().$type<FieldDefinition[]>().notNull().default(sql`'[]'::jsonb`),
|
|
22
|
+
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
23
|
+
updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
24
|
+
},
|
|
25
|
+
(table) => [
|
|
26
|
+
// NULLS NOT DISTINCT so two global types cannot share a name — plain UNIQUE would
|
|
27
|
+
// treat every NULL space_id as distinct and let duplicates through.
|
|
28
|
+
unique('content_types_space_name_key').on(table.spaceId, table.name).nullsNotDistinct(),
|
|
29
|
+
index('content_types_space_idx').on(table.spaceId),
|
|
30
|
+
],
|
|
31
|
+
);
|