From fa85b7d0ebaff178f06e29c35d32f52cdbf17323 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Mon, 13 Mar 2023 12:01:14 +0800 Subject: [PATCH] refactor: remove withtyped in core (#3375) Keeping withtyped will introduce an additional database pool per tenant, which is not good for performance and it should be more like all-or-nothing choice. So remove it for core, but keep it in cloud. --- .../cli/src/commands/database/seed/tables.ts | 3 +- packages/cloud/src/libraries/tenants.ts | 4 +- packages/core/package.json | 2 - .../core/src/env-set/create-query-client.ts | 18 ---- packages/core/src/env-set/index.ts | 26 +----- packages/core/src/index.ts | 7 +- packages/core/src/libraries/hook.test.ts | 35 ++----- packages/core/src/libraries/hook.ts | 15 ++- .../core/src/middleware/koa-auth/utils.ts | 2 +- packages/core/src/model-routers/index.ts | 9 -- packages/core/src/queries/hooks.ts | 56 ++++++++++++ packages/core/src/routes/hook.ts | 91 ++++++++++++++----- packages/core/src/tenants/Libraries.ts | 5 +- packages/core/src/tenants/Queries.ts | 2 + packages/core/src/tenants/Tenant.ts | 8 +- packages/core/src/tenants/TenantContext.ts | 2 - packages/core/src/tenants/index.ts | 8 +- packages/core/src/tenants/utils.ts | 24 +++-- packages/core/src/test-utils/query-client.ts | 24 ----- packages/core/src/test-utils/tenant.ts | 5 +- packages/core/src/utils/zod.ts | 8 ++ .../src/tests/api/hooks.test.ts | 9 +- .../schemas/src/foundations/jsonb-types.ts | 59 +++++++----- packages/schemas/src/models/hooks.ts | 64 ------------- packages/schemas/src/models/index.ts | 1 - packages/schemas/src/types/hook.ts | 14 +++ packages/schemas/src/types/index.ts | 1 + packages/schemas/src/types/log/hook.ts | 2 +- packages/schemas/tables/hooks.sql | 13 +++ pnpm-lock.yaml | 4 - 30 files changed, 259 insertions(+), 262 deletions(-) delete mode 100644 packages/core/src/env-set/create-query-client.ts delete mode 100644 packages/core/src/model-routers/index.ts create mode 100644 packages/core/src/queries/hooks.ts delete mode 100644 packages/core/src/test-utils/query-client.ts delete mode 100644 packages/schemas/src/models/hooks.ts create mode 100644 packages/schemas/src/types/hook.ts create mode 100644 packages/schemas/tables/hooks.sql diff --git a/packages/cli/src/commands/database/seed/tables.ts b/packages/cli/src/commands/database/seed/tables.ts index 938c32204..bec750a46 100644 --- a/packages/cli/src/commands/database/seed/tables.ts +++ b/packages/cli/src/commands/database/seed/tables.ts @@ -17,7 +17,7 @@ import { createAdminTenantApplicationRole, CloudScope, } from '@logto/schemas'; -import { Hooks, Tenants } from '@logto/schemas/models'; +import { Tenants } from '@logto/schemas/models'; import type { DatabaseTransactionConnection } from 'slonik'; import { sql } from 'slonik'; import { raw } from 'slonik-sql-tag-raw'; @@ -94,7 +94,6 @@ export const createTables = async (connection: DatabaseTransactionConnection) => }; const allQueries: Array<[string, string]> = [ - [Hooks.tableName, Hooks.raw], [Tenants.tableName, Tenants.raw], ...queries.filter(([file]) => !lifecycleNames.includes(file.slice(1, -4))), ]; diff --git a/packages/cloud/src/libraries/tenants.ts b/packages/cloud/src/libraries/tenants.ts index b25086c33..ed2dfc29a 100644 --- a/packages/cloud/src/libraries/tenants.ts +++ b/packages/cloud/src/libraries/tenants.ts @@ -69,7 +69,7 @@ export class TenantsLibrary { const applications = createApplicationsQueries(transaction); const roles = createRolesQuery(transaction); - /* --- Start --- */ + /* === Start === */ await transaction.start(); // Init tenant @@ -116,7 +116,7 @@ export class TenantsLibrary { ); await transaction.end(); - /* --- End --- */ + /* === End === */ return { id: tenantId, indicator: adminDataInAdminTenant.resource.indicator }; } diff --git a/packages/core/package.json b/packages/core/package.json index ec672455f..91edd7318 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -36,8 +36,6 @@ "@logto/schemas": "workspace:*", "@logto/shared": "workspace:*", "@silverhand/essentials": "2.4.0", - "@withtyped/postgres": "^0.8.1", - "@withtyped/server": "^0.8.1", "aws-sdk": "^2.1329.0", "chalk": "^5.0.0", "clean-deep": "^3.4.0", diff --git a/packages/core/src/env-set/create-query-client.ts b/packages/core/src/env-set/create-query-client.ts deleted file mode 100644 index f091214e9..000000000 --- a/packages/core/src/env-set/create-query-client.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { assert } from '@silverhand/essentials'; -import { PostgresQueryClient } from '@withtyped/postgres'; -import { parseDsn } from 'slonik'; - -import { MockQueryClient } from '#src/test-utils/query-client.js'; - -const createQueryClient = (databaseDsn: string, isTest: boolean) => { - // Database connection is disabled in unit test environment - if (isTest) { - return new MockQueryClient(); - } - - assert(parseDsn(databaseDsn), new Error('Database name is required')); - - return new PostgresQueryClient({ connectionString: databaseDsn }); -}; - -export default createQueryClient; diff --git a/packages/core/src/env-set/index.ts b/packages/core/src/env-set/index.ts index 79d09f34c..6286275a5 100644 --- a/packages/core/src/env-set/index.ts +++ b/packages/core/src/env-set/index.ts @@ -1,15 +1,12 @@ import { GlobalValues } from '@logto/shared'; import type { Optional } from '@silverhand/essentials'; import { appendPath } from '@silverhand/essentials'; -import type { PostgreSql } from '@withtyped/postgres'; -import type { QueryClient } from '@withtyped/server'; import type { DatabasePool } from 'slonik'; import { createLogtoConfigLibrary } from '#src/libraries/logto-config.js'; import { createLogtoConfigQueries } from '#src/queries/logto-config.js'; import createPool from './create-pool.js'; -import createQueryClient from './create-query-client.js'; import loadOidcValues from './oidc.js'; import { throwNotLoadedError } from './throw-errors.js'; import { getTenantEndpoint } from './utils.js'; @@ -40,15 +37,9 @@ export class EnvSet { return this.values.dbUrl; } - static queryClient = createQueryClient(this.dbUrl, this.isTest); - - /** @deprecated Only for backward compatibility; Will be replaced soon. */ - static pool = createPool(this.dbUrl, this.isTest); + static sharedPool = createPool(this.dbUrl, this.isTest); #pool: Optional; - // Use another pool for `withtyped` while adopting the new model, - // as we cannot extract the original PgPool from slonik - #queryClient: Optional>; #oidc: Optional>>; constructor(public readonly tenantId: string, public readonly databaseUrl: string) {} @@ -65,18 +56,6 @@ export class EnvSet { return this.#pool; } - get queryClient() { - if (!this.#queryClient) { - return throwNotLoadedError(); - } - - return this.#queryClient; - } - - get queryClientSafe() { - return this.#queryClient; - } - get oidc() { if (!this.#oidc) { return throwNotLoadedError(); @@ -89,7 +68,6 @@ export class EnvSet { const pool = await createPool(this.databaseUrl, EnvSet.isTest); this.#pool = pool; - this.#queryClient = createQueryClient(this.databaseUrl, EnvSet.isTest); const { getOidcConfigs } = createLogtoConfigLibrary(createLogtoConfigQueries(pool)); @@ -99,7 +77,7 @@ export class EnvSet { } async end() { - await Promise.all([this.#pool?.end(), this.#queryClient?.end()]); + await this.#pool?.end(); } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b9733359c..e5b15da82 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -18,13 +18,14 @@ try { const app = new Koa({ proxy: EnvSet.values.trustProxyHeader, }); + const sharedAdminPool = await EnvSet.sharedPool; await initI18n(); await loadConnectorFactories(); await Promise.all([ - checkRowLevelSecurity(EnvSet.queryClient), - checkAlterationState(await EnvSet.pool), + checkRowLevelSecurity(sharedAdminPool), + checkAlterationState(sharedAdminPool), ]); - await SystemContext.shared.loadStorageProviderConfig(await EnvSet.pool); + await SystemContext.shared.loadStorageProviderConfig(sharedAdminPool); // Import last until init completed const { default: initApp } = await import('./app/init.js'); diff --git a/packages/core/src/libraries/hook.test.ts b/packages/core/src/libraries/hook.test.ts index ce2d2a6c8..a1b23592c 100644 --- a/packages/core/src/libraries/hook.test.ts +++ b/packages/core/src/libraries/hook.test.ts @@ -1,11 +1,8 @@ -import { InteractionEvent, LogResult } from '@logto/schemas'; -import { HookEvent } from '@logto/schemas/lib/models/hooks.js'; +import type { Hook } from '@logto/schemas'; +import { HookEvent, InteractionEvent, LogResult } from '@logto/schemas'; import { createMockUtils } from '@logto/shared/esm'; -import type { InferModelType } from '@withtyped/server'; import { got } from 'got'; -import type { ModelRouters } from '#src/model-routers/index.js'; - import type { Interaction } from './hook.js'; const { jest } = import.meta; @@ -18,19 +15,15 @@ await mockEsmWithActual('@logto/core-kit', () => ({ generateStandardId: () => nanoIdMock, })); -const { createModelRouters } = await import('#src/model-routers/index.js'); -const { MockQueryClient } = await import('#src/test-utils/query-client.js'); const { MockQueries } = await import('#src/test-utils/tenant.js'); -const queryClient = new MockQueryClient(); -const queryFunction = jest.fn(); - const url = 'https://logto.gg'; -const hook: InferModelType = { +const hook: Hook = { + tenantId: 'bar', id: 'foo', event: HookEvent.PostSignIn, config: { headers: { bar: 'baz' }, url, retries: 3 }, - createdAt: new Date(), + createdAt: Date.now() / 1000, }; const post = jest @@ -39,13 +32,9 @@ const post = jest .mockImplementation(jest.fn(async () => ({ statusCode: 200, body: '{"message":"ok"}' }))); const insertLog = jest.fn(); - -// eslint-disable-next-line unicorn/consistent-function-scoping -mockEsmDefault('#src/env-set/create-query-client.js', () => () => queryClient); -jest.spyOn(queryClient, 'query').mockImplementation(queryFunction); +const findAllHooks = jest.fn().mockResolvedValue([hook]); const { createHookLibrary } = await import('./hook.js'); -const modelRouters = createModelRouters(new MockQueryClient()); const { triggerInteractionHooksIfNeeded } = createHookLibrary( new MockQueries({ // @ts-expect-error @@ -55,14 +44,10 @@ const { triggerInteractionHooksIfNeeded } = createHookLibrary( findApplicationById: async () => ({ id: 'app_id', extraField: 'not_ok' }), }, logs: { insertLog }, - }), - modelRouters + hooks: { findAllHooks }, + }) ); -const readAll = jest - .spyOn(modelRouters.hook.client, 'readAll') - .mockResolvedValue({ rows: [hook], rowCount: 1 }); - describe('triggerInteractionHooksIfNeeded()', () => { afterEach(() => { jest.clearAllMocks(); @@ -71,7 +56,7 @@ describe('triggerInteractionHooksIfNeeded()', () => { it('should return if no user ID found', async () => { await triggerInteractionHooksIfNeeded(InteractionEvent.SignIn); - expect(queryFunction).not.toBeCalled(); + expect(findAllHooks).not.toBeCalled(); }); it('should set correct payload when hook triggered', async () => { @@ -87,7 +72,7 @@ describe('triggerInteractionHooksIfNeeded()', () => { } as Interaction ); - expect(readAll).toHaveBeenCalled(); + expect(findAllHooks).toHaveBeenCalled(); expect(post).toHaveBeenCalledWith(url, { headers: { 'user-agent': 'Logto (https://logto.io)', bar: 'baz' }, json: { diff --git a/packages/core/src/libraries/hook.ts b/packages/core/src/libraries/hook.ts index a4d351343..9cb4a89ea 100644 --- a/packages/core/src/libraries/hook.ts +++ b/packages/core/src/libraries/hook.ts @@ -1,6 +1,11 @@ import { generateStandardId } from '@logto/core-kit'; -import { InteractionEvent, LogResult, userInfoSelectFields } from '@logto/schemas'; -import { HookEventPayload, HookEvent } from '@logto/schemas/models'; +import { + HookEvent, + HookEventPayload, + InteractionEvent, + LogResult, + userInfoSelectFields, +} from '@logto/schemas'; import { trySafe } from '@logto/shared'; import { conditional, pick } from '@silverhand/essentials'; import type { Response } from 'got'; @@ -8,7 +13,6 @@ import { got, HTTPError } from 'got'; import type Provider from 'oidc-provider'; import { LogEntry } from '#src/middleware/koa-audit-log.js'; -import type { ModelRouters } from '#src/model-routers/index.js'; import type Queries from '#src/tenants/Queries.js'; const parseResponse = ({ statusCode, body }: Response) => ({ @@ -25,12 +29,13 @@ const eventToHook: Record = { export type Interaction = Awaited>; -export const createHookLibrary = (queries: Queries, { hook }: ModelRouters) => { +export const createHookLibrary = (queries: Queries) => { const { applications: { findApplicationById }, logs: { insertLog }, // TODO: @gao should we use the library function thus we can pass full userinfo to the payload? users: { findUserById }, + hooks: { findAllHooks }, } = queries; const triggerInteractionHooksIfNeeded = async ( @@ -47,7 +52,7 @@ export const createHookLibrary = (queries: Queries, { hook }: ModelRouters) => { } const hookEvent = eventToHook[event]; - const { rows } = await hook.client.readAll(); + const rows = await findAllHooks(); const [user, application] = await Promise.all([ trySafe(findUserById(userId)), diff --git a/packages/core/src/middleware/koa-auth/utils.ts b/packages/core/src/middleware/koa-auth/utils.ts index 6446a22b1..8016a6be8 100644 --- a/packages/core/src/middleware/koa-auth/utils.ts +++ b/packages/core/src/middleware/koa-auth/utils.ts @@ -33,7 +33,7 @@ export const getAdminTenantTokenValidationSet = async (): Promise<{ return { keys: [], issuer: [] }; } - const pool = await EnvSet.pool; + const pool = await EnvSet.sharedPool; const { value } = await pool.one(sql` select ${sql.join([fields.key, fields.value], sql`,`)} from ${table} where ${fields.tenantId} = ${adminTenantId} diff --git a/packages/core/src/model-routers/index.ts b/packages/core/src/model-routers/index.ts deleted file mode 100644 index 258b9cd28..000000000 --- a/packages/core/src/model-routers/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Hooks } from '@logto/schemas/models'; -import { createModelRouter } from '@withtyped/postgres'; -import type { QueryClient } from '@withtyped/server'; - -export type ModelRouters = ReturnType; - -export const createModelRouters = (queryClient: QueryClient) => ({ - hook: createModelRouter(Hooks, queryClient).withCrud(), -}); diff --git a/packages/core/src/queries/hooks.ts b/packages/core/src/queries/hooks.ts new file mode 100644 index 000000000..6b3a6e763 --- /dev/null +++ b/packages/core/src/queries/hooks.ts @@ -0,0 +1,56 @@ +import type { CreateHook, Hook } from '@logto/schemas'; +import { Hooks } from '@logto/schemas'; +import type { OmitAutoSetFields } from '@logto/shared'; +import { convertToIdentifiers, manyRows } from '@logto/shared'; +import type { CommonQueryMethods } from 'slonik'; +import { sql } from 'slonik'; + +import { buildFindEntityByIdWithPool } from '#src/database/find-entity-by-id.js'; +import { buildInsertIntoWithPool } from '#src/database/insert-into.js'; +import { buildUpdateWhereWithPool } from '#src/database/update-where.js'; +import { DeletionError } from '#src/errors/SlonikError/index.js'; + +const { table, fields } = convertToIdentifiers(Hooks); + +export const createHooksQueries = (pool: CommonQueryMethods) => { + const findAllHooks = async () => + manyRows( + pool.query(sql` + select ${sql.join(Object.values(fields), sql`, `)} + from ${table} + `) + ); + + const findHookById = buildFindEntityByIdWithPool(pool)(Hooks); + + const insertHook = buildInsertIntoWithPool(pool)(Hooks, { + returning: true, + }); + + const updateHook = buildUpdateWhereWithPool(pool)(Hooks, true); + + const updateHookById = async ( + id: string, + set: Partial>, + jsonbMode: 'replace' | 'merge' = 'merge' + ) => updateHook({ set, where: { id }, jsonbMode }); + + const deleteHookById = async (id: string) => { + const { rowCount } = await pool.query(sql` + delete from ${table} + where ${fields.id}=${id} + `); + + if (rowCount < 1) { + throw new DeletionError(Hooks.table, id); + } + }; + + return { + findAllHooks, + findHookById, + insertHook, + updateHookById, + deleteHookById, + }; +}; diff --git a/packages/core/src/routes/hook.ts b/packages/core/src/routes/hook.ts index 9f894a96c..8cda306bf 100644 --- a/packages/core/src/routes/hook.ts +++ b/packages/core/src/routes/hook.ts @@ -1,29 +1,76 @@ -import { koaAdapter, RequestError } from '@withtyped/server'; -import type { MiddlewareType } from 'koa'; -import koaBody from 'koa-body'; +import { generateStandardId } from '@logto/core-kit'; +import { Hooks } from '@logto/schemas'; +import { z } from 'zod'; -import LogtoRequestError from '#src/errors/RequestError/index.js'; +import koaGuard from '#src/middleware/koa-guard.js'; import type { AuthedRouter, RouterInitArgs } from './types.js'; -// Organize this function if we decide to adopt withtyped eventually -const errorHandler: MiddlewareType = async (_, next) => { - try { - await next(); - } catch (error: unknown) { - if (error instanceof RequestError) { - throw new LogtoRequestError( - { code: 'request.general', status: error.status }, - error.original - ); - } - - throw error; - } -}; - export default function hookRoutes( - ...[router, { modelRouters }]: RouterInitArgs + ...[router, { queries }]: RouterInitArgs ) { - router.all('/hooks/(.*)?', koaBody(), errorHandler, koaAdapter(modelRouters.hook.routes())); + const { findAllHooks, findHookById, insertHook, updateHookById, deleteHookById } = queries.hooks; + + router.get('/hooks', async (ctx, next) => { + ctx.body = await findAllHooks(); + + return next(); + }); + + router.post( + '/hooks', + koaGuard({ body: Hooks.createGuard.omit({ id: true }) }), + async (ctx, next) => { + ctx.body = await insertHook({ + id: generateStandardId(), + ...ctx.guard.body, + }); + + return next(); + } + ); + + router.get( + '/hooks/:id', + koaGuard({ params: z.object({ id: z.string().min(1) }) }), + async (ctx, next) => { + const { + params: { id }, + } = ctx.guard; + + ctx.body = await findHookById(id); + + return next(); + } + ); + + router.patch( + '/hooks/:id', + koaGuard({ + params: z.object({ id: z.string().min(1) }), + body: Hooks.createGuard.omit({ id: true }).partial(), + }), + async (ctx, next) => { + const { + params: { id }, + body, + } = ctx.guard; + + ctx.body = await updateHookById(id, body); + + return next(); + } + ); + + router.delete( + '/hooks/:id', + koaGuard({ params: z.object({ id: z.string().min(1) }) }), + async (ctx, next) => { + const { id } = ctx.guard.params; + await deleteHookById(id); + ctx.status = 204; + + return next(); + } + ); } diff --git a/packages/core/src/tenants/Libraries.ts b/packages/core/src/tenants/Libraries.ts index acee41282..9ddef88ab 100644 --- a/packages/core/src/tenants/Libraries.ts +++ b/packages/core/src/tenants/Libraries.ts @@ -8,7 +8,6 @@ import { createSignInExperienceLibrary } from '#src/libraries/sign-in-experience import { createSocialLibrary } from '#src/libraries/social.js'; import { createUserLibrary } from '#src/libraries/user.js'; import { createVerificationStatusLibrary } from '#src/libraries/verification-status.js'; -import type { ModelRouters } from '#src/model-routers/index.js'; import type Queries from './Queries.js'; @@ -18,11 +17,11 @@ export default class Libraries { signInExperiences = createSignInExperienceLibrary(this.queries, this.connectors); phrases = createPhraseLibrary(this.queries); resources = createResourceLibrary(this.queries); - hooks = createHookLibrary(this.queries, this.modelRouters); + hooks = createHookLibrary(this.queries); socials = createSocialLibrary(this.queries, this.connectors); passcodes = createPasscodeLibrary(this.queries, this.connectors); applications = createApplicationLibrary(this.queries); verificationStatuses = createVerificationStatusLibrary(this.queries); - constructor(private readonly queries: Queries, private readonly modelRouters: ModelRouters) {} + constructor(private readonly queries: Queries) {} } diff --git a/packages/core/src/tenants/Queries.ts b/packages/core/src/tenants/Queries.ts index 732bc52a9..770d783dd 100644 --- a/packages/core/src/tenants/Queries.ts +++ b/packages/core/src/tenants/Queries.ts @@ -4,6 +4,7 @@ import { createApplicationQueries } from '#src/queries/application.js'; import { createApplicationsRolesQueries } from '#src/queries/applications-roles.js'; import { createConnectorQueries } from '#src/queries/connector.js'; import { createCustomPhraseQueries } from '#src/queries/custom-phrase.js'; +import { createHooksQueries } from '#src/queries/hooks.js'; import { createLogQueries } from '#src/queries/log.js'; import { createLogtoConfigQueries } from '#src/queries/logto-config.js'; import { createOidcModelInstanceQueries } from '#src/queries/oidc-model-instance.js'; @@ -34,6 +35,7 @@ export default class Queries { usersRoles = createUsersRolesQueries(this.pool); applicationsRoles = createApplicationsRolesQueries(this.pool); verificationStatuses = createVerificationStatusQueries(this.pool); + hooks = createHooksQueries(this.pool); constructor(public readonly pool: CommonQueryMethods) {} } diff --git a/packages/core/src/tenants/Tenant.ts b/packages/core/src/tenants/Tenant.ts index fee79ab77..5a27c7cda 100644 --- a/packages/core/src/tenants/Tenant.ts +++ b/packages/core/src/tenants/Tenant.ts @@ -16,8 +16,6 @@ import koaOIDCErrorHandler from '#src/middleware/koa-oidc-error-handler.js'; import koaSlonikErrorHandler from '#src/middleware/koa-slonik-error-handler.js'; import koaSpaProxy from '#src/middleware/koa-spa-proxy.js'; import koaSpaSessionGuard from '#src/middleware/koa-spa-session-guard.js'; -import type { ModelRouters } from '#src/model-routers/index.js'; -import { createModelRouters } from '#src/model-routers/index.js'; import initOidc from '#src/oidc/init.js'; import initMeApis from '#src/routes-me/init.js'; import initApis from '#src/routes/init.js'; @@ -39,7 +37,6 @@ export default class Tenant implements TenantContext { public readonly provider: Provider; public readonly queries: Queries; public readonly libraries: Libraries; - public readonly modelRouters: ModelRouters; public readonly app: Koa; @@ -56,9 +53,8 @@ export default class Tenant implements TenantContext { } private constructor(public readonly envSet: EnvSet, public readonly id: string) { - const modelRouters = createModelRouters(envSet.queryClient); const queries = new Queries(envSet.pool); - const libraries = new Libraries(queries, modelRouters); + const libraries = new Libraries(queries); const isAdminTenant = id === adminTenantId; const mountedApps = [ ...Object.values(UserApps), @@ -66,7 +62,6 @@ export default class Tenant implements TenantContext { ]; this.envSet = envSet; - this.modelRouters = modelRouters; this.queries = queries; this.libraries = libraries; @@ -90,7 +85,6 @@ export default class Tenant implements TenantContext { provider, queries, libraries, - modelRouters, envSet, }; // Mount APIs diff --git a/packages/core/src/tenants/TenantContext.ts b/packages/core/src/tenants/TenantContext.ts index cfcdd2f06..35d6c2bc6 100644 --- a/packages/core/src/tenants/TenantContext.ts +++ b/packages/core/src/tenants/TenantContext.ts @@ -1,7 +1,6 @@ import type Provider from 'oidc-provider'; import type { EnvSet } from '#src/env-set/index.js'; -import type { ModelRouters } from '#src/model-routers/index.js'; import type Libraries from './Libraries.js'; import type Queries from './Queries.js'; @@ -12,5 +11,4 @@ export default abstract class TenantContext { public abstract readonly provider: Provider; public abstract readonly queries: Queries; public abstract readonly libraries: Libraries; - public abstract readonly modelRouters: ModelRouters; } diff --git a/packages/core/src/tenants/index.ts b/packages/core/src/tenants/index.ts index 8263b72f6..f265e2ce0 100644 --- a/packages/core/src/tenants/index.ts +++ b/packages/core/src/tenants/index.ts @@ -25,7 +25,13 @@ export class TenantPool { } async endAll(): Promise { - await Promise.all(this.cache.dump().map(async ([, tenant]) => tenant.value.envSet.end())); + await Promise.all( + this.cache.dump().map(([, tenant]) => { + const { poolSafe } = tenant.value.envSet; + + return poolSafe?.end(); + }) + ); } } diff --git a/packages/core/src/tenants/utils.ts b/packages/core/src/tenants/utils.ts index 40641b545..bcfdda2a0 100644 --- a/packages/core/src/tenants/utils.ts +++ b/packages/core/src/tenants/utils.ts @@ -2,9 +2,9 @@ import { Systems } from '@logto/schemas'; import { Tenants } from '@logto/schemas/models'; import { isKeyInObject } from '@logto/shared'; import { conditional, conditionalString } from '@silverhand/essentials'; -import { identifier, sql } from '@withtyped/postgres'; -import type { QueryClient } from '@withtyped/server'; -import { parseDsn, stringifyDsn } from 'slonik'; +import type { CommonQueryMethods } from 'slonik'; +import { parseDsn, sql, stringifyDsn } from 'slonik'; +import { z } from 'zod'; import { EnvSet } from '#src/env-set/index.js'; @@ -13,16 +13,19 @@ export class TenantNotFoundError extends Error {} /** * This function is to fetch the tenant password for the corresponding Postgres user. * - * In multi-tenancy mode, Logto should ALWAYS use a restricted user with RLS enforced to ensure data isolation between tenants. + * ** **CAUTION** ** In multi-tenancy mode, Logto should ALWAYS use a restricted user with RLS enforced to ensure data isolation between tenants. */ export const getTenantDatabaseDsn = async (tenantId: string) => { - const { queryClient, dbUrl } = EnvSet; + const { sharedPool, dbUrl } = EnvSet; const { tableName, rawKeys: { id, dbUser, dbUserPassword }, } = Tenants; - const { rows } = await queryClient.query(sql` + const identifier = (id: string) => sql.identifier([id]); + const pool = await sharedPool; + + const { rows } = await pool.query(sql` select ${identifier(dbUser)}, ${identifier(dbUserPassword)} from ${identifier(tableName)} where ${identifier(id)} = ${tenantId} @@ -33,17 +36,18 @@ export const getTenantDatabaseDsn = async (tenantId: string) => { } const options = parseDsn(dbUrl); - const username = rows[0][dbUser]; - const password = rows[0][dbUserPassword]; + const { dbUser: username, dbUserPassword: password } = z + .object({ dbUser: z.string(), dbUserPassword: z.string().optional() }) + .parse(rows[0]); return stringifyDsn({ ...options, - username: conditional(typeof username === 'string' && username), + username, password: conditional(typeof password === 'string' && password), }); }; -export const checkRowLevelSecurity = async (client: QueryClient) => { +export const checkRowLevelSecurity = async (client: CommonQueryMethods) => { const { rows } = await client.query(sql` select tablename from pg_catalog.pg_tables diff --git a/packages/core/src/test-utils/query-client.ts b/packages/core/src/test-utils/query-client.ts deleted file mode 100644 index 3f87f0643..000000000 --- a/packages/core/src/test-utils/query-client.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { PostgreSql } from '@withtyped/postgres'; -import type { Transaction } from '@withtyped/server'; -import { QueryClient } from '@withtyped/server'; - -// Consider move to withtyped if everything goes well -export class MockQueryClient extends QueryClient { - async transaction(): Promise> { - throw new Error('Method not implemented.'); - } - - async connect() { - console.debug('MockQueryClient connect'); - } - - async end() { - console.debug('MockQueryClient end'); - } - - async query() { - console.debug('MockQueryClient query'); - - return { rows: [], rowCount: 0 }; - } -} diff --git a/packages/core/src/test-utils/tenant.ts b/packages/core/src/test-utils/tenant.ts index cd9dd1e17..af5881fb5 100644 --- a/packages/core/src/test-utils/tenant.ts +++ b/packages/core/src/test-utils/tenant.ts @@ -1,6 +1,5 @@ import { createMockPool, createMockQueryResult } from 'slonik'; -import { createModelRouters } from '#src/model-routers/index.js'; import Libraries from '#src/tenants/Libraries.js'; import Queries from '#src/tenants/Queries.js'; import type TenantContext from '#src/tenants/TenantContext.js'; @@ -8,7 +7,6 @@ import type TenantContext from '#src/tenants/TenantContext.js'; import { mockEnvSet } from './env-set.js'; import type { GrantMock } from './oidc-provider.js'; import { createMockProvider } from './oidc-provider.js'; -import { MockQueryClient } from './query-client.js'; export class MockQueries extends Queries { constructor(queriesOverride?: Partial2) { @@ -49,7 +47,6 @@ export class MockTenant implements TenantContext { public envSet = mockEnvSet; public queries: Queries; public libraries: Libraries; - public modelRouters = createModelRouters(new MockQueryClient()); constructor( public provider = createMockProvider(), @@ -57,7 +54,7 @@ export class MockTenant implements TenantContext { librariesOverride?: Partial2 ) { this.queries = new MockQueries(queriesOverride); - this.libraries = new Libraries(this.queries, this.modelRouters); + this.libraries = new Libraries(this.queries); this.setPartial('libraries', librariesOverride); } diff --git a/packages/core/src/utils/zod.ts b/packages/core/src/utils/zod.ts index 679a25795..82aae8c9a 100644 --- a/packages/core/src/utils/zod.ts +++ b/packages/core/src/utils/zod.ts @@ -5,6 +5,7 @@ import { conditional } from '@silverhand/essentials'; import type { OpenAPIV3 } from 'openapi-types'; import type { ZodStringDef } from 'zod'; import { + ZodRecord, ZodArray, ZodBoolean, ZodEffects, @@ -209,6 +210,13 @@ export const zodTypeToSwagger = ( }; } + if (config instanceof ZodRecord) { + return { + type: 'object', + additionalProperties: zodTypeToSwagger(config.valueSchema), + }; + } + if (config instanceof ZodArray) { return { type: 'array', diff --git a/packages/integration-tests/src/tests/api/hooks.test.ts b/packages/integration-tests/src/tests/api/hooks.test.ts index fb6ec2019..b8d951495 100644 --- a/packages/integration-tests/src/tests/api/hooks.test.ts +++ b/packages/integration-tests/src/tests/api/hooks.test.ts @@ -1,8 +1,5 @@ -import type { LogKey } from '@logto/schemas'; -import { SignInIdentifier, LogResult, InteractionEvent } from '@logto/schemas'; -import type { Hooks } from '@logto/schemas/models'; -import { HookEvent } from '@logto/schemas/models'; -import type { InferModelType } from '@withtyped/server'; +import type { Hook, LogKey } from '@logto/schemas'; +import { HookEvent, SignInIdentifier, LogResult, InteractionEvent } from '@logto/schemas'; import { authedAdminApi, deleteUser, getLogs, putInteraction } from '#src/api/index.js'; import { initClient, processSession } from '#src/helpers/client.js'; @@ -11,8 +8,6 @@ import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience. import { generateNewUser, generateNewUserProfile } from '#src/helpers/user.js'; import { waitFor } from '#src/utils.js'; -type Hook = InferModelType; - const createPayload = (event: HookEvent, url = 'not_work_url'): Partial => ({ event, config: { diff --git a/packages/schemas/src/foundations/jsonb-types.ts b/packages/schemas/src/foundations/jsonb-types.ts index 37b23f3ff..06e9eb579 100644 --- a/packages/schemas/src/foundations/jsonb-types.ts +++ b/packages/schemas/src/foundations/jsonb-types.ts @@ -9,17 +9,13 @@ export { type Storage, } from '@logto/connector-kit'; -/** - * Commonly Used - */ +/* === Commonly Used === */ export const arbitraryObjectGuard = z.record(z.unknown()); export type ArbitraryObject = z.infer; -/** - * OIDC Model Instances - */ +/* === OIDC Model Instances === */ export const oidcModelInstancePayloadGuard = z .object({ @@ -79,9 +75,7 @@ export const customClientMetadataGuard = z.object({ export type CustomClientMetadata = z.infer; -/** - * Users - */ +/* === Users === */ export const roleNamesGuard = z.string().array(); const identityGuard = z.object({ @@ -93,9 +87,7 @@ export const identitiesGuard = z.record(identityGuard); export type Identity = z.infer; export type Identities = z.infer; -/** - * SignIn Experiences - */ +/* === SignIn Experiences === */ export const colorGuard = z.object({ primaryColor: z.string().regex(hexColorRegEx), @@ -151,9 +143,7 @@ export const connectorTargetsGuard = z.string().array(); export type ConnectorTargets = z.infer; -/** - * Settings - */ +/* === Logto Configs === */ export enum AppearanceMode { SyncWithSystem = 'system', @@ -161,9 +151,7 @@ export enum AppearanceMode { DarkMode = 'dark', } -/** - * Phrases - */ +/* === Phrases === */ export type Translation = { [key: string]: string | Translation; @@ -173,9 +161,7 @@ export const translationGuard: z.ZodType = z.lazy(() => z.record(z.string().or(translationGuard)) ); -/** - * Logs - */ +/* === Logs === */ export enum LogResult { Success = 'Success', @@ -202,3 +188,34 @@ export const logContextPayloadGuard = z * Here we use `string` to make it compatible with the Zod guard. **/ export type LogContextPayload = z.infer; + +/* === Hooks === */ + +export enum HookEvent { + PostRegister = 'PostRegister', + PostSignIn = 'PostSignIn', + PostResetPassword = 'PostResetPassword', +} + +export const hookEventGuard: z.ZodType = z.nativeEnum(HookEvent); + +export type HookConfig = { + /** We don't need `type` since v1 only has web hook */ + // type: 'web'; + /** Method fixed to `POST` */ + url: string; + /** Additional headers that attach to the request */ + headers?: Record; + /** + * Retry times when hook response status >= 500. + * + * Must be less than or equal to `3`. Use `0` to disable retry. + **/ + retries: number; +}; + +export const hookConfigGuard: z.ZodType = z.object({ + url: z.string(), + headers: z.record(z.string()).optional(), + retries: z.number().gte(0).lte(3), +}); diff --git a/packages/schemas/src/models/hooks.ts b/packages/schemas/src/models/hooks.ts deleted file mode 100644 index 9beff43a0..000000000 --- a/packages/schemas/src/models/hooks.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { generateStandardId } from '@logto/core-kit'; -import { createModel } from '@withtyped/server'; -import { z } from 'zod'; - -import type { Application, User } from '../db-entries/index.js'; -import type { userInfoSelectFields } from '../types/index.js'; - -export enum HookEvent { - PostRegister = 'PostRegister', - PostSignIn = 'PostSignIn', - PostResetPassword = 'PostResetPassword', -} - -export type HookEventPayload = { - hookId: string; - event: HookEvent; - createdAt: string; - sessionId?: string; - userAgent?: string; - userId?: string; - user?: Pick; - application?: Pick; -} & Record; - -export type HookConfig = { - /** We don't need `type` since v1 only has web hook */ - // type: 'web'; - /** Method fixed to `POST` */ - url: string; - /** Additional headers that attach to the request */ - headers?: Record; - /** - * Retry times when hook response status >= 500. - * - * Must be less than or equal to `3`. Use `0` to disable retry. - **/ - retries: number; -}; - -export const hookConfigGuard: z.ZodType = z.object({ - url: z.string(), - headers: z.record(z.string()).optional(), - retries: z.number().gte(0).lte(3), -}); - -export const Hooks = createModel(/* sql */ ` - create table hooks ( - tenant_id varchar(21) not null - references tenants (id) on update cascade on delete cascade, - id varchar(21) not null, - event varchar(128) not null, - config jsonb /* @use HookConfig */ not null, - created_at timestamptz not null default(now()), - primary key (id) - ); - - create index hooks__id on hooks (tenant_id, id); - - create index hooks__event on hooks (tenant_id, event); -`) - .extend('id', { default: () => generateStandardId(), readonly: true }) - .extend('event', z.nativeEnum(HookEvent)) // Tried to use `.refine()` to show the correct error path, but not working. - .extend('config', hookConfigGuard) - .exclude('tenantId'); diff --git a/packages/schemas/src/models/index.ts b/packages/schemas/src/models/index.ts index 1d3899738..e0eae7dc7 100644 --- a/packages/schemas/src/models/index.ts +++ b/packages/schemas/src/models/index.ts @@ -1,2 +1 @@ -export * from './hooks.js'; export * from './tenants.js'; diff --git a/packages/schemas/src/types/hook.ts b/packages/schemas/src/types/hook.ts new file mode 100644 index 000000000..652c6df78 --- /dev/null +++ b/packages/schemas/src/types/hook.ts @@ -0,0 +1,14 @@ +import type { Application, User } from '../db-entries/index.js'; +import type { HookEvent } from '../foundations/index.js'; +import type { userInfoSelectFields } from './user.js'; + +export type HookEventPayload = { + hookId: string; + event: HookEvent; + createdAt: string; + sessionId?: string; + userAgent?: string; + userId?: string; + user?: Pick; + application?: Pick; +} & Record; diff --git a/packages/schemas/src/types/index.ts b/packages/schemas/src/types/index.ts index 195456ac0..f244182d2 100644 --- a/packages/schemas/src/types/index.ts +++ b/packages/schemas/src/types/index.ts @@ -13,3 +13,4 @@ export * from './application.js'; export * from './system.js'; export * from './tenant.js'; export * from './user-assets.js'; +export * from './hook.js'; diff --git a/packages/schemas/src/types/log/hook.ts b/packages/schemas/src/types/log/hook.ts index d97f02c29..92d328995 100644 --- a/packages/schemas/src/types/log/hook.ts +++ b/packages/schemas/src/types/log/hook.ts @@ -1,4 +1,4 @@ -import type { HookEvent } from '../../models/hooks.js'; +import type { HookEvent } from '../../foundations/index.js'; /** The type of a hook event. */ export enum Type { diff --git a/packages/schemas/tables/hooks.sql b/packages/schemas/tables/hooks.sql new file mode 100644 index 000000000..70122951b --- /dev/null +++ b/packages/schemas/tables/hooks.sql @@ -0,0 +1,13 @@ +create table hooks ( + tenant_id varchar(21) not null + references tenants (id) on update cascade on delete cascade, + id varchar(21) not null, + event varchar(128) /* @use HookEvent */ not null, + config jsonb /* @use HookConfig */ not null, + created_at timestamptz not null default(now()), + primary key (id) + ); + + create index hooks__id on hooks (tenant_id, id); + + create index hooks__event on hooks (tenant_id, event); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5f691b33d..b3857d4c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -361,8 +361,6 @@ importers: '@types/semver': ^7.3.12 '@types/sinon': ^10.0.13 '@types/supertest': ^2.0.11 - '@withtyped/postgres': ^0.8.1 - '@withtyped/server': ^0.8.1 aws-sdk: ^2.1329.0 chalk: ^5.0.0 clean-deep: ^3.4.0 @@ -425,8 +423,6 @@ importers: '@logto/schemas': link:../schemas '@logto/shared': link:../shared '@silverhand/essentials': 2.4.0 - '@withtyped/postgres': 0.8.1_@withtyped+server@0.8.1 - '@withtyped/server': 0.8.1 aws-sdk: 2.1329.0 chalk: 5.1.2 clean-deep: 3.4.0