@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.
Files changed (53) hide show
  1. package/README.md +21 -0
  2. package/drizzle.config.ts +1 -1
  3. package/migrations/0005_menus.sql +44 -0
  4. package/migrations/0006_roles.sql +13 -0
  5. package/migrations/0007_apikey-permissions.sql +4 -0
  6. package/migrations/0008_workflows.sql +49 -0
  7. package/migrations/meta/0005_snapshot.json +2504 -0
  8. package/migrations/meta/0006_snapshot.json +2605 -0
  9. package/migrations/meta/0007_snapshot.json +2605 -0
  10. package/migrations/meta/0008_snapshot.json +2986 -0
  11. package/migrations/meta/_journal.json +28 -0
  12. package/package.json +10 -5
  13. package/src/cli/create-db.ts +30 -0
  14. package/src/cli/migrate.ts +2 -9
  15. package/src/client.ts +8 -1
  16. package/src/errors.ts +50 -0
  17. package/src/index.ts +8 -2
  18. package/src/migrate.ts +21 -0
  19. package/src/pagination.ts +52 -0
  20. package/src/query.ts +1 -5
  21. package/src/repositories/asset-usage.ts +1 -1
  22. package/src/repositories/asset.ts +13 -21
  23. package/src/repositories/content-type.ts +1 -1
  24. package/src/repositories/content.ts +150 -97
  25. package/src/repositories/index.ts +12 -0
  26. package/src/repositories/menu.ts +235 -0
  27. package/src/repositories/role.ts +85 -0
  28. package/src/repositories/space.ts +7 -2
  29. package/src/repositories/user.ts +171 -25
  30. package/src/repositories/webhook.ts +46 -0
  31. package/src/repositories/workflow.ts +306 -0
  32. package/src/schema/assets.ts +108 -0
  33. package/src/schema/auth.ts +166 -0
  34. package/src/schema/content-types.ts +31 -0
  35. package/src/schema/content.ts +133 -0
  36. package/src/schema/index.ts +38 -0
  37. package/src/schema/menus.ts +61 -0
  38. package/src/schema/relations.ts +64 -0
  39. package/src/schema/spaces.ts +20 -0
  40. package/src/schema/webhooks.ts +46 -0
  41. package/src/schema/workflows.ts +92 -0
  42. package/{test/helpers.ts → src/testing-fixtures.ts} +21 -35
  43. package/src/testing.ts +105 -0
  44. package/test/asset-usage.test.ts +3 -3
  45. package/test/menu.test.ts +126 -0
  46. package/test/publish.test.ts +31 -3
  47. package/test/query.test.ts +33 -3
  48. package/test/role.test.ts +81 -0
  49. package/test/tree.test.ts +3 -3
  50. package/test/user.test.ts +126 -0
  51. package/test/webhook.test.ts +48 -0
  52. package/vitest.config.ts +0 -2
  53. package/src/schema.ts +0 -513
