From f1b8ec5691f8648bb01fcb7bb5a7db9b067f71e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Thu, 8 Feb 2024 17:02:25 +0100 Subject: [PATCH] wip: multi org multi region --- src/app.ts | 43 ++-- .../20240205145527_organizations.ts | 14 + src/migrations/20240205150932_regions.ts | 49 ++++ src/migrations/20240206210140_region_name.ts | 15 ++ .../20240206213031_region_maintenance_db.ts | 15 ++ .../20240207203256_organization_acl.ts | 22 ++ ...0240207212311_organization_resource_acl.ts | 22 ++ .../20240207214054_resource_region.ts | 28 ++ src/repositories.ts | 202 ++++++++++++--- src/resolvers.ts | 147 ++++++++++- src/schema.graphql | 51 ++++ src/services.ts | 61 ----- src/services/authz.ts | 26 ++ src/services/comments.ts | 25 ++ src/services/databaseManagement.ts | 242 ++++++++++++++++++ src/services/resources.ts | 45 ++++ src/types.ts | 62 ++++- 17 files changed, 930 insertions(+), 139 deletions(-) create mode 100644 src/migrations/20240205145527_organizations.ts create mode 100644 src/migrations/20240205150932_regions.ts create mode 100644 src/migrations/20240206210140_region_name.ts create mode 100644 src/migrations/20240206213031_region_maintenance_db.ts create mode 100644 src/migrations/20240207203256_organization_acl.ts create mode 100644 src/migrations/20240207212311_organization_resource_acl.ts create mode 100644 src/migrations/20240207214054_resource_region.ts delete mode 100644 src/services.ts create mode 100644 src/services/authz.ts create mode 100644 src/services/comments.ts create mode 100644 src/services/databaseManagement.ts create mode 100644 src/services/resources.ts diff --git a/src/app.ts b/src/app.ts index 2e86588..97e2590 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,42 +1,31 @@ -import { ApolloServer } from '@apollo/server' -import { resolvers } from './resolvers' -import { startStandaloneServer } from '@apollo/server/standalone' -import { readFileSync } from 'fs' -import { typeDefs as scalarTypeDefs } from 'graphql-scalars' -import { knex } from './db' +import { ApolloServer } from "@apollo/server"; +import { resolvers } from "./resolvers"; +import { startStandaloneServer } from "@apollo/server/standalone"; +import { readFileSync } from "fs"; +import { typeDefs as scalarTypeDefs } from "graphql-scalars"; +import { migrateAll } from "./services/databaseManagement"; -const typeDefs = readFileSync('src/schema.graphql', { encoding: 'utf-8' }) +const typeDefs = readFileSync("src/schema.graphql", { encoding: "utf-8" }); // The ApolloServer constructor requires two parameters: your schema // definition and your set of resolvers. const server = new ApolloServer({ typeDefs: [typeDefs, ...scalarTypeDefs], - resolvers -}) + resolvers, +}); const startServer = async (): Promise => { const { url } = await startStandaloneServer(server, { - listen: { port: 4000 } - }) + listen: { port: 4000 }, + }); - const plannedMigrations: Array<{ file: string }> = ( - await knex.migrate.list() - )[1] - if (plannedMigrations.length > 0) { - console.log( - `🕰️ planning migrations: ${plannedMigrations - .map((m) => m.file) - .join(',')}` - ) - } + await migrateAll(); - await knex.migrate.latest() - - console.log(`🚀 Server ready at: ${url}`) -} + console.log(`🚀 Server ready at: ${url}`); +}; startServer() .then() .catch((err: Error) => - console.log(`🔥 failed to start server ${err.message}`) - ) + console.log(`🔥 failed to start server ${err.message}`), + ); diff --git a/src/migrations/20240205145527_organizations.ts b/src/migrations/20240205145527_organizations.ts new file mode 100644 index 0000000..2f48382 --- /dev/null +++ b/src/migrations/20240205145527_organizations.ts @@ -0,0 +1,14 @@ +import type { Knex } from "knex"; + +const tableName = "organizations"; + +export async function up(knex: Knex): Promise { + return await knex.schema.createTable(tableName, (table) => { + table.text("id").primary(); + table.text("name"); + }); +} + +export async function down(knex: Knex): Promise { + return await knex.schema.dropTable(tableName); +} diff --git a/src/migrations/20240205150932_regions.ts b/src/migrations/20240205150932_regions.ts new file mode 100644 index 0000000..2dbf057 --- /dev/null +++ b/src/migrations/20240205150932_regions.ts @@ -0,0 +1,49 @@ +import type { Knex } from "knex"; + +const regionsTableName = "regions"; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable(regionsTableName, (table) => { + table.text("id").primary(); + table.text("connectionString"); + }); + await knex.schema.createTable("organizations_regions", (table) => { + table + .text("organizationId") + .references("id") + .inTable("organizations") + .notNullable() + .onDelete("cascade"); + table + .text("regionId") + .references("id") + .inTable("regions") + .notNullable() + .onDelete("cascade"); + }); + await knex.schema.createTable("resource_organization_region", (table) => { + table + .text("resourceId") + .references("id") + .inTable("resources") + .notNullable() + .onDelete("cascade"); + table + .text("organizationId") + .references("id") + .inTable("organizations") + .notNullable() + .onDelete("cascade"); + table + .text("regionId") + .references("id") + .inTable("regions") + .notNullable() + .onDelete("cascade"); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTable(regionsTableName); + await knex.schema.dropTable("organizations_regions"); +} diff --git a/src/migrations/20240206210140_region_name.ts b/src/migrations/20240206210140_region_name.ts new file mode 100644 index 0000000..0083c30 --- /dev/null +++ b/src/migrations/20240206210140_region_name.ts @@ -0,0 +1,15 @@ +import type { Knex } from "knex"; + +const regionsTableName = "regions"; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable(regionsTableName, (table) => { + table.text("name").notNullable().defaultTo("region"); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable(regionsTableName, (table) => { + table.dropColumn("name"); + }); +} diff --git a/src/migrations/20240206213031_region_maintenance_db.ts b/src/migrations/20240206213031_region_maintenance_db.ts new file mode 100644 index 0000000..58c4c6b --- /dev/null +++ b/src/migrations/20240206213031_region_maintenance_db.ts @@ -0,0 +1,15 @@ +import type { Knex } from "knex"; + +const regionsTableName = "regions"; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable(regionsTableName, (table) => { + table.text("maintenanceDb").notNullable().defaultTo("region"); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable(regionsTableName, (table) => { + table.dropColumn("maintenanceDb"); + }); +} diff --git a/src/migrations/20240207203256_organization_acl.ts b/src/migrations/20240207203256_organization_acl.ts new file mode 100644 index 0000000..8dba3bf --- /dev/null +++ b/src/migrations/20240207203256_organization_acl.ts @@ -0,0 +1,22 @@ +import type { Knex } from "knex"; + +const tableName = "organization_acl"; + +export async function up(knex: Knex): Promise { + return await knex.schema.createTable(tableName, (table) => { + table + .string("userId") + .references("id") + .inTable("users") + .onDelete("cascade"); + table + .string("organizationId") + .references("id") + .inTable("organizations") + .onDelete("cascade"); + }); +} + +export async function down(knex: Knex): Promise { + return await knex.schema.dropTable(tableName); +} diff --git a/src/migrations/20240207212311_organization_resource_acl.ts b/src/migrations/20240207212311_organization_resource_acl.ts new file mode 100644 index 0000000..14835e8 --- /dev/null +++ b/src/migrations/20240207212311_organization_resource_acl.ts @@ -0,0 +1,22 @@ +import type { Knex } from "knex"; + +const tableName = "organization_resource_acl"; + +export async function up(knex: Knex): Promise { + return await knex.schema.createTable(tableName, (table) => { + table + .string("resourceId") + .references("id") + .inTable("resources") + .onDelete("cascade"); + table + .string("organizationId") + .references("id") + .inTable("organizations") + .onDelete("cascade"); + }); +} + +export async function down(knex: Knex): Promise { + return await knex.schema.dropTable(tableName); +} diff --git a/src/migrations/20240207214054_resource_region.ts b/src/migrations/20240207214054_resource_region.ts new file mode 100644 index 0000000..ceb943e --- /dev/null +++ b/src/migrations/20240207214054_resource_region.ts @@ -0,0 +1,28 @@ +import type { Knex } from "knex"; + +const tableName = "resource_region_organization"; + +export async function up(knex: Knex): Promise { + return await knex.schema.createTable(tableName, (table) => { + table + .string("resourceId") + .references("id") + .inTable("resources") + .onDelete("cascade") + .primary(); + table + .string("regionId") + .references("id") + .inTable("regions") + .onDelete("cascade"); + table + .string("organizationId") + .references("id") + .inTable("organizations") + .onDelete("cascade"); + }); +} + +export async function down(knex: Knex): Promise { + return await knex.schema.dropTable(tableName); +} diff --git a/src/repositories.ts b/src/repositories.ts index 8acefd9..8f9136d 100644 --- a/src/repositories.ts +++ b/src/repositories.ts @@ -1,35 +1,67 @@ +import { Knex } from "knex"; import { knex } from "./db"; -import { UserRecord, Resource, ResourceAcl, Comment } from "./types"; +import { + UserRecord, + Resource, + ResourceAcl, + Comment, + Region, + OrganizationsRegions, + Organization, + OrganizationAcl, + OrganizationResourceAcl, + ResourceRegion, + ResourceRegionOrg, +} from "./types"; const Users = () => knex("users"); const Resources = () => knex("resources"); const ResourceAclRepo = () => knex("resource_acl"); -const Comments = () => knex("comments"); export const queryUser = async (userId: string): Promise => { return (await Users().where("id", "=", userId).first()) ?? null; }; -export const queryResource = async ( - resourceId: string, -): Promise => { - return (await Resources().where("id", "=", resourceId).first()) ?? null; +export const getUsersFrom = (db: Knex) => async (): Promise => { + return await db("users").select(); }; -export const queryResourceAcl = async ({ - resourceId, - userId, -}: { - resourceId: string; - userId: string; -}): Promise => { - return ( - (await ResourceAclRepo() - .where("userId", "=", userId) - .andWhere("resourceId", "=", resourceId) - .first()) ?? null - ); -}; +export const saveUserTo = + (db: Knex) => + async (user: UserRecord): Promise => { + await db("users").insert(user); + }; + +export const saveResourceTo = + (db: Knex) => + async (resource: Resource): Promise => { + await db("resources").insert(resource); + }; + +export const queryResourceFrom = + (db: Knex) => + async (resourceId: string): Promise => { + return ( + (await db("resources").where({ id: resourceId }).first()) ?? + null + ); + }; + +export const queryResourceAclFrom = + (db: Knex) => + async ({ resourceId, userId }: ResourceAcl): Promise => { + return ( + (await db("resource_acl") + .where({ userId, resourceId }) + .first()) ?? null + ); + }; + +export const saveResourceAclTo = + (db: Knex) => + async (resourceAcl: ResourceAcl): Promise => { + await db("resource_acl").insert(resourceAcl); + }; export const countResources = async (userId: string): Promise => { const [rawCount] = await ResourceAclRepo().count().where({ userId }); @@ -54,23 +86,115 @@ export const queryResources = async ({ return await query.limit(limit); }; -export const countComments = async (resourceId: string): Promise => { - const [rawCount] = await Comments().count().where({ resourceId }); - return parseInt(rawCount.count as string); -}; +export const countCommentsIn = + (db: Knex) => + async (resourceId: string): Promise => { + const [rawCount] = await db("comments") + .count() + .where({ resourceId }); + return parseInt(rawCount.count as string); + }; -export const queryComments = async ({ - resourceId, - limit, - cursor, -}: { - resourceId: string; - limit: number; - cursor: string | null; -}): Promise => { - const query = Comments().where({ resourceId }); - if (cursor) { - query.andWhere("createdAt", "<", cursor); - } - return await query.limit(limit); -}; +export const queryCommentsFrom = + (db: Knex) => + async ({ + resourceId, + limit, + cursor, + }: { + resourceId: string; + limit: number; + cursor: string | null; + }): Promise => { + const query = db("comments").where({ resourceId }); + if (cursor) { + query.andWhere("createdAt", "<", cursor); + } + return await query.limit(limit); + }; + +export const saveCommentTo = + (db: Knex) => + async (comment: Comment): Promise => { + await db("comments").insert(comment); + }; + +export const getRegionsFrom = (db: Knex) => async (): Promise> => + await db("regions").select(); + +export const getRegionFrom = + (db: Knex) => + async (id: string): Promise => + (await db("regions").where({ id }).first()) ?? null; + +export const getOrganizationRegionsFrom = + (db: Knex) => async (): Promise> => + await db("organizations_regions").select(); + +export const queryOrganizationRegionsFrom = + (db: Knex) => + async ({ + regionId, + organizationId, + }: OrganizationsRegions): Promise => + (await db("organizations_regions") + .where({ regionId, organizationId }) + .first()) ?? null; + +export const saveRegionTo = (db: Knex) => async (region: Region) => + await db("regions").insert(region); + +export const saveOrganizationTo = + (db: Knex) => async (organization: Organization) => + await db("organizations").insert(organization); + +export const getOrganizationFrom = + (db: Knex) => + async (id: string): Promise => { + return ( + (await db("organizations").where({ id }).first()) ?? null + ); + }; + +export const getOrganizationsFrom = + (db: Knex) => async (): Promise => + await db("organizations").select(); + +export const saveOrganizationsRegionsTo = + (db: Knex) => + async (or: OrganizationsRegions): Promise => + await db("organizations_regions").insert(or); + +export const saveOrganizationAclTo = + (db: Knex) => + async (orgAcl: OrganizationAcl): Promise => { + await db("organization_acl").insert(orgAcl); + }; + +export const queryOrganizationAclFrom = + (db: Knex) => + async ({ + userId, + organizationId, + }: OrganizationAcl): Promise => + (await db("organization_acl") + .where({ userId, organizationId }) + .first()) ?? null; + +export const saveOrganizationResourceAclTo = + (db: Knex) => + async (item: OrganizationResourceAcl): Promise => { + await db("organization_resource_acl").insert(item); + }; + +export const saveResourceRegionOrganizationTo = + (db: Knex) => async (item: ResourceRegionOrg) => { + await db("resource_region_organization").insert(item); + }; + +export const queryResourceRegionOrganizationFrom = + (db: Knex) => + async (resourceId: string): Promise => + (await db("resource_region_organization") + .where({ resourceId }) + .first()) ?? null; diff --git a/src/resolvers.ts b/src/resolvers.ts index 0bf7f4d..b509b67 100644 --- a/src/resolvers.ts +++ b/src/resolvers.ts @@ -1,26 +1,63 @@ -import { queryResourceAcl } from "./repositories"; -import { getUser, getResource, getComments, getResources } from "./services"; +import { + getOrganizationsFrom, + getRegionsFrom, + queryOrganizationAclFrom, + queryOrganizationRegionsFrom, + queryResourceAclFrom, + saveOrganizationResourceAclTo, + saveOrganizationAclTo, + saveResourceAclTo, + saveResourceTo, + saveResourceRegionOrganizationTo, + saveCommentTo, + queryResourceFrom, + queryUser, + countCommentsIn, + queryCommentsFrom, + getUsersFrom, + saveUserTo, +} from "./repositories"; +import { getComments } from "./services/comments"; +import { createResource, getResources } from "./services/resources"; import { GraphQLError } from "graphql"; import { Resource, - ResourceCollection, UserRecord, CommentCollection, PaginationArgs, + ResourceCreateArgs, + OrganizationsRegions, + OrganizationAcl, + CommentCreateArgs, + UserCreateArgs, } from "./types"; +import { + bindRegionToOrganization, + createOrganization, + getDbClient, + getMainDbClient, + getResourceDatabaseConnection, + registerRegion, +} from "./services/databaseManagement"; +import { authorizeUserOrgRegion } from "./services/authz"; +import cryptoRandomString from "crypto-random-string"; // Resolvers define how to fetch the types defined in your schema. // This resolver retrieves books from the "books" array above. export const resolvers = { Query: { + async users() { + return await getUsersFrom(getMainDbClient())(); + }, async user(_: unknown, args: { id: string }) { - return await getUser(args.id); + return await queryUser(args.id); }, async resource( _: unknown, args: { id: string; userId: string }, ): Promise { - const maybeAcl = await queryResourceAcl({ + const mainDb = getMainDbClient(); + const maybeAcl = await queryResourceAclFrom(mainDb)({ userId: args.userId, resourceId: args.id, }); @@ -34,7 +71,8 @@ export const resolvers = { }, ); } - const maybeResource = await getResource(args.id); + const db = await getResourceDatabaseConnection(args.id); + const maybeResource = await queryResourceFrom(db)(args.id); if (maybeResource == null) { throw new GraphQLError("Resource not found", { extensions: { code: "RESOURCE_NOT_FOUND" }, @@ -42,6 +80,12 @@ export const resolvers = { } return maybeResource; }, + async organizations() { + return await getOrganizationsFrom(getMainDbClient())(); + }, + async regions() { + return await getRegionsFrom(getMainDbClient())(); + }, }, User: { async resources(parent: UserRecord, args: PaginationArgs) { @@ -53,11 +97,100 @@ export const resolvers = { parent: Resource, { limit, cursor }: PaginationArgs, ): Promise { - return await getComments({ + const db = await getResourceDatabaseConnection(parent.id); + return await getComments( + countCommentsIn(db), + queryCommentsFrom(db), + )({ resourceId: parent.id, limit, cursor, }); }, }, + Mutation: { + async createUser( + _: unknown, + { input: { name } }: { input: UserCreateArgs }, + ) { + const id = cryptoRandomString({ length: 10 }); + await saveUserTo(getMainDbClient())({ id, name }); + return id; + }, + async registerRegion( + _: unknown, + args: { + name: string; + connectionString: string; + maintenanceDb: string; + }, + ) { + return await registerRegion(args); + }, + async createOrganization(_: unknown, args: { name: string }) { + return await createOrganization(args.name); + }, + async addRegionToOrganization(_: unknown, args: OrganizationsRegions) { + await bindRegionToOrganization(args); + }, + async addUserToOrganization( + _: unknown, + { input: args }: { input: OrganizationAcl }, + ) { + await saveOrganizationAclTo(getMainDbClient())(args); + }, + async createResource( + _: unknown, + { input: args }: { input: ResourceCreateArgs }, + ) { + const mainDb = getMainDbClient(); + await authorizeUserOrgRegion( + queryOrganizationAclFrom(mainDb), + queryOrganizationRegionsFrom(mainDb), + )(args); + + const db = + args.regionId && args.organizationId + ? await getDbClient({ + regionId: args.regionId, + organizationId: args.organizationId, + }) + : mainDb; + + const resourceId = await createResource( + saveResourceTo(db), + saveResourceAclTo(mainDb), + )(args); + + if (args.organizationId) { + await saveOrganizationResourceAclTo(mainDb)({ + organizationId: args.organizationId, + resourceId, + }); + await saveResourceRegionOrganizationTo(mainDb)({ + resourceId, + organizationId: args.organizationId, + // i know its not null here, the authz function ensures it + regionId: args.regionId!, + }); + } + return resourceId; + }, + async addComment( + _: unknown, + { input: args }: { input: CommentCreateArgs }, + ) { + const mainDb = getMainDbClient(); + const resourceAcl = await queryResourceAclFrom(mainDb)(args); + if (!resourceAcl) + throw new Error("The user doesn't have access to the given resource"); + //2. get resource db client + const db = await getResourceDatabaseConnection(args.resourceId); + //3. save comment to db + const id = cryptoRandomString({ length: 10 }); + const createdAt = new Date(); + await saveCommentTo(db)({ id, createdAt, ...args }); + return id; + }, + }, }; diff --git a/src/schema.graphql b/src/schema.graphql index 318615e..86933bd 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -30,8 +30,59 @@ type User { resources(limit: Int! = 10, cursor: String = null): ResourceCollection! } +type Organization { + id: String! + name: String! +} + +type Region { + id: String! + name: String! + maintenanceDb: String! +} + type Query { user(id: String!): User + users: [User!] resource(id: String!, userId: String!): Resource + + organizations: [Organization!] + regions: [Region!] +} + +input ResourceCreateInput { + userId: String! + name: String! + organizationId: String = null + regionId: String = null +} + +input OrganizationAcl { + userId: String! + organizationId: String! +} + +input CommentInput { + userId: String! + content: String! + resourceId: String! +} + +input UserCreateArgs { + name: String! +} + +type Mutation { + createUser(input: UserCreateArgs!): String! + registerRegion( + name: String! + connectionString: String! + maintenanceDb: String! + ): String! + createOrganization(name: String!): String! + addRegionToOrganization(organizationId: String!, regionId: String!): Boolean + addUserToOrganization(input: OrganizationAcl!): Boolean + createResource(input: ResourceCreateInput!): String! + addComment(input: CommentInput!): String! } diff --git a/src/services.ts b/src/services.ts deleted file mode 100644 index 33037eb..0000000 --- a/src/services.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { - queryUser, - queryResource, - countComments, - queryComments, - countResources, - queryResources, -} from "./repositories"; -import { - UserRecord, - Resource, - CommentCollection, - PaginationArgs, - ResourceCollection, -} from "./types"; - -export const getUser = async (id: string): Promise => { - return await queryUser(id); -}; - -export const getResource = async (id: string): Promise => { - return await queryResource(id); -}; - -interface GetResourcesArgs extends PaginationArgs { - userId: string; -} -export const getResources = async ( - params: GetResourcesArgs, -): Promise => { - const totalCount = await countResources(params.userId); - const items = await queryResources(params); - let cursor = null; - if (items.length > 0) { - cursor = items.slice(-1)[0].createdAt.toISOString(); - } - return { - totalCount, - items, - cursor, - }; -}; - -export const getComments = async (params: { - resourceId: string; - limit: number; - cursor: string | null; -}): Promise => { - // yes, i should be doing base64 de and encoding with the cursor... - const totalCount = await countComments(params.resourceId); - const items = await queryComments(params); - let cursor = null; - if (items.length > 0) { - cursor = items.slice(-1)[0].createdAt.toISOString(); - } - return { - totalCount, - items, - cursor, - }; -}; diff --git a/src/services/authz.ts b/src/services/authz.ts new file mode 100644 index 0000000..812cc90 --- /dev/null +++ b/src/services/authz.ts @@ -0,0 +1,26 @@ +import { + OrganizationAcl, + OrganizationsRegions, + UserOrgRegionArgs, +} from "../types"; + +export const authorizeUserOrgRegion = + ( + orgAclGetter: (params: OrganizationAcl) => Promise, + orgRegionGetter: ( + params: OrganizationsRegions, + ) => Promise, + ) => + async ({ userId, regionId, organizationId }: UserOrgRegionArgs) => { + if (!organizationId && regionId) + throw new Error("public org doesn't support regions"); + if (organizationId) { + if (!regionId) throw new Error("organizations can only write to regions"); + const orgAcl = await orgAclGetter({ organizationId, userId }); + if (!orgAcl) + throw new Error("user doesn't have access to this organization"); + const orgRegion = await orgRegionGetter({ organizationId, regionId }); + if (!orgRegion) + throw new Error("organization doesnt have access to this region"); + } + }; diff --git a/src/services/comments.ts b/src/services/comments.ts new file mode 100644 index 0000000..3431525 --- /dev/null +++ b/src/services/comments.ts @@ -0,0 +1,25 @@ +import { CommentCollection, PaginationArgs, Comment } from "../types"; + +interface GetCommentsArgs extends PaginationArgs { + resourceId: string; +} + +export const getComments = + ( + countComments: (resourceId: string) => Promise, + queryComments: (params: GetCommentsArgs) => Promise, + ) => + async (params: GetCommentsArgs): Promise => { + // yes, i should be doing base64 de and encoding with the cursor... + const totalCount = await countComments(params.resourceId); + const items = await queryComments(params); + let cursor = null; + if (items.length > 0) { + cursor = items.slice(-1)[0].createdAt.toISOString(); + } + return { + totalCount, + items, + cursor, + }; + }; diff --git a/src/services/databaseManagement.ts b/src/services/databaseManagement.ts new file mode 100644 index 0000000..2ad2dbe --- /dev/null +++ b/src/services/databaseManagement.ts @@ -0,0 +1,242 @@ +import { POSTGRES_URL } from "../config"; +import { + getOrganizationFrom, + getOrganizationRegionsFrom, + getRegionFrom, + queryResourceRegionOrganizationFrom, + saveOrganizationTo, + saveOrganizationsRegionsTo, + saveRegionTo, +} from "../repositories"; +import { OrganizationsRegions, Region } from "../types"; +import knex, { Knex } from "knex"; +import cryptoRandomString from "crypto-random-string"; + +const migrateToLatest = async (client: Knex): Promise => { + const plannedMigrations: Array<{ file: string }> = ( + await client.migrate.list() + )[1]; + if (plannedMigrations.length > 0) { + console.log( + `🕰️ planning migrations: ${plannedMigrations + .map((m) => m.file) + .join(",")}`, + ); + } else { + console.log("no migrations are planned"); + } + // TODO: make sure if a migration fails, all migrations are rolled back + await client.migrate.latest(); +}; + +export const migrateAll = async (): Promise => { + const databaseSchemas = await getAllDatabaseSchemaConnections(); + + await Promise.all( + databaseSchemas.map(async (sc) => await migrateToLatest(sc)), + ); + // 1. get all regions from main DB + // 2. construct region specific knex clients and cache them by + // 3. structure the cache so that it accomodates client creation by resource id + // 4. get all organization regions from main DB + // 5. for in all regions for all organizations, run the migration + // 6. do not forget the migration for the main DB + // +}; + +const createDatabaseConfig = (connectionString: string): Knex.Config => { + return { + client: "pg", + connection: { + connectionString, + }, + // connection: connectionString, + migrations: { + directory: "src/migrations", + extension: "ts", + }, + }; +}; + +const mainClient = knex(createDatabaseConfig(POSTGRES_URL)); + +const _connectionStore: Map = new Map(); + +interface RegionWithMaybeOrganization { + regionId: string; + organizationId?: string | undefined; +} + +const _createConnectionKey = ({ + organizationId, + regionId, +}: RegionWithMaybeOrganization): string => { + return organizationId ? `${organizationId}@${regionId}` : regionId; +}; + +export const getDbClient = async ({ + regionId, + organizationId, +}: RegionWithMaybeOrganization): Promise => { + const connectionKey = _createConnectionKey({ organizationId, regionId }); + const maybeClient = _connectionStore.get(connectionKey); + if (maybeClient) return maybeClient; + const maybeRegion = await mainClient("regions") + .select() + .where({ id: regionId }) + .first(); + if (!maybeRegion) throw Error(`region ${regionId} not found`); + const connectionString = organizationId + ? `${maybeRegion.connectionString}/${organizationId}` + : `${maybeRegion.connectionString}/${maybeRegion.maintenanceDb}`; + const client = knex(createDatabaseConfig(connectionString)); + _connectionStore.set(connectionKey, client); + return client; +}; + +export const getMainDbClient = (): Knex => mainClient; + +export const registerRegion = async ({ + name, + connectionString, + maintenanceDb, +}: { + name: string; + connectionString: string; + maintenanceDb: string; +}): Promise => { + // TODO: validate the connectionString, so that the knex client can connect to it + const id = cryptoRandomString({ length: 10 }); + await saveRegionTo(mainClient)({ + id, + name, + connectionString, + maintenanceDb, + }); + return id; +}; + +export const createOrganization = async (name: string): Promise => { + const id = cryptoRandomString({ length: 10 }); + await saveOrganizationTo(mainClient)({ id, name }); + return id; +}; + +const createDb = async (client: Knex, name: string): Promise => { + try { + await client.raw(`create database "${name}"`); + } catch (err) { + if (!(err instanceof Error)) throw err; + if (!err.message.includes("already exists")) throw err; + } +}; + +const setUpUserReplication = async ({ + from, + to, +}: { + from: Knex; + to: Knex; +}): Promise => { + // TODO: ensure its created... + const connectionString: string = + from.client.config.connection.connectionString; + try { + await from.raw("CREATE PUBLICATION userspub FOR TABLE users;"); + } catch (err) { + if (!(err instanceof Error)) throw err; + if (!err.message.includes("already exists")) throw err; + } + try { + const toUrl = new URL(to.client.config.connection.connectionString); + await to.raw( + `CREATE SUBSCRIPTION userssub_${toUrl.pathname.replace("/", "")} CONNECTION '${connectionString}' PUBLICATION userspub;`, + ); + } catch (err) { + if (!(err instanceof Error)) throw err; + if (!err.message.includes("already exists")) throw err; + } +}; + +const setUpResourceReplication = async ({ + from, + fromRegionName, + to, +}: { + from: Knex; + fromRegionName: string; + to: Knex; +}): Promise => { + // TODO: ensure its created... + const connectionString: string = + from.client.config.connection.connectionString; + const connUrl = new URL(connectionString); + try { + await from.raw("CREATE PUBLICATION resourcepub FOR TABLE resources;"); + } catch (err) { + if (!(err instanceof Error)) throw err; + if (!err.message.includes("already exists")) throw err; + } + try { + await to.raw( + `CREATE SUBSCRIPTION "resroucesub_${fromRegionName.replace( + " ", + "", + )}_${connUrl.pathname.replace( + "/", + "", + )}" CONNECTION '${connectionString}' PUBLICATION resourcepub;`, + ); + } catch (err) { + if (!(err instanceof Error)) throw err; + if (!err.message.includes("already exists")) throw err; + } +}; + +export const bindRegionToOrganization = async ({ + regionId, + organizationId, +}: OrganizationsRegions): Promise => { + const region = await getRegionFrom(mainClient)(regionId); + if (!region) throw Error(`region ${regionId} not found`); + const organization = await getOrganizationFrom(mainClient)(organizationId); + if (!organization) throw Error(`organization ${organizationId} not found`); + + const regionClient = await getDbClient({ regionId }); + + await createDb(regionClient, organizationId); + + const client = await getDbClient({ organizationId, regionId }); + const connectionKey = _createConnectionKey({ organizationId, regionId }); + + await migrateToLatest(client); + + await setUpUserReplication({ from: mainClient, to: client }); + await setUpResourceReplication({ + from: client, + fromRegionName: region.name, + to: mainClient, + }); + + _connectionStore.set(connectionKey, client); + await saveOrganizationsRegionsTo(mainClient)({ organizationId, regionId }); +}; + +export const getAllDatabaseSchemaConnections = async (): Promise => { + const organizationRegions = await getOrganizationRegionsFrom(mainClient)(); + const clients = await Promise.all( + organizationRegions.map(async (or) => { + const client = await getDbClient(or); + return client; + }), + ); + return [mainClient, ...clients]; +}; + +export const getResourceDatabaseConnection = async ( + resourceId: string, +): Promise => { + const resourceRegionOrg = + await queryResourceRegionOrganizationFrom(mainClient)(resourceId); + return resourceRegionOrg ? await getDbClient(resourceRegionOrg) : mainClient; +}; diff --git a/src/services/resources.ts b/src/services/resources.ts new file mode 100644 index 0000000..c2ebff8 --- /dev/null +++ b/src/services/resources.ts @@ -0,0 +1,45 @@ +import cryptoRandomString from "crypto-random-string"; +import { countResources, queryResources } from "../repositories"; +import { + Resource, + PaginationArgs, + ResourceCollection, + ResourceCreateArgs, + ResourceAcl, +} from "../types"; + +interface GetResourcesArgs extends PaginationArgs { + userId: string; +} +export const getResources = async ( + params: GetResourcesArgs, +): Promise => { + const totalCount = await countResources(params.userId); + const items = await queryResources(params); + let cursor = null; + if (items.length > 0) { + cursor = items.slice(-1)[0].createdAt.toISOString(); + } + return { + totalCount, + items, + cursor, + }; +}; + +export const createResource = + ( + resourceSaver: (resource: Resource) => Promise, + resourceAclSaver: (resourceAcl: ResourceAcl) => Promise, + ) => + async ({ userId, name }: ResourceCreateArgs): Promise => { + //1. if no org, create project in main region, validate that, regionId is null + //2. if org, validate if user has access to the org + //3. if org and region, validate if org has access to region + //4. create resource + const id = cryptoRandomString({ length: 10 }); + const resource = { id, name, createdAt: new Date() }; + await resourceSaver(resource); + await resourceAclSaver({ resourceId: id, userId }); + return id; + }; diff --git a/src/types.ts b/src/types.ts index 2ec420c..cfb6843 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,11 +1,14 @@ -export interface Comment { - id: string; +export interface CommentCreateArgs { userId: string; content: string; - createdAt: Date; resourceId: string; } +export interface Comment extends CommentCreateArgs { + id: string; + createdAt: Date; +} + export interface PaginationArgs { limit: number; cursor: string | null; @@ -19,6 +22,16 @@ interface Collection { export interface CommentCollection extends Collection {} +export interface UserOrgRegionArgs { + userId: string; + organizationId: string | null; + regionId: string | null; +} + +export interface ResourceCreateArgs extends UserOrgRegionArgs { + name: string; +} + export interface Resource { id: string; name: string; @@ -27,11 +40,14 @@ export interface Resource { export interface ResourceCollection extends Collection {} -export interface UserRecord { - id: string; +export interface UserCreateArgs { name: string; } +export interface UserRecord extends UserCreateArgs { + id: string; +} + export interface User extends UserRecord { resources: { cursor: string | null; @@ -44,3 +60,39 @@ export interface ResourceAcl { userId: string; resourceId: string; } + +export interface Region { + id: string; + name: string; + connectionString: string; + maintenanceDb: string; +} + +export interface Organization { + id: string; + name: string; +} + +export interface OrganizationAcl { + userId: string; + organizationId: string; +} + +export interface OrganizationsRegions { + organizationId: string; + regionId: string; +} + +export interface OrganizationResourceAcl { + organizationId: string; + resourceId: string; +} + +export interface ResourceRegion { + resourceId: string; + regionId: string; +} + +export interface ResourceRegionOrg extends ResourceRegion { + organizationId: string; +}