@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,133 @@
|
|
|
1
|
+
import type { ContentStatus } from '@manablox/core';
|
|
2
|
+
import { sql } from 'drizzle-orm';
|
|
3
|
+
import {
|
|
4
|
+
index,
|
|
5
|
+
integer,
|
|
6
|
+
jsonb,
|
|
7
|
+
pgTable,
|
|
8
|
+
text,
|
|
9
|
+
timestamp,
|
|
10
|
+
unique,
|
|
11
|
+
uniqueIndex,
|
|
12
|
+
uuid,
|
|
13
|
+
} from 'drizzle-orm/pg-core';
|
|
14
|
+
import { ltree, tsvector } from '../columns.js';
|
|
15
|
+
import { spaces } from './spaces.js';
|
|
16
|
+
|
|
17
|
+
/** Shared column set for `contents` and its published projection. */
|
|
18
|
+
const contentColumns = {
|
|
19
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
20
|
+
spaceId: uuid()
|
|
21
|
+
.notNull()
|
|
22
|
+
.references(() => spaces.id, { onDelete: 'cascade' }),
|
|
23
|
+
typeId: uuid().notNull(),
|
|
24
|
+
locale: text().notNull(),
|
|
25
|
+
/** Shared by all translations of one logical document. */
|
|
26
|
+
localizationId: uuid().notNull(),
|
|
27
|
+
parentId: uuid(),
|
|
28
|
+
title: text().notNull(),
|
|
29
|
+
slug: text().notNull(),
|
|
30
|
+
/** Materialised ancestor path, root first, ending in this node's own id. */
|
|
31
|
+
path: ltree().notNull(),
|
|
32
|
+
/** Routable URL path. NULL when the content type has no slug — such a node is a
|
|
33
|
+
* structural container, not a page, so it must not occupy a permalink of its own. */
|
|
34
|
+
permalink: text(),
|
|
35
|
+
/**
|
|
36
|
+
* Accumulated permalink prefix: every ancestor segment plus this node's own, with
|
|
37
|
+
* slug-less levels skipped. Descendants derive from this rather than from `permalink`,
|
|
38
|
+
* which is NULL for a slug-less node and would otherwise truncate the chain.
|
|
39
|
+
*/
|
|
40
|
+
permalinkPath: text().notNull().default(''),
|
|
41
|
+
/**
|
|
42
|
+
* This node's own contribution to its descendants' permalinks: the slug when the
|
|
43
|
+
* content type has one, NULL when it does not.
|
|
44
|
+
*
|
|
45
|
+
* Denormalised deliberately, so permalink recomputation is a single recursive CTE that
|
|
46
|
+
* needs no knowledge of the type registry.
|
|
47
|
+
*/
|
|
48
|
+
permalinkSegment: text(),
|
|
49
|
+
status: text().$type<ContentStatus>().notNull().default('draft'),
|
|
50
|
+
position: integer().notNull().default(0),
|
|
51
|
+
fields: jsonb().$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
|
|
52
|
+
/** Concatenated contributions from each field type's `search()`. */
|
|
53
|
+
searchText: text().notNull().default(''),
|
|
54
|
+
/** Optimistic-lock counter; a write with a stale version is rejected. */
|
|
55
|
+
version: integer().notNull().default(1),
|
|
56
|
+
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
57
|
+
updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
58
|
+
createdBy: uuid(),
|
|
59
|
+
updatedBy: uuid(),
|
|
60
|
+
publishedAt: timestamp({ withTimezone: true }),
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const contents = pgTable(
|
|
64
|
+
'contents',
|
|
65
|
+
{
|
|
66
|
+
...contentColumns,
|
|
67
|
+
search: tsvector().generatedAlwaysAs(
|
|
68
|
+
sql`to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(search_text, ''))`,
|
|
69
|
+
),
|
|
70
|
+
},
|
|
71
|
+
(table) => [
|
|
72
|
+
index('contents_path_gist_idx').using('gist', table.path),
|
|
73
|
+
index('contents_parent_idx').on(table.parentId),
|
|
74
|
+
index('contents_space_type_idx').on(table.spaceId, table.typeId),
|
|
75
|
+
index('contents_localization_idx').on(table.localizationId),
|
|
76
|
+
index('contents_status_idx').on(table.spaceId, table.status),
|
|
77
|
+
index('contents_fields_gin_idx').using('gin', sql`${table.fields} jsonb_path_ops`),
|
|
78
|
+
index('contents_search_gin_idx').using('gin', table.search),
|
|
79
|
+
// NULLS NOT DISTINCT so two root-level siblings cannot share a slug — with the
|
|
80
|
+
// default NULLS DISTINCT, every `parent_id IS NULL` row is unique by definition and
|
|
81
|
+
// the constraint would never fire at the top of the tree.
|
|
82
|
+
unique('contents_sibling_slug_key')
|
|
83
|
+
.on(table.spaceId, table.parentId, table.locale, table.slug)
|
|
84
|
+
.nullsNotDistinct(),
|
|
85
|
+
uniqueIndex('contents_permalink_key')
|
|
86
|
+
.on(table.spaceId, table.locale, table.permalink)
|
|
87
|
+
.where(sql`permalink is not null`),
|
|
88
|
+
],
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The delivery projection. Written transactionally on publish, read by the public
|
|
93
|
+
* GraphQL API. Mirrors `contents` so a row can be copied column-for-column.
|
|
94
|
+
*/
|
|
95
|
+
export const publishedContents = pgTable(
|
|
96
|
+
'published_contents',
|
|
97
|
+
{
|
|
98
|
+
...contentColumns,
|
|
99
|
+
/** Version of `contents` this projection was made from. */
|
|
100
|
+
sourceVersion: integer().notNull().default(1),
|
|
101
|
+
search: tsvector().generatedAlwaysAs(
|
|
102
|
+
sql`to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(search_text, ''))`,
|
|
103
|
+
),
|
|
104
|
+
},
|
|
105
|
+
(table) => [
|
|
106
|
+
index('published_contents_path_gist_idx').using('gist', table.path),
|
|
107
|
+
index('published_contents_parent_idx').on(table.parentId),
|
|
108
|
+
index('published_contents_space_type_idx').on(table.spaceId, table.typeId),
|
|
109
|
+
index('published_contents_fields_gin_idx').using('gin', sql`${table.fields} jsonb_path_ops`),
|
|
110
|
+
index('published_contents_search_gin_idx').using('gin', table.search),
|
|
111
|
+
uniqueIndex('published_contents_permalink_key')
|
|
112
|
+
.on(table.spaceId, table.locale, table.permalink)
|
|
113
|
+
.where(sql`permalink is not null`),
|
|
114
|
+
],
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
/** Full snapshot per save, so any edit can be rolled back. */
|
|
118
|
+
export const contentVersions = pgTable(
|
|
119
|
+
'content_versions',
|
|
120
|
+
{
|
|
121
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
122
|
+
contentId: uuid().notNull(),
|
|
123
|
+
version: integer().notNull(),
|
|
124
|
+
label: text(),
|
|
125
|
+
snapshot: jsonb().$type<Record<string, unknown>>().notNull(),
|
|
126
|
+
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
127
|
+
createdBy: uuid(),
|
|
128
|
+
},
|
|
129
|
+
(table) => [
|
|
130
|
+
uniqueIndex('content_versions_content_version_key').on(table.contentId, table.version),
|
|
131
|
+
index('content_versions_content_idx').on(table.contentId, table.createdAt),
|
|
132
|
+
],
|
|
133
|
+
);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The database schema, one file per entity. `drizzle.config.ts` points at this directory,
|
|
3
|
+
* and `relations.ts` ties the tables together so `db.query` can traverse them.
|
|
4
|
+
*/
|
|
5
|
+
export * from './assets.js';
|
|
6
|
+
export * from './auth.js';
|
|
7
|
+
export * from './content.js';
|
|
8
|
+
export * from './content-types.js';
|
|
9
|
+
export * from './menus.js';
|
|
10
|
+
export * from './relations.js';
|
|
11
|
+
export * from './spaces.js';
|
|
12
|
+
export * from './webhooks.js';
|
|
13
|
+
export * from './workflows.js';
|
|
14
|
+
|
|
15
|
+
import type { assets, assetUsages, assetVariants } from './assets.js';
|
|
16
|
+
import type { memberships, roles, users } from './auth.js';
|
|
17
|
+
import type { contents, contentVersions } from './content.js';
|
|
18
|
+
import type { contentTypes } from './content-types.js';
|
|
19
|
+
import type { menuItems, menus } from './menus.js';
|
|
20
|
+
import type { spaces } from './spaces.js';
|
|
21
|
+
import type { pushSubscriptions, workflowRuns, workflows } from './workflows.js';
|
|
22
|
+
|
|
23
|
+
export type SpaceRow = typeof spaces.$inferSelect;
|
|
24
|
+
export type ContentRow = typeof contents.$inferSelect;
|
|
25
|
+
export type ContentInsert = typeof contents.$inferInsert;
|
|
26
|
+
export type ContentTypeRow = typeof contentTypes.$inferSelect;
|
|
27
|
+
export type AssetRow = typeof assets.$inferSelect;
|
|
28
|
+
export type AssetVariantRow = typeof assetVariants.$inferSelect;
|
|
29
|
+
export type AssetUsageRow = typeof assetUsages.$inferSelect;
|
|
30
|
+
export type UserRow = typeof users.$inferSelect;
|
|
31
|
+
export type MembershipRow = typeof memberships.$inferSelect;
|
|
32
|
+
export type RoleRow = typeof roles.$inferSelect;
|
|
33
|
+
export type ContentVersionRow = typeof contentVersions.$inferSelect;
|
|
34
|
+
export type MenuRow = typeof menus.$inferSelect;
|
|
35
|
+
export type MenuItemRow = typeof menuItems.$inferSelect;
|
|
36
|
+
export type WorkflowRow = typeof workflows.$inferSelect;
|
|
37
|
+
export type WorkflowRunRow = typeof workflowRuns.$inferSelect;
|
|
38
|
+
export type PushSubscriptionRow = typeof pushSubscriptions.$inferSelect;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyPgColumn,
|
|
3
|
+
index,
|
|
4
|
+
integer,
|
|
5
|
+
pgTable,
|
|
6
|
+
text,
|
|
7
|
+
timestamp,
|
|
8
|
+
unique,
|
|
9
|
+
uuid,
|
|
10
|
+
} from 'drizzle-orm/pg-core';
|
|
11
|
+
import { spaces } from './spaces.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A named navigation a site renders: "main", "footer", "legal". A document may sit in
|
|
15
|
+
* any number of them, which is why membership is a row here and not a flag on the
|
|
16
|
+
* document.
|
|
17
|
+
*/
|
|
18
|
+
export const menus = pgTable(
|
|
19
|
+
'menus',
|
|
20
|
+
{
|
|
21
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
22
|
+
spaceId: uuid()
|
|
23
|
+
.notNull()
|
|
24
|
+
.references(() => spaces.id, { onDelete: 'cascade' }),
|
|
25
|
+
name: text().notNull(),
|
|
26
|
+
/** What the delivery API is asked for: `menu(name: "main")`. */
|
|
27
|
+
machineName: text().notNull(),
|
|
28
|
+
description: text(),
|
|
29
|
+
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
30
|
+
updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
31
|
+
},
|
|
32
|
+
(table) => [unique('menus_space_machine_name_key').on(table.spaceId, table.machineName)],
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* One entry of a menu, nested to any depth.
|
|
37
|
+
*
|
|
38
|
+
* A content entry points at the document's `localizationId` rather than at one row, so
|
|
39
|
+
* the same menu serves every locale: delivery resolves the translation for the locale
|
|
40
|
+
* it is asked for. A link entry has a `url` instead. Either may carry a `label`; for a
|
|
41
|
+
* content entry it overrides the document's title.
|
|
42
|
+
*/
|
|
43
|
+
export const menuItems = pgTable(
|
|
44
|
+
'menu_items',
|
|
45
|
+
{
|
|
46
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
47
|
+
menuId: uuid()
|
|
48
|
+
.notNull()
|
|
49
|
+
.references(() => menus.id, { onDelete: 'cascade' }),
|
|
50
|
+
/** Cascades, so removing an entry takes its sub-entries with it. */
|
|
51
|
+
parentId: uuid().references((): AnyPgColumn => menuItems.id, { onDelete: 'cascade' }),
|
|
52
|
+
position: integer().notNull().default(0),
|
|
53
|
+
localizationId: uuid(),
|
|
54
|
+
label: text(),
|
|
55
|
+
url: text(),
|
|
56
|
+
},
|
|
57
|
+
(table) => [
|
|
58
|
+
index('menu_items_menu_idx').on(table.menuId, table.parentId, table.position),
|
|
59
|
+
index('menu_items_localization_idx').on(table.localizationId),
|
|
60
|
+
],
|
|
61
|
+
);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { relations } from 'drizzle-orm';
|
|
2
|
+
import { assets, assetUsages, assetVariants } from './assets.js';
|
|
3
|
+
import { memberships, roles, sessions, users } from './auth.js';
|
|
4
|
+
import { contents, contentVersions } from './content.js';
|
|
5
|
+
import { contentTypes } from './content-types.js';
|
|
6
|
+
import { menuItems, menus } from './menus.js';
|
|
7
|
+
import { spaces } from './spaces.js';
|
|
8
|
+
|
|
9
|
+
export const spacesRelations = relations(spaces, ({ many }) => ({
|
|
10
|
+
contents: many(contents),
|
|
11
|
+
contentTypes: many(contentTypes),
|
|
12
|
+
assets: many(assets),
|
|
13
|
+
memberships: many(memberships),
|
|
14
|
+
menus: many(menus),
|
|
15
|
+
roles: many(roles),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
export const contentsRelations = relations(contents, ({ one, many }) => ({
|
|
19
|
+
space: one(spaces, { fields: [contents.spaceId], references: [spaces.id] }),
|
|
20
|
+
parent: one(contents, {
|
|
21
|
+
fields: [contents.parentId],
|
|
22
|
+
references: [contents.id],
|
|
23
|
+
relationName: 'tree',
|
|
24
|
+
}),
|
|
25
|
+
children: many(contents, { relationName: 'tree' }),
|
|
26
|
+
versions: many(contentVersions),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
export const assetsRelations = relations(assets, ({ one, many }) => ({
|
|
30
|
+
space: one(spaces, { fields: [assets.spaceId], references: [spaces.id] }),
|
|
31
|
+
variants: many(assetVariants),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
export const assetVariantsRelations = relations(assetVariants, ({ one }) => ({
|
|
35
|
+
asset: one(assets, { fields: [assetVariants.assetId], references: [assets.id] }),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
export const assetUsagesRelations = relations(assetUsages, ({ one }) => ({
|
|
39
|
+
asset: one(assets, { fields: [assetUsages.assetId], references: [assets.id] }),
|
|
40
|
+
content: one(contents, { fields: [assetUsages.contentId], references: [contents.id] }),
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
export const usersRelations = relations(users, ({ many }) => ({
|
|
44
|
+
sessions: many(sessions),
|
|
45
|
+
memberships: many(memberships),
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
export const membershipsRelations = relations(memberships, ({ one }) => ({
|
|
49
|
+
user: one(users, { fields: [memberships.userId], references: [users.id] }),
|
|
50
|
+
space: one(spaces, { fields: [memberships.spaceId], references: [spaces.id] }),
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
export const menusRelations = relations(menus, ({ one, many }) => ({
|
|
54
|
+
space: one(spaces, { fields: [menus.spaceId], references: [spaces.id] }),
|
|
55
|
+
items: many(menuItems),
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
export const menuItemsRelations = relations(menuItems, ({ one }) => ({
|
|
59
|
+
menu: one(menus, { fields: [menuItems.menuId], references: [menus.id] }),
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
export const rolesRelations = relations(roles, ({ one }) => ({
|
|
63
|
+
space: one(spaces, { fields: [roles.spaceId], references: [spaces.id] }),
|
|
64
|
+
}));
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm';
|
|
2
|
+
import { jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
|
|
3
|
+
|
|
4
|
+
export const spaces = pgTable(
|
|
5
|
+
'spaces',
|
|
6
|
+
{
|
|
7
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
8
|
+
name: text().notNull(),
|
|
9
|
+
machineName: text().notNull(),
|
|
10
|
+
description: text(),
|
|
11
|
+
/** Public origin of the space's frontend — used by the visual editor iframe. */
|
|
12
|
+
url: text().notNull(),
|
|
13
|
+
defaultLocale: text().notNull().default('en'),
|
|
14
|
+
locales: jsonb().$type<string[]>().notNull().default(sql`'["en"]'::jsonb`),
|
|
15
|
+
settings: jsonb().$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
|
|
16
|
+
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
17
|
+
updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
18
|
+
},
|
|
19
|
+
(table) => [uniqueIndex('spaces_machine_name_key').on(table.machineName)],
|
|
20
|
+
);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm';
|
|
2
|
+
import {
|
|
3
|
+
boolean,
|
|
4
|
+
index,
|
|
5
|
+
integer,
|
|
6
|
+
jsonb,
|
|
7
|
+
pgTable,
|
|
8
|
+
text,
|
|
9
|
+
timestamp,
|
|
10
|
+
uuid,
|
|
11
|
+
} from 'drizzle-orm/pg-core';
|
|
12
|
+
import { spaces } from './spaces.js';
|
|
13
|
+
|
|
14
|
+
export const webhooks = pgTable(
|
|
15
|
+
'webhooks',
|
|
16
|
+
{
|
|
17
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
18
|
+
spaceId: uuid()
|
|
19
|
+
.notNull()
|
|
20
|
+
.references(() => spaces.id, { onDelete: 'cascade' }),
|
|
21
|
+
name: text().notNull(),
|
|
22
|
+
url: text().notNull(),
|
|
23
|
+
secret: text(),
|
|
24
|
+
events: jsonb().$type<string[]>().notNull().default(sql`'[]'::jsonb`),
|
|
25
|
+
enabled: boolean().notNull().default(true),
|
|
26
|
+
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
27
|
+
},
|
|
28
|
+
(table) => [index('webhooks_space_idx').on(table.spaceId)],
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
export const webhookDeliveries = pgTable(
|
|
32
|
+
'webhook_deliveries',
|
|
33
|
+
{
|
|
34
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
35
|
+
webhookId: uuid()
|
|
36
|
+
.notNull()
|
|
37
|
+
.references(() => webhooks.id, { onDelete: 'cascade' }),
|
|
38
|
+
event: text().notNull(),
|
|
39
|
+
payload: jsonb().$type<Record<string, unknown>>().notNull(),
|
|
40
|
+
status: integer(),
|
|
41
|
+
error: text(),
|
|
42
|
+
attempt: integer().notNull().default(1),
|
|
43
|
+
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
44
|
+
},
|
|
45
|
+
(table) => [index('webhook_deliveries_webhook_idx').on(table.webhookId, table.createdAt)],
|
|
46
|
+
);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
WorkflowCursor,
|
|
3
|
+
WorkflowRunContext,
|
|
4
|
+
WorkflowRunStatus,
|
|
5
|
+
WorkflowStep,
|
|
6
|
+
WorkflowStepLog,
|
|
7
|
+
WorkflowTrigger,
|
|
8
|
+
} from '@manablox/core';
|
|
9
|
+
import { sql } from 'drizzle-orm';
|
|
10
|
+
import { boolean, index, jsonb, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
|
11
|
+
import { users } from './auth.js';
|
|
12
|
+
import { spaces } from './spaces.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A workflow: something that happens when content changes, or on a schedule. The
|
|
16
|
+
* trigger and the steps are documents rather than rows — a workflow is edited and saved
|
|
17
|
+
* as one thing, and a step type added later needs no migration.
|
|
18
|
+
*/
|
|
19
|
+
export const workflows = pgTable(
|
|
20
|
+
'workflows',
|
|
21
|
+
{
|
|
22
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
23
|
+
spaceId: uuid()
|
|
24
|
+
.notNull()
|
|
25
|
+
.references(() => spaces.id, { onDelete: 'cascade' }),
|
|
26
|
+
name: text().notNull(),
|
|
27
|
+
description: text(),
|
|
28
|
+
enabled: boolean().notNull().default(false),
|
|
29
|
+
trigger: jsonb().$type<WorkflowTrigger>().notNull(),
|
|
30
|
+
steps: jsonb().$type<WorkflowStep[]>().notNull().default(sql`'[]'::jsonb`),
|
|
31
|
+
/**
|
|
32
|
+
* The minute a scheduled workflow was last claimed for, so two processes ticking at
|
|
33
|
+
* once start one run between them rather than one each.
|
|
34
|
+
*/
|
|
35
|
+
lastScheduledAt: timestamp({ withTimezone: true }),
|
|
36
|
+
lastRunAt: timestamp({ withTimezone: true }),
|
|
37
|
+
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
38
|
+
updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
39
|
+
},
|
|
40
|
+
(table) => [index('workflows_space_idx').on(table.spaceId, table.enabled)],
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* One execution of a workflow, with everything needed to continue it after a pause: the
|
|
45
|
+
* context the templates read, the index of the next step and the log so far.
|
|
46
|
+
*/
|
|
47
|
+
export const workflowRuns = pgTable(
|
|
48
|
+
'workflow_runs',
|
|
49
|
+
{
|
|
50
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
51
|
+
workflowId: uuid()
|
|
52
|
+
.notNull()
|
|
53
|
+
.references(() => workflows.id, { onDelete: 'cascade' }),
|
|
54
|
+
spaceId: uuid()
|
|
55
|
+
.notNull()
|
|
56
|
+
.references(() => spaces.id, { onDelete: 'cascade' }),
|
|
57
|
+
/** The event name, or `schedule` / `manual`. */
|
|
58
|
+
trigger: text().notNull(),
|
|
59
|
+
status: text().$type<WorkflowRunStatus>().notNull().default('queued'),
|
|
60
|
+
context: jsonb().$type<WorkflowRunContext>().notNull(),
|
|
61
|
+
/** Where to continue; a paused run resumes here. See `WorkflowCursor`. */
|
|
62
|
+
cursor: jsonb().$type<WorkflowCursor>().notNull().default(sql`'[]'::jsonb`),
|
|
63
|
+
log: jsonb().$type<WorkflowStepLog[]>().notNull().default(sql`'[]'::jsonb`),
|
|
64
|
+
error: text(),
|
|
65
|
+
/** Set while `waiting`: when the scheduler should pick the run up again. */
|
|
66
|
+
resumeAt: timestamp({ withTimezone: true }),
|
|
67
|
+
startedAt: timestamp({ withTimezone: true }),
|
|
68
|
+
finishedAt: timestamp({ withTimezone: true }),
|
|
69
|
+
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
70
|
+
},
|
|
71
|
+
(table) => [
|
|
72
|
+
index('workflow_runs_workflow_idx').on(table.workflowId, table.createdAt),
|
|
73
|
+
index('workflow_runs_resume_idx').on(table.status, table.resumeAt),
|
|
74
|
+
],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
/** A browser a user allowed push notifications in; one row per device. */
|
|
78
|
+
export const pushSubscriptions = pgTable(
|
|
79
|
+
'push_subscriptions',
|
|
80
|
+
{
|
|
81
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
82
|
+
userId: uuid()
|
|
83
|
+
.notNull()
|
|
84
|
+
.references(() => users.id, { onDelete: 'cascade' }),
|
|
85
|
+
endpoint: text().notNull().unique('push_subscriptions_endpoint_key'),
|
|
86
|
+
keys: jsonb().$type<{ p256dh: string; auth: string }>().notNull(),
|
|
87
|
+
userAgent: text(),
|
|
88
|
+
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
89
|
+
lastUsedAt: timestamp({ withTimezone: true }),
|
|
90
|
+
},
|
|
91
|
+
(table) => [index('push_subscriptions_user_idx').on(table.userId)],
|
|
92
|
+
);
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Fixtures for a repository-level test: two plain field types, a `page` and a `folder`
|
|
3
|
+
* content type, a migrated database with one space, and a node maker. Shared through
|
|
4
|
+
* `@manablox/db/testing` so a suite in another package can start from the same world.
|
|
5
|
+
*/
|
|
3
6
|
import {
|
|
4
7
|
type ContentTypeDefinition,
|
|
5
8
|
ContentTypeRegistry,
|
|
@@ -8,24 +11,9 @@ import {
|
|
|
8
11
|
FieldTypeRegistry,
|
|
9
12
|
type StandardSchemaV1,
|
|
10
13
|
} from '@manablox/core';
|
|
11
|
-
import {
|
|
12
|
-
import
|
|
13
|
-
import {
|
|
14
|
-
import { createDatabase, type DatabaseHandle } from '../src/client.js';
|
|
15
|
-
import { createRepositories, type Repositories } from '../src/repositories/index.js';
|
|
16
|
-
|
|
17
|
-
const MIGRATIONS = resolve(dirname(fileURLToPath(import.meta.url)), '../migrations');
|
|
18
|
-
/**
|
|
19
|
-
* Only ever connected to in order to `create database`: every test file works in its own
|
|
20
|
-
* fresh database and drops it again, so this points at a Postgres *server*, not at data
|
|
21
|
-
* the tests touch. That is why falling back to `DATABASE_URL` is safe — it means the
|
|
22
|
-
* suite runs wherever the app itself runs (a container, CI, the host) without a second
|
|
23
|
-
* variable that has to be kept in step. `TEST_DATABASE_URL` still overrides it.
|
|
24
|
-
*/
|
|
25
|
-
const ADMIN_URL =
|
|
26
|
-
process.env.TEST_DATABASE_URL ??
|
|
27
|
-
process.env.DATABASE_URL ??
|
|
28
|
-
'postgres://manablox:manablox@localhost:5432/manablox';
|
|
14
|
+
import { createDatabase, type DatabaseHandle } from './client.js';
|
|
15
|
+
import { createRepositories, type Repositories } from './repositories/index.js';
|
|
16
|
+
import { createTestDatabase } from './testing.js';
|
|
29
17
|
|
|
30
18
|
const anySchema = <T>(): StandardSchemaV1<unknown, T> => ({
|
|
31
19
|
'~standard': { version: 1, vendor: 'test', validate: (value) => ({ value: value as T }) },
|
|
@@ -56,8 +44,10 @@ export const numberField = defineFieldType({
|
|
|
56
44
|
admin: { input: 'number' },
|
|
57
45
|
});
|
|
58
46
|
|
|
59
|
-
export interface
|
|
47
|
+
export interface RepositoryTestContext {
|
|
60
48
|
handle: DatabaseHandle;
|
|
49
|
+
/** Every statement sent to Postgres, so a test can assert what a call cost. */
|
|
50
|
+
queries: string[];
|
|
61
51
|
repos: Repositories;
|
|
62
52
|
registry: ContentTypeRegistry;
|
|
63
53
|
types: { page: ContentTypeDefinition; folder: ContentTypeDefinition };
|
|
@@ -66,16 +56,13 @@ export interface TestContext {
|
|
|
66
56
|
}
|
|
67
57
|
|
|
68
58
|
/** Spins up an isolated database per suite, migrated from the real migration files. */
|
|
69
|
-
export async function
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const handle = createDatabase({ url, max: 4 });
|
|
77
|
-
await applyBootstrapSql(handle.sql);
|
|
78
|
-
await migrate(handle.db, { migrationsFolder: MIGRATIONS });
|
|
59
|
+
export async function createRepositoryContext(name: string): Promise<RepositoryTestContext> {
|
|
60
|
+
const database = await createTestDatabase(name);
|
|
61
|
+
const queries: string[] = [];
|
|
62
|
+
const handle = createDatabase(
|
|
63
|
+
{ url: database.url, max: 4 },
|
|
64
|
+
{ onQuery: (query) => queries.push(query) },
|
|
65
|
+
);
|
|
79
66
|
|
|
80
67
|
const fieldTypes = new FieldTypeRegistry();
|
|
81
68
|
fieldTypes.register(stringField);
|
|
@@ -108,22 +95,21 @@ export async function createTestContext(name: string): Promise<TestContext> {
|
|
|
108
95
|
|
|
109
96
|
return {
|
|
110
97
|
handle,
|
|
98
|
+
queries,
|
|
111
99
|
repos,
|
|
112
100
|
registry,
|
|
113
101
|
types: { page, folder },
|
|
114
102
|
spaceId: space.id,
|
|
115
103
|
close: async () => {
|
|
116
104
|
await handle.close();
|
|
117
|
-
|
|
118
|
-
await cleanup.unsafe(`drop database if exists "${dbName}" with (force)`);
|
|
119
|
-
await cleanup.end();
|
|
105
|
+
await database.drop();
|
|
120
106
|
},
|
|
121
107
|
};
|
|
122
108
|
}
|
|
123
109
|
|
|
124
110
|
/** Convenience creator so tests read as tree shapes rather than field soup. */
|
|
125
111
|
export async function makeNode(
|
|
126
|
-
ctx:
|
|
112
|
+
ctx: RepositoryTestContext,
|
|
127
113
|
options: {
|
|
128
114
|
title: string;
|
|
129
115
|
slug: string;
|
package/src/testing.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { migrate } from 'drizzle-orm/postgres-js/migrator';
|
|
6
|
+
import postgres from 'postgres';
|
|
7
|
+
import { applyBootstrapSql } from './bootstrap.js';
|
|
8
|
+
import { createDatabase } from './client.js';
|
|
9
|
+
|
|
10
|
+
const MIGRATIONS = resolve(dirname(fileURLToPath(import.meta.url)), '../migrations');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Only ever connected to in order to `create database`: every test file works in its own
|
|
14
|
+
* fresh database and drops it again, so this points at a Postgres *server*, not at data
|
|
15
|
+
* the tests touch. That is why falling back to `DATABASE_URL` is safe — it means the
|
|
16
|
+
* suite runs wherever the app itself runs (a container, CI, the host) without a second
|
|
17
|
+
* variable that has to be kept in step. `TEST_DATABASE_URL` still overrides it.
|
|
18
|
+
*/
|
|
19
|
+
export const TEST_ADMIN_URL =
|
|
20
|
+
process.env.TEST_DATABASE_URL ??
|
|
21
|
+
process.env.DATABASE_URL ??
|
|
22
|
+
'postgres://manablox:manablox@localhost:5432/manablox';
|
|
23
|
+
|
|
24
|
+
/** A fingerprint of the migration files, so a schema change gets a fresh template. */
|
|
25
|
+
function migrationsFingerprint(): string {
|
|
26
|
+
const hash = createHash('sha1');
|
|
27
|
+
for (const file of readdirSync(MIGRATIONS).sort()) {
|
|
28
|
+
if (file.endsWith('.sql')) hash.update(readFileSync(join(MIGRATIONS, file)));
|
|
29
|
+
}
|
|
30
|
+
return hash.digest('hex').slice(0, 10);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const TEMPLATE = `manablox_test_template_${migrationsFingerprint()}`;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Makes sure the migrated template exists, once per server.
|
|
37
|
+
*
|
|
38
|
+
* Migrating takes a second or two; `create database … template …` takes milliseconds,
|
|
39
|
+
* so every suite clones the template instead of migrating from scratch. Suites run in
|
|
40
|
+
* parallel across processes, so the check-and-create is serialised on a session-level
|
|
41
|
+
* advisory lock — the second process finds the template already there.
|
|
42
|
+
*/
|
|
43
|
+
async function ensureTemplate(admin: postgres.Sql): Promise<void> {
|
|
44
|
+
await admin.unsafe(`select pg_advisory_lock(hashtext('${TEMPLATE}'))`);
|
|
45
|
+
try {
|
|
46
|
+
const [row] = await admin.unsafe(
|
|
47
|
+
`select 1 as found from pg_database where datname = '${TEMPLATE}'`,
|
|
48
|
+
);
|
|
49
|
+
if (row) return;
|
|
50
|
+
|
|
51
|
+
await admin.unsafe(`create database "${TEMPLATE}"`);
|
|
52
|
+
const handle = createDatabase({ url: withDatabase(TEST_ADMIN_URL, TEMPLATE), max: 1 });
|
|
53
|
+
try {
|
|
54
|
+
await applyBootstrapSql(handle.sql);
|
|
55
|
+
await migrate(handle.db, { migrationsFolder: MIGRATIONS });
|
|
56
|
+
} finally {
|
|
57
|
+
await handle.close();
|
|
58
|
+
}
|
|
59
|
+
} finally {
|
|
60
|
+
await admin.unsafe(`select pg_advisory_unlock(hashtext('${TEMPLATE}'))`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function withDatabase(url: string, name: string): string {
|
|
65
|
+
return url.replace(/\/[^/?]*(\?.*)?$/, `/${name}$1`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface TestDatabase {
|
|
69
|
+
name: string;
|
|
70
|
+
url: string;
|
|
71
|
+
/** Drops the database. Safe to call once every connection to it is closed. */
|
|
72
|
+
drop: () => Promise<void>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A fresh, migrated database for one test suite, cloned from the template.
|
|
77
|
+
*
|
|
78
|
+
* `prefix` names the suite in `pg_database`, which is what you grep for when a run was
|
|
79
|
+
* interrupted and left a database behind.
|
|
80
|
+
*/
|
|
81
|
+
export async function createTestDatabase(prefix: string): Promise<TestDatabase> {
|
|
82
|
+
const name = `manablox_test_${prefix}_${Date.now().toString(36)}_${process.pid.toString(36)}`;
|
|
83
|
+
const admin = postgres(TEST_ADMIN_URL, { max: 1 });
|
|
84
|
+
try {
|
|
85
|
+
await ensureTemplate(admin);
|
|
86
|
+
await admin.unsafe(`create database "${name}" template "${TEMPLATE}"`);
|
|
87
|
+
} finally {
|
|
88
|
+
await admin.end();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
name,
|
|
93
|
+
url: withDatabase(TEST_ADMIN_URL, name),
|
|
94
|
+
drop: async () => {
|
|
95
|
+
const cleanup = postgres(TEST_ADMIN_URL, { max: 1 });
|
|
96
|
+
try {
|
|
97
|
+
await cleanup.unsafe(`drop database if exists "${name}" with (force)`);
|
|
98
|
+
} finally {
|
|
99
|
+
await cleanup.end();
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export * from './testing-fixtures.js';
|
package/test/asset-usage.test.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import { createRepositoryContext, makeNode, type RepositoryTestContext } from '../src/testing.js';
|
|
3
3
|
|
|
4
|
-
let ctx:
|
|
4
|
+
let ctx: RepositoryTestContext;
|
|
5
5
|
let assetA: string;
|
|
6
6
|
let assetB: string;
|
|
7
7
|
|
|
8
8
|
beforeAll(async () => {
|
|
9
|
-
ctx = await
|
|
9
|
+
ctx = await createRepositoryContext('asset_usage');
|
|
10
10
|
assetA = (await createAsset('a.jpg')).id;
|
|
11
11
|
assetB = (await createAsset('b.jpg')).id;
|
|
12
12
|
});
|