From 264a5455a3c13a33413a2a235712104384331bf5 Mon Sep 17 00:00:00 2001 From: Travis Fischer Date: Tue, 20 May 2025 16:10:48 +0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=95=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api-v1/deployments/create-deployment.ts | 62 ++++++++--------- .../src/api-v1/deployments/get-deployment.ts | 5 +- .../api-v1/deployments/publish-deployment.ts | 4 +- .../api-v1/deployments/update-deployment.ts | 4 +- .../api/src/api-v1/projects/create-project.ts | 6 +- .../api/src/lib/auth/create-provider-token.ts | 4 +- apps/api/src/lib/consumers/upsert-consumer.ts | 66 ++++++++++++------- .../lib/deployments/get-deployment-by-id.ts | 27 ++++++++ .../normalize-deployment-version.ts | 8 +-- .../src/lib/deployments/publish-deployment.ts | 2 +- ...ts => try-get-deployment-by-identifier.ts} | 45 ++++++++----- .../validate-deployment-origin-adapter.ts | 10 +-- apps/api/src/lib/middleware/team.ts | 4 ++ packages/db/src/schema/common.ts | 10 ++- packages/db/src/schema/consumer.ts | 31 ++------- packages/db/src/schema/deployment.ts | 27 ++++---- packages/db/src/schema/log-entry.ts | 6 +- packages/db/src/schema/project.ts | 24 +++---- packages/db/src/schemas.ts | 18 +++-- .../src/parse-faas-identifier.test.ts | 8 ++- .../validators/src/parse-faas-uri.test.ts | 2 +- packages/validators/src/parse-faas-uri.ts | 10 +-- packages/validators/src/types.ts | 6 +- packages/validators/src/validators.test.ts | 66 +++++++++++-------- packages/validators/src/validators.ts | 4 +- 25 files changed, 258 insertions(+), 201 deletions(-) create mode 100644 apps/api/src/lib/deployments/get-deployment-by-id.ts rename apps/api/src/lib/deployments/{try-get-deployment.ts => try-get-deployment-by-identifier.ts} (63%) diff --git a/apps/api/src/api-v1/deployments/create-deployment.ts b/apps/api/src/api-v1/deployments/create-deployment.ts index 7a9150c8..f8f1c2e2 100644 --- a/apps/api/src/api-v1/deployments/create-deployment.ts +++ b/apps/api/src/api-v1/deployments/create-deployment.ts @@ -75,11 +75,11 @@ export function registerV1DeploymentsCreateDeployment( // TODO: investigate better short hash generation const hash = sha256().slice(0, 8) - const deploymentId = `${project.id}@${hash}` + const deploymentIdentifier = `${project.identifier}@${hash}` assert( - validators.deploymentId(deploymentId), + validators.deploymentIdentifier(deploymentIdentifier), 400, - `Invalid deployment id "${deploymentId}"` + `Invalid deployment identifier "${deploymentIdentifier}"` ) let { version } = body @@ -87,13 +87,13 @@ export function registerV1DeploymentsCreateDeployment( assert( version, 400, - `Deployment "version" field is required to publish deployment "${deploymentId}"` + `Deployment "version" field is required to publish deployment "${deploymentIdentifier}"` ) } if (version) { version = normalizeDeploymentVersion({ - deploymentId, + deploymentIdentifier, project, version }) @@ -102,36 +102,36 @@ export function registerV1DeploymentsCreateDeployment( // Validate OpenAPI originUrl and originAdapter await validateDeploymentOriginAdapter({ ...pick(body, 'originUrl', 'originAdapter'), - deploymentId, + deploymentIdentifier, logger }) - let [[deployment]] = await db.transaction(async (tx) => { - return Promise.all([ - // Create the deployment - tx - .insert(schema.deployments) - .values({ - ...body, - id: deploymentId, - hash, - userId: user.id, - teamId: teamMember?.teamId, - projectId, - version - }) - .returning(), + // Create the deployment + let [deployment] = await db + .insert(schema.deployments) + .values({ + ...body, + identifier: deploymentIdentifier, + hash, + userId: user.id, + teamId: teamMember?.teamId, + projectId, + version + }) + .returning() + assert( + deployment, + 500, + `Failed to create deployment "${deploymentIdentifier}"` + ) - // Update the project - tx - .update(schema.projects) - .set({ - lastDeploymentId: deploymentId - }) - .where(eq(schema.projects.id, projectId)) - ]) - }) - assert(deployment, 500, `Failed to create deployment "${deploymentId}"`) + // Update the project + await db + .update(schema.projects) + .set({ + lastDeploymentId: deployment.id + }) + .where(eq(schema.projects.id, projectId)) if (publish) { deployment = await publishDeployment(c, { diff --git a/apps/api/src/api-v1/deployments/get-deployment.ts b/apps/api/src/api-v1/deployments/get-deployment.ts index ea84adce..d758613f 100644 --- a/apps/api/src/api-v1/deployments/get-deployment.ts +++ b/apps/api/src/api-v1/deployments/get-deployment.ts @@ -4,7 +4,7 @@ import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedEnv } from '@/lib/types' import { schema } from '@/db' import { acl } from '@/lib/acl' -import { tryGetDeployment } from '@/lib/deployments/try-get-deployment' +import { getDeploymentById } from '@/lib/deployments/get-deployment-by-id' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, @@ -45,7 +45,8 @@ export function registerV1DeploymentsGetDeployment( const { deploymentId } = c.req.valid('param') const { populate = [] } = c.req.valid('query') - const deployment = await tryGetDeployment(c, deploymentId, { + const deployment = await getDeploymentById({ + deploymentId, with: { ...Object.fromEntries(populate.map((field) => [field, true])) } diff --git a/apps/api/src/api-v1/deployments/publish-deployment.ts b/apps/api/src/api-v1/deployments/publish-deployment.ts index f288fe90..42217ad3 100644 --- a/apps/api/src/api-v1/deployments/publish-deployment.ts +++ b/apps/api/src/api-v1/deployments/publish-deployment.ts @@ -4,8 +4,8 @@ import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedEnv } from '@/lib/types' import { schema } from '@/db' import { acl } from '@/lib/acl' +import { getDeploymentById } from '@/lib/deployments/get-deployment-by-id' import { publishDeployment } from '@/lib/deployments/publish-deployment' -import { tryGetDeployment } from '@/lib/deployments/try-get-deployment' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, @@ -54,7 +54,7 @@ export function registerV1DeploymentsPublishDeployment( const { version } = c.req.valid('json') // First ensure the deployment exists and the user has access to it - const deployment = await tryGetDeployment(c, deploymentId) + const deployment = await getDeploymentById({ deploymentId }) assert(deployment, 404, `Deployment not found "${deploymentId}"`) await acl(c, deployment, { label: 'Deployment' }) diff --git a/apps/api/src/api-v1/deployments/update-deployment.ts b/apps/api/src/api-v1/deployments/update-deployment.ts index 0233133f..76897cd1 100644 --- a/apps/api/src/api-v1/deployments/update-deployment.ts +++ b/apps/api/src/api-v1/deployments/update-deployment.ts @@ -4,7 +4,7 @@ import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedEnv } from '@/lib/types' import { db, eq, schema } from '@/db' import { acl } from '@/lib/acl' -import { tryGetDeployment } from '@/lib/deployments/try-get-deployment' +import { getDeploymentById } from '@/lib/deployments/get-deployment-by-id' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, @@ -53,7 +53,7 @@ export function registerV1DeploymentsUpdateDeployment( const body = c.req.valid('json') // First ensure the deployment exists and the user has access to it - let deployment = await tryGetDeployment(c, deploymentId) + let deployment = await getDeploymentById({ deploymentId }) assert(deployment, 404, `Deployment not found "${deploymentId}"`) await acl(c, deployment, { label: 'Deployment' }) diff --git a/apps/api/src/api-v1/projects/create-project.ts b/apps/api/src/api-v1/projects/create-project.ts index baed5ea2..902e9635 100644 --- a/apps/api/src/api-v1/projects/create-project.ts +++ b/apps/api/src/api-v1/projects/create-project.ts @@ -53,17 +53,17 @@ export function registerV1ProjectsCreateProject( const teamMember = c.get('teamMember') const namespace = teamMember ? teamMember.teamSlug : user.username - const id = `${namespace}/${body.name}` + const identifier = `${namespace}/${body.name}` const [project] = await db .insert(schema.projects) .values({ ...body, - id, + identifier, teamId: teamMember?.teamId, userId: user.id, _secret: sha256(), - _providerToken: createProviderToken({ id }) + _providerToken: createProviderToken({ identifier }) }) .returning() assert(project, 500, `Failed to create project "${body.name}"`) diff --git a/apps/api/src/lib/auth/create-provider-token.ts b/apps/api/src/lib/auth/create-provider-token.ts index 10c667dc..170799f4 100644 --- a/apps/api/src/lib/auth/create-provider-token.ts +++ b/apps/api/src/lib/auth/create-provider-token.ts @@ -2,8 +2,8 @@ import jwt from 'jsonwebtoken' import { env } from '@/lib/env' -export function createProviderToken(project: { id: string }) { +export function createProviderToken(project: { identifier: string }) { // TODO: Possibly in the future store stripe account ID as well and require // provider tokens to refresh after account changes? - return jwt.sign({ projectId: project.id }, env.JWT_SECRET) + return jwt.sign({ projectIdentifier: project.identifier }, env.JWT_SECRET) } diff --git a/apps/api/src/lib/consumers/upsert-consumer.ts b/apps/api/src/lib/consumers/upsert-consumer.ts index cf2787d9..abecc787 100644 --- a/apps/api/src/lib/consumers/upsert-consumer.ts +++ b/apps/api/src/lib/consumers/upsert-consumer.ts @@ -1,8 +1,7 @@ import { assert } from '@agentic/platform-core' -import { parseFaasIdentifier } from '@agentic/platform-validators' import type { AuthenticatedContext } from '@/lib/types' -import { and, db, eq, schema } from '@/db' +import { and, db, eq, type RawDeployment, type RawProject, schema } from '@/db' import { acl } from '@/lib/acl' import { upsertStripeConnectCustomer } from '@/lib/billing/upsert-stripe-connect-customer' import { upsertStripeCustomer } from '@/lib/billing/upsert-stripe-customer' @@ -25,12 +24,42 @@ export async function upsertConsumer( assert(consumerId || deploymentId, 400, 'Missing required "deploymentId"') const logger = c.get('logger') const userId = c.get('userId') + let deployment: RawDeployment | undefined + let project: RawProject | undefined let projectId: string | undefined + async function initDeploymentAndProject() { + assert(deploymentId, 400, 'Missing required "deploymentId"') + if (deployment && project) { + // Already initialized + return + } + + deployment = await db.query.deployments.findFirst({ + where: eq(schema.deployments.id, deploymentId), + with: { + project: true + } + }) + assert(deployment, 404, `Deployment not found "${deploymentId}"`) + assert( + !deployment.deletedAt, + 410, + `Deployment has been deleted by its owner "${deployment.id}"` + ) + await acl(c, deployment, { label: 'Deployment' }) + + project = deployment.project! + assert( + project, + 404, + `Project not found "${projectId}" for deployment "${deploymentId}"` + ) + await acl(c, project, { label: 'Project' }) + } + if (deploymentId) { - const parsedIds = parseFaasIdentifier(deploymentId) - assert(parsedIds, 400, 'Invalid "deploymentId"') - projectId = parsedIds.projectId + await initDeploymentAndProject() } if (!consumerId) { @@ -83,28 +112,15 @@ export async function upsertConsumer( existingConsumer.plan !== plan || existingConsumer.deploymentId !== deploymentId, 409, - `User "${user.email}" already has an active subscription to plan "${plan}" for project "${projectId}"` + + plan + ? `User "${user.email}" already has an active subscription to plan "${plan}" for project "${projectId}"` + : `User "${user.email}" already has cancelled their subscription for project "${projectId}"` ) - const deployment = await db.query.deployments.findFirst({ - where: eq(schema.deployments.id, deploymentId), - with: { - project: true - } - }) - assert(deployment, 404, `Deployment not found "${deploymentId}"`) - - const { project } = deployment - assert( - project, - 404, - `Project not found "${projectId}" for deployment "${deploymentId}"` - ) - assert( - !deployment.deletedAt, - 410, - `Deployment has been deleted by its owner "${deployment.id}"` - ) + await initDeploymentAndProject() + assert(deployment, 500, `Error getting deployment "${deploymentId}"`) + assert(project, 500, `Error getting project "${projectId}"`) if (plan) { const pricingPlan = deployment.pricingPlans.find((p) => p.slug === plan) diff --git a/apps/api/src/lib/deployments/get-deployment-by-id.ts b/apps/api/src/lib/deployments/get-deployment-by-id.ts new file mode 100644 index 00000000..558a2691 --- /dev/null +++ b/apps/api/src/lib/deployments/get-deployment-by-id.ts @@ -0,0 +1,27 @@ +import { db, eq, type RawDeployment, schema } from '@/db' + +/** + * Finds the Deployment with the given id. + * + * Does not take care of ACLs. + * + * Returns `undefined` if not found. + */ +export async function getDeploymentById({ + deploymentId, + ...dbQueryOpts +}: { + deploymentId: string + with?: { + user?: true + team?: true + project?: true + } +}): Promise { + const deployment = await db.query.deployments.findFirst({ + ...dbQueryOpts, + where: eq(schema.deployments.id, deploymentId) + }) + + return deployment +} diff --git a/apps/api/src/lib/deployments/normalize-deployment-version.ts b/apps/api/src/lib/deployments/normalize-deployment-version.ts index 96a9597d..b829522e 100644 --- a/apps/api/src/lib/deployments/normalize-deployment-version.ts +++ b/apps/api/src/lib/deployments/normalize-deployment-version.ts @@ -4,11 +4,11 @@ import semver from 'semver' import type { RawProject } from '@/db' export function normalizeDeploymentVersion({ - deploymentId, + deploymentIdentifier, version: rawVersion, project }: { - deploymentId: string + deploymentIdentifier: string version: string project: RawProject }): string | undefined { @@ -18,14 +18,14 @@ export function normalizeDeploymentVersion({ assert( semver.valid(version), 400, - `Invalid semver version "${version}" for deployment "${deploymentId}"` + `Invalid semver version "${version}" for deployment "${deploymentIdentifier}"` ) const lastPublishedVersion = project.lastPublishedDeployment?.version assert( !lastPublishedVersion || semver.gt(version, lastPublishedVersion), 400, - `Semver version "${version}" must be greater than the current published version "${lastPublishedVersion}" for deployment "${deploymentId}"` + `Semver version "${version}" must be greater than the current published version "${lastPublishedVersion}" for deployment "${deploymentIdentifier}"` ) return version diff --git a/apps/api/src/lib/deployments/publish-deployment.ts b/apps/api/src/lib/deployments/publish-deployment.ts index 4e3b3d16..f4527ba9 100644 --- a/apps/api/src/lib/deployments/publish-deployment.ts +++ b/apps/api/src/lib/deployments/publish-deployment.ts @@ -26,7 +26,7 @@ export async function publishDeployment( await acl(ctx, project, { label: 'Project' }) const version = normalizeDeploymentVersion({ - deploymentId: deployment.id, + deploymentIdentifier: deployment.identifier, project, version: rawVersion }) diff --git a/apps/api/src/lib/deployments/try-get-deployment.ts b/apps/api/src/lib/deployments/try-get-deployment-by-identifier.ts similarity index 63% rename from apps/api/src/lib/deployments/try-get-deployment.ts rename to apps/api/src/lib/deployments/try-get-deployment-by-identifier.ts index 111b2c42..f9c39519 100644 --- a/apps/api/src/lib/deployments/try-get-deployment.ts +++ b/apps/api/src/lib/deployments/try-get-deployment-by-identifier.ts @@ -6,46 +6,57 @@ import { db, eq, type RawDeployment, schema } from '@/db' import { ensureAuthUser } from '@/lib/ensure-auth-user' /** - * Attempts to find the Deployment matching the given identifier. + * Attempts to find the Deployment matching the given deployment identifier. + * + * Throws a HTTP 404 error if not found. + * + * Does not take care of ACLs. */ -export async function tryGetDeployment( +export async function tryGetDeploymentByIdentifier( ctx: AuthenticatedContext, - identifier: string, - dbQueryOpts: { + { + deploymentIdentifier, + ...dbQueryOpts + }: { + deploymentIdentifier: string with?: { user?: true team?: true project?: true } - } = {} -): Promise { + } +): Promise { const user = await ensureAuthUser(ctx) const teamMember = ctx.get('teamMember') const namespace = teamMember ? teamMember.teamSlug : user.username - const parsedFaas = parseFaasIdentifier(identifier, { + const parsedFaas = parseFaasIdentifier(deploymentIdentifier, { namespace }) - assert(parsedFaas, 400, `Invalid deployment identifier "${identifier}"`) + assert( + parsedFaas, + 400, + `Invalid deployment identifier "${deploymentIdentifier}"` + ) - const { projectId, deploymentHash, version } = parsedFaas + const { projectIdentifier, deploymentHash, version } = parsedFaas if (deploymentHash) { - const deploymentId = `${projectId}@${deploymentHash}` + const deploymentIdentifier = `${projectIdentifier}@${deploymentHash}` const deployment = await db.query.deployments.findFirst({ ...dbQueryOpts, - where: eq(schema.deployments.id, deploymentId) + where: eq(schema.deployments.identifier, deploymentIdentifier) }) - assert(deployment, 404, `Deployment not found "${deploymentId}"`) + assert(deployment, 404, `Deployment not found "${deploymentIdentifier}"`) return deployment } else if (version === 'latest') { const project = await db.query.projects.findFirst({ ...dbQueryOpts, - where: eq(schema.projects.id, projectId) + where: eq(schema.projects.identifier, projectIdentifier) }) - assert(project, 404, `Project not found "${projectId}"`) + assert(project, 404, `Project not found "${projectIdentifier}"`) assert( project.lastPublishedDeploymentId, 404, @@ -66,9 +77,9 @@ export async function tryGetDeployment( } else if (version === 'dev') { const project = await db.query.projects.findFirst({ ...dbQueryOpts, - where: eq(schema.projects.id, projectId) + where: eq(schema.projects.id, projectIdentifier) }) - assert(project, 404, `Project not found "${projectId}"`) + assert(project, 404, `Project not found "${projectIdentifier}"`) assert( project.lastDeploymentId, 404, @@ -88,5 +99,5 @@ export async function tryGetDeployment( return deployment } - assert(false, 400, `Invalid Deployment identifier "${identifier}"`) + assert(false, 400, `Invalid Deployment identifier "${deploymentIdentifier}"`) } diff --git a/apps/api/src/lib/deployments/validate-deployment-origin-adapter.ts b/apps/api/src/lib/deployments/validate-deployment-origin-adapter.ts index d94c6160..f5b1086a 100644 --- a/apps/api/src/lib/deployments/validate-deployment-origin-adapter.ts +++ b/apps/api/src/lib/deployments/validate-deployment-origin-adapter.ts @@ -10,12 +10,12 @@ import { validateOpenAPISpec } from '@/lib/validate-openapi-spec' * NOTE: This method may mutate `originAdapter.spec`. */ export async function validateDeploymentOriginAdapter({ - deploymentId, + deploymentIdentifier, originUrl, originAdapter, logger }: { - deploymentId: string + deploymentIdentifier: string originUrl: string originAdapter: DeploymentOriginAdapter logger: Logger @@ -23,14 +23,14 @@ export async function validateDeploymentOriginAdapter({ assert( originUrl, 400, - `Origin URL is required for deployment "${deploymentId}"` + `Origin URL is required for deployment "${deploymentIdentifier}"` ) if (originAdapter.type === 'openapi') { assert( originAdapter.spec, 400, - `OpenAPI spec is required for deployment "${deploymentId}" with origin adapter type set to "openapi"` + `OpenAPI spec is required for deployment "${deploymentIdentifier}" with origin adapter type set to "openapi"` ) // Validate and normalize the OpenAPI spec @@ -54,7 +54,7 @@ export async function validateDeploymentOriginAdapter({ assert( originAdapter.type === 'raw', 400, - `Invalid origin adapter type "${originAdapter.type}" for deployment "${deploymentId}"` + `Invalid origin adapter type "${originAdapter.type}" for deployment "${deploymentIdentifier}"` ) } } diff --git a/apps/api/src/lib/middleware/team.ts b/apps/api/src/lib/middleware/team.ts index 9fde9592..01a04d95 100644 --- a/apps/api/src/lib/middleware/team.ts +++ b/apps/api/src/lib/middleware/team.ts @@ -5,6 +5,10 @@ import type { AuthenticatedEnv } from '@/lib/types' import { and, db, eq, schema } from '@/db' import { aclTeamMember } from '@/lib/acl-team-member' +// TODO: Instead of accepting `teamId` query param, change the authenticate +// middleware to accept a different JWT payload and then use that to +// determine the intended user and/or team. + export const team = createMiddleware( async function teamMiddleware(ctx, next) { const teamId = ctx.req.query('teamId') diff --git a/packages/db/src/schema/common.ts b/packages/db/src/schema/common.ts index 59f299de..fcb10a86 100644 --- a/packages/db/src/schema/common.ts +++ b/packages/db/src/schema/common.ts @@ -33,7 +33,10 @@ export function stripeId>( /** * `namespace/projectName` */ -export function projectId>( +export function projectIdentifier< + U extends string, + T extends Readonly<[U, ...U[]]> +>( config?: PgVarcharConfig, never> ): PgVarcharBuilderInitial<'', Writable, 130> { return varchar({ length: 130, ...config }) @@ -42,7 +45,10 @@ export function projectId>( /** * `namespace/projectName@hash` */ -export function deploymentId>( +export function deploymentIdentifier< + U extends string, + T extends Readonly<[U, ...U[]]> +>( config?: PgVarcharConfig, never> ): PgVarcharBuilderInitial<'', Writable, 160> { return varchar({ length: 160, ...config }) diff --git a/packages/db/src/schema/consumer.ts b/packages/db/src/schema/consumer.ts index efb9855a..9289fc4e 100644 --- a/packages/db/src/schema/consumer.ts +++ b/packages/db/src/schema/consumer.ts @@ -1,4 +1,3 @@ -import { validators } from '@agentic/platform-validators' import { relations } from '@fisch0920/drizzle-orm' import { boolean, @@ -14,9 +13,7 @@ import { createSelectSchema, createUpdateSchema, cuid, - deploymentId, id, - projectId, stripeId, timestamps } from './common' @@ -70,7 +67,7 @@ export const consumers = pgTable( .references(() => users.id), // The project this user is subscribed to - projectId: projectId() + projectId: cuid() .notNull() .references(() => projects.id, { onDelete: 'cascade' @@ -78,7 +75,7 @@ export const consumers = pgTable( // The specific deployment this user is subscribed to, since pricing can // change across deployment versions) - deploymentId: deploymentId() + deploymentId: cuid() .notNull() .references(() => deployments.id, { onDelete: 'cascade' @@ -134,17 +131,7 @@ export const consumersRelations = relations(consumers, ({ one }) => ({ })) export const consumerSelectSchema = createSelectSchema(consumers, { - _stripeSubscriptionItemIdMap: stripeSubscriptionItemIdMapSchema, - - deploymentId: (schema) => - schema.refine((id) => validators.deploymentId(id), { - message: 'Invalid deployment id' - }), - - projectId: (schema) => - schema.refine((id) => validators.projectId(id), { - message: 'Invalid project id' - }) + _stripeSubscriptionItemIdMap: stripeSubscriptionItemIdMapSchema }) .omit({ _stripeSubscriptionId: true, @@ -171,10 +158,7 @@ export const consumerSelectSchema = createSelectSchema(consumers, { .openapi('Consumer') export const consumerInsertSchema = createInsertSchema(consumers, { - deploymentId: (schema) => - schema.refine((id) => validators.deploymentId(id), { - message: 'Invalid deployment id' - }), + deploymentId: (schema) => schema.cuid2().optional(), plan: z.string().nonempty() }) @@ -186,12 +170,7 @@ export const consumerInsertSchema = createInsertSchema(consumers, { .strict() export const consumerUpdateSchema = createUpdateSchema(consumers, { - deploymentId: (schema) => - schema - .refine((id) => validators.deploymentId(id), { - message: 'Invalid deployment id' - }) - .optional() + deploymentId: (schema) => schema.cuid2().optional() }) .pick({ plan: true, diff --git a/packages/db/src/schema/deployment.ts b/packages/db/src/schema/deployment.ts index e0ee6e2f..b9e90cfe 100644 --- a/packages/db/src/schema/deployment.ts +++ b/packages/db/src/schema/deployment.ts @@ -5,17 +5,19 @@ import { index, jsonb, pgTable, - text + text, + uniqueIndex } from '@fisch0920/drizzle-orm/pg-core' import { z } from '@hono/zod-openapi' +import { deploymentIdentifierSchema, projectIdSchema } from '../schemas' import { createInsertSchema, createSelectSchema, createUpdateSchema, cuid, - deploymentId, - projectId, + deploymentIdentifier, + id, timestamps } from './common' import { projects } from './project' @@ -31,10 +33,10 @@ import { users } from './user' export const deployments = pgTable( 'deployments', { - // namespace/projectName@hash - id: deploymentId().primaryKey(), + id, ...timestamps, + identifier: deploymentIdentifier().unique().notNull(), hash: text().notNull(), version: text(), @@ -48,7 +50,7 @@ export const deployments = pgTable( .notNull() .references(() => users.id), teamId: cuid().references(() => teams.id), - projectId: projectId() + projectId: cuid() .notNull() .references(() => projects.id, { onDelete: 'cascade' @@ -78,6 +80,7 @@ export const deployments = pgTable( // coupons: jsonb().$type().default([]).notNull() }, (table) => [ + uniqueIndex('deployment_identifier_idx').on(table.identifier), index('deployment_userId_idx').on(table.userId), index('deployment_teamId_idx').on(table.teamId), index('deployment_projectId_idx').on(table.projectId), @@ -112,12 +115,7 @@ export const deploymentsRelations = relations(deployments, ({ one }) => ({ // TODO: virtual openapi spec? (hide openapi.servers) export const deploymentSelectSchema = createSelectSchema(deployments, { - // build: z.object({}), - // env: z.object({}), - id: (schema) => - schema.refine((id) => validators.deploymentId(id), { - message: 'Invalid deployment id' - }), + identifier: deploymentIdentifierSchema, hash: (schema) => schema.refine((hash) => validators.deploymentHash(hash), { @@ -148,10 +146,7 @@ export const deploymentSelectSchema = createSelectSchema(deployments, { .openapi('Deployment') export const deploymentInsertSchema = createInsertSchema(deployments, { - projectId: (schema) => - schema.refine((id) => validators.projectId(id), { - message: 'Invalid project id' - }), + projectId: projectIdSchema, iconUrl: (schema) => schema diff --git a/packages/db/src/schema/log-entry.ts b/packages/db/src/schema/log-entry.ts index 4aa724f9..f8751dab 100644 --- a/packages/db/src/schema/log-entry.ts +++ b/packages/db/src/schema/log-entry.ts @@ -5,11 +5,9 @@ import { createInsertSchema, createSelectSchema, cuid, - deploymentId, id, logEntryLevelEnum, logEntryTypeEnum, - projectId, timestamps } from './common' import { consumers } from './consumer' @@ -39,8 +37,8 @@ export const logEntries = pgTable( // relations (optional) userId: cuid(), - projectId: projectId(), - deploymentId: deploymentId(), + projectId: cuid(), + deploymentId: cuid(), consumerId: cuid(), // misc metadata (optional) diff --git a/packages/db/src/schema/project.ts b/packages/db/src/schema/project.ts index 04c2a13c..c8675513 100644 --- a/packages/db/src/schema/project.ts +++ b/packages/db/src/schema/project.ts @@ -6,19 +6,21 @@ import { integer, jsonb, pgTable, - text + text, + uniqueIndex } from '@fisch0920/drizzle-orm/pg-core' import { z } from '@hono/zod-openapi' +import { projectIdentifierSchema } from '../schemas' import { createInsertSchema, createSelectSchema, createUpdateSchema, cuid, - deploymentId, + id, pricingCurrencyEnum, pricingIntervalEnum, - projectId, + projectIdentifier, stripeId, timestamps } from './common' @@ -38,10 +40,10 @@ import { users } from './user' export const projects = pgTable( 'projects', { - // namespace/projectName - id: projectId().primaryKey(), + id, ...timestamps, + identifier: projectIdentifier().unique().notNull(), name: text().notNull(), alias: text(), @@ -51,10 +53,10 @@ export const projects = pgTable( teamId: cuid(), // Most recently published Deployment if one exists - lastPublishedDeploymentId: deploymentId(), + lastPublishedDeploymentId: cuid(), // Most recent Deployment if one exists - lastDeploymentId: deploymentId(), + lastDeploymentId: cuid(), applicationFeePercent: integer().default(20).notNull(), @@ -122,6 +124,7 @@ export const projects = pgTable( _stripeAccountId: stripeId() }, (table) => [ + uniqueIndex('project_identifier_idx').on(table.identifier), index('project_userId_idx').on(table.userId), index('project_teamId_idx').on(table.teamId), index('project_alias_idx').on(table.alias), @@ -159,6 +162,8 @@ export const projectsRelations = relations(projects, ({ one }) => ({ })) export const projectSelectSchema = createSelectSchema(projects, { + identifier: projectIdentifierSchema, + applicationFeePercent: (schema) => schema.nonnegative(), _stripeProductIdMap: stripeProductIdMapSchema, @@ -202,10 +207,7 @@ export const projectSelectSchema = createSelectSchema(projects, { .openapi('Project') export const projectInsertSchema = createInsertSchema(projects, { - id: (schema) => - schema.refine((id) => validators.projectId(id), { - message: 'Invalid project id' - }), + identifier: projectIdentifierSchema, name: (schema) => schema.refine((name) => validators.projectName(name), { diff --git a/packages/db/src/schemas.ts b/packages/db/src/schemas.ts index 151cec49..e3aa03e2 100644 --- a/packages/db/src/schemas.ts +++ b/packages/db/src/schemas.ts @@ -13,19 +13,25 @@ function getCuidSchema(idLabel: string) { export const cuidSchema = getCuidSchema('id') export const userIdSchema = getCuidSchema('user id') +export const teamIdSchema = getCuidSchema('team id') export const consumerIdSchema = getCuidSchema('consumer id') +export const projectIdSchema = getCuidSchema('project id') +export const deploymentIdSchema = getCuidSchema('deployment id') +export const logEntryIdSchema = getCuidSchema('log entry id') -export const projectIdSchema = z +export const projectIdentifierSchema = z .string() - .refine((id) => validators.projectId(id), { - message: 'Invalid project id' + .refine((id) => validators.projectIdentifier(id), { + message: 'Invalid project identifier' }) + .openapi('ProjectIdentifier') -export const deploymentIdSchema = z +export const deploymentIdentifierSchema = z .string() - .refine((id) => validators.deploymentId(id), { - message: 'Invalid deployment id' + .refine((id) => validators.deploymentIdentifier(id), { + message: 'Invalid deployment identifier' }) + .openapi('DeploymentIdentifier') export const usernameSchema = z .string() diff --git a/packages/validators/src/parse-faas-identifier.test.ts b/packages/validators/src/parse-faas-identifier.test.ts index 962c4f15..bb668f4f 100644 --- a/packages/validators/src/parse-faas-identifier.test.ts +++ b/packages/validators/src/parse-faas-identifier.test.ts @@ -6,14 +6,16 @@ import * as validators from './validators' function success(...args: Parameters) { const result = parseFaasIdentifier(...args) expect(result).toBeTruthy() - expect(result!.projectId).toBeTruthy() + expect(result!.projectIdentifier).toBeTruthy() expect(result!.version || result!.deploymentHash).toBeTruthy() - expect(validators.projectId(result!.projectId)).toBe(true) + expect(validators.projectIdentifier(result!.projectIdentifier)).toBe(true) expect(validators.servicePath(result!.servicePath)).toBe(true) if (result!.deploymentHash) { expect(validators.deploymentHash(result!.deploymentHash)).toBe(true) - expect(validators.deploymentId(result!.deploymentId!)).toBe(true) + expect(validators.deploymentIdentifier(result!.deploymentIdentifier!)).toBe( + true + ) } expect(result).toMatchSnapshot() diff --git a/packages/validators/src/parse-faas-uri.test.ts b/packages/validators/src/parse-faas-uri.test.ts index 809c2e6f..16911a8e 100644 --- a/packages/validators/src/parse-faas-uri.test.ts +++ b/packages/validators/src/parse-faas-uri.test.ts @@ -5,7 +5,7 @@ import { parseFaasUri } from './parse-faas-uri' function success(value: string) { const result = parseFaasUri(value) expect(result).toBeTruthy() - expect(result?.projectId).toBeTruthy() + expect(result?.projectIdentifier).toBeTruthy() expect(result?.version || result?.deploymentHash).toBeTruthy() expect(result).toMatchSnapshot() } diff --git a/packages/validators/src/parse-faas-uri.ts b/packages/validators/src/parse-faas-uri.ts index 5eaab458..5171afc7 100644 --- a/packages/validators/src/parse-faas-uri.ts +++ b/packages/validators/src/parse-faas-uri.ts @@ -22,15 +22,15 @@ export function parseFaasUri(uri: string): ParsedFaasIdentifier | undefined { const pdsMatch = uri.match(projectDeploymentServiceRe) if (pdsMatch) { - const projectId = pdsMatch[1]! + const projectIdentifier = pdsMatch[1]! const deploymentHash = pdsMatch[2]! const servicePath = pdsMatch[3] || '/' return { - projectId, + projectIdentifier, deploymentHash, servicePath, - deploymentId: `${projectId}@${deploymentHash}` + deploymentIdentifier: `${projectIdentifier}@${deploymentHash}` } } @@ -38,7 +38,7 @@ export function parseFaasUri(uri: string): ParsedFaasIdentifier | undefined { if (pvsMatch) { return { - projectId: pvsMatch[1]!, + projectIdentifier: pvsMatch[1]!, version: pvsMatch[2]!, servicePath: pvsMatch[3] || '/' } @@ -48,7 +48,7 @@ export function parseFaasUri(uri: string): ParsedFaasIdentifier | undefined { if (psMatch) { return { - projectId: psMatch[1]!, + projectIdentifier: psMatch[1]!, servicePath: psMatch[2] || '/', version: 'latest' } diff --git a/packages/validators/src/types.ts b/packages/validators/src/types.ts index 2b1e7808..7333f7eb 100644 --- a/packages/validators/src/types.ts +++ b/packages/validators/src/types.ts @@ -1,13 +1,13 @@ export type ParsedFaasIdentifier = { - projectId: string + projectIdentifier: string servicePath: string deploymentHash?: string - deploymentId?: string + deploymentIdentifier?: string version?: string } & ( | { deploymentHash: string - deploymentId: string + deploymentIdentifier: string } | { version: string diff --git a/packages/validators/src/validators.test.ts b/packages/validators/src/validators.test.ts index 01a09ab5..36372ba4 100644 --- a/packages/validators/src/validators.test.ts +++ b/packages/validators/src/validators.test.ts @@ -72,40 +72,50 @@ test('deploymentHash failure', () => { expect(validators.deploymentHash('012345678')).toBe(false) }) -test('projectId success', () => { - expect(validators.projectId('username/project-name')).toBe(true) - expect(validators.projectId('a/123')).toBe(true) +test('projectIdentifier success', () => { + expect(validators.projectIdentifier('username/project-name')).toBe(true) + expect(validators.projectIdentifier('a/123')).toBe(true) }) -test('projectId failure', () => { - expect(validators.projectId('aaa//0123')).toBe(false) - expect(validators.projectId('foo@bar')).toBe(false) - expect(validators.projectId('abc/1.23')).toBe(false) - expect(validators.projectId('012345678/123@latest')).toBe(false) - expect(validators.projectId('foo@dev')).toBe(false) - expect(validators.projectId('username/Project-Name')).toBe(false) - expect(validators.projectId('_/___')).toBe(false) +test('projectIdentifier failure', () => { + expect(validators.projectIdentifier('aaa//0123')).toBe(false) + expect(validators.projectIdentifier('foo@bar')).toBe(false) + expect(validators.projectIdentifier('abc/1.23')).toBe(false) + expect(validators.projectIdentifier('012345678/123@latest')).toBe(false) + expect(validators.projectIdentifier('foo@dev')).toBe(false) + expect(validators.projectIdentifier('username/Project-Name')).toBe(false) + expect(validators.projectIdentifier('_/___')).toBe(false) }) -test('deploymentId success', () => { - expect(validators.deploymentId('username/project-name@01234567')).toBe(true) - expect(validators.deploymentId('a/123@01234567')).toBe(true) +test('deploymentIdentifier success', () => { + expect( + validators.deploymentIdentifier('username/project-name@01234567') + ).toBe(true) + expect(validators.deploymentIdentifier('a/123@01234567')).toBe(true) }) -test('deploymentId failure', () => { - expect(validators.deploymentId('username/project-name@012345678')).toBe(false) - expect(validators.deploymentId('username/project-name@latest')).toBe(false) - expect(validators.deploymentId('username/project-name@dev')).toBe(false) - expect(validators.deploymentId('username/Project-Name@01234567')).toBe(false) - expect(validators.deploymentId('a/123@0123A567')).toBe(false) - expect(validators.deploymentId('_/___@012.4567')).toBe(false) - expect(validators.deploymentId('_/___@01234567')).toBe(false) - expect(validators.deploymentId('aaa//0123@01234567')).toBe(false) - expect(validators.deploymentId('foo@bar@01234567')).toBe(false) - expect(validators.deploymentId('abc/1.23@01234567')).toBe(false) - expect(validators.deploymentId('012345678/123@latest')).toBe(false) - expect(validators.deploymentId('012345678/123@dev')).toBe(false) - expect(validators.deploymentId('012345678/123@1.0.1')).toBe(false) +test('deploymentIdentifier failure', () => { + expect( + validators.deploymentIdentifier('username/project-name@012345678') + ).toBe(false) + expect(validators.deploymentIdentifier('username/project-name@latest')).toBe( + false + ) + expect(validators.deploymentIdentifier('username/project-name@dev')).toBe( + false + ) + expect( + validators.deploymentIdentifier('username/Project-Name@01234567') + ).toBe(false) + expect(validators.deploymentIdentifier('a/123@0123A567')).toBe(false) + expect(validators.deploymentIdentifier('_/___@012.4567')).toBe(false) + expect(validators.deploymentIdentifier('_/___@01234567')).toBe(false) + expect(validators.deploymentIdentifier('aaa//0123@01234567')).toBe(false) + expect(validators.deploymentIdentifier('foo@bar@01234567')).toBe(false) + expect(validators.deploymentIdentifier('abc/1.23@01234567')).toBe(false) + expect(validators.deploymentIdentifier('012345678/123@latest')).toBe(false) + expect(validators.deploymentIdentifier('012345678/123@dev')).toBe(false) + expect(validators.deploymentIdentifier('012345678/123@1.0.1')).toBe(false) }) test('serviceName success', () => { diff --git a/packages/validators/src/validators.ts b/packages/validators/src/validators.ts index d0f1e54c..10bb06e4 100644 --- a/packages/validators/src/validators.ts +++ b/packages/validators/src/validators.ts @@ -40,11 +40,11 @@ export function deploymentHash(value: string): boolean { return !!value && deploymentHashRe.test(value) } -export function projectId(value: string): boolean { +export function projectIdentifier(value: string): boolean { return !!value && projectRe.test(value) } -export function deploymentId(value: string): boolean { +export function deploymentIdentifier(value: string): boolean { return !!value && deploymentRe.test(value) }