@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
@@ -1,7 +1,8 @@
1
1
  import { type ContentStatus, type ContentTypeRegistry, ManabloxError } from '@manablox/core';
2
2
  import { and, eq, getTableColumns, inArray, type SQL, sql } from 'drizzle-orm';
3
- import type { Database } from '../client.js';
3
+ import type { Executor } from '../client.js';
4
4
  import { idToLabel } from '../columns.js';
5
+ import { type Paginated, paginate } from '../pagination.js';
5
6
  import {
6
7
  buildContentWhere,
7
8
  buildOrderBy,
@@ -15,9 +16,9 @@ import {
15
16
  contentVersions,
16
17
  publishedContents,
17
18
  spaces,
18
- } from '../schema.js';
19
+ } from '../schema/index.js';
19
20
 
20
- export type { ContentFilter, ContentSort, Pagination };
21
+ export type { ContentFilter, ContentSort, Paginated, Pagination };
21
22
 
22
23
  export interface ContentWriteData {
23
24
  id?: string | undefined;
@@ -30,7 +31,6 @@ export interface ContentWriteData {
30
31
  slug: string;
31
32
  fields: Record<string, unknown>;
32
33
  searchText?: string | undefined;
33
- visibleInMenu?: boolean | undefined;
34
34
  position?: number | undefined;
35
35
  status?: ContentStatus | undefined;
36
36
  /** Whether the content type contributes its slug to descendants' permalinks. */
@@ -46,16 +46,40 @@ export interface TreeNode {
46
46
  children: TreeNode[];
47
47
  }
48
48
 
49
- export interface Paginated<T> {
50
- items: T[];
51
- total: number;
52
- limit: number;
53
- offset: number;
54
- }
49
+ /**
50
+ * The columns `publish()` copies from the draft into the projection: every column the
51
+ * two tables share, in the projection's order, derived from the schema so a new column
52
+ * cannot be forgotten on one side. `source_version` exists only on the projection and
53
+ * is filled from `version`.
54
+ */
55
+ const PROJECTION_COLUMNS: string[] = (() => {
56
+ // The schema relies on `casing: 'snake_case'`, which Drizzle applies when it builds a
57
+ // query — `column.name` is still the TypeScript key, so the conversion is repeated here.
58
+ const columnNames = (table: typeof contents | typeof publishedContents) =>
59
+ Object.values(getTableColumns(table))
60
+ // `search` is a generated tsvector; Postgres refuses an explicit value for it.
61
+ .filter((column) => column.generated === undefined)
62
+ .map((column) =>
63
+ column.name.replace(/[A-Z]/g, (letter: string) => `_${letter.toLowerCase()}`),
64
+ );
65
+ const draft = new Set(columnNames(contents));
66
+ return columnNames(publishedContents).filter(
67
+ (name) => draft.has(name) || name === 'source_version',
68
+ );
69
+ })();
70
+
71
+ /** Columns that identify the row and are never rewritten on a republish. */
72
+ const PROJECTION_IDENTITY = new Set([
73
+ 'id',
74
+ 'space_id',
75
+ 'localization_id',
76
+ 'created_at',
77
+ 'created_by',
78
+ ]);
55
79
 
56
80
  export class ContentRepository {
57
81
  constructor(
58
- private readonly db: Database,
82
+ private readonly db: Executor,
59
83
  private readonly registry: ContentTypeRegistry,
60
84
  ) {}
61
85
 
@@ -170,6 +194,26 @@ export class ContentRepository {
170
194
  /**
171
195
  * Every other row in a document's localization group — its translations.
172
196
  */
197
+ /**
198
+ * One document per localization group, for checking that several groups exist in a
199
+ * space at once — a menu's entries, say. The locale returned is whichever sorts first;
200
+ * a caller that needs a particular one asks `localizationSiblings` for that group.
201
+ */
202
+ async findByLocalizationIds(spaceId: string, localizationIds: string[]): Promise<ContentRow[]> {
203
+ if (localizationIds.length === 0) return [];
204
+ const rows = await this.db
205
+ .select()
206
+ .from(contents)
207
+ .where(and(eq(contents.spaceId, spaceId), inArray(contents.localizationId, localizationIds)))
208
+ .orderBy(contents.localizationId, contents.locale);
209
+ const seen = new Set<string>();
210
+ return (rows as unknown as ContentRow[]).filter((row) => {
211
+ if (seen.has(row.localizationId)) return false;
212
+ seen.add(row.localizationId);
213
+ return true;
214
+ });
215
+ }
216
+
173
217
  async localizationSiblings(
174
218
  spaceId: string,
175
219
  localizationId: string,
@@ -208,23 +252,12 @@ export class ContentRepository {
208
252
  ): Promise<Paginated<ContentRow>> {
209
253
  const table = published ? publishedContents : contents;
210
254
  const where = buildContentWhere(table, filter, this.registry);
211
-
212
- // A window count rides along with the page, so pagination is one round trip.
213
- const rows = await this.db
214
- .select({ ...getTableColumns(table), total: sql<number>`count(*) over ()::int` })
215
- .from(table)
216
- .where(where)
217
- .orderBy(buildOrderBy(sorts))
218
- .limit(pagination.limit)
219
- .offset(pagination.offset);
220
-
221
- const items = rows as Array<ContentRow & { total: number }>;
222
- return {
223
- items: items.map(({ total: _total, ...row }) => row as ContentRow),
224
- total: items[0]?.total ?? 0,
225
- limit: pagination.limit,
226
- offset: pagination.offset,
227
- };
255
+ const page = await paginate(this.db, table, {
256
+ where,
257
+ orderBy: buildOrderBy(sorts),
258
+ pagination,
259
+ });
260
+ return page as unknown as Paginated<ContentRow>;
228
261
  }
229
262
 
230
263
  /**
@@ -281,12 +314,9 @@ export class ContentRepository {
281
314
  async create(data: ContentWriteData): Promise<ContentRow> {
282
315
  return this.db.transaction(async (tx) => {
283
316
  const id = data.id ?? crypto.randomUUID();
284
- const parentPath = await this.parentPath(tx as unknown as Database, data.parentId ?? null);
317
+ const parentPath = await this.parentPath(tx, data.parentId ?? null);
285
318
  const path = parentPath ? `${parentPath}.${idToLabel(id)}` : idToLabel(id);
286
- const parentPrefix = await this.parentPermalinkPath(
287
- tx as unknown as Database,
288
- data.parentId ?? null,
289
- );
319
+ const parentPrefix = await this.parentPermalinkPath(tx, data.parentId ?? null);
290
320
  const segment = data.hasSlug ? data.slug : null;
291
321
  const permalinkPath = joinSegment(parentPrefix, segment);
292
322
  const permalink = segment === null ? null : permalinkPath;
@@ -307,7 +337,6 @@ export class ContentRepository {
307
337
  permalinkPath,
308
338
  permalinkSegment: segment,
309
339
  status: data.status ?? 'draft',
310
- visibleInMenu: data.visibleInMenu ?? false,
311
340
  position: data.position ?? 0,
312
341
  fields: data.fields,
313
342
  searchText: data.searchText ?? '',
@@ -318,14 +347,14 @@ export class ContentRepository {
318
347
  .returning();
319
348
 
320
349
  if (!row) throw new ManabloxError('content.create.failed');
321
- await this.snapshot(tx as unknown as Database, row, data.actorId ?? null);
350
+ await this.snapshot(tx, row, data.actorId ?? null);
322
351
  return row as ContentRow;
323
352
  });
324
353
  }
325
354
 
326
355
  async update(id: string, data: ContentWriteData): Promise<ContentRow> {
327
356
  return this.db.transaction(async (tx) => {
328
- const current = await this.lockRow(tx as unknown as Database, id);
357
+ const current = await this.lockRow(tx, id);
329
358
 
330
359
  if (data.expectedVersion !== undefined && data.expectedVersion !== current.version) {
331
360
  throw ManabloxError.conflict('content.version.conflict', {
@@ -339,10 +368,10 @@ export class ContentRepository {
339
368
  const segmentChanged = segment !== current.permalinkSegment;
340
369
 
341
370
  if (parentChanged) {
342
- await this.assertNotOwnDescendant(tx as unknown as Database, id, data.parentId ?? null);
371
+ await this.assertNotOwnDescendant(tx, id, data.parentId ?? null);
343
372
  }
344
373
 
345
- const parentPath = await this.parentPath(tx as unknown as Database, data.parentId ?? null);
374
+ const parentPath = await this.parentPath(tx, data.parentId ?? null);
346
375
  const newPath = parentPath ? `${parentPath}.${idToLabel(id)}` : idToLabel(id);
347
376
 
348
377
  const [row] = await tx
@@ -357,7 +386,6 @@ export class ContentRepository {
357
386
  permalinkSegment: segment,
358
387
  fields: data.fields,
359
388
  searchText: data.searchText ?? '',
360
- visibleInMenu: data.visibleInMenu ?? current.visibleInMenu,
361
389
  position: data.position ?? current.position,
362
390
  version: current.version + 1,
363
391
  updatedAt: new Date(),
@@ -369,15 +397,15 @@ export class ContentRepository {
369
397
  if (!row) throw ManabloxError.notFound('content.notFound', { id });
370
398
 
371
399
  if (parentChanged) {
372
- await this.moveSubtree(tx as unknown as Database, id, current.path, newPath);
400
+ await this.moveSubtree(tx, id, current.path, newPath);
373
401
  }
374
402
 
375
403
  if (parentChanged || segmentChanged) {
376
- await this.recomputePermalinks(tx as unknown as Database, contents, id);
404
+ await this.recomputePermalinks(tx, contents, id);
377
405
  }
378
406
 
379
- const fresh = (await this.findRow(tx as unknown as Database, id)) ?? (row as ContentRow);
380
- await this.snapshot(tx as unknown as Database, fresh, data.actorId ?? null);
407
+ const fresh = (await this.findRow(tx, id)) ?? (row as ContentRow);
408
+ await this.snapshot(tx, fresh, data.actorId ?? null);
381
409
  return fresh;
382
410
  });
383
411
  }
@@ -400,7 +428,7 @@ export class ContentRepository {
400
428
  */
401
429
  async move(id: string, parentId: string | null, position: number): Promise<ContentRow> {
402
430
  return this.db.transaction(async (tx) => {
403
- const db = tx as unknown as Database;
431
+ const db = tx;
404
432
  const current = await this.findRow(db, id);
405
433
  if (!current) throw ManabloxError.notFound('content.notFound', { id });
406
434
 
@@ -433,9 +461,7 @@ export class ContentRepository {
433
461
  .set({ parentId, path: newPath, updatedAt: new Date() })
434
462
  .where(eq(contents.id, id));
435
463
 
436
- for (const [at, sibling] of order.entries()) {
437
- await tx.update(contents).set({ position: at }).where(eq(contents.id, sibling));
438
- }
464
+ await this.renumber(db, order);
439
465
 
440
466
  if (parentChanged) {
441
467
  await this.moveSubtree(db, id, current.path, newPath);
@@ -455,9 +481,10 @@ export class ContentRepository {
455
481
  ),
456
482
  )
457
483
  .orderBy(sql`${contents.position} asc, ${contents.title} asc`)) as { id: string }[];
458
- for (const [at, sibling] of former.entries()) {
459
- await tx.update(contents).set({ position: at }).where(eq(contents.id, sibling.id));
460
- }
484
+ await this.renumber(
485
+ db,
486
+ former.map((row) => row.id),
487
+ );
461
488
  }
462
489
 
463
490
  const fresh = await this.findRow(db, id);
@@ -466,8 +493,32 @@ export class ContentRepository {
466
493
  });
467
494
  }
468
495
 
496
+ /**
497
+ * Writes `position = index` for a whole sibling list in one statement: the ids and
498
+ * their new positions travel as two arrays and are joined by `unnest`, so a drag in a
499
+ * forty-child section costs one round trip rather than forty. Rows already in place
500
+ * are left untouched, so their `updated_at` and version do not move either.
501
+ */
502
+ private async renumber(db: Executor, ids: string[]): Promise<void> {
503
+ if (ids.length === 0) return;
504
+ const idList = sql.join(
505
+ ids.map((id) => sql`${id}::uuid`),
506
+ sql`, `,
507
+ );
508
+ const positions = sql.join(
509
+ ids.map((_, index) => sql`${index}::int`),
510
+ sql`, `,
511
+ );
512
+ await db.execute(sql`
513
+ update contents
514
+ set position = v.pos
515
+ from (select unnest(array[${idList}]) as id, unnest(array[${positions}]) as pos) v
516
+ where contents.id = v.id and contents.position is distinct from v.pos
517
+ `);
518
+ }
519
+
469
520
  private async moveSubtree(
470
- db: Database,
521
+ db: Executor,
471
522
  id: string,
472
523
  oldPath: string,
473
524
  newPath: string,
@@ -486,7 +537,7 @@ export class ContentRepository {
486
537
  * `concat_ws` drops NULL segments so a type without a slug is transparent in the path.
487
538
  */
488
539
  private async recomputePermalinks(
489
- db: Database,
540
+ db: Executor,
490
541
  table: typeof contents | typeof publishedContents,
491
542
  rootId: string,
492
543
  ): Promise<void> {
@@ -526,7 +577,7 @@ export class ContentRepository {
526
577
  /** Deletes a node and its whole subtree, in both the draft and published tables. */
527
578
  async delete(id: string): Promise<number> {
528
579
  return this.db.transaction(async (tx) => {
529
- const current = await this.findRow(tx as unknown as Database, id);
580
+ const current = await this.findRow(tx, id);
530
581
  if (!current) throw ManabloxError.notFound('content.notFound', { id });
531
582
 
532
583
  await tx.execute(sql`
@@ -553,50 +604,54 @@ export class ContentRepository {
553
604
  */
554
605
  async publish(id: string, actorId: string | null = null): Promise<ContentRow> {
555
606
  return this.db.transaction(async (tx) => {
556
- const row = await this.findRow(tx as unknown as Database, id);
607
+ const row = await this.findRow(tx, id);
557
608
  if (!row) throw ManabloxError.notFound('content.notFound', { id });
558
609
 
610
+ // What the projection held for this node before, if anything. Descendants derive
611
+ // their permalinks from the node's `permalink_path`, so if that is unchanged by
612
+ // this publish they cannot have gone stale and the subtree walk below is skipped.
613
+ const previous = (
614
+ await tx
615
+ .select({ permalinkPath: publishedContents.permalinkPath })
616
+ .from(publishedContents)
617
+ .where(eq(publishedContents.id, id))
618
+ .limit(1)
619
+ )[0];
620
+
559
621
  const publishedAt = new Date();
560
622
  // `db.execute` binds parameters through postgres.js's unsafe path, which has no
561
623
  // Date serialiser — pass an ISO string and let Postgres cast it.
562
624
  const publishedAtIso = publishedAt.toISOString();
563
625
 
626
+ // What the projection gets that the draft row does not say: it is published, now,
627
+ // by this actor, from this draft version.
628
+ const overrides: Record<string, SQL> = {
629
+ status: sql`'published'`,
630
+ source_version: sql`version`,
631
+ updated_at: sql`now()`,
632
+ updated_by: sql`${actorId}::uuid`,
633
+ published_at: sql`${publishedAtIso}::timestamptz`,
634
+ };
635
+ const columns = PROJECTION_COLUMNS.map((name) => sql.identifier(name));
636
+ const values = PROJECTION_COLUMNS.map((name) => overrides[name] ?? sql.identifier(name));
637
+ const updates = PROJECTION_COLUMNS.filter((name) => !PROJECTION_IDENTITY.has(name)).map(
638
+ (name) => sql`${sql.identifier(name)} = excluded.${sql.identifier(name)}`,
639
+ );
640
+
564
641
  await tx.execute(sql`
565
- insert into published_contents (
566
- id, space_id, type_id, locale, localization_id, parent_id, title, slug, path,
567
- permalink, permalink_path, permalink_segment, status, visible_in_menu, position, fields,
568
- search_text, version, source_version, created_at, updated_at, created_by,
569
- updated_by, published_at
570
- )
571
- select id, space_id, type_id, locale, localization_id, parent_id, title, slug, path,
572
- permalink, permalink_path, permalink_segment, 'published', visible_in_menu, position, fields,
573
- search_text, version, version, created_at, now(), created_by,
574
- ${actorId}::uuid, ${publishedAtIso}::timestamptz
642
+ insert into published_contents (${sql.join(columns, sql`, `)})
643
+ select ${sql.join(values, sql`, `)}
575
644
  from contents where id = ${id}::uuid
576
- on conflict (id) do update set
577
- type_id = excluded.type_id,
578
- locale = excluded.locale,
579
- parent_id = excluded.parent_id,
580
- title = excluded.title,
581
- slug = excluded.slug,
582
- path = excluded.path,
583
- permalink = excluded.permalink,
584
- permalink_path = excluded.permalink_path,
585
- permalink_segment = excluded.permalink_segment,
586
- status = 'published',
587
- visible_in_menu = excluded.visible_in_menu,
588
- position = excluded.position,
589
- fields = excluded.fields,
590
- search_text = excluded.search_text,
591
- version = excluded.version,
592
- source_version = excluded.version,
593
- updated_at = now(),
594
- updated_by = excluded.updated_by,
595
- published_at = excluded.published_at
645
+ on conflict (id) do update set ${sql.join(updates, sql`, `)}
596
646
  `);
597
647
 
598
- // Descendants already live in the projection may sit under a stale permalink.
599
- await this.recomputePermalinks(tx as unknown as Database, publishedContents, id);
648
+ // Descendants already in the projection may sit under a stale permalink: the node
649
+ // moved or was re-slugged since they were published, or it was never projected and
650
+ // they were published beneath its draft path. A republish that leaves the path as
651
+ // it was cannot have affected them.
652
+ if (previous?.permalinkPath !== row.permalinkPath) {
653
+ await this.recomputePermalinks(tx, publishedContents, id);
654
+ }
600
655
 
601
656
  const [updated] = await tx
602
657
  .update(contents)
@@ -645,19 +700,17 @@ export class ContentRepository {
645
700
  return rows;
646
701
  }
647
702
 
648
- async versionSnapshot(
649
- contentId: string,
650
- version: number,
651
- ): Promise<Record<string, unknown> | null> {
703
+ /** The row exactly as it was at that version — `snapshot()` stores the whole row. */
704
+ async versionSnapshot(contentId: string, version: number): Promise<ContentRow | null> {
652
705
  const rows = await this.db
653
706
  .select({ snapshot: contentVersions.snapshot })
654
707
  .from(contentVersions)
655
708
  .where(and(eq(contentVersions.contentId, contentId), eq(contentVersions.version, version)))
656
709
  .limit(1);
657
- return rows[0]?.snapshot ?? null;
710
+ return (rows[0]?.snapshot as ContentRow | undefined) ?? null;
658
711
  }
659
712
 
660
- private async snapshot(db: Database, row: ContentRow, actorId: string | null): Promise<void> {
713
+ private async snapshot(db: Executor, row: ContentRow, actorId: string | null): Promise<void> {
661
714
  await db
662
715
  .insert(contentVersions)
663
716
  .values({
@@ -673,19 +726,19 @@ export class ContentRepository {
673
726
  // Internals
674
727
  // -------------------------------------------------------------------------
675
728
 
676
- private async findRow(db: Database, id: string): Promise<ContentRow | null> {
729
+ private async findRow(db: Executor, id: string): Promise<ContentRow | null> {
677
730
  const rows = await db.select().from(contents).where(eq(contents.id, id)).limit(1);
678
731
  return (rows[0] as ContentRow | undefined) ?? null;
679
732
  }
680
733
 
681
- private async lockRow(db: Database, id: string): Promise<ContentRow> {
734
+ private async lockRow(db: Executor, id: string): Promise<ContentRow> {
682
735
  const rows = await db.select().from(contents).where(eq(contents.id, id)).limit(1).for('update');
683
736
  const row = rows[0] as ContentRow | undefined;
684
737
  if (!row) throw ManabloxError.notFound('content.notFound', { id });
685
738
  return row;
686
739
  }
687
740
 
688
- private async parentPath(db: Database, parentId: string | null): Promise<string | null> {
741
+ private async parentPath(db: Executor, parentId: string | null): Promise<string | null> {
689
742
  if (!parentId) return null;
690
743
  const rows = await db
691
744
  .select({ path: contents.path })
@@ -697,7 +750,7 @@ export class ContentRepository {
697
750
  return path;
698
751
  }
699
752
 
700
- private async parentPermalinkPath(db: Database, parentId: string | null): Promise<string> {
753
+ private async parentPermalinkPath(db: Executor, parentId: string | null): Promise<string> {
701
754
  if (!parentId) return '';
702
755
  const rows = await db
703
756
  .select({ permalinkPath: contents.permalinkPath })
@@ -709,7 +762,7 @@ export class ContentRepository {
709
762
 
710
763
  /** Guards against making a node its own ancestor, which would orphan the subtree. */
711
764
  private async assertNotOwnDescendant(
712
- db: Database,
765
+ db: Executor,
713
766
  id: string,
714
767
  parentId: string | null,
715
768
  ): Promise<void> {
@@ -4,8 +4,12 @@ import { AssetRepository } from './asset.js';
4
4
  import { AssetUsageRepository } from './asset-usage.js';
5
5
  import { ContentRepository } from './content.js';
6
6
  import { ContentTypeRepository } from './content-type.js';
7
+ import { MenuRepository } from './menu.js';
8
+ import { RoleRepository } from './role.js';
7
9
  import { SpaceRepository } from './space.js';
8
10
  import { UserRepository } from './user.js';
11
+ import { WebhookRepository } from './webhook.js';
12
+ import { WorkflowRepository } from './workflow.js';
9
13
 
10
14
  export interface Repositories {
11
15
  content: ContentRepository;
@@ -14,6 +18,10 @@ export interface Repositories {
14
18
  assets: AssetRepository;
15
19
  assetUsages: AssetUsageRepository;
16
20
  users: UserRepository;
21
+ menus: MenuRepository;
22
+ roles: RoleRepository;
23
+ workflows: WorkflowRepository;
24
+ webhooks: WebhookRepository;
17
25
  }
18
26
 
19
27
  export function createRepositories(db: Database, registry: ContentTypeRegistry): Repositories {
@@ -24,5 +32,9 @@ export function createRepositories(db: Database, registry: ContentTypeRegistry):
24
32
  assets: new AssetRepository(db),
25
33
  assetUsages: new AssetUsageRepository(db),
26
34
  users: new UserRepository(db),
35
+ menus: new MenuRepository(db),
36
+ roles: new RoleRepository(db),
37
+ workflows: new WorkflowRepository(db),
38
+ webhooks: new WebhookRepository(db),
27
39
  };
28
40
  }