package/src/schema.ts DELETED
@@ -1,513 +0,0 @@
1
- import type { ContentStatus, FieldDefinition } from '@manablox/core';
2
- import { relations, sql } from 'drizzle-orm';
3
- import {
4
- boolean,
5
- index,
6
- integer,
7
- jsonb,
8
- pgTable,
9
- primaryKey,
10
- text,
11
- timestamp,
12
- unique,
13
- uniqueIndex,
14
- uuid,
15
- } from 'drizzle-orm/pg-core';
16
- import { ltree, tsvector } from './columns.js';
17
-
18
- // ---------------------------------------------------------------------------
19
- // Spaces
20
- // ---------------------------------------------------------------------------
21
-
22
- export const spaces = pgTable(
23
- 'spaces',
24
- {
25
- id: uuid().primaryKey().defaultRandom(),
26
- name: text().notNull(),
27
- machineName: text().notNull(),
28
- description: text(),
29
- /** Public origin of the space's frontend — used by the visual editor iframe. */
30
- url: text().notNull(),
31
- defaultLocale: text().notNull().default('en'),
32
- locales: jsonb().$type<string[]>().notNull().default(sql`'["en"]'::jsonb`),
33
- settings: jsonb().$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
34
- createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
35
- updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
36
- },
37
- (table) => [uniqueIndex('spaces_machine_name_key').on(table.machineName)],
38
- );
39
-
40
- // ---------------------------------------------------------------------------
41
- // Content types (runtime-defined only; code-defined live in the registry)
42
- // ---------------------------------------------------------------------------
43
-
44
- export const contentTypes = pgTable(
45
- 'content_types',
46
- {
47
- id: uuid().primaryKey().defaultRandom(),
48
- spaceId: uuid().references(() => spaces.id, { onDelete: 'cascade' }),
49
- name: text().notNull(),
50
- label: text().notNull(),
51
- description: text(),
52
- icon: text(),
53
- kind: text().$type<'content' | 'block'>().notNull().default('content'),
54
- hasSlug: boolean().notNull().default(true),
55
- isPublishable: boolean().notNull().default(true),
56
- isVisibleInTree: boolean().notNull().default(true),
57
- canBeVisibleInMenu: boolean().notNull().default(true),
58
- fields: jsonb().$type<FieldDefinition[]>().notNull().default(sql`'[]'::jsonb`),
59
- createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
60
- updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
61
- },
62
- (table) => [
63
- // NULLS NOT DISTINCT so two global types cannot share a name — plain UNIQUE would
64
- // treat every NULL space_id as distinct and let duplicates through.
65
- unique('content_types_space_name_key').on(table.spaceId, table.name).nullsNotDistinct(),
66
- index('content_types_space_idx').on(table.spaceId),
67
- ],
68
- );
69
-
70
- // ---------------------------------------------------------------------------
71
- // Content
72
- // ---------------------------------------------------------------------------
73
-
74
- /** Shared column set for `contents` and its published projection. */
75
- const contentColumns = {
76
- id: uuid().primaryKey().defaultRandom(),
77
- spaceId: uuid()
78
- .notNull()
79
- .references(() => spaces.id, { onDelete: 'cascade' }),
80
- typeId: uuid().notNull(),
81
- locale: text().notNull(),
82
- /** Shared by all translations of one logical document. */
83
- localizationId: uuid().notNull(),
84
- parentId: uuid(),
85
- title: text().notNull(),
86
- slug: text().notNull(),
87
- /** Materialised ancestor path, root first, ending in this node's own id. */
88
- path: ltree().notNull(),
89
- /** Routable URL path. NULL when the content type has no slug — such a node is a
90
- * structural container, not a page, so it must not occupy a permalink of its own. */
91
- permalink: text(),
92
- /**
93
- * Accumulated permalink prefix: every ancestor segment plus this node's own, with
94
- * slug-less levels skipped. Descendants derive from this rather than from `permalink`,
95
- * which is NULL for a slug-less node and would otherwise truncate the chain.
96
- */
97
- permalinkPath: text().notNull().default(''),
98
- /**
99
- * This node's own contribution to its descendants' permalinks: the slug when the
100
- * content type has one, NULL when it does not.
101
- *
102
- * Denormalised deliberately, so permalink recomputation is a single recursive CTE that
103
- * needs no knowledge of the type registry.
104
- */
105
- permalinkSegment: text(),
106
- status: text().$type<ContentStatus>().notNull().default('draft'),
107
- visibleInMenu: boolean().notNull().default(false),
108
- position: integer().notNull().default(0),
109
- fields: jsonb().$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
110
- /** Concatenated contributions from each field type's `search()`. */
111
- searchText: text().notNull().default(''),
112
- /** Optimistic-lock counter; a write with a stale version is rejected. */
113
- version: integer().notNull().default(1),
114
- createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
115
- updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
116
- createdBy: uuid(),
117
- updatedBy: uuid(),
118
- publishedAt: timestamp({ withTimezone: true }),
119
- };
120
-
121
- export const contents = pgTable(
122
- 'contents',
123
- {
124
- ...contentColumns,
125
- search: tsvector().generatedAlwaysAs(
126
- sql`to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(search_text, ''))`,
127
- ),
128
- },
129
- (table) => [
130
- index('contents_path_gist_idx').using('gist', table.path),
131
- index('contents_parent_idx').on(table.parentId),
132
- index('contents_space_type_idx').on(table.spaceId, table.typeId),
133
- index('contents_localization_idx').on(table.localizationId),
134
- index('contents_status_idx').on(table.spaceId, table.status),
135
- index('contents_fields_gin_idx').using('gin', sql`${table.fields} jsonb_path_ops`),
136
- index('contents_search_gin_idx').using('gin', table.search),
137
- // NULLS NOT DISTINCT so two root-level siblings cannot share a slug — with the
138
- // default NULLS DISTINCT, every `parent_id IS NULL` row is unique by definition and
139
- // the constraint would never fire at the top of the tree.
140
- unique('contents_sibling_slug_key')
141
- .on(table.spaceId, table.parentId, table.locale, table.slug)
142
- .nullsNotDistinct(),
143
- uniqueIndex('contents_permalink_key')
144
- .on(table.spaceId, table.locale, table.permalink)
145
- .where(sql`permalink is not null`),
146
- ],
147
- );
148
-
149
- /**
150
- * The delivery projection. Written transactionally on publish, read by the public
151
- * GraphQL API. Mirrors `contents` so a row can be copied column-for-column.
152
- */
153
- export const publishedContents = pgTable(
154
- 'published_contents',
155
- {
156
- ...contentColumns,
157
- /** Version of `contents` this projection was made from. */
158
- sourceVersion: integer().notNull().default(1),
159
- search: tsvector().generatedAlwaysAs(
160
- sql`to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(search_text, ''))`,
161
- ),
162
- },
163
- (table) => [
164
- index('published_contents_path_gist_idx').using('gist', table.path),
165
- index('published_contents_parent_idx').on(table.parentId),
166
- index('published_contents_space_type_idx').on(table.spaceId, table.typeId),
167
- index('published_contents_fields_gin_idx').using('gin', sql`${table.fields} jsonb_path_ops`),
168
- index('published_contents_search_gin_idx').using('gin', table.search),
169
- uniqueIndex('published_contents_permalink_key')
170
- .on(table.spaceId, table.locale, table.permalink)
171
- .where(sql`permalink is not null`),
172
- ],
173
- );
174
-
175
- /** Full snapshot per save, so any edit can be rolled back. */
176
- export const contentVersions = pgTable(
177
- 'content_versions',
178
- {
179
- id: uuid().primaryKey().defaultRandom(),
180
- contentId: uuid().notNull(),
181
- version: integer().notNull(),
182
- label: text(),
183
- snapshot: jsonb().$type<Record<string, unknown>>().notNull(),
184
- createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
185
- createdBy: uuid(),
186
- },
187
- (table) => [
188
- uniqueIndex('content_versions_content_version_key').on(table.contentId, table.version),
189
- index('content_versions_content_idx').on(table.contentId, table.createdAt),
190
- ],
191
- );
192
-
193
- // ---------------------------------------------------------------------------
194
- // Assets
195
- // ---------------------------------------------------------------------------
196
-
197
- export const assets = pgTable(
198
- 'assets',
199
- {
200
- id: uuid().primaryKey().defaultRandom(),
201
- spaceId: uuid()
202
- .notNull()
203
- .references(() => spaces.id, { onDelete: 'cascade' }),
204
- driver: text().notNull().default('local'),
205
- /** Storage-relative key, e.g. `<space>/2026/09/photo.jpg`. */
206
- key: text().notNull(),
207
- filename: text().notNull(),
208
- name: text().notNull(),
209
- mimeType: text().notNull(),
210
- size: integer().notNull(),
211
- width: integer(),
212
- height: integer(),
213
- /** Seconds, for audio/video. */
214
- duration: integer(),
215
- /** SHA-256 of the bytes, for dedupe. */
216
- checksum: text(),
217
- alt: text(),
218
- title: text(),
219
- meta: jsonb().$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
220
- createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
221
- updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
222
- createdBy: uuid(),
223
- },
224
- (table) => [
225
- uniqueIndex('assets_driver_key_key').on(table.driver, table.key),
226
- index('assets_space_idx').on(table.spaceId, table.createdAt),
227
- index('assets_checksum_idx').on(table.spaceId, table.checksum),
228
- index('assets_mime_idx').on(table.spaceId, table.mimeType),
229
- ],
230
- );
231
-
232
- export const assetVariants = pgTable(
233
- 'asset_variants',
234
- {
235
- id: uuid().primaryKey().defaultRandom(),
236
- assetId: uuid()
237
- .notNull()
238
- .references(() => assets.id, { onDelete: 'cascade' }),
239
- preset: text().notNull(),
240
- format: text().notNull(),
241
- key: text().notNull(),
242
- width: integer(),
243
- height: integer(),
244
- size: integer().notNull(),
245
- createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
246
- },
247
- (table) => [
248
- uniqueIndex('asset_variants_asset_preset_format_key').on(
249
- table.assetId,
250
- table.preset,
251
- table.format,
252
- ),
253
- ],
254
- );
255
-
256
- /**
257
- * Which documents reference which assets, and whether any of them is published.
258
- *
259
- * Assets have no draft/published distinction of their own, so before this table an
260
- * asset id resolved on the public API whether or not anything published pointed at it —
261
- * including a file uploaded for a draft that never shipped. Maintained from the same
262
- * publish/unpublish/delete hooks that purge the cache, out of the references each field
263
- * type already declares.
264
- */
265
- export const assetUsages = pgTable(
266
- 'asset_usages',
267
- {
268
- assetId: uuid()
269
- .notNull()
270
- .references(() => assets.id, { onDelete: 'cascade' }),
271
- contentId: uuid()
272
- .notNull()
273
- .references(() => contents.id, { onDelete: 'cascade' }),
274
- spaceId: uuid()
275
- .notNull()
276
- .references(() => spaces.id, { onDelete: 'cascade' }),
277
- /** True when the *published* projection of `contentId` references the asset. */
278
- published: boolean().notNull().default(false),
279
- updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
280
- },
281
- (table) => [
282
- primaryKey({ columns: [table.assetId, table.contentId] }),
283
- // The public asset check is `asset_id = any($1) and published`; this index serves it
284
- // without touching the heap.
285
- index('asset_usages_published_idx').on(table.assetId, table.published),
286
- index('asset_usages_content_idx').on(table.contentId),
287
- ],
288
- );
289
-
290
- // ---------------------------------------------------------------------------
291
- // Auth (better-auth owns these; declared here so Drizzle can join and migrate them)
292
- // ---------------------------------------------------------------------------
293
-
294
- export const users = pgTable(
295
- 'users',
296
- {
297
- id: uuid().primaryKey().defaultRandom(),
298
- name: text().notNull().default(''),
299
- email: text().notNull(),
300
- emailVerified: boolean().notNull().default(false),
301
- image: text(),
302
- /** Instance-wide role. Space-scoped roles live on `memberships`. */
303
- role: text().notNull().default('editor'),
304
- banned: boolean().notNull().default(false),
305
- banReason: text(),
306
- createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
307
- updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
308
- },
309
- (table) => [uniqueIndex('users_email_key').on(table.email)],
310
- );
311
-
312
- /** One row per active session, so multiple devices can be signed in at once. */
313
- export const sessions = pgTable(
314
- 'sessions',
315
- {
316
- id: uuid().primaryKey().defaultRandom(),
317
- userId: uuid()
318
- .notNull()
319
- .references(() => users.id, { onDelete: 'cascade' }),
320
- token: text().notNull(),
321
- expiresAt: timestamp({ withTimezone: true }).notNull(),
322
- ipAddress: text(),
323
- userAgent: text(),
324
- createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
325
- updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
326
- },
327
- (table) => [
328
- uniqueIndex('sessions_token_key').on(table.token),
329
- index('sessions_user_idx').on(table.userId),
330
- index('sessions_expires_idx').on(table.expiresAt),
331
- ],
332
- );
333
-
334
- export const accounts = pgTable(
335
- 'accounts',
336
- {
337
- id: uuid().primaryKey().defaultRandom(),
338
- userId: uuid()
339
- .notNull()
340
- .references(() => users.id, { onDelete: 'cascade' }),
341
- accountId: text().notNull(),
342
- providerId: text().notNull(),
343
- /** Required by better-auth 1.7 for OIDC issuer disambiguation. */
344
- issuer: text().notNull().default(''),
345
- accessToken: text(),
346
- refreshToken: text(),
347
- accessTokenExpiresAt: timestamp({ withTimezone: true }),
348
- refreshTokenExpiresAt: timestamp({ withTimezone: true }),
349
- scope: text(),
350
- idToken: text(),
351
- password: text(),
352
- createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
353
- updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
354
- },
355
- (table) => [uniqueIndex('accounts_provider_account_key').on(table.providerId, table.accountId)],
356
- );
357
-
358
- export const verifications = pgTable(
359
- 'verifications',
360
- {
361
- id: uuid().primaryKey().defaultRandom(),
362
- identifier: text().notNull(),
363
- value: text().notNull(),
364
- expiresAt: timestamp({ withTimezone: true }).notNull(),
365
- createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
366
- updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
367
- },
368
- (table) => [index('verifications_identifier_idx').on(table.identifier)],
369
- );
370
-
371
- export const apikeys = pgTable(
372
- 'apikeys',
373
- {
374
- id: uuid().primaryKey().defaultRandom(),
375
- name: text(),
376
- start: text(),
377
- prefix: text(),
378
- key: text().notNull(),
379
- userId: uuid()
380
- .notNull()
381
- .references(() => users.id, { onDelete: 'cascade' }),
382
- enabled: boolean().notNull().default(true),
383
- expiresAt: timestamp({ withTimezone: true }),
384
- lastRequest: timestamp({ withTimezone: true }),
385
- permissions: text(),
386
- /**
387
- * Space ids this key may act in; `null` means every space the owner belongs to.
388
- * A restriction only ever narrows the owner's own access, never widens it.
389
- */
390
- spaceIds: jsonb().$type<string[] | null>(),
391
- metadata: text(),
392
- createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
393
- updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
394
- },
395
- (table) => [index('apikeys_user_idx').on(table.userId)],
396
- );
397
-
398
- /** Space-scoped role assignment. */
399
- export const memberships = pgTable(
400
- 'memberships',
401
- {
402
- userId: uuid()
403
- .notNull()
404
- .references(() => users.id, { onDelete: 'cascade' }),
405
- spaceId: uuid()
406
- .notNull()
407
- .references(() => spaces.id, { onDelete: 'cascade' }),
408
- role: text()
409
- .$type<'owner' | 'admin' | 'editor' | 'author' | 'viewer'>()
410
- .notNull()
411
- .default('editor'),
412
- createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
413
- },
414
- (table) => [
415
- primaryKey({ columns: [table.userId, table.spaceId] }),
416
- index('memberships_space_idx').on(table.spaceId),
417
- ],
418
- );
419
-
420
- // ---------------------------------------------------------------------------
421
- // Webhooks
422
- // ---------------------------------------------------------------------------
423
-
424
- export const webhooks = pgTable(
425
- 'webhooks',
426
- {
427
- id: uuid().primaryKey().defaultRandom(),
428
- spaceId: uuid()
429
- .notNull()
430
- .references(() => spaces.id, { onDelete: 'cascade' }),
431
- name: text().notNull(),
432
- url: text().notNull(),
433
- secret: text(),
434
- events: jsonb().$type<string[]>().notNull().default(sql`'[]'::jsonb`),
435
- enabled: boolean().notNull().default(true),
436
- createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
437
- },
438
- (table) => [index('webhooks_space_idx').on(table.spaceId)],
439
- );
440
-
441
- export const webhookDeliveries = pgTable(
442
- 'webhook_deliveries',
443
- {
444
- id: uuid().primaryKey().defaultRandom(),
445
- webhookId: uuid()
446
- .notNull()
447
- .references(() => webhooks.id, { onDelete: 'cascade' }),
448
- event: text().notNull(),
449
- payload: jsonb().$type<Record<string, unknown>>().notNull(),
450
- status: integer(),
451
- error: text(),
452
- attempt: integer().notNull().default(1),
453
- createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
454
- },
455
- (table) => [index('webhook_deliveries_webhook_idx').on(table.webhookId, table.createdAt)],
456
- );
457
-
458
- // ---------------------------------------------------------------------------
459
- // Relations
460
- // ---------------------------------------------------------------------------
461
-
462
- export const spacesRelations = relations(spaces, ({ many }) => ({
463
- contents: many(contents),
464
- contentTypes: many(contentTypes),
465
- assets: many(assets),
466
- memberships: many(memberships),
467
- }));
468
-
469
- export const contentsRelations = relations(contents, ({ one, many }) => ({
470
- space: one(spaces, { fields: [contents.spaceId], references: [spaces.id] }),
471
- parent: one(contents, {
472
- fields: [contents.parentId],
473
- references: [contents.id],
474
- relationName: 'tree',
475
- }),
476
- children: many(contents, { relationName: 'tree' }),
477
- versions: many(contentVersions),
478
- }));
479
-
480
- export const assetsRelations = relations(assets, ({ one, many }) => ({
481
- space: one(spaces, { fields: [assets.spaceId], references: [spaces.id] }),
482
- variants: many(assetVariants),
483
- }));
484
-
485
- export const assetVariantsRelations = relations(assetVariants, ({ one }) => ({
486
- asset: one(assets, { fields: [assetVariants.assetId], references: [assets.id] }),
487
- }));
488
-
489
- export const assetUsagesRelations = relations(assetUsages, ({ one }) => ({
490
- asset: one(assets, { fields: [assetUsages.assetId], references: [assets.id] }),
491
- content: one(contents, { fields: [assetUsages.contentId], references: [contents.id] }),
492
- }));
493
-
494
- export const usersRelations = relations(users, ({ many }) => ({
495
- sessions: many(sessions),
496
- memberships: many(memberships),
497
- }));
498
-
499
- export const membershipsRelations = relations(memberships, ({ one }) => ({
500
- user: one(users, { fields: [memberships.userId], references: [users.id] }),
501
- space: one(spaces, { fields: [memberships.spaceId], references: [spaces.id] }),
502
- }));
503
-
504
- export type SpaceRow = typeof spaces.$inferSelect;
505
- export type ContentRow = typeof contents.$inferSelect;
506
- export type ContentInsert = typeof contents.$inferInsert;
507
- export type ContentTypeRow = typeof contentTypes.$inferSelect;
508
- export type AssetRow = typeof assets.$inferSelect;
509
- export type AssetVariantRow = typeof assetVariants.$inferSelect;
510
- export type AssetUsageRow = typeof assetUsages.$inferSelect;
511
- export type UserRow = typeof users.$inferSelect;
512
- export type MembershipRow = typeof memberships.$inferSelect;
513
- export type ContentVersionRow = typeof contentVersions.$inferSelect